fix(impact): order-independent depth>1 bridge evidence

A symbol reachable from multiple parents in the inter-procedural BFS got its
proven/unproven bridge label from whichever parent the DB returned first
(first-writer-wins), so a diamond-reachable depth≥2 symbol could flip label
run-to-run. Compute evidence for every edge, keep the strongest across all
parents (callgraph-bridge wins, via betterBridgeEvidence), and stamp the
finalized label onto the impacted items after the depth loop. The label is now
deterministic; reach is unaffected.

Addresses PR #2227 tri-review finding (P3, correctness + adversarial).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-18 15:54:51 +00:00
parent 3ed88b330f
commit 3618ee62cf
2 changed files with 64 additions and 16 deletions

View file

@ -309,6 +309,24 @@ function pdgBridgeEvidenceForImpact(input: {
};
}
/**
* Pick the stronger of two bridge-evidence verdicts for the same reached symbol.
* `callgraph-bridge` (proven) beats `unproven-bridge`, so a node reachable from
* multiple parents is proven if ANY parent proves it. This makes the
* proven/unproven label order-independent of DB row iteration — a diamond-reached
* symbol gets the same label regardless of which parent the BFS visits first
* (PR #2227 tri-review, P3).
*/
export function betterBridgeEvidence(
existing: PdgBridgeEvidenceInfo | undefined,
candidate: PdgBridgeEvidenceInfo,
): PdgBridgeEvidenceInfo {
if (!existing) return candidate;
if (existing.evidence === 'callgraph-bridge') return existing;
if (candidate.evidence === 'callgraph-bridge') return candidate;
return existing;
}
function normalizePdgBridgeByDepth(byDepth: Record<number, unknown[]>): Record<number, unknown[]> {
const normalized: Record<number, unknown[]> = {};
for (const [depthKey, items] of Object.entries(byDepth ?? {})) {
@ -5340,27 +5358,37 @@ export class LocalBackend {
if (!includeTests && isTestFilePath(filePath)) continue;
// Bridge evidence is computed for EVERY edge (not just the first to
// reach a node) and the strongest verdict across all parents is kept
// (`callgraph-bridge` wins). This makes a diamond-reachable node's
// proven/unproven label order-independent of DB row iteration; the
// final label is stamped onto the impacted items after the depth loop.
if (opts.pdgBridge) {
const ev = pdgBridgeEvidenceForImpact({
bridge: opts.pdgBridge,
depth,
calleeName: rel.name || rel[2],
inherited: pdgBridgeEvidenceById.get(sourceId),
});
pdgBridgeEvidenceById.set(
String(relId),
betterBridgeEvidence(pdgBridgeEvidenceById.get(String(relId)), ev),
);
}
if (!visited.has(relId)) {
visited.add(relId);
nextFrontier.push(relId);
const storedConfidence = rel.confidence ?? rel[6];
const relationType = rel.relType || rel[5];
const calleeName = rel.name || rel[2];
const bridgeEvidence = opts.pdgBridge
? pdgBridgeEvidenceForImpact({
bridge: opts.pdgBridge,
depth,
calleeName,
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 =
typeof storedConfidence === 'number' && storedConfidence > 0
? storedConfidence
: confidenceForRelType(relationType);
// pdgEvidence is stamped after the depth loop from the finalized,
// order-independent pdgBridgeEvidenceById map.
impacted.push({
depth,
id: relId,
@ -5369,12 +5397,6 @@ export class LocalBackend {
filePath,
relationType,
confidence: effectiveConfidence,
...(bridgeEvidence
? {
pdgEvidence: bridgeEvidence.evidence,
pdgBridgeBasis: bridgeEvidence.basis,
}
: {}),
});
}
}
@ -5389,6 +5411,19 @@ export class LocalBackend {
frontier = nextFrontier;
}
// Stamp the finalized, order-independent bridge evidence (strongest across
// all parents) onto each impacted item. Deferred from the BFS loop so a
// diamond-reachable node reflects a proven parent regardless of visit order.
if (opts.pdgBridge) {
for (const item of impacted as Array<Record<string, unknown>>) {
const ev = pdgBridgeEvidenceById.get(String(item.id));
if (ev) {
item.pdgEvidence = ev.evidence;
item.pdgBridgeBasis = ev.basis;
}
}
}
const grouped: Record<number, any[]> = {};
for (const item of impacted) {
if (!grouped[item.depth]) grouped[item.depth] = [];

View file

@ -104,6 +104,7 @@ import {
LocalBackend,
REPO_ID_HASH_LENGTH,
parseListReposPagination,
betterBridgeEvidence,
} from '../../src/mcp/local/local-backend.js';
import {
listRegisteredRepos,
@ -1705,6 +1706,18 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
expect([...bridge.sliceCalleeNames]).toContain('seedCallee');
});
it('betterBridgeEvidence keeps callgraph-bridge regardless of parent order (U3 order-independence)', () => {
const proven = { evidence: 'callgraph-bridge' as const, basis: 'in slice' };
const unproven = { evidence: 'unproven-bridge' as const, basis: 'not in slice' };
// A node reached from a proven and an unproven parent is proven either way —
// the diamond label does not depend on which edge the BFS visits first.
expect(betterBridgeEvidence(unproven, proven).evidence).toBe('callgraph-bridge');
expect(betterBridgeEvidence(proven, unproven).evidence).toBe('callgraph-bridge');
// First verdict wins when neither is stronger; undefined existing takes the candidate.
expect(betterBridgeEvidence(undefined, unproven).evidence).toBe('unproven-bridge');
expect(betterBridgeEvidence(unproven, unproven).evidence).toBe('unproven-bridge');
});
it("mode:'pdg' + crossDepth → hard {error} (single-repo PDG impact)", async () => {
resolveSingleTarget();
const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');