Skip to content

fix(edge_builder): restrict same-file this-dispatch fallback to caller's own class#1343

Merged
carlos-alm merged 3 commits into
mainfrom
fix/edge-builder-this-dispatch-scope-1324
Jun 6, 2026
Merged

fix(edge_builder): restrict same-file this-dispatch fallback to caller's own class#1343
carlos-alm merged 3 commits into
mainfrom
fix/edge-builder-this-dispatch-scope-1324

Conversation

@carlos-alm
Copy link
Copy Markdown
Contributor

Summary

  • Bug: In edge_builder.rs, the broader same-file suffix scan for this/self/super dispatch returned ALL methods with the matching name in the file, including from unrelated classes. A file containing Shape, Calculator, and Formatter all with area() would produce false-positive edges Shape.describe → Calculator.area and Shape.describe → Formatter.area.
  • Fix: When the scan finds more than one method with the suffix, restrict to methods whose qualified name starts with the caller's own class prefix. A single unambiguous match is returned as-is (correct for CHA subclass-override lookup). Multiple matches with none from the caller's class → return nothing.
  • Test: New fixture tests/fixtures/this-dispatch-scope/shapes.ts + integration test this-dispatch-scope.test.ts. WASM assertions are active; native assertions are todo until the next binary release includes this Rust fix.

Closes #1324

Test plan

  • npx vitest run tests/integration/this-dispatch-scope.test.ts — 4 pass, 2 todo
  • npx vitest run tests/integration/build-parity.test.ts tests/integration/pts-parity.test.ts tests/integration/phase-8.5-cha-dispatch.test.ts — all pass, no regressions
  • After next binary release: remove it.todo guards and confirm native assertions pass

…r's own class

When a file contains multiple unrelated classes all defining a method with the
same name, the broader same-file suffix scan emitted false-positive call edges
(e.g. this.area() in Shape.describe matched Calculator.area and Formatter.area).

The fix: when the scan finds more than one method with the matching suffix,
restrict the result to methods whose qualified name starts with the caller's
own class prefix.  A single unambiguous match is returned as-is (handles the
CHA case of one subclass override).  If multiple classes match and none is the
caller's class, return nothing rather than emitting false edges.

WASM tests are active; native tests marked todo pending next binary release.
Closes #1324
docs check acknowledged
@claude
Copy link
Copy Markdown

claude Bot commented Jun 6, 2026

Claude finished @carlos-alm's task —— View job


PR Review — fix(edge_builder): restrict same-file this-dispatch fallback to caller's own class

  • Read changed files
  • Phase 1: Root cause analysis
  • Phase 2: Configuration change detection
  • Phase 3: Test integrity
  • Phase 4: Code quality
  • Phase 5: Backlog compliance

Root Cause Analysis

Problem Being Solved: In edge_builder.rs, step 5 ("scoped fallback") of resolve_call_targets performs a same-file suffix scan when a this/self/super call can't be resolved via import-aware or direct same-file lookup. The bug: that scan returned all methods in the file with the matching name, across all classes. A file with Shape, Calculator, and Formatter — each defining area() — would produce false-positive Shape.describe → Calculator.area and Shape.describe → Formatter.area edges.

Why This Approach: The fix is surgical and correctly distinguishes two sub-cases:

  • Single match → return as-is (correct for CHA subclass override, where there is no ambiguity)
  • Multiple matches → restrict to methods whose qualified name starts with the caller's own class prefix (Shape. from Shape.describe); return nothing if none match

This mirrors the WASM path's behavior: resolveThisDispatch in cha.ts always scopes resolution to the caller's own class (and its hierarchy), and the pre-CHA fallback at build-edges.ts:1009-1017 also extracts the class prefix from callerName before qualifying the lookup. The fix achieves parity with what the TypeScript/WASM side already does.

Risk Assessment: Low risk. The logic change is in a well-isolated branch (lines 474–489 of edge_builder.rs). The single-match path is unchanged, preserving CHA subclass expansion. The multiple-match path now returns nothing when the caller's class has no match — this is correct because if the caller's class doesn't define or inherit the method and multiple unrelated classes do, emitting any edge would be a false positive.


Phase 2: Configuration Changes

None. No linting rules, build config, TypeScript flags, or test thresholds were modified.


Phase 3: Test Integrity

