refactor(tests): consolidate Milestone Module — 10 files → 4 (#3754)
* refactor(tests): consolidate Milestone Module — 10 files → 4 Closes #3753 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(changeset): correct fragment format for 3753-consolidate-milestone-tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(changeset): add required frontmatter to 3753 changeset fragment Adds `type: Fixed` and `pr: 3753` frontmatter so docs-lint passes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(tests): bug-2943 execFileSync timeout 5s→15s for Windows starvation PR #3754's milestone consolidation reshuffles run-tests.cjs chunk composition. On Windows/Node 22 under --test-concurrency=4, bug-2943 subprocesses now share concurrency slots with bug-2760-codex-install subtests (8–15s each), starving past the 5000ms timeout. 15s covers the observed 13.5s worst case with headroom. Refs #3753 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
19
.changeset/3753-consolidate-milestone-tests.md
Normal file
19
.changeset/3753-consolidate-milestone-tests.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3753
|
||||
---
|
||||
<!-- docs-exempt: internal test refactor only — no user-facing surface changed -->
|
||||
|
||||
## 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
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #2684:
|
||||
* `gsd-sdk query milestone.complete <version>` 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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>✅ v1.0 Foundation — SHIPPED</summary>',
|
||||
'',
|
||||
'### Phase 1: Bootstrap',
|
||||
'**Goal:** Bootstrap the project',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'## 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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
505
tests/milestone-archive.test.cjs
Normal file
505
tests/milestone-archive.test.cjs
Normal file
@@ -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',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>✅ v1.0 Foundation — SHIPPED</summary>',
|
||||
'',
|
||||
'### Phase 1: Bootstrap',
|
||||
'**Goal:** Bootstrap the project',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'## 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');
|
||||
});
|
||||
});
|
||||
@@ -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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user