From 9b1eca9f346a4c2d8531a49666e5172d3da59af9 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 8 Jun 2026 02:18:58 -0600 Subject: [PATCH 1/5] fix(resolver): apply confidence filter to static receiver fallback in resolveByMethodOrGlobal Every other lookup.byName() path in this function applies computeConfidence >= 0.5 before returning candidates. The direct static receiver fallback (added in #1395) was the only exception, risking false-positive edges across distant directories. All same- directory static calls (e.g. C# fixture) still resolve at confidence 0.7. Closes #1398 --- src/domain/graph/builder/call-resolver.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index 20dd885c..c9281eb4 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -121,7 +121,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; } From 7ef79a25b5886d8d4b26ff083feaac1a9c529edb Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 8 Jun 2026 16:14:07 -0600 Subject: [PATCH 2/5] fix(parity): resolve C# same-class static bare calls in WASM and native (closes #1416) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both WASM and native engines were missing call edges for bare static method calls within the same C# class — e.g. `IsValidEmail()` inside `Validators.ValidateUser` should resolve to `Validators.IsValidEmail`, but neither engine had a same-class fallback for no-receiver calls. Three-part fix: 1. WASM (`build-edges.ts`): after the `this.method()` same-class fallback, add a parallel fallback for no-receiver calls: when `targets` is empty and the call has no receiver, try `CallerClass.callName` in the same file. Only fires after the global exact lookup already failed, so module-level functions always win. 2. Native Rust (`edge_builder.rs`): mirror the WASM fallback in step 5 of `resolve_call_targets` — when `call.receiver.is_none()` and the global exact lookup returns nothing, try `CallerClass.callName` scoped to the same file. 3. Role parity (`native-orchestrator.ts`): the Rust pipeline classifies roles before JS CHA/this-dispatch post-passes add edges, giving stale fan-out medians. When those post-passes insert new edges, run a full role re-classification so the final roles see the complete graph. Result: C# same-file static recall improves from 0/2 (0%) to 2/2 (100%). Build-parity test: 8/8 pass (nodes, edges, roles, ast_nodes identical). docs check acknowledged --- crates/codegraph-core/src/edge_builder.rs | 20 +++++++++++++++++++ .../graph/builder/stages/build-edges.ts | 19 ++++++++++++++++++ .../builder/stages/native-orchestrator.ts | 20 +++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/crates/codegraph-core/src/edge_builder.rs b/crates/codegraph-core/src/edge_builder.rs index e527bf07..210c2776 100644 --- a/crates/codegraph-core/src/edge_builder.rs +++ b/crates/codegraph-core/src/edge_builder.rs @@ -503,6 +503,26 @@ fn resolve_call_targets<'a>( .unwrap_or_default(); if !exact.is_empty() { return exact; } + // Bare-call same-class fallback: mirrors the WASM buildFileCallEdges same-class + // bare-call fallback. When a no-receiver call can't be resolved globally, try the + // caller's own class prefix: `IsValidEmail()` in `Validators.ValidateUser` → + // `Validators.IsValidEmail`. Safe: only fires after the global exact lookup fails, + // so module-level functions always take priority. + if call.receiver.is_none() { + 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" && n.file == rel_path) + .copied() + .collect()) + .unwrap_or_default(); + if !class_scoped.is_empty() { return class_scoped; } + } + } + // 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. diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 0d9449c7..2bc1f52f 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -1261,6 +1261,25 @@ function buildFileCallEdges( } } + // Same-class bare-call fallback: when a no-receiver call can't be resolved + // globally, try the caller's own class as a qualifier. Handles C# static + // sibling calls: `IsValidEmail()` inside `Validators.ValidateUser` resolves + // to `Validators.IsValidEmail`. Safe for JS/TS: only fires when byName() + // already returned nothing (so module-level functions are found first). + if (targets.length === 0 && !call.receiver && caller.callerName != null) { + const dotIdx = caller.callerName.indexOf('.'); + if (dotIdx > 0) { + const className = caller.callerName.slice(0, dotIdx); + const qualifiedName = `${className}.${call.name}`; + const qualified = lookup + .byNameAndFile(qualifiedName, relPath) + .filter((n) => n.kind === 'method'); + if (qualified.length > 0) { + targets = qualified; + } + } + } + // Object.defineProperty accessor fallback: when a function is registered as // a getter/setter via `Object.defineProperty(obj, "bar", { get: getter })`, // calls to `this.X()` inside `getter` resolve against `obj` (this === obj diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index 1fde3b3e..fa4d0e71 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -1660,6 +1660,26 @@ export async function tryNativeOrchestrator( } } + // Full role re-classification after JS edge-writing post-passes. + // The Rust orchestrator classifies roles before these post-passes (CHA, + // this-dispatch) add edges, so the Rust-computed roles and the cached + // fan-out medians are stale. A full re-classification ensures the final + // roles reflect the true fan-in/out with all edges in place. + if (chaTargetIds.size > 0 || thisDispatchTargetIds.size > 0) { + try { + const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as { + classifyNodeRoles: ( + db: BetterSqlite3Database, + changedFiles?: string[] | null, + ) => Record; + }; + classifyNodeRoles(ctx.db as unknown as BetterSqlite3Database, null); + debug(`Post-pass full role re-classification complete`); + } catch (err) { + debug(`Post-pass full role re-classification failed: ${toErrorMessage(err)}`); + } + } + // Backfill the `technique` column on `calls` edges written by the Rust // orchestrator, which does not write the column. Runs after all edge-writing // phases (including the WASM dropped-language backfill, CHA post-pass, and From 95d023454ae66e9737a49392dacbd96a7536c321 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 8 Jun 2026 20:49:47 -0600 Subject: [PATCH 3/5] refactor(native-orchestrator): remove redundant partial role re-classifications The full classifyNodeRoles(null) pass added in this PR already subsumes both the CHA-only and thisDispatch-only file-scoped partial passes that preceded it. Remove the two partial passes to avoid running the classifier twice on thisDispatch-only builds. --- .../builder/stages/native-orchestrator.ts | 95 +------------------ 1 file changed, 2 insertions(+), 93 deletions(-) diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index 6a6c7dd9..38f9f271 100644 --- a/src/domain/graph/builder/stages/native-orchestrator.ts +++ b/src/domain/graph/builder/stages/native-orchestrator.ts @@ -1607,52 +1607,9 @@ 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). + // Returns the target node IDs of newly inserted edges; used to determine whether + // a full role re-classification is needed after all edge-writing post-passes complete. 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)`, - ); - } - } catch (err) { - debug(`CHA post-pass role re-classification failed: ${toErrorMessage(err)}`); - } - } // Function-as-object-property post-pass: the Rust engine does not yet recognise // `fn.method = function() {}` patterns. Re-parse only those JS/TS files via @@ -1674,54 +1631,6 @@ export async function tryNativeOrchestrator( !!result.isFullBuild, ); - // Re-classify roles for methods that gained incoming this/super dispatch edges. - // The Rust orchestrator classifies roles BEFORE this post-pass, so target methods - // (e.g. Animal.speak, ConcreteWorker.prepare) that had no callers at Rust time - // are classified `dead` or `dead-ffi`. Inserting the new call edges does not - // automatically update those role labels — without a re-run the stale labels - // propagate to dead-code detection and API boundary analysis. - if (thisDispatchTargetIds.size > 0) { - try { - const db = ctx.db as unknown as BetterSqlite3Database; - const idArray = Array.from(thisDispatchTargetIds); - 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( - `this/super dispatch post-pass: re-classified roles for ${affectedFiles.length} target file(s)`, - ); - } - } catch (err) { - debug(`this/super dispatch post-pass role re-classification failed: ${toErrorMessage(err)}`); - } - } - // Full role re-classification after JS edge-writing post-passes. // The Rust orchestrator classifies roles before these post-passes (CHA, // this-dispatch) add edges, so the Rust-computed roles and the cached From 9be5c71abcab0ae71ff56ea13ef61705e0491cae Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 9 Jun 2026 15:35:10 -0600 Subject: [PATCH 4/5] fix(build-edges): use lastIndexOf for namespace-aware class extraction in same-class fallbacks Both the this.method() fallback and the bare-call same-class fallback were using indexOf('.') which takes the first dot segment. For a caller named MyNS.Validators.ValidateUser this yields MyNS instead of Validators, causing the sibling edge lookup to miss. Align with call-resolver.ts which uses lastIndexOf + prevDot to isolate only the segment immediately before the method name. --- src/domain/graph/builder/stages/build-edges.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index d5a05fed..28466239 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -1340,9 +1340,10 @@ function buildFileCallEdges( // not the enclosing class, so qualifying with the child class name would // produce a false edge when the child also defines a same-named method. if (targets.length === 0 && call.receiver === 'this' && caller.callerName != null) { - const dotIdx = caller.callerName.indexOf('.'); - if (dotIdx > 0) { - const className = caller.callerName.slice(0, dotIdx); + const lastDot = caller.callerName.lastIndexOf('.'); + if (lastDot > 0) { + const prevDot = caller.callerName.lastIndexOf('.', lastDot - 1); + const className = caller.callerName.slice(prevDot + 1, lastDot); const qualifiedName = `${className}.${call.name}`; const qualified = lookup .byNameAndFile(qualifiedName, relPath) @@ -1359,9 +1360,10 @@ function buildFileCallEdges( // to `Validators.IsValidEmail`. Safe for JS/TS: only fires when byName() // already returned nothing (so module-level functions are found first). if (targets.length === 0 && !call.receiver && caller.callerName != null) { - const dotIdx = caller.callerName.indexOf('.'); - if (dotIdx > 0) { - const className = caller.callerName.slice(0, dotIdx); + const lastDot = caller.callerName.lastIndexOf('.'); + if (lastDot > 0) { + const prevDot = caller.callerName.lastIndexOf('.', lastDot - 1); + const className = caller.callerName.slice(prevDot + 1, lastDot); const qualifiedName = `${className}.${call.name}`; const qualified = lookup .byNameAndFile(qualifiedName, relPath) From 014c2925af207d45959b36f02ffe833563defddc Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Tue, 9 Jun 2026 16:49:56 -0600 Subject: [PATCH 5/5] feat(resolver): infer C# var-declared instance types from new-expression initializers When a local variable is declared with `var` and initialized via `new ClassName(...)`, seed the typeMap with the constructor type at confidence 1.0 so that subsequent method calls on that variable resolve correctly. Fixes 0/4 recall on the csharp receiver-typed benchmark. The C# tree-sitter grammar uses `implicit_type` (not `var_keyword`) for `var` declarations, and the `object_creation_expression` sits as a direct child of `variable_declarator` with no `equals_value_clause` wrapper. Applied to both the WASM (TypeScript) and native (Rust) extractors to maintain engine parity. Closes #1402 --- .../codegraph-core/src/extractors/csharp.rs | 22 ++++++++++++++++++- src/extractors/csharp.ts | 21 +++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/crates/codegraph-core/src/extractors/csharp.rs b/crates/codegraph-core/src/extractors/csharp.rs index 444109e6..5992dda0 100644 --- a/crates/codegraph-core/src/extractors/csharp.rs +++ b/crates/codegraph-core/src/extractors/csharp.rs @@ -455,7 +455,27 @@ fn match_csharp_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols, "variable_declaration" => { let type_node = node.child_by_field_name("type").or_else(|| node.child(0)); if let Some(type_node) = type_node { - if type_node.kind() != "var_keyword" && type_node.kind() != "implicit_type" { + if type_node.kind() == "implicit_type" { + // var x = new Foo() — infer type from object_creation_expression initializer + for i in 0..node.child_count() { + if let Some(declarator) = node.child(i) { + if declarator.kind() != "variable_declarator" { continue; } + let name_node = declarator.child_by_field_name("name") + .or_else(|| declarator.child(0)); + let Some(name_node) = name_node else { continue }; + if name_node.kind() != "identifier" { continue; } + let Some(obj_creation) = find_child(&declarator, "object_creation_expression") else { continue }; + let Some(ctor_type_node) = obj_creation.child_by_field_name("type") else { continue }; + if let Some(ctor_type) = extract_csharp_type_name(&ctor_type_node, source) { + symbols.type_map.push(TypeMapEntry { + name: node_text(&name_node, source).to_string(), + type_name: ctor_type.to_string(), + confidence: 1.0, + }); + } + } + } + } else if type_node.kind() != "var_keyword" { if let Some(type_name) = extract_csharp_type_name(&type_node, source) { for i in 0..node.child_count() { if let Some(child) = node.child(i) { diff --git a/src/extractors/csharp.ts b/src/extractors/csharp.ts index 52f47cb4..0082b841 100644 --- a/src/extractors/csharp.ts +++ b/src/extractors/csharp.ts @@ -332,7 +332,26 @@ function extractCSharpTypeMap(node: TreeSitterNode, ctx: ExtractorOutput): void /** 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 || typeNode.type === 'var_keyword') return; + if (!typeNode) return; + + if (typeNode.type === 'implicit_type') { + // var x = new Foo() — infer type from object_creation_expression initializer + if (!ctx.typeMap) 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 || nameNode.type !== 'identifier') continue; + const objCreation = findChild(child, 'object_creation_expression'); + if (!objCreation) continue; + const ctorTypeNode = objCreation.childForFieldName('type'); + if (!ctorTypeNode) continue; + const ctorType = extractCSharpTypeName(ctorTypeNode); + if (ctorType) setTypeMapEntry(ctx.typeMap, nameNode.text, ctorType, 1.0); + } + return; + } + const typeName = extractCSharpTypeName(typeNode); if (!typeName) return; for (let i = 0; i < node.childCount; i++) {