Skip to content

Commit 9037c13

Browse files
authored
fix(parity): resolve C# same-class bare static calls + confidence filter for static receiver fallback (#1417)
* 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 * fix(parity): resolve C# same-class static bare calls in WASM and native (closes #1416) 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 * 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. * 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. * 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
1 parent 5a06fa2 commit 9037c13

6 files changed

Lines changed: 104 additions & 158 deletions

File tree

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

Lines changed: 29 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -438,33 +438,6 @@ fn extract_csharp_base_types(
438438

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

441-
/// Extract the constructor type from a `var x = new Foo()` initializer.
442-
fn extract_var_init_type(declarator: &Node, source: &[u8]) -> Option<String> {
443-
for i in 0..declarator.child_count() {
444-
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.
448-
if child.kind() == "object_creation_expression" {
449-
if let Some(t) = child.child_by_field_name("type") {
450-
return extract_csharp_type_name(&t, source).map(|s| s.to_string());
451-
}
452-
}
453-
if child.kind() == "equals_value_clause" {
454-
for j in 0..child.child_count() {
455-
if let Some(expr) = child.child(j) {
456-
if expr.kind() == "object_creation_expression" {
457-
if let Some(t) = expr.child_by_field_name("type") {
458-
return extract_csharp_type_name(&t, source).map(|s| s.to_string());
459-
}
460-
}
461-
}
462-
}
463-
}
464-
}
465-
None
466-
}
467-
468441
fn extract_csharp_type_name<'a>(type_node: &Node<'a>, source: &'a [u8]) -> Option<&'a str> {
469442
match type_node.kind() {
470443
"identifier" | "qualified_name" => Some(node_text(type_node, source)),
@@ -482,29 +455,38 @@ fn match_csharp_type_map(node: &Node, source: &[u8], symbols: &mut FileSymbols,
482455
"variable_declaration" => {
483456
let type_node = node.child_by_field_name("type").or_else(|| node.child(0));
484457
if let Some(type_node) = type_node {
485-
let is_var = type_node.kind() == "implicit_type" || type_node.kind() == "var_keyword";
486-
let explicit_type_name: Option<String> = if is_var {
487-
None
488-
} else {
489-
extract_csharp_type_name(&type_node, source).map(|s| s.to_string())
490-
};
491-
if is_var || explicit_type_name.is_some() {
458+
if type_node.kind() == "implicit_type" {
459+
// var x = new Foo() — infer type from object_creation_expression initializer
492460
for i in 0..node.child_count() {
493-
if let Some(child) = node.child(i) {
494-
if child.kind() == "variable_declarator" {
495-
let name_node = child.child_by_field_name("name")
496-
.or_else(|| child.child(0));
497-
if let Some(name_node) = name_node {
498-
if name_node.kind() == "identifier" {
499-
let type_name = if is_var {
500-
extract_var_init_type(&child, source)
501-
} else {
502-
explicit_type_name.clone()
503-
};
504-
if let Some(type_name) = type_name {
461+
if let Some(declarator) = node.child(i) {
462+
if declarator.kind() != "variable_declarator" { continue; }
463+
let name_node = declarator.child_by_field_name("name")
464+
.or_else(|| declarator.child(0));
465+
let Some(name_node) = name_node else { continue };
466+
if name_node.kind() != "identifier" { continue; }
467+
let Some(obj_creation) = find_child(&declarator, "object_creation_expression") else { continue };
468+
let Some(ctor_type_node) = obj_creation.child_by_field_name("type") else { continue };
469+
if let Some(ctor_type) = extract_csharp_type_name(&ctor_type_node, source) {
470+
symbols.type_map.push(TypeMapEntry {
471+
name: node_text(&name_node, source).to_string(),
472+
type_name: ctor_type.to_string(),
473+
confidence: 1.0,
474+
});
475+
}
476+
}
477+
}
478+
} else if type_node.kind() != "var_keyword" {
479+
if let Some(type_name) = extract_csharp_type_name(&type_node, source) {
480+
for i in 0..node.child_count() {
481+
if let Some(child) = node.child(i) {
482+
if child.kind() == "variable_declarator" {
483+
let name_node = child.child_by_field_name("name")
484+
.or_else(|| child.child(0));
485+
if let Some(name_node) = name_node {
486+
if name_node.kind() == "identifier" {
505487
symbols.type_map.push(TypeMapEntry {
506488
name: node_text(&name_node, source).to_string(),
507-
type_name,
489+
type_name: type_name.to_string(),
508490
confidence: 0.9,
509491
});
510492
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const MODULE_SCOPED_BARE_CALL_EXTENSIONS = new Set([
4040
'.cts',
4141
]);
4242

43-
function isModuleScopedLanguage(relPath: string): boolean {
43+
export function isModuleScopedLanguage(relPath: string): boolean {
4444
const ext = relPath.slice(relPath.lastIndexOf('.'));
4545
return MODULE_SCOPED_BARE_CALL_EXTENSIONS.has(ext);
4646
}

src/domain/graph/builder/stages/build-edges.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { enrichTypeMapWithTsc } from '../../resolver/ts-resolver.js';
3030
import {
3131
type CallNodeLookup,
3232
findCaller,
33+
isModuleScopedLanguage,
3334
resolveCallTargets,
3435
resolveReceiverEdge,
3536
} from '../call-resolver.js';
@@ -1340,9 +1341,35 @@ function buildFileCallEdges(
13401341
// not the enclosing class, so qualifying with the child class name would
13411342
// produce a false edge when the child also defines a same-named method.
13421343
if (targets.length === 0 && call.receiver === 'this' && caller.callerName != null) {
1343-
const dotIdx = caller.callerName.indexOf('.');
1344-
if (dotIdx > 0) {
1345-
const className = caller.callerName.slice(0, dotIdx);
1344+
const lastDot = caller.callerName.lastIndexOf('.');
1345+
if (lastDot > 0) {
1346+
const prevDot = caller.callerName.lastIndexOf('.', lastDot - 1);
1347+
const className = caller.callerName.slice(prevDot + 1, lastDot);
1348+
const qualifiedName = `${className}.${call.name}`;
1349+
const qualified = lookup
1350+
.byNameAndFile(qualifiedName, relPath)
1351+
.filter((n) => n.kind === 'method');
1352+
if (qualified.length > 0) {
1353+
targets = qualified;
1354+
}
1355+
}
1356+
}
1357+
1358+
// Same-class bare-call fallback: when a no-receiver call can't be resolved
1359+
// globally, try the caller's own class as a qualifier. Handles C# static
1360+
// sibling calls: `IsValidEmail()` inside `Validators.ValidateUser` resolves
1361+
// to `Validators.IsValidEmail`. Skipped for JS/TS where bare calls are
1362+
// module-scoped, not class-scoped.
1363+
if (
1364+
targets.length === 0 &&
1365+
!call.receiver &&
1366+
caller.callerName != null &&
1367+
!isModuleScopedLanguage(relPath)
1368+
) {
1369+
const lastDot = caller.callerName.lastIndexOf('.');
1370+
if (lastDot > 0) {
1371+
const prevDot = caller.callerName.lastIndexOf('.', lastDot - 1);
1372+
const className = caller.callerName.slice(prevDot + 1, lastDot);
13461373
const qualifiedName = `${className}.${call.name}`;
13471374
const qualified = lookup
13481375
.byNameAndFile(qualifiedName, relPath)

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

Lines changed: 20 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -402,9 +402,9 @@ async function runPostNativeAnalysis(
402402
* Note: `this`/`super` dispatch is handled separately by `runPostNativeThisDispatch`,
403403
* which WASM-re-parses JS/TS files to obtain raw call site receiver info.
404404
*
405-
* Returns the set of target node IDs for newly inserted CHA edges so the caller
406-
* can re-classify roles for the affected implementation files. An empty set
407-
* means no edges were added and role re-classification is unnecessary.
405+
* Returns the count of newly inserted CHA edges so the caller can determine
406+
* whether a full role re-classification is needed. Zero means no edges were
407+
* added and role re-classification is unnecessary.
408408
*/
409409
function runPostNativeCha(db: BetterSqlite3Database): number {
410410
// Fast guard: no hierarchy edges → no CHA work
@@ -1607,37 +1607,9 @@ export async function tryNativeOrchestrator(
16071607
}
16081608

16091609
// Phase 8.5: expand CHA call edges (interface dispatch → concrete implementations).
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-
//
1621-
// Performance: classifyNodeRoles is O(all_nodes). For most repos this is sub-100ms;
1622-
// on very large codebases (100k+ nodes) it may add a few hundred ms per build.
1623-
// If this becomes a bottleneck, consider a two-pass strategy: incremental first
1624-
// (fast, slightly inaccurate), then full only when the median shifts by >N%.
1610+
// Returns the count of newly inserted edges; used to determine whether
1611+
// a full role re-classification is needed after all edge-writing post-passes complete.
16251612
const chaEdgeCount = runPostNativeCha(ctx.db as unknown as BetterSqlite3Database);
1626-
if (chaEdgeCount > 0) {
1627-
try {
1628-
const db = ctx.db as unknown as BetterSqlite3Database;
1629-
const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
1630-
classifyNodeRoles: (
1631-
db: BetterSqlite3Database,
1632-
changedFiles?: string[] | null,
1633-
) => Record<string, number>;
1634-
};
1635-
classifyNodeRoles(db);
1636-
debug(`CHA post-pass: full role re-classification after ${chaEdgeCount} new CHA edges`);
1637-
} catch (err) {
1638-
debug(`CHA post-pass role re-classification failed: ${toErrorMessage(err)}`);
1639-
}
1640-
}
16411613

16421614
// Function-as-object-property post-pass: the Rust engine does not yet recognise
16431615
// `fn.method = function() {}` patterns. Re-parse only those JS/TS files via
@@ -1659,51 +1631,23 @@ export async function tryNativeOrchestrator(
16591631
!!result.isFullBuild,
16601632
);
16611633

1662-
// Re-classify roles for methods that gained incoming this/super dispatch edges.
1663-
// The Rust orchestrator classifies roles BEFORE this post-pass, so target methods
1664-
// (e.g. Animal.speak, ConcreteWorker.prepare) that had no callers at Rust time
1665-
// are classified `dead` or `dead-ffi`. Inserting the new call edges does not
1666-
// automatically update those role labels — without a re-run the stale labels
1667-
// propagate to dead-code detection and API boundary analysis.
1668-
if (thisDispatchTargetIds.size > 0) {
1634+
// Full role re-classification after JS edge-writing post-passes.
1635+
// The Rust orchestrator classifies roles before these post-passes (CHA,
1636+
// this-dispatch) add edges, so the Rust-computed roles and the cached
1637+
// fan-out medians are stale. A full re-classification ensures the final
1638+
// roles reflect the true fan-in/out with all edges in place.
1639+
if (chaEdgeCount > 0 || thisDispatchTargetIds.size > 0) {
16691640
try {
1670-
const db = ctx.db as unknown as BetterSqlite3Database;
1671-
const idArray = Array.from(thisDispatchTargetIds);
1672-
const CHUNK_SIZE = 500;
1673-
const seenFiles = new Set<string>();
1674-
const affectedFiles: Array<{ file: string }> = [];
1675-
for (let i = 0; i < idArray.length; i += CHUNK_SIZE) {
1676-
const chunk = idArray.slice(i, i + CHUNK_SIZE);
1677-
const placeholders = chunk.map(() => '?').join(',');
1678-
const rows = db
1679-
.prepare(
1680-
`SELECT DISTINCT file FROM nodes WHERE id IN (${placeholders}) AND file IS NOT NULL`,
1681-
)
1682-
.all(...chunk) as Array<{ file: string }>;
1683-
for (const row of rows) {
1684-
if (!seenFiles.has(row.file)) {
1685-
seenFiles.add(row.file);
1686-
affectedFiles.push(row);
1687-
}
1688-
}
1689-
}
1690-
if (affectedFiles.length > 0) {
1691-
const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
1692-
classifyNodeRoles: (
1693-
db: BetterSqlite3Database,
1694-
changedFiles?: string[] | null,
1695-
) => Record<string, number>;
1696-
};
1697-
classifyNodeRoles(
1698-
db,
1699-
affectedFiles.map((r) => r.file),
1700-
);
1701-
debug(
1702-
`this/super dispatch post-pass: re-classified roles for ${affectedFiles.length} target file(s)`,
1703-
);
1704-
}
1641+
const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
1642+
classifyNodeRoles: (
1643+
db: BetterSqlite3Database,
1644+
changedFiles?: string[] | null,
1645+
) => Record<string, number>;
1646+
};
1647+
classifyNodeRoles(ctx.db as unknown as BetterSqlite3Database, null);
1648+
debug(`Post-pass full role re-classification complete`);
17051649
} catch (err) {
1706-
debug(`this/super dispatch post-pass role re-classification failed: ${toErrorMessage(err)}`);
1650+
debug(`Post-pass full role re-classification failed: ${toErrorMessage(err)}`);
17071651
}
17081652
}
17091653

src/extractors/csharp.ts

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

332-
/** Extract the constructor type from a `var x = new Foo()` initializer. */
333-
function extractVarInitType(declarator: TreeSitterNode): string | null {
334-
for (let i = 0; i < declarator.childCount; i++) {
335-
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.
339-
if (child?.type === 'object_creation_expression') {
340-
const tNode = child.childForFieldName('type');
341-
if (tNode) return extractCSharpTypeName(tNode);
342-
}
343-
if (child?.type === 'equals_value_clause') {
344-
for (let j = 0; j < child.childCount; j++) {
345-
const expr = child.child(j);
346-
if (expr?.type === 'object_creation_expression') {
347-
const tNode = expr.childForFieldName('type');
348-
if (tNode) return extractCSharpTypeName(tNode);
349-
}
350-
}
351-
}
352-
}
353-
return null;
354-
}
355-
356-
/** Extract type info from a variable_declaration node (local vars with explicit or inferred types). */
332+
/** Extract type info from a variable_declaration node (local vars with explicit types). */
357333
function handleCSharpVarDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
358334
const typeNode = node.childForFieldName('type') || node.child(0);
359335
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;
336+
337+
if (typeNode.type === 'implicit_type') {
338+
// var x = new Foo() — infer type from object_creation_expression initializer
339+
if (!ctx.typeMap) return;
340+
for (let i = 0; i < node.childCount; i++) {
341+
const child = node.child(i);
342+
if (child?.type !== 'variable_declarator') continue;
343+
const nameNode = child.childForFieldName('name') || child.child(0);
344+
if (nameNode?.type !== 'identifier') continue;
345+
const objCreation = findChild(child, 'object_creation_expression');
346+
if (!objCreation) continue;
347+
const ctorTypeNode = objCreation.childForFieldName('type');
348+
if (!ctorTypeNode) continue;
349+
const ctorType = extractCSharpTypeName(ctorTypeNode);
350+
if (ctorType) setTypeMapEntry(ctx.typeMap, nameNode.text, ctorType, 1.0);
351+
}
352+
return;
353+
}
354+
355+
const typeName = extractCSharpTypeName(typeNode);
356+
if (!typeName) return;
363357
for (let i = 0; i < node.childCount; i++) {
364358
const child = node.child(i);
365359
if (child?.type !== 'variable_declarator') continue;
366360
const nameNode = child.childForFieldName('name') || child.child(0);
367361
if (nameNode?.type !== 'identifier' || !ctx.typeMap) continue;
368-
const typeName = isVar ? extractVarInitType(child) : explicitTypeName;
369362
if (typeName) setTypeMapEntry(ctx.typeMap, nameNode.text, typeName, 0.9);
370363
}
371364
}

tests/parsers/csharp.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,8 @@ public class Service : BaseService, IDisposable {
160160
service.AddUser(null);
161161
}
162162
}`);
163-
expect(symbols.typeMap.get('service')).toEqual({ type: 'UserService', confidence: 0.9 });
164-
expect(symbols.typeMap.get('repo')).toEqual({ type: 'UserRepository', confidence: 0.9 });
163+
expect(symbols.typeMap.get('service')).toEqual({ type: 'UserService', confidence: 1.0 });
164+
expect(symbols.typeMap.get('repo')).toEqual({ type: 'UserRepository', confidence: 1.0 });
165165
});
166166

167167
it('populates typeMap for explicitly-typed local variables', () => {

0 commit comments

Comments
 (0)