Skip to content
Merged
4 changes: 4 additions & 0 deletions crates/codegraph-core/src/extractors/csharp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,9 +438,13 @@ fn extract_csharp_base_types(

// ── Type map extraction ─────────────────────────────────────────────────────

/// Extract the constructor type from a `var x = new Foo()` initializer.
fn extract_var_init_type(declarator: &Node, source: &[u8]) -> Option<String> {
for i in 0..declarator.child_count() {
let Some(child) = declarator.child(i) else { continue };
// Defensive: handle object_creation_expression as a direct child of variable_declarator.
// The standard grammar always wraps it in equals_value_clause, but this guard is kept
// as a belt-and-suspenders fallback for edge cases or future grammar changes.
if child.kind() == "object_creation_expression" {
if let Some(t) = child.child_by_field_name("type") {
return extract_csharp_type_name(&t, source).map(|s| s.to_string());
Expand Down
37 changes: 35 additions & 2 deletions src/domain/graph/builder/call-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,28 @@ export interface CallNodeLookup {

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

/**
* Languages where bare `foo()` calls inside a class method are lexically scoped
* to the module, not the class — there is no implicit this/class binding.
* For these languages, the same-class fallback must not run for bare (no-receiver)
* calls that found no exact same-file match.
*/
const MODULE_SCOPED_BARE_CALL_EXTENSIONS = new Set([
'.js',
'.mjs',
'.cjs',
'.jsx',
'.ts',
'.tsx',
'.mts',
'.cts',
]);

function isModuleScopedLanguage(relPath: string): boolean {
const ext = relPath.slice(relPath.lastIndexOf('.'));
return MODULE_SCOPED_BARE_CALL_EXTENSIONS.has(ext);
}

// ── Shared resolution functions ──────────────────────────────────────────

export function findCaller(
Expand Down Expand Up @@ -136,7 +158,11 @@ export function resolveByMethodOrGlobal(
const qualifiedName = `${effectiveReceiver}.${call.name}`;
const direct = lookup
.byName(qualifiedName)
.filter((n) => n.kind === 'method' || n.kind === 'function');
.filter(
(n) =>
(n.kind === 'method' || n.kind === 'function') &&
computeConfidence(relPath, n.file, null) >= 0.5,
);
if (direct.length > 0) return direct;
}

Expand Down Expand Up @@ -200,7 +226,14 @@ export function resolveByMethodOrGlobal(
// Also covers no-receiver calls inside class methods, e.g. `IsValidEmail(x)` inside
// `Validators.ValidateUser` → try `Validators.IsValidEmail` (C#/Java static siblings).
// This seeds the initial edge that runChaPostPass later expands to subclass overrides.
if (callerName) {
//
// For JS/TS, bare (no-receiver) calls are module-scoped — there is no implicit class
// binding. Skip the same-class fallback for bare calls in those languages to prevent
// false positives (e.g. `flush()` inside `Processor.run` must not resolve to
// `Processor.flush`). this.method() calls are unaffected: they still reach the fallback
// because `call.receiver === 'this'` is truthy, not a bare call.
const isBareCall = !call.receiver;
if (callerName && !(isBareCall && isModuleScopedLanguage(relPath))) {
const dotIdx = callerName.lastIndexOf('.');
if (dotIdx > -1) {
// Extract only the segment immediately before the method name so that
Expand Down
37 changes: 20 additions & 17 deletions src/extractors/csharp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,27 +329,13 @@ function extractCSharpTypeMap(node: TreeSitterNode, ctx: ExtractorOutput): void
extractCSharpTypeMapDepth(node, ctx, 0);
}

/** Extract type info from a variable_declaration node (local vars with explicit types). */
function handleCSharpVarDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
const typeNode = node.childForFieldName('type') || node.child(0);
if (!typeNode) return;
const isVar = typeNode.type === 'implicit_type' || typeNode.type === 'var_keyword';
const explicitTypeName = isVar ? null : extractCSharpTypeName(typeNode);
if (!isVar && !explicitTypeName) return;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type !== 'variable_declarator') continue;
const nameNode = child.childForFieldName('name') || child.child(0);
if (nameNode?.type !== 'identifier' || !ctx.typeMap) continue;
const typeName = isVar ? extractVarInitType(child) : explicitTypeName;
if (typeName) setTypeMapEntry(ctx.typeMap, nameNode.text, typeName, 0.9);
}
}

/** Extract the constructor type from a `var x = new Foo()` initializer. */
function extractVarInitType(declarator: TreeSitterNode): string | null {
for (let i = 0; i < declarator.childCount; i++) {
const child = declarator.child(i);
// Defensive: handle object_creation_expression as a direct child of variable_declarator.
// The standard grammar always wraps it in equals_value_clause, but this guard is kept
// as a belt-and-suspenders fallback for edge cases or future grammar changes.
if (child?.type === 'object_creation_expression') {
const tNode = child.childForFieldName('type');
if (tNode) return extractCSharpTypeName(tNode);
Expand All @@ -367,6 +353,23 @@ function extractVarInitType(declarator: TreeSitterNode): string | null {
return null;
}

/** Extract type info from a variable_declaration node (local vars with explicit or inferred types). */
function handleCSharpVarDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
const typeNode = node.childForFieldName('type') || node.child(0);
if (!typeNode) return;
const isVar = typeNode.type === 'implicit_type' || typeNode.type === 'var_keyword';
const explicitTypeName = isVar ? null : extractCSharpTypeName(typeNode);
if (!isVar && !explicitTypeName) return;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type !== 'variable_declarator') continue;
const nameNode = child.childForFieldName('name') || child.child(0);
if (nameNode?.type !== 'identifier' || !ctx.typeMap) continue;
const typeName = isVar ? extractVarInitType(child) : explicitTypeName;
if (typeName) setTypeMapEntry(ctx.typeMap, nameNode.text, typeName, 0.9);
}
}

/** Extract type info from a parameter node. */
function handleCSharpParam(node: TreeSitterNode, ctx: ExtractorOutput): void {
const typeNode = node.childForFieldName('type');
Expand Down
21 changes: 21 additions & 0 deletions tests/benchmarks/resolution/fixtures/javascript/class-scope.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Regression guard: bare function calls in JS class methods must NOT resolve
// to same-named class methods. In JS/TS, bare foo() is lexically scoped to
// the module, not the class — there is no implicit this binding on bare calls.
//
// If the call.receiver guard in resolveByMethodOrGlobal (call-resolver.ts) is
// ever removed, the resolver would incorrectly emit Processor.run → Processor.flush
// (a false positive). The 1.0 precision floor on the JS fixture catches that
// regression immediately.

export function processData(x) {
return x * 2;
}

export class Processor {
run(x) {
processData(x); // same-file module-level function — resolves correctly
flush(); // bare call; no module-level 'flush' in scope — must NOT resolve to Processor.flush
}

flush() {} // Processor.flush exists; bare flush() in run() must not target it
}
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,13 @@
"kind": "calls",
"mode": "receiver-typed",
"notes": "this.service.doB() — receiver-typed via ClassB.service = new ServiceB() (class-scoped typeMap key prevents collision with ClassA.service)"
},
{
"source": { "name": "Processor.run", "file": "class-scope.js" },
"target": { "name": "processData", "file": "class-scope.js" },
"kind": "calls",
"mode": "same-file",
"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)"
}
]
}
7 changes: 4 additions & 3 deletions tests/benchmarks/resolution/resolution-benchmark.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,10 @@ const THRESHOLDS: Record<string, { precision: number; recall: number }> = {
// adds bind/call/apply resolution (3 new edges in bind-call-apply.js), total expected now 33.
// Phase 8.3f adds Object.defineProperty accessor this-dispatch (#1335): getter→baz in
// define-property.js and accessorGetter→accessorTarget.accessMethod in define-property-accessor.js,
// total expected now 35. call/apply this-rebinding adds 2 edges (runCallThis→invoker,
// invoker→handler) and removes the false-positive from handler being extracted as a callback
// arg of .call() — total expected now 37.
// total expected now 35. multi-class.js adds 4 class-scoped typeMap edges (#1382) → 39.
// call/apply this-rebinding adds 2 edges (runCallThis→invoker, invoker→handler) and removes
// the false-positive from handler being extracted as a callback arg of .call() (#1405) → 41.
// #1407 adds class-scope.js (bare-call guard), +1 → total 42.
javascript: { precision: 1.0, recall: 0.9 },
// pts-javascript: hand-authored points-to JS fixture (for-of, Set, Array.from, spread) — patterns
// too broad for the main JS fixture. Patterns split per file to prevent intra-fixture FPs.
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/call-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
* one dot segment (e.g. 'Namespace.ClassName.method'), the same-class dispatch
* must use only the segment immediately before the method name ('ClassName'),
* not the full qualified prefix ('Namespace.ClassName').
*
* Also covers the static receiver confidence filter (#1398): the direct qualified
* method fallback must apply computeConfidence >= 0.5 to avoid false edges from
* distant files in a polyglot project.
*
* Also covers the bare-call JS/TS module-scope guard (#1407): bare `foo()` calls
* (no receiver) inside a JS/TS class method must NOT fall through to the same-class
* lookup, because bare calls in those languages are module-scoped, not class-scoped.
*/
import { describe, expect, it } from 'vitest';
import type { CallNodeLookup } from '../../src/domain/graph/builder/call-resolver.js';
Expand Down Expand Up @@ -88,3 +96,87 @@ describe('resolveByMethodOrGlobal — same-class this-dispatch with qualified ca
expect(result).toEqual([]);
});
});

describe('resolveByMethodOrGlobal — static receiver confidence filter (#1398)', () => {
it('returns same-directory static target (confidence 0.7 >= 0.5)', () => {
const target = { id: 1, file: 'app/Validators.cs', kind: 'method' };
const lookup = makeLookup({ 'Validators.IsValidEmail': [target] });
const result = resolveByMethodOrGlobal(
lookup,
{ name: 'IsValidEmail', receiver: 'Validators' },
'app/Program.cs',
new Map(),
);
expect(result).toEqual([target]);
});

it('filters out distant static target (confidence 0.3 < 0.5)', () => {
const target = { id: 2, file: 'lib/util/Validators.cs', kind: 'method' };
const lookup = makeLookup({ 'Validators.IsValidEmail': [target] });
const result = resolveByMethodOrGlobal(
lookup,
{ name: 'IsValidEmail', receiver: 'Validators' },
'app/main/Program.cs',
new Map(),
);
expect(result).toEqual([]);
});
});

describe('resolveByMethodOrGlobal — bare-call JS/TS module-scope guard (#1407)', () => {
// `flush()` inside `Processor.run` — no receiver, JS/TS file.
// Must NOT resolve to `Processor.flush` (class-scoped lookup is incorrect for JS/TS).
const flushMethod = { id: 10, file: 'processor.ts', kind: 'method' };

it('does NOT resolve bare call to same-class method in a .ts file', () => {
const lookup = makeLookup({ 'Processor.flush': [flushMethod] });
const result = resolveByMethodOrGlobal(
lookup,
{ name: 'flush', receiver: null },
'processor.ts',
new Map(),
'Processor.run',
);
// bare call + .ts → module-scoped language → same-class fallback skipped
expect(result).toEqual([]);
});

it('does NOT resolve bare call to same-class method in a .js file', () => {
const lookup = makeLookup({ 'Processor.flush': [flushMethod] });
const result = resolveByMethodOrGlobal(
lookup,
{ name: 'flush', receiver: null },
'processor.js',
new Map(),
'Processor.run',
);
expect(result).toEqual([]);
});

it('DOES resolve this.flush() in a .ts file (receiver present — not a bare call)', () => {
const lookup = makeLookup({ 'Processor.flush': [flushMethod] });
const result = resolveByMethodOrGlobal(
lookup,
{ name: 'flush', receiver: 'this' },
'processor.ts',
new Map(),
'Processor.run',
);
// this.flush() has a receiver → not a bare call → same-class fallback runs
expect(result).toEqual([flushMethod]);
});

it('DOES resolve bare call to same-class method in a .cs file (C# is not module-scoped)', () => {
const csMethod = { id: 20, file: 'Processor.cs', kind: 'method' };
const lookup = makeLookup({ 'Processor.Flush': [csMethod] });
const result = resolveByMethodOrGlobal(
lookup,
{ name: 'Flush', receiver: null },
'Processor.cs',
new Map(),
'Processor.Run',
);
// C# is not module-scoped → same-class fallback runs → Processor.Flush found
expect(result).toEqual([csMethod]);
});
});
Loading