Merge pull request #3191 from gsd-build/fix/3164-gsd-tools-milestone-archive-layout
fix(gsd-tools): support .planning/milestones/v*-phases/ layout (#3164)
This commit is contained in:
5
.changeset/witty-wasps-hum.md
Normal file
5
.changeset/witty-wasps-hum.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 3191
|
||||||
|
---
|
||||||
|
validate consistency, validate health, and find-phase now scan .planning/milestones/v*-phases/ dirs in addition to the flat .planning/phases/ layout. Projects using milestone-archive layout no longer receive spurious W006 warnings for every active phase. Fixes #3164.
|
||||||
@@ -6,6 +6,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased](https://github.com/gsd-build/get-shit-done/compare/v1.39.1...HEAD)
|
## [Unreleased](https://github.com/gsd-build/get-shit-done/compare/v1.39.1...HEAD)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Milestone-archive layout support** — `validate consistency`, `validate health`, and `find-phase` now scan `.planning/milestones/v*-phases/` directories in addition to the flat `.planning/phases/` layout. Projects that have graduated to milestone-archive layout no longer receive spurious W006 "Phase N in ROADMAP.md but no directory on disk" warnings for every active phase. (#3164)
|
||||||
|
|
||||||
### Feature
|
### Feature
|
||||||
|
|
||||||
- **Six namespace meta-skills with keyword-tag descriptions** — replace the flat 86-skill
|
- **Six namespace meta-skills with keyword-tag descriptions** — replace the flat 86-skill
|
||||||
|
|||||||
@@ -212,48 +212,64 @@ function cmdFindPhase(cwd, phase, raw) {
|
|||||||
error('phase identifier required');
|
error('phase identifier required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
const planBase = planningDir(cwd);
|
||||||
const normalized = normalizePhaseName(phase);
|
const normalized = normalizePhaseName(phase);
|
||||||
|
const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [], searched_directories: [] };
|
||||||
|
|
||||||
const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [] };
|
// Build candidate search dirs: flat layout first, then milestone-archive layout.
|
||||||
|
const searchDirs = [];
|
||||||
|
const flatPhasesDir = path.join(planBase, 'phases');
|
||||||
|
if (fs.existsSync(flatPhasesDir)) searchDirs.push(flatPhasesDir);
|
||||||
try {
|
try {
|
||||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
const milestonesDir = path.join(planBase, 'milestones');
|
||||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
|
const entries = fs.readdirSync(milestonesDir, { withFileTypes: true })
|
||||||
|
.filter(e => e.isDirectory() && /^v\d+.*-phases$/.test(e.name))
|
||||||
const match = dirs.find(d => phaseTokenMatches(d, normalized));
|
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||||
if (!match) {
|
for (const e of entries) {
|
||||||
output(notFound, raw, '');
|
searchDirs.push(path.join(milestonesDir, e.name));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
} catch { /* no milestones dir */ }
|
||||||
|
|
||||||
// Extract phase number — supports project-code-prefixed (CK-01-name), numeric (01-name), and custom IDs
|
notFound.searched_directories = searchDirs.map((searchDir) =>
|
||||||
const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
|
toPosixPath(path.join(path.relative(cwd, planBase), path.relative(planBase, searchDir))));
|
||||||
|| match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
|
||||||
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
|
||||||
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
|
||||||
|
|
||||||
const phaseDir = path.join(phasesDir, match);
|
for (const searchDir of searchDirs) {
|
||||||
const phaseFiles = fs.readdirSync(phaseDir);
|
try {
|
||||||
const plans = phaseFiles.filter(isCanonicalPlanFile).sort();
|
const entries = fs.readdirSync(searchDir, { withFileTypes: true });
|
||||||
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort();
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => comparePhaseNum(a, b));
|
||||||
// #2893 — same diagnostic as phase-plan-index for consistency.
|
|
||||||
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, plans);
|
|
||||||
|
|
||||||
const result = {
|
const match = dirs.find(d => phaseTokenMatches(d, normalized));
|
||||||
found: true,
|
if (!match) continue;
|
||||||
directory: toPosixPath(path.join(path.relative(cwd, planningDir(cwd)), 'phases', match)),
|
|
||||||
phase_number: phaseNumber,
|
|
||||||
phase_name: phaseName,
|
|
||||||
plans,
|
|
||||||
summaries,
|
|
||||||
};
|
|
||||||
if (planNamingWarning) result.warning = planNamingWarning;
|
|
||||||
|
|
||||||
output(result, raw, result.directory);
|
// Extract phase number — supports project-code-prefixed (CK-01-name), numeric (01-name), and custom IDs
|
||||||
} catch {
|
const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
|
||||||
output(notFound, raw, '');
|
|| match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
||||||
|
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
||||||
|
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
||||||
|
|
||||||
|
const phaseDir = path.join(searchDir, match);
|
||||||
|
const phaseFiles = fs.readdirSync(phaseDir);
|
||||||
|
const plans = phaseFiles.filter(isCanonicalPlanFile).sort();
|
||||||
|
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort();
|
||||||
|
// #2893 — same diagnostic as phase-plan-index for consistency.
|
||||||
|
const planNamingWarning = describeNonCanonicalPlans(phaseFiles, plans);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
found: true,
|
||||||
|
directory: toPosixPath(path.join(path.relative(cwd, planBase), path.relative(planBase, searchDir), match)),
|
||||||
|
phase_number: phaseNumber,
|
||||||
|
phase_name: phaseName,
|
||||||
|
plans,
|
||||||
|
summaries,
|
||||||
|
};
|
||||||
|
if (planNamingWarning) result.warning = planNamingWarning;
|
||||||
|
|
||||||
|
output(result, raw, result.directory);
|
||||||
|
return;
|
||||||
|
} catch { continue; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
output(notFound, raw, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractObjective(content) {
|
function extractObjective(content) {
|
||||||
|
|||||||
@@ -396,9 +396,76 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
|
|||||||
}, raw, verified === results.length ? 'valid' : 'invalid');
|
}, raw, verified === results.length ? 'valid' : 'invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i;
|
||||||
|
const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
|
||||||
|
|
||||||
|
function listMilestoneArchiveDirs(planBase) {
|
||||||
|
const milestonesDir = path.join(planBase, 'milestones');
|
||||||
|
try {
|
||||||
|
return fs.readdirSync(milestonesDir, { withFileTypes: true })
|
||||||
|
.filter((e) => e.isDirectory() && MILESTONE_ARCHIVE_DIR_RE.test(e.name))
|
||||||
|
.map((e) => path.join(milestonesDir, e.name))
|
||||||
|
.sort((a, b) => path.basename(a).localeCompare(path.basename(b), undefined, { numeric: true }));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveMilestoneArchiveDir(planBase) {
|
||||||
|
const archiveDirs = listMilestoneArchiveDirs(planBase);
|
||||||
|
if (archiveDirs.length === 0) return null;
|
||||||
|
|
||||||
|
// Prefer STATE.md milestone when it maps to an on-disk archive dir.
|
||||||
|
try {
|
||||||
|
const statePath = path.join(planBase, 'STATE.md');
|
||||||
|
if (fs.existsSync(statePath)) {
|
||||||
|
const state = fs.readFileSync(statePath, 'utf-8');
|
||||||
|
const m = state.match(/^\s*(?:\*\*)?milestone(?:\*\*)?:\s*([^\s\r\n#]+).*$/mi);
|
||||||
|
if (m && m[1]) {
|
||||||
|
const milestone = m[1].trim();
|
||||||
|
const candidate = path.join(planBase, 'milestones', `${milestone}-phases`);
|
||||||
|
if (archiveDirs.includes(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* intentionally empty */ }
|
||||||
|
|
||||||
|
// Fallback when STATE.md is absent/stale: highest (most recent) archive by version-ish name.
|
||||||
|
return archiveDirs[archiveDirs.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPhaseRoots(planBase) {
|
||||||
|
const roots = [];
|
||||||
|
const flatPhasesDir = path.join(planBase, 'phases');
|
||||||
|
if (fs.existsSync(flatPhasesDir)) roots.push(flatPhasesDir);
|
||||||
|
const activeArchive = getActiveMilestoneArchiveDir(planBase);
|
||||||
|
if (activeArchive) roots.push(activeArchive);
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a Set of phase numbers found on disk across active phase roots.
|
||||||
|
function collectDiskPhases(planBase) {
|
||||||
|
const diskPhases = new Set();
|
||||||
|
const phaseRoots = collectPhaseRoots(planBase);
|
||||||
|
const scanDir = (dir) => {
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
const m = e.name.match(PHASE_TOKEN_FROM_DIR_RE);
|
||||||
|
if (m) diskPhases.add(m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* dir absent */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const root of phaseRoots) scanDir(root);
|
||||||
|
|
||||||
|
return diskPhases;
|
||||||
|
}
|
||||||
|
|
||||||
function cmdValidateConsistency(cwd, raw) {
|
function cmdValidateConsistency(cwd, raw) {
|
||||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
const planBase = planningDir(cwd);
|
||||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
const roadmapPath = path.join(planBase, 'ROADMAP.md');
|
||||||
const errors = [];
|
const errors = [];
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
|
|
||||||
@@ -420,16 +487,8 @@ function cmdValidateConsistency(cwd, raw) {
|
|||||||
roadmapPhases.add(m[1]);
|
roadmapPhases.add(m[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get phases on disk
|
// Get phases on disk (flat layout + milestone-archive layout)
|
||||||
const diskPhases = new Set();
|
const diskPhases = collectDiskPhases(planBase);
|
||||||
try {
|
|
||||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
||||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
||||||
for (const dir of dirs) {
|
|
||||||
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
|
||||||
if (dm) diskPhases.add(dm[1]);
|
|
||||||
}
|
|
||||||
} catch { /* intentionally empty */ }
|
|
||||||
|
|
||||||
// Check: phases in ROADMAP but not on disk
|
// Check: phases in ROADMAP but not on disk
|
||||||
for (const p of roadmapPhases) {
|
for (const p of roadmapPhases) {
|
||||||
@@ -461,60 +520,53 @@ function cmdValidateConsistency(cwd, raw) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check: plan numbering within phases
|
const phaseRoots = collectPhaseRoots(planBase);
|
||||||
try {
|
for (const phaseRoot of phaseRoots) {
|
||||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
try {
|
||||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
const entries = fs.readdirSync(phaseRoot, { withFileTypes: true });
|
||||||
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
||||||
|
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
const phasePath = path.join(phaseRoot, dir);
|
||||||
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort();
|
const phaseLabel = path.relative(planBase, phasePath).replace(/\\/g, '/');
|
||||||
|
const phaseFiles = fs.readdirSync(phasePath);
|
||||||
|
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort();
|
||||||
|
|
||||||
// Extract plan numbers
|
// Extract plan numbers
|
||||||
const planNums = plans.map(p => {
|
const planNums = plans.map(p => {
|
||||||
const pm = p.match(/-(\d{2})-PLAN\.md$/);
|
const pm = p.match(/-(\d{2})-PLAN\.md$/);
|
||||||
return pm ? parseInt(pm[1], 10) : null;
|
return pm ? parseInt(pm[1], 10) : null;
|
||||||
}).filter(n => n !== null);
|
}).filter(n => n !== null);
|
||||||
|
|
||||||
for (let i = 1; i < planNums.length; i++) {
|
for (let i = 1; i < planNums.length; i++) {
|
||||||
if (planNums[i] !== planNums[i - 1] + 1) {
|
if (planNums[i] !== planNums[i - 1] + 1) {
|
||||||
warnings.push(`Gap in plan numbering in ${dir}: plan ${planNums[i - 1]} → ${planNums[i]}`);
|
warnings.push(`Gap in plan numbering in ${phaseLabel}: plan ${planNums[i - 1]} → ${planNums[i]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check: plans without summaries (completed plans)
|
||||||
|
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md'));
|
||||||
|
const planIds = new Set(plans.map(p => p.replace('-PLAN.md', '')));
|
||||||
|
const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', '')));
|
||||||
|
|
||||||
|
// Summary without matching plan is suspicious
|
||||||
|
for (const sid of summaryIds) {
|
||||||
|
if (!planIds.has(sid)) {
|
||||||
|
warnings.push(`Summary ${sid}-SUMMARY.md in ${phaseLabel} has no matching PLAN.md`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check: frontmatter in plans has required fields
|
||||||
|
for (const plan of plans) {
|
||||||
|
const content = fs.readFileSync(path.join(phasePath, plan), 'utf-8');
|
||||||
|
const fm = extractFrontmatter(content);
|
||||||
|
if (!fm.wave) {
|
||||||
|
warnings.push(`${phaseLabel}/${plan}: missing 'wave' in frontmatter`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch { /* intentionally empty */ }
|
||||||
// Check: plans without summaries (completed plans)
|
}
|
||||||
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md'));
|
|
||||||
const planIds = new Set(plans.map(p => p.replace('-PLAN.md', '')));
|
|
||||||
const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', '')));
|
|
||||||
|
|
||||||
// Summary without matching plan is suspicious
|
|
||||||
for (const sid of summaryIds) {
|
|
||||||
if (!planIds.has(sid)) {
|
|
||||||
warnings.push(`Summary ${sid}-SUMMARY.md in ${dir} has no matching PLAN.md`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* intentionally empty */ }
|
|
||||||
|
|
||||||
// Check: frontmatter in plans has required fields
|
|
||||||
try {
|
|
||||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
||||||
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
||||||
|
|
||||||
for (const dir of dirs) {
|
|
||||||
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
|
||||||
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md'));
|
|
||||||
|
|
||||||
for (const plan of plans) {
|
|
||||||
const content = fs.readFileSync(path.join(phasesDir, dir, plan), 'utf-8');
|
|
||||||
const fm = extractFrontmatter(content);
|
|
||||||
|
|
||||||
if (!fm.wave) {
|
|
||||||
warnings.push(`${dir}/${plan}: missing 'wave' in frontmatter`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* intentionally empty */ }
|
|
||||||
|
|
||||||
const passed = errors.length === 0;
|
const passed = errors.length === 0;
|
||||||
output({ passed, errors, warnings, warning_count: warnings.length }, raw, passed ? 'passed' : 'failed');
|
output({ passed, errors, warnings, warning_count: warnings.length }, raw, passed ? 'passed' : 'failed');
|
||||||
@@ -598,16 +650,7 @@ function cmdValidateHealth(cwd, options, raw) {
|
|||||||
// (not yet materialized on disk) and shipped-milestone history phases
|
// (not yet materialized on disk) and shipped-milestone history phases
|
||||||
// (archived / cleared off disk). Matching only against on-disk dirs
|
// (archived / cleared off disk). Matching only against on-disk dirs
|
||||||
// produces false W002 warnings in both cases.
|
// produces false W002 warnings in both cases.
|
||||||
const validPhases = new Set();
|
const validPhases = collectDiskPhases(planBase);
|
||||||
try {
|
|
||||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
||||||
for (const e of entries) {
|
|
||||||
if (e.isDirectory()) {
|
|
||||||
const m = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/);
|
|
||||||
if (m) validPhases.add(m[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch { /* intentionally empty */ }
|
|
||||||
// Union in every phase declared anywhere in ROADMAP.md (current + shipped + backlog).
|
// Union in every phase declared anywhere in ROADMAP.md (current + shipped + backlog).
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(roadmapPath)) {
|
if (fs.existsSync(roadmapPath)) {
|
||||||
@@ -765,11 +808,7 @@ function cmdValidateHealth(cwd, options, raw) {
|
|||||||
roadmapPhases.add(m[1]);
|
roadmapPhases.add(m[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const diskPhases = new Set();
|
const diskPhases = collectDiskPhases(planBase);
|
||||||
for (const e of phaseDirEntries) {
|
|
||||||
const dm = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
|
||||||
if (dm) diskPhases.add(dm[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a set of phases explicitly marked not-yet-started in the ROADMAP
|
// Build a set of phases explicitly marked not-yet-started in the ROADMAP
|
||||||
// summary list (- [ ] **Phase N:**). These phases are intentionally absent
|
// summary list (- [ ] **Phase N:**). These phases are intentionally absent
|
||||||
|
|||||||
262
tests/bug-3164-milestone-archive-layout.test.cjs
Normal file
262
tests/bug-3164-milestone-archive-layout.test.cjs
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
'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 } = 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 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(
|
||||||
|
warnings.some(w => w.includes('Gap in plan numbering in milestones/v1.7-phases/65-current')),
|
||||||
|
`Expected plan numbering warning from active archive root, got:\n ${warnings.join('\n ')}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
warnings.some(w => w.includes("milestones/v1.7-phases/65-current/65-01-PLAN.md: missing 'wave'"))
|
||||||
|
|| warnings.some(w => w.includes("milestones/v1.7-phases/65-current/65-03-PLAN.md: missing 'wave'")),
|
||||||
|
`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)}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user