diff --git a/crates/codegraph-core/src/edge_builder.rs b/crates/codegraph-core/src/edge_builder.rs index e527bf07..c3a769fe 100644 --- a/crates/codegraph-core/src/edge_builder.rs +++ b/crates/codegraph-core/src/edge_builder.rs @@ -503,28 +503,31 @@ 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" + && import_resolution::compute_confidence(rel_path, &n.file, None) >= 0.5) + .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 e291a7cf..4b5402db 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -192,10 +192,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) { // Extract only the segment immediately before the method name so that diff --git a/src/domain/graph/builder/stages/native-orchestrator.ts b/src/domain/graph/builder/stages/native-orchestrator.ts index f53dd94e..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; } /** @@ -1607,48 +1607,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). - const chaTargetIds = runPostNativeCha(ctx.db as unknown as BetterSqlite3Database); - if (chaTargetIds.size > 0) { + // 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 chaEdgeCount = runPostNativeCha(ctx.db as unknown as BetterSqlite3Database); + if (chaEdgeCount > 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 ${chaEdgeCount} 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 433eea8f..4fc04653 100644 --- a/tests/benchmarks/resolution/resolution-benchmark.test.ts +++ b/tests/benchmarks/resolution/resolution-benchmark.test.ts @@ -145,7 +145,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 },