Skip to content

explain: add path::Symbol resolution and fix the ambiguity retry hint - #3491

Closed
ayushcodes10 wants to merge 3 commits into
Graphify-Labs:v8from
ayushcodes10:fix-3485-explain-path-scoped-symbol
Closed

explain: add path::Symbol resolution and fix the ambiguity retry hint#3491
ayushcodes10 wants to merge 3 commits into
Graphify-Labs:v8from
ayushcodes10:fix-3485-explain-path-scoped-symbol

Conversation

@ayushcodes10

Copy link
Copy Markdown
Contributor

Summary

Fixes #3485. explain's ambiguity message told callers to retry with "the repo relative path", but a bare path resolves to the FILE node, not the symbol, and there was no query form that scoped a symbol to a specific file.

  • Adds a path::Symbol query form, resolved in _find_node_tiers before the ordinary tiers, so explain and the MCP tools get_node/get_neighbors (which share the same resolver) can all disambiguate a symbol by file.
  • Updates the ambiguity message in both the explain CLI and the shared MCP resolver to recommend path::symbol instead of the old, misleading suggestion, using a generic <path>::symbol placeholder so the message stays identical regardless of node iteration order (there is an existing test guarding exactly that property).

Test plan

  • Added 3 regression tests in tests/test_explain_cli.py: the exact ambiguous scenario from the issue resolves to the right node either way, a symbol not present in the given file falls through to "no match" cleanly, and a genuine Foo::Bar style label (Rust/C++ conventions) still resolves via ordinary matching when nothing before :: matches a file path.
  • Full suite: python3 -m pytest -q — 5423 passed, only the pre-existing unrelated failures (test_ollama_retry_cap.py missing openai in this env, one flaky timing assertion in test_ts_import_type_arguments.py).
  • python3 -m tools.skillgen --check — OK.
  • Manually reproduced the issue's exact repro and confirmed the new message and resolution end to end.

🤖 Generated with Claude Code

https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh

ayushcodes10 and others added 2 commits September 11, 2026 14:59
A bare symbol name that exists in multiple files is ambiguous, and the
only suggested retry (a bare repo relative path) resolves to the file
node itself, never the symbol, so it could not actually disambiguate
anything. This adds a path::Symbol form that restricts the label match
to nodes defined in that file, wired into _find_node_tiers ahead of
the ordinary tiers so it is available to explain, get_node and
get_neighbors alike. Fixes Graphify-Labs#3485.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
The prior retry suggestion, a bare repo relative path, resolves to the
file node rather than the symbol, so following it never actually
disambiguates anything. Now that path::symbol exists, point both the
explain CLI and the shared MCP resolver at it, using a generic
placeholder rather than one of the rival paths so the message stays
the same regardless of node iteration order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 2 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_find\_node\_tiers changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_find\_node\_tiers behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","label":"'\)'"\}, the old code produced \[\] but the new code produces \(\[\], \[\], \[\], \[\]\). Paste that input straight into a regression test.

Behavior changes: \_resolve\_single\_node changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_resolve\_single\_node behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","label":"'\)'"\}, the old code produced raises ValueError but the new code produces \(None, "No node matching '\)' found\."\). Paste that input straight into a regression test.


Graphify review — findings

Adds a path::Symbol query form to node resolution: _find_node_tiers now intercepts labels containing ::, resolves them via _resolve_path_scoped_symbol against both source file and label/id, and returns the result as a source_exact match so a same-named symbol in a specific file is reachable without its opaque id. When the path or symbol part matches nothing, it falls through to ordinary matching, so genuine :: labels (Rust modules, C++ namespaces) still resolve. Updates the ambiguity retry hint in both the CLI and _resolve_single_node to suggest <path>::symbol instead of a bare repo-relative path, which resolved to the file node rather than the symbol.

Worth a look

  • '::'-containing label that is a legitimate symbol name may be captured by path-scoping and return empty, changing resolutiongraphify/serve.py:1405 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Rust/C++ '::' label no longer resolves when path_part matches a filegraphify/serve.py:1407 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 625 functions depend on the 218 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _query_graph_text() — 25 callers, 10 callees
  • new: dispatch_command() — 2 callers, 124 callees
  • new: _score_query() — 15 callers, 6 callees
  • new: _query_terms() — 20 callers, 3 callees
  • new: run_benchmark() — 16 callers, 3 callees
  • new: _stale_graph_sources() — 7 callers, 6 callees
  • new: _load_graph() — 14 callers, 3 callees
  • new: _build_server() — 2 callers, 16 callees
  • …and 11 more — each is listed as a finding

Verification — 625 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 581 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

