Files
msd-core/src/validate.cts
Cody Anderson 612fcb00f7 fix(#2232): cap phase-token continuation segments at exactly 2 digits (all sites) (#2254)
* fix(#2232): cap phase-token continuation segments at exactly 2 digits (all sites)

A phase whose slug's first word is a ≥2-digit number (dir
14-2026-photos-performance, roadmap phase "2026 Photos & Performance" →
slug 2026-photos-…) had its phase token over-collected as "14-2026"
instead of "14", so every phase-locating verb (init.plan-phase,
init.execute-phase, phase-plan-index, state.planned-phase,
roadmap.annotate-dependencies) resolved phase_dir=null / plan_count=0
while the directory existed. This is the residual case #2043 explicitly
scoped out: its ≥2-digit continuation gate (\d{2,}) distinguishes
single-digit slug words but not multi-digit ones (years, counts).

The structural distinguisher: getPhaseDirFromPhaseId writes sub-phase and
plan continuation segments zero-padded to EXACTLY 2 digits, so a genuine
continuation's digit run is exactly 2 — \d{2}(?!\d). The (?!\d) guard
caps the run without anchoring what follows, so each call site keeps its
own trailing grammar (letter suffixes, dotted sub-phases, boundaries).

Shared-source, not hand-synced: the grammar lives once in phase-id.cts as
PHASE_CONTINUATION_SEGMENT_SOURCE / isPhaseContinuationSegment (the #2121
single-owner seam), consumed by all five #2043 sites:
- phase-id.cts extractPhaseToken (the reported repro)
- validate.cts PHASE_TOKEN_FROM_DIR_RE + canonicalPlanStem
- roadmap-parser.cts isDirInMilestone numericRe (hyphenated mode)
- core-utils.cts + phase.cts extractCanonicalPlanId (paired plan
  component only — the LEADING phase component keeps unbounded \d{2,};
  phase numbers ≥100 are legitimate)

Digit-width policy, resolved per triage and locked by boundary tests at
1/2/3/4-digit continuation widths across all sites: sub-phase/plan
numbers ≥100 are out of the dir-token grammar. validate.cts
phaseDirNameRe's leading \d{2,} is intentionally untouched — it encodes
the write-side padding of the leading dir number, not the continuation
heuristic, and has no year collision.

Fixes #2232

Claude-Session: https://claude.ai/code/session_017KaYUJnfzV3JVVuQnhkcjg

* chore(#2232): add changeset for PR #2254

Claude-Session: https://claude.ai/code/session_017KaYUJnfzV3JVVuQnhkcjg

* test(#2232): parity gate + fast-check properties for the continuation cap

Addresses trek-e's review on PR #2254 (M1, M2, B1). Test-only — the fix
itself was verified as a true root-cause fix, so no source changes.

M1 — drift/parity enforcement for the new shared constant.
scripts/lint-phase-id-drift.cjs guards PHASE_NUMBER_TOKEN_SOURCE only; its
TOKEN_DRIFT_RE cannot match a bare \d{2,} re-derivation, so a future edit
reintroducing a raw digit-cap at a consuming site would pass lint + CI
silently. Extending the lint was rejected: \d{2,} legitimately appears at
the intentionally-unbounded LEADING-token sites (validate phaseDirNameRe,
core-utils/phase tokenRe), so a textual guard would need sanctions on
correct code and would flag by spelling rather than by behaviour.

Instead, per the repo's *-parity.test.cjs precedent, added
tests/phase-continuation-parity.test.cjs: a shared digit-width corpus
(1/2/3/4/5) asserting every consuming surface's notion of "is this segment
absorbed" equals isPhaseContinuationSegment(). Covers all five #2043 sites:
extractPhaseToken, PHASE_TOKEN_FROM_DIR_RE, canonicalPlanStem,
extractCanonicalPlanId (paired component), and roadmap isDirInMilestone
(hyphenated mode, on a real ROADMAP fixture). The corpus states the policy
independently of the regex, so it fails on divergence rather than mirroring
whatever the code does.

Failing-first verified: reverting PHASE_TOKEN_FROM_DIR_RE to \d{2,} fails 3
parity tests; reverting the owner constant itself fails 11 across parity +
properties + examples.

M2 — fast-check properties for the changed parser (4 added to
phase-id.test.cjs, following its existing inline fc precedent):
- biconditional: a segment is absorbed IFF its digit run is exactly 2
- the owner agrees with observable extraction for every digit run
- metamorphic: a write-side getPhaseDirFromPhaseId dir round-trips to its
  own normalizePhaseName id — ties the cap to the zero-padding convention
  it mirrors, so a change to the write-side width fails loudly
- metamorphic: the round-trip holds when the phase name leads with a year
  (the #2232 bug itself, generatively)
Digit runs are generated as digit strings (not String(int)) so leading-zero
forms like "02" — the whole point of the rule — are actually exercised.

B1 — GitGuardian red. The session-trailer hypothesis is disproven: the same
Claude-Session trailer rides 3 commits now merged to next via #2173, whose
GitGuardian check PASSED. GitGuardian's own comment names
tests/phase-id.test.cjs:260 — the synthetic dir literal 'M1-14-2026-photos'
tripping the generic high-entropy detector. Composed it from parts; the
assertion is unchanged, only the source spelling.

Refs #2232

Claude-Session: https://claude.ai/code/session_019SkiJk38YWAbmxHrGxEmuU

* test(#2232): name the parity gate after the invariant, not the phase module

CI caught two failures from the new parity test, both one root cause:
lint-test-file-count caps each production module at 2 test files (primary +
one integration, per the #3740 consolidation). The file was named
phase-continuation-parity.test.cjs, and the linter clusters a test to a
production module by name prefix — "phase-*" bound it to src/phase.cts,
whose cluster (phase.test.cjs + phase-dependency-levels.test.cjs) was
already at the cap, making 3. That tripped the lint-tests job AND the
ubuntu-24 unit lane, where tests/lint-test-file-count.test.cjs is a
meta-test asserting the linter exits 0 against the real repo.

Renamed to continuation-grammar-parity.test.cjs, matching the convention
the repo's other cross-cutting parity gates already follow: they are named
after the INVARIANT, not a module — capability-precedence-parity,
agent-classification-parity, and runtime-launcher-parity all have no
corresponding src/*.cts, so they cluster to nothing. The gate tests a
grammar shared ACROSS phase-id/validate/core-utils/roadmap-parser rather
than the phase module specifically, so the invariant-name is also the
semantically correct home. Not allowlisted: a novel offender belongs under
the cap, not ratcheted into the exemption list.

Content unchanged — same 12 assertions across the same 5 surfaces.

Refs #2232

Claude-Session: https://claude.ai/code/session_019SkiJk38YWAbmxHrGxEmuU

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-07-15 15:33:58 -04:00

155 lines
7.8 KiB
TypeScript

/**
* Validate Helpers — pure computation helpers and regex constants extracted from
* sdk/src/query/validate.ts (ADR-457 build-at-publish: the hand-written
* bin/lib/validate.cjs collapsed to a TypeScript source of truth). Behaviour is
* preserved byte-for-behaviour from the prior hand-written .cjs; only types are
* added.
*
* 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
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const {
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
PHASE_NUMBER_TOKEN_SOURCE,
PHASE_CONTINUATION_SEGMENT_SOURCE,
} = phaseIdMod;
// ── 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).
export const phaseDirNameRe = new RegExp(
`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}\\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".
// #2043: a *continuation* sub-phase segment must be zero-padded, so a
// single-digit slug word after a phase number (e.g. "46-6-rs-…", slug "6 Rs …") is
// NOT absorbed — it captures "46", not "46-6". #2232: the continuation width is
// exactly 2 (PHASE_CONTINUATION_SEGMENT_SOURCE), so a ≥3-digit slug word (a year:
// "14-2026-photos-…") is not absorbed either — it captures "14", not "14-2026".
// The first component stays "\d+"
// (with the "[A-Z]?" suffix) so single-digit letter-suffixed phase ids ("1A") and
// milestone-prefixed single-digit sub-phases ("M1-2" → prefix "M1-" stripped, then
// "2") still match. The trailing boundary "(?:-|$)" (was "(?:-[a-z]|$)") lets a slug
// that starts with a digit terminate the token.
export const PHASE_TOKEN_FROM_DIR_RE = new RegExp(
`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-${PHASE_CONTINUATION_SEGMENT_SOURCE})*[A-Z]?(?:\\.\\d+)*)(?:-|$)`,
'i',
);
export const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
// ── Issue #26: I001 canonicalization ────────────────────────────────────────
export function canonicalPlanStem(stem: string): string {
// #2043: the plan component (after the phase number) must be zero-padded,
// so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken
// for a "46-6" phase/plan pair. #2232: exactly 2 digits, so a year-leading
// slug ("14-2026-photos-…") is not mistaken for a "14-2026" pair either.
const m = stem.match(
new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE}-${PHASE_CONTINUATION_SEGMENT_SOURCE})`, 'i'),
);
return m ? m[1] : stem;
}
/** Result of buildRoadmapPhaseVariants. */
export interface RoadmapPhaseVariantsResult {
roadmapPhases: Set<string>;
roadmapPhaseVariants: Set<string>;
}
// ── Issue #6: phase variant helpers (W006/W007) ──────────────────────────────
export function phaseVariants(phase: string): Set<string> {
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;
}
export function buildRoadmapPhaseVariants(roadmapContent: string): RoadmapPhaseVariantsResult {
const roadmapPhases = new Set<string>();
const roadmapPhaseVariants = new Set<string>();
// Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:),
// and bracket-prefixed (### [GSD] Phase 2-01:) headings.
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
const phasePattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi;
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(roadmapContent)) !== null) {
roadmapPhases.add(m[1]);
for (const variant of phaseVariants(m[1])) roadmapPhaseVariants.add(variant);
}
// Also matches checklist-style entries (checked or unchecked):
// - [x] **Phase 01: name** - [X] **Phase 2-01: name** - [ ] **Phase 3: name**
// This is a supported ROADMAP format (parallel to buildNotStartedPhaseVariants).
const checklistPattern = /-\s*\[[ xX]\]\s*\*{0,2}Phase\s+([\w][\w.-]*)\s*:/gi;
let cm: RegExpExecArray | null;
while ((cm = checklistPattern.exec(roadmapContent)) !== null) {
roadmapPhases.add(cm[1]);
for (const variant of phaseVariants(cm[1])) roadmapPhaseVariants.add(variant);
}
return { roadmapPhases, roadmapPhaseVariants };
}
export function buildNotStartedPhaseVariants(roadmapContent: string): Set<string> {
const notStartedPhases = new Set<string>();
// Also matches milestone-prefixed and bracket-prefixed checklist items.
const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+([\w][\w.-]*)[:\s*]/gi;
let um: RegExpExecArray | null;
while ((um = uncheckedPattern.exec(roadmapContent)) !== null) {
for (const variant of phaseVariants(um[1])) notStartedPhases.add(variant);
}
return notStartedPhases;
}