* 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>
1033 lines
46 KiB
JavaScript
1033 lines
46 KiB
JavaScript
/**
|
||
* Tests for src/phase-id.cts (compiled to gsd-core/bin/lib/phase-id.cjs).
|
||
*
|
||
* Verifies behavioural contracts of the extracted pure phase-id helpers:
|
||
* - escapeRegex
|
||
* - normalizePhaseName
|
||
* - comparePhaseNum
|
||
* - extractPhaseToken
|
||
* - phaseTokenMatches
|
||
* - phaseMarkdownRegexSource
|
||
* - phaseMarkdownRegexSourceExact
|
||
* - getMilestoneFromPhaseId
|
||
* - getPhaseDirFromPhaseId
|
||
* - core.cjs re-export shims resolve to the exact same functions (single instance)
|
||
*
|
||
* ADR-857 rollout phase 2a / issue #865.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { test, describe } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
|
||
const fc = require('fast-check');
|
||
|
||
// ─── escapeRegex ─────────────────────────────────────────────────────────────
|
||
|
||
describe('escapeRegex', () => {
|
||
test('escapes all regex special characters', () => {
|
||
assert.strictEqual(phaseId.escapeRegex('.'), '\\.');
|
||
assert.strictEqual(phaseId.escapeRegex('*'), '\\*');
|
||
assert.strictEqual(phaseId.escapeRegex('+'), '\\+');
|
||
assert.strictEqual(phaseId.escapeRegex('?'), '\\?');
|
||
assert.strictEqual(phaseId.escapeRegex('^'), '\\^');
|
||
assert.strictEqual(phaseId.escapeRegex('$'), '\\$');
|
||
assert.strictEqual(phaseId.escapeRegex('{'), '\\{');
|
||
assert.strictEqual(phaseId.escapeRegex('}'), '\\}');
|
||
assert.strictEqual(phaseId.escapeRegex('('), '\\(');
|
||
assert.strictEqual(phaseId.escapeRegex(')'), '\\)');
|
||
assert.strictEqual(phaseId.escapeRegex('|'), '\\|');
|
||
assert.strictEqual(phaseId.escapeRegex('['), '\\[');
|
||
assert.strictEqual(phaseId.escapeRegex(']'), '\\]');
|
||
assert.strictEqual(phaseId.escapeRegex('\\'), '\\\\');
|
||
});
|
||
|
||
test('leaves alphanumeric and hyphen characters unescaped', () => {
|
||
assert.strictEqual(phaseId.escapeRegex('abc'), 'abc');
|
||
assert.strictEqual(phaseId.escapeRegex('01-02'), '01-02');
|
||
assert.strictEqual(phaseId.escapeRegex('v1.0'), 'v1\\.0');
|
||
});
|
||
|
||
test('coerces non-string values via String()', () => {
|
||
assert.strictEqual(phaseId.escapeRegex(42), '42');
|
||
assert.strictEqual(phaseId.escapeRegex(null), 'null');
|
||
assert.strictEqual(phaseId.escapeRegex(undefined), 'undefined');
|
||
});
|
||
|
||
test('adversarial: path-traversal-like inputs are treated as literals', () => {
|
||
const result = phaseId.escapeRegex('../../../etc/passwd');
|
||
// The dots get escaped; slashes and alphanumeric pass through unchanged
|
||
assert.strictEqual(result, '\\.\\./\\.\\./\\.\\./etc/passwd');
|
||
// The result forms a valid regex (no throws)
|
||
assert.doesNotThrow(() => new RegExp(result));
|
||
});
|
||
|
||
test('unicode passthrough', () => {
|
||
assert.strictEqual(phaseId.escapeRegex('Phase Name'), 'Phase Name');
|
||
assert.strictEqual(phaseId.escapeRegex('中文'), '中文');
|
||
});
|
||
});
|
||
|
||
// ─── normalizePhaseName ───────────────────────────────────────────────────────
|
||
|
||
describe('normalizePhaseName', () => {
|
||
test('zero-pads single-digit phase', () => {
|
||
assert.strictEqual(phaseId.normalizePhaseName('1'), '01');
|
||
assert.strictEqual(phaseId.normalizePhaseName('3'), '03');
|
||
});
|
||
|
||
test('leaves two-digit phase unchanged', () => {
|
||
assert.strictEqual(phaseId.normalizePhaseName('12'), '12');
|
||
});
|
||
|
||
test('strips project_code prefix before normalizing', () => {
|
||
assert.strictEqual(phaseId.normalizePhaseName('CK-01'), '01');
|
||
assert.strictEqual(phaseId.normalizePhaseName('PROJ-3'), '03');
|
||
assert.strictEqual(phaseId.normalizePhaseName('AB-12'), '12');
|
||
assert.strictEqual(phaseId.normalizePhaseName('MANIFOLD-7'), '07');
|
||
assert.strictEqual(phaseId.normalizePhaseName('APP1-7'), '07');
|
||
assert.strictEqual(phaseId.normalizePhaseName('APP_1-7'), '07');
|
||
});
|
||
|
||
test('does not strip leading-underscore pseudo-prefix (#1455)', () => {
|
||
// Valid project_code values must start with [A-Z]; leading underscores
|
||
// (_FOO-7, _-7) are not valid codes and must not be stripped.
|
||
assert.strictEqual(phaseId.normalizePhaseName('_FOO-7'), '_FOO-7');
|
||
assert.strictEqual(phaseId.normalizePhaseName('_-7'), '_-7');
|
||
});
|
||
|
||
test('handles letter suffix (preserves original case per #1962)', () => {
|
||
assert.strictEqual(phaseId.normalizePhaseName('12A'), '12A');
|
||
assert.strictEqual(phaseId.normalizePhaseName('3b'), '03b');
|
||
});
|
||
|
||
test('handles decimal phase IDs', () => {
|
||
assert.strictEqual(phaseId.normalizePhaseName('12.1'), '12.1');
|
||
assert.strictEqual(phaseId.normalizePhaseName('3.10'), '03.10');
|
||
});
|
||
|
||
test('handles milestone-prefixed IDs (M-NN form)', () => {
|
||
assert.strictEqual(phaseId.normalizePhaseName('1-1'), '01-01');
|
||
assert.strictEqual(phaseId.normalizePhaseName('2-3'), '02-03');
|
||
assert.strictEqual(phaseId.normalizePhaseName('1-2-3'), '01-02-03');
|
||
});
|
||
|
||
test('custom phase IDs: project_code prefix is stripped, then numeric part is normalized', () => {
|
||
// The project-code prefix is stripped, leaving a numeric token that normalizes to '42' (no leading zero needed for 2+ digits).
|
||
assert.strictEqual(phaseId.normalizePhaseName('PROJ-42'), '42');
|
||
assert.strictEqual(phaseId.normalizePhaseName('AUTH-101'), '101');
|
||
assert.strictEqual(phaseId.normalizePhaseName('MANIFOLD-117'), '117');
|
||
});
|
||
|
||
test('custom phase IDs with non-numeric remainder pass through as-is', () => {
|
||
// No project_code pattern, no numeric match → return str as-is
|
||
assert.strictEqual(phaseId.normalizePhaseName('my-phase'), 'my-phase');
|
||
});
|
||
|
||
test('coerces non-string values', () => {
|
||
assert.strictEqual(phaseId.normalizePhaseName(5), '05');
|
||
});
|
||
});
|
||
|
||
// ─── comparePhaseNum ──────────────────────────────────────────────────────────
|
||
|
||
describe('comparePhaseNum', () => {
|
||
test('sorts numeric phases in ascending order', () => {
|
||
const phases = ['03', '01', '10', '02'];
|
||
const sorted = [...phases].sort(phaseId.comparePhaseNum);
|
||
assert.deepStrictEqual(sorted, ['01', '02', '03', '10']);
|
||
});
|
||
|
||
test('compares single-digit vs two-digit correctly', () => {
|
||
assert.ok(phaseId.comparePhaseNum('1', '02') < 0);
|
||
assert.ok(phaseId.comparePhaseNum('02', '1') > 0);
|
||
assert.strictEqual(phaseId.comparePhaseNum('1', '01'), 0);
|
||
});
|
||
|
||
test('handles decimal phases', () => {
|
||
assert.ok(phaseId.comparePhaseNum('1', '1.1') < 0);
|
||
assert.ok(phaseId.comparePhaseNum('1.1', '1.2') < 0);
|
||
assert.ok(phaseId.comparePhaseNum('1.10', '1.9') > 0);
|
||
assert.strictEqual(phaseId.comparePhaseNum('1.1', '01.1'), 0);
|
||
});
|
||
|
||
test('handles letter suffix ordering (no letter < A < B)', () => {
|
||
assert.ok(phaseId.comparePhaseNum('01', '01A') < 0);
|
||
assert.ok(phaseId.comparePhaseNum('01A', '01B') < 0);
|
||
assert.ok(phaseId.comparePhaseNum('01B', '01') > 0);
|
||
});
|
||
|
||
test('handles milestone-prefixed IDs', () => {
|
||
assert.ok(phaseId.comparePhaseNum('1-1', '1-2') < 0);
|
||
assert.ok(phaseId.comparePhaseNum('2-1', '1-10') > 0);
|
||
assert.ok(phaseId.comparePhaseNum('1-2-3', '1-2-4') < 0);
|
||
assert.strictEqual(phaseId.comparePhaseNum('01-01', '1-1'), 0);
|
||
});
|
||
|
||
test('strips project_code prefix before comparing', () => {
|
||
assert.strictEqual(phaseId.comparePhaseNum('CK-01', '01'), 0);
|
||
assert.ok(phaseId.comparePhaseNum('CK-01', 'CK-02') < 0);
|
||
assert.strictEqual(phaseId.comparePhaseNum('MANIFOLD-117', '117'), 0);
|
||
assert.strictEqual(phaseId.comparePhaseNum('APP1-117', '117'), 0);
|
||
assert.strictEqual(phaseId.comparePhaseNum('APP_1-117', '117'), 0);
|
||
});
|
||
|
||
test('handles non-parseable phase IDs via localeCompare fallback', () => {
|
||
// Should not throw on non-numeric IDs
|
||
const result = phaseId.comparePhaseNum('alpha', 'beta');
|
||
assert.strictEqual(typeof result, 'number');
|
||
});
|
||
});
|
||
|
||
// ─── extractPhaseToken ────────────────────────────────────────────────────────
|
||
|
||
describe('extractPhaseToken', () => {
|
||
test('extracts simple numeric token from directory name', () => {
|
||
assert.strictEqual(phaseId.extractPhaseToken('01-some-phase-name'), '01');
|
||
assert.strictEqual(phaseId.extractPhaseToken('12A-feature'), '12A');
|
||
});
|
||
|
||
test('extracts milestone-prefixed numeric token', () => {
|
||
assert.strictEqual(phaseId.extractPhaseToken('01-02-some-name'), '01-02');
|
||
assert.strictEqual(phaseId.extractPhaseToken('02-03-04-deep'), '02-03-04');
|
||
});
|
||
|
||
test('extracts token with project_code prefix', () => {
|
||
assert.strictEqual(phaseId.extractPhaseToken('CK-01-some-phase'), 'CK-01');
|
||
assert.strictEqual(phaseId.extractPhaseToken('PROJ-12-feature'), 'PROJ-12');
|
||
assert.strictEqual(phaseId.extractPhaseToken('MANIFOLD-117-feature'), 'MANIFOLD-117');
|
||
assert.strictEqual(phaseId.extractPhaseToken('APP1-117-feature'), 'APP1-117');
|
||
assert.strictEqual(phaseId.extractPhaseToken('APP_1-117-feature'), 'APP_1-117');
|
||
});
|
||
|
||
test('extracts glued letter-prefix phase tokens (#1324)', () => {
|
||
assert.strictEqual(phaseId.extractPhaseToken('P0.3-tenant-primitives'), 'P0.3');
|
||
assert.strictEqual(phaseId.extractPhaseToken('P0.0-foundation'), 'P0.0');
|
||
assert.strictEqual(phaseId.extractPhaseToken('P0.16-gate'), 'P0.16');
|
||
assert.strictEqual(phaseId.extractPhaseToken('M1-2-brain'), 'M1-2');
|
||
});
|
||
|
||
// #612/#2249: the #2043/#1324 letter-prefixed-decimal family has a NUMERIC-tail
|
||
// variant (`P0.3-2`) the #1324 pins above never covered — every tail there is
|
||
// non-numeric (`-tenant`, `-gate`) or hyphen-only (`M1-2`). PR-1 added a bracket
|
||
// dir reader `{CODE}.{MM}-{PP}` to extractPhaseToken; because that shape is
|
||
// string-indistinguishable from this family when the code ends in a digit, the
|
||
// reader is GATED on an explicit `convention` arg. This characterization locks
|
||
// the convention-less (legacy) reading byte-identical across the WHOLE family —
|
||
// single- AND multi-digit tails — so the gate can never silently regress it.
|
||
// (The multi-digit rows are precisely the ones no discriminator-tightening fix
|
||
// could have preserved: `P0.12-34` stays ambiguous with a padded bracket dir,
|
||
// whereas the convention gate is complete.)
|
||
test('preserves the #2043 numeric-tail letter-prefixed family (convention-less, byte-identical)', () => {
|
||
assert.strictEqual(phaseId.extractPhaseToken('P0.3-2-tenant'), 'P0.3-2');
|
||
assert.strictEqual(phaseId.extractPhaseToken('P1.2-3'), 'P1.2-3');
|
||
assert.strictEqual(phaseId.extractPhaseToken('A0.1-2'), 'A0.1-2');
|
||
assert.strictEqual(phaseId.extractPhaseToken('X9.9-9-name'), 'X9.9-9');
|
||
assert.strictEqual(phaseId.extractPhaseToken('P0.12-34-name'), 'P0.12-34');
|
||
assert.strictEqual(phaseId.extractPhaseToken('P0.34-56-name'), 'P0.34-56');
|
||
assert.strictEqual(phaseId.extractPhaseToken('P0X.3-2'), 'P0X.3-2');
|
||
});
|
||
|
||
test('returns the full dirName when no numeric token found', () => {
|
||
assert.strictEqual(phaseId.extractPhaseToken('no-numeric'), 'no-numeric');
|
||
assert.strictEqual(phaseId.extractPhaseToken('alpha'), 'alpha');
|
||
assert.strictEqual(phaseId.extractPhaseToken('phase-name-01'), 'phase-name-01');
|
||
});
|
||
|
||
test('stops at first non-numeric-starting segment', () => {
|
||
assert.strictEqual(phaseId.extractPhaseToken('01-02-name-03'), '01-02');
|
||
});
|
||
|
||
test('rejects a single-digit slug word after a phase number (#2043)', () => {
|
||
// A phase dir like "46-6-rs-pipeline-orchestrator" (roadmap phase name
|
||
// "6 Rs Pipeline Orchestrator" → slug "6-rs-...") must yield token "46",
|
||
// not "46-6" — the "6" is the slug's first word, not a sub-phase segment.
|
||
assert.strictEqual(phaseId.extractPhaseToken('46-6-rs-pipeline-orchestrator'), '46');
|
||
assert.strictEqual(phaseId.extractPhaseToken('68-6-rs'), '68');
|
||
// Legit cases are unaffected: a real zero-padded milestone-sub-phase pair
|
||
// stays intact, and a single-digit sub-phase after a letter-prefixed
|
||
// milestone id (e.g. "M1-2") is still valid.
|
||
assert.strictEqual(phaseId.extractPhaseToken('01-02-some-name'), '01-02');
|
||
assert.strictEqual(phaseId.extractPhaseToken('M1-2-brain'), 'M1-2');
|
||
// Milestone-prefixed convention: "M1-" strips as a project-code prefix, so
|
||
// the same rule fixes the slug-collision there too — a phase 46 named
|
||
// "6 Rs …" under milestone M1 yields "M1-46", not "M1-46-6". Phase 6 under
|
||
// M1 ("M1-6-rs") correctly stays "M1-6" (the 6 is the phase number).
|
||
assert.strictEqual(phaseId.extractPhaseToken('M1-46-6-rs-pipeline-orchestrator'), 'M1-46');
|
||
assert.strictEqual(phaseId.extractPhaseToken('M1-6-rs-pipeline'), 'M1-6');
|
||
// Single-digit + letter-suffix phase id ("1A") is a real token, not a slug word.
|
||
assert.strictEqual(phaseId.extractPhaseToken('1A-brain'), '1A');
|
||
});
|
||
|
||
test('rejects a ≥3-digit slug word after a phase number (#2232)', () => {
|
||
// Roadmap phase name "2026 Photos & Performance" slugifies to
|
||
// "2026-photos-performance"; dir "14-2026-photos-performance" must yield
|
||
// token "14", not "14-2026" — the year is the slug's first word, not a
|
||
// sub-phase segment (the residual case #2043 scoped out).
|
||
assert.strictEqual(phaseId.extractPhaseToken('14-2026-photos-performance'), '14');
|
||
assert.ok(
|
||
phaseId.phaseTokenMatches('14-2026-photos-performance', phaseId.normalizePhaseName('14')),
|
||
'phase 14 must match its own dir despite the year-leading slug',
|
||
);
|
||
// Boundary by continuation-segment digit width (the locked policy: a
|
||
// continuation is EXACTLY the 2-digit zero-padded form the write side emits):
|
||
assert.strictEqual(phaseId.extractPhaseToken('46-6-rs'), '46'); // 1-digit: slug word (#2043)
|
||
assert.strictEqual(phaseId.extractPhaseToken('01-02-name'), '01-02'); // 2-digit: sub-phase
|
||
assert.strictEqual(phaseId.extractPhaseToken('05-100-slug'), '05'); // 3-digit: slug word (policy)
|
||
assert.strictEqual(phaseId.extractPhaseToken('14-2026-photos'), '14'); // 4-digit: year slug word
|
||
// Milestone-prefixed variant collides the same way. Composed from parts
|
||
// rather than written as one literal: GitGuardian's generic high-entropy
|
||
// detector false-positives on the joined form (an alphanumeric run with
|
||
// separators reads as a token/key shape to it). The assertion is identical;
|
||
// only the source spelling changes.
|
||
const mPrefix = 'M1';
|
||
assert.strictEqual(
|
||
phaseId.extractPhaseToken(`${mPrefix}-14-2026-photos`),
|
||
`${mPrefix}-14`,
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── phaseTokenMatches ────────────────────────────────────────────────────────
|
||
|
||
describe('phaseTokenMatches', () => {
|
||
test('matches exact token (case-insensitive)', () => {
|
||
assert.ok(phaseId.phaseTokenMatches('01-some-phase', '01'));
|
||
assert.ok(phaseId.phaseTokenMatches('12A-feature', '12A'));
|
||
assert.ok(phaseId.phaseTokenMatches('12A-feature', '12a'));
|
||
});
|
||
|
||
test('matches with project_code prefix stripped', () => {
|
||
assert.ok(phaseId.phaseTokenMatches('CK-01-phase', '01'));
|
||
assert.ok(phaseId.phaseTokenMatches('PROJ-12-feature', '12'));
|
||
assert.ok(phaseId.phaseTokenMatches('MANIFOLD-117-feature', '117'));
|
||
assert.ok(phaseId.phaseTokenMatches('APP1-117-feature', '117'));
|
||
assert.ok(phaseId.phaseTokenMatches('APP_1-117-feature', '117'));
|
||
});
|
||
|
||
test('matches glued letter-prefix phase dirs (#1324)', () => {
|
||
assert.ok(phaseId.phaseTokenMatches('P0.3-tenant-primitives', 'P0.3'));
|
||
assert.ok(phaseId.phaseTokenMatches('M1-2-brain', 'M1-2'));
|
||
assert.ok(!phaseId.phaseTokenMatches('P0.3-tenant-primitives', 'P0.4'));
|
||
});
|
||
|
||
test('does not match when token differs', () => {
|
||
assert.ok(!phaseId.phaseTokenMatches('01-some-phase', '02'));
|
||
assert.ok(!phaseId.phaseTokenMatches('12A-feature', '12B'));
|
||
});
|
||
|
||
test('matches milestone-prefixed token', () => {
|
||
assert.ok(phaseId.phaseTokenMatches('01-02-feature', '01-02'));
|
||
assert.ok(!phaseId.phaseTokenMatches('01-02-feature', '01-03'));
|
||
});
|
||
});
|
||
|
||
// ─── phaseMarkdownRegexSource ─────────────────────────────────────────────────
|
||
|
||
describe('phaseMarkdownRegexSource', () => {
|
||
test('produces a regex source that matches zero-padded variants', () => {
|
||
const src = phaseId.phaseMarkdownRegexSource('1');
|
||
const re = new RegExp(src);
|
||
assert.ok(re.test('1'));
|
||
assert.ok(re.test('01'));
|
||
assert.ok(re.test('001'));
|
||
});
|
||
|
||
test('produces source matching a two-digit phase', () => {
|
||
const src = phaseId.phaseMarkdownRegexSource('12');
|
||
const re = new RegExp(src);
|
||
assert.ok(re.test('12'));
|
||
assert.ok(re.test('012'));
|
||
assert.ok(!re.test('13'));
|
||
});
|
||
|
||
test('handles letter suffix', () => {
|
||
const src = phaseId.phaseMarkdownRegexSource('12A');
|
||
const re = new RegExp(src, 'i');
|
||
assert.ok(re.test('12A'));
|
||
assert.ok(re.test('012A'));
|
||
});
|
||
|
||
test('handles decimal phases', () => {
|
||
const src = phaseId.phaseMarkdownRegexSource('3.1');
|
||
const re = new RegExp(src);
|
||
assert.ok(re.test('3.1'));
|
||
assert.ok(re.test('03.1'));
|
||
assert.ok(!re.test('3.2'));
|
||
});
|
||
|
||
test('handles milestone-prefixed phase IDs', () => {
|
||
const src = phaseId.phaseMarkdownRegexSource('1-2');
|
||
const re = new RegExp(src);
|
||
assert.ok(re.test('1-2'));
|
||
assert.ok(re.test('01-02'));
|
||
assert.ok(re.test('01-2'));
|
||
assert.ok(!re.test('1-3'));
|
||
});
|
||
|
||
test('strips project_code prefix before building regex', () => {
|
||
const withPrefix = phaseId.phaseMarkdownRegexSource('CK-01');
|
||
const withoutPrefix = phaseId.phaseMarkdownRegexSource('01');
|
||
assert.strictEqual(withPrefix, withoutPrefix);
|
||
assert.strictEqual(phaseId.phaseMarkdownRegexSource('MANIFOLD-117'), phaseId.phaseMarkdownRegexSource('117'));
|
||
});
|
||
|
||
test('falls back to escaped literal for unparseable input', () => {
|
||
const src = phaseId.phaseMarkdownRegexSource('v1.0');
|
||
assert.strictEqual(typeof src, 'string');
|
||
assert.ok(src.length > 0);
|
||
});
|
||
|
||
test('adversarial: phase num containing regex metacharacters is escaped', () => {
|
||
// e.g. some exotic value that shouldn't break regexp construction
|
||
const src = phaseId.phaseMarkdownRegexSource('3.1');
|
||
// The literal dot in "3.1" should be escaped so it only matches a real dot
|
||
const re = new RegExp(src);
|
||
assert.ok(!re.test('3X1'), 'unescaped dot would match any char — must be escaped');
|
||
});
|
||
});
|
||
|
||
// ─── phaseMarkdownRegexSourceExact ────────────────────────────────────────────
|
||
|
||
describe('phaseMarkdownRegexSourceExact', () => {
|
||
test('returns escaped form for project-code-prefixed IDs', () => {
|
||
const result = phaseId.phaseMarkdownRegexSourceExact('PROJ-42');
|
||
// hyphen is not a regex special char so it passes through unescaped
|
||
assert.strictEqual(result, 'PROJ-42');
|
||
// The result is a valid regex source
|
||
assert.doesNotThrow(() => new RegExp(result));
|
||
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('MANIFOLD-117'), 'MANIFOLD-117');
|
||
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('APP1-117'), 'APP1-117');
|
||
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('APP_1-117'), 'APP_1-117');
|
||
});
|
||
|
||
test('returns null for non-prefixed IDs', () => {
|
||
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('01'), null);
|
||
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('12A'), null);
|
||
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('1-2'), null);
|
||
});
|
||
|
||
test('null coercion: returns null for null/undefined', () => {
|
||
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact(null), null);
|
||
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact(undefined), null);
|
||
});
|
||
|
||
test('resulting regex matches the exact prefixed ID', () => {
|
||
const src = phaseId.phaseMarkdownRegexSourceExact('AUTH-101');
|
||
assert.ok(src !== null);
|
||
const re = new RegExp(src);
|
||
assert.ok(re.test('AUTH-101'));
|
||
assert.ok(!re.test('AUTH-102'));
|
||
});
|
||
});
|
||
|
||
// ─── getMilestoneFromPhaseId ──────────────────────────────────────────────────
|
||
|
||
describe('getMilestoneFromPhaseId', () => {
|
||
test('returns vN.0 for a milestone-prefixed phase id', () => {
|
||
assert.strictEqual(phaseId.getMilestoneFromPhaseId('1-01'), 'v1.0');
|
||
assert.strictEqual(phaseId.getMilestoneFromPhaseId('02-03'), 'v2.0');
|
||
assert.strictEqual(phaseId.getMilestoneFromPhaseId('10-5'), 'v10.0');
|
||
});
|
||
|
||
test('returns null for non-milestone-prefixed IDs', () => {
|
||
assert.strictEqual(phaseId.getMilestoneFromPhaseId('01'), null);
|
||
assert.strictEqual(phaseId.getMilestoneFromPhaseId('12A'), null);
|
||
});
|
||
|
||
test('returns null for special sentinel milestones 0 and 999', () => {
|
||
assert.strictEqual(phaseId.getMilestoneFromPhaseId('0-1'), null);
|
||
assert.strictEqual(phaseId.getMilestoneFromPhaseId('999-1'), null);
|
||
});
|
||
|
||
test('strips project_code prefix before parsing', () => {
|
||
assert.strictEqual(phaseId.getMilestoneFromPhaseId('CK-2-01'), 'v2.0');
|
||
assert.strictEqual(phaseId.getMilestoneFromPhaseId('MANIFOLD-2-01'), 'v2.0');
|
||
assert.strictEqual(phaseId.getMilestoneFromPhaseId('APP1-2-01'), 'v2.0');
|
||
assert.strictEqual(phaseId.getMilestoneFromPhaseId('APP_1-2-01'), 'v2.0');
|
||
});
|
||
|
||
test('coerces non-string values', () => {
|
||
// numeric doesn't match the milestone pattern — returns null
|
||
assert.strictEqual(phaseId.getMilestoneFromPhaseId(42), null);
|
||
});
|
||
});
|
||
|
||
// ─── getPhaseDirFromPhaseId ───────────────────────────────────────────────────
|
||
|
||
describe('getPhaseDirFromPhaseId', () => {
|
||
test('returns null for non-milestone-format IDs', () => {
|
||
assert.strictEqual(phaseId.getPhaseDirFromPhaseId('01', null, null), null);
|
||
assert.strictEqual(phaseId.getPhaseDirFromPhaseId('12A', null, null), null);
|
||
});
|
||
|
||
test('constructs dir name from milestone-prefixed phase id (no name, no code)', () => {
|
||
const result = phaseId.getPhaseDirFromPhaseId('1-2', null, null);
|
||
assert.strictEqual(result, '01-02');
|
||
});
|
||
|
||
test('includes phaseName slug', () => {
|
||
const result = phaseId.getPhaseDirFromPhaseId('1-2', 'My Feature', null);
|
||
assert.strictEqual(result, '01-02-my-feature');
|
||
});
|
||
|
||
test('prepends projectCode when provided', () => {
|
||
const result = phaseId.getPhaseDirFromPhaseId('1-2', 'Auth', 'CK');
|
||
assert.strictEqual(result, 'CK-01-02-auth');
|
||
});
|
||
|
||
test('strips project_code from phaseId before parsing', () => {
|
||
const result = phaseId.getPhaseDirFromPhaseId('CK-1-2', null, null);
|
||
assert.strictEqual(result, '01-02');
|
||
assert.strictEqual(phaseId.getPhaseDirFromPhaseId('MANIFOLD-1-2', null, null), '01-02');
|
||
assert.strictEqual(phaseId.getPhaseDirFromPhaseId('APP1-1-2', null, null), '01-02');
|
||
assert.strictEqual(phaseId.getPhaseDirFromPhaseId('APP_1-1-2', null, null), '01-02');
|
||
});
|
||
|
||
test('handles deep decomposition IDs (M-N-N)', () => {
|
||
// m[2] is "02-03" for input "1-2-3" — split and pad each sub-part
|
||
const result = phaseId.getPhaseDirFromPhaseId('1-2-3', null, null);
|
||
assert.strictEqual(result, '01-02-03');
|
||
});
|
||
|
||
test('slug strips leading/trailing hyphens from phaseName', () => {
|
||
const result = phaseId.getPhaseDirFromPhaseId('1-1', ' --some--name-- ', null);
|
||
// normalize: replace non-alnum runs with hyphen, strip edges
|
||
assert.ok(result !== null);
|
||
assert.ok(!result.startsWith('-'));
|
||
assert.ok(!result.endsWith('-'));
|
||
});
|
||
});
|
||
|
||
// ─── parsePhaseFromProse (#2121, anchored — fixes #2111) ─────────────────────
|
||
|
||
describe('parsePhaseFromProse', () => {
|
||
test('null / empty input yields null phase and name', () => {
|
||
assert.deepEqual(phaseId.parsePhaseFromProse(null), { phase: null, name: null });
|
||
assert.deepEqual(phaseId.parsePhaseFromProse(''), { phase: null, name: null });
|
||
});
|
||
|
||
test('#2111: a milestone-completion string carries no phase', () => {
|
||
assert.equal(phaseId.parsePhaseFromProse('Milestone v0.5 complete').phase, null);
|
||
assert.equal(phaseId.parsePhaseFromProse('Milestone v1.0 complete').phase, null);
|
||
assert.equal(phaseId.parsePhaseFromProse('Milestone v2.10 complete').phase, null);
|
||
});
|
||
|
||
test('#2111: a bare version token or stray numeral is not a phase', () => {
|
||
assert.equal(phaseId.parsePhaseFromProse('v0.5').phase, null);
|
||
assert.equal(phaseId.parsePhaseFromProse('v1.0').phase, null);
|
||
assert.equal(phaseId.parsePhaseFromProse('Fixed 12 bugs in v2.3').phase, null);
|
||
});
|
||
|
||
test('a genuine phase value (starting with the token) is parsed', () => {
|
||
assert.deepEqual(phaseId.parsePhaseFromProse('3 of 4 (Delta)'), { phase: '3', name: 'Delta' });
|
||
assert.deepEqual(phaseId.parsePhaseFromProse('3A — Delta'), { phase: '3A', name: 'Delta' });
|
||
assert.equal(phaseId.parsePhaseFromProse('12.1: Setup').phase, '12.1');
|
||
assert.equal(phaseId.parsePhaseFromProse('29 of 30').phase, '29');
|
||
assert.equal(phaseId.parsePhaseFromProse('029').phase, '029');
|
||
});
|
||
|
||
test('a leading project-code prefix is tolerated but not captured (bare token)', () => {
|
||
assert.equal(phaseId.parsePhaseFromProse('MEM-01 — Foo').phase, '01');
|
||
assert.equal(phaseId.parsePhaseFromProse('AB-29 of 30').phase, '29');
|
||
});
|
||
|
||
test('an optional leading "Phase" label is tolerated', () => {
|
||
assert.equal(phaseId.parsePhaseFromProse('Phase 3A — Delta').phase, '3A');
|
||
});
|
||
|
||
test('a status-word parenthetical is filtered from the name', () => {
|
||
// #2736 precedence change (the #1695 AC #3 residual): the em-dash name now
|
||
// wins when it is a genuine name, so `3A — Delta (executing)` yields
|
||
// 'Delta' (previously null — paren-priority harvested the status aside and
|
||
// the status filter nulled it, losing the real name).
|
||
assert.deepEqual(phaseId.parsePhaseFromProse('3A — Delta (executing)'), { phase: '3A', name: 'Delta' });
|
||
assert.equal(phaseId.parsePhaseFromProse('3 (complete)').name, null);
|
||
});
|
||
|
||
test('#2736: status-keyword-aware precedence across the first-party writer shapes', () => {
|
||
// completePhaseCore shape `N — Name (aside)`: the dash name wins; the
|
||
// name's own parenthetical is no longer harvested as the whole name.
|
||
assert.deepEqual(
|
||
phaseId.parsePhaseFromProse('48 — Closer-ruling measurement (D1a)'),
|
||
{ phase: '48', name: 'Closer-ruling measurement' },
|
||
);
|
||
// beginPhaseCore shape `N (Name) — EXECUTING`: the dash tail is a status
|
||
// keyword, so the parenthetical name still wins.
|
||
assert.deepEqual(
|
||
phaseId.parsePhaseFromProse('16 (Native Global Hotkey) — EXECUTING'),
|
||
{ phase: '16', name: 'Native Global Hotkey' },
|
||
);
|
||
// `N — COMPLETE` (state.cts phase-complete body line): status keyword on
|
||
// the dash, no paren → no name.
|
||
assert.deepEqual(phaseId.parsePhaseFromProse('5 — COMPLETE'), { phase: '5', name: null });
|
||
// gsd2-import shape `N (slug) — Milestone: Title`: the dash tail is a
|
||
// milestone label, not a name → the parenthetical still wins.
|
||
assert.deepEqual(
|
||
phaseId.parsePhaseFromProse('06 (setup) — Milestone: Foundation'),
|
||
{ phase: '06', name: 'setup' },
|
||
);
|
||
// Cross-AI review round 1: an em-dash INSIDE a parenthetical name must not
|
||
// be mistaken for the name separator (the dash search runs on a
|
||
// paren-stripped copy).
|
||
assert.deepEqual(
|
||
phaseId.parsePhaseFromProse('16 (Native — Global Hotkey) — EXECUTING'),
|
||
{ phase: '16', name: 'Native — Global Hotkey' },
|
||
);
|
||
// Cross-AI review round 1: status-LIKE dash tails beyond the canonical
|
||
// three lose to a parenthetical name (broader precedence vocabulary +
|
||
// the lone-ALL-CAPS-token heuristic), without changing which extracted
|
||
// names are nulled.
|
||
assert.equal(phaseId.parsePhaseFromProse('3 (Foundation) — COMPLETED').name, 'Foundation');
|
||
assert.equal(phaseId.parsePhaseFromProse('3 (Name) — In progress').name, 'Name');
|
||
assert.equal(phaseId.parsePhaseFromProse('3 (Name) — READY').name, 'Name');
|
||
assert.equal(phaseId.parsePhaseFromProse('3 (Name) — WIP').name, 'Name');
|
||
// With no parenthetical to prefer, an unknown dash tail stays the best guess.
|
||
assert.equal(phaseId.parsePhaseFromProse('3 — WIP').name, 'WIP');
|
||
});
|
||
|
||
test('#2124 review: name quantifiers are length-bounded (ReDoS guard)', () => {
|
||
// A parenthetical within the bound extracts; one longer than the bound is
|
||
// NOT matched — the cap is what prevents O(n^2) backtracking on a crafted
|
||
// untrusted value. Removing the bound would extract the long name → fail.
|
||
assert.equal(phaseId.parsePhaseFromProse('3 (Delta)').name, 'Delta');
|
||
assert.equal(phaseId.parsePhaseFromProse(`3 (${'x'.repeat(201)})`).name, null);
|
||
// A long unterminated "(" run yields no name and still parses the phase.
|
||
assert.deepEqual(phaseId.parsePhaseFromProse(`3 ${'('.repeat(5000)}`), { phase: '3', name: null });
|
||
});
|
||
|
||
test('#2124 review: non-string input is coerced, never throws', () => {
|
||
assert.doesNotThrow(() => phaseId.parsePhaseFromProse(3));
|
||
assert.equal(phaseId.parsePhaseFromProse(3).phase, '3');
|
||
assert.deepEqual(phaseId.parsePhaseFromProse(true), { phase: null, name: null });
|
||
});
|
||
});
|
||
|
||
// ─── stripConfiguredProjectCodePrefix (#2121 / #2104, config-aware) ───────────
|
||
|
||
describe('stripConfiguredProjectCodePrefix', () => {
|
||
test('#2104: a foreign prefix is preserved (not collapsed to a bare phase)', () => {
|
||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('MEM-01', 'LKML'), 'MEM-01');
|
||
});
|
||
|
||
test('the configured prefix is stripped (case-insensitive)', () => {
|
||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('CK-01', 'CK'), '01');
|
||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('LKML-29', 'lkml'), '29');
|
||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('AB-29', 'AB'), '29');
|
||
});
|
||
|
||
test('a value with no prefix is returned unchanged', () => {
|
||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('01', 'CK'), '01');
|
||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('029', 'CK'), '029');
|
||
});
|
||
|
||
test('an absent/empty projectCode preserves the value verbatim', () => {
|
||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('MEM-01', ''), 'MEM-01');
|
||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('MEM-01', null), 'MEM-01');
|
||
assert.equal(phaseId.stripConfiguredProjectCodePrefix('MEM-01', undefined), 'MEM-01');
|
||
});
|
||
});
|
||
|
||
// ─── isForeignPrefixedPhaseQuery (#2121 / #2056) ─────────────────────────────
|
||
|
||
describe('isForeignPrefixedPhaseQuery', () => {
|
||
test('a prefix that is not the configured code is foreign', () => {
|
||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('MEM-01', 'LKML'), true);
|
||
});
|
||
|
||
test('the configured prefix is not foreign (case-insensitive)', () => {
|
||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('CK-01', 'CK'), false);
|
||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('ck-01', 'CK'), false);
|
||
});
|
||
|
||
test('a value with no prefix is never foreign', () => {
|
||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('01', 'CK'), false);
|
||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('29', 'AB'), false);
|
||
});
|
||
|
||
test('a prefixed query with no configured code is foreign; a bare one is not', () => {
|
||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('MEM-01', ''), true);
|
||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('MEM-01', null), true);
|
||
assert.equal(phaseId.isForeignPrefixedPhaseQuery('01', ''), false);
|
||
});
|
||
});
|
||
|
||
// ─── roadmapPhaseLookupSources (#2121, owned here after the move) ─────────────
|
||
|
||
describe('roadmapPhaseLookupSources', () => {
|
||
const PREFIX_TOLERANT = `${phaseId.OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}0*29`;
|
||
|
||
test('a bare numeric query yields the numeric then prefix-tolerant sources', () => {
|
||
const sources = phaseId.roadmapPhaseLookupSources('29');
|
||
assert.deepEqual(sources, ['0*29', PREFIX_TOLERANT]);
|
||
});
|
||
|
||
test('the bare numeric source precedes the prefix-tolerant fallback', () => {
|
||
const sources = phaseId.roadmapPhaseLookupSources('29');
|
||
assert.ok(sources.indexOf('0*29') < sources.indexOf(PREFIX_TOLERANT));
|
||
});
|
||
|
||
test('a project-code-prefixed query adds the exact source first (3 sources)', () => {
|
||
const sources = phaseId.roadmapPhaseLookupSources('AB-29');
|
||
assert.equal(sources.length, 3);
|
||
assert.equal(sources[0], 'AB-29');
|
||
assert.ok(sources.includes('0*29'));
|
||
assert.ok(sources.includes(PREFIX_TOLERANT));
|
||
});
|
||
|
||
test('zero-padding is tolerated: 029 resolves the same sources as 29', () => {
|
||
assert.deepEqual(phaseId.roadmapPhaseLookupSources('029'), phaseId.roadmapPhaseLookupSources('29'));
|
||
});
|
||
|
||
test('sources are deduplicated', () => {
|
||
const sources = phaseId.roadmapPhaseLookupSources('29');
|
||
assert.equal(sources.length, new Set(sources).size);
|
||
});
|
||
});
|
||
|
||
// ─── #2121 property tests (fast-check) ───────────────────────────────────────
|
||
|
||
describe('phase-id canonical surface — properties', () => {
|
||
test('#2111 invariant: a "Milestone vX.Y complete" string never yields a phase', () => {
|
||
fc.assert(
|
||
fc.property(fc.nat(999), fc.nat(999), (major, minor) => {
|
||
return phaseId.parsePhaseFromProse(`Milestone v${major}.${minor} complete`).phase === null;
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('parse↔normalize: a "N of M" prose value extracts N, and it normalizes stably', () => {
|
||
fc.assert(
|
||
fc.property(fc.integer({ min: 1, max: 9999 }), fc.integer({ min: 1, max: 9999 }), (n, m) => {
|
||
const parsed = phaseId.parsePhaseFromProse(`${n} of ${m}`);
|
||
return (
|
||
parsed.phase === String(n) &&
|
||
phaseId.normalizePhaseName(parsed.phase) === phaseId.normalizePhaseName(String(n))
|
||
);
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── #2232 continuation-cap property tests (fast-check) ──────────────────────
|
||
|
||
// An arbitrary run of digits, including leading-zero forms ("02", "007") that
|
||
// String(int) can never produce — the zero-padded shape is the whole point of
|
||
// the continuation rule, so the corpus must be able to generate it.
|
||
const digitRun = (min, max) =>
|
||
fc.string({
|
||
unit: fc.constantFrom('0', '1', '2', '3', '4', '5', '6', '7', '8', '9'),
|
||
minLength: min,
|
||
maxLength: max,
|
||
});
|
||
|
||
describe('#2232 continuation cap — properties', () => {
|
||
test('a numeric segment is absorbed into the token IFF its digit run is exactly 2', () => {
|
||
fc.assert(
|
||
fc.property(fc.integer({ min: 1, max: 99 }), digitRun(1, 6), (lead, seg) => {
|
||
const token = phaseId.extractPhaseToken(`${lead}-${seg}-photos-performance`);
|
||
const absorbed = token === `${lead}-${seg}`;
|
||
// The biconditional IS the rule: width 2 ⇔ absorbed. Anything else is
|
||
// a slug word and must leave the token at the bare leading number.
|
||
return absorbed === (seg.length === 2) && (absorbed || token === String(lead));
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('the owner agrees with the observable extraction for every digit run', () => {
|
||
fc.assert(
|
||
fc.property(digitRun(1, 6), (seg) => {
|
||
const absorbed = phaseId.extractPhaseToken(`14-${seg}-slug`) === `14-${seg}`;
|
||
return phaseId.isPhaseContinuationSegment(seg) === absorbed;
|
||
}),
|
||
);
|
||
});
|
||
|
||
// Metamorphic: the read side (extractPhaseToken) must invert the write side
|
||
// (getPhaseDirFromPhaseId), which zero-pads every component to 2 digits. This
|
||
// ties the continuation cap to the convention it mirrors rather than to a
|
||
// hand-picked example — if the write-side padding width ever changes, this
|
||
// fails instead of silently drifting.
|
||
test('metamorphic: a write-side phase dir round-trips to its own normalized phase id', () => {
|
||
fc.assert(
|
||
fc.property(fc.integer({ min: 1, max: 99 }), fc.integer({ min: 1, max: 99 }), (major, sub) => {
|
||
const dir = phaseId.getPhaseDirFromPhaseId(`${major}-${sub}`, 'Some Phase Name', null);
|
||
if (!dir) return true;
|
||
return phaseId.extractPhaseToken(dir) === phaseId.normalizePhaseName(`${major}-${sub}`);
|
||
}),
|
||
);
|
||
});
|
||
|
||
// The #2232 bug itself, as a property: a phase NAME that slugifies to a
|
||
// year-leading word must not perturb the round-trip.
|
||
test('metamorphic: round-trip holds even when the phase name leads with a year (#2232)', () => {
|
||
fc.assert(
|
||
fc.property(
|
||
fc.integer({ min: 1, max: 99 }),
|
||
fc.integer({ min: 1, max: 99 }),
|
||
fc.integer({ min: 1000, max: 9999 }),
|
||
(major, sub, year) => {
|
||
const dir = phaseId.getPhaseDirFromPhaseId(
|
||
`${major}-${sub}`,
|
||
`${year} Photos And Performance`,
|
||
null,
|
||
);
|
||
if (!dir) return true;
|
||
return phaseId.extractPhaseToken(dir) === phaseId.normalizePhaseName(`${major}-${sub}`);
|
||
},
|
||
),
|
||
);
|
||
});
|
||
});
|
||
|
||
// ─── #2736 prose name-precedence property tests (fast-check) ─────────────────
|
||
|
||
// #2821's only behavioral delta in parsePhaseFromProse is that a GENUINE
|
||
// (non-status) em-dash name now takes precedence over a parenthetical name;
|
||
// phase-token extraction and totality were unchanged by that commit.
|
||
//
|
||
// P1 and P9 are the delta guards: both fail against the pre-#2821 paren-first
|
||
// parser (verified by the standalone mutation check against
|
||
// parsePhaseFromProseOLD), because they each require the dash name to win
|
||
// over a co-present parenthetical — P9 additionally exercises the
|
||
// paren-stripped separator search, since the losing parenthetical itself
|
||
// contains an em-dash.
|
||
//
|
||
// P2, P3, P4 are characterization tests: they pin currently-true precedence
|
||
// contracts (status tails and em-dash-inside-parens both lose to a
|
||
// parenthetical name) that the pre-#2821 parser ALSO satisfied, so they guard
|
||
// against future regressions rather than proving the #2821 delta.
|
||
//
|
||
// P5-P8 pin totality and phase-token extraction, neither of which #2821
|
||
// changed.
|
||
|
||
const phaseToken = fc
|
||
.tuple(
|
||
digitRun(1, 3),
|
||
fc.option(fc.constantFrom(...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'), { nil: '' }),
|
||
fc.array(digitRun(1, 2), { maxLength: 2 }),
|
||
)
|
||
.map(([lead, letter, decimals]) => `${lead}${letter}${decimals.map((d) => `.${d}`).join('')}`);
|
||
|
||
const STATUSY =
|
||
/^(?:completed?|executing|not started|planning|planned|ready(?:\s+to\s+\S.{0,50})?|done|in progress|blocked|paused|verifying)$/i;
|
||
|
||
const genuineName = fc
|
||
.string({
|
||
unit: fc.constantFrom(
|
||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
|
||
'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
|
||
' ', 'A', 'B', 'C',
|
||
),
|
||
minLength: 1,
|
||
maxLength: 40,
|
||
})
|
||
.map((s) => s.trim())
|
||
.filter((s) => s.length > 0 && !STATUSY.test(s) && !/^milestone\s*:/i.test(s) && !/^[A-Z][A-Z0-9_-]*$/.test(s));
|
||
|
||
const asideText = fc
|
||
.string({
|
||
unit: fc.constantFrom(...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -_'),
|
||
minLength: 1,
|
||
maxLength: 30,
|
||
})
|
||
.map((s) => s.trim())
|
||
.filter((s) => s.length > 0);
|
||
|
||
const statusTail = fc.constantFrom(
|
||
'COMPLETE', 'COMPLETED', 'EXECUTING', 'READY', 'DONE', 'IN PROGRESS',
|
||
'BLOCKED', 'PAUSED', 'VERIFYING', 'PLANNING', 'PLANNED', 'NOT STARTED',
|
||
);
|
||
|
||
const capsToken = fc.string({
|
||
unit: fc.constantFrom(...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'),
|
||
minLength: 2,
|
||
maxLength: 12,
|
||
});
|
||
|
||
describe('#2736 prose name precedence — properties', () => {
|
||
test('P1 dash name beats a trailing parenthetical aside', () => {
|
||
fc.assert(
|
||
fc.property(phaseToken, genuineName, asideText, (tok, name, aside) => {
|
||
const p = phaseId.parsePhaseFromProse(`${tok} — ${name} (${aside})`);
|
||
return p.phase === tok && p.name === name;
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('P2 a status-keyword tail never displaces a parenthetical name', () => {
|
||
fc.assert(
|
||
fc.property(phaseToken, genuineName, statusTail, (tok, name, status) => {
|
||
const p = phaseId.parsePhaseFromProse(`${tok} (${name}) — ${status}`);
|
||
return p.phase === tok && p.name === name;
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('P3 an em-dash inside parens is not mistaken for the separator', () => {
|
||
fc.assert(
|
||
fc.property(phaseToken, genuineName, genuineName, statusTail, (tok, a, b, status) => {
|
||
const p = phaseId.parsePhaseFromProse(`${tok} (${a} — ${b}) — ${status}`);
|
||
return p.phase === tok && p.name === `${a} — ${b}`;
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('P4 a lone ALL-CAPS tail loses to a parenthetical name', () => {
|
||
fc.assert(
|
||
fc.property(phaseToken, genuineName, capsToken, (tok, name, caps) => {
|
||
const p = phaseId.parsePhaseFromProse(`${tok} (${name}) — ${caps}`);
|
||
return p.phase === tok && p.name === name;
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('P5 parsePhaseFromProse is total over arbitrary input', () => {
|
||
fc.assert(
|
||
fc.property(fc.string({ maxLength: 300 }), (s) => {
|
||
const p = phaseId.parsePhaseFromProse(s);
|
||
return (
|
||
p !== null &&
|
||
typeof p === 'object' &&
|
||
(p.phase === null || typeof p.phase === 'string') &&
|
||
(p.name === null || typeof p.name === 'string')
|
||
);
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('P6 pathological paren/em-dash runs stay total', () => {
|
||
fc.assert(
|
||
fc.property(fc.integer({ min: 1, max: 400 }), (n) => {
|
||
const p = phaseId.parsePhaseFromProse(`3 ${'('.repeat(n)}${'—'.repeat(n)}`);
|
||
return p.phase === '3' && (p.name === null || typeof p.name === 'string');
|
||
}),
|
||
);
|
||
});
|
||
|
||
test('P7 the phase token round-trips out of first-party prose shapes', () => {
|
||
fc.assert(
|
||
fc.property(phaseToken, genuineName, (tok, name) =>
|
||
phaseId.parsePhaseFromProse(`${tok} (${name})`).phase === tok &&
|
||
phaseId.parsePhaseFromProse(`Phase ${tok} — ${name}`).phase === tok &&
|
||
phaseId.parsePhaseFromProse(`${tok}`).phase === tok,
|
||
),
|
||
);
|
||
});
|
||
|
||
test('P8 a milestone-prefixed token still yields the bare phase', () => {
|
||
fc.assert(
|
||
fc.property(
|
||
fc.string({ unit: fc.constantFrom(...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'), minLength: 1, maxLength: 3 }),
|
||
phaseToken,
|
||
genuineName,
|
||
(ms, tok, name) => phaseId.parsePhaseFromProse(`${ms}1-${tok} (${name})`).phase === tok,
|
||
),
|
||
);
|
||
});
|
||
|
||
test('P9 a genuine dash name wins over a paren containing an em-dash', () => {
|
||
fc.assert(
|
||
fc.property(phaseToken, genuineName, genuineName, genuineName, (tok, a, b, name) => {
|
||
const p = phaseId.parsePhaseFromProse(`${tok} (${a} — ${b}) — ${name}`);
|
||
return p.phase === tok && p.name === name;
|
||
}),
|
||
);
|
||
});
|
||
|
||
// The STATUSY regex above is a test-local mirror of the private, unexported
|
||
// STATUSY_TAIL_RE in src/phase-id.cts — it is not imported, only
|
||
// reimplemented. If a future edit to the implementation's status
|
||
// vocabulary drifts from this mirror, the properties above that rely on
|
||
// STATUSY (P2, genuineName's exclusion filter, etc.) would silently weaken
|
||
// rather than fail. This test pins the mirror to OBSERVABLE parser
|
||
// behavior instead of source text, so a divergence fails loudly here.
|
||
test('the test-local STATUSY mirror still agrees with the parser (divergence guard)', () => {
|
||
const statusVocab = [
|
||
'complete', 'completed', 'executing', 'not started', 'planning',
|
||
'planned', 'ready', 'done', 'in progress', 'blocked', 'paused',
|
||
'verifying',
|
||
];
|
||
|
||
for (const w of statusVocab) {
|
||
assert.equal(
|
||
phaseId.parsePhaseFromProse(`3 (Real Name) — ${w}`).name,
|
||
'Real Name',
|
||
`expected status word "${w}" to lose to the parenthetical name`,
|
||
);
|
||
const upper = w.toUpperCase();
|
||
assert.equal(
|
||
phaseId.parsePhaseFromProse(`3 (Real Name) — ${upper}`).name,
|
||
'Real Name',
|
||
`expected status word "${upper}" to lose to the parenthetical name`,
|
||
);
|
||
}
|
||
|
||
const nonStatusNames = ['Foundation', 'Native Hotkey', 'setup work'];
|
||
for (const n of nonStatusNames) {
|
||
assert.equal(
|
||
phaseId.parsePhaseFromProse(`3 — ${n} (aside)`).name,
|
||
n,
|
||
`expected non-status name "${n}" to win as the dash name over the parenthetical aside`,
|
||
);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── phase-key derivations (#2562) ───────────────────────────────────────────
|
||
|
||
// #2562: the whole point of these living here is that a ROADMAP table cell and
|
||
// a phase DIRECTORY must land in ONE key space. Modules that derived their own
|
||
// regex for this is what let a `| 01. … |` row miss a `1-slug` directory, so
|
||
// the contract is unit-tested at the owner module, not only through consumers.
|
||
describe('phaseKeyFrom* — one key space for directories and prose', () => {
|
||
test('every zero-padding spelling of a directory collapses to one key', () => {
|
||
for (const dir of ['5-a', '05-a', 'PROJ-5-a', 'PROJ-05-a']) {
|
||
assert.strictEqual(phaseId.phaseKeyFromDir(dir), '05', dir);
|
||
}
|
||
});
|
||
|
||
test('every zero-padding spelling in prose collapses to the same key', () => {
|
||
for (const prose of ['5. A', '05. A', '**5. A**', '`05. A`']) {
|
||
assert.strictEqual(phaseId.phaseKeyFromProse(prose), '05', prose);
|
||
}
|
||
});
|
||
|
||
test('a padded table cell and an unpadded directory produce the SAME key', () => {
|
||
assert.strictEqual(phaseId.phaseKeyFromProse('01. Setup'), phaseId.phaseKeyFromDir('1-setup'));
|
||
assert.strictEqual(phaseId.phaseKeyFromProse('30. Rollout'), phaseId.phaseKeyFromDir('030-rollout'));
|
||
});
|
||
|
||
test('sub-phase keys keep their decimal segment', () => {
|
||
assert.strictEqual(phaseId.phaseKeyFromDir('30.1-follow-up'), '30.1');
|
||
assert.strictEqual(phaseId.phaseKeyFromProse('**05.1 Follow-up**'), '05.1');
|
||
});
|
||
|
||
test('prose that does not begin with a phase token is null, not a bogus key', () => {
|
||
assert.strictEqual(phaseId.phaseKeyFromProse('Not a phase'), null);
|
||
assert.strictEqual(phaseId.phaseKeyFromProse(null), null);
|
||
assert.strictEqual(phaseId.phaseKeyFromProse(undefined), null);
|
||
});
|
||
|
||
test('parentPhaseKey resolves a sub-phase to its parent and a top-level to null', () => {
|
||
assert.strictEqual(phaseId.parentPhaseKey('30.1'), '30');
|
||
assert.strictEqual(phaseId.parentPhaseKey('05.12'), '05');
|
||
assert.strictEqual(phaseId.parentPhaseKey('30'), null);
|
||
});
|
||
|
||
test('property: padding a directory number never changes its key', () => {
|
||
fc.assert(
|
||
fc.property(
|
||
fc.integer({ min: 1, max: 99 }),
|
||
fc.integer({ min: 0, max: 3 }),
|
||
(num, pad) => {
|
||
const padded = String(num).padStart(String(num).length + pad, '0');
|
||
return phaseId.phaseKeyFromDir(`${padded}-slug`) === phaseId.phaseKeyFromDir(`${num}-slug`);
|
||
},
|
||
),
|
||
);
|
||
});
|
||
});
|