* fix(#2562): scope workstream progress/status to the current milestone `workstream progress` / `workstream status` / `workstream list` share one derivation that could report a workstream's CURRENT milestone as "milestone complete" / 100% while phases in that milestone were unstarted, in progress, or failing verification. Three coupled defects: 1. The shipped signal was project-lifetime, not milestone-scoped: workstreamMilestoneShipped() returned true if ANY *-ROADMAP.md snapshot existed or "SHIPPED" appeared anywhere in ROADMAP.md. Every prior shipped milestone leaves a permanent collapsed <summary>✅ … SHIPPED</summary> block, so any post-v1.0 workstream was pinned to "milestone complete" forever (over-correction from #1913). 2. The denominator dropped declared-but-unscaffolded phases, and completed PRIOR-milestone phase directories inflated the numerator, letting progress_percent round to 100 while real work remained. 3. Phase completeness ignored the VERIFICATION verdict — SUMMARY >= PLAN count alone marked a phase complete even with a human_needed verdict. Fix: derive both numerator and denominator from artifacts scoped to the current milestone. The current version comes from the workstream STATE.md `milestone:` field (ROADMAP in-progress markers can be stale); the ROADMAP `## Progress` table maps every phase — including dirless ones — to its milestone, and the matching set is both the denominator and the directory membership filter. The shipped signal now requires the CURRENT version's archived ROADMAP snapshot (REQUIREMENTS snapshots are not accepted; they can be written at milestone start) or the current milestone's own line marked shipped. Phases with an explicit failing verdict (gaps_found/human_needed) count as in_progress; missing/unknown/stale are left untouched so verifier-disabled projects do not regress to never-complete. Greenfield roadmaps with no versioned Progress table, and projects whose current version cannot be determined, keep the prior behaviour. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#2562): add changeset for workstream milestone-scoping fix Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(#2562): parse the Progress table via findTableWithColumns The ad-hoc pipe-table regex tripped the local/no-adhoc-markdown-parsing ESLint rule. Use the canonical markdown-table helper instead: the milestone-grouped RoadmapProgress variant is located by its required `Phase` + `Milestone` columns and cells are addressed by column NAME, so the parser tolerates column reordering and injected columns. The `flat` variant (no Milestone column) yields no attribution, which is the intended fallback to legacy counting. Behaviour is unchanged: verified against a real multi-workstream project (same status/percent/phase and plan counts before and after). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2562): count table-only phases in the unscoped denominator Addresses the reporter's repro detail: a phase declared as a `## Progress` table row with no `### Phase N` heading is missed by countRoadmapPhases EVEN WHEN other headings exist — the heading regex counts 1 for a "1 heading + 1 table-only" roadmap — not just in the zero-heading fallback path. Milestone scoping did not cover this, because a flat Progress table (no Milestone column) carries no per-phase attribution, so greenfield and single-milestone projects kept the old heading-only denominator and the declared phase silently vanished from it. When milestone scoping cannot engage, the denominator is now the union of the Progress table's declared phase numbers and the phase directories, so neither source can shrink it. Verified against the reporter's minimal fixture (phase 1: 1 PLAN + 1 SUMMARY + gaps_found; phase 2: table row only, no heading, no dir), which now reports 0/2 at 0% across all four table/STATE permutations instead of 1/1 at 100%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#2562): attribute dir-only sub-phases to their parent's milestone A sub-phase directory inserted mid-milestone (e.g. `30.1-…` under a table-declared phase 30) usually has no ROADMAP Progress-table row, so it had no milestone attribution and was scoped out of the rollup entirely — its completed work was invisible and it could never hold the percentage below 100. It now inherits its parent phase's milestone and joins BOTH sides of the calculation. Both sides is the load-bearing part: adding it to the numerator alone would let completed_phases exceed a denominator that never counted it, cap back to 100% via Math.min, and reintroduce exactly the defect this issue reports. A regression test pins that failure mode (all declared phases complete + an in-progress dir-only sub-phase → 75%, not 100%). Attribution is deliberately one-directional: a sub-phase counts only when its PARENT is in the current milestone, so a follow-up created in a later milestone under an older parent is excluded rather than misattributed — conservative (under-count) rather than falsely inflating. Verified on a real project: the reported workstream moves from 2/6 (33%) to 3/7 (43%), the 3/7 being the honest figure — a completed sub-phase that was previously invisible now counts, and so does its plan total. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#2562): describe the denominator + sub-phase fixes in the changeset The fragment was written at the first commit and only covered the three original defects. Bring it up to date with what actually ships: the table-only-phase denominator union (heading-only counting dropped a declared phase even when other headings existed) and sub-phase milestone inheritance across both sides of the calculation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(#2562): promote the canonical phase-key surface to phase-id state.cts kept `phaseKeyFromToken`/`phaseKeyFromDir` private, so every other module that had to compare two independently-derived phase references — a ROADMAP table cell against a phase directory, say — wrote its own regex. That is the defect class #2562 reports: a padded `01` and an unpadded `1-slug` land in different key spaces and the comparison silently yields nothing. Move the pair to the phase-id owner module and add `phaseKeyFromProse` (for ROADMAP/STATE prose, markdown emphasis stripped) and `parentPhaseKey` (a sub-phase's parent). state.cts imports them; its call sites are unchanged. * fix(#2562): own milestone-shipped detection and accept a workstream scope Three changes to the module that owns milestone parsing, so its consumers stop reimplementing it: - `isMilestoneShippedInRoadmap(content, version)` answers "does the ROADMAP mark THIS milestone shipped" from heading and `<summary>` lines only. A bullet that merely names the version (`- [x] 03-01: ship the v2.0 login endpoint`) is prose about a phase, not a milestone verdict. The version token is boundary-matched with `(?![\w.-])` — `\b` does not bound it, since `.` is a non-word character, so a shipped `v2.0.1` heading would otherwise close `v2.0`. - `extractCurrentMilestone` and `getMilestonePhaseFilter` take an optional trailing workstream name and thread it to `planningDir(cwd, ws)`. A caller iterating workstreams cannot set `GSD_WORKSTREAM` per iteration, which is what the existing resolution falls back to. Omitted, resolution is unchanged. - `getMilestonePhaseFilter` exposes `versionScoped`, true only when the phase set really is one milestone's. On an unversioned roadmap `phaseCount` spans the project's lifetime and must not be read as a current-milestone denominator. The closed/active milestone-marker patterns were kept in three byte-identical copies; they are hoisted to module scope as one `isClosedMilestoneHeading`. * fix(#2562): derive membership and denominator from one phase-key space The milestone scoping added earlier in this PR derived the ROADMAP table key and the phase-directory key with two different regexes, and dropped rows it could not attribute. Each of those was another way to reproduce the symptom this issue reports — a rollup contradicting its own `phases[]` listing: - a padded `| 01. … |` row never matched a `1-slug` directory (and a bespoke `^0*(\d+…)` never matched `PROJ-05-…` at all), so phases fell out of the milestone entirely and the percentage collapsed or pinned; - a blank or malformed Milestone cell deleted the phase from BOTH sides, letting an unstarted phase vanish and the remainder round to 100%; - shipped detection scanned bullets, so any checkmarked line naming the version closed the milestone; - the numerator counted per-directory while the denominator counted distinct phases, so a stale same-numbered directory (Bug #2445's scenario) pushed `completed_phases` past the denominator, where `Math.min` capped it to 100% and hid the unstarted phase. Both sides now key off the phase-id owner module (`phaseKeyFromDir` / `phaseKeyFromProse`), directory membership additionally consults `getMilestonePhaseFilter` when that filter is genuinely version-scoped, and the denominator is the union of the roadmap's declarations with the member directories' keys — so `completed_phases <= denominator` holds by construction. The Builder asserts it and throws; the `Math.min` cap survives only on the legacy unscoped path, where the denominator is a heading count that cannot bound the numerator. An unattributable row degrades over-inclusively (kept, never dropped), matching the degrade direction roadmap-parser already commits to. * test(#2562): boundary coverage for each milestone-scoping reproduction One test per way the scoping could still report "milestone complete"/100% while phases are incomplete: zero-padded rows vs padded dirs (and the mirror), project-code-prefixed dirs, a blank/malformed Milestone cell, a checkmarked bullet naming the version, a shipped `v2.0.1` heading against a current `v2.0`, and a stale same-numbered directory. Plus the current milestone's own shipped heading (the signal must survive the boundary fix), the Builder's numerator-above-denominator throw, a parity check that every non-`passed` verifier status blocks completeness, and a guard that scoping reads the workstream's ROADMAP rather than the project root's. Reverting only `src/` reddens six of them. * docs(#2562): record the milestone-scoped semantics and its consumer impact CONTEXT.md: the Workstream Inventory Module's completion fields now describe the current milestone, not the workstream's lifetime; phase-id owns the canonical phase-key surface; roadmap-parser owns milestone shipped/active classification and takes an optional workstream scope. Changeset: name the behaviour change explicitly — `roadmap_phase_count`, `completed_phases` and `progress_percent` change meaning with no schema signal, and `getOtherActiveWorkstreamInventories` filters on the derived status, so consumers see real movement. * fix(#2562): collapse every zero-padding spelling to one phase key A property test over the key surface — table cell and directory decorated INDEPENDENTLY, which is the point — found a divergence neither review named: `padStart(2, '0')` is a no-op once the input is already ≥2 characters, so `5` normalised to `05` while `005` stayed `005`. A `| 5. … |` row and a `005-slug` directory therefore never compared equal, which is the same failure mode as the padded-vs-unpadded blocker, one level down. The strip belongs in `phaseKeyFromToken`, not in `normalizePhaseName`: applying it to the latter regressed multi-decimal leading-zero plan IDs (`001.10-PLAN.md` capture + wave assignment), which rely on its verbatim rendering. Confining it to the key surface fixes the comparison and leaves rendering untouched. Also tightens `isMilestoneShippedInRoadmap`'s patterns to anchored, complementary character classes so an untrusted ROADMAP cannot drive backtracking, and makes the project-code test discriminating — it previously passed pre-fix, because an unresolvable key collapsed scoping to the whole roadmap and happened to land on the same number. It now carries a prior-milestone directory that a collapse would wrongly admit. * fix(#2562): prefer the milestone-attributing Progress table; pin the seams Three gaps the earlier self-check missed: - Both RoadmapProgress variants carry a `Plans Complete` column, so probing it first picked a FLAT table appearing earlier in the document over the milestone-grouped one that actually carries the attribution. Every row came back unattributed, was treated as current-milestone, and silently re-admitted prior-milestone phases. The attributing shape is probed first; flipping the order reddens the new test. - `lint-phase-id-drift` exempts phase-id.cts by design, so it is silent on `phaseKeyFromToken`'s own segment strip by construction — not evidence. Its interaction with `stripProjectCodePrefix` (which runs AFTER) is pinned across project codes and hyphenated ids, including the pre-existing `M1-46-6` vs `M1-46-6-rs` asymmetry, which is `extractPhaseToken`'s #2043/#2232 slug-word rule and not something to "fix" by accident. - `listWorkstreamInventories` loops every workstream with no try/catch, so a REACHABLE Builder-invariant throw would take down `workstream list`/`status`/ `progress` for all of them. The invariant test only exercised the pure Builder with hand-built inputs. A test now drives `inspectWorkstream` over every adversarial shape at once (prior-milestone dirs, three colliding duplicates, a dirless declaration, an unattributed row, a project-code prefix, a dir-only sub-phase) and asserts it does not throw and the invariant holds — so the throw stays a contract assertion for external callers, not a runtime path. Also covers the active-marker-wins rule (`## v2.0 — 🚧 IN PROGRESS … ✅` must not mark shipped), which nothing exercised. * fix(#2562): scope a declared-but-empty current milestone instead of falling back to history The review's open MAJOR. `STATE.md`'s `milestone:` field updates the moment `/gsd-new-milestone` writes the heading, while the `## Progress` table and phase sections land later. In that window nothing attributes a phase to the current milestone, `scoped` went false, and the fallback counted the project's ENTIRE phase history as both numerator and denominator — a milestone with zero work done reported 100% off its predecessors'. That is #2562's own symptom reached by a different precondition, and none of the 16 tests covered it. Reproduced first, four ROADMAP shapes, at `inspectWorkstream` rather than the Builder — the Builder takes the scoping decision as an input, so a Builder-level test proves it honours a flag, not that the derivation sets it. Three of the four reported 2/2 100% with no phase of the current milestone begun. Which signal witnesses the state depends on the ROADMAP's shape, and no single one covers all three: - `## v3.0` exists but declares no phases. `getMilestonePhaseFilter` DOES locate the section and sets `versionScoped`, then the zero-phase pass-all degrade resets it to false — erasing the only evidence the milestone exists. Neither existing flag survives that path, so this adds `versionSectionFound`, set beside `versionScoped` and deliberately preserved through the degrade. - No section for this version at all, in a roadmap that versions its others — the existing `missingExplicitVersion`, already exposed and tested. - Unversioned headings, but a Progress table attributing every row elsewhere: neither filter flag fires and the table is the only witness. A ROADMAP that attributes NO versions anywhere matches none of them, which is the point. Its rows parse with `version: null`, land in `currentMilestoneKeys`, and never reach the new branch. `readCurrentMilestoneVersion` returns a non-null version for very nearly every project (`getMilestoneInfo` defaults to `v1.0`), so keying off `currentVersion` alone would have zeroed out every free-form legacy project — the condition looks fussy for that reason. A test pins it. Within an empty milestone, membership inverts: a directory belongs unless another milestone's row claims it. Excluding everything would have dropped a phase scaffolded before the roadmap caught up from BOTH sides of the rollup, and hiding real work is the same class of defect as inventing it — this codebase degrades over-inclusive, never under. Scoping is now stated by the caller (`milestoneScoped`) rather than inferred from `currentMilestonePhaseCount > 0`. That inference was the root cause: it cannot represent a milestone that is scoped AND legitimately zero-phase, so the Builder read "no phases yet" as "no scoping" and reopened the whole-history path. The count-derived value stays the default for callers that say nothing. A regression test also pins that a zero denominator does not trip the Builder's `completed_phases <= denominator` throw, since `listWorkstreamInventories` has no try/catch and a crash on every freshly-declared milestone would be worse than a wrong percentage. The changeset and CONTEXT.md no longer claim membership is derived in "ONE" / "a SINGLE" phase-key space. `getMilestonePhaseFilter` still runs its own `normalizePhaseIdSegments`; the signals are OR'd so a divergence can only widen membership, but two normalisers coexist and the docs now say so. * fix(#2562): cross-validate the shipped marker against the milestone's artifacts `status: "milestone complete"` was asserted from the shipped marker alone, so a single payload could report it beside `progress_percent: 67` — this issue's own symptom, reached through `status` rather than the percentage. The marker is now a claim checked against the milestone's own artifacts, and the two signals are checked at DIFFERENT strengths because one check cannot serve both. A `heading` marker (operator-typed, live ROADMAP) is refused on a short completion ratio, which also catches phases declared but never scaffolded. A `snapshot` marker is NOT ratio-gated: `milestone complete` moves the milestone's phase dirs into `milestones/<version>-phases/` (milestone.cts:755-762) while copying — never truncating — the live ROADMAP (:671-674), so a correctly archived milestone reads 0/N by construction and a ratio gate would strip `milestone complete` from every archived milestone. It is refused instead when an in-milestone phase dir is still live and unfinished, reachable because `milestone complete` does not advance STATE's `milestone:` field (state-transition.cts:1335 vs :1224). `legacy` stays ungated — only reachable when scoping is off. A refused marker does not fall through to a STATE field claiming the same thing; against contradicting artifacts neither source may report completion. The refusal surfaces as `milestone_shipped_unverified` rather than staying silent, distinct from `status_conflict` (derived-vs-field only). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd * test(#2562): pin both marker strengths, the archived guard, and the owner modules workstream-inventory: four tests, all four red against the prior src and green with it. A live-ROADMAP SHIPPED heading over an incomplete milestone is refused; an archived snapshot SURVIVES its phase dirs being moved out (the regression the obvious single ratio-gate would cause — swapping the snapshot branch to that gate reddens this AND the pre-existing `CURRENT-version snapshot marks the milestone complete` at :321); an archived snapshot is refused once a phase is reopened under it; and a refused marker is not re-asserted by a STATE field claiming the same. roadmap-parser: `isMilestoneShippedInRoadmap` gets unit coverage at its owner module rather than only through the inventory that consumes it, plus two characterisation tests for `getMilestonePhaseFilter`'s legacy call surface — omitting the new trailing `ws` param is indistinguishable from `undefined`/`null`, and the `GSD_WORKSTREAM` env fallback still resolves. These characterise the call surface; they do not stand in for coverage of its individual callers. phase-id: the `phaseKeyFrom*` / `parentPhaseKey` one-key-space contract, incl. a property that padding a directory number never changes its key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd * docs(#2562): record the two-strength shipped cross-check + milestone_shipped_unverified Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd * fix(#2562): refuse an archived snapshot on a DIRTY archive, not just a live-unfinished dir The snapshot arm checked `liveIncompletePhases > 0`, which misses the shape @davesienkowski reproduced: a COMPLETE live dir beside a phase declared in the Progress table with no directory. Nothing is live-and-unfinished, the marker sails through, and `cmdWorkstreamProgress` returns `{"status":"milestone complete","progress_percent":50}` — the reported symptom verbatim, from one payload. Reproduced at 483a3ba30 before changing anything. His diagnosis is the right one and better than mine: an in-milestone directory outliving the archive means the archive is not CLEAN, and once that is true the completion ratio is meaningful again. So the check is the conjunction — any live in-milestone dir AND `completedPhases < effectivePhaseCount`. That strictly subsumes the old predicate (an incomplete member dir is in the denominator and not the numerator, so the ratio is always short when one exists) and leaves the clean-archive guard green, since a clean archive has no live dirs at all. Also corrects the module comment: the `scoped &&` prefix ungates all three signals, not just `legacy`. That is correct behaviour — unscoped, the denominator is the whole-roadmap count and membership is everything, so there is no current-milestone artifact set to check a current-milestone claim against — but the comment claimed otherwise. And the `milestone.cts` citations were ~28 lines stale after the rebase; they are now :700-702 (copy) and :783-790 (move), re-verified against this head. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd * fix(#2562): project milestone_shipped_unverified from list, status and progress The inventory carried the field and every renderer dropped it — `workstream.cts` was not in this PR's diff at all — so at the CLI a refused marker looked exactly like no marker: a fallback `status` and nothing saying one was seen and rejected. That is the silent collapse this issue is about, reintroduced one layer up, and it made the changeset's "visible rather than silent" claim false at every surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd * test(#2562): pin the dirty-archive shape and the CLI projection Five tests, all five red against the prior src and green with it. The reviewer's repro at the builder: an archived snapshot with a COMPLETE live dir beside a dirless declared phase must be refused, and status must not contradict the percentage. Four at the CLI via runGsdTools, the surface that was dropping the field rather than the builder that already had it: `workstream progress`/`status`/`list` each project `milestone_shipped_unverified: true` for that workstream, and a clean archive still reports `false` with `status: "milestone complete"`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd * docs(#2562): correct the snapshot check, the scoped-only caveat and the CLI claim Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PpFzuEHKTN1jaypSN44rzd --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
786 lines
39 KiB
TypeScript
786 lines
39 KiB
TypeScript
/**
|
|
* Pure phase-id parsing/matching helpers — normalize, token match,
|
|
* milestone/phase-dir id parsing, phase-markdown regex builders.
|
|
*
|
|
* Extracted from core.cts (ADR-857 rollout phase 2a / issue #865).
|
|
* The hand-written bodies are preserved byte-for-behaviour; only the module
|
|
* boundary moved. The core.cjs re-export spine was retired in epic #1267;
|
|
* callers import phase-id helpers from phase-id.cjs directly.
|
|
*
|
|
* Dependencies: none (pure string/regex, no Node built-ins required).
|
|
*/
|
|
|
|
// ─── Phase-id helpers ─────────────────────────────────────────────────────────
|
|
|
|
function escapeRegex(value: unknown): string {
|
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
// project_code values start with an uppercase letter (e.g. PROJ, APP_CODE);
|
|
// leading underscores are not valid project codes per .planning/config.json.
|
|
const PROJECT_CODE_PREFIX_STRIP_RE = /^[A-Z][A-Z0-9_]*-(?=\d)/;
|
|
const PROJECT_CODE_PREFIX_STRIP_RE_I = /^[A-Z][A-Z0-9_]*-(?=\d)/i;
|
|
const PROJECT_CODE_PREFIX_CAPTURE_RE_I = /^([A-Z][A-Z0-9_]*)-(\d.*)/i;
|
|
const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?';
|
|
|
|
// #1729: phase headers may carry a parenthetical tag between the number and the
|
|
// colon, e.g. `### Phase 26 (Cluster B): Title`. This optional, non-capturing
|
|
// fragment is injected at every phase-header regex call site (immediately after
|
|
// the phase-number token, before the colon/space delimiter) so the resolver
|
|
// tolerates the tag — mirroring how `[...]` is already tolerated before `Phase`.
|
|
// `[^)\n]*` keeps the match single-line (headers are one line) to avoid
|
|
// over-consuming across a malformed multi-line document. Injected at the call
|
|
// site (not baked into phaseMarkdownRegexSource) so it applies uniformly to
|
|
// both the numeric and project-code-exact escaped sources, and so the decimal
|
|
// sub-phase patterns can place it after the `.N` segment.
|
|
//
|
|
// Enumeration/parse call sites that read phase headers from a regex *literal*
|
|
// (rather than a `new RegExp` built from an interpolated phase number) cannot
|
|
// reference this constant; they inline its literal-regex mirror instead —
|
|
// `(?:\s*\([^)\n]{0,200}\))?` — kept character-for-character equivalent to this
|
|
// source. Both forms must change together; see the #1729 regression test.
|
|
const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
|
|
|
|
// #2128: the canonical phase-NUMBER-TOKEN grammar — a phase number with an
|
|
// optional single-letter variant suffix and optional dotted sub-phases
|
|
// (1, 01, 12A, 12.1, 3.2.1). This is the ENUMERATION/scan counterpart to
|
|
// phaseMarkdownRegexSource: use phaseMarkdownRegexSource(n) to build a source
|
|
// for ONE KNOWN number; reference this constant when a call site must match ANY
|
|
// phase and capture its token. Enumeration/parse sites inline this into a
|
|
// `new RegExp(...)` instead of re-deriving the grammar as a literal, so every
|
|
// phase-token producer shares one owner. The anti-divergence guard
|
|
// (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is
|
|
// introduced outside this module without a `// phase-id-owner:` justification.
|
|
const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
|
|
|
|
// #2232: the canonical CONTINUATION-segment grammar — a dash-separated segment
|
|
// that extends a phase token (a zero-padded sub-phase or plan number, e.g. the
|
|
// "01" in "02-01-setup"). getPhaseDirFromPhaseId writes these zero-padded to
|
|
// exactly 2 digits, so the digit RUN of a genuine continuation is exactly 2:
|
|
// #2043's `\d{2,}` (2-or-more) over-collected a slug word that merely leads
|
|
// with ≥2 digits (a year: "14-2026-photos-…" yielded token "14-2026", so every
|
|
// phase-locating verb reported the phase as missing). The `(?!\d)` guard caps
|
|
// the run at 2 without anchoring what may follow, so call sites keep their own
|
|
// trailing grammar (letter suffixes, dotted sub-phases, segment boundaries).
|
|
// POLICY (locked by boundary tests): sub-phase/plan numbers ≥100 are out of the
|
|
// dir-token grammar — the LEADING phase number stays unbounded (`\d+`), only
|
|
// continuation segments are width-capped. Shared from here so the five #2043
|
|
// call sites cannot drift independently (see scripts/lint-phase-id-drift.cjs).
|
|
const PHASE_CONTINUATION_SEGMENT_SOURCE = '\\d{2}(?!\\d)';
|
|
const PHASE_CONTINUATION_SEGMENT_PREFIX_RE = new RegExp(`^${PHASE_CONTINUATION_SEGMENT_SOURCE}`);
|
|
function isPhaseContinuationSegment(seg: string): boolean {
|
|
return PHASE_CONTINUATION_SEGMENT_PREFIX_RE.test(seg);
|
|
}
|
|
|
|
// #612 (PR-1): bracket-convention token/heading sources, kept next to the M-NN
|
|
// PHASE_NUMBER_TOKEN_SOURCE so this owner file stays the single origin of every
|
|
// phase-token grammar. `src/phase-id.cts` is exempt from the #2128 drift guard
|
|
// (scripts/lint-phase-id-drift.cjs) by construction, and that guard fails any
|
|
// literal re-derivation of the token grammar elsewhere — so the downstream
|
|
// bracket readers (PR-2: roadmap/validate/verify) must build their regexes by
|
|
// interpolating these exports, never by copying the literal.
|
|
//
|
|
// The canonical numeric WIDTH of a bracket identity field, mirroring pad2()'s
|
|
// output: exactly 2 digits, or 3+ with no leading zero. Owned here as a SOURCE
|
|
// so the read side (BRACKET_PHASE_TOKEN_SOURCE, below) and the emit-side
|
|
// validator (CANONICAL_NUMERIC_RE, which toDir enforces) are one rule rather
|
|
// than two literals that agree today and drift tomorrow.
|
|
const BRACKET_CANONICAL_NUMERIC_SOURCE = '(?:[1-9]\\d{2,}|\\d{2})';
|
|
|
|
// BRACKET_PHASE_TOKEN_SOURCE differs from PHASE_NUMBER_TOKEN_SOURCE by a
|
|
// dot-OR-dash sub-separator: a bracket dir/heading numeric run is `MM-PP[.SS]`
|
|
// (a hyphen joins milestone↔phase, a dot joins phase↔sub-phase), whereas M-NN
|
|
// sub-phases are dot-only.
|
|
//
|
|
// The run is POSITIONAL, not a free repetition — `MM-PP[.SS][-LL]` — and each
|
|
// position gets the width its DELIMITER can actually afford:
|
|
//
|
|
// MM leading unbounded — delimited by the `{CODE}.` prefix
|
|
// -PP dash-1 canonical — the grammar REQUIRES this dash, so it is a field
|
|
// separator, not a continuation heuristic
|
|
// .SS dot canonical — a slug carries no dot (toDir sanitizes them
|
|
// away), so this position cannot collide
|
|
// -LL dash-2 #2232 cap — the ONLY slug-adjacent position, and therefore
|
|
// the only one a slug word can collide with
|
|
//
|
|
// #2232 reconciliation: the slug-adjacent position interpolates the single-owner
|
|
// PHASE_CONTINUATION_SEGMENT_SOURCE, so the #2232 bug class cannot reopen on the
|
|
// bracket path — dir `PROJ.01-14-2026-photos-…` (a slug leading with a year)
|
|
// yields `01-14`, never `01-14-2026`.
|
|
//
|
|
// DELIBERATE DIVERGENCE from the M-NN dir-token path (pinned by the parity gate
|
|
// in tests/continuation-grammar-parity.test.cjs, which fails if these two rules
|
|
// drift for a reason nobody intended): the non-slug-adjacent positions stay
|
|
// WIDER than #2232's cap. Bracket admits 3+-digit milestone/phase/sub-phase
|
|
// (CANONICAL_NUMERIC_RE — `[GSD.100] 05` is a pinned regression), and unlike the
|
|
// M-NN continuations those positions are delimiter-disambiguated rather than
|
|
// heuristically recognized, so there is no year collision to defend against.
|
|
// Interpolating the cap verbatim at every position would only under-collect ids
|
|
// that toDir itself emits: `PROJ.02-105-slug` (3-digit phase) would read as
|
|
// `02`, and `[GSD.02] 05.100` (3-digit sub-phase) as `05`. Upstream draws this
|
|
// same line for the same reason — core-utils/phase cap the paired PLAN component
|
|
// while the leading phase component stays unbounded (phase numbers ≥100 are
|
|
// legitimate). The trade-off this accepts is #2232's policy verbatim: a PLAN
|
|
// ≥100 is out of the token grammar.
|
|
//
|
|
// Still deliberately MORE PERMISSIVE than parsePhaseId's strict grammar (it
|
|
// admits a letter-suffixed and unpadded leading token that the parser rejects):
|
|
// this is a READ-TOLERANCE source for the PR-2 readers, which must recognize a
|
|
// bracket-shaped token before deciding what to do with it — it is not the
|
|
// emit/identity grammar. parsePhaseId stays the arbiter of well-formedness.
|
|
const BRACKET_PHASE_TOKEN_SOURCE =
|
|
`\\d+[A-Z]?` +
|
|
`(?:-${BRACKET_CANONICAL_NUMERIC_SOURCE}(?!\\d))?` +
|
|
`(?:\\.${BRACKET_CANONICAL_NUMERIC_SOURCE}(?!\\d))?` +
|
|
`(?:-${PHASE_CONTINUATION_SEGMENT_SOURCE})?`;
|
|
|
|
// A phase HEADING intro under bracket is either a `[...]` bracket (optionally
|
|
// followed by a `Phase ` label) or a bare `Phase ` label; a bare number is NOT
|
|
// a phase-heading intro. The `[^\]]{1,200}` bound mirrors the existing
|
|
// roadmap-parser heading regexes (ReDoS-safe: a header is one short line).
|
|
const PHASE_HEADING_PREFIX_SRC = '(?:\\[[^\\]]{1,200}\\]\\s*(?:Phase\\s+)?|Phase\\s+)';
|
|
|
|
function stripProjectCodePrefix(value: unknown, caseInsensitive = true): string {
|
|
const input = String(value);
|
|
const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
|
|
return input.replace(re, '');
|
|
}
|
|
|
|
function hasProjectCodePrefix(value: unknown): boolean {
|
|
return PROJECT_CODE_PREFIX_STRIP_RE_I.test(String(value));
|
|
}
|
|
|
|
function normalizePhaseName(phase: unknown): string {
|
|
const str = String(phase);
|
|
// Strip optional project_code prefix (e.g., 'CK-01' → '01')
|
|
const stripped = stripProjectCodePrefix(str, false);
|
|
// Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition).
|
|
const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
|
if (milestoneMatch) {
|
|
const major = milestoneMatch[1].padStart(2, '0');
|
|
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) {
|
|
const padded = match[1].padStart(2, '0');
|
|
// Preserve original case of letter suffix (#1962).
|
|
const letter = match[2] || '';
|
|
const decimal = match[3] || '';
|
|
return padded + letter + decimal;
|
|
}
|
|
// Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is
|
|
return str;
|
|
}
|
|
|
|
function getMilestoneFromPhaseId(phaseId: unknown, convention?: string): string | null {
|
|
// READING-B (#612): under the bracket convention the milestone comes from the
|
|
// `[PROJECT.MM]` / `{CODE}.{MM}-` prefix, never the phase-token leading
|
|
// integer (ADR-612 Decision 6). Gated on 'bracket' so the `null` and
|
|
// 'milestone-prefixed' (M-NN) paths keep the legacy leading-int rule
|
|
// (READING-A) below, byte-untouched. The optional parameter keeps this helper
|
|
// pure (no config read) and backward-compatible: every existing single-arg
|
|
// caller resolves to the unchanged READING-A body.
|
|
if (convention === 'bracket') {
|
|
const b = String(phaseId).match(/^([A-Z][A-Z0-9_]*)\.(\d+)/);
|
|
if (!b) return null;
|
|
const mm = parseInt(b[2], 10);
|
|
if (SENTINEL_RANGES.includes(mm)) return null; // sentinel milestones have no real milestone
|
|
return `v${mm}.0`;
|
|
}
|
|
const stripped = stripProjectCodePrefix(phaseId);
|
|
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: unknown, phaseName: string | null | undefined, projectCode: string | null | undefined): string | null {
|
|
const stripped = stripProjectCodePrefix(phaseId);
|
|
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;
|
|
}
|
|
|
|
// ─── Bracket phase-ID grammar (#612, PR-1) ──────────────────────────────────
|
|
// One pure round-trippable model (ADR-612 §3 / Decision 4). parsePhaseId
|
|
// accepts the display form `[PROJECT.MM] PP[.SS][-LL]` or the on-disk/token form
|
|
// `{PROJECT}.{MM}-{PP}[.{SS}][-{LL|slug}]`; renderPhaseId / toDir are its two
|
|
// emitters. READING-B: the milestone lives in the `[PROJECT.MM]` prefix, so no
|
|
// token dimension is ever overloaded (the M-NN collapse pinned in
|
|
// tests/adr-612-collision-characterization.test.cjs cannot occur on this path).
|
|
// `plan` is a filename-surface dimension only — renderPhaseId emits it; toDir
|
|
// drops it (directories carry a slug, not a plan). The project code follows the
|
|
// repo's established `[A-Z][A-Z0-9_]*` grammar (the config-validated
|
|
// project_code shape shared with OPTIONAL_PROJECT_CODE_PREFIX_SOURCE), not the
|
|
// ADR §1 illustration's `[A-Z]{1,6}`, so that every project_code the config
|
|
// permits (digits / underscore / >6 chars) parses.
|
|
//
|
|
// Strict-reject posture (ADR-612 Decision 4's `render(parse(x)) === x`
|
|
// contract, held exactly): parsePhaseId accepts ONLY the canonical form of
|
|
// each branch — unpadded numbers, over-padded numbers, and multi-space or
|
|
// stray leading/trailing whitespace are all rejected rather than silently
|
|
// normalized, so two distinct input strings can never parse to the same
|
|
// tuple while one of them fails to round-trip. toDir mirrors this on the
|
|
// write side: every interpolated PhaseId field is validated (PhaseId is a
|
|
// structural type — nothing forces callers through parsePhaseId, so a hand-
|
|
// built id must not be able to smuggle a path-traversal segment onto disk),
|
|
// and the slug must sanitize to a non-empty, non-all-digit token (an empty
|
|
// slug would leave a dangling trailing hyphen; an all-digit slug is
|
|
// string-indistinguishable from the plan grammar's trailing tail and would
|
|
// silently break the disk↔identity bijection on read-back).
|
|
type PhaseId = {
|
|
project: string; // 'GSD'
|
|
milestone: string; // '02' (zero-padded, from the bracket/dir prefix)
|
|
phase: string; // '05' (zero-padded)
|
|
subphase?: string; // '03' (optional)
|
|
plan?: string; // '01' (filename surface only)
|
|
};
|
|
|
|
const pad2 = (n: string): string => String(parseInt(n, 10)).padStart(2, '0');
|
|
|
|
function parsePhaseId(input: string): PhaseId {
|
|
// No .trim(): the match anchors (`^`...`$`) then reject leading/trailing
|
|
// whitespace outright, folding that case into the same "not a bracket
|
|
// phase id" rejection below rather than needing its own check.
|
|
const str = String(input);
|
|
|
|
// Display form: [PROJECT.MM] PP[.SS][-LL]. The match itself stays
|
|
// permissive on purpose (it will happily match an unpadded number or a
|
|
// multi-space run) — canonicality is enforced UNIFORMLY below via the
|
|
// render round-trip (ADR-612 Decision 4) rather than by hand-tuning every
|
|
// numeric / whitespace sub-pattern, so a field added later inherits the
|
|
// check for free instead of needing its own regex micro-surgery.
|
|
const disp = str.match(/^\[([A-Z][A-Z0-9_]*)\.(\d+)\]\s+(\d+)(?:\.(\d+))?(?:-(\d+))?$/);
|
|
if (disp) {
|
|
const id: PhaseId = { project: disp[1], milestone: pad2(disp[2]), phase: pad2(disp[3]) };
|
|
if (disp[4] !== undefined) id.subphase = pad2(disp[4]);
|
|
if (disp[5] !== undefined) id.plan = pad2(disp[5]);
|
|
// Canonicality by construction: re-render the parsed id and require
|
|
// byte-equality with the input. This rejects unpadded ('[GSD.5] 5'),
|
|
// over-padded ('[GSD.005] 05'), and multi-space-separated ('[GSD.02] 05')
|
|
// variants uniformly, without special-casing any one of them — the emit
|
|
// path (renderPhaseId) is the single source of truth for "canonical".
|
|
if (renderPhaseId(id) !== str) {
|
|
throw new Error(`parsePhaseId: not canonical: ${JSON.stringify(input)}`);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
// Dir / token form: {PROJECT}.{MM}-{PP}[.{SS}][-{plan|slug}]
|
|
const dir = str.match(/^([A-Z][A-Z0-9_]*)\.(\d+)-(\d+)(?:\.(\d+))?(?:-(.+))?$/);
|
|
if (dir) {
|
|
const id: PhaseId = { project: dir[1], milestone: pad2(dir[2]), phase: pad2(dir[3]) };
|
|
if (dir[4] !== undefined) id.subphase = pad2(dir[4]);
|
|
// Trailing segment: a pure-integer tail is the plan; anything else is a
|
|
// slug (dropped from the tuple — it is not an identity dimension). The
|
|
// plan tail participates in the canonicality check below; the slug tail
|
|
// is read-tolerant pass-through (a slug is not an identity dimension) and
|
|
// is exempt from it.
|
|
const tail = dir[5];
|
|
const tailIsPlan = tail !== undefined && /^\d+$/.test(tail);
|
|
if (tailIsPlan) id.plan = pad2(tail);
|
|
|
|
// Canonicality by construction, mirroring the display branch: rebuild the
|
|
// exact dir/token string this id would emit and require it match the
|
|
// input verbatim. Rejects unpadded milestone/phase ('GSD.2-5') and
|
|
// unpadded plan tails ('GSD.02-05-1') without special-casing either.
|
|
const sub = id.subphase ? `.${id.subphase}` : '';
|
|
const tailOut = tail === undefined ? '' : tailIsPlan ? `-${pad2(tail)}` : `-${tail}`;
|
|
const canonical = `${id.project}.${id.milestone}-${id.phase}${sub}${tailOut}`;
|
|
if (canonical !== str) {
|
|
throw new Error(`parsePhaseId: not canonical: ${JSON.stringify(input)}`);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
// Ambiguous / bare tokens (e.g. `02-04`, `05`, `2-01`) match neither branch,
|
|
// as does a display/dir form carrying leading/trailing whitespace (the
|
|
// anchors never match it): reject rather than guess a tuple (ADR-612
|
|
// conservative default). The rejection lives ONLY in this new parser —
|
|
// normalizePhaseName and every other legacy reader keep accepting those
|
|
// tokens unchanged.
|
|
throw new Error(`parsePhaseId: not a bracket phase id: ${JSON.stringify(input)}`);
|
|
}
|
|
|
|
function renderPhaseId(id: PhaseId): string {
|
|
const sub = id.subphase ? `.${id.subphase}` : '';
|
|
const plan = id.plan ? `-${id.plan}` : '';
|
|
return `[${id.project}.${id.milestone}] ${id.phase}${sub}${plan}`;
|
|
}
|
|
|
|
// PhaseId is a structural type: nothing forces a caller through parsePhaseId,
|
|
// so toDir cannot trust project/milestone/phase/subphase are already
|
|
// canonical — each is validated below against the exact shape parsePhaseId
|
|
// itself would ever produce, closing off a hand-built id as a path-traversal
|
|
// vector. PROJECT_ID_RE mirrors the parser's `[A-Z][A-Z0-9_]*` grammar;
|
|
// CANONICAL_NUMERIC_RE mirrors pad2()'s output shape — exactly 2 digits, or
|
|
// 3+ digits with no leading zero. It is BUILT from
|
|
// BRACKET_CANONICAL_NUMERIC_SOURCE rather than re-spelled as a literal, so this
|
|
// emit-side gate and the read-side token source cannot disagree about what
|
|
// "canonical width" means (the anchors here make the source's trailing `(?!\d)`
|
|
// guard, which the unanchored read side needs, redundant).
|
|
const PROJECT_ID_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
const CANONICAL_NUMERIC_RE = new RegExp(`^${BRACKET_CANONICAL_NUMERIC_SOURCE}$`);
|
|
|
|
function toDir(id: PhaseId, slug: string): string {
|
|
if (!PROJECT_ID_RE.test(id.project)) {
|
|
throw new Error(`toDir: invalid project: ${JSON.stringify(id.project)}`);
|
|
}
|
|
if (!CANONICAL_NUMERIC_RE.test(id.milestone)) {
|
|
throw new Error(`toDir: invalid milestone: ${JSON.stringify(id.milestone)}`);
|
|
}
|
|
if (!CANONICAL_NUMERIC_RE.test(id.phase)) {
|
|
throw new Error(`toDir: invalid phase: ${JSON.stringify(id.phase)}`);
|
|
}
|
|
if (id.subphase !== undefined && !CANONICAL_NUMERIC_RE.test(id.subphase)) {
|
|
throw new Error(`toDir: invalid subphase: ${JSON.stringify(id.subphase)}`);
|
|
}
|
|
// A non-string slug (e.g. an omitted second argument) must not be silently
|
|
// coerced by String(...) into the literal token 'undefined'/'null' on disk.
|
|
if (typeof slug !== 'string') {
|
|
throw new Error(`toDir: slug must be a string: ${JSON.stringify(slug)}`);
|
|
}
|
|
|
|
const sub = id.subphase ? `.${id.subphase}` : '';
|
|
// Slug guard: the slug becomes an on-disk path segment, so collapse it to a
|
|
// safe lowercase token — never a path separator or `..` traversal.
|
|
const safeSlug = slug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
// A slug that sanitizes to nothing (e.g. '!!!') would otherwise emit a
|
|
// dangling trailing hyphen.
|
|
if (!safeSlug) {
|
|
throw new Error(`toDir: slug sanitizes to empty: ${JSON.stringify(slug)}`);
|
|
}
|
|
// An all-digit slug (e.g. '2026') is string-indistinguishable from the
|
|
// parsePhaseId dir branch's plan tail, so it would re-parse as a plan, not
|
|
// a slug — silently breaking the disk↔identity bijection on read-back.
|
|
if (/^\d+$/.test(safeSlug)) {
|
|
throw new Error(`toDir: slug must not be all-digit: ${JSON.stringify(slug)}`);
|
|
}
|
|
return `${id.project}.${id.milestone}-${id.phase}${sub}-${safeSlug}`;
|
|
}
|
|
|
|
// Milestone integers reserved as non-milestone sentinels (0.x backlog / 999.x
|
|
// icebox); a phase id in these ranges has no real milestone.
|
|
const SENTINEL_RANGES: readonly number[] = Object.freeze([0, 999]);
|
|
|
|
function isSentinelPhaseId(phaseId: unknown, convention?: string): boolean {
|
|
const s = String(phaseId);
|
|
// Bracket milestone lives in the `{CODE}.{MM}` prefix. GATED on
|
|
// convention === 'bracket' for the same reason as extractPhaseToken below and
|
|
// getMilestoneFromPhaseId above: that prefix is string-indistinguishable from
|
|
// the legacy #1324 letter-prefixed-decimal family (`P0.0-foundation` is a real
|
|
// phase, NOT sentinel milestone 0) whenever the code ends in a digit. A
|
|
// convention-less caller uses the legacy/bare leading-int rule below, so no
|
|
// existing reader gains a false positive; the bracket reading is opt-in.
|
|
if (convention === 'bracket') {
|
|
const bracket = s.match(/^[A-Z][A-Z0-9_]*\.(\d+)/); // bracket: milestone in the prefix
|
|
if (bracket) return SENTINEL_RANGES.includes(parseInt(bracket[1], 10));
|
|
}
|
|
const legacy = stripProjectCodePrefix(s).match(/^0*(\d+)/); // legacy/bare: leading int
|
|
if (!legacy) return false;
|
|
return SENTINEL_RANGES.includes(parseInt(legacy[1], 10));
|
|
}
|
|
|
|
/**
|
|
* Render a regex source fragment matching a phase number against ROADMAP/STATE
|
|
* prose regardless of zero-padding on either side.
|
|
*/
|
|
function phaseMarkdownRegexSource(phaseNum: unknown): string {
|
|
const stripped = stripProjectCodePrefix(phaseNum);
|
|
|
|
// Milestone-prefixed IDs: M-NN or M-N-N (deep).
|
|
const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
|
|
if (milestoneSegments && milestoneSegments[2]) {
|
|
const majorUnpadded = milestoneSegments[1].replace(/^0+/, '') || '0';
|
|
const subParts = milestoneSegments[2].slice(1).split('-');
|
|
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);
|
|
|
|
const integer = match[1].replace(/^0+/, '') || '0';
|
|
const letter = match[2] ? escapeRegex(match[2]) : '';
|
|
const decimal = match[3] ? escapeRegex(match[3]) : '';
|
|
return `0*${escapeRegex(integer)}${letter}${decimal}`;
|
|
}
|
|
|
|
/**
|
|
* #3599: when the caller passed a project-code-prefixed ID like `PROJ-42`,
|
|
* return the exact-escaped form.
|
|
*/
|
|
function phaseMarkdownRegexSourceExact(phaseNum: unknown): string | null {
|
|
const raw = String(phaseNum);
|
|
if (!hasProjectCodePrefix(raw)) return null;
|
|
return escapeRegex(raw);
|
|
}
|
|
|
|
function comparePhaseNum(a: unknown, b: unknown): number {
|
|
// Strip optional project_code prefix before comparing
|
|
const sa = stripProjectCodePrefix(a);
|
|
const sb = stripProjectCodePrefix(b);
|
|
|
|
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;
|
|
}
|
|
const sufA = milestoneA[3] || '';
|
|
const sufB = milestoneB[3] || '';
|
|
if (sufA !== sufB) return sufA < sufB ? -1 : 1;
|
|
return 0;
|
|
}
|
|
|
|
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 (!pa || !pb) return String(a).localeCompare(String(b));
|
|
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
|
|
if (intDiff !== 0) return intDiff;
|
|
const la = (pa[2] || '').toUpperCase();
|
|
const lb = (pb[2] || '').toUpperCase();
|
|
if (la !== lb) {
|
|
if (!la) return -1;
|
|
if (!lb) return 1;
|
|
return la < lb ? -1 : 1;
|
|
}
|
|
const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
|
|
const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
|
|
const maxLen = Math.max(aDecParts.length, bDecParts.length);
|
|
if (aDecParts.length === 0 && bDecParts.length > 0) return -1;
|
|
if (bDecParts.length === 0 && aDecParts.length > 0) return 1;
|
|
for (let i = 0; i < maxLen; i++) {
|
|
const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0;
|
|
const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0;
|
|
if (av !== bv) return av - bv;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Extract the phase token from a directory name.
|
|
*/
|
|
function extractPhaseToken(dirName: string, convention?: string): string {
|
|
// #612 bracket dir form `{CODE}.{MM}-{PP}[.{SS}]-slug` → phase token `PP[.SS]`.
|
|
// GATED on convention === 'bracket' (mirrors getMilestoneFromPhaseId's READING-B
|
|
// decision above). A bracket dir `{CODE}.{MM}-{PP}` is string-INDISTINGUISHABLE
|
|
// from the legacy #2043/#1324 letter-prefixed-decimal family (`P0.3-2`,
|
|
// `P0.12-34`) whenever the project code ends in a digit, so NO string-only
|
|
// discriminator can separate the two conventions — auto-detecting here silently
|
|
// reinterpreted `P0.3-2` → `2` (was `P0.3-2`), a byte-identical-read regression
|
|
// on this CRITICAL 6-caller helper (ADR-2121). Requiring an explicit convention
|
|
// signal keeps every existing (convention-less) call site byte-identical to
|
|
// prior behaviour — see the #2043 numeric-tail characterization in
|
|
// tests/phase-id.test.cjs — while keeping the helper pure (optional param, no
|
|
// config read). The captured token is dot-only (`PP[.SS]`); the milestone↔phase
|
|
// hyphen and any trailing plan/slug are excluded.
|
|
if (convention === 'bracket') {
|
|
const bracketDir = dirName.match(/^[A-Z][A-Z0-9_]*\.\d+-(\d+(?:\.\d+)?)/);
|
|
if (bracketDir) return bracketDir[1];
|
|
}
|
|
|
|
const codePrefixMatch = dirName.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
|
|
let prefix = '';
|
|
let rest = dirName;
|
|
if (codePrefixMatch) {
|
|
prefix = codePrefixMatch[1] + '-';
|
|
rest = codePrefixMatch[2];
|
|
}
|
|
|
|
const segments = rest.split('-');
|
|
const tokenSegments: string[] = [];
|
|
// #2043: distinguish a real (zero-padded) phase/sub-phase segment from a
|
|
// single-digit slug word. A pure-numeric leading segment ("46") only
|
|
// continues with exactly-2-digit segments (#2232: a ≥3-digit run is a slug
|
|
// word such as a year — "14-2026-photos-…" yields "14", not "14-2026"), so
|
|
// "46-6-rs-…" yields "46" (the "6" is the
|
|
// slug's first word), not "46-6". Milestone-prefixed ids like "M1-2" reach here
|
|
// with "M1-" already stripped as a project-code prefix (see
|
|
// PROJECT_CODE_PREFIX_CAPTURE_RE_I), so "2" is the leading segment and the same
|
|
// pure-numeric rule applies (M1-46-6-rs → "M1-46"). The firstLetterPrefixed
|
|
// carve-out covers letter+digit leading segments that survive prefix stripping
|
|
// because of punctuation (e.g. "P0.3-2"), whose single-digit continuation is
|
|
// intentionally preserved (unchanged from prior behaviour).
|
|
let firstLetterPrefixed = false;
|
|
for (let i = 0; i < segments.length; i++) {
|
|
const seg = segments[i];
|
|
if (i === 0) {
|
|
if (/^\d/.test(seg)) {
|
|
tokenSegments.push(seg);
|
|
} else if (/^[A-Za-z]{1,3}\d/.test(seg)) {
|
|
tokenSegments.push(seg);
|
|
firstLetterPrefixed = true;
|
|
} else {
|
|
break;
|
|
}
|
|
} else if (isPhaseContinuationSegment(seg) || (firstLetterPrefixed && /^\d/.test(seg))) {
|
|
tokenSegments.push(seg);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (tokenSegments.length === 0) {
|
|
return dirName;
|
|
}
|
|
|
|
return prefix + tokenSegments.join('-');
|
|
}
|
|
|
|
/**
|
|
* Check if a directory name's phase token matches the normalized phase exactly.
|
|
*/
|
|
function phaseTokenMatches(dirName: string, normalized: string): boolean {
|
|
const token = extractPhaseToken(dirName);
|
|
if (token.toUpperCase() === normalized.toUpperCase()) return true;
|
|
const stripped = stripProjectCodePrefix(dirName);
|
|
if (stripped !== dirName) {
|
|
const strippedToken = extractPhaseToken(stripped);
|
|
if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ─── Canonical phase KEY surface (#2562) ─────────────────────────────────────
|
|
//
|
|
// A phase "key" is the padding-, case- and project-code-insensitive identity of
|
|
// a phase, for use as a Map/Set key when two independently-derived phase
|
|
// references (a ROADMAP table cell and a phase directory name, say) must be
|
|
// compared. Promoted here from a local pair in state.cts (#2445) so every
|
|
// consumer derives BOTH sides of a comparison from the SAME function — deriving
|
|
// one side with a bespoke regex is the #2562 defect class (a `01` table cell
|
|
// never matching a `1-slug` directory, silently zeroing a rollup).
|
|
|
|
/**
|
|
* Canonical key for an already-extracted phase TOKEN (`"5"`, `"05"`, `"005"`,
|
|
* `"12A"`, `"30.1"`, `"PROJ-05"`). Padding- and case-insensitive: every
|
|
* spelling of a number collapses to one key.
|
|
*
|
|
* Leading zeros are stripped per hyphen-separated segment BEFORE
|
|
* `normalizePhaseName` pads to the 2-digit convention. Padding alone is not a
|
|
* normalisation — `padStart(2)` is a no-op once the input is already ≥2
|
|
* characters, so `5` yielded `05` while `005` stayed `005` and the two never
|
|
* compared equal. The strip is deliberately confined to this key surface:
|
|
* `normalizePhaseName` itself is a RENDERING function whose verbatim treatment
|
|
* of wide IDs (`001.10`) is relied on by plan-ID capture and wave assignment.
|
|
* Arithmetic is avoided (`parseInt` would lose precision on a long digit run).
|
|
*/
|
|
function phaseKeyFromToken(token: unknown): string {
|
|
const stripped = String(token)
|
|
.split('-')
|
|
.map(segment => segment.replace(/^0+(?=\d)/, ''))
|
|
.join('-');
|
|
return normalizePhaseName(stripped).toUpperCase();
|
|
}
|
|
|
|
/**
|
|
* Canonical key for a phase DIRECTORY name (`"05-schedule-8"` → `"05"`,
|
|
* `"PROJ-5-x"` → `"05"`, `"30.1-follow-up"` → `"30.1"`).
|
|
*/
|
|
function phaseKeyFromDir(dirName: string): string {
|
|
return phaseKeyFromToken(extractPhaseToken(dirName));
|
|
}
|
|
|
|
/**
|
|
* Canonical key for a phase referenced in PROSE — a ROADMAP `## Progress` table
|
|
* cell (`"30. Schedule 8 rollout"`, `"**05.1 Follow-up**"`) or a STATE.md
|
|
* `Phase:` value. Markdown emphasis is stripped first so a bolded cell is not
|
|
* mistaken for a non-phase. Returns null when the value does not BEGIN with a
|
|
* phase token (`parsePhaseFromProse` anchoring, #2111).
|
|
*/
|
|
function phaseKeyFromProse(value: string | null | undefined): string | null {
|
|
if (value == null) return null;
|
|
const { phase } = parsePhaseFromProse(String(value).replace(/[*_`~]/g, ''));
|
|
return phase === null ? null : phaseKeyFromToken(phase);
|
|
}
|
|
|
|
/**
|
|
* The PARENT phase key of a sub-phase key (`"30.1"` → `"30"`), or null for a
|
|
* top-level phase. A sub-phase directory inserted mid-milestone frequently has
|
|
* no ROADMAP row of its own and inherits its parent's milestone (#2562).
|
|
*/
|
|
function parentPhaseKey(key: string): string | null {
|
|
const dot = key.indexOf('.');
|
|
return dot === -1 ? null : key.slice(0, dot);
|
|
}
|
|
|
|
// ─── #2121 canonical surface (ADR-2121) ──────────────────────────────────────
|
|
|
|
/**
|
|
* Parse a phase identifier from a STATE.md `Phase:` prose field VALUE — the text
|
|
* after the `Phase:` label (e.g. `"3 of 4 (Delta)"`, `"3A — Delta (executing)"`,
|
|
* or `"Milestone v0.5 complete"`).
|
|
*
|
|
* The token is anchored to the START of the value (after an optional literal
|
|
* `Phase ` label and an optional project-code prefix) so a phase is only
|
|
* returned when the value actually begins with one. This is the #2111 fix: the
|
|
* prior unanchored `/\b(\d+[A-Z]?(?:\.\d+)*)\b/i` mined the first numeral
|
|
* anywhere, so `"Milestone v0.5 complete"` collapsed to `"5"` (the minor-version
|
|
* digit) and `"v1.0"` to `"0"` (a reserved sentinel). Here both yield
|
|
* `{ phase: null }` because they do not begin with a phase token. The name
|
|
* extraction (parenthetical or em-dash tail, minus status words) is unchanged.
|
|
*/
|
|
function parsePhaseFromProse(value: string | null): { phase: string | null; name: string | null } {
|
|
if (!value) return { phase: null, name: null };
|
|
// Coerce defensively so a non-string caller cannot throw on this canonical
|
|
// surface (mirrors the sibling #2121 functions' String(...) handling).
|
|
const str = String(value);
|
|
const phaseMatch = str.match(/^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i);
|
|
// The name-extraction quantifiers are length-bounded so a crafted long
|
|
// unterminated run (many `(` or `—`) in an untrusted STATE.md field value
|
|
// cannot drive O(n^2) regex backtracking (CPU-exhaustion DoS). A real phase
|
|
// name is far shorter than the cap.
|
|
const parenName = str.match(/\(([^)]{1,200})\)/);
|
|
// #2736 (the #1695 AC #3 residual): status-keyword-aware precedence. The
|
|
// first-party writer shapes are `N — Name (aside)` (completePhaseCore),
|
|
// `N (Name) — EXECUTING` (beginPhaseCore), `N — COMPLETE`, and the
|
|
// gsd2-import `N (slug) — Milestone: Title`. A blind paren-first read
|
|
// harvests the aside as the name on the first shape; a blind dash-first
|
|
// read harvests the status keyword on the others. Prefer the em-dash name
|
|
// when it is a genuine name, else fall back to the parenthetical. Still
|
|
// lossy for names that themselves contain a parenthetical — transitions
|
|
// that hold the exact name bypass this parser entirely via the
|
|
// syncStateFrontmatter authoritative override.
|
|
//
|
|
// The em-dash separator is searched on a paren-stripped copy, so an em-dash
|
|
// INSIDE a parenthetical name (`16 (Native — Global Hotkey) — EXECUTING`)
|
|
// can never be mistaken for the name separator.
|
|
const strNoParens = str.replace(/\([^)\n]{0,200}\)/g, ' ');
|
|
const dashName = strNoParens.match(/—\s*([^(\n]{1,200}?)\s*$/);
|
|
// The precedence-decision vocabulary is deliberately broader than the final
|
|
// name-nulling filter below: a dash tail that merely LOOKS like a status
|
|
// annotation should lose to a parenthetical name, without changing which
|
|
// extracted names are nulled (that set stays the long-standing three).
|
|
const STATUS_WORD_RE = /^(?:complete|executing|not started)$/i;
|
|
const STATUSY_TAIL_RE = /^(?:completed?|executing|not started|planning|planned|ready(?:\s+to\s+\S.{0,50})?|done|in progress|blocked|paused|verifying)$/i;
|
|
const dashRaw = dashName?.[1]?.trim() ?? null;
|
|
const dashIsName = dashRaw !== null && dashRaw.length > 0
|
|
&& !STATUSY_TAIL_RE.test(dashRaw)
|
|
&& !/^milestone\s*:/i.test(dashRaw)
|
|
// A lone ALL-CAPS token after the dash reads as a status marker whenever a
|
|
// parenthetical name exists to prefer (the beginPhase writer's systematic
|
|
// `(Name) — STATUS` shape); with no parenthetical it stays the best guess.
|
|
&& !(parenName && /^[A-Z][A-Z0-9_-]*$/.test(dashRaw));
|
|
const rawName = dashIsName ? dashRaw : (parenName?.[1] ?? dashRaw ?? null);
|
|
const name = rawName && !STATUS_WORD_RE.test(rawName.trim())
|
|
? rawName.trim()
|
|
: null;
|
|
return {
|
|
phase: phaseMatch ? phaseMatch[1] : null,
|
|
name,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Config-AWARE project-code prefix strip. Unlike the config-blind
|
|
* `stripProjectCodePrefix` (which strips ANY `<CODE>-` shape), this strips the
|
|
* leading `<CODE>-` ONLY when `<CODE>` case-insensitively equals the configured
|
|
* `projectCode`. A foreign prefix (`MEM-01` when the configured code is `LKML`)
|
|
* or an absent/empty `projectCode` is preserved verbatim — this is the #2104
|
|
* fix: a foreign-prefixed id must not collapse to a bare numeric phase and
|
|
* collide with a real one.
|
|
*/
|
|
function stripConfiguredProjectCodePrefix(value: unknown, projectCode: string | null | undefined): string {
|
|
const input = String(value);
|
|
const configured = typeof projectCode === 'string' ? projectCode.trim() : '';
|
|
if (!configured) return input;
|
|
const m = input.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
|
|
if (!m) return input;
|
|
if (m[1].toUpperCase() !== configured.toUpperCase()) return input;
|
|
return m[2];
|
|
}
|
|
|
|
/**
|
|
* True when `phase` carries a project-code prefix that is NOT the configured
|
|
* `projectCode` (or when no `projectCode` is configured). The canonical
|
|
* predicate the init-command foreign-prefix guard (#2056 / PR #2105) delegates
|
|
* to, so every call site shares one foreign-prefix rule.
|
|
*/
|
|
function isForeignPrefixedPhaseQuery(phase: unknown, projectCode: unknown): boolean {
|
|
const m = String(phase).match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
|
|
if (!m) return false;
|
|
const configured = typeof projectCode === 'string' ? projectCode.trim() : '';
|
|
return !configured || m[1].toUpperCase() !== configured.toUpperCase();
|
|
}
|
|
|
|
/**
|
|
* Canonical ROADMAP heading lookup-source list (moved here from
|
|
* roadmap-parser.cts so phase-id.cts is the single owner of the ordering).
|
|
* Sources are tried in a fixed, deduplicated order: exact (only when the query
|
|
* itself is project-code-prefixed) → bare numeric / padding-tolerant →
|
|
* prefix-tolerant fallback. The bare numeric source precedes the prefix-tolerant
|
|
* form so a canonical heading (`### Phase 117:`) is preferred over a drifted
|
|
* prefixed one (`### Phase MANIFOLD-117:`) when both exist in one ROADMAP.
|
|
*/
|
|
function roadmapPhaseLookupSources(phaseNum: unknown): string[] {
|
|
const sources: string[] = [];
|
|
const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
|
|
if (exactSource) sources.push(exactSource);
|
|
|
|
const numericSource = phaseMarkdownRegexSource(phaseNum);
|
|
sources.push(numericSource);
|
|
sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
|
|
|
|
return [...new Set(sources)];
|
|
}
|
|
|
|
export = {
|
|
escapeRegex,
|
|
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
|
OPTIONAL_PHASE_TAG_SOURCE,
|
|
PHASE_NUMBER_TOKEN_SOURCE,
|
|
PHASE_CONTINUATION_SEGMENT_SOURCE,
|
|
isPhaseContinuationSegment,
|
|
BRACKET_PHASE_TOKEN_SOURCE,
|
|
PHASE_HEADING_PREFIX_SRC,
|
|
stripProjectCodePrefix,
|
|
normalizePhaseName,
|
|
getMilestoneFromPhaseId,
|
|
getPhaseDirFromPhaseId,
|
|
parsePhaseId,
|
|
renderPhaseId,
|
|
toDir,
|
|
SENTINEL_RANGES,
|
|
isSentinelPhaseId,
|
|
phaseMarkdownRegexSource,
|
|
phaseMarkdownRegexSourceExact,
|
|
comparePhaseNum,
|
|
extractPhaseToken,
|
|
phaseTokenMatches,
|
|
phaseKeyFromToken,
|
|
phaseKeyFromDir,
|
|
phaseKeyFromProse,
|
|
parentPhaseKey,
|
|
parsePhaseFromProse,
|
|
stripConfiguredProjectCodePrefix,
|
|
isForeignPrefixedPhaseQuery,
|
|
roadmapPhaseLookupSources,
|
|
};
|