Skip to content

Fix Elixir guarded and multi-clause function - #980

Merged
6 commits merged into
tirth8205:stagingfrom
Bakul2006:parserFix
Nov 6, 2026
Merged

6 commits merged into
tirth8205:stagingfrom
Bakul2006:parserFix

Conversation

@Bakul2006

Copy link
Copy Markdown
Contributor

Pull Request

Linked issue

Closes #979

What & why

Fixes two Elixir function graphing issues:

  • Guarded def functions using when were silently dropped because their
    function heads are wrapped in a binary_operator node. The parser now
    recursively unwraps when expressions to find the actual function head.
  • Multi-clause functions were deduplicated by qualified name, causing the last
    clause to overwrite the earlier clause's line range. Function/Test nodes now
    preserve the minimum line_start and maximum line_end across clauses.

Added regression coverage for guarded function calls, zero-arity functions,
and multi-clause function ranges.

How it was tested

uv run pytest tests/test_multilang.py --tb=short -q
uv run pytest tests/ --tb=short -q
uv run ruff check code_review_graph/
uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
uv run python -m py_compile code_review_graph/parser.py code_review_graph/graph.py
git diff --check

@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

code-review-graph review

Overall risk: 0.40 (MEDIUM) — 17 changed function(s)/class(es), 0 affected flow(s), 4 test gap(s) (3 with no tested caller found, 1 reached only through a caller)

Risk-scored changes

Risk Level Symbol Location Tested
0.40 medium code_review_graph/parser.py::CodeParser.function_head code_review_graph/parser.py:8056 no
0.35 low code_review_graph/parser.py::CodeParser._elixir_function_name_and_params code_review_graph/parser.py:8046 no
0.20 low code_review_graph/graph.py::GraphStore._replace_file_data code_review_graph/graph.py:1019 indirect
0.15 low code_review_graph/graph.py::GraphStore code_review_graph/graph.py:691 yes
0.15 low code_review_graph/graph.py::GraphStore.upsert_node code_review_graph/graph.py:797 yes
0.15 low code_review_graph/parser.py::CodeParser code_review_graph/parser.py:2942 yes
0.15 low code_review_graph/parser.py::CodeParser._extract_elixir_constructs code_review_graph/parser.py:8093 no
0.05 low tests/test_e2e_real_repo.py::test_hybrid_search_ranks_impact_radius_nodes_first tests/test_e2e_real_repo.py:387 (test)
0.05 low tests/test_graph.py::TestGraphStore tests/test_graph.py:23 (test)
0.05 low tests/test_graph.py::TestGraphStore.test_upsert_function_overwrites_line_range_for_same_qualified_name tests/test_graph.py:73 (test)

Test gaps

  • code_review_graph/parser.py::CodeParser._elixir_function_name_and_params (code_review_graph/parser.py:8046)
  • code_review_graph/parser.py::CodeParser.function_head (code_review_graph/parser.py:8056)
  • code_review_graph/parser.py::CodeParser._extract_elixir_constructs (code_review_graph/parser.py:8093)

Reached only through a caller

No test names these directly, but a tested caller reaches them along the call path shown. That is a path in the call graph, not a record of execution -- the caller's tests may never take this branch. Treat it as where to look, not as coverage: these count as gaps and are scored as untested.

  • code_review_graph/graph.py::GraphStore._replace_file_data (code_review_graph/graph.py:1019) — via code_review_graph/graph.py::GraphStore.store_file_batch, 1 hop(s)

Token savings: this graph-backed report used ~300,803 fewer tokens (~99%) than reading every changed file in full (estimated, chars/4 approximation).


Powered by code-review-graph — local-first analysis; no code leaves the CI runner.

@tirth8205
tirth8205 changed the base branch from main to staging September 15, 2026 13:10
@tirth8205

tirth8205 commented Sep 15, 2026 •

Copy link
Copy Markdown
Owner

This fails the checks once merged into staging.

Merged into origin/staging (clean, ort strategy). pytest (py3.13): "FAILED tests/test_multilang.py::TestElixirParsing::test_multi_clause_functions_keep_the_full_graph_range / 1 failed, 3224 passed, 9 skipped, 2 xpassed, 1 warning in 112.79s". Failure: "assert (5, 5) == (4, 5)" at tests/test_multilang.py:2769. ruff: "All checks passed!". mypy: "Success: no issues found in 72 source files".

To fix:

  • Rebase onto current staging and apply the MIN/MAX line-range merge to the ON CONFLICT clause in GraphStore._replace_file_data (code_review_graph/graph.py ~line 446-470), which is the writer used by build/update/forget since PR perf(graph): batch edge/node writes and bare-endpoint resolution #858; without this the multi-clause fix is dead code in the real pipeline...
  • Decide and test the semantics of upsert_node when the same Function qualified_name is re-upserted at a new location (probe: 2-4 then 10-12 yields 2-12).
  • Add a test that drives the multi-clause range through store_file_batch as well as store_file_nodes_edges.
  • Add parser tests for compound guards (when a and b), stacked guards (when g1 when g2), guarded defp/defmacro, and a guarded zero-arity def; all pass on manual probe but nothing pins them.