The it.todo guards for native (lines 75–76 in this-dispatch-scope.test.ts) are appropriate, not a regression:

  1. The fix is in Rust (edge_builder.rs) and the native binary hasn't been rebuilt yet. it.todo marks the assertions as known-pending rather than skipping them silently or lowering their strictness.
  2. This pattern is established in the repo — see phase-8.5-cha-dispatch.test.ts:131,154,181 using the same guard with the same explanation format.
  3. The WASM assertions (lines 78–96) are fully active and verify the correct behavior for the TypeScript code path that was fixed.

One concern worth noting: the test uses describe.each(ENGINES) which runs the full buildGraph + DB query for both engines. For the native engine run, the beforeAll still builds the graph and the positive edge test (Shape.describe → Shape.area) fires for native too (line 61). This means if the native binary also incorrectly emits the false-positive edges, the false-positive assertions will be silently deferred by it.todo rather than caught. This is an accepted tradeoff when the binary isn't yet released — the comment is explicit and the issue number is cited.

No red flags: no assertions were weakened, no coverage thresholds lowered, no existing tests modified.


Phase 4: Code Quality

edge_builder.rs — the fix (lines 468–490):

let suffix = format!(".{}", call.name);
if let Some(file_nodes) = ctx.nodes_by_file.get(rel_path) {
    let same_file_methods: Vec<&NodeInfo> = file_nodes.iter()
        .filter(|n| n.kind == "method" && n.name.ends_with(&suffix))
        .copied()
        .collect();
    match same_file_methods.len() {
        0 => {}
        1 => return same_file_methods,
        _ => {
            if let Some(dot_pos) = caller_name.find('.') {
                let caller_prefix = format!("{}.", &caller_name[..dot_pos]);
                let caller_scoped: Vec<&NodeInfo> = same_file_methods.iter()
                    .filter(|n| n.name.starts_with(&caller_prefix))
                    .copied()
                    .collect();
                if !caller_scoped.is_empty() { return caller_scoped; }
            }
        }
    }
}

Minor concern — double prefix extraction (lines 453–460 and 480–488): The caller's class prefix is extracted twice in the same branch: once for the "class-scoped exact lookup" (line 453) and again for the "broader same-file suffix scan" (line 480). The variables are class_prefix and caller_prefix respectively, computed identically. This is not a bug, but it's a small readability debt — consider hoisting the prefix extraction above both sub-branches. Not a blocking issue.

Correctness of nodes_by_file usage: nodes_by_file is built in EdgeContext::new (lines 119–126) and stores all nodes keyed by file, not just methods. The filter n.kind == "method" is applied correctly at point of use (line 471).

Correctness of suffix variable shadowing: At line 392 there's an outer suffix variable in the step-3 block (let suffix = format!(".{}", call.name)), and at line 468 a new suffix is declared inside step 5. These are in different scopes (step 3 exits early before step 5 is reached), so no shadowing issue occurs. However the near-duplication is worth noting for readability.

nodes_by_file vs nodes_by_name_and_file for the same-file scan: The fix uses ctx.nodes_by_file.get(rel_path) then filters by name.ends_with(&suffix). Step 2 already tried ctx.nodes_by_name_and_file.get(&(call.name.as_str(), rel_path)) which looks up by exact name, not qualified name. For qualified method names like Shape.area, the exact-name lookup in step 2 would not match call.name = "area". The suffix scan is therefore the correct approach for finding qualified method names.


Backlog Compliance

  • Zero-dep: ✓ — No new runtime dependencies. Rust-only change.
  • Foundation-aligned: ✓ — Pure local analysis, no cloud, no external tooling, no agent behavior.
  • Problem-fit: 5/5 — This directly prevents hallucinated edges in the dependency graph. False-positive Shape.describe → Calculator.area edges would mislead AI agents about blast radius, making this a core accuracy fix for the stated problem.
  • Breaking: No — additive fix, graph output is more accurate but the schema and API contracts are unchanged.
  • Tier: 1

Critical Concerns

None blocking.

One suggestion (non-blocking): The double find('.') / prefix extraction (lines 453 and 480) could be refactored to extract class_prefix once and share it, reducing duplication. This is cosmetic but worth a follow-up.

Future obligation: The it.todo items (native engine) need to be converted to active assertions after the next binary release. The PR body already notes this. It would help to link the tracking directly — e.g. open a small follow-up issue tagged native-parity so it doesn't get forgotten across release cycles.


Final Recommendation

  • Rating: ⭐⭐⭐⭐ (4/5)
  • Action: APPROVE
  • Reasoning: The fix is correct, well-scoped, and aligns with the existing TypeScript/WASM path's behavior. The test coverage is appropriate for the current state of the native binary. No config relaxations, no weakened assertions, no scope creep. The only callouts are a minor refactor opportunity (prefix extraction hoisting) and a procedural note about converting it.todo guards after the next binary release.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 6, 2026

