Skip to content

fix(graph): preserve same-file nodes on qualified_name collision (#585) - #715

Merged
4 commits merged into
tirth8205:stagingfrom
SarveshaKumarKS:fix/585-qualified-name-collisions
Nov 9, 2026
Merged

4 commits merged into
tirth8205:stagingfrom
SarveshaKumarKS:fix/585-qualified-name-collisions

Conversation

@SarveshaKumarKS

@SarveshaKumarKS SarveshaKumarKS commented Jul 22, 2026 •

Copy link
Copy Markdown

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_name is unique and upsert_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:

nodes = [
    NodeInfo(kind="Function", name="render", file_path="/repo/app.py", line_start=10, line_end=12),
    NodeInfo(kind="Function", name="render", file_path="/repo/app.py", line_start=20, line_end=24),
]
store.store_file_nodes_edges("/repo/app.py", nodes, [])

Before:

[("/repo/app.py::render", 20, 24)]

After:

[("/repo/app.py::render", 10, 12),
 ("/repo/app.py::render:S<semantic-fingerprint>", 20, 24)]

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:

  • outgoing edges use the source definition whose line range contains the edge;
  • CONTAINS edges use the target definition's start line; and
  • recursive calls use the same disambiguated identity as their source definition.

An 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:

  • two same-file definitions with the same base qualified name both survive with distinct line ranges;
  • direct re-indexing of the same definition updates in place without creating a duplicate;
  • a harmless line insertion above duplicate definitions does not change their identities;
  • distinct cross-file callers to every duplicate still resolve after re-indexing;
  • recursive caller/callee queries resolve a disambiguated node; and
  • ambiguous incoming targets remain explicitly attached to the canonical identity.

Validation on current main (805c4c6):

uv run pytest
uv run ruff check code_review_graph/
uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional

Results: 2,070 passed, 5 skipped, 2 xpassed; Ruff clean; mypy clean across 66 source files.

Checklist

  • Tests added for new functionality
  • All tests pass: uv run pytest
  • Linting passes: uv run ruff check code_review_graph/
  • Type checking passes: uv run mypy code_review_graph/ --ignore-missing-imports --no-strict-optional
  • Lines are at most 100 characters
  • Implementation docstrings describe the behavior

@tirth8205

Copy link
Copy Markdown
Owner

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 render definitions, create an incoming CALLS edge to the second identity (target.py::render:L20), insert one harmless line above both definitions, and re-index the target file. The second identity becomes :L21; the :L20 node is gone, while the cross-file incoming edge still targets :L20, so it is dangling. Root cause: the identity encodes line_start, and store_file_nodes_edges() removes the file rows before identities are recomputed. The direct upsert_node() test does not exercise that real replace/re-index path. Please replace the line-based identity with a semantic stable identity, or implement explicit old-to-new identity migration for incoming edges, and add regressions for harmless line shifts, callers to every duplicate, and explicit ambiguous-target behavior. I am keeping both the PR and #585 open until those cases pass.

@SarveshaKumarKS
SarveshaKumarKS force-pushed the fix/585-qualified-name-collisions branch from 216b6f7 to 1abf73b Compare July 25, 2026 02:07
@SarveshaKumarKS

Copy link
Copy Markdown
Author

Thanks for the detailed regression. I reproduced the dangling-edge failure and pushed a revised implementation.
The line-based :L<line_start> identity has been replaced with a deterministic semantic fingerprint, so harmless line shifts no longer change collided node identities or leave incoming edges dangling.
I added regressions covering the real store_file_nodes_edges() lifecycle, callers to every duplicate, line-shift re-indexing, direct upsert idempotency, recursive resolution, and explicit ambiguous-target behavior.
The PR is rebased onto current main, and the updated validation is:
2,070 passed, 5 skipped, 2 xpassed
Ruff clean
mypy clean across 66 source files
Updated head: 1abf73b
The PR description has also been updated with the identity and edge-resolution trade-offs. When you have a chance, could you please re-review the revised implementation?

@tirth8205

Copy link
Copy Markdown
Owner

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 render definitions, point a caller at the second semantic-suffix identity, then remove the first definition and reindex the surviving second with a harmless line shift. The survivor changes from target.py::render:S9354c815bcc1 to bare target.py::render, while the caller still targets the removed suffixed identity. The new lifecycle regression fails exactly on that mismatch. This remains blocked until survivor identity is stable when a collision set shrinks; please also cover growth and reordering of the collision set.

@tirth8205

Copy link
Copy Markdown
Owner

Revalidated head 1abf73b after applying it to exact current main 329dbb91. The submitted and relevant storage/lifecycle suites pass (94 tests), but collision-set transitions still corrupt graph identity.

  • Shrink: [A = bare, B = suffix] becoming only B changes B to the bare key and leaves an existing caller's suffix target dangling.
  • Growth: starting with unique B at the bare key and then inserting A before it gives A the old bare key and moves B to a suffix. The existing caller silently changes meaning from B to A.

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.

@SarveshaKumarKS
SarveshaKumarKS force-pushed the fix/585-qualified-name-collisions branch from 1abf73b to 392651a Compare July 27, 2026 20:59
@SarveshaKumarKS

Copy link
Copy Markdown
Author

Thanks for the precise shrink and growth cases. I reproduced all three collision-set transitions and pushed a new revision.
The replace path now reconciles definitions with their previous identities before deleting the old file rows:

  • semantic matches retain their identities when definitions reorder;
  • semantically identical definitions use nearest previous source location across harmless line shifts;
  • growth preserves the existing definition’s bare identity and gives the new definition a suffix; and
  • shrink preserves the surviving definition’s suffix identity, so its existing callers remain valid.

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.
Added regressions cover shrink, growth, reorder, callers to every surviving identity, bare resolution after shrink, and explicit ambiguous-target behavior.
Validation on current main (cbb74ed):

  • 2,313 passed, 5 skipped, 2 xpassed
  • all 75 graph storage tests passed
  • Ruff clean
  • mypy clean across 70 source files

Updated head: 392651a
Could you please re-run the lifecycle cases against this revision?

@tirth8205

Copy link
Copy Markdown
Owner

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 reserved, after the semantic match fails (signature changed) and the exact-location rescue fails (line moved). When the collision set for a base has exactly one old row and one new definition, they should be paired regardless of signature or line changes. Please add a regression for a lone definition whose params and line_start both change, asserting the bare identity is retained. Second, a performance regression: _resolve_stored_identity uses "substr(qualified_name, 1, ?) = ?", which EXPLAIN QUERY PLAN shows as a full index SCAN, and it runs for every edge endpoint that does not match an existing node (unresolved library-call targets are common). Measured 0.86 ms per unresolved edge at 20k nodes; a full build of this repo goes from about 3.8s on main to about 6.4s on this branch. Please replace it with an index-seekable range or LIKE query. One smaller note: symbol names containing colons (Objective-C selectors, which the parser supports via .m files) can false-match the ":S" prefix heuristic; worth a guard or at least a comment.

@SarveshaKumarKS
SarveshaKumarKS force-pushed the fix/585-qualified-name-collisions branch from 392651a to 398adda Compare August 4, 2026 21:16
@SarveshaKumarKS

Copy link
Copy Markdown
Author

Thanks for the precise reproductions. I confirmed all three issues and pushed a revised implementation.
For the no-collision lifecycle, a base with exactly one old row and one new definition is now paired unconditionally. This preserves the bare identity even when both the signature and source location change, so existing incoming callers remain valid across repeated re-indexes.
_resolve_stored_identity() now searches the qualified-name index using the bounded range [base:S, base:T) instead of applying substr() to every row. EXPLAIN QUERY PLAN reports:
SEARCH nodes USING COVERING INDEX idx_nodes_qualified (qualified_name>? AND qualified_name<?)
Collision candidates are also validated against the complete generated suffix format rather than only checking the :S prefix. This prevents colon-bearing symbols such as Objective-C selectors from being mistaken for disambiguated identities.
Added regressions cover:

  • a lone definition whose parameters and line both change across repeated re-indexes;
  • preservation of its bare identity and existing cross-file caller;
  • use of the index-bounded lookup; and
  • Objective-C-style colon symbols not being treated as collision suffixes.

Validation on current main (1a010de):

  • 2,409 passed, 5 skipped, 2 xpassed
  • all 81 graph tests passed
  • Ruff clean
  • mypy clean across 70 source files

Updated head: 398adda
Could you please revalidate this revision?

@tirth8205

Copy link
Copy Markdown
Owner

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.

@tirth8205

Copy link
Copy Markdown
Owner

Changes required: same-file collision identities still change when the file path uses backslashes. Using GraphStore.store_file_nodes_edges, store two render nodes for C:\t\target.py at lines 10 and 20 with params (a) and (b), then replace that file with only (b) at line 21; the surviving node's qualified name changes from its semantic suffix to the bare name, leaving incoming references behind. Normalise the planning path and preserve the surviving identity before addressing the remaining repeated scans.

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

tirth8205 commented Sep 15, 2026 •

Copy link
Copy Markdown
Owner

This no longer merges into staging. Conflicts in code_review_graph/graph.py, tests/test_graph.py.

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 push

When resolving:

A large integration branch landed on staging today, which is why this drifted.

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

@tirth8205 tirth8205 added the needs-rebase Branch no longer merges into staging label Sep 15, 2026
@tirth8205

Copy link
Copy Markdown
Owner

This no longer merges into staging. 2 files conflict and the branch is 313 commits behind:

  • code_review_graph/graph.py
  • tests/test_graph.py

Worth knowing before you resolve:

  • code_review_graph/graph.py: staging added the corrupt-database classifiers and a crg_parent_dir SQLite function registered outside the open guard. Keep that guard wrapped around exactly the statements it covers.
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 push

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

@tirth8205

Copy link
Copy Markdown
Owner

Thank you for this PR. I ran small scripts, the graph tests, and a trial merge.

Before I can merge it, please fix these:

  1. _plan_file_identities looks up old rows with the raw file_path (code_review_graph/graph.py:374-379). Windows paths like C:\t\target.py break this. Example: store render(a) and render(b). Add a caller of render(b). Then store only render(b). The caller edge now points to a deleted node. Please call normalize_file_path first.
  2. Each new name scans all old rows again (code_review_graph/graph.py:384-390). Updating one file with 6000 functions takes about 4 seconds. On staging it takes about 0.2 seconds. Please group old rows by name in one pass.
  3. upsert_node for render at line 10, then at line 11, leaves two rows. Staging keeps one row.

Please update your branch with the latest staging. There are conflicts in code_review_graph/graph.py and tests/test_graph.py. Staging now writes files through _replace_file_data. Please move the fix there.

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

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

Unresolved critical defects can swap or lose definition identities, misdirect existing callers, and associate test coverage with the wrong duplicate definition.

5 open issues
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,),
Comment on lines +414 to +418
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
Comment on lines +384 to +390
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"])
]
tirth8205 added a commit that referenced this pull request Nov 9, 2026
…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>
@tirth8205 tirth8205 closed this pull request by merging all changes into tirth8205:staging in d01c1fd Nov 9, 2026
HengYangDS pushed a commit to HengYangDS/code-review-graph that referenced this pull request Nov 9, 2026
…irth8205#715)

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HengYangDS pushed a commit to HengYangDS/code-review-graph that referenced this pull request Nov 9, 2026
…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>
HengYangDS pushed a commit to HengYangDS/code-review-graph that referenced this pull request Nov 9, 2026
…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>
HengYangDS pushed a commit to HengYangDS/code-review-graph that referenced this pull request Nov 9, 2026
…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>
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>
HengYangDS pushed a commit to HengYangDS/code-review-graph that referenced this pull request Nov 9, 2026
…#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>
HengYangDS pushed a commit to HengYangDS/code-review-graph that referenced this pull request Nov 9, 2026
…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>
HengYangDS pushed a commit to HengYangDS/code-review-graph that referenced this pull request Nov 9, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-rebase Branch no longer merges into staging

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Silent node loss: same-file qualified_name collisions are dropped on insert

3 participants