Merge origin/staging into your branch first to reproduce it.

PRs now target staging, not main. Yours was retargeted already, so nothing to do there.

@tirth8205 tirth8205 added the checks-failing Fails CI when merged into staging label Sep 15, 2026
@Bakul2006

Copy link
Copy Markdown
Contributor Author

Ok @tirth8205 I will look into it in a while

@tirth8205

Copy link
Copy Markdown
Owner

You pushed b6c6c62 after saying you would look into it, and it covers all four items. The failure is gone.

Gates on b6c6c62 merged into staging 9c13f4c: 3343 passed, 9 skipped, 2 xpassed, ruff clean, mypy clean. test_multi_clause_functions_keep_the_full_graph_range passes through both writers now. All 16 CI checks green.

Item by item:

  • The MIN/MAX merge is on the ON CONFLICT in _replace_file_data, so the multi-clause fix is live on build, update and forget rather than dead code behind upsert_node.
  • The upsert_node semantics question is answered and pinned: 2-4 then 10-12 gives 2-12, asserted by test_upsert_function_preserves_full_line_range. Union is the right answer here, and it is safe in practice because _replace_file_data deletes the file's rows before inserting, so a function that moves cannot inherit a stale start, and the only production caller of upsert_node is event_resolver.py writing virtual Event nodes at 0,0.
  • store_file_batch is now driven by the same range test.
  • The five guard forms are parametrised and pass: compound when a and b, stacked when g1 when g2, guarded defp, guarded defmacro, and a guarded zero-arity def.

One nit, not blocking. The two writers now disagree for non-Function kinds. upsert_node guards the MIN/MAX with WHEN excluded.kind IN ('Function', 'Test') AND nodes.kind IN ('Function', 'Test'); _replace_file_data applies it unconditionally. Measured on a C# file with partial class Foo split across lines 2 and 6:

staging, store_file_nodes_edges : Foo 6,6
yours,   store_file_nodes_edges : Foo 2,6
yours,   upsert_node            : Foo 6,6

2,6 is arguably the better answer for a partial class, but it is a side effect of dropping the guard rather than a decision anyone made, and it is untested. Pick one, apply it to both writers, and pin it with a test. That can be a follow-up.

Queued for merge.

Removing checks-failing.

@tirth8205 tirth8205 removed the checks-failing Fails CI when merged into staging label Sep 16, 2026
@Bakul2006

Copy link
Copy Markdown
Contributor Author

Yeah thanks for the review @tirth8205 Just a follow up from my side Would love to connect with you if you do allow as per your time on linkedin

@tirth8205

Copy link
Copy Markdown
Owner

The MIN/MAX line-range merge on the ON CONFLICT in _replace_file_data, code_review_graph/graph.py:1051, corrupts line ranges in every language, not just Elixir. I built psf/requests on both branches and diffed the tables row by row. Node and edge counts are identical and every edge row is byte-identical, but 21 node rows widened their range, claiming 907 extra source lines between them.

tests/test_utils.py::RegHandle                    991-993 -> 894-993
tests/test_utils.py::OpenKey                      997-998 -> 901-998
tests/test_lowlevel.py::digest_response_handler   251-261 -> 152-261
src/requests/models.py::_encode_params            151-180 -> 134-180

class RegHandle is defined twice in tests/test_utils.py, once inside each of two different test functions, at 894 and at 991. The writer sees two rows with the same qualified_name and welds them. It cannot do better. qualified_name is only file_path::parent.identity_name, graph.py:3513, so the SQL cannot tell sibling clauses of one Elixir function from two unrelated definitions. Before this PR the last writer won and the node carried one real definition's range. Now it carries a span that matches no definition at all.

That reaches the read path. map_changes_to_nodes, changes.py:557, attributes a hunk to a node by line-range overlap alone. A one-line edit at tests/test_utils.py:940 maps to 2 nodes on staging and to 6 on this branch. OpenKey, QueryValueEx, RegHandle and RegHandle.Close come back as changed, and they live at 991-1007. detect_changes_tool, risk scores, affected flows and the span widening in tools/review.py:296 all read those ranges.

The queued follow-up does not fix this. upsert_node's guard is excluded.kind IN ('Function','Test') AND nodes.kind IN ('Function','Test'), and 19 of the 21 welded nodes are Functions, so the guarded form welds them too.

Second problem, in the parser. function_head at parser.py:8041 unwraps the when binary_operator and keeps only its left, so a short-form body is never walked.

def guarded(x) when is_binary(x), do: helper(x)

staging misses the guarded node but records a CALLS edge to N.helper. This branch creates the node and drops the edge. On plug that is 11 resolved intra-project call-sites. test_guarded_functions_are_parsed_and_called uses a do ... end body, so nothing covers the short form.

