Files
msd-core/tests/check-tdd-review-checkpoint-e2e.test.cjs
0xdhx 08021b02c0 fix(#2665): scrub config-location env vars in every TEST_ENV_BASE declaration
TEST_ENV_BASE blanks session-identity variables but none of the three that
decide WHERE a child process writes: CLAUDE_CONFIG_DIR, GSD_RUNTIME and
CODEX_HOME. The config-home resolver is env-first (runtime-homes.cts, the
dot-home case consults the env var before the home-derived fallback), so an
ambient CLAUDE_CONFIG_DIR in the developer's shell beats a call site that
sandboxes only HOME. The suite then writes into the developer's real config
directory -- including a registered skill under <configDir>/skills/ whose
body carries behavioural directives that load into later sessions.

Blank all three alongside the session-identity vars. `...env` still spreads
last, so the five call sites that already constrain these locally keep
winning with their explicit values.

TEST_ENV_BASE is re-declared in nine files, so the three lines are added
nine times rather than once. Consolidating the nine into a single exported
constant -- and fixing the TERM_SESSION / TERM_SESSION_ID drift between the
copies -- is deliberately left out of this change; see the PR body.

One call site needed adjusting. capability-state.test.cjs's
`capability state --runtime claude` CLI test passed no env at all and
compared the CHILD's resolved config dir against the PARENT process's
getGlobalConfigDir('claude'). That agreed only because the child inherited
the developer's ambient CLAUDE_CONFIG_DIR -- i.e. it passed *because of*
the leak. It now redirects both runtime homes into the sandbox and asserts
against values the test controls, so it is hermetic with the variable set
or unset.

Regression case folded into the owning module's test file rather than a new
bug-NNNN file, per scripts/lint-regression-test-names.cjs. It sets the
variable on the PARENT process, which is the actual vector; setting it in
the per-call env argument would exercise a path that was never broken.
2026-08-08 05:49:33 -05:00

544 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: '',
// Config-LOCATION vars. Distinct in kind from the session-identity vars
// above: these decide WHERE a child writes, so leaving them ambient lets a
// test that sandboxes HOME still escape into the developer's real config dir.
CLAUDE_CONFIG_DIR: '',
GSD_RUNTIME: '',
CODEX_HOME: '',
};
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);
}
});
});