GitNexus/gitnexus/test/integration/taint-explain.test.ts
Gergő Magyar 7c3d4e6862
feat(pdg): control dependence — post-dominators + CDG (Ferrante) [M5 #2085] (#2188)
* feat(pdg): add CDG + POST_DOMINATE edge types (M5 #2085)

* feat(pdg): post-dominator tree on reverse CFG (M5 #2085)

* feat(pdg): Ferrante control-dependence over the post-dom tree (M5 #2085)

* feat(pdg): emitFileCdg + optional POST_DOMINATE debug edges (M5 #2085)

* feat(pdg): wire CDG emission in-phase + pdgModeMismatch CDG-cap stamp (M5 #2085)

* test(pdg): CDG snapshot + end-to-end pipeline answerability (M5 #2085)

* fix(review): apply autofix feedback (M5 #2085)

* fix(pdg): label CDG edges by controller arm sense, not edge kind (#2188 F1/F2/F4)

Tri-review (with Codex as the independent engine) found the CDG 'T'/'F' label
was wrong for the commonest control flow: the M1 TS visitor wires a condition's
fall-through FALSE arm as `seq`/`loop-back`, but `branchSense` mapped both to
'T', so guard clauses, if-no-else, and loop `break` got 'T' instead of 'F' (F1,
P1). The structural CDG edges were correct; only the label — the AC3 "under what
condition does X run?" answer — was wrong.

- F1: replace edge-kind `branchSense` with controller-arm-sense `labelFor`. An
  ambiguous fall-through edge (seq/loop-back) takes the COMPLEMENT of its source
  block's explicit cond-true/cond-false sibling arm. This correctly handles
  do/while (loop-back = TRUE arm) and inner-if-in-loop (loop-back = FALSE arm) —
  the ambiguity a kind→label table cannot resolve. Adds real-parser regression
  tests (the hand-built tests used a fictional cond-false edge and missed it).
- F2: correct the false "sound over-approximation that never drops a real
  dependence" claim in post-dominators.ts — exit-unreachable regions both drop
  and invent control dependences (latent for the current TS visitor, which keeps
  EXIT reverse-reachable). Reframe the exit-less-loop test to characterize, not
  bless, the degenerate behavior.
- F4: make the AC2 property-test reference compute post-dominance INDEPENDENTLY
  (node-removal reachability, no shared code with post-dominators.ts), so a
  post-dom direction bug can no longer pass both the impl and the reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): root-prettier format + run-analyze pdg stamp gains maxCdgEdgesPerFunction (#2085)

Two deterministic CI failures from the M5 CDG work:
- quality/format: basicblock-roundtrip.test.ts failed CI's root `prettier --check .`
  (the pre-commit hook uses the gitnexus-local prettier config, which differs);
  reformatted with the root config.
- tests/ubuntu/coverage: run-analyze.test.ts pinned the resolved RepoMeta.pdg
  shape (DEFAULTS) and the all-zero cap override without the new
  maxCdgEdgesPerFunction key (default 5000); added it so resolvePdgConfig
  toEqual and pdgModeMismatch(DEFAULTS) pass. (The stale-test sweep missed this
  file in PR #2188 — same trap M2 hit.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(mcp): add pdg_query tool definition (controls/flows modes) [M6 #2086]

* feat(mcp): pdg_query backend — controls (CDG) + flows (REACHING_DEF) + e2e test [M6 #2086]

* feat(mcp): document PDG edges + pdg_query (schema, cypher, skill, --pdg-gated ai-context) [M6 #2086]

* fix(mcp): correct pdg_query symbol-anchor lower bound + harden inputs [PR #2188 review]

Tri-review (Codex + adversarial + correctness lanes) of the M6 pdg_query
surface found the symbol-anchor window over-includes a neighbor function's
block. The upper bound was widened to the 1-based BasicBlock basis (symEnd+1)
but the lower bound was left 0-based, so a block on the line directly above the
target function leaked into the result. Shift both bounds +1 ([symStart+1,
symEnd+1]) so the window is the function's true block span.

Also from the same review:
- pdg_query no longer throws on a no-arguments MCP call: the dispatch passes
  raw `params`, so default it to {} → a clean mode-validation error instead of
  a TypeError. (`explain` shares this latent pattern — pre-existing follow-up.)
- tools.ts: the controls-mode description no longer hard-codes the 'F' branch
  sense for guards — `if (!ok) return;` rides the predicate's 'T' arm; the
  guard:true flag is label-agnostic (regex on the dependent block text).

Tests: a hand-seeded adjacency regression (verified failing without the
lower-bound +1) + a no-arguments validation test. Skill doc updated to document
the two-sided [symStart+1, symEnd+1] window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): drop always-true anchor conditional in pdg_query [CodeQL #2188]

CodeQL alert 756 flagged `...(anchor ? { anchor } : {})` in _pdgQueryImpl as a
useless conditional: `anchor` is unconditionally assigned in both the file-path
and symbol branches before the return (the not-found/ambiguous/no-layer paths
return earlier), so it is always truthy. Drop `| undefined` from the declaration
(TypeScript definite-assignment holds across both branches) and emit `anchor`
directly.

No runtime change — the `anchor` field was already present on every result.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cli): add hasPdg to the noStats bridge expectation [#2188]

The M6 work threaded `hasPdg: options.pdg === true` into the AIContextOptions
passed to generateAIContextFiles on the --skills regeneration path, but this
test's strict .toEqual expectation predated it (4 keys vs 3 → CI failure). Add
`hasPdg: false` (the value on this non---pdg path). The assertion stays strict;
the #1477 noStats bridging it guards is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): collapse generateGitNexusContent params to an options bag [#2188]

The function had grown to 9 positional params; reaching `hasPdg` meant passing
six `undefined`s (the M6 review's maintainability flag). Collapse params 3-9
(generatedSkills, groupNames, noStats, skipSkills, runnerPath, defaultBranch,
hasPdg) into a `GitNexusContentOptions` object with the defaults moved to
destructuring. The body is unchanged (same local names); the single production
caller and the test calls become self-documenting named fields.

Pure refactor — generated AGENTS.md/CLAUDE.md content is byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cfg): skip CDG for exit-unreachable CFGs (unsound post-dominance) [#2188]

M5 review P2: computePostDominators roots only at cfg.exitIndex and nothing
enforced that EXIT is reachable from every block. For an entry-reachable region
that cannot reach EXIT (a non-terminating loop, or a multi-terminal CFG a future
visitor might emit) the EXIT-rooted reverse walk degenerates — it both drops
real control dependences and invents spurious ones.

Add a pure precondition predicate `isExitReachableFromAllBlocks` (co-located with
the algorithm it guards) and gate it in emitFileCdg: a CFG that violates it is
skipped for CDG (counted as skippedUnsoundFunctions + one onWarn), while its CFG
and REACHING_DEF projections — which do not depend on post-dominance — are kept.
A CDG-specific gate, not a widening of isEmitSafeCfg, so the blast radius is
exactly the unsound CDG. The current TS visitor always satisfies the
precondition (every loop gets a structural header→loopExit edge), so CDG output
for real fixtures is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cfg): bound computeControlDependence materialization (heap parity) [#2188]

M5 review P2: unlike computeReachingDefs (maxFacts) and the emit-side edge cap,
computeControlDependence materialized the full deduped seen/out before
emitFileCdg's per-function cap could trim it — O(edges × post-dom depth) heap
for a deeply nested function.

Add a `maxEdges` ceiling (default 0 = unbounded) returning {edges, truncated},
mirroring computeReachingDefs's {facts, truncated}. The ceiling is checked
before pushing a new unique edge, so `truncated` means a genuine overflow (not
merely "reached cap"). emitFileCdg passes a FIXED materialization ceiling (8× the
default edge cap) — deliberately NOT derived from the runtime edge cap, because
CDG's materialization IS the deduped-edge quantity the cap reports on (deriving
it would pre-truncate that set and lose the exact dropped count). A ceiling hit
is surfaced via onWarn + the truncated flag — never silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(mcp): share resolveBlockAnchor; fix explain's anchor off-by-one [#2188]

M6 review P2 (duplication) + the flagged pre-existing _explainImpl correctness
follow-up. _pdgQueryImpl and _explainImpl each carried a near-identical
symbol↔block anchor resolver that had DRIFTED: pdg_query used the corrected
[symStart+1, symEnd+1] window (BasicBlock startLine is 1-based, the symbol span
0-based) while _explainImpl still used [symStart, symEnd] — dropping a taint
source on the function's final line AND leaking a neighbor's block on the line
directly above.

Extract one `resolveBlockAnchor` helper, used by both, that applies the correct
window and a single (bare) clause convention (callers compose their own WHERE).
This removes ~50 duplicated lines and fixes explain's anchor in one place.

A hand-seeded characterization test (taint-explain Block 4) pins both bounds —
verified to FAIL on the pre-fix window (it returned the line-10 neighbor instead
of the line-15 final-line source). Existing taint-explain + pdg-query suites are
unchanged (their fixtures have interior sources/sinks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(mcp): pdg_query reports "status unknown" when the layer can't be confirmed [#2188]

M6 review P3 (Codex): when meta is UNREADABLE and the bounded global existence
probe returns zero rows of the edge type, _pdgQueryImpl asserted "no PDG layer"
— but a genuinely edge-free layer (all-linear functions) is indistinguishable
from a missing one via that probe. Soften only that fallback path to an
inconclusive "PDG layer status unknown — was this repo indexed with --pdg?"
note. The meta-stamped path (stamp present, cap absent ⇒ layer truly missing)
keeps the definitive "no PDG layer" wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(mcp): cover pdg_query ambiguous / pagination / Windows-path gaps [#2188]

M6 review test-gap follow-ups, all hand-seeded with controlled data:
- ambiguous symbol name → status:'ambiguous' + ranked candidates shape
  (uid/name/filePath/score), never a silent guess;
- total/truncated page boundary in both directions (limit below the match count
  sets truncated with the full total; limit above it omits truncated);
- a Windows-style filePath containing ':' resolves and fnLineOf decodes the
  function-line segment correctly (split-from-right past the drive letter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): ship gitnexus-pdg-query skill mirrors + add pdg_query to the guide [#2086]

M6 bundled pdg_query into this PR, but the skill shipped only in the canonical
gitnexus/skills/ root. Mirror it (byte-identical) to the two hand-maintained
roots the sibling taint skill uses — .claude/skills/gitnexus/ and the plugin —
so Claude Code + plugin users get it too.

Also extend the gitnexus-guide tool reference (all 3 copies, now byte-identical):
add a `pdg_query` row + a "Control & data dependence" section mirroring the
taint/`explain` section, and reconcile the pre-existing drift where only the
.claude copy carried the `check` tool row (a real registered tool) — all three
now list it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(architecture): refresh CFG/PDG section for the full M1–M6 stack [#2086]

The PR body had deferred the "ARCHITECTURE docs refresh" to #2086; now that M6
ships here, do it:
- MCP tools table gains `explain` and `pdg_query` (were absent).
- "Optional CFG/PDG emission" was M1-only; rewrite to cover the whole opt-in
  stack — M1 CFG, M2 REACHING_DEF, M3/M4 taint, M5 CDG (Ferrante over CHK
  post-dominators, with the exit-unreachable skip), M6 read surface (pdg_query +
  explain, anchored + LIMIT-bounded, shared resolveBlockAnchor) — and note the
  no-Function→BasicBlock-edge join.
- LadybugDB schema notes the `--pdg` additions: the `BasicBlock` node table and
  the CFG/REACHING_DEF/CDG/TAINTED/SANITIZES/TAINT_PATH relation types, kept out
  of the default VALID_RELATION_TYPES / web schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:49:03 +01:00

562 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Integration Tests: MCP `explain` tool (#2083 M3 U6)
*
* End-to-end against a REAL LadybugDB: the pdg-repo fixture is indexed by the
* real pipeline with `--pdg` (workers — requires `node scripts/build.js`), the
* resulting BasicBlock nodes + TAINTED/SANITIZES edges and the fixture's
* Function symbols are persisted into the test DB, and `explain` is exercised
* through the full `callTool` dispatch:
*
* - anchorless enumerate (≥1 finding, decoded hops, deterministic order)
* - anchored by file and by symbol (line-span granularity)
* - sanitized-only function → zero TAINTED findings (its safety evidence is
* the SANITIZES edge, not part of explain's response)
* - unknown symbol → context()-style not-found
* - a repo WITHOUT the taint layer → the "no taint layer" note, not an error
*
* Seeding via the real emit output (not hand-written rows) pins the format
* compatibility between U4's write path and U6's read path — id template,
* `;<kind>` reason header, hop encoding.
*/
import { describe, it, expect, beforeAll, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos, loadMeta } from '../../src/storage/repo-manager.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/storage/repo-manager.js')>();
return {
...actual,
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
// No meta.json exists for the seeded test DB — explain's meta probe must
// degrade to the TAINTED-row existence probe (the seeded-DB reality).
loadMeta: vi.fn().mockResolvedValue(null),
};
});
const FIXTURE = path.join(__dirname, 'cfg', 'fixtures', 'pdg-repo');
// ─── Block 1: a --pdg index with real taint findings ─────────────────
withTestLbugDB(
'taint-explain',
(handle) => {
describe('explain tool against a --pdg index', () => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) {
throw new Error(
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
);
}
backend = ext._backend;
});
it('anchorless explain enumerates the persisted findings with decoded hops', async () => {
const result = await backend.callTool('explain', {});
expect(result).not.toHaveProperty('error');
expect(result.totalFindings).toBeGreaterThanOrEqual(1);
expect(result.findings.length).toBeGreaterThanOrEqual(1);
expect(result.truncated).toBeUndefined();
// The vulnerable flow: req.body → cmd → exec(cmd) in vuln.ts.
const vuln = result.findings.find((f: any) => f.file.endsWith('vuln.ts'));
expect(vuln).toBeDefined();
expect(vuln.sinkKind).toBe('command-injection');
expect(vuln.functionLine).toBe(9); // runUserCommand's start line
// Ordered hops with the variable carried on each hop (AC3): seed def
// (cmd @ const line 10) → sink use (cmd @ exec line 11).
expect(vuln.hops.map((h: any) => `${h.variable}@${h.line}`)).toEqual(['cmd@10', 'cmd@11']);
expect(vuln.source).toEqual({ variable: 'cmd', line: 10 });
expect(vuln.sink).toEqual({ line: 11 });
expect(vuln.pathIncomplete).toBeUndefined();
// The intra-procedural contract caveat reaches the consumer.
expect(result.note).toMatch(/intra-procedural/i);
});
it('anchorless enumerate is deterministic across calls', async () => {
const a = await backend.callTool('explain', {});
const b = await backend.callTool('explain', {});
expect(a).toEqual(b);
});
it('anchored by file path returns the finding (suffix match accepted)', async () => {
for (const target of ['vuln.ts']) {
const result = await backend.callTool('explain', { target });
expect(result).not.toHaveProperty('error');
expect(result.anchor).toEqual({ file: target });
expect(result.findings.length).toBeGreaterThanOrEqual(1);
for (const f of result.findings) expect(f.file.endsWith('vuln.ts')).toBe(true);
}
});
it('anchored by an unrelated file returns zero findings (repo HAS the layer — no note about it)', async () => {
const result = await backend.callTool('explain', { target: 'sample.ts' });
expect(result).not.toHaveProperty('error');
expect(result.findings).toEqual([]);
expect(result.totalFindings).toBe(0);
// The repo has TAINTED rows, so the "no taint layer" hint must NOT fire.
expect(result.note ?? '').not.toMatch(/no taint layer/i);
});
it('anchored by the vulnerable function name returns full hop detail', async () => {
const result = await backend.callTool('explain', { target: 'runUserCommand' });
expect(result).not.toHaveProperty('error');
expect(result.anchor.symbol).toBe('runUserCommand');
expect(result.findings).toHaveLength(1);
expect(result.totalFindings).toBe(1);
const f = result.findings[0];
expect(f.sinkKind).toBe('command-injection');
expect(f.hops.map((h: any) => h.variable)).toEqual(['cmd', 'cmd']);
});
it('the sanitized-only function returns no TAINTED finding', async () => {
const result = await backend.callTool('explain', { target: 'sendEncoded' });
expect(result).not.toHaveProperty('error');
expect(result.anchor.symbol).toBe('sendEncoded');
expect(result.findings).toEqual([]);
expect(result.totalFindings).toBe(0);
});
it('an unknown symbol target mirrors context() not-found semantics', async () => {
const result = await backend.callTool('explain', { target: 'nonexistentTaintFn999' });
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/not found/i);
});
it('a dotted symbol name resolves as a symbol, not a silent file miss', async () => {
// Regression: `Class.method` was classified as a file (the `.method`
// extension-like suffix) and returned a silent empty file-anchored
// result. It must now route to symbol resolution — here, not-found.
const result = await backend.callTool('explain', { target: 'UserController.create' });
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/not found/i);
// Must NOT be a silent file-anchored empty result.
expect(result.anchor).toBeUndefined();
});
it('a dotted symbol whose tail looks bare still resolves as a symbol', async () => {
// `runUserCommand` is a real fixture symbol; a dotted lead-in that does
// not match any symbol confirms the symbol branch (not file routing).
const result = await backend.callTool('explain', { target: 'Service.runUserCommand' });
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/not found/i);
});
it('rejects an out-of-bounds limit with a clear error', async () => {
// Includes the non-integer / non-finite / non-numeric cases the
// interpolated `LIMIT ${limit}` depends on the guard rejecting.
for (const limit of [0, -1, 1.5, 10_000, NaN, Infinity, -Infinity, '50']) {
const result = await backend.callTool('explain', { limit });
expect(result).toHaveProperty('error');
expect(result.error).toMatch(/limit/i);
}
});
it('limit pages the enumerate and reports truncation honestly', async () => {
const all = await backend.callTool('explain', {});
const page = await backend.callTool('explain', { limit: 1 });
expect(page.findings).toHaveLength(Math.min(1, all.totalFindings));
expect(page.totalFindings).toBe(all.totalFindings);
if (all.totalFindings > 1) {
expect(page.truncated).toBe(true);
// Deterministic order: the page is a prefix of the full enumerate.
expect(page.findings[0]).toEqual(all.findings[0]);
}
});
});
},
{
poolAdapter: true,
afterSetup: async (handle) => {
// 1. Index the pdg-repo fixture with the REAL pipeline (--pdg on).
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-explain-'));
try {
fs.cpSync(FIXTURE, repoDir, { recursive: true });
const pipelineResult = await runPipelineFromRepo(repoDir, () => {}, { pdg: true });
// 2. Persist the emit output into the test DB: BasicBlock nodes,
// TAINTED/SANITIZES edges, and the Function symbols (for the
// symbol-anchored path through resolveSymbolCandidates).
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
const nodes: Array<{ label: string; props: Record<string, unknown> }> = [];
pipelineResult.graph.forEachNode((n) => {
if (n.label === 'BasicBlock') {
nodes.push({
label: 'BasicBlock',
props: {
id: n.id,
filePath: n.properties.filePath ?? '',
startLine: n.properties.startLine ?? 0,
endLine: n.properties.endLine ?? 0,
text: n.properties.text ?? '',
},
});
} else if (n.label === 'Function') {
nodes.push({
label: 'Function',
props: {
id: n.id,
name: n.properties.name ?? '',
filePath: n.properties.filePath ?? '',
startLine: n.properties.startLine ?? 0,
endLine: n.properties.endLine ?? 0,
},
});
}
});
for (const node of nodes) {
const assignments = Object.keys(node.props)
.map((k) => `${k}: $${k}`)
.join(', ');
await adapter.executePrepared(
`CREATE (n:${node.label} {${assignments}})`,
node.props as Record<string, any>,
);
}
let taintEdges = 0;
for (const rel of pipelineResult.graph.iterRelationships()) {
if (rel.type !== 'TAINTED' && rel.type !== 'SANITIZES') continue;
await adapter.executePrepared(
`MATCH (a:BasicBlock {id: $src}), (b:BasicBlock {id: $dst})
CREATE (a)-[:CodeRelation {type: '${rel.type}', confidence: $confidence, reason: $reason, step: 0}]->(b)`,
{
src: rel.sourceId,
dst: rel.targetId,
confidence: rel.confidence ?? 1.0,
reason: rel.reason ?? '',
},
);
taintEdges++;
}
if (taintEdges === 0) {
throw new Error('fixture produced no TAINTED/SANITIZES edges — taint emit regressed?');
}
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
// 3. Register the test DB and boot the backend (calltool harness shape).
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'taint-repo',
path: '/taint/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 2, nodes: 4, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);
// ─── Block 2: a repo indexed WITHOUT --pdg ───────────────────────────
withTestLbugDB(
'taint-explain-nopdg',
(handle) => {
describe('explain tool without a taint layer', () => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) {
throw new Error(
'LocalBackend not initialized — afterSetup did not attach _backend to handle',
);
}
backend = ext._backend;
});
it('returns the no-taint-layer note via the row-existence probe (meta unreadable)', async () => {
const result = await backend.callTool('explain', {});
expect(result).not.toHaveProperty('error');
expect(result.findings).toEqual([]);
expect(result.totalFindings).toBe(0);
expect(result.note).toMatch(/no taint layer/i);
expect(result.note).toContain('--pdg');
});
it('an anchored call also reports the missing layer, not a bogus empty result', async () => {
const result = await backend.callTool('explain', { target: 'plain.ts' });
expect(result).not.toHaveProperty('error');
expect(result.findings).toEqual([]);
expect(result.note).toMatch(/no taint layer/i);
});
it('returns the note via the RepoMeta.pdg probe when meta is readable but unstamped', async () => {
// A readable meta WITHOUT a pdg stamp short-circuits before any
// block-space query (the #2099 F1 presence ≡ layer-exists contract).
vi.mocked(loadMeta).mockResolvedValueOnce({} as any);
const result = await backend.callTool('explain', {});
expect(result.findings).toEqual([]);
expect(result.totalFindings).toBe(0);
expect(result.note).toMatch(/no taint layer/i);
});
it('an M1/M2-era pdg stamp (no taintModelVersion) reports the missing taint layer', async () => {
// The pdg stamp exists (BasicBlock/REACHING_DEF were recorded) but
// taint never ran — no taintModelVersion. The taint-layer probe must
// gate on taintModelVersion, not generic pdg presence, so this surfaces
// the actionable "run analyze" hint instead of a bare empty result.
vi.mocked(loadMeta).mockResolvedValueOnce({
pdg: { mode: 'on', maxFunctionLines: 2000 },
} as any);
const result = await backend.callTool('explain', {});
expect(result.findings).toEqual([]);
expect(result.totalFindings).toBe(0);
expect(result.note).toMatch(/no taint layer/i);
});
});
},
{
seed: [
`CREATE (fn:Function {id: 'func:plainFn', name: 'plainFn', filePath: 'src/plain.ts', startLine: 1, endLine: 5, isExported: true, content: 'function plainFn() {}', description: 'no taint layer here'})`,
],
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'plain-repo',
path: '/plain/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'def456',
stats: { files: 1, nodes: 1, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);
// ─── Block 3: interprocedural TAINT_PATH findings (#2084 M4 U7) ───────
//
// Seeds the cross-file interproc-repo fixture's emit output (Function nodes +
// TAINT_PATH edges) into a real DB and proves `explain` surfaces the
// cross-function findings (marked `interprocedural: true`) with decoded
// function-level hops + the sink kind.
const INTERPROC_FIXTURE = path.join(__dirname, 'cfg', 'fixtures', 'interproc-repo');
withTestLbugDB(
'taint-explain-interproc',
(handle) => {
describe('explain tool — cross-function TAINT_PATH findings', () => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) throw new Error('LocalBackend not initialized');
backend = ext._backend;
});
it('anchorless enumerate includes interprocedural findings', async () => {
const res = (await backend.callTool('explain', {})) as {
findings: Array<Record<string, unknown>>;
};
const ip = res.findings.filter((f) => f.interprocedural === true);
expect(ip.length).toBeGreaterThan(0);
// handle → runIt, command-injection, with function-level hops.
const hr = ip.find(
(f) =>
(f.source as { function?: string })?.function === 'handle' &&
(f.sink as { function?: string })?.function === 'runIt',
);
expect(hr, 'expected an interprocedural handle → runIt finding').toBeDefined();
expect(hr!.sinkKind).toBe('command-injection');
expect(Array.isArray(hr!.hops)).toBe(true);
expect((hr!.hops as unknown[]).length).toBeGreaterThan(0);
});
it('symbol-anchored on the sink function surfaces the cross-function finding', async () => {
const res = (await backend.callTool('explain', { target: 'runIt' })) as {
findings: Array<Record<string, unknown>>;
};
const ip = res.findings.filter((f) => f.interprocedural === true);
expect(ip.some((f) => (f.sink as { function?: string })?.function === 'runIt')).toBe(true);
});
it('totalFindings counts the full interproc layer and truncated is set on overflow (#2084 review P2-4)', async () => {
// The fixture yields multiple interproc findings; limit:1 must page to 1
// while totalFindings reports the true (un-capped) count and truncated is set.
const full = (await backend.callTool('explain', {})) as {
findings: unknown[];
totalFindings: number;
};
const ipFull = full.findings.filter((f: any) => f.interprocedural === true).length;
expect(ipFull).toBeGreaterThan(1);
const paged = (await backend.callTool('explain', { limit: 1 })) as {
findings: unknown[];
totalFindings: number;
truncated?: boolean;
};
expect(paged.findings.length).toBe(1);
expect(paged.truncated).toBe(true);
// totalFindings reflects the real interproc total, not the 1-row slice.
expect(paged.totalFindings).toBeGreaterThanOrEqual(ipFull);
});
});
},
{
poolAdapter: true,
afterSetup: async (handle) => {
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-explain-ip-'));
try {
fs.cpSync(INTERPROC_FIXTURE, repoDir, { recursive: true });
const pipelineResult = await runPipelineFromRepo(repoDir, () => {}, { pdg: true });
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// Persist Function/Method nodes (TAINT_PATH endpoints).
const seenIds = new Set<string>();
pipelineResult.graph.forEachNode((n) => {
if (n.label !== 'Function' && n.label !== 'Method') return;
if (seenIds.has(n.id)) return;
seenIds.add(n.id);
});
for (const n of pipelineResult.graph.iterNodes()) {
if (n.label !== 'Function' && n.label !== 'Method') continue;
await adapter.executePrepared(
`CREATE (x:${n.label} {id: $id, name: $name, filePath: $filePath, startLine: $startLine, endLine: $endLine})`,
{
id: n.id,
name: n.properties.name ?? '',
filePath: n.properties.filePath ?? '',
startLine: n.properties.startLine ?? 0,
endLine: n.properties.endLine ?? 0,
},
);
}
let tpEdges = 0;
for (const rel of pipelineResult.graph.iterRelationships()) {
if (rel.type !== 'TAINT_PATH') continue;
await adapter.executePrepared(
// The fixture's endpoints are all top-level Function nodes; Kuzu
// rejects an untyped node match in a rel CREATE (read MATCH is fine).
`MATCH (a:Function {id: $src}), (b:Function {id: $dst})
CREATE (a)-[:CodeRelation {type: 'TAINT_PATH', confidence: $confidence, reason: $reason, step: 0}]->(b)`,
{
src: rel.sourceId,
dst: rel.targetId,
confidence: rel.confidence ?? 0.6,
reason: rel.reason ?? '',
},
);
tpEdges++;
}
if (tpEdges === 0) {
throw new Error('interproc fixture produced no TAINT_PATH edges — fixpoint regressed?');
}
} finally {
fs.rmSync(repoDir, { recursive: true, force: true });
}
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'interproc-repo',
path: '/interproc/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'ip0001',
stats: { files: 2, nodes: 4, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);
// ─── Block 4: symbol-anchor window correctness (#2188 _explainImpl off-by-one) ──
//
// Hand-seeded with controlled line numbers (no parser dependency). `tailFn`
// occupies 0-based symbol lines 10–14, so its BasicBlocks land on 1-based lines
// 11–15 and the correct anchor window is [symStart+1, symEnd+1] = [11,15]. The
// pre-fix _explainImpl used [symStart, symEnd] = [10,14], which both DROPPED a
// taint source on the function's final line (1-based 15) and LEAKED a neighbor's
// block on the line directly above (1-based 10). One query proves both bounds —
// and FAILS on the pre-fix window (it would return the line-10 neighbor instead).
withTestLbugDB(
'taint-explain-anchor-window',
(handle) => {
describe('explain symbol anchoring (#2188 [symStart+1, symEnd+1] window)', () => {
let backend: LocalBackend;
beforeAll(() => {
const ext = handle as typeof handle & { _backend?: LocalBackend };
if (!ext._backend) throw new Error('LocalBackend not initialized');
backend = ext._backend;
});
it('includes the final-line taint source and excludes the neighbor-above block', async () => {
const result = (await backend.callTool('explain', { target: 'tailFn' })) as {
findings: Array<{ source?: { line?: number } }>;
error?: string;
};
expect(result).not.toHaveProperty('error');
// Only tailFn's own final-line (1-based 15) taint source survives; the
// neighbor's line-10 block is below the [11,15] window (lower-bound +1).
expect(result.findings).toHaveLength(1);
expect(result.findings[0].source?.line).toBe(15);
expect(result.findings.some((f) => f.source?.line === 10)).toBe(false);
});
});
},
{
poolAdapter: true,
afterSetup: async (handle) => {
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
// tailFn: 0-based span 10–14 ⇒ 1-based blocks on 11–15, window [11,15].
await adapter.executePrepared(
`CREATE (fn:Function {id: 'func:tailFn', name: 'tailFn', filePath: 'anchor.ts', startLine: 10, endLine: 14, isExported: true, content: 'function tailFn() {}', description: 'anchor-window regression'})`,
{},
);
const block = (id: string, startLine: number, text: string) =>
adapter.executePrepared(
`CREATE (b:BasicBlock {id: $id, filePath: 'anchor.ts', startLine: $startLine, endLine: $startLine, text: $text})`,
{ id, startLine, text },
);
// tailFn's source/sink on its FINAL line (1-based 15 = endLine 14 + 1).
await block('BasicBlock:anchor.ts:11:0:5', 15, 'const x = req.body;');
await block('BasicBlock:anchor.ts:11:0:6', 15, 'exec(x);');
// a neighbor function's block on the line directly ABOVE tailFn (1-based 10).
await block('BasicBlock:anchor.ts:9:0:0', 10, 'const y = other();');
await block('BasicBlock:anchor.ts:9:0:1', 10, 'use(y);');
const tainted = (src: string, dst: string, reason: string) =>
adapter.executePrepared(
`MATCH (a:BasicBlock {id: $src}), (b:BasicBlock {id: $dst})
CREATE (a)-[:CodeRelation {type: 'TAINTED', confidence: 1.0, reason: $reason, step: 0}]->(b)`,
{ src, dst, reason },
);
await tainted('BasicBlock:anchor.ts:11:0:5', 'BasicBlock:anchor.ts:11:0:6', 'tail');
await tainted('BasicBlock:anchor.ts:9:0:0', 'BasicBlock:anchor.ts:9:0:1', 'neighbor');
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'anchor-repo',
path: '/anchor/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'aw0001',
stats: { files: 1, nodes: 5, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as any)._backend = backend;
},
},
);