GitNexus/gitnexus/test/unit/cfg/post-dominators.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

230 lines
7.8 KiB
TypeScript
Raw Permalink 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.

import { describe, it, expect } from 'vitest';
import {
computePostDominators,
isExitReachableFromAllBlocks,
postDominates,
} from '../../../src/core/ingestion/cfg/post-dominators.js';
import type {
BasicBlockData,
CfgEdgeData,
FunctionCfg,
} from '../../../src/core/ingestion/cfg/types.js';
// U2 (#2085 M5) — post-dominators on the EXIT-rooted reverse CFG. Pinned on
// hand-built FunctionCfg literals with zero tree-sitter dependency, mirroring
// reaching-defs.test.ts / cfg-builder.test.ts. Post-dominance has crisp,
// well-known expected outputs per topology, so the expected ipdom values ARE
// the spec for the Cooper–Harvey–Kennedy iterative dominators implementation.
// ── hand-built CFG helper ───────────────────────────────────────────────────
function mkCfg(
blockCount: number,
edges: [number, number][],
opts: { entry?: number; exit?: number } = {},
): FunctionCfg {
const entry = opts.entry ?? 0;
const exit = opts.exit ?? blockCount - 1;
const blocks: BasicBlockData[] = Array.from({ length: blockCount }, (_, i) => ({
index: i,
startLine: i + 1,
endLine: i + 1,
text: '',
kind: i === entry ? 'entry' : i === exit ? 'exit' : 'normal',
}));
const cfgEdges: CfgEdgeData[] = edges.map(([from, to]) => ({ from, to, kind: 'seq' }));
return {
filePath: 't.ts',
functionStartLine: 1,
functionStartColumn: 0,
entryIndex: entry,
exitIndex: exit,
blocks,
edges: cfgEdges,
};
}
const NONE = -1;
describe('computePostDominators — ipdom on the reverse CFG', () => {
it('linear chain: each block is post-dominated by its successor', () => {
// 0(entry) → 1 → 2 → 3(exit)
const cfg = mkCfg(4, [
[0, 1],
[1, 2],
[2, 3],
]);
const tree = computePostDominators(cfg);
expect(tree.ipdom[3]).toBe(NONE); // exit (root) has no post-dominator above it
expect(tree.ipdom[2]).toBe(3);
expect(tree.ipdom[1]).toBe(2);
expect(tree.ipdom[0]).toBe(1);
expect(postDominates(tree, 3, 0)).toBe(true);
expect(postDominates(tree, 2, 0)).toBe(true);
expect(postDominates(tree, 0, 2)).toBe(false);
expect(postDominates(tree, 1, 1)).toBe(true); // reflexive
});
it('diamond (if/else with join): the join post-dominates the branch, arms do not', () => {
// 0(branch) → 1(then), 2(else); 1,2 → 3(join) → 4(exit)
const cfg = mkCfg(5, [
[0, 1],
[0, 2],
[1, 3],
[2, 3],
[3, 4],
]);
const tree = computePostDominators(cfg);
expect(tree.ipdom[4]).toBe(NONE);
expect(tree.ipdom[3]).toBe(4);
expect(tree.ipdom[1]).toBe(3);
expect(tree.ipdom[2]).toBe(3);
expect(tree.ipdom[0]).toBe(3); // join post-dominates the branch, not an arm
expect(postDominates(tree, 3, 0)).toBe(true);
expect(postDominates(tree, 1, 0)).toBe(false); // `then` does NOT post-dominate the branch
expect(postDominates(tree, 2, 0)).toBe(false);
expect(postDominates(tree, 3, 1)).toBe(true);
});
it('while loop: the header post-dominates the body; back-edge does not break the tree', () => {
// 0(entry) → 1(header) → 2(body) → 1; header → 3(exit)
const cfg = mkCfg(4, [
[0, 1],
[1, 2],
[2, 1],
[1, 3],
]);
const tree = computePostDominators(cfg);
expect(tree.ipdom[3]).toBe(NONE);
expect(tree.ipdom[1]).toBe(3);
expect(tree.ipdom[2]).toBe(1); // every path from body to exit goes through the header
expect(tree.ipdom[0]).toBe(1);
expect(postDominates(tree, 1, 2)).toBe(true);
expect(postDominates(tree, 3, 2)).toBe(true);
expect(postDominates(tree, 2, 1)).toBe(false);
});
it('multiple returns collapsing to a single EXIT: exit post-dominates everything', () => {
// 0(entry) → 1(cond) → 2(return), 3(return); 2,3 → 4(exit)
const cfg = mkCfg(5, [
[0, 1],
[1, 2],
[1, 3],
[2, 4],
[3, 4],
]);
const tree = computePostDominators(cfg);
expect(tree.ipdom[4]).toBe(NONE);
expect(tree.ipdom[1]).toBe(4);
expect(tree.ipdom[2]).toBe(4);
expect(tree.ipdom[3]).toBe(4);
expect(tree.ipdom[0]).toBe(1);
for (const b of [0, 1, 2, 3]) expect(postDominates(tree, 4, b)).toBe(true);
});
it('exit-less infinite loop: blocks that cannot reach EXIT have no post-dominator (KTD5)', () => {
// 0(entry) → 1 → 2 → 1 (no edge ever reaches exit block 3)
const cfg = mkCfg(4, [
[0, 1],
[1, 2],
[2, 1],
]);
const tree = computePostDominators(cfg);
expect(tree.ipdom[3]).toBe(NONE); // exit itself
expect(tree.ipdom[0]).toBe(NONE); // cannot reach exit
expect(tree.ipdom[1]).toBe(NONE);
expect(tree.ipdom[2]).toBe(NONE);
// No post-dominator means only reflexive post-dominance, and the climb must
// terminate (no infinite loop) even on the cycle.
expect(postDominates(tree, 3, 0)).toBe(false);
expect(postDominates(tree, 0, 0)).toBe(true);
expect(postDominates(tree, 1, 2)).toBe(false);
});
it('trivial single-block function (entry === exit) does not crash', () => {
const cfg = mkCfg(1, [], { entry: 0, exit: 0 });
const tree = computePostDominators(cfg);
expect(tree.ipdom[0]).toBe(NONE);
expect(postDominates(tree, 0, 0)).toBe(true);
});
it('is deterministic across runs', () => {
const make = (): FunctionCfg =>
mkCfg(5, [
[0, 1],
[0, 2],
[1, 3],
[2, 3],
[3, 4],
]);
const a = computePostDominators(make());
const b = computePostDominators(make());
expect(a.ipdom).toEqual(b.ipdom);
});
});
// ── post-dominance soundness precondition (#2188 review) ────────────────────
// EXIT must be reachable (forward) from every block reachable from ENTRY, else
// the EXIT-rooted reverse walk degenerates and CDG is unsound. The current TS
// visitor always satisfies this; the guard protects future / hand-built CFGs.
describe('isExitReachableFromAllBlocks', () => {
it('holds for a normal single-EXIT diamond (every block reaches EXIT)', () => {
const cfg = mkCfg(5, [
[0, 1],
[0, 2],
[1, 3],
[2, 3],
[3, 4],
]);
expect(isExitReachableFromAllBlocks(cfg)).toBe(true);
});
it('holds for a loop whose header has a structural edge to EXIT', () => {
// 0=entry → 1=header; header → 2=body → back to header; header → 3=exit.
const cfg = mkCfg(4, [
[0, 1],
[1, 2],
[2, 1],
[1, 3],
]);
expect(isExitReachableFromAllBlocks(cfg)).toBe(true);
});
it('fails when an entry-reachable region cannot reach EXIT (exit-less loop)', () => {
// 0=entry → 1; 1↔2 spin forever with no edge to 3=exit. EXIT is unreachable
// from the {1,2} region → post-dominance would be unsound there.
const cfg = mkCfg(4, [
[0, 1],
[1, 2],
[2, 1],
]);
expect(isExitReachableFromAllBlocks(cfg)).toBe(false);
});
it('fails for the review counterexample (A→B, A→L, B→X→L→A; EXIT disconnected)', () => {
// Indices: 0=A(entry), 1=B, 2=X, 3=L, 4=EXIT (disconnected). The A/B/X/L
// cycle never reaches EXIT, so the precondition must reject it.
const cfg = mkCfg(5, [
[0, 1],
[0, 3],
[1, 2],
[2, 3],
[3, 0],
]);
expect(isExitReachableFromAllBlocks(cfg)).toBe(false);
});
it('ignores blocks unreachable from ENTRY (they need not reach EXIT)', () => {
// 0=entry → 1=exit directly; 2 is an island unreachable from entry. The
// island does not violate the precondition (it is never analyzed).
const cfg = mkCfg(3, [[0, 1]], { entry: 0, exit: 1 });
expect(isExitReachableFromAllBlocks(cfg)).toBe(true);
});
it('holds for the single-block CFG (entry === exit)', () => {
expect(isExitReachableFromAllBlocks(mkCfg(1, [], { entry: 0, exit: 0 }))).toBe(true);
});
});