Files
msd-core/tests/check-gap-analysis-plan-post-e2e.test.cjs
sim 3925839f2a test(#4652): failing-first coverage for the four unconfined boundaries
Phase 2 of epic #4636, absorbing #4327 and #4354. Tests only; no fix. These
MUST fail.

Four CLI boundaries join externally-supplied input to a managed root with no
containment validation. Each was driven through the real CLI and confirmed
unconfined before the assertions were written:

  todo complete <name>                     src/commands.cts cmdTodoComplete
  check predicate --phase-dir <dir>        check-command-router cmdCheckPredicate
  check decision-coverage-plan <dir>       check-command-router resolvePath
  check gap-analysis.plan-post <dir>       check-command-router

Boundary 1 is worse than the issue describes. #4327 reports that a traversal
name "resolves outside the todos root", which reads as an information leak.
Measured, it is destructive: `todo complete ../../../../b1out/leak.md` exited
0, MOVED the outside file into completed/, and unlinked the original. The file
was gone. cmdTodoComplete ends in fs.unlinkSync(sourcePath), so an unconfined
name does not merely read across the boundary, it consumes across it.

Boundary 2 reproduces #4354 exactly: a BLOCKING gate returned
{"block":false,"details":{"match":true}} sourced entirely from a SECURITY.md
in a directory the caller chose, outside the project.

Boundaries 3 and 4 are not named in the epic. Both accepted an outside phase
dir and exited 0.

Rows that exist because they are the ones nobody enumerates:

- ORDERING. A real file is created outside the todos root, then the traversal
  name targeting it is asserted rejected AND the outside file asserted still
  present and unmoved. #4327 notes the existence check and the move target
  BOTH follow the unvalidated join, so a rejection that lands after the read
  has already leaked — and, per the finding above, after the unlink has
  already destroyed.
- `a/../../b.md` — looks balanced, resolves outside.
- --dry-run must reject too; a preview must not leak a resolved outside path.
- ${PHASE_DIR} interpolation into a command-exit-zero predicate is the SECOND
  predicate kind, which a fix inside gate-predicate-evaluator.cts would miss.
- An absolute path INSIDE the project must still be accepted at every
  boundary — absolute is not a synonym for escaping.

Cross-boundary rows loop over one shared list of escaping inputs and assert
all four reject with the same shape, so four sites adopting one predicate
cannot drift into four rejection contracts.

Property tests cover BOTH directions — outside is always rejected, inside is
always accepted. A property asserting only rejection is satisfied by a
predicate that rejects everything, which is the degenerate-implementation trap
found in Phase 1's review. Both are seeded.

Regressions fold into the owning module suites rather than a new
tests/fix-NNNN-*.test.cjs, per scripts/lint-regression-test-names.cjs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 09:43:50 -04:00

854 lines
42 KiB
JavaScript

'use strict';
/**
* E2E content tests for plan:post hook — gap-analysis gate.
*
* ADR-857 phase 6 backlog: check-gap-analysis-plan-post-e2e.test.cjs
*
* Tests exercise:
* - loop render-hooks plan:post (gate discovery)
* - check gap-analysis.plan-post (advisory gate check)
*
* HARD RULES enforced here:
* - Every test runs a real CLI subprocess or the real resolver + real registry.
* - No readFileSync + .includes() source-grep on workflow files.
* - Asserts TYPED CONTENT (JSON fields, counts, booleans, strings).
* - Each test fully isolated (own fixture), cleanup in afterEach.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawnSync } = require('child_process');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const { LOOP_HOOK_POINT_CLI_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
// ─── Shared helpers ───────────────────────────────────────────────────────────
/**
* Write REQUIREMENTS.md with REQ-IDs in checkbox format.
* @param {string} planningDir
* @param {string[]} ids
*/
function writeRequirements(planningDir, ids) {
const lines = ids.map((id, i) => `- [ ] **${id}** Requirement ${i + 1} description`);
fs.writeFileSync(
path.join(planningDir, 'REQUIREMENTS.md'),
`# Requirements\n\n${lines.join('\n')}\n`
);
}
/**
* Write CONTEXT.md with a <decisions> block containing decisions.
* @param {string} phaseDir
* @param {{id: string, text: string}[]} decisions
*/
function writeContext(phaseDir, decisions) {
const dLines = decisions.map(d => `- **${d.id}:** ${d.text}`).join('\n');
fs.writeFileSync(
path.join(phaseDir, 'CONTEXT.md'),
`# Phase Context\n\n<decisions>\n## Implementation Decisions\n\n${dLines}\n</decisions>\n`
);
}
/**
* Write a PLAN.md with the given body.
* @param {string} phaseDir
* @param {string} name e.g. '01'
* @param {string} body
*/
function writePlan(phaseDir, name, body) {
fs.writeFileSync(path.join(phaseDir, `${name}-PLAN.md`), body);
}
/**
* Run loop render-hooks via spawnSync for low-level exit-code control.
* @param {string} point
* @param {string} cwd
* @returns {{ status: number, stdout: string, stderr: string }}
*/
function spawnRenderHooks(point, cwd) {
const result = spawnSync(process.execPath, [GSD_TOOLS, 'loop', 'render-hooks', point, '--raw'], {
cwd,
encoding: 'utf8',
timeout: LOOP_HOOK_POINT_CLI_TIMEOUT_MS,
env: { ...process.env, GSD_SESSION_KEY: '', CODEX_THREAD_ID: '', CLAUDE_SESSION_ID: '' },
});
return {
status: result.status,
stdout: (result.stdout || '').trim(),
stderr: (result.stderr || '').trim(),
};
}
/**
* Run check gap-analysis.plan-post via CLI with controlled args.
* @param {string[]} extraArgs args after 'gap-analysis.plan-post'
* @param {string} cwd
* @returns {{ success: boolean, output: string, error: string, exitCode: number }}
*/
function runGapCheck(extraArgs, cwd) {
return runGsdTools(['check', 'gap-analysis.plan-post', ...extraArgs, '--raw'], cwd);
}
// ─── Section 1: render-hooks plan:post ───────────────────────────────────────
describe('render-hooks plan:post — gate discovery', () => {
let tmpDir;
let phaseDir;
beforeEach(() => {
tmpDir = createTempProject();
phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
// Initialize a valid config so schema defaults apply
const init = runGsdTools('config-ensure-section', tmpDir);
assert.ok(init.success, `config-ensure-section failed: ${init.error}`);
});
afterEach(() => cleanup(tmpDir));
test('[happy] render-hooks plan:post returns gap-analysis gate hook with correct typed shape when workflow.post_planning_gaps=true (default)', () => {
// Default config → post_planning_gaps=true (schema default)
const r = spawnRenderHooks('plan:post', tmpDir);
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
const envelope = JSON.parse(r.stdout);
assert.strictEqual(envelope.point, 'plan:post');
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be array');
assert.strictEqual(envelope.activeHooks.length, 1, 'exactly one active hook expected');
const hook = envelope.activeHooks[0];
assert.strictEqual(hook.capId, 'gap-analysis', 'capId must be gap-analysis');
assert.strictEqual(hook.kind, 'gate', 'kind must be gate');
assert.strictEqual(hook.blocking, false, 'blocking must be false (advisory)');
assert.strictEqual(hook.onError, 'skip', 'onError must be skip');
assert.strictEqual(hook.when, 'workflow.post_planning_gaps', 'when must be workflow.post_planning_gaps');
assert.deepStrictEqual(hook.check, { query: 'gap-analysis.plan-post' }, 'check.query must be gap-analysis.plan-post');
// rendered must mention the gate
assert.ok(typeof envelope.rendered === 'string', 'rendered must be string');
assert.ok(envelope.rendered.includes('gap-analysis'), 'rendered must mention gap-analysis');
assert.ok(envelope.rendered.includes('gap-analysis.plan-post'), 'rendered must include check query');
});
test('[negative] render-hooks plan:post returns empty activeHooks when workflow.post_planning_gaps=false (gate deactivated)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { post_planning_gaps: false } })
);
const r = spawnRenderHooks('plan:post', tmpDir);
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
const envelope = JSON.parse(r.stdout);
assert.strictEqual(envelope.point, 'plan:post');
// GENUINE check: must be EMPTY, not length 1
assert.deepStrictEqual(envelope.activeHooks, [], 'activeHooks must be empty when gate disabled');
assert.strictEqual(envelope.rendered, '_No active hooks at plan:post._',
'rendered placeholder must match exactly when no hooks active');
});
test('[happy] render-hooks plan:post with explicit post_planning_gaps=true in config returns same hook as default', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { post_planning_gaps: true } })
);
const r = spawnRenderHooks('plan:post', tmpDir);
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
const envelope = JSON.parse(r.stdout);
assert.strictEqual(envelope.activeHooks.length, 1, 'exactly one hook with explicit true');
assert.strictEqual(envelope.activeHooks[0].capId, 'gap-analysis');
assert.strictEqual(envelope.activeHooks[0].blocking, false);
});
test('[bva] render-hooks plan:post envelope has exactly 3 keys (point, activeHooks, rendered) — Hyrum\'s law shape pin', () => {
const r = spawnRenderHooks('plan:post', tmpDir);
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
const envelope = JSON.parse(r.stdout);
const keys = Object.keys(envelope).sort();
assert.deepStrictEqual(keys, ['activeHooks', 'point', 'rendered'],
`envelope must have exactly 3 keys, got: ${keys.join(',')}`);
});
});
// ─── Section 2: check gap-analysis.plan-post — content tests ─────────────────
describe('check gap-analysis.plan-post — gate content E2E', () => {
let tmpDir;
let phaseDir;
beforeEach(() => {
tmpDir = createTempProject();
phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
const init = runGsdTools('config-ensure-section', tmpDir);
assert.ok(init.success, `config-ensure-section failed: ${init.error}`);
});
afterEach(() => cleanup(tmpDir));
// ── Coverage table tests ────────────────────────────────────────────────────
test('[happy] check gap-analysis.plan-post returns block:false with coverage table when phaseDir has plans covering some REQ-IDs', () => {
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01', 'REQ-02']);
writePlan(phaseDir, '01', '# Plan 1\n\nImplements REQ-01 only.\n');
const r = runGapCheck([phaseDir, 'REQ-01,REQ-02'], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
// GENUINE typed field assertions
assert.strictEqual(out.block, false, 'block must be false (gap-analysis is always advisory)');
assert.strictEqual(out.passed, true);
assert.strictEqual(out.enabled, true);
assert.strictEqual(out.counts.total, 2, 'total must be 2');
assert.strictEqual(out.counts.covered, 1, 'covered must be 1 (REQ-01 only)');
assert.strictEqual(out.counts.uncovered, 1, 'uncovered must be 1 (REQ-02 not in plan)');
// Table content — assert specific coverage rows
assert.ok(out.table.includes('REQ-01'), 'table must include REQ-01');
assert.ok(out.table.includes('REQ-02'), 'table must include REQ-02');
assert.ok(out.table.includes('✓ Covered'), 'table must show covered row');
assert.ok(out.table.includes('✗ Not covered'), 'table must show not-covered row');
});
test('[happy] check gap-analysis.plan-post returns block:false with all-covered summary when all REQ-IDs and D-IDs are in plans', () => {
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
writeContext(phaseDir, [{ id: 'D-01', text: 'Use pattern X for consistency' }]);
writePlan(phaseDir, '01', '# Plan 1\n\nImplements REQ-01 and D-01.\n');
const r = runGapCheck([phaseDir], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.strictEqual(out.block, false);
assert.strictEqual(out.enabled, true);
assert.strictEqual(out.counts.total, 2, 'total must be 2 (1 req + 1 decision)');
assert.strictEqual(out.counts.covered, 2, 'both items must be covered');
// GENUINE: uncovered must be 0, not 1
assert.strictEqual(out.counts.uncovered, 0, 'uncovered must be 0 when all covered');
assert.ok(/all 2 items covered/i.test(out.summary), `summary must say "all 2 items covered", got: ${out.summary}`);
});
test('[empty-resolution] check gap-analysis.plan-post returns block:false with empty rows when no REQUIREMENTS.md and no CONTEXT.md exist', () => {
// No REQUIREMENTS.md, no CONTEXT.md — only a PLAN.md
writePlan(phaseDir, '01', '# Plan\n\nSome content.\n');
const r = runGapCheck([phaseDir], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.strictEqual(out.block, false);
assert.strictEqual(out.enabled, true);
// GENUINE: total must be 0 (nothing to check)
assert.strictEqual(out.counts.total, 0, 'total must be 0 with no requirements/decisions');
assert.strictEqual(out.counts.uncovered, 0);
assert.ok(/no requirements or decisions/i.test(out.summary),
`summary must mention "no requirements or decisions", got: ${out.summary}`);
});
// ── Disabled gate tests ─────────────────────────────────────────────────────
test('[negative] check gap-analysis.plan-post returns enabled:false with block:false when workflow.post_planning_gaps=false', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { post_planning_gaps: false } })
);
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
writePlan(phaseDir, '01', '# Plan\n\nImplements REQ-01.\n');
const r = runGapCheck([phaseDir], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.strictEqual(out.block, false, 'block must be false when disabled');
assert.strictEqual(out.passed, true);
// GENUINE: enabled must be FALSE when gate is disabled
assert.strictEqual(out.enabled, false, 'enabled must be false when post_planning_gaps=false');
assert.strictEqual(out.table, '', 'table must be empty string when disabled');
assert.ok(/disabled/i.test(out.summary), `summary must mention disabled, got: ${out.summary}`);
assert.strictEqual(out.counts.total, 0);
});
// ── Missing arg tests ───────────────────────────────────────────────────────
test('[negative] check gap-analysis.plan-post exits non-zero with error string when phaseDir argument is omitted', () => {
// Pass only --raw, no phaseDir
const r = runGsdTools(['check', 'gap-analysis.plan-post', '--raw'], tmpDir);
// GENUINE: must fail, not succeed
assert.strictEqual(r.success, false, 'must fail when phaseDir omitted');
assert.strictEqual(r.exitCode, 1, 'exit code must be 1');
assert.ok(r.error.includes('requires a phase-dir argument'),
`stderr must say "requires a phase-dir argument", got: ${r.error}`);
// Output should NOT be valid JSON (it's an error message, not JSON)
let parsed;
try { parsed = JSON.parse(r.output); } catch (_) { parsed = null; }
assert.strictEqual(parsed, null, 'output must not be valid JSON when phase-dir is missing');
});
// ── BVA: phaseReqIds=TBD ────────────────────────────────────────────────────
test('[bva] check gap-analysis.plan-post with phaseReqIds=TBD returns zero requirement rows but still reports CONTEXT.md decisions', () => {
writeRequirements(path.join(tmpDir, '.planning'), ['OTHER-01', 'OTHER-02']);
writeContext(phaseDir, [{ id: 'D-01', text: 'Use canonical pattern for this module' }]);
writePlan(phaseDir, '01', '# Plan\n\nNo decisions addressed here.\n');
// TBD means: skip requirements, but still report CONTEXT.md decisions
const r = runGapCheck([phaseDir, 'TBD'], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.strictEqual(out.enabled, true);
// GENUINE: only D-01 (from CONTEXT.md) — OTHER-01/OTHER-02 must be excluded
assert.strictEqual(out.counts.total, 1, 'total must be 1 (only D-01 from CONTEXT.md)');
// REQUIREMENTS.md rows must not appear
assert.ok(!out.table.includes('OTHER-01'), 'OTHER-01 must not appear in table when phaseReqIds=TBD');
assert.ok(!out.table.includes('OTHER-02'), 'OTHER-02 must not appear in table when phaseReqIds=TBD');
// D-01 must appear
assert.ok(out.table.includes('D-01'), 'D-01 from CONTEXT.md must still appear');
});
// ── BVA: mapped REQ-ID absent from REQUIREMENTS.md ─────────────────────────
test('[bva] check gap-analysis.plan-post with mapped REQ-ID absent from REQUIREMENTS.md emits Missing-from-REQUIREMENTS.md status in table', () => {
// REQUIREMENTS.md has only REQ-01, but phaseReqIds includes REQ-99 (absent)
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
writePlan(phaseDir, '01', '# Plan\n\nImplements REQ-01.\n');
const r = runGapCheck([phaseDir, 'REQ-01,REQ-99'], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.strictEqual(out.enabled, true);
// GENUINE: uncovered must be 1 (REQ-99 is "missing" which counts as uncovered)
assert.strictEqual(out.counts.uncovered, 1, 'uncovered must be 1 for missing REQ-99');
assert.strictEqual(out.counts.total, 2, 'total must be 2 (REQ-01 + REQ-99)');
assert.ok(out.table.includes('REQ-99'), 'table must include REQ-99');
// GENUINE: the status row for REQ-99 must say "Missing from REQUIREMENTS.md"
assert.ok(out.table.includes('Missing from REQUIREMENTS.md'),
`table must contain "Missing from REQUIREMENTS.md" for REQ-99, got table: ${out.table}`);
// REQ-01 must still be covered
assert.ok(out.table.includes('✓ Covered'), 'REQ-01 must show as covered');
});
// ── #3189: prose trailing a real ID list must not be reported as missing ──
//
// ROADMAP `**Requirements:**` lines carry prose after the ID list (locked-
// decision annotations, ambiguity scores, prohibitions, dates). The workflow
// passes that value verbatim into --phase-req-ids. Pre-fix, every prose word
// was reported as an individually-missing requirement (8 real IDs became 21
// reported uncovered in the issue). Post-fix, only ID-shaped tokens reach the
// comparison.
//
// NOTE: the issue's exact reproduction uses hyphen-less `R1`..`R8`, but the
// codebase's REQUIREMENTS.md parser (`parseRequirements`, `ID_PATTERN =
// [A-Z][A-Z0-9]*-[A-Za-z0-9_-]+`) REQUIRES a hyphen, so `R1` is not a valid
// REQUIREMENTS.md ID in this codebase. The `R1`..`R8` shape is covered
// directly against `normalizePhaseReqIds` in tests/gap-checker.property.test.cjs
// (the filter accepts hyphen-less digit-bearing IDs per the issue spec). These
// E2E fixtures use the hyphenated `REQ-01`..`REQ-08` family so the full
// pipeline (parseRequirements → normalizePhaseReqIds → coverage compare) is
// exercised end-to-end; the prose annotations are the issue's verbatim.
test('[#3189] check gap-analysis.plan-post with prose-annotated phase-req-ids drops every prose fragment, keeps only ID-shaped tokens', () => {
// REQUIREMENTS.md defines exactly REQ-01..REQ-08 — the real requirement set.
writeRequirements(path.join(tmpDir, '.planning'),
['REQ-01', 'REQ-02', 'REQ-03', 'REQ-04', 'REQ-05', 'REQ-06', 'REQ-07', 'REQ-08']);
// The plan addresses all eight real IDs.
writePlan(phaseDir, '01',
'# Plan\n\nImplements REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-07, and REQ-08.\n');
// The issue's prose-annotated shape, with REQ-01..REQ-08 as the ID list.
// The trailing clause is the issue's verbatim prose (locked-decision
// annotation, ambiguity score, prohibition range).
const rawReqIds = 'REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-07, REQ-08 (locked <date> — canonical source `NN-SPEC.md ## Requirements`; ambiguity 0.12; + prohibitions P1-P3)';
const r = runGapCheck([phaseDir, rawReqIds], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
// After the #3189 shape filter, the ID-shaped tokens that survive are
// REQ-01..REQ-08 PLUS `P1-P3` (from "prohibitions P1-P3"). `P1-P3` is
// syntactically ID-shaped — it matches `PHASE_REQ_ID_SHAPE_RE` exactly as
// `SEL-01` does, and the codebase's `ID_PATTERN` accepts it too, so dropping
// it would create a false mismatch for projects using `P1-P3`-shape IDs. It
// is NOT in REQUIREMENTS.md, so it correctly surfaces as a single "Missing
// from REQUIREMENTS.md" ghost row.
//
// Pre-fix, this same input produced a report where every prose word was a
// separate "Missing from REQUIREMENTS.md" row. Post-fix the prose noise is
// gone: 8 covered REQ-IDs + 1 ID-shaped ghost (P1-P3).
assert.strictEqual(out.counts.total, 9,
`total must be 9 (REQ-01..REQ-08 + the ID-shaped P1-P3); got ${out.counts.total}. Full table:\n${out.table}`);
assert.strictEqual(out.counts.covered, 8, 'all 8 REQ-IDs are covered by the plan');
assert.strictEqual(out.counts.uncovered, 1, 'the one uncovered item is P1-P3 (ID-shaped ghost, not prose)');
// Every surviving ID-shaped token must appear in the table. (The check
// query exposes `table`/`counts` but not the raw `rows` array, so assert
// via the rendered table.)
for (const id of ['REQ-01', 'REQ-02', 'REQ-03', 'REQ-04', 'REQ-05', 'REQ-06', 'REQ-07', 'REQ-08', 'P1-P3']) {
assert.ok(out.table.includes(id),
`${id} must appear in the table. Full table:\n${out.table}`);
}
// P1-P3 (the ID-shaped ghost) must be flagged Missing from REQUIREMENTS.md.
assert.ok(out.table.includes('Missing from REQUIREMENTS.md'),
`P1-P3 must be flagged Missing from REQUIREMENTS.md. Full table:\n${out.table}`);
// No prose fragment may appear as a table row — these are the exact
// fragments the issue reported as fake missing requirements. Only table
// ROW lines (starting with `|`) are checked: the table string itself begins
// with the `## Post-Planning Gap Analysis` markdown heading, so a raw
// `out.table.includes('##')` would trivially be true.
const tableRows = out.table.split('\n').filter(line => line.startsWith('|'));
const proseFragments = ['—', '##', '`NN-SPEC.md', '+', '0.12;', '<date>',
'ambiguity', 'locked', 'prohibitions', 'Requirements;', 'canonical', 'source;'];
for (const frag of proseFragments) {
assert.ok(!tableRows.some(line => line.includes(frag)),
`prose fragment ${JSON.stringify(frag)} must NOT appear in any table row. Full table:\n${out.table}`);
}
});
test('[#3189] a genuinely-missing real requirement ID is still reported (no narrowing) — prose-annotated mixed list', () => {
// REQUIREMENTS.md defines REQ-01..REQ-03 only; REQ-99 is cited in
// phase-req-ids but absent (a real missing-requirement signal). The trailing
// prose must be dropped, but REQ-99 (a real ID shape) must still be reported
// as Missing — the fix must not narrow real coverage detection.
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01', 'REQ-02', 'REQ-03']);
writePlan(phaseDir, '01', '# Plan\n\nImplements REQ-01, REQ-02, REQ-03.\n');
const rawReqIds = 'REQ-01, REQ-02, REQ-03, REQ-99 (locked 2026-08-07 — ambiguity 0.12)';
const r = runGapCheck([phaseDir, rawReqIds], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
// GENUINE: total must be 4 — REQ-01..REQ-03 (covered) + REQ-99 (ghost). The
// prose is dropped, but the real missing ID REQ-99 is preserved.
assert.strictEqual(out.counts.total, 4,
`total must be 4 (REQ-01..REQ-03 + REQ-99; prose dropped, REQ-99 preserved); got ${out.counts.total}`);
assert.strictEqual(out.counts.uncovered, 1, 'REQ-99 is the one uncovered item');
assert.ok(out.table.includes('REQ-99'), 'REQ-99 (real missing ID) must appear in the table');
assert.ok(out.table.includes('Missing from REQUIREMENTS.md'),
'REQ-99 must be flagged Missing from REQUIREMENTS.md');
// Prose fragments still dropped.
assert.ok(!out.table.includes('locked'), 'prose "locked" must NOT appear');
assert.ok(!out.table.includes('ambiguity'), 'prose "ambiguity" must NOT appear');
});
});
// ─── Section 3: Full pipeline — render-hooks → check dispatch ─────────────────
describe('Full pipeline: render-hooks plan:post discovers gate, then check dispatched', () => {
let tmpDir;
let phaseDir;
beforeEach(() => {
tmpDir = createTempProject();
phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
const init = runGsdTools('config-ensure-section', tmpDir);
assert.ok(init.success, `config-ensure-section failed: ${init.error}`);
});
afterEach(() => cleanup(tmpDir));
test('[happy] Full pipeline: render-hooks plan:post discovers gate hook, then check dispatched with hook.check.query returns advisory table — gate never blocking', () => {
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01', 'REQ-02']);
writePlan(phaseDir, '01', '# Plan 1\n\nImplements REQ-01 only.\n');
// Step 1: discover the gate hook via render-hooks
const hookResult = spawnRenderHooks('plan:post', tmpDir);
assert.strictEqual(hookResult.status, 0, `render-hooks exited non-zero: ${hookResult.stderr}`);
const envelope = JSON.parse(hookResult.stdout);
assert.strictEqual(envelope.activeHooks.length, 1, 'must discover exactly 1 gate hook');
const hook = envelope.activeHooks[0];
// GENUINE: gate must be advisory (blocking=false)
assert.strictEqual(hook.blocking, false, 'gap-analysis gate must be non-blocking');
assert.strictEqual(hook.check.query, 'gap-analysis.plan-post', 'check.query must be gap-analysis.plan-post');
// Step 2: dispatch the check using the discovered query
const checkResult = runGapCheck([phaseDir], tmpDir);
assert.ok(checkResult.success, `check failed: ${checkResult.error}`);
const out = JSON.parse(checkResult.output);
// GENUINE: the check result must also say block:false
assert.strictEqual(out.block, false, 'check must return block:false (advisory gate)');
assert.strictEqual(out.counts.uncovered, 1, 'one uncovered item: REQ-02');
assert.ok(out.table.length > 0, 'table must be non-empty');
assert.ok(out.table.includes('REQ-01'), 'table must show REQ-01');
assert.ok(out.table.includes('REQ-02'), 'table must show REQ-02');
});
test('[happy] Full pipeline: when post_planning_gaps=true and all items covered, check returns zero uncovered', () => {
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
writePlan(phaseDir, '01', '# Plan\n\nImplements REQ-01.\n');
// Confirm hook exists via render-hooks
const hookResult = spawnRenderHooks('plan:post', tmpDir);
assert.strictEqual(hookResult.status, 0);
const envelope = JSON.parse(hookResult.stdout);
assert.strictEqual(envelope.activeHooks.length, 1);
// Run the check
const checkResult = runGapCheck([phaseDir], tmpDir);
assert.ok(checkResult.success, `check failed: ${checkResult.error}`);
const out = JSON.parse(checkResult.output);
assert.strictEqual(out.block, false);
assert.strictEqual(out.enabled, true);
assert.strictEqual(out.counts.total, 1);
assert.strictEqual(out.counts.covered, 1);
assert.strictEqual(out.counts.uncovered, 0);
});
test('[negative] Full pipeline: when post_planning_gaps=false, render-hooks returns empty and check returns enabled:false — dual contract agreement', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { post_planning_gaps: false } })
);
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
writePlan(phaseDir, '01', '# Plan\n\nSome content.\n');
// Step 1: render-hooks must return empty (gate suppressed)
const hookResult = spawnRenderHooks('plan:post', tmpDir);
assert.strictEqual(hookResult.status, 0);
const envelope = JSON.parse(hookResult.stdout);
// GENUINE: both render-hooks and check must agree on suppression
assert.deepStrictEqual(envelope.activeHooks, [],
'render-hooks must return empty activeHooks when gate disabled');
assert.strictEqual(envelope.rendered, '_No active hooks at plan:post._');
// Step 2: check must return enabled:false, confirming dual-contract agreement
writePlan(phaseDir, '01', '# Plan\n\nSome content.\n');
const checkResult = runGapCheck([phaseDir], tmpDir);
assert.ok(checkResult.success, `check failed: ${checkResult.error}`);
const out = JSON.parse(checkResult.output);
// GENUINE: enabled must be false (both surfaces agree gate is suppressed)
assert.strictEqual(out.enabled, false,
'check must return enabled:false when render-hooks also shows empty — dual contract parity');
});
});
// ─── Section 3b: issue #2316 (Secondary B) — all-unregistered ghost REQ-IDs ──
//
// runGapAnalysis's `items.length === 0` short-circuit fires BEFORE ghostReqIds
// (phaseReqIds cited by ROADMAP but absent from REQUIREMENTS.md) is folded
// into `rows`. When EVERY cited REQ-ID is a ghost, reqItems is filtered down
// to [] and there is no CONTEXT.md, so items.length === 0 and the ghost rows
// never appear — the phase reports "No requirements or decisions to check."
// instead of the ⚠ Missing from REQUIREMENTS.md rows. A phase with ONE
// unregistered ID mixed with a registered one already surfaces correctly
// (see the mapped-REQ-ID-absent test in Section 2 above) — only the
// ALL-unregistered case is broken.
describe('issue #2316 (Secondary B): all-unregistered phaseReqIds must still emit ghost rows, not the empty-state message', () => {
let tmpDir;
let phaseDir;
beforeEach(() => {
tmpDir = createTempProject();
phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
const init = runGsdTools('config-ensure-section', tmpDir);
assert.ok(init.success, `config-ensure-section failed: ${init.error}`);
writeRequirements(path.join(tmpDir, '.planning'), ['KNOWN-01']);
writePlan(phaseDir, '01', '# Plan\n\nImplements KNOWN-01.\n');
});
afterEach(() => cleanup(tmpDir));
test(
'#2316-6a (control, mixed known+ghost): a phase mapping one registered and one ghost REQ-ID already surfaces the ghost row',
() => {
const r = runGapCheck([phaseDir, 'KNOWN-01,ORPHAN-01'], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.ok(
out.table.includes('ORPHAN-01') && out.table.includes('Missing from REQUIREMENTS.md'),
`#2316-6a FAILED: mixed control must still list ORPHAN-01 as Missing, got table: ${out.table}`,
);
assert.strictEqual(out.counts.total, 2, '#2316-6a FAILED: total must count both REQ-IDs');
},
);
test(
'#2316-6b: a phase whose EVERY cited REQ-ID is unregistered must still return the ghost rows, not "No requirements or decisions to check."',
() => {
const r = runGapCheck([phaseDir, 'ORPHAN-01,ORPHAN-02'], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.ok(
!/No requirements or decisions to check/.test(out.table),
`#2316-6b FAILED: all-unregistered REQ-IDs must not collapse to the empty-state message.\nFull table: ${out.table}`,
);
assert.ok(
out.table.includes('ORPHAN-01') && out.table.includes('ORPHAN-02'),
`#2316-6b FAILED: table must list both ghost REQ-IDs, got: ${out.table}`,
);
assert.ok(
out.table.includes('Missing from REQUIREMENTS.md'),
`#2316-6b FAILED: table must show "Missing from REQUIREMENTS.md" status for the ghost IDs, got: ${out.table}`,
);
assert.strictEqual(out.counts.total, 2, '#2316-6b FAILED: total must count both ghost REQ-IDs, not 0');
assert.strictEqual(out.counts.uncovered, 2, '#2316-6b FAILED: uncovered must count both ghost REQ-IDs, not 0');
assert.strictEqual(out.enabled, true, '#2316-6b FAILED: enabled must still be true');
},
);
test(
'#2334 HIGH 1: all-ghost REQ-IDs + a malformed CONTEXT.md <decisions> line (could-not-parse) must still return the ghost rows, not silently drop to the mismatch-only message',
() => {
// Same "items.length === 0" defect #2316-6b fixed ~34 lines below in
// runGapAnalysis, but in the sibling `ctxExtraction.outcome ===
// 'could-not-parse'` branch a few lines ABOVE it: that branch gated its
// own ghost-row inclusion on the unguarded `items.length > 0`, so a
// malformed <decisions> line made both ghost rows vanish and `total`
// drop 2 -> 0 for the exact same all-ghost phase #2316-6b already
// covers on the happy (non-malformed) CONTEXT.md path.
fs.writeFileSync(
path.join(phaseDir, 'CONTEXT.md'),
'# Phase Context\n\n<decisions>\n## Implementation Decisions\n\n' +
'- **D-01 this line is malformed and has no closing bold/colon\n</decisions>\n',
);
const r = runGapCheck([phaseDir, 'ORPHAN-01,ORPHAN-02'], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.ok(
!/No requirements or decisions to check/.test(out.table),
`#2334 HIGH 1 FAILED: all-ghost + could-not-parse must not collapse to the empty-state message.\nFull table: ${out.table}`,
);
assert.ok(
out.table.includes('ORPHAN-01') && out.table.includes('ORPHAN-02'),
`#2334 HIGH 1 FAILED: table must still list both ghost REQ-IDs despite the malformed decisions line, got: ${out.table}`,
);
assert.ok(
/format mismatch/i.test(out.table),
`#2334 HIGH 1 FAILED: the could-not-parse signal must still be present in the table, got: ${out.table}`,
);
assert.strictEqual(out.counts.total, 2, '#2334 HIGH 1 FAILED: total must count both ghost REQ-IDs, not 0');
assert.strictEqual(out.counts.uncovered, 2, '#2334 HIGH 1 FAILED: uncovered must count both ghost REQ-IDs, not 0');
},
);
});
// ─── Section 4: Pure resolver tests against real registry ────────────────────
describe('resolveLoopHooks plan:post — pure function against real registry', () => {
const { resolveLoopHooks, renderLoopHooks } = require('../gsd-core/bin/lib/loop-resolver.cjs');
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
test('[happy] resolveLoopHooks plan:post with post_planning_gaps=true returns one gap-analysis gate', () => {
const result = resolveLoopHooks({
point: 'plan:post',
registry: realRegistry,
config: { workflow: { post_planning_gaps: true } },
});
assert.strictEqual(result.point, 'plan:post');
assert.ok(Array.isArray(result.activeHooks));
assert.strictEqual(result.activeHooks.length, 1, 'must be exactly 1 hook with post_planning_gaps=true');
const hook = result.activeHooks[0];
assert.strictEqual(hook.capId, 'gap-analysis');
assert.strictEqual(hook.kind, 'gate');
assert.strictEqual(hook.blocking, false);
assert.strictEqual(hook.onError, 'skip');
});
test('[negative] resolveLoopHooks plan:post with post_planning_gaps=false returns empty activeHooks', () => {
const result = resolveLoopHooks({
point: 'plan:post',
registry: realRegistry,
config: { workflow: { post_planning_gaps: false } },
});
assert.strictEqual(result.point, 'plan:post');
// GENUINE: must be empty array (not length-1)
assert.deepStrictEqual(result.activeHooks, [],
'activeHooks must be empty when post_planning_gaps=false');
});
test('[happy] renderLoopHooks plan:post with empty activeHooks returns exact placeholder string', () => {
const result = resolveLoopHooks({
point: 'plan:post',
registry: realRegistry,
config: { workflow: { post_planning_gaps: false } },
});
const rendered = renderLoopHooks(result);
// GENUINE: must be exact placeholder, not a hook string
assert.strictEqual(rendered, '_No active hooks at plan:post._',
'rendered must be exact placeholder when no active hooks');
});
test('[bva] resolveLoopHooks plan:post with absent config uses schema default (post_planning_gaps=true)', () => {
// No workflow key in config → schema default should be true → hook active
const result = resolveLoopHooks({
point: 'plan:post',
registry: realRegistry,
config: {},
});
// GENUINE: schema default=true means the hook should activate even with empty config
assert.strictEqual(result.activeHooks.length, 1,
'schema default for post_planning_gaps is true — hook must activate with empty config');
assert.strictEqual(result.activeHooks[0].capId, 'gap-analysis');
});
test('[happy] real registry byLoopPoint plan:post has 1 step (mempalace), 2 contributions (external-job planner + claude-orchestration ultraplan ownership), and 1 gate (gap-analysis)', () => {
const entry = realRegistry.byLoopPoint['plan:post'];
assert.ok(entry, 'plan:post must exist in byLoopPoint');
assert.ok(Array.isArray(entry.steps), 'steps must be an array');
assert.ok(Array.isArray(entry.contributions), 'contributions must be an array');
assert.ok(Array.isArray(entry.gates), 'gates must be an array');
assert.strictEqual(entry.steps.length, 1, 'plan:post must have 1 step (mempalace capture)');
assert.strictEqual(entry.steps[0].capId, 'mempalace', 'plan:post step must be from mempalace');
// #1143: claude-orchestration registers a plan:post contribution declaring
// ultraplan plan-offload ownership under its runtime gate (default-off).
assert.strictEqual(entry.contributions.length, 2, 'plan:post must have 2 contributions (external-job planner + claude-orchestration ultraplan ownership)');
const capIds = entry.contributions.map(c => c.capId).sort();
assert.deepStrictEqual(capIds, ['claude-orchestration', 'external-job'],
`plan:post contributions must be external-job + claude-orchestration; got ${capIds.join(',')}`);
assert.strictEqual(entry.gates.length, 1, 'plan:post must have exactly one gate');
assert.strictEqual(entry.gates[0].capId, 'gap-analysis');
});
});
// ─── #4652: containment boundaries — resolvePath (check-command-router.cts:92)
// and `check gap-analysis.plan-post <phase-dir>` ───────────────────────────────
//
// Boundary 3: `check decision-coverage-plan <phase-dir>` resolves the phase-dir
// positional via `resolvePath()`, which just does
// `path.isAbsolute(p) ? p : path.join(projectDir, p)` — no containment check.
// Boundary 4: `check gap-analysis.plan-post <phase-dir>` takes `args[2]`
// unconfined and joins it directly in `runGapAnalysis` (gap-checker.cts).
function runDecisionCoveragePlan(extraFlags, phaseDir, contextPath, cwd) {
return runGsdTools(['query', 'check.decision-coverage-plan', ...extraFlags, phaseDir, contextPath], cwd);
}
describe('resolvePath / check decision-coverage-plan — containment boundary (#4652)', () => {
let tmpDir;
let phaseDir;
let outsideDir;
beforeEach(() => {
tmpDir = createTempProject();
phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-decision-outside-'));
});
afterEach(() => {
cleanup(tmpDir);
cleanup(outsideDir);
});
test('[RED #4652] an outside phase-dir is rejected (currently resolves and proceeds unconfined)', () => {
const contextPath = path.join(phaseDir, 'CONTEXT.md');
fs.writeFileSync(
contextPath,
'# Phase Context\n\n<decisions>\n## Implementation Decisions\n\n- **D-01:** Use pattern X\n</decisions>\n',
);
fs.writeFileSync(path.join(outsideDir, '01-PLAN.md'), '# Plan\n\nImplements D-01.\n');
const relOutside = path.relative(tmpDir, outsideDir);
const result = runGsdTools(
['--json-errors', 'query', 'check.decision-coverage-plan', relOutside, contextPath],
tmpDir,
);
assert.strictEqual(
result.success,
false,
`an outside phase-dir must be rejected before evaluating plan coverage ` +
`(currently: ${result.success ? `SUCCEEDED with output ${result.output}` : 'failed for an unrelated reason'})`,
);
});
test('[regression] a valid in-project relative phase-dir still proceeds', () => {
const contextPath = path.join(phaseDir, 'CONTEXT.md');
fs.writeFileSync(
contextPath,
'# Phase Context\n\n<decisions>\n## Implementation Decisions\n\n- **D-01:** Use pattern X\n</decisions>\n',
);
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), '# Plan\n\nImplements D-01.\n');
const relPhaseDir = path.relative(tmpDir, phaseDir);
const result = runDecisionCoveragePlan([], relPhaseDir, contextPath, tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, true, 'in-project phase-dir with covered decision must pass');
});
test('[regression] an absolute path INSIDE the project is accepted', () => {
const contextPath = path.join(phaseDir, 'CONTEXT.md');
fs.writeFileSync(
contextPath,
'# Phase Context\n\n<decisions>\n## Implementation Decisions\n\n- **D-01:** Use pattern X\n</decisions>\n',
);
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), '# Plan\n\nImplements D-01.\n');
const result = runDecisionCoveragePlan([], phaseDir, contextPath, tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.passed, true, 'absolute in-project phase-dir must be accepted');
});
});
describe('check gap-analysis.plan-post — containment boundary (#4652)', () => {
let tmpDir;
let phaseDir;
let outsideDir;
beforeEach(() => {
tmpDir = createTempProject();
phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
const init = runGsdTools('config-ensure-section', tmpDir);
assert.ok(init.success, `config-ensure-section failed: ${init.error}`);
outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-gap-outside-'));
});
afterEach(() => {
cleanup(tmpDir);
cleanup(outsideDir);
});
test('[RED #4652] an outside phase-dir is rejected (currently resolves and proceeds unconfined)', () => {
fs.writeFileSync(path.join(outsideDir, '01-PLAN.md'), '# Plan\n\nSome content.\n');
const relOutside = path.relative(tmpDir, outsideDir);
const result = runGsdTools(['--json-errors', 'check', 'gap-analysis.plan-post', relOutside, '--raw'], tmpDir);
assert.strictEqual(
result.success,
false,
`an outside phase-dir must be rejected ` +
`(currently: ${result.success ? `SUCCEEDED with output ${result.output}` : 'failed for an unrelated reason'})`,
);
});
test('[regression] a valid phase-dir still proceeds (advisory, block:false)', () => {
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
writePlan(phaseDir, '01', '# Plan\n\nImplements REQ-01.\n');
const r = runGapCheck([phaseDir], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.strictEqual(out.block, false, 'gap-analysis is always advisory');
});
test('[regression] a missing phase-dir argument still gives the existing SDK_MISSING_ARG error', () => {
const result = runGsdTools(['--json-errors', 'check', 'gap-analysis.plan-post', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'must fail when phaseDir omitted');
const parsed = JSON.parse(result.error);
assert.strictEqual(parsed.reason, 'sdk_missing_arg', 'must keep the existing SDK_MISSING_ARG reason');
});
});