Skip to content

Commit 7ef79a2

Browse files
committed
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
1 parent a2f35d1 commit 7ef79a2

3 files changed

Lines changed: 59 additions & 0 deletions

File tree

crates/codegraph-core/src/edge_builder.rs

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

506+
// Bare-call same-class fallback: mirrors the WASM buildFileCallEdges same-class
507+
// bare-call fallback. When a no-receiver call can't be resolved globally, try the
508+
// caller's own class prefix: `IsValidEmail()` in `Validators.ValidateUser` →
509+
// `Validators.IsValidEmail`. Safe: only fires after the global exact lookup fails,
510+
// so module-level functions always take priority.
511+
if call.receiver.is_none() {
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" && n.file == rel_path)
519+
.copied()
520+
.collect())
521+
.unwrap_or_default();
522+
if !class_scoped.is_empty() { return class_scoped; }
523+
}
524+
}
525+
506526
// For this/self/super: prefer class-scoped exact lookup (e.g. `this.area()` in
507527
// `Shape.describe` → try `Shape.area` first). This avoids false edges to unrelated
508528
// classes that happen to have a method with the same name in the same file.

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,6 +1261,25 @@ function buildFileCallEdges(
12611261
}
12621262
}
12631263

1264+
// Same-class bare-call fallback: when a no-receiver call can't be resolved
1265+
// globally, try the caller's own class as a qualifier. Handles C# static
1266+
// sibling calls: `IsValidEmail()` inside `Validators.ValidateUser` resolves
1267+
// to `Validators.IsValidEmail`. Safe for JS/TS: only fires when byName()
1268+
// already returned nothing (so module-level functions are found first).
1269+
if (targets.length === 0 && !call.receiver && caller.callerName != null) {
1270+
const dotIdx = caller.callerName.indexOf('.');
1271+
if (dotIdx > 0) {
1272+
const className = caller.callerName.slice(0, dotIdx);
1273+
const qualifiedName = `${className}.${call.name}`;
1274+
const qualified = lookup
1275+
.byNameAndFile(qualifiedName, relPath)
1276+
.filter((n) => n.kind === 'method');
1277+
if (qualified.length > 0) {
1278+
targets = qualified;
1279+
}
1280+
}
1281+
}
1282+
12641283
// Object.defineProperty accessor fallback: when a function is registered as
12651284
// a getter/setter via `Object.defineProperty(obj, "bar", { get: getter })`,
12661285
// calls to `this.X()` inside `getter` resolve against `obj` (this === obj

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1660,6 +1660,26 @@ export async function tryNativeOrchestrator(
16601660
}
16611661
}
16621662

1663+
// Full role re-classification after JS edge-writing post-passes.
1664+
// The Rust orchestrator classifies roles before these post-passes (CHA,
1665+
// this-dispatch) add edges, so the Rust-computed roles and the cached
1666+
// fan-out medians are stale. A full re-classification ensures the final
1667+
// roles reflect the true fan-in/out with all edges in place.
1668+
if (chaTargetIds.size > 0 || thisDispatchTargetIds.size > 0) {
1669+
try {
1670+
const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
1671+
classifyNodeRoles: (
1672+
db: BetterSqlite3Database,
1673+
changedFiles?: string[] | null,
1674+
) => Record<string, number>;
1675+
};
1676+
classifyNodeRoles(ctx.db as unknown as BetterSqlite3Database, null);
1677+
debug(`Post-pass full role re-classification complete`);
1678+
} catch (err) {
1679+
debug(`Post-pass full role re-classification failed: ${toErrorMessage(err)}`);
1680+
}
1681+
}
1682+
16631683
// Backfill the `technique` column on `calls` edges written by the Rust
16641684
// orchestrator, which does not write the column. Runs after all edge-writing
16651685
// phases (including the WASM dropped-language backfill, CHA post-pass, and

0 commit comments

Comments
 (0)