From af5fbc38f58c7d4e960da3a03690e7fc721e9194 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Mon, 6 Jul 2026 22:18:04 -0600 Subject: [PATCH] fix: add missing same-class bare-call fallback to incremental rebuild The full-build call-resolution pipeline (stages/build-edges.ts) retries a no-receiver call as . when global resolution fails -- this is how C#/Java-style static sibling calls (e.g. IsValidEmail() inside Validators.ValidateUser) resolve to Validators.IsValidEmail. The incremental single-file rebuild path (incremental.ts, used by watch mode) only implemented the same-class this.method() and Object.defineProperty fallbacks, leaving class-scoped bare calls without a matching fallback. Consolidates resolveSameClassThisFallback and resolveSameClassBareCallFallback into the shared call-resolver.ts module (previously private to build-edges.ts) so the full-build and incremental pipelines call the exact same functions and cannot drift out of sync again. incremental.ts's applyCallResolutionFallbacks (renamed from applyThisReceiverFallbacks, since it now covers more than this-receiver calls) wires in the bare-call strategy between the existing this-fallback and the defineProperty accessor fallback, mirroring resolveFallbackTargets's strategy order in build-edges.ts. --- src/domain/graph/builder/call-resolver.ts | 46 ++++++ src/domain/graph/builder/incremental.ts | 33 +++-- .../graph/builder/stages/build-edges.ts | 40 +----- tests/fixtures/bare-call-scope/Validators.cs | 29 ++++ ...765-incremental-bare-call-fallback.test.ts | 115 +++++++++++++++ tests/unit/call-resolver.test.ts | 133 ++++++++++++++++++ 6 files changed, 346 insertions(+), 50 deletions(-) create mode 100644 tests/fixtures/bare-call-scope/Validators.cs create mode 100644 tests/integration/issue-1765-incremental-bare-call-fallback.test.ts diff --git a/src/domain/graph/builder/call-resolver.ts b/src/domain/graph/builder/call-resolver.ts index 68a5a2589..df87f0bad 100644 --- a/src/domain/graph/builder/call-resolver.ts +++ b/src/domain/graph/builder/call-resolver.ts @@ -60,6 +60,52 @@ export function resolveSameClassQualifiedMethod( .filter((n) => n.kind === 'method'); } +/** + * Same-class `this.method()` fallback: when the call receiver is `this` and + * the primary resolution pass found nothing, derive the enclosing class name + * from the caller (e.g. `Logger.info` → class prefix `Logger`) and retry with + * the qualified method name `Logger._write`. This mirrors what the native + * Rust engine does implicitly via its class-scoped symbol table. + * + * NOTE: restricted to `this` only — `super.method()` targets a parent class, + * 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. + * + * Shared by the full-build (`stages/build-edges.ts`) and incremental + * (`incremental.ts`) call-resolution pipelines so the two cannot drift out of + * sync (#1765). + */ +export function resolveSameClassThisFallback( + call: { name: string; receiver?: string | null }, + callerName: string | null, + relPath: string, + lookup: CallNodeLookup, +): Array<{ id: number; file: string; kind?: string }> { + if (call.receiver !== 'this' || callerName == null) return []; + return resolveSameClassQualifiedMethod(call.name, callerName, relPath, lookup); +} + +/** + * 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`. Skipped for JS/TS where bare calls are + * module-scoped, not class-scoped. + * + * Shared by the full-build (`stages/build-edges.ts`) and incremental + * (`incremental.ts`) call-resolution pipelines so the two cannot drift out of + * sync (#1765). + */ +export function resolveSameClassBareCallFallback( + call: { name: string; receiver?: string | null }, + callerName: string | null, + relPath: string, + lookup: CallNodeLookup, +): Array<{ id: number; file: string; kind?: string }> { + if (call.receiver || callerName == null || isModuleScopedLanguage(relPath)) return []; + return resolveSameClassQualifiedMethod(call.name, callerName, relPath, lookup); +} + // ── Shared resolution functions ────────────────────────────────────────── /** diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index c1f443795..282935dc1 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -26,7 +26,8 @@ import { findCaller, resolveCallTargets, resolveReceiverEdge, - resolveSameClassQualifiedMethod, + resolveSameClassBareCallFallback, + resolveSameClassThisFallback, } from './call-resolver.js'; import { BUILTIN_RECEIVERS, fileHash, fileStat, readFileSafe } from './helpers.js'; import { importNamePairs } from './import-utils.js'; @@ -607,7 +608,7 @@ function buildIncrementalTypeMap(symbols: ExtractorOutput): Map } /** - * Strategy 2 — Object.defineProperty accessor fallback. + * Strategy 3 — 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`. Looks up the receiver var in the @@ -643,10 +644,16 @@ function resolveDefinePropertyTarget( } /** - * Apply `this`-receiver fallback resolution strategies for a single call site - * when the primary resolveCallTargets pass returned no targets. + * Apply call-resolution fallback strategies for a single call site when the + * primary resolveCallTargets pass returned no targets. + * + * Runs in order (mirrors `resolveFallbackTargets` in the full-build + * `stages/build-edges.ts` pipeline — see #1765): + * 1. Same-class `this.method()` fallback. + * 2. Same-class bare-call fallback for non-JS/TS class-scoped languages. + * 3. Object.defineProperty accessor fallback (this-calls inside getter/setter). */ -function applyThisReceiverFallbacks( +function applyCallResolutionFallbacks( call: { name: string; receiver?: string | null }, callerName: string | null, relPath: string, @@ -658,12 +665,14 @@ function applyThisReceiverFallbacks( if (initialTargets.length > 0) return initialTargets; // Strategy 1: same-class `this.method()` fallback. - if (call.receiver === 'this' && callerName != null) { - const s1 = resolveSameClassQualifiedMethod(call.name, callerName, relPath, lookup); - if (s1.length > 0) return s1; - } + const sameClassThis = resolveSameClassThisFallback(call, callerName, relPath, lookup); + if (sameClassThis.length > 0) return sameClassThis; + + // Strategy 2: same-class bare-call fallback (non-this, class-scoped languages). + const sameClassBare = resolveSameClassBareCallFallback(call, callerName, relPath, lookup); + if (sameClassBare.length > 0) return sameClassBare; - // Strategy 2: Object.defineProperty accessor fallback. + // Strategy 3: Object.defineProperty accessor fallback. if (call.receiver === 'this' && callerName != null && definePropertyReceivers) { return resolveDefinePropertyTarget( call.name, @@ -760,7 +769,7 @@ function buildCallEdges( importedOriginalNames, ); - const targets = applyThisReceiverFallbacks( + const targets = applyCallResolutionFallbacks( call, caller.callerName, relPath, @@ -801,7 +810,7 @@ function buildCallEdges( * same file), as opposed to `'points-to'` (alias/pts fallback) or `'cha'` / * `'super-dispatch'` (virtual-dispatch expansion). `incremental.ts`'s call * resolution (`resolveCallTargets` + the same-class/defineProperty - * fallbacks in `applyThisReceiverFallbacks`) implements only that same + * fallbacks in `applyCallResolutionFallbacks`) implements only that same * direct-resolution cascade — it has no pts or CHA/RTA post-pass of its own * — so every edge it emits is the direct-resolution case and always * belongs under `'ts-native'`, regardless of which engine parsed the file. diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index d12abfc4e..d85ec7d72 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -44,7 +44,8 @@ import { isModuleScopedLanguage, resolveCallTargets, resolveReceiverEdge, - resolveSameClassQualifiedMethod, + resolveSameClassBareCallFallback, + resolveSameClassThisFallback, } from '../call-resolver.js'; import type { ChaContext } from '../cha.js'; import { buildChaContext, resolveChaTargets, resolveThisDispatch } from '../cha.js'; @@ -1169,43 +1170,6 @@ function resolveKotlinReflectionPreQualified( return []; } -/** - * Same-class `this.method()` fallback: when the call receiver is `this` and - * resolveCallTargets found nothing, derive the enclosing class name from the - * caller (e.g. `Logger.info` → class prefix `Logger`) and retry with the - * qualified method name `Logger._write`. This mirrors what the native Rust - * engine does implicitly via its class-scoped symbol table. - * NOTE: restricted to `this` only — `super.method()` targets a parent class, - * 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. - */ -function resolveSameClassThisFallback( - call: Call, - callerName: string | null, - relPath: string, - lookup: CallNodeLookup, -): Array<{ id: number; file: string; kind?: string }> { - if (call.receiver !== 'this' || callerName == null) return []; - return resolveSameClassQualifiedMethod(call.name, callerName, relPath, lookup); -} - -/** - * 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`. Skipped for JS/TS where bare calls are - * module-scoped, not class-scoped. - */ -function resolveSameClassBareCallFallback( - call: Call, - callerName: string | null, - relPath: string, - lookup: CallNodeLookup, -): Array<{ id: number; file: string; kind?: string }> { - if (call.receiver || callerName == null || isModuleScopedLanguage(relPath)) return []; - return resolveSameClassQualifiedMethod(call.name, callerName, relPath, lookup); -} - /** * RES-3: reflection with literal method name — JVM getMethod("name") / invokeMethod("name"). * Java/Scala/Groovy methods are stored as class-qualified names (e.g. Reflection.greet), diff --git a/tests/fixtures/bare-call-scope/Validators.cs b/tests/fixtures/bare-call-scope/Validators.cs new file mode 100644 index 000000000..257fb1f9b --- /dev/null +++ b/tests/fixtures/bare-call-scope/Validators.cs @@ -0,0 +1,29 @@ +namespace App +{ + public static class Validators + { + public static bool ValidateUser(string email) + { + return IsValidEmail(email); + } + + public static bool IsValidEmail(string email) + { + return email.Contains("@"); + } + } + + public static class Formatters + { + // Same method name as Validators.IsValidEmail — must NOT cross-resolve. + public static bool IsValidEmail(string value) + { + return false; + } + + public static string FormatName(string name) + { + return name.Trim(); + } + } +} diff --git a/tests/integration/issue-1765-incremental-bare-call-fallback.test.ts b/tests/integration/issue-1765-incremental-bare-call-fallback.test.ts new file mode 100644 index 000000000..4ce203b97 --- /dev/null +++ b/tests/integration/issue-1765-incremental-bare-call-fallback.test.ts @@ -0,0 +1,115 @@ +/** + * Regression test for #1765: incremental rebuild was missing the same-class + * bare-call fallback that the full-build pipeline has + * (`resolveSameClassBareCallFallback` in `stages/build-edges.ts`). + * + * For class-scoped languages (e.g. C#), a bare call with no receiver that + * fails global resolution should retry qualified as `.` + * — this is how C# static sibling calls like `IsValidEmail()` inside + * `Validators.ValidateUser` resolve to `Validators.IsValidEmail`. + * + * `incremental.ts`'s call-resolution path (`applyCallResolutionFallbacks`) + * now shares the same fallback helpers as the full build + * (`resolveSameClassThisFallback` / `resolveSameClassBareCallFallback` in + * `call-resolver.ts`), so a single-file incremental rebuild (watch mode) + * must produce the exact same call edges as a full `codegraph build`. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildGraph } from '../../src/domain/graph/builder.js'; + +const FIXTURE_DIR = path.join(import.meta.dirname, '..', 'fixtures', 'bare-call-scope'); + +interface CallEdgeRow { + caller_name: string; + callee_name: string; +} + +function readCallEdges(dbPath: string): CallEdgeRow[] { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS caller_name, n2.name AS callee_name + FROM edges e + JOIN nodes n1 ON e.source_id = n1.id + JOIN nodes n2 ON e.target_id = n2.id + WHERE e.kind = 'calls' + ORDER BY n1.name, n2.name`, + ) + .all() as CallEdgeRow[]; + } finally { + db.close(); + } +} + +describe('incremental same-class bare-call fallback parity (#1765)', () => { + let tmpDir: string; + let fullEdges: CallEdgeRow[]; + let incrEdges: CallEdgeRow[]; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-bare-call-parity-')); + const fullDir = path.join(tmpDir, 'full'); + const incrDir = path.join(tmpDir, 'incr'); + + fs.cpSync(FIXTURE_DIR, fullDir, { recursive: true }); + fs.cpSync(FIXTURE_DIR, incrDir, { recursive: true }); + + // Initial full build on the incr copy (establishes baseline hashes) + await buildGraph(incrDir, { incremental: false, skipRegistry: true, engine: 'wasm' }); + + // Comment-only touch — triggers incremental rebuild of Validators.cs + const touch = (dir: string) => + fs.appendFileSync(path.join(dir, 'Validators.cs'), '\n// touch\n'); + touch(fullDir); + touch(incrDir); + + // Full build from scratch + await buildGraph(fullDir, { incremental: false, skipRegistry: true, engine: 'wasm' }); + // Incremental rebuild — exercises applyCallResolutionFallbacks's bare-call strategy + await buildGraph(incrDir, { incremental: true, skipRegistry: true, engine: 'wasm' }); + + fullEdges = readCallEdges(path.join(fullDir, '.codegraph', 'graph.db')); + incrEdges = readCallEdges(path.join(incrDir, '.codegraph', 'graph.db')); + }, 60_000); + + afterAll(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('incremental emits Validators.ValidateUser → Validators.IsValidEmail (bare-call same-class fallback)', () => { + const edge = incrEdges.find( + (e) => + e.caller_name === 'Validators.ValidateUser' && e.callee_name === 'Validators.IsValidEmail', + ); + expect( + edge, + `Expected Validators.ValidateUser -> Validators.IsValidEmail in incremental build.\nActual edges:\n${JSON.stringify(incrEdges, null, 2)}`, + ).toBeDefined(); + }); + + it('incremental does NOT emit Validators.ValidateUser → Formatters.IsValidEmail (cross-class false-positive)', () => { + const edge = incrEdges.find( + (e) => + e.caller_name === 'Validators.ValidateUser' && e.callee_name === 'Formatters.IsValidEmail', + ); + expect( + edge, + `Expected NO Validators.ValidateUser -> Formatters.IsValidEmail edge.\nActual edges:\n${JSON.stringify(incrEdges, null, 2)}`, + ).toBeUndefined(); + }); + + it('incremental edges match full build edges exactly', () => { + const fullSet = new Set(fullEdges.map((e) => `${e.caller_name}→${e.callee_name}`)); + const incrSet = new Set(incrEdges.map((e) => `${e.caller_name}→${e.callee_name}`)); + const missing = [...fullSet].filter((k) => !incrSet.has(k)); + const extra = [...incrSet].filter((k) => !fullSet.has(k)); + expect(missing, `Missing in incremental: ${missing.join(', ')}`).toEqual([]); + expect(extra, `Extra in incremental: ${extra.join(', ')}`).toEqual([]); + }); +}); diff --git a/tests/unit/call-resolver.test.ts b/tests/unit/call-resolver.test.ts index 2c4d5daa9..d2a498172 100644 --- a/tests/unit/call-resolver.test.ts +++ b/tests/unit/call-resolver.test.ts @@ -19,6 +19,8 @@ import type { CallNodeLookup } from '../../src/domain/graph/builder/call-resolve import { resolveByMethodOrGlobal, resolveReceiverEdge, + resolveSameClassBareCallFallback, + resolveSameClassThisFallback, } from '../../src/domain/graph/builder/call-resolver.js'; function makeLookup( @@ -212,6 +214,137 @@ describe('resolveByMethodOrGlobal — bare-call JS/TS module-scope guard (#1407) }); }); +// ── resolveSameClassThisFallback / resolveSameClassBareCallFallback (#1765) ── + +/** + * These two functions are shared by the full-build (`stages/build-edges.ts`) + * and incremental (`incremental.ts`) call-resolution pipelines so they cannot + * drift out of sync (#1765: incremental rebuild was missing the same-class + * bare-call fallback entirely). Testing them directly here — independent of + * `resolveByMethodOrGlobal`'s own embedded same-class fallback — proves each + * pipeline gets a working fallback even if a future change narrows or removes + * the embedded one in `resolveByGlobal` (`../resolver/strategy.ts`). + * + * `byNameAndFile` is scoped by construction to `makeLookup`'s `sameFile` + * bucket only — a deliberately different data source than `byName` — so a + * passing test proves these functions genuinely resolve via the file-scoped + * lookup, not by accident through some other path. + */ +function makeScopedLookup( + sameFile: Record>, +): CallNodeLookup { + return { + byNameAndFile(name, file) { + return sameFile[`${name}|${file}`] ?? []; + }, + byName() { + return []; + }, + isBarrel() { + return false; + }, + resolveBarrel() { + return null; + }, + nodeId() { + return undefined; + }, + }; +} + +describe('resolveSameClassThisFallback (#1765)', () => { + const areaMethod = { id: 1, file: 'shapes.cs', kind: 'method' }; + + it('resolves this.area() inside Shape.describe to Shape.area', () => { + const lookup = makeScopedLookup({ 'Shape.area|shapes.cs': [areaMethod] }); + const result = resolveSameClassThisFallback( + { name: 'area', receiver: 'this' }, + 'Shape.describe', + 'shapes.cs', + lookup, + ); + expect(result).toEqual([areaMethod]); + }); + + it('does not resolve when receiver is not this (e.g. super)', () => { + const lookup = makeScopedLookup({ 'Shape.area|shapes.cs': [areaMethod] }); + const result = resolveSameClassThisFallback( + { name: 'area', receiver: 'super' }, + 'Shape.describe', + 'shapes.cs', + lookup, + ); + expect(result).toEqual([]); + }); + + it('does not resolve when callerName is null', () => { + const lookup = makeScopedLookup({ 'Shape.area|shapes.cs': [areaMethod] }); + const result = resolveSameClassThisFallback( + { name: 'area', receiver: 'this' }, + null, + 'shapes.cs', + lookup, + ); + expect(result).toEqual([]); + }); +}); + +describe('resolveSameClassBareCallFallback (#1765)', () => { + const isValidEmailMethod = { id: 2, file: 'Validators.cs', kind: 'method' }; + + it('resolves bare IsValidEmail() inside Validators.ValidateUser to Validators.IsValidEmail', () => { + const lookup = makeScopedLookup({ + 'Validators.IsValidEmail|Validators.cs': [isValidEmailMethod], + }); + const result = resolveSameClassBareCallFallback( + { name: 'IsValidEmail', receiver: null }, + 'Validators.ValidateUser', + 'Validators.cs', + lookup, + ); + expect(result).toEqual([isValidEmailMethod]); + }); + + it('does not resolve when a receiver is present (not a bare call)', () => { + const lookup = makeScopedLookup({ + 'Validators.IsValidEmail|Validators.cs': [isValidEmailMethod], + }); + const result = resolveSameClassBareCallFallback( + { name: 'IsValidEmail', receiver: 'Validators' }, + 'Validators.ValidateUser', + 'Validators.cs', + lookup, + ); + expect(result).toEqual([]); + }); + + it('does not resolve bare calls in a .ts file (module-scoped language)', () => { + const lookup = makeScopedLookup({ + 'Validators.isValidEmail|validators.ts': [{ id: 3, file: 'validators.ts', kind: 'method' }], + }); + const result = resolveSameClassBareCallFallback( + { name: 'isValidEmail', receiver: null }, + 'Validators.validateUser', + 'validators.ts', + lookup, + ); + expect(result).toEqual([]); + }); + + it('does not resolve when callerName is null', () => { + const lookup = makeScopedLookup({ + 'Validators.IsValidEmail|Validators.cs': [isValidEmailMethod], + }); + const result = resolveSameClassBareCallFallback( + { name: 'IsValidEmail', receiver: null }, + null, + 'Validators.cs', + lookup, + ); + expect(result).toEqual([]); + }); +}); + // ── resolveReceiverEdge ────────────────────────────────────────────────────── /**