* 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:
5
.changeset/steady-pandas-purr.md
Normal file
5
.changeset/steady-pandas-purr.md
Normal 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.
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
569
get-shit-done/bin/lib/roadmap-upgrade.cjs
Normal file
569
get-shit-done/bin/lib/roadmap-upgrade.cjs
Normal 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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
"features.global_learnings",
|
||||
"learnings.max_inject",
|
||||
"project_code",
|
||||
"phase_id_convention",
|
||||
"phase_naming",
|
||||
"manager.flags.discuss",
|
||||
"manager.flags.plan",
|
||||
|
||||
187
tests/backwards-compat-phase-id.test.cjs
Normal file
187
tests/backwards-compat-phase-id.test.cjs
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
90
tests/milestone-helper.test.cjs
Normal file
90
tests/milestone-helper.test.cjs
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
235
tests/milestone-prefixed-convention.test.cjs
Normal file
235
tests/milestone-prefixed-convention.test.cjs
Normal 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))}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user