diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index d617676e2..b7726bda4 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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> = { const confidenceForRelType = (relType: string | undefined): number => IMPACT_RELATION_CONFIDENCE[relType ?? ''] ?? 0.5; +type PdgBridgeEvidence = Extract; + +interface PdgBridgeEvidenceInfo { + evidence: PdgBridgeEvidence; + basis: string; + site?: { filePath: string; line: number }; +} + +interface PdgBridgeOptions { + /** Line keys (`:`) proven reachable by the local PDG slice. */ + firstHopLineKeys?: ReadonlySet; + /** 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): Record { + const normalized: Record = {}; + 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; + 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, +): Partial> { + const counts: Partial> = {}; + 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>, +): 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(); + 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 = {}; const byDepthCounts: Record = {}; 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 { const { maxDepth, relationTypes, includeTests, minConfidence } = opts; @@ -5001,6 +5172,7 @@ export class LocalBackend { const impacted: any[] = []; const visited = new Set([symId]); + const pdgBridgeEvidenceById = new Map(); 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 } : {}), + } + : {}), }); } } diff --git a/gitnexus/src/mcp/local/pdg-impact.ts b/gitnexus/src/mcp/local/pdg-impact.ts index 3a94c8577..134495228 100644 --- a/gitnexus/src/mcp/local/pdg-impact.ts +++ b/gitnexus/src/mcp/local/pdg-impact.ts @@ -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>; +} + export interface PdgInterproceduralImpact { engine: 'symbol-graph'; + evidence: Extract; impactedCount: number; byDepthCounts: Record; byDepth: Record; + evidenceCounts?: Partial>; 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. diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index a4142aeb5..2bfd17629 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -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', diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 2536fa3d6..fd513071c 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -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(); }); diff --git a/gitnexus/test/unit/pdg-impact-engine.test.ts b/gitnexus/test/unit/pdg-impact-engine.test.ts index 9899ecfaa..cf844e549 100644 --- a/gitnexus/test/unit/pdg-impact-engine.test.ts +++ b/gitnexus/test/unit/pdg-impact-engine.test.ts @@ -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'); }); });