Repository navigation
fix(graph): preserve same-file nodes on qualified_name collision (#585) - #715
4 commits merged into
Conversation
|
I independently applied this head to current main and ran the submitted collision tests; all 3 pass. It is not mergeable yet because the missing incremental lifecycle case loses graph meaning. Exact failing regression: store two same-file |
216b6f7 to
1abf73b
Compare
|
Thanks for the detailed regression. I reproduced the dangling-edge failure and pushed a revised implementation. |
|
I revalidated the updated head after the lifecycle reconciliation work. The original same-file collision is improved, but one identity transition still leaves a dangling edge: index two same-file |
|
Revalidated head
This is not mergeable until identity is independent of collision-set size/order, or incoming edges are migrated atomically. Please add shrink, growth, and reorder lifecycle regressions plus explicit ambiguity behavior. |
1abf73b to
392651a
Compare
|
Thanks for the precise shrink and growth cases. I reproduced all three collision-set transitions and pushed a new revision.
A newly indexed bare edge endpoint resolves to that historical suffix only when it is the sole candidate. If multiple definitions remain possible, the target stays canonical and ambiguous rather than being routed arbitrarily.
Updated head: 392651a |
|
Thanks for the continued iteration; the shrink, growth, and reorder cases from my last review now pass, and the 8 new regressions genuinely fail without the fix. Two new blockers found at head 392651a. First, a correctness regression on the common no-collision path: index a single unique function render(a) at line 10, then re-index the file with render(a, b) at line 12 (an ordinary edit that changes the signature while lines shift). The node is permanently re-keyed from /repo/app.py::render to /repo/app.py::render:Sda4664594a6e, get_node on the bare name returns None, an existing cross-file caller edge to the bare name dangles, and repeated re-indexes never restore the bare key. Root cause: in _plan_file_identities the fallback denies the bare key because it is in |
392651a to
398adda
Compare
|
Thanks for the precise reproductions. I confirmed all three issues and pushed a revised implementation.
Validation on current main (1a010de):
Updated head: 398adda |
|
The lifecycle cases from my last review all pass now, and pytest, ruff, and mypy are clean on current main. Two blockers remain. First, _plan_file_identities (graph.py:374) queries WHERE file_path = ? with its raw argument while remove_file_data normalizes. incremental.py passes str(full_path), backslash-spelled on Windows, so the plan finds no old rows and reconciliation silently no-ops there. Repro on any OS: store two same-file render defs via the spelling C:\t\target.py, point a caller at the suffixed survivor, shrink to one def, reindex with the same spelling. The survivor re-keys to bare and the caller dangles. Fix is normalize_file_path at the top of the function, plus a regression using a backslash spelling. Second, the plan is quadratic. The old_by_base loop at graph.py:384 rescans every old row per base: reindexing 1500/3000/6000 unique defs takes 0.30/1.16/4.74s versus about 0.02s on main, and 1000 identical same-name defs cost 1.44s in the pairs loop. Bucket old rows by base in one pass. Minor: direct upsert_node of a unique def whose line shifted now creates a suffixed duplicate instead of updating in place. |
|
Changes required: same-file collision identities still change when the file path uses backslashes. Using |
|
This no longer merges into git fetch origin && git merge origin/staging
# resolve, then
uv run pytest tests/ -q
uv run ruff check code_review_graph/
uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
git pushWhen resolving:
A large integration branch landed on PRs now target |
|
This no longer merges into
Worth knowing before you resolve:
git fetch origin
git merge origin/staging
# resolve, then
uv run pytest tests/ -q
uv run ruff check code_review_graph/
uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
git pushI have not reviewed the change itself yet. That comes once it merges and the checks run against the merged state, since staging has moved a long way and the result is what matters. |
|
Thank you for this PR. I ran small scripts, the graph tests, and a trial merge. Before I can merge it, please fix these:
Please update your branch with the latest staging. There are conflicts in After you push the fix, I will test it again. |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical defects can swap or lose definition identities, misdirect existing callers, and associate test coverage with the wrong duplicate definition.
5 open issues
Normalize paths when looking up previous identities · New Preserve duplicate-definition identities across line shifts · New Reuse unmatched identities before allocating collision suffixes · New Align TESTED_BY edges with disambiguated CALLS edges · New Index previous identities to avoid quadratic matching · New
What changed in this PR
This PR addresses #585 by preserving same-file definitions that share a qualified name instead of silently overwriting nodes in the graph.
Changes:
- Adds semantic collision suffixes and reconciles identities during file replacement.
- Aligns supported edge endpoints with disambiguated nodes while retaining canonical targets for ambiguous calls.
- Adds regression tests for collision preservation, incremental identity stability, and caller/callee resolution.
| File | Description |
|---|---|
| tests/test_graph.py | Adds collision, reindexing, and edge-resolution regression tests. |
| code_review_graph/graph.py | Implements collision-aware node identities and edge alignment. |
Review effort: Balanced
| "SELECT id, kind, name, qualified_name, file_path, line_start, " | ||
| "line_end, language, parent_name, params, return_type, modifiers, " | ||
| "is_test FROM nodes WHERE file_path = ? ORDER BY id", | ||
| (file_path,), |
| for _, index, row_id, row in sorted(pairs): | ||
| if planned[index] is not None or row_id in used_old_ids: | ||
| continue | ||
| planned[index] = row["qualified_name"] | ||
| assigned.add(row["qualified_name"]) |
| assigned.add(row["qualified_name"]) | ||
| used_old_ids.add(row["id"]) | ||
|
|
||
| for index in indices: |
| collisions: dict[str, list[tuple[NodeInfo, str]]], | ||
| ) -> EdgeInfo: | ||
| """Point an edge at the colliding definition evidenced by its line.""" | ||
| source = edge.source |
| for base in new_by_base: | ||
| old_by_base[base] = [ | ||
| row | ||
| for row in old_rows | ||
| if row["qualified_name"] == base | ||
| or self._is_collision_identity(base, row["qualified_name"]) | ||
| ] |
…iew of #715) A call with nothing to choose between same-file duplicates no longer binds to the first one, the reason #586 was refused. Like an unbound target in #1171 it keeps the plain name, lists every duplicate, and says AMBIGUOUS 0.4, so callers_of on either claims nothing. Line scope and recursion still bind. A settle pass after each store fixes calls written before or after the duplicates and binds them again once one definition is left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d01c1fd
…irth8205#715) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…205#715) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tirth8205#715) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…205#715) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…irth8205#715) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tirth8205#715) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ew of tirth8205#715) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…w of tirth8205#715) The bulk store path (tirth8205#721) ran an index seek for both endpoints of every edge. Endpoints in the file being stored are settled by its own identity map, bare names cannot be collision bases, and a repeated target in another file is looked up once. upsert_edge resolves its endpoints before staging's unchanged statements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h8205#715) A tail lookup such as Handler.process reads nodes.symbol. Spelling it from the suffixed identity hid the duplicate, so query_graph reported one confident match instead of an ambiguous target with both definitions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ew of tirth8205#715) Strict xfail. Every unevidenced call still binds to the first duplicate, the objection tirth8205#586 was closed over, so find_dead_code lists the called area(int, int) overload that staging's collapsed node kept alive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
…#715) The override and _upsert_node existed for the per-row store path that the bulk _replace_file_data (tirth8205#721) replaced, so upsert_node keeps staging's public signature. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st (review of tirth8205#715) The fixture rebuilt a pre-tirth8205#1087 graph by disabling only the parser's scope index. With tirth8205#585 fixed the writer no longer collapses the collisions that produces, so the legacy writer is simulated too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tirth8205#1139 makes graph.py load the parser on first use, so EdgeInfo is only a type name there. tirth8205#715 builds EdgeInfo objects when it aligns edges with colliding names, which raised NameError on every store. The two methods now import it themselves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>


