Files
msd-core/get-shit-done/bin/lib/validate.cjs
Tom Boucher 0a12b06381 feat(#39): milestone-prefixed phase IDs (M-NN convention) + migration tool + validation (#565)
* feat(#39): milestone-prefixed phase IDs (M-NN convention) + migration tool + validation

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

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

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

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

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

* chore: update changeset pr reference to #565

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

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

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:15:55 -04:00

119 lines
5.6 KiB
JavaScript

'use strict';
/**
* Validate Helpers — pure computation helpers and regex constants extracted from
* sdk/src/query/validate.ts. No I/O. No async. No filesystem operations.
*
* Issue #6 drift items (three helpers):
* 1. phaseVariants() — replaces parseInt-based padded/unpadded check in verify.cjs
* Check 8 (W006 disk-existence and W007 roadmap-membership checks).
* 2. buildRoadmapPhaseVariants() — replaces raw roadmapPhases set in W007 loop.
* 3. buildNotStartedPhaseVariants() — replaces raw+zero-padded notStartedPhases
* in W006 skip logic.
*
* Issue #26 drift items (four constants/helpers):
* 4. phaseDirNameRe — W005 phase directory naming regex (was inline in verify.cjs Check 6).
* 5. PHASE_TOKEN_FROM_DIR_RE — extracts phase token from dir name (was inline in
* verify.cjs forEachArchivedPhaseToken / collectDiskPhases).
* 6. MILESTONE_ARCHIVE_DIR_RE — identifies milestone archive directories (was inline).
* 7. canonicalPlanStem() — I001 PLAN/SUMMARY stem canonicalization (was inline in Check 7).
*
* I/O adapter pattern (ADR-3524 §4): pure transforms extracted from the SDK.
*
* References:
* - ADR-3524 (docs/adr/3524-cjs-sdk-hard-seam.md)
* - Issue #6 (open-gsd/gsd-core)
* - Issue #26 (open-gsd/gsd-core)
* - PR #154 (issue #4) — generator pattern precedent
* - PR #156 (issue #6) — validate.ts generator that #26 extends
*/
// ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
// 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 ────────────────────────────────────────
function canonicalPlanStem(stem) {
const m = stem.match(/^(\d+[A-Z]?(?:\.\d+)*-\d+)/i);
return m ? m[1] : 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);
// 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();
// 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]);
for (const variant of phaseVariants(m[1])) roadmapPhaseVariants.add(variant);
}
return { roadmapPhases, roadmapPhaseVariants };
}
function buildNotStartedPhaseVariants(roadmapContent) {
const notStartedPhases = new Set();
// 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);
}
return notStartedPhases;
}
module.exports = {
// Issue #26 exports (W005 regex, W006-archived regex constants, I001 helper)
phaseDirNameRe,
PHASE_TOKEN_FROM_DIR_RE,
MILESTONE_ARCHIVE_DIR_RE,
canonicalPlanStem,
// Issue #6 exports (W006/W007 phase variant helpers)
phaseVariants,
buildRoadmapPhaseVariants,
buildNotStartedPhaseVariants,
};