* 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>
162 lines
7.0 KiB
JavaScript
162 lines
7.0 KiB
JavaScript
'use strict';
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
/**
|
|
* Unit coverage for the #3216 WIDENING of the milestone-window drift guard
|
|
* (scripts/lint-milestone-window-drift.cjs, epic #3180, issue #3184/#3216,
|
|
* ADR-3180 Decision 4(a)).
|
|
*
|
|
* Modelled on tests/enumeration-drift-guard.test.cjs: this file covers ONLY
|
|
* the tokens/behavior #3216 added — the literal `##` heading-anchor path,
|
|
* the grouped and interpolated version shapes, and the function-scoped
|
|
* (not file-scoped) exemption boundary. Behavioral throughout: assertions
|
|
* drive `findMilestoneWindowDrift` / `headingMatcherLiterals` / `scanRepo`
|
|
* directly — no `readFileSync().includes()` in a test body.
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const {
|
|
findMilestoneWindowDrift,
|
|
scanRepo,
|
|
headingMatcherLiterals,
|
|
} = require(path.join(ROOT, 'scripts', 'lint-milestone-window-drift.cjs'));
|
|
|
|
describe('#3216 widened tokens: findMilestoneWindowDrift (pure)', () => {
|
|
test('an interpolated-version heading matcher (the real :785 shape) IS flagged', () => {
|
|
const line =
|
|
" const headingMatch = roadmap.match(new RegExp(`^##[^\\\\n]*${escapedVer}[:\\\\s]+([^\\\\n(]+)`, 'im'));";
|
|
const v = findMilestoneWindowDrift(line, 'src/fake.cts');
|
|
assert.equal(v.length, 1);
|
|
assert.equal(v[0].line, 1);
|
|
});
|
|
|
|
test('a grouped-version regex-literal heading matcher (the real :806 shape) IS flagged', () => {
|
|
const line = ' const headingMatch = cleaned.match(/## (?!.*✅).*v(\\d+(?:\\.\\d+)+)[:\\s]+([^\\n(]+)/);';
|
|
const v = findMilestoneWindowDrift(line, 'src/fake.cts');
|
|
assert.equal(v.length, 1);
|
|
assert.equal(v[0].line, 1);
|
|
});
|
|
|
|
test('an unanchored enumeration shape (the real :454 shape) IS flagged', () => {
|
|
const line = ' const milestonePattern = /##\\s*(.*v(\\d+(?:\\.\\d+)+)[^(\\n]*)/gi;';
|
|
const v = findMilestoneWindowDrift(line, 'src/fake.cts');
|
|
assert.equal(v.length, 1);
|
|
assert.equal(v[0].line, 1);
|
|
});
|
|
|
|
test('phaseHeadingRegexCarryingOnlyTheQuantifierIsNotFlagged', () => {
|
|
// Token (a) alone — no literal heading run, no version/marker token — is
|
|
// the "where is phase N's heading" question, already single-owned
|
|
// elsewhere (#2121), and must stay clean.
|
|
const line = ' const phaseRe = /^#{2,4}\\s*Phase\\s+(\\d+)/gm;';
|
|
assert.deepEqual(findMilestoneWindowDrift(line, 'src/fake.cts'), []);
|
|
});
|
|
|
|
test('a heading-BUILDING template is NOT flagged (the false-positive #3216 narrowing exists to prevent)', () => {
|
|
// A template that BUILDS a heading for output (`## ${version} — ${name}`)
|
|
// is not a re-derivation of WHERE a milestone section begins — it never
|
|
// matches against document text at all — so it must stay outside the
|
|
// literal-heading-run detector, or every heading writer in the tree
|
|
// would be flagged as if it were `locateMilestoneHeadings`.
|
|
const line = ' const heading = `## ${version} — ${name}`;';
|
|
assert.deepEqual(findMilestoneWindowDrift(line, 'src/fake.cts'), []);
|
|
});
|
|
|
|
test('literalHashShapeInACommentIsNotFlagged', () => {
|
|
const line =
|
|
" // const headingMatch = roadmap.match(new RegExp(`^##[^\\\\n]*${escapedVer}[:\\\\s]+([^\\\\n(]+)`, 'im'));";
|
|
assert.deepEqual(findMilestoneWindowDrift(line, 'src/fake.cts'), []);
|
|
});
|
|
|
|
test('literalHashShapeInAJsDocContinuationIsNotFlagged', () => {
|
|
const line =
|
|
" * const headingMatch = roadmap.match(new RegExp(`^##[^\\\\n]*${escapedVer}[:\\\\s]+([^\\\\n(]+)`, 'im'));";
|
|
assert.deepEqual(findMilestoneWindowDrift(line, 'src/fake.cts'), []);
|
|
});
|
|
|
|
test('a # run inside a string literal that is never fed to new RegExp( is NOT flagged', () => {
|
|
const line = " const colour = fgHash + '#ffffff';";
|
|
assert.deepEqual(findMilestoneWindowDrift(line, 'src/fake.cts'), []);
|
|
});
|
|
});
|
|
|
|
describe('#3216: headingMatcherLiterals (pure)', () => {
|
|
test('a regex literal on the line is returned', () => {
|
|
const line = 'const x = /abc/;';
|
|
assert.deepEqual(headingMatcherLiterals(line, line), ['/abc/']);
|
|
});
|
|
|
|
test('a string/template literal is returned ONLY when the line also contains new RegExp(', () => {
|
|
const withNewRegExp = 'const re = new RegExp(`##foo`);';
|
|
assert.deepEqual(headingMatcherLiterals(withNewRegExp, withNewRegExp), ['`##foo`']);
|
|
|
|
const plainAssignment = 'const heading = `##foo`;';
|
|
assert.deepEqual(headingMatcherLiterals(plainAssignment, plainAssignment), []);
|
|
});
|
|
|
|
test('a line with no literals returns an empty array', () => {
|
|
const line = 'const x = 1 + 2;';
|
|
assert.deepEqual(headingMatcherLiterals(line, line), []);
|
|
});
|
|
});
|
|
|
|
describe('#3216: function-scoped exemptions', () => {
|
|
const OWNER_REL = path.join('src', 'roadmap-parser.cts');
|
|
|
|
test('namedCanonicalFunctionsRemainExemptAfterWidening', () => {
|
|
const text = [
|
|
'function locateMilestoneHeadings(content, version) {',
|
|
" const headingMatch = content.match(new RegExp(`^##[^\\\\n]*${escapedVer}[:\\\\s]+([^\\\\n(]+)`, 'im'));",
|
|
' return headingMatch;',
|
|
'}',
|
|
].join('\n');
|
|
assert.deepEqual(findMilestoneWindowDrift(text, OWNER_REL), []);
|
|
});
|
|
|
|
test('the same flagged shape inside getMilestoneInfo IS flagged, proving the exemption is function-scoped not file-scoped (row 40/53)', () => {
|
|
const text = [
|
|
'function getMilestoneInfo(cwd) {',
|
|
" const headingMatch = content.match(new RegExp(`^##[^\\\\n]*${escapedVer}[:\\\\s]+([^\\\\n(]+)`, 'im'));",
|
|
' return headingMatch;',
|
|
'}',
|
|
].join('\n');
|
|
const v = findMilestoneWindowDrift(text, OWNER_REL);
|
|
assert.equal(v.length, 1);
|
|
assert.equal(v[0].line, 2);
|
|
});
|
|
|
|
test('the same shape in an unrelated file path IS flagged (no exemption entry at all)', () => {
|
|
const text = [
|
|
'function getMilestoneInfo(cwd) {',
|
|
" const headingMatch = content.match(new RegExp(`^##[^\\\\n]*${escapedVer}[:\\\\s]+([^\\\\n(]+)`, 'im'));",
|
|
' return headingMatch;',
|
|
'}',
|
|
].join('\n');
|
|
const v = findMilestoneWindowDrift(text, path.join('src', 'somewhere-else.cts'));
|
|
assert.equal(v.length, 1);
|
|
});
|
|
});
|
|
|
|
describe('#3216: the live repo', () => {
|
|
test('liveRepoHasZeroMilestoneWindowRederivations', () => {
|
|
// Expected to FAIL (RED) until the #3216 consolidation lands — at write
|
|
// time the live repo still carries 3 un-consolidated re-derivations
|
|
// (roadmap-parser.cts getMilestoneInfo x2, roadmap.cts:454
|
|
// cmdRoadmapAnalyze — see 50-test-matrix.md's "Scope amendment" section).
|
|
// This is the failing-first proof the widened guard binds to real code,
|
|
// not just synthetic fixtures.
|
|
const violations = scanRepo(ROOT);
|
|
assert.deepEqual(
|
|
violations,
|
|
[],
|
|
'unsanctioned milestone-window re-derivation(s) — route through roadmap-parser.cts '
|
|
+ 'computeMilestoneSectionEnd / locateMilestoneHeadings / isMilestoneBoundedInRoadmap:\n'
|
|
+ violations.map((d) => ` ${d.file}:${d.line} ${d.found}`).join('\n'),
|
|
);
|
|
});
|
|
});
|