Files
msd-core/tests/check-tdd-review-checkpoint-e2e.test.cjs
Tom Boucher 9faacc0c15 test(#3148): bound the long tail and delete the unbounded-spawn allowlist (#3192)
* test(#3148): bound the long tail and delete the allowlist

Migrates the final 170 unbounded sync spawn sites across 49 files, then
removes the allowlist entirely. local/no-unbounded-spawn now runs with no
exemption surface across tests/**: there is no file to add a name to.

drift-detection's throw-native git() helper routes to gitOrThrow -- bare
runGit would have taken 16 call sites quiet on failure. commands.test.cjs
has two independently-scoped runGsdTools/runCli helpers, one already bounded
and one not; they are kept distinct rather than unified, the same trap as the
two same-named git() helpers in Wave 1.

runNpm's bound was erasable. Its options spread callerOptions after the
defaults, so an explicit timeout:undefined silently dropped the 180000ms
bound -- the rule flagged it and was right; it was not a false positive. Fixed
by destructuring with a default, with a test that fails when the default is
removed.

Two sites stay on a raw spawn with an explicit timeout because the seam
cannot express them: one needs shell:true for npm.cmd on Windows, one
redirects stdout to a real fd. Both are the rule's own documented second
option, not an escape from it.

Closure verified rather than asserted: the derivation scan reports 0 unbounded
spawn helpers and 0 unbounded direct git call sites, and a temporary file
carrying an unbounded spawn still errors with the allowlist gone.

Closes #3064.

* test(#3148): close a hole in the guard's own eslint-disable ban

The ban listed only the top level of tests/, so it was blind to 37 .cjs
files under tests/helpers, qa, observability, fixtures and dispatch. With the
allowlist deleted this test is the sole remaining way to detect someone
silencing the rule inline, so the gap was load-bearing: a nested file could
carry an unbounded spawn plus an eslint-disable and pass everything.

Proven before and after. A probe planted under tests/helpers with both was
invisible to the guard and clean under eslint; after making the listing
recursive the guard fails on it. The scanned set goes from 771 files to 808.

Pre-existing since the guard shipped, but this wave is what promoted it to
sole defense, so it is fixed here rather than filed.

Also converts the last hand-rolled throw check to throwIfFailed and the last
re-derived legacy shape to compose toLegacyResult, which makes the epic's
none-remain claim true rather than nearly true. toLegacyResult itself is not
widened -- eight callers depend on its shape and one consumer does not
justify changing a shared contract.

* fix(#3148): correct seam incoherence at the bound and a slow review-lane error path

Two real failures from the remote runner, both fixed at the cause.

The seam could return outcome TIMED_OUT together with exitCode 0. At the
exact bound spawnSync reports ETIMEDOUT while the child has already exited
with a real status, and toSeamResult classified on the error code while
passing status straight through -- an incoherent pair its own boundary test
was written to catch, and did. A status that is not null is direct evidence
the child exited on its own, so it now decides the outcome before the
error-code branches run. process-seam.cjs was deliberately untouched by every
earlier wave; this is a defect in the module itself, kept surgical, with a
unit test that fails against the old logic.

review-lane with an unknown subcommand fell through to its usage error only
after loading the capability registry and building a per-lane plan, which
spawns one child process per lane -- up to twelve. The error path took
~1288ms instead of ~119ms, and under bench load it outran a caller's spawn
timeout and was killed before writing anything, which is the empty stdout and
stderr CI saw. It now fails fast before any of that work begins.

This is the epic's first production change. It is user-facing, so it carries
a changeset rather than a no-changelog label.

* test(#3148): replace a real-race timeout test with a deterministic one

E9 raced git rev-parse against a 1ms bound and assumed git always lost. On a
warm container git finishes first, spawnSync returns status 0 with no error
at all, the seam correctly classifies EXITED, and gitOrThrow correctly does
not throw -- so the test failed on both lanes. A probe confirms a genuine
timeout always carries status null, so this was never the seam misbehaving.

Raising the bound would only lengthen the odds, which is the same defect with
better luck. The test now drives gitOrThrow against a stubbed runGit that
returns a synthetic TIMED_OUT result, so it asserts exactly what it always
meant to -- that a timeout propagates as a throw -- with no timing
dependence. Five consecutive runs are identical where the old one varied.

I wrote this test in Wave 0; it is a real-race test by construction and
CLAUDE.md says to replace those rather than re-run them.

* chore(#3148): backfill changeset PR number 3192

---------

Co-authored-by: sim <sim@local>
2026-08-07 21:03:50 -04:00

538 lines
24 KiB
JavaScript

'use strict';
/**
* E2E content tests for execute:post hook resolution and tdd.review-checkpoint gate.
*
* Hook point: execute:post
* Focus:
* - loop render-hooks execute:post typed envelope (step + gate ordering, both-on / tdd-off / both-off)
* - check tdd.review-checkpoint via CLI subprocess with real git fixtures:
* RED+GREEN → block:false,violations:0,Pass
* no commits → block:true,missing:[RED,GREEN]
* RED only → block:true,missing:[GREEN]
* no type:tdd plans → block:false,tddPlans:0
* violations=1 boundary → block:true with advisory table
* missing phase arg → exitCode:1
* - rendered text format: Step 1 code-review before Gate tdd
*
* HARD RULES followed:
* - CONTENT/E2E only: every test drives a real CLI subprocess or real resolver
* - No readFileSync source-grep (scripts/lint-no-source-grep.cjs would reject it)
* - Genuine assertions: negative/BVA cases assert the SPECIFIC differing value
* - Fully isolated: each test has its own createTempProject / createTempGitProject
* - Git fixtures use real file commits (not --allow-empty) so git log --grep -- . matches
*/
const { describe, test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
// ─── Git fixture helper (inlined — do NOT modify helpers.cjs) ──────────────────
/**
* Create a temp dir with a git repo and initial commit containing a .planning/
* phases directory structure. Commits real files (not --allow-empty) so that
* git log --grep -- . works correctly (the -- path filter skips empty-tree commits).
*
* Returns { tmpDir } — cleanup() in afterEach.
*/
function createTddGitFixture({ planFiles = [] } = {}) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tdd-e2e-'));
function git(...args) {
return gitOrThrow(args, {
cwd: tmpDir,
env: {
...process.env,
GIT_AUTHOR_NAME: 'Test',
GIT_AUTHOR_EMAIL: 'test@test.com',
GIT_COMMITTER_NAME: 'Test',
GIT_COMMITTER_EMAIL: 'test@test.com',
},
}).trim();
}
git('init', '--initial-branch=main');
git('config', 'user.email', 'test@test.com');
git('config', 'user.name', 'Test');
// Create planning directory
const planningDir = path.join(tmpDir, '.planning');
const phasesDir = path.join(planningDir, 'phases');
fs.mkdirSync(planningDir, { recursive: true });
// Write plan files
for (const { dir, filename, content } of planFiles) {
const phaseDir = path.join(phasesDir, dir);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, filename), content, 'utf8');
}
// Write a config.json with git tracking so initial commit has a real file
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}', 'utf8');
git('add', '.');
git('commit', '-m', 'init: project scaffold');
return { tmpDir, git };
}
/**
* Build a type:tdd PLAN.md frontmatter block content.
*/
function tddPlan(phaseNum, planId) {
return `---\ntype: tdd\nphase: ${phaseNum}\nslug: ${planId}\n---\n# Task: ${planId}\n`;
}
/**
* Same as tddPlan but with CRLF line endings — reproduces #2449.
* Some Windows editors / toolchains write PLAN.md with CRLF; with
* core.autocrlf=input the working-tree CRLF is invisible to git diff/status
* (the blob is normalized on commit), so the contributor cannot tell their
* plan is being silently misclassified.
*/
function tddPlanCrlf(phaseNum, planId) {
return `---\r\ntype: tdd\r\nphase: ${phaseNum}\r\nslug: ${planId}\r\n---\r\n# Task: ${planId}\r\n`;
}
/**
* Mixed-endings variant: CRLF frontmatter + LF body. Realistic when a Windows
* editor normalizes Markdown body text but preserves frontmatter byte-for-byte,
* or when tooling concatenates CRLF + LF fragments. The frontmatter delimiter
* still must match (the bug class), and the LF body must not be confused by
* downstream code that splits on \n (it gets an LF-prefixed body).
*/
function tddPlanMixedCrlfLf(phaseNum, planId) {
return `---\r\ntype: tdd\r\nphase: ${phaseNum}\r\nslug: ${planId}\r\n---\n# Task: ${planId}\n`;
}
/**
* Build a type:execute PLAN.md (non-TDD) content.
*/
function executePlan(phaseNum, planId) {
return `---\ntype: execute\nphase: ${phaseNum}\nslug: ${planId}\n---\n# Task: ${planId}\n`;
}
/**
* Commit a real file in the git fixture with the given commit message.
* Needed because git log --grep with -- path filter only matches commits
* that changed at least one tracked file.
*/
function commitFile(git, tmpDir, filename, commitMessage) {
const filepath = path.join(tmpDir, filename);
// Append timestamp to make each file unique
fs.writeFileSync(filepath, `${commitMessage}\n${Date.now()}\n`, 'utf8');
git('add', filepath);
git('commit', '-m', commitMessage);
}
// ─── Helpers for subprocess invocation ────────────────────────────────────────
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
};
function runTools(args, cwd) {
const argv = Array.isArray(args)
? args
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
.map((t) => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
try {
const stdout = execFileSync(process.execPath, [TOOLS_PATH, ...argv], {
cwd,
encoding: 'utf-8',
env: { ...process.env, ...TEST_ENV_BASE },
timeout: 60000,
});
return { success: true, output: stdout.trim(), exitCode: 0, error: '' };
} catch (err) {
return {
success: false,
output: err.stdout?.toString().trim() || '',
error: err.stderr?.toString().trim() || err.message,
exitCode: err.status ?? 1,
};
}
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe('execute:post render-hooks — typed envelope resolution', () => {
let tmpDir;
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
test('[happy] tdd_mode=true and code_review=true: both hooks in typed shape with step before gate', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ep-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { code_review: true, tdd_mode: true } }),
'utf8'
);
const result = runTools('loop render-hooks execute:post --raw', tmpDir);
assert.ok(result.success, `render-hooks should succeed. stderr: ${result.error}`);
const envelope = JSON.parse(result.output);
assert.strictEqual(envelope.point, 'execute:post', 'point field must be execute:post');
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array');
assert.strictEqual(envelope.activeHooks.length, 2, 'both hooks (step + gate) must be active');
// Step 1: code-review step (must come before gate)
const step = envelope.activeHooks[0];
assert.strictEqual(step.kind, 'step', 'first hook must be a step');
assert.strictEqual(step.capId, 'code-review', 'step capId must be code-review');
assert.deepStrictEqual(step.ref, { skill: 'code-review' }, 'step ref must point to code-review skill');
assert.ok(Array.isArray(step.produces), 'produces must be array');
assert.ok(step.produces.includes('REVIEW.md'), 'step must produce REVIEW.md');
assert.strictEqual(step.onError, 'skip', 'code-review step onError must be skip');
// Gate: tdd advisory gate (must come after step)
const gate = envelope.activeHooks[1];
assert.strictEqual(gate.kind, 'gate', 'second hook must be a gate');
assert.strictEqual(gate.capId, 'tdd', 'gate capId must be tdd');
assert.deepStrictEqual(gate.check, { query: 'tdd.review-checkpoint' }, 'gate check query must match');
assert.strictEqual(gate.blocking, false, 'tdd gate must be advisory (blocking=false)');
});
test('[negative] code_review=false and tdd_mode=false: empty activeHooks with no-hooks rendered text', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ep-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { code_review: false, tdd_mode: false } }),
'utf8'
);
const result = runTools('loop render-hooks execute:post --raw', tmpDir);
assert.ok(result.success, `render-hooks should succeed even with both disabled. stderr: ${result.error}`);
const envelope = JSON.parse(result.output);
assert.strictEqual(envelope.point, 'execute:post');
// SPECIFIC assertion: 0 hooks, not 1 or 2
assert.strictEqual(envelope.activeHooks.length, 0, 'both disabled: must return ZERO active hooks, not any');
assert.ok(
envelope.rendered.includes('_No active hooks at execute:post._'),
`rendered must contain placeholder text, got: ${envelope.rendered}`
);
});
test('[negative] tdd_mode=false excludes tdd gate but code-review step active by schema default', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ep-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { tdd_mode: false } }),
'utf8'
);
const result = runTools('loop render-hooks execute:post --raw', tmpDir);
assert.ok(result.success, `render-hooks should succeed. stderr: ${result.error}`);
const envelope = JSON.parse(result.output);
// SPECIFIC assertion: exactly 1 hook (code-review only), not 0 or 2
assert.strictEqual(envelope.activeHooks.length, 1, 'tdd_mode=false: exactly 1 hook (step only), not 2');
assert.strictEqual(envelope.activeHooks[0].capId, 'code-review', 'sole hook must be code-review step');
assert.strictEqual(envelope.activeHooks[0].kind, 'step', 'sole hook must be kind=step');
// Confirm tdd gate is absent
const tddHook = envelope.activeHooks.find((h) => h.capId === 'tdd');
assert.strictEqual(tddHook, undefined, 'no tdd gate hook must be present when tdd_mode=false');
});
test('[happy] rendered text format: Step 1 code-review before Gate tdd in correct markdown', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ep-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { code_review: true, tdd_mode: true } }),
'utf8'
);
const result = runTools('loop render-hooks execute:post --raw', tmpDir);
assert.ok(result.success, `render-hooks should succeed. stderr: ${result.error}`);
const envelope = JSON.parse(result.output);
const rendered = envelope.rendered;
// Step 1 code-review heading must appear first
assert.ok(
rendered.includes('### Step 1: skill:code-review (code-review)'),
`rendered must start with Step 1 heading. got: ${rendered.slice(0, 200)}`
);
// produces and consumes in step section
assert.ok(rendered.includes('produces: REVIEW.md'), 'rendered must include produces: REVIEW.md');
assert.ok(rendered.includes('consumes: SUMMARY.md'), 'rendered must include consumes: SUMMARY.md');
// when key for code-review step
assert.ok(rendered.includes('when: `workflow.code_review`'), 'rendered must include when for code-review');
// Gate tdd appears AFTER the step
assert.ok(
rendered.includes('**Gate** (tdd): check={"query":"tdd.review-checkpoint"}, blocking=false, onError=skip'),
`rendered must include Gate tdd section. got: ${rendered}`
);
// Step 1 must come before the gate
const step1Idx = rendered.indexOf('### Step 1');
const gateIdx = rendered.indexOf('**Gate** (tdd)');
assert.ok(step1Idx < gateIdx, 'Step 1 code-review must appear before Gate tdd in rendered text');
});
});
// ─── check tdd.review-checkpoint via CLI — git fixture tests ───────────────────
describe('check tdd.review-checkpoint — CLI subprocess E2E with git fixtures', () => {
test('[happy] RED+GREEN commits present: block:false, violations:0, status Pass', () => {
const { tmpDir, git } = createTddGitFixture({
planFiles: [
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: tddPlan(1, '01-01') },
],
});
try {
// RED: failing test commit (must touch a real file for git log --grep -- . to work)
commitFile(git, tmpDir, 'test-login.js', 'test(01-01): failing test for login');
// GREEN: implementation commit
commitFile(git, tmpDir, 'login.js', 'feat(01-01): implement login');
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
assert.ok(result.success, `check should succeed with exit 0. stderr: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.block, false, 'RED+GREEN present: block must be false, not true');
assert.strictEqual(out.passed, true, 'passed must be true');
assert.strictEqual(out.tddPlans, 1, 'must find 1 tdd plan');
assert.strictEqual(out.violations, 0, 'violations must be 0 when both commits present');
assert.ok(Array.isArray(out.rows), 'rows must be array');
assert.strictEqual(out.rows.length, 1, 'must have 1 row');
assert.strictEqual(out.rows[0].planId, '01-01', 'planId must be 01-01');
assert.strictEqual(out.rows[0].red, true, 'red must be true');
assert.strictEqual(out.rows[0].green, true, 'green must be true');
assert.strictEqual(out.rows[0].status, 'Pass', 'status must be Pass');
assert.strictEqual(out.rows[0].missing.length, 0, 'missing array must be empty');
} finally {
cleanup(tmpDir);
}
});
test('[negative] type:tdd plan with no commits: block:true, violations:1, missing includes RED and GREEN', () => {
const { tmpDir } = createTddGitFixture({
planFiles: [
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: tddPlan(1, '01-01') },
],
});
try {
// No additional commits — only the init commit exists
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
assert.ok(result.success, `check should exit 0 (advisory gate). stderr: ${result.error}`);
const out = JSON.parse(result.output);
// SPECIFIC assertion: block must be TRUE (distinguishes from the passing case)
assert.strictEqual(out.block, true, 'no commits: block must be TRUE, not false');
assert.strictEqual(out.tddPlans, 1, 'must find 1 tdd plan');
assert.strictEqual(out.violations, 1, 'violations must be 1');
assert.strictEqual(out.rows[0].red, false, 'red must be false without test() commit');
assert.strictEqual(out.rows[0].green, false, 'green must be false without feat() commit');
assert.strictEqual(out.rows[0].status, 'FAIL', 'status must be FAIL');
// missing must include both RED and GREEN
assert.ok(out.rows[0].missing.includes('RED'), 'missing must include RED');
assert.ok(out.rows[0].missing.includes('GREEN'), 'missing must include GREEN');
} finally {
cleanup(tmpDir);
}
});
test('[negative] RED present but GREEN missing: block:true, violations:1, missing deepEqual [GREEN]', () => {
const { tmpDir, git } = createTddGitFixture({
planFiles: [
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: tddPlan(1, '01-01') },
],
});
try {
// Only RED commit — no feat() commit
commitFile(git, tmpDir, 'test-auth.js', 'test(01-01): failing auth test');
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
assert.ok(result.success, `check should exit 0. stderr: ${result.error}`);
const out = JSON.parse(result.output);
// SPECIFIC assertion: block true, violations 1
assert.strictEqual(out.block, true, 'RED only: block must be true');
assert.strictEqual(out.violations, 1, 'violations must be exactly 1');
assert.strictEqual(out.rows[0].red, true, 'red must be true (commit present)');
assert.strictEqual(out.rows[0].green, false, 'green must be false (no feat commit)');
assert.strictEqual(out.rows[0].status, 'FAIL', 'status must be FAIL');
// missing must be exactly ['GREEN'] — not ['RED', 'GREEN']
assert.deepStrictEqual(out.rows[0].missing, ['GREEN'], 'missing must deepEqual [GREEN] when RED present');
} finally {
cleanup(tmpDir);
}
});
test('[empty-resolution] no type:tdd plans (type:execute only): block:false, tddPlans:0, empty rows', () => {
const { tmpDir } = createTddGitFixture({
planFiles: [
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: executePlan(1, '01-01') },
],
});
try {
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
assert.ok(result.success, `check should succeed with exit 0. stderr: ${result.error}`);
const out = JSON.parse(result.output);
// SPECIFIC assertion: block false AND tddPlans 0 (distinguishes from a plan that passes)
assert.strictEqual(out.block, false, 'no tdd plans: block must be false');
assert.strictEqual(out.tddPlans, 0, 'tddPlans must be 0 when no type:tdd files');
assert.strictEqual(out.violations, 0, 'violations must be 0');
assert.strictEqual(out.rows.length, 0, 'rows must be empty array');
assert.strictEqual(out.table, '', 'table must be empty string when no tdd plans');
} finally {
cleanup(tmpDir);
}
});
test('[crlf] type:tdd plan with CRLF endings is still detected (#2449)', () => {
// src/check-command-router.cts:751 used /^---\n...\n---/ which can't match
// CRLF frontmatter delimiters, so a CRLF PLAN.md was silently classified
// as "no type:tdd plans" — output indistinguishable from a phase that
// genuinely contains no TDD plans. The advisory gate then short-circuited
// to a confident pass with no violations table.
const { tmpDir } = createTddGitFixture({
planFiles: [
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: tddPlanCrlf(1, '01-01') },
],
});
try {
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
assert.ok(result.success, `check should succeed. stderr: ${result.error}`);
const out = JSON.parse(result.output);
// SPECIFIC assertion: tddPlans must be 1, not 0 — this is the bug.
// Before the fix, the CRLF frontmatter regex returned null, the plan was
// never classified type:tdd, and tddPlanFiles stayed empty.
assert.strictEqual(out.tddPlans, 1, 'CRLF type:tdd plan must be detected (was silently 0 before #2449 fix)');
// No RED/GREEN commits in the fixture → block:true, violations:1.
assert.strictEqual(out.block, true, 'no commits on the detected plan: block must be TRUE');
assert.strictEqual(out.violations, 1, 'one violation expected for the detected plan');
assert.strictEqual(out.rows.length, 1, 'the detected plan must produce a row');
assert.strictEqual(out.rows[0].planId, '01-01', 'planId must be 01-01');
} finally {
cleanup(tmpDir);
}
});
test('[crlf-mixed] type:tdd plan with CRLF frontmatter + LF body is still detected (#2449)', () => {
// Mixed-endings variant of the [crlf] test: frontmatter delimiters are CRLF
// (the bug class) but the body is LF (e.g. an editor that normalizes body
// text but preserves frontmatter bytes, or a toolchain that concatenates
// CRLF + LF fragments). The classifier only reads frontmatter, so the LF
// body must not change detection — but verify, since the original bug was
// a parser differential and mixed endings are the most adversarial case.
const { tmpDir } = createTddGitFixture({
planFiles: [
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: tddPlanMixedCrlfLf(1, '01-01') },
],
});
try {
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
assert.ok(result.success, `check should succeed. stderr: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.tddPlans, 1, 'CRLF-frontmatter + LF-body type:tdd plan must be detected');
assert.strictEqual(out.rows[0].planId, '01-01', 'planId must be 01-01');
} finally {
cleanup(tmpDir);
}
});
test('[bva] violations=1 boundary: exactly 1 violation sets block:true and advisory message present', () => {
// Two tdd plans: 01-01 passes (both commits), 01-02 fails (no commits)
// violations = 1 exactly — boundary test (violations > 0 → block:true)
const { tmpDir, git } = createTddGitFixture({
planFiles: [
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: tddPlan(1, '01-01') },
{ dir: '01-phase1', filename: '01-02-PLAN.md', content: tddPlan(1, '01-02') },
],
});
try {
// 01-01: both RED and GREEN commits (passes)
commitFile(git, tmpDir, 'test1.js', 'test(01-01): failing test');
commitFile(git, tmpDir, 'impl1.js', 'feat(01-01): implementation');
// 01-02: no commits (fails)
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
assert.ok(result.success, `check should exit 0. stderr: ${result.error}`);
const out = JSON.parse(result.output);
// SPECIFIC: block must be TRUE for violations=1 (not false as it would be for violations=0)
assert.strictEqual(out.block, true, 'violations=1 boundary: block must be true');
assert.strictEqual(out.violations, 1, 'violations must be exactly 1 (not 0, not 2)');
assert.strictEqual(out.tddPlans, 2, 'tddPlans must be 2');
assert.strictEqual(out.passed, true, 'advisory gate: passed stays true');
// Check both rows
const passRow = out.rows.find((r) => r.planId === '01-01');
const failRow = out.rows.find((r) => r.planId === '01-02');
assert.ok(passRow, '01-01 row must exist');
assert.ok(failRow, '01-02 row must exist');
assert.strictEqual(passRow.status, 'Pass', '01-01 must Pass');
assert.strictEqual(failRow.status, 'FAIL', '01-02 must FAIL');
// Advisory table must mention the warning text
assert.ok(
out.table.includes('⚠ Gate violations are advisory'),
'table must include advisory warning when violations > 0'
);
} finally {
cleanup(tmpDir);
}
});
test('[negative] missing phase argument: exitCode 1 and error contains required message', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tdd-noarg-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
try {
const result = runTools('check tdd.review-checkpoint --raw', tmpDir);
// SPECIFIC: exitCode must be 1 (non-zero), not 0
assert.strictEqual(result.success, false, 'missing phase arg must cause failure (success=false)');
assert.strictEqual(result.exitCode, 1, 'exitCode must be 1, not 0');
// Error message must identify the command and what's missing
const errText = result.error + result.output;
assert.ok(
errText.includes('tdd.review-checkpoint') || errText.includes('phase argument'),
`error must reference tdd.review-checkpoint or phase argument. got: ${errText}`
);
} finally {
cleanup(tmpDir);
}
});
});