diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 9953c1d08..7f56f2fc1 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -4773,13 +4773,19 @@ export class LocalBackend { // Statement-precise inter-procedural reach: a first-hop callee is "proven" // iff it is invoked in a block of the criterion's dependence slice. The - // slice blocks carry the leaf callee names they call (`BasicBlock.callees`); - // upstream/whole-symbol seeds have no discriminating slice, so the bridge + // slice = the seed block(s) (the changed line itself) UNION the dependent + // reachable blocks — both carry the leaf callee names they call + // (`BasicBlock.callees`). The seed block is included because a callee + // invoked directly on the changed line is the most-directly-impacted one, + // yet `reachableBlocks` excludes the seed by the seed-minus-reachable + // convention. Upstream seeds carry no discriminating slice, so the bridge // falls back to preserving callgraph reach. const reachableBlocks = ((pdgResult as any).reachableBlocks ?? []) as string[]; + const seedBlocks = ((pdgResult as any).seedBlocks ?? []) as string[]; + const sliceBlocks = [...seedBlocks, ...reachableBlocks]; const sliceCalleeNames = - direction === 'downstream' && reachableBlocks.length > 0 - ? await this.calleesOfBlocks(repo, reachableBlocks) + direction === 'downstream' && sliceBlocks.length > 0 + ? await this.calleesOfBlocks(repo, sliceBlocks) : new Set(); const pdgBridge: PdgBridgeOptions | undefined = sliceCalleeNames.size > 0 ? { sliceCalleeNames } : undefined; diff --git a/gitnexus/src/mcp/local/pdg-impact.ts b/gitnexus/src/mcp/local/pdg-impact.ts index 3c3746810..25cb04fc7 100644 --- a/gitnexus/src/mcp/local/pdg-impact.ts +++ b/gitnexus/src/mcp/local/pdg-impact.ts @@ -362,6 +362,8 @@ export interface PdgImpactSuccessResult extends PdgImpactBaseResult { target: Required; epistemic: 'pdg-intra-procedural'; reachableBlocks: string[]; + /** The criterion's own seed blocks (changed statement / whole-symbol body). */ + seedBlocks: string[]; blockCount: number; affectedStatements: PdgStatement[]; affectedStatementCount: number; @@ -378,6 +380,8 @@ export interface PdgImpactEmptyResult extends PdgImpactBaseResult { target: Required; epistemic: 'no-pdg-body' | 'pdg-no-block-at-line' | 'pdg-intra-procedural'; reachableBlocks: string[]; + /** The criterion's own seed blocks (changed statement / whole-symbol body). */ + seedBlocks: string[]; blockCount: number; affectedStatements: PdgStatement[]; affectedStatementCount: number; @@ -500,6 +504,13 @@ function assemblePdgImpactResult(input: { target: { id: string; name: string; type: string; filePath: string }; direction: 'upstream' | 'downstream'; reachableBlocks: string[]; + /** + * The criterion's own seed blocks (the changed statement / whole-symbol body). + * Surfaced so the dispatcher can prove inter-procedural callees invoked + * directly on the changed line, which are NOT in `reachableBlocks` (the + * seed-minus-reachable convention — seeds are the target, not dependents). + */ + seedBlocks: string[]; /** Reachable blocks resolved to source statements (the useful slice output). */ affectedStatements?: PdgStatement[]; /** The 1-based source line the slice was seeded on (statement mode only). */ @@ -604,6 +615,7 @@ function assemblePdgImpactResult(input: { // Raw block-level detail retained alongside the symbol projection (U3 tests // and the accuracy harness read these). reachableBlocks, + seedBlocks: input.seedBlocks, blockCount: reachableBlocks.length, depthReached: input.depthReached, unresolvedBlockCount: unresolvedCount, @@ -999,6 +1011,7 @@ export async function runImpactPDG(deps: RunPdgImpactDeps): Promise { expect(result.pdgInterprocedural).toBeDefined(); }); + it("mode:'pdg' downstream: a callee invoked ON the seeded line is proven even with no downstream dependents", async () => { + // Regression for the PR #2227 tri-review P2: the seed block is excluded from + // `reachableBlocks` (seed-minus-reachable convention), so a callee called + // directly on the changed line — with NO downstream-dependent block — used to + // be dropped from the statement-precise set. The dispatch now unions the seed + // block's callees, so it must be proven. + resolveSingleTarget(); + (executeParameterized as any).mockResolvedValue([ + { + id: 'func:main', + name: 'main', + type: 'Function', + filePath: 'src/index.ts', + callees: 'seedCallee', + }, + ]); + // reachableBlocks EMPTY (line N has no downstream dependents) but seedBlocks + // carries the changed line's own block — the case that regressed. + vi.spyOn(backend as any, '_runImpactPDG').mockResolvedValueOnce({ + mode: 'pdg', + target: { id: 'func:main', name: 'main', type: 'Function', filePath: 'src/index.ts' }, + direction: 'downstream', + risk: 'UNKNOWN', + impactedCount: 0, + epistemic: 'pdg-intra-procedural', + reachableBlocks: [], + seedBlocks: ['BasicBlock:src/index.ts:8:0:0'], + blockCount: 0, + affectedStatements: [], + affectedStatementCount: 0, + criterionLine: 8, + }); + const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + await backend.callTool('impact', { + target: 'main', + direction: 'downstream', + mode: 'pdg', + line: 8, + }); + const bridge = bfsSpy.mock.calls[0][4].pdgBridge; + // The bridge is seeded from the seed block (not just reachableBlocks), so the + // seed-line callee is provable. + expect(bridge).toBeDefined(); + expect([...bridge.sliceCalleeNames]).toContain('seedCallee'); + }); + it("mode:'pdg' + crossDepth → hard {error} (single-repo PDG impact)", async () => { resolveSingleTarget(); const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS');