fix: explain no longer resolves an ambiguous name to an arbitrary file - #2305
fix: explain no longer resolves an ambiguous name to an arbitrary file#23050bLoM wants to merge 1 commit into
Conversation
`_find_node` ranks matches but never reports that a tie was broken, so callers taking `matches[0]` present one arbitrary file as the answer. Two workspaces that each define `MetricsPort` put both nodes in the same `exact` tier, separated only by `G.nodes()` iteration order — reorder the graph and the same query answers with a different file, equally confidently. `affected` already declines this via `resolve_seed()` returning None, and `path` warns when the top two scores are within 10%. Only `explain` (and the MCP `get_neighbors` tool, which shares the matcher) was silent. Split `_find_node` into `_find_node_tiers` (logic unchanged, tiers exposed) plus a flattening wrapper, and add `find_node_ambiguity()`, which returns rivals only when the winning tier spans multiple source files. Matches within one file (a file node plus its members) stay ordinary precedence and resolve as before. `_disambiguate_file_node_labels` (Graphify-Labs#2032) already relabels colliding *file* nodes; this covers the symbol case it does not reach.
There was a problem hiding this comment.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify review — findings
This PR adds ambiguity detection for the explain command in both the CLI and MCP server paths. It refactors _find_node to expose match tiers via a new _find_node_tiers helper, and introduces find_node_ambiguity, which reports rival candidates when the winning match tier spans multiple source files (e.g. the same symbol label defined in different workspaces). When ambiguity is detected, both entry points now list the candidate files/node ids and instruct the user to retry with a more specific identifier instead of returning an arbitrary match. The test file adds cases covering: an ambiguous label listing all candidates and exiting non-zero, order-independence of the reported candidate set, and confirmation that multiple matches within a single file are not treated as ambiguous.
No blocking issues surfaced. 4 lower-confidence candidates did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 452 functions depend on the 169 node(s) this change touches.
Health — this change adds coupling hotspots:
- worse:
dispatch_command()— 2 callers, 110 callees - worse:
main()— 80 callers, 2 callees - worse:
_run()— 12 callers, 1 callees
Verification — 452 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: 425 function(s) in the blast radius were not formally verified this run
· 3 more finding(s) on lines outside this diff (see the check run).
The bug
graphify explain "<name>"answers an ambiguous name by taking_find_node(G, label)[0]._find_noderanks matches intosource_exact + exact + prefix + substring, but never signals that a tie was broken — so when the winning tier holds several nodes from different files, the command prints one of them as unqualified fact.In a monorepo this is the common case, not an edge case: every workspace has its own
MetricsPort/index.ts/metrics.port.ts, and they all land in the sameexacttier separated only byG.nodes()iteration order.Reproduction
A graph with two
MetricsPortsymbols in different files:Swap only the order of the two nodes in the JSON — nothing else — and the same query answers with the other file, just as confidently:
The sibling commands already handle this. On the identical graph:
So
affecteddeclines andpathwarns; onlyexplain— and the MCPget_neighborstool, which shares the matcher — silently guessed. A wrong answer that looks authoritative is worse than an error, especially for an agent consuming the output.This is distinct from #2032:
_disambiguate_file_node_labelsrelabels colliding file nodes, which does not reach symbol-label collisions.The change
_find_nodeinto_find_node_tiers(matching logic unchanged, tiers exposed) plus a thin flattening wrapper, so_find_node's contract and ordering are byte-identical for existing callers.find_node_ambiguity(G, label)— returns one representative per distinctsource_filewhen the winning tier is split across files, else[].explainlists the candidates with their node ids and exits 1; MCPget_neighborsreturns the same listing.Matches within one file (a file node plus its members) remain ordinary precedence and resolve exactly as before.
Tests
Three added to
tests/test_explain_cli.py:test_explain_ambiguous_label_lists_every_candidatetest_explain_ambiguous_answer_does_not_depend_on_node_order— encodes the bug directly: the answer must not change when only node order changestest_explain_matches_within_one_file_are_not_ambiguous— regression guardVerified RED/GREEN: the first two fail against unpatched
cli.py(the failure output showsNode: MetricsPort / ID: chat_metrics_portbeing reported as fact) and pass with the change. The third passes both ways by design.Full suite, same environment, before vs after: identical failure sets — nothing broken, nothing incidentally fixed. (There are pre-existing failures in
test_skillgen.pyandtest_terraform.pyin my environment, from a shallow clone with noorigin/v8to diff against and a missing grammar; they are unrelated and present on a clean checkout too.)Note for maintainers
MCP
get_node(serve.py,_tool_get_node) has the same defect class via a third, independent ad-hoc matcher (label in (d.get('label') or '').lower(), thenmatches[0]). I left it out to keep this diff reviewable — happy to fold in a fix if you'd prefer it here.