Files
msd-core/tests/health-validation.test.cjs
Tom Boucher d6788a6805 test(#4523): migrate config/env/locking/perf batch to named timeout constants (#4673)
Batch 12 of the ad hoc timeout literal migration (epic #4445). Replaces
every bare numeric timeout/timeoutMs object-literal property in
tests/check-env.test.cjs, tests/config-get-default.test.cjs,
tests/federated-config.test.cjs,
tests/gsd-check-update-worker-platform-gate.test.cjs,
tests/gsd-mcp-server-bin.test.cjs, tests/health-validation.test.cjs,
tests/locking-bugs-1909-1916-1925-1927.test.cjs,
tests/perf-316-state-lock-buffer-alloc.test.cjs,
tests/perf-317-context-monitor-fs.test.cjs, and
tests/pi-config-dir-env-override.test.cjs with a named constant, per
eslint-rules/no-adhoc-timeout-literal.cjs. Removes these 10 files from the
rule's allowlist.

Ground truth via eslint matched the issue's stated 26 sites across 10 files
exactly. Reuses PROBE_TIMEOUT_MS, GENERATOR_SCRIPT_TIMEOUT_MS,
GSD_TOOLS_CLI_MODERATE_TIMEOUT_MS, and INSTALL_TIMEOUT_MS across 4 files.
No new shared constants needed -- every recurring value across files was
independently verified to be a genuinely different operation class, per
this migration's standing rule that numeric coincidence is never identity.
Adds 11 new file-local constants, three of which are not real subprocess
timeouts at all (a config-merge fixture value, and two node:test per-test
timeout options bounding ReDoS/lock-retry regression backstops).

Per the issue's explicit mandate, gsd-check-update-worker-platform-gate.test.cjs
now imports (read-only) NPM_VIEW_TIMEOUT_MS from
gsd-core/bin/check-latest-version.cjs for disclosure -- this file and that
production module once independently guessed the same 15000ms value,
causing the PR #4428 Windows double-SIGKILL collision. No site in this
file's current bare literals actually wraps a live npm-view call needing
margin arithmetic, so the import documents the historical relationship
honestly rather than fabricating a computation. No src/bin file edited
(only a read-only import added), no numeric value changed anywhere.

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:33:26 -04:00

2114 lines
90 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GSD Tools Tests - Health Validation
*
* Tests for fix/health-validation-1473c:
* - W011: STATE/ROADMAP cross-validation (phase divergence detection)
* - W012: branching_strategy validation
* - W013: context_window validation
* - W014: phase_branch_template placeholder validation
* - W015: milestone_branch_template placeholder validation
* - stateReplaceFieldWithFallback field-miss warning
* - Boundary conditions and edge cases
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── Helpers ────────────────────────────────────────────────────────────────
function writeMinimalRoadmap(tmpDir, phases = ['1']) {
const lines = phases.map(n => `### Phase ${n}: Phase ${n} Description`).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n${lines}\n`
);
}
function writeMinimalStateMd(tmpDir, content) {
const defaultContent = content || `# Session State\n\n## Current Position\n\nPhase: 1\n`;
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
defaultContent
);
}
function writeMinimalProjectMd(tmpDir) {
const sections = ['## What This Is', '## Core Value', '## Requirements'];
const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
`# Project\n\n${content}`
);
}
function writeValidConfigJson(tmpDir, overrides = {}) {
const base = { model_profile: 'balanced', commit_docs: true };
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ ...base, ...overrides }, null, 2)
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. W011: STATE/ROADMAP cross-validation
// ─────────────────────────────────────────────────────────────────────────────
describe('W011: STATE/ROADMAP cross-validation', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('STATE says current phase but ROADMAP shows it as complete -> warning', () => {
writeMinimalProjectMd(tmpDir);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n- [x] Phase 3: Database Layer\n\n### Phase 3: Database Layer\n**Goal:** DB setup\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Session State\n\n**Current Phase:** 03\n**Current Phase Name:** Database Layer\n**Status:** In progress\n`
);
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-database-layer'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W011'),
`Expected W011 in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('STATE and ROADMAP agree (phase not checked off) -> no W011 warning', () => {
writeMinimalProjectMd(tmpDir);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n- [ ] Phase 2: API Layer\n\n### Phase 2: API Layer\n**Goal:** Build API\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Session State\n\n**Current Phase:** 2\n**Status:** In progress\n`
);
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api-layer'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.warnings.some(w => w.code === 'W011'),
`Should not have W011: ${JSON.stringify(output.warnings)}`
);
});
// ─── #3280 — the STATE.md format `gsd-tools state update` /
// `state begin-phase` actually persist: YAML frontmatter `current_phase` +
// `status` (syncStateFrontmatter), not the legacy bold-prose fields. New
// fixtures added alongside the legacy cases above (which stay, since real
// user repos still carry the prose format).
test('#3280: frontmatter current_phase + ROADMAP [x] -> W011 warning', () => {
writeMinimalProjectMd(tmpDir);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n- [x] Phase 3: Database Layer\n\n### Phase 3: Database Layer\n**Goal:** DB setup\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'---',
"gsd_state_version: '1.0'",
'milestone: v1.0',
'current_phase: 3',
'current_phase_name: Database Layer',
'status: executing',
'---',
'',
'# Session State',
'',
'## Current Position',
'',
'Phase: 3 of 4 (Database Layer)',
'',
].join('\n')
);
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-database-layer'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W011'),
`Expected W011 in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('#3280: frontmatter current_phase + status completed (writer vocabulary) -> no W011 warning', () => {
writeMinimalProjectMd(tmpDir);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n- [x] Phase 3: Database Layer\n\n### Phase 3: Database Layer\n**Goal:** DB setup\n`
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
[
'---',
"gsd_state_version: '1.0'",
'current_phase: 3',
'current_phase_name: Database Layer',
'status: completed',
'---',
'',
'# Session State',
'',
'## Current Position',
'',
'Phase: 3 of 4 (Database Layer)',
'',
].join('\n')
);
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-database-layer'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.warnings.some(w => w.code === 'W011'),
`Should not have W011 for a completed phase in the writer's own status vocabulary: ${JSON.stringify(output.warnings)}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 2. W012-W015: Config field validation
// ─────────────────────────────────────────────────────────────────────────────
describe('config field validation', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('W012: invalid branching_strategy triggers warning', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { branching_strategy: 'banana' });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W012'),
`Expected W012 in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('W013: negative context_window triggers warning', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { context_window: -500 });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W013'),
`Expected W013 in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('W014: phase_branch_template missing {phase} triggers warning', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { phase_branch_template: 'gsd/no-placeholder-{slug}' });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W014'),
`Expected W014 in warnings: ${JSON.stringify(output.warnings)}`
);
});
test('W015: milestone_branch_template missing {milestone} triggers warning', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { milestone_branch_template: 'release/no-placeholder' });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W015'),
`Expected W015 in warnings: ${JSON.stringify(output.warnings)}`
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 3. Boundary conditions
// ─────────────────────────────────────────────────────────────────────────────
describe('boundary conditions', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('context_window config accepts 500000 (boundary value)', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { context_window: 500000 });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.warnings.some(w => w.code === 'W013'),
`Should not have W013 for context_window=500000: ${JSON.stringify(output.warnings)}`
);
});
test('context_window config accepts 200000 (default value)', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir, { context_window: 200000 });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.warnings.some(w => w.code === 'W013'),
`Should not have W013 for context_window=200000: ${JSON.stringify(output.warnings)}`
);
});
test('W013 does NOT fire when context_window is absent from config', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n');
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
!output.warnings.some(w => w.code === 'W013'),
`Should not have W013 when context_window is absent: ${JSON.stringify(output.warnings)}`
);
});
test('health check handles STATE.md with no Current Phase field (no W011 crash)', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1']);
writeMinimalStateMd(tmpDir, '# Session State\n\nSome content but no phase reference.\n');
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command should not crash: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(typeof output.status === 'string', 'should return a status string');
assert.ok(Array.isArray(output.errors), 'should return errors array');
assert.ok(Array.isArray(output.warnings), 'should return warnings array');
});
test('health check handles empty ROADMAP.md (no crash)', () => {
writeMinimalProjectMd(tmpDir);
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '');
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1.\n');
writeValidConfigJson(tmpDir);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command should not crash on empty ROADMAP.md: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(typeof output.status === 'string', 'should return a status string');
assert.ok(Array.isArray(output.errors), 'should return errors array');
assert.ok(Array.isArray(output.warnings), 'should return warnings array');
});
test('config.json with trailing comma -- validate health reports parse error', () => {
writeMinimalProjectMd(tmpDir);
writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1.\n');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
'{"model_profile": "balanced",}'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: Test Phase\n'
);
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `validate health should not crash on invalid JSON: ${result.error}`);
const output = JSON.parse(result.output);
const hasE005 = output.errors.some(e => e.code === 'E005');
assert.ok(hasE005, `Should report E005 for invalid config.json: ${JSON.stringify(output.errors)}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// 4. stateReplaceFieldWithFallback warning
// ─────────────────────────────────────────────────────────────────────────────
describe('stateReplaceFieldWithFallback field-miss warning', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('advance-plan completes even when fields are missing (non-fatal)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`# Project State\n\n**Current Phase:** 01\n**Current Plan:** 1\n**Total Plans in Phase:** 3\n`
);
const result = runGsdTools('state advance-plan', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.advanced === true || output.reason === 'last_plan', 'advance should complete');
});
test('validate health on 50-phase project completes in under 3000ms', () => {
// Stress test for the new health checks at scale
let roadmapContent = '# Roadmap v1.0\n\n';
for (let i = 1; i <= 50; i++) {
roadmapContent += `- [${i <= 25 ? 'x' : ' '}] Phase ${i}: Feature ${i}\n`;
}
roadmapContent += '\n';
for (let i = 1; i <= 50; i++) {
roadmapContent += `### Phase ${i}: Feature ${i}\n\n**Goal:** Build feature ${i}\n**Plans:** 1 plans\n\n`;
}
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent);
writeMinimalProjectMd(tmpDir);
writeMinimalStateMd(tmpDir, '# Session State\n\n**Current Phase:** 26\n**Status:** Planning\n');
writeValidConfigJson(tmpDir);
const phasesDir = path.join(tmpDir, '.planning', 'phases');
for (let i = 1; i <= 50; i++) {
const pad = String(i).padStart(2, '0');
const phaseDir = path.join(phasesDir, `${pad}-feature-${i}`);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, `${pad}-01-PLAN.md`), `# Plan ${i}\n`);
if (i <= 25) {
fs.writeFileSync(path.join(phaseDir, `${pad}-01-SUMMARY.md`), `# Summary ${i}\n`);
}
}
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `validate health should succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(typeof output.status === 'string', 'Should return a status string');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/6-validate-cjs-drift-regression.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:6-validate-cjs-drift-regression (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression tests for issue #6 (open-gsd/gsd-core):
* Three validation behaviors present in validate.ts are missing from verify.cjs,
* producing silent false negatives on the CJS production path.
*
* Three drift items fixed by porting phaseVariants() and activeDiskPhases to verify.cjs:
*
* 1. W007 activeDiskPhases — verify.cjs uses diskPhases (includes archived) for the
* W007 check; validate.ts uses activeDiskPhases (active phasesDir only). Archived
* phases absent from current ROADMAP trigger false W007 in verify.cjs.
*
* 2. phaseVariants() normalization — validate.ts has a phaseVariants() function
* generating padded/unpadded/letter-suffix variants for matching. verify.cjs uses
* only parseInt→padded (drops letter suffix), causing false W006/W007 for
* letter-suffix phases with padding mismatch between ROADMAP and disk.
*
* 3. W006 unchecked-phase variant skip — same phaseVariants() gap causes false W006
* for phases with padding mismatch: ROADMAP says "3B", disk has "03B-foo", but
* verify.cjs padded("3B") = "03" (drops letter) → "03B" on disk not matched.
*
* References:
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
* - Issue #6 (open-gsd/gsd-core)
* - PR #154 (issue #4) — precedent for the generator pattern
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { runGsdTools, cleanup } = require('./helpers.cjs');
// ── Fixture helpers ──────────────────────────────────────────────────────────
function mkplanning(base) {
const planningDir = path.join(base, '.planning');
const phasesDir = path.join(planningDir, 'phases');
fs.mkdirSync(phasesDir, { recursive: true });
return { planningDir, phasesDir };
}
function writeProjectMd(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'PROJECT.md'),
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
);
}
function writeStateMd(planningDir, phase = '2') {
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
`# State\n\n**Current Phase:** ${phase}\n**Status:** In progress\n`,
);
}
function writeConfigJson(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ model_profile: 'balanced' }),
);
}
// ── Drift Item 1: W007 activeDiskPhases ────────────────────────────────────
//
// Project has a shipped milestone archive (milestones/v1.0-phases/) with old phase
// "1" inside, and active phasesDir with phase "2". Current ROADMAP only mentions
// Phase 2 (v1.0 phases were shipped and removed from ROADMAP).
//
// validate.ts: activeDiskPhases = phases from active phasesDir only (not archived).
// "1" is only in diskPhases (via forEachArchivedPhaseToken), not activeDiskPhases.
// W007 iterates activeDiskPhases → "1" never checked → no false W007.
//
// verify.cjs pre-fix: diskPhases = collectDiskPhases() + forEachArchivedPhaseToken().
// diskPhases includes "1" (from old archive). W007 iterates diskPhases → "1" not
// in roadmapPhases → W007 fires for "1". False positive.
describe('Drift item 1 — W007 activeDiskPhases: no false W007 for archived phases', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d1-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir);
writeConfigJson(planningDir);
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 2:** API',
'',
'### Phase 2: API',
'',
'## Progress',
'| Phase | Status |',
'|-------|--------|',
'| 2 | Complete |',
].join('\n'),
);
// Active phasesDir: only phase 2
fs.mkdirSync(path.join(phasesDir, '2-api'), { recursive: true });
// Current active milestone archive: v1.1-phases contains phase 2 (in ROADMAP)
fs.mkdirSync(
path.join(planningDir, 'milestones', 'v1.1-phases', '2-api'),
{ recursive: true },
);
// Old shipped milestone archive: v1.0-phases contains phase 1 (no longer in ROADMAP)
// v1.0 sorts before v1.1 so getActiveMilestoneArchiveDir returns v1.1.
// forEachArchivedPhaseToken walks BOTH v1.0 and v1.1, so diskPhases gets "1".
// collectDiskPhases (activeDiskPhases) only uses v1.1 (active archive) — "1" excluded.
fs.mkdirSync(
path.join(planningDir, 'milestones', 'v1.0-phases', '1-foundation'),
{ recursive: true },
);
});
after(() => {
cleanup(tmpDir);
});
test('no W007 for archived phase "1" absent from current ROADMAP', () => {
// validate.ts: activeDiskPhases has only "2" (from active phasesDir + v1.1 archive).
// "1" is in diskPhases (forEachArchivedPhaseToken walks v1.0) but NOT activeDiskPhases.
// W007 iterates activeDiskPhases → "1" never checked → no false W007. Correct.
// verify.cjs pre-fix: diskPhases = collectDiskPhases + forEachArchivedPhaseToken.
// diskPhases includes "1" (from v1.0 archive). W007 iterates diskPhases → "1" not
// in roadmapPhases → W007 fires for "1". False positive.
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
// Filter to W007 that mentions only phase "1" (not "1A", "01A", etc.)
const w007Phase1 = w007.filter(
(w) => /\bPhase 1\b/i.test(w.message) && !/\b1[A-Z]\b/i.test(w.message),
);
assert.strictEqual(
w007Phase1.length,
0,
`Expected no W007 for archived phase 1 (v1.0 archive), got: ${JSON.stringify(w007)}`,
);
});
});
// ── Drift Item 2: phaseVariants() normalization ────────────────────────────
//
// ROADMAP has "### Phase 01A:" (zero-padded letter-suffix heading).
// Disk has directory "1A-foo" (unpadded letter-suffix form).
// These should match because phaseVariants("01A") = {"01A", "1A", "01A"}.
//
// validate.ts: diskPhases has "1A". phaseVariants("01A") includes "1A" → match → no W006.
// activeDiskPhases has "1A". phaseVariants("1A") includes "01A" → roadmapPhaseVariants
// has "01A" → match → no W007.
//
// verify.cjs pre-fix:
// W006 loop for "01A": padded = String(parseInt("01A",10)).padStart(2,'0') = "01".
// diskPhases.has("01A")? NO. diskPhases.has("01")? NO. → W006 fires. Bug.
// W007 loop for "1A": unpadded = String(parseInt("1A",10)) = "1".
// roadmapPhases.has("1A")? NO. roadmapPhases.has("1")? NO. → W007 fires. Bug.
describe('Drift item 2 — phaseVariants() normalization: letter-suffix zero-padding mismatch', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d2-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir);
writeConfigJson(planningDir);
// ROADMAP: Phase 01A (zero-padded + letter suffix)
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 01A:** Suffix Phase',
'',
'### Phase 01A: Suffix Phase',
'',
'## Progress',
'| Phase | Status |',
'|-------|--------|',
'| 01A | Complete |',
].join('\n'),
);
// Disk: unpadded form "1A-foo"
fs.mkdirSync(path.join(phasesDir, '1A-suffix-phase'), { recursive: true });
});
after(() => {
cleanup(tmpDir);
});
test('no false W006 when ROADMAP says 01A and disk has 1A-... (phaseVariants normalizes)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w006 = (data.warnings ?? []).filter((w) => w.code === 'W006');
assert.strictEqual(
w006.length,
0,
`Expected no W006 (01A == 1A after normalization), got: ${JSON.stringify(w006)}`,
);
});
test('no false W007 when disk has 1A and ROADMAP says 01A (phaseVariants normalizes)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
assert.strictEqual(
w007.length,
0,
`Expected no W007 (1A on disk matches 01A in ROADMAP), got: ${JSON.stringify(w007)}`,
);
});
});
// ── Drift Item 3: W006 false positive when disk uses zero-padded letter form ─
//
// ROADMAP has "### Phase 3B:" (unpadded letter-suffix heading).
// Disk has directory "03B-feature" (zero-padded letter-suffix form).
//
// validate.ts:
// diskPhases has "03B". phaseVariants("3B") = {"3B","3B","03B"}.
// existsOnDisk = diskPhases.has("03B") = TRUE → no W006.
// activeDiskPhases has "03B". phaseVariants("03B") = {"03B","3B","03B"}.
// roadmapPhaseVariants has {"3B","3B","03B"} → "03B" found → no W007.
//
// verify.cjs pre-fix:
// W006 for "3B": padded = parseInt("3B")=3 → "03" (drops "B").
// diskPhases.has("3B")? NO. diskPhases.has("03")? NO (dir is "03B" not "03"). → W006 fires.
// W007 for "03B": unpadded = String(parseInt("03B",10)) = "3".
// roadmapPhases.has("03B")? NO. roadmapPhases.has("3")? NO (ROADMAP has "3B" not "3"). → W007.
describe('Drift item 3 — W006 false positive when disk has zero-padded letter form', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-6-d3-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir, '3B');
writeConfigJson(planningDir);
// ROADMAP: Phase 3B (unpadded in heading)
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 3B:** Feature Extension',
'',
'### Phase 3B: Feature Extension',
'',
'## Progress',
'| Phase | Status |',
'|-------|--------|',
'| 3B | Complete |',
].join('\n'),
);
// Disk: "03B-feature" (zero-padded letter-suffix form)
fs.mkdirSync(path.join(phasesDir, '03B-feature'), { recursive: true });
});
after(() => {
cleanup(tmpDir);
});
test('no false W006 when ROADMAP says 3B and disk has 03B-... (phaseVariants covers zero-padded)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w006 = (data.warnings ?? []).filter((w) => w.code === 'W006');
assert.strictEqual(
w006.length,
0,
`Expected no W006 (3B == 03B after normalization), got: ${JSON.stringify(w006)}`,
);
});
test('no false W007 when disk has 03B and ROADMAP says 3B (phaseVariants covers both forms)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
assert.strictEqual(
w007.length,
0,
`Expected no W007 (03B on disk matches 3B in ROADMAP), got: ${JSON.stringify(w007)}`,
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-416-archive-dir-null.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-416-archive-dir-null (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression tests for issue #416 (open-gsd/gsd-core).
*
* Bug: getActiveMilestoneArchiveDir falls back to the newest archive directory
* when STATE.md names a milestone that has no matching archive yet, producing
* W007 false positives for phases from a prior (completed) milestone.
*
* Fix: when STATE.md is present and parseable and names a milestone, but no
* milestones/<vX.Y>-phases/ directory matches, return null. The version-sort
* fallback to the newest archive fires only when STATE.md is absent or
* unparseable.
*
* Knuth invariant: the resolver answers one question —
* "what archive directory holds the active milestone's phases?"
* Answer space: <dir> | null.
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { runGsdTools, cleanup } = require('./helpers.cjs');
// Consolidation #1969: scope GSD_TEST_MODE to this folded block so it does not
// leak (via the runGsdTools env copy) into host tests registered before the fold.
const __savedTestMode = process.env.GSD_TEST_MODE;
before(() => { process.env.GSD_TEST_MODE = '1'; });
after(() => {
if (__savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
else process.env.GSD_TEST_MODE = __savedTestMode;
});
// ── helpers ──────────────────────────────────────────────────────────────────
function mkplanning(base) {
const planningDir = path.join(base, '.planning');
const phasesDir = path.join(planningDir, 'phases');
fs.mkdirSync(phasesDir, { recursive: true });
return planningDir;
}
function writeMinimalRoadmap(planningDir, phases) {
// phases: array of { num, name, checked }
const checkboxes = phases.map(({ num, name, checked }) =>
`- [${checked ? 'x' : ' '}] **Phase ${num}:** ${name}`,
).join('\n');
const headings = phases.map(({ num, name }) =>
`### Phase ${num}: ${name}\n**Goal:** Completed.\n`,
).join('\n');
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
`# Roadmap\n\n${checkboxes}\n\n${headings}`,
);
}
function writeStateMdMilestone(planningDir, milestone) {
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
`# State\n\n**milestone:** ${milestone}\n**Current Phase:** 23\n**Status:** In progress\n`,
);
}
function writeProjectMd(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'PROJECT.md'),
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
);
}
function writeConfigJson(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ model_profile: 'balanced' }),
);
}
function mkArchivePhases(planningDir, version, phaseNums) {
// Creates .planning/milestones/<version>-phases/<NN>-phase-name/ dirs
const archiveDir = path.join(planningDir, 'milestones', `${version}-phases`);
for (const num of phaseNums) {
const padded = String(num).padStart(2, '0');
fs.mkdirSync(path.join(archiveDir, `${padded}-phase-${num}`), { recursive: true });
}
return archiveDir;
}
// ─────────────────────────────────────────────────────────────────────────────
// Case 1: STATE.md milestone: v6.0, only v5.0-phases/ on disk
// → resolver returns null, verifier emits zero W007 for phases 17–22
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #416 case 1: STATE.md v6.0 with only v5.0-phases/ on disk → null, no W007', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c1-'));
const planningDir = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeConfigJson(planningDir);
// Active milestone is v6.0 — no archive for it yet (phases live in flat phases/)
writeStateMdMilestone(planningDir, 'v6.0');
// v5.0 was the prior completed milestone; its archive exists on disk
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19, 20, 21, 22]);
// ROADMAP reflects only v6.0 phases (v5.0 phases are in a prior milestone,
// not in the current roadmap section)
writeMinimalRoadmap(planningDir, [
{ num: 23, name: 'New Foundation', checked: false },
]);
});
after(() => { cleanup(tmpDir); });
test('validate health emits zero W007 warnings (no prior-milestone phases surfaced)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
assert.strictEqual(
w007.length,
0,
`Expected zero W007 — phases 17–22 from v5.0-phases/ must not appear as "active".\nGot: ${JSON.stringify(w007, null, 2)}`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Case 2: No STATE.md, multiple archives on disk → version-sort fallback
// returns the highest-versioned archive (existing behavior preserved)
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #416 case 2: no STATE.md + multiple archives → version-sort fallback to newest', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c2-'));
const planningDir = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeConfigJson(planningDir);
// No STATE.md — resolver must use the version-sort fallback
// Two archives: v4.0 and v5.0; v5.0 is newer
mkArchivePhases(planningDir, 'v4.0', [10, 11, 12]);
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19]);
// ROADMAP lists v5.0 phases so W007 does not fire
writeMinimalRoadmap(planningDir, [
{ num: 17, name: 'Alpha', checked: true },
{ num: 18, name: 'Beta', checked: true },
{ num: 19, name: 'Gamma', checked: true },
]);
});
after(() => { cleanup(tmpDir); });
test('validate health succeeds and does not emit W007 for v5.0 archive phases', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
// v5.0 phases (17–19) are in the archive returned by the fallback and
// in the ROADMAP, so no W007 should fire.
assert.strictEqual(
w007.length,
0,
`Expected zero W007 for v5.0 archive phases present in ROADMAP.\nGot: ${JSON.stringify(w007, null, 2)}`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Case 3: STATE.md milestone: v5.0, matching v5.0-phases/ exists → returns it
// (regression guard — happy path must not break)
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #416 case 3: STATE.md v5.0 with matching v5.0-phases/ → returns archive dir', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-416-c3-'));
const planningDir = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeConfigJson(planningDir);
// STATE.md names v5.0 and a matching archive exists
writeStateMdMilestone(planningDir, 'v5.0');
mkArchivePhases(planningDir, 'v5.0', [17, 18, 19, 20, 21, 22]);
// ROADMAP lists v5.0 phases so W007 does not fire
writeMinimalRoadmap(planningDir, [
{ num: 17, name: 'Alpha', checked: true },
{ num: 18, name: 'Beta', checked: true },
{ num: 19, name: 'Gamma', checked: true },
{ num: 20, name: 'Delta', checked: true },
{ num: 21, name: 'Epsilon', checked: true },
{ num: 22, name: 'Zeta', checked: true },
]);
});
after(() => { cleanup(tmpDir); });
test('validate health emits zero W007 — archive phases are in ROADMAP and active', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `validate health failed: ${result.error}`);
const data = JSON.parse(result.output);
const w007 = (data.warnings ?? []).filter((w) => w.code === 'W007');
assert.strictEqual(
w007.length,
0,
`Expected zero W007 for matching v5.0 archive with v5.0 in STATE.md.\nGot: ${JSON.stringify(w007, null, 2)}`,
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/26-w005-w006-i001-cjs-drift-regression.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:26-w005-w006-i001-cjs-drift-regression (consolidation epic #1969 B3 #1972)", () => {
'use strict';
/**
* Regression tests for issue #26 (open-gsd/gsd-core).
* Three generator-pattern drift items: W005 phaseDirNameRe,
* W006-archived regex constants (PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE),
* I001 canonicalPlanStem.
*
* After the generator migration, all three helpers are sourced from
* validate.cjs. If they diverge from validate.ts, these
* tests go RED.
*
* References:
* - Issue #26 (open-gsd/gsd-core) — three drift items + reproducer
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
* - PR #154 (issue #4) — generator pattern precedent
* - PR #156 (issue #6) — validate.ts generator scaffolding (#26 extends this)
* - Original PR #3479 — first validate.ts false-positive fix (never reached CJS)
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { runGsdTools, cleanup } = require('./helpers.cjs');
function mkplanning(base) {
const planningDir = path.join(base, '.planning');
const phasesDir = path.join(planningDir, 'phases');
fs.mkdirSync(phasesDir, { recursive: true });
return { planningDir, phasesDir };
}
function writeProjectMd(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'PROJECT.md'),
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
);
}
function writeStateMd(planningDir, phase) {
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
`# State\n\n**Current Phase:** ${phase}\n**Status:** In progress\n`,
);
}
function writeConfigJson(planningDir) {
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({ model_profile: 'balanced' }),
);
}
// ── Drift Item W005: phaseDirNameRe ──────────────────────────────────────────
//
// Issue #26 reproducer (verbatim):
// mkdir -p .planning/phases/999.1-foo
// echo "# Roadmap" > .planning/ROADMAP.md
// node .claude/gsd-core/bin/gsd-tools.cjs validate health
// # Bug: emits W005 about 999.1-foo not following NN-name format
//
// verify.cjs must consume phaseDirNameRe from validate.cjs so
// the regex /^\d{2,}(?:\.\d+)*-[\w-]+$/ is the single source of truth.
describe('Drift item W005 — phaseDirNameRe: 999.X-name dirs must not trigger W005', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d1-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir, '999.1');
writeConfigJson(planningDir);
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
'# Roadmap\n\n- [x] **Phase 999.1:** Long Phase\n\n### Phase 999.1: Long Phase\n',
);
// Exact reproducer from issue #26
fs.mkdirSync(path.join(phasesDir, '999.1-foo'), { recursive: true });
});
after(() => { cleanup(tmpDir); });
test('no W005 for 999.1-foo (multi-digit sub-phase prefix)', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w005 = (data.warnings ?? []).filter((w) => w.code === 'W005');
assert.strictEqual(w005.length, 0,
`Expected zero W005 for 999.1-foo, got: ${JSON.stringify(w005)}`);
});
test('phaseDirNameRe is exported from validate.cjs', () => {
const gen = require('../gsd-core/bin/lib/validate.cjs');
assert.ok(gen.phaseDirNameRe instanceof RegExp,
'validate.cjs must export phaseDirNameRe as a RegExp');
const re = gen.phaseDirNameRe;
assert.ok(re.test('01-setup'), 'should accept 01-setup');
assert.ok(re.test('999-longphase'), 'should accept 999-longphase (3-digit prefix)');
assert.ok(re.test('999.1-foo'), 'should accept 999.1-foo (sub-phase)');
assert.ok(re.test('MANIFOLD-999.1-foo'), 'should accept long project-code prefixes');
assert.ok(re.test('APP1-999.1-foo'), 'should accept numeric characters in project-code prefixes');
assert.ok(re.test('APP_1-999.1-foo'), 'should accept underscore characters in project-code prefixes');
assert.ok(!re.test('1-shortname'), 'should reject single-digit prefix');
});
});
// ── W023 (#2408): collision warning for two on-disk phase dirs normalizing to
// the same phase key. The check groups phaseDirEntries by their normalized
// key (normalizePhaseName) and emits a warning for any group with ≥2 dirs,
// listing both directory names and each one's independently-computed status.
// Wording is neutral — never guesses which directory is "real".
describe('W023 — colliding phase directories (issue #2408)', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2408-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir, '1.0');
writeConfigJson(planningDir);
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'## Milestone v1.0',
'',
'### Phase 5: Real',
'**Goal:** The real phase',
].join('\n'),
);
// 05-real/ — Complete (PLAN + SUMMARY + passing VERIFICATION)
const realDir = path.join(phasesDir, '05-real');
fs.mkdirSync(realDir, { recursive: true });
fs.writeFileSync(path.join(realDir, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(realDir, '01-01-SUMMARY.md'), '# Summary');
// Real convention is `*-VERIFICATION.md` (gsd-core/workflows/verify-work.md:573's
// `ls "${PHASE_DIR}"/*-VERIFICATION.md` glob; verification.cts:425's
// `readVerificationStatus` matches the same `-VERIFICATION.md` suffix) — a bare
// `VERIFICATION.md` with no prefix is never produced by /gsd-verify-work and is
// invisible to the canonical reader `isPhaseComplete` now sources this rule from.
fs.writeFileSync(path.join(realDir, '05-real-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified');
// 05-real-stray/ — empty → Not Started
fs.mkdirSync(path.join(phasesDir, '05-real-stray'), { recursive: true });
});
after(() => { cleanup(tmpDir); });
test('emits W023 naming both colliding directories and their statuses', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w023 = (data.warnings ?? []).filter((w) => w.code === 'W023');
assert.strictEqual(w023.length, 1, `Expected exactly one W023, got: ${JSON.stringify(w023)}`);
const msg = w023[0].message;
assert.ok(msg.includes('"05"'), `W023 message must name the normalized phase key, got: ${msg}`);
assert.ok(msg.includes('05-real'), `W023 must name the real dir, got: ${msg}`);
assert.ok(msg.includes('05-real-stray'), `W023 must name the stray dir, got: ${msg}`);
// Independently-computed statuses appear alongside each dir.
assert.ok(msg.includes('Complete'), `W023 must include 05-real's Complete status, got: ${msg}`);
assert.ok(msg.includes('Not Started'), `W023 must include 05-real-stray's Not Started status, got: ${msg}`);
// Neutral wording across every rendered field (message AND fix/action) —
// never guesses which dir is "the real one" or names a specific dir as the
// one to remove. The check scans the union of message+fix so a future drift
// toward "remove 05-real-stray" in either field fails this test.
const combined = `${w023[0].message} ${w023[0].fix || ''}`;
assert.ok(!/real directory is/i.test(combined) && !/remove\s+05-real-stray/i.test(combined),
`W023 wording must stay neutral across all rendered fields, got: ${combined}`);
});
test('W023 listing order is deterministic across readdirSync orders (tie-break on dir name)', () => {
// Re-running validate health should produce the same W023 message byte-for-
// byte. The collision case itself (two dirs sharing the same phase token)
// is exactly where comparePhaseNum returns 0 — the dir-name tie-break is
// what keeps the comma-separated order stable. fs.readdirSync order is
// non-deterministic across platforms; without the tie-break, this test
// could flake on some hosts. Run twice and assert identical output.
const r1 = runGsdTools(['validate', 'health', '--json'], tmpDir);
const r2 = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(r1.success && r2.success, true);
const m1 = JSON.parse(r1.output).warnings.find((w) => w.code === 'W023').message;
const m2 = JSON.parse(r2.output).warnings.find((w) => w.code === 'W023').message;
assert.strictEqual(m1, m2, `W023 must be deterministic across runs, got:\n ${m1}\n ${m2}`);
// And specifically: the lower-name dir comes first under localeCompare.
assert.ok(m1.indexOf('05-real (') < m1.indexOf('05-real-stray ('),
`tie-break must place '05-real' before '05-real-stray' (localeCompare), got: ${m1}`);
});
test('does NOT emit W023 when no collision exists (single dir per key)', () => {
// Fresh tree with a single non-colliding dir.
const clean = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2408-clean-'));
const { planningDir, phasesDir } = mkplanning(clean);
writeProjectMd(planningDir);
writeStateMd(planningDir, '1.0');
writeConfigJson(planningDir);
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
'# Roadmap\n\n## Milestone v1.0\n\n### Phase 5: Real\n**Goal:** The real phase\n',
);
const realDir = path.join(phasesDir, '05-real');
fs.mkdirSync(realDir, { recursive: true });
fs.writeFileSync(path.join(realDir, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(realDir, '01-01-SUMMARY.md'), '# Summary');
try {
const result = runGsdTools(['validate', 'health', '--json'], clean);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w023 = (data.warnings ?? []).filter((w) => w.code === 'W023');
assert.strictEqual(w023.length, 0, `Expected zero W023 without collision, got: ${JSON.stringify(w023)}`);
} finally {
cleanup(clean);
}
});
});
// ── Drift Item W006-archived: PHASE_TOKEN_FROM_DIR_RE / MILESTONE_ARCHIVE_DIR_RE ─
//
// forEachArchivedPhaseToken() in verify.cjs uses two inline regex constants.
// After migration both are sourced from validate.cjs:
// PHASE_TOKEN_FROM_DIR_RE — extracts token from dir name like "64-auth-service"
// MILESTONE_ARCHIVE_DIR_RE — matches archive dirs like "v1.0-phases"
//
// Test: phase 64 was archived to milestones/v1.0-phases/64-auth-service/.
// Without correct archive detection, W006 fires for "Phase 64 in ROADMAP.md
// but no directory on disk".
describe('Drift item W006-archived — MILESTONE_ARCHIVE_DIR_RE and PHASE_TOKEN_FROM_DIR_RE', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d2-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir, '65');
writeConfigJson(planningDir);
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 65:** Current Work',
'',
'### Phase 65: Current Work',
'',
'<details>',
'<summary>Milestone v1.0 — Shipped</summary>',
'',
'### Phase 64: Auth Service',
'',
'</details>',
'',
].join('\n'),
);
// Active phasesDir: only phase 65
fs.mkdirSync(path.join(phasesDir, '65-current-work'), { recursive: true });
// Archive: milestones/v1.0-phases/64-auth-service
// MILESTONE_ARCHIVE_DIR_RE must match "v1.0-phases"
// PHASE_TOKEN_FROM_DIR_RE must extract "64" from "64-auth-service"
fs.mkdirSync(
path.join(planningDir, 'milestones', 'v1.0-phases', '64-auth-service'),
{ recursive: true },
);
});
after(() => { cleanup(tmpDir); });
test('no W006 for Phase 64 archived under milestones/v1.0-phases/', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const w006 = (data.warnings ?? []).filter(
(w) => w.code === 'W006' && /Phase 64/i.test(w.message),
);
assert.strictEqual(w006.length, 0,
`Expected no W006 for archived Phase 64, got: ${JSON.stringify(w006)}`);
});
test('MILESTONE_ARCHIVE_DIR_RE is exported and matches vN.N-phases dirs', () => {
const gen = require('../gsd-core/bin/lib/validate.cjs');
assert.ok(gen.MILESTONE_ARCHIVE_DIR_RE instanceof RegExp,
'validate.cjs must export MILESTONE_ARCHIVE_DIR_RE');
const re = gen.MILESTONE_ARCHIVE_DIR_RE;
assert.ok(re.test('v1.0-phases'), 'should match v1.0-phases');
assert.ok(re.test('v1.10-phases'), 'should match v1.10-phases');
assert.ok(!re.test('phases'), 'should NOT match plain phases');
assert.ok(!re.test('1.0-phases'), 'should NOT match missing v prefix');
});
test('PHASE_TOKEN_FROM_DIR_RE is exported and extracts phase tokens correctly', () => {
const gen = require('../gsd-core/bin/lib/validate.cjs');
assert.ok(gen.PHASE_TOKEN_FROM_DIR_RE instanceof RegExp,
'validate.cjs must export PHASE_TOKEN_FROM_DIR_RE');
const re = gen.PHASE_TOKEN_FROM_DIR_RE;
assert.strictEqual(re.exec('64-auth-service')?.[1], '64');
assert.strictEqual(re.exec('03B-feature')?.[1], '03B');
assert.strictEqual(re.exec('999.1-foo')?.[1], '999.1');
assert.strictEqual(re.exec('CK-64-auth')?.[1], 'CK-64');
assert.strictEqual(re.exec('MANIFOLD-64-auth')?.[1], 'MANIFOLD-64');
assert.strictEqual(re.exec('APP1-64-auth')?.[1], 'APP1-64');
assert.strictEqual(re.exec('APP_1-64-auth')?.[1], 'APP_1-64');
});
test('PHASE_TOKEN_FROM_DIR_RE rejects a single-digit slug word after a phase number (#2043)', () => {
const gen = require('../gsd-core/bin/lib/validate.cjs');
const re = gen.PHASE_TOKEN_FROM_DIR_RE;
// Roadmap phase name "6 Rs Pipeline Orchestrator" slugifies to
// "6-rs-pipeline-orchestrator"; the resulting dir "46-6-rs-pipeline-orchestrator"
// must extract phase token "46", not "46-6" (was the pre-fix, buggy behavior).
assert.strictEqual(re.exec('46-6-rs-pipeline-orchestrator')?.[1], '46');
// Legit multi-segment (zero-padded) milestone-prefixed tokens are preserved.
assert.strictEqual(re.exec('02-01-setup')?.[1], '02-01');
// Single-digit letter-suffix phase ids ("1A"/"01A") and milestone-prefixed
// single-digit sub-phases ("M1-2") must still match (the fix tightens
// only the continuation, not the first component).
assert.strictEqual(re.exec('1A-foo')?.[1], '1A');
assert.strictEqual(re.exec('01A-foo')?.[1], '01A');
assert.strictEqual(re.exec('M1-2-setup')?.[1], 'M1-2');
});
});
// ── Drift Item I001: canonicalPlanStem ────────────────────────────────────────
//
// validate.ts Check 7: canonicalPlanStem('68-01-scaffolding') → '68-01'
// verify.cjs had an inline copy. After migration, canonicalPlanStem is
// sourced from validate.cjs.
//
// Test: "68-01-scaffolding-PLAN.md" + "68-01-SUMMARY.md" → no I001
// Both stems canonicalize to "68-01" → match found → I001 suppressed.
describe('Drift item I001 — canonicalPlanStem: long PLAN stem matches short SUMMARY stem', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-26-d3-'));
const { planningDir, phasesDir } = mkplanning(tmpDir);
writeProjectMd(planningDir);
writeStateMd(planningDir, '68');
writeConfigJson(planningDir);
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
'# Roadmap\n\n- [x] **Phase 68:** Scaffolding\n\n### Phase 68: Scaffolding\n',
);
const phaseDir = path.join(phasesDir, '68-scaffolding');
fs.mkdirSync(phaseDir, { recursive: true });
// Long-stem PLAN + short-stem SUMMARY → must match via canonicalPlanStem
fs.writeFileSync(path.join(phaseDir, '68-01-scaffolding-PLAN.md'), '---\nwave: 1\n---\n# Plan\n');
fs.writeFileSync(path.join(phaseDir, '68-01-SUMMARY.md'), '# Summary\n');
});
after(() => { cleanup(tmpDir); });
test('no I001 when 68-01-scaffolding-PLAN.md matches 68-01-SUMMARY.md via canonicalPlanStem', () => {
const result = runGsdTools(['validate', 'health', '--json'], tmpDir);
assert.strictEqual(result.success, true, `unexpected failure: ${result.error}`);
const data = JSON.parse(result.output);
const i001 = (data.info ?? []).filter((i) => i.code === 'I001');
assert.strictEqual(i001.length, 0,
`Expected zero I001, got: ${JSON.stringify(i001)}`);
});
test('canonicalPlanStem is exported from validate.cjs', () => {
const gen = require('../gsd-core/bin/lib/validate.cjs');
assert.strictEqual(typeof gen.canonicalPlanStem, 'function',
'validate.cjs must export canonicalPlanStem as a function');
assert.strictEqual(gen.canonicalPlanStem('68-01-scaffolding'), '68-01');
assert.strictEqual(gen.canonicalPlanStem('68-01'), '68-01');
assert.strictEqual(gen.canonicalPlanStem('3A-01-feature'), '3A-01');
assert.strictEqual(gen.canonicalPlanStem('no-match'), 'no-match');
});
test('canonicalPlanStem rejects a single-digit slug word after a phase number (#2043)', () => {
const gen = require('../gsd-core/bin/lib/validate.cjs');
// "46-6-rs-pipeline-orchestrator" is not a valid PLAN stem shape (the "6"
// is a slug word, not a sub-phase segment), so it must return the input
// unchanged rather than the pre-fix, buggy "46-6".
assert.notStrictEqual(gen.canonicalPlanStem('46-6-rs-pipeline-orchestrator'), '46-6');
// Legit multi-segment stems are still canonicalized correctly, including a
// single-digit letter-suffix phase id ("3A") whose plan component is zero-padded.
assert.strictEqual(gen.canonicalPlanStem('68-01-scaffolding'), '68-01');
assert.strictEqual(gen.canonicalPlanStem('3A-01-feature'), '3A-01');
});
test('PHASE_TOKEN_FROM_DIR_RE rejects a ≥3-digit slug word after a phase number (#2232)', () => {
const gen = require('../gsd-core/bin/lib/validate.cjs');
const re = gen.PHASE_TOKEN_FROM_DIR_RE;
// Dir "14-2026-photos-performance" (roadmap phase name "2026 Photos &
// Performance") must extract token "14", not "14-2026" — the year is the
// slug's first word. Boundary by continuation-segment digit width:
assert.strictEqual(re.exec('14-2026-photos-performance')?.[1], '14'); // 4-digit: slug
assert.strictEqual(re.exec('05-100-slug')?.[1], '05'); // 3-digit: slug (policy)
assert.strictEqual(re.exec('02-01-setup')?.[1], '02-01'); // 2-digit: sub-phase
assert.strictEqual(re.exec('46-6-rs')?.[1], '46'); // 1-digit: slug (#2043)
});
test('PHASE_TOKEN_FROM_DIR_RE reads a 2-digit segment literally, whatever follows (#2528)', () => {
const gen = require('../gsd-core/bin/lib/validate.cjs');
const re = gen.PHASE_TOKEN_FROM_DIR_RE;
// A 1-digit word after the continuation is not evidence about the
// continuation: "10-24-7-autonomy" (phase 10, slug "24/7 Autonomy") and
// "10-24-7-zip" (sub-phase 10.24, slug "7-Zip") are the same string shape.
assert.strictEqual(re.exec('10-24-7-autonomy')?.[1], '10-24');
assert.strictEqual(re.exec('10-24-7-zip')?.[1], '10-24');
// Lowercase inside the segment IS evidence — the write side never emits it.
assert.strictEqual(re.exec('05-80-20-25abc')?.[1], '05-80-20');
});
test('canonicalPlanStem does not pair a ≥3-digit slug word (#2232)', () => {
const gen = require('../gsd-core/bin/lib/validate.cjs');
// A year-leading slug is not a plan component: the stem is returned
// unchanged rather than the bogus "14-2026".
assert.strictEqual(
gen.canonicalPlanStem('14-2026-photos-performance'),
'14-2026-photos-performance',
);
assert.strictEqual(gen.canonicalPlanStem('05-100-slug'), '05-100-slug');
// Legit zero-padded plan components still canonicalize.
assert.strictEqual(gen.canonicalPlanStem('68-01-scaffolding'), '68-01');
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-663-redos-roadmap-phase-parsing.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-663-redos-roadmap-phase-parsing (consolidation epic #1969 B3 #1972)", () => {
/**
* Regression test for the ReDoS fixes in buildRoadmapPhaseVariants() and
* buildNotStartedPhaseVariants() (src/validate.cts, fix #663).
*
* The old patterns used nested quantifiers that caused catastrophic
* backtracking on crafted input:
* old: [\w][\w.-]*(?:-[\w.-]+)* ← ambiguous alternation → exponential
* new: [\w][\w.-]* ← single quantifier → linear
*
* The same nested-quantifier shape was fixed identically in src/verify.cts,
* src/commands.cts, and src/phase.cts.
*
* Part A: behavior preservation — the collapsed regex still matches the same
* phase identifiers as before on normal roadmap content.
* Part B: ReDoS adversarial fixtures — calls buildRoadmapPhaseVariants /
* buildNotStartedPhaseVariants with pathological input (a malformed heading
* or checklist line that has NO terminating colon). The adversarial input
* would cause catastrophic backtracking under the OLD nested-quantifier
* pattern; the fix makes backtracking linear. We assert on the STRUCTURED
* RESULT (the returned Set is empty — no match — because the colon is
* absent) rather than on elapsed time, in compliance with the
* local/no-elapsed-assertion ESLint rule. A { timeout: 5000 } backstop is
* retained so the test fails fast if a future regression reintroduces a
* slow pattern.
*
* Requirements: TEST-663-B
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const {
buildRoadmapPhaseVariants,
buildNotStartedPhaseVariants,
} = require('../gsd-core/bin/lib/validate.cjs');
/**
* NOT a subprocess spawn timeout. This is `node:test`'s own per-test
* `{ timeout }` option (the second positional argument to `test(name,
* options, fn)`), used as a hang BACKSTOP for two ReDoS (#663) adversarial-
* input regression tests -- catches any regression that re-introduces a
* catastrophically-backtracking pattern. Coincides numerically with
* tests/helpers/timeouts.cjs's SCAN_USAGE_ERROR_TIMEOUT_MS and
* MALFORMED_INPUT_HOOK_TIMEOUT_MS but is a completely different mechanism
* (a node:test option, not a subprocess spawn bound) -- disclosed, not
* merged.
*/
const REDOS_BACKSTOP_TEST_TIMEOUT_MS = 5000;
// ─── Part A: behavior preservation ───────────────────────────────────────────
describe('buildRoadmapPhaseVariants — behavior preservation (#663)', () => {
test('matches plain numeric heading (Phase 1:)', () => {
const content = [
'# Roadmap',
'## Phase 1: Foo',
].join('\n');
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
assert.ok(roadmapPhases.has('1'), 'roadmapPhases should contain "1"');
});
test('matches milestone-prefixed heading (Phase 2-01:)', () => {
const content = [
'# Roadmap',
'### Phase 2-01: Bar',
].join('\n');
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
assert.ok(roadmapPhases.has('2-01'), 'roadmapPhases should contain "2-01"');
});
test('matches bracket-prefixed heading ([GSD] Phase 3.2:)', () => {
const content = [
'# Roadmap',
'### [GSD] Phase 3.2: Baz',
].join('\n');
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
assert.ok(roadmapPhases.has('3.2'), 'roadmapPhases should contain "3.2"');
});
test('collects all phase identifiers from mixed-format roadmap', () => {
const content = [
'# Roadmap',
'## Phase 1: Alpha',
'### Phase 2-01: Beta',
'### [GSD] Phase 3.2: Gamma',
].join('\n');
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
assert.ok(roadmapPhases.has('1'), 'should have phase 1');
assert.ok(roadmapPhases.has('2-01'), 'should have phase 2-01');
assert.ok(roadmapPhases.has('3.2'), 'should have phase 3.2');
});
test('populates roadmapPhaseVariants with padding-normalized forms', () => {
const content = [
'# Roadmap',
'### Phase 2-01: Beta',
].join('\n');
const { roadmapPhaseVariants } = buildRoadmapPhaseVariants(content);
// phaseVariants() adds both padded and unpadded forms
assert.ok(roadmapPhaseVariants.has('2-01') || roadmapPhaseVariants.has('02-01'),
'roadmapPhaseVariants should contain at least one padding form of 2-01');
});
});
describe('buildNotStartedPhaseVariants — behavior preservation (#663)', () => {
test('matches unchecked checklist item (- [ ] Phase 4-01:)', () => {
const content = [
'# Roadmap',
'- [ ] Phase 4-01: Qux',
].join('\n');
const notStarted = buildNotStartedPhaseVariants(content);
// phaseVariants() expands 4-01 into multiple forms; at minimum the raw form is present.
assert.ok(notStarted.has('4-01') || notStarted.has('04-01'),
'notStarted should contain a variant of 4-01');
});
test('does not pick up checked items', () => {
const content = [
'# Roadmap',
'- [x] Phase 5: Done',
].join('\n');
const notStarted = buildNotStartedPhaseVariants(content);
assert.strictEqual(notStarted.has('5'), false, 'completed phase should not be in notStarted');
});
});
// ─── Part B: ReDoS adversarial fixtures ──────────────────────────────────────
describe('buildRoadmapPhaseVariants — ReDoS adversarial fixture (#663)', () => {
// Pathological input: a heading line where the phase-id segment consists of
// many consecutive "-a" chunks with NO terminating colon. Under the old
// nested-quantifier pattern ([\w.-]*(?:-[\w.-]+)*\s*:) the engine must
// explore exponentially many ways to partition the "-a" repetitions before
// concluding there is no match. The fixed single-quantifier pattern
// ([\w.-]*\s*:) backtracks linearly. We assert that the malformed heading
// yields NO match (empty roadmapPhases Set) — the correct behavior when the
// terminating colon is absent. The { timeout: 5000 } backstop catches any
// regression that re-introduces a slow pattern.
test('malformed heading without colon yields no phase match (adversarial input)', { timeout: REDOS_BACKSTOP_TEST_TIMEOUT_MS }, () => {
const pathological = '## Phase a' + '-a'.repeat(32) + ' ';
const { roadmapPhases } = buildRoadmapPhaseVariants(pathological);
assert.strictEqual(roadmapPhases.size, 0,
'a heading with no terminating colon should not match any phase');
});
});
describe('buildNotStartedPhaseVariants — ReDoS adversarial fixture (#663)', () => {
// Same analysis: the old uncheckedPattern used the same nested quantifier.
// A checklist-style line with many "-a" segments and no colon triggers the
// same catastrophic backtracking. Assert the correct structured result:
// the malformed line yields an empty notStarted Set.
test('malformed unchecked-item without terminator yields no phase match (adversarial input)', { timeout: REDOS_BACKSTOP_TEST_TIMEOUT_MS }, () => {
// No trailing colon or whitespace: the regex terminator [:\s*] cannot match,
// so the engine must backtrack through all '-a' repetitions and conclude no
// match. Under the old nested-quantifier pattern this was exponential;
// under the fixed linear pattern it returns immediately with an empty Set.
const pathological = '- [ ] Phase a' + '-a'.repeat(32);
const notStarted = buildNotStartedPhaseVariants(pathological);
assert.strictEqual(notStarted.size, 0,
'an unchecked-item line with no terminating colon or space should not match any phase');
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-892-validate-checklist-roadmap-phases.test.cjs — consolidation epic #1969 (B3 #1972)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-892-validate-checklist-roadmap-phases (consolidation epic #1969 B3 #1972)", () => {
/**
* Regression test for #892: checklist-style roadmap phases (`- [x] **Phase NN: name**`)
* were silently skipped by `buildRoadmapPhaseVariants()`, causing W007 false positives
* on every on-disk phase dir when the project uses the checklist ROADMAP format.
*
* Covers:
* A. Unit-level: `buildRoadmapPhaseVariants()` in validate.cts recognises both
* checked (`- [x]`) and unchecked (`- [ ]`) checklist items.
* B. Integration: `validate health` emits NO W007 for a checklist-only roadmap
* whose phase dirs all appear in the checklist.
* C. Integration: `validate consistency` emits NO "exists on disk but not in
* ROADMAP.md" warning for the same checklist-only roadmap.
*
* Requirements: BUG-892
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const {
buildRoadmapPhaseVariants,
} = require('../gsd-core/bin/lib/validate.cjs');
// ─── A: Unit-level ────────────────────────────────────────────────────────────
describe('buildRoadmapPhaseVariants — checklist format support (#892)', () => {
test('matches checked checklist item `- [x] **Phase 01: name**`', () => {
const content = [
'# Roadmap',
'',
'- [x] **Phase 01: infrastructure-hardening**',
].join('\n');
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
assert.ok(
roadmapPhases.has('01') || roadmapPhases.has('1'),
`roadmapPhases should contain a variant of "01", got: ${JSON.stringify([...roadmapPhases])}`
);
});
test('matches checked checklist item with uppercase X `- [X] **Phase 02: foo**`', () => {
const content = [
'# Roadmap',
'',
'- [X] **Phase 02: feature-work**',
].join('\n');
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
assert.ok(
roadmapPhases.has('02') || roadmapPhases.has('2'),
`roadmapPhases should contain a variant of "02", got: ${JSON.stringify([...roadmapPhases])}`
);
});
test('matches unchecked checklist item `- [ ] **Phase 03: name**`', () => {
// unchecked items are also phases — they just have not been started
const content = [
'# Roadmap',
'',
'- [ ] **Phase 03: future-work**',
].join('\n');
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
assert.ok(
roadmapPhases.has('03') || roadmapPhases.has('3'),
`roadmapPhases should contain a variant of "03", got: ${JSON.stringify([...roadmapPhases])}`
);
});
test('collects all phases from a pure checklist roadmap (no ## headings)', () => {
const content = [
'# Roadmap',
'',
'- [x] **Phase 01: alpha**',
'- [x] **Phase 02: beta**',
'- [ ] **Phase 03: gamma**',
].join('\n');
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
const has = (id) => roadmapPhases.has(id) || roadmapPhases.has(id.replace(/^0+/, '')) || roadmapPhases.has(String(parseInt(id, 10)).padStart(2, '0'));
assert.ok(has('01'), 'should contain phase 01');
assert.ok(has('02'), 'should contain phase 02');
assert.ok(has('03'), 'should contain phase 03');
});
test('populates roadmapPhaseVariants with padding-normalised forms for checklist phases', () => {
const content = [
'# Roadmap',
'',
'- [x] **Phase 01: something**',
].join('\n');
const { roadmapPhaseVariants } = buildRoadmapPhaseVariants(content);
// phaseVariants() adds both '1' and '01' forms
assert.ok(
roadmapPhaseVariants.has('1') || roadmapPhaseVariants.has('01'),
`roadmapPhaseVariants should contain at least one padding form, got: ${JSON.stringify([...roadmapPhaseVariants])}`
);
});
test('mixed roadmap (headings + checklist) collects phases from both styles', () => {
const content = [
'# Roadmap',
'',
'## Phase 1: heading-style',
'',
'- [x] **Phase 02: checklist-style**',
].join('\n');
const { roadmapPhases } = buildRoadmapPhaseVariants(content);
assert.ok(
roadmapPhases.has('1') || roadmapPhases.has('01'),
'should contain heading-style phase 1'
);
assert.ok(
roadmapPhases.has('02') || roadmapPhases.has('2'),
'should contain checklist-style phase 02'
);
});
});
// ─── B: validate health — no W007 for checklist-only roadmaps ────────────────
describe('validate health — checklist-style roadmap phases must not emit W007 (#892)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('no W007 when phase dirs match checked checklist entries in ROADMAP.md', () => {
// Write PROJECT.md, STATE.md, config.json, and a checklist-only ROADMAP.md
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
'# Project\n\n## What This Is\n\nTest.\n\n## Core Value\n\nValue.\n\n## Requirements\n\nRequirements.\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Session State\n\n## Current Position\n\nPhase 1 in progress.\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2)
);
// Checklist-only ROADMAP: no ## Phase headings, only checklist items
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 01: infrastructure-hardening**',
'- [x] **Phase 02: feature-work**',
'',
].join('\n')
);
// Create matching phase directories on disk
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-infrastructure-hardening'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature-work'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const w007s = output.warnings.filter((w) => w.code === 'W007');
assert.strictEqual(
w007s.length,
0,
`W007 must not fire for phases whose dirs are listed in a checklist-style ROADMAP.md, got: ${JSON.stringify(w007s)}`
);
});
test('W007 still fires when a phase dir is genuinely absent from a checklist roadmap', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
'# Project\n\n## What This Is\n\nTest.\n\n## Core Value\n\nValue.\n\n## Requirements\n\nRequirements.\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Session State\n\n## Current Position\n\nPhase 1 in progress.\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2)
);
// ROADMAP only lists phase 01, not phase 99
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 01: known-phase**',
'',
].join('\n')
);
// Phase 01 dir is present but also an orphan phase 99 that is NOT in ROADMAP
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-known-phase'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '99-orphan'), { recursive: true });
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const w007s = output.warnings.filter((w) => w.code === 'W007');
assert.ok(
w007s.length > 0,
`W007 must still fire for a phase dir genuinely not listed in ROADMAP.md, got warnings: ${JSON.stringify(output.warnings)}`
);
// Should only flag phase 99, not phase 01
assert.ok(
w007s.some((w) => w.message.includes('99')),
`W007 should reference orphan phase 99, got: ${JSON.stringify(w007s)}`
);
assert.ok(
!w007s.some((w) => w.message.includes('01') || w.message.includes('1')),
`W007 must NOT flag phase 01 which is in the checklist, got: ${JSON.stringify(w007s)}`
);
});
});
// ─── C: validate consistency — no false positive for checklist-only roadmaps ─
describe('validate consistency — checklist-style roadmap phases must not emit false warnings (#892)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('no "exists on disk but not in ROADMAP.md" warning for checklist-matched phases', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'PROJECT.md'),
'# Project\n\n## What This Is\n\nTest.\n\n## Core Value\n\nValue.\n\n## Requirements\n\nRequirements.\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Session State\n\n## Current Position\n\nPhase 1 in progress.\n'
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2)
);
// Checklist-only ROADMAP
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'- [x] **Phase 01: infrastructure-hardening**',
'- [x] **Phase 02: feature-work**',
'',
].join('\n')
);
// Create matching phase directories
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-infrastructure-hardening'), { recursive: true });
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature-work'), { recursive: true });
const result = runGsdTools('validate consistency', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
// No "exists on disk but not in ROADMAP" warnings for checklist-listed phases
const diskNotInRoadmapWarnings = (output.warnings || []).filter(
(w) => typeof w === 'string'
? w.includes('exists on disk but not in ROADMAP')
: (w.message || '').includes('exists on disk but not in ROADMAP')
);
assert.strictEqual(
diskNotInRoadmapWarnings.length,
0,
`No "exists on disk but not in ROADMAP.md" warnings should fire for checklist-listed phases, got: ${JSON.stringify(diskNotInRoadmapWarnings)}`
);
});
});
});
}
// ── W024 (#2573): STATE.md commit-age freshness advisory ─────────────────────
//
// Advisory ONLY. It appends to warnings[] and must never change `status` or any
// existing count — promoting it to a gate is a separate, disclosed change.
//
// Goodhart guard (why the threshold is coarse and the wording is a proxy):
// `state_head` restamps on EVERY state write, so a low commits-behind count
// means "something wrote STATE recently", not "STATE's content is accurate".
// The number is a freshness proxy; the message must never assert drift.
describe('W024 — STATE.md commit-age freshness advisory (#2573)', () => {
const { after } = require('node:test');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { runGsdTools, cleanup } = require('./helpers.cjs');
const { runGit, OUTCOME } = require('./helpers/process-seam.cjs');
const {
STATE_HEAD_ADVISORY_COMMITS,
} = require('../gsd-core/bin/lib/verify.cjs');
const dirs = [];
const track = (d) => { dirs.push(d); return d; };
after(() => { while (dirs.length) cleanup(dirs.pop()); });
// `runGit` (tests/helpers/process-seam.cjs) reports a failed spawn as DATA,
// never as a throw — by design, so retry-aware callers can inspect it. A
// fixture builder that ignores that return value swallows the failure and
// silently produces a WEAKER input (e.g. one fewer commit, a blank
// `state_head`) than what the test asked for: the exact "swallowed
// precondition laundered into a plausible downstream outcome" shape that
// eslint-rules/no-swallowed-precondition.cjs exists to catch, just in test
// code instead of source. `mustGit` closes that gap for this builder.
function mustGit(args, options) {
const r = runGit(args, options);
if (r.outcome !== OUTCOME.EXITED || r.exitCode !== 0) {
throw new Error(
`mustGit: \`git ${args.join(' ')}\` did not succeed ` +
`(outcome=${r.outcome}, exitCode=${r.exitCode}): ${r.stderr.trim()}`
);
}
return r;
}
function project({ commitsAhead, stateHead = 'BASE' }) {
const base = track(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2573-h-')));
const planningDir = path.join(base, '.planning');
fs.mkdirSync(path.join(planningDir, 'phases'), { recursive: true });
fs.writeFileSync(
path.join(planningDir, 'PROJECT.md'),
'# Project\n\n## What This Is\nTest.\n\n## Core Value\nTest.\n\n## Requirements\nTest.\n',
);
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ model_profile: 'balanced' }));
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
'# Roadmap\n\n## Milestone v1.0\n\n### Phase 1: One\n**Goal:** g\n',
);
mustGit(['init', '-q'], { cwd: base });
mustGit(['config', 'user.email', 't@t.com'], { cwd: base });
mustGit(['config', 'user.name', 'T'], { cwd: base });
mustGit(['config', 'commit.gpgsign', 'false'], { cwd: base });
mustGit(['add', '-A'], { cwd: base });
mustGit(['commit', '-q', '-m', 'seed'], { cwd: base });
const seed = mustGit(['rev-parse', 'HEAD'], { cwd: base }).stdout.trim();
const head = seed;
assert.ok(head.length > 0, 'FIXTURE ERROR: `git rev-parse HEAD` for the seed commit returned empty');
fs.writeFileSync(
path.join(planningDir, 'STATE.md'),
[
'---',
'status: executing',
...(stateHead === null ? [] : [`state_head: ${stateHead === 'BASE' ? head : stateHead}`]),
'---',
'',
'# State',
'',
'**Current Phase:** 1',
'**Status:** In progress',
'',
].join('\n'),
);
for (let i = 0; i < commitsAhead; i++) {
fs.writeFileSync(path.join(base, `f${i}.txt`), `${i}\n`);
mustGit(['add', '-A'], { cwd: base });
mustGit(['commit', '-q', '-m', `c${i}`], { cwd: base });
}
// Precondition check: prove the fixture built what it claims rather than
// trusting the loop above ran to completion. A silently-failed `git
// commit` here previously dropped one commit off the count with no
// signal, which was enough to move `readStateHeadFreshness`
// (src/state.cts) across the W024 threshold and produce a false negative
// (#2573 flake). Fail as a FIXTURE error, not as the assertion under test.
const actualCommitsAhead = Number(
mustGit(['rev-list', '--count', `${seed}..HEAD`], { cwd: base }).stdout.trim()
);
assert.strictEqual(
actualCommitsAhead,
commitsAhead,
`FIXTURE ERROR: requested commitsAhead=${commitsAhead} but ` +
`\`git rev-list --count ${seed}..HEAD\` reports ${actualCommitsAhead}`
);
return base;
}
function health(dir) {
const r = runGsdTools(['validate', 'health', '--json'], dir);
assert.strictEqual(r.success, true, `unexpected failure: ${r.error}`);
return JSON.parse(r.output);
}
const w024 = (d) => (d.warnings ?? []).filter((w) => w.code === 'W024');
test('exports a named threshold constant rather than a bare magic number', () => {
assert.strictEqual(typeof STATE_HEAD_ADVISORY_COMMITS, 'number');
assert.ok(STATE_HEAD_ADVISORY_COMMITS > 0);
});
test('does NOT fire when STATE.md was written at HEAD', () => {
const data = health(project({ commitsAhead: 0 }));
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
assert.strictEqual(w024.length, 0, `expected no W024, got ${JSON.stringify(w024)}`);
});
test('boundary: silent at threshold-1, fires at threshold+1', () => {
// The 0/1/20 cases alone leave the actual boundary untested — 1 is a
// trivial-fit, not an edge. These two pin the comparison operator.
const below = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS - 1 }));
assert.strictEqual((below.warnings ?? []).filter((w) => w.code === 'W024').length, 0,
`must stay silent at ${STATE_HEAD_ADVISORY_COMMITS - 1} commits`);
const above = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS + 1 }));
assert.strictEqual((above.warnings ?? []).filter((w) => w.code === 'W024').length, 1,
`must fire at ${STATE_HEAD_ADVISORY_COMMITS + 1} commits`);
});
test('does NOT fire below the threshold (a healthy project stays quiet)', () => {
// Hyrum guard: firing on every ordinary project would change health's
// observable "clean" state and make anything gating on clean-health noisy.
const data = health(project({ commitsAhead: 1 }));
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
assert.strictEqual(w024.length, 0, `expected no W024 at 1 commit, got ${JSON.stringify(w024)}`);
});
test('fires at/above the threshold, states the count, and stays a proxy (never asserts drift)', () => {
const data = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS }));
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
assert.strictEqual(w024.length, 1, `expected exactly one W024, got ${JSON.stringify(data.warnings)}`);
const msg = String(w024[0].message);
assert.ok(msg.includes(String(STATE_HEAD_ADVISORY_COMMITS)),
`W024 must state the commit count, got: ${msg}`);
assert.ok(/approximate/i.test(msg),
`W024 must frame the signal as approximate (freshness proxy), got: ${msg}`);
assert.ok(!/\bdrift(ed)?\b|\bis wrong\b|\bstale content\b/i.test(msg),
`W024 must NOT assert drift — it is a proxy, not a measurement. Got: ${msg}`);
});
test('is advisory only — lands in warnings[], never errors[] or the repair set', () => {
// NOT "stale.status === fresh.status": these fixtures already carry W006
// ("Phase in ROADMAP but no directory"), so both sides are `degraded`
// regardless of W024 and that assertion can never fail. Health derives
// status from warnings by design (warnings -> degraded), so the real
// invariant is that W024 is a WARNING and never escalates.
const data = health(project({ commitsAhead: STATE_HEAD_ADVISORY_COMMITS }));
assert.strictEqual(w024(data).length, 1, 'precondition: W024 fired');
assert.ok(
!(data.errors ?? []).some((e) => e.code === 'W024'),
`W024 must never appear in errors[]: ${JSON.stringify(data.errors)}`,
);
assert.notStrictEqual(data.status, 'broken',
'an advisory must never break health');
assert.ok(!w024(data)[0].repairable,
'W024 is diagnostic, not auto-repairable — it must not enter the repair set');
});
test('absent state_head → no W024 (unknown is not a finding)', () => {
const data = health(project({ commitsAhead: 5, stateHead: null }));
const w024 = (data.warnings ?? []).filter((w) => w.code === 'W024');
assert.strictEqual(w024.length, 0, `unknown must stay silent, got ${JSON.stringify(w024)}`);
});
});