Pull Request
Linked issue
Closes #585
What & why
Two distinct definitions in the same file could produce the same base qualified name. Because
nodes.qualified_nameis unique andupsert_node()updated on that conflict, the later definition silently replaced the earlier node. Its line range disappeared, and distinct graph edges could collapse onto the surviving node.Minimal reproduction:
Before:
After:
Identity strategy
The first definition keeps the existing bare qualified name. Only genuine collisions are disambiguated, so all unambiguous identities remain unchanged.
A collided definition receives a deterministic suffix derived from semantic node fields rather than its source line. If multiple colliding definitions have identical semantic fields, a deterministic occurrence suffix is added. This preserves both definitions while keeping their identities stable when harmless lines are inserted above them during an incremental rebuild.
That stability matters because qualified names are also edge keys. A line-based suffix changed after a line shift, leaving cross-file incoming edges pointed at a deleted identity. The semantic suffix keeps those identities—and therefore their incoming edges—valid across the real
store_file_nodes_edges()remove-and-replace lifecycle.Edge-resolution trade-off
Batch storage aligns an edge endpoint with a collided identity only when the edge provides enough evidence:
CONTAINSedges use the target definition's start line; andAn incoming call with no evidence about which duplicate it targets deliberately remains on the canonical bare identity. It is not assigned arbitrarily to a disambiguated definition.
How it was tested
The regression coverage now verifies:
Validation on current
main(805c4c6):Results: 2,070 passed, 5 skipped, 2 xpassed; Ruff clean; mypy clean across 66 source files.
Checklist
uv run pytestuv run ruff check code_review_graph/uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional