From af35c9a955350a9cd3f1c3e7c3252294b852ac7a Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Thu, 18 Jun 2026 12:12:54 +0000 Subject: [PATCH] feat(impact): statement-precise inter-procedural reach (pdg) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make PDG impact mode's cross-function reach a real precision win instead of a flat tie with callgraph. The proven/unproven-bridge labeling was degenerate: it tried to read a call-site line off the CALLS edge, but edges carry only {type, confidence, reason} (no line), so downstream was always "unproven" and upstream always defaulted "proven" — neither a real signal. Fix: persist what the CFG already harvests but dropped. `BasicBlock.callees` (new STRING column) now carries the space-joined leaf callee names invoked in each block, extracted in `emitFileCfgs` from the per-statement `sites`. The impact bridge then marks a first-hop callee "proven" (callgraph-bridge) iff its name appears in the callees of a block in the changed line's dependence slice, else "unproven-bridge" — a sound, statement-precise discriminator. `mode:'pdg'` gains an additive `statementPreciseByDepth` (+ counts, `statementPreciseImpactedCount`, `statementPrecision`): the proven subset of the inter-procedural reach. The full `interproceduralByDepth` is unchanged and still preserves callgraph reach, so nothing is lost — the precise view sits alongside. Measured on the live GitNexus index (240 functions): full reach stays identical to callgraph (still no false "finds more"), and the statement-precise subset is strictly tighter than callgraph on 43/90 with-slice functions (median proven 1 vs callgraph 2 symbols). Ground-truth `measure.mjs --check` stays green (native PDG/callgraph F1 unchanged). Latency is ~1.2-1.6x (the extra slice-callees lookup) — precision, not speed, as scoped. INCREMENTAL_SCHEMA_VERSION → 2 (the new column forces a full re-analyze of pre-v2 indexes; absent column degrades gracefully to the prior behavior). `blast-radius.mjs` + its unit test gain the statement-precise axis; README's four-axis verdict updated. Removes the dead `parseRelationSiteLine`. Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus-shared/src/graph/types.ts | 3 + gitnexus/bench/impact-pdg/README.md | 22 ++- gitnexus/bench/impact-pdg/blast-radius.mjs | 32 +++- gitnexus/src/core/ingestion/cfg/emit.ts | 32 +++- gitnexus/src/core/lbug/csv-generator.ts | 8 +- gitnexus/src/core/lbug/lbug-adapter.ts | 9 +- gitnexus/src/core/lbug/schema.ts | 1 + gitnexus/src/mcp/local/local-backend.ts | 177 ++++++++++++------ gitnexus/src/mcp/local/pdg-impact.ts | 16 ++ gitnexus/src/storage/repo-manager.ts | 5 +- gitnexus/test/unit/calltool-dispatch.test.ts | 34 +++- .../impact-pdg-blast-radius-metrics.test.ts | 16 ++ 12 files changed, 274 insertions(+), 81 deletions(-) diff --git a/gitnexus-shared/src/graph/types.ts b/gitnexus-shared/src/graph/types.ts index 085c27d03..1977219b4 100644 --- a/gitnexus-shared/src/graph/types.ts +++ b/gitnexus-shared/src/graph/types.ts @@ -94,6 +94,9 @@ export type NodeProperties = { middleware?: string[]; // BasicBlock (taint/PDG substrate, issue #2080) — reuses filePath/startLine/endLine. text?: string; + /** BasicBlock: space-joined leaf callee names invoked in the block — the + * statement-precise inter-procedural reach substrate for impact mode. */ + callees?: string; // Extensible [key: string]: unknown; }; diff --git a/gitnexus/bench/impact-pdg/README.md b/gitnexus/bench/impact-pdg/README.md index baed1d00f..2cef1a780 100644 --- a/gitnexus/bench/impact-pdg/README.md +++ b/gitnexus/bench/impact-pdg/README.md @@ -486,17 +486,21 @@ blocks). |---|---|---| | **Tighter / fewer false alarms** | ✅ strongly confirmed | *Correctness:* the line-seeded slice equals the curated intra dependence exactly — intra & mixed PDG F1 = 1.000, FPIS = FNIS = 0. *Magnitude:* the slice is a median **0.30** (downstream) / **0.22** (upstream) of the function body; **240/240** functions localize below whole-body — a ~70–78% cut in the intra-procedural inspection set, with no proven dropped dependency. | | **Catches impact callgraph misses** | ✅ confirmed (new axis) | Callgraph emits *no* statement-level output (unified intra-line CIS = 0, recall 0 on every fixture); PDG recovers every true dependent statement (intra recall = 1.000). PDG answers a def→use / control-dependence question callgraph cannot represent at all. | -| **Finds more callers/callees** | ❌ refuted (tie by design) | The PDG inter-procedural symbol set is **identical** to callgraph on 240/240 real functions (0 pdg-only, 0 callgraph-only) and recall = precision = 1.000 vs callgraph in the 5-case probe. PDG bridges inter-procedural reach *through* the call graph — same set, plus proven/unproven labels. | -| **Faster / cheaper** | ❌ refuted | PDG carries ~**1.2–1.6×** callgraph latency (the slice query + bridge labeling). It buys precision, not speed. | +| **Finds *more* callers/callees** | ❌ refuted (tie, by design) | Full PDG inter-procedural reach is **identical** to callgraph on 240/240 real functions (0 pdg-only, 0 callgraph-only). PDG bridges inter-procedural reach *through* the call graph, so it never finds reach the call graph misses. | +| **Tighter cross-function reach (statement-precise)** | ✅ confirmed (precision, additive) | `mode:'pdg'` now also exposes `statementPreciseByDepth` — the callees actually invoked from the changed line's dependence slice (`BasicBlock.callees`), dropping symbols only reachable from independent statements. Strictly tighter than callgraph on **43/90** with-slice functions (median proven **1** vs callgraph **2** symbols); the full reach stays available alongside it. `statementPrecision` reports the cut. | +| **Faster / cheaper** | ❌ refuted | PDG carries ~**1.2–1.6×** callgraph latency (the slice query + the slice-callees lookup). It buys precision, not speed. | **Headline.** PDG makes `impact` *much* better at the localization/precision -question — *"what exactly does changing **this** statement affect?"* — narrowing -the intra-procedural blast radius to roughly a quarter-to-a-third of the function -body with ground-truth-proven correctness, and adding a statement-level -dependence axis callgraph has no answer for. It is deliberately **not** a wider or -faster cross-function reach; for cross-symbol blast radius, `mode:'callgraph'` -remains the comparator. The two compose — that is the whole point of the unified -result, not a default switch. +question — *"what exactly does changing **this** statement affect?"* It narrows the +intra-procedural blast radius to roughly a quarter-to-a-third of the function body +with ground-truth-proven correctness, adds a statement-level dependence axis +callgraph has no answer for, and — via the persisted `BasicBlock.callees` substrate +— now also reports a **statement-precise** cross-function reach (only the callees +the changed line actually reaches), strictly tighter than callgraph on roughly half +of with-slice functions. It is deliberately **not** a *wider* or *faster* +cross-function reach: the full callgraph reach is preserved alongside the precise +view, and `mode:'callgraph'` remains the comparator for raw blast radius. The +surfaces compose — that is the point of the unified result, not a default switch. Reproduce the verdict: diff --git a/gitnexus/bench/impact-pdg/blast-radius.mjs b/gitnexus/bench/impact-pdg/blast-radius.mjs index db2d9cd1f..aefb26f9a 100644 --- a/gitnexus/bench/impact-pdg/blast-radius.mjs +++ b/gitnexus/bench/impact-pdg/blast-radius.mjs @@ -132,6 +132,20 @@ export function summarizeBlastRadius(cases) { totalPdgOnlySymbols: cases.reduce((a, c) => a + c.pdgOnly, 0), totalCgOnlySymbols: cases.reduce((a, c) => a + c.cgOnly, 0), }, + // Statement-precise inter-procedural reach (the axis-3 precision win): the + // proven subset invoked from the changed line's slice, vs the full callgraph + // reach. Only the with-slice cases can discriminate; empty-slice/upstream + // cases preserve full reach (precision 1) and are reported separately. + statementPrecise: { + casesWithSlice: cases.filter((c) => c.sliceBlocks > 0).length, + casesTighterThanCallgraph: cases.filter((c) => c.statementPreciseSymbols < c.callgraphSymbols) + .length, + medianStatementPrecision: round( + median(cases.map((c) => c.statementPrecision).filter((v) => v !== null && v !== undefined)), + ), + medianPreciseSymbols: median(cases.map((c) => c.statementPreciseSymbols)), + medianCallgraphSymbols: median(cases.map((c) => c.callgraphSymbols)), + }, latency: { medianCallgraphMs: round(median(cases.map((c) => c.callgraphMs))), medianPdgMs: round(median(cases.map((c) => c.pdgMs))), @@ -245,6 +259,12 @@ async function run() { const pdgSyms = symbolSetFromByDepth( pdg?.interproceduralByDepth ?? pdg?.pdgInterprocedural?.byDepth ?? {}, ); + // Statement-precise (proven) inter-procedural reach: the subset invoked + // from the criterion's dependence slice. Tighter than callgraph when the + // changed line reaches only some of the function's callees. + const preciseSyms = symbolSetFromByDepth( + pdg?.statementPreciseByDepth ?? pdg?.pdgInterprocedural?.statementPreciseByDepth ?? {}, + ); const pdgOnly = [...pdgSyms].filter((x) => !cgSyms.has(x)).length; const cgOnly = [...cgSyms].filter((x) => !pdgSyms.has(x)).length; @@ -258,6 +278,9 @@ async function run() { ratio: bodyBlocks ? round(sliceBlocks / bodyBlocks) : null, callgraphSymbols: cgSyms.size, pdgSymbols: pdgSyms.size, + statementPreciseSymbols: preciseSyms.size, + statementPrecision: + typeof pdg?.statementPrecision === 'number' ? round(pdg.statementPrecision) : null, pdgOnly, cgOnly, epistemic: pdg?.epistemic ?? null, @@ -286,6 +309,7 @@ async function run() { const loc = summary.localization; const inter = summary.interSymbol; + const prec = summary.statementPrecise; const lat = summary.latency; const lines = []; lines.push('=== impact-PDG real-code blast-radius / localization probe ==='); @@ -300,10 +324,16 @@ async function run() { `functions localized below whole-body.`, ); lines.push( - `Inter-symbol reach (axis: more callers/callees): identical to callgraph on ` + + `Inter-symbol reach (axis: more callers/callees): full reach identical to callgraph on ` + `${inter.casesIdentical}/${cases.length} functions ` + `(pdg-only ${inter.totalPdgOnlySymbols}, callgraph-only ${inter.totalCgOnlySymbols}).`, ); + lines.push( + `Statement-precise reach (axis: tighter cross-function): ${prec.casesTighterThanCallgraph}/` + + `${prec.casesWithSlice} with-slice functions narrow below full callgraph reach; median ` + + `statement-precision ${fmt(prec.medianStatementPrecision)} (proven median ` + + `${prec.medianPreciseSymbols} vs callgraph ${prec.medianCallgraphSymbols} symbols).`, + ); lines.push( `Latency (axis: faster): callgraph median ${fmt(lat.medianCallgraphMs, 1)}ms, ` + `pdg median ${fmt(lat.medianPdgMs, 1)}ms, pdg/cg ${fmt(lat.medianPdgOverCallgraph)}x.`, diff --git a/gitnexus/src/core/ingestion/cfg/emit.ts b/gitnexus/src/core/ingestion/cfg/emit.ts index f4550951d..d8c02db8a 100644 --- a/gitnexus/src/core/ingestion/cfg/emit.ts +++ b/gitnexus/src/core/ingestion/cfg/emit.ts @@ -28,7 +28,7 @@ import { NO_IPDOM, } from './post-dominators.js'; import { augmentForPostDom } from './synthetic-escape.js'; -import type { BindingEntry, FunctionCfg } from './types.js'; +import type { BasicBlockData, BindingEntry, FunctionCfg } from './types.js'; /** * Default per-function CFG edge cap. A pathological generated function could @@ -255,6 +255,31 @@ export const hasEmitSafeFacts = (cfg: FunctionCfg): boolean => { * no silent truncation (KTD6/R6). Block nodes are always fully emitted (their * count is bounded by the function's statement count); only edges are capped. */ +/** + * Space-joined, sorted, de-duplicated leaf callee names invoked directly in a + * block (`call`/`new` sites; the leaf of a dotted path — `child_process.exec` ⇒ + * `exec`). This is the persisted substrate for statement-precise inter-procedural + * impact: a callee reached from a function is "proven" to be impacted by a + * changed statement iff its name appears in the callees of a block in that + * statement's dependence slice. `sites` is harvested only for TS/JS under `--pdg` + * (and absent on synthetic ENTRY/EXIT), so the field is empty elsewhere and the + * bridge degrades to the prior (callgraph-equal) behavior. Space-joined because + * leaf names are identifiers (no spaces) and the field is itself one CSV cell. + */ +export function calleesOfBlock(block: BasicBlockData): string { + const names = new Set(); + for (const stmt of block.statements ?? []) { + for (const site of stmt.sites ?? []) { + if (site.kind === 'member-read') continue; + const callee = site.callee; + if (!callee) continue; + const leaf = callee.slice(callee.lastIndexOf('.') + 1); + if (leaf) names.add(leaf); + } + } + return [...names].sort().join(' '); +} + export function emitFileCfgs( graph: KnowledgeGraph, cfgs: readonly FunctionCfg[], @@ -277,6 +302,11 @@ export function emitFileCfgs( startLine: b.startLine, endLine: b.endLine, text: b.text, + // Space-joined leaf callee names invoked in this block — the + // statement-precise inter-procedural reach substrate. Harvested from + // the per-statement `sites` (already on the side channel); dropping + // them here is what made the impact-mode bridge labeling degenerate. + callees: calleesOfBlock(b), }, }); result.blocks++; diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 0dc6eba94..608bac65f 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -261,11 +261,14 @@ export const buildRelRow = (rel: GraphRelationship): string => * No `name` column; blocks are identified by id + source span. Shared by the * whole-graph emit pass and the streaming PDG emit sink (issue #2202) so the * two paths produce byte-identical BasicBlock rows by construction. */ -export const BASICBLOCK_CSV_HEADER = 'id,filePath,startLine,endLine,text'; +export const BASICBLOCK_CSV_HEADER = 'id,filePath,startLine,endLine,text,callees'; /** Build the escaped CSV row (no trailing newline) for one BasicBlock node. * Single source of the BasicBlock row bytes — used by `streamAllCSVsToDisk` - * and by the streaming `PdgEmitSink` (issue #2202). */ + * and by the streaming `PdgEmitSink` (issue #2202). `callees` is a comma-free + * (space-joined) list of the leaf callee names invoked in the block — the + * statement-precise inter-procedural reach substrate (the field is itself a CSV + * cell, so the inner separator must NOT be a comma). */ export const buildBasicBlockRow = (node: GraphNode): string => [ escapeCSVField(node.id), @@ -273,6 +276,7 @@ export const buildBasicBlockRow = (node: GraphNode): string => escapeCSVNumber(node.properties.startLine, -1), escapeCSVNumber(node.properties.endLine, -1), escapeCSVField(node.properties.text || ''), + escapeCSVField(String(node.properties.callees ?? '')), ].join(','); export interface StreamedCSVResult { diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index f5ff27281..e36ae1a46 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -1311,8 +1311,9 @@ export const getCopyQuery = (table: NodeTableName, filePath: string): string => return `COPY ${t}(id, name, filePath, description) FROM "${filePath}" ${COPY_CSV_OPTS}`; } if (table === 'BasicBlock') { - // Taint/PDG substrate (issue #2080) — no name column. - return `COPY ${t}(id, filePath, startLine, endLine, text) FROM "${filePath}" ${COPY_CSV_OPTS}`; + // Taint/PDG substrate (issue #2080) — no name column. `callees` is the + // statement-precise inter-procedural reach substrate (space-joined leaf names). + return `COPY ${t}(id, filePath, startLine, endLine, text, callees) FROM "${filePath}" ${COPY_CSV_OPTS}`; } if (table === 'Method') { return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`; @@ -1368,7 +1369,7 @@ export const insertNodeToLbug = async ( query = `CREATE (n:Section {id: ${escapeValue(properties.id)}, name: ${escapeValue(properties.name)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, level: ${properties.level || 1}, content: ${escapeValue(properties.content || '')}${descPart}})`; } else if (label === 'BasicBlock') { // Taint/PDG substrate (issue #2080) — no name column. - query = `CREATE (n:BasicBlock {id: ${escapeValue(properties.id)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, text: ${escapeValue(properties.text || '')}})`; + query = `CREATE (n:BasicBlock {id: ${escapeValue(properties.id)}, filePath: ${escapeValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, text: ${escapeValue(properties.text || '')}, callees: ${escapeValue(properties.callees || '')}})`; } else if (TABLES_WITH_EXPORTED.has(label)) { const descPart = properties.description ? `, description: ${escapeValue(properties.description)}` @@ -1454,7 +1455,7 @@ export const batchInsertNodesToLbug = async ( query = `MERGE (n:Section {id: ${escapeValue(properties.id)}}) SET n.name = ${escapeValue(properties.name)}, n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.level = ${properties.level || 1}, n.content = ${escapeValue(properties.content || '')}${descPart}`; } else if (label === 'BasicBlock') { // Taint/PDG substrate (issue #2080) — no name column. - query = `MERGE (n:BasicBlock {id: ${escapeValue(properties.id)}}) SET n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.text = ${escapeValue(properties.text || '')}`; + query = `MERGE (n:BasicBlock {id: ${escapeValue(properties.id)}}) SET n.filePath = ${escapeValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.text = ${escapeValue(properties.text || '')}, n.callees = ${escapeValue(properties.callees || '')}`; } else if (TABLES_WITH_EXPORTED.has(label)) { const descPart = properties.description ? `, n.description = ${escapeValue(properties.description)}` diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 4c8cc7cc5..133243149 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -235,6 +235,7 @@ CREATE NODE TABLE BasicBlock ( startLine INT64, endLine INT64, text STRING, + callees STRING, PRIMARY KEY (id) )`; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index b7726bda4..d3de6e8d8 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -256,45 +256,28 @@ type PdgBridgeEvidence = Extract:`) proven reachable by the local PDG slice. */ - firstHopLineKeys?: ReadonlySet; - /** File containing the first-hop call sites for a line-seeded downstream slice. */ - targetFilePath?: string; -} - -function parseRelationSiteLine(relationId: unknown): number | undefined { - const id = String(relationId ?? ''); - if (!id) return undefined; - const parts = id.split(':'); - const maybeLine = Number(parts[parts.length - 2]); - const maybeCol = Number(parts[parts.length - 1]); - if (Number.isInteger(maybeLine) && maybeLine >= 1 && Number.isInteger(maybeCol)) { - return maybeLine; - } - const cobolLine = id.match(/:L(\d+)(?::|$)/i); - if (cobolLine) { - const line = Number(cobolLine[1]); - if (Number.isInteger(line) && line >= 1) return line; - } - return undefined; -} - -function lineKey(filePath: string | undefined, line: number | undefined): string | undefined { - if (!filePath || !Number.isInteger(line) || (line as number) < 1) return undefined; - return `${filePath}:${line}`; + /** + * Leaf callee names invoked in the criterion's dependence-slice blocks + * (`BasicBlock.callees`). A first-hop callee is "proven" statement-precise iff + * its name is in this set — i.e. it is actually called from a statement the + * changed line reaches, not merely somewhere in the whole function. Empty/absent + * ⇒ no statement slice to discriminate (upstream or whole-symbol) ⇒ the symbol + * graph is used as a compatibility bridge (all callgraph-bridge), preserving + * callgraph reach. + */ + sliceCalleeNames?: ReadonlySet; } function pdgBridgeEvidenceForImpact(input: { bridge: PdgBridgeOptions; depth: number; - relationId: unknown; + calleeName: unknown; inherited?: PdgBridgeEvidenceInfo; }): PdgBridgeEvidenceInfo { - const { bridge, depth, relationId, inherited } = input; + const { bridge, depth, calleeName, inherited } = input; if (depth > 1) { return ( inherited ?? { @@ -304,32 +287,25 @@ function pdgBridgeEvidenceForImpact(input: { ); } - const firstHopLineKeys = bridge.firstHopLineKeys; - if (!firstHopLineKeys || firstHopLineKeys.size === 0) { + const sliceCalleeNames = bridge.sliceCalleeNames; + if (!sliceCalleeNames || sliceCalleeNames.size === 0) { return { evidence: 'callgraph-bridge', basis: 'whole-symbol PDG result uses symbol graph as compatibility bridge', }; } - const siteLine = parseRelationSiteLine(relationId); - const siteKey = lineKey(bridge.targetFilePath, siteLine); - if (siteKey && firstHopLineKeys.has(siteKey)) { + const name = typeof calleeName === 'string' ? calleeName : ''; + if (name && sliceCalleeNames.has(name)) { return { evidence: 'callgraph-bridge', - basis: 'first-hop call site is in the local PDG statement slice', - site: { filePath: bridge.targetFilePath!, line: siteLine! }, + basis: 'callee is invoked in a block of the local PDG dependence slice', }; } return { evidence: 'unproven-bridge', - basis: siteLine - ? 'first-hop call site is not in the local PDG statement slice' - : 'first-hop call-site line is unavailable on the existing graph edge', - ...(siteLine && bridge.targetFilePath - ? { site: { filePath: bridge.targetFilePath, line: siteLine } } - : {}), + basis: 'callee is not invoked in any block of the local PDG dependence slice', }; } @@ -386,6 +362,37 @@ function dominantInterproceduralEvidence( return undefined; } +/** + * Statement-precise projection of the inter-procedural bridge: the subset PROVEN + * to be invoked from the criterion's dependence slice (`callgraph-bridge`), + * dropping `unproven-bridge` symbols — reachable in the call graph but only from + * statements the changed line does not reach. Additive: the full + * `interproceduralByDepth` is unchanged and still preserves callgraph reach; this + * answers the tighter "which other functions does changing THIS line affect?". + * For an upstream / whole-symbol seed there is no discriminating slice, so every + * symbol is `callgraph-bridge` and the projection equals the full reach + * (`statementPrecision` = 1). Name-based matching (a callee invoked from both an + * in-slice and out-of-slice site resolves to proven) makes the projected set a + * conservative SUPERSET of the strictly-proven subset. + */ +function projectStatementPreciseByDepth( + byDepth: Record, +): Record { + const out: Record = {}; + for (const [depthKey, items] of Object.entries(byDepth ?? {})) { + const depth = Number(depthKey); + if (!Number.isFinite(depth) || !Array.isArray(items)) continue; + const proven = items.filter( + (item) => + item && + typeof item === 'object' && + (item as { pdgEvidence?: unknown }).pdgEvidence !== 'unproven-bridge', + ); + if (proven.length > 0) out[depth] = proven; + } + return out; +} + /** Structured error logging for query failures — replaces empty catch blocks */ function logQueryError(context: string, err: unknown): void { const msg = err instanceof Error ? err.message : String(err); @@ -4764,21 +4771,18 @@ export class LocalBackend { executeParameterized, }); - const firstHopLineKeys = new Set(); - if (typeof (pdgResult as any).criterionLine === 'number') { - firstHopLineKeys.add(`${sym.filePath}:${(pdgResult as any).criterionLine}`); - } - for (const stmt of ((pdgResult as any).affectedStatements ?? []) as Array<{ - filePath?: string; - line?: number; - }>) { - const key = lineKey(stmt.filePath, stmt.line); - if (key) firstHopLineKeys.add(key); - } - const pdgBridge = - direction === 'downstream' && firstHopLineKeys.size > 0 - ? { firstHopLineKeys, targetFilePath: sym.filePath } - : undefined; + // 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 + // falls back to preserving callgraph reach. + const reachableBlocks = ((pdgResult as any).reachableBlocks ?? []) as string[]; + const sliceCalleeNames = + direction === 'downstream' && reachableBlocks.length > 0 + ? await this.calleesOfBlocks(repo, reachableBlocks) + : new Set(); + const pdgBridge: PdgBridgeOptions | undefined = + sliceCalleeNames.size > 0 ? { sliceCalleeNames } : undefined; try { const interproceduralResult = await this._runImpactBFS(repo, sym, symType, direction, { @@ -4808,6 +4812,35 @@ export class LocalBackend { }); } + /** + * Union of the leaf callee names invoked across a set of dependence-slice + * blocks (`BasicBlock.callees`, space-joined at emit). Drives statement-precise + * inter-procedural evidence: a first-hop callee reached from the criterion is + * "proven" (callgraph-bridge) iff its name is in this set, else unproven-bridge. + * Empty when the slice blocks call nothing or carry no harvested callees + * (non-TS/JS or synthetic ENTRY/EXIT blocks) — the bridge then preserves + * callgraph reach. A query failure is logged and degrades to empty (no proof), + * never throws (the inter-procedural reach is still returned). + */ + private async calleesOfBlocks(repo: RepoHandle, blockIds: string[]): Promise> { + const names = new Set(); + if (blockIds.length === 0) return names; + try { + const rows = await executeParameterized( + repo.lbugPath, + `MATCH (b:BasicBlock) WHERE b.id IN $ids RETURN b.callees AS callees`, + { ids: blockIds }, + ); + for (const r of rows as any[]) { + const raw = String(r.callees ?? r[0] ?? ''); + for (const n of raw.split(' ')) if (n) names.add(n); + } + } catch (e) { + logQueryError('impact:pdg-slice-callees', e); + } + return names; + } + /** * Delegates the PDG impact engine to `pdg-impact.ts`. * @@ -4855,6 +4888,20 @@ export class LocalBackend { const interproceduralByDepthCounts = interproceduralResult?.byDepthCounts ?? {}; const interproceduralEvidenceCounts = countPdgEvidence(interproceduralByDepth); const interproceduralEvidence = dominantInterproceduralEvidence(interproceduralEvidenceCounts); + // Additive statement-precise projection (see projectStatementPreciseByDepth): + // the proven subset of the inter-procedural reach. `interproceduralByDepth` + // above is unchanged and still preserves full callgraph reach. + const statementPreciseByDepth = projectStatementPreciseByDepth(interproceduralByDepth); + const statementPreciseByDepthCounts: Record = {}; + for (const [depthKey, items] of Object.entries(statementPreciseByDepth)) { + statementPreciseByDepthCounts[Number(depthKey)] = (items as unknown[]).length; + } + const provenBridgeCount = interproceduralEvidenceCounts['callgraph-bridge'] ?? 0; + const unprovenBridgeCount = interproceduralEvidenceCounts['unproven-bridge'] ?? 0; + const statementPrecision = + provenBridgeCount + unprovenBridgeCount > 0 + ? provenBridgeCount / (provenBridgeCount + unprovenBridgeCount) + : null; const byDepth: Record = {}; const byDepthCounts: Record = {}; const depthKeys = Array.from( @@ -4952,6 +4999,13 @@ export class LocalBackend { byDepthCounts, interproceduralByDepth, interproceduralByDepthCounts, + // Statement-precise (proven) inter-procedural reach — additive; tighter than + // `interproceduralByDepth` for a line-seeded downstream slice, equal to it + // otherwise. `statementPrecision` = |proven| / |proven + unproven|. + statementPreciseByDepth, + statementPreciseByDepthCounts, + statementPreciseImpactedCount: provenBridgeCount, + statementPrecision, affected_processes: affectedProcesses, affected_modules: affectedModules, byDepth, @@ -4975,6 +5029,10 @@ export class LocalBackend { byDepthCounts: interproceduralByDepthCounts, byDepth: interproceduralByDepth, evidenceCounts: interproceduralEvidenceCounts, + statementPreciseByDepth, + statementPreciseByDepthCounts, + statementPreciseImpactedCount: provenBridgeCount, + statementPrecision, partial, }, }; @@ -5281,12 +5339,12 @@ export class LocalBackend { nextFrontier.push(relId); const storedConfidence = rel.confidence ?? rel[6]; const relationType = rel.relType || rel[5]; - const relationId = rel.relationId ?? rel[7]; + const calleeName = rel.name || rel[2]; const bridgeEvidence = opts.pdgBridge ? pdgBridgeEvidenceForImpact({ bridge: opts.pdgBridge, depth, - relationId, + calleeName, inherited: pdgBridgeEvidenceById.get(sourceId), }) : undefined; @@ -5309,7 +5367,6 @@ export class LocalBackend { ? { pdgEvidence: bridgeEvidence.evidence, pdgBridgeBasis: bridgeEvidence.basis, - ...(bridgeEvidence.site ? { pdgBridgeSite: bridgeEvidence.site } : {}), } : {}), }); diff --git a/gitnexus/src/mcp/local/pdg-impact.ts b/gitnexus/src/mcp/local/pdg-impact.ts index 134495228..3c3746810 100644 --- a/gitnexus/src/mcp/local/pdg-impact.ts +++ b/gitnexus/src/mcp/local/pdg-impact.ts @@ -322,6 +322,16 @@ export interface PdgInterproceduralImpact { byDepthCounts: Record; byDepth: Record; evidenceCounts?: Partial>; + /** + * Statement-precise (proven) subset of `byDepth` — additive. Tighter than + * `byDepth` for a line-seeded downstream slice (drops `unproven-bridge` + * symbols not invoked from the dependence slice), equal to it otherwise. + * `statementPrecision` = |proven| / |reach| (null when there is no reach). + */ + statementPreciseByDepth?: Record; + statementPreciseByDepthCounts?: Record; + statementPreciseImpactedCount?: number; + statementPrecision?: number | null; partial: boolean; } @@ -338,6 +348,12 @@ export interface PdgImpactBaseResult extends PdgImpactParityFields { interproceduralEpistemic?: string; interproceduralBoundaries?: unknown[]; interproceduralError?: string; + // Statement-precise (proven) inter-procedural reach, surfaced at the top level + // alongside interproceduralByDepth (also nested under pdgInterprocedural). + statementPreciseByDepth?: Record; + statementPreciseByDepthCounts?: Record; + statementPreciseImpactedCount?: number; + statementPrecision?: number | null; pdgInterprocedural?: PdgInterproceduralImpact; pdgEvidence?: PdgImpactEvidenceSummary; } diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 9e39a6cc0..d733065fb 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -212,8 +212,11 @@ export interface RepoMeta { /** * Bumped whenever incremental-indexing invariants change incompatibly. + * v2: `BasicBlock.callees` column added (statement-precise inter-procedural + * reach substrate) — an index built before this lacks the column, so a full + * re-analyze is required rather than an incremental top-up. */ -export const INCREMENTAL_SCHEMA_VERSION = 1; +export const INCREMENTAL_SCHEMA_VERSION = 2; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index fd513071c..77ff4d4d5 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -1612,8 +1612,34 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { it("mode:'pdg' + downstream line:8 routes to the PDG traversal and seeds bridge evidence", async () => { resolveSingleTarget(); + // The target-resolution row doubles as the calleesOfBlocks row: `callees` + // ('callee') is the leaf name persisted on the slice's BasicBlock, the + // statement-precise substrate the bridge keys on. + (executeParameterized as any).mockResolvedValue([ + { + id: 'func:main', + name: 'main', + type: 'Function', + filePath: 'src/index.ts', + callees: 'callee', + }, + ]); + // A line-seeded downstream slice with one reachable block → the dispatch + // queries that block's callees and seeds the bridge with them. + const pdgSpy = 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: ['BasicBlock:src/index.ts:8:0:1'], + blockCount: 1, + affectedStatements: [{ line: 8, filePath: 'src/index.ts', text: 'callee()' }], + affectedStatementCount: 1, + criterionLine: 8, + }); const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); - const pdgSpy = vi.spyOn(backend as any, '_runImpactPDG'); const result = await backend.callTool('impact', { target: 'main', direction: 'downstream', @@ -1626,8 +1652,10 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { expect(pdgSpy).toHaveBeenCalledTimes(1); expect(bfsSpy).toHaveBeenCalledTimes(1); const bridge = bfsSpy.mock.calls[0][4].pdgBridge; - expect(bridge.targetFilePath).toBe('src/index.ts'); - expect([...bridge.firstHopLineKeys]).toContain('src/index.ts:8'); + // The bridge now carries the slice's callee names (statement-precise reach), + // resolved from BasicBlock.callees — not the dead call-site-line keys. + expect(bridge).toBeDefined(); + expect([...bridge.sliceCalleeNames]).toContain('callee'); expect(result.pdgInterprocedural).toBeDefined(); }); diff --git a/gitnexus/test/unit/impact-pdg-blast-radius-metrics.test.ts b/gitnexus/test/unit/impact-pdg-blast-radius-metrics.test.ts index da080dd8a..75f8efc51 100644 --- a/gitnexus/test/unit/impact-pdg-blast-radius-metrics.test.ts +++ b/gitnexus/test/unit/impact-pdg-blast-radius-metrics.test.ts @@ -46,6 +46,9 @@ describe('impact-pdg blast-radius metric helpers', () => { bodyBlocks: 20, sliceBlocks: 4, ratio: 0.2, + callgraphSymbols: 6, + statementPreciseSymbols: 3, + statementPrecision: 0.5, pdgOnly: 0, cgOnly: 0, callgraphMs: 100, @@ -55,6 +58,9 @@ describe('impact-pdg blast-radius metric helpers', () => { bodyBlocks: 16, sliceBlocks: 8, ratio: 0.5, + callgraphSymbols: 4, + statementPreciseSymbols: 4, + statementPrecision: 1, pdgOnly: 0, cgOnly: 0, callgraphMs: 80, @@ -64,6 +70,9 @@ describe('impact-pdg blast-radius metric helpers', () => { bodyBlocks: 10, sliceBlocks: 10, ratio: 1, + callgraphSymbols: 3, + statementPreciseSymbols: 1, + statementPrecision: 0.333, pdgOnly: 2, cgOnly: 1, callgraphMs: 60, @@ -91,6 +100,13 @@ describe('impact-pdg blast-radius metric helpers', () => { medianPdgMs: 120, medianPdgOverCallgraph: 1.5, }); + expect(summary.statementPrecise).toMatchObject({ + casesWithSlice: 3, + casesTighterThanCallgraph: 2, // 3<6 and 1<3; the 4==4 case does not narrow + medianStatementPrecision: 0.5, + medianPreciseSymbols: 3, + medianCallgraphSymbols: 4, + }); }); it('computes median deterministically (odd and even lengths)', () => {