diff --git a/gitnexus/src/cli/eval-server.ts b/gitnexus/src/cli/eval-server.ts index 5402e96f7..b4c755776 100644 --- a/gitnexus/src/cli/eval-server.ts +++ b/gitnexus/src/cli/eval-server.ts @@ -264,6 +264,61 @@ export function formatImpactResult(result: any): string { ); } + // (2b) STATEMENT-ANCHORED SLICE (mode:'pdg' + line). When `criterionLine` is + // present the result is a statement slice: the seeded line plus the list of + // dependent statements (`affectedStatements: {line,filePath,text}[]`). Render + // those statements directly — this IS the useful output of statement mode — + // rather than the symbol-projection bucket below. Empty cases: + // - `pdg-no-block-at-line`: the line is blank / a comment / outside the + // body (no statement block) — print the steering note. + // - empty `affectedStatements` with `pdg-intra-procedural`: the line has no + // dependents in this direction — print the steering note. + // Each non-empty case also surfaces truncation honestly. + if (typeof result.criterionLine === 'number') { + const slice: any[] = Array.isArray(result.affectedStatements) + ? result.affectedStatements + : []; + const count = + typeof result.affectedStatementCount === 'number' + ? result.affectedStatementCount + : slice.length; + // File anchor for the heading — the seeded statement's file (every slice + // statement shares the function's file). Fall back to the target's file. + const anchorFile = slice[0]?.filePath || target?.filePath || name; + + if (count === 0 || slice.length === 0) { + // No statement block at the line, or no dependents in this direction. + // Print the honest note (pdg-no-block-at-line or the no-dependence note) + // verbatim — never an empty "isolated" headline. + return ( + `No statements ${direction}-dependent on ${anchorFile}:${result.criterionLine}.` + + (result.note ? `\n${result.note}` : '') + ); + } + + const slLines: string[] = []; + slLines.push( + `Statements ${direction}-dependent on ${anchorFile}:${result.criterionLine} (${count}):`, + ); + for (const s of slice) { + const text = typeof s.text === 'string' ? s.text : ''; + slLines.push(` L${s.line}: ${text}`); + } + // Truncation honesty — the slice may be a lower bound (depth or per-step + // LIMIT bound). Surface it the same way the symbol render does. + if (result.truncated) { + const by = result.truncatedBy ? ` (by ${result.truncatedBy})` : ''; + slLines.push( + `⚠️ Truncated${by} — the dependence slice was bounded; deeper PDG-dependent statements may exist.`, + ); + } + if (result.note) { + slLines.push(''); + slLines.push(`ℹ️ ${result.note}`); + } + return slLines.join('\n').trim(); + } + const items: any[] = (result.byDepth && result.byDepth[1]) || []; const bucketCount = result.byDepthCounts?.[1] ?? items.length; const pdgLines: string[] = []; diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index ee857c74d..72e4da365 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -343,6 +343,10 @@ program 'Engine: callgraph (default) or pdg (opt-in, intra-procedural; needs analyze --pdg)', 'callgraph', ) + .option( + '--line ', + '1-based source line — PDG-only statement anchor (--mode pdg): slice the dependence from the statement at this line and show what depends on it', + ) .option('-r, --repo ', 'Target repository') .option('--branch ', 'Scope to a specific branch index (multi-branch repos)') .option('-u, --uid ', 'Direct symbol UID (zero-ambiguity lookup)') diff --git a/gitnexus/src/cli/tool.ts b/gitnexus/src/cli/tool.ts index 130f909bf..34b633956 100644 --- a/gitnexus/src/cli/tool.ts +++ b/gitnexus/src/cli/tool.ts @@ -125,6 +125,7 @@ export async function impactCommand( options?: { direction?: string; mode?: string; + line?: string; repo?: string; branch?: string; uid?: string; @@ -163,6 +164,12 @@ export async function impactCommand( const rawOffset = parseInt(options?.offset ?? '', 10); const parsedLimit = Number.isFinite(rawLimit) ? rawLimit : undefined; const parsedOffset = Number.isFinite(rawOffset) ? rawOffset : undefined; + // `--line` is a PDG-only statement anchor (1-based source line). Parse it to + // an integer when provided and thread it ONLY when present, so the backend's + // line-without-pdg / non-positive-integer validation fires on the real value + // rather than on a silently-dropped flag. A non-numeric `--line` parses to + // NaN, which the backend rejects as a non-positive integer (loud, not silent). + const parsedLine = options?.line !== undefined ? parseInt(options.line, 10) : undefined; const result = await backend.callTool('impact', { target: target || undefined, target_uid: options?.uid, @@ -172,6 +179,8 @@ export async function impactCommand( // Forward the engine selector; backend validates the enum (callgraph/pdg) // and treats the default 'callgraph' identically to an omitted mode. mode: options?.mode, + // PDG-only statement anchor — forwarded only when --line was given. + ...(parsedLine !== undefined ? { line: parsedLine } : {}), maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined, includeTests: options?.includeTests ?? false, repo: options?.repo, diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 7def81066..94598383b 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -129,6 +129,50 @@ function fnFileOf(id: string): string { return parts.slice(1, parts.length - 3).join(':'); } +/** A reachable dependence block resolved to its source statement. */ +export interface PdgStatement { + /** 1-based source line where the statement's block starts. */ + line: number; + /** Repo-relative file path (parsed from the block id). */ + filePath: string; + /** The statement's source text (BasicBlock.text), trimmed. */ + text: string; +} + +/** + * Resolve a set of reachable BasicBlock ids to their source statements + * (line + text), deduped by `(filePath, line)` and sorted by line. This is the + * useful output of a statement-anchored PDG slice — the dependent statements the + * change reaches. A query error propagates (no `.catch` swallow) so a DB failure + * is never silently reported as "no affected statements". + */ +async function pdgStatementsForBlocks( + lbugPath: string, + blockIds: string[], + exec: typeof executeParameterized, +): Promise { + if (blockIds.length === 0) return []; + const rows = await exec( + lbugPath, + `MATCH (b:BasicBlock) WHERE b.id IN $ids + RETURN b.id AS id, b.startLine AS line, b.text AS text`, + { ids: blockIds }, + ); + const byKey = new Map(); + for (const r of rows as any[]) { + const id = String(r.id ?? r[0] ?? ''); + const line = Number(r.line ?? r[1] ?? 0); + if (!id || !Number.isFinite(line) || line <= 0) continue; + const filePath = fnFileOf(id); + const text = String(r.text ?? r[2] ?? '').trim(); + const key = `${filePath}:${line}`; + if (!byKey.has(key)) byKey.set(key, { line, filePath, text }); + } + return [...byKey.values()].sort((a, b) => + a.filePath === b.filePath ? a.line - b.line : a.filePath < b.filePath ? -1 : 1, + ); +} + // ── Block → owning-symbol projection types (U4) ────────────────────────────── /** @@ -357,6 +401,10 @@ function assemblePdgImpactResult(input: { target: { id: string; name: string; type: string; filePath: string }; direction: 'upstream' | 'downstream'; reachableBlocks: 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). */ + criterionLine?: number; projection: { symbols: OwningSymbol[]; unresolvedCount: number; ambiguousCount: number }; depthReached: number; truncated: boolean; @@ -364,6 +412,8 @@ function assemblePdgImpactResult(input: { }): Record { const { target, direction, reachableBlocks, projection } = input; const { symbols, unresolvedCount, ambiguousCount } = projection; + const affectedStatements = input.affectedStatements ?? []; + const statementMode = typeof input.criterionLine === 'number'; // Items for the single collapsed bucket. Shaped like the call-graph byDepth // items (`{ depth, id, name, type, filePath, processes }`) so consumers that @@ -390,13 +440,21 @@ function assemblePdgImpactResult(input: { const byDepth: Record = items.length > 0 ? { 1: items } : {}; const byDepthCounts: Record = { 1: items.length }; - const noteParts: string[] = [ - `mode:'pdg' — intra-procedural Program Dependence Graph. ${impactedCount} owning ` + - `${impactedCount === 1 ? 'symbol' : 'symbols'} reached via ${reachableBlocks.length} ` + - `dependence ${reachableBlocks.length === 1 ? 'block' : 'blocks'} ` + - `(${direction} over CDG + REACHING_DEF). Cross-function (inter-procedural) impact is ` + - `NOT modeled in this mode — use mode:'callgraph' for the call-graph blast radius.`, - ]; + const noteParts: string[] = statementMode + ? [ + `mode:'pdg' — intra-procedural slice from line ${input.criterionLine} of ` + + `'${target.name}'. ${affectedStatements.length} ` + + `${affectedStatements.length === 1 ? 'statement is' : 'statements are'} ${direction}-` + + `dependent on it (over CDG + REACHING_DEF). Cross-function (inter-procedural) impact ` + + `is NOT modeled in this mode — use mode:'callgraph' for the call-graph blast radius.`, + ] + : [ + `mode:'pdg' — intra-procedural Program Dependence Graph. ${impactedCount} owning ` + + `${impactedCount === 1 ? 'symbol' : 'symbols'} reached via ${reachableBlocks.length} ` + + `dependence ${reachableBlocks.length === 1 ? 'block' : 'blocks'} ` + + `(${direction} over CDG + REACHING_DEF). Cross-function (inter-procedural) impact is ` + + `NOT modeled in this mode — use mode:'callgraph' for the call-graph blast radius.`, + ]; if (ambiguousCount > 0) { noteParts.push( `${ambiguousCount} owning-symbol ${ambiguousCount === 1 ? 'projection is' : 'projections are'} ` + @@ -425,6 +483,12 @@ function assemblePdgImpactResult(input: { // PDG-specific epistemic marker — NOT the callgraph 'lower-bound'/DI copy. epistemic: 'pdg-intra-procedural', note: noteParts.join(' '), + // Statement-level slice: the dependent source statements (line + text) the + // change reaches. This is the primary useful output of statement mode; the + // accuracy harness scores against these lines. + ...(statementMode ? { criterionLine: input.criterionLine } : {}), + affectedStatements, + affectedStatementCount: affectedStatements.length, // Raw block-level detail retained alongside the symbol projection (U3 tests // and the accuracy harness read these). reachableBlocks, @@ -980,6 +1044,15 @@ interface ImpactParams { * Validated in `_impactImpl`; any other value is a hard `{ error }`. */ mode?: ImpactMode; + /** + * Statement anchor for `mode:'pdg'` (1-based source line). When provided, the + * PDG traversal seeds the dependence slice on the BasicBlock(s) at THIS line + * within the target symbol — answering "what statements depend on the code at + * line N?" — instead of the whole-symbol seed (which is empty for a function, + * since its intra-procedural reach stays inside its own blocks). Only + * meaningful with `mode:'pdg'`; rejected for `mode:'callgraph'`. + */ + line?: number; maxDepth?: number; crossDepth?: number; relationTypes?: string[]; @@ -3505,6 +3578,40 @@ export class LocalBackend { return { anchorClause: 'a.id STARTS WITH $idPrefix', queryParams: { idPrefix } }; } + /** + * Build a STATEMENT seed anchor: the BasicBlock(s) starting at a specific + * 1-based source `line` WITHIN the resolved symbol. This is what makes + * `mode:'pdg'` useful — seeding the dependence slice on a single statement + * (the thing being changed) rather than the whole symbol. A whole-symbol seed + * captures every intra-procedural block, so the reachable-minus-seed set is + * empty (all intra reach is within the seed); a statement seed leaves the + * other dependent statements reachable. `BasicBlock.startLine` is 1-based and + * matches the source line, so no `+1` offset applies here (unlike the symbol + * span, where the 0-based symbol bounds are shifted). Bounded to the symbol's + * own span when known, so a line shared with a sibling symbol can't leak. + */ + private blockAnchorForStatement( + sym: { filePath: string; startLine?: number; endLine?: number }, + line: number, + ): { anchorClause: string; queryParams: Record } { + const idPrefix = `BasicBlock:${sym.filePath}:`; + if ( + typeof sym.startLine === 'number' && + typeof sym.endLine === 'number' && + sym.endLine >= sym.startLine + ) { + return { + anchorClause: + 'a.id STARTS WITH $idPrefix AND a.startLine = $line AND a.startLine >= $symStart AND a.startLine <= $symEnd', + queryParams: { idPrefix, line, symStart: sym.startLine + 1, symEnd: sym.endLine + 1 }, + }; + } + return { + anchorClause: 'a.id STARTS WITH $idPrefix AND a.startLine = $line', + queryParams: { idPrefix, line }, + }; + } + /** * Explain tool (#2083 M3 U6) — persisted taint-finding explanation. * WAL-aware wrapper mirroring `context`. @@ -4843,6 +4950,31 @@ export class LocalBackend { } const mode = modeResult.mode; + // `line` is a PDG-only statement anchor. Reject it on the callgraph path + // rather than silently ignore (the symbol→symbol BFS has no statement notion). + if (params.line !== undefined && mode !== 'pdg') { + return { + error: `Parameter 'line' is only supported with mode:'pdg' (it anchors the dependence slice on a statement). Remove it or set mode:'pdg'.`, + target: { name: params.target }, + direction: params.direction, + impactedCount: 0, + risk: 'UNKNOWN', + }; + } + // A provided `line` must be a positive integer. + if ( + params.line !== undefined && + (!Number.isInteger(params.line) || (params.line as number) < 1) + ) { + return { + error: `Parameter 'line' must be a positive integer (1-based source line), got ${JSON.stringify(params.line)}.`, + target: { name: params.target }, + direction: params.direction, + impactedCount: 0, + risk: 'UNKNOWN', + }; + } + if (mode === 'pdg') { // KTD12 — param-compatibility hard rejections (decided as errors, NOT // silent ignores and NOT an `ignoredParams` echo). Each names a symbol- @@ -5143,6 +5275,7 @@ export class LocalBackend { symType, direction, maxDepth, + line: params.line, limit: Number.isFinite(params.limit) ? params.limit : 100, // KTD2 extraction-seam discipline: hand the engine its DB dependency // explicitly rather than `this.`-binding it, so the traversal (U3/U4) @@ -5212,9 +5345,15 @@ export class LocalBackend { direction: 'upstream' | 'downstream'; maxDepth: number; limit: number; + /** Statement anchor (1-based source line) — see ImpactParams.line. */ + line?: number; executeParameterized: typeof executeParameterized; }): Promise { - const { repo, sym, direction, maxDepth, executeParameterized: exec } = deps; + const { repo, sym, direction, maxDepth, line, executeParameterized: exec } = deps; + // `line` present ⇒ statement-anchored slice (the useful mode); absent ⇒ + // whole-symbol seed (intra-procedural reach collapses to empty for a + // function — kept for back-compat, with a note steering the caller to `line`). + const statementMode = typeof line === 'number' && Number.isInteger(line) && line >= 1; // `target` carries the call-graph-compatible shape (id/name/type/filePath) so // `collectImpactSymbolUids` keys on it identically to a callgraph result. const target = { @@ -5247,7 +5386,9 @@ export class LocalBackend { // So build the seed anchor DIRECTLY from the resolved symbol's // [startLine+1, endLine+1] window — the same window `resolveBlockAnchor`'s // symbol branch produces, without re-running `resolveSymbolCandidates`. - const { anchorClause, queryParams } = this.blockAnchorForResolvedSymbol(sym); + const { anchorClause, queryParams } = statementMode + ? this.blockAnchorForStatement(sym, line as number) + : this.blockAnchorForResolvedSymbol(sym); const seedRows = await exec( repo.lbugPath, @@ -5274,18 +5415,27 @@ export class LocalBackend { mode: 'pdg', target, direction, + ...(statementMode ? { criterionLine: line } : {}), reachableBlocks: [], blockCount: 0, + affectedStatements: [], + affectedStatementCount: 0, truncated: false, depthReached: 0, - // "No PDG body" — structurally no CFG/blocks for this symbol kind. - epistemic: 'no-pdg-body', - note: - `'${sym.name}' has no PDG body — no BasicBlocks / control- or data-dependence ` + - `edges exist for this symbol (e.g. an interface, type alias, abstract/ambient ` + - `member, or a one-line declaration with no CFG). This is NOT a confident ` + - `"no impact": the intra-procedural PDG mode cannot model this symbol kind. ` + - `Use mode:'callgraph' for its inter-procedural blast radius.`, + // statementMode: the requested line has no statement block inside the + // symbol (blank line, comment, outside the body, or a line the CFG did + // not materialise). Distinct from "no PDG body". + epistemic: statementMode ? 'pdg-no-block-at-line' : 'no-pdg-body', + note: statementMode + ? `No PDG statement block starts at line ${line} within '${sym.name}' ` + + `(${sym.filePath}). The line may be blank, a comment, a brace, or outside ` + + `the symbol's body. Pass a line that begins an executable statement.` + : `'${sym.name}' has no PDG body — no BasicBlocks / control- or data-dependence ` + + `edges exist for this symbol (e.g. an interface, type alias, abstract/ambient ` + + `member, or a one-line declaration with no CFG). This is NOT a confident ` + + `"no impact": the intra-procedural PDG mode cannot model this symbol kind. ` + + `Pass line: to slice from a statement, or use mode:'callgraph' for the ` + + `inter-procedural blast radius.`, impactedCount: 0, risk: 'UNKNOWN', // KTD8 parity fields so a consumer iterating byDepth / reading the @@ -5360,24 +5510,41 @@ export class LocalBackend { ? 'limit' : undefined; + // ── Resolve the reachable blocks to source statements (line + text) ──────── + // This is the useful output of statement mode: the dependent statements the + // change at `line` reaches. Fetched once for the whole reachable set; sorted + // by line. Failure surfaces (no `.catch` swallow) rather than masquerading + // as "no affected statements". + const affectedStatements = await pdgStatementsForBlocks(repo.lbugPath, reachableBlocks, exec); + // ── Has a PDG body but no intra-procedural dependence reachability ───────── // Distinct from "no PDG body": the function exists and has blocks, but no - // CDG/REACHING_DEF edge leaves the target's blocks in this direction. Still - // not a confident zero — surface the explicit note + UNKNOWN (KTD6/KTD8). + // CDG/REACHING_DEF edge leaves the target's blocks in this direction. For a + // WHOLE-SYMBOL seed this is the expected (and uninformative) result — every + // intra-procedural block is already a seed — so the note steers to `line`. + // Still not a confident zero — explicit note + UNKNOWN (KTD6/KTD8). if (reachableBlocks.length === 0) { return { mode: 'pdg', target, direction, + ...(statementMode ? { criterionLine: line } : {}), impactedCount: 0, risk: 'UNKNOWN', epistemic: 'pdg-intra-procedural', - note: - `'${sym.name}' has a PDG body but no intra-procedural ${direction} dependence edges ` + - `(no CDG/REACHING_DEF reachability from its blocks). This is distinct from "no PDG body". ` + - `Use mode:'callgraph' for its inter-procedural blast radius.`, + note: statementMode + ? `No statement in '${sym.name}' is ${direction}-dependent on line ${line} ` + + `(no CDG/REACHING_DEF reachability from that statement). The line may have no ` + + `dependents in this direction.` + : `'${sym.name}' has a PDG body but a WHOLE-SYMBOL ${direction} slice is empty: ` + + `intra-procedural dependence stays inside the function, so every reachable block ` + + `is already part of the seed. Pass line: to slice from a specific statement ` + + `(what depends on the code at that line), or use mode:'callgraph' for the ` + + `inter-procedural blast radius.`, reachableBlocks: [] as string[], blockCount: 0, + affectedStatements: [], + affectedStatementCount: 0, depthReached, unresolvedBlockCount: 0, ambiguousProjectionCount: 0, @@ -5403,6 +5570,8 @@ export class LocalBackend { }, direction, reachableBlocks, + affectedStatements, + criterionLine: statementMode ? (line as number) : undefined, projection, depthReached, truncated, diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index d481d4939..bd7c621dd 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -411,6 +411,8 @@ Returns affected symbols grouped by depth, plus risk assessment, affected execut MODE (opt-in): "callgraph" (default) walks symbol→symbol edges (CALLS/IMPORTS/EXTENDS/IMPLEMENTS) — inter-procedural, the established behavior. "pdg" computes the blast radius from the persisted Program Dependence Graph (control + data dependence) — finer-grained WITHIN a function but intra-procedural, and requires an index built with \`gitnexus analyze --pdg\`. The two modes answer the same question with different engines; pdg is incompatible with relationTypes/crossDepth/minConfidence and with @group targets (each rejected). +STATEMENT-ANCHORED PDG SLICE: with mode:'pdg', pass "line" (1-based source line within the target symbol) to seed the dependence slice on the statement at that line and return what depends on it — the dependent statements (line + text), not the whole-symbol set. Without "line", a whole-symbol pdg slice is structurally empty (intra-procedural reach stays inside the function), so "line" is what makes pdg mode useful. + WHEN TO USE: Before making code changes — especially refactoring, renaming, or modifying shared code. Shows what would break. AFTER THIS: Review d=1 items (WILL BREAK). Use context() on high-risk symbols. @@ -459,6 +461,12 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep description: "Blast-radius engine. 'callgraph' (default) = inter-procedural symbol→symbol traversal (current behavior). 'pdg' = opt-in, intra-procedural Program Dependence Graph traversal (control + data dependence); requires an index built with `gitnexus analyze --pdg`. The pdg mode is incompatible with relationTypes/crossDepth/minConfidence and with @group targets — each is rejected, not silently ignored.", }, + line: { + type: 'integer', + minimum: 1, + description: + "1-based source line — PDG-only statement anchor (mode:'pdg'). Seeds the dependence slice on the statement at this line and returns what depends on it. Without it, a whole-symbol pdg slice is empty (intra-procedural reach stays inside the function).", + }, file_path: { type: 'string', description: 'File path hint to disambiguate common names', diff --git a/gitnexus/test/integration/impact-pdg-shape.test.ts b/gitnexus/test/integration/impact-pdg-shape.test.ts index dd899659c..dc925b944 100644 --- a/gitnexus/test/integration/impact-pdg-shape.test.ts +++ b/gitnexus/test/integration/impact-pdg-shape.test.ts @@ -194,6 +194,58 @@ withTestLbugDB( }); }); + // ── Statement-mode result shape (criterionLine + slice + KTD8 parity) ───── + describe('statement-mode result carries the slice fields AND the KTD8 parity fields', () => { + it('a line-seeded result has criterionLine/affectedStatements/affectedStatementCount', async () => { + const result = await backend.callTool('impact', { + target: 'accum', + direction: 'downstream', + mode: 'pdg', + line: 72, + }); + expect(result.error).toBeUndefined(); + expect(result.mode).toBe('pdg'); + // Statement-mode-specific fields. + expect(result.criterionLine).toBe(72); + expect(Array.isArray(result.affectedStatements)).toBe(true); + expect(result.affectedStatementCount).toBe(result.affectedStatements.length); + expect(result.affectedStatementCount).toBe(2); + for (const s of result.affectedStatements) { + expect(s).toHaveProperty('line'); + expect(s).toHaveProperty('filePath'); + expect(s).toHaveProperty('text'); + } + // The slice statements are the downstream-dependent ones (lines 10, 12). + const lines = (result.affectedStatements as any[]).map((s) => s.line).sort((a, b) => a - b); + expect(lines).toEqual([74, 76]); + }); + + it('the statement-mode result ALSO carries the KTD8 parity fields (byDepth/target/risk/empty processes-modules)', async () => { + const result = await backend.callTool('impact', { + target: 'accum', + direction: 'downstream', + mode: 'pdg', + line: 72, + }); + // byDepth is the single collapsed bucket (block-hops ≠ call-hops). + expect(Object.keys(result.byDepth)).toEqual(['1']); + expect(result.byDepthCounts['1']).toBe(result.byDepth['1'].length); + // target carries the call-graph-compatible shape. + expect(result.target.id).toBe('func:accum'); + expect(result.target.name).toBe('accum'); + expect(result.target.filePath).toBe(F); + expect(typeof result.target.type).toBe('string'); + // risk is the UNKNOWN sentinel (never a confident LOW). + expect(result.risk).toBe('UNKNOWN'); + expect(result.risk).not.toBe('LOW'); + // Empty processes/modules — consumers coalesce []. + expect(result.affected_processes).toEqual([]); + expect(result.affected_modules).toEqual([]); + expect(result.summary.processes_affected).toBe(0); + expect(result.summary.modules_affected).toBe(0); + }); + }); + // ── Consumer safety: group cross-impact treats the PDG result correctly ─── describe('group consumer safety (cross-impact.ts)', () => { it('collectImpactSymbolUids collects the PDG owning-symbol UIDs (non-zero)', async () => { @@ -423,6 +475,27 @@ withTestLbugDB( await edge('CDG', K2, T, 'T'); await edge('CDG', T, U, 'T'); + // ── Statement-anchored fixture `accum` (mode:'pdg' + line) ──────────────── + // Self-contained multi-statement fn at 0-based [70,80] (window [71,81]); a + // line range that does NOT overlap S@11 / D@21,22 / K@31,32 above, so its + // whole-symbol seed cannot pick up an unrelated block. Every dependence + // stays inside it, so a STATEMENT seed slices the dependent statements. + // 71: let sum = 0; (A) 72: for (…) { (B, criterion) + // 74: sum = sum + x; (C) 76: return sum; (D) + // CDG B→C; RD A→C, C→D ⇒ downstream from line 72 = {C@74, D@76}. + await fn('func:accum', 'accum', 70, 80); + const AccA = `BasicBlock:${F}:71:0:0`; // line 71 + const AccB = `BasicBlock:${F}:71:0:1`; // line 72 + const AccC = `BasicBlock:${F}:71:0:2`; // line 74 + const AccD = `BasicBlock:${F}:71:0:3`; // line 76 + await block(AccA, 71, 'let sum = 0;'); + await block(AccB, 72, 'for (const x of xs) {'); + await block(AccC, 74, 'sum = sum + x;'); + await block(AccD, 76, 'return sum;'); + await edge('CDG', AccB, AccC, 'loop'); + await edge('REACHING_DEF', AccA, AccC, 'sum'); + await edge('REACHING_DEF', AccC, AccD, 'sum'); + // ── Windows drive-colon path fixture (exercises fnFileOf split-from-right) ─ // Separate file `C:/src/win.ts`, isolated from `target`'s graph so the // existing impactedCount/byDepth assertions are untouched. `winFn` is its diff --git a/gitnexus/test/integration/impact-pdg-traversal.test.ts b/gitnexus/test/integration/impact-pdg-traversal.test.ts index 0afa96ba4..8d351b7f3 100644 --- a/gitnexus/test/integration/impact-pdg-traversal.test.ts +++ b/gitnexus/test/integration/impact-pdg-traversal.test.ts @@ -163,6 +163,89 @@ withTestLbugDB( }); }); + // Helper: the slice statements' lines (sorted) for an `accum` line-seeded call. + const sliceLines = (result: any): number[] => + [...((result?.affectedStatements as any[]) ?? [])].map((s) => s.line).sort((a, b) => a - b); + + describe('statement-anchored seed (mode:pdg + line)', () => { + it('downstream from line 72 returns exactly the statements dependent on it (NOT the whole symbol)', async () => { + const result = await backend.callTool('impact', { + target: 'accum', + direction: 'downstream', + mode: 'pdg', + line: 72, + }); + expect(result.error).toBeUndefined(); + expect(result.mode).toBe('pdg'); + expect(result.criterionLine).toBe(72); + // line 72 (the loop header B) controls C@74, whose sum flows to D@76. + expect(sliceLines(result)).toEqual([74, 76]); + expect(result.affectedStatementCount).toBe(2); + // The dependent statements carry the real source line + text. + const byLine = new Map((result.affectedStatements as any[]).map((s) => [s.line, s])); + expect(byLine.get(74).text).toBe('sum = sum + x;'); + expect(byLine.get(74).filePath).toBe(F); + expect(byLine.get(76).text).toBe('return sum;'); + // It is NOT the whole-symbol set — line 71 (the def above the criterion) is + // upstream of the criterion, not downstream of it, so it must be absent. + expect(sliceLines(result)).not.toContain(71); + }); + + it('upstream from line 74 returns the statements line 74 depends on (the def + the controller)', async () => { + const result = await backend.callTool('impact', { + target: 'accum', + direction: 'upstream', + mode: 'pdg', + line: 74, + }); + expect(result.error).toBeUndefined(); + expect(result.criterionLine).toBe(74); + // C@74 depends on A@71 (RD def of sum) and B@72 (CDG controller). + expect(sliceLines(result)).toEqual([71, 72]); + expect(result.affectedStatementCount).toBe(2); + // NOT the whole-symbol set — D@76 is downstream of line 74, never upstream. + expect(sliceLines(result)).not.toContain(76); + }); + + it('whole-symbol (no line) is empty and steers the caller to line:', async () => { + // `accum`'s entire dependence stays inside its own [7,13] window, so a + // whole-symbol seed reaches nothing (every block is a co-seed) — the + // structurally-empty WHOLE-SYMBOL case. + const result = await backend.callTool('impact', { + target: 'accum', + direction: 'downstream', + mode: 'pdg', + }); + expect(result.error).toBeUndefined(); + expect(result.mode).toBe('pdg'); + // No criterionLine (whole-symbol mode), an empty slice, and the steering note. + expect(result.criterionLine).toBeUndefined(); + expect(result.affectedStatements).toEqual([]); + expect(result.affectedStatementCount).toBe(0); + expect(result.note).toMatch(/WHOLE-SYMBOL/); + expect(result.note).toMatch(/line:|Pass line/i); + // Still never a confident "safe" zero. + expect(result.risk).not.toBe('LOW'); + }); + + it('a line with no statement block → epistemic pdg-no-block-at-line (distinct from no-pdg-body)', async () => { + const result = await backend.callTool('impact', { + target: 'accum', + direction: 'downstream', + mode: 'pdg', + line: 73, // blank line inside accum — no block starts here + }); + expect(result.error).toBeUndefined(); + expect(result.mode).toBe('pdg'); + expect(result.criterionLine).toBe(73); + // Distinct from the no-PDG-body epistemic (the line has no statement block). + expect(result.epistemic).toBe('pdg-no-block-at-line'); + expect(result.epistemic).not.toBe('no-pdg-body'); + expect(result.affectedStatements).toEqual([]); + expect(result.risk).not.toBe('LOW'); + }); + }); + describe('truncation signalling', () => { it('maxDepth=1 truncates the chain and flags truncated (not silently short)', async () => { const result = await backend.callTool('impact', { @@ -327,6 +410,37 @@ withTestLbugDB( await edge('CDG', S, K1, 'T'); await edge('CDG', K1, K2, 'T'); + // ── Statement-anchored fixture `accum` (mode:'pdg' + line) ──────────────── + // A SELF-CONTAINED multi-statement function whose every dependence stays + // inside its own [71,81] window — so a WHOLE-SYMBOL seed reaches nothing + // (every block is a co-seed) while a STATEMENT seed (line N) yields exactly + // the statements dependent on line N. Lives in a line range that does NOT + // overlap the `target`/`up`/`down`/`ctl` blocks above, so its whole-symbol + // seed cannot pick up an unrelated block (the window is `[startLine+1, + // endLine+1]`). Mirrors the accumulator idiom: + // 71: let sum = 0; (A — RD def of sum) + // 72: for (const x of xs) { (B — CDG controller of the loop body) + // 73: (blank — NO block, the no-block-at-line case) + // 74: sum = sum + x; (C — accumulate; controlled by B, uses A) + // 76: return sum; (D — RD use of C's sum def) + // Edges (all intra-`accum`): + // CDG: B(72) → C(74) the loop controls the accumulate body + // RD: A(71) → C(74) sum's initial def reaches the accumulate use + // RD: C(74) → D(76) the accumulated sum flows to the return + // ⇒ downstream from line 72 = {C@74, D@76}; upstream from line 74 = {A@71, B@72}. + await fn('func:accum', 'accum', 70, 80); // window [71,81] + const AccA = `BasicBlock:${F}:71:0:0`; // line 71 + const AccB = `BasicBlock:${F}:71:0:1`; // line 72 (same fn, distinct blockIdx) + const AccC = `BasicBlock:${F}:71:0:2`; // line 74 + const AccD = `BasicBlock:${F}:71:0:3`; // line 76 + await block(AccA, 71, 'let sum = 0;'); + await block(AccB, 72, 'for (const x of xs) {'); + await block(AccC, 74, 'sum = sum + x;'); + await block(AccD, 76, 'return sum;'); + await edge('CDG', AccB, AccC, 'loop'); + await edge('REACHING_DEF', AccA, AccC, 'sum'); + await edge('REACHING_DEF', AccC, AccD, 'sum'); + vi.mocked(listRegisteredRepos).mockResolvedValue([ { name: 'traversal-repo', diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index 6ebf30341..848bdb11c 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -1496,6 +1496,60 @@ describe('LocalBackend impact mode (KTD1/KTD5/KTD12)', () => { }, ); + it.each([['callgraph'], [undefined]])( + 'line param with mode:%j → structured {error} (line is PDG-only), never a callgraph result', + async (mode) => { + resolveSingleTarget(); + const bfsSpy = vi.spyOn(backend as any, '_runImpactBFS'); + const result = await backend.callTool('impact', { + target: 'main', + direction: 'upstream', + mode: mode as any, + line: 8, + }); + expect(result.error).toMatch(/'line' is only supported with mode:'pdg'/); + expect(result.risk).toBe('UNKNOWN'); + // A PDG-only param on the callgraph path must NOT silently run the BFS. + expect(bfsSpy).not.toHaveBeenCalled(); + }, + ); + + it.each([[0], [-1], [1.5]])( + "mode:'pdg' + non-positive-integer line %j → structured {error}, never routed to traversal", + async (badLine) => { + resolveSingleTarget(); + const pdgSpy = vi.spyOn(backend as any, '_runImpactPDG'); + const result = await backend.callTool('impact', { + target: 'main', + direction: 'upstream', + mode: 'pdg', + line: badLine as any, + }); + expect(result.error).toMatch(/'line' must be a positive integer/); + expect(result.risk).toBe('UNKNOWN'); + // The validation fires BEFORE the traversal — a bad line never seeds a slice. + expect(pdgSpy).not.toHaveBeenCalled(); + }, + ); + + it("mode:'pdg' + line:8 routes to the PDG traversal (no validation error, never the BFS)", async () => { + resolveSingleTarget(); + 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: 'upstream', + mode: 'pdg', + line: 8, + }); + // A valid line routes cleanly into the PDG engine — no line/mode error. + expect(result.error).toBeUndefined(); + expect(result.mode).toBe('pdg'); + expect(pdgSpy).toHaveBeenCalledTimes(1); + // KTD5: the callgraph engine never runs under a pdg + line call. + expect(bfsSpy).not.toHaveBeenCalled(); + }); + it.each([ ['relationTypes', { relationTypes: ['CALLS'] }], ['crossDepth', { crossDepth: 2 }], diff --git a/gitnexus/test/unit/cli-impact-pdg-format.test.ts b/gitnexus/test/unit/cli-impact-pdg-format.test.ts index 09f85584c..6104147db 100644 --- a/gitnexus/test/unit/cli-impact-pdg-format.test.ts +++ b/gitnexus/test/unit/cli-impact-pdg-format.test.ts @@ -214,8 +214,10 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => { expect(out).not.toContain('No downstream dependencies found'); }); - it('renders has-body-but-no-dependence as not-isolated, with the cross-function caveat', () => { - // `_runImpactPDG` reachableBlocks.length === 0 path (body exists, no edges). + it('renders whole-symbol-empty as not-isolated, steering the caller to line:', () => { + // `_runImpactPDG` reachableBlocks.length === 0 path WITHOUT a line (whole- + // symbol seed). The note now frames it as a structurally-empty WHOLE-SYMBOL + // slice and steers to `line:` (the useful statement-anchored mode). const out = formatImpactResult({ mode: 'pdg', target: { @@ -229,11 +231,15 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => { risk: 'UNKNOWN', epistemic: 'pdg-intra-procedural', note: - "'noop' has a PDG body but no intra-procedural downstream dependence edges " + - '(no CDG/REACHING_DEF reachability from its blocks). This is distinct from "no PDG body". ' + - "Use mode:'callgraph' for its inter-procedural blast radius.", + "'noop' has a PDG body but a WHOLE-SYMBOL downstream slice is empty: " + + 'intra-procedural dependence stays inside the function, so every reachable block ' + + 'is already part of the seed. Pass line: to slice from a specific statement ' + + "(what depends on the code at that line), or use mode:'callgraph' for the " + + 'inter-procedural blast radius.', reachableBlocks: [], blockCount: 0, + affectedStatements: [], + affectedStatementCount: 0, depthReached: 1, unresolvedBlockCount: 0, ambiguousProjectionCount: 0, @@ -244,12 +250,114 @@ describe('formatImpactResult — PDG (mode:pdg) rendering', () => { affected_modules: [], }); expect(out).toContain('no intra-procedural PDG-dependent symbols'); + // The new note steers to the statement-anchored mode. + expect(out).toContain('WHOLE-SYMBOL'); + expect(out).toMatch(/line:/); // The caveat may reference the word "isolated" to disclaim it, but the // confident callgraph "appears isolated." headline must be absent. expect(out).not.toContain('appears isolated'); expect(out).not.toContain('No downstream dependencies found'); expect(out.toLowerCase()).toContain('cross-function'); }); + + // ── Statement-anchored (mode:'pdg' + line) rendering ────────────────────── + // A representative statement-mode result, shaped like `assemblePdgImpactResult` + // emits when seeded on a line: criterionLine + affectedStatements + count. + function pdgStatementSlice(overrides: Record = {}): Record { + return { + mode: 'pdg', + target: { + id: 'Function:src/svc.ts:accum', + name: 'accum', + type: 'Function', + filePath: 'src/svc.ts', + }, + direction: 'downstream', + criterionLine: 8, + affectedStatements: [ + { line: 10, filePath: 'src/svc.ts', text: 'sum = sum + x;' }, + { line: 12, filePath: 'src/svc.ts', text: 'return sum;' }, + ], + affectedStatementCount: 2, + impactedCount: 1, + risk: 'UNKNOWN', + epistemic: 'pdg-intra-procedural', + note: + "mode:'pdg' — intra-procedural slice from line 8 of 'accum'. 2 statements are " + + 'downstream-dependent on it (over CDG + REACHING_DEF). Cross-function (inter-procedural) ' + + "impact is NOT modeled in this mode — use mode:'callgraph' for the call-graph blast radius.", + reachableBlocks: ['b1', 'b2'], + blockCount: 2, + depthReached: 2, + unresolvedBlockCount: 0, + ambiguousProjectionCount: 0, + summary: { direct: 1, processes_affected: 0, modules_affected: 0 }, + byDepthCounts: { 1: 1 }, + affected_processes: [], + affected_modules: [], + byDepth: { 1: [] }, + ...overrides, + }; + } + + it('renders a statement slice as an L: list under the criterion-line heading', () => { + const out = formatImpactResult(pdgStatementSlice()); + // Heading carries direction + file:criterionLine + count. + expect(out).toContain('Statements downstream-dependent on src/svc.ts:8 (2):'); + // Each dependent statement renders as ` L: `. + expect(out).toContain(' L10: sum = sum + x;'); + expect(out).toContain(' L12: return sum;'); + // It is the statement list — NOT the symbol-projection "PDG-dependent symbols" + // heading (that is the whole-symbol render path). + expect(out).not.toContain('PDG-dependent symbols'); + // The intra-procedural caveat note still surfaces. + expect(out.toLowerCase()).toContain('cross-function'); + }); + + it('flags slice truncation honestly', () => { + const out = formatImpactResult(pdgStatementSlice({ truncated: true, truncatedBy: 'depth' })); + expect(out).toContain('Truncated'); + expect(out).toContain('by depth'); + }); + + it('renders a no-block-at-line result as the steering note, never an empty isolated headline', () => { + // `_runImpactPDG` seedBlocks.length === 0 in statement mode. + const out = formatImpactResult({ + mode: 'pdg', + target: { + id: 'Function:src/svc.ts:accum', + name: 'accum', + type: 'Function', + filePath: 'src/svc.ts', + }, + direction: 'downstream', + criterionLine: 9, + reachableBlocks: [], + blockCount: 0, + affectedStatements: [], + affectedStatementCount: 0, + truncated: false, + depthReached: 0, + epistemic: 'pdg-no-block-at-line', + note: + "No PDG statement block starts at line 9 within 'accum' (src/svc.ts). The line may be " + + "blank, a comment, a brace, or outside the symbol's body. Pass a line that begins an " + + 'executable statement.', + impactedCount: 0, + risk: 'UNKNOWN', + byDepth: {}, + byDepthCounts: { 1: 0 }, + summary: { direct: 0, processes_affected: 0, modules_affected: 0 }, + affected_processes: [], + affected_modules: [], + unresolvedBlockCount: 0, + ambiguousProjectionCount: 0, + }); + expect(out).toContain('No statements downstream-dependent on src/svc.ts:9'); + expect(out).toContain('No PDG statement block starts at line 9'); + expect(out).not.toContain('appears isolated'); + expect(out).not.toContain('PDG-dependent symbols'); + }); }); describe('formatImpactResult — callgraph rendering is UNCHANGED (regression guard)', () => { diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 0841b06ca..98f10e019 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -134,6 +134,21 @@ describe('GITNEXUS_TOOLS', () => { expect(impactTool.inputSchema.required).toContain('direction'); }); + it('impact tool advertises the PDG-only `line` statement anchor (integer, min 1, not required)', () => { + const impactTool = GITNEXUS_TOOLS.find((t) => t.name === 'impact')!; + const line = (impactTool.inputSchema.properties as Record).line; + expect(line).toBeDefined(); + expect(line.type).toBe('integer'); + expect(line.minimum).toBe(1); + // Statement-anchored slice is optional — never required. + expect(impactTool.inputSchema.required).not.toContain('line'); + // The description names the mode:'pdg' statement-anchor semantics. + expect(line.description).toMatch(/statement anchor/i); + expect(line.description).toMatch(/pdg/i); + // The top-level description mentions the statement-anchored slice. + expect(impactTool.description).toMatch(/statement-anchored|STATEMENT-ANCHORED/); + }); + it('rename tool requires new_name', () => { const renameTool = GITNEXUS_TOOLS.find((t) => t.name === 'rename')!; expect(renameTool.inputSchema.required).toContain('new_name');