Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions crates/codegraph-core/src/edge_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,15 +460,27 @@ fn resolve_call_targets<'a>(
if !class_scoped.is_empty() { return class_scoped; }
}

// Broader fallback: same-file suffix scan to pick up CHA-expanded targets
// (subclasses that override the method).
// Broader fallback: same-file suffix scan. Always restrict to the caller's
// own class prefix — regardless of how many matches are found — to avoid
// false-positive edges to unrelated classes in the same file.
// (e.g. this.area() inside Shape.describe must never yield Calculator.area,
// even when Calculator.area is the only method with that name in the file.)
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();
if !same_file_methods.is_empty() { return same_file_methods; }
if !same_file_methods.is_empty() {
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; }
}
}
}
}
return exact; // empty
Expand Down
24 changes: 24 additions & 0 deletions tests/fixtures/this-dispatch-scope/shapes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Three unrelated classes in one file, each with an area() method.
// this.area() inside Shape.describe must resolve only to Shape.area,
// not to Calculator.area or Formatter.area.

export class Shape {
describe(): string {
return `area=${this.area()}`;
}
area(): number {
return 0;
}
}

export class Calculator {
area(): number {
return 100;
}
}

export class Formatter {
area(): string {
return 'n/a';
}
}
16 changes: 16 additions & 0 deletions tests/fixtures/this-dispatch-scope/single-sibling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Two classes in one file; only one defines area().
// this.area() inside Caller.run must NOT resolve to Sibling.area
// even when Sibling.area is the only method with that suffix in the file.
// The caller's own class (Caller) has no area() → the edge must be omitted.

export class Caller {
run(): string {
return `result=${this.area()}`;
}
}

export class Sibling {
area(): number {
return 42;
}
}
118 changes: 118 additions & 0 deletions tests/integration/this-dispatch-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* this-dispatch scope: same-file fallback must not emit false-positive edges
* to methods in unrelated classes.
*
* Fixtures:
* - shapes.ts — three unrelated classes (Shape, Calculator, Formatter) all
* defining area(). this.area() inside Shape.describe must resolve only to
* Shape.area (multi-match disambiguation path).
* - single-sibling.ts — two classes: Caller (no area()) and Sibling (area()).
* this.area() inside Caller.run must NOT resolve to Sibling.area even though
* it is the only method with that suffix in the file (single-match path).
*
* Covers the Rust edge_builder fix in issue #1324.
*/

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildGraph } from '../../src/domain/graph/builder.js';
import type { EngineMode } from '../../src/types.js';

const FIXTURE_DIR = path.join(import.meta.dirname, '..', 'fixtures', 'this-dispatch-scope');

interface CallEdgeRow {
caller_name: string;
callee_name: string;
}

function readCallEdges(dbPath: string): CallEdgeRow[] {
const db = new Database(dbPath, { readonly: true });
try {
return db
.prepare(
`SELECT n1.name AS caller_name, n2.name AS callee_name
FROM edges e
JOIN nodes n1 ON e.source_id = n1.id
JOIN nodes n2 ON e.target_id = n2.id
WHERE e.kind = 'calls'`,
)
.all() as CallEdgeRow[];
} finally {
db.close();
}
}

const ENGINES: EngineMode[] = ['wasm', 'native'];

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

beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `codegraph-this-scope-${engine}-`));
fs.cpSync(FIXTURE_DIR, tmpDir, { recursive: true });
await buildGraph(tmpDir, { incremental: false, skipRegistry: true, engine });
callEdges = readCallEdges(path.join(tmpDir, '.codegraph', 'graph.db'));
}, 60_000);

afterAll(() => {
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('emits Shape.describe → Shape.area (correct this-dispatch)', () => {
const edge = callEdges.find(
(e) => e.caller_name === 'Shape.describe' && e.callee_name === 'Shape.area',
);
expect(
edge,
`Expected Shape.describe → Shape.area edge.\nActual edges:\n${JSON.stringify(callEdges, null, 2)}`,
).toBeDefined();
});

// Native binary v3.11.2 does not include the edge_builder.rs fix for issue #1324 yet.
// These assertions are active for WASM and will be re-enabled for native once a new
// binary is published that includes the Rust fix.
if (engine === 'native') {
it.todo('does NOT emit Shape.describe → Calculator.area (native binary gap #1324)');
it.todo('does NOT emit Shape.describe → Formatter.area (native binary gap #1324)');
it.todo(
'does NOT emit Caller.run → Sibling.area (single-match false-positive, native binary gap #1324)',
);
} else {
it('does NOT emit Shape.describe → Calculator.area (unrelated class, same method name)', () => {
const edge = callEdges.find(
(e) => e.caller_name === 'Shape.describe' && e.callee_name === 'Calculator.area',
);
expect(
edge,
`Expected NO Shape.describe → Calculator.area edge (false-positive from same-file scan).\nActual edges:\n${JSON.stringify(callEdges, null, 2)}`,
).toBeUndefined();
});

it('does NOT emit Shape.describe → Formatter.area (unrelated class, same method name)', () => {
const edge = callEdges.find(
(e) => e.caller_name === 'Shape.describe' && e.callee_name === 'Formatter.area',
);
expect(
edge,
`Expected NO Shape.describe → Formatter.area edge (false-positive from same-file scan).\nActual edges:\n${JSON.stringify(callEdges, null, 2)}`,
).toBeUndefined();
});

// single-sibling.ts: only one class (Sibling) has area(); Caller does not.
// The single-match arm must still check the caller's own class — Caller.run
// must not gain a false edge to Sibling.area.
it('does NOT emit Caller.run → Sibling.area (single-match false-positive, same-file scan)', () => {
const edge = callEdges.find(
(e) => e.caller_name === 'Caller.run' && e.callee_name === 'Sibling.area',
);
expect(
edge,
`Expected NO Caller.run → Sibling.area edge (false-positive from single-match suffix scan).\nActual edges:\n${JSON.stringify(callEdges, null, 2)}`,
).toBeUndefined();
});
}
});
Loading