diff --git a/gitnexus/src/mcp/local/pdg-impact.ts b/gitnexus/src/mcp/local/pdg-impact.ts index 25cb04fc7..74cd2bb96 100644 --- a/gitnexus/src/mcp/local/pdg-impact.ts +++ b/gitnexus/src/mcp/local/pdg-impact.ts @@ -988,9 +988,22 @@ export async function runImpactPDG(deps: RunPdgImpactDeps): Promise String(r.id ?? r[0] ?? '')) .filter((id: string) => id.length > 0); + // Pin the OWNING function for a statement seed: a closure body block that + // starts on the SAME source line as the seeded statement satisfies the + // (forgiving) symbol-span window but belongs to a different function, so its + // intra slice would leak in. The block id encodes the 1-based function start + // line, and a block of THIS symbol has fnLine === sym.startLine + 1 (block + // lines 1-based, symbol startLine 0-based). Drop foreign-fn seed blocks — + // defensively: only when it leaves ≥1 seed, so a kind whose fnLine convention + // differs never loses a real seed (it keeps the prior, slightly-loose set). + if (statementMode && typeof sym.startLine === 'number') { + const ownerFnLine = sym.startLine + 1; + const owned = seedBlocks.filter((id) => fnLineOf(id) === ownerFnLine); + if (owned.length > 0) seedBlocks = owned; + } // FIX 7: the seed query probes one row past `stepLimit`, then processes at // most `stepLimit` rows like every BFS step. A function with more seed blocks // than `stepLimit` would silently under-seed (and thus under-report) — flag diff --git a/gitnexus/test/unit/pdg-impact-engine.test.ts b/gitnexus/test/unit/pdg-impact-engine.test.ts index cf844e549..65f533045 100644 --- a/gitnexus/test/unit/pdg-impact-engine.test.ts +++ b/gitnexus/test/unit/pdg-impact-engine.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; import { IMPACT_MAX_DEPTH } from '../../src/mcp/tools.js'; -import { pdgLayerStatus, runImpactPDG } from '../../src/mcp/local/pdg-impact.js'; +import { + pdgLayerStatus, + runImpactPDG, + type RunPdgImpactDeps, +} from '../../src/mcp/local/pdg-impact.js'; describe('runImpactPDG', () => { it('clamps huge maxDepth values to the documented impact traversal cap', async () => { @@ -76,6 +80,41 @@ describe('runImpactPDG', () => { expect((result as any).pdgEvidence.localSymbols).toBe('owner-projection'); expect((result as any).byDepth[1][0].pdgEvidence).toBe('owner-projection'); }); + + it('pins the owning function: a same-source-line closure block does not leak into the seed', async () => { + // Symbol starts at 0 → owning fnLine === 1. The seed query (a forgiving + // startLine-within-window match) returns BOTH the symbol's own block at the + // seeded line AND a closure body block that happens to start on the same + // source line but is owned by a function starting at line 5 (fnLine 5). + const owned = 'BasicBlock:src/hot.ts:1:0:3'; // fnLine 1 === sym.startLine + 1 + const closureLeak = 'BasicBlock:src/hot.ts:5:10:0'; // fnLine 5 — a nested closure + const exec: RunPdgImpactDeps['executeParameterized'] = async (_repo, query) => { + if (query.includes('MATCH (a:BasicBlock) WHERE')) { + return [{ id: owned }, { id: closureLeak }]; + } + // No downstream reachability — exercises the seedBlocks-carrying branch. + if (query.includes('MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)')) return []; + if (query.includes('MATCH (b:BasicBlock) WHERE b.id IN $ids')) return []; + if (query.includes('MATCH (s:`Function`)')) return []; + return []; + }; + + const result = await runImpactPDG({ + repo: { lbugPath: 'repo' }, + sym: { id: 'func:hot', name: 'hot', filePath: 'src/hot.ts', startLine: 0, endLine: 20 }, + symType: 'Function', + direction: 'downstream', + maxDepth: 2, + limit: 50, + line: 7, + executeParameterized: exec, + }); + + // Only the owning-function block survives; the closure block is dropped. + // Unconditional match: fails if `seedBlocks` is absent, has the wrong + // length, or contains the closure block — no vacuous branch. + expect(result).toMatchObject({ seedBlocks: [owned] }); + }); }); describe('pdgLayerStatus', () => {