diff --git a/src/domain/graph/builder/incremental.ts b/src/domain/graph/builder/incremental.ts index c1f44379..809fa7b8 100644 --- a/src/domain/graph/builder/incremental.ts +++ b/src/domain/graph/builder/incremental.ts @@ -24,6 +24,7 @@ import { computeConfidence, resolveImportPath } from '../resolve.js'; import { type CallNodeLookup, findCaller, + isModuleScopedLanguage, resolveCallTargets, resolveReceiverEdge, resolveSameClassQualifiedMethod, @@ -643,10 +644,23 @@ function resolveDefinePropertyTarget( } /** - * Apply `this`-receiver fallback resolution strategies for a single call site - * when the primary resolveCallTargets pass returned no targets. + * Apply fallback resolution strategies for a single call site when the + * primary resolveCallTargets pass returned no targets. + * + * Runs in order: + * 1. Same-class `this.method()` fallback. + * 2. Same-class bare-call fallback for non-JS/TS class-scoped languages + * (e.g. C# static sibling calls: `IsValidEmail()` inside + * `Validators.ValidateUser` resolves to `Validators.IsValidEmail`). + * 3. Object.defineProperty accessor fallback (this-calls inside getter/setter). + * + * Mirrors the same-class fallback strategies in `resolveFallbackTargets` + * (stages/build-edges.ts, full-build path). The Kotlin-reflection + * pre-qualify and reflection-keyExpr fallbacks are intentionally not + * mirrored here — that broader points-to/CHA/dynamic-sink gap between the + * two paths is tracked separately (#1815/#1852), not by this function. */ -function applyThisReceiverFallbacks( +function applyCallFallbacks( call: { name: string; receiver?: string | null }, callerName: string | null, relPath: string, @@ -663,7 +677,15 @@ function applyThisReceiverFallbacks( if (s1.length > 0) return s1; } - // Strategy 2: Object.defineProperty accessor fallback. + // Strategy 2: same-class bare-call fallback. Skipped for JS/TS, where a + // bare call is module-scoped, not class-scoped (mirrors + // resolveSameClassBareCallFallback in stages/build-edges.ts). + if (!call.receiver && callerName != null && !isModuleScopedLanguage(relPath)) { + const s2 = resolveSameClassQualifiedMethod(call.name, callerName, relPath, lookup); + if (s2.length > 0) return s2; + } + + // Strategy 3: Object.defineProperty accessor fallback. if (call.receiver === 'this' && callerName != null && definePropertyReceivers) { return resolveDefinePropertyTarget( call.name, @@ -760,7 +782,7 @@ function buildCallEdges( importedOriginalNames, ); - const targets = applyThisReceiverFallbacks( + const targets = applyCallFallbacks( call, caller.callerName, relPath, @@ -801,7 +823,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 `applyCallFallbacks`) 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/tests/integration/issue-1765-incremental-same-class-barecall.test.ts b/tests/integration/issue-1765-incremental-same-class-barecall.test.ts new file mode 100644 index 00000000..9cd7dec6 --- /dev/null +++ b/tests/integration/issue-1765-incremental-same-class-barecall.test.ts @@ -0,0 +1,180 @@ +/** + * Regression test for #1765: incremental single-file rebuild (`codegraph + * watch` -> `rebuildFile` in `src/domain/graph/builder/incremental.ts`) was + * missing the same-class bare-call fallback that the full-build path has + * (`resolveSameClassBareCallFallback` in `stages/build-edges.ts`). + * + * For class-scoped languages (non-JS/TS), a bare call with no receiver that + * fails resolution retries qualified as `.` — this is + * how e.g. C# static sibling calls (`IsValidEmail()` inside + * `Validators.ValidateUser` -> `Validators.IsValidEmail`) get resolved. + * `incremental.ts`'s `applyThisReceiverFallbacks` only implemented the + * `this.method()` and Object.defineProperty fallbacks, so a bare-call sibling + * edge resolved on a full build could go missing after a watch-mode + * single-file rebuild of the same file. + * + * Mirrors the full-build-vs-incremental-rebuild comparison pattern from + * `issue-1744-incremental-edge-technique.test.ts`. + */ +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 { getNodeId as getNodeIdQuery, initSchema, openDb } from '../../src/db/index.js'; +import { rebuildFile } from '../../src/domain/graph/builder/incremental.js'; +import { buildGraph } from '../../src/domain/graph/builder.js'; +import { isNativeAvailable } from '../../src/infrastructure/native.js'; +import type { EngineMode } from '../../src/types.js'; + +function writeFixture(dir: string, marker: string): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'Validators.cs'), + `namespace Demo; +public static class Validators { + // ${marker} + public static bool IsValidEmail(string email) { + return email.Contains("@"); + } + public static bool IsValidName(string name) { + return name.Length >= 2; + } + public static bool ValidateUser(string email, string name) { + return IsValidEmail(email) && IsValidName(name); + } +} +`, + ); +} + +interface CallEdgeRow { + src: string; + tgt: string; + kind: string; +} + +function readCallEdges(dbPath: string): CallEdgeRow[] { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare( + `SELECT n1.name AS src, n2.name AS tgt, e.kind + 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(); + } +} + +function makeStmts(db: ReturnType) { + return { + insertNode: db.prepare( + 'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)', + ), + getNodeId: { + get: (name: string, kind: string, file: string, line: number) => { + const id = getNodeIdQuery(db, name, kind, file, line); + return id != null ? { id } : undefined; + }, + }, + insertEdge: db.prepare( + 'INSERT INTO edges (source_id, target_id, kind, confidence, dynamic) VALUES (?, ?, ?, ?, ?)', + ), + countNodes: db.prepare('SELECT COUNT(*) as c FROM nodes WHERE file = ?'), + countEdges: db.prepare( + 'SELECT COUNT(*) as c FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file = ?)', + ), + findNodeInFile: db.prepare( + "SELECT id, kind, file FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant') AND file = ?", + ), + findNodeByName: db.prepare( + "SELECT id, file, kind FROM nodes WHERE name = ? AND kind IN ('function', 'method', 'class', 'interface', 'type', 'struct', 'enum', 'trait', 'record', 'module', 'constant')", + ), + listSymbols: db.prepare("SELECT name, kind, line FROM nodes WHERE file = ? AND kind != 'file'"), + upsertFileHash: db.prepare( + 'INSERT OR REPLACE INTO file_hashes (file, hash, mtime, size) VALUES (?, ?, ?, ?)', + ), + deleteFileHash: db.prepare('DELETE FROM file_hashes WHERE file = ?'), + }; +} + +function runWatchScenario(engine: EngineMode): void { + describe(`codegraph watch (rebuildFile): same-class bare-call fallback matches full rebuild (#1765) — ${engine}`, () => { + let projDir: string; + let refDir: string; + + beforeAll(async () => { + projDir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-1765-watch-${engine}-`)); + writeFixture(projDir, 'v1'); + await buildGraph(projDir, { engine, incremental: false, skipRegistry: true }); + + // Edit Validators.cs, then rebuild ONLY it via the watcher's + // single-file incremental path (the exact code path under test). + writeFixture(projDir, 'v2 edited'); + const dbPath = path.join(projDir, '.codegraph', 'graph.db'); + const db = openDb(dbPath); + try { + initSchema(db); + const stmts = makeStmts(db); + await rebuildFile( + db, + projDir, + path.join(projDir, 'Validators.cs'), + stmts, + { engine }, + null, + ); + } finally { + db.close(); + } + + refDir = fs.mkdtempSync(path.join(os.tmpdir(), `cg-1765-watch-ref-${engine}-`)); + writeFixture(refDir, 'v2 edited'); + await buildGraph(refDir, { engine, incremental: false, skipRegistry: true }); + }, 60_000); + + afterAll(() => { + fs.rmSync(projDir, { recursive: true, force: true }); + fs.rmSync(refDir, { recursive: true, force: true }); + }); + + it('resolves the same-class bare-call sibling edges after rebuildFile, matching a full rebuild', () => { + const incremental = readCallEdges(path.join(projDir, '.codegraph', 'graph.db')); + const reference = readCallEdges(path.join(refDir, '.codegraph', 'graph.db')); + + expect(reference).toContainEqual({ + src: 'Validators.ValidateUser', + tgt: 'Validators.IsValidEmail', + kind: 'calls', + }); + expect(reference).toContainEqual({ + src: 'Validators.ValidateUser', + tgt: 'Validators.IsValidName', + kind: 'calls', + }); + + expect(incremental).toEqual(reference); + expect(incremental).toContainEqual({ + src: 'Validators.ValidateUser', + tgt: 'Validators.IsValidEmail', + kind: 'calls', + }); + expect(incremental).toContainEqual({ + src: 'Validators.ValidateUser', + tgt: 'Validators.IsValidName', + kind: 'calls', + }); + }); + }); +} + +runWatchScenario('wasm'); +describe.skipIf(!isNativeAvailable())('native engine coverage', () => { + runWatchScenario('native'); +});