From e206fbe9f45b8cab465b1acf3b90676acc4a29d0 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 7 Jun 2026 20:21:18 -0600 Subject: [PATCH 1/4] 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 --- tests/integration/prototype-method-resolution.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/prototype-method-resolution.test.ts b/tests/integration/prototype-method-resolution.test.ts index 76e15240..396d2335 100644 --- a/tests/integration/prototype-method-resolution.test.ts +++ b/tests/integration/prototype-method-resolution.test.ts @@ -48,7 +48,7 @@ beforeAll(async () => { for (const [rel, content] of Object.entries(FIXTURE)) { fs.writeFileSync(path.join(tmpDir, rel), content); } - await buildGraph(tmpDir, { incremental: false, skipRegistry: true }); + await buildGraph(tmpDir, { incremental: false, skipRegistry: true, engine: 'wasm' }); }); afterAll(() => { From 2ce58cd4a0e33d21c3115e275ff939bdda1b207a Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 7 Jun 2026 20:53:34 -0600 Subject: [PATCH 2/4] test(integration): add TODO comment for WASM engine pin (#1400) --- tests/integration/prototype-method-resolution.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/prototype-method-resolution.test.ts b/tests/integration/prototype-method-resolution.test.ts index 396d2335..4e4b956d 100644 --- a/tests/integration/prototype-method-resolution.test.ts +++ b/tests/integration/prototype-method-resolution.test.ts @@ -48,6 +48,9 @@ beforeAll(async () => { for (const [rel, content] of Object.entries(FIXTURE)) { fs.writeFileSync(path.join(tmpDir, rel), content); } + // TODO(#1381): pinned to WASM because the published native binary predates the + // prototype-method fixes landed in #1331. Remove the pin (or add a dual-engine + // variant) once the native binary ships the corresponding Rust-side extraction. await buildGraph(tmpDir, { incremental: false, skipRegistry: true, engine: 'wasm' }); }); From 0831e3604fe620bac60af486bf80a2ebf2f52ccf Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sun, 7 Jun 2026 21:33:41 -0600 Subject: [PATCH 3/4] fix(resolver): qualified callerName mismatch in class-scoped typeMap lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/codegraph-core/src/edge_builder.rs | 42 +++++++------- src/domain/graph/builder/call-resolver.ts | 6 +- .../builder/stages/native-orchestrator.ts | 56 ++++++------------- .../resolution/resolution-benchmark.test.ts | 2 +- 4 files changed, 44 insertions(+), 62 deletions(-) diff --git a/crates/codegraph-core/src/edge_builder.rs b/crates/codegraph-core/src/edge_builder.rs index e527bf07..d72ef958 100644 --- a/crates/codegraph-core/src/edge_builder.rs +++ b/crates/codegraph-core/src/edge_builder.rs @@ -503,28 +503,28 @@ fn resolve_call_targets<'a>( .unwrap_or_default(); if !exact.is_empty() { return exact; } - // For this/self/super: prefer class-scoped exact lookup (e.g. `this.area()` in - // `Shape.describe` → try `Shape.area` first). This avoids false edges to unrelated - // classes that happen to have a method with the same name in the same file. - // Fall back to the broader same-file suffix scan only when the class-scoped lookup - // finds nothing (e.g. when the caller is at module scope or the name is unknown). - if call.receiver.is_some() { - // Extract the class prefix from the enclosing caller name (e.g. "Shape" from "Shape.describe"). - if let Some(dot_pos) = caller_name.find('.') { - let class_prefix = &caller_name[..dot_pos]; - let qualified = format!("{}.{}", class_prefix, call.name); - let class_scoped: Vec<&NodeInfo> = ctx.nodes_by_name - .get(qualified.as_str()) - .map(|v| v.iter().filter(|n| n.kind == "method").copied().collect()) - .unwrap_or_default(); - if !class_scoped.is_empty() { return class_scoped; } - } + // Class-scoped exact lookup: prefer `ClassName.method` when the caller is a qualified + // method (e.g. `this.area()` or plain `area()` in `Shape.describe` → try `Shape.area`). + // Covers both this/self/super dispatch AND no-receiver static sibling calls (e.g. + // `IsValidEmail()` inside `Validators.ValidateUser` → `Validators.IsValidEmail`). + // This avoids false edges to unrelated classes that happen to have a method with the + // same name in the same file. + if let Some(dot_pos) = caller_name.find('.') { + let class_prefix = &caller_name[..dot_pos]; + let qualified = format!("{}.{}", class_prefix, call.name); + let class_scoped: Vec<&NodeInfo> = ctx.nodes_by_name + .get(qualified.as_str()) + .map(|v| v.iter().filter(|n| n.kind == "method").copied().collect()) + .unwrap_or_default(); + if !class_scoped.is_empty() { return class_scoped; } + } - // 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.) + // Broader fallback: same-file suffix scan. Only for this/self/super (not no-receiver + // plain calls) to avoid false positives on global function calls inside class methods. + // Always restricts to the caller's own class prefix to avoid false 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). + if call.receiver.is_some() { 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() diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index 54c59641..0e4aba55 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -177,10 +177,12 @@ export function resolveByMethodOrGlobal( .filter((t) => computeConfidence(relPath, t.file, null) >= 0.5); if (exact.length > 0) return exact; - // For this/self/super receiver: try same-class method lookup via callerName. + // Try same-class method lookup via callerName. // e.g. `this.area()` inside `Shape.describe` → try `Shape.area`. + // 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 (call.receiver && callerName) { + if (callerName) { const dotIdx = callerName.lastIndexOf('.'); if (dotIdx > -1) { const callerClass = callerName.slice(0, dotIdx); diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index 1fde3b3e..41c6e2a5 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -1545,48 +1545,28 @@ export async function tryNativeOrchestrator( } // Phase 8.5: expand CHA call edges (interface dispatch → concrete implementations). - // `runPostNativeCha` returns the target node IDs of newly inserted edges so we - // can re-classify roles for the implementation files. The Rust orchestrator ran - // role classification BEFORE this post-pass, so without a re-run the newly-called - // implementor methods stay classified as `dead-ffi` (no incoming edges at Rust time). + // The Rust orchestrator ran role classification BEFORE this post-pass, so without + // a re-run the newly-called implementor methods stay classified as `dead-ffi`. + // + // CHA also changes the global fan-out distribution (callee files gain fan_in, and + // new edges shift the median). A full re-classification is required — not just the + // callee files — because the median shift can change roles in unrelated files whose + // fan-out sits near the old median. (Example: a method that called two siblings + // pre-CHA might be near the median, but post-CHA the median is higher, changing + // its role from utility → core.) Using an incremental pass with a stale median + // cache would produce incorrect roles outside the CHA-affected file set. const chaTargetIds = runPostNativeCha(ctx.db as unknown as BetterSqlite3Database); if (chaTargetIds.size > 0) { try { const db = ctx.db as unknown as BetterSqlite3Database; - const idArray = Array.from(chaTargetIds); - const CHUNK_SIZE = 500; - const seenFiles = new Set(); - const affectedFiles: Array<{ file: string }> = []; - for (let i = 0; i < idArray.length; i += CHUNK_SIZE) { - const chunk = idArray.slice(i, i + CHUNK_SIZE); - const placeholders = chunk.map(() => '?').join(','); - const rows = db - .prepare( - `SELECT DISTINCT file FROM nodes WHERE id IN (${placeholders}) AND file IS NOT NULL`, - ) - .all(...chunk) as Array<{ file: string }>; - for (const row of rows) { - if (!seenFiles.has(row.file)) { - seenFiles.add(row.file); - affectedFiles.push(row); - } - } - } - if (affectedFiles.length > 0) { - const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as { - classifyNodeRoles: ( - db: BetterSqlite3Database, - changedFiles?: string[] | null, - ) => Record; - }; - classifyNodeRoles( - db, - affectedFiles.map((r) => r.file), - ); - debug( - `CHA post-pass: re-classified roles for ${affectedFiles.length} implementation file(s)`, - ); - } + const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as { + classifyNodeRoles: ( + db: BetterSqlite3Database, + changedFiles?: string[] | null, + ) => Record; + }; + classifyNodeRoles(db); + debug(`CHA post-pass: full role re-classification after ${chaTargetIds.size} new CHA edges`); } catch (err) { debug(`CHA post-pass role re-classification failed: ${toErrorMessage(err)}`); } diff --git a/tests/benchmarks/resolution/resolution-benchmark.test.ts b/tests/benchmarks/resolution/resolution-benchmark.test.ts index 1e78c0ee..63d0f79c 100644 --- a/tests/benchmarks/resolution/resolution-benchmark.test.ts +++ b/tests/benchmarks/resolution/resolution-benchmark.test.ts @@ -137,7 +137,7 @@ const THRESHOLDS: Record = { python: { precision: 0.7, recall: 0.3 }, go: { precision: 0.7, recall: 0.3 }, java: { precision: 0.7, recall: 0.3 }, - csharp: { precision: 0.5, recall: 0.2 }, + csharp: { precision: 1.0, recall: 0.8 }, kotlin: { precision: 0.6, recall: 0.2 }, // Lower bars — resolution still maturing rust: { precision: 0.6, recall: 0.2 }, From c1a32c5e872efac46e312c5bb064a839ee24cc0b Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 8 Jun 2026 00:43:58 -0600 Subject: [PATCH 4/4] refactor(resolver): confidence filter in class-scoped Rust lookup; runPostNativeCha returns count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 to number — caller only reads .size, individual IDs are never used; clearer intent and avoids the unused Set allocation (#1403) --- crates/codegraph-core/src/edge_builder.rs | 5 ++++- .../builder/stages/native-orchestrator.ts | 18 +++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/codegraph-core/src/edge_builder.rs b/crates/codegraph-core/src/edge_builder.rs index d72ef958..c3a769fe 100644 --- a/crates/codegraph-core/src/edge_builder.rs +++ b/crates/codegraph-core/src/edge_builder.rs @@ -514,7 +514,10 @@ fn resolve_call_targets<'a>( let qualified = format!("{}.{}", class_prefix, call.name); let class_scoped: Vec<&NodeInfo> = ctx.nodes_by_name .get(qualified.as_str()) - .map(|v| v.iter().filter(|n| n.kind == "method").copied().collect()) + .map(|v| v.iter() + .filter(|n| n.kind == "method" + && import_resolution::compute_confidence(rel_path, &n.file, None) >= 0.5) + .copied().collect()) .unwrap_or_default(); if !class_scoped.is_empty() { return class_scoped; } } diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index 0c9468f9..341a460e 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -406,12 +406,12 @@ async function runPostNativeAnalysis( * can re-classify roles for the affected implementation files. An empty set * means no edges were added and role re-classification is unnecessary. */ -function runPostNativeCha(db: BetterSqlite3Database): Set { +function runPostNativeCha(db: BetterSqlite3Database): number { // Fast guard: no hierarchy edges → no CHA work const hasHierarchy = db .prepare(`SELECT 1 FROM edges WHERE kind IN ('extends', 'implements') LIMIT 1`) .get(); - if (!hasHierarchy) return new Set(); + if (!hasHierarchy) return 0; // Build implementors map: parent/interface name → [child/implementing class names] const hierarchyRows = db @@ -433,7 +433,7 @@ function runPostNativeCha(db: BetterSqlite3Database): Set { } if (!list.includes(row.child_name)) list.push(row.child_name); } - if (implementors.size === 0) return new Set(); + if (implementors.size === 0) return 0; // RTA: collect class names that are actually instantiated via `new X()`. // Primary query targets `class`-kind nodes (the canonical schema). @@ -506,7 +506,7 @@ function runPostNativeCha(db: BetterSqlite3Database): Set { `SELECT id, file AS method_file FROM nodes WHERE name = ? AND kind = 'method'`, ); const newEdges: Array<[number, number, string, number, number, string]> = []; - const newTargetIds = new Set(); + let newEdgeCount = 0; for (const { source_id, method_name, caller_file } of callToMethods) { const dotIdx = method_name.indexOf('.'); @@ -545,7 +545,7 @@ function runPostNativeCha(db: BetterSqlite3Database): Set { CHA_DISPATCH_PENALTY; if (conf <= 0) continue; newEdges.push([source_id, methodNode.id, 'calls', conf, 0, 'cha']); - newTargetIds.add(methodNode.id); + newEdgeCount++; } } @@ -558,7 +558,7 @@ function runPostNativeCha(db: BetterSqlite3Database): Set { if (newEdges.length > 0) { db.transaction(() => batchInsertEdges(db, newEdges))(); } - return newTargetIds; + return newEdgeCount; } /** @@ -1617,8 +1617,8 @@ export async function tryNativeOrchestrator( // pre-CHA might be near the median, but post-CHA the median is higher, changing // its role from utility → core.) Using an incremental pass with a stale median // cache would produce incorrect roles outside the CHA-affected file set. - const chaTargetIds = runPostNativeCha(ctx.db as unknown as BetterSqlite3Database); - if (chaTargetIds.size > 0) { + const chaEdgeCount = runPostNativeCha(ctx.db as unknown as BetterSqlite3Database); + if (chaEdgeCount > 0) { try { const db = ctx.db as unknown as BetterSqlite3Database; const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as { @@ -1628,7 +1628,7 @@ export async function tryNativeOrchestrator( ) => Record; }; classifyNodeRoles(db); - debug(`CHA post-pass: full role re-classification after ${chaTargetIds.size} new CHA edges`); + debug(`CHA post-pass: full role re-classification after ${chaEdgeCount} new CHA edges`); } catch (err) { debug(`CHA post-pass role re-classification failed: ${toErrorMessage(err)}`); }