fix(impact): pin owning function in the statement seed anchor

The statement seed query matches blocks by startLine within the symbol's span
(a forgiving window), so a closure body block that starts on the SAME source
line as the seeded statement — but is owned by a different (nested) function —
leaked into the seed and contaminated the intra slice with the closure's
dependence. Filter the seed blocks to those whose owning fnLine === sym.startLine
+ 1 (block ids encode the 1-based function start line). Defensive: the filter
only applies when it leaves >=1 seed, so a symbol kind whose fnLine convention
differs never loses a real seed.

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

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

View file

@ -988,9 +988,22 @@ export async function runImpactPDG(deps: RunPdgImpactDeps): Promise<PdgImpactRes
queryParams,
);
const seedRows = rawSeedRows.slice(0, stepLimit);
const seedBlocks: string[] = seedRows
let seedBlocks: string[] = seedRows
.map((r: any) => 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

View file

@ -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', () => {