Codegraph Impact Analysis

12 functions changed7 callers affected across 3 files

  • resolve_call_targets in crates/codegraph-core/src/edge_builder.rs:369 (6 transitive callers)
  • Shape in tests/fixtures/this-dispatch-scope/shapes.ts:5 (0 transitive callers)
  • Shape.describe in tests/fixtures/this-dispatch-scope/shapes.ts:6 (1 transitive callers)
  • Shape.area in tests/fixtures/this-dispatch-scope/shapes.ts:9 (0 transitive callers)
  • Calculator in tests/fixtures/this-dispatch-scope/shapes.ts:14 (0 transitive callers)
  • Calculator.area in tests/fixtures/this-dispatch-scope/shapes.ts:15 (0 transitive callers)
  • Formatter in tests/fixtures/this-dispatch-scope/shapes.ts:20 (0 transitive callers)
  • Formatter.area in tests/fixtures/this-dispatch-scope/shapes.ts:21 (0 transitive callers)
  • Caller in tests/fixtures/this-dispatch-scope/single-sibling.ts:6 (0 transitive callers)
  • Caller.run in tests/fixtures/this-dispatch-scope/single-sibling.ts:7 (0 transitive callers)
  • Sibling in tests/fixtures/this-dispatch-scope/single-sibling.ts:12 (0 transitive callers)
  • Sibling.area in tests/fixtures/this-dispatch-scope/single-sibling.ts:13 (0 transitive callers)

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Jun 6, 2026

Greptile Summary

This PR fixes a false-positive edge generation bug in edge_builder.rs where the same-file suffix scan for this/self/super dispatch returned methods from any class in the file, not just the caller\u2019s own class. The fix adds a caller-class prefix filter to the suffix scan, and two new TypeScript fixtures + an integration test suite (WASM-active, native-todo) verify both the multi-match and single-match false-positive cases.

  • edge_builder.rs: The suffix scan fallback now applies starts_with(caller_prefix) filtering for all match counts, consistently preventing Shape.describe \u2192 Calculator.area-style false edges.
  • this-dispatch-scope.test.ts: Covers both fixtures across both engine modes; the positive edge test runs for both engines while three negative assertions are guarded as it.todo for the native binary until the next release.

Confidence Score: 5/5

The change is narrowly scoped to one fallback branch in the call-resolution pipeline and is safe to merge.

The Rust fix is logically sound and both the multi-match and single-match false-positive paths are covered by the new integration tests. The intentional removal of cross-class CHA subclass-override detection from the suffix scan is guarded by the existing phase-8.5-cha-dispatch test suite.

No files require special attention; both comments are minor observations.

Important Files Changed

Filename Overview
crates/codegraph-core/src/edge_builder.rs Suffix-scan fallback now restricts matches to the caller's own class prefix, eliminating false-positive edges to unrelated same-file classes
tests/integration/this-dispatch-scope.test.ts New integration test suite covers multi-match disambiguation (shapes.ts) and single-match false-positive (single-sibling.ts); WASM active, native todo
tests/fixtures/this-dispatch-scope/shapes.ts Fixture with three unrelated classes sharing the same method name, targeting the multi-match disambiguation path
tests/fixtures/this-dispatch-scope/single-sibling.ts Fixture for the single-match false-positive path: Caller has no area() but Sibling does

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["resolve_call_targets"] --> B["Steps 1-4: exact, same-file,\nmethod match, type-aware"]
    B -- "found" --> Z["return targets"]
    B -- "not found" --> C["receiver is this/self/super"]
    C --> D["Class-scoped exact lookup\nShape.area in nodes_by_name"]
    D -- "found" --> Z
    D -- "not found" --> E["Same-file suffix scan\nfile_nodes ending with .area"]
    E -- "empty" --> F["return empty"]
    E -- "non-empty" --> G["caller_name has dot?"]
    G -- "no" --> F
    G -- "yes: prefix = Shape." --> H["Filter: starts_with Shape."]
    H -- "non-empty" --> Z
    H -- "empty" --> F
Loading

Fix All in Claude Code

Reviews (4): Last reviewed commit: "Merge branch 'main' into fix/edge-builde..." | Re-trigger Greptile

