diff --git a/gitnexus/src/core/ingestion/cfg/emit.ts b/gitnexus/src/core/ingestion/cfg/emit.ts index d8c02db8a..c25fa6e27 100644 --- a/gitnexus/src/core/ingestion/cfg/emit.ts +++ b/gitnexus/src/core/ingestion/cfg/emit.ts @@ -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(); 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; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index fcc3fa03d..59b88db17 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -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; } -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 { diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 15969200f..5605bc690 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -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 — diff --git a/gitnexus/test/unit/cfg-callees-of-block.test.ts b/gitnexus/test/unit/cfg-callees-of-block.test.ts new file mode 100644 index 000000000..4135fa07c --- /dev/null +++ b/gitnexus/test/unit/cfg-callees-of-block.test.ts @@ -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(''); + }); +});