Skip to content

Commit 0831e36

Browse files
committed
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.
1 parent 2ce58cd commit 0831e36

4 files changed

Lines changed: 44 additions & 62 deletions

File tree

crates/codegraph-core/src/edge_builder.rs

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -503,28 +503,28 @@ 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().filter(|n| n.kind == "method").copied().collect())
518+
.unwrap_or_default();
519+
if !class_scoped.is_empty() { return class_scoped; }
520+
}
522521

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.)
522+
// Broader fallback: same-file suffix scan. Only for this/self/super (not no-receiver
523+
// plain calls) to avoid false positives on global function calls inside class methods.
524+
// Always restricts to the caller's own class prefix to avoid false edges to unrelated
525+
// classes in the same file (e.g. this.area() inside Shape.describe must never yield
526+
// Calculator.area, even when Calculator.area is the only method with that name).
527+
if call.receiver.is_some() {
528528
let suffix = format!(".{}", call.name);
529529
if let Some(file_nodes) = ctx.nodes_by_file.get(rel_path) {
530530
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
@@ -177,10 +177,12 @@ export function resolveByMethodOrGlobal(
177177
.filter((t) => computeConfidence(relPath, t.file, null) >= 0.5);
178178
if (exact.length > 0) return exact;
179179

180-
// For this/self/super receiver: try same-class method lookup via callerName.
180+
// Try same-class method lookup via callerName.
181181
// e.g. `this.area()` inside `Shape.describe` → try `Shape.area`.
182+
// Also covers no-receiver calls inside class methods, e.g. `IsValidEmail(x)` inside
183+
// `Validators.ValidateUser` → try `Validators.IsValidEmail` (C#/Java static siblings).
182184
// This seeds the initial edge that runChaPostPass later expands to subclass overrides.
183-
if (call.receiver && callerName) {
185+
if (callerName) {
184186
const dotIdx = callerName.lastIndexOf('.');
185187
if (dotIdx > -1) {
186188
const callerClass = callerName.slice(0, dotIdx);

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

Lines changed: 18 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1545,48 +1545,28 @@ export async function tryNativeOrchestrator(
15451545
}
15461546

15471547
// Phase 8.5: expand CHA call edges (interface dispatch → concrete implementations).
1548-
// `runPostNativeCha` returns the target node IDs of newly inserted edges so we
1549-
// can re-classify roles for the implementation files. The Rust orchestrator ran
1550-
// role classification BEFORE this post-pass, so without a re-run the newly-called
1551-
// implementor methods stay classified as `dead-ffi` (no incoming edges at Rust time).
1548+
// The Rust orchestrator ran role classification BEFORE this post-pass, so without
1549+
// a re-run the newly-called implementor methods stay classified as `dead-ffi`.
1550+
//
1551+
// CHA also changes the global fan-out distribution (callee files gain fan_in, and
1552+
// new edges shift the median). A full re-classification is required — not just the
1553+
// callee files — because the median shift can change roles in unrelated files whose
1554+
// fan-out sits near the old median. (Example: a method that called two siblings
1555+
// pre-CHA might be near the median, but post-CHA the median is higher, changing
1556+
// its role from utility → core.) Using an incremental pass with a stale median
1557+
// cache would produce incorrect roles outside the CHA-affected file set.
15521558
const chaTargetIds = runPostNativeCha(ctx.db as unknown as BetterSqlite3Database);
15531559
if (chaTargetIds.size > 0) {
15541560
try {
15551561
const db = ctx.db as unknown as BetterSqlite3Database;
1556-
const idArray = Array.from(chaTargetIds);
1557-
const CHUNK_SIZE = 500;
1558-
const seenFiles = new Set<string>();
1559-
const affectedFiles: Array<{ file: string }> = [];
1560-
for (let i = 0; i < idArray.length; i += CHUNK_SIZE) {
1561-
const chunk = idArray.slice(i, i + CHUNK_SIZE);
1562-
const placeholders = chunk.map(() => '?').join(',');
1563-
const rows = db
1564-
.prepare(
1565-
`SELECT DISTINCT file FROM nodes WHERE id IN (${placeholders}) AND file IS NOT NULL`,
1566-
)
1567-
.all(...chunk) as Array<{ file: string }>;
1568-
for (const row of rows) {
1569-
if (!seenFiles.has(row.file)) {
1570-
seenFiles.add(row.file);
1571-
affectedFiles.push(row);
1572-
}
1573-
}
1574-
}
1575-
if (affectedFiles.length > 0) {
1576-
const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
1577-
classifyNodeRoles: (
1578-
db: BetterSqlite3Database,
1579-
changedFiles?: string[] | null,
1580-
) => Record<string, number>;
1581-
};
1582-
classifyNodeRoles(
1583-
db,
1584-
affectedFiles.map((r) => r.file),
1585-
);
1586-
debug(
1587-
`CHA post-pass: re-classified roles for ${affectedFiles.length} implementation file(s)`,
1588-
);
1589-
}
1562+
const { classifyNodeRoles } = (await import('../../../../features/structure.js')) as {
1563+
classifyNodeRoles: (
1564+
db: BetterSqlite3Database,
1565+
changedFiles?: string[] | null,
1566+
) => Record<string, number>;
1567+
};
1568+
classifyNodeRoles(db);
1569+
debug(`CHA post-pass: full role re-classification after ${chaTargetIds.size} new CHA edges`);
15901570
} catch (err) {
15911571
debug(`CHA post-pass role re-classification failed: ${toErrorMessage(err)}`);
15921572
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ const THRESHOLDS: Record<string, { precision: number; recall: number }> = {
137137
python: { precision: 0.7, recall: 0.3 },
138138
go: { precision: 0.7, recall: 0.3 },
139139
java: { precision: 0.7, recall: 0.3 },
140-
csharp: { precision: 0.5, recall: 0.2 },
140+
csharp: { precision: 1.0, recall: 0.8 },
141141
kotlin: { precision: 0.6, recall: 0.2 },
142142
// Lower bars — resolution still maturing
143143
rust: { precision: 0.6, recall: 0.2 },

0 commit comments

Comments
 (0)