if !same_file_methods.is_empty() { return same_file_methods; }
match same_file_methods.len() {
0 => {}
1 => return same_file_methods,
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.

P1 Single-match path bypasses the same class-scope restriction as the multi-match fix

The 1 => return same_file_methods arm returns the sole match without verifying it belongs to the caller's class. This leaves the original false-positive intact for a very common real-world layout: a file with Shape (no area() method) and exactly one other class Calculator with area(). The class-scoped lookup finds nothing, the suffix scan finds 1 entry (Calculator.area), and it's returned as a Shape.describe → Calculator.area edge — the exact bug being fixed for 2+ matches. Since the surrounding if call.receiver.is_some() guard already limits us to this/self/super dispatch (so caller_name should always have a dot), applying the caller-prefix filter to the 1-match arm would close the same hole consistently. The CHA subclass-override scenario could be preserved by falling through to that arm only when the single match does start with caller_prefix.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — the single-match arm now applies the same caller-class prefix filter as the multi-match arm. The match same_file_methods.len() block has been replaced with a unified if !same_file_methods.is_empty() + caller_prefix filter that runs for any number of matches. A new fixture tests/fixtures/this-dispatch-scope/single-sibling.ts (two classes: Caller with no area(), Sibling with area()) plus a corresponding WASM assertion verify that Caller.run → Sibling.area is not emitted.


describe.each(ENGINES)('this-dispatch scope (%s)', (engine) => {
let tmpDir: string;
let callEdges: CallEdgeRow[];
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.

P2 callEdges is declared without an initialiser. If buildGraph rejects (network hiccup, missing binary, etc.), Vitest marks all tests in the suite as failed with the beforeAll error, but if it surfaces before that, any test body reaching callEdges.find(…) throws a cryptic "variable used before assignment" instead of a clean failing assertion. Initialising to [] makes the failure mode explicit.

Suggested change
let callEdges: CallEdgeRow[];
let callEdges: CallEdgeRow[] = [];

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — changed to let callEdges: CallEdgeRow[] = []; so any buildGraph rejection produces a clear failing assertion rather than a cryptic "variable used before assignment" error.

…an too (#1343)

The 1-match arm of the same-file suffix scan was returning any sole
method regardless of whether it belonged to the caller's class.  A file
with Caller (no area()) and Sibling (area()) would produce a false
Caller.run → Sibling.area edge — the same bug fixed for 2+ matches.

Replace the three-arm match with a unified caller-prefix filter applied
to all non-empty results.  Add fixture single-sibling.ts and a
corresponding WASM assertion to cover the single-match false-positive
path.  Initialize callEdges to [] for cleaner beforeAll failure mode.
@carlos-alm
Copy link
Copy Markdown
Contributor Author

@greptileai

@carlos-alm
Copy link
Copy Markdown
Contributor Author

@claude

@claude
Copy link
Copy Markdown

claude Bot commented Jun 6, 2026

Claude finished @carlos-alm's task —— View job


PR Review — Updated State

  • Read updated files
  • Verify single-match arm fix (Greptile Issue 1)
  • Verify callEdges initializer fix (Greptile Issue 2)
  • Verify new single-sibling fixture and test
  • Final assessment

Both Greptile issues are correctly addressed.

Issue 1 — Single-match arm (edge_builder.rs:474–484):

The match same_file_methods.len() with its 1 => return same_file_methods arm has been replaced with a unified if !same_file_methods.is_empty() block that always applies the caller_prefix filter, regardless of match count. A file with Caller (no area()) and one Sibling.area() will find 1 suffix match, but the filter n.name.starts_with("Caller.") yields nothing → falls through correctly with no edge emitted. The fix is complete and consistent.

Issue 2 — callEdges initializer (test.ts:52):

let callEdges: CallEdgeRow[] = [];

Confirmed initialized to []. Clean failure mode if buildGraph rejects.

New fixture single-sibling.ts: Correctly exercises the single-match case — Caller calls this.area() but only Sibling.area() exists in the file. The WASM assertion at test line 108–116 verifies this edge is NOT emitted.

One minor observation: The test at line 65 (Shape.describe → Shape.area) fires for both engines including native. If the native binary still emits the correct positive edge (which it should, since only the false-positive suppression is missing from the old binary), this is fine. No issue here.


Both concerns are fully resolved. The PR is ready to merge.

View job

@carlos-alm carlos-alm merged commit 16af0e0 into main Jun 6, 2026
28 checks passed
@carlos-alm carlos-alm deleted the fix/edge-builder-this-dispatch-scope-1324 branch June 6, 2026 07:59
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 6, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(edge_builder): restrict same-file this-dispatch fallback to caller's own class

1 participant