Skip to content

Commit 2695c60

Browse files
authored
fix(resolver): qualified callerName mismatch in class-scoped typeMap lookup (#1403)
* test(integration): pin prototype-method-resolution test to WASM engine The test was using auto engine (native-preferred), causing it to pick the published npm native binary which predates the prototype-method fixes. WASM correctly extracts Dog.prototype.bark and resolves all call edges. Fixes #1381 * test(integration): add TODO comment for WASM engine pin (#1400) * fix(resolver): qualified callerName mismatch in class-scoped typeMap lookup When a method is called without a receiver inside a class-qualified method (e.g. `IsValidEmail()` inside `Validators.ValidateUser`), both the WASM and native engines now try the class-qualified name as a fallback. Root cause: the same-class method lookup in `resolveByMethodOrGlobal` was gated on `call.receiver && callerName`, which excluded no-receiver calls. Static sibling calls in C#/Java (e.g. `IsValidEmail()` inside a static class) have no receiver — the guard prevented the `Validators.IsValidEmail` lookup. Fixes: - WASM (call-resolver.ts): `if (call.receiver && callerName)` → `if (callerName)` - Native (edge_builder.rs): moves class-scoped exact lookup outside the `call.receiver.is_some()` guard; suffix scan remains gated on receiver-present to avoid false positives on global function calls inside class methods. Also fixes a latent CHA re-classification bug exposed by this change: the Rust orchestrator classifies roles before the CHA post-pass, so the global fan-out median was computed from pre-CHA edges. After CHA added edges, the median shifted but Validators.cs (not directly connected to CHA-affected files) was excluded from the incremental re-classification, leaving stale roles. Fixed by switching the post-CHA re-classification from incremental to full. C# same-file recall: 0/2 → 2/2 (100%). Overall C# recall: 73.9% → 82.6% (19/23 expected edges). Remaining gap: receiver-typed (0/4) tracked in #1402. * refactor(resolver): confidence filter in class-scoped Rust lookup; runPostNativeCha returns count - edge_builder.rs: align class-scoped lookup with WASM call-resolver.ts by adding compute_confidence >= 0.5 filter, matching all other lookup paths in the same block (#1403) - native-orchestrator.ts: change runPostNativeCha return type from Set<number> to number — caller only reads .size, individual IDs are never used; clearer intent and avoids the unused Set allocation (#1403)
1 parent d95a09d commit 2695c60

4 files changed

Lines changed: 55 additions & 70 deletions

File tree

crates/codegraph-core/src/edge_builder.rs

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -503,28 +503,31 @@ fn resolve_call_targets<'a>(
503503
.unwrap_or_default();
504504
if !exact.is_empty() { return exact; }
505505

506-
// For this/self/super: prefer class-scoped exact lookup (e.g. `this.area()` in
507-
// `Shape.describe` → try `Shape.area` first). This avoids false edges to unrelated
508-
// classes that happen to have a method with the same name in the same file.
509-
// Fall back to the broader same-file suffix scan only when the class-scoped lookup
510-
// finds nothing (e.g. when the caller is at module scope or the name is unknown).
511-
if call.receiver.is_some() {
512-
// Extract the class prefix from the enclosing caller name (e.g. "Shape" from "Shape.describe").
513-
if let Some(dot_pos) = caller_name.find('.') {
514-
let class_prefix = &caller_name[..dot_pos];
515-
let qualified = format!("{}.{}", class_prefix, call.name);
516-
let class_scoped: Vec<&NodeInfo> = ctx.nodes_by_name
517-
.get(qualified.as_str())
518-
.map(|v| v.iter().filter(|n| n.kind == "method").copied().collect())
519-
.unwrap_or_default();
520-
if !class_scoped.is_empty() { return class_scoped; }
521-
}
506+
// Class-scoped exact lookup: prefer `ClassName.method` when the caller is a qualified
507+
// method (e.g. `this.area()` or plain `area()` in `Shape.describe` → try `Shape.area`).
508+
// Covers both this/self/super dispatch AND no-receiver static sibling calls (e.g.
509+
// `IsValidEmail()` inside `Validators.ValidateUser` → `Validators.IsValidEmail`).
510+
// This avoids false edges to unrelated classes that happen to have a method with the
511+
// same name in the same file.
512+
if let Some(dot_pos) = caller_name.find('.') {
513+
let class_prefix = &caller_name[..dot_pos];
514+
let qualified = format!("{}.{}", class_prefix, call.name);
515+
let class_scoped: Vec<&NodeInfo> = ctx.nodes_by_name
516+
.get(qualified.as_str())
517+
.map(|v| v.iter()
518+
.filter(|n| n.kind == "method"
519+
&& import_resolution::compute_confidence(rel_path, &n.file, None) >= 0.5)
520+
.copied().collect())
521+
.unwrap_or_default();
522+
if !class_scoped.is_empty() { return class_scoped; }
523+
}
522524

523-
// Broader fallback: same-file suffix scan. Always restrict to the caller's
524-
// own class prefix — regardless of how many matches are found — to avoid
525-
// false-positive edges to unrelated classes in the same file.
526-
// (e.g. this.area() inside Shape.describe must never yield Calculator.area,
527-
// even when Calculator.area is the only method with that name in the file.)
525+
// Broader fallback: same-file suffix scan. Only for this/self/super (not no-receiver
526+
// plain calls) to avoid false positives on global function calls inside class methods.
527+
// Always restricts to the caller's own class prefix to avoid false edges to unrelated
528+
// classes in the same file (e.g. this.area() inside Shape.describe must never yield
529+
// Calculator.area, even when Calculator.area is the only method with that name).
530+
if call.receiver.is_some() {
528531
let suffix = format!(".{}", call.name);
529532
if let Some(file_nodes) = ctx.nodes_by_file.get(rel_path) {
530533
let same_file_methods: Vec<&NodeInfo> = file_nodes.iter()

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,10 +192,12 @@ export function resolveByMethodOrGlobal(
192192
.filter((t) => computeConfidence(relPath, t.file, null) >= 0.5);
193193
if (exact.length > 0) return exact;
194194

195-
// For this/self/super receiver: try same-class method lookup via callerName.
195+
// Try same-class method lookup via callerName.
196196
// e.g. `this.area()` inside `Shape.describe` → try `Shape.area`.
197+
// Also covers no-receiver calls inside class methods, e.g. `IsValidEmail(x)` inside
198+
// `Validators.ValidateUser` → try `Validators.IsValidEmail` (C#/Java static siblings).
197199
// This seeds the initial edge that runChaPostPass later expands to subclass overrides.
198-
if (call.receiver && callerName) {
200+
if (callerName) {
199201
const dotIdx = callerName.lastIndexOf('.');
200202
if (dotIdx > -1) {
201203
// Extract only the segment immediately before the method name so that

src/domain/graph/builder/stages/native-orchestrator.ts

Lines changed: 26 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -406,12 +406,12 @@ async function runPostNativeAnalysis(
406406
* can re-classify roles for the affected implementation files. An empty set
407407
* means no edges were added and role re-classification is unnecessary.
408408
*/
409-
function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
409+
function runPostNativeCha(db: BetterSqlite3Database): number {
410410
// Fast guard: no hierarchy edges → no CHA work
411411
const hasHierarchy = db
412412
.prepare(`SELECT 1 FROM edges WHERE kind IN ('extends', 'implements') LIMIT 1`)
413413
.get();
414-
if (!hasHierarchy) return new Set();
414+
if (!hasHierarchy) return 0;
415415

416416
// Build implementors map: parent/interface name → [child/implementing class names]
417417
const hierarchyRows = db
@@ -433,7 +433,7 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
433433
}
434434
if (!list.includes(row.child_name)) list.push(row.child_name);
435435
}
436-
if (implementors.size === 0) return new Set();
436+
if (implementors.size === 0) return 0;
437437

438438
// RTA: collect class names that are actually instantiated via `new X()`.
439439
// Primary query targets `class`-kind nodes (the canonical schema).
@@ -506,7 +506,7 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
506506
`SELECT id, file AS method_file FROM nodes WHERE name = ? AND kind = 'method'`,
507507
);
508508
const newEdges: Array<[number, number, string, number, number, string]> = [];
509-
const newTargetIds = new Set<number>();
509+
let newEdgeCount = 0;
510510

511511
for (const { source_id, method_name, caller_file } of callToMethods) {
512512
const dotIdx = method_name.indexOf('.');
@@ -545,7 +545,7 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
545545
CHA_DISPATCH_PENALTY;
546546
if (conf <= 0) continue;
547547
newEdges.push([source_id, methodNode.id, 'calls', conf, 0, 'cha']);
548-
newTargetIds.add(methodNode.id);
548+
newEdgeCount++;
549549
}
550550
}
551551

@@ -558,7 +558,7 @@ function runPostNativeCha(db: BetterSqlite3Database): Set<number> {
558558
if (newEdges.length > 0) {
559559
db.transaction(() => batchInsertEdges(db, newEdges))();
560560
}
561-
return newTargetIds;
561+
return newEdgeCount;
562562
}
563563

564564
/**
@@ -1607,48 +1607,28 @@ export async function tryNativeOrchestrator(
16071607
}
16081608

16091609
// Phase 8.5: expand CHA call edges (interface dispatch → concrete implementations).
1610-
// `runPostNativeCha` returns the target node IDs of newly inserted edges so we
1611-
// can re-classify roles for the implementation files. The Rust orchestrator ran
1612-
// role classification BEFORE this post-pass, so without a re-run the newly-called
1613-
// implementor methods stay classified as `dead-ffi` (no incoming edges at Rust time).
1614-
const chaTargetIds = runPostNativeCha(ctx.db as unknown as BetterSqlite3Database);
1615-
if (chaTargetIds.size > 0) {
1610+
// The Rust orchestrator ran role classification BEFORE this post-pass, so without
1611+
// a re-run the newly-called implementor methods stay classified as `dead-ffi`.
1612+
//
1613+
// CHA also changes the global fan-out distribution (callee files gain fan_in, and
1614+
// new edges shift the median). A full re-classification is required — not just the
1615+
// callee files — because the median shift can change roles in unrelated files whose
1616+
// fan-out sits near the old median. (Example: a method that called two siblings
1617+
// pre-CHA might be near the median, but post-CHA the median is higher, changing
1618+
// its role from utility → core.) Using an incremental pass with a stale median
1619+
// cache would produce incorrect roles outside the CHA-affected file set.
1620+
const chaEdgeCount = runPostNativeCha(ctx.db as unknown as BetterSqlite3Database);
1621+
if (chaEdgeCount > 0) {
16161622
try {
16171623
const db = ctx.db as unknown as BetterSqlite3Database;
1618-
const idArray = Array.from(chaTargetIds);
1619-
const CHUNK_SIZE = 500;
1620-
const seenFiles = new Set<string>();
1621-
const affectedFiles: Array<{ file: string }> = [];
1622-
for (let i = 0; i < idArray.length; i += CHUNK_SIZE) {
1623-
const chunk = idArray.slice(i, i + CHUNK_SIZE);
1624-
const placeholders = chunk.map(() => '?').join(',');
1625-
const rows = db
1626-
.prepare(
1627-
`SELECT DISTINCT file FROM nodes WHERE id IN (${placeholders}) AND file IS NOT NULL`,
1628-
)
1629-
.all(...chunk) as Array<{ file: string }>;
1630-
for (const row of rows) {
1631-
if (!seenFiles.has(row.file)) {
1632-
seenFiles.add(row.file);
1633-
affectedFiles.push(row);
1634-
}
1635-
}
1636-
}
1637-
if (affectedFiles.length > 0) {
1638-
const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
1639-
classifyNodeRoles: (
1640-
db: BetterSqlite3Database,
1641-
changedFiles?: string[] | null,
1642-
) => Record<string, number>;
1643-
};
1644-
classifyNodeRoles(
1645-
db,
1646-
affectedFiles.map((r) => r.file),
1647-
);
1648-
debug(
1649-
`CHA post-pass: re-classified roles for ${affectedFiles.length} implementation file(s)`,
1650-
);
1651-
}
1624+
const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
1625+
classifyNodeRoles: (
1626+
db: BetterSqlite3Database,
1627+
changedFiles?: string[] | null,
1628+
) => Record<string, number>;
1629+
};
1630+
classifyNodeRoles(db);
1631+
debug(`CHA post-pass: full role re-classification after ${chaEdgeCount} new CHA edges`);
16521632
} catch (err) {
16531633
debug(`CHA post-pass role re-classification failed: ${toErrorMessage(err)}`);
16541634
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ const THRESHOLDS: Record<string, { precision: number; recall: number }> = {
145145
python: { precision: 0.7, recall: 0.3 },
146146
go: { precision: 0.7, recall: 0.3 },
147147
java: { precision: 0.7, recall: 0.3 },
148-
csharp: { precision: 0.5, recall: 0.2 },
148+
csharp: { precision: 1.0, recall: 0.8 },
149149
kotlin: { precision: 0.6, recall: 0.2 },
150150
// Lower bars — resolution still maturing
151151
rust: { precision: 0.6, recall: 0.2 },

0 commit comments

Comments
 (0)