* refactor(#3180): one owner for completion ratio, a prompt-layer drift guard, and a written behavior contract The 2026-08-08 coverage audit on #3180 found the epic's copy counts were a lower bound for the third consecutive time, and that two derivation families had never been named at all. ADR-3180 gains Decision 7 — a normative behavior contract that says what the right answer IS for each derivation, not merely who owns it. A reviewer with no written rule can only ask "does this look like the others", which is how a fifth copy passes review. Decision 4 gains (d) scan surface is every authored surface and an owner FILE is never exempt, only its named functions; and (e) a surface that cannot be consolidated today ships ratcheted, never unguarded. Completion ratio: `clampPercent` sat exported and unused beside six hand-inlined copies of its own body across five modules. All six now route through it; `clampPercentFromFraction` is added for the one caller that already held a fraction. Every migration is behaviour-identical — clampPercent's first line IS the `total > 0 ? … : 0` ternary each copy carried. Guarded by lint-completion-ratio-drift.cjs, which reports zero re-derivations with no file-level exemption. Prompt layer: workflow markdown re-derives live-plan counting in raw shell (#1762), invisible to every `src/`-scoped guard. lint-planning-prompt-drift.cjs scans it with a shrink-only baseline of the 7 sites that exist today — new sites fail, and a baseline entry that stops firing fails too, so an acknowledgment can never outlive the thing it describes. lint-milestone-window-drift.cjs stops exempting its owner file wholesale; only the four named canonical functions are exempt now. The blanket exemption was pointed at the one file most likely to grow the next copy, and it had. Refs #3180 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3180): link Phases 6-8 sub-issues (#3216, #3217, #3218) from ADR-3180 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3180): address orthogonal review — consumer-output identity tests, count-keyed ratchet, property coverage Five findings from the two orthogonal review passes, all fixed. Decision 4(c) breach: the completion-ratio identity test asserted at the OWNER, which is exactly the bypass that decision exists to close — a consumer can call clampPercent and then post-process locally, leaving both the lint and an owner-level test green. It now drives `roadmap analyze`, `query progress` and `stats` and asserts on their own output, over a fixture containing a `status: superseded` plan so a consumer that re-counted raw files would report 60 where the owner reports 75. Decision 4(e) breach: ratchet entries named the epic (#3180) rather than the issue that removes them. They name Phase 8 (#3218) now. The ratchet keyed on (file, text) alone, so plan-phase.md's two byte-identical sites were one indistinguishable key and migrating either would have left the guard green with the other alive. Entries carry an occurrence count; fewer than acknowledged fails as a partial migration, more fails as a new copy. Adds the missing MAX_REGEX_LITERAL_LEN boundary coverage the sibling guard's test already had, and the fast-check property tests CONTRIBUTING requires for clamp/budget-limit functions. Refs #3180 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: stop wrapping a nested double-spawn in a 15s wall-clock budget (bug #641 probes) `tests/ci-test-scope.test.cjs`'s `bug #641` block spawned `run-tests.cjs` under PROBE_TIMEOUT_MS=15000; that child then spawned a nested `node --test`. A fixed wall-clock budget around a double spawn, running inside a container that is concurrently executing the full ~31k-test suite, fails by construction under load. Confirmed against three full matrix runs. Every failure was shaped `null !== 0` — the child was KILLED, never an assertion about the thing under test. One captured probe had already printed the correct resolution (`suite="all" files=2: a.test.cjs b.test.cjs`) and was killed anyway. It reproduces on `next` alone: 5 failures on linux-node22, 0 on linux-node24. The victim subset varies by run and by lane. What these tests are actually about is suite-token RESOLUTION — `unit` as a bare token in --files/--files-from. Executing the seeded trivial files is incidental and is the entire timeout surface, so the assertions move in-process against the same functions `main()` calls, in the same order. `parseArgs`, `selectExplicitFiles`, `selectFiles` and `walkTestFiles` are exported for that; no behavior, signature or logic changed. No coverage lost: `tests/run-tests-harness.test.cjs` already spawns the harness for real and asserts exit codes end to end, on a 120s budget. Pre-existing on `next`, fixed here rather than deferred. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: delete the three elapsed-time assertions CLAUDE.md forbids asserting on wall-clock time. Three assertions did, and all three are load-sensitive: on a saturated bench each can fail while the code under test is correct. In every case the load-bearing assertion sits on the line above and the timing line adds no discrimination. run-with-timeout: the stated worry — "was this 124 the cap firing or the 30s harness backstop?" — is already answered by the assertion above it. A backstop kills by signal, which surfaces as status null, never 124. Observed directly this session: three matrix runs produced exactly that null shape from killed children. normalize-test-command and context-predicates: both bounded a ReDoS check. A threshold only ever separates "fast" from "slightly slow", which is bench load, not correctness — catastrophic backtracking on 800 KB of input does not take 251ms, it does not finish at all. A real regression therefore shows up as the suite being killed on that test, which is louder and more reliable than a number. The structural assertions (returned unchanged; cleanly rejected) are what actually carry those tests, and they stay. The sweep now reports zero elapsed-time assertions in tests/. The remaining Date.now() uses are unique-path suffixes, barrier deadlines, fixture timestamps and fake mtimes — none of them assertions. Pre-existing on `next`, fixed here rather than deferred. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3180): backfill changeset PR number (#3223) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3180): key the prompt-drift ratchet on POSIX paths so it works on Windows The baseline keys on (file, trimmed text). `file` came from scanTree's `path.relative()`, which uses NATIVE separators, while the committed baseline stores POSIX. On Windows every violation was therefore unmatched — reported as FRESH — and every baseline entry matched nothing — reported as STALE. The guard failed 100% of the time there, on both CI shards: ✖ scanRepo(repoRoot) matches the baseline exactly: zero fresh AND zero stale + { file: 'gsd-core\\workflows\\execute-plan.md', ... } The remote runner this repo gates on is Linux-only and cannot see this class at all; the GitHub Actions Windows lane is what caught it. Normalization is unconditional — never gated on process.platform. A platform-conditional normalizer makes the POSIX path the special case and leaves the Windows branch unexercised on every other OS, which is the same blind spot in a different place. It is applied at one seam inside findPromptDrift, which builds `file` on every returned violation, so the baseline key, the --update writer, the stderr report and the tests all consume one normalized value. The regression tests drive a Windows-shaped relPath directly and run on every OS rather than skipping off-Windows — a test that only runs on the platform where the bug lives is why this escaped. They include a sanity check that un-normalized input does NOT match, so the assertion cannot pass vacuously. Audited the three sibling guards: none keys against a committed cross-platform baseline, and their exemption keys are path.join-built, so producer and consumer share the native convention. Left correct code alone rather than making them look alike. scripts/lib/drift-scan.cjs is untouched — normalizing there would break those three on Windows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1322 lines
54 KiB
JavaScript
1322 lines
54 KiB
JavaScript
/**
|
||
* Tests for the milestone-window single-owner contract (#3184, epic #3180
|
||
* Phase 2, ADR-3180). Matrix: .gsd/phase/refactor-3184-milestone-window-single-owner/50-test-matrix.md
|
||
*
|
||
* Covers:
|
||
* - src/roadmap-parser.cts `classifyMilestoneWindow` / `extractCurrentMilestoneScoped`
|
||
* — the SCOPE discriminator decision table (section A).
|
||
* - src/roadmap-parser.cts `computeMilestoneSectionEnd` — the sole section-end
|
||
* owner, replacing three former byte-identical copies (section B).
|
||
* - Consumer-output identity (ADR-3180 Decision 4c): `roadmap analyze`,
|
||
* `milestone complete --dry-run`, `getMilestonePhaseFilter`, `state sync`,
|
||
* and `phase complete`'s raw-range write scoping all asserted at the
|
||
* CONSUMER's own observable output, never the owner's return value alone
|
||
* (section C).
|
||
* - Destructive-consumer refusal: `milestone complete` refuses to archive a
|
||
* TRUNCATED window without `--force`, with the negative proof that
|
||
* nothing on disk moved (section D).
|
||
* - `state.cts`'s two `isMilestoneBoundedInRoadmap` call sites (section E).
|
||
* - `scripts/lint-milestone-window-drift.cjs` — the whole-repo drift guard
|
||
* (section F).
|
||
* - fast-check document-shaped property tests (section G, #2371 provenance).
|
||
*
|
||
* Uses helpers.cjs createTempDir/cleanup per CONTRIBUTING.md — never inline
|
||
* mkdtemp. IO failure injection uses mock.method(shellProj, 'platformReadSync', ...)
|
||
* restored via t.after(), never fs.chmodSync (root bypasses 000 in Docker/CI).
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const { test, mock } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const fc = require('fast-check');
|
||
|
||
const roadmapParser = require('../gsd-core/bin/lib/roadmap-parser.cjs');
|
||
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
|
||
const shellProj = require('../gsd-core/bin/lib/shell-command-projection.cjs');
|
||
const { createTempDir, cleanup, runGsdTools } = require('./helpers.cjs');
|
||
const driftGuard = require('../scripts/lint-milestone-window-drift.cjs');
|
||
const { sanitizeForReport } = require('../scripts/lib/drift-scan.cjs');
|
||
|
||
const {
|
||
classifyMilestoneWindow,
|
||
extractCurrentMilestoneScoped,
|
||
computeMilestoneSectionEnd,
|
||
locateMilestoneHeadings,
|
||
isMilestoneBoundedInRoadmap,
|
||
currentMilestoneRawRanges,
|
||
getMilestonePhaseFilter,
|
||
stripShippedMilestones,
|
||
} = roadmapParser;
|
||
|
||
// ─── Fixture helpers ───────────────────────────────────────────────────────
|
||
|
||
function planningDirOf(cwd) {
|
||
return path.join(cwd, '.planning');
|
||
}
|
||
|
||
function writeRoadmap(cwd, content) {
|
||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||
fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), content);
|
||
}
|
||
|
||
function writeState(cwd, fields) {
|
||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||
const lines = ['---'];
|
||
for (const [k, v] of Object.entries(fields)) lines.push(`${k}: ${v}`);
|
||
lines.push('---', '');
|
||
fs.writeFileSync(path.join(planningDirOf(cwd), 'STATE.md'), lines.join('\n'));
|
||
}
|
||
|
||
function writeFile(cwd, relPath, content) {
|
||
const full = path.join(cwd, relPath);
|
||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||
fs.writeFileSync(full, content);
|
||
}
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
// Section A — Scope classification (classifyMilestoneWindow + extractCurrentMilestoneScoped)
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
|
||
test('unscoped read by design reports COMPLETE', () => {
|
||
const content = ['# Roadmap', '', '## v1.0 Old ✅ SHIPPED', '', '### Phase 1: Foo'].join('\n');
|
||
const result = extractCurrentMilestoneScoped(content);
|
||
assert.strictEqual(result.scope, SCOPE.COMPLETE);
|
||
assert.strictEqual(result.value, stripShippedMilestones(content));
|
||
});
|
||
|
||
test('scoped window with phases reports COMPLETE', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v1.0' });
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'## v1.0 Current 🚧',
|
||
'',
|
||
'### Phase 1: Foo',
|
||
'',
|
||
'### Phase 2: Bar',
|
||
].join('\n');
|
||
|
||
const result = extractCurrentMilestoneScoped(content, cwd);
|
||
assert.strictEqual(result.scope, SCOPE.COMPLETE);
|
||
});
|
||
|
||
test('genuinely empty milestone is COMPLETE not TRUNCATED', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v1.0' });
|
||
const content = ['# Roadmap', '', '## v1.0 Current 🚧', '', 'Nothing planned yet.'].join('\n');
|
||
|
||
const result = extractCurrentMilestoneScoped(content, cwd);
|
||
assert.strictEqual(result.scope, SCOPE.COMPLETE);
|
||
});
|
||
|
||
test('window closed before the phase region reports TRUNCATED', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v3.0' });
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'## v3.0 In Progress 🚧',
|
||
'',
|
||
'Some preamble notes. No phase headings here.',
|
||
'',
|
||
'## v4.0 Next',
|
||
'',
|
||
'### Phase 1: Foo',
|
||
'',
|
||
'### Phase 2: Bar',
|
||
].join('\n');
|
||
|
||
const result = extractCurrentMilestoneScoped(content, cwd);
|
||
assert.strictEqual(result.scope, SCOPE.TRUNCATED);
|
||
});
|
||
|
||
test('free-form roadmap is COMPLETE not UNSCOPED', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
// No STATE.md milestone field at all -- no version resolvable, and the
|
||
// roadmap carries no versioned milestone headings anywhere.
|
||
const content = ['# Roadmap', '', '## Overview', '', '### Phase 1: Foo'].join('\n');
|
||
|
||
const result = extractCurrentMilestoneScoped(content, cwd);
|
||
assert.strictEqual(result.scope, SCOPE.COMPLETE);
|
||
});
|
||
|
||
test('versioned roadmap with no resolvable milestone is UNSCOPED', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
// No STATE.md milestone field -- no version resolvable -- but the roadmap
|
||
// DOES carry versioned milestone headings elsewhere.
|
||
const content = ['# Roadmap', '', '## v1.0 Old ✅ SHIPPED', '', '### Phase 1: Foo'].join('\n');
|
||
|
||
const result = extractCurrentMilestoneScoped(content, cwd);
|
||
assert.strictEqual(result.scope, SCOPE.UNSCOPED);
|
||
});
|
||
|
||
test('absent milestone section is UNSCOPED', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v9.9' });
|
||
const content = ['# Roadmap', '', '## v1.0 Old ✅ SHIPPED', '', '### Phase 1: Foo'].join('\n');
|
||
writeRoadmap(cwd, content);
|
||
|
||
const result = extractCurrentMilestoneScoped(content, cwd);
|
||
assert.strictEqual(result.scope, SCOPE.UNSCOPED);
|
||
|
||
// missingExplicitVersion is a getMilestonePhaseFilter-only field (not on
|
||
// ScopedResult) -- confirm the SAME "absent section" disposition is
|
||
// preserved there too, for the explicit-version-override argument shape.
|
||
const filter = getMilestonePhaseFilter(cwd, 'v9.9');
|
||
assert.strictEqual(filter.missingExplicitVersion, true);
|
||
assert.strictEqual(filter.scope, SCOPE.UNSCOPED);
|
||
});
|
||
|
||
test('unreadable roadmap reports UNREADABLE', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
writeRoadmap(cwd, ['# Roadmap', '', '## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
|
||
writeState(cwd, { milestone: 'v1.0' });
|
||
|
||
mock.method(shellProj, 'platformReadSync', () => {
|
||
throw new Error('EIO: simulated unreadable ROADMAP.md');
|
||
});
|
||
t.after(() => {
|
||
mock.restoreAll();
|
||
cleanup(cwd);
|
||
});
|
||
|
||
const filter = getMilestonePhaseFilter(cwd);
|
||
assert.strictEqual(filter.scope, SCOPE.UNREADABLE);
|
||
// The filter still degrades pass-all -- it is not a destructive consumer.
|
||
assert.strictEqual(filter('anything-01'), true);
|
||
assert.strictEqual(filter.phaseCount, 0);
|
||
});
|
||
|
||
test('empty roadmap is COMPLETE', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
// No STATE.md -- no version resolvable on empty content either.
|
||
const result = extractCurrentMilestoneScoped('', cwd);
|
||
assert.strictEqual(result.scope, SCOPE.COMPLETE);
|
||
});
|
||
|
||
test('bullet-style phase entries count as phases', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v1.0' });
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'## v1.0 Current 🚧',
|
||
'',
|
||
'- [ ] **Phase 1 — Foo**',
|
||
'- [ ] **Phase 2 — Bar**',
|
||
].join('\n');
|
||
|
||
const result = extractCurrentMilestoneScoped(content, cwd);
|
||
assert.strictEqual(result.scope, SCOPE.COMPLETE);
|
||
});
|
||
|
||
test('bullet-only doc still detects truncation', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v1.0' });
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'## v1.0 Current 🚧',
|
||
'',
|
||
'No phases yet.',
|
||
'',
|
||
'## v2.0 Next',
|
||
'',
|
||
'- [ ] **Phase 1 — Foo**',
|
||
].join('\n');
|
||
|
||
const result = extractCurrentMilestoneScoped(content, cwd);
|
||
assert.strictEqual(result.scope, SCOPE.TRUNCATED);
|
||
});
|
||
|
||
// #3184 review finding: `hasPhaseEntries`'s bullet fallback was not
|
||
// fence-aware (unlike its ATX-heading path, which uses `tokenizeHeadings`).
|
||
// A FENCED example of the bullet syntax -- e.g. documentation showing the
|
||
// convention inside a non-<details>-wrapped SHIPPED milestone section --
|
||
// inflated `documentHasPhaseEntries` and misclassified a genuinely-empty
|
||
// active milestone TRUNCATED instead of COMPLETE, which then made
|
||
// `cmdMilestoneComplete` refuse a legitimate archive without --force.
|
||
test('fenced bullet-phase example is not a phase entry', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v1.0' });
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'## v0.9 Old ✅ SHIPPED',
|
||
'',
|
||
'Example bullet-phase syntax for reference:',
|
||
'',
|
||
'```markdown',
|
||
'- [ ] **Phase 3 — Name**',
|
||
'```',
|
||
'',
|
||
'## v1.0 Current 🚧',
|
||
'',
|
||
'Nothing planned yet.',
|
||
].join('\n');
|
||
|
||
const result = extractCurrentMilestoneScoped(content, cwd);
|
||
// Genuinely empty active milestone: the only bullet-phase-shaped text
|
||
// anywhere in the document is fenced, so it must not count as a real
|
||
// phase entry on either side of the row-8 comparison -- COMPLETE, not
|
||
// TRUNCATED.
|
||
assert.strictEqual(result.scope, SCOPE.COMPLETE);
|
||
});
|
||
|
||
// Companion to the fenced case above: a real (unfenced) bullet phase entry
|
||
// outside the window must still classify TRUNCATED, proving the fence-aware
|
||
// fix strips fences rather than disabling bullet detection outright. This is
|
||
// the same fixture as 'bullet-only doc still detects truncation' above,
|
||
// asserted again here to pin both directions of the fix in one place.
|
||
test('unfenced bullet-phase entry outside the window still truncates', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v1.0' });
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'## v1.0 Current 🚧',
|
||
'',
|
||
'No phases yet.',
|
||
'',
|
||
'## v2.0 Next',
|
||
'',
|
||
'- [ ] **Phase 1 — Foo**',
|
||
].join('\n');
|
||
|
||
const result = extractCurrentMilestoneScoped(content, cwd);
|
||
assert.strictEqual(result.scope, SCOPE.TRUNCATED);
|
||
});
|
||
|
||
test('shipped-details phases do not fake a truncation', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v2.0' });
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'<details>',
|
||
'<summary>✅ v1.0 SHIPPED</summary>',
|
||
'',
|
||
'### Phase 1: Foo',
|
||
'',
|
||
'</details>',
|
||
'',
|
||
'## v2.0 Current 🚧',
|
||
'',
|
||
'No phases yet.',
|
||
].join('\n');
|
||
|
||
const result = extractCurrentMilestoneScoped(content, cwd);
|
||
assert.strictEqual(result.scope, SCOPE.COMPLETE);
|
||
});
|
||
|
||
test('sentinel-only window is COMPLETE', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v1.0' });
|
||
const content = ['# Roadmap', '', '## v1.0 Current 🚧', '', '### Phase 999.1: Backlog item'].join('\n');
|
||
|
||
const result = extractCurrentMilestoneScoped(content, cwd);
|
||
assert.strictEqual(result.scope, SCOPE.COMPLETE);
|
||
});
|
||
|
||
test('phase prose and horizontal rules are not phase entries', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v1.0' });
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'## v1.0 Current 🚧',
|
||
'',
|
||
'As discussed in Phase 3, we will revisit this.',
|
||
'',
|
||
'---',
|
||
'',
|
||
'More notes.',
|
||
].join('\n');
|
||
|
||
const result = extractCurrentMilestoneScoped(content, cwd);
|
||
// Neither the prose mention nor the `---` rule is a phase entry, so this
|
||
// reduces to the "genuinely empty milestone" shape -- COMPLETE, not TRUNCATED.
|
||
assert.strictEqual(result.scope, SCOPE.COMPLETE);
|
||
});
|
||
|
||
test('fenced milestone heading is not a boundary', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v1.0' });
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'## v1.0 Current 🚧',
|
||
'',
|
||
'```markdown',
|
||
'## v9.9 milestone',
|
||
'```',
|
||
'',
|
||
'### Phase 1: Foo',
|
||
].join('\n');
|
||
|
||
const result = extractCurrentMilestoneScoped(content, cwd);
|
||
// The fenced heading must not stop the window early -- Phase 1 stays
|
||
// inside it, so the window has phase entries and reads COMPLETE.
|
||
assert.strictEqual(result.scope, SCOPE.COMPLETE);
|
||
});
|
||
|
||
test('partial truncation is not detected (documented limit)', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v1.0' });
|
||
const content = [
|
||
'# Roadmap',
|
||
'',
|
||
'## v1.0 Current 🚧',
|
||
'',
|
||
'### Phase 1: Foo',
|
||
'',
|
||
'## v2.0 Next',
|
||
'',
|
||
'### Phase 2: Bar',
|
||
].join('\n');
|
||
|
||
const result = extractCurrentMilestoneScoped(content, cwd);
|
||
// The window has SOME phases (Phase 1), so this reads COMPLETE even
|
||
// though the document has more (Phase 2) outside the window -- design's
|
||
// documented "partial truncation is invisible" limit.
|
||
assert.strictEqual(result.scope, SCOPE.COMPLETE);
|
||
});
|
||
|
||
// ─── A17: CRLF variants classify identically (table-driven) ───────────────
|
||
|
||
const CRLF_TABLE = [
|
||
{
|
||
name: 'A2 scoped-window-with-phases',
|
||
version: 'v1.0',
|
||
lines: ['# Roadmap', '', '## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'],
|
||
},
|
||
{
|
||
name: 'A3 genuinely-empty-milestone',
|
||
version: 'v1.0',
|
||
lines: ['# Roadmap', '', '## v1.0 Current 🚧', '', 'Nothing planned yet.'],
|
||
},
|
||
{
|
||
name: 'A4 window-closed-before-phases',
|
||
version: 'v3.0',
|
||
lines: [
|
||
'# Roadmap', '', '## v3.0 In Progress 🚧', '', 'Some preamble notes. No phase headings here.',
|
||
'', '## v4.0 Next', '', '### Phase 1: Foo', '', '### Phase 2: Bar',
|
||
],
|
||
},
|
||
{
|
||
name: 'A5 free-form-legacy',
|
||
version: null,
|
||
lines: ['# Roadmap', '', '## Overview', '', '### Phase 1: Foo'],
|
||
},
|
||
{
|
||
name: 'A6 versioned-unscoped',
|
||
version: null,
|
||
lines: ['# Roadmap', '', '## v1.0 Old ✅ SHIPPED', '', '### Phase 1: Foo'],
|
||
},
|
||
{
|
||
name: 'A10 bullet-in-window',
|
||
version: 'v1.0',
|
||
lines: ['# Roadmap', '', '## v1.0 Current 🚧', '', '- [ ] **Phase 1 — Foo**', '- [ ] **Phase 2 — Bar**'],
|
||
},
|
||
{
|
||
name: 'A11 bullet-only-truncated',
|
||
version: 'v1.0',
|
||
lines: [
|
||
'# Roadmap', '', '## v1.0 Current 🚧', '', 'No phases yet.', '',
|
||
'## v2.0 Next', '', '- [ ] **Phase 1 — Foo**',
|
||
],
|
||
},
|
||
{
|
||
name: 'A12 shipped-details-trap',
|
||
version: 'v2.0',
|
||
lines: [
|
||
'# Roadmap', '', '<details>', '<summary>✅ v1.0 SHIPPED</summary>', '',
|
||
'### Phase 1: Foo', '', '</details>', '', '## v2.0 Current 🚧', '', 'No phases yet.',
|
||
],
|
||
},
|
||
{
|
||
name: 'A13 sentinel-only-window',
|
||
version: 'v1.0',
|
||
lines: ['# Roadmap', '', '## v1.0 Current 🚧', '', '### Phase 999.1: Backlog item'],
|
||
},
|
||
{
|
||
name: 'A15 fenced-heading-not-boundary',
|
||
version: 'v1.0',
|
||
lines: [
|
||
'# Roadmap', '', '## v1.0 Current 🚧', '', '```markdown', '## v9.9 milestone', '```',
|
||
'', '### Phase 1: Foo',
|
||
],
|
||
},
|
||
];
|
||
|
||
test('CRLF variants classify identically', (t) => {
|
||
const tmpDirs = [];
|
||
t.after(() => {
|
||
for (const dir of tmpDirs) cleanup(dir);
|
||
});
|
||
|
||
for (const row of CRLF_TABLE) {
|
||
const lfContent = row.lines.join('\n');
|
||
const crlfContent = row.lines.join('\n').replace(/\n/g, '\r\n');
|
||
|
||
const lfCwd = createTempDir('gsd-milestone-window-crlf-lf-');
|
||
const crlfCwd = createTempDir('gsd-milestone-window-crlf-crlf-');
|
||
tmpDirs.push(lfCwd, crlfCwd);
|
||
|
||
if (row.version) {
|
||
writeState(lfCwd, { milestone: row.version });
|
||
writeState(crlfCwd, { milestone: row.version });
|
||
}
|
||
const lfScope = extractCurrentMilestoneScoped(lfContent, lfCwd).scope;
|
||
const crlfScope = extractCurrentMilestoneScoped(crlfContent, crlfCwd).scope;
|
||
assert.strictEqual(crlfScope, lfScope, `CRLF mismatch for ${row.name}: LF=${lfScope} CRLF=${crlfScope}`);
|
||
}
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
// Section B — Section-end owner (computeMilestoneSectionEnd)
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
|
||
test('stops at the next same-level milestone heading', () => {
|
||
const headingLine = '## v1.0 Current 🚧';
|
||
const content = [headingLine, 'body', '## v2.0 Next 📋', 'more'].join('\n');
|
||
const headingStart = content.indexOf(headingLine);
|
||
const end = computeMilestoneSectionEnd(content, headingLine, headingStart);
|
||
assert.strictEqual(end, content.indexOf('## v2.0 Next 📋'));
|
||
});
|
||
|
||
test('runs to end of document when no boundary follows', () => {
|
||
const headingLine = '## v1.0 Current 🚧';
|
||
const content = [headingLine, 'body forever'].join('\n');
|
||
const headingStart = content.indexOf(headingLine);
|
||
const end = computeMilestoneSectionEnd(content, headingLine, headingStart);
|
||
assert.strictEqual(end, content.length);
|
||
});
|
||
|
||
test('level-2 boundary stops a level-2 section', () => {
|
||
const headingLine = '## v1.0 Current 🚧';
|
||
const content = [headingLine, 'body', '## v2.0 Next 📋'].join('\n');
|
||
const headingStart = content.indexOf(headingLine);
|
||
const end = computeMilestoneSectionEnd(content, headingLine, headingStart);
|
||
assert.strictEqual(end, content.indexOf('## v2.0 Next 📋'));
|
||
assert.notStrictEqual(end, content.length);
|
||
});
|
||
|
||
test('level-3 boundary stops a level-3 section', () => {
|
||
const headingLine = '### v1.0 Current 🚧';
|
||
const content = [headingLine, 'body', '### v2.0 Next 📋'].join('\n');
|
||
const headingStart = content.indexOf(headingLine);
|
||
const end = computeMilestoneSectionEnd(content, headingLine, headingStart);
|
||
assert.strictEqual(end, content.indexOf('### v2.0 Next 📋'));
|
||
assert.notStrictEqual(end, content.length);
|
||
});
|
||
|
||
test('level-4 heading is not a milestone boundary', () => {
|
||
const headingLine = '## v1.0 Current 🚧';
|
||
const content = [headingLine, 'body', '#### v2.0 Next 📋', 'tail marker here'].join('\n');
|
||
const headingStart = content.indexOf(headingLine);
|
||
const end = computeMilestoneSectionEnd(content, headingLine, headingStart);
|
||
// #{1,3} is the owner's level ceiling -- a level-4 heading is outside it
|
||
// and must never stop the window, regardless of the marker it carries.
|
||
assert.strictEqual(end, content.length);
|
||
});
|
||
|
||
test('deeper heading is not a boundary', () => {
|
||
const headingLine = '## v1.0 Current 🚧';
|
||
const content = [headingLine, 'body', '### v2.0 Sub 📋', 'tail'].join('\n');
|
||
const headingStart = content.indexOf(headingLine);
|
||
const end = computeMilestoneSectionEnd(content, headingLine, headingStart);
|
||
assert.strictEqual(end, content.length);
|
||
});
|
||
|
||
test('phase heading is never a boundary', () => {
|
||
const headingLine = '## v1.0 Current 🚧';
|
||
const content = [headingLine, 'body', '## Phase 2: v2.0 Launch 📋', 'tail'].join('\n');
|
||
const headingStart = content.indexOf(headingLine);
|
||
const end = computeMilestoneSectionEnd(content, headingLine, headingStart);
|
||
assert.strictEqual(end, content.length);
|
||
});
|
||
|
||
test('unmarked heading is not a boundary', () => {
|
||
const headingLine = '## v1.0 Current 🚧';
|
||
const content = [headingLine, 'body', '## Notes', 'tail'].join('\n');
|
||
const headingStart = content.indexOf(headingLine);
|
||
const end = computeMilestoneSectionEnd(content, headingLine, headingStart);
|
||
assert.strictEqual(end, content.length);
|
||
});
|
||
|
||
test('own heading is not its own boundary', () => {
|
||
// A single heading with no other content: the only tokenizeHeadings
|
||
// candidate is the heading itself (offset === headingStart), which the
|
||
// `h.offset <= headingStart` skip must exclude, forcing a fall-through to
|
||
// content.length rather than a zero-length section.
|
||
const headingLine = '## v1.0 Current 🚧';
|
||
const content = [headingLine, 'body'].join('\n');
|
||
const headingStart = content.indexOf(headingLine);
|
||
const end = computeMilestoneSectionEnd(content, headingLine, headingStart);
|
||
assert.strictEqual(end, content.length);
|
||
assert.notStrictEqual(end, headingStart);
|
||
});
|
||
|
||
test('offset inside the heading line is not a boundary', () => {
|
||
// Defensive-seam test: production callers always pass a headingText whose
|
||
// length matches the real heading LINE, so afterHeading never legitimately
|
||
// overlaps a DIFFERENT heading's offset. This exercises the seam directly
|
||
// by passing an artificially long headingText that extends afterHeading
|
||
// past a second, real heading's own offset -- that second heading's
|
||
// candidacy must be skipped as "inside the heading span", not picked up
|
||
// as a boundary.
|
||
const headingLine = '## v1.0 Current 🚧';
|
||
const content = [headingLine, '## v2.0 Next 📋', 'tail'].join('\n');
|
||
const realStart = content.indexOf(headingLine);
|
||
const paddedHeadingText = headingLine + '\n## v2.0 Next 📋';
|
||
const end = computeMilestoneSectionEnd(content, paddedHeadingText, realStart);
|
||
assert.strictEqual(end, content.length);
|
||
});
|
||
|
||
test('three former copies agree via one owner', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v1.0' });
|
||
// No preamble before the milestone heading and no "(Phase Details)"
|
||
// append, so extractCurrentMilestoneScoped's `.value` is EXACTLY
|
||
// content.slice(sectionStart, sectionEnd) -- letting all three former
|
||
// call sites be cross-checked against the SAME owner offset.
|
||
const content = ['## v1.0 Current 🚧', '', '### Phase 1: Foo', ''].join('\n');
|
||
|
||
const headingMatches = locateMilestoneHeadings(content, 'v1.0');
|
||
assert.strictEqual(headingMatches.length, 1);
|
||
const selected = headingMatches[0];
|
||
const ownerEnd = computeMilestoneSectionEnd(content, selected[0], selected.index);
|
||
|
||
// Consumer 1: currentMilestoneRawRanges.
|
||
const ranges = currentMilestoneRawRanges(content, cwd);
|
||
assert.ok(ranges);
|
||
assert.strictEqual(ranges.primary.end, ownerEnd);
|
||
assert.strictEqual(ranges.primary.start, selected.index);
|
||
|
||
// Consumer 2: extractCurrentMilestoneScoped -- with no preamble and no
|
||
// details append, `.value` equals the same [start,end) slice exactly.
|
||
const scoped = extractCurrentMilestoneScoped(content, cwd);
|
||
assert.strictEqual(scoped.value, content.slice(selected.index, ownerEnd));
|
||
|
||
// Consumer 3: getMilestonePhaseFilter's versionOverride branch -- same
|
||
// phase set as slicing [start,end) directly would produce.
|
||
const filter = getMilestonePhaseFilter(cwd, 'v1.0');
|
||
assert.strictEqual(filter('01-foo'), true);
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
// Section C — Consumer-output identity (ADR-3180 Decision 4c)
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
|
||
test('roadmap.analyze phase set matches the owner window', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v2.0' });
|
||
writeRoadmap(cwd, [
|
||
'<details>',
|
||
'<summary>✅ v1.0 SHIPPED</summary>',
|
||
'',
|
||
'### Phase 1: Foo',
|
||
'',
|
||
'</details>',
|
||
'',
|
||
'## v2.0 Current 🚧',
|
||
'',
|
||
'### Phase 1: Foo',
|
||
'',
|
||
'### Phase 2: Bar',
|
||
].join('\n'));
|
||
fs.mkdirSync(path.join(cwd, '.planning', 'phases', '01-foo'), { recursive: true });
|
||
fs.mkdirSync(path.join(cwd, '.planning', 'phases', '02-bar'), { recursive: true });
|
||
|
||
const analyzeResult = runGsdTools(['roadmap', 'analyze', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(analyzeResult.success, true, analyzeResult.error);
|
||
const analyzed = JSON.parse(analyzeResult.output);
|
||
const analyzedNumbers = analyzed.phases.map((p) => p.number).sort();
|
||
|
||
// Owner window: getMilestonePhaseFilter membership for the SAME cwd/version.
|
||
const filter = getMilestonePhaseFilter(cwd, 'v2.0');
|
||
assert.strictEqual(filter('01-foo'), true);
|
||
assert.strictEqual(filter('02-bar'), true);
|
||
assert.deepStrictEqual(analyzedNumbers, ['1', '2']);
|
||
assert.strictEqual(analyzed.scope, SCOPE.COMPLETE);
|
||
});
|
||
|
||
test('roadmap analyze reports scope truncated on a truncated window', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
// #3165 layout: an ACTIVE milestone heading for STATE.md's version,
|
||
// immediately followed by a CLOSED milestone heading at the SAME heading
|
||
// level before any `### Phase N:` section -- the phase sections live
|
||
// under the CLOSED heading, outside the ACTIVE window.
|
||
writeState(cwd, { milestone: 'v3.0' });
|
||
writeRoadmap(cwd, [
|
||
'# Roadmap',
|
||
'',
|
||
'## v3.0 Current 🚧',
|
||
'',
|
||
'## v2.0 Old ✅ SHIPPED',
|
||
'',
|
||
'### Phase 1: Foo',
|
||
'',
|
||
'### Phase 2: Bar',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools(['roadmap', 'analyze', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(result.success, true, result.error);
|
||
const analyzed = JSON.parse(result.output);
|
||
assert.strictEqual(analyzed.scope, SCOPE.TRUNCATED);
|
||
// Deliberately unchanged: the count stays 0 either way -- `scope` is what
|
||
// carries the truncation signal, not `phase_count`.
|
||
assert.strictEqual(analyzed.phase_count, 0);
|
||
});
|
||
|
||
test('roadmap analyze reports scope complete on a genuinely empty milestone', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
// Same shape as the truncated fixture above, but the document carries no
|
||
// phase entries anywhere -- the negative proof that the truncated
|
||
// assertion above is not just "any zero-phase roadmap reports truncated".
|
||
writeState(cwd, { milestone: 'v3.0' });
|
||
writeRoadmap(cwd, [
|
||
'# Roadmap',
|
||
'',
|
||
'## v3.0 Current 🚧',
|
||
'',
|
||
'## v2.0 Old ✅ SHIPPED',
|
||
'',
|
||
'Nothing here either.',
|
||
].join('\n'));
|
||
|
||
const result = runGsdTools(['roadmap', 'analyze', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(result.success, true, result.error);
|
||
const analyzed = JSON.parse(result.output);
|
||
assert.strictEqual(analyzed.scope, SCOPE.COMPLETE);
|
||
assert.strictEqual(analyzed.phase_count, 0);
|
||
});
|
||
|
||
test('roadmap analyze emits a scope field on every result', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v2.0' });
|
||
writeRoadmap(cwd, ['## v2.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n'));
|
||
fs.mkdirSync(path.join(cwd, '.planning', 'phases', '01-foo'), { recursive: true });
|
||
fs.mkdirSync(path.join(cwd, '.planning', 'phases', '02-bar'), { recursive: true });
|
||
|
||
const result = runGsdTools(['roadmap', 'analyze', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(result.success, true, result.error);
|
||
const analyzed = JSON.parse(result.output);
|
||
assert.strictEqual(Object.hasOwn(analyzed, 'scope'), true);
|
||
assert.strictEqual(Object.values(SCOPE).includes(analyzed.scope), true);
|
||
});
|
||
|
||
test('milestone.complete scoping matches the owner window', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v2.0' });
|
||
// #3184 review finding: the shipped v1.0 phase MUST use a phase NUMBER
|
||
// that does not also appear in v2.0's own window. getMilestonePhaseFilter
|
||
// scopes by matching a directory's NUMERIC phase-id prefix against the
|
||
// set of phase numbers found inside the target milestone's own sliced
|
||
// window -- it has no notion of "which milestone section a directory
|
||
// came from" beyond that number. The original fixture gave both the
|
||
// shipped v1.0 phase and the current v2.0 phase the SAME number ("1"),
|
||
// so both `01-old-shipped` and `01-foo` matched by numeric-prefix
|
||
// coincidence regardless of windowing -- that tested directory-naming
|
||
// overlap, not window scoping, and encoded a wrong expectation.
|
||
writeRoadmap(cwd, [
|
||
'<details>',
|
||
'<summary>✅ v1.0 SHIPPED</summary>',
|
||
'',
|
||
'### Phase 5: Foo',
|
||
'',
|
||
'</details>',
|
||
'',
|
||
'## v2.0 Current 🚧',
|
||
'',
|
||
'### Phase 1: Foo',
|
||
].join('\n'));
|
||
fs.mkdirSync(path.join(cwd, '.planning', 'phases', '05-old-shipped'), { recursive: true });
|
||
fs.mkdirSync(path.join(cwd, '.planning', 'phases', '01-foo'), { recursive: true });
|
||
|
||
const dryRun = runGsdTools(['milestone', 'complete', 'v2.0', '--dry-run', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(dryRun.success, true, dryRun.error);
|
||
const parsed = JSON.parse(dryRun.output);
|
||
|
||
const filter = getMilestonePhaseFilter(cwd, 'v2.0');
|
||
const expectedArchived = ['05-old-shipped', '01-foo'].filter((name) => filter(name)).sort();
|
||
assert.deepStrictEqual([...parsed.would_archive.phases].sort(), expectedArchived);
|
||
assert.strictEqual(expectedArchived.includes('01-foo'), true);
|
||
assert.strictEqual(expectedArchived.includes('05-old-shipped'), false);
|
||
});
|
||
|
||
test('filter membership matches the owner window', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v1.0' });
|
||
const content = ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n');
|
||
writeRoadmap(cwd, content);
|
||
|
||
const filter = getMilestonePhaseFilter(cwd);
|
||
assert.strictEqual(filter.phaseCount, 2);
|
||
assert.strictEqual(filter('01-foo'), true);
|
||
assert.strictEqual(filter('02-bar'), true);
|
||
assert.strictEqual(filter('03-baz'), false);
|
||
assert.strictEqual(filter.scope, SCOPE.COMPLETE);
|
||
});
|
||
|
||
// #3184 review finding: `getMilestonePhaseFilter`'s #2199 bullet scan ran
|
||
// against un-stripped window content, so a fenced bullet-phase example
|
||
// inflated `milestonePhaseNums` / `phaseCount` the same way it inflated
|
||
// `hasPhaseEntries` above.
|
||
test('fenced bullet-phase example does not inflate phaseCount', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v1.0' });
|
||
const content = [
|
||
'## v1.0 Current 🚧',
|
||
'',
|
||
'### Phase 1: Foo',
|
||
'',
|
||
'Example bullet-phase syntax for reference:',
|
||
'',
|
||
'```markdown',
|
||
'- [ ] **Phase 3 — Name**',
|
||
'```',
|
||
].join('\n');
|
||
writeRoadmap(cwd, content);
|
||
|
||
const filter = getMilestonePhaseFilter(cwd, 'v1.0');
|
||
// Only the real heading (Phase 1) counts -- the fenced bullet example
|
||
// (Phase 3) must not.
|
||
assert.strictEqual(filter.phaseCount, 1);
|
||
assert.strictEqual(filter('01-foo'), true);
|
||
assert.strictEqual(filter('03-name'), false);
|
||
});
|
||
|
||
test('state bounding matches the owner predicate', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
fs.mkdirSync(path.join(cwd, '.planning', 'phases', '01-foo'), { recursive: true });
|
||
writeFile(cwd, '.planning/phases/01-foo/01-PLAN.md', '# Plan\n');
|
||
writeFile(cwd, '.planning/phases/01-foo/01-SUMMARY.md', '# Summary\n');
|
||
writeState(cwd, { milestone: 'v2.0.1' });
|
||
|
||
// Unbound case: asserted v2.0.1, heading only has v2.0 -- exercises the
|
||
// exact #2562-class boundary defect (row 17).
|
||
writeRoadmap(cwd, ['## v2.0 Launch', '', '### Phase 1: Foo'].join('\n'));
|
||
const roadmapUnbound = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8');
|
||
assert.strictEqual(isMilestoneBoundedInRoadmap(roadmapUnbound, 'v2.0.1'), false);
|
||
|
||
const jsonUnbound = runGsdTools(['state', 'json', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(jsonUnbound.success, true, jsonUnbound.error);
|
||
const parsedUnbound = JSON.parse(jsonUnbound.output);
|
||
assert.strictEqual('percent' in (parsedUnbound.progress || {}), false);
|
||
|
||
const syncUnbound = runGsdTools(['state', 'sync', '--verify', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(syncUnbound.success, true, syncUnbound.error);
|
||
const syncParsedUnbound = JSON.parse(syncUnbound.output);
|
||
assert.strictEqual(syncParsedUnbound.changes.length, 1);
|
||
|
||
// Bound case: heading matches exactly.
|
||
writeRoadmap(cwd, ['## v2.0.1 Launch', '', '### Phase 1: Foo'].join('\n'));
|
||
const roadmapBound = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8');
|
||
assert.strictEqual(isMilestoneBoundedInRoadmap(roadmapBound, 'v2.0.1'), true);
|
||
|
||
const jsonBound = runGsdTools(['state', 'json', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(jsonBound.success, true, jsonBound.error);
|
||
const parsedBound = JSON.parse(jsonBound.output);
|
||
assert.strictEqual('percent' in (parsedBound.progress || {}), true);
|
||
|
||
const syncBound = runGsdTools(['state', 'sync', '--verify', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(syncBound.success, true, syncBound.error);
|
||
const syncParsedBound = JSON.parse(syncBound.output);
|
||
assert.strictEqual(syncParsedBound.changes.length, 0);
|
||
});
|
||
|
||
test('raw ranges match the owner window', (t) => {
|
||
// NOTE: 40-design.md's blast-radius table (line ~92) names cmdPhaseInsert
|
||
// as currentMilestoneRawRanges's sole dependent. The current source
|
||
// (src/phase.cts:2250) shows the actual call site is inside
|
||
// cmdPhaseComplete instead -- a real discrepancy between the design doc
|
||
// and the code, reported back per the dispatch brief rather than silently
|
||
// adjusted around.
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v2.0' });
|
||
writeRoadmap(cwd, [
|
||
'<details>',
|
||
'<summary>✅ v1.0 SHIPPED</summary>',
|
||
'',
|
||
'### Phase 1: Foo',
|
||
'',
|
||
'**Plans**: 0/1 plans complete',
|
||
'',
|
||
'</details>',
|
||
'',
|
||
'## v2.0 Current 🚧',
|
||
'',
|
||
'### Phase 1: Foo',
|
||
'',
|
||
'**Plans**: 0/1 plans complete',
|
||
].join('\n'));
|
||
writeFile(cwd, '.planning/phases/01-foo/01-PLAN.md', '# Plan\n');
|
||
writeFile(cwd, '.planning/phases/01-foo/01-SUMMARY.md', '---\none-liner: did the thing\n---\n# Summary\n');
|
||
writeFile(cwd, '.planning/phases/01-foo/01-VERIFICATION.md', '---\nstatus: passed\n---\n# Verification\n');
|
||
|
||
const before = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8');
|
||
const ranges = currentMilestoneRawRanges(before, cwd);
|
||
assert.ok(ranges, 'expected a resolvable milestone window');
|
||
assert.ok(ranges.primary.start > 0, 'fixture must have a non-empty preamble to protect');
|
||
|
||
const result = runGsdTools(['phase', 'complete', '1', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(result.success, true, result.error);
|
||
|
||
const after = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8');
|
||
// Negative proof: the SHIPPED v1.0 section -- which lies entirely BEFORE
|
||
// the owner's computed window start -- is byte-identical after the write.
|
||
// A consumer that re-derived its own (potentially wrong) window boundary
|
||
// instead of consuming currentMilestoneRawRanges could leak the mutation
|
||
// into this identically-shaped sibling "Phase 1" section; this is exactly
|
||
// the regression currentMilestoneRawRanges exists to prevent.
|
||
assert.strictEqual(after.slice(0, ranges.primary.start), before.slice(0, ranges.primary.start));
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
// Section D — Destructive-consumer refusal (Tier-2)
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
|
||
function buildTruncatedFixture(cwd) {
|
||
writeState(cwd, { milestone: 'v3.0' });
|
||
writeRoadmap(cwd, [
|
||
'# Roadmap',
|
||
'',
|
||
'## v3.0 In Progress 🚧',
|
||
'',
|
||
'Some preamble notes. No phase headings here.',
|
||
'',
|
||
'## v4.0 Next',
|
||
'',
|
||
'### Phase 1: Foo',
|
||
'',
|
||
'### Phase 2: Bar',
|
||
].join('\n'));
|
||
fs.mkdirSync(path.join(cwd, '.planning', 'phases', '1-foo'), { recursive: true });
|
||
fs.mkdirSync(path.join(cwd, '.planning', 'phases', '2-bar'), { recursive: true });
|
||
}
|
||
|
||
test('milestone complete refuses to archive a truncated window', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
buildTruncatedFixture(cwd);
|
||
|
||
const result = runGsdTools(['milestone', 'complete', 'v3.0', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(result.success, false);
|
||
assert.notStrictEqual(result.exitCode, 0);
|
||
});
|
||
|
||
test('refusal leaves the phases directory untouched', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
buildTruncatedFixture(cwd);
|
||
|
||
const before = fs.readdirSync(path.join(cwd, '.planning', 'phases')).sort();
|
||
const result = runGsdTools(['milestone', 'complete', 'v3.0', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(result.success, false);
|
||
const after = fs.readdirSync(path.join(cwd, '.planning', 'phases')).sort();
|
||
assert.deepStrictEqual(after, before);
|
||
});
|
||
|
||
test('--force overrides the truncation refusal', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
buildTruncatedFixture(cwd);
|
||
|
||
const result = runGsdTools(['milestone', 'complete', 'v3.0', '--force', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(result.success, true, result.error);
|
||
const parsed = JSON.parse(result.output);
|
||
assert.strictEqual(parsed.archived.phases, true);
|
||
});
|
||
|
||
test('genuinely empty milestone still completes', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
writeState(cwd, { milestone: 'v1.0' });
|
||
writeRoadmap(cwd, ['# Roadmap', '', '## v1.0 Current 🚧', '', 'Nothing planned yet.'].join('\n'));
|
||
|
||
const filter = getMilestonePhaseFilter(cwd, 'v1.0');
|
||
assert.strictEqual(filter.scope, SCOPE.COMPLETE);
|
||
|
||
const result = runGsdTools(['milestone', 'complete', 'v1.0', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(result.success, true, result.error);
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
// Section E — state.cts boundary defect (design rows 17)
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
|
||
test('version token is boundary-matched not substring-matched', () => {
|
||
const content = ['## v2.0 Launch', '', '### Phase 1: Foo'].join('\n');
|
||
assert.strictEqual(isMilestoneBoundedInRoadmap(content, 'v2.0.1'), false);
|
||
});
|
||
|
||
test('exact version matches', () => {
|
||
const content = ['## v2.0.1 Launch', '', '### Phase 1: Foo'].join('\n');
|
||
assert.strictEqual(isMilestoneBoundedInRoadmap(content, 'v2.0.1'), true);
|
||
});
|
||
|
||
test('both state bounding sites agree', (t) => {
|
||
const cwd = createTempDir('gsd-milestone-window-');
|
||
t.after(() => cleanup(cwd));
|
||
fs.mkdirSync(path.join(cwd, '.planning', 'phases', '01-foo'), { recursive: true });
|
||
writeFile(cwd, '.planning/phases/01-foo/01-PLAN.md', '# Plan\n');
|
||
writeFile(cwd, '.planning/phases/01-foo/01-SUMMARY.md', '# Summary\n');
|
||
|
||
for (const [heading, expectBound] of [['## v2.0 Launch', false], ['## v2.0.1 Launch', true]]) {
|
||
writeState(cwd, { milestone: 'v2.0.1' });
|
||
writeRoadmap(cwd, [heading, '', '### Phase 1: Foo'].join('\n'));
|
||
const roadmapRaw = fs.readFileSync(path.join(cwd, '.planning', 'ROADMAP.md'), 'utf-8');
|
||
const ownerVerdict = isMilestoneBoundedInRoadmap(roadmapRaw, 'v2.0.1');
|
||
assert.strictEqual(ownerVerdict, expectBound);
|
||
|
||
// Site 1: buildStateFrontmatter, reached via `state json` -- typed via
|
||
// progress.percent presence/absence.
|
||
const jsonResult = runGsdTools(['state', 'json', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(jsonResult.success, true, jsonResult.error);
|
||
const parsedJson = JSON.parse(jsonResult.output);
|
||
assert.strictEqual('percent' in (parsedJson.progress || {}), expectBound, `state json site for ${heading}`);
|
||
|
||
// Site 2: cmdStateSync's own direct isMilestoneBoundedInRoadmap call --
|
||
// typed via the structural (numeric) changes[] length: an unbound
|
||
// milestone unconditionally pushes exactly one "Progress: skipped"
|
||
// change; a bound, already-in-sync fixture pushes none.
|
||
const syncResult = runGsdTools(['state', 'sync', '--verify', '--cwd', cwd, '--raw'], cwd);
|
||
assert.strictEqual(syncResult.success, true, syncResult.error);
|
||
const parsedSync = JSON.parse(syncResult.output);
|
||
assert.strictEqual(parsedSync.changes.length, expectBound ? 0 : 1, `state sync site for ${heading}`);
|
||
}
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
// Section F — Drift guard (scripts/lint-milestone-window-drift.cjs)
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
|
||
const REPO_ROOT = path.join(__dirname, '..');
|
||
|
||
// A single line carrying BOTH the heading-quantifier token (a) and the
|
||
// phase-lookahead token (b1) -- the narrowest shape findMilestoneWindowDrift
|
||
// flags, independent of any version/marker pairing.
|
||
function violatingLine() {
|
||
return "const RE = /^#{1,3}\\s+(?!Phase\\s+\\S).*/;\n";
|
||
}
|
||
|
||
test('zero independent re-derivations remain', () => {
|
||
const violations = driftGuard.scanRepo(REPO_ROOT);
|
||
assert.deepStrictEqual(violations, []);
|
||
});
|
||
|
||
test('a new re-derivation is reported', (t) => {
|
||
const root = createTempDir('gsd-milestone-window-drift-root-');
|
||
t.after(() => cleanup(root));
|
||
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
||
fs.writeFileSync(path.join(root, 'src', 'fake.cts'), violatingLine());
|
||
|
||
const violations = driftGuard.scanRepo(root);
|
||
assert.strictEqual(violations.length, 1);
|
||
assert.strictEqual(violations[0].file, path.join('src', 'fake.cts'));
|
||
assert.strictEqual(violations[0].line, 1);
|
||
});
|
||
|
||
test('function-scoped exemption suppresses only its own function', (t) => {
|
||
const root = createTempDir('gsd-milestone-window-drift-root-');
|
||
t.after(() => cleanup(root));
|
||
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
||
const content = [
|
||
'function checkW021() {',
|
||
` ${violatingLine().trim()}`,
|
||
'}',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(root, 'src', 'roadmap-command-router.cts'), content);
|
||
|
||
const violations = driftGuard.scanRepo(root);
|
||
assert.deepStrictEqual(violations, []);
|
||
});
|
||
|
||
test('exemption is function-scoped, not file-scoped', (t) => {
|
||
const root = createTempDir('gsd-milestone-window-drift-root-');
|
||
t.after(() => cleanup(root));
|
||
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
||
const content = [
|
||
'function checkW021() {',
|
||
` ${violatingLine().trim()}`,
|
||
'}',
|
||
'',
|
||
'function someOtherFunction() {',
|
||
` ${violatingLine().trim()}`,
|
||
'}',
|
||
'',
|
||
].join('\n');
|
||
fs.writeFileSync(path.join(root, 'src', 'roadmap-command-router.cts'), content);
|
||
|
||
const violations = driftGuard.scanRepo(root);
|
||
// The exempted function's line is suppressed; the SAME shape inside a
|
||
// DIFFERENT function in the same exempted FILE is still reported.
|
||
assert.strictEqual(violations.length, 1);
|
||
assert.strictEqual(violations[0].line, 6);
|
||
});
|
||
|
||
test('symlinked source is not an evasion', { skip: process.platform === 'win32' ? 'symlink creation needs privilege on Windows' : false }, (t) => {
|
||
const root = createTempDir('gsd-milestone-window-drift-root-');
|
||
t.after(() => cleanup(root));
|
||
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
||
fs.mkdirSync(path.join(root, 'vendor'), { recursive: true });
|
||
const realFile = path.join(root, 'vendor', 'real-target.cts');
|
||
fs.writeFileSync(realFile, violatingLine());
|
||
fs.symlinkSync(realFile, path.join(root, 'src', 'linked.cts'));
|
||
|
||
const violations = driftGuard.scanRepo(root);
|
||
assert.strictEqual(violations.length, 1);
|
||
assert.strictEqual(violations[0].file, path.join('vendor', 'real-target.cts'));
|
||
});
|
||
|
||
test('root confinement holds', { skip: process.platform === 'win32' ? 'symlink creation needs privilege on Windows' : false }, (t) => {
|
||
const root = createTempDir('gsd-milestone-window-drift-root-');
|
||
const outside = createTempDir('gsd-milestone-window-drift-outside-');
|
||
t.after(() => {
|
||
cleanup(root);
|
||
cleanup(outside);
|
||
});
|
||
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
||
const outsideDir = path.join(outside, 'dir');
|
||
fs.mkdirSync(outsideDir, { recursive: true });
|
||
fs.writeFileSync(path.join(outsideDir, 'evil.cts'), violatingLine());
|
||
fs.symlinkSync(outsideDir, path.join(root, 'src', 'outdir'), 'dir');
|
||
|
||
const violations = driftGuard.scanRepo(root);
|
||
assert.strictEqual(violations.length, 0);
|
||
});
|
||
|
||
// The guard's owner file (src/roadmap-parser.cts) is no longer exempt as a
|
||
// whole file — only its named canonical functions are (FUNCTION_SCOPED_EXEMPTIONS).
|
||
// These synthesize a relPath of 'src/roadmap-parser.cts' WITHOUT touching the
|
||
// real source file, exercising findMilestoneWindowDrift directly.
|
||
test('a non-exempt top-level function in src/roadmap-parser.cts IS reported', () => {
|
||
const relPath = path.join('src', 'roadmap-parser.cts');
|
||
const text = [
|
||
'function someOtherFunction() {',
|
||
` ${violatingLine().trim()}`,
|
||
'}',
|
||
].join('\n');
|
||
const violations = driftGuard.findMilestoneWindowDrift(text, relPath);
|
||
assert.strictEqual(violations.length, 1);
|
||
assert.strictEqual(violations[0].line, 2);
|
||
});
|
||
|
||
const OWNER_FILE_EXEMPT_FUNCTIONS = [
|
||
'isMilestoneShippedInRoadmap',
|
||
'locateMilestoneHeadings',
|
||
'hasMilestoneSectioning',
|
||
'extractCurrentMilestoneScoped',
|
||
];
|
||
|
||
for (const fnName of OWNER_FILE_EXEMPT_FUNCTIONS) {
|
||
test(`owner-file exempt function ${fnName} in src/roadmap-parser.cts is NOT reported`, () => {
|
||
const relPath = path.join('src', 'roadmap-parser.cts');
|
||
const text = [
|
||
`function ${fnName}() {`,
|
||
` ${violatingLine().trim()}`,
|
||
'}',
|
||
].join('\n');
|
||
const violations = driftGuard.findMilestoneWindowDrift(text, relPath);
|
||
assert.deepStrictEqual(violations, []);
|
||
});
|
||
}
|
||
|
||
test('report output is sanitized', () => {
|
||
// findMilestoneWindowDrift returns the RAW fragment; main() sanitizes both
|
||
// `file` and `found` at the reporting boundary via the shared
|
||
// sanitizeForReport (scripts/lib/drift-scan.cjs) before writing to stderr.
|
||
// Assert that boundary actually escapes the hazardous classes a violating
|
||
// line/path could carry: C0/C1 control bytes and bidi override codepoints.
|
||
assert.strictEqual(sanitizeForReport(String.fromCharCode(0x1b)), '\\x1b');
|
||
assert.strictEqual(sanitizeForReport(''), '\\u202e');
|
||
const text = violatingLine().trim();
|
||
assert.strictEqual(sanitizeForReport(text), text, 'ordinary regex-literal punctuation must pass through unchanged');
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
// Section G — Property tests (fast-check, document-shaped, #2371)
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// Generators build documents from a heading/prose/fence/bullet alphabet,
|
||
// tracking each block's own offset/level/marker/phase-ness as it is
|
||
// assembled -- never by calling tokenizeHeadings, the milestone regexes, or
|
||
// any ROADMAP writer. The oracle (expected boundary) is computed from that
|
||
// SAME independently-tracked block metadata, not from the parser under
|
||
// test, so the property can fail against a real regression.
|
||
|
||
const SAFE_WORD = fc.stringMatching(/^[A-Za-z][A-Za-z0-9]{0,8}$/);
|
||
|
||
const headingBlockGen = fc.record({
|
||
kind: fc.constant('heading'),
|
||
level: fc.integer({ min: 1, max: 6 }),
|
||
isPhase: fc.boolean(),
|
||
hasMarker: fc.boolean(),
|
||
word: SAFE_WORD,
|
||
}).map((b) => ({
|
||
...b,
|
||
render() {
|
||
const prefix = '#'.repeat(this.level);
|
||
const phasePart = this.isPhase ? `Phase 3: ` : '';
|
||
const markerPart = this.hasMarker ? ' v2.0' : '';
|
||
return `${prefix} ${phasePart}${this.word}${markerPart}`;
|
||
},
|
||
}));
|
||
|
||
const proseBlockGen = SAFE_WORD.map((word) => ({
|
||
kind: 'prose',
|
||
render() { return `prose ${word} line`; },
|
||
}));
|
||
|
||
const fenceBlockGen = SAFE_WORD.map((word) => ({
|
||
kind: 'fence',
|
||
render() { return ['```text', `## fake ${word} v9.9`, '```'].join('\n'); },
|
||
}));
|
||
|
||
const bulletBlockGen = SAFE_WORD.map((word) => ({
|
||
kind: 'bullet',
|
||
render() { return `- [ ] ${word}`; },
|
||
}));
|
||
|
||
const blockGen = fc.oneof(headingBlockGen, proseBlockGen, fenceBlockGen, bulletBlockGen);
|
||
|
||
// G1: computeMilestoneSectionEnd always returns content.length or a valid
|
||
// same-or-shallower, non-Phase, marker-carrying heading offset, and always
|
||
// strictly after headingStart.
|
||
test('section end is always a valid boundary or EOF', () => {
|
||
const documentGen = fc.record({
|
||
before: fc.array(blockGen, { maxLength: 4 }),
|
||
// The target heading itself must be a valid milestone-heading shape
|
||
// (level 1-3, mirrors locateMilestoneHeadings's own #{1,3} precondition
|
||
// -- computeMilestoneSectionEnd is never called in production with a
|
||
// deeper headingText).
|
||
target: fc.record({
|
||
kind: fc.constant('heading'),
|
||
level: fc.integer({ min: 1, max: 3 }),
|
||
isPhase: fc.constant(false),
|
||
hasMarker: fc.constant(true),
|
||
word: SAFE_WORD,
|
||
}).map((b) => ({ ...b, render() { return `${'#'.repeat(this.level)} ${this.word} v1.0`; } })),
|
||
after: fc.array(blockGen, { minLength: 1, maxLength: 6 }),
|
||
});
|
||
|
||
fc.assert(
|
||
fc.property(documentGen, ({ before, target, after }) => {
|
||
const blocks = [...before, target, ...after];
|
||
let offset = 0;
|
||
const rendered = [];
|
||
const withOffsets = blocks.map((b) => {
|
||
const text = b.render();
|
||
const o = offset;
|
||
rendered.push(text);
|
||
offset += text.length + 1; // +1 for the '\n' join separator
|
||
return { ...b, text, offset: o };
|
||
});
|
||
const content = rendered.join('\n');
|
||
const targetEntry = withOffsets[before.length];
|
||
|
||
const end = computeMilestoneSectionEnd(content, targetEntry.text, targetEntry.offset);
|
||
|
||
assert.ok(end > targetEntry.offset, `end (${end}) must be strictly after headingStart (${targetEntry.offset})`);
|
||
|
||
if (end === content.length) return true;
|
||
|
||
const expectedBoundary = withOffsets
|
||
.slice(before.length + 1)
|
||
.find((b) => b.kind === 'heading' && b.level <= targetEntry.level && !b.isPhase && b.hasMarker);
|
||
assert.ok(expectedBoundary, `expected a boundary block at end=${end} but none was tracked`);
|
||
assert.strictEqual(end, expectedBoundary.offset);
|
||
return true;
|
||
}),
|
||
{ seed: 3184, numRuns: 200 },
|
||
);
|
||
});
|
||
|
||
// G2: classifyMilestoneWindow never returns TRUNCATED when the document has
|
||
// zero phase entries -- a pure decision-table property, no document text at all.
|
||
test('truncation requires phases outside the window', () => {
|
||
const inputGen = fc.record({
|
||
readable: fc.boolean(),
|
||
versionResolved: fc.boolean(),
|
||
hasVersionedMilestones: fc.boolean(),
|
||
headingFound: fc.boolean(),
|
||
windowHasPhaseEntries: fc.boolean(),
|
||
documentHasPhaseEntries: fc.constant(false),
|
||
});
|
||
fc.assert(
|
||
fc.property(inputGen, (input) => {
|
||
const scope = classifyMilestoneWindow(input);
|
||
assert.notStrictEqual(scope, SCOPE.TRUNCATED);
|
||
return true;
|
||
}),
|
||
{ seed: 3184, numRuns: 200 },
|
||
);
|
||
});
|
||
|
||
// G3: classification is invariant under LF<->CRLF conversion of the same document.
|
||
test('classification is newline-invariant', (t) => {
|
||
const documentGen = fc.record({
|
||
versioned: fc.boolean(),
|
||
blocks: fc.array(blockGen, { minLength: 1, maxLength: 6 }),
|
||
});
|
||
|
||
const report = fc.check(
|
||
fc.property(documentGen, ({ versioned, blocks }) => {
|
||
const headingLine = versioned ? '## v1.0 Current 🚧' : null;
|
||
const lines = headingLine ? [headingLine, ...blocks.map((b) => b.render())] : blocks.map((b) => b.render());
|
||
const lfContent = lines.join('\n');
|
||
const crlfContent = lines.join('\n').replace(/\n/g, '\r\n');
|
||
|
||
const lfCwd = createTempDir('gsd-milestone-window-g3-lf-');
|
||
const crlfCwd = createTempDir('gsd-milestone-window-g3-crlf-');
|
||
if (versioned) {
|
||
writeState(lfCwd, { milestone: 'v1.0' });
|
||
writeState(crlfCwd, { milestone: 'v1.0' });
|
||
}
|
||
const lfScope = extractCurrentMilestoneScoped(lfContent, lfCwd).scope;
|
||
const crlfScope = extractCurrentMilestoneScoped(crlfContent, crlfCwd).scope;
|
||
cleanup(lfCwd);
|
||
cleanup(crlfCwd);
|
||
return lfScope === crlfScope;
|
||
}),
|
||
{ seed: 3184, numRuns: 50 },
|
||
);
|
||
if (report.failed) {
|
||
t.diagnostic(`G3 counterexample: ${JSON.stringify(report.counterexample)}`);
|
||
}
|
||
assert.strictEqual(report.failed, false, 'classification must be newline-invariant');
|
||
});
|