fix(impact): resolve repo-relative file paths via filePath (fixes #3074) (#3084)

* fix(impact): resolve repo-relative file paths via filePath (fixes #3074)

- resolve repo-relative paths like supabase/functions/_shared/crypto.ts
  via n.filePath exact + anchored ENDS WITH suffix, not just n.id/n.name
- return impactedCount:null on not_found so miss cannot be read as 0/UNKNOWN safe
- relax parenthesised OR-clause test to allow extra filePath terms

* fix(impact): scope filePath match to File nodes (review #3084 P1)

* fix(impact): make file path resolution parseable and safe

* docs(pdg): align result contract fixtures with v3

* test(impact): add exact path precedence and not_found contract assertions
This commit is contained in:
Chareonwit Kunna 2026-08-29 16:57:59 +07:00 • committed by GitHub
parent 38a0837e4b
commit 7c723ce794
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 113 additions and 19 deletions

View file

@ -3818,7 +3818,22 @@ export class LocalBackend {
} else if (isQualified) {
// Parenthesised because the kind filter below is appended with AND, which
// binds tighter than OR.
whereClause = `WHERE (n.id = $symName OR n.name = $symName)`;
// #3074: a repo-relative file path (e.g. "supabase/functions/_shared/crypto.ts")
// is the most natural way to name a File and is exactly what `target.filePath`
// reports, but the old clause only matched `n.id` (= "File:<path>") or basename
// `n.name`, so the same path the graph stores never resolved. Also match the
// repo-relative `n.filePath` exactly and via an anchored suffix (segment-boundary
// "ENDS WITH $suffix" where suffix is "/"+path) so "a.ts" does not spuriously
// match "mylib/a.ts" — same anchoring used in detect_changes (#2915).
const suffix = pathSuffixOf(name);
// File-path terms must be scoped to File nodes — n.filePath is shared by
// every symbol in the file, so an unlabeled predicate would turn
// "src/actions.ts" into every symbol in that file (bot review #3084 P1).
// LadybugDB does not allow label tests in WHERE (n:File), so scope via
// id prefix — File nodes are `File:<path>`.
whereClause = `WHERE (n.id = $symName OR n.name = $symName OR (n.id STARTS WITH $filePrefix AND (n.filePath = $symName OR n.filePath ENDS WITH $suffix)))`;
queryParams.suffix = suffix;
queryParams.filePrefix = 'File:';
} else {
whereClause = `WHERE n.name = $symName`;
}
@ -3909,7 +3924,7 @@ export class LocalBackend {
if (rows.length === 0) return { kind: 'not_found' };
// Normalise row shape across object / tuple returns from LadybugDB.
const normalized = rows.map((r: any) => ({
let normalized = rows.map((r: any) => ({
id: (r.id ?? r[0]) as string,
name: (r.name ?? r[1]) as string,
type: (r.type ?? r[2] ?? '') as string,
@ -3919,6 +3934,16 @@ export class LocalBackend {
...(include_content ? { content: (r.content ?? r[6]) as string | undefined } : {}),
}));
// An exact File path wins over anchored suffix candidates. Without this,
// `lib/a.ts` and `src/lib/a.ts` both score as File candidates and turn an
// otherwise unambiguous exact target into `ambiguous` (#3084 review P2).
if (isQualified) {
const exactFiles = normalized.filter(
(candidate) => candidate.id.startsWith('File:') && candidate.filePath === name,
);
if (exactFiles.length > 0) normalized = exactFiles;
}
// The COUNT can never legitimately be below the page it accompanies, so a
// value under `normalized.length` means the count leg failed or returned an
// unreadable shape. Keep the window size as the floor — reporting zero would
@ -6127,6 +6152,7 @@ export class LocalBackend {
direction: params.direction,
suggestion,
recoverySuggestion,
undetermined: true,
});
return pdgErr;
}
@ -6134,7 +6160,7 @@ export class LocalBackend {
error: message,
target: { name: params.target },
direction: params.direction,
impactedCount: 0,
impactedCount: null,
risk: 'UNKNOWN',
suggestion,
...(recoverySuggestion ? { recoverySuggestion } : {}),
@ -6229,6 +6255,7 @@ export class LocalBackend {
`(single-repo PDG impact). Remove them or use mode:'callgraph' for cross-repo fan-out.`,
target: crossDepthTarget,
direction,
undetermined: true,
});
return pdgErr;
}
@ -6295,12 +6322,17 @@ export class LocalBackend {
error: `Target '${missing}' not found`,
target: notFoundTarget,
direction,
undetermined: true,
})
: {
error: `Target '${missing}' not found`,
target: { name: target },
direction,
impactedCount: 0,
// #3074 follow-up: do not ship a normal-shaped 0/UNKNOWN blast radius
// alongside the error — it reads as a real "nothing depends on this"
// answer. Null marks UNDETERMINED (same as the ambiguous path) so a
// consumer testing `impactedCount === 0` cannot misread a miss as safe.
impactedCount: null,
risk: 'UNKNOWN',
};
}

View file

@ -118,8 +118,10 @@ export function splitCalleeIds(raw: unknown): string[] {
* Bump on any breaking change to the PDG result fields.
* v2: `startLine` in the result is now 1-based display (#2380), matching the
* context/query/impact tools (was 0-based).
* v3: error envelopes use `impactedCount: null` when a target cannot be resolved
* (#3074), so consumers cannot read a miss as a measured zero.
*/
export const PDG_RESULT_VERSION = 2 as const;
export const PDG_RESULT_VERSION = 3 as const;
/** A reachable dependence block resolved to its source statement. */
export interface PdgStatement {
@ -742,7 +744,7 @@ export interface PdgInterproceduralImpact {
export interface PdgImpactBaseResult extends PdgImpactParityFields {
mode: 'pdg';
/** Contract version of the mode:'pdg' impact result shape; bump on any breaking change to the PDG result fields. */
pdgResultVersion: 2;
pdgResultVersion: 3;
target: PdgImpactTarget;
direction: 'upstream' | 'downstream';
impactedCount: number;
@ -815,11 +817,11 @@ export interface PdgImpactDegradedResult extends PdgImpactBaseResult {
export interface PdgImpactErrorResult {
mode?: 'pdg';
/** Contract version of the mode:'pdg' impact result shape; bump on any breaking change to the PDG result fields. */
pdgResultVersion: 2;
pdgResultVersion: 3;
error: string;
target: PdgImpactTarget;
direction: 'upstream' | 'downstream';
impactedCount: 0;
impactedCount: number | null;
risk: 'UNKNOWN';
suggestion?: string;
recoverySuggestion?: string;
@ -838,6 +840,8 @@ export function makePdgImpactErrorResult(input: {
mode?: 'pdg';
suggestion?: string;
recoverySuggestion?: string;
/** True when analysis did not obtain a measured impact count. */
undetermined?: boolean;
}): PdgImpactErrorResult {
return {
...(input.mode ? { mode: input.mode } : {}),
@ -845,7 +849,10 @@ export function makePdgImpactErrorResult(input: {
error: input.error,
target: input.target,
direction: input.direction,
impactedCount: 0,
// #3074 follow-up + P2 review: an unmeasured PDG result must not ship a
// confident-looking 0 blast radius. null marks UNDETERMINED, so a consumer
// testing === 0 cannot misread a miss or failed query as safe.
impactedCount: input.undetermined ? null : 0,
risk: 'UNKNOWN',
...(input.suggestion ? { suggestion: input.suggestion } : {}),
...(input.recoverySuggestion ? { recoverySuggestion: input.recoverySuggestion } : {}),

View file

@ -469,7 +469,7 @@ MODE (opt-in): "callgraph" (default) walks symbol→symbol edges (CALLS/IMPORTS/
STATEMENT-ANCHORED PDG SLICE: with mode:'pdg', pass "line" (1-based source line within the target symbol) to seed the dependence slice on the statement at that line and return what depends on it in affectedStatements (line + text). Inter-procedural symbols are still reported through interproceduralByDepth/pdgInterprocedural and the compatibility byDepth bucket. Without "line", pdg returns whole-symbol inter-procedural reach plus local whole-symbol PDG diagnostics.
PDG OUTPUT CONTRACT: every mode:'pdg' result (success, empty, degraded, or error) carries pdgResultVersion:2 — a stable discriminator for external consumers that bumps on any breaking change to the PDG result shape (distinct from the DB schema version). Successful PDG results include mode:'pdg', a full target envelope (id/name/type/filePath), affectedStatements, affectedStatementCount, interproceduralByDepth/pdgInterprocedural for cross-function reach, compatibility byDepth/byDepthCounts, risk:'UNKNOWN', and a note describing the unified contract. Degraded PDG results (no-layer, sub-layer-missing, unknown) keep mode:'pdg', pdgResultVersion:2, target metadata when the target resolves, risk:'UNKNOWN', note/remediation, and empty byDepth parity fields — never a false-safe zero. If depth and limit both bound the slice, truncatedByReasons reports both causes while truncatedBy remains scalar. Return-value-ascent coverage is published structurally at pdgEvidence.ascent — present iff the inter-procedural descent ran, including on an empty slice — with referencesScanned (DISTINCT callees scanned for a CALL_SUMMARY: a distinct-id tally, not a call-site count — two call sites to the same callee count once), returnFlowFound (whether the ascent fired anywhere in the slice), undecodableSummaryCount, examinedComplete (whether that scan covered every callee the index recorded a resolved id for on the visited blocks), incompleteReasons ('traversal-truncated' | 'callee-list-capped' | 'callee-ids-unrecorded'), and callSummaryLayerPresent. Read callSummaryLayerPresent FIRST: false ⇒ a pre-CALL_SUMMARY index, so {referencesScanned:N>0, returnFlowFound:false} is self-consistent and says nothing about the callees — the scan ran, but no layer existed in which a return-flow could be recorded (remedy: re-run gitnexus analyze --pdg). Branch on those fields; the note narrates the same facts in prose for humans and is not a stable contract.
PDG OUTPUT CONTRACT: every mode:'pdg' result (success, empty, degraded, or error) carries pdgResultVersion:3 — a stable discriminator for external consumers that bumps on any breaking change to the PDG result shape (distinct from the DB schema version). Successful PDG results include mode:'pdg', a full target envelope (id/name/type/filePath), affectedStatements, affectedStatementCount, interproceduralByDepth/pdgInterprocedural for cross-function reach, compatibility byDepth/byDepthCounts, risk:'UNKNOWN', and a note describing the unified contract. Degraded PDG results (no-layer, sub-layer-missing, unknown) keep mode:'pdg', pdgResultVersion:3, target metadata when the target resolves, risk:'UNKNOWN', note/remediation, and empty byDepth parity fields — never a false-safe zero. If depth and limit both bound the slice, truncatedByReasons reports both causes while truncatedBy remains scalar. Return-value-ascent coverage is published structurally at pdgEvidence.ascent — present iff the inter-procedural descent ran, including on an empty slice — with referencesScanned (DISTINCT callees scanned for a CALL_SUMMARY: a distinct-id tally, not a call-site count — two call sites to the same callee count once), returnFlowFound (whether the ascent fired anywhere in the slice), undecodableSummaryCount, examinedComplete (whether that scan covered every callee the index recorded a resolved id for on the visited blocks), incompleteReasons ('traversal-truncated' | 'callee-list-capped' | 'callee-ids-unrecorded'), and callSummaryLayerPresent. Read callSummaryLayerPresent FIRST: false ⇒ a pre-CALL_SUMMARY index, so {referencesScanned:N>0, returnFlowFound:false} is self-consistent and says nothing about the callees — the scan ran, but no layer existed in which a return-flow could be recorded (remedy: re-run gitnexus analyze --pdg). Branch on those fields; the note narrates the same facts in prose for humans and is not a stable contract.
WHEN TO USE: Before making code changes — especially refactoring, renaming, or modifying shared code. Shows what would break.
AFTER THIS: Review d=1 items (WILL BREAK). Use context() on high-risk symbols.

View file

@ -17,7 +17,7 @@
* "complete" result.
*
* This golden asserts the EXACT degraded envelope (not just non-crash):
* - the result is still mode:'pdg' with pdgResultVersion:2 (the contract
* - the result is still mode:'pdg' with pdgResultVersion:3 (the contract
* discriminator);
* - the intra slice is PRESENT (CALL_SUMMARY is NOT a required sub-layer — the
* index is `ready`, pdgLayer is undefined, risk is UNKNOWN, epistemic is the
@ -75,14 +75,14 @@ withTestLbugDB(
});
describe('CALL_SUMMARY-absent (v3 / pre-FU-C index): the ascent is silent but the user is TOLD', () => {
it('returns the EXACT degraded envelope — mode:pdg, pdgResultVersion:2, intra slice present, risk UNKNOWN', async () => {
it('returns the EXACT degraded envelope — mode:pdg, pdgResultVersion:3, intra slice present, risk UNKNOWN', async () => {
const result = await slice();
// Golden envelope: the index is `ready` (CALL_SUMMARY is NOT a required
// sub-layer), so this is a real traversal result — NOT a pdgLayer
// degradation early-return. The intra slice ran and risk stays UNKNOWN.
expect(result).toMatchObject({
mode: 'pdg',
pdgResultVersion: 2,
pdgResultVersion: 3,
risk: 'UNKNOWN',
epistemic: 'pdg-intra-procedural',
target: { id: 'func:fnA', name: 'fnA' },

View file

@ -1344,12 +1344,67 @@ describe('LocalBackend.callTool', () => {
await backend.callTool('context', { name: 'src/a.ts:collide', kind: 'Function' });
const parenthesised =
/WHERE \(n\.id = \$symName OR n\.name = \$symName\) AND n\.id STARTS WITH \$kindPrefix/;
/WHERE \(n\.id = \$symName OR n\.name = \$symName OR \(n\.id STARTS WITH \$filePrefix AND \(n\.filePath = \$symName OR n\.filePath ENDS WITH \$suffix\)\)\) AND n\.id STARTS WITH \$kindPrefix/;
const calls = resolverCalls();
expect(calls).toHaveLength(2);
expect(calls.filter((c) => parenthesised.test(c.query))).toHaveLength(2);
});
it('exact File path wins over suffixed matches during qualified resolution (#3084 review P2)', async () => {
(executeParameterized as any).mockImplementation(async (_repo: string, query: string) => {
if (query.startsWith('MATCH (n)')) {
return [
{
id: 'File:src/lib/a.ts',
name: 'a.ts',
filePath: 'src/lib/a.ts',
kind: 'File',
total_hits: 1,
},
{
id: 'File:lib/a.ts',
name: 'a.ts',
filePath: 'lib/a.ts',
kind: 'File',
total_hits: 1,
},
];
}
return [{ total: 2 }];
});
const result = await backend.callTool('context', { name: 'lib/a.ts' });
expect(result).toMatchObject({
status: 'found',
symbol: {
filePath: 'lib/a.ts',
uid: 'File:lib/a.ts',
},
});
});
it('not_found impact queries return impactedCount null and risk UNKNOWN across modes (#3074 / #3084 review)', async () => {
(executeParameterized as any).mockImplementation(async () => []);
const cgResult = await backend.callTool('impact', { target: 'nonexistent_target_xyz' });
expect(cgResult).toMatchObject({
error: "Target 'nonexistent_target_xyz' not found",
impactedCount: null,
risk: 'UNKNOWN',
});
const pdgResult = await backend.callTool('impact', {
target: 'nonexistent_target_xyz',
mode: 'pdg',
});
expect(pdgResult).toMatchObject({
error: "Target 'nonexistent_target_xyz' not found",
impactedCount: null,
risk: 'UNKNOWN',
pdgResultVersion: 3,
});
});
it('retries UNFILTERED when the kind hint matches no label prefix (#2787 review F5)', async () => {
// `kind` is a free-form string on the tool schema. A miscased or
// repo-absent kind must not turn a real name into `not_found` — the
@ -3241,7 +3296,7 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
expect(result.mode).toBe('pdg');
expect(result.target).toEqual({ name: 'missingSymbol' });
expect(result.direction).toBe('upstream');
expect(result.impactedCount).toBe(0);
expect(result.impactedCount).toBeNull();
expect(result.risk).toBe('UNKNOWN');
});
@ -3257,7 +3312,7 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
expect(result.mode).toBe('pdg');
expect(result.target).toEqual({ name: 'main' });
expect(result.direction).toBe('downstream');
expect(result.impactedCount).toBe(0);
expect(result.impactedCount).toBeNull();
expect(result.risk).toBe('UNKNOWN');
expect(result.suggestion).toMatch(/context/);
implSpy.mockRestore();

View file

@ -38,7 +38,7 @@ function pdgFindings(overrides: Record<string, unknown> = {}): Record<string, un
];
return {
mode: 'pdg',
pdgResultVersion: 2,
pdgResultVersion: 3,
target: {
id: 'Function:src/svc.ts:computeTotal',
name: 'computeTotal',
@ -136,7 +136,7 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => {
// The PDG result family advertises a contract version (FIX #2) so external
// MCP/agent consumers can version against future shape evolution. It is a
// mode:'pdg'-only field — never on the default callgraph result.
expect(pdgFindings()).toMatchObject({ mode: 'pdg', pdgResultVersion: 2 });
expect(pdgFindings()).toMatchObject({ mode: 'pdg', pdgResultVersion: 3 });
});
it('surfaces ambiguous-projection and unresolved block counts honestly', () => {

View file

@ -36,7 +36,7 @@ const local = (
impactedCount: number,
): PdgImpactSuccessResult => ({
mode: 'pdg',
pdgResultVersion: 2,
pdgResultVersion: 3,
target: { id: 'T', name: 'criterion', type: 'Function', filePath: 'src/a.ts' },
direction: 'downstream',
risk: 'UNKNOWN',