* refactor(#3216): widen milestone-window guard to literal-## matchers The guard keyed only on the `#{N,M}` quantifier plus a literal version or phase-lookahead token. getMilestoneInfo hand-rolls its milestone-heading match with a literal `^##`/`## ` and an interpolated ${escapedVer}, so it satisfied neither token and the guard reported a clean zero on a file carrying live re-derivations (#3171, #3197) — a zero it did not earn. Widen token (a) to a literal 2-6 `#` run, admitted ONLY inside a heading-MATCHER literal (a regex literal, or a string/template handed to new RegExp) so a heading-BUILDING template is not mistaken for a re-derivation. Widen token (b) with the grouped `v(\d+(?:\.\d+)+)` shape and an interpolated version placeholder. Ships BEFORE the consolidation per ADR-3180 s7.2: a guard widened afterwards measures an already-cleaned surface. It is expected to be RED until the consolidation lands. * test(#3216): failing-first milestone-identity single-owner suite 63 tests across two files, from the matrix in .gsd/phase/. Section H of milestone-window-single-owner.test.cjs covers the 21 input classes of the design's behavior table plus its negative space; milestone-window-drift-guard covers the widened tokens and proves the exemption is function-scoped, not file-scoped. Copy count is 3 found by the guard, not 1 per the epic (ADR-3180 Amendment 3's standing rule, holding for the fourth consecutive phase): both getMilestoneInfo sites plus cmdRoadmapAnalyze's milestone enumeration at roadmap.cts:454, which carries the same #3171 truncation and #3197 phase-heading confusion. Expected RED until the consolidation lands. * refactor(#3216): bind milestone identity to the canonical locator getMilestoneInfo hand-rolled two milestone-heading regexes inside the owner's own file. Both were wrong, differently: the STATE-version site's ^## anchor is level-blind so [^\n]* absorbs a third #, and the fallback site had no anchor at all, so '## ' matched from the second # of '###'. Against '### Phase 7: Close v3.3 gaps' the fallback returned {v3.3, gaps} (#3197). Both captured names with [^\n(], truncating at a parenthetical (#3171). Bind both to the canonical grammar. locateMilestoneHeadings becomes a version-filtered view over one shared source, and a new version-agnostic listMilestoneHeadings enumerates milestone headings for callers that need all of them. getMilestoneInfo returns ScopedResult<MilestoneInfo|null>; the {v1.0,'milestone'} default, which was output-identical to a real v1.0 project, is deleted. The #2245 never-throws invariant is preserved. Copy count: 3 found by the guard, not 1 per the epic. The third was cmdRoadmapAnalyze's own milestone enumeration (roadmap.cts:454), carrying both defects in the implementation the epic blessed. buildStateFrontmatter and archivePhaseDirectories branch on scope: the first writes null rather than a fabricated identity, the second falls through to its dated-label fallback. A fabricated v3.3 passes ARCHIVE_VERSION_LABEL_RE, so it would otherwise misfile phase history. Also fixes an unsafe cast in init.cts that masked these type errors across five call sites, which would have shipped undefined milestone fields under green tsc. * fix(#3216): restore the #1761 unbounded guard and bullet precedence Review and the first full-matrix run surfaced five real defects in the consolidation, all fixed here rather than by relaxing the tests that caught them: - buildStateFrontmatter gated its isMilestoneBoundedInRoadmap check on the scope-gated milestone value, which is null on any non-COMPLETE scope, so the #1761 unbounded guard was silently skipped and state json reported a percent it must omit. It now gates on the STATE-asserted version, independent of identity scope. - The rewrite lost #2135's precedence: the name-bearing progress-marker bullet is consulted before the heading again. - A single-segment version (v3, no dot) did not resolve; the name-extraction fallback now accepts it. - A version carrying regex metacharacters, or a $& / $1 replacement pattern, is matched literally. - listMilestoneHeadings' heading field trimmed, so a CRLF roadmap no longer leaks a trailing carriage return into roadmap analyze's output. Also emits milestone_version / milestone_name / current_milestone as explicit null rather than omitting the key, so the prompt layer cannot render a bare placeholder, and corrects an init.cts comment plus a cast left inconsistent. * test(#3216): update milestone-identity expectations to the scoped contract getMilestoneInfo returns ScopedResult<MilestoneInfo|null> and the {v1.0,'milestone'} default is deleted, so the suites asserting the old shape assert removed behavior. Updated rather than weakened: every touched call site now asserts the scope explicitly against the frozen SCOPE enum. roadmap-parser.test.cjs: 20 expectations moved to {value,scope}. The #1881 unreadable-vs-absent diagnostic assertions are untouched and still prove their original point — only the return shape moved. One pre-existing assert.ok(info) is now a specific UNSCOPED assertion, so that case is stronger than before. new-milestone-clear-phases.test.cjs: the test asserting phases clear archives under the v1.0 default now asserts the dated archived-<YYYYMMDD> fallback, which is the deliberate consequence of deleting that default. Two of this branch's own tests were also corrected after they drove the implementation the wrong way: the parity test compared raw heading text and so pushed a stray ## prefix into roadmap analyze's public output, and the hostile metacharacter row demanded a pathological version resolve, which pushed a widening of the ADR-locked \b boundary. Both now assert what the contract actually requires. * docs(#3216): document milestone identity and correct the CONTEXT.md entry ADR-3180 s7.2 moves to Enforced and gains two rules that were unstated: the name derives from the heading's own version token and drops a trailing status marker, and a free-form legacy ROADMAP with no version anywhere is UNSCOPED with no identity rather than a defaulted v1.0 (decided by the maintainer before implementation, per s7's own rule that an unstated behavior is not decided). Amendment 4 records Phase 6's validation, including that the copy count was a lower bound for the fourth consecutive phase. CONTEXT.md's Roadmap Parser entry described locateMilestoneHeadings as boundary-matched with (?![\w.-]) — the alternative Amendment 2 tried and REVERTED. The code uses \b and says so, and the ADR agrees; the revert updated code and ADR and missed CONTEXT.md, which is the epic's own fixed-on-one-copy failure class in the docs layer, on a file that is itself a PR gate. * fix(#3216): persist the real version on a truncated identity buildStateFrontmatter wrote null for BOTH milestone and milestone_name on any non-COMPLETE scope, discarding a real version. ADR-3180 s7.2 rule 6: a version known with no resolvable name is TRUNCATED carrying {version, name: null} — 'the version is a real answer, the name is a non-answer, and collapsing the two is the failure this contract exists to prevent.' The two fields are now gated by what is actually known: the version whenever one exists (COMPLETE or TRUNCATED), the name only on COMPLETE. Never fabricated. Caught by this phase's own Decision 4(c) consumer-output test, which is the argument for asserting at the consumer rather than the owner — the owner was correct throughout; only the consumer collapsed its answer. * refactor(#3216): extract helpers and make cmdCommit's scope gate explicit From the two-axis code review: - init.cts repeated the identical getMilestoneInfo cast at five sites with copy-pasted comments — duplication inside a PR whose thesis is that duplicates get deleted. Extracted milestoneRecord(cwd); the one site-specific comment is kept, the four generic copies removed. - getMilestoneInfo hand-built its { value, scope } literal at ten return points; a local scoped() constructor now does it once. Every per-branch rationale comment is preserved and no returned value or scope changed. - cmdCommit gated the milestone branch name on plain truthiness, which is also true for TRUNCATED, so an unresolved identity drove branch creation incidentally rather than deliberately. It now gates on the SCOPE enum, accepting COMPLETE or TRUNCATED because both carry a real version, and the comment records why that differs from archivePhaseDirectories — which demands COMPLETE because it uses the value as a filesystem path component. * test(#3216): cover the bare-version-in-prose truncated path The spec review found the bareVersionMatch path — no STATE version, no milestone heading, a version token only in prose — returning TRUNCATED with no test exercising that exact shape, violating Decision 4's boundary-coverage requirement. * docs(#3216): record the missed Tier-2 surfaces and rule 5's corollary Decision 3 requires an explicit call-out for EVERY Tier-2 change, and Amendment 4's first draft named eight surfaces while the change touched thirteen. Adds cmdCommit's branch-name construction and the four init JSON bundles, an incomplete list being the same defect in miniature that this epic removes. s7.2 rule 5 gains a corollary separating two cases the original wording ran together: no version token ANYWHERE is UNSCOPED, while a bare version token in prose or a non-milestone heading is weak but real evidence and yields TRUNCATED under rule 6. * chore(#3216): set changeset fragment pr to 3226 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -23,6 +23,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const roadmapParser = require('../gsd-core/bin/lib/roadmap-parser.cjs');
|
||||
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
|
||||
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
@@ -512,25 +513,26 @@ describe('roadmap-parser: getMilestoneInfo', () => {
|
||||
beforeEach(() => { tmpDir = createTempProject(); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('returns default when ROADMAP.md missing', () => {
|
||||
test('returns UNREADABLE scope (value null) when ROADMAP.md missing (#3216: the v1.0/"milestone" default was deleted per ADR-3180 §7.2 rule 4)', () => {
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.version, 'v1.0');
|
||||
assert.strictEqual(info.name, 'milestone');
|
||||
assert.deepStrictEqual(info, { value: null, scope: SCOPE.UNREADABLE });
|
||||
});
|
||||
|
||||
test('reads version from STATE.md and heading name', () => {
|
||||
writeState(tmpDir, { milestone: 'v2.0' });
|
||||
writeRoadmap(tmpDir, '## v2.0: The Big Launch\n### Phase 1: Setup\n');
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.version, 'v2.0');
|
||||
assert.match(info.name, /Big Launch/);
|
||||
assert.strictEqual(info.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(info.value.version, 'v2.0');
|
||||
assert.match(info.value.name, /Big Launch/);
|
||||
});
|
||||
|
||||
test('falls back to 🚧 WIP marker when STATE.md has no milestone', () => {
|
||||
writeRoadmap(tmpDir, '## 🚧 **v1.5 Work In Progress**\n### Phase 1: Do stuff\n');
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.version, 'v1.5');
|
||||
assert.match(info.name, /Work In Progress/i);
|
||||
assert.strictEqual(info.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(info.value.version, 'v1.5');
|
||||
assert.match(info.value.name, /Work In Progress/i);
|
||||
});
|
||||
|
||||
test('extracts from heading when no STATE.md and no WIP marker', () => {
|
||||
@@ -539,8 +541,9 @@ describe('roadmap-parser: getMilestoneInfo', () => {
|
||||
'### Phase 1: Not started',
|
||||
].join('\n'));
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.version, 'v3.0');
|
||||
assert.match(info.name, /Future Milestone/);
|
||||
assert.strictEqual(info.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(info.value.version, 'v3.0');
|
||||
assert.match(info.value.name, /Future Milestone/);
|
||||
});
|
||||
|
||||
test('skips completed ✅ milestones', () => {
|
||||
@@ -550,7 +553,8 @@ describe('roadmap-parser: getMilestoneInfo', () => {
|
||||
].join('\n'));
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
// Should not use the ✅-prefixed version as the current milestone
|
||||
assert.strictEqual(info.version, 'v2.0');
|
||||
assert.strictEqual(info.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(info.value.version, 'v2.0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -580,8 +584,9 @@ describe('roadmap-parser: getMilestoneInfo #2135 — milestone_name clobber', ()
|
||||
'### Phase 36: Something',
|
||||
].join('\n'));
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.version, 'v1.8');
|
||||
assert.strictEqual(info.name, 'user session cleanup');
|
||||
assert.strictEqual(info.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(info.value.version, 'v1.8');
|
||||
assert.strictEqual(info.value.name, 'user session cleanup');
|
||||
});
|
||||
|
||||
test('case B: nameless ## heading + 🚧 marker carries the real name', () => {
|
||||
@@ -594,32 +599,36 @@ describe('roadmap-parser: getMilestoneInfo #2135 — milestone_name clobber', ()
|
||||
'### Phase 1: Hypothesis',
|
||||
].join('\n'));
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.version, 'v1.9');
|
||||
assert.strictEqual(info.name, 'Falsifiability');
|
||||
assert.strictEqual(info.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(info.value.version, 'v1.9');
|
||||
assert.strictEqual(info.value.name, 'Falsifiability');
|
||||
});
|
||||
|
||||
test('case C: canonical ## vX.Y: Name (no regression)', () => {
|
||||
writeState(tmpDir, { gsd_state_version: '1.0', milestone: 'v2.0' });
|
||||
writeRoadmap(tmpDir, '## v2.0: The Big Launch\n### Phase 1: Setup\n');
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.version, 'v2.0');
|
||||
assert.strictEqual(info.name, 'The Big Launch');
|
||||
assert.strictEqual(info.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(info.value.version, 'v2.0');
|
||||
assert.strictEqual(info.value.name, 'The Big Launch');
|
||||
});
|
||||
|
||||
test('case D: canonical ## vX.Y — Name (em-dash delimiter stripped)', () => {
|
||||
writeState(tmpDir, { gsd_state_version: '1.0', milestone: 'v2.5' });
|
||||
writeRoadmap(tmpDir, '## v2.5 — Galaxy Release\n### Phase 1: Start\n');
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.version, 'v2.5');
|
||||
assert.strictEqual(info.name, 'Galaxy Release');
|
||||
assert.strictEqual(info.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(info.value.version, 'v2.5');
|
||||
assert.strictEqual(info.value.name, 'Galaxy Release');
|
||||
});
|
||||
|
||||
test('case E: 🚧 bullet only, no ## heading (no regression)', () => {
|
||||
writeState(tmpDir, { gsd_state_version: '1.0', milestone: 'v1.5' });
|
||||
writeRoadmap(tmpDir, 'Some intro text.\n\n- 🚧 **v1.5 Quick Fix** — minor\n');
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.version, 'v1.5');
|
||||
assert.strictEqual(info.name, 'Quick Fix');
|
||||
assert.strictEqual(info.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(info.value.version, 'v1.5');
|
||||
assert.strictEqual(info.value.name, 'Quick Fix');
|
||||
});
|
||||
|
||||
test('anchored regex never matches a ## heading quoted inside backticks mid-line', () => {
|
||||
@@ -632,7 +641,8 @@ describe('roadmap-parser: getMilestoneInfo #2135 — milestone_name clobber', ()
|
||||
'## v3.0: Real Name',
|
||||
].join('\n'));
|
||||
const info = getMilestoneInfo(tmpDir);
|
||||
assert.strictEqual(info.name, 'Real Name');
|
||||
assert.strictEqual(info.scope, SCOPE.COMPLETE);
|
||||
assert.strictEqual(info.value.name, 'Real Name');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2726,10 +2736,12 @@ describe('feat-3594: roadmap parser does not crash on ANY corpus fixture', () =>
|
||||
// ─── #1881: an unreadable ROADMAP is not an absent one ───────────────────────
|
||||
//
|
||||
// getRoadmapPhaseInternal returns null for a read failure exactly as it does for
|
||||
// "phase not found", and getMilestoneInfo returns {v1.0, milestone} — which reads
|
||||
// as a brand-new project — for a read failure exactly as it does for "no ROADMAP
|
||||
// yet". Per ADR-1411's "corrupt is not absent" amendment both return values are
|
||||
// preserved and the cause is surfaced out of band instead.
|
||||
// "phase not found", and getMilestoneInfo (#3216: now {value, scope}) returns
|
||||
// {value:null, scope:SCOPE.UNREADABLE} for a read failure exactly as it does for
|
||||
// "no ROADMAP yet" — the shared return shape stays sentinel-identical between the
|
||||
// two causes; only the out-of-band diagnostic distinguishes them. Per ADR-1411's
|
||||
// "corrupt is not absent" amendment both return values are preserved and the
|
||||
// cause is surfaced out of band instead.
|
||||
//
|
||||
// The discriminator is the errno, and it is load-bearing in the silent direction:
|
||||
// getMilestoneInfo has no existsSync guard, so platformReadSync's null-for-ENOENT
|
||||
@@ -2822,7 +2834,7 @@ describe('#1881 unreadable ROADMAP vs absent ROADMAP', () => {
|
||||
const dir = project(t, HEALTHY);
|
||||
let info;
|
||||
const emitted = emissionsDuring(() => { info = getMilestoneInfo(dir); });
|
||||
assert.deepStrictEqual(info, { version: 'v2.3', name: 'Alpha' });
|
||||
assert.deepStrictEqual(info, { value: { version: 'v2.3', name: 'Alpha' }, scope: SCOPE.COMPLETE });
|
||||
assert.strictEqual(emitted, 0);
|
||||
});
|
||||
|
||||
@@ -2836,14 +2848,14 @@ describe('#1881 unreadable ROADMAP vs absent ROADMAP', () => {
|
||||
assert.strictEqual(emitted, 1);
|
||||
});
|
||||
|
||||
test('an unreadable roadmap is reported on a milestone lookup, and still returns the default', (t) => {
|
||||
test('an unreadable roadmap is reported on a milestone lookup, and returns {value:null, scope:UNREADABLE} (#3216: the plausible-looking v1.0 default was deleted)', (t) => {
|
||||
_resetUnusableInputWarningsForTests();
|
||||
const dir = project(t, HEALTHY);
|
||||
failReads(t, (p) => p.endsWith('ROADMAP.md'), eacces());
|
||||
let info;
|
||||
const emitted = emissionsDuring(() => { info = getMilestoneInfo(dir); });
|
||||
assert.deepStrictEqual(info, { version: 'v1.0', name: 'milestone' },
|
||||
'the plausible-looking default must be preserved exactly');
|
||||
assert.deepStrictEqual(info, { value: null, scope: SCOPE.UNREADABLE },
|
||||
'a read fault must surface as UNREADABLE, never a fabricated version/name');
|
||||
assert.strictEqual(emitted, 1);
|
||||
});
|
||||
|
||||
@@ -2857,7 +2869,7 @@ describe('#1881 unreadable ROADMAP vs absent ROADMAP', () => {
|
||||
info = getMilestoneInfo(dir);
|
||||
});
|
||||
assert.strictEqual(phase, null);
|
||||
assert.deepStrictEqual(info, { version: 'v1.0', name: 'milestone' });
|
||||
assert.deepStrictEqual(info, { value: null, scope: SCOPE.UNREADABLE });
|
||||
assert.strictEqual(emitted, 0, 'a missing ROADMAP.md must never be reported as unreadable');
|
||||
});
|
||||
|
||||
@@ -2883,7 +2895,8 @@ describe('#1881 unreadable ROADMAP vs absent ROADMAP', () => {
|
||||
});
|
||||
assert.strictEqual(phase, null);
|
||||
assert.strictEqual(emitted, 0);
|
||||
assert.ok(info, 'still returns a milestone default');
|
||||
assert.deepStrictEqual(info, { value: null, scope: SCOPE.UNSCOPED },
|
||||
'no version token anywhere reachable — UNSCOPED, not a fabricated version');
|
||||
});
|
||||
|
||||
test('an unreadable STATE.md alone stays silent — the inner catch is deliberate', (t) => {
|
||||
@@ -2897,7 +2910,7 @@ describe('#1881 unreadable ROADMAP vs absent ROADMAP', () => {
|
||||
failReads(t, (p) => p.endsWith('STATE.md'), eacces());
|
||||
let info;
|
||||
const emitted = emissionsDuring(() => { info = getMilestoneInfo(dir); });
|
||||
assert.deepStrictEqual(info, { version: 'v2.3', name: 'Alpha' });
|
||||
assert.deepStrictEqual(info, { value: { version: 'v2.3', name: 'Alpha' }, scope: SCOPE.COMPLETE });
|
||||
assert.strictEqual(emitted, 0);
|
||||
});
|
||||
|
||||
@@ -2980,7 +2993,7 @@ describe('#1881 unreadable ROADMAP vs absent ROADMAP', () => {
|
||||
emissionsDuring(() => {
|
||||
assert.doesNotThrow(() => { info = getMilestoneInfo(dir); });
|
||||
});
|
||||
assert.deepStrictEqual(info, { version: 'v1.0', name: 'milestone' });
|
||||
assert.deepStrictEqual(info, { value: null, scope: SCOPE.UNREADABLE });
|
||||
});
|
||||
|
||||
test('getRoadmapPhaseInternal does not throw when the read fails', (t) => {
|
||||
@@ -3014,7 +3027,7 @@ describe('#1881 unreadable ROADMAP vs absent ROADMAP', () => {
|
||||
assert.doesNotThrow(() => { info = getMilestoneInfo(dir); });
|
||||
assert.doesNotThrow(() => { phase = getRoadmapPhaseInternal(dir, '1'); });
|
||||
});
|
||||
assert.deepStrictEqual(info, { version: 'v1.0', name: 'milestone' });
|
||||
assert.deepStrictEqual(info, { value: null, scope: SCOPE.UNREADABLE });
|
||||
assert.strictEqual(phase, null);
|
||||
assert.strictEqual(emitted, 0,
|
||||
'the path never resolved, so there is no file to name');
|
||||
|
||||
Reference in New Issue
Block a user