feat(impact): label pdg bridge evidence

This commit is contained in:
Gergo Magyar 2026-06-18 10:14:04 +00:00
parent a9fd55a91b
commit d9d702dc4d
5 changed files with 313 additions and 7 deletions

View file

@ -75,6 +75,7 @@ import {
runImpactPDG,
validateImpactMode,
type ImpactMode,
type PdgImpactEvidence,
type PdgImpactResult,
} from './pdg-impact.js';
@ -250,6 +251,141 @@ export const IMPACT_RELATION_CONFIDENCE: Readonly<Record<string, number>> = {
const confidenceForRelType = (relType: string | undefined): number =>
IMPACT_RELATION_CONFIDENCE[relType ?? ''] ?? 0.5;
type PdgBridgeEvidence = Extract<PdgImpactEvidence, 'callgraph-bridge' | 'unproven-bridge'>;
interface PdgBridgeEvidenceInfo {
evidence: PdgBridgeEvidence;
basis: string;
site?: { filePath: string; line: number };
}
interface PdgBridgeOptions {
/** Line keys (`<filePath>:<line>`) proven reachable by the local PDG slice. */
firstHopLineKeys?: ReadonlySet<string>;
/** File containing the first-hop call sites for a line-seeded downstream slice. */
targetFilePath?: string;
}
function parseRelationSiteLine(relationId: unknown): number | undefined {
const id = String(relationId ?? '');
if (!id) return undefined;
const parts = id.split(':');
const maybeLine = Number(parts[parts.length - 2]);
const maybeCol = Number(parts[parts.length - 1]);
if (Number.isInteger(maybeLine) && maybeLine >= 1 && Number.isInteger(maybeCol)) {
return maybeLine;
}
const cobolLine = id.match(/:L(\d+)(?::|$)/i);
if (cobolLine) {
const line = Number(cobolLine[1]);
if (Number.isInteger(line) && line >= 1) return line;
}
return undefined;
}
function lineKey(filePath: string | undefined, line: number | undefined): string | undefined {
if (!filePath || !Number.isInteger(line) || (line as number) < 1) return undefined;
return `${filePath}:${line}`;
}
function pdgBridgeEvidenceForImpact(input: {
bridge: PdgBridgeOptions;
depth: number;
relationId: unknown;
inherited?: PdgBridgeEvidenceInfo;
}): PdgBridgeEvidenceInfo {
const { bridge, depth, relationId, inherited } = input;
if (depth > 1) {
return (
inherited ?? {
evidence: 'unproven-bridge',
basis: 'first-hop evidence unavailable for inherited symbol-graph reach',
}
);
}
const firstHopLineKeys = bridge.firstHopLineKeys;
if (!firstHopLineKeys || firstHopLineKeys.size === 0) {
return {
evidence: 'callgraph-bridge',
basis: 'whole-symbol PDG result uses symbol graph as compatibility bridge',
};
}
const siteLine = parseRelationSiteLine(relationId);
const siteKey = lineKey(bridge.targetFilePath, siteLine);
if (siteKey && firstHopLineKeys.has(siteKey)) {
return {
evidence: 'callgraph-bridge',
basis: 'first-hop call site is in the local PDG statement slice',
site: { filePath: bridge.targetFilePath!, line: siteLine! },
};
}
return {
evidence: 'unproven-bridge',
basis: siteLine
? 'first-hop call site is not in the local PDG statement slice'
: 'first-hop call-site line is unavailable on the existing graph edge',
...(siteLine && bridge.targetFilePath
? { site: { filePath: bridge.targetFilePath, line: siteLine } }
: {}),
};
}
function normalizePdgBridgeByDepth(byDepth: Record<number, unknown[]>): Record<number, unknown[]> {
const normalized: Record<number, unknown[]> = {};
for (const [depthKey, items] of Object.entries(byDepth ?? {})) {
const depth = Number(depthKey);
if (!Number.isFinite(depth) || !Array.isArray(items)) continue;
normalized[depth] = items.map((item) => {
if (!item || typeof item !== 'object') return item;
const record = item as Record<string, unknown>;
const evidence =
typeof record.pdgEvidence === 'string'
? (record.pdgEvidence as PdgImpactEvidence)
: 'callgraph-bridge';
return {
...record,
pdgEvidence: evidence,
...(record.pdgEvidenceReason
? {}
: {
pdgEvidenceReason:
evidence === 'unproven-bridge'
? 'symbol reached through the resolved symbol graph, but the existing graph did not prove the first-hop call site is in the local PDG slice'
: 'symbol reached through the resolved symbol graph compatibility bridge',
}),
};
});
}
return normalized;
}
function countPdgEvidence(
byDepth: Record<number, unknown[]>,
): Partial<Record<PdgImpactEvidence, number>> {
const counts: Partial<Record<PdgImpactEvidence, number>> = {};
for (const items of Object.values(byDepth ?? {})) {
if (!Array.isArray(items)) continue;
for (const item of items) {
if (!item || typeof item !== 'object') continue;
const evidence = (item as { pdgEvidence?: unknown }).pdgEvidence;
if (typeof evidence !== 'string') continue;
counts[evidence as PdgImpactEvidence] = (counts[evidence as PdgImpactEvidence] ?? 0) + 1;
}
}
return counts;
}
function dominantInterproceduralEvidence(
counts: Partial<Record<PdgImpactEvidence, number>>,
): PdgBridgeEvidence | undefined {
if ((counts['unproven-bridge'] ?? 0) > 0) return 'unproven-bridge';
if ((counts['callgraph-bridge'] ?? 0) > 0) return 'callgraph-bridge';
return undefined;
}
/** Structured error logging for query failures — replaces empty catch blocks */
function logQueryError(context: string, err: unknown): void {
const msg = err instanceof Error ? err.message : String(err);
@ -4628,6 +4764,22 @@ export class LocalBackend {
executeParameterized,
});
const firstHopLineKeys = new Set<string>();
if (typeof (pdgResult as any).criterionLine === 'number') {
firstHopLineKeys.add(`${sym.filePath}:${(pdgResult as any).criterionLine}`);
}
for (const stmt of ((pdgResult as any).affectedStatements ?? []) as Array<{
filePath?: string;
line?: number;
}>) {
const key = lineKey(stmt.filePath, stmt.line);
if (key) firstHopLineKeys.add(key);
}
const pdgBridge =
direction === 'downstream' && firstHopLineKeys.size > 0
? { firstHopLineKeys, targetFilePath: sym.filePath }
: undefined;
try {
const interproceduralResult = await this._runImpactBFS(repo, sym, symType, direction, {
maxDepth,
@ -4636,6 +4788,7 @@ export class LocalBackend {
minConfidence,
limit: Number.isFinite(params.limit) ? params.limit : 100,
offset: Number.isFinite(params.offset) ? params.offset : 0,
pdgBridge,
});
return this.composeUnifiedPdgImpactResult(pdgResult, interproceduralResult);
} catch (e) {
@ -4698,8 +4851,10 @@ export class LocalBackend {
const localByDepth = pdgResult.byDepth ?? {};
const localByDepthCounts = pdgResult.byDepthCounts ?? {};
const interproceduralByDepth = interproceduralResult?.byDepth ?? {};
const interproceduralByDepth = normalizePdgBridgeByDepth(interproceduralResult?.byDepth ?? {});
const interproceduralByDepthCounts = interproceduralResult?.byDepthCounts ?? {};
const interproceduralEvidenceCounts = countPdgEvidence(interproceduralByDepth);
const interproceduralEvidence = dominantInterproceduralEvidence(interproceduralEvidenceCounts);
const byDepth: Record<number, unknown[]> = {};
const byDepthCounts: Record<number, number> = {};
const depthKeys = Array.from(
@ -4769,7 +4924,8 @@ export class LocalBackend {
const noteParts = [
pdgResult.note,
`Inter-procedural symbol reach is included using the resolved symbol graph; ` +
`statement-level PDG reach remains in affectedStatements.`,
`statement-level PDG reach remains in affectedStatements. The symbol reach is ` +
`labeled as a PDG evidence bridge, not as pure statement-level dependence.`,
];
if (errorMessage) {
noteParts.push(
@ -4780,6 +4936,13 @@ export class LocalBackend {
`The inter-procedural symbol reach is a lower bound because unresolved indirection was detected.`,
);
}
if ((interproceduralEvidenceCounts['unproven-bridge'] ?? 0) > 0) {
noteParts.push(
`${interproceduralEvidenceCounts['unproven-bridge']} inter-procedural ` +
`symbol(s) are labeled unproven-bridge: the resolved symbol graph reaches them, ` +
`but the current graph did not prove their first-hop call site is in the local PDG slice.`,
);
}
return {
...pdgResult,
@ -4800,11 +4963,18 @@ export class LocalBackend {
? { interproceduralBoundaries: interproceduralResult.boundaries }
: {}),
...(errorMessage ? { interproceduralError: errorMessage } : {}),
pdgEvidence: {
...((pdgResult as any).pdgEvidence ?? {}),
...(interproceduralEvidence ? { interprocedural: interproceduralEvidence } : {}),
interproceduralEvidenceCounts,
},
pdgInterprocedural: {
engine: 'symbol-graph',
evidence: interproceduralEvidence ?? 'callgraph-bridge',
impactedCount: interproceduralImpactedCount,
byDepthCounts: interproceduralByDepthCounts,
byDepth: interproceduralByDepth,
evidenceCounts: interproceduralEvidenceCounts,
partial,
},
};
@ -4959,6 +5129,7 @@ export class LocalBackend {
skipPerSymbolEnrichment?: boolean;
skipEpistemic?: boolean;
skipEnrichment?: boolean;
pdgBridge?: PdgBridgeOptions;
},
): Promise<any> {
const { maxDepth, relationTypes, includeTests, minConfidence } = opts;
@ -5001,6 +5172,7 @@ export class LocalBackend {
const impacted: any[] = [];
const visited = new Set<string>([symId]);
const pdgBridgeEvidenceById = new Map<string, PdgBridgeEvidenceInfo>();
let frontier = [symId];
let traversalComplete = true;
@ -5087,8 +5259,8 @@ export class LocalBackend {
// ids/types/confidence are bound parameters (see above) — no interpolation.
const query =
direction === 'upstream'
? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence`
: `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence`;
? `MATCH (caller)-[r:CodeRelation]->(n) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, caller.id AS id, caller.name AS name, labels(caller)[0] AS type, caller.filePath AS filePath, r.type AS relType, r.confidence AS confidence, r.reason AS relationReason`
: `MATCH (n)-[r:CodeRelation]->(callee) WHERE n.id IN $frontierIds AND r.type IN $relTypes${confidenceFilter} RETURN n.id AS sourceId, callee.id AS id, callee.name AS name, labels(callee)[0] AS type, callee.filePath AS filePath, r.type AS relType, r.confidence AS confidence, r.reason AS relationReason`;
try {
const related = await executeParameterized(repo.lbugPath, query, {
@ -5098,6 +5270,7 @@ export class LocalBackend {
});
for (const rel of related) {
const sourceId = String(rel.sourceId ?? rel[0] ?? '');
const relId = rel.id || rel[1];
const filePath = rel.filePath || rel[4] || '';
@ -5108,6 +5281,16 @@ export class LocalBackend {
nextFrontier.push(relId);
const storedConfidence = rel.confidence ?? rel[6];
const relationType = rel.relType || rel[5];
const relationId = rel.relationId ?? rel[7];
const bridgeEvidence = opts.pdgBridge
? pdgBridgeEvidenceForImpact({
bridge: opts.pdgBridge,
depth,
relationId,
inherited: pdgBridgeEvidenceById.get(sourceId),
})
: undefined;
if (bridgeEvidence) pdgBridgeEvidenceById.set(String(relId), bridgeEvidence);
// Prefer the stored confidence from the graph (set at analysis time);
// fall back to the per-type floor for edges without a stored value.
const effectiveConfidence =
@ -5122,6 +5305,13 @@ export class LocalBackend {
filePath,
relationType,
confidence: effectiveConfidence,
...(bridgeEvidence
? {
pdgEvidence: bridgeEvidence.evidence,
pdgBridgeBasis: bridgeEvidence.basis,
...(bridgeEvidence.site ? { pdgBridgeSite: bridgeEvidence.site } : {}),
}
: {}),
});
}
}

View file

@ -298,11 +298,30 @@ export interface PdgImpactParityFields {
affected_modules: unknown[];
}
export type PdgImpactEvidence =
| 'local-dependence'
| 'owner-projection'
| 'callgraph-bridge'
| 'unproven-bridge'
| 'degraded';
export interface PdgImpactEvidenceSummary {
statements?: PdgImpactEvidence;
localSymbols?: PdgImpactEvidence;
interprocedural?: PdgImpactEvidence;
localSymbolCount?: number;
unresolvedBlockCount?: number;
ambiguousProjectionCount?: number;
interproceduralEvidenceCounts?: Partial<Record<PdgImpactEvidence, number>>;
}
export interface PdgInterproceduralImpact {
engine: 'symbol-graph';
evidence: Extract<PdgImpactEvidence, 'callgraph-bridge' | 'unproven-bridge'>;
impactedCount: number;
byDepthCounts: Record<number, number>;
byDepth: Record<number, unknown[]>;
evidenceCounts?: Partial<Record<PdgImpactEvidence, number>>;
partial: boolean;
}
@ -320,6 +339,7 @@ export interface PdgImpactBaseResult extends PdgImpactParityFields {
interproceduralBoundaries?: unknown[];
interproceduralError?: string;
pdgInterprocedural?: PdgInterproceduralImpact;
pdgEvidence?: PdgImpactEvidenceSummary;
}
export interface PdgImpactSuccessResult extends PdgImpactBaseResult {
@ -493,6 +513,11 @@ function assemblePdgImpactResult(input: {
...(s.startLine !== undefined ? { startLine: s.startLine } : {}),
...(s.ambiguous ? { ambiguous: true } : {}),
...(s.id === null ? { unresolved: true } : {}),
pdgEvidence: (s.id === null ? 'degraded' : 'owner-projection') as PdgImpactEvidence,
pdgEvidenceReason:
s.id === null
? 'reachable BasicBlock has no owning Function/Method/Constructor projection'
: 'reachable BasicBlock projected to its owning symbol',
processes: [] as unknown[],
}));
@ -547,6 +572,13 @@ function assemblePdgImpactResult(input: {
// PDG-specific epistemic marker — NOT the callgraph 'lower-bound'/DI copy.
epistemic: 'pdg-intra-procedural',
note: noteParts.join(' '),
pdgEvidence: {
statements: 'local-dependence',
localSymbols: unresolvedCount > 0 ? 'degraded' : 'owner-projection',
localSymbolCount: impactedCount,
unresolvedBlockCount: unresolvedCount,
ambiguousProjectionCount: ambiguousCount,
},
// Statement-level slice: the dependent source statements (line + text) the
// change reaches. This is the primary useful output of statement mode; the
// accuracy harness scores against these lines.

View file

@ -465,7 +465,7 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
enum: ['callgraph', 'pdg'],
default: 'callgraph',
description:
"Blast-radius engine. 'callgraph' (default) = inter-procedural symbol→symbol traversal (established comparator). 'pdg' = unified PDG-facing impact: statement-level affectedStatements from the persisted control/data dependence layer plus inter-procedural symbols in interproceduralByDepth/pdgInterprocedural and the compatibility byDepth bucket; requires `gitnexus analyze --pdg`. PDG is incompatible with crossDepth and @group targets; relationTypes/minConfidence filter the inter-symbol reach.",
"Blast-radius engine. 'callgraph' (default) = inter-procedural symbol→symbol traversal (established comparator). 'pdg' = unified PDG-facing impact: intra-procedural statement-level affectedStatements from the persisted control/data dependence layer plus inter-procedural symbols in interproceduralByDepth/pdgInterprocedural and the compatibility byDepth bucket; requires `gitnexus analyze --pdg`. PDG symbol reach is labeled as a PDG evidence bridge, not pure statement-level dependence, and successful PDG results are UNKNOWN-risk. PDG is incompatible with crossDepth and @group targets; relationTypes/minConfidence filter the inter-symbol reach.",
},
line: {
type: 'integer',

View file

@ -1479,6 +1479,84 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
expect(bfsSpy).toHaveBeenCalledTimes(1);
});
it("mode:'pdg' labels interprocedural symbols as a callgraph bridge", async () => {
resolveSingleTarget();
vi.spyOn(backend as any, '_runImpactBFS').mockResolvedValueOnce({
target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
direction: 'downstream',
impactedCount: 1,
risk: 'LOW',
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
byDepthCounts: { 1: 1 },
affected_processes: [],
affected_modules: [],
byDepth: {
1: [
{
depth: 1,
id: 'func:callee',
name: 'callee',
type: 'Function',
filePath: 'src/callee.ts',
},
],
},
});
const result = await backend.callTool('impact', {
target: 'main',
direction: 'downstream',
mode: 'pdg',
});
expect(result.error).toBeUndefined();
expect(result.mode).toBe('pdg');
expect(result.pdgInterprocedural.evidence).toBe('callgraph-bridge');
expect(result.pdgInterprocedural.evidenceCounts['callgraph-bridge']).toBe(1);
expect(result.pdgEvidence.interprocedural).toBe('callgraph-bridge');
expect(result.interproceduralByDepth[1][0].pdgEvidence).toBe('callgraph-bridge');
expect(result.note).toContain('labeled as a PDG evidence bridge');
});
it("mode:'pdg' preserves unproven bridge evidence when call-site proof is unavailable", async () => {
resolveSingleTarget();
vi.spyOn(backend as any, '_runImpactBFS').mockResolvedValueOnce({
target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' },
direction: 'downstream',
impactedCount: 1,
risk: 'LOW',
summary: { direct: 1, processes_affected: 0, modules_affected: 0 },
byDepthCounts: { 1: 1 },
affected_processes: [],
affected_modules: [],
byDepth: {
1: [
{
depth: 1,
id: 'func:callee',
name: 'callee',
type: 'Function',
filePath: 'src/callee.ts',
pdgEvidence: 'unproven-bridge',
},
],
},
});
const result = await backend.callTool('impact', {
target: 'main',
direction: 'downstream',
mode: 'pdg',
});
expect(result.error).toBeUndefined();
expect(result.mode).toBe('pdg');
expect(result.pdgInterprocedural.evidence).toBe('unproven-bridge');
expect(result.pdgInterprocedural.evidenceCounts['unproven-bridge']).toBe(1);
expect(result.pdgEvidence.interprocedural).toBe('unproven-bridge');
expect(result.note).toContain('labeled unproven-bridge');
});
it.each([['PDG'], ['pgd'], [''], [0], [null]])(
'invalid mode %j → structured {error}, never a callgraph result (KTD5 anti-silent-fallback)',
async (bad) => {
@ -1532,13 +1610,13 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
},
);
it("mode:'pdg' + line:8 routes to the PDG traversal and interprocedural reach", async () => {
it("mode:'pdg' + downstream line:8 routes to the PDG traversal and seeds bridge evidence", async () => {
resolveSingleTarget();
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');
const pdgSpy = vi.spyOn(backend as any, '_runImpactPDG');
const result = await backend.callTool('impact', {
target: 'main',
direction: 'upstream',
direction: 'downstream',
mode: 'pdg',
line: 8,
});
@ -1547,6 +1625,9 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
expect(result.mode).toBe('pdg');
expect(pdgSpy).toHaveBeenCalledTimes(1);
expect(bfsSpy).toHaveBeenCalledTimes(1);
const bridge = bfsSpy.mock.calls[0][4].pdgBridge;
expect(bridge.targetFilePath).toBe('src/index.ts');
expect([...bridge.firstHopLineKeys]).toContain('src/index.ts:8');
expect(result.pdgInterprocedural).toBeDefined();
});

View file

@ -72,6 +72,9 @@ describe('runImpactPDG', () => {
expect((result as any).affectedStatementCount).toBe(2);
expect((result as any).affectedStatements.map((s: any) => s.line)).toEqual([2, 2]);
expect((result as any).affectedStatements.map((s: any) => s.text)).toEqual(['a();', 'b();']);
expect((result as any).pdgEvidence.statements).toBe('local-dependence');
expect((result as any).pdgEvidence.localSymbols).toBe('owner-projection');
expect((result as any).byDepth[1][0].pdgEvidence).toBe('owner-projection');
});
});