feat(#39): milestone-prefixed phase IDs (M-NN convention) + migration tool + validation (#565)

* feat(#39): milestone-prefixed phase IDs (M-NN convention) + migration tool + validation

- Add getMilestoneFromPhaseId() / getPhaseDirFromPhaseId() helpers to core.cjs
- Fix isDirInMilestone to match M-NN-style dirs (02-01-setup) against M-NN ROADMAP headings
- Extend heading regex to tolerate [bracket-token] scope prefix on phase headings
- Add W021 validation rule for milestone prefix mismatch
- Add gsd-tools roadmap validate + roadmap upgrade --convention milestone-prefixed
- Add phase_id_convention config field (null default, backwards-compatible)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#39): address 4 Codex review findings in milestone-prefixed phase ID implementation

- getMilestoneFromPhaseId: tighten regex to require a digit after the hyphen (rejects '1-' and '1-abc')
- isDirInMilestone: use convention-aware regex — only capture M-NN segments when ROADMAP itself uses hyphenated phase IDs, preventing legacy dirs like '01-02-setup' from being misread as phase '1-02'
- checkW021: add UNPREFIXED_PHASE_RE path so unprefixed headings (### Phase 1:) also fire W021 when convention is milestone-prefixed
- roadmap-upgrade: remove isMigratedDirName dir-name check (false-positive for legacy dirs); config + ROADMAP heading checks at lines 194 and 212 are sufficient

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: update changeset pr reference to #565

* fix(#39): restore phaseDirNameRe 2-digit minimum; add roadmap-upgrade to inventory

- validate.cjs: \d{1,} → \d{2,} to keep single-digit prefix rejection per W005 contract
- docs/INVENTORY.md: 79 → 80, add roadmap-upgrade.cjs row
- docs/INVENTORY-MANIFEST.json: regenerated (roadmap-upgrade.cjs entry)

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:
Tom Boucher
2026-05-31 23:15:55 -04:00
committed by GitHub
parent 48c1827028
commit 0a12b06381
21 changed files with 1593 additions and 63 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 565
---
**Milestone-prefixed phase ID convention (`Phase M-NN`) with migration tool and validation** — introduces globally unique phase IDs within a project, resolving cross-session reference ambiguity behind bugs #3537/#3287/#3297/#3298. Adds `getMilestoneFromPhaseId()` / `getPhaseDirFromPhaseId()` helpers to `core.cjs`, fixes `isDirInMilestone` to correctly match `GSD-02-01-setup` style dirs, extends heading regex to tolerate `[bracket-token]` scope prefixes, adds W021 validation rule for milestone-prefix mismatch, adds `gsd-tools roadmap validate` and `roadmap upgrade --convention milestone-prefixed` commands, and introduces the `phase_id_convention` config field (`null` default, fully backwards-compatible). Closes #39.

View File

@@ -6,8 +6,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- Milestone-prefixed phase ID convention (M-NN) for globally unique phase IDs within a project (#39)
- `getMilestoneFromPhaseId()` and `getPhaseDirFromPhaseId()` helpers in core.cjs (#39)
- W021 validation rule: fires when a phase ID's integer prefix mismatches its enclosing milestone section (#39)
- `gsd-tools roadmap validate` subcommand for convention compliance checking (#39)
- `gsd-tools roadmap upgrade --convention milestone-prefixed` migration tool (dry-run by default, `--apply` to mutate) (#39)
- `phase_id_convention` config field (`null` | `'milestone-prefixed'` | `'free-form'`), defaults to `null` (legacy free-form, no breaking change) (#39)
### Fixed
- `isDirInMilestone` now correctly matches M-NN-style phase directories against milestone-prefixed ROADMAP headings (#39)
- `searchPhaseInContent` heading regex now tolerates `[bracket-token]` scope prefix (e.g., `### [GSD] Phase 2-01:`) (#39)
- **README version guidance now uses npm/package metadata as the source of truth** — README, localized READMEs, and the docs index no longer present archived release-note or canary-stream numbers as the current GSD Core package version. (#545)
## [1.2.0](https://www.npmjs.com/package/@opengsd/gsd-core/v/1.2.0) - 2026-05-31

View File

@@ -1369,6 +1369,40 @@ Threads are lightweight cross-session knowledge stores for work that spans multi
---
## Roadmap Management Commands
### `roadmap validate`
Validate ROADMAP.md for structural integrity, including milestone-prefix consistency.
**Prerequisites:** `.planning/ROADMAP.md` exists
**Produces:** Validation report; exits non-zero on any error or warning
```bash
node gsd-tools.cjs roadmap validate
```
---
### `roadmap upgrade --convention milestone-prefixed`
Migrate legacy `Phase N` IDs to the milestone-prefixed `Phase M-NN` convention.
| Flag | Required | Description |
|------|----------|-------------|
| `--convention milestone-prefixed` | Yes | Target convention to migrate to |
| `--apply` | No | Write changes to disk (default: dry-run only) |
**Prerequisites:** `.planning/ROADMAP.md` exists
**Produces:** Dry-run diff (default) or in-place ROADMAP.md rewrite (`--apply`)
```bash
node gsd-tools.cjs roadmap upgrade --convention milestone-prefixed # dry-run
node gsd-tools.cjs roadmap upgrade --convention milestone-prefixed --apply # apply
```
---
## State Management Commands
### `state validate`

View File

@@ -145,6 +145,7 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new
| `dynamic_routing.escalate_on_failure` | boolean | `true`, `false` | `true` | When `false`, escalation is disabled even if `enabled: true` — every attempt uses the default tier. Added in v1.40 |
| `dynamic_routing.max_escalations` | integer | `0`, `1`, `2`, … | `1` | Hard cap on retries per agent invocation. Beyond the cap the resolver returns the cap-tier model. Added in v1.40 |
| `project_code` | string | any short string | (none) | Prefix for phase directory names (e.g., `"ABC"` produces `ABC-01-setup/`). Added in v1.31 |
| `phase_id_convention` | enum | `"milestone-prefixed"`, `null` | `null` | Phase ID naming convention. `null` = legacy numeric IDs (`Phase 1`, `Phase 2`). `"milestone-prefixed"` = globally unique IDs that encode the enclosing milestone (`Phase 1-01`, `Phase 1-02`). Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate an existing ROADMAP.md. |
| `response_language` | string | language code | (none) | Language for agent responses (e.g., `"pt"`, `"ko"`, `"ja"`). Propagates to all spawned agents for cross-phase language consistency. Added in v1.32 |
| `context_window` | number | any integer | `200000` | Context window size in tokens. Set `1000000` for 1M-context models (e.g., `claude-opus-4-7[1m]`). Values `>= 500000` enable adaptive context enrichment (full-body reads of prior SUMMARY.md, deeper anti-pattern reads). Configured via `/gsd-config --advanced`. |
| `context_profile` | string | `dev`, `research`, `review` | (none) | Execution context preset that applies a pre-configured bundle of mode, model, and workflow settings for the current type of work. Added in v1.34 |

View File

@@ -1,5 +1,5 @@
{
"generated": "2026-05-31",
"generated": "2026-06-01",
"families": {
"agents": [
"gsd-advisor-researcher",
@@ -311,6 +311,7 @@
"prompt-budget.cjs",
"review-reviewer-selection.cjs",
"roadmap-command-router.cjs",
"roadmap-upgrade.cjs",
"roadmap.cjs",
"runtime-artifact-layout.cjs",
"runtime-homes.cjs",

View File

@@ -362,7 +362,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
---
## CLI Modules (79 shipped)
## CLI Modules (80 shipped)
Full listing: `get-shit-done/bin/lib/*.cjs`.
@@ -419,6 +419,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`.
| `prompt-budget.cjs` | Pure token-budget accounting for review prompts — estimates tokens, applies deterministic trim priority (head-shrink PROJECT.md, proportional plan truncation, drop context/research/requirements, hard-fail guard), returns structured metadata for `review.max_prompt_tokens` (#3081) |
| `review-reviewer-selection.cjs` | Reviewer selection/normalization helpers for `/gsd-review` default reviewer policy and precedence |
| `roadmap-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools roadmap` |
| `roadmap-upgrade.cjs` | Migration tool for converting legacy `Phase N` entries to milestone-prefixed `Phase M-NN` convention; `computeMigrationPlan` + `applyMigration` with dry-run default and atomic rollback |
| `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress |
| `runtime-artifact-layout.cjs` | Runtime artifact layout module — resolves the artifact directory shapes (commands, agents, skills) for each supported runtime; single source of truth for per-runtime artifact placement (#3663) |
| `runtime-name-policy.cjs` | Runtime name normalization policy — canonical token sanitization for runtime identifiers used in path construction and display |

View File

@@ -46,6 +46,8 @@
* roadmap analyze Full roadmap parse with disk status
* roadmap update-plan-progress <N> Update progress table row from disk (PLAN vs SUMMARY counts)
* roadmap annotate-dependencies <N> Add wave dependency notes + cross-cutting constraints to ROADMAP.md
* roadmap validate Validate phase ID convention compliance
* roadmap upgrade [--apply] --convention milestone-prefixed Migrate phase IDs to M-NN convention
*
* Requirements Operations:
* requirements mark-complete <ids> Mark requirement IDs as complete in REQUIREMENTS.md

View File

@@ -540,6 +540,22 @@ const ROADMAP_COMMAND_ALIASES = [
],
"subcommand": "annotate-dependencies",
"mutation": true
},
{
"canonical": "roadmap.validate",
"aliases": [
"roadmap validate"
],
"subcommand": "validate",
"mutation": false
},
{
"canonical": "roadmap.upgrade",
"aliases": [
"roadmap upgrade"
],
"subcommand": "upgrade",
"mutation": true
}
];

View File

@@ -4,7 +4,7 @@
const fs = require('fs');
const path = require('path');
const { execGit, platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
const { loadConfig, isGitIgnored, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal, extractOneLinerFromBody, getRoadmapPhaseInternal } = require('./core.cjs');
const { loadConfig, isGitIgnored, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal, extractOneLinerFromBody, getRoadmapPhaseInternal, extractPhaseToken } = require('./core.cjs');
const { renderEffortForRuntime, RUNTIMES_WITH_FAST_MODE } = require('./model-catalog.cjs');
const { planningDir, planningPaths } = require('./planning-workspace.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
@@ -994,7 +994,9 @@ function cmdStats(cwd, format, raw) {
const roadmapRaw = platformReadSync(roadmapPath);
if (roadmapRaw === null) throw new Error('roadmap missing');
const roadmapContent = extractCurrentMilestone(roadmapRaw, cwd);
const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
// Matches both plain numeric (Phase 1:) and milestone-prefixed (Phase 2-01:) headings.
// Also tolerates optional [bracket-token] scope prefix on phase headings.
const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:\s*([^\n]+)/gi;
let match;
while ((match = headingPattern.exec(roadmapContent)) !== null) {
const key = normalizePhaseName(match[1]);
@@ -1017,9 +1019,12 @@ function cmdStats(cwd, format, raw) {
.sort((a, b) => comparePhaseNum(a, b));
for (const dir of dirs) {
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
const phaseNum = dm ? dm[1] : dir;
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
// Use extractPhaseToken to correctly parse M-NN-style and code-prefixed dir names.
const phaseToken = extractPhaseToken(dir);
const phaseNum = phaseToken || dir;
// phaseName is everything after the token (strip leading '-')
const afterToken = dir.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
const phaseName = afterToken ? afterToken.replace(/-/g, ' ') : '';
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;

View File

@@ -668,6 +668,18 @@ function normalizePhaseName(phase) {
const str = String(phase);
// Strip optional project_code prefix (e.g., 'CK-01' → '01')
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, '');
// Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition).
// Examples: '2-01', '02-01', '2-4-1', '02-04-01'.
// Must be tested BEFORE the plain numeric path so '2-01' → '02-01', not '02'.
// Pattern: at least two dash-separated all-digit segments (letter/decimal suffix on last).
const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
if (milestoneMatch) {
const major = milestoneMatch[1].padStart(2, '0');
// Each sub-segment gets zero-padded to at least 2 digits.
const subSegments = milestoneMatch[2].slice(1).split('-').map(s => s.padStart(2, '0'));
const suffix = milestoneMatch[3] || '';
return `${major}-${subSegments.join('-')}${suffix}`;
}
// Standard numeric phases: 1, 01, 12A, 12.1
const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
if (match) {
@@ -683,6 +695,32 @@ function normalizePhaseName(phase) {
return str;
}
function getMilestoneFromPhaseId(phaseId) {
const str = String(phaseId);
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
const m = stripped.match(/^0*(\d+)-\d/);
if (!m) return null;
const major = parseInt(m[1], 10);
if (major === 0 || major === 999) return null;
return `v${major}.0`;
}
function getPhaseDirFromPhaseId(phaseId, phaseName, projectCode) {
const str = String(phaseId);
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
const m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/);
if (!m) return null;
const milestone = String(parseInt(m[1], 10)).padStart(2, '0');
const subParts = m[2].split('-').map(p => String(parseInt(p, 10)).padStart(2, '0'));
const sub = subParts.join('-');
const slug = phaseName
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
: '';
const parts = [milestone, sub, slug].filter(Boolean);
const base = parts.join('-');
return projectCode ? `${projectCode}-${base}` : base;
}
/**
* Render a regex source fragment matching a phase number against ROADMAP/STATE
* prose regardless of zero-padding on either side. Skills pass the resolved
@@ -699,6 +737,24 @@ function normalizePhaseName(phase) {
*/
function phaseMarkdownRegexSource(phaseNum) {
const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
// Milestone-prefixed IDs: M-NN or M-N-N (deep). Each numeric segment is padding-tolerant.
// Pattern: one or more dash-separated all-digit groups (last may have letter/decimal suffix).
const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
if (milestoneSegments && milestoneSegments[2]) {
// Has at least one dash-separated segment — treat as milestone-prefixed
const majorUnpadded = milestoneSegments[1].replace(/^0+/, '') || '0';
const subParts = milestoneSegments[2].slice(1).split('-'); // drop leading '-'
const subFragments = subParts.map(s => {
const unpadded = s.replace(/^0+/, '') || '0';
return `0*${escapeRegex(unpadded)}`;
});
const suffix = milestoneSegments[3] || '';
const suffixFragment = suffix ? escapeRegex(suffix) : '';
return `0*${escapeRegex(majorUnpadded)}-${subFragments.join('-')}${suffixFragment}`;
}
// Plain numeric phase: 1, 01, 12A, 12.1
const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i);
if (!match) return escapeRegex(phaseNum);
@@ -729,8 +785,35 @@ function phaseMarkdownRegexSourceExact(phaseNum) {
function comparePhaseNum(a, b) {
// Strip optional project_code prefix before comparing (e.g., 'CK-01-name' → '01-name')
const sa = String(a).replace(/^[A-Z]{1,6}-/, '');
const sb = String(b).replace(/^[A-Z]{1,6}-/, '');
const sa = String(a).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
const sb = String(b).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
// Milestone-prefixed IDs: one or more dash-separated all-digit segments.
// e.g. '02-10', '2-01', '02-04-01'. Compare segment by segment numerically.
// A string matches this form when it starts with digits and has at least one '-digit' group.
const milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
if (milestoneA && milestoneB) {
const segsA = [parseInt(milestoneA[1], 10), ...milestoneA[2].slice(1).split('-').map(s => parseInt(s, 10))];
const segsB = [parseInt(milestoneB[1], 10), ...milestoneB[2].slice(1).split('-').map(s => parseInt(s, 10))];
const maxSegs = Math.max(segsA.length, segsB.length);
for (let i = 0; i < maxSegs; i++) {
const av = segsA[i] !== undefined ? segsA[i] : 0;
const bv = segsB[i] !== undefined ? segsB[i] : 0;
if (av !== bv) return av - bv;
}
// Segments equal — compare any trailing letter/decimal suffix
const sufA = milestoneA[3] || '';
const sufB = milestoneB[3] || '';
if (sufA !== sufB) return sufA < sufB ? -1 : 1;
return 0;
}
// If one is milestone-prefixed and the other is not, milestone-prefixed sorts first
// (they come from different conventions; preserve caller's intent by string comparison).
if (milestoneA || milestoneB) return String(a).localeCompare(String(b));
const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
// If either is non-numeric (custom ID), fall back to string comparison
@@ -761,20 +844,59 @@ function comparePhaseNum(a, b) {
/**
* Extract the phase token from a directory name.
* Supports: '01-name', '1009A-name', '999.6-name', 'CK-01-name', 'PROJ-42-name'.
* Returns the token portion (e.g. '01', '1009A', '999.6', 'PROJ-42') or the full name if no separator.
* A token is the leading all-numeric (or project-code-prefixed) run of dash-separated
* segments, up to but not including the first segment that starts with a letter after the
* optional code prefix. The last numeric segment may carry a letter suffix (e.g. 12A) or
* decimal suffix (e.g. 999.6).
*
* Examples:
* '01-name' → '01'
* '02-01-setup' → '02-01' (milestone-prefixed 2-segment)
* '02-04-01-deep' → '02-04-01' (deep 3-segment)
* 'CK-01-name' → 'CK-01' (project-code-prefixed)
* 'GSD-02-01-setup' → 'GSD-02-01' (code-prefixed milestone)
* 'GSD-02-04-01-deep' → 'GSD-02-04-01'
* '1009A-name' → '1009A'
* '999.6-name' → '999.6'
* 'PROJ-42-name' → 'PROJ-42' (custom ID)
*/
function extractPhaseToken(dirName) {
// Try project-code-prefixed numeric: CK-01-name → CK-01, CK-01A.2-name → CK-01A.2
const codePrefixed = dirName.match(/^([A-Z]{1,6}-\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
if (codePrefixed) return codePrefixed[1];
// Try plain numeric: 01-name, 1009A-name, 999.6-name
const numeric = dirName.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
if (numeric) return numeric[1];
// Custom IDs: PROJ-42-name → everything before the last segment that looks like a name
const custom = dirName.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)(?:-[a-z]|$)/i);
if (custom) return custom[1];
return dirName;
// Optional project-code prefix: 1–6 uppercase letters followed by a digit segment.
const codePrefixMatch = dirName.match(/^([A-Z]{1,6})-(\d.*)/i);
let prefix = '';
let rest = dirName;
if (codePrefixMatch) {
// Distinguish code prefix (e.g. GSD-, CK-) from purely numeric-looking start.
// The prefix must be all-uppercase-letter (already guaranteed by [A-Z]{1,6}) and
// the first char after '-' must be a digit so we don't swallow PROJ-42-name prematurely.
prefix = codePrefixMatch[1] + '-';
rest = codePrefixMatch[2];
}
// Greedily consume all leading all-digit segments (possibly with A-Z letter or .N suffix on the last).
// Stop when a segment starts with a letter (that is not a continuation of the last digit segment).
const segments = rest.split('-');
const tokenSegments = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (/^\d/.test(seg)) {
// Numeric segment (possibly trailing letter or .N suffix on last) — always include
tokenSegments.push(seg);
} else {
// First letter-start segment after digits → name portion starts here
break;
}
}
if (tokenSegments.length === 0) {
// No leading numeric segment — could be a custom ID like PROJ-42
// If we stripped a code prefix, return the full original (prefix stripped the code but rest is numeric handled above)
// For purely letter-start directory with no code prefix (shouldn't normally happen), return as-is
return dirName;
}
// The last numeric segment may have a letter suffix (1009A) or decimal (.6) already included.
return prefix + tokenSegments.join('-');
}
/**
@@ -811,13 +933,13 @@ function searchPhaseInDir(baseDir, relBase, normalized) {
const match = dirs.find(d => phaseTokenMatches(d, normalized));
if (!match) return null;
// Extract phase number and name — supports numeric (01-name), project-code-prefixed (CK-01-name), and custom (PROJ-42-name)
const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
|| match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
|| match.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-(.+)/i)
|| [null, match, null];
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
// Extract phase number and name using extractPhaseToken for correctness with all ID forms
// including deep milestone-prefixed (02-04-01-deep → 02-04-01 / deep) and code-prefixed.
const phaseToken = extractPhaseToken(match);
const phaseNumber = phaseToken || normalized;
// phase_name is everything after the token (strip leading '-')
const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
const phaseName = afterToken || null;
const phaseDir = path.join(baseDir, match);
const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
const plans = unsortedPlans.sort();
@@ -1140,8 +1262,9 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
// #3537: route through canonical padding-tolerant fragment. The prior
// hand-rolled `isNumeric` branch only stripped padding on integer-only
// ids and missed decimal padding (`02.7` against `Phase 2.7:` headings).
// Also tolerate optional [bracket-token] scope prefix on phase headings.
const phasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`,
`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`,
'i'
);
const headerMatch = content.match(phasePattern);
@@ -1150,7 +1273,8 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
const phaseName = headerMatch[1].trim();
const headerIndex = headerMatch.index;
const restOfContent = content.slice(headerIndex);
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+[\w]/i);
// Boundary: next phase heading — also matches bracket-prefixed form.
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i);
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
const section = content.slice(headerIndex, sectionEnd).trim();
@@ -1926,6 +2050,18 @@ function getMilestonePhaseFilter(cwd, versionOverride) {
if (roadmapContent === null) throw new Error('missing');
let roadmap = extractCurrentMilestone(roadmapContent, cwd);
// Emit a deprecation warning for "free-form" roadmaps: those that have
// Phase headings but no versioned milestone sections (## vX.Y / ## Roadmap vX.Y).
// This is non-fatal — the roadmap continues to work via legacy behaviour.
const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
if (!hasVersionedMilestonesGlobal && hasPhaseHeadings) {
console.warn(
'[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' +
'Set phase_id_convention in config.json to suppress this warning.'
);
}
if (versionOverride) {
const escapedVersion = escapeRegex(versionOverride);
// Exclude phase headings (e.g. "### Phase 1: v1.3 migration") that mention
@@ -2002,8 +2138,9 @@ function getMilestonePhaseFilter(cwd, versionOverride) {
}
}
// Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:)
const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
// Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:).
// Also tolerate optional [bracket-token] scope prefix (e.g., ### [GSD] Phase 2-01:).
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
let m;
while ((m = phasePattern.exec(roadmap)) !== null) {
milestonePhaseNums.add(m[1]);
@@ -2018,13 +2155,25 @@ function getMilestonePhaseFilter(cwd, versionOverride) {
}
const normalized = new Set(
[...milestonePhaseNums].map(n => (n.replace(/^0+(?=\d)/, '') || '0').toLowerCase())
[...milestonePhaseNums].map(n => n.split('-').map(seg => (seg.replace(/^0+(?=\d)/, '') || '0')).join('-').toLowerCase())
);
function normalizePhaseIdSegments(id) {
return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-');
}
// Only capture hyphenated M-NN segments when the ROADMAP itself uses that convention.
// Legacy ROADMAPs with phase IDs like '1' must use the simple first-segment regex or
// a legacy dir like '01-02-setup' (phase 1, slug '02-setup') would match as '1-02'.
const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
const numericRe = roadmapUsesHyphenedIds
? /^0*(\d+(?:-0*\d+)*[A-Za-z]?(?:\.\d+)*)/
: /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
function isDirInMilestone(dirName) {
// Try numeric match first
const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
if (m && normalized.has(m[1].toLowerCase())) return true;
const m = dirName.match(numericRe);
if (m && normalized.has(normalizePhaseIdSegments(m[1]).toLowerCase())) return true;
// Try custom ID match (e.g. PROJ-42-description → PROJ-42)
const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true;
@@ -2037,8 +2186,8 @@ function getMilestonePhaseFilter(cwd, versionOverride) {
// milestone is keyed on the bare numeric form.
const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
if (stripped !== dirName) {
const sm = stripped.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
if (sm && normalized.has(sm[1].toLowerCase())) return true;
const sm = stripped.match(numericRe);
if (sm && normalized.has(normalizePhaseIdSegments(sm[1]).toLowerCase())) return true;
}
return false;
}
@@ -2127,6 +2276,8 @@ module.exports = {
isGitIgnored,
escapeRegex,
normalizePhaseName,
getMilestoneFromPhaseId,
getPhaseDirFromPhaseId,
phaseMarkdownRegexSource,
phaseMarkdownRegexSourceExact,
comparePhaseNum,

View File

@@ -1,7 +1,91 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { ROADMAP_SUBCOMMANDS } = require('./command-aliases.cjs');
const { routeCjsCommandFamily } = require('./cjs-command-router-adapter.cjs');
const roadmapUpgrade = require('./roadmap-upgrade.cjs');
const { planningDir } = require('./planning-workspace.cjs');
const { loadConfig } = require('./core.cjs');
/**
* Check each phase entry in a milestone-prefixed ROADMAP.md for W021 violations.
*
* W021: a phase whose ID integer prefix does not match its enclosing milestone's
* major version number.
*
* Sentinel milestones (0 = pre-milestone, 999 = backlog) are exempt.
*
* @param {string} content - ROADMAP.md content
* @returns {Array<{code:'W021', message:string}>}
*/
function checkW021(content) {
const warnings = [];
// Sentinel milestone integers exempt from W021
const SENTINELS = new Set([0, 999]);
const MIGRATION_CMD = 'gsd-tools roadmap upgrade --convention milestone-prefixed';
// Milestone section heading: ## [GSD] v2.0 — Label OR ## v2.0: Label OR ## Roadmap v2.0
// OR ## ✅ v2.0 OR ## 🚧 v2.0 (emoji-prefixed variants used by roadmap templates)
// Capture the major integer.
const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu;
// Migrated phase heading: ### Phase M-NN: Name (M-NN or unpadded M-N form)
const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*\s*:/i;
// Unprefixed legacy phase heading: ### Phase N: Name (no hyphen sub-index)
const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)\s*:/i;
let currentMilestoneMajor = null;
const lines = content.split('\n');
for (const line of lines) {
const milestoneMatch = line.match(MILESTONE_RE);
if (milestoneMatch) {
currentMilestoneMajor = parseInt(milestoneMatch[1], 10);
continue;
}
const phaseMatch = line.match(PHASE_RE);
if (phaseMatch) {
const phaseMajor = parseInt(phaseMatch[1], 10);
if (SENTINELS.has(phaseMajor)) continue; // exempt
if (currentMilestoneMajor !== null && phaseMajor !== currentMilestoneMajor) {
const phaseId = `${phaseMatch[1]}-${phaseMatch[2]}`;
warnings.push({
code: 'W021',
message:
`Phase ID prefix mismatch: phase "${phaseId}" is listed under v${currentMilestoneMajor}.x ` +
`but its prefix (${phaseMajor}) does not match. ` +
`Run \`${MIGRATION_CMD}\` to fix.`,
});
}
continue;
}
// When the convention is active, an unprefixed heading (### Phase 1:) is itself a W021
// violation — it is missing the required M-NN prefix entirely.
const unprefixedMatch = line.match(UNPREFIXED_PHASE_RE);
if (unprefixedMatch && currentMilestoneMajor !== null) {
const rawId = unprefixedMatch[1];
// Skip if it matched PHASE_RE already (it didn't reach here in that case)
// Also skip if it looks like a bare integer whose prefix matches the section
// — those pass; only non-matching or non-prefixed forms fire W021.
const numericMajor = parseInt(rawId, 10);
if (!SENTINELS.has(numericMajor)) {
warnings.push({
code: 'W021',
message:
`Phase ID "${rawId}" is not in M-NN form (milestone-prefixed convention is active). ` +
`Run \`${MIGRATION_CMD}\` to migrate.`,
});
}
}
}
return warnings;
}
/**
* Manifest-backed roadmap subcommand router.
@@ -19,6 +103,56 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }) {
analyze: () => roadmap.cmdRoadmapAnalyze(cwd, raw),
'update-plan-progress': () => roadmap.cmdRoadmapUpdatePlanProgress(cwd, args[2], raw),
'annotate-dependencies': () => roadmap.cmdRoadmapAnnotateDependencies(cwd, args[2], raw),
'validate': () => {
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
let roadmapContent = '';
try {
roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
} catch {
// ROADMAP.md missing — return empty warnings
}
// W021 only fires when phase_id_convention is explicitly 'milestone-prefixed'.
// Authoritative source: .planning/config.json (set by the upgrade command).
// Fallback: ROADMAP.md frontmatter (for projects that set the field there directly).
let convention;
try {
const cfg = loadConfig(cwd);
convention = cfg.phase_id_convention;
} catch {
convention = undefined;
}
if (convention === undefined || convention === null) {
// Fallback: read from ROADMAP.md frontmatter
const fmMatch = roadmapContent.match(/^---\r?\n([\s\S]+?)\r?\n---/);
if (fmMatch) {
const kvMatch = fmMatch[1].match(/^phase_id_convention:\s*(.*)$/m);
if (kvMatch) {
const val = kvMatch[1].trim();
if (val !== 'null' && val !== '') {
convention = val.replace(/^["']|["']$/g, '');
}
}
}
}
const warnings = (convention === 'milestone-prefixed')
? checkW021(roadmapContent)
: [];
const result = { warnings };
if (raw) process.stdout.write(JSON.stringify(result));
else process.stdout.write(JSON.stringify(result, null, 2));
},
'upgrade': () => {
const dryRun = !args.includes('--apply');
const convention = args.find((a, i) => args[i-1] === '--convention') || 'milestone-prefixed';
if (convention !== 'milestone-prefixed') {
process.stderr.write('Only --convention milestone-prefixed is supported\n');
process.exit(1);
}
const plan = roadmapUpgrade.computeMigrationPlan(cwd);
roadmapUpgrade.applyMigration(cwd, plan, { dryRun });
},
},
});
}

View File

@@ -0,0 +1,569 @@
'use strict';
/**
* Roadmap Upgrade — Migration tool for converting legacy 'Phase N' phase IDs
* to milestone-prefixed 'Phase M-NN' form.
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { planningDir } = require('./planning-workspace.cjs');
// ─── Regex helpers ────────────────────────────────────────────────────────────
// Matches legacy phase headings: ### Phase N: Name (also decimal: Phase 2.1:)
// Captures: (hashes)(spaces)(phase-number)(rest-of-line)
const LEGACY_PHASE_HEADING_RE = /^(#{2,4})\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:(.*)/i;
// Matches already-migrated phase headings: ### Phase M-NN: Name
const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+\d+-\d{2}\s*:/i;
// Matches milestone section headings: ## v1.0, ## Roadmap v2.0, ## ✅ v1.0, ## [GSD] v1.0, etc.
// The optional bracket-token prefix (e.g., [GSD]) must be tested before the emoji group.
const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu;
// Matches checklist phase references: - [ ] **Phase N:** or - [x] **Phase N:** (also decimal)
const CHECKLIST_PHASE_RE = /^(\s*-\s*\[[ x]\]\s*\*{0,2})Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
// ─── Pure computation helpers ─────────────────────────────────────────────────
/**
* Parse the ROADMAP.md content and build a list of phase entries with their
* enclosing milestone major version.
*
* Returns an array of:
* { lineIndex, headingLine, milestoneInt, legacyPhaseNum, phaseName }
*/
function parseRoadmapPhases(lines) {
const results = [];
let currentMilestoneInt = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const milestoneMatch = line.match(MILESTONE_HEADING_RE);
if (milestoneMatch) {
currentMilestoneInt = parseInt(milestoneMatch[1], 10);
continue;
}
if (MIGRATED_PHASE_HEADING_RE.test(line)) {
// Already-migrated heading found — caller will detect this
results.push({ lineIndex: i, headingLine: line, alreadyMigrated: true });
continue;
}
const phaseMatch = line.match(LEGACY_PHASE_HEADING_RE);
if (phaseMatch) {
results.push({
lineIndex: i,
headingLine: line,
milestoneInt: currentMilestoneInt,
legacyPhaseNum: phaseMatch[2],
phaseName: phaseMatch[3].trim(),
hashes: phaseMatch[1],
alreadyMigrated: false,
});
}
}
return results;
}
/**
* Assign sub-indices within each milestone, building a per-entry mapping.
*
* Input: array from parseRoadmapPhases (non-migrated entries only).
* Returns: Map<lineIndex, { newId, milestoneInt, subIndex }>
*
* Keyed by `lineIndex` (the unique position of the heading line in ROADMAP.md)
* so that identical legacy phase numbers in different milestones (e.g., two
* `Phase 1` headings in v1.0 and v2.0) each get their own correct M-NN ID
* instead of the later milestone's mapping overwriting the earlier one.
*
* Sub-indices are 1-based and sequential within each milestone.
*/
function assignSubIndices(phaseEntries) {
const milestoneCounters = new Map(); // milestoneInt → counter
const mapping = new Map(); // lineIndex → { newId, milestoneInt, subIndex }
for (const entry of phaseEntries) {
if (entry.alreadyMigrated) continue;
const m = entry.milestoneInt;
if (m === null || m === undefined) continue;
const counter = (milestoneCounters.get(m) || 0) + 1;
milestoneCounters.set(m, counter);
const subIndex = String(counter).padStart(2, '0');
const newId = `${m}-${subIndex}`;
mapping.set(entry.lineIndex, { newId, milestoneInt: m, subIndex: counter, legacyPhaseNum: entry.legacyPhaseNum });
}
return mapping;
}
/**
* Read a phase directory name and return its numeric token (stripping project_code prefix).
* e.g. "GSD-01-setup" → "01", "01-setup" → "01", "02-implement" → "02", "02.1-hotfix" → "02.1"
*/
function extractPhaseNumFromDir(dirName) {
// Strip optional project_code prefix: "GSD-01-setup" → "01-setup"
const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
// Matches: digits + optional letter + optional decimal suffix, followed by '-' or end.
// e.g. "02.1-hotfix" → "02.1", "01-setup" → "01"
const m = stripped.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
return m ? m[1] : null;
}
/**
* Build the new directory name from old name and new phase ID.
* old: "01-setup" newId: "1-02" projectCode: "GSD" → "GSD-01-02-setup"
* old: "01-setup" newId: "1-02" projectCode: null → "01-02-setup"
* old: "GSD-01-setup" newId: "1-02" projectCode: "GSD" → "GSD-01-02-setup"
*/
function buildNewDirName(oldDirName, newId, projectCode) {
// Strip existing project_code prefix
const stripped = oldDirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
// Extract slug: everything after "NN-" (the old phase num, including decimal like 02.1)
const slugMatch = stripped.match(/^\d+[A-Z]?(?:\.\d+)*-(.*)/i);
const slug = slugMatch ? slugMatch[1] : stripped;
// Build M-NN prefix (zero-pad both parts)
const [milestoneStr, subStr] = newId.split('-');
const milestoneInt = parseInt(milestoneStr, 10);
const subIndex = subStr;
const paddedMilestone = String(milestoneInt).padStart(2, '0');
const newBase = slug ? `${paddedMilestone}-${subIndex}-${slug}` : `${paddedMilestone}-${subIndex}`;
return projectCode ? `${projectCode}-${newBase}` : newBase;
}
/**
* Read project_code from config.json if present.
*/
function readProjectCode(configPath) {
try {
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw);
return parsed.project_code || null;
} catch {
return null;
}
}
// ─── computeMigrationPlan ─────────────────────────────────────────────────────
/**
* Compute a migration plan without touching the filesystem.
*
* @param {string} cwd
* @param {object} [options]
* @returns {{
* alreadyMigrated: boolean,
* phases: Array<{oldId, newId, oldDir, newDir}>,
* roadmapEdits: Array<{lineIndex, from, to}>,
* crossRefEdits: Array<{file, from, to}>,
* }}
*/
function computeMigrationPlan(cwd, options = {}) {
const pDir = planningDir(cwd);
const roadmapPath = path.join(pDir, 'ROADMAP.md');
const configPath = path.join(pDir, 'config.json');
const phasesDir = path.join(pDir, 'phases');
// ── Check config for existing convention ─────────────────────────────────
let configData = {};
try {
configData = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch { /* config may not exist */ }
if (configData.phase_id_convention === 'milestone-prefixed') {
return { alreadyMigrated: true, phases: [], roadmapEdits: [], crossRefEdits: [] };
}
const projectCode = configData.project_code || null;
// ── Read ROADMAP.md ───────────────────────────────────────────────────────
let roadmapContent = '';
try {
roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
} catch {
throw new Error(`ROADMAP.md not found at ${roadmapPath}`);
}
const lines = roadmapContent.split('\n');
const parsedPhases = parseRoadmapPhases(lines);
// Check for any already-migrated headings
const hasAnyMigrated = parsedPhases.some(e => e.alreadyMigrated);
if (hasAnyMigrated) {
return { alreadyMigrated: true, phases: [], roadmapEdits: [], crossRefEdits: [] };
}
const legacyPhases = parsedPhases.filter(e => !e.alreadyMigrated);
const idMapping = assignSubIndices(legacyPhases);
// Secondary lookup: (milestoneInt, normalizedLegacyNum) → newId
// Used for directory renames and checklist rewrites where line position is unknown.
// For simplicity, each milestone gets its own Map from legacy num → newId.
const milestoneIdMap = new Map(); // milestoneInt → Map<normalizedLegacyNum, newId>
for (const [, entry] of idMapping) {
if (!milestoneIdMap.has(entry.milestoneInt)) {
milestoneIdMap.set(entry.milestoneInt, new Map());
}
const mMap = milestoneIdMap.get(entry.milestoneInt);
const legacyNum = entry.legacyPhaseNum;
// Register integer forms (covers plain numeric and letter-suffix IDs)
const intPart = parseInt(legacyNum, 10);
const paddedLegacy = String(intPart).padStart(2, '0');
const unpaddedLegacy = String(intPart);
mMap.set(paddedLegacy, entry.newId);
mMap.set(unpaddedLegacy, entry.newId);
// Also register the original form and padded-integer+decimal form
// so decimal IDs like "2.1" / "02.1" round-trip correctly.
mMap.set(legacyNum, entry.newId);
const dotIdx = legacyNum.indexOf('.');
if (dotIdx !== -1) {
const decimalSuffix = legacyNum.slice(dotIdx); // e.g. ".1"
mMap.set(paddedLegacy + decimalSuffix, entry.newId);
mMap.set(unpaddedLegacy + decimalSuffix, entry.newId);
}
}
// ── Read existing phase directories ───────────────────────────────────────
let existingDirs = [];
try {
existingDirs = fs.readdirSync(phasesDir).filter(d => {
try {
return fs.statSync(path.join(phasesDir, d)).isDirectory();
} catch { return false; }
});
} catch { /* phases dir may not exist */ }
// ── Build phase rename pairs ───────────────────────────────────────────────
// Flat ordered list of (legacyPhaseNum, newId) in ROADMAP order, for dir matching.
const orderedMappings = [...idMapping.values()].map(e => ({
legacyPhaseNum: e.legacyPhaseNum,
newId: e.newId,
milestoneInt: e.milestoneInt,
_used: false,
}));
// Note: if the same legacy phase number appears in multiple milestones (the exact legacy
// ambiguity this tool is designed to resolve), directories are matched in ROADMAP document
// order — the first ROADMAP occurrence of a given number claims the first matching disk dir.
// This is the only unambiguous assignment strategy for flat dirs that carry no milestone
// context. The dry-run output shows the complete rename plan so users can review before
// applying with --apply.
const phases = [];
for (const dirName of existingDirs) {
const phaseNum = extractPhaseNumFromDir(dirName);
if (!phaseNum) continue;
const intPart = parseInt(phaseNum, 10);
const paddedPhaseNum = String(intPart).padStart(2, '0');
const unpaddedPhaseNum = String(intPart);
// For decimal IDs like "02.1", also try "2.1"
const dotIdx = phaseNum.indexOf('.');
const decimalUnpadded = dotIdx !== -1 ? unpaddedPhaseNum + phaseNum.slice(dotIdx) : null;
// Find the first unused mapping whose legacy number matches (exact, padded, unpadded, or decimal)
const found = orderedMappings.find(m => !m._used && (
m.legacyPhaseNum === phaseNum ||
m.legacyPhaseNum === paddedPhaseNum ||
m.legacyPhaseNum === unpaddedPhaseNum ||
(decimalUnpadded && m.legacyPhaseNum === decimalUnpadded)
));
if (!found) continue;
found._used = true;
const newDirName = buildNewDirName(dirName, found.newId, projectCode);
if (newDirName !== dirName) {
phases.push({
oldId: phaseNum,
newId: found.newId,
oldDir: dirName,
newDir: newDirName,
});
}
}
// ── Build ROADMAP.md line edits ────────────────────────────────────────────
const roadmapEdits = [];
for (const entry of legacyPhases) {
// Use lineIndex as the canonical key (not legacyPhaseNum, which may collide across milestones)
const mapping = idMapping.get(entry.lineIndex);
if (!mapping) continue;
// Rewrite heading line: "### Phase N: Name" → "### Phase M-NN: Name"
const oldLine = lines[entry.lineIndex];
const newLine = oldLine.replace(
/^(#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*:)/i,
`$1${mapping.newId}$2`
);
if (newLine !== oldLine) {
roadmapEdits.push({ lineIndex: entry.lineIndex, from: oldLine, to: newLine });
}
}
// Rewrite checklist lines in ROADMAP.md — use milestone context to resolve collisions.
let currentChecklistMilestone = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Track enclosing milestone section for context-aware lookup
const milestoneHeadingMatch = line.match(MILESTONE_HEADING_RE);
if (milestoneHeadingMatch) {
currentChecklistMilestone = parseInt(milestoneHeadingMatch[1], 10);
}
// Already in roadmapEdits? skip
if (roadmapEdits.some(e => e.lineIndex === i)) continue;
// Match checklist items: "- [ ] **Phase N:**" or "- [x] Phase N:" (also decimal)
const checklistMatch = line.match(/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)(\d+[A-Z]?(?:\.\d+)*)(\s*[:\s*])/i);
if (checklistMatch) {
const legacyNum = checklistMatch[2];
const cIntPart = parseInt(legacyNum, 10);
const paddedLegacy = String(cIntPart).padStart(2, '0');
const unpaddedLegacy = String(cIntPart);
const cDotIdx = legacyNum.indexOf('.');
const paddedLegacyDecimal = cDotIdx !== -1 ? paddedLegacy + legacyNum.slice(cDotIdx) : null;
// Prefer milestone-context lookup (avoids collision across milestones)
let newId;
if (currentChecklistMilestone !== null && milestoneIdMap.has(currentChecklistMilestone)) {
const mMap = milestoneIdMap.get(currentChecklistMilestone);
newId = mMap.get(legacyNum) || mMap.get(paddedLegacy) || mMap.get(unpaddedLegacy);
if (!newId && paddedLegacyDecimal) newId = mMap.get(paddedLegacyDecimal);
}
if (!newId) {
// Fallback: use ordered flat list (no milestone collision in this roadmap)
const found = orderedMappings.find(m =>
m.legacyPhaseNum === legacyNum ||
m.legacyPhaseNum === paddedLegacy ||
m.legacyPhaseNum === unpaddedLegacy ||
(paddedLegacyDecimal && m.legacyPhaseNum === paddedLegacyDecimal)
);
if (found) newId = found.newId;
}
if (newId) {
const newLine = line.replace(
/^(\s*-\s*\[[ x]\]\s*\*{0,2}Phase\s+)\d+[A-Z]?(?:\.\d+)*(\s*[:\s*])/i,
`$1${newId}$2`
);
if (newLine !== line) {
roadmapEdits.push({ lineIndex: i, from: line, to: newLine });
}
}
}
}
// ── Build cross-ref edits for STATE.md and PROJECT.md ────────────────────
const crossRefEdits = [];
const crossRefFiles = ['STATE.md', 'PROJECT.md'];
for (const fileName of crossRefFiles) {
const filePath = path.join(pDir, fileName);
if (!fs.existsSync(filePath)) continue;
const fileContent = fs.readFileSync(filePath, 'utf8');
// Iterate using orderedMappings (ROADMAP order) — idMapping is now keyed by lineIndex.
for (const m of orderedMappings) {
const legacyNum = m.legacyPhaseNum;
const xIntPart = parseInt(legacyNum, 10);
const paddedNum = String(xIntPart).padStart(2, '0');
const unpaddedNum = String(xIntPart);
// Decimal suffix (e.g. ".1" from "2.1") — preserve in cross-ref patterns
const xDotIdx = legacyNum.indexOf('.');
const decimalSuffix = xDotIdx !== -1 ? legacyNum.slice(xDotIdx) : '';
// Rewrite project_code-prefixed references: "GSD-01-" → "GSD-01-02-"
if (projectCode) {
const [milestoneStr, subStr] = m.newId.split('-');
const paddedMilestone = String(parseInt(milestoneStr, 10)).padStart(2, '0');
const prefixedNew = `${projectCode}-${paddedMilestone}-${subStr}-`;
// Try both padded and original forms as old prefix
for (const oldNum of new Set([paddedNum + decimalSuffix, unpaddedNum + decimalSuffix, paddedNum, unpaddedNum])) {
const prefixedOld = `${projectCode}-${oldNum}-`;
if (fileContent.includes(prefixedOld)) {
crossRefEdits.push({ file: fileName, from: prefixedOld, to: prefixedNew });
}
}
}
// Rewrite prose references: "Phase 1:" → "Phase 1-01:", "Phase 2.1:" → "Phase 1-02:"
const proseOldPatterns = new Set([
`Phase ${unpaddedNum}${decimalSuffix}:`,
`Phase ${paddedNum}${decimalSuffix}:`,
`Phase ${legacyNum}:`,
]);
for (const proseOld of proseOldPatterns) {
if (fileContent.includes(proseOld)) {
const proseNew = `Phase ${m.newId}:`;
crossRefEdits.push({ file: fileName, from: proseOld, to: proseNew });
}
}
}
}
return {
alreadyMigrated: false,
phases,
roadmapEdits,
crossRefEdits,
};
}
// ─── applyMigration ───────────────────────────────────────────────────────────
/**
* Apply the migration plan computed by computeMigrationPlan().
*
* @param {string} cwd
* @param {{ alreadyMigrated, phases, roadmapEdits, crossRefEdits }} plan
* @param {object} [options]
* @param {boolean} [options.dryRun=true] - Print plan and exit without mutating.
* @returns {{
* applied?: boolean,
* alreadyMigrated?: boolean,
* renamedDirs?: string[],
* editedFiles?: string[],
* }}
*/
function applyMigration(cwd, plan, options = {}) {
const dryRun = options.dryRun !== false; // default true
if (plan.alreadyMigrated) {
return { alreadyMigrated: true };
}
if (dryRun) {
process.stdout.write(JSON.stringify(plan, null, 2) + '\n');
return { dryRun: true };
}
// ── Real run: verify clean working tree ───────────────────────────────────
let gitStatus;
try {
gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8' });
} catch (err) {
throw new Error(`git status failed: ${err.message}`);
}
if (gitStatus.trim().length > 0) {
throw new Error('Working tree is dirty. Commit or stash changes before migrating.');
}
// Capture HEAD sha for rollback
let headSha;
try {
headSha = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8' }).trim();
} catch (err) {
throw new Error(`git rev-parse HEAD failed: ${err.message}`);
}
const pDir = planningDir(cwd);
const phasesDir = path.join(pDir, 'phases');
const roadmapPath = path.join(pDir, 'ROADMAP.md');
const configPath = path.join(pDir, 'config.json');
const renamedDirs = [];
const editedFiles = [];
try {
// 1. Rename phase directories
for (const phaseEntry of plan.phases) {
const oldPath = path.join(phasesDir, phaseEntry.oldDir);
const newPath = path.join(phasesDir, phaseEntry.newDir);
if (fs.existsSync(oldPath)) {
fs.renameSync(oldPath, newPath);
renamedDirs.push(`${phaseEntry.oldDir} → ${phaseEntry.newDir}`);
}
}
// 2. Rewrite ROADMAP.md phase headings
if (plan.roadmapEdits.length > 0) {
const roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
const lines = roadmapContent.split('\n');
// Sort edits by lineIndex to apply in order
const sortedEdits = [...plan.roadmapEdits].sort((a, b) => a.lineIndex - b.lineIndex);
for (const edit of sortedEdits) {
if (lines[edit.lineIndex] === edit.from) {
lines[edit.lineIndex] = edit.to;
}
}
fs.writeFileSync(roadmapPath, lines.join('\n'), 'utf8');
editedFiles.push('ROADMAP.md');
}
// 3. Rewrite cross-refs in STATE.md and PROJECT.md
const crossRefsByFile = new Map();
for (const edit of plan.crossRefEdits) {
if (!crossRefsByFile.has(edit.file)) {
crossRefsByFile.set(edit.file, []);
}
crossRefsByFile.get(edit.file).push(edit);
}
for (const [fileName, edits] of crossRefsByFile) {
const filePath = path.join(pDir, fileName);
if (!fs.existsSync(filePath)) continue;
let content = fs.readFileSync(filePath, 'utf8');
let changed = false;
for (const edit of edits) {
if (content.includes(edit.from)) {
// Replace all occurrences
content = content.split(edit.from).join(edit.to);
changed = true;
}
}
if (changed) {
fs.writeFileSync(filePath, content, 'utf8');
editedFiles.push(fileName);
}
}
// 4. Update config.json: set phase_id_convention to 'milestone-prefixed'
let configData = {};
try {
configData = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch { /* config may not exist yet */ }
configData.phase_id_convention = 'milestone-prefixed';
fs.writeFileSync(configPath, JSON.stringify(configData, null, 2) + '\n', 'utf8');
editedFiles.push('config.json');
} catch (err) {
// Rollback via git reset --hard + git clean
try {
execSync(`git reset --hard ${headSha}`, { cwd, stdio: 'pipe' });
execSync('git clean -fd .planning/phases/', { cwd, stdio: 'pipe' });
} catch (rollbackErr) {
// Swallow rollback errors — surface original error
}
throw new Error(`Migration failed (rolled back to ${headSha}): ${err.message}`);
}
return { applied: true, renamedDirs, editedFiles };
}
// ─── Exports ──────────────────────────────────────────────────────────────────
module.exports = {
computeMigrationPlan,
applyMigration,
};

View File

@@ -63,7 +63,7 @@ function countPhasePlansAndSummaries(phaseDir) {
function searchPhaseInContent(content, escapedPhase, phaseNum) {
// Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name
const phasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
'i'
);
const headerMatch = content.match(phasePattern);
@@ -92,9 +92,10 @@ function searchPhaseInContent(content, escapedPhase, phaseNum) {
const phaseName = headerMatch[1].trim();
const headerIndex = headerMatch.index;
// Find the end of this section (next ## or ### phase header, or end of file)
// Find the end of this section (next ## or ### phase header, or end of file).
// Also matches bracket-prefixed headings like ### [GSD] Phase 2-01:.
const restOfContent = content.slice(headerIndex);
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+[\w][\w.-]*/i);
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w][\w.-]*/i);
const sectionEnd = nextHeaderMatch
? headerIndex + nextHeaderMatch.index
: content.length;
@@ -203,7 +204,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
const phasesDir = planningPaths(cwd).phases;
// Extract all phase headings: ## Phase N: Name or ### Phase N: Name
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)\s*:\s*([^\n]+)/gi;
const phases = [];
let match;
@@ -225,7 +226,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
const restOfContent = content.slice(sectionStart);
// #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
// recognised as section boundaries.
const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
const nextHeader = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+\d[\d.-]*/i);
const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
const section = content.slice(sectionStart, sectionEnd);

View File

@@ -29,8 +29,13 @@
*/
// ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
const phaseDirNameRe = /^\d{2,}(?:\.\d+)*-[\w-]+$/;
const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i;
// Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup),
// deep dirs (02-04-01-deep), and project-code-prefixed variants (GSD-02-01-setup).
const phaseDirNameRe = /^(?:[A-Z]{1,6}-)?\d{2,}(?:-\d+)*(?:\.\d+)*-[\w-]+$/i;
// Extracts the full phase token from a directory name, including milestone-prefixed
// multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup".
// Greedily captures all leading all-digit segments before the first letter-start segment.
const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+(?:-\d+)*[A-Z]?(?:\.\d+)*)(?:-[a-z]|$)/i;
const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
// ── Issue #26: I001 canonicalization ────────────────────────────────────────
@@ -41,26 +46,46 @@ function canonicalPlanStem(stem) {
// ── Issue #6: phase variant helpers (W006/W007) ──────────────────────────────
function phaseVariants(phase) {
const variants = new Set([phase]);
const dotIdx = phase.indexOf('.');
const head = dotIdx === -1 ? phase : phase.slice(0, dotIdx);
const tail = dotIdx === -1 ? '' : phase.slice(dotIdx);
const variants = new Set([phase]);
const dotIdx = phase.indexOf('.');
const head = dotIdx === -1 ? phase : phase.slice(0, dotIdx);
const tail = dotIdx === -1 ? '' : phase.slice(dotIdx);
const headMatch = head.match(/^(\d+)([A-Z]?)$/i);
if (!headMatch)
return variants;
const numericHead = headMatch[1];
const letterSuffix = headMatch[2] || '';
variants.add(`${String(parseInt(numericHead, 10))}${letterSuffix}${tail}`);
variants.add(`${numericHead.padStart(2, '0')}${letterSuffix}${tail}`);
return variants;
// Milestone-prefixed IDs: M-NN or M-N-N. Add padding-normalized variant.
// e.g. "2-01" → also "02-01"; "02-01" → also "2-01"
const milestoneHeadMatch = head.match(/^(\d+)((?:-\d+)+)([A-Z]?)$/i);
if (milestoneHeadMatch) {
const major = milestoneHeadMatch[1];
const subSegs = milestoneHeadMatch[2]; // e.g. "-01" or "-04-01"
const letter = milestoneHeadMatch[3] || '';
const paddedMajor = major.padStart(2, '0');
const unpaddedMajor = String(parseInt(major, 10));
// Pad/unpad sub-segments individually
const paddedSubs = subSegs.slice(1).split('-').map(s => s.padStart(2, '0')).join('-');
const unpaddedSubs = subSegs.slice(1).split('-').map(s => String(parseInt(s, 10))).join('-');
variants.add(`${paddedMajor}-${paddedSubs}${letter}${tail}`);
variants.add(`${unpaddedMajor}-${unpaddedSubs}${letter}${tail}`);
variants.add(`${unpaddedMajor}-${paddedSubs}${letter}${tail}`);
variants.add(`${paddedMajor}-${unpaddedSubs}${letter}${tail}`);
return variants;
}
// Plain numeric/decimal IDs: "1", "01", "12A", "12.1"
const headMatch = head.match(/^(\d+)([A-Z]?)$/i);
if (!headMatch) return variants;
const numericHead = headMatch[1];
const letterSuffix = headMatch[2] || '';
variants.add(`${String(parseInt(numericHead, 10))}${letterSuffix}${tail}`);
variants.add(`${numericHead.padStart(2, '0')}${letterSuffix}${tail}`);
return variants;
}
function buildRoadmapPhaseVariants(roadmapContent) {
const roadmapPhases = new Set();
const roadmapPhaseVariants = new Set();
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
// Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:),
// and bracket-prefixed (### [GSD] Phase 2-01:) headings.
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi;
let m;
while ((m = phasePattern.exec(roadmapContent)) !== null) {
roadmapPhases.add(m[1]);
@@ -71,7 +96,8 @@ function buildRoadmapPhaseVariants(roadmapContent) {
function buildNotStartedPhaseVariants(roadmapContent) {
const notStartedPhases = new Set();
const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s*]/gi;
// Also matches milestone-prefixed and bracket-prefixed checklist items.
const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)[:\s*]/gi;
let um;
while ((um = uncheckedPattern.exec(roadmapContent)) !== null) {
for (const variant of phaseVariants(um[1])) notStartedPhases.add(variant);

View File

@@ -507,6 +507,34 @@ function collectDiskPhases(planBase) {
return diskPhases;
}
// W021: phase ID integer prefix doesn't match its enclosing milestone section
// Only fires when phase_id_convention is 'milestone-prefixed' (opt-in).
// Returns array of mismatch objects: { phaseId, foundInMilestone, expectedMilestone }
function checkMilestonePrefixMismatches(roadmapContent, { getMilestoneFromPhaseId }) {
const mismatches = [];
// Find all milestone sections (## vN.N or ## [code] vN.N)
const sections = [];
const sectionRx = /^#{1,3}\s+(?:\[[^\]]+\]\s*)?.*v(\d+\.\d+)/gim;
let m;
while ((m = sectionRx.exec(roadmapContent)) !== null) {
if (sections.length > 0) sections[sections.length - 1].end = m.index;
sections.push({ version: `v${m[1]}`, start: m.index, end: roadmapContent.length });
}
for (const section of sections) {
const content = roadmapContent.slice(section.start, section.end);
const phaseRx = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
let pm;
while ((pm = phaseRx.exec(content)) !== null) {
const phaseId = pm[1];
const expectedMilestone = getMilestoneFromPhaseId(phaseId);
if (expectedMilestone !== null && expectedMilestone !== section.version) {
mismatches.push({ phaseId, foundInMilestone: section.version, expectedMilestone });
}
}
}
return mismatches;
}
function cmdValidateConsistency(cwd, raw) {
const planBase = planningDir(cwd);
const roadmapPath = path.join(planBase, 'ROADMAP.md');
@@ -527,7 +555,9 @@ function cmdValidateConsistency(cwd, raw) {
// stripped). Used for the "in ROADMAP but not on disk" check — we only require
// disk dirs for the active milestone's phases.
const roadmapPhases = new Set();
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
// Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), and
// milestone-prefixed (Phase 2-01:) headings, including bracket-prefixed form.
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi;
let m;
while ((m = phasePattern.exec(roadmapContent)) !== null) {
roadmapPhases.add(m[1]);
@@ -539,7 +569,7 @@ function cmdValidateConsistency(cwd, raw) {
// though it is absent from the active-milestone scope. Without this, narrowing
// the scope (#501) would flag every shipped phase dir as a spurious orphan.
const fullRoadmapPhases = new Set();
const fullPhasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
const fullPhasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi;
let fm;
while ((fm = fullPhasePattern.exec(roadmapContentRaw)) !== null) {
fullRoadmapPhases.add(fm[1]);
@@ -558,8 +588,12 @@ function cmdValidateConsistency(cwd, raw) {
// Check: phases on disk but not in ROADMAP (compared against the FULL roadmap
// so shipped-milestone phase dirs are not flagged as orphans — #501)
for (const p of diskPhases) {
// For plain numeric IDs, also try the unpadded form (e.g. "02" → "2").
// For milestone-prefixed IDs (e.g. "02-01"), use normalizePhaseName to
// canonicalize padding before comparing with the ROADMAP entries.
const normalized = normalizePhaseName(p);
const unpadded = String(parseInt(p, 10));
if (!fullRoadmapPhases.has(p) && !fullRoadmapPhases.has(unpadded)) {
if (!fullRoadmapPhases.has(p) && !fullRoadmapPhases.has(normalized) && !fullRoadmapPhases.has(unpadded)) {
warnings.push(`Phase ${p} exists on disk but not in ROADMAP.md`);
}
}
@@ -1059,6 +1093,31 @@ function cmdValidateHealth(cwd, options, raw) {
}
} catch { /* git worktree not available or not a git repo — skip silently */ }
// ─── Check 11b: Phase ID / milestone-section mismatch (W021) ─────────────
// Only active when phase_id_convention === 'milestone-prefixed' in config.json.
try {
const phaseConvention = (() => {
if (!fs.existsSync(configPath)) return null;
try {
const configRaw = fs.readFileSync(configPath, 'utf-8');
const configParsed = JSON.parse(configRaw);
return configParsed.phase_id_convention || null;
} catch { return null; }
})();
if (phaseConvention === 'milestone-prefixed') {
if (fs.existsSync(roadmapPath)) {
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const { getMilestoneFromPhaseId } = require('./core.cjs');
const mismatches = checkMilestonePrefixMismatches(roadmapContent, { getMilestoneFromPhaseId });
for (const m of mismatches) {
addIssue('warning', 'W021',
`Phase ${m.phaseId}: integer prefix implies ${m.expectedMilestone} but listed under ${m.foundInMilestone}`,
'Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate (dry-run by default)');
}
}
}
} catch { /* W021 check is advisory — skip on error */ }
// ─── Check 12: MILESTONES.md / archive snapshot drift (#2446) ─────────────
const milestonesPath = path.join(planBase, 'MILESTONES.md');
const milestonesArchiveDir = path.join(planBase, 'milestones');

View File

@@ -11,6 +11,7 @@
"context_window": 200000,
"phase_naming": "sequential",
"project_code": null,
"phase_id_convention": null,
"mode": "interactive",
"claude_md_path": "./CLAUDE.md",
"git": {

View File

@@ -83,6 +83,7 @@
"features.global_learnings",
"learnings.max_inject",
"project_code",
"phase_id_convention",
"phase_naming",
"manager.flags.discuss",
"manager.flags.plan",

View File

@@ -0,0 +1,187 @@
/**
* Backwards-compatibility tests for legacy phase ID conventions.
*
* Covers:
* 1. Legacy 'Phase N' ROADMAP entries still work when phase_id_convention
* is null (the default — no config key set).
* 2. Deprecated warning fires for free-form roadmaps (non-fatal).
* 3. No automatic migration happens when a free-form roadmap is loaded.
* 4. isDirInMilestone still works for old-style dirs ('02-setup') against
* ROADMAP entries 'Phase 2:'.
* 5. isDirInMilestone works for new-style dirs ('GSD-02-01-setup') against
* ROADMAP entries 'Phase 2-01:'.
* 6. Heading regex matches both '### Phase 2-01: Setup' and
* '### [GSD] Phase 2-01: Setup'.
*
* Tests 1-3 exercise new behavior and will FAIL until implemented.
* Tests 4-6 exercise existing/new behavior and should pass once wired.
*/
'use strict';
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, captureConsole } = require('./helpers.cjs');
const { getMilestonePhaseFilter } = require('../get-shit-done/bin/lib/core.cjs');
// ─── helpers ─────────────────────────────────────────────────────────────────
function writeRoadmap(tmpDir, content) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content);
}
function writeConfig(tmpDir, obj) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify(obj)
);
}
// ─── suite ───────────────────────────────────────────────────────────────────
describe('backwards-compat: legacy Phase N roadmap entries', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── test 1: legacy entries work with null phase_id_convention ──────────────
test('Phase N ROADMAP entries work when phase_id_convention is null (default)', () => {
// No phase_id_convention key → default (null) must still honour Phase N headings.
writeRoadmap(tmpDir, [
'## Roadmap v1.0: Current',
'',
'### Phase 1: Setup',
'**Goal:** initial setup',
'',
'### Phase 2: Build',
'**Goal:** build the thing',
].join('\n'));
const filter = getMilestonePhaseFilter(tmpDir);
assert.strictEqual(filter('01-setup'), true, 'old-style dir must match Phase 1');
assert.strictEqual(filter('02-build'), true, 'old-style dir must match Phase 2');
assert.strictEqual(filter('03-deploy'), false, 'unlisted phase must not match');
});
// ── test 2: deprecated warning fires for free-form roadmaps ───────────────
test('deprecated warning fires (non-fatal) when roadmap has no versioned milestone headings', () => {
// A "free-form" roadmap: phase headings but no ## vX.Y milestone section.
writeRoadmap(tmpDir, [
'### Phase 1: Setup',
'**Goal:** setup',
'',
'### Phase 2: Build',
'**Goal:** build',
].join('\n'));
const { stderr } = captureConsole(() => {
getMilestonePhaseFilter(tmpDir);
});
// Warning must fire but must not throw — non-fatal.
assert.match(
stderr,
/deprecated|free.form|phase_id_convention/i,
'a deprecation warning must be emitted for free-form roadmaps'
);
});
// ── test 3: no automatic migration ────────────────────────────────────────
test('loading a free-form roadmap does not rewrite ROADMAP.md on disk', () => {
const roadmapContent = [
'### Phase 1: Setup',
'**Goal:** setup',
].join('\n');
writeRoadmap(tmpDir, roadmapContent);
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
const before = fs.readFileSync(roadmapPath, 'utf-8');
// Trigger a load — must not silently migrate the file.
getMilestonePhaseFilter(tmpDir);
const after = fs.readFileSync(roadmapPath, 'utf-8');
assert.equal(after, before, 'ROADMAP.md must not be rewritten during load');
});
// ── test 4: old-style dirs ('02-setup') match 'Phase 2:' ─────────────────
test('isDirInMilestone: old-style dir "02-setup" matches ROADMAP "Phase 2:"', () => {
writeRoadmap(tmpDir, [
'## Roadmap v1.0: Current',
'',
'### Phase 2: Setup',
'**Goal:** setup',
].join('\n'));
const filter = getMilestonePhaseFilter(tmpDir);
assert.strictEqual(filter('02-setup'), true, '"02-setup" must match "Phase 2:"');
assert.strictEqual(filter('2-setup'), true, '"2-setup" must also match "Phase 2:"');
assert.strictEqual(filter('03-other'), false, 'unlisted dir must not match');
});
// ── test 5: new-style dirs ('GSD-02-01-setup') match 'Phase 2-01:' ───────
test('isDirInMilestone: new-style dir "GSD-02-01-setup" matches ROADMAP "Phase 2-01:"', () => {
writeRoadmap(tmpDir, [
'## Roadmap v1.0: Current',
'',
'### Phase 2-01: Setup',
'**Goal:** setup',
].join('\n'));
writeConfig(tmpDir, { project_code: 'GSD' });
const filter = getMilestonePhaseFilter(tmpDir);
assert.strictEqual(
filter('GSD-02-01-setup'),
true,
'"GSD-02-01-setup" must match "Phase 2-01:"'
);
assert.strictEqual(
filter('02-01-setup'),
true,
'"02-01-setup" must match "Phase 2-01:" without project prefix'
);
});
// ── test 6: heading regex matches both plain and [GSD]-prefixed headings ──
test('phase heading regex matches "### Phase 2-01: Setup" and "### [GSD] Phase 2-01: Setup"', () => {
const plain = '### Phase 2-01: Setup';
const bracketed = '### [GSD] Phase 2-01: Setup';
// Both heading variants must be captured by the phasePattern used internally.
// We exercise this via getMilestonePhaseFilter with a roadmap containing each form.
const plainRoadmap = ['## Roadmap v1.0: Current', '', plain, '**Goal:** g'].join('\n');
const bracketedRoadmap = ['## Roadmap v1.0: Current', '', bracketed, '**Goal:** g'].join('\n');
writeRoadmap(tmpDir, plainRoadmap);
const filterPlain = getMilestonePhaseFilter(tmpDir);
assert.strictEqual(
filterPlain('02-01-setup'),
true,
'plain heading "### Phase 2-01:" must be matched'
);
writeRoadmap(tmpDir, bracketedRoadmap);
const filterBracketed = getMilestonePhaseFilter(tmpDir);
assert.strictEqual(
filterBracketed('02-01-setup'),
true,
'"### [GSD] Phase 2-01:" must also be matched by the heading regex'
);
});
});

View File

@@ -0,0 +1,90 @@
// Tests for getMilestoneFromPhaseId and getPhaseDirFromPhaseId helpers (issue #39).
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const {
getMilestoneFromPhaseId,
getPhaseDirFromPhaseId,
} = require('../get-shit-done/bin/lib/core.cjs');
// ─── getMilestoneFromPhaseId ────────────────────────────────────────────────
describe('getMilestoneFromPhaseId', () => {
test('maps milestone integer 1 to v1.0', () => {
assert.strictEqual(getMilestoneFromPhaseId('1-01'), 'v1.0');
});
test('uses only the top-level integer: 2-4-1 → v2.0', () => {
assert.strictEqual(getMilestoneFromPhaseId('2-4-1'), 'v2.0');
});
test('handles double-digit milestone: 10-01 → v10.0', () => {
assert.strictEqual(getMilestoneFromPhaseId('10-01'), 'v10.0');
});
test('returns null for sentinel 999 (backlog)', () => {
assert.strictEqual(getMilestoneFromPhaseId('999-1'), null);
});
test('returns null for sentinel 0 (pre-milestone spike)', () => {
assert.strictEqual(getMilestoneFromPhaseId('0-1'), null);
});
test('returns null when there is no hyphen separator', () => {
assert.strictEqual(getMilestoneFromPhaseId('1'), null);
});
test('strips project_code prefix: CK-2-01 → v2.0', () => {
assert.strictEqual(getMilestoneFromPhaseId('CK-2-01'), 'v2.0');
});
test('strips longer project_code prefix: GSD-10-01 → v10.0', () => {
assert.strictEqual(getMilestoneFromPhaseId('GSD-10-01'), 'v10.0');
});
test('returns null for fully non-numeric input', () => {
assert.strictEqual(getMilestoneFromPhaseId('invalid'), null);
});
});
// ─── getPhaseDirFromPhaseId ─────────────────────────────────────────────────
describe('getPhaseDirFromPhaseId', () => {
test('produces zero-padded dir with project code', () => {
assert.strictEqual(
getPhaseDirFromPhaseId('2-01', 'Setup Database', 'GSD'),
'GSD-02-01-setup-database',
);
});
test('omits project code when not provided', () => {
assert.strictEqual(
getPhaseDirFromPhaseId('2-01', 'Setup Database'),
'02-01-setup-database',
);
});
test('handles double-digit milestone with project code', () => {
assert.strictEqual(
getPhaseDirFromPhaseId('10-01', 'Build Feature', 'CK'),
'CK-10-01-build-feature',
);
});
test('produces zero-padded dir without project code: 1-01 → 01-01-setup', () => {
assert.strictEqual(
getPhaseDirFromPhaseId('1-01', 'Setup'),
'01-01-setup',
);
});
test('returns null for phase IDs without the M-NN hyphen form', () => {
assert.strictEqual(
getPhaseDirFromPhaseId('nohyphen', 'Some Title', 'GSD'),
null,
);
});
});

View File

@@ -0,0 +1,235 @@
'use strict';
/**
* W021 validation rule — milestone-prefixed phase ID convention.
*
* W021 fires when a phase ID's integer prefix doesn't match its enclosing
* milestone section (e.g. phase '1-01' listed under ## v2.0 is a mismatch).
*
* Also covers: `gsd-tools roadmap validate` subcommand shape.
*
* These features do NOT exist yet — this file is written TDD-first.
*/
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');
// ---------------------------------------------------------------------------
// Fixture builder
// ---------------------------------------------------------------------------
/**
* Build a ROADMAP.md with milestone-prefixed sections at
* `tmpDir/.planning/ROADMAP.md`.
*
* @param {string} tmpDir - Temp project root returned by createTempProject().
* @param {Array<{version: string, label: string, phases: Array<{id: string, name: string}>}>} milestones
* Each milestone maps to a `## [GSD] vX.Y — Label` section; each phase maps
* to a `### Phase <id>: <name>` heading inside that section.
* @param {object} [opts]
* @param {string|null} [opts.conventionValue] - Value for the `phase_id_convention`
* front-matter field. Pass `null` to emit the key with a null/absent value.
* Omit (undefined) to use the default ('milestone-prefixed').
*/
function buildRoadmap(tmpDir, milestones, opts = {}) {
const { conventionValue } = opts;
let conventionLine;
if (conventionValue === null) {
conventionLine = 'phase_id_convention: null';
} else if (conventionValue === undefined) {
conventionLine = 'phase_id_convention: milestone-prefixed';
} else {
conventionLine = `phase_id_convention: ${conventionValue}`;
}
const frontmatter = `---\n${conventionLine}\n---\n\n`;
const sections = milestones
.map(({ version, label, phases }) => {
const phaseBlocks = phases
.map(({ id, name }) => `### Phase ${id}: ${name}\n**Goal:** Placeholder goal\n`)
.join('\n');
return `## [GSD] ${version} — ${label}\n\n${phaseBlocks}`;
})
.join('\n\n');
const content = `${frontmatter}# Roadmap\n\n${sections}\n`;
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content);
}
// ---------------------------------------------------------------------------
// Suite
// ---------------------------------------------------------------------------
describe('W021 — milestone-prefixed phase ID convention', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── 1. Mismatch fires W021 ────────────────────────────────────────────────
test('W021 fires when phase 1-01 is listed under ## v2.0 (mismatch)', () => {
buildRoadmap(tmpDir, [
{
version: 'v2.0',
label: 'Expansion',
phases: [{ id: '1-01', name: 'Setup' }],
},
]);
const result = runGsdTools(['roadmap', 'validate'], tmpDir);
assert.ok(result.success, `roadmap validate should exit 0 even with warnings: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(Array.isArray(out.warnings), 'output.warnings should be an array');
const w021 = out.warnings.filter(w => w.code === 'W021');
assert.ok(w021.length > 0, 'at least one W021 warning expected for prefix mismatch');
const warning = w021[0];
assert.ok(warning.message, 'W021 entry should have a message field');
});
// ── 2. Match does NOT fire W021 ───────────────────────────────────────────
test('W021 does NOT fire when phase 2-01 is under ## v2.0 (match)', () => {
buildRoadmap(tmpDir, [
{
version: 'v2.0',
label: 'Expansion',
phases: [{ id: '2-01', name: 'New thing' }],
},
]);
const result = runGsdTools(['roadmap', 'validate'], tmpDir);
assert.ok(result.success, `roadmap validate failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(Array.isArray(out.warnings), 'output.warnings should be an array');
const w021 = out.warnings.filter(w => w.code === 'W021');
assert.strictEqual(w021.length, 0, 'no W021 warnings expected when prefix matches milestone');
});
// ── 3. Sentinel ranges are exempt ────────────────────────────────────────
test('W021 does NOT fire for sentinel range: phase 999-01 (backlog)', () => {
buildRoadmap(tmpDir, [
{
version: 'v1.0',
label: 'Foundation',
phases: [{ id: '999-01', name: 'Backlog item' }],
},
]);
const result = runGsdTools(['roadmap', 'validate'], tmpDir);
assert.ok(result.success, `roadmap validate failed: ${result.error}`);
const out = JSON.parse(result.output);
const w021 = (out.warnings || []).filter(w => w.code === 'W021');
assert.strictEqual(w021.length, 0, 'backlog sentinel (999-xx) should be exempt from W021');
});
test('W021 does NOT fire for sentinel range: phase 0-01 (pre-milestone)', () => {
buildRoadmap(tmpDir, [
{
version: 'v1.0',
label: 'Foundation',
phases: [{ id: '0-01', name: 'Pre-milestone work' }],
},
]);
const result = runGsdTools(['roadmap', 'validate'], tmpDir);
assert.ok(result.success, `roadmap validate failed: ${result.error}`);
const out = JSON.parse(result.output);
const w021 = (out.warnings || []).filter(w => w.code === 'W021');
assert.strictEqual(w021.length, 0, 'pre-milestone sentinel (0-xx) should be exempt from W021');
});
// ── 4. null convention disables W021 ─────────────────────────────────────
test('W021 does NOT fire when phase_id_convention is null (free-form roadmap)', () => {
buildRoadmap(
tmpDir,
[
{
version: 'v2.0',
label: 'Expansion',
// Deliberately mismatched prefix to confirm the rule is disabled
phases: [{ id: '1-01', name: 'Setup' }],
},
],
{ conventionValue: null }
);
const result = runGsdTools(['roadmap', 'validate'], tmpDir);
assert.ok(result.success, `roadmap validate failed: ${result.error}`);
const out = JSON.parse(result.output);
const w021 = (out.warnings || []).filter(w => w.code === 'W021');
assert.strictEqual(w021.length, 0, 'W021 must not fire when convention is null');
});
// ── 5. `roadmap validate` returns JSON with warnings array ───────────────
test("'gsd-tools roadmap validate' subcommand returns JSON with warnings array", () => {
buildRoadmap(tmpDir, [
{
version: 'v1.0',
label: 'Foundation',
phases: [{ id: '1-01', name: 'Setup' }],
},
]);
const result = runGsdTools(['roadmap', 'validate'], tmpDir);
assert.ok(result.success, `roadmap validate should succeed: ${result.error}`);
let out;
try {
out = JSON.parse(result.output);
} catch {
assert.fail(`roadmap validate output is not valid JSON: ${result.output}`);
}
assert.ok(typeof out === 'object' && out !== null, 'output should be a JSON object');
assert.ok(Array.isArray(out.warnings), 'output should have a warnings array');
});
// ── 6. W021 message includes migration command ────────────────────────────
test('W021 warning text includes the migration command', () => {
buildRoadmap(tmpDir, [
{
version: 'v2.0',
label: 'Expansion',
phases: [{ id: '1-01', name: 'Mismatched phase' }],
},
]);
const result = runGsdTools(['roadmap', 'validate'], tmpDir);
assert.ok(result.success, `roadmap validate failed: ${result.error}`);
const out = JSON.parse(result.output);
const w021 = (out.warnings || []).filter(w => w.code === 'W021');
assert.ok(w021.length > 0, 'W021 warning expected');
const migrationCmd = 'gsd-tools roadmap upgrade --convention milestone-prefixed';
const hasMigration = w021.some(w => typeof w.message === 'string' && w.message.includes(migrationCmd));
assert.ok(
hasMigration,
`W021 warning message should include "${migrationCmd}". Got: ${JSON.stringify(w021.map(w => w.message))}`
);
});
});

View File

@@ -82,6 +82,6 @@ describe('roadmap-command-router', () => {
},
});
assert.equal(message, 'Unknown roadmap subcommand. Available: analyze, get-phase, update-plan-progress, annotate-dependencies');
assert.equal(message, 'Unknown roadmap subcommand. Available: analyze, get-phase, update-plan-progress, annotate-dependencies, validate, upgrade');
});
});