Files
msd-core/tests/new-milestone-clear-phases.test.cjs
Tom Boucher 2c32e8d890 test(#1973): consolidate 48 workflow regression tests into workflow-aspect suites
Fold 48 issue-named workflow-markdown regression files into the canonical test
that owns each workflow aspect (execute-phase-*, plan-phase-*, quick-*, discuss-*,
worktree-cleanup, secure-phase, verify, update, settings, etc.), across 32 existing
suites. Verbatim block-scoped describe wrappers; 281 subtests conserved 1:1. No new
test files. Host-env pre-check: only gsd-settings-advanced spawns CLI and it sets no
GSD_WORKSTREAM/GSD_PROJECT value — no leak risk.

Regenerates regression-name allowlist (222->181), ratchets file-count allowlist
(verify 11->10), makes 30 relocated allow-test-rule exemptions issue-ref-compliant
(ADR-456; prunes 30 stale ids). lint:ci green.

Part of epic #1969. Closes #1973.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:17:32 -04:00

342 lines
15 KiB
JavaScript

/**
* GSD Tools Tests - New Milestone Clear Phases (#1588, #1447)
*
* Verifies that `phases clear` removes all phase subdirectories from
* .planning/phases/, leaving the directory itself intact.
*
* Also covers the #1447 uncommitted-changes guard: phases clear must refuse
* to delete phase directories that contain uncommitted work.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
describe('phases clear command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('clears all phase subdirectories from .planning/phases/', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
// Simulate phases left over from a previous milestone
const phase1 = path.join(phasesDir, '01-foundation');
const phase2 = path.join(phasesDir, '02-api');
const phase3 = path.join(phasesDir, '03-ui');
fs.mkdirSync(phase1, { recursive: true });
fs.mkdirSync(phase2, { recursive: true });
fs.mkdirSync(phase3, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(phase2, '02-01-SUMMARY.md'), '# Summary');
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, 3, 'should report 3 directories cleared');
// phases/ directory itself must still exist
assert.ok(fs.existsSync(phasesDir), '.planning/phases/ directory should still exist');
// all subdirectories must be gone
const remaining = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory());
assert.strictEqual(remaining.length, 0, 'no phase subdirectories should remain');
});
test('succeeds with cleared=0 when phases directory is already empty', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
// createTempProject creates the directory but leaves it empty
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, 'should report 0 cleared when already empty');
assert.ok(fs.existsSync(phasesDir), '.planning/phases/ directory should still exist');
});
test('succeeds with cleared=0 when phases directory does not exist', () => {
// Remove the phases directory entirely
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- mid-test removal to simulate absent phases dir (SUT behavior, not teardown)
fs.rmSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true, force: 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, 'should report 0 cleared when directory absent');
});
test('does not remove files (only directories) at the phases root', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
// Put a stray file directly in phases/ (edge case)
fs.writeFileSync(path.join(phasesDir, 'README.md'), '# Phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
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 report 1 directory cleared (not the file)');
// File must survive
assert.ok(
fs.existsSync(path.join(phasesDir, 'README.md')),
'files at phases root should be preserved'
);
});
test('archives nested phase content (moved, not deleted) (#1871)', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
const nested = path.join(phase1, 'subdir');
fs.mkdirSync(nested, { recursive: true });
fs.writeFileSync(path.join(nested, 'deep-file.md'), '# Deep');
const result = runGsdTools('phases clear --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
// Source is cleared (moved away)...
assert.ok(!fs.existsSync(phase1), 'phase directory should be moved out of .planning/phases/');
// ...but the nested content SURVIVES in the archive (not destroyed).
const archive = findPhasesArchive(tmpDir);
assert.ok(archive, 'an archive dir milestones/*-phases/ should exist');
assert.ok(
fs.existsSync(path.join(archive, '01-foundation', 'subdir', 'deep-file.md')),
'nested phase content must be preserved in the archive, not deleted',
);
});
});
// Locate the `milestones/<version>-phases/` archive directory created by phases clear.
function findPhasesArchive(tmpDir) {
const milestonesDir = path.join(tmpDir, '.planning', 'milestones');
try {
for (const entry of fs.readdirSync(milestonesDir, { withFileTypes: true })) {
if (entry.isDirectory() && /-phases$/.test(entry.name)) {
return path.join(milestonesDir, entry.name);
}
}
} catch {
/* no milestones dir */
}
return null;
}
// ─── #1447: uncommitted-changes guard ───────────────────────────────────────
describe('phases clear: uncommitted-changes guard (#1447)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempGitProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('aborts with error when phase dirs contain uncommitted files', () => {
// Add a phase directory with an untracked (uncommitted) file
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, 'PLAN.md'), '# Plan (uncommitted)');
// Do NOT commit — leave as untracked/uncommitted changes
const result = runGsdTools('phases clear --confirm', tmpDir);
assert.ok(!result.success, 'phases clear should fail when uncommitted changes exist');
assert.ok(
result.error.includes('uncommitted') || result.error.includes('aborted'),
`expected error about uncommitted changes, got: ${result.error}`
);
// Phase directory must still exist (was not deleted)
assert.ok(fs.existsSync(phase1), 'phase directory must survive when guard fires');
});
test('aborts when phase dirs have staged but uncommitted changes', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, 'PLAN.md'), '# Plan (staged)');
// Stage the file but do not commit
execSync('git add .planning/phases/', { cwd: tmpDir, stdio: 'pipe' });
const result = runGsdTools('phases clear --confirm', tmpDir);
assert.ok(!result.success, 'phases clear should fail when staged-but-uncommitted changes exist');
assert.ok(
result.error.includes('uncommitted') || result.error.includes('aborted'),
`expected error about uncommitted changes, got: ${result.error}`
);
assert.ok(fs.existsSync(phase1), 'phase directory must survive when guard fires');
});
test('--force bypasses the uncommitted-changes guard and deletes anyway', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, 'PLAN.md'), '# Plan (uncommitted)');
// Do NOT commit
const result = runGsdTools('phases clear --confirm --force', tmpDir);
assert.ok(result.success, `--force should bypass guard and succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.cleared, 1, 'should clear 1 phase directory');
assert.ok(!fs.existsSync(phase1), 'phase directory must be removed when --force is passed');
});
test('succeeds without --force when all phase files are committed', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, 'PLAN.md'), '# Plan (committed)');
// Commit the phase files
execSync('git add .planning/phases/', { cwd: tmpDir, stdio: 'pipe' });
execSync('git commit -m "add phase"', { cwd: tmpDir, stdio: 'pipe' });
const result = runGsdTools('phases clear --confirm', tmpDir);
assert.ok(result.success, `should succeed when phase files are committed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.cleared, 1, 'should clear 1 phase directory');
// #1871: a committed phase dir is ARCHIVED (moved to milestones/*-phases/), not destroyed.
assert.ok(!fs.existsSync(phase1), 'committed phase directory should be moved out of .planning/phases/');
const archive = findPhasesArchive(tmpDir);
assert.ok(archive, 'a milestones/*-phases/ archive should be created for committed phase dirs');
assert.ok(
fs.existsSync(path.join(archive, '01-foundation', 'PLAN.md')),
'committed phase content must be preserved in the archive, not hard-deleted',
);
});
test('guard skips gracefully when not in a git repo (no guard, proceeds normally)', () => {
// Non-git project: createTempProject creates a plain project without git
const nonGitDir = createTempProject();
try {
const phasesDir = path.join(nonGitDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, 'PLAN.md'), '# Plan');
// Without git, the guard cannot check status — it should skip and proceed
const result = runGsdTools('phases clear --confirm', nonGitDir);
assert.ok(result.success, `should succeed in non-git repo: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.cleared, 1, 'should clear 1 phase directory in non-git project');
} finally {
cleanup(nonGitDir);
}
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/enh-2433-todo-phase-linking.test.cjs — consolidation epic #1969 (B4 #1973)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:enh-2433-todo-phase-linking (consolidation epic #1969 B4 #1973)", () => {
'use strict';
// allow-test-rule: source-text-is-the-product (see #2433)
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* Tests for gsd-new-milestone todo-to-phase linking (#2433).
* Verifies the workflow text contains the correct linking and auto-close steps.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
const NEW_MILESTONE = fs.readFileSync(
path.join(ROOT, 'gsd-core/workflows/new-milestone.md'), 'utf-8'
);
const EXECUTE_PHASE = fs.readFileSync(
path.join(ROOT, 'gsd-core/workflows/execute-phase.md'), 'utf-8'
);
test('new-milestone.md: step 10.5 links pending todos to roadmap phases', () => {
assert.ok(NEW_MILESTONE.includes('10.5'), 'step 10.5 should exist');
assert.ok(NEW_MILESTONE.includes('resolves_phase'), 'should reference resolves_phase field');
assert.ok(NEW_MILESTONE.includes('.planning/todos/pending'), 'should scan pending todos directory');
});
test('new-milestone.md: todo linking runs after roadmap commit', () => {
const roadmapCommitIdx = NEW_MILESTONE.indexOf('docs: create milestone v[X.Y] roadmap');
const step105Idx = NEW_MILESTONE.indexOf('10.5. Link Pending Todos');
const step11Idx = NEW_MILESTONE.indexOf('## 11. Done');
assert.ok(roadmapCommitIdx < step105Idx, 'step 10.5 should come after roadmap commit');
assert.ok(step105Idx < step11Idx, 'step 10.5 should come before step 11');
});
test('new-milestone.md: todo linking is best-effort and leaves unmatched todos unmodified', () => {
assert.ok(NEW_MILESTONE.includes('best-effort'), 'should describe best-effort matching');
assert.ok(NEW_MILESTONE.includes('unmatched'), 'should mention leaving unmatched todos alone');
assert.ok(NEW_MILESTONE.includes('confident match'), 'should gate on confident match');
});
test('new-milestone.md: step 10.5 commits tagged todos', () => {
// After #3797 architectural fix, callsites use gsd_run
assert.ok(NEW_MILESTONE.includes('gsd_run query commit'), 'should commit tagged todos');
assert.ok(NEW_MILESTONE.includes('resolves_phase after milestone'), 'commit message should mention resolves_phase');
});
test('new-milestone.md: success_criteria includes todo linking', () => {
assert.ok(NEW_MILESTONE.includes('resolves_phase: N'), 'success_criteria should mention resolves_phase tagging');
});
test('execute-phase.md: close_phase_todos step exists', () => {
assert.ok(EXECUTE_PHASE.includes('close_phase_todos'), 'close_phase_todos step should exist');
assert.ok(EXECUTE_PHASE.includes('resolves_phase'), 'should check resolves_phase in todos');
});
test('execute-phase.md: auto-close moves todos to completed directory', () => {
assert.ok(EXECUTE_PHASE.includes('.planning/todos/completed'), 'should move to completed dir');
assert.ok(EXECUTE_PHASE.includes('.planning/todos/pending'), 'should scan pending dir');
assert.ok(EXECUTE_PHASE.includes('mv "$TODO_FILE" "$COMPLETED_DIR/"'), 'should use mv to move files');
});
test('execute-phase.md: close_phase_todos runs after update_roadmap', () => {
const updateRoadmapIdx = EXECUTE_PHASE.indexOf('name="update_roadmap"');
const closeTodosIdx = EXECUTE_PHASE.indexOf('name="close_phase_todos"');
assert.ok(updateRoadmapIdx < closeTodosIdx, 'close_phase_todos should run after update_roadmap');
});
test('execute-phase.md: auto-close never blocks phase completion', () => {
const closeTodosSection = EXECUTE_PHASE.slice(
EXECUTE_PHASE.indexOf('name="close_phase_todos"'),
EXECUTE_PHASE.indexOf('name="update_project_md"')
);
assert.ok(
closeTodosSection.includes('never blocks') || closeTodosSection.includes('additive'),
'close_phase_todos should be non-blocking'
);
});
test('execute-phase.md: awk extracts resolves_phase from YAML frontmatter', () => {
assert.ok(EXECUTE_PHASE.includes('awk'), 'should use awk for frontmatter extraction');
assert.ok(EXECUTE_PHASE.includes('resolves_phase:'), 'awk pattern should match resolves_phase key');
});
});
}