fix(impact): signal the per-statement site cap on BasicBlock.callees

When a statement's call sites are truncated at DEFAULT_PDG_MAX_SITES_PER_STATEMENT
the recorded callee list is incomplete, so over-cap callees were silently absent
from BasicBlock.callees — making the impact bridge label a reachable-but-dropped
callee `unproven-bridge` with no signal. calleesOfBlock now emits a reserved
sentinel ('*', not a valid identifier leaf) for a capped block, and the bridge
treats a slice containing it as callee-unknown → keeps reach callgraph-equal
(proven) rather than under-proving. No fixture/real block hits the cap, so the
emit byte-identity fingerprint is unchanged (gate stays green; no re-index).

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:21:30 +00:00
parent 2a511ebd88
commit 64049b0c99
4 changed files with 109 additions and 1 deletions

View file

@ -28,8 +28,20 @@ import {
NO_IPDOM,
} from './post-dominators.js';
import { augmentForPostDom } from './synthetic-escape.js';
import { DEFAULT_PDG_MAX_SITES_PER_STATEMENT } from './visitors/call-site-harvest.js';
import type { BasicBlockData, BindingEntry, FunctionCfg } from './types.js';
/**
* Reserved token placed in `BasicBlock.callees` when a statement's call sites
* were truncated at {@link DEFAULT_PDG_MAX_SITES_PER_STATEMENT}: the recorded
* callee list is then INCOMPLETE, so over-cap callees are absent. `*` is not a
* valid identifier leaf, so it cannot collide with a real callee name. The
* impact bridge treats a slice containing this sentinel as "callees unknown" and
* keeps reach callgraph-equal (proven), rather than falsely labeling an
* absent-but-real callee `unproven-bridge`.
*/
export const CALLEES_TRUNCATED_SENTINEL = '*';
/**
* Default per-function CFG edge cap. A pathological generated function could
* otherwise emit an unbounded edge set; the cap bounds graph growth and is
@ -269,6 +281,13 @@ export const hasEmitSafeFacts = (cfg: FunctionCfg): boolean => {
export function calleesOfBlock(block: BasicBlockData): string {
const names = new Set<string>();
for (const stmt of block.statements ?? []) {
// A statement whose recorded sites reached the per-statement cap may have
// dropped over-cap callees (the harvester stops at the cap). Flag the block
// callee-unknown so the impact bridge keeps it callgraph-equal rather than
// under-proving an absent-but-real callee.
if ((stmt.sites?.length ?? 0) >= DEFAULT_PDG_MAX_SITES_PER_STATEMENT) {
names.add(CALLEES_TRUNCATED_SENTINEL);
}
for (const site of stmt.sites ?? []) {
if (site.kind === 'member-read') continue;
const callee = site.callee;

View file

@ -17,6 +17,7 @@ import {
isLbugReady,
} from '../../core/lbug/pool-adapter.js';
import { isValidQueryParams } from '../../core/lbug/query-params.js';
import { CALLEES_TRUNCATED_SENTINEL } from '../../core/ingestion/cfg/emit.js';
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/lbug-config.js';
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
@ -271,7 +272,7 @@ interface PdgBridgeOptions {
sliceCalleeNames?: ReadonlySet<string>;
}
function pdgBridgeEvidenceForImpact(input: {
export function pdgBridgeEvidenceForImpact(input: {
bridge: PdgBridgeOptions;
depth: number;
calleeName: unknown;
@ -295,6 +296,16 @@ function pdgBridgeEvidenceForImpact(input: {
};
}
// A slice block whose call sites were truncated at the per-statement cap has an
// INCOMPLETE callee list, so absence from the set does not prove absence from
// the slice. Keep such reach callgraph-equal rather than under-proving.
if (sliceCalleeNames.has(CALLEES_TRUNCATED_SENTINEL)) {
return {
evidence: 'callgraph-bridge',
basis: 'a slice block truncated its call sites — callee set is incomplete (callee-unknown)',
};
}
const name = typeof calleeName === 'string' ? calleeName : '';
if (name && sliceCalleeNames.has(name)) {
return {

View file

@ -105,7 +105,9 @@ import {
REPO_ID_HASH_LENGTH,
parseListReposPagination,
betterBridgeEvidence,
pdgBridgeEvidenceForImpact,
} from '../../src/mcp/local/local-backend.js';
import { CALLEES_TRUNCATED_SENTINEL } from '../../src/core/ingestion/cfg/emit.js';
import {
listRegisteredRepos,
cleanupOldKuzuFiles,
@ -1718,6 +1720,25 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => {
expect(betterBridgeEvidence(unproven, unproven).evidence).toBe('unproven-bridge');
});
it('pdgBridgeEvidenceForImpact treats a truncated-slice (sentinel) as callee-unknown → proven', () => {
// A slice block that hit the per-statement site cap has an incomplete callee
// list; the sentinel forces callgraph-equal so an absent-but-real callee is
// not under-proven.
const truncated = pdgBridgeEvidenceForImpact({
bridge: { sliceCalleeNames: new Set([CALLEES_TRUNCATED_SENTINEL, 'foo']) },
depth: 1,
calleeName: 'unrelatedNotInSlice',
});
expect(truncated.evidence).toBe('callgraph-bridge');
// Without the sentinel, a callee not in the slice is unproven.
const notTruncated = pdgBridgeEvidenceForImpact({
bridge: { sliceCalleeNames: new Set(['foo']) },
depth: 1,
calleeName: 'unrelatedNotInSlice',
});
expect(notTruncated.evidence).toBe('unproven-bridge');
});
it("mode:'pdg' degrades gracefully when the slice-callees query fails (no bridge, no throw)", async () => {
// calleesOfBlocks swallows a DB error and returns an empty set, so the bridge
// is not built and the inter-procedural reach falls back to callgraph-equal —

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { CALLEES_TRUNCATED_SENTINEL, calleesOfBlock } from '../../src/core/ingestion/cfg/emit.js';
import { DEFAULT_PDG_MAX_SITES_PER_STATEMENT } from '../../src/core/ingestion/cfg/visitors/call-site-harvest.js';
import type { BasicBlockData, SiteRecord } from '../../src/core/ingestion/cfg/types.js';
const callSite = (callee: string): SiteRecord => ({ kind: 'call', callee });
const block = (statements: BasicBlockData['statements']): BasicBlockData => ({
index: 0,
startLine: 1,
endLine: 1,
text: '',
kind: 'normal',
statements,
});
describe('calleesOfBlock', () => {
it('emits sorted, de-duplicated leaf callee names (dotted paths reduced to the leaf)', () => {
const result = calleesOfBlock(
block([
{ line: 1, defs: [], uses: [], sites: [callSite('child_process.exec'), callSite('foo')] },
{ line: 2, defs: [], uses: [], sites: [callSite('a.b.bar'), callSite('foo')] },
]),
);
expect(result).toBe('bar exec foo');
});
it('ignores member-read sites and sites without a callee', () => {
const result = calleesOfBlock(
block([
{
line: 1,
defs: [],
uses: [],
sites: [{ kind: 'member-read', property: 'body' }, { kind: 'call' }, callSite('only')],
},
]),
);
expect(result).toBe('only');
});
it('flags a block callee-unknown with the sentinel when a statement hits the site cap', () => {
const cappedSites: SiteRecord[] = Array.from(
{ length: DEFAULT_PDG_MAX_SITES_PER_STATEMENT },
() => callSite('foo'),
);
const result = calleesOfBlock(block([{ line: 1, defs: [], uses: [], sites: cappedSites }]));
// The sentinel sorts first ('*' < letters) and rides alongside the real names.
expect(result.split(' ')).toContain(CALLEES_TRUNCATED_SENTINEL);
expect(result.split(' ')).toContain('foo');
});
it('returns an empty string for a block with no call sites', () => {
expect(calleesOfBlock(block([{ line: 1, defs: [], uses: [] }]))).toBe('');
expect(calleesOfBlock(block(undefined))).toBe('');
});
});