Skip to content

Commit 5a06fa2

Browse files
authored
test(bench): add JS fixture for bare-call class-scoped lookup regression guard (#1422)
* test(bench): add JS fixture for bare-call class-scoped lookup regression guard (#1407) Adds class-scope.js to the JS resolution benchmark. The fixture contains a class with a bare flush() call inside run() and a Processor.flush method that must NOT be the target of that call — JS bare calls are lexically scoped to the module, never the class. The 1.0 precision floor for the JS fixture acts as the enforcement mechanism: if the call.receiver guard in resolveByMethodOrGlobal is ever removed, the resolver would emit Processor.run → Processor.flush as a false positive, immediately failing CI. Closes #1407 * fix(parity): infer var-declared local types from new-expression initializers in C# Both the WASM and native C# extractors were skipping variable declarations with `var`/`implicit_type` type nodes entirely, so `var service = new UserService(repo)` never added `service → UserService` to the typeMap. The call-edge resolver therefore could not resolve `service.AddUser()` or `service.GetUser()` to the qualified methods on `UserService`. The dist copy of the WASM extractor already had the fix (extractVarInitType), but the source had drifted out of sync. This commit re-introduces the logic in both engines: WASM (TypeScript): add `extractVarInitType(declarator)` that walks the variable_declarator children looking for an `object_creation_expression` (or one inside an `equals_value_clause`), then reads the `type` field via `extractCSharpTypeName`. `handleCSharpVarDecl` now sets `isVar` for `implicit_type | var_keyword` and calls `extractVarInitType` in that branch. Native (Rust): mirror the same logic in `extract_var_init_type` and update `match_csharp_type_map` to drive it when the type node is `var_keyword` or `implicit_type`. Infrastructure: add CODEGRAPH_NATIVE_ADDON_PATH env-var override to `loadNative()` for local development workflows where the published npm binary can't be loaded (its dylib install-name points to the CI build path). Tests now pass with CODEGRAPH_NATIVE_ADDON_PATH set to the locally rebuilt binary. Fixes: #1418 (build-parity test failure — native vs WASM C# edge/role divergence on `var`-typed local variables instantiated with `new`). * fix(resolver): gate bare-call same-class lookup on language (#1425) In JS/TS, bare foo() calls inside a class method are module-scoped — there is no implicit class binding. The same-class fallback in resolveByMethodOrGlobal was incorrectly emitting class-scoped edges for bare calls with no module-level match (e.g. flush() inside Processor.run resolving to Processor.flush). Gate the fallback: skip for bare calls in JS/TS, keep for this.method() calls and for C#/Java where bare calls are class-scoped. Closes #1425. * fix(resolver): apply confidence filter to static receiver fallback The direct qualified-name fallback in resolveByMethodOrGlobal was the only resolution path that did not apply computeConfidence >= 0.5, risking false edges from distant files in polyglot projects with name collisions. Closes #1398 * test(resolver): add unit tests for bare-call JS/TS module-scope guard (#1407) * fix(csharp): remove duplicate extract_var_init_type from merge (#1422)
1 parent 76907e6 commit 5a06fa2

7 files changed

Lines changed: 183 additions & 22 deletions

File tree

crates/codegraph-core/src/extractors/csharp.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,9 +438,13 @@ fn extract_csharp_base_types(
438438

439439
// ── Type map extraction ─────────────────────────────────────────────────────
440440

441+
/// Extract the constructor type from a `var x = new Foo()` initializer.
441442
fn extract_var_init_type(declarator: &Node, source: &[u8]) -> Option<String> {
442443
for i in 0..declarator.child_count() {
443444
let Some(child) = declarator.child(i) else { continue };
445+
// Defensive: handle object_creation_expression as a direct child of variable_declarator.
446+
// The standard grammar always wraps it in equals_value_clause, but this guard is kept
447+
// as a belt-and-suspenders fallback for edge cases or future grammar changes.
444448
if child.kind() == "object_creation_expression" {
445449
if let Some(t) = child.child_by_field_name("type") {
446450
return extract_csharp_type_name(&t, source).map(|s| s.to_string());

src/domain/graph/builder/call-resolver.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,28 @@ export interface CallNodeLookup {
2323

2424
export const RECEIVER_KINDS = new Set(['class', 'struct', 'interface', 'type', 'module']);
2525

26+
/**
27+
* Languages where bare `foo()` calls inside a class method are lexically scoped
28+
* to the module, not the class — there is no implicit this/class binding.
29+
* For these languages, the same-class fallback must not run for bare (no-receiver)
30+
* calls that found no exact same-file match.
31+
*/
32+
const MODULE_SCOPED_BARE_CALL_EXTENSIONS = new Set([
33+
'.js',
34+
'.mjs',
35+
'.cjs',
36+
'.jsx',
37+
'.ts',
38+
'.tsx',
39+
'.mts',
40+
'.cts',
41+
]);
42+
43+
function isModuleScopedLanguage(relPath: string): boolean {
44+
const ext = relPath.slice(relPath.lastIndexOf('.'));
45+
return MODULE_SCOPED_BARE_CALL_EXTENSIONS.has(ext);
46+
}
47+
2648
// ── Shared resolution functions ──────────────────────────────────────────
2749

2850
export function findCaller(
@@ -136,7 +158,11 @@ export function resolveByMethodOrGlobal(
136158
const qualifiedName = `${effectiveReceiver}.${call.name}`;
137159
const direct = lookup
138160
.byName(qualifiedName)
139-
.filter((n) => n.kind === 'method' || n.kind === 'function');
161+
.filter(
162+
(n) =>
163+
(n.kind === 'method' || n.kind === 'function') &&
164+
computeConfidence(relPath, n.file, null) >= 0.5,
165+
);
140166
if (direct.length > 0) return direct;
141167
}
142168

@@ -200,7 +226,14 @@ export function resolveByMethodOrGlobal(
200226
// Also covers no-receiver calls inside class methods, e.g. `IsValidEmail(x)` inside
201227
// `Validators.ValidateUser` → try `Validators.IsValidEmail` (C#/Java static siblings).
202228
// This seeds the initial edge that runChaPostPass later expands to subclass overrides.
203-
if (callerName) {
229+
//
230+
// For JS/TS, bare (no-receiver) calls are module-scoped — there is no implicit class
231+
// binding. Skip the same-class fallback for bare calls in those languages to prevent
232+
// false positives (e.g. `flush()` inside `Processor.run` must not resolve to
233+
// `Processor.flush`). this.method() calls are unaffected: they still reach the fallback
234+
// because `call.receiver === 'this'` is truthy, not a bare call.
235+
const isBareCall = !call.receiver;
236+
if (callerName && !(isBareCall && isModuleScopedLanguage(relPath))) {
204237
const dotIdx = callerName.lastIndexOf('.');
205238
if (dotIdx > -1) {
206239
// Extract only the segment immediately before the method name so that

src/extractors/csharp.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -329,27 +329,13 @@ function extractCSharpTypeMap(node: TreeSitterNode, ctx: ExtractorOutput): void
329329
extractCSharpTypeMapDepth(node, ctx, 0);
330330
}
331331

332-
/** Extract type info from a variable_declaration node (local vars with explicit types). */
333-
function handleCSharpVarDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
334-
const typeNode = node.childForFieldName('type') || node.child(0);
335-
if (!typeNode) return;
336-
const isVar = typeNode.type === 'implicit_type' || typeNode.type === 'var_keyword';
337-
const explicitTypeName = isVar ? null : extractCSharpTypeName(typeNode);
338-
if (!isVar && !explicitTypeName) return;
339-
for (let i = 0; i < node.childCount; i++) {
340-
const child = node.child(i);
341-
if (child?.type !== 'variable_declarator') continue;
342-
const nameNode = child.childForFieldName('name') || child.child(0);
343-
if (nameNode?.type !== 'identifier' || !ctx.typeMap) continue;
344-
const typeName = isVar ? extractVarInitType(child) : explicitTypeName;
345-
if (typeName) setTypeMapEntry(ctx.typeMap, nameNode.text, typeName, 0.9);
346-
}
347-
}
348-
349332
/** Extract the constructor type from a `var x = new Foo()` initializer. */
350333
function extractVarInitType(declarator: TreeSitterNode): string | null {
351334
for (let i = 0; i < declarator.childCount; i++) {
352335
const child = declarator.child(i);
336+
// Defensive: handle object_creation_expression as a direct child of variable_declarator.
337+
// The standard grammar always wraps it in equals_value_clause, but this guard is kept
338+
// as a belt-and-suspenders fallback for edge cases or future grammar changes.
353339
if (child?.type === 'object_creation_expression') {
354340
const tNode = child.childForFieldName('type');
355341
if (tNode) return extractCSharpTypeName(tNode);
@@ -367,6 +353,23 @@ function extractVarInitType(declarator: TreeSitterNode): string | null {
367353
return null;
368354
}
369355

356+
/** Extract type info from a variable_declaration node (local vars with explicit or inferred types). */
357+
function handleCSharpVarDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
358+
const typeNode = node.childForFieldName('type') || node.child(0);
359+
if (!typeNode) return;
360+
const isVar = typeNode.type === 'implicit_type' || typeNode.type === 'var_keyword';
361+
const explicitTypeName = isVar ? null : extractCSharpTypeName(typeNode);
362+
if (!isVar && !explicitTypeName) return;
363+
for (let i = 0; i < node.childCount; i++) {
364+
const child = node.child(i);
365+
if (child?.type !== 'variable_declarator') continue;
366+
const nameNode = child.childForFieldName('name') || child.child(0);
367+
if (nameNode?.type !== 'identifier' || !ctx.typeMap) continue;
368+
const typeName = isVar ? extractVarInitType(child) : explicitTypeName;
369+
if (typeName) setTypeMapEntry(ctx.typeMap, nameNode.text, typeName, 0.9);
370+
}
371+
}
372+
370373
/** Extract type info from a parameter node. */
371374
function handleCSharpParam(node: TreeSitterNode, ctx: ExtractorOutput): void {
372375
const typeNode = node.childForFieldName('type');
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Regression guard: bare function calls in JS class methods must NOT resolve
2+
// to same-named class methods. In JS/TS, bare foo() is lexically scoped to
3+
// the module, not the class — there is no implicit this binding on bare calls.
4+
//
5+
// If the call.receiver guard in resolveByMethodOrGlobal (call-resolver.ts) is
6+
// ever removed, the resolver would incorrectly emit Processor.run → Processor.flush
7+
// (a false positive). The 1.0 precision floor on the JS fixture catches that
8+
// regression immediately.
9+
10+
export function processData(x) {
11+
return x * 2;
12+
}
13+
14+
export class Processor {
15+
run(x) {
16+
processData(x); // same-file module-level function — resolves correctly
17+
flush(); // bare call; no module-level 'flush' in scope — must NOT resolve to Processor.flush
18+
}
19+
20+
flush() {} // Processor.flush exists; bare flush() in run() must not target it
21+
}

tests/benchmarks/resolution/fixtures/javascript/expected-edges.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,13 @@
289289
"kind": "calls",
290290
"mode": "receiver-typed",
291291
"notes": "this.service.doB() — receiver-typed via ClassB.service = new ServiceB() (class-scoped typeMap key prevents collision with ClassA.service)"
292+
},
293+
{
294+
"source": { "name": "Processor.run", "file": "class-scope.js" },
295+
"target": { "name": "processData", "file": "class-scope.js" },
296+
"kind": "calls",
297+
"mode": "same-file",
298+
"notes": "Bare call to same-file module-level function — regression guard: bare flush() in run() must NOT resolve to Processor.flush (class-scoped lookup must be receiver-gated)"
292299
}
293300
]
294301
}

tests/benchmarks/resolution/resolution-benchmark.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,10 @@ const THRESHOLDS: Record<string, { precision: number; recall: number }> = {
126126
// adds bind/call/apply resolution (3 new edges in bind-call-apply.js), total expected now 33.
127127
// Phase 8.3f adds Object.defineProperty accessor this-dispatch (#1335): getter→baz in
128128
// define-property.js and accessorGetter→accessorTarget.accessMethod in define-property-accessor.js,
129-
// total expected now 35. call/apply this-rebinding adds 2 edges (runCallThis→invoker,
130-
// invoker→handler) and removes the false-positive from handler being extracted as a callback
131-
// arg of .call() — total expected now 37.
129+
// total expected now 35. multi-class.js adds 4 class-scoped typeMap edges (#1382) → 39.
130+
// call/apply this-rebinding adds 2 edges (runCallThis→invoker, invoker→handler) and removes
131+
// the false-positive from handler being extracted as a callback arg of .call() (#1405) → 41.
132+
// #1407 adds class-scope.js (bare-call guard), +1 → total 42.
132133
javascript: { precision: 1.0, recall: 0.9 },
133134
// pts-javascript: hand-authored points-to JS fixture (for-of, Set, Array.from, spread) — patterns
134135
// too broad for the main JS fixture. Patterns split per file to prevent intra-fixture FPs.

tests/unit/call-resolver.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55
* one dot segment (e.g. 'Namespace.ClassName.method'), the same-class dispatch
66
* must use only the segment immediately before the method name ('ClassName'),
77
* not the full qualified prefix ('Namespace.ClassName').
8+
*
9+
* Also covers the static receiver confidence filter (#1398): the direct qualified
10+
* method fallback must apply computeConfidence >= 0.5 to avoid false edges from
11+
* distant files in a polyglot project.
12+
*
13+
* Also covers the bare-call JS/TS module-scope guard (#1407): bare `foo()` calls
14+
* (no receiver) inside a JS/TS class method must NOT fall through to the same-class
15+
* lookup, because bare calls in those languages are module-scoped, not class-scoped.
816
*/
917
import { describe, expect, it } from 'vitest';
1018
import type { CallNodeLookup } from '../../src/domain/graph/builder/call-resolver.js';
@@ -88,3 +96,87 @@ describe('resolveByMethodOrGlobal — same-class this-dispatch with qualified ca
8896
expect(result).toEqual([]);
8997
});
9098
});
99+
100+
describe('resolveByMethodOrGlobal — static receiver confidence filter (#1398)', () => {
101+
it('returns same-directory static target (confidence 0.7 >= 0.5)', () => {
102+
const target = { id: 1, file: 'app/Validators.cs', kind: 'method' };
103+
const lookup = makeLookup({ 'Validators.IsValidEmail': [target] });
104+
const result = resolveByMethodOrGlobal(
105+
lookup,
106+
{ name: 'IsValidEmail', receiver: 'Validators' },
107+
'app/Program.cs',
108+
new Map(),
109+
);
110+
expect(result).toEqual([target]);
111+
});
112+
113+
it('filters out distant static target (confidence 0.3 < 0.5)', () => {
114+
const target = { id: 2, file: 'lib/util/Validators.cs', kind: 'method' };
115+
const lookup = makeLookup({ 'Validators.IsValidEmail': [target] });
116+
const result = resolveByMethodOrGlobal(
117+
lookup,
118+
{ name: 'IsValidEmail', receiver: 'Validators' },
119+
'app/main/Program.cs',
120+
new Map(),
121+
);
122+
expect(result).toEqual([]);
123+
});
124+
});
125+
126+
describe('resolveByMethodOrGlobal — bare-call JS/TS module-scope guard (#1407)', () => {
127+
// `flush()` inside `Processor.run` — no receiver, JS/TS file.
128+
// Must NOT resolve to `Processor.flush` (class-scoped lookup is incorrect for JS/TS).
129+
const flushMethod = { id: 10, file: 'processor.ts', kind: 'method' };
130+
131+
it('does NOT resolve bare call to same-class method in a .ts file', () => {
132+
const lookup = makeLookup({ 'Processor.flush': [flushMethod] });
133+
const result = resolveByMethodOrGlobal(
134+
lookup,
135+
{ name: 'flush', receiver: null },
136+
'processor.ts',
137+
new Map(),
138+
'Processor.run',
139+
);
140+
// bare call + .ts → module-scoped language → same-class fallback skipped
141+
expect(result).toEqual([]);
142+
});
143+
144+
it('does NOT resolve bare call to same-class method in a .js file', () => {
145+
const lookup = makeLookup({ 'Processor.flush': [flushMethod] });
146+
const result = resolveByMethodOrGlobal(
147+
lookup,
148+
{ name: 'flush', receiver: null },
149+
'processor.js',
150+
new Map(),
151+
'Processor.run',
152+
);
153+
expect(result).toEqual([]);
154+
});
155+
156+
it('DOES resolve this.flush() in a .ts file (receiver present — not a bare call)', () => {
157+
const lookup = makeLookup({ 'Processor.flush': [flushMethod] });
158+
const result = resolveByMethodOrGlobal(
159+
lookup,
160+
{ name: 'flush', receiver: 'this' },
161+
'processor.ts',
162+
new Map(),
163+
'Processor.run',
164+
);
165+
// this.flush() has a receiver → not a bare call → same-class fallback runs
166+
expect(result).toEqual([flushMethod]);
167+
});
168+
169+
it('DOES resolve bare call to same-class method in a .cs file (C# is not module-scoped)', () => {
170+
const csMethod = { id: 20, file: 'Processor.cs', kind: 'method' };
171+
const lookup = makeLookup({ 'Processor.Flush': [csMethod] });
172+
const result = resolveByMethodOrGlobal(
173+
lookup,
174+
{ name: 'Flush', receiver: null },
175+
'Processor.cs',
176+
new Map(),
177+
'Processor.Run',
178+
);
179+
// C# is not module-scoped → same-class fallback runs → Processor.Flush found
180+
expect(result).toEqual([csMethod]);
181+
});
182+
});

0 commit comments

Comments
 (0)