mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
feat(impact): statement-precise inter-procedural reach (pdg)
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) <noreply@anthropic.com>
This commit is contained in:
parent
fc1a94f035
commit
af35c9a955
12 changed files with 274 additions and 81 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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++;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)}`
|
||||
|
|
|
|||
|
|
@ -235,6 +235,7 @@ CREATE NODE TABLE BasicBlock (
|
|||
startLine INT64,
|
||||
endLine INT64,
|
||||
text STRING,
|
||||
callees STRING,
|
||||
PRIMARY KEY (id)
|
||||
)`;
|
||||
|
||||
|
|
|
|||
|
|
@ -256,45 +256,28 @@ type PdgBridgeEvidence = Extract<PdgImpactEvidence, 'callgraph-bridge' | 'unprov
|
|||
interface PdgBridgeEvidenceInfo {
|
||||
evidence: PdgBridgeEvidence;
|
||||
basis: string;
|
||||
site?: { filePath: string; line: number };
|
||||
}
|
||||
|
||||
interface PdgBridgeOptions {
|
||||
/** Line keys (`<filePath>:<line>`) proven reachable by the local PDG slice. */
|
||||
firstHopLineKeys?: ReadonlySet<string>;
|
||||
/** 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<string>;
|
||||
}
|
||||
|
||||
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<number, unknown[]>,
|
||||
): Record<number, unknown[]> {
|
||||
const out: Record<number, unknown[]> = {};
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<Set<string>> {
|
||||
const names = new Set<string>();
|
||||
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<number, number> = {};
|
||||
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<number, unknown[]> = {};
|
||||
const byDepthCounts: Record<number, number> = {};
|
||||
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 } : {}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -322,6 +322,16 @@ export interface PdgInterproceduralImpact {
|
|||
byDepthCounts: Record<number, number>;
|
||||
byDepth: Record<number, unknown[]>;
|
||||
evidenceCounts?: Partial<Record<PdgImpactEvidence, number>>;
|
||||
/**
|
||||
* 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<number, unknown[]>;
|
||||
statementPreciseByDepthCounts?: Record<number, number>;
|
||||
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<number, unknown[]>;
|
||||
statementPreciseByDepthCounts?: Record<number, number>;
|
||||
statementPreciseImpactedCount?: number;
|
||||
statementPrecision?: number | null;
|
||||
pdgInterprocedural?: PdgInterproceduralImpact;
|
||||
pdgEvidence?: PdgImpactEvidenceSummary;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue