Skip to content

fix: explain no longer resolves an ambiguous name to an arbitrary file - #2305

Open
0bLoM wants to merge 1 commit into
Graphify-Labs:v8from
0bLoM:fix/explain-ambiguous-node-match
Open

fix: explain no longer resolves an ambiguous name to an arbitrary file#2305
0bLoM wants to merge 1 commit into
Graphify-Labs:v8from
0bLoM:fix/explain-ambiguous-node-match

Conversation

@0bLoM

@0bLoM 0bLoM commented Jul 29, 2026

Copy link
Copy Markdown

The bug

graphify explain "<name>" answers an ambiguous name by taking _find_node(G, label)[0]. _find_node ranks matches into source_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 same exact tier separated only by G.nodes() iteration order.

Reproduction

A graph with two MetricsPort symbols in different files:

$ graphify explain "MetricsPort" --graph graph.json
Node: MetricsPort
  ID:        chat_metrics_port
  Source:    services/chat/src/application/ports/metrics.port.ts

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:

Node: MetricsPort
  ID:        scraping_metrics_port
  Source:    services/scraping/src/application/ports/metrics.port.ts

The sibling commands already handle this. On the identical graph:

$ graphify affected "MetricsPort" --graph graph.json
No unique node match for MetricsPort

$ graphify path "MetricsPort" "trackScrapeEvent" --graph graph.json
warning: source match was ambiguous (top score 9320.28, runner-up 9320.28)

So affected declines and path warns; only explain — and the MCP get_neighbors tool, 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_labels relabels colliding file nodes, which does not reach symbol-label collisions.

The change

  • Split _find_node into _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.
  • Add find_node_ambiguity(G, label) — returns one representative per distinct source_file when the winning tier is split across files, else [].
  • CLI explain lists the candidates with their node ids and exits 1; MCP get_neighbors returns the same listing.

Matches within one file (a file node plus its members) remain ordinary precedence and resolve exactly as before.

$ graphify explain "MetricsPort" --graph graph.json
Ambiguous: 'MetricsPort' matches 2 nodes in different files.
  services/chat/src/application/ports/metrics.port.ts
    id: chat_metrics_port
  services/scraping/src/application/ports/metrics.port.ts
    id: scraping_metrics_port
Retry with the repo-relative path or the full node id.

Tests

Three added to tests/test_explain_cli.py:

  • test_explain_ambiguous_label_lists_every_candidate
  • test_explain_ambiguous_answer_does_not_depend_on_node_order — encodes the bug directly: the answer must not change when only node order changes
  • test_explain_matches_within_one_file_are_not_ambiguous — regression guard

Verified RED/GREEN: the first two fail against unpatched cli.py (the failure output shows Node: MetricsPort / ID: chat_metrics_port being 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.py and test_terraform.py in my environment, from a shallow clone with no origin/v8 to 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(), then matches[0]). I left it out to keep this diff reviewable — happy to fold in a fix if you'd prefer it here.

`_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.

@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.

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).

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.

1 participant