Third, guard calls such as is_binary and user-defined defguard macros no longer produce CALLS edges at all. Defensible, but silent and untested.

The Elixir fix itself is right. On plug it recovers 29 functions that were invisible and 152 correct CONTAINS edges, and most of the 294 lost CALLS are phantom def-head self-calls and guard predicates. Gates are clean at 4263 passed, and the one mypy error is pre-existing on staging.

To merge: move the clause merge into the Elixir path in the parser, where sibling clauses are known to be siblings, and leave both ON CONFLICT clauses writing excluded.line_start and excluded.line_end as staging does. If it has to stay in SQL, guard it far more narrowly than by kind and add a test in a non-Elixir language asserting that two same-name definitions in one file do not weld. Then walk the , do: body after function_head unwraps the guard, and pin it with a test.

@tirth8205

Copy link
Copy Markdown
Owner

Thank you for this PR.

I merged it with the latest staging and ran the tests.

Before I can merge it, please fix these:

  1. Two unrelated functions with the same name now share one line range. See code_review_graph/graph.py:1055-1056 and graph.py:796-800. Example: one def helper() is inside outer1 at line 2. Another is inside outer2 at line 8. helper is now stored as lines 2-8. On staging it is 8-8. So an edit at line 4 now maps to helper. Please remove the MIN/MAX there. Please add a test for this case.

  2. A guarded short-form body loses its call. See code_review_graph/parser.py:8041. Example: def guarded(x) when is_binary(x), do: helper(x). This now has no CALLS edge to helper. Staging still finds it. Please walk the do: body and add a test.

After you push the fix, I will test it again.

@Bakul2006

Copy link
Copy Markdown
Contributor Author

Hi @tirth8205, thanks again for the detailed review.

I've addressed both requested issues and added regression coverage:

  • Updated both graph storage conflict handlers to overwrite incoming line ranges instead of unconditionally merging them with MIN/MAX.
  • Fixed guarded Elixir short-form function parsing so calls in the do: body are recorded correctly without treating guard predicates as ordinary body calls.

The focused graph and multilingual suites pass with 559 tests. I also adjusted the impact-radius test to avoid relying on a global top-10 ranking that changes when the test corpus grows, without changing production ranking behaviour.

The full suite now reports 5,051 passed, 884 skipped, 3 xfailed, 2 xpassed, and one remaining failure. The remaining failure is a Windows asyncio-policy test running under Linux/Python 3.14; it also reproduces on the PR base and staging. Native Windows behaviour has not been verified in this environment.

Ruff, mypy, and git diff --check all pass. The watch-observer failure was investigated and appears to be a pre-existing test synchronization race; the test passed in repeated isolated runs on the PR base, current branch, and staging.

The requested fixes remain intact, and I haven't changed the watcher implementation or weakened the Windows test. The patch is ready for review, subject to the normal supported-version CI validation.

Thanks for your time and feedback.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Both file-storage paths still overwrite earlier Elixir clause ranges, leaving incomplete spans for clauses with the same qualified name.

1 open issue
What changed in this PR

This PR addresses Elixir function graphing from #979 by recognizing guarded definitions and extracting calls from short-form bodies. It also adds multi-clause storage tests, though range preservation remains unimplemented.

Changes:

  • Recursively unwraps when guards to identify function heads.
  • Extracts calls from short-form do: bodies and adds Elixir regression coverage.
  • Adds graph range tests and adjusts the search-ranking test to query functions.
File Description
tests/​test_multilang.py Adds guarded-function, short-form call, and multi-clause storage tests.
tests/​test_graph.py Tests duplicate-range overwrites and separate nested-function ranges.
tests/​test_e2e_real_repo.py Filters search results to functions before checking the top ten.
code_review_graph/​parser.py Handles guarded heads and traverses short-form function bodies.
code_review_graph/​graph.py Reformats upsert assignments without changing range behavior.

Review effort: Balanced

Comment on lines +1067 to +1068
line_start=excluded.line_start,
line_end=excluded.line_end,
@tirth8205 tirth8205 closed this pull request by merging all changes into tirth8205:staging in 0160eb0 Nov 6, 2026
gowrishacv pushed a commit to gowrishacv/code-review-graph that referenced this pull request Nov 6, 2026
…review of tirth8205#980)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gowrishacv pushed a commit to gowrishacv/code-review-graph that referenced this pull request Nov 6, 2026
HengYangDS pushed a commit to HengYangDS/code-review-graph that referenced this pull request Nov 9, 2026
…ew of tirth8205#715)

The staging test pinned tirth8205#980's last-writer-wins collapse of same-name
clauses. With tirth8205#585 fixed both rows stay and the first keeps the bare name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Elixir: def with a when guard is silently dropped from the graph; multi-clause defs collapse to the last clause

3 participants