fix(impact): prove callees invoked on the seeded line (statement-precise)

The statement-precise projection dropped a callee invoked directly on the
changed line when that callee was not also called from a downstream-dependent
block: sliceCalleeNames was built only from reachableBlocks, which excludes the
seed block by the seed-minus-reachable convention. Such a callee — the most
directly impacted of all — was labeled unproven-bridge and dropped from
statementPreciseByDepth, deflating statementPrecision.

runImpactPDG now surfaces its seedBlocks (threaded through assemblePdgImpactResult
and both empty/no-reachability early returns), and the dispatch unions
calleesOfBlocks(seedBlocks ∪ reachableBlocks). A callee on the changed line is
now proven. Recall was already preserved (full interproceduralByDepth unchanged);
this fixes the precision label.

Verified: parseSourceSafe@231 statementPrecision 0.500 → 0.667 (the seed-line
callee is now proven). New dispatch regression test covers the empty-reachable /
non-empty-seed case.

Addresses PR #2227 tri-review headline (P2, Codex + correctness + adversarial).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-18 15:50:19 +00:00
parent 7a303cffc3
commit 3ed88b330f
3 changed files with 75 additions and 4 deletions

View file

@ -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<string>();
const pdgBridge: PdgBridgeOptions | undefined =
sliceCalleeNames.size > 0 ? { sliceCalleeNames } : undefined;

View file

@ -362,6 +362,8 @@ export interface PdgImpactSuccessResult extends PdgImpactBaseResult {
target: Required<PdgImpactTarget>;
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<PdgImpactTarget>;
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<PdgImpactRes
direction,
...(statementMode ? { criterionLine: line } : {}),
reachableBlocks: [],
seedBlocks: [],
blockCount: 0,
affectedStatements: [],
affectedStatementCount: 0,
@ -1126,6 +1139,11 @@ export async function runImpactPDG(deps: RunPdgImpactDeps): Promise<PdgImpactRes
`(what depends on the code at that line). Inter-procedural symbol reach is attached ` +
`separately by the unified impact dispatcher.`,
reachableBlocks: [] as string[],
// Carry the real seed blocks (non-empty here — the function HAS blocks, they
// are all seeds): a callee invoked directly on the seeded line must still be
// provable even when the line has no downstream dependents (the seed-line FN
// the tri-review found). Empty reachableBlocks must NOT zero the seed callees.
seedBlocks,
blockCount: 0,
affectedStatements: [],
affectedStatementCount: 0,
@ -1155,6 +1173,7 @@ export async function runImpactPDG(deps: RunPdgImpactDeps): Promise<PdgImpactRes
},
direction,
reachableBlocks,
seedBlocks,
affectedStatements,
criterionLine: statementMode ? (line as number) : undefined,
projection,

View file

@ -1659,6 +1659,52 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
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');