31 of 268 test file(s) selected (12%) via static blast radius.

  • tests/test_affected_cli.py — impact
  • tests/test_agents_platform.py — impact
  • tests/test_benchmark.py — impact
  • tests/test_benchmark_raw_graph.py — impact
  • tests/test_codebuddy.py — impact
  • tests/test_devin.py — impact
  • tests/test_explain_cli.py — impact, changed-test
  • tests/test_extract_cli.py — impact
  • tests/test_file_label_disambiguation.py — impact
  • tests/test_global_add_tag_inference.py — impact
  • tests/test_god_nodes_cli.py — impact
  • tests/test_hollow_chunks_arm_shrink_guard.py — impact
  • tests/test_hook_guard_token_match.py — impact
  • tests/test_hook_out_of_project_paths.py — impact
  • tests/test_hook_strict.py — impact
  • tests/test_incomplete_build_guard.py — impact
  • tests/test_install.py — impact
  • tests/test_install_references.py — impact
  • tests/test_merge_chunks_validation.py — impact
  • tests/test_multigraph_diagnostics.py — impact
  • tests/test_no_dedup_flag.py — impact
  • tests/test_partial_cache.py — impact
  • tests/test_path_cli.py — impact
  • tests/test_query_cli.py — impact
  • tests/test_query_induced_edges.py — impact
  • tests/test_query_mcp_direction.py — impact
  • tests/test_query_names_its_graph.py — impact
  • tests/test_serve.py — impact
  • tests/test_serve_http.py — impact
  • tests/test_stale_prune.py — impact
  • tests/test_unverified_semantic_shrink.py — impact

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

Behavior changes: \_find\_node\_tiers changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_find\_node\_tiers behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","label":"'\)'"\}, the old code produced \[\] but the new code produces \(\[\], \[\], \[\], \[\]\). Paste that input straight into a regression test.

Behavior changes: \_resolve\_single\_node changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_resolve\_single\_node behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","label":"'\)'"\}, the old code produced raises ValueError but the new code produces \(None, "No node matching '\)' found\."\). Paste that input straight into a regression test.

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

· 19 more finding(s) on lines outside this diff (see the check run).

A native Rust or C++ label containing "::" could be hijacked by the
new path::Symbol resolution when the substring before "::" happens to
match some unrelated file elsewhere in the graph, for example an
extensionless file literally named the same as a Rust module. The
query would then resolve to a node in that unrelated file instead of
the node the literal label actually names. Path scoped resolution now
only runs when the raw, unsplit label does not already have a literal
exact match among real node labels, since a genuine label essentially
never equals a whole path plus symbol string verbatim. Found by the
graphify review bot on PR 3491.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
@ayushcodes10

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Went through both advisory findings and the formal verification section.

Both "Worth a look" findings were real — reproduced the collision directly: a native :: label (e.g. a Rust mylib::Bar) could be hijacked by the new path::Symbol resolution when the substring before :: happens to match an unrelated file elsewhere in the graph (an extensionless file literally named the same as the module), returning a node from that unrelated file instead of the one the literal label names. Fixed in the latest commit: path::Symbol resolution now only runs when the raw, unsplit label doesn't already have a literal exact match among real node labels — a genuine label essentially never equals a whole path::symbol string verbatim, so this only ever suppresses the false-positive case. Added a regression test (test_explain_double_colon_label_not_hijacked_by_a_coincidental_path_match) covering the exact collision shape, and reconfirmed the existing Rust/C++ non-regression test and the original #3485 disambiguation case still pass.

The formal verification section's two "behavior changes" are intentional, not regressions — both are the same underlying fix: _find_node_tiers previously returned a bare [] for an empty/punctuation-only query, which crashed any caller unpacking it into 4 tiers (_resolve_single_node included, hence "raises ValueError" in the old column). That's a pre-existing latent bug, unrelated to #3485, fixed in passing since I was directly editing the adjacent line. The new behavior — returning (None, "No node matching '...' found.") instead of crashing — is correct and confirmed working as intended.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) alter behavior, breaking input(s) attached.

Behavior changes: \_find\_node\_tiers changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_find\_node\_tiers behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","label":"'\)'"\}, the old code produced \[\] but the new code produces \(\[\], \[\], \[\], \[\]\). Paste that input straight into a regression test.

Behavior changes: \_resolve\_single\_node changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_resolve\_single\_node behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","label":"'\)'"\}, the old code produced raises ValueError but the new code produces \(None, "No node matching '\)' found\."\). Paste that input straight into a regression test.


Graphify review — findings

Adds a path::Symbol query form to node lookup so an ambiguous symbol name can be pinned to a specific file without knowing its opaque node id; _find_node_tiers splits on ::, matches nodes whose source_file and label both satisfy the two parts via _resolve_path_scoped_symbol, and returns them in the source_exact tier so existing callers are unchanged. Guards against hijacking native :: labels (Rust modules, C++ namespaces) by first checking whether the raw string literally matches a node's own label via _label_has_literal_exact_match, and falls through to ordinary matching when the path-scoped resolution finds nothing. Updates the ambiguity messages in both the CLI and _resolve_single_node to suggest the new path::symbol retry form instead of the bare repo-relative path that resolved to the file node.

