diff --git a/.changeset/3753-consolidate-milestone-tests.md b/.changeset/3753-consolidate-milestone-tests.md new file mode 100644 index 000000000..392f1d886 --- /dev/null +++ b/.changeset/3753-consolidate-milestone-tests.md @@ -0,0 +1,19 @@ +--- +type: Fixed +pr: 3753 +--- + + +## Summary + +Consolidates the Milestone Module test cluster from 10 files to 4, bringing the cluster within the lint-test-file-count allowlist ceiling introduced in PR #3738. + +- Rewrote `tests/milestone.test.cjs` (869 → 720 LOC) with compact shared helpers, absorbing `milestone-regex-global.test.cjs` and `bug-3043-milestone-complete-scope.test.cjs` +- New `tests/milestone-archive.test.cjs` (505 LOC) absorbs `bug-2684`, `bug-2787`, `bug-3164`, `bug-3600` — milestone archive layout, fenced code block parsing, phase filter, version forwarding +- Expanded `tests/milestone-summary.test.cjs` (471 LOC) to absorb `milestone-audit.test.cjs` +- `sdk/src/milestone-runner.test.ts` unchanged (421 LOC) +- All 99 CJS assertions and 11 SDK assertions preserved (no regression) +- Added Milestone Module glossary entry to `CONTEXT.md` +- Allowlist update deferred to rebase after PR #3738 merges; pre-rebase, `milestone` entry would change from 10 → 4 + +Closes #3753 diff --git a/CONTEXT.md b/CONTEXT.md index eb0232b1c..41f97bbb8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -8,6 +8,9 @@ ## Glossary — Domain modules and seams +### Milestone Module +Module owning `milestone complete` (archive roadmap/requirements/phases, build MILESTONES.md entry, update STATE.md), `requirements mark-complete` (checkbox + table update with regex-global-state fix), and `phases clear`. Key behaviors: milestone-phase scoping (extract phases from ROADMAP.md milestone slice, support project-code-prefix dirs e.g. CK-01-name, exclude prior-milestone phases), milestone-archive layout (resolve phase dirs from `.planning/milestones/v*-phases/` when `.planning/phases/` absent), fenced-code-block boundary tracking in `extractCurrentMilestone`. Source of truth: `get-shit-done/bin/lib/milestone.cjs`; SDK surface: `sdk/src/query/milestone.ts` (query handlers for `milestone.complete`, `phases.archive`). SDK milestone runner (`GSD.run()`) lives in `sdk/src/index.ts` — discovers phases via `roadmapAnalyze`, runs each incomplete phase, emits `MilestoneStart`/`MilestoneComplete` events. Test consolidation: PR #3753 (10 files → 4). + ### Dispatch Pipeline Module Module that composes Dispatch Policy Module, Query Execution Policy Module, and per-stage handlers (input-validation, plan, execution, result-builder, formatting, error-mapping, observability) into the end-to-end pipeline that produces a `QueryDispatchResult`. Entry point: `sdk/src/query/query-dispatch.ts`. Typed contract: `sdk/src/query/query-dispatch-contract.ts`. diff --git a/tests/bug-2684-milestone-complete-version.test.cjs b/tests/bug-2684-milestone-complete-version.test.cjs deleted file mode 100644 index aaa449e0d..000000000 --- a/tests/bug-2684-milestone-complete-version.test.cjs +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Regression tests for bug #2684: - * `gsd-sdk query milestone.complete ` always fails with - * GSDError: version required for phases archive. - * - * Root cause: milestoneComplete extracted version from args[0] but passed - * [] instead of args (or [version]) to phasesArchive, so phasesArchive - * never received the version string and threw immediately. - * - * Fix: pass args (or [version]) when delegating to phasesArchive. - */ - -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 SDK_CLI = path.join(__dirname, '..', 'sdk', 'dist', 'cli.js'); -const { execFileSync } = require('child_process'); - -function runSdkQuery(args, cwd) { - try { - const result = execFileSync(process.execPath, [SDK_CLI, 'query', ...args], { - cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - const parsed = JSON.parse(result.trim()); - return { success: true, data: parsed }; - } catch (err) { - const stderr = err.stderr?.toString().trim() || ''; - const stdout = err.stdout?.toString().trim() || ''; - // If the output is JSON despite non-zero exit, parse it - try { - const parsed = JSON.parse(stdout); - return { success: true, data: parsed }; - } catch { - /* not JSON */ - } - return { success: false, error: stderr || err.message }; - } -} - -describe('bug #2684: milestone.complete forwards version to phases.archive', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('milestone.complete v1.0 does not throw version required error', () => { - // Minimal project: ROADMAP.md so milestone filter can run, one phase dir - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n` - ); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); - - const result = runSdkQuery(['milestone.complete', 'v1.0'], tmpDir); - - assert.ok( - result.success, - `milestone.complete should succeed, got error: ${result.error}` - ); - assert.ok( - !result.error || !result.error.includes('version required'), - `should not throw "version required" — got: ${result.error}` - ); - }); - - test('milestone.complete returns version in response data', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n` - ); - - const result = runSdkQuery(['milestone.complete', 'v2.5'], tmpDir); - - assert.ok(result.success, `Command failed: ${result.error}`); - assert.strictEqual(result.data.version, 'v2.5', 'version should be echoed in response'); - }); - - test('milestone.complete with --archive-phases forwards version correctly', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n` - ); - const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan'); - fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary'); - - // With --archive-phases, the version must reach the archive logic - // Without the fix this would throw "version required for phases archive" - const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-phases'], tmpDir); - - assert.ok(result.success, `milestone.complete --archive-phases failed: ${result.error}`); - assert.strictEqual(result.data.version, 'v1.0'); - // The archive flag should have moved the phase dir - assert.ok( - result.data.archived.phases === true, - 'phases should be archived when --archive-phases is passed' - ); - const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases'); - assert.ok(fs.existsSync(archiveDir), 'archive directory should exist'); - }); - - test('phases.archive v1.0 (direct call, workaround) also works', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n` - ); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); - - const result = runSdkQuery(['phases.archive', 'v1.0'], tmpDir); - - assert.ok(result.success, `phases.archive failed: ${result.error}`); - assert.strictEqual(result.data.version, 'v1.0'); - }); -}); diff --git a/tests/bug-2787-milestone-fenced-block-truncation.test.cjs b/tests/bug-2787-milestone-fenced-block-truncation.test.cjs deleted file mode 100644 index a9849ba3b..000000000 --- a/tests/bug-2787-milestone-fenced-block-truncation.test.cjs +++ /dev/null @@ -1,203 +0,0 @@ -'use strict'; - -/** - * Regression test for #2787: - * extractCurrentMilestone truncates ROADMAP.md at heading-like lines inside - * fenced code blocks. The nextMilestonePattern regex runs against the raw - * string with the `m` flag, which matches `^` at every newline — including - * newlines inside ``` blocks. A line like `# Ops runbook (v1.0 compat)` inside - * a fence matches the pattern and prematurely sets sectionEnd, hiding all - * phases defined after the fenced block. - */ - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); - -describe('extractCurrentMilestone — fenced code block boundary (#2787)', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('roadmap analyze returns all phases when a fenced block contains a heading-like line matching the milestone-end pattern', () => { - // ROADMAP.md: milestone v1.1 with 4 phases. Between Phase 2 and Phase 3, - // a fenced code block contains `# Ops runbook — v1.0 compat`, which - // matches ^#{1,2}\s+.*v\d+\.\d+ (the nextMilestonePattern) and would - // prematurely terminate the milestone slice before the fix. - const roadmap = [ - '# Project Roadmap', - '', - '## ✅ v1.0: Foundation', - '', - '
', - '✅ v1.0 Foundation — SHIPPED', - '', - '### Phase 1: Bootstrap', - '**Goal:** Bootstrap the project', - '', - '
', - '', - '## Roadmap v1.1: New Work', - '', - '### Phase 1: Setup', - '**Goal:** Set up the environment', - '', - '### Phase 2: Core Logic', - '**Goal:** Implement core logic', - '', - 'Deployment notes:', - '', - '```bash', - '# Ops runbook — v1.0 compat', - 'echo "deploy complete"', - '```', - '', - '### Phase 3: Testing', - '**Goal:** Write regression tests', - '', - '### Phase 4: Deploy', - '**Goal:** Ship to production', - ].join('\n'); - - fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - '---\nmilestone: v1.1\n---\n\n# GSD State\n' - ); - - const result = runGsdTools('roadmap analyze', tmpDir); - assert.ok(result.success, `roadmap analyze should succeed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual( - output.phase_count, - 4, - [ - 'All 4 phases in the v1.1 milestone section should be found.', - `Got ${output.phase_count} phase(s): ${JSON.stringify(output.phases?.map(p => p.number))}`, - 'Phases 3 and 4 are likely being cut off by the fenced code block heading match.', - ].join(' ') - ); - }); - - test('roadmap analyze returns all phases when a fenced block contains a backtick-tilde fence with milestone-like heading', () => { - // Verify tilde fences (~~~) are also tracked correctly. - const roadmap = [ - '## Roadmap v2.0: Feature Work', - '', - '### Phase 1: Alpha', - '**Goal:** Alpha release', - '', - '~~~markdown', - '## Prior art (v1.9 snapshot)', - '~~~', - '', - '### Phase 2: Beta', - '**Goal:** Beta release', - ].join('\n'); - - fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - '---\nmilestone: v2.0\n---\n\n# GSD State\n' - ); - - const result = runGsdTools('roadmap analyze', tmpDir); - assert.ok(result.success, `roadmap analyze should succeed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual( - output.phase_count, - 2, - [ - 'Both phases in the v2.0 milestone section should be found.', - `Got ${output.phase_count} phase(s).`, - 'Phase 2 is likely being cut off by the tilde-fenced heading match.', - ].join(' ') - ); - }); - - test('fenced block with info string (e.g. ```js) is not closed by a nested info-string line', () => { - // A closing fence MUST have only optional trailing spaces — an info string - // like ```js inside an open fence must NOT close it. Before the fix the - // regex matched any line starting with ``` regardless of what followed, so - // a line like "```js" inside the fenced block would toggle fenceChar off - // and expose the heading-like line that follows to the milestone-end check. - const roadmap = [ - '## Roadmap v3.0: Info-String Edge Case', - '', - '### Phase 1: Setup', - '**Goal:** First phase', - '', - '```text', - '```js', - '# This heading-like line (v3.0 compat) must NOT end the milestone', - '```', - '', - '### Phase 2: Core', - '**Goal:** Second phase', - ].join('\n'); - - fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - '---\nmilestone: v3.0\n---\n\n# GSD State\n' - ); - - const result = runGsdTools('roadmap analyze', tmpDir); - assert.ok(result.success, `roadmap analyze should succeed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual( - output.phase_count, - 2, - [ - 'Both phases should be found; the ```js line inside the fence must not close it.', - `Got ${output.phase_count} phase(s).`, - ].join(' ') - ); - }); - - test('roadmap get-phase finds a phase defined after a fenced code block', () => { - const roadmap = [ - '## Roadmap v1.1: New Work', - '', - '### Phase 1: Setup', - '**Goal:** Bootstrap', - '', - '```bash', - '# Runbook for v1.0 deploy', - '```', - '', - '### Phase 2: Core', - '**Goal:** Core implementation', - ].join('\n'); - - fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - '---\nmilestone: v1.1\n---\n\n# GSD State\n' - ); - - const result = runGsdTools('roadmap get-phase 2', tmpDir); - assert.ok(result.success, `roadmap get-phase should succeed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.ok( - output.found, - [ - 'Phase 2 should be found even though it comes after a fenced code block.', - `Got: found=${output.found}`, - ].join(' ') - ); - assert.strictEqual(output.phase_number, '2', 'should return phase number 2'); - }); -}); diff --git a/tests/bug-2943-config-get-context-window-default.test.cjs b/tests/bug-2943-config-get-context-window-default.test.cjs index b42fe4137..b986b5ff5 100644 --- a/tests/bug-2943-config-get-context-window-default.test.cjs +++ b/tests/bug-2943-config-get-context-window-default.test.cjs @@ -52,10 +52,13 @@ describe('bug-2943: config-get returns schema default for context_window', () => let stderr = ''; let exitCode = 0; try { + // Windows/Node 22 under --test-concurrency=4 can starve subprocess slots when + // sharing a wave with bug-2760-codex-install (8–15s install subtests). 15s covers + // observed worst case (13.5s) with headroom. stdout = execFileSync(process.execPath, args, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], - timeout: 5000, + timeout: 15000, }); } catch (err) { exitCode = err.status ?? 1; diff --git a/tests/bug-3043-milestone-complete-scope.test.cjs b/tests/bug-3043-milestone-complete-scope.test.cjs deleted file mode 100644 index 2235aba14..000000000 --- a/tests/bug-3043-milestone-complete-scope.test.cjs +++ /dev/null @@ -1,57 +0,0 @@ -const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); - -describe('bug #3043: milestone complete respects explicit version scope', () => { - test('milestone.complete v3.6 uses v3.6 phases even when STATE milestone is v3.5', () => { - const tmpDir = createTempProject('gsd-bug-3043-'); - try { - fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v3.5\n---\n'); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - '# Roadmap\n\n## 🚧 v3.5 Paused\n### Phase 103: old\n### Phase 104: old2\n\n## 🚧 v3.6 Current\n### Phase 108: new\n', - ); - fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n'); - - const oldDirA = path.join(tmpDir, '.planning', 'phases', '103.old'); - const oldDirB = path.join(tmpDir, '.planning', 'phases', '104.old'); - const newDir = path.join(tmpDir, '.planning', 'phases', '108.new'); - fs.mkdirSync(oldDirA, { recursive: true }); - fs.mkdirSync(oldDirB, { recursive: true }); - fs.mkdirSync(newDir, { recursive: true }); - fs.writeFileSync(path.join(oldDirA, 'SUMMARY.md'), 'one-liner: old milestone A\n\n## Summary\nold\n'); - fs.writeFileSync(path.join(oldDirB, 'SUMMARY.md'), 'one-liner: old milestone B\n\n## Summary\nold\n'); - fs.writeFileSync(path.join(newDir, 'SUMMARY.md'), 'one-liner: new milestone\n\n## Summary\nnew\n'); - - const result = runGsdTools(['milestone', 'complete', 'v3.6', '--raw'], tmpDir); - assert.equal(result.success, true, result.error || result.output); - const payload = JSON.parse(result.output); - - assert.equal(payload.version, 'v3.6'); - assert.equal(payload.phases, 1, `expected v3.6 to scope to one phase, got ${payload.phases}`); - } finally { - cleanup(tmpDir); - } - }); - - test('milestone.complete fails when explicit milestone version resolves no phases', () => { - const tmpDir = createTempProject('gsd-bug-3043-empty-'); - try { - fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v1.0\n---\n'); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - '# Roadmap\n\n## 🚧 v1.0\n### Phase 1: foundation\n', - ); - fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n'); - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); - - const result = runGsdTools(['milestone', 'complete', 'v9.9', '--raw'], tmpDir); - assert.equal(result.success, false, 'expected command to fail when no phases match explicit version'); - assert.match(result.error || '', /no phases|phase/i); - } finally { - cleanup(tmpDir); - } - }); -}); diff --git a/tests/bug-3164-milestone-archive-layout.test.cjs b/tests/bug-3164-milestone-archive-layout.test.cjs deleted file mode 100644 index d1f07237e..000000000 --- a/tests/bug-3164-milestone-archive-layout.test.cjs +++ /dev/null @@ -1,263 +0,0 @@ -'use strict'; - -/** - * #3164 — gsd-tools doesn't support .planning/milestones/v*-phases/ layout. - * - * Validators hardcode `phasesDir = .planning/phases/`. On projects that have - * graduated to milestone-archive layout (.planning/milestones/v*-phases/), - * the old path doesn't exist and diskPhases stays empty, triggering W006 - * "Phase N in ROADMAP.md but no directory on disk" for every active phase. - * - * Fix: resolve phasesDir to the active milestone's archive dir when - * .planning/phases/ does not exist. - */ - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { createTempProject, cleanup, runGsdTools, toPosixPath } = require('./helpers.cjs'); - -function setupMilestoneArchiveProject(tmpDir, options = {}) { - const { - milestone = 'v1.7', - phases = ['64-secondary-grader-fix'], - roadmapPhases = ['64'], - } = options; - - // Remove the default .planning/phases/ dir (milestone-archive layout has no flat phases/) - fs.rmSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true, force: true }); - - // Create milestone-archive phase directories - const archiveDir = path.join(tmpDir, '.planning', 'milestones', `${milestone}-phases`); - for (const phase of phases) { - const phaseDir = path.join(archiveDir, phase); - fs.mkdirSync(phaseDir, { recursive: true }); - fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), `# Plan\nPhase ${phase}\n`); - } - - // Write STATE.md with current milestone - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `milestone: ${milestone}\n# Session State\n\nPhase: ${roadmapPhases[0]}\n` - ); - - // Write PROJECT.md - fs.writeFileSync( - path.join(tmpDir, '.planning', 'PROJECT.md'), - '# Project\n\n## What This Is\nTest.\n## Core Value\nTest.\n## Requirements\nTest.\n' - ); - - // Write ROADMAP.md with phases in the milestone section - const phaseLines = roadmapPhases.map(n => `### Phase ${n}: Description\n\nGoal: implement it.\n`).join('\n'); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap\n\n## Roadmap ${milestone}: Current\n\n${phaseLines}\n` - ); - - // Write config.json - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2) - ); -} - -describe('#3164 — validate consistency: milestone-archive layout', () => { - let tmpDir; - - beforeEach(() => { tmpDir = createTempProject(); }); - afterEach(() => { cleanup(tmpDir); }); - - test('no W006 warnings for phases that exist in .planning/milestones/v*-phases/', () => { - setupMilestoneArchiveProject(tmpDir, { - milestone: 'v1.7', - phases: ['64-secondary-grader-fix'], - roadmapPhases: ['64'], - }); - - const result = runGsdTools('validate consistency', tmpDir); - assert.ok(result.success, `validate consistency should succeed: ${result.error}`); - - const out = JSON.parse(result.output); - const w006 = (out.warnings || []).filter(w => w.includes('Phase 64') && w.includes('no directory')); - assert.deepStrictEqual( - w006, [], - `Got spurious W006 for phase 64 in milestone-archive layout:\n ${w006.join('\n ')}` - ); - }); - - test('no W006 when multiple phases exist in milestone-archive layout', () => { - setupMilestoneArchiveProject(tmpDir, { - milestone: 'v1.7', - phases: ['48-feature-a', '51-feature-b', '64-feature-c'], - roadmapPhases: ['48', '51', '64'], - }); - - const result = runGsdTools('validate consistency', tmpDir); - assert.ok(result.success, `validate consistency should succeed: ${result.error}`); - - const out = JSON.parse(result.output); - const w006 = (out.warnings || []).filter(w => w.includes('no directory')); - assert.deepStrictEqual( - w006, [], - `Got spurious W006 warnings in milestone-archive layout:\n ${w006.join('\n ')}` - ); - }); - - test('prefixed archive dir names (CK-64-...) are recognized as phase 64', () => { - setupMilestoneArchiveProject(tmpDir, { - milestone: 'v1.7', - phases: ['CK-64-secondary-grader-fix'], - roadmapPhases: ['64'], - }); - - const result = runGsdTools('validate consistency', tmpDir); - assert.ok(result.success, `validate consistency should succeed: ${result.error}`); - - const out = JSON.parse(result.output); - const w006 = (out.warnings || []).filter(w => w.includes('Phase 64') && w.includes('no directory')); - assert.deepStrictEqual( - w006, [], - `Prefixed phase dir should count as phase 64, got W006:\n ${w006.join('\n ')}` - ); - }); - - test('consistency scans only active milestone archive and still validates plans/frontmatter', () => { - // Remove default flat phases dir; this project is archive-only. - fs.rmSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true, force: true }); - - // Old archived milestone should NOT be treated as active on-disk phase roots. - const oldDir = path.join(tmpDir, '.planning', 'milestones', 'v1.6-phases', '64-legacy'); - fs.mkdirSync(oldDir, { recursive: true }); - fs.writeFileSync(path.join(oldDir, '64-01-PLAN.md'), '# legacy plan\n'); - - // Active milestone includes intentionally malformed plan numbering/frontmatter. - const activeDir = path.join(tmpDir, '.planning', 'milestones', 'v1.7-phases', '65-current'); - fs.mkdirSync(activeDir, { recursive: true }); - fs.writeFileSync(path.join(activeDir, '65-01-PLAN.md'), '# plan 1\n'); - fs.writeFileSync(path.join(activeDir, '65-03-PLAN.md'), '# plan 3\n'); - - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - '# Session State\n\n**Milestone:** v1.7 Current Milestone\nPhase: 65\n' - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - '# Roadmap\n\n## Roadmap v1.7: Current\n\n### Phase 65: Current work\n\nGoal: test.\n' - ); - - const result = runGsdTools('validate consistency', tmpDir); - assert.ok(result.success, `validate consistency should succeed: ${result.error}`); - - const out = JSON.parse(result.output); - const warnings = out.warnings || []; - const warningsPosix = warnings.map(w => toPosixPath(w)); - const phase64Warnings = warnings.filter(w => w.includes('Phase 64 exists on disk but not in ROADMAP.md')); - assert.deepStrictEqual( - phase64Warnings, - [], - `Old archived milestone phase 64 should not be treated as active:\n ${phase64Warnings.join('\n ')}` - ); - assert.ok( - warningsPosix.some(w => /Gap in plan numbering in .*milestones\/v1\.7-phases\/65-current/.test(w)), - `Expected plan numbering warning from active archive root, got:\n ${warnings.join('\n ')}` - ); - assert.ok( - warningsPosix.some(w => /milestones\/v1\.7-phases\/65-current\/65-01-PLAN\.md: missing 'wave'/.test(w)) - || warningsPosix.some(w => /milestones\/v1\.7-phases\/65-current\/65-03-PLAN\.md: missing 'wave'/.test(w)), - `Expected frontmatter warning from active archive plans, got:\n ${warnings.join('\n ')}` - ); - }); -}); - -describe('#3164 — validate health: milestone-archive layout', () => { - let tmpDir; - - beforeEach(() => { tmpDir = createTempProject(); }); - afterEach(() => { cleanup(tmpDir); }); - - test('no W006 warnings for phases that exist in .planning/milestones/v*-phases/', () => { - setupMilestoneArchiveProject(tmpDir, { - milestone: 'v1.7', - phases: ['64-secondary-grader-fix'], - roadmapPhases: ['64'], - }); - - const result = runGsdTools('validate health', tmpDir); - assert.ok(result.success, `validate health should succeed: ${result.error}`); - - const out = JSON.parse(result.output); - const w006 = (out.warnings || []).filter(w => { - const msg = typeof w === 'string' ? w : w.message; - return msg && msg.includes('Phase 64') && msg.includes('no directory'); - }); - assert.deepStrictEqual( - w006, [], - `Got spurious W006 for phase 64 in milestone-archive validate health:\n ${w006.map(w => typeof w === 'string' ? w : w.message).join('\n ')}` - ); - }); -}); - -describe('#3164 — find-phase: milestone-archive layout', () => { - let tmpDir; - - beforeEach(() => { tmpDir = createTempProject(); }); - afterEach(() => { cleanup(tmpDir); }); - - test('find-phase 64 returns found:true for phase in .planning/milestones/v*-phases/', () => { - setupMilestoneArchiveProject(tmpDir, { - milestone: 'v1.7', - phases: ['64-secondary-grader-fix'], - roadmapPhases: ['64'], - }); - - const result = runGsdTools('find-phase 64', tmpDir); - assert.ok(result.success, `find-phase should succeed: ${result.error}`); - - const out = JSON.parse(result.output); - assert.strictEqual(out.found, true, `find-phase 64 should return found:true, got: ${JSON.stringify(out)}`); - }); - - test('find-phase searches milestone archives in deterministic sorted order', () => { - // Remove flat phases dir so search relies on milestone archives only. - fs.rmSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true, force: true }); - - const milestonesDir = path.join(tmpDir, '.planning', 'milestones'); - const v110 = path.join(milestonesDir, 'v1.10-phases', '64-from-110'); - const v12 = path.join(milestonesDir, 'v1.2-phases', '64-from-12'); - fs.mkdirSync(v110, { recursive: true }); - fs.mkdirSync(v12, { recursive: true }); - fs.writeFileSync(path.join(v110, 'PLAN.md'), '# v1.10 plan\n'); - fs.writeFileSync(path.join(v12, 'PLAN.md'), '# v1.2 plan\n'); - - const result = runGsdTools('find-phase 64', tmpDir); - assert.ok(result.success, `find-phase should succeed: ${result.error}`); - - const out = JSON.parse(result.output); - assert.strictEqual(out.found, true, `find-phase 64 should return found:true, got: ${JSON.stringify(out)}`); - assert.strictEqual( - out.directory, - '.planning/milestones/v1.2-phases/64-from-12', - `Expected deterministic archive ordering (v1.2 before v1.10), got directory: ${out.directory}` - ); - }); - - test('find-phase not-found payload includes searched_directories', () => { - setupMilestoneArchiveProject(tmpDir, { - milestone: 'v1.7', - phases: ['64-secondary-grader-fix'], - roadmapPhases: ['64'], - }); - - const result = runGsdTools('find-phase 999', tmpDir); - assert.ok(result.success, `find-phase should succeed with found:false payload: ${result.error}`); - - const out = JSON.parse(result.output); - assert.strictEqual(out.found, false, `find-phase 999 should return found:false, got: ${JSON.stringify(out)}`); - assert.ok(Array.isArray(out.searched_directories), 'searched_directories should be an array on not-found payload'); - assert.ok( - out.searched_directories.includes('.planning/milestones/v1.7-phases'), - `searched_directories should include active archive dir, got: ${JSON.stringify(out.searched_directories)}` - ); - }); -}); diff --git a/tests/bug-3600-milestone-phase-filter-project-code-prefix.test.cjs b/tests/bug-3600-milestone-phase-filter-project-code-prefix.test.cjs deleted file mode 100644 index 3b0aabc83..000000000 --- a/tests/bug-3600-milestone-phase-filter-project-code-prefix.test.cjs +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Bug #3600: `init.new-milestone` reports `phase_dir_count: 0` for - * project-code-prefixed phase directories (e.g. `.planning/phases/CK-01-name`) - * against a ROADMAP that uses numeric `Phase 1:` headings. - * - * Root cause: `getMilestonePhaseFilter` builds an `isDirInMilestone(dirName)` - * predicate that tries two paths: - * 1) Numeric match — requires the directory name to START with a digit; - * `CK-01-name` starts with `C`, so this path skips. - * 2) Custom-ID match — captures the leading token (`CK-01-name` as a - * whole) and compares it to the normalised milestone phase IDs - * (`1`). No match. - * - * The predicate has no path that strips a project-code prefix before the - * numeric match. This fix adds that third path: when both existing - * matches fail, strip an optional `^[A-Z]{1,6}-(?=\d)` prefix (the same - * shape `normalizePhaseName` already strips) and retry the numeric match. - * - * The fix is shared between the CJS impl in `core.cjs` and the SDK twin - * in `sdk/src/query/state.ts`. The behavioural test exercises the CJS - * surface (the active runtime). - */ - -'use strict'; - -process.env.GSD_TEST_MODE = '1'; - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); - -function writeRoadmap(tmpDir, body) { - fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), body); -} -function writeState(tmpDir, version) { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `---\nmilestone: ${version}\n---\n`, - ); -} -function writeConfig(tmpDir, configObj) { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify(configObj, null, 2), - ); -} -function ensurePhaseDir(tmpDir, name) { - fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', name), { recursive: true }); -} - -describe('bug #3600: milestone phase filter understands project-code-prefixed directories', () => { - let tmpDir; - beforeEach(() => { - tmpDir = createTempProject('bug-3600-'); - }); - afterEach(() => { - cleanup(tmpDir); - }); - - test('init.new-milestone counts CK-NN-name dirs against numeric `Phase N:` headings', () => { - writeConfig(tmpDir, { project_code: 'CK' }); - writeState(tmpDir, 'v1.0.0'); - writeRoadmap( - tmpDir, - [ - '# Roadmap', - '', - '## Current Milestone: v1.0.0 - Test', - '', - '### Phase 1: Discovery', - '**Goal:** GoalOne', - '', - '### Phase 2: Build', - '**Goal:** GoalTwo', - '', - ].join('\n'), - ); - ensurePhaseDir(tmpDir, 'CK-01-discovery'); - ensurePhaseDir(tmpDir, 'CK-02-build'); - - const r = runGsdTools(['init', 'new-milestone', '--json'], tmpDir); - assert.ok(r.success, `init new-milestone failed: ${r.error || r.output}`); - const payload = JSON.parse(r.output); - assert.strictEqual( - payload.phase_dir_count, - 2, - `expected phase_dir_count=2 for two CK-NN-name dirs against Phase 1/Phase 2 headings, got ${payload.phase_dir_count}`, - ); - }); - - test('unprefixed directories continue to count (#3537 / existing contract)', () => { - writeState(tmpDir, 'v1.0.0'); - writeRoadmap( - tmpDir, - [ - '# Roadmap', - '', - '## Current Milestone: v1.0.0 - Test', - '', - '### Phase 1: First', - '**Goal:** g', - '', - ].join('\n'), - ); - ensurePhaseDir(tmpDir, '01-first'); - - const r = runGsdTools(['init', 'new-milestone', '--json'], tmpDir); - assert.ok(r.success); - const payload = JSON.parse(r.output); - assert.strictEqual(payload.phase_dir_count, 1); - }); - - test('custom-ID match for PROJ-42 directory + Phase PROJ-42: heading still works', () => { - // Existing custom-ID path: directory name exactly equals the custom - // phase ID (no slug suffix). The PROJ-42 → Phase PROJ-42: match must - // continue to fire via the second branch of `isDirInMilestone`, not - // get pre-empted by the new strip-and-retry branch. - writeConfig(tmpDir, { project_code: 'PROJ' }); - writeState(tmpDir, 'v1.0.0'); - writeRoadmap( - tmpDir, - [ - '# Roadmap', - '', - '## Current Milestone: v1.0.0 - Test', - '', - '### Phase PROJ-42: Custom', - '**Goal:** g', - '', - ].join('\n'), - ); - ensurePhaseDir(tmpDir, 'PROJ-42'); - - const r = runGsdTools(['init', 'new-milestone', '--json'], tmpDir); - assert.ok(r.success); - const payload = JSON.parse(r.output); - assert.strictEqual( - payload.phase_dir_count, - 1, - 'PROJ-42 directory must still match Phase PROJ-42: via the custom-ID path', - ); - }); - - test('directories that do not match the milestone do NOT count', () => { - // Counter-test: a 999-backlog directory or a totally-unrelated phase - // must NOT be counted in the milestone tally. - writeConfig(tmpDir, { project_code: 'CK' }); - writeState(tmpDir, 'v1.0.0'); - writeRoadmap( - tmpDir, - [ - '# Roadmap', - '', - '## Current Milestone: v1.0.0 - Test', - '', - '### Phase 1: First', - '**Goal:** g', - '', - ].join('\n'), - ); - ensurePhaseDir(tmpDir, 'CK-01-first'); - // Future / backlog phases that should not be counted in this milestone. - ensurePhaseDir(tmpDir, 'CK-99-backlog'); - ensurePhaseDir(tmpDir, 'CK-100-future'); - - const r = runGsdTools(['init', 'new-milestone', '--json'], tmpDir); - assert.ok(r.success); - const payload = JSON.parse(r.output); - assert.strictEqual( - payload.phase_dir_count, - 1, - 'only CK-01-first should match Phase 1; CK-99 and CK-100 must be excluded', - ); - }); -}); diff --git a/tests/milestone-archive.test.cjs b/tests/milestone-archive.test.cjs new file mode 100644 index 000000000..ae528e85e --- /dev/null +++ b/tests/milestone-archive.test.cjs @@ -0,0 +1,505 @@ +'use strict'; + +/** + * GSD Tools Tests - Milestone Archive Layout and Phase Filter + * + * Covers: + * - bug #2684: milestone.complete forwards version to phases.archive + * - bug #2787: extractCurrentMilestone fenced code block boundary + * - bug #3164: validate consistency/health/find-phase with milestone-archive layout + * - bug #3600: getMilestonePhaseFilter with project-code-prefixed directories + */ + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('child_process'); +const { createTempProject, cleanup, runGsdTools, toPosixPath } = require('./helpers.cjs'); + +const SDK_CLI = path.join(__dirname, '..', 'sdk', 'dist', 'cli.js'); + +function runSdkQuery(args, cwd) { + try { + const result = execFileSync(process.execPath, [SDK_CLI, 'query', ...args], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return { success: true, data: JSON.parse(result.trim()) }; + } catch (err) { + const stdout = err.stdout?.toString().trim() || ''; + try { return { success: true, data: JSON.parse(stdout) }; } catch { /* not JSON */ } + return { success: false, error: (err.stderr?.toString().trim() || err.message) }; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// bug #2684: milestone.complete forwards version to phases.archive +// ───────────────────────────────────────────────────────────────────────────── + +describe('bug #2684: milestone.complete forwards version to phases.archive', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('milestone.complete v1.0 does not throw version required error', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n`, + ); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); + + const result = runSdkQuery(['milestone.complete', 'v1.0'], tmpDir); + assert.ok(result.success, `milestone.complete should succeed, got error: ${result.error}`); + assert.ok( + !result.error || !result.error.includes('version required'), + `should not throw "version required" — got: ${result.error}`, + ); + }); + + test('milestone.complete returns version in response data', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n`, + ); + + const result = runSdkQuery(['milestone.complete', 'v2.5'], tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + assert.strictEqual(result.data.version, 'v2.5'); + }); + + test('milestone.complete with --archive-phases forwards version correctly', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n`, + ); + const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan'); + fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary'); + + const result = runSdkQuery(['milestone.complete', 'v1.0', '--archive-phases'], tmpDir); + assert.ok(result.success, `milestone.complete --archive-phases failed: ${result.error}`); + assert.strictEqual(result.data.version, 'v1.0'); + assert.ok(result.data.archived.phases === true, 'phases should be archived'); + assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases'))); + }); + + test('phases.archive v1.0 (direct call) also works', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n`, + ); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); + + const result = runSdkQuery(['phases.archive', 'v1.0'], tmpDir); + assert.ok(result.success, `phases.archive failed: ${result.error}`); + assert.strictEqual(result.data.version, 'v1.0'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// bug #2787: extractCurrentMilestone — fenced code block boundary +// ───────────────────────────────────────────────────────────────────────────── + +describe('extractCurrentMilestone — fenced code block boundary (#2787)', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('roadmap analyze returns all phases when a fenced block contains a heading-like line matching the milestone-end pattern', () => { + const roadmap = [ + '# Project Roadmap', + '', + '## ✅ v1.0: Foundation', + '', + '
', + '✅ v1.0 Foundation — SHIPPED', + '', + '### Phase 1: Bootstrap', + '**Goal:** Bootstrap the project', + '', + '
', + '', + '## Roadmap v1.1: New Work', + '', + '### Phase 1: Setup', + '**Goal:** Set up the environment', + '', + '### Phase 2: Core Logic', + '**Goal:** Implement core logic', + '', + 'Deployment notes:', + '', + '```bash', + '# Ops runbook — v1.0 compat', + 'echo "deploy complete"', + '```', + '', + '### Phase 3: Testing', + '**Goal:** Write regression tests', + '', + '### Phase 4: Deploy', + '**Goal:** Ship to production', + ].join('\n'); + + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v1.1\n---\n\n# GSD State\n'); + + const result = runGsdTools('roadmap analyze', tmpDir); + assert.ok(result.success, `roadmap analyze should succeed: ${result.error}`); + assert.strictEqual(JSON.parse(result.output).phase_count, 4, 'All 4 phases in v1.1 should be found'); + }); + + test('roadmap analyze returns all phases when a fenced block contains a backtick-tilde fence with milestone-like heading', () => { + const roadmap = [ + '## Roadmap v2.0: Feature Work', + '', + '### Phase 1: Alpha', + '**Goal:** Alpha release', + '', + '~~~markdown', + '## Prior art (v1.9 snapshot)', + '~~~', + '', + '### Phase 2: Beta', + '**Goal:** Beta release', + ].join('\n'); + + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v2.0\n---\n\n# GSD State\n'); + + const result = runGsdTools('roadmap analyze', tmpDir); + assert.ok(result.success, `roadmap analyze should succeed: ${result.error}`); + assert.strictEqual(JSON.parse(result.output).phase_count, 2, 'Both phases in v2.0 should be found'); + }); + + test('fenced block with info string (e.g. ```js) is not closed by a nested info-string line', () => { + const roadmap = [ + '## Roadmap v3.0: Info-String Edge Case', + '', + '### Phase 1: Setup', + '**Goal:** First phase', + '', + '```text', + '```js', + '# This heading-like line (v3.0 compat) must NOT end the milestone', + '```', + '', + '### Phase 2: Core', + '**Goal:** Second phase', + ].join('\n'); + + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v3.0\n---\n\n# GSD State\n'); + + const result = runGsdTools('roadmap analyze', tmpDir); + assert.ok(result.success); + assert.strictEqual(JSON.parse(result.output).phase_count, 2, 'Both phases should be found; ```js line must not close fence'); + }); + + test('roadmap get-phase finds a phase defined after a fenced code block', () => { + const roadmap = [ + '## Roadmap v1.1: New Work', + '', + '### Phase 1: Setup', + '**Goal:** Bootstrap', + '', + '```bash', + '# Runbook for v1.0 deploy', + '```', + '', + '### Phase 2: Core', + '**Goal:** Core implementation', + ].join('\n'); + + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v1.1\n---\n\n# GSD State\n'); + + const result = runGsdTools('roadmap get-phase 2', tmpDir); + assert.ok(result.success); + const output = JSON.parse(result.output); + assert.ok(output.found, 'Phase 2 should be found even after a fenced code block'); + assert.strictEqual(output.phase_number, '2'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// bug #3164: milestone-archive layout support in validate/find-phase +// ───────────────────────────────────────────────────────────────────────────── + +function setupMilestoneArchiveProject(tmpDir, options = {}) { + const { + milestone = 'v1.7', + phases = ['64-secondary-grader-fix'], + roadmapPhases = ['64'], + } = options; + + fs.rmSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true, force: true }); + + const archiveDir = path.join(tmpDir, '.planning', 'milestones', `${milestone}-phases`); + for (const phase of phases) { + const phaseDir = path.join(archiveDir, phase); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, 'PLAN.md'), `# Plan\nPhase ${phase}\n`); + } + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `milestone: ${milestone}\n# Session State\n\nPhase: ${roadmapPhases[0]}\n`, + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'PROJECT.md'), + '# Project\n\n## What This Is\nTest.\n## Core Value\nTest.\n## Requirements\nTest.\n', + ); + const phaseLines = roadmapPhases.map(n => `### Phase ${n}: Description\n\nGoal: implement it.\n`).join('\n'); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n## Roadmap ${milestone}: Current\n\n${phaseLines}\n`, + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'balanced', commit_docs: true }, null, 2), + ); +} + +describe('#3164 — validate consistency: milestone-archive layout', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('no W006 warnings for phases that exist in .planning/milestones/v*-phases/', () => { + setupMilestoneArchiveProject(tmpDir, { milestone: 'v1.7', phases: ['64-secondary-grader-fix'], roadmapPhases: ['64'] }); + + const result = runGsdTools('validate consistency', tmpDir); + assert.ok(result.success); + + const w006 = (JSON.parse(result.output).warnings || []).filter(w => w.includes('Phase 64') && w.includes('no directory')); + assert.deepStrictEqual(w006, [], `Got spurious W006: ${w006.join(', ')}`); + }); + + test('no W006 when multiple phases exist in milestone-archive layout', () => { + setupMilestoneArchiveProject(tmpDir, { milestone: 'v1.7', phases: ['48-feature-a', '51-feature-b', '64-feature-c'], roadmapPhases: ['48', '51', '64'] }); + + const result = runGsdTools('validate consistency', tmpDir); + assert.ok(result.success); + + const w006 = (JSON.parse(result.output).warnings || []).filter(w => w.includes('no directory')); + assert.deepStrictEqual(w006, [], `Got spurious W006: ${w006.join(', ')}`); + }); + + test('prefixed archive dir names (CK-64-...) are recognized as phase 64', () => { + setupMilestoneArchiveProject(tmpDir, { milestone: 'v1.7', phases: ['CK-64-secondary-grader-fix'], roadmapPhases: ['64'] }); + + const result = runGsdTools('validate consistency', tmpDir); + assert.ok(result.success); + + const w006 = (JSON.parse(result.output).warnings || []).filter(w => w.includes('Phase 64') && w.includes('no directory')); + assert.deepStrictEqual(w006, [], `Prefixed phase dir should count as phase 64`); + }); + + test('consistency scans only active milestone archive and still validates plans/frontmatter', () => { + fs.rmSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true, force: true }); + + const oldDir = path.join(tmpDir, '.planning', 'milestones', 'v1.6-phases', '64-legacy'); + fs.mkdirSync(oldDir, { recursive: true }); + fs.writeFileSync(path.join(oldDir, '64-01-PLAN.md'), '# legacy plan\n'); + + const activeDir = path.join(tmpDir, '.planning', 'milestones', 'v1.7-phases', '65-current'); + fs.mkdirSync(activeDir, { recursive: true }); + fs.writeFileSync(path.join(activeDir, '65-01-PLAN.md'), '# plan 1\n'); + fs.writeFileSync(path.join(activeDir, '65-03-PLAN.md'), '# plan 3\n'); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '# Session State\n\n**Milestone:** v1.7 Current Milestone\nPhase: 65\n', + ); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + '# Roadmap\n\n## Roadmap v1.7: Current\n\n### Phase 65: Current work\n\nGoal: test.\n', + ); + + const result = runGsdTools('validate consistency', tmpDir); + assert.ok(result.success); + + const out = JSON.parse(result.output); + const warnings = out.warnings || []; + const warningsPosix = warnings.map(w => toPosixPath(w)); + + const phase64Warnings = warnings.filter(w => w.includes('Phase 64 exists on disk but not in ROADMAP.md')); + assert.deepStrictEqual(phase64Warnings, [], 'Old archived milestone phase 64 should not be treated as active'); + assert.ok( + warningsPosix.some(w => /Gap in plan numbering in .*milestones\/v1\.7-phases\/65-current/.test(w)), + `Expected plan numbering warning, got: ${warnings.join(', ')}`, + ); + assert.ok( + warningsPosix.some(w => /milestones\/v1\.7-phases\/65-current\/65-01-PLAN\.md: missing 'wave'/.test(w)) + || warningsPosix.some(w => /milestones\/v1\.7-phases\/65-current\/65-03-PLAN\.md: missing 'wave'/.test(w)), + `Expected frontmatter warning from active archive plans`, + ); + }); +}); + +describe('#3164 — validate health: milestone-archive layout', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('no W006 warnings for phases that exist in .planning/milestones/v*-phases/', () => { + setupMilestoneArchiveProject(tmpDir, { milestone: 'v1.7', phases: ['64-secondary-grader-fix'], roadmapPhases: ['64'] }); + + const result = runGsdTools('validate health', tmpDir); + assert.ok(result.success); + + const w006 = (JSON.parse(result.output).warnings || []).filter(w => { + const msg = typeof w === 'string' ? w : w.message; + return msg && msg.includes('Phase 64') && msg.includes('no directory'); + }); + assert.deepStrictEqual(w006, []); + }); +}); + +describe('#3164 — find-phase: milestone-archive layout', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('find-phase 64 returns found:true for phase in .planning/milestones/v*-phases/', () => { + setupMilestoneArchiveProject(tmpDir, { milestone: 'v1.7', phases: ['64-secondary-grader-fix'], roadmapPhases: ['64'] }); + + const result = runGsdTools('find-phase 64', tmpDir); + assert.ok(result.success); + assert.strictEqual(JSON.parse(result.output).found, true); + }); + + test('find-phase searches milestone archives in deterministic sorted order', () => { + fs.rmSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true, force: true }); + + const milestonesDir = path.join(tmpDir, '.planning', 'milestones'); + const v110 = path.join(milestonesDir, 'v1.10-phases', '64-from-110'); + const v12 = path.join(milestonesDir, 'v1.2-phases', '64-from-12'); + fs.mkdirSync(v110, { recursive: true }); + fs.mkdirSync(v12, { recursive: true }); + fs.writeFileSync(path.join(v110, 'PLAN.md'), '# v1.10 plan\n'); + fs.writeFileSync(path.join(v12, 'PLAN.md'), '# v1.2 plan\n'); + + const result = runGsdTools('find-phase 64', tmpDir); + assert.ok(result.success); + const out = JSON.parse(result.output); + assert.strictEqual(out.found, true); + assert.strictEqual(out.directory, '.planning/milestones/v1.2-phases/64-from-12'); + }); + + test('find-phase not-found payload includes searched_directories', () => { + setupMilestoneArchiveProject(tmpDir, { milestone: 'v1.7', phases: ['64-secondary-grader-fix'], roadmapPhases: ['64'] }); + + const result = runGsdTools('find-phase 999', tmpDir); + assert.ok(result.success); + const out = JSON.parse(result.output); + assert.strictEqual(out.found, false); + assert.ok(Array.isArray(out.searched_directories)); + assert.ok( + out.searched_directories.includes('.planning/milestones/v1.7-phases'), + `searched_directories should include active archive dir, got: ${JSON.stringify(out.searched_directories)}`, + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// bug #3600: milestone phase filter understands project-code-prefixed directories +// ───────────────────────────────────────────────────────────────────────────── + +describe('bug #3600: milestone phase filter understands project-code-prefixed directories', () => { + let tmpDir; + + beforeEach(() => { tmpDir = createTempProject('bug-3600-'); }); + afterEach(() => { cleanup(tmpDir); }); + + function writeState(tmpDir, version) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), `---\nmilestone: ${version}\n---\n`); + } + function writeRoadmap(tmpDir, body) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), body); + } + function writeConfig(tmpDir, configObj) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify(configObj, null, 2)); + } + function ensurePhaseDir(tmpDir, name) { + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', name), { recursive: true }); + } + + test('init.new-milestone counts CK-NN-name dirs against numeric `Phase N:` headings', () => { + writeConfig(tmpDir, { project_code: 'CK' }); + writeState(tmpDir, 'v1.0.0'); + writeRoadmap(tmpDir, [ + '# Roadmap', '', + '## Current Milestone: v1.0.0 - Test', '', + '### Phase 1: Discovery', '**Goal:** GoalOne', '', + '### Phase 2: Build', '**Goal:** GoalTwo', '', + ].join('\n')); + ensurePhaseDir(tmpDir, 'CK-01-discovery'); + ensurePhaseDir(tmpDir, 'CK-02-build'); + + const r = runGsdTools(['init', 'new-milestone', '--json'], tmpDir); + assert.ok(r.success, `init new-milestone failed: ${r.error || r.output}`); + const payload = JSON.parse(r.output); + assert.strictEqual(payload.phase_dir_count, 2, + `expected phase_dir_count=2, got ${payload.phase_dir_count}`); + }); + + test('unprefixed directories continue to count (#3537 / existing contract)', () => { + writeState(tmpDir, 'v1.0.0'); + writeRoadmap(tmpDir, [ + '# Roadmap', '', + '## Current Milestone: v1.0.0 - Test', '', + '### Phase 1: First', '**Goal:** g', '', + ].join('\n')); + ensurePhaseDir(tmpDir, '01-first'); + + const r = runGsdTools(['init', 'new-milestone', '--json'], tmpDir); + assert.ok(r.success); + assert.strictEqual(JSON.parse(r.output).phase_dir_count, 1); + }); + + test('custom-ID match for PROJ-42 directory + Phase PROJ-42: heading still works', () => { + writeConfig(tmpDir, { project_code: 'PROJ' }); + writeState(tmpDir, 'v1.0.0'); + writeRoadmap(tmpDir, [ + '# Roadmap', '', + '## Current Milestone: v1.0.0 - Test', '', + '### Phase PROJ-42: Custom', '**Goal:** g', '', + ].join('\n')); + ensurePhaseDir(tmpDir, 'PROJ-42'); + + const r = runGsdTools(['init', 'new-milestone', '--json'], tmpDir); + assert.ok(r.success); + assert.strictEqual(JSON.parse(r.output).phase_dir_count, 1, + 'PROJ-42 directory must still match Phase PROJ-42: via the custom-ID path'); + }); + + test('directories that do not match the milestone do NOT count (counter-test)', () => { + writeConfig(tmpDir, { project_code: 'CK' }); + writeState(tmpDir, 'v1.0.0'); + writeRoadmap(tmpDir, [ + '# Roadmap', '', + '## Current Milestone: v1.0.0 - Test', '', + '### Phase 1: First', '**Goal:** g', '', + ].join('\n')); + ensurePhaseDir(tmpDir, 'CK-01-first'); + ensurePhaseDir(tmpDir, 'CK-99-backlog'); + ensurePhaseDir(tmpDir, 'CK-100-future'); + + const r = runGsdTools(['init', 'new-milestone', '--json'], tmpDir); + assert.ok(r.success); + assert.strictEqual(JSON.parse(r.output).phase_dir_count, 1, + 'only CK-01-first should match Phase 1; CK-99 and CK-100 must be excluded'); + }); +}); diff --git a/tests/milestone-audit.test.cjs b/tests/milestone-audit.test.cjs deleted file mode 100644 index f0439bb0c..000000000 --- a/tests/milestone-audit.test.cjs +++ /dev/null @@ -1,189 +0,0 @@ -'use strict'; -// allow-test-rule: source-text-is-the-product -// Reads .md/.json/.yml product files whose deployed text IS what the -// runtime loads — testing text content tests the deployed contract. - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); - -describe('audit.cjs module (#2158)', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject('audit-test'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('auditOpenArtifacts returns structured result with counts', () => { - const { auditOpenArtifacts } = require('../get-shit-done/bin/lib/audit.cjs'); - const result = auditOpenArtifacts(tmpDir); - assert.ok(typeof result === 'object', 'result must be object'); - assert.ok(typeof result.counts === 'object', 'result must have counts'); - assert.ok(typeof result.counts.total === 'number', 'counts.total must be number'); - assert.ok(typeof result.has_open_items === 'boolean', 'has_open_items must be boolean'); - }); - - test('auditOpenArtifacts handles missing planning directories gracefully', () => { - const { auditOpenArtifacts } = require('../get-shit-done/bin/lib/audit.cjs'); - // tmpDir has .planning/ but no debug/ or threads/ subdirs - const result = auditOpenArtifacts(tmpDir); - assert.strictEqual(result.counts.total, 0, 'empty project should have 0 open items'); - assert.strictEqual(result.has_open_items, false); - }); - - test('auditOpenArtifacts detects open debug sessions', () => { - const { auditOpenArtifacts } = require('../get-shit-done/bin/lib/audit.cjs'); - // Create a fake debug session - const debugDir = path.join(tmpDir, '.planning', 'debug'); - fs.mkdirSync(debugDir, { recursive: true }); - fs.writeFileSync(path.join(debugDir, 'test-bug.md'), [ - '---', - 'status: investigating', - 'trigger: login fails', - 'updated: 2026-04-10', - '---', - '# Debug: test-bug', - ].join('\n')); - - const result = auditOpenArtifacts(tmpDir); - assert.strictEqual(result.counts.debug_sessions, 1); - assert.ok(result.has_open_items); - }); - - test('auditOpenArtifacts ignores resolved debug sessions', () => { - const { auditOpenArtifacts } = require('../get-shit-done/bin/lib/audit.cjs'); - const resolvedDir = path.join(tmpDir, '.planning', 'debug', 'resolved'); - fs.mkdirSync(resolvedDir, { recursive: true }); - fs.writeFileSync(path.join(resolvedDir, 'old-bug.md'), [ - '---', - 'status: resolved', - '---', - '# Resolved', - ].join('\n')); - - const result = auditOpenArtifacts(tmpDir); - assert.strictEqual(result.counts.debug_sessions, 0); - }); - - test('formatAuditReport returns string with header', () => { - const { auditOpenArtifacts, formatAuditReport } = require('../get-shit-done/bin/lib/audit.cjs'); - const result = auditOpenArtifacts(tmpDir); - const report = formatAuditReport(result); - assert.ok(typeof report === 'string'); - assert.ok(report.includes('Artifact Audit') || report.includes('artifact audit') || report.includes('All artifact')); - }); - - test('formatAuditReport shows all clear when no open items', () => { - const { auditOpenArtifacts, formatAuditReport } = require('../get-shit-done/bin/lib/audit.cjs'); - const result = auditOpenArtifacts(tmpDir); - const report = formatAuditReport(result); - assert.ok(report.includes('clear') || report.includes('0 items') || report.includes('no open'), - 'clean report should indicate all clear'); - }); -}); - -describe('complete-milestone workflow has pre-close audit gate (#2158)', () => { - const completeMilestoneContent = fs.readFileSync( - path.join(__dirname, '..', 'get-shit-done', 'workflows', 'complete-milestone.md'), - 'utf8' - ); - - test('complete-milestone has pre_close_artifact_audit step', () => { - assert.ok( - completeMilestoneContent.includes('pre_close_artifact_audit') || - completeMilestoneContent.includes('audit-open'), - 'missing pre-close audit gate' - ); - }); - - test('complete-milestone surfaces deferred items to STATE.md', () => { - assert.ok(completeMilestoneContent.includes('Deferred Items'), - 'missing Deferred Items carry-forward logic'); - }); - - test('complete-milestone has security note for audit output', () => { - assert.ok( - completeMilestoneContent.includes('sanitiz') || completeMilestoneContent.includes('SECURITY'), - 'missing security note in milestone audit gate' - ); - }); -}); - -describe('verify-work workflow has phase artifact check (#2157)', () => { - const verifyWorkContent = fs.readFileSync( - path.join(__dirname, '..', 'get-shit-done', 'workflows', 'verify-work.md'), - 'utf8' - ); - - test('verify-work has scan_phase_artifacts step', () => { - assert.ok( - verifyWorkContent.includes('scan_phase_artifacts') || verifyWorkContent.includes('audit-open'), - 'missing phase artifact scan step' - ); - }); - - test('verify-work prompts user on open UAT gaps', () => { - assert.ok( - verifyWorkContent.includes('gaps') && verifyWorkContent.includes('Proceed'), - 'missing user prompt for open gaps' - ); - }); -}); - -describe('state.md template has Deferred Items section (#2158)', () => { - const stateTemplate = fs.readFileSync( - path.join(__dirname, '..', 'get-shit-done', 'templates', 'state.md'), - 'utf8' - ); - - test('state.md template includes Deferred Items section', () => { - assert.ok(stateTemplate.includes('Deferred Items'), - 'state.md template missing Deferred Items section'); - }); -}); - -describe('audit-open CLI command — ReferenceError regression (#2236)', () => { - // The audit-open case in gsd-tools.cjs called bare output() instead of - // core.output(), crashing with ReferenceError: output is not defined - // on every invocation. These tests exercise the CLI dispatch directly so - // a regression at the call site is caught even if the lib tests all pass. - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject('audit-open-cli-test'); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - test('audit-open exits without error on an empty project', () => { - const result = runGsdTools(['audit-open'], tmpDir); - assert.ok(result.success, `audit-open crashed: ${result.error}`); - }); - - test('audit-open --json exits without error and returns valid JSON', () => { - const result = runGsdTools(['audit-open', '--json'], tmpDir); - assert.ok(result.success, `audit-open --json crashed: ${result.error}`); - let parsed; - assert.doesNotThrow(() => { parsed = JSON.parse(result.output); }, 'output must be valid JSON'); - assert.ok(typeof parsed === 'object', 'parsed output must be an object'); - assert.ok(typeof parsed.counts === 'object', 'JSON output must include counts'); - }); - - test('audit-open error is not ReferenceError: output is not defined', () => { - // Even if the command fails for some other reason, it must not throw the - // specific ReferenceError that was the bug in #2236. - const result = runGsdTools(['audit-open'], tmpDir); - assert.ok( - !String(result.error).includes('output is not defined'), - `ReferenceError regression: ${result.error}` - ); - }); -}); diff --git a/tests/milestone-regex-global.test.cjs b/tests/milestone-regex-global.test.cjs deleted file mode 100644 index 99f68334f..000000000 --- a/tests/milestone-regex-global.test.cjs +++ /dev/null @@ -1,115 +0,0 @@ -// allow-test-rule: structural-regression-guard -// milestone.cjs must use replace()+compare, not test()+replace(), to avoid regex -// lastIndex corruption with global flags. A behavioral test cannot distinguish which -// pattern was used — it can only observe wrong output after multiple calls, which is -// fragile. Structural inspection locks the correct fix in place. - -/** - * Regression tests for regex global state bug in milestone.cjs - * - * The original code used test() + replace() with global-flag regexes. - * test() advances lastIndex, so a subsequent replace() on the same - * regex object starts from the wrong position and can miss the match. - * - * The fix uses replace() directly and compares before/after to detect - * whether a substitution occurred, avoiding the lastIndex pitfall. - */ - -'use strict'; - -const { describe, test, before } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); - -const MILESTONE_SRC = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'milestone.cjs'); - -describe('milestone.cjs regex global state fix', () => { - let src; - - before(() => { - src = fs.readFileSync(MILESTONE_SRC, 'utf-8'); - }); - - test('checkbox update uses replace() + compare, not test() + replace()', () => { - // The old pattern: if (pattern.test(content)) { content = content.replace(pattern, ...); } - // The new pattern: const after = content.replace(pattern, ...); if (after !== content) { ... } - const funcBody = src.slice( - src.indexOf('function cmdRequirementsMarkComplete'), - src.indexOf('function cmdMilestoneComplete') - ); - - // Should NOT have test() followed by replace() on the same pattern for checkboxes - assert.ok( - !funcBody.includes('checkboxPattern.test(reqContent)'), - 'Should not call test() on checkboxPattern — use replace() + compare instead' - ); - - // Should have the replace-then-compare pattern - assert.ok( - funcBody.includes('afterCheckbox !== reqContent') || - funcBody.includes('afterCheckbox!==reqContent'), - 'Should compare before/after replace to detect checkbox changes' - ); - }); - - test('table update uses replace() + compare, not test() + replace()', () => { - const funcBody = src.slice( - src.indexOf('function cmdRequirementsMarkComplete'), - src.indexOf('function cmdMilestoneComplete') - ); - - // Should NOT have test() followed by replace() on the same pattern for tables - assert.ok( - !funcBody.includes('tablePattern.test(reqContent)'), - 'Should not call test() on tablePattern — use replace() + compare instead' - ); - - // Should have the replace-then-compare pattern - assert.ok( - funcBody.includes('afterTable !== reqContent') || - funcBody.includes('afterTable!==reqContent'), - 'Should compare before/after replace to detect table changes' - ); - }); - - test('done-check regexes use non-global flag (only need existence check)', () => { - const funcBody = src.slice( - src.indexOf('function cmdRequirementsMarkComplete'), - src.indexOf('function cmdMilestoneComplete') - ); - - // The doneCheckbox and doneTable patterns should use 'i' not 'gi' - // since test() with 'g' flag has stateful lastIndex - const doneCheckboxMatch = funcBody.match(/doneCheckbox\s*=\s*new RegExp\([^)]+,\s*'([^']+)'\)/); - const doneTableMatch = funcBody.match(/doneTable\s*=\s*new RegExp\([^)]+,\s*'([^']+)'\)/); - - assert.ok(doneCheckboxMatch, 'doneCheckbox regex should exist'); - assert.ok(doneTableMatch, 'doneTable regex should exist'); - assert.ok( - !doneCheckboxMatch[1].includes('g'), - 'doneCheckbox should not use global flag (only needs existence check via test())' - ); - assert.ok( - !doneTableMatch[1].includes('g'), - 'doneTable should not use global flag (only needs existence check via test())' - ); - }); - - test('no duplicate regex construction for the same pattern', () => { - const funcBody = src.slice( - src.indexOf('function cmdRequirementsMarkComplete'), - src.indexOf('function cmdMilestoneComplete') - ); - - // The old code created the table pattern twice — once for test(), once for replace(). - // Count lines that construct a regex with 'tablePattern' or the Pending table pattern. - const tableConstructions = funcBody.split('\n').filter( - line => line.includes('tablePattern') && line.includes('new RegExp') - ); - assert.ok( - tableConstructions.length <= 1, - `Table pattern regex should be constructed at most once, found ${tableConstructions.length}` - ); - }); -}); diff --git a/tests/milestone-summary.test.cjs b/tests/milestone-summary.test.cjs index 908d9747f..cc488df35 100644 --- a/tests/milestone-summary.test.cjs +++ b/tests/milestone-summary.test.cjs @@ -2,12 +2,16 @@ // Tracked in #2974 for migration to typed-IR assertions per CONTRIBUTING.md // "Prohibited: Raw Text Matching on Test Outputs". Per-file review may // reclassify some entries as source-text-is-the-product during migration. +// allow-test-rule: source-text-is-the-product +// Reads .md/.json/.yml product files whose deployed text IS what the +// runtime loads — testing text content tests the deployed contract. /** - * GSD Milestone Summary Tests + * GSD Milestone Summary + Audit Tests * - * Validates the milestone-summary command and workflow files exist - * and follow expected patterns. Tests artifact discovery logic. + * Validates the milestone-summary command, milestone-audit module (#2158), + * workflow audit gates (complete-milestone, verify-work), and STATE.md template. + * Also tests artifact discovery logic. */ const { test, describe, beforeEach, afterEach } = require('node:test'); @@ -318,3 +322,150 @@ describe('milestone-summary git stats resilience', () => { ); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// audit.cjs module (#2158) +// ───────────────────────────────────────────────────────────────────────────── + +describe('audit.cjs module (#2158)', () => { + const { createTempProject: createTP, cleanup: cleanTP, runGsdTools: run } = require('./helpers.cjs'); + let tmpDir; + + beforeEach(() => { tmpDir = createTP('audit-test'); }); + afterEach(() => { cleanTP(tmpDir); }); + + test('auditOpenArtifacts returns structured result with counts', () => { + const { auditOpenArtifacts } = require('../get-shit-done/bin/lib/audit.cjs'); + const result = auditOpenArtifacts(tmpDir); + assert.ok(typeof result === 'object'); + assert.ok(typeof result.counts === 'object'); + assert.ok(typeof result.counts.total === 'number'); + assert.ok(typeof result.has_open_items === 'boolean'); + }); + + test('auditOpenArtifacts handles missing planning directories gracefully', () => { + const { auditOpenArtifacts } = require('../get-shit-done/bin/lib/audit.cjs'); + const result = auditOpenArtifacts(tmpDir); + assert.strictEqual(result.counts.total, 0); + assert.strictEqual(result.has_open_items, false); + }); + + test('auditOpenArtifacts detects open debug sessions', () => { + const { auditOpenArtifacts } = require('../get-shit-done/bin/lib/audit.cjs'); + const debugDir = path.join(tmpDir, '.planning', 'debug'); + fs.mkdirSync(debugDir, { recursive: true }); + fs.writeFileSync(path.join(debugDir, 'test-bug.md'), [ + '---', 'status: investigating', 'trigger: login fails', 'updated: 2026-04-10', '---', + '# Debug: test-bug', + ].join('\n')); + + const result = auditOpenArtifacts(tmpDir); + assert.strictEqual(result.counts.debug_sessions, 1); + assert.ok(result.has_open_items); + }); + + test('auditOpenArtifacts ignores resolved debug sessions', () => { + const { auditOpenArtifacts } = require('../get-shit-done/bin/lib/audit.cjs'); + const resolvedDir = path.join(tmpDir, '.planning', 'debug', 'resolved'); + fs.mkdirSync(resolvedDir, { recursive: true }); + fs.writeFileSync(path.join(resolvedDir, 'old-bug.md'), ['---', 'status: resolved', '---', '# Resolved'].join('\n')); + + const result = auditOpenArtifacts(tmpDir); + assert.strictEqual(result.counts.debug_sessions, 0); + }); + + test('formatAuditReport returns string with header', () => { + const { auditOpenArtifacts, formatAuditReport } = require('../get-shit-done/bin/lib/audit.cjs'); + const report = formatAuditReport(auditOpenArtifacts(tmpDir)); + assert.ok(typeof report === 'string'); + assert.ok(report.includes('Artifact Audit') || report.includes('artifact audit') || report.includes('All artifact')); + }); + + test('formatAuditReport shows all clear when no open items', () => { + const { auditOpenArtifacts, formatAuditReport } = require('../get-shit-done/bin/lib/audit.cjs'); + const report = formatAuditReport(auditOpenArtifacts(tmpDir)); + assert.ok(report.includes('clear') || report.includes('0 items') || report.includes('no open')); + }); +}); + +describe('complete-milestone workflow has pre-close audit gate (#2158)', () => { + const completeMilestoneContent = fs.readFileSync( + path.join(__dirname, '..', 'get-shit-done', 'workflows', 'complete-milestone.md'), + 'utf8', + ); + + test('complete-milestone has pre_close_artifact_audit step', () => { + assert.ok( + completeMilestoneContent.includes('pre_close_artifact_audit') || + completeMilestoneContent.includes('audit-open'), + ); + }); + + test('complete-milestone surfaces deferred items to STATE.md', () => { + assert.ok(completeMilestoneContent.includes('Deferred Items')); + }); + + test('complete-milestone has security note for audit output', () => { + assert.ok( + completeMilestoneContent.includes('sanitiz') || completeMilestoneContent.includes('SECURITY'), + ); + }); +}); + +describe('verify-work workflow has phase artifact check (#2157)', () => { + const verifyWorkContent = fs.readFileSync( + path.join(__dirname, '..', 'get-shit-done', 'workflows', 'verify-work.md'), + 'utf8', + ); + + test('verify-work has scan_phase_artifacts step', () => { + assert.ok( + verifyWorkContent.includes('scan_phase_artifacts') || verifyWorkContent.includes('audit-open'), + ); + }); + + test('verify-work prompts user on open UAT gaps', () => { + assert.ok(verifyWorkContent.includes('gaps') && verifyWorkContent.includes('Proceed')); + }); +}); + +describe('state.md template has Deferred Items section (#2158)', () => { + const stateTemplate = fs.readFileSync( + path.join(__dirname, '..', 'get-shit-done', 'templates', 'state.md'), + 'utf8', + ); + + test('state.md template includes Deferred Items section', () => { + assert.ok(stateTemplate.includes('Deferred Items')); + }); +}); + +describe('audit-open CLI command — ReferenceError regression (#2236)', () => { + const { createTempProject: createTP, cleanup: cleanTP, runGsdTools: run } = require('./helpers.cjs'); + let tmpDir; + + beforeEach(() => { tmpDir = createTP('audit-open-cli-test'); }); + afterEach(() => { cleanTP(tmpDir); }); + + test('audit-open exits without error on an empty project', () => { + const result = run(['audit-open'], tmpDir); + assert.ok(result.success, `audit-open crashed: ${result.error}`); + }); + + test('audit-open --json exits without error and returns valid JSON', () => { + const result = run(['audit-open', '--json'], tmpDir); + assert.ok(result.success, `audit-open --json crashed: ${result.error}`); + let parsed; + assert.doesNotThrow(() => { parsed = JSON.parse(result.output); }); + assert.ok(typeof parsed === 'object'); + assert.ok(typeof parsed.counts === 'object'); + }); + + test('audit-open error is not ReferenceError: output is not defined', () => { + const result = run(['audit-open'], tmpDir); + assert.ok( + !String(result.error).includes('output is not defined'), + `ReferenceError regression: ${result.error}`, + ); + }); +}); diff --git a/tests/milestone.test.cjs b/tests/milestone.test.cjs index ff1e7933a..d80a5f52e 100644 --- a/tests/milestone.test.cjs +++ b/tests/milestone.test.cjs @@ -4,45 +4,64 @@ /** * GSD Tools Tests - Milestone + * + * Covers: milestone complete command, phases clear command, + * requirements mark-complete command (regex-global fix), new-milestone + * workflow verification gate, milestone complete version scoping (#3043). */ -const { test, describe, beforeEach, afterEach } = require('node:test'); +'use strict'; + +const { test, describe, before, 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 writeState(tmpDir, extra = '') { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n${extra}`, + ); +} + +function writeRoadmap(tmpDir, content) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content); +} + +function mkPhaseDir(tmpDir, name, opts = {}) { + const p = path.join(tmpDir, '.planning', 'phases', name); + fs.mkdirSync(p, { recursive: true }); + if (opts.plan) fs.writeFileSync(path.join(p, `${name.split('-')[0]}-01-PLAN.md`), '# Plan\n'); + if (opts.oneLiner) { + fs.writeFileSync( + path.join(p, `${name.split('-')[0]}-01-SUMMARY.md`), + `---\none-liner: ${opts.oneLiner}\n---\n# Summary\n`, + ); + } + return p; +} + +// ───────────────────────────────────────────────────────────────────────────── +// milestone complete command +// ───────────────────────────────────────────────────────────────────────────── + describe('milestone complete command', () => { let tmpDir; - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); test('archives roadmap, requirements, creates MILESTONES.md', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0 MVP\n\n### Phase 1: Foundation\n**Goal:** Setup\n` - ); + writeRoadmap(tmpDir, `# Roadmap v1.0 MVP\n\n### Phase 1: Foundation\n**Goal:** Setup\n`); fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), - `# Requirements\n\n- [ ] User auth\n- [ ] Dashboard\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` - ); - - const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(p1, { recursive: true }); - fs.writeFileSync( - path.join(p1, '01-01-SUMMARY.md'), - `---\none-liner: Set up project infrastructure\n---\n# Summary\n` + `# Requirements\n\n- [ ] User auth\n- [ ] Dashboard\n`, ); + writeState(tmpDir); + mkPhaseDir(tmpDir, '01-foundation', { oneLiner: 'Set up project infrastructure' }); const result = runGsdTools('milestone complete v1.0 --name MVP Foundation', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); @@ -52,189 +71,110 @@ describe('milestone complete command', () => { assert.strictEqual(output.phases, 1); assert.ok(output.archived.roadmap, 'roadmap should be archived'); assert.ok(output.archived.requirements, 'requirements should be archived'); - - // Verify archive files exist assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-ROADMAP.md')), - 'archived roadmap should exist' + 'archived roadmap should exist', ); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-REQUIREMENTS.md')), - 'archived requirements should exist' - ); - - // Verify MILESTONES.md created - assert.ok( - fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md')), - 'MILESTONES.md should be created' + 'archived requirements should exist', ); + assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md'))); const milestones = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8'); - assert.ok(milestones.includes('v1.0 MVP Foundation'), 'milestone entry should contain name'); - assert.ok(milestones.includes('Set up project infrastructure'), 'accomplishments should be listed'); + assert.ok(milestones.includes('v1.0 MVP Foundation')); + assert.ok(milestones.includes('Set up project infrastructure')); }); test('prepends to existing MILESTONES.md (reverse chronological)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'MILESTONES.md'), - `# Milestones\n\n## v0.9 Alpha (Shipped: 2025-01-01)\n\n---\n\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` + `# Milestones\n\n## v0.9 Alpha (Shipped: 2025-01-01)\n\n---\n\n`, ); + writeRoadmap(tmpDir, `# Roadmap v1.0\n`); + writeState(tmpDir); const result = runGsdTools('milestone complete v1.0 --name Beta', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const milestones = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8'); - assert.ok(milestones.includes('v0.9 Alpha'), 'existing entry should be preserved'); - assert.ok(milestones.includes('v1.0 Beta'), 'new entry should be present'); - // New entry should appear BEFORE old entry (reverse chronological) - const newIdx = milestones.indexOf('v1.0 Beta'); - const oldIdx = milestones.indexOf('v0.9 Alpha'); - assert.ok(newIdx < oldIdx, 'new entry should appear before old entry (reverse chronological)'); + assert.ok(milestones.includes('v0.9 Alpha')); + assert.ok(milestones.includes('v1.0 Beta')); + assert.ok(milestones.indexOf('v1.0 Beta') < milestones.indexOf('v0.9 Alpha'), 'new entry before old'); }); test('three sequential completions maintain reverse-chronological order', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'MILESTONES.md'), - `# Milestones\n\n## v1.0 First (Shipped: 2025-01-01)\n\n---\n\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.1\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` + `# Milestones\n\n## v1.0 First (Shipped: 2025-01-01)\n\n---\n\n`, ); + writeRoadmap(tmpDir, `# Roadmap v1.1\n`); + writeState(tmpDir); - let result = runGsdTools('milestone complete v1.1 --name Second', tmpDir); - assert.ok(result.success, `v1.1 failed: ${result.error}`); + assert.ok(runGsdTools('milestone complete v1.1 --name Second', tmpDir).success); + writeRoadmap(tmpDir, `# Roadmap v1.2\n`); + assert.ok(runGsdTools('milestone complete v1.2 --name Third', tmpDir).success); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.2\n` - ); - - result = runGsdTools('milestone complete v1.2 --name Third', tmpDir); - assert.ok(result.success, `v1.2 failed: ${result.error}`); - - const milestones = fs.readFileSync( - path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8' - ); - - const idx10 = milestones.indexOf('v1.0 First'); - const idx11 = milestones.indexOf('v1.1 Second'); - const idx12 = milestones.indexOf('v1.2 Third'); - - assert.ok(idx10 !== -1, 'v1.0 should be present'); - assert.ok(idx11 !== -1, 'v1.1 should be present'); - assert.ok(idx12 !== -1, 'v1.2 should be present'); - assert.ok(idx12 < idx11, 'v1.2 should appear before v1.1'); - assert.ok(idx11 < idx10, 'v1.1 should appear before v1.0'); + const m = fs.readFileSync(path.join(tmpDir, '.planning', 'MILESTONES.md'), 'utf-8'); + const [i10, i11, i12] = ['v1.0 First', 'v1.1 Second', 'v1.2 Third'].map(s => m.indexOf(s)); + assert.ok(i10 !== -1 && i11 !== -1 && i12 !== -1); + assert.ok(i12 < i11, 'v1.2 before v1.1'); + assert.ok(i11 < i10, 'v1.1 before v1.0'); }); test('archives phase directories with --archive-phases flag', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` - ); - - const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(p1, { recursive: true }); - fs.writeFileSync( - path.join(p1, '01-01-SUMMARY.md'), - `---\none-liner: Set up project infrastructure\n---\n# Summary\n` - ); + writeRoadmap(tmpDir, `# Roadmap v1.0\n`); + writeState(tmpDir); + mkPhaseDir(tmpDir, '01-foundation', { oneLiner: 'Set up project infrastructure' }); const result = runGsdTools('milestone complete v1.0 --name MVP --archive-phases', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.archived.phases, true, 'phases should be archived'); - - // Phase directory moved to milestones/v1.0-phases/ - assert.ok( - fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-foundation')), - 'archived phase directory should exist in milestones/v1.0-phases/' - ); - - // Original phase directory no longer exists - assert.ok( - !fs.existsSync(p1), - 'original phase directory should no longer exist' - ); + assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-foundation'))); + assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'))); }); test('archived REQUIREMENTS.md contains archive header', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), - `# Requirements\n\n- [ ] **TEST-01**: core.cjs has tests\n- [ ] **TEST-02**: more tests\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` + `# Requirements\n\n- [ ] **TEST-01**: core.cjs has tests\n- [ ] **TEST-02**: more tests\n`, ); + writeRoadmap(tmpDir, `# Roadmap v1.0\n`); + writeState(tmpDir); - const result = runGsdTools('milestone complete v1.0 --name MVP', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); + assert.ok(runGsdTools('milestone complete v1.0 --name MVP', tmpDir).success); const archivedReq = fs.readFileSync( - path.join(tmpDir, '.planning', 'milestones', 'v1.0-REQUIREMENTS.md'), 'utf-8' + path.join(tmpDir, '.planning', 'milestones', 'v1.0-REQUIREMENTS.md'), 'utf-8', ); - assert.ok(archivedReq.includes('Requirements Archive: v1.0'), 'should contain archive version'); - assert.ok(archivedReq.includes('SHIPPED'), 'should contain SHIPPED status'); - assert.ok(archivedReq.includes('Archived:'), 'should contain Archived: date line'); - // Original content preserved after header - assert.ok(archivedReq.includes('# Requirements'), 'original content should be preserved'); - assert.ok(archivedReq.includes('**TEST-01**'), 'original requirement items should be preserved'); + assert.ok(archivedReq.includes('Requirements Archive: v1.0')); + assert.ok(archivedReq.includes('SHIPPED')); + assert.ok(archivedReq.includes('Archived:')); + assert.ok(archivedReq.includes('# Requirements')); + assert.ok(archivedReq.includes('**TEST-01**')); }); test('STATE.md gets updated during milestone complete', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` - ); + writeRoadmap(tmpDir, `# Roadmap v1.0\n`); + writeState(tmpDir); const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); - assert.strictEqual(output.state_updated, true, 'state_updated should be true'); + assert.strictEqual(output.state_updated, true); const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); - assert.ok(state.includes('v1.0 milestone complete'), 'status should be updated to milestone complete'); - assert.ok( - state.includes('v1.0 milestone completed and archived'), - 'last activity description should reference milestone completion' - ); + assert.ok(state.includes('v1.0 milestone complete')); + assert.ok(state.includes('v1.0 milestone completed and archived')); }); test('normalizes stale STATE.md narrative tails after milestone complete (#3088)', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0\n` - ); + writeRoadmap(tmpDir, `# Roadmap v1.0\n`); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n\n## Current Position\n\nPhase: 03 — EXECUTING\nPlan: 03-02\nStatus: Executing\nLast activity: 2025-01-01 — Running phase\n\n## Operator Next Steps\n\n- Re-run /gsd:complete-milestone v1.0\n` + `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n\n## Current Position\n\nPhase: 03 — EXECUTING\nPlan: 03-02\nStatus: Executing\nLast activity: 2025-01-01 — Running phase\n\n## Operator Next Steps\n\n- Re-run /gsd:complete-milestone v1.0\n`, ); const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir); @@ -244,17 +184,12 @@ describe('milestone complete command', () => { assert.ok(state.includes('Phase: Milestone v1.0 complete')); assert.ok(state.includes('Status: Awaiting next milestone')); assert.ok(!state.includes('Re-run /gsd:complete-milestone')); - // #3584: persisted ROADMAP/STATE strings now use the runtime-routable - // hyphen-form slash command (formatter resolves codex → $gsd-, others → /gsd-). assert.ok(state.includes('/gsd-new-milestone')); }); test('appends canonical narrative sections when STATE.md headings are missing (#3088)', () => { - fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0\n`); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` - ); + writeRoadmap(tmpDir, `# Roadmap v1.0\n`); + writeState(tmpDir); const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); @@ -263,57 +198,33 @@ describe('milestone complete command', () => { assert.ok(state.includes('## Current Position')); assert.ok(state.includes('Phase: Milestone v1.0 complete')); assert.ok(state.includes('## Operator Next Steps')); - // #3584: hyphen form is the runtime-routable shape for skills-based installs. assert.ok(state.includes('/gsd-new-milestone')); }); test('handles missing ROADMAP.md gracefully', () => { - // Only STATE.md — no ROADMAP.md, no REQUIREMENTS.md - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` - ); + writeState(tmpDir); const result = runGsdTools('milestone complete v1.0 --name NoRoadmap', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); - assert.strictEqual(output.archived.roadmap, false, 'roadmap should not be archived'); - assert.strictEqual(output.archived.requirements, false, 'requirements should not be archived'); - assert.strictEqual(output.milestones_updated, true, 'MILESTONES.md should still be created'); - - assert.ok( - fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md')), - 'MILESTONES.md should be created even without ROADMAP.md' - ); + assert.strictEqual(output.archived.roadmap, false); + assert.strictEqual(output.archived.requirements, false); + assert.strictEqual(output.milestones_updated, true); + assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'MILESTONES.md'))); }); test('scopes stats to current milestone phases only', () => { - // Set up ROADMAP.md that only references Phase 3 and Phase 4 - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.1\n\n### Phase 3: New Feature\n**Goal:** Build it\n\n### Phase 4: Polish\n**Goal:** Ship it\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` + writeRoadmap(tmpDir, + `# Roadmap v1.1\n\n### Phase 3: New Feature\n**Goal:** Build it\n\n### Phase 4: Polish\n**Goal:** Ship it\n`, ); + writeState(tmpDir); - // Create phases from PREVIOUS milestone (should be excluded) - const p1 = path.join(tmpDir, '.planning', 'phases', '01-old-setup'); - fs.mkdirSync(p1, { recursive: true }); - fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan\n'); - fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '---\none-liner: Old setup work\n---\n# Summary\n'); - const p2 = path.join(tmpDir, '.planning', 'phases', '02-old-core'); - fs.mkdirSync(p2, { recursive: true }); - fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan\n'); - fs.writeFileSync(path.join(p2, '02-01-SUMMARY.md'), '---\none-liner: Old core work\n---\n# Summary\n'); - - // Create phases for CURRENT milestone (should be included) - const p3 = path.join(tmpDir, '.planning', 'phases', '03-new-feature'); - fs.mkdirSync(p3, { recursive: true }); - fs.writeFileSync(path.join(p3, '03-01-PLAN.md'), '# Plan\n'); - fs.writeFileSync(path.join(p3, '03-01-SUMMARY.md'), '---\none-liner: Built new feature\n---\n# Summary\n'); + // Previous milestone phases — must be excluded + mkPhaseDir(tmpDir, '01-old-setup', { plan: true, oneLiner: 'Old setup work' }); + mkPhaseDir(tmpDir, '02-old-core', { plan: true, oneLiner: 'Old core work' }); + // Current milestone phases + mkPhaseDir(tmpDir, '03-new-feature', { plan: true, oneLiner: 'Built new feature' }); const p4 = path.join(tmpDir, '.planning', 'phases', '04-polish'); fs.mkdirSync(p4, { recursive: true }); fs.writeFileSync(path.join(p4, '04-01-PLAN.md'), '# Plan\n'); @@ -324,237 +235,129 @@ describe('milestone complete command', () => { assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); - // Should only count phases 3 and 4, not 1 and 2 - assert.strictEqual(output.phases, 2, 'should count only milestone phases (3, 4)'); + assert.strictEqual(output.phases, 2, 'should count only phases 3 and 4'); assert.strictEqual(output.plans, 3, 'should count only plans from phases 3 and 4'); - // Accomplishments should only be from phases 3 and 4 - assert.ok(output.accomplishments.includes('Built new feature'), 'should include current milestone accomplishment'); - assert.ok(output.accomplishments.includes('Polished UI'), 'should include current milestone accomplishment'); - assert.ok(!output.accomplishments.includes('Old setup work'), 'should NOT include previous milestone accomplishment'); - assert.ok(!output.accomplishments.includes('Old core work'), 'should NOT include previous milestone accomplishment'); + assert.ok(output.accomplishments.includes('Built new feature')); + assert.ok(output.accomplishments.includes('Polished UI')); + assert.ok(!output.accomplishments.includes('Old setup work')); + assert.ok(!output.accomplishments.includes('Old core work')); }); test('archive-phases only archives current milestone phases', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.1\n\n### Phase 2: Current Work\n**Goal:** Do it\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` + writeRoadmap(tmpDir, + `# Roadmap v1.1\n\n### Phase 2: Current Work\n**Goal:** Do it\n`, ); + writeState(tmpDir); + mkPhaseDir(tmpDir, '01-old', { plan: true }); + mkPhaseDir(tmpDir, '02-current', { plan: true }); - // Phase from previous milestone - const p1 = path.join(tmpDir, '.planning', 'phases', '01-old'); - fs.mkdirSync(p1, { recursive: true }); - fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan\n'); + assert.ok(runGsdTools('milestone complete v1.1 --name Test --archive-phases', tmpDir).success); - // Phase from current milestone - const p2 = path.join(tmpDir, '.planning', 'phases', '02-current'); - fs.mkdirSync(p2, { recursive: true }); - fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan\n'); - - const result = runGsdTools('milestone complete v1.1 --name Test --archive-phases', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - // Phase 2 should be archived - assert.ok( - fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.1-phases', '02-current')), - 'current milestone phase should be archived' - ); - // Phase 1 should still be in place (not archived) - assert.ok( - fs.existsSync(path.join(tmpDir, '.planning', 'phases', '01-old')), - 'previous milestone phase should NOT be archived' - ); + assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.1-phases', '02-current'))); + assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '01-old'))); }); test('phase 1 in roadmap does NOT match directory 10-something (no prefix collision)', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` - ); - - const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); - fs.mkdirSync(p1, { recursive: true }); - fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan\n'); - fs.writeFileSync( - path.join(p1, '01-01-SUMMARY.md'), - '---\none-liner: Foundation work\n---\n' - ); - - const p10 = path.join(tmpDir, '.planning', 'phases', '10-scaling'); - fs.mkdirSync(p10, { recursive: true }); - fs.writeFileSync(path.join(p10, '10-01-PLAN.md'), '# Plan\n'); - fs.writeFileSync( - path.join(p10, '10-01-SUMMARY.md'), - '---\none-liner: Scaling work\n---\n' + writeRoadmap(tmpDir, + `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n`, ); + writeState(tmpDir); + mkPhaseDir(tmpDir, '01-foundation', { plan: true, oneLiner: 'Foundation work' }); + mkPhaseDir(tmpDir, '10-scaling', { plan: true, oneLiner: 'Scaling work' }); const result = runGsdTools('milestone complete v1.0 --name MVP', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.phases, 1, 'should count only phase 1, not phase 10'); - assert.strictEqual(output.plans, 1, 'should count only plans from phase 1'); - assert.ok( - output.accomplishments.includes('Foundation work'), - 'should include phase 1 accomplishment' - ); - assert.ok( - !output.accomplishments.includes('Scaling work'), - 'should NOT include phase 10 accomplishment' - ); + assert.strictEqual(output.plans, 1); + assert.ok(output.accomplishments.includes('Foundation work')); + assert.ok(!output.accomplishments.includes('Scaling work')); }); test('non-numeric directory is excluded when milestone scoping is active', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0\n\n### Phase 1: Core\n**Goal:** Build core\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` - ); - - const p1 = path.join(tmpDir, '.planning', 'phases', '01-core'); - fs.mkdirSync(p1, { recursive: true }); - fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan\n'); - - // Non-phase directory — should be excluded + writeRoadmap(tmpDir, `# Roadmap v1.0\n\n### Phase 1: Core\n**Goal:** Build core\n`); + writeState(tmpDir); + mkPhaseDir(tmpDir, '01-core', { plan: true }); const misc = path.join(tmpDir, '.planning', 'phases', 'notes'); fs.mkdirSync(misc, { recursive: true }); fs.writeFileSync(path.join(misc, 'PLAN.md'), '# Not a phase\n'); const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); + assert.ok(result.success); const output = JSON.parse(result.output); - assert.strictEqual(output.phases, 1, 'non-numeric dir should not be counted as a phase'); - assert.strictEqual(output.plans, 1, 'plans from non-numeric dir should not be counted'); + assert.strictEqual(output.phases, 1); + assert.strictEqual(output.plans, 1); }); test('large phase numbers (456, 457) scope correctly', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.49\n\n### Phase 456: DACP\n**Goal:** Ship DACP\n\n### Phase 457: Integration\n**Goal:** Integrate\n` + writeRoadmap(tmpDir, + `# Roadmap v1.49\n\n### Phase 456: DACP\n**Goal:** Ship DACP\n\n### Phase 457: Integration\n**Goal:** Integrate\n`, ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` - ); - - const p456 = path.join(tmpDir, '.planning', 'phases', '456-dacp'); - fs.mkdirSync(p456, { recursive: true }); - fs.writeFileSync(path.join(p456, '456-01-PLAN.md'), '# Plan\n'); - - const p457 = path.join(tmpDir, '.planning', 'phases', '457-integration'); - fs.mkdirSync(p457, { recursive: true }); - fs.writeFileSync(path.join(p457, '457-01-PLAN.md'), '# Plan\n'); - - // Phase 45 from prior milestone — should not match - const p45 = path.join(tmpDir, '.planning', 'phases', '45-old'); - fs.mkdirSync(p45, { recursive: true }); - fs.writeFileSync(path.join(p45, 'PLAN.md'), '# Plan\n'); + writeState(tmpDir); + mkPhaseDir(tmpDir, '456-dacp', { plan: true }); + mkPhaseDir(tmpDir, '457-integration', { plan: true }); + mkPhaseDir(tmpDir, '45-old', { plan: true }); const result = runGsdTools('milestone complete v1.49 --name DACP', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.phases, 2, 'should count only phases 456 and 457'); + assert.ok(result.success); + assert.strictEqual(JSON.parse(result.output).phases, 2); }); test('counts tasks from **Tasks:** N in summary body', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` - ); - + writeRoadmap(tmpDir, `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n`); + writeState(tmpDir); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync( path.join(p1, '01-01-SUMMARY.md'), - `---\none-liner: Built the foundation\n---\n\n# Phase 1: Foundation Summary\n\n**Built the foundation**\n\n## Performance\n\n- **Duration:** 28 min\n- **Tasks:** 7\n- **Files modified:** 12\n` + `---\none-liner: Built the foundation\n---\n\n# Phase 1: Foundation Summary\n\n**Built the foundation**\n\n## Performance\n\n- **Duration:** 28 min\n- **Tasks:** 7\n- **Files modified:** 12\n`, ); const result = runGsdTools('milestone complete v1.0 --name MVP', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.tasks, 7, 'should count tasks from **Tasks:** N field'); + assert.ok(result.success); + assert.strictEqual(JSON.parse(result.output).tasks, 7); }); test('extracts one-liner from body when not in frontmatter', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` - ); - + writeRoadmap(tmpDir, `# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n`); + writeState(tmpDir); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); - // No one-liner in frontmatter, but present in body as bold line fs.writeFileSync( path.join(p1, '01-01-SUMMARY.md'), - `---\nphase: "01"\n---\n\n# Phase 1: Foundation Summary\n\n**JWT auth with refresh rotation using jose library**\n\n## Performance\n` + `---\nphase: "01"\n---\n\n# Phase 1: Foundation Summary\n\n**JWT auth with refresh rotation using jose library**\n\n## Performance\n`, ); const result = runGsdTools('milestone complete v1.0 --name MVP', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.ok( - output.accomplishments.includes('JWT auth with refresh rotation using jose library'), - 'should extract one-liner from body bold line' - ); + assert.ok(result.success); + assert.ok(JSON.parse(result.output).accomplishments.includes('JWT auth with refresh rotation using jose library')); }); test('updates STATE.md with plain format fields', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0\n` - ); + writeRoadmap(tmpDir, `# Roadmap v1.0\n`); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\nStatus: In progress\nLast Activity: 2025-01-01\nLast Activity Description: Working\n` + `# State\n\nStatus: In progress\nLast Activity: 2025-01-01\nLast Activity Description: Working\n`, ); const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); - assert.ok(state.includes('v1.0 milestone complete'), 'plain Status field should be updated'); + assert.ok(result.success); + assert.ok(fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8').includes('v1.0 milestone complete')); }); test('handles empty phases directory', () => { - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap v1.0\n` - ); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'STATE.md'), - `# State\n\n**Status:** In progress\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n` - ); - // phases directory exists but is empty (from createTempProject) + writeRoadmap(tmpDir, `# Roadmap v1.0\n`); + writeState(tmpDir); const result = runGsdTools('milestone complete v1.0 --name EmptyPhases', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); + assert.ok(result.success); const output = JSON.parse(result.output); - assert.strictEqual(output.phases, 0, 'phase count should be 0'); - assert.strictEqual(output.plans, 0, 'plan count should be 0'); - assert.strictEqual(output.tasks, 0, 'task count should be 0'); + assert.strictEqual(output.phases, 0); + assert.strictEqual(output.plans, 0); + assert.strictEqual(output.tasks, 0); }); }); @@ -565,13 +368,8 @@ describe('milestone complete command', () => { describe('phases clear command', () => { let tmpDir; - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); test('deletes normal phase directories when --confirm is passed', () => { const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup'); @@ -579,42 +377,34 @@ describe('phases clear command', () => { fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan\n'); const result = runGsdTools('phases clear --confirm', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.cleared, 1, 'should have cleared 1 directory'); - assert.ok(!fs.existsSync(p1), '01-setup should be deleted'); + assert.ok(result.success); + assert.strictEqual(JSON.parse(result.output).cleared, 1); + assert.ok(!fs.existsSync(p1)); }); test('requires --confirm when phase directories exist', () => { const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup'); fs.mkdirSync(p1, { recursive: true }); - - const result = runGsdTools('phases clear', tmpDir); - assert.ok(!result.success, 'should fail without --confirm'); + assert.ok(!runGsdTools('phases clear', tmpDir).success); }); test('preserves 999.x backlog phase directories during clear (#1853)', () => { const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup'); const p999a = path.join(tmpDir, '.planning', 'phases', '999.1-some-idea'); const p999b = path.join(tmpDir, '.planning', 'phases', '999.2-another-idea'); - fs.mkdirSync(p1, { recursive: true }); fs.mkdirSync(p999a, { recursive: true }); fs.mkdirSync(p999b, { recursive: true }); - fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan\n'); - fs.writeFileSync(path.join(p999a, 'PLAN.md'), '# Backlog idea\n'); - fs.writeFileSync(path.join(p999b, 'PLAN.md'), '# Another backlog idea\n'); + fs.writeFileSync(path.join(p999a, 'PLAN.md'), '# Backlog\n'); + fs.writeFileSync(path.join(p999b, 'PLAN.md'), '# Backlog 2\n'); const result = runGsdTools('phases clear --confirm', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.cleared, 1, 'should have cleared only 1 directory (not backlog)'); - assert.ok(!fs.existsSync(p1), '01-setup should be deleted'); - assert.ok(fs.existsSync(p999a), '999.1-some-idea should be preserved'); - assert.ok(fs.existsSync(p999b), '999.2-another-idea should be preserved'); + assert.ok(result.success); + assert.strictEqual(JSON.parse(result.output).cleared, 1); + assert.ok(!fs.existsSync(p1)); + assert.ok(fs.existsSync(p999a)); + assert.ok(fs.existsSync(p999b)); }); test('reports 0 cleared when only backlog phases exist', () => { @@ -622,30 +412,21 @@ describe('phases clear command', () => { fs.mkdirSync(p999a, { recursive: true }); const result = runGsdTools('phases clear --confirm', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.cleared, 0, 'cleared should be 0 when only backlog phases exist'); - assert.ok(fs.existsSync(p999a), '999.1-idea should be preserved'); + assert.ok(result.success); + assert.strictEqual(JSON.parse(result.output).cleared, 0); + assert.ok(fs.existsSync(p999a)); }); }); // ───────────────────────────────────────────────────────────────────────────── -// requirements mark-complete command +// requirements mark-complete command — regex global-state fix (#milestone-regex-global) // ───────────────────────────────────────────────────────────────────────────── describe('requirements mark-complete command', () => { let tmpDir; - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - // ─── helpers ────────────────────────────────────────────────────────────── + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); function writeRequirements(tmpDir, content) { fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), content, 'utf-8'); @@ -679,22 +460,19 @@ describe('requirements mark-complete command', () => { | INFRA-01 | Phase 6 | Pending | `; - // ─── tests ──────────────────────────────────────────────────────────────── - test('marks single requirement complete (checkbox + table)', () => { writeRequirements(tmpDir, STANDARD_REQUIREMENTS); const result = runGsdTools('requirements mark-complete TEST-01', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); + assert.ok(result.success); const output = JSON.parse(result.output); assert.strictEqual(output.updated, true); - assert.ok(output.marked_complete.includes('TEST-01'), 'TEST-01 should be marked complete'); + assert.ok(output.marked_complete.includes('TEST-01')); const content = readRequirements(tmpDir); assert.ok(content.includes('- [x] **TEST-01**'), 'checkbox should be checked'); assert.ok(content.includes('| TEST-01 | Phase 1 | Complete |'), 'table row should be Complete'); - // Other checkboxes unchanged assert.ok(content.includes('- [ ] **TEST-02**'), 'TEST-02 should remain unchecked'); }); @@ -702,120 +480,161 @@ describe('requirements mark-complete command', () => { writeRequirements(tmpDir, STANDARD_REQUIREMENTS); const result = runGsdTools('requirements mark-complete TEST-01,REG-01,INFRA-01', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); + assert.ok(result.success); const output = JSON.parse(result.output); - assert.strictEqual(output.marked_complete.length, 3, 'should mark 3 requirements complete'); + assert.strictEqual(output.marked_complete.length, 3); assert.ok(output.marked_complete.includes('TEST-01')); assert.ok(output.marked_complete.includes('REG-01')); assert.ok(output.marked_complete.includes('INFRA-01')); const content = readRequirements(tmpDir); - assert.ok(content.includes('- [x] **TEST-01**'), 'TEST-01 checkbox should be checked'); - assert.ok(content.includes('- [x] **REG-01**'), 'REG-01 checkbox should be checked'); - assert.ok(content.includes('- [x] **INFRA-01**'), 'INFRA-01 checkbox should be checked'); - assert.ok(content.includes('| TEST-01 | Phase 1 | Complete |'), 'TEST-01 table should be Complete'); - assert.ok(content.includes('| REG-01 | Phase 1 | Complete |'), 'REG-01 table should be Complete'); - assert.ok(content.includes('| INFRA-01 | Phase 6 | Complete |'), 'INFRA-01 table should be Complete'); + assert.ok(content.includes('- [x] **TEST-01**')); + assert.ok(content.includes('- [x] **REG-01**')); + assert.ok(content.includes('- [x] **INFRA-01**')); + assert.ok(content.includes('| TEST-01 | Phase 1 | Complete |')); + assert.ok(content.includes('| REG-01 | Phase 1 | Complete |')); + assert.ok(content.includes('| INFRA-01 | Phase 6 | Complete |')); }); test('accepts space-separated IDs', () => { writeRequirements(tmpDir, STANDARD_REQUIREMENTS); const result = runGsdTools('requirements mark-complete TEST-01 TEST-02', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.marked_complete.length, 2, 'should mark 2 requirements complete'); + assert.ok(result.success); + assert.strictEqual(JSON.parse(result.output).marked_complete.length, 2); const content = readRequirements(tmpDir); - assert.ok(content.includes('- [x] **TEST-01**'), 'TEST-01 should be checked'); - assert.ok(content.includes('- [x] **TEST-02**'), 'TEST-02 should be checked'); + assert.ok(content.includes('- [x] **TEST-01**')); + assert.ok(content.includes('- [x] **TEST-02**')); }); test('accepts bracket-wrapped IDs [REQ-01, REQ-02]', () => { writeRequirements(tmpDir, STANDARD_REQUIREMENTS); const result = runGsdTools('requirements mark-complete [TEST-01,TEST-02]', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.strictEqual(output.marked_complete.length, 2, 'should mark 2 requirements complete'); + assert.ok(result.success); + assert.strictEqual(JSON.parse(result.output).marked_complete.length, 2); const content = readRequirements(tmpDir); - assert.ok(content.includes('- [x] **TEST-01**'), 'TEST-01 should be checked'); - assert.ok(content.includes('- [x] **TEST-02**'), 'TEST-02 should be checked'); + assert.ok(content.includes('- [x] **TEST-01**')); + assert.ok(content.includes('- [x] **TEST-02**')); }); test('returns not_found for invalid IDs while updating valid ones', () => { writeRequirements(tmpDir, STANDARD_REQUIREMENTS); const result = runGsdTools('requirements mark-complete TEST-01,FAKE-99', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); + assert.ok(result.success); const output = JSON.parse(result.output); - assert.strictEqual(output.updated, true, 'should still update valid IDs'); - assert.ok(output.marked_complete.includes('TEST-01'), 'TEST-01 should be marked complete'); - assert.ok(output.not_found.includes('FAKE-99'), 'FAKE-99 should be in not_found'); - assert.strictEqual(output.total, 2, 'total should reflect all IDs attempted'); + assert.strictEqual(output.updated, true); + assert.ok(output.marked_complete.includes('TEST-01')); + assert.ok(output.not_found.includes('FAKE-99')); + assert.strictEqual(output.total, 2); }); test('idempotent — re-marking already-complete requirement does not corrupt', () => { writeRequirements(tmpDir, STANDARD_REQUIREMENTS); - // TEST-03 already has [x] and Complete in the fixture const result = runGsdTools('requirements mark-complete TEST-03', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); + assert.ok(result.success); const output = JSON.parse(result.output); - assert.ok(output.already_complete.includes('TEST-03'), 'already-complete ID should be in already_complete'); - assert.deepStrictEqual(output.not_found, [], 'should not appear in not_found'); + assert.ok(output.already_complete.includes('TEST-03')); + assert.deepStrictEqual(output.not_found, []); const content = readRequirements(tmpDir); - // File should not be corrupted — no [xx] or doubled markers - assert.ok(content.includes('- [x] **TEST-03**'), 'existing [x] should remain intact'); - assert.ok(!content.includes('[xx]'), 'should not have doubled x markers'); - assert.ok(!content.includes('- [x] [x]'), 'should not have duplicate checkbox'); + assert.ok(content.includes('- [x] **TEST-03**')); + assert.ok(!content.includes('[xx]')); + assert.ok(!content.includes('- [x] [x]')); }); test('returns already_complete for idempotent calls on completed requirements', () => { writeRequirements(tmpDir, STANDARD_REQUIREMENTS); - // TEST-03 is already [x] in the fixture - const result = runGsdTools('requirements mark-complete TEST-03', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.deepStrictEqual(output.already_complete, ['TEST-03'], - 'should report TEST-03 as already_complete'); - assert.deepStrictEqual(output.not_found, [], - 'should not report already-complete IDs as not_found'); + const output = JSON.parse(runGsdTools('requirements mark-complete TEST-03', tmpDir).output); + assert.deepStrictEqual(output.already_complete, ['TEST-03']); + assert.deepStrictEqual(output.not_found, []); }); test('mixed: updates pending, reports already-complete, and flags missing', () => { writeRequirements(tmpDir, STANDARD_REQUIREMENTS); - const result = runGsdTools('requirements mark-complete TEST-01,TEST-03,FAKE-99', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - assert.deepStrictEqual(output.marked_complete, ['TEST-01'], - 'should mark TEST-01 complete'); - assert.deepStrictEqual(output.already_complete, ['TEST-03'], - 'should report TEST-03 as already_complete'); - assert.deepStrictEqual(output.not_found, ['FAKE-99'], - 'should report FAKE-99 as not_found'); + const output = JSON.parse( + runGsdTools('requirements mark-complete TEST-01,TEST-03,FAKE-99', tmpDir).output, + ); + assert.deepStrictEqual(output.marked_complete, ['TEST-01']); + assert.deepStrictEqual(output.already_complete, ['TEST-03']); + assert.deepStrictEqual(output.not_found, ['FAKE-99']); }); test('missing REQUIREMENTS.md returns expected error structure', () => { - // createTempProject does not create REQUIREMENTS.md — so it's already missing + const output = JSON.parse(runGsdTools('requirements mark-complete TEST-01', tmpDir).output); + assert.strictEqual(output.updated, false); + assert.strictEqual(output.reason, 'REQUIREMENTS.md not found'); + }); +}); - const result = runGsdTools('requirements mark-complete TEST-01', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); +// ───────────────────────────────────────────────────────────────────────────── +// milestone.cjs regex global-state fix (structural regression guard) +// ───────────────────────────────────────────────────────────────────────────── - const output = JSON.parse(result.output); - assert.strictEqual(output.updated, false, 'updated should be false'); - assert.strictEqual(output.reason, 'REQUIREMENTS.md not found', 'should report file not found'); +describe('milestone.cjs regex global state fix', () => { + // allow-test-rule: structural-regression-guard + // milestone.cjs must use replace()+compare, not test()+replace(), to avoid + // regex lastIndex corruption with global flags. + const MILESTONE_SRC = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'milestone.cjs'); + let src; + + before(() => { src = fs.readFileSync(MILESTONE_SRC, 'utf-8'); }); + + test('checkbox update uses replace() + compare, not test() + replace()', () => { + const funcBody = src.slice( + src.indexOf('function cmdRequirementsMarkComplete'), + src.indexOf('function cmdMilestoneComplete'), + ); + assert.ok(!funcBody.includes('checkboxPattern.test(reqContent)')); + assert.ok( + funcBody.includes('afterCheckbox !== reqContent') || + funcBody.includes('afterCheckbox!==reqContent'), + ); + }); + + test('table update uses replace() + compare, not test() + replace()', () => { + const funcBody = src.slice( + src.indexOf('function cmdRequirementsMarkComplete'), + src.indexOf('function cmdMilestoneComplete'), + ); + assert.ok(!funcBody.includes('tablePattern.test(reqContent)')); + assert.ok( + funcBody.includes('afterTable !== reqContent') || + funcBody.includes('afterTable!==reqContent'), + ); + }); + + test('done-check regexes use non-global flag (only need existence check)', () => { + const funcBody = src.slice( + src.indexOf('function cmdRequirementsMarkComplete'), + src.indexOf('function cmdMilestoneComplete'), + ); + const doneCheckboxMatch = funcBody.match(/doneCheckbox\s*=\s*new RegExp\([^)]+,\s*'([^']+)'\)/); + const doneTableMatch = funcBody.match(/doneTable\s*=\s*new RegExp\([^)]+,\s*'([^']+)'\)/); + assert.ok(doneCheckboxMatch, 'doneCheckbox regex should exist'); + assert.ok(doneTableMatch, 'doneTable regex should exist'); + assert.ok(!doneCheckboxMatch[1].includes('g')); + assert.ok(!doneTableMatch[1].includes('g')); + }); + + test('no duplicate regex construction for the same pattern', () => { + const funcBody = src.slice( + src.indexOf('function cmdRequirementsMarkComplete'), + src.indexOf('function cmdMilestoneComplete'), + ); + const tableConstructions = funcBody.split('\n').filter( + line => line.includes('tablePattern') && line.includes('new RegExp'), + ); + assert.ok(tableConstructions.length <= 1, `Expected ≤1 tablePattern construction, got ${tableConstructions.length}`); }); }); @@ -828,42 +647,72 @@ describe('new-milestone workflow verification gate', () => { const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'new-milestone.md'); const content = fs.readFileSync(workflowPath, 'utf8'); - // Must have a verification step between goal gathering and PROJECT.md writing - assert.ok( - content.includes('Verify Milestone Understanding'), - 'workflow must have a "Verify Milestone Understanding" step' - ); - - // Verification must come before Step 4 (Update PROJECT.md) + assert.ok(content.includes('Verify Milestone Understanding')); const verifyIdx = content.indexOf('Verify Milestone Understanding'); const updateIdx = content.indexOf('## 4. Update PROJECT.md'); - assert.ok(verifyIdx > 0, 'verification step must exist'); - assert.ok(updateIdx > 0, 'Update PROJECT.md step must exist'); - assert.ok( - verifyIdx < updateIdx, - 'verification step must appear before Update PROJECT.md step' - ); + assert.ok(verifyIdx > 0); + assert.ok(updateIdx > 0); + assert.ok(verifyIdx < updateIdx); }); test('verification step uses AskUserQuestion with adjust loop', () => { const workflowPath = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'new-milestone.md'); const content = fs.readFileSync(workflowPath, 'utf8'); - // Extract the section between 3.5 and 4 - const sectionStart = content.indexOf('## 3.5'); - const sectionEnd = content.indexOf('## 4.'); - const section = content.slice(sectionStart, sectionEnd); - - assert.ok(section.includes('AskUserQuestion'), 'verification must use AskUserQuestion'); - assert.ok(section.includes('Adjust'), 'verification must offer Adjust option'); - assert.ok(section.includes('Looks good'), 'verification must offer Looks good option'); + const section = content.slice(content.indexOf('## 3.5'), content.indexOf('## 4.')); + assert.ok(section.includes('AskUserQuestion')); + assert.ok(section.includes('Adjust')); + assert.ok(section.includes('Looks good')); assert.ok( section.includes('Loop until') || section.includes('loop until') || section.includes('re-present'), - 'verification must loop until user approves' ); }); }); // ───────────────────────────────────────────────────────────────────────────── -// validate consistency command +// milestone complete respects explicit version scope (#3043) // ───────────────────────────────────────────────────────────────────────────── + +describe('milestone complete explicit version scope (#3043)', () => { + test('milestone.complete v3.6 uses v3.6 phases even when STATE milestone is v3.5', () => { + const tmpDir = createTempProject('gsd-bug-3043-'); + try { + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v3.5\n---\n'); + writeRoadmap( + tmpDir, + '# Roadmap\n\n## 🚧 v3.5 Paused\n### Phase 103: old\n### Phase 104: old2\n\n## 🚧 v3.6 Current\n### Phase 108: new\n', + ); + fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n'); + + for (const [dir, liner] of [['103.old', 'old milestone A'], ['104.old', 'old milestone B'], ['108.new', 'new milestone']]) { + const p = path.join(tmpDir, '.planning', 'phases', dir); + fs.mkdirSync(p, { recursive: true }); + fs.writeFileSync(path.join(p, 'SUMMARY.md'), `one-liner: ${liner}\n\n## Summary\n${liner.split(' ')[0]}\n`); + } + + const result = runGsdTools(['milestone', 'complete', 'v3.6', '--raw'], tmpDir); + assert.equal(result.success, true, result.error || result.output); + const payload = JSON.parse(result.output); + assert.equal(payload.version, 'v3.6'); + assert.equal(payload.phases, 1, `expected 1 phase for v3.6, got ${payload.phases}`); + } finally { + cleanup(tmpDir); + } + }); + + test('milestone.complete fails when explicit milestone version resolves no phases', () => { + const tmpDir = createTempProject('gsd-bug-3043-empty-'); + try { + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nmilestone: v1.0\n---\n'); + writeRoadmap(tmpDir, '# Roadmap\n\n## 🚧 v1.0\n### Phase 1: foundation\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), '# Requirements\n'); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true }); + + const result = runGsdTools(['milestone', 'complete', 'v9.9', '--raw'], tmpDir); + assert.equal(result.success, false, 'expected command to fail when no phases match explicit version'); + assert.match(result.error || '', /no phases|phase/i); + } finally { + cleanup(tmpDir); + } + }); +});