Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions src/domain/graph/builder/incremental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { computeConfidence, resolveImportPath } from '../resolve.js';
import {
type CallNodeLookup,
findCaller,
isModuleScopedLanguage,
resolveCallTargets,
resolveReceiverEdge,
resolveSameClassQualifiedMethod,
Expand Down Expand Up @@ -652,10 +653,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,
Expand All @@ -672,7 +686,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,
Expand Down Expand Up @@ -769,7 +791,7 @@ function buildCallEdges(
importedOriginalNames,
);

const targets = applyThisReceiverFallbacks(
const targets = applyCallFallbacks(
call,
caller.callerName,
relPath,
Expand Down Expand Up @@ -810,7 +832,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.
Expand Down
180 changes: 180 additions & 0 deletions tests/integration/issue-1765-incremental-same-class-barecall.test.ts
Original file line number Diff line number Diff line change
@@ -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 `<CallerClass>.<callName>` — 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<typeof openDb>) {
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');
});
Loading