Worth a look

  • rstrip('()') strips all trailing parens/chars, not a trailing '()' pairgraphify/serve.py:1372 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 629 functions depend on the 222 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _query_graph_text() — 25 callers, 10 callees
  • new: dispatch_command() — 2 callers, 124 callees
  • new: _score_query() — 15 callers, 6 callees
  • new: _query_terms() — 20 callers, 3 callees
  • new: run_benchmark() — 16 callers, 3 callees
  • new: _stale_graph_sources() — 7 callers, 6 callees
  • new: _load_graph() — 14 callers, 3 callees
  • new: _build_server() — 2 callers, 16 callees
  • …and 11 more — each is listed as a finding

Verification — 629 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 585 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

31 of 268 test file(s) selected (12%) via static blast radius.

  • tests/test_affected_cli.py — impact
  • tests/test_agents_platform.py — impact
  • tests/test_benchmark.py — impact
  • tests/test_benchmark_raw_graph.py — impact
  • tests/test_codebuddy.py — impact
  • tests/test_devin.py — impact
  • tests/test_explain_cli.py — impact, changed-test
  • tests/test_extract_cli.py — impact
  • tests/test_file_label_disambiguation.py — impact
  • tests/test_global_add_tag_inference.py — impact
  • tests/test_god_nodes_cli.py — impact
  • tests/test_hollow_chunks_arm_shrink_guard.py — impact
  • tests/test_hook_guard_token_match.py — impact
  • tests/test_hook_out_of_project_paths.py — impact
  • tests/test_hook_strict.py — impact
  • tests/test_incomplete_build_guard.py — impact
  • tests/test_install.py — impact
  • tests/test_install_references.py — impact
  • tests/test_merge_chunks_validation.py — impact
  • tests/test_multigraph_diagnostics.py — impact
  • tests/test_no_dedup_flag.py — impact
  • tests/test_partial_cache.py — impact
  • tests/test_path_cli.py — impact
  • tests/test_query_cli.py — impact
  • tests/test_query_induced_edges.py — impact
  • tests/test_query_mcp_direction.py — impact
  • tests/test_query_names_its_graph.py — impact
  • tests/test_serve.py — impact
  • tests/test_serve_http.py — impact
  • tests/test_stale_prune.py — impact
  • tests/test_unverified_semantic_shrink.py — impact

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

Behavior changes: \_find\_node\_tiers changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_find\_node\_tiers behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","label":"'\)'"\}, the old code produced \[\] but the new code produces \(\[\], \[\], \[\], \[\]\). Paste that input straight into a regression test.

Behavior changes: \_resolve\_single\_node changes behavior, here is the input that shows it.

The verifier found a concrete input on which \_resolve\_single\_node behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.

Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.

Evidence: On input \{"G":"\(lambda \_g: \(\_g\.add\_nodes\_from\(\[\('a', \{'label': 'A', 'kind': 'fn'\}\), \('b', \{'label': 'B'\}\)\]\), \_g\.add\_edges\_from\(\[\('a', 'b', \{'weight': 2, 'kind': 'calls'\}\)\]\), \_g\)\[\-1\]\)\(\_\_import\_\_\('networkx'\)\.Graph\(\)\)","label":"'\)'"\}, the old code produced raises ValueError but the new code produces \(None, "No node matching '\)' found\."\). Paste that input straight into a regression test.

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

· 19 more finding(s) on lines outside this diff (see the check run).

@ayushcodes10

Copy link
Copy Markdown
Contributor Author

Re the rstrip('()') finding: correct that str.rstrip strips a character set repeatedly rather than one trailing () pair, so e.g. 'foo(())'.rstrip('()') -> 'foo', not 'foo()'. But this isn't something my diff introduces — _label_has_literal_exact_match's bare_label = norm_label.rstrip("()") is a direct copy of the exact same line already used by the exact tier a few lines below it (and elsewhere in this file), which is deliberate: the whole point of this pre-check is to ask "would the raw label match via the same rule the exact tier itself uses", so the two need to share the identical bare_label computation or the guard could disagree with the tier it's meant to predict. Fixing the stripping precision only in my new function would desync it from the tier loop; fixing it everywhere is a real but separate, pre-existing issue in this file's matching logic, unrelated to #3485 — happy to file it separately if useful, but leaving it out of this PR.

@safishamsi

Copy link
Copy Markdown
Collaborator

Shipped in v0.9.59 — landed on v8 via cherry-pick with your authorship preserved. Thanks @ayushcodes10 — graphify explain now accepts a path::Symbol form and the ambiguity hint shows a form the resolver accepts. Release: https://github.com/Graphify-Labs/graphify/releases/tag/v0.9.59

@safishamsi safishamsi closed this Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants