Skip to content
Closed
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
46 changes: 46 additions & 0 deletions src/domain/graph/builder/call-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────

/**
Expand Down
33 changes: 21 additions & 12 deletions src/domain/graph/builder/incremental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -607,7 +608,7 @@ function buildIncrementalTypeMap(symbols: ExtractorOutput): Map<string, unknown>
}

/**
* 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
Expand Down Expand Up @@ -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).
Comment on lines +650 to +654

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The JSDoc says this function "mirrors resolveFallbackTargets" but it still omits two fallbacks that are present in the full-build pipeline: the Kotlin reflection pre-qualified pass (run before primary resolution) and resolveReflectionKeyExprFallback (run between the bare-call and defineProperty strategies). The PR description documents these gaps and files them as #1946, but a reader of only this function would incorrectly assume full parity. A short note in the comment would prevent future confusion.

Suggested change
* 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).
* Runs in order (partially mirrors `resolveFallbackTargets` in the full-build
* `stages/build-edges.ts` pipeline see #1765 for this fix, #1946 for the
* remaining divergences: Kotlin reflection pre-qualified pass and
* resolveReflectionKeyExprFallback are not yet wired into the incremental path):
* 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).

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

*/
function applyThisReceiverFallbacks(
function applyCallResolutionFallbacks(
call: { name: string; receiver?: string | null },
callerName: string | null,
relPath: string,
Expand All @@ -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,
Expand Down Expand Up @@ -760,7 +769,7 @@ function buildCallEdges(
importedOriginalNames,
);

const targets = applyThisReceiverFallbacks(
const targets = applyCallResolutionFallbacks(
call,
caller.callerName,
relPath,
Expand Down Expand Up @@ -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.
Expand Down
40 changes: 2 additions & 38 deletions src/domain/graph/builder/stages/build-edges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down
29 changes: 29 additions & 0 deletions tests/fixtures/bare-call-scope/Validators.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
115 changes: 115 additions & 0 deletions tests/integration/issue-1765-incremental-bare-call-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -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 `<CallerClass>.<callName>`
* — 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([]);
});
});
Loading
Loading