diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 7f56f2fc1..a165aff2e 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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): Record { const normalized: Record = {}; 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>) { + const ev = pdgBridgeEvidenceById.get(String(item.id)); + if (ev) { + item.pdgEvidence = ev.evidence; + item.pdgBridgeBasis = ev.basis; + } + } + } + const grouped: Record = {}; for (const item of impacted) { if (!grouped[item.depth]) grouped[item.depth] = []; diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index b9e4e5ac7..535f761fd 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -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');