Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
3419 lines
138 KiB
JavaScript
3419 lines
138 KiB
JavaScript
'use strict';
|
||
|
||
// docs-guard-exempt: this file performs no filesystem read of any docs/ path.
|
||
// Its only docs/ occurrence is the ADR-612 Decision 1 citation inside a line
|
||
// comment further down; every read call here targets a tmpdir .planning
|
||
// fixture. It trips Detector 3 because an odd prose backtick in the comment
|
||
// block above that citation opens a span the template-literal heuristic reads
|
||
// as a literal containing the citation. Registering it in the docs-guard lane
|
||
// would run this suite on docs/ changes whose content it never reads, so the
|
||
// marker is the correct side of that trade per this lint's own guidance.
|
||
|
||
/**
|
||
* PR-2 (#2761 / epic #612) — total_phases counts bracket phase headings.
|
||
*
|
||
* `total_phases` is derived TWICE: buildStateFrontmatter feeds `state json`, and
|
||
* cmdStateSync feeds `state sync`. The second carries the comment "Mirrors the
|
||
* logic in buildStateFrontmatter so both report consistent percents (#3242 Bug
|
||
* B)". Teaching one and not the other ships that divergence.
|
||
*
|
||
* TWO ORACLE HAZARDS this file is shaped around:
|
||
*
|
||
* 1. #1446 removed total_phases from the ratchet, so it corrects DOWNWARD
|
||
* silently. Every assertion here is an EXACT number; "no error" or ">= n"
|
||
* passes straight through the bug.
|
||
*
|
||
* 2. Reading `state json` after `state sync` measures the READ derivation
|
||
* twice — sync leaves STATE.md untouched when its computed total already
|
||
* matches, so the write-path guard is never observed and a mutation to it
|
||
* survives. The sync assertions below pre-write a WRONG total into the
|
||
* frontmatter, run sync, and then read THE FILE, so the number asserted is
|
||
* the one sync actually wrote.
|
||
*/
|
||
|
||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { runMsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||
const phaseIdHelpers = require('../msd-core/bin/lib/phase-id.cjs');
|
||
|
||
let tmpDir;
|
||
|
||
const stateMd = () => [
|
||
'---',
|
||
'msd_state_version: 1.0',
|
||
'milestone: v2.0',
|
||
'milestone_name: Expansion',
|
||
'status: executing',
|
||
'---',
|
||
'',
|
||
'# Project State',
|
||
'',
|
||
'**Phase:** 05',
|
||
'',
|
||
// The body Progress line is what makes sync derive and WRITE the progress
|
||
// block; without it sync has nothing to update and STATE.md is left untouched.
|
||
'**Progress:** [░░░░░░░░░░] 0%',
|
||
'',
|
||
].join('\n');
|
||
|
||
function writeProject(roadmap, convention, dirs = ['MSD.02-01-setup']) {
|
||
const planning = path.join(tmpDir, '.planning');
|
||
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), roadmap, 'utf-8');
|
||
fs.writeFileSync(path.join(planning, 'STATE.md'), stateMd(), 'utf-8');
|
||
fs.writeFileSync(
|
||
path.join(planning, 'config.json'),
|
||
JSON.stringify(convention === undefined ? {} : { phase_id_convention: convention }), 'utf-8',
|
||
);
|
||
// `state sync` short-circuits with no phase directories, leaving STATE.md
|
||
// byte-unchanged — which is exactly how a mutation to the write-path counter
|
||
// survives a test that reads `state json` afterwards. Give it real work.
|
||
// #3186 made completion disk-strict: a PLAN and SUMMARY no longer imply a
|
||
// completed phase without a passing verification verdict. Keep these fixtures
|
||
// modeling the complete phases they modeled before that upstream change.
|
||
// A dir spec is either `'name'` (a PLAN, SUMMARY, and passing verification)
|
||
// or `['name', false]` (a PLAN alone — incomplete). The mixed fixtures need
|
||
// both shapes to distinguish completed_phases from total_phases.
|
||
// Write verification last so it cannot be stale relative to the summary.
|
||
for (const spec of dirs) {
|
||
const [d, complete = true] = Array.isArray(spec) ? spec : [spec, true];
|
||
const dir = path.join(planning, 'phases', d);
|
||
fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(path.join(dir, '01-01-x-PLAN.md'), '# plan\n', 'utf-8');
|
||
if (complete) {
|
||
fs.writeFileSync(path.join(dir, '01-01-x-SUMMARY.md'), '# summary\n', 'utf-8');
|
||
fs.writeFileSync(
|
||
path.join(dir, verificationNameFor(d)),
|
||
'---\nstatus: passed\n---\n# Verification\n',
|
||
'utf-8',
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The verification filename cmdScaffold writes into `dir` when the phase is
|
||
* named by its unqualified token — `${normalizePhaseName(phase)}-VERIFICATION.md`
|
||
* (`src/commands.cts`): the PHASE'S OWN padded token.
|
||
*
|
||
* UPDATED at the origin/next merge (#3511): every complete dir used to get a
|
||
* hardcoded `01-VERIFICATION.md`, which was harmless while nothing scoped a
|
||
* phase directory's listing. Upstream #3511 made membership real — in any dir
|
||
* that is not phase 01 that hardcoded name is now (correctly, on BOTH
|
||
* conventions once #612 threads `convention` into the seam) a cross-phase
|
||
* stray, excluded from completion. These fixtures mean "a COMPLETE phase",
|
||
* not "a phase holding another phase's report", so they must write what the
|
||
* scaffolder writes. The stray shape is pinned deliberately, as its own
|
||
* regression block — see "#612 PR-2: a cross-phase stray in a bracket dir"
|
||
* below — so this rename cannot silently stand in for the seam fix.
|
||
*
|
||
* The padding comes from the production `normalizePhaseName` itself (single
|
||
* owner, no fixture drift). What stays fixture-local is the bracket-prefix
|
||
* strip below — deliberately: production does NOT strip `{CODE}.` prefixes
|
||
* (`normalizePhaseName('MSD.02-01')` returns it unchanged via the custom-id
|
||
* arm; scaffold echoes whatever `--phase` form it is given), so a repo's
|
||
* artifact layout is unqualified or qualified depending on how phases were
|
||
* scaffolded. This file's fixtures model the UNQUALIFIED layout; the
|
||
* QUALIFIED layout is modeled by tests/adr-612-bracket-read-tolerance.test.cjs
|
||
* and by the qualified-stem assertions in the stray-regression block below.
|
||
*/
|
||
function verificationNameFor(dir) {
|
||
// Fixture-local unqualified-layout modeling (see docstring) — not a claim
|
||
// about scaffold, and not a second grammar owner: the seam's own tests
|
||
// assert the production regex.
|
||
const rest = dir.replace(/^[A-Z][A-Z0-9_]*\.\d+-/i, '');
|
||
const token = [];
|
||
for (const seg of rest.split('-')) {
|
||
if (/^\d+(?:\.\d+)*$/.test(seg)) token.push(seg);
|
||
else break;
|
||
}
|
||
assert.ok(token.length > 0, `fixture dir ${dir} carries no phase token to name its verification`);
|
||
return `${phaseIdHelpers.normalizePhaseName(token.join('-'))}-VERIFICATION.md`;
|
||
}
|
||
|
||
/** total_phases as the READ path derives it. */
|
||
function readTotal() {
|
||
const r = runMsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(r.success, `state json failed: ${r.error}`);
|
||
return JSON.parse(r.output).progress?.total_phases ?? null;
|
||
}
|
||
|
||
/**
|
||
* total_phases as the WRITE path derives it — read back out of STATE.md, not out
|
||
* of `state json`. This is the assertion that observes cmdStateSync at all.
|
||
*/
|
||
function syncedTotal() {
|
||
const r = runMsdTools(['state', 'sync'], tmpDir);
|
||
assert.ok(r.success, `state sync failed: ${r.error}`);
|
||
const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
const m = raw.match(/^\s*total_phases:\s*(\d+)\s*$/m);
|
||
assert.ok(m, 'state sync must have written a total_phases into STATE.md');
|
||
return parseInt(m[1], 10);
|
||
}
|
||
|
||
/**
|
||
* The PERCENT `state sync` wrote into the STATE.md body.
|
||
*
|
||
* This is the only observable of cmdStateSync's own counter. Its
|
||
* `syncTotalPhases` never reaches the frontmatter `total_phases` field — that
|
||
* one is written by the read derivation — it reaches `computeProgressPercent`
|
||
* and nothing else. Asserting the frontmatter number after a sync therefore
|
||
* measures the READ path twice and lets a mutation to the write-path counter
|
||
* survive, which is exactly how this guard shipped untested the first time.
|
||
* Percent is completed/total, so the denominator is visible here.
|
||
*/
|
||
function syncedPercent() {
|
||
const r = runMsdTools(['state', 'sync'], tmpDir);
|
||
assert.ok(r.success, `state sync failed: ${r.error}`);
|
||
const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
// eslint-disable-next-line local/no-unbounded-quantifier -- parses the STATE.md this test just wrote, fixed-size fixture output, not adversarial input
|
||
const m = raw.match(/^\*\*Progress:\*\*[^\r\n]*?(\d+)%/m);
|
||
assert.ok(m, `state sync must have written a Progress percent; got:\n${raw}`);
|
||
return parseInt(m[1], 10);
|
||
}
|
||
|
||
/** The explicit reason `state sync` withheld progress for an unsafe scope. */
|
||
function syncSkipReason() {
|
||
const r = runMsdTools(['state', 'sync'], tmpDir);
|
||
assert.ok(r.success, `state sync failed: ${r.error}`);
|
||
const changes = JSON.parse(r.output).changes || [];
|
||
const skipped = changes.find((change) => /^Progress: skipped/.test(change));
|
||
assert.ok(skipped, `state sync must record why Progress was skipped; got: ${JSON.stringify(changes)}`);
|
||
const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.match(raw, /^\*\*Progress:\*\* \[░{10}\] 0%$/m,
|
||
'no percent may be rendered when sync says it skipped');
|
||
return skipped;
|
||
}
|
||
|
||
const BRACKET_ROADMAP = `# Roadmap
|
||
|
||
## [MSD.02] v2.0
|
||
|
||
### [MSD.02] 01: Setup
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 05: Real work
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 06: Follow-up
|
||
**Goal:** c
|
||
`;
|
||
|
||
describe('#612 PR-2: bracket phase headings enter total_phases', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-count-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('the read path counts all three (exact)', () => {
|
||
writeProject(BRACKET_ROADMAP, 'bracket');
|
||
assert.equal(readTotal(), 3);
|
||
});
|
||
|
||
test('#3242: the WRITE path writes the same number, observed in STATE.md', () => {
|
||
// Stale total forces sync to do real work, so the number in the file is the
|
||
// one cmdStateSync computed rather than the one that was already there.
|
||
writeProject(BRACKET_ROADMAP, 'bracket');
|
||
assert.equal(syncedTotal(), 3, 'state sync must WRITE 3');
|
||
assert.equal(readTotal(), 3, 'and the read path must agree');
|
||
});
|
||
|
||
test('a NON-bracket repo counts neither form of the same roadmap', () => {
|
||
// One phase directory on disk, three bracket headings the reader cannot see:
|
||
// the total falls back to the directory count, exactly as it did before.
|
||
writeProject(BRACKET_ROADMAP, undefined);
|
||
assert.equal(readTotal(), 1, 'bracket headings are invisible without the convention');
|
||
assert.equal(syncedTotal(), 1);
|
||
});
|
||
|
||
test('a mixed legacy + bracket roadmap counts both on a bracket repo', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## v2.0
|
||
|
||
### Phase 1: Legacy one
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 05: Bracket one
|
||
**Goal:** b
|
||
|
||
### Phase Overview:
|
||
`, 'bracket',
|
||
// #2761 Major 1: the default single dir ('MSD.02-01-setup') names a phase
|
||
// number ("01") this roadmap never declares — only "1" (legacy-labelled)
|
||
// and "05" (bracket) are real — so post-Major-1 the disk-side milestone
|
||
// filter correctly excludes it and `state sync` becomes a no-op (nothing
|
||
// to write). Naming the ACTUAL bracket-declared phase here keeps this
|
||
// test about what it says it's about (mixed heading-style counting), not
|
||
// an accidental side effect of the disk-scan gating fixed elsewhere.
|
||
['MSD.02-05-bracket-one']);
|
||
assert.equal(readTotal(), 2, '`Phase Overview:` still excluded');
|
||
assert.equal(syncedTotal(), 2);
|
||
});
|
||
});
|
||
|
||
describe('#612 PR-2: bracket sentinels stay OUT of both derivations', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-count-sent-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
const SENTINEL_ROADMAP = `# Roadmap
|
||
|
||
## [MSD.02] v2.0
|
||
|
||
### [MSD.999] 01: Icebox item
|
||
**Goal:** a
|
||
|
||
### [MSD.00] 02: Pre-milestone
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 05: Real work
|
||
**Goal:** c
|
||
|
||
### [MSD.02] 06: Follow-up
|
||
**Goal:** d
|
||
`;
|
||
|
||
test('999.x and 0.x are excluded from the READ derivation (exact)', () => {
|
||
writeProject(SENTINEL_ROADMAP, 'bracket');
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
|
||
test('999.x and 0.x are excluded from the WRITE derivation too (observed in STATE.md)', () => {
|
||
// The assertion that actually exercises cmdStateSync's sentinel guard —
|
||
// reading `state json` after sync would measure the read path a second time
|
||
// and let a mutation to the write path survive.
|
||
// One completed phase directory — named for the REAL "05" phase this
|
||
// roadmap declares (#2761 Major 1: the default dir names phase "01",
|
||
// which this roadmap never declares, and the disk-side milestone filter
|
||
// now correctly excludes an undeclared phase number from the WRITE-path
|
||
// scan too, making `state sync` a no-op on the default dir). With the
|
||
// sentinel guard the denominator is 2 (05, 06) so the percent is 50;
|
||
// without it the two sentinel headings inflate it to 4 and the percent
|
||
// drops to 25.
|
||
writeProject(SENTINEL_ROADMAP, 'bracket', ['MSD.02-05-real-work']);
|
||
assert.equal(syncedPercent(), 50, 'sentinel headings must not inflate the sync denominator');
|
||
});
|
||
|
||
test('a LOWERCASE sentinel bracket is excluded from both', () => {
|
||
const doc = SENTINEL_ROADMAP.replace(/MSD\./g, 'msd.');
|
||
writeProject(doc, 'bracket', ['msd.02-05-real-work']);
|
||
assert.equal(readTotal(), 2);
|
||
assert.equal(syncedTotal(), 2);
|
||
});
|
||
|
||
test('G5: a 999 token under a real milestone is still a backlog sentinel', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] v2.0
|
||
|
||
### [MSD.02] 999: Late work
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 05: Real
|
||
**Goal:** b
|
||
`, 'bracket', ['MSD.02-05-real']);
|
||
// The composed rule: bracket-sentinel OR legacy 999 token.
|
||
assert.equal(readTotal(), 1);
|
||
assert.equal(syncedTotal(), 1);
|
||
});
|
||
});
|
||
|
||
describe('#612 PR-2: #1514 retired bracket phases leave the denominator', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-retired-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
// The canonical #1514 gesture, verbatim from the shipped legacy tests: strike
|
||
// the checklist BULLET and leave the detail heading intact. A bracket-form
|
||
// retirement went undetected, so the phase stayed in the denominator forever
|
||
// and a shipped bracket milestone could never reach 100%.
|
||
const retiredRoadmap = (bullet, heading) => `# Roadmap
|
||
|
||
## [MSD.02] v2.0
|
||
|
||
- [x] ${bullet} — folded into 05; number retired
|
||
- [ ] **[MSD.02] 05: Real work**
|
||
- [ ] **[MSD.02] 06: Follow-up**
|
||
|
||
${heading}
|
||
**Goal:** folded
|
||
|
||
### [MSD.02] 05: Real work
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 06: Follow-up
|
||
**Goal:** c
|
||
`;
|
||
|
||
test('bullet-only strike (the canonical gesture) excludes the phase', () => {
|
||
writeProject(
|
||
retiredRoadmap('~~**[MSD.02] 04: Delta**~~', '### [MSD.02] 04: Delta'), 'bracket',
|
||
// #2761 Major 1: name one of the two REAL (non-retired) phases so the
|
||
// WRITE-path milestone filter has something to admit — the retired "04"
|
||
// itself is deliberately never used here (that's a separate dedicated
|
||
// test below).
|
||
['MSD.02-05-real-work']);
|
||
assert.equal(readTotal(), 2, 'the retired bracket phase must leave the denominator');
|
||
assert.equal(syncedTotal(), 2);
|
||
});
|
||
|
||
test('unbolded bullet strike also excludes', () => {
|
||
writeProject(
|
||
retiredRoadmap('~~[MSD.02] 04: Delta~~', '### [MSD.02] 04: Delta'), 'bracket');
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
|
||
test('a struck HEADING is excluded as well (it simply stops matching)', () => {
|
||
writeProject(
|
||
retiredRoadmap('~~**[MSD.02] 04: Delta**~~', '#### ~~**[MSD.02] 04: Delta**~~'), 'bracket');
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
|
||
test('the legacy retirement gesture is unchanged', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## v2.0
|
||
|
||
- [x] ~~**Phase 04: Delta**~~ — folded into Phase 05; number retired
|
||
- [ ] **Phase 05: Real**
|
||
|
||
### Phase 04: Delta
|
||
**Goal:** folded
|
||
|
||
### Phase 05: Real
|
||
**Goal:** b
|
||
|
||
### Phase 06: Other
|
||
**Goal:** c
|
||
`, undefined);
|
||
assert.equal(readTotal(), 2, 'legacy 04 retired, 05 and 06 remain');
|
||
});
|
||
|
||
test('a retired bracket phase DIRECTORY is skipped too', () => {
|
||
// The other half of the same comparison: the retired key has to match the
|
||
// directory's key, and phaseKeyFromDir needs the convention to produce one.
|
||
writeProject(
|
||
retiredRoadmap('~~**[MSD.02] 04: Delta**~~', '### [MSD.02] 04: Delta'), 'bracket',
|
||
['MSD.02-04-delta', 'MSD.02-05-real-work', 'MSD.02-06-follow-up']);
|
||
// Three directories on disk, one of them retired. If phaseKeyFromDir cannot
|
||
// key a bracket directory the retired one is counted anyway and the total is
|
||
// 3 — the denominator a shipped bracket milestone could never work off.
|
||
assert.equal(readTotal(), 2, 'the retired phase must not be re-added by its directory');
|
||
});
|
||
});
|
||
|
||
describe('#612 PR-2: legacy counting is byte-identical', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-count-legacy-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('#549: pure-word section headings still excluded (exact)', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## v2.0
|
||
|
||
## Phase Overview:
|
||
|
||
### Phase 1: One
|
||
**Goal:** a
|
||
|
||
### Phase 2.1: Two point one
|
||
**Goal:** b
|
||
|
||
### Phase 12A: Letter suffix
|
||
**Goal:** c
|
||
|
||
#### Phase Details:
|
||
`, undefined);
|
||
assert.equal(readTotal(), 3);
|
||
assert.equal(syncedTotal(), 3);
|
||
});
|
||
|
||
test('#1445: a legacy 999.x heading is still excluded from the read path', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## v2.0
|
||
|
||
### Phase 999.1: Icebox
|
||
**Goal:** a
|
||
|
||
### Phase 5: Real
|
||
**Goal:** b
|
||
`, undefined);
|
||
assert.equal(readTotal(), 1);
|
||
});
|
||
|
||
test('a project-code phase id still counts (exact)', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## v2.0
|
||
|
||
### Phase PROJ-42: Coded
|
||
**Goal:** a
|
||
|
||
### Phase 5: Real
|
||
**Goal:** b
|
||
`, undefined);
|
||
assert.equal(readTotal(), 2);
|
||
assert.equal(syncedTotal(), 2);
|
||
});
|
||
});
|
||
|
||
describe('#612 PR-2: the ADR-canonical milestone heading scopes the milestone', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-scope-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
// ADR-612 Decision 1 pins the bracket milestone heading as `## [MSD.02] Foundation`
|
||
// — a NAME, with no version. Milestone scoping matches STATE's `milestone: v2.0`
|
||
// STRING against a heading, so the canonical form matched nothing, scoping was
|
||
// lost, and total_phases fell back to the on-disk directory count. Every earlier
|
||
// fixture in this file embeds `v2.0` in the heading and so never ran the form
|
||
// the ADR actually specifies.
|
||
const roadmap = (heading) => `# Roadmap
|
||
|
||
${heading}
|
||
|
||
### [MSD.02] 05: Real work
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 06: Follow-up
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 07: Third
|
||
**Goal:** c
|
||
`;
|
||
|
||
test('name-only heading: total_phases comes from the ROADMAP, not the dir count', () => {
|
||
writeProject(roadmap('## [MSD.02] Foundation'), 'bracket', ['MSD.02-05-real-work']);
|
||
assert.equal(readTotal(), 3, 'three headings in scope, not one directory');
|
||
});
|
||
|
||
test('the dir count no longer drives the answer', () => {
|
||
// The tell for the fallback: without scoping the total tracks the number of
|
||
// directories instead of staying at the ROADMAP's phase count.
|
||
writeProject(roadmap('## [MSD.02] Foundation'), 'bracket',
|
||
['MSD.02-05-real-work', 'MSD.02-06-follow-up']);
|
||
assert.equal(readTotal(), 3);
|
||
});
|
||
|
||
test('the version-embedded heading still works', () => {
|
||
writeProject(roadmap('## [MSD.02] v2.0 — Foundation'), 'bracket', ['MSD.02-05-real-work']);
|
||
assert.equal(readTotal(), 3);
|
||
});
|
||
|
||
test('an unpadded bracket milestone scopes NOTHING (emit-grammar strict)', () => {
|
||
// Post-unification an unpadded `[MSD.2]` is malformed: it is not a phase id,
|
||
// so it must not bound or scope a milestone either. The tell is that the
|
||
// bracket reading equals the null-convention reading — if either behaviour
|
||
// flips, these two numbers diverge.
|
||
// The tell is that the unpadded heading SCOPES NOTHING: with it, the reading
|
||
// must equal the reading of a roadmap that has no milestone heading at all.
|
||
// If `[MSD.2]` ever starts scoping again, these two diverge.
|
||
const dirs = ['MSD.02-05-real-work'];
|
||
writeProject(roadmap('## [MSD.2] Foundation'), 'bracket', dirs);
|
||
const unpadded = readTotal();
|
||
writeProject(roadmap('## Some heading with no milestone'), 'bracket', dirs);
|
||
const unscoped = readTotal();
|
||
assert.equal(unpadded, unscoped,
|
||
'an unpadded bracket milestone must bound nothing, exactly like no milestone heading');
|
||
// And the canonical spelling DOES scope, so the pair is not trivially equal.
|
||
writeProject(roadmap('## [MSD.02] Foundation'), 'bracket', dirs);
|
||
assert.equal(readTotal(), 3, 'the padded spelling scopes');
|
||
});
|
||
|
||
test('a milestone that does NOT match STATE is not scoped in', () => {
|
||
// Use two genuine milestone sections, neither matching STATE's v2.0. This
|
||
// removes the old fixture's accidental dependence on the word "milestone"
|
||
// appearing in a bracket phase title. Upstream #3354/#3480, strengthened by
|
||
// #3642 for the single-section sibling, classifies this as milestoned but
|
||
// unbounded: neither the whole-document count nor the on-disk directory
|
||
// count is authoritative. With no stored total, `state json` reports null
|
||
// rather than guessing.
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.03] Later milestone
|
||
|
||
### [MSD.03] 09: Not scoped here
|
||
**Goal:** a
|
||
|
||
## [MSD.04] Even later milestone
|
||
|
||
### [MSD.04] 10: Also not scoped
|
||
**Goal:** b
|
||
`, 'bracket', ['MSD.02-05-real-work']);
|
||
assert.equal(readTotal(), null, 'no phases for the asserted milestone — withheld, not computed');
|
||
});
|
||
|
||
test('a NON-bracket repo does not gain bracket scoping', () => {
|
||
writeProject(roadmap('## [MSD.02] Foundation'), undefined, ['MSD.02-05-real-work']);
|
||
assert.equal(readTotal(), 1, 'no scoping, no counting — invisible as designed');
|
||
});
|
||
});
|
||
|
||
describe('#612 PR-2: labeled sentinels, composed sentinels, and the disk-side filter', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-g3g4g5-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
const analyze = () => {
|
||
const r = runMsdTools(['roadmap', 'analyze'], tmpDir);
|
||
assert.ok(r.success, `roadmap analyze failed: ${r.error}`);
|
||
return JSON.parse(r.output);
|
||
};
|
||
|
||
test('G3: a LABELED bracket sentinel is excluded from every counter', () => {
|
||
// `### [MSD.999] Phase 07:` fell through to the base alternative, which
|
||
// captures nothing — so analyze applied the legacy token rule and counted it
|
||
// while state json excluded it. Two derivations of one ROADMAP disagreed.
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] v2.0
|
||
|
||
### [MSD.999] Phase 07: Icebox labeled
|
||
**Goal:** a
|
||
|
||
### [MSD.999] 08: Icebox bare
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 01: Real
|
||
**Goal:** c
|
||
`, 'bracket');
|
||
const out = analyze();
|
||
assert.deepEqual(out.phases.map(p => p.number), ['01'], 'labeled and bare both excluded');
|
||
assert.equal(readTotal(), 1);
|
||
assert.equal(out.phase_count, readTotal(), 'analyze and state must agree');
|
||
});
|
||
|
||
test('G5: the legacy 999/0 token rule still applies to a bracketed heading', () => {
|
||
// READING-B ADDS a rule; it does not replace one. A mid-migration ROADMAP
|
||
// carrying a legacy backlog block under bracket headings must not gain
|
||
// denominator entries.
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] v2.0
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 999: Backlog
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 0: Zero
|
||
**Goal:** c
|
||
`, 'bracket');
|
||
const out = analyze();
|
||
assert.deepEqual(out.phases.map(p => p.number), ['01'],
|
||
'analyze excludes both the 999 and the 0 token, as it does for legacy headings');
|
||
// DISCLOSED, pre-existing: the state counter's legacy token rule is 999-only
|
||
// — it has never excluded a bare `0` — so `[MSD.02] 0:` still reaches the
|
||
// denominator there. Adding a 0 filter would move legacy totals, which is out
|
||
// of scope; what this pin asserts is that the 999 rule was not DROPPED for
|
||
// bracketed headings.
|
||
// Under bracket the token rule composes as the full {0, 999} set, so this
|
||
// counter now agrees with roadmap analyze. The LEGACY path keeps its
|
||
// pre-existing 999-only rule — pinned separately.
|
||
assert.equal(readTotal(), 1, 'both 999 and 0 excluded under bracket');
|
||
});
|
||
|
||
test('G4: the disk-side milestone filter does not count another milestone dirs', () => {
|
||
// getMilestonePhaseFilter's heading scan collected nothing on a bracket
|
||
// ROADMAP, so it degraded to pass-all and Math.max(dirs, roadmap) counted
|
||
// the previous milestone's directories — making bracket strictly worse than
|
||
// the M-NN convention it supersedes.
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] v2.0
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 03: Three
|
||
**Goal:** c
|
||
`, 'bracket', ['MSD.01-01-prev', 'MSD.01-02-prev2', 'MSD.02-01-one', 'MSD.02-02-two', 'MSD.02-03-three']);
|
||
assert.equal(readTotal(), 3, 'scoped to this milestone, not the whole disk');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// The four numbers `total_phases` was hiding.
|
||
//
|
||
// Every bracket counting assertion above reads `total_phases` and nothing else,
|
||
// and `total_phases` is the ONE number the disk-side milestone filter cannot
|
||
// move: `Math.max(phaseDirs.length, roadmapPhaseCount)` (state.cts) floors it at
|
||
// the ROADMAP count no matter how many directories the filter rejects. So a
|
||
// filter that rejects EVERY bracket directory leaves that number right and
|
||
// zeroes `completed_phases`, `total_plans`, `completed_plans` and `percent` —
|
||
// green suite, `state json` reporting 0% on a repo `state sync` calls 67% in the
|
||
// same second. These pin all five.
|
||
//
|
||
// THE ORACLE IS THE LEGACY TWIN, and it is computed in the same run rather than
|
||
// quoted: each test builds the identical repo in the flat legacy spelling and
|
||
// asserts the bracket reading equals it, number for number. Exact literals are
|
||
// asserted too — an equality alone would pass with both sides broken.
|
||
//
|
||
// WHY FLAT LEGACY AND NOT M-NN. The M-NN spelling of these shapes cannot serve
|
||
// as the oracle: buildStateFrontmatter's #2445 de-dup key is
|
||
// `dir.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/)`, which captures only the LEADING
|
||
// integer, so `02-01-one`, `02-02-two` and `02-03-three` all key to `2` and two
|
||
// of the three directories are dropped before they are ever counted. Measured
|
||
// on the true base build (d04592de), the M-NN twin of the first shape below
|
||
// reads `[3,0,1,0,0]` where flat legacy reads `[3,2,3,2,67]`; the divergence is
|
||
// present identically at base and is untouched by this PR. It is a legacy defect
|
||
// in a key space bracket directories cannot enter — `MSD.02-01-one` does not
|
||
// match that regex at all, so each bracket dir keys to its own name. The
|
||
// source's own `phase-id-owner:` sanction at that line records the divergence.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/** The whole progress block as the READ path derives it. */
|
||
function readProgress() {
|
||
const r = runMsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(r.success, `state json failed: ${r.error}`);
|
||
const p = JSON.parse(r.output).progress || {};
|
||
return [p.total_phases, p.completed_phases, p.total_plans, p.completed_plans, p.percent];
|
||
}
|
||
|
||
/**
|
||
* The progress block `state sync` WROTE into STATE.md.
|
||
*
|
||
* Labelled precisely: this is the READ derivation observed a second time (sync
|
||
* rebuilds the frontmatter through buildStateFrontmatter). It is asserted
|
||
* because a write that disagrees with `state json` is the #3242 Bug B artifact
|
||
* this file exists to prevent — but it is NOT coverage of cmdStateSync's own
|
||
* counter. That counter reaches `computeProgressPercent` and nothing else, so
|
||
* `syncedPercent()` above is its only observable.
|
||
*
|
||
* `state json` echoes a `progress:` frontmatter block verbatim when one exists
|
||
* and only derives when there is none, so this must be read out of the FILE and
|
||
* `stateMd()` must stay block-free. (Measured: same repo, block-free `state
|
||
* json` → 3/2/3/2/67; with a `progress: 99…` block → 99/99/99/99/99.)
|
||
*/
|
||
function syncedProgress() {
|
||
const r = runMsdTools(['state', 'sync'], tmpDir);
|
||
assert.ok(r.success, `state sync failed: ${r.error}`);
|
||
const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
const m = raw.match(
|
||
// eslint-disable-next-line local/no-unbounded-quantifier -- parses the STATE.md this test just wrote, fixed-size fixture output, not adversarial input
|
||
/total_phases:\s*(\d+)[\s\S]*?completed_phases:\s*(\d+)[\s\S]*?total_plans:\s*(\d+)[\s\S]*?completed_plans:\s*(\d+)[\s\S]*?percent:\s*(-?\d+)/);
|
||
assert.ok(m, `state sync must have written a full progress block; got:\n${raw}`);
|
||
return m.slice(1).map(Number);
|
||
}
|
||
|
||
describe('#612 PR-2: the disk-side filter scopes bracket dirs — all five numbers', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-fivenum-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
// ── SHAPE 1: one milestone, three phases, three dirs, the first two complete ──
|
||
const ONE_MILESTONE_BRACKET = `# Roadmap
|
||
|
||
## [MSD.02] v2.0: Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 03: Three
|
||
**Goal:** c
|
||
`;
|
||
const ONE_MILESTONE_LEGACY = `# Roadmap
|
||
|
||
## v2.0: Current
|
||
|
||
### Phase 01: One
|
||
**Goal:** a
|
||
|
||
### Phase 02: Two
|
||
**Goal:** b
|
||
|
||
### Phase 03: Three
|
||
**Goal:** c
|
||
`;
|
||
// The M-NN spelling of the same shape — pinned as a CHARACTERIZATION at the end
|
||
// of this block, not used as an oracle. See the comment there.
|
||
const ONE_MILESTONE_MNN = `# Roadmap
|
||
|
||
## v2.0: Current
|
||
|
||
### Phase 2-01: One
|
||
**Goal:** a
|
||
|
||
### Phase 2-02: Two
|
||
**Goal:** b
|
||
|
||
### Phase 2-03: Three
|
||
**Goal:** c
|
||
`;
|
||
const ONE_BRACKET_DIRS = ['MSD.02-01-one', 'MSD.02-02-two', ['MSD.02-03-three', false]];
|
||
const ONE_LEGACY_DIRS = ['01-one', '02-two', ['03-three', false]];
|
||
const ONE_MNN_DIRS = ['02-01-one', '02-02-two', ['02-03-three', false]];
|
||
|
||
test('shape 1 READ: 3 phases, 2 complete, 3 plans, 2 done, 67% — not four zeros', () => {
|
||
writeProject(ONE_MILESTONE_BRACKET, 'bracket', ONE_BRACKET_DIRS);
|
||
assert.deepEqual(readProgress(), [3, 2, 3, 2, 67],
|
||
'every bracket directory must satisfy the milestone filter');
|
||
});
|
||
|
||
test('shape 1 READ equals its flat-legacy twin exactly', () => {
|
||
writeProject(ONE_MILESTONE_BRACKET, 'bracket', ONE_BRACKET_DIRS);
|
||
const bracket = readProgress();
|
||
writeProject(ONE_MILESTONE_LEGACY, undefined, ONE_LEGACY_DIRS);
|
||
const legacy = readProgress();
|
||
assert.deepEqual(bracket, legacy, 'bracket must read exactly what the legacy twin reads');
|
||
assert.deepEqual(legacy, [3, 2, 3, 2, 67], 'and the twin is the right answer, not a shared wrong one');
|
||
});
|
||
|
||
test('shape 1 WRITE: sync writes 67%, and its frontmatter agrees with `state json`', () => {
|
||
writeProject(ONE_MILESTONE_BRACKET, 'bracket', ONE_BRACKET_DIRS);
|
||
// The write derivation's own observable.
|
||
assert.equal(syncedPercent(), 67, 'state sync must write 67% into the body');
|
||
// …and the block it wrote must not contradict it (#3242 Bug B).
|
||
assert.deepEqual(syncedProgress(), [3, 2, 3, 2, 67]);
|
||
assert.deepEqual(readProgress(), syncedProgress(), 'the two derivations must not disagree');
|
||
});
|
||
|
||
test('shape 1 WRITE percent equals its flat-legacy twin', () => {
|
||
writeProject(ONE_MILESTONE_BRACKET, 'bracket', ONE_BRACKET_DIRS);
|
||
const bracket = syncedPercent();
|
||
writeProject(ONE_MILESTONE_LEGACY, undefined, ONE_LEGACY_DIRS);
|
||
assert.equal(bracket, syncedPercent());
|
||
assert.equal(bracket, 67);
|
||
});
|
||
|
||
// ── SHAPE 2: two milestones, scoped to v2.0, two stale prior-milestone dirs ──
|
||
const TWO_MILESTONE_BRACKET = `# Roadmap
|
||
|
||
## [MSD.01] v1.0: Prior
|
||
|
||
### [MSD.01] 01: Old one
|
||
**Goal:** a
|
||
|
||
### [MSD.01] 02: Old two
|
||
**Goal:** b
|
||
|
||
## [MSD.02] v2.0: Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** c
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** d
|
||
|
||
### [MSD.02] 03: Three
|
||
**Goal:** e
|
||
`;
|
||
const TWO_MILESTONE_LEGACY = `# Roadmap
|
||
|
||
## v1.0: Prior
|
||
|
||
### Phase 01: Old one
|
||
**Goal:** a
|
||
|
||
### Phase 02: Old two
|
||
**Goal:** b
|
||
|
||
## v2.0: Current
|
||
|
||
### Phase 03: One
|
||
**Goal:** c
|
||
|
||
### Phase 04: Two
|
||
**Goal:** d
|
||
|
||
### Phase 05: Three
|
||
**Goal:** e
|
||
`;
|
||
const TWO_BRACKET_DIRS = ['MSD.01-01-old-one', 'MSD.01-02-old-two', 'MSD.02-01-one',
|
||
['MSD.02-02-two', false], ['MSD.02-03-three', false]];
|
||
const TWO_LEGACY_DIRS = ['01-old-one', '02-old-two', '03-one', ['04-two', false], ['05-three', false]];
|
||
|
||
test('shape 2 READ: the prior milestone dirs are excluded — 3/1/3/1/33', () => {
|
||
// Both milestones number their phases 01/02/…, so the bare token cannot tell
|
||
// `MSD.01-01-old-one` from `MSD.02-01-one`. Only the milestone-qualified key
|
||
// separates them; matching on the token would read 5/3/5/3/60 instead.
|
||
writeProject(TWO_MILESTONE_BRACKET, 'bracket', TWO_BRACKET_DIRS);
|
||
assert.deepEqual(readProgress(), [3, 1, 3, 1, 33]);
|
||
});
|
||
|
||
test('shape 2 READ equals its flat-legacy twin exactly', () => {
|
||
writeProject(TWO_MILESTONE_BRACKET, 'bracket', TWO_BRACKET_DIRS);
|
||
const bracket = readProgress();
|
||
writeProject(TWO_MILESTONE_LEGACY, undefined, TWO_LEGACY_DIRS);
|
||
const legacy = readProgress();
|
||
assert.deepEqual(bracket, legacy);
|
||
assert.deepEqual(legacy, [3, 1, 3, 1, 33]);
|
||
});
|
||
|
||
test('shape 2 WRITE (#2761 Major 1 fix): bracket closes to 33% (matches read); legacy stays at the disclosed 60%', () => {
|
||
// UPDATED by #2761 Major 1 — this test used to pin the BUG this fix
|
||
// closes, titled "the DISCLOSED legacy gap, mirrored — not closed":
|
||
// cmdStateSync did its own `fs.readdirSync` and never called the
|
||
// milestone filter, so its denominator was the whole disk (3 summaries
|
||
// over 5 plans = 60%) against the read path's scoped 33% — for BOTH
|
||
// bracket and legacy alike, since the divergence was engine-wide, not
|
||
// bracket-specific.
|
||
//
|
||
// Major 1 gates cmdStateSync's disk scan by getMilestonePhaseFilter under
|
||
// `phase_id_convention === 'bracket'` ONLY — deliberately NOT
|
||
// unconditionally: an unconditional filter would ALSO move every legacy
|
||
// repo's persisted percent, which is out of this fix's scope (the
|
||
// binding constraint is "legacy stays byte-identical"). So: bracket now
|
||
// closes to 33% (agrees with the read path — repro3's fix, verified
|
||
// here on a SECOND fixture with real prior-milestone noise dirs);
|
||
// legacy stays at the pre-existing 60% (unchanged, deliberately).
|
||
writeProject(TWO_MILESTONE_BRACKET, 'bracket', TWO_BRACKET_DIRS);
|
||
assert.equal(syncedPercent(), 33, 'bracket: the gap is now closed, agrees with the read path');
|
||
writeProject(TWO_MILESTONE_LEGACY, undefined, TWO_LEGACY_DIRS);
|
||
assert.equal(syncedPercent(), 60, 'legacy: the gap remains — moving it is out of this fix\'s scope');
|
||
});
|
||
|
||
test('shape 2 WRITE frontmatter agrees with `state json` on both spellings', () => {
|
||
writeProject(TWO_MILESTONE_BRACKET, 'bracket', TWO_BRACKET_DIRS);
|
||
assert.deepEqual(syncedProgress(), [3, 1, 3, 1, 33]);
|
||
assert.deepEqual(readProgress(), syncedProgress());
|
||
writeProject(TWO_MILESTONE_LEGACY, undefined, TWO_LEGACY_DIRS);
|
||
assert.deepEqual(syncedProgress(), [3, 1, 3, 1, 33]);
|
||
assert.deepEqual(readProgress(), syncedProgress());
|
||
});
|
||
|
||
test('a bracket repo carrying LEGACY-shaped dirs is unaffected (the branch is additive)', () => {
|
||
// The bracket branch tries the qualified key first and FALLS THROUGH on a
|
||
// miss, so the three legacy dir checks still run on a bracket project. An
|
||
// early `return false` there would silently drop this repo to zero.
|
||
writeProject(ONE_MILESTONE_LEGACY, 'bracket', ONE_LEGACY_DIRS);
|
||
assert.deepEqual(readProgress(), [3, 2, 3, 2, 67]);
|
||
});
|
||
|
||
test('CHARACTERIZATION: the M-NN twin of shape 1 no longer collapses — three plans, not one', () => {
|
||
// Holds the oracle substitution honest. The two "equals its flat-legacy
|
||
// twin" tests above compare bracket against FLAT legacy; nothing else in the
|
||
// suite pins the M-NN spelling, so the changeset's claim that the M-NN
|
||
// divergence is pre-existing and untouched would go stale silently the first
|
||
// time a sibling slice widens the de-dup key.
|
||
//
|
||
// Upstream #3185 replaced the leading-integer de-dup key with canonical
|
||
// phaseKeyFromDir ownership. The M-NN directories now key as 02-01,
|
||
// 02-02, and 02-03, so all three distinct phases and plans survive.
|
||
writeProject(ONE_MILESTONE_MNN, undefined, ONE_MNN_DIRS);
|
||
const mnn = readProgress();
|
||
assert.equal(mnn[0], 3, 'M-NN: total_phases still tracks the ROADMAP');
|
||
assert.equal(mnn[2], 3, 'M-NN: all three dirs survive the de-dup after #3185');
|
||
// Build the flat-legacy twin in a fresh project. Reusing this project would
|
||
// stack both directory spellings now that the M-NN entries no longer
|
||
// collapse under the old leading-integer key.
|
||
cleanup(tmpDir);
|
||
tmpDir = createTempProject('adr-612-fivenum-mnn-twin-');
|
||
writeProject(ONE_MILESTONE_LEGACY, undefined, ONE_LEGACY_DIRS);
|
||
assert.deepEqual(readProgress(), [3, 2, 3, 2, 67]);
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// R4-m3 — the milestone-qualified key is a string SPLICE, so a heading whose
|
||
// token carries its own hyphen mis-parses into the wrong directory.
|
||
//
|
||
// `### [MSD.02] Phase 02-01:` spliced to `MSD.02-02-01`, which the qualified-key
|
||
// grammar reads as milestone 02 / phase 02 — the trailing `-01` truncated, both
|
||
// such headings collapsing to one key, and the heading claiming
|
||
// `MSD.02-02-two` (the directory it does NOT name) while rejecting
|
||
// `MSD.02-01-one` (the one it does).
|
||
//
|
||
// The oracle is the SAME ROADMAP read under `milestone-prefixed`, which is
|
||
// base-identical on this shape — so the bracket acceptance vector must equal it.
|
||
// Scope note: only the ACCEPTANCE VECTOR is claimed base-equivalent.
|
||
// `total_phases` on this fixture does move 1 -> 2, because the bracket heading
|
||
// count is the feature this PR ships; measured, that move is identical with and
|
||
// without this guard, and identical to what the canonical `### [MSD.02] 01:`
|
||
// spelling does (both read 2 with zero directories on disk, where base reads 0).
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe('#612 PR-2: a hyphenated heading token forms no qualified key', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-hyphen-tok-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
const MIXED = `# Roadmap
|
||
|
||
## [MSD.02] v2.0: Current
|
||
|
||
### [MSD.02] Phase 02-01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] Phase 02-02: Two
|
||
**Goal:** b
|
||
`;
|
||
const DIRS = ['MSD.02-02-two', 'MSD.02-01-one', '02-01-mnn', '02-2026-photos', '46-6-rs-thing', '2-01-x'];
|
||
|
||
/** The disk-side filter's own acceptance vector, read straight off the module. */
|
||
const acceptance = () => {
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const f = rp.getMilestonePhaseFilter(tmpDir);
|
||
return Object.fromEntries(DIRS.map(d => [d, !!f(d)]));
|
||
};
|
||
|
||
test('the heading does not claim the directory it does not name', () => {
|
||
writeProject(MIXED, 'bracket', DIRS);
|
||
const a = acceptance();
|
||
assert.equal(a['MSD.02-02-two'], false,
|
||
'`[MSD.02] Phase 02-01` must not claim MSD.02-02-two by a truncated key');
|
||
assert.equal(a['MSD.02-01-one'], false,
|
||
'and it does not resolve its own dir either — unqualified, exactly as at base');
|
||
assert.equal(a['02-01-mnn'], true, 'the legacy fall-through is untouched');
|
||
});
|
||
|
||
test('the acceptance vector equals the milestone-prefixed control on the same ROADMAP', () => {
|
||
writeProject(MIXED, 'bracket', DIRS);
|
||
const bracket = acceptance();
|
||
cleanup(tmpDir);
|
||
tmpDir = createTempProject('adr-612-hyphen-tok-ctl-');
|
||
writeProject(MIXED, 'milestone-prefixed', DIRS);
|
||
const control = acceptance();
|
||
assert.deepEqual(bracket, control,
|
||
'a hyphenated token must read the disk identically under both conventions');
|
||
});
|
||
|
||
test('a CANONICAL bracket heading still forms its qualified key', () => {
|
||
// Guards the guard: `!token.includes('-')` must not disable qualified
|
||
// matching for the spelling the convention actually specifies.
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] v2.0: Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
`, 'bracket', ['MSD.02-01-one', 'MSD.01-01-old-one']);
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const f = rp.getMilestonePhaseFilter(tmpDir);
|
||
assert.equal(f('MSD.02-01-one'), true, 'the canonical qualified key still resolves');
|
||
assert.equal(f('MSD.01-01-old-one'), false, 'and still scopes out the foreign milestone');
|
||
});
|
||
});
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// R4-m2 — extractCurrentMilestone must not throw.
|
||
//
|
||
// Its bracket-scoping fallback calls resolvePhaseIdConvention, which reaches
|
||
// planningDir, which throws a plain Error for a MSD_PROJECT/MSD_WORKSTREAM
|
||
// segment containing `/`, `\` or `..`. At base the only planningDir call in this
|
||
// function sits inside the STATE-read try, so the function returned normally;
|
||
// an unguarded call broke the never-throws invariant that getRoadmapPhaseInternal
|
||
// and getMilestoneInfo carry #2245 / ADR-227 notes about.
|
||
//
|
||
// Module level on purpose: the CLI rejects a bad MSD_WORKSTREAM up front, so
|
||
// this contract is only observable to an in-process embedder — which is exactly
|
||
// who the invariant protects.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
describe('#612 PR-2: extractCurrentMilestone never throws on a poisoned env', () => {
|
||
const ROADMAP = `# Roadmap
|
||
|
||
## Milestones
|
||
|
||
- 🚧 **v1.0 Alpha** — in progress
|
||
|
||
## Alpha
|
||
|
||
### Phase 01: Setup
|
||
`;
|
||
|
||
test('a traversal segment in MSD_WORKSTREAM degrades instead of escaping', () => {
|
||
const dir = createTempProject('adr-612-envguard-');
|
||
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), ROADMAP, 'utf-8');
|
||
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), '{}', 'utf-8');
|
||
const prior = process.env.MSD_WORKSTREAM;
|
||
try {
|
||
process.env.MSD_WORKSTREAM = '../evil';
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const out = rp.extractCurrentMilestone(ROADMAP, dir);
|
||
assert.equal(typeof out, 'string', 'must return content, not throw');
|
||
assert.ok(out.length > 0);
|
||
} finally {
|
||
// Restore before anything else in this chunk runs — a leaked
|
||
// MSD_WORKSTREAM would poison every later test in the same process.
|
||
if (prior === undefined) delete process.env.MSD_WORKSTREAM;
|
||
else process.env.MSD_WORKSTREAM = prior;
|
||
cleanup(dir);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── REGRESSION: the version-less bracket milestone heading now scopes ──────
|
||
|
||
/**
|
||
* FIXED (#2761 B1, reviewer trek-e blocker). Every other bracket fixture in
|
||
* this repo writes its milestone heading as `## [MSD.02] v2.0: Current` — with a
|
||
* version. ADR-612's canonical form is a NAME and no version
|
||
* (`## [MSD.02] Foundation`), which `isMilestoneBounded`'s own doc comment in
|
||
* state.cts calls out as canonical. Until this fix, that form left the
|
||
* disk-side filter unscoped: directories from BOTH the prior and the later
|
||
* milestone were admitted into the current one.
|
||
*
|
||
* Mechanism, in `extractCurrentMilestone` (roadmap-parser.cts):
|
||
* - the bracket scope branch selects the right `currentSection`, but
|
||
* - `preambleCutoff` was driven only by `anyMilestonePattern`, which requires
|
||
* `v\d+\.\d+` or a status emoji. A version-less roadmap matches none, so the
|
||
* cutoff fell back to the CURRENT milestone's own offset and every PRIOR
|
||
* milestone landed in the preamble — whose phase-stripping regex only strips
|
||
* `Phase N:`-labelled headings, so bracket phase headings survived it; and
|
||
* - `computeSectionEnd` accepted a boundary only if the heading carried a
|
||
* version or emoji, so with none present the section ran to EOF and every
|
||
* LATER milestone was swept in too.
|
||
*
|
||
* The leak was bidirectional and had two sites. The fix: under the bracket
|
||
* scope branch (`bracketScopeConvention === 'bracket'`), both
|
||
* `computeSectionEnd` and the `preambleCutoff` scan ALSO accept a
|
||
* `#{1,2}\s+\[CODE.MM\]` heading as a milestone boundary, built from
|
||
* phase-id.cts's `BRACKET_ID_SRC` (the single owner of the bracket-id grammar)
|
||
* rather than a re-typed literal. `#{1,2}` is the discriminator because bracket
|
||
* PHASE headings are `###` and carry the same `[CODE.MM]` prefix (a `#{1,3}`
|
||
* pattern would match both, which is why the scope branch's own matcher returns
|
||
* the phase headings as well). Reachable ONLY when the bracket scope branch has
|
||
* fired, so version-bearing/emoji repos and non-bracket conventions take the
|
||
* exact pre-existing code path.
|
||
*
|
||
* These tests used to assert the pre-fix reading; they are inverted here as the
|
||
* fix's regression proof, not deleted.
|
||
*/
|
||
describe('#612 PR-2 REGRESSION: a version-less bracket milestone scopes correctly', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-versionless-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
const VERSIONLESS = `# Roadmap
|
||
|
||
## [MSD.01] Prior Milestone
|
||
|
||
### [MSD.01] 01: Old one
|
||
**Goal:** a
|
||
|
||
## [MSD.02] Current Milestone
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** c
|
||
|
||
## [MSD.03] Later Milestone
|
||
|
||
### [MSD.03] 01: Later one
|
||
**Goal:** d
|
||
`;
|
||
// Same roadmap, milestone headings carrying their version — the shape every
|
||
// other fixture in this file uses, and the control that proves the difference
|
||
// is the VERSION STRING and nothing else.
|
||
const VERSIONED = VERSIONLESS
|
||
.replace('## [MSD.01] Prior Milestone', '## [MSD.01] v1.0: Prior Milestone')
|
||
.replace('## [MSD.02] Current Milestone', '## [MSD.02] v2.0: Current Milestone')
|
||
.replace('## [MSD.03] Later Milestone', '## [MSD.03] v3.0: Later Milestone');
|
||
|
||
const DIRS = ['MSD.01-01-old-one', 'MSD.02-01-one', 'MSD.02-02-two', 'MSD.03-01-later-one'];
|
||
|
||
const accepts = () => {
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const f = rp.getMilestonePhaseFilter(tmpDir);
|
||
return Object.fromEntries(DIRS.map(d => [d, !!f(d)]));
|
||
};
|
||
|
||
test('CONTROL: with a version in the heading, scoping works in both directions', () => {
|
||
writeProject(VERSIONED, 'bracket', DIRS);
|
||
assert.deepEqual(accepts(), {
|
||
'MSD.01-01-old-one': false,
|
||
'MSD.02-01-one': true,
|
||
'MSD.02-02-two': true,
|
||
'MSD.03-01-later-one': false,
|
||
});
|
||
});
|
||
|
||
test('without a version, scoping ALSO works in both directions (regression proof)', () => {
|
||
// Mirrors the CONTROL assertion exactly, on the version-less fixture — the
|
||
// fix makes the two shapes agree.
|
||
writeProject(VERSIONLESS, 'bracket', DIRS);
|
||
assert.deepEqual(accepts(), {
|
||
'MSD.01-01-old-one': false,
|
||
'MSD.02-01-one': true,
|
||
'MSD.02-02-two': true,
|
||
'MSD.03-01-later-one': false,
|
||
});
|
||
});
|
||
|
||
test('without a version, the PRIOR milestone no longer leaks in (preambleCutoff)', () => {
|
||
writeProject(VERSIONLESS, 'bracket', DIRS);
|
||
assert.equal(accepts()['MSD.01-01-old-one'], false,
|
||
'regression proof — pinned true before the #2761 B1 scoping fix');
|
||
});
|
||
|
||
test('without a version, the LATER milestone no longer leaks in (computeSectionEnd)', () => {
|
||
writeProject(VERSIONLESS, 'bracket', DIRS);
|
||
assert.equal(accepts()['MSD.03-01-later-one'], false,
|
||
'regression proof — pinned true before the #2761 B1 scoping fix');
|
||
});
|
||
|
||
test('total_phases counts only the asserted milestone, not the whole disk', () => {
|
||
writeProject(VERSIONLESS, 'bracket', DIRS);
|
||
// 4 directories on disk, 2 phases in the milestone STATE.md asserts.
|
||
assert.equal(readTotal(), 2, 'regression proof — pinned 4 before the #2761 B1 scoping fix');
|
||
});
|
||
|
||
test('the current milestone\'s own dirs are admitted either way (no under-count)', () => {
|
||
// Whatever else leaked before the fix, the milestone's real phases must
|
||
// always resolve — that property held before and still holds after.
|
||
writeProject(VERSIONLESS, 'bracket', DIRS);
|
||
const a = accepts();
|
||
assert.equal(a['MSD.02-01-one'], true);
|
||
assert.equal(a['MSD.02-02-two'], true);
|
||
});
|
||
});
|
||
|
||
// ─── #2761 B1 FOLLOW-UP: mixed heading shapes and boundary heading levels ───
|
||
//
|
||
// Two gaps flagged during self-review of the B1 fix above, closed here with
|
||
// deterministic fixtures:
|
||
//
|
||
// 1. The earliest-of-either comparison added to `preambleCutoff` (taking
|
||
// whichever of the version/emoji match or the bracket match sits first in
|
||
// the document) was only exercised where the two patterns happen to agree
|
||
// on the same heading (every milestone in the REGRESSION block above is
|
||
// uniformly version-bearing or uniformly version-less). A genuinely mixed
|
||
// roadmap — one milestone version-bearing, its sibling version-less — was
|
||
// untested.
|
||
//
|
||
// 2. `computeSectionEnd`'s `h.level <= 2` conjunct (added alongside the
|
||
// pre-existing `h.level > level` skip) is REDUNDANT whenever the selected
|
||
// milestone heading is level 2 — the ADR-canonical shape, and every
|
||
// existing fixture in this repo: `h.level > level` alone already implies
|
||
// `h.level <= 2` there, so a mutant deleting the conjunct would survive
|
||
// every test written before this one. It is NOT redundant when the
|
||
// selected heading is level 3 (or level 1) — see the fixtures below.
|
||
describe('#612 PR-2 B1 FOLLOW-UP: mixed heading shapes and boundary heading levels', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-mixed-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
const acceptsFor = (dirs) => {
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const f = rp.getMilestonePhaseFilter(tmpDir);
|
||
return Object.fromEntries(dirs.map((d) => [d, !!f(d)]));
|
||
};
|
||
|
||
test('mixed shape: version-bearing PRIOR + version-less CURRENT — prior stays out of the preamble leak set', () => {
|
||
// The mid-migration shape: an already-versioned milestone sits before a
|
||
// newer one that has not yet had its version added. anyMilestonePattern
|
||
// alone already finds MSD.01 here — it's the first (and only) version-
|
||
// bearing heading in the document — so this fixture pins that the
|
||
// earliest-of-either comparison does not regress that pre-existing path
|
||
// when the two patterns agree on the same heading, while computeSectionEnd
|
||
// still needs the bracket-boundary fix to correctly exclude MSD.03 (which
|
||
// remains version-less).
|
||
const roadmap = `# Roadmap
|
||
|
||
## [MSD.01] v1.0: Prior Milestone
|
||
|
||
### [MSD.01] 01: Old one
|
||
**Goal:** a
|
||
|
||
## [MSD.02] Current Milestone
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** c
|
||
|
||
## [MSD.03] Later Milestone
|
||
|
||
### [MSD.03] 01: Later one
|
||
**Goal:** d
|
||
`;
|
||
const dirs = ['MSD.01-01-old-one', 'MSD.02-01-one', 'MSD.02-02-two', 'MSD.03-01-later-one'];
|
||
writeProject(roadmap, 'bracket', dirs);
|
||
assert.deepEqual(acceptsFor(dirs), {
|
||
'MSD.01-01-old-one': false,
|
||
'MSD.02-01-one': true,
|
||
'MSD.02-02-two': true,
|
||
'MSD.03-01-later-one': false,
|
||
});
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
|
||
test('boundary heading level: a level-3 CURRENT milestone heading still scopes correctly (kills the h.level<=2 equivalent-mutant)', () => {
|
||
// The selected milestone heading is written with THREE hashes
|
||
// (`### [MSD.02] Foundation`) — unusual, but syntactically admitted by the
|
||
// same `#{1,3}` grammar every heading matcher in this function already
|
||
// compiles. With level=3, `h.level > level` alone no longer excludes a
|
||
// level-3 heading, so computeSectionEnd's own first phase heading
|
||
// (`### [MSD.02] 01: One`) — itself bracket-shaped — would ALSO satisfy the
|
||
// bracket-boundary test if the `h.level <= 2` conjunct were removed,
|
||
// truncating the section to nothing but the bare milestone heading and
|
||
// dropping BOTH of its own phases. A real PRIOR milestone precedes it so the
|
||
// preamble side-channel cannot independently rescue the truncated phases —
|
||
// confirmed by hand-mutating a throwaway build copy: without the guard this
|
||
// fixture's own phases vanish from the returned scope entirely, and
|
||
// getMilestonePhaseFilter's zero-token pass-all degrade then admits every
|
||
// directory on disk instead (the exact pre-#612 symptom).
|
||
const roadmap = `# Roadmap
|
||
|
||
## [MSD.01] v1.0: Prior Milestone
|
||
|
||
### [MSD.01] 01: Old
|
||
**Goal:** z
|
||
|
||
### [MSD.02] Foundation
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
|
||
## [MSD.03] v3.0: Next Milestone
|
||
|
||
### [MSD.03] 01: Later
|
||
**Goal:** c
|
||
`;
|
||
const dirs = ['MSD.01-01-old', 'MSD.02-01-one', 'MSD.02-02-two', 'MSD.03-01-later'];
|
||
writeProject(roadmap, 'bracket', dirs);
|
||
assert.deepEqual(acceptsFor(dirs), {
|
||
'MSD.01-01-old': false,
|
||
'MSD.02-01-one': true,
|
||
'MSD.02-02-two': true,
|
||
'MSD.03-01-later': false,
|
||
});
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
|
||
test('boundary heading level: a level-1 CURRENT milestone heading also scopes correctly (#{1,2} tolerance, not just level 2)', () => {
|
||
// A level-1/level-1 pairing (consistent heading-level convention across
|
||
// sibling milestones) — distinct from the level-3 case above: this pins
|
||
// that the `#{1,2}` bracket-boundary source tolerates level 1, not only the
|
||
// ADR-canonical level 2.
|
||
const roadmap = `# [MSD.02] Foundation
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
|
||
# [MSD.03] v3.0: Next Milestone
|
||
|
||
### [MSD.03] 01: Later
|
||
**Goal:** c
|
||
`;
|
||
const dirs = ['MSD.02-01-one', 'MSD.02-02-two', 'MSD.03-01-later'];
|
||
writeProject(roadmap, 'bracket', dirs);
|
||
assert.deepEqual(acceptsFor(dirs), {
|
||
'MSD.02-01-one': true,
|
||
'MSD.02-02-two': true,
|
||
'MSD.03-01-later': false,
|
||
});
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
});
|
||
|
||
// ─── #2761 B1 (round-2 adversarial review, Blocker 1): a SAME-MILESTONE ──────
|
||
// ─── continuation heading is not mistaken for a DIFFERENT milestone's ───────
|
||
// ─── boundary ────────────────────────────────────────────────────────────────
|
||
//
|
||
// The B1 fix above (08d5b0c4) taught computeSectionEnd/preambleCutoff to
|
||
// recognise ANY `#{1,2} [CODE.MM]` heading as a milestone boundary. It did not
|
||
// distinguish "a heading for a DIFFERENT milestone" (a real boundary) from "a
|
||
// heading that merely CONTINUES the CURRENT milestone's own section" (e.g. a
|
||
// version-less checklist/detail split: `## [MSD.02] Foundation (Phase
|
||
// Details)`, or an ad-hoc continuation heading `## [MSD.02] Foundation —
|
||
// continued`) — the latter is not a boundary at all. The `(Phase Details)`
|
||
// re-append at `detailsMatch` below only searches `allMatches` (the
|
||
// VERSION-STRING matches from the top of this function), so a version-less
|
||
// continuation heading was cut out by the boundary and never re-appended: the
|
||
// milestone's OWN later phases silently vanished from the returned scope,
|
||
// while `getMilestonePhaseFilter`'s phaseCount stayed non-zero (unlike the
|
||
// original #612 defect), so the pass-all degrade never caught it either — a
|
||
// confidently wrong, non-degraded phase count for a still-incomplete
|
||
// milestone.
|
||
//
|
||
// Fixed here by teaching isBracketMilestoneBoundary a same-milestone
|
||
// exclusion: a candidate heading whose OWN bracket id (case-folded) equals the
|
||
// SELECTED milestone's bracket id is never a boundary.
|
||
describe('#612 PR-2 B1 round-2: a same-milestone continuation heading is not a boundary', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-b1r2-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
const D = [['MSD.02-01-one', true], ['MSD.02-02-two', false]];
|
||
|
||
/**
|
||
* Per the file header's oracle-hazard #2: pre-write a WRONG total_phases
|
||
* into STATE.md's frontmatter before calling `syncedTotal()`/
|
||
* `syncedPercent()`, so `state sync` is forced to do real work rather than
|
||
* silently no-op because its computed total already happens to match.
|
||
*/
|
||
function poisonTotalPhases() {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const raw = fs.readFileSync(statePath, 'utf-8');
|
||
fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8');
|
||
}
|
||
|
||
test('RED (repro8 case 1): version-bearing head + version-less "(Phase Details)" continuation — 2/1/50, not 1/1/100', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] v2.0: Foundation
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** b
|
||
|
||
## [MSD.02] Foundation (Phase Details)
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** c
|
||
`, 'bracket', D);
|
||
assert.equal(readTotal(), 2, 'pinned 1 before this fix — the continuation heading truncated the section');
|
||
poisonTotalPhases();
|
||
assert.equal(syncedTotal(), 2, 'the WRITE path must agree with the READ path');
|
||
assert.equal(syncedPercent(), 50, 'pinned 100 before this fix');
|
||
});
|
||
|
||
test('RED (repro5): fully version-less milestone split across two headings, no siblings — 2/1/50, not 1/1/100', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] Foundation
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** b
|
||
|
||
## [MSD.02] Foundation — continued
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** c
|
||
`, 'bracket', D);
|
||
assert.equal(readTotal(), 2, 'pinned 1 before this fix');
|
||
poisonTotalPhases();
|
||
assert.equal(syncedTotal(), 2);
|
||
// The roadmap is version-less while STATE pins v2.0, so upstream #3217
|
||
// withholds the percentage rather than treating the scope as complete.
|
||
assert.match(syncSkipReason(), /scope is "unscoped", not COMPLETE \(#3217\)/);
|
||
});
|
||
|
||
test('PIN (repro8 case 3): a trailing DIFFERENT-id icebox section still terminates the primary section — unchanged at 2/1/50', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] v2.0: Foundation
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** c
|
||
|
||
## [MSD.999] Icebox
|
||
|
||
### [MSD.999] 07: Someday
|
||
**Goal:** z
|
||
`, 'bracket', D);
|
||
assert.equal(readTotal(), 2);
|
||
poisonTotalPhases();
|
||
assert.equal(syncedTotal(), 2);
|
||
assert.equal(syncedPercent(), 50);
|
||
});
|
||
|
||
test('PIN (repro10 A1): all-version-bearing + icebox + "(Phase Details)" — exact 2/1/50, no double-count', () => {
|
||
// The same-milestone exclusion must not make the PRIMARY section swallow
|
||
// the (Phase Details) section a second time on top of the pre-existing
|
||
// detailsMatch re-append — total_phases must read EXACTLY 2, not 4.
|
||
const dirs = [
|
||
['MSD.01-01-old', true],
|
||
['MSD.02-01-one', true],
|
||
['MSD.02-02-two', false],
|
||
['MSD.03-01-later', true],
|
||
];
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.01] v1.0: Prior
|
||
|
||
### [MSD.01] 01: Old
|
||
**Goal:** a
|
||
|
||
## [MSD.02] v2.0: Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** b
|
||
|
||
## [MSD.03] v3.0: Later
|
||
|
||
### [MSD.03] 01: Later
|
||
**Goal:** d
|
||
|
||
## [MSD.02] v2.0: Current (Phase Details)
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** c
|
||
|
||
## [MSD.999] Icebox
|
||
|
||
### [MSD.999] 07: Someday
|
||
**Goal:** z
|
||
`, 'bracket', dirs);
|
||
assert.equal(readTotal(), 2, 'exact — a regression here would double-count to 4');
|
||
// NOTE: no syncedTotal()/syncedPercent() assertions here — this fixture
|
||
// carries dirs OUTSIDE the current milestone (MSD.01-01-old,
|
||
// MSD.03-01-later), which is exactly the shape that exposes Major 1
|
||
// (cmdStateSync's body percent is computed from an unfiltered whole-disk
|
||
// scan). Asserting the synced percent here before Major 1's fix lands
|
||
// would fail on a DIFFERENT, not-yet-fixed defect. Covered once Major 1 is
|
||
// fixed (#2761 Major 1 commit), where this fixture's synced values are
|
||
// asserted directly.
|
||
});
|
||
});
|
||
|
||
// ─── #2761 B2 (round-2 adversarial review, Blocker 2): the heading ──────────
|
||
// ─── discriminator is CONTENT, not level ────────────────────────────────────
|
||
//
|
||
// Three sites disagreed about which heading levels are a bracket milestone:
|
||
// the selector (`^#{1,3}\s+\[CODE.MM\]`) and `isMilestoneBounded` (state.cts)
|
||
// both admit level 1-3, but the B1 boundary only admitted level 1-2
|
||
// (`h.level <= 2`). A `###`-level bracket milestone was therefore SELECTED and
|
||
// BOUNDED but never TERMINATED: computeSectionEnd ran with level=3, a level-3
|
||
// SIBLING milestone survived `h.level > level` (not deeper), failed the
|
||
// version/emoji test (version-less), then failed `h.level <= 2` — so the
|
||
// function fell through to `return content.length`, sweeping the sibling
|
||
// milestone's own phases into the current one. This reproduces trek-e's
|
||
// original #612 defect verbatim (a safe degrade became a confidently-wrong
|
||
// persisted number) on a heading level the selector and bounding predicate
|
||
// both already admit.
|
||
//
|
||
// ADR-612 Decision 1 (docs/adr/612-bracket-phase-id-convention.md:56)
|
||
// specifies the discriminator as CONTENT: "a phase heading is a bracket
|
||
// followed by a digit-then-colon; a milestone heading is a bracket followed
|
||
// by a name." Fixed by replacing the `level > 2` rejection with
|
||
// BRACKET_PHASE_TAIL_RE (built from phase-id.cts's single-owner
|
||
// phaseHeadingPrefixSrcFor, not a re-typed grammar), with the level check
|
||
// widened to a depth-sanity cap of 3 (mirroring the selector's own `#{1,3}`
|
||
// ceiling — NOT a phase/milestone discriminator itself).
|
||
describe('#612 PR-2 B2 round-2: the bracket boundary is a CONTENT discriminator, not a level cap', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-b2r2-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('RED (repro2 case C): all milestones ###, all phases ####, version-less — total 2, completion scoped, percent withheld', () => {
|
||
// MSD.02 (asserted milestone) has 2 phases, both complete. isMilestoneBounded
|
||
// already returns true at #{1,3} (state.cts, unaffected by this fix), so the
|
||
// phase totals remain directly observable — pinned 4/3 (whole-disk
|
||
// fallback) before this fix. Upstream #3573 now threads the asserted v2.0
|
||
// milestone into truthful progress scoping. Because this deliberately
|
||
// version-less roadmap cannot resolve that versioned window, percentage
|
||
// is withheld instead of manufacturing 100 from an UNSCOPED input.
|
||
const dirs = [
|
||
['MSD.01-01-old', true],
|
||
['MSD.02-01-one', true],
|
||
['MSD.02-02-two', true],
|
||
['MSD.03-01-later', false],
|
||
];
|
||
writeProject(`# Roadmap
|
||
|
||
### [MSD.01] Prior Milestone
|
||
|
||
#### [MSD.01] 01: Old one
|
||
**Goal:** a
|
||
|
||
### [MSD.02] Current Milestone
|
||
|
||
#### [MSD.02] 01: One
|
||
**Goal:** b
|
||
|
||
#### [MSD.02] 02: Two
|
||
**Goal:** c
|
||
|
||
### [MSD.03] Later Milestone
|
||
|
||
#### [MSD.03] 01: Later one
|
||
**Goal:** d
|
||
`, 'bracket', dirs);
|
||
assert.equal(readTotal(), 2, 'pinned 4 before this fix (whole-doc fallback)');
|
||
const r = runMsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(r.success, `state json failed: ${r.error}`);
|
||
const progress = JSON.parse(r.output).progress;
|
||
assert.equal(progress?.total_phases, 2, 'pinned 4 before this fix (whole-doc fallback)');
|
||
assert.equal(progress?.completed_phases, 2, 'both real MSD.02 phases are complete — scoping, not the whole disk, is what this fixture pins');
|
||
// Upstream #3573 threads STATE's stored `milestone: v2.0` into this read.
|
||
// A version-less ROADMAP cannot resolve that version, so the scope remains
|
||
// UNSCOPED and the percent is withheld even though the bracket-scoped
|
||
// totals above are correct.
|
||
assert.equal(progress?.percent, undefined, 'percent is withheld (upstream #3573 x version-less-bracket-document interaction) — disclosed, not a scoping regression');
|
||
});
|
||
|
||
test('mechanism (repro7): extractCurrentMilestone actually scopes a level-3 milestone, not the whole document', () => {
|
||
const dirs = ['MSD.01-01-old', 'MSD.02-01-one', 'MSD.02-02-two', 'MSD.03-01-later'];
|
||
const roadmap = `# Roadmap
|
||
|
||
### [MSD.01] Prior
|
||
|
||
#### [MSD.01] 01: Old
|
||
**Goal:** a
|
||
|
||
### [MSD.02] Current
|
||
|
||
#### [MSD.02] 01: One
|
||
**Goal:** b
|
||
|
||
#### [MSD.02] 02: Two
|
||
**Goal:** c
|
||
|
||
### [MSD.03] Later
|
||
|
||
#### [MSD.03] 01: Later
|
||
**Goal:** d
|
||
`;
|
||
writeProject(roadmap, 'bracket', dirs);
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const scope = rp.extractCurrentMilestone(
|
||
fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'),
|
||
tmpDir,
|
||
);
|
||
assert.ok(scope.length < roadmap.length, 'pinned scope === full document before this fix (fell through to content.length)');
|
||
assert.ok(scope.includes('01: One') && scope.includes('02: Two'), 'the milestone\'s own phases must survive scoping');
|
||
assert.ok(!scope.includes('Old') && !scope.includes('Later'), 'sibling milestones must not leak in');
|
||
});
|
||
|
||
test('PIN: a dotted sub-phase heading ([MSD.02] 05.03:) is phase-tail-shaped, not a boundary', () => {
|
||
// The tail grammar must cover BOTH `05:` and the dotted `05.03:` forms —
|
||
// this is precisely where a regex slip in the tail discriminator would
|
||
// hide (a milestone-shaped false negative would truncate the section at
|
||
// what is actually a real sub-phase heading).
|
||
const dirs = ['MSD.02-01-one', 'MSD.02-05.03-sub', 'MSD.03-01-later'];
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 05.03: Name
|
||
**Goal:** b
|
||
|
||
## [MSD.03] Later
|
||
|
||
### [MSD.03] 01: Later
|
||
**Goal:** c
|
||
`, 'bracket', dirs);
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const f = rp.getMilestonePhaseFilter(tmpDir);
|
||
assert.equal(!!f('MSD.02-01-one'), true);
|
||
assert.equal(!!f('MSD.02-05.03-sub'), true, 'the dotted sub-phase heading must not be excluded as a boundary');
|
||
assert.equal(!!f('MSD.03-01-later'), false);
|
||
assert.equal(readTotal(), 2, 'both MSD.02 phases (01 and 05.03) must be counted');
|
||
});
|
||
|
||
test('PIN (repro8 case 3, content-discriminator mechanism): a trailing DIFFERENT-id icebox section still terminates — 2/1/50', () => {
|
||
// Re-pins the same shape as the B1 round-2 block above, now that the
|
||
// discriminator is CONTENT rather than level: [MSD.999] is bracket-shaped
|
||
// and NOT phase-tail-shaped ("Icebox" carries no digit-colon), and its id
|
||
// differs from the selected milestone's — a boundary either way.
|
||
const D = [['MSD.02-01-one', true], ['MSD.02-02-two', false]];
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] v2.0: Foundation
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** c
|
||
|
||
## [MSD.999] Icebox
|
||
|
||
### [MSD.999] 07: Someday
|
||
**Goal:** z
|
||
`, 'bracket', D);
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
});
|
||
|
||
// ─── #2761 Blocker 3 (round-2 adversarial review): the preamble-cutoff ──────
|
||
// ─── bracket scan is now fence-aware ─────────────────────────────────────────
|
||
//
|
||
// `preambleCutoff`'s bracket branch used a raw `content.match`/`matchAll` —
|
||
// blind to fenced code blocks — while its sibling `computeSectionEnd` (a few
|
||
// lines above it) already consumed `tokenizeHeadings(content)`, which strips
|
||
// fences. A fenced markdown example in the preamble containing a bracket
|
||
// heading (ADR-612's own docs do exactly this) was textually the earliest
|
||
// `#{1,3} [CODE.MM]` match: `preambleCutoff` landed INSIDE the fence,
|
||
// `preamble = content.slice(0, preambleCutoff)` ended with an unclosed
|
||
// opener, and the unbalanced fence then blinded
|
||
// `getMilestonePhaseFilter`'s own `tokenizeHeadings(scope)` call — EVERY
|
||
// heading in the returned scope vanished, `phaseCount` degraded to 0, and the
|
||
// pass-all filter admitted every directory on disk (repro11's mechanism).
|
||
// Regression vs round-1, which had no bracket pattern to blind and so fell
|
||
// back to the correct (non-fenced) heading.
|
||
//
|
||
// Fixed by scanning the SAME `currentMilestoneHeadings` token list
|
||
// computeSectionEnd consumes, instead of a raw regex — closing the asymmetry
|
||
// between the two halves of one boundary semantic.
|
||
describe('#612 PR-2 Blocker 3 round-2: preambleCutoff is fence-aware (bracket branch only)', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-b3fence-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
const D = [
|
||
['MSD.01-01-stray', true],
|
||
['MSD.02-01-one', true],
|
||
['MSD.02-02-two', false],
|
||
['MSD.03-01-stray', true],
|
||
];
|
||
|
||
test('RED (repro12 bracket row): a fenced example bracket heading in the preamble no longer blinds the scan — 2/1/50', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
Authoring guide:
|
||
|
||
\`\`\`markdown
|
||
## [MSD.00] Example milestone heading
|
||
\`\`\`
|
||
|
||
## [MSD.02] Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** c
|
||
`, 'bracket', D);
|
||
assert.equal(readTotal(), 2, 'pinned 4 before this fix (fence-blind scan degraded phaseCount to 0)');
|
||
});
|
||
|
||
test('mechanism (repro11): the returned scope has an EVEN, balanced fence count and a non-degraded phaseCount', () => {
|
||
const roadmap = `# Roadmap
|
||
|
||
Docs for authors:
|
||
|
||
\`\`\`markdown
|
||
## [MSD.00] Example milestone heading
|
||
### [MSD.00] 01: Example phase
|
||
\`\`\`
|
||
|
||
## [MSD.02] Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** c
|
||
|
||
## [MSD.03] Later
|
||
|
||
### [MSD.03] 01: Later
|
||
**Goal:** d
|
||
`;
|
||
const dirs = ['MSD.01-01-old', 'MSD.02-01-one', 'MSD.02-02-two', 'MSD.03-01-later'];
|
||
writeProject(roadmap, 'bracket', dirs);
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const ms = require('../msd-core/bin/lib/markdown-sectionizer.cjs');
|
||
const scope = rp.extractCurrentMilestone(
|
||
fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'),
|
||
tmpDir,
|
||
);
|
||
const fenceCount = (scope.match(/^```/gm) || []).length;
|
||
assert.equal(fenceCount % 2, 0, 'pinned an ODD (unbalanced) fence count before this fix');
|
||
const headings = ms.tokenizeHeadings(scope).map((h) => h.text);
|
||
assert.ok(headings.some((t) => /MSD\.02.*01: One/.test(t)), 'real headings must survive tokenization, not collapse to just "Roadmap"');
|
||
const f = rp.getMilestonePhaseFilter(tmpDir);
|
||
assert.notEqual(f.phaseCount, 0, 'pinned 0 (pass-all degrade) before this fix');
|
||
assert.deepEqual(
|
||
Object.fromEntries(dirs.map((d) => [d, !!f(d)])),
|
||
{ 'MSD.01-01-old': false, 'MSD.02-01-one': true, 'MSD.02-02-two': true, 'MSD.03-01-later': false },
|
||
'pinned every directory admitted (pass-all) before this fix',
|
||
);
|
||
});
|
||
|
||
test('UPSTREAM COMPOSITION (repro12 LEGACY control): #3573 scopes the fenced-example fixture to total 2', () => {
|
||
// This bracket-only fix still does not widen the LEGACY
|
||
// `anyMilestonePattern` raw-match path. Upstream's newer state derivation,
|
||
// however, routes this read through fence-aware `sliceMilestoneWindow`
|
||
// using STATE's stored milestone. The fixture therefore no longer reaches
|
||
// the blind path and must pin the improved scoped result, not the old
|
||
// whole-document total of 4.
|
||
writeProject(`# Roadmap
|
||
|
||
Authoring guide:
|
||
|
||
\`\`\`markdown
|
||
## Milestone v9.0: Example
|
||
\`\`\`
|
||
|
||
## Milestone v2.0: Current
|
||
|
||
### Phase 01: One
|
||
**Goal:** b
|
||
|
||
### Phase 02: Two
|
||
**Goal:** c
|
||
`, undefined, [['03-stray', true], ['01-one', true], ['02-two', false], ['04-stray', true]]);
|
||
assert.equal(readTotal(), 2, 'upstream #3573 now scopes this read via sliceMilestoneWindow instead of the fence-blind anyMilestonePattern path');
|
||
});
|
||
|
||
test('PIN (repro10 A3): a fenced heading INSIDE the current section still must not terminate it', () => {
|
||
const dirs = ['MSD.01-01-old', 'MSD.02-01-one', 'MSD.02-02-two', 'MSD.03-01-later'];
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** b
|
||
|
||
\`\`\`markdown
|
||
## [MSD.03] Not a real heading
|
||
\`\`\`
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** c
|
||
|
||
## [MSD.03] Later
|
||
|
||
### [MSD.03] 01: Later
|
||
**Goal:** d
|
||
`, 'bracket', dirs);
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
});
|
||
|
||
// ─── #2761 B3 (self-caught, round-2 verification): CURRENT version-bearing, ──
|
||
// ─── a sibling milestone is not ──────────────────────────────────────────────
|
||
//
|
||
// The B1 fix (commit 08d5b0c4) resolved `bracketScopeConvention` only inside
|
||
// the `if (headingMatches.length === 0)` gate that also drives SELECTION's own
|
||
// bracket fallback. That gate is correct for SELECTION (only try the bracket
|
||
// heading shape when the version-string match found nothing), but
|
||
// `bracketScopeConvention` also feeds `computeSectionEnd`'s and
|
||
// `preambleCutoff`'s boundary-detection — which accidentally inherited
|
||
// SELECTION's gate instead of having its own.
|
||
//
|
||
// Trigger shape: the CURRENT milestone heading is ITSELF version-bearing
|
||
// (`## [MSD.02] v2.0: Current Milestone`), so the primary version-string
|
||
// `sectionPattern` matches it directly — `headingMatches.length !== 0` from the
|
||
// very first check — and the entire bracket-resolution branch is skipped. A
|
||
// sibling milestone (PRIOR or LATER) that is version-less then gets NEITHER
|
||
// the version/emoji boundary rule (it has no version) NOR the bracket boundary
|
||
// rule (never resolved) — reproducing the exact #612 defect symptom
|
||
// (total_phases falls back to the whole-disk count) through a structural
|
||
// shape B1's own fixtures never exercised (every B1 fixture is uniformly
|
||
// version-bearing or uniformly version-less across all three milestones).
|
||
describe('#612 PR-2 B3: bracket boundaries engage even when CURRENT is version-bearing but a sibling is not', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-b3-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
const ROADMAP = `# Roadmap
|
||
|
||
## [MSD.01] Prior Milestone
|
||
|
||
### [MSD.01] 01: Old one
|
||
**Goal:** a
|
||
|
||
## [MSD.02] v2.0: Current Milestone
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** c
|
||
|
||
## [MSD.03] Later Milestone
|
||
|
||
### [MSD.03] 01: Later one
|
||
**Goal:** d
|
||
`;
|
||
const DIRS = ['MSD.01-01-old-one', 'MSD.02-01-one', 'MSD.02-02-two', 'MSD.03-01-later-one'];
|
||
|
||
const acceptsFor = (dirs) => {
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const f = rp.getMilestonePhaseFilter(tmpDir);
|
||
return Object.fromEntries(dirs.map((d) => [d, !!f(d)]));
|
||
};
|
||
|
||
test('CURRENT version-bearing + both siblings version-less: scoping works in both directions', () => {
|
||
writeProject(ROADMAP, 'bracket', DIRS);
|
||
assert.deepEqual(acceptsFor(DIRS), {
|
||
'MSD.01-01-old-one': false,
|
||
'MSD.02-01-one': true,
|
||
'MSD.02-02-two': true,
|
||
'MSD.03-01-later-one': false,
|
||
});
|
||
});
|
||
|
||
test('the PRIOR (version-less) milestone does not leak into the preamble (preambleCutoff twin)', () => {
|
||
writeProject(ROADMAP, 'bracket', DIRS);
|
||
assert.equal(acceptsFor(DIRS)['MSD.01-01-old-one'], false);
|
||
});
|
||
|
||
test('the LATER (version-less) milestone does not leak into scope (computeSectionEnd twin)', () => {
|
||
writeProject(ROADMAP, 'bracket', DIRS);
|
||
assert.equal(acceptsFor(DIRS)['MSD.03-01-later-one'], false);
|
||
});
|
||
|
||
test('total_phases counts only the asserted milestone, not the whole disk', () => {
|
||
writeProject(ROADMAP, 'bracket', DIRS);
|
||
// 4 directories on disk, 2 phases in the milestone STATE.md asserts.
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
});
|
||
|
||
// ─── #2761 Blocker 1 (round-3 adversarial re-verify): the preambleCutoff ────
|
||
// ─── scan drops current-milestone content whenever ANY bracket-shaped ──────
|
||
// ─── heading precedes the selected milestone heading ────────────────────────
|
||
//
|
||
// The round-2 fix (39c42a89) threaded `selectedBracketId` as the REAL value
|
||
// at computeSectionEnd but as bare `null` at the preambleCutoff scan — the
|
||
// deviation's own rationale ("the selected heading's own occurrence is
|
||
// always the correct earliest answer") was right, but `null` disables the
|
||
// same-milestone check for EVERY heading, not just the selected one. Any
|
||
// bracket-shaped heading earlier than the selected milestone — same id
|
||
// (cases A, B) or a DIFFERENT id with no children of its own (case D) — was
|
||
// wrongly accepted as the earliest boundary, and the region between it and
|
||
// the real `sectionStart` was silently dropped: a completed phase vanished
|
||
// and `state sync` persisted a confident 0% where base and round-1 both
|
||
// correctly wrote 50%.
|
||
//
|
||
// Fixed with two changes, both scoped to the preambleCutoff scan only
|
||
// (computeSectionEnd is untouched — it already threads the real
|
||
// `selectedBracketId`):
|
||
// (a) `h.offset === sectionStart` bypasses BOTH the same-milestone check
|
||
// and the same-id-child rule below — the selected heading's own
|
||
// position is definitionally correct, and this is the one case where
|
||
// neither discriminator should even run (rejecting it would be
|
||
// rejecting the heading against ITSELF).
|
||
// (b) every OTHER bracket-shaped, non-phase-tail-shaped candidate must
|
||
// additionally have a matching-id CHILD (the next strictly-deeper
|
||
// heading beneath it) to count as a boundary — `bracketHeadingHasMatchingChild`.
|
||
// This is what distinguishes a genuine sibling milestone (whose own
|
||
// phase children carry ITS bracket id) from an unrelated bracket-shaped
|
||
// PROSE heading like `## [ADR.612] Heading convention used by this
|
||
// roadmap` sitting above the current milestone's own content.
|
||
describe('#612 PR-2 Blocker 1 round-3: preambleCutoff identity is offset- and child-aware', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-b1r3-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
const D = [['MSD.02-01-one', true], ['MSD.02-02-two', false]];
|
||
|
||
function poisonTotalPhases() {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const raw = fs.readFileSync(statePath, 'utf-8');
|
||
fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8');
|
||
}
|
||
|
||
test('RED case A (rv-attack1): a same-milestone version-LESS heading earlier than the version-bearing selected heading — 2/1/50, not 1/0/0', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] Foundation
|
||
|
||
- [ ] **[MSD.02] 01: One**
|
||
- [ ] **[MSD.02] 02: Two**
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
## [MSD.02] v2.0: Foundation (Phase Details)
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', D);
|
||
assert.equal(readTotal(), 2, 'pinned 1 before this fix — the earlier checklist heading dropped everything before sectionStart');
|
||
poisonTotalPhases();
|
||
assert.equal(syncedTotal(), 2);
|
||
assert.equal(syncedPercent(), 50, 'pinned 0 before this fix — a confidently wrong persisted 0%');
|
||
});
|
||
|
||
test('RED case B (rv-attack1): a same-milestone OVERVIEW heading (no "(Phase Details)" spelling) earlier than the version-bearing selected heading — 2/1/50, not 1/0/0', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] Foundation (overview)
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
## [MSD.02] v2.0: Foundation
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', D);
|
||
assert.equal(readTotal(), 2, 'pinned 1 before this fix');
|
||
poisonTotalPhases();
|
||
assert.equal(syncedTotal(), 2);
|
||
assert.equal(syncedPercent(), 50, 'pinned 0 before this fix');
|
||
});
|
||
|
||
test('RED case D (rv-attack1b): a DIFFERENT-id bracket-shaped PROSE heading before the selected milestone — 2/1/50, not 1/0/0', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## [ADR.612] Heading convention used by this roadmap
|
||
|
||
Phases are listed under their milestone.
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
## [MSD.02] v2.0: Foundation
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', D);
|
||
assert.equal(readTotal(), 2, 'pinned 1 before this fix — [ADR.612] read as a genuine boundary with no child-id check');
|
||
poisonTotalPhases();
|
||
assert.equal(syncedTotal(), 2);
|
||
assert.equal(syncedPercent(), 50, 'pinned 0 before this fix');
|
||
});
|
||
|
||
test('PIN: a genuine prior sibling milestone (real children sharing its own id) is still excluded from the preamble', () => {
|
||
const dirs = ['MSD.01-01-old-one', 'MSD.02-01-one', 'MSD.02-02-two'];
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.01] Prior Milestone
|
||
|
||
### [MSD.01] 01: Old one
|
||
**Goal:** a
|
||
|
||
## [MSD.02] v2.0: Foundation
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** c
|
||
`, 'bracket', dirs.map((d, i) => [d, i !== 2]));
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const f = rp.getMilestonePhaseFilter(tmpDir);
|
||
assert.equal(!!f('MSD.01-01-old-one'), false, 'the prior milestone must still be excluded — the child rule must not re-admit a genuine sibling');
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
|
||
test('PIN: a CHILDLESS prior sibling milestone (no phases of its own) degrades to NOT cutting the preamble — over-inclusive, safe', () => {
|
||
// "## [MSD.01] Empty Prior Milestone" has no deeper heading before the
|
||
// next heading at its own level — the child rule rejects it as a
|
||
// boundary, so its own heading TEXT stays in the preamble. That text is
|
||
// not phase-shaped (no digit-colon), so it contributes nothing to any
|
||
// count — over-inclusive, not under-inclusive, the safe direction.
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.01] Empty Prior Milestone
|
||
|
||
## [MSD.02] v2.0: Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', D);
|
||
assert.equal(readTotal(), 2, 'the current milestone\'s own two phases must still be counted, unpolluted by the childless sibling');
|
||
poisonTotalPhases();
|
||
assert.equal(syncedPercent(), 50);
|
||
});
|
||
|
||
test('Nit 2 PIN: a colon-less bracket heading ("[MSD.02] 05" with no trailing colon) does not spuriously terminate the preamble', () => {
|
||
// A colon-less bracket heading is bracket-shaped but NOT phase-tail-shaped
|
||
// (BRACKET_PHASE_TAIL_RE requires the trailing colon), so
|
||
// isBracketMilestoneBoundary alone would read it as a boundary. It is
|
||
// also — precisely because it is malformed/incomplete rather than a real
|
||
// milestone — childless (nothing deeper follows it before the next
|
||
// same-or-shallower heading), so the child rule neutralizes it here.
|
||
writeProject(`# Roadmap
|
||
|
||
### [MSD.02] 05
|
||
|
||
## [MSD.02] v2.0: Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', D);
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
|
||
test('mechanism (rv-mech1): extractCurrentMilestone now scopes the FULL document for case A, phaseCount reflects both real phases', () => {
|
||
const roadmap = `# Roadmap
|
||
|
||
## [MSD.02] Foundation
|
||
|
||
- [ ] **[MSD.02] 01: One**
|
||
- [ ] **[MSD.02] 02: Two**
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
## [MSD.02] v2.0: Foundation (Phase Details)
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`;
|
||
writeProject(roadmap, 'bracket', D);
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const scope = rp.extractCurrentMilestone(
|
||
fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'),
|
||
tmpDir,
|
||
);
|
||
assert.equal(scope, roadmap, 'pinned a truncated scope (bytes [11,124) dropped) before this fix');
|
||
const f = rp.getMilestonePhaseFilter(tmpDir);
|
||
assert.equal(f.phaseCount, 2, 'pinned phaseCount 1 before this fix');
|
||
assert.equal(!!f('MSD.02-01-one'), true, 'pinned false before this fix — a real dir of the CURRENT milestone');
|
||
assert.equal(!!f('MSD.02-02-two'), true);
|
||
});
|
||
});
|
||
|
||
// ─── #2761 Major 1 (round-3 adversarial re-verify): the version/emoji half ──
|
||
// ─── of preambleCutoff's min() is now fence-aware too, on the bracket ───────
|
||
// ─── branch only ─────────────────────────────────────────────────────────────
|
||
//
|
||
// ff6bf0a8 (round-2 Blocker 3) made the BRACKET half of preambleCutoff's
|
||
// "earliest milestone-shaped heading" search fence-aware, but left the
|
||
// VERSION/emoji half a raw `content.match` even on the bracket branch. A
|
||
// fenced VERSION-BEARING example heading in a bracket repo's preamble
|
||
// (ADR-612's own docs illustrate the LEGACY heading shape this way, inside a
|
||
// fenced authoring-guide block) was still textually the earliest match for
|
||
// that raw regex, winning the min() and formerly un-suppressing a wrong
|
||
// persisted 75%. On the rebased base, #3217 keeps the unsafe percentage
|
||
// suppressed explicitly when the version-less milestone cannot yield a
|
||
// COMPLETE version override.
|
||
describe('#612 PR-2 Major 1 round-3: preambleCutoff\'s version/emoji half is fence-aware on the bracket branch', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-m1r3-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
const D = [
|
||
['MSD.02-01-one', true], ['MSD.02-02-two', false],
|
||
['MSD.03-01-x', true], ['MSD.03-02-y', true],
|
||
];
|
||
|
||
test('RED case C1 (rv-attack3c): a fenced VERSION-bearing example does not inflate the total; unsafe percent stays withheld', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
Authoring guide — a milestone heading looks like:
|
||
|
||
\`\`\`markdown
|
||
## Milestone v9.0: Example
|
||
\`\`\`
|
||
|
||
## [MSD.02] Current
|
||
|
||
### [MSD.02] 01: One
|
||
### [MSD.02] 02: Two
|
||
|
||
## [MSD.03] Later
|
||
|
||
### [MSD.03] 01: X
|
||
### [MSD.03] 02: Y
|
||
`, 'bracket', D);
|
||
assert.equal(readTotal(), 2, 'pinned 4 before this fix — the fenced VERSION heading won the min() and swallowed everything before it into the preamble unstripped');
|
||
poisonTotalPhases();
|
||
assert.match(syncSkipReason(), /scope is "unscoped", not COMPLETE \(#3217\)/,
|
||
'upstream must keep the unsafe percentage withheld rather than resurface the old 75%');
|
||
});
|
||
|
||
test('PIN case C2: a fenced BRACKET-shaped heading in the same position (round-2\'s own fix target) stays unchanged at 2/1/50', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
Authoring guide — a milestone heading looks like:
|
||
|
||
\`\`\`markdown
|
||
## [MSD.00] Example
|
||
\`\`\`
|
||
|
||
## [MSD.02] Current
|
||
|
||
### [MSD.02] 01: One
|
||
### [MSD.02] 02: Two
|
||
|
||
## [MSD.03] Later
|
||
|
||
### [MSD.03] 01: X
|
||
### [MSD.03] 02: Y
|
||
`, 'bracket', D);
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
|
||
function poisonTotalPhases() {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const raw = fs.readFileSync(statePath, 'utf-8');
|
||
fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8');
|
||
}
|
||
});
|
||
|
||
// ─── #2761 round-3 hardening (team-lead review of f87bba0e): two edges in ──
|
||
// ─── the new preambleCutoff code ────────────────────────────────────────────
|
||
//
|
||
// AMENDMENT 1 — bracketHeadingHasMatchingChild originally checked only
|
||
// `headings[index + 1]` (the IMMEDIATE next heading), not the candidate's
|
||
// whole subtree. A genuine prior sibling milestone whose section opens with
|
||
// a non-bracket subsection before its first phase (`## [MSD.01] Setup` /
|
||
// `### Notes` / `### [MSD.01] 01: Old`) was therefore wrongly rejected as a
|
||
// boundary — its own real phase heading is TWO headings deep, not one — and
|
||
// its entire section leaked into the preamble unstripped.
|
||
//
|
||
// CONFIRMED RED at f87bba0e before this fix (per the team lead's request to
|
||
// check observability, not just theory): the leak is NOT merely inert —
|
||
// `MSD.01-01-old`'s directory was wrongly admitted into the CURRENT
|
||
// milestone's filter via the leaked heading's qualified key
|
||
// (`MSD.01-01`), reading **3/2/67%** where truth is **2/1/50%**. Scope
|
||
// membership DOES drive the disk-side filter on this shape. Fixed by
|
||
// scanning the candidate's full SUBTREE (continue past a non-matching
|
||
// deeper heading instead of returning false immediately; only a
|
||
// same-or-shallower heading actually closes the subtree).
|
||
//
|
||
// AMENDMENT 2 — the round-3 Major 1 fix (c483552a) ported the version/emoji
|
||
// half of preambleCutoff to the token-based scan with no level cap; the raw
|
||
// `content.match(anyMilestonePattern)` it replaced was anchored `^#{1,3}\s+`.
|
||
// A level-4+ version-bearing heading in the preamble (`#### v2.0 notes`)
|
||
// therefore won the scan on the bracket branch where the raw pattern (and
|
||
// the legacy path, unaffected) ignores it outright — a heading neither the
|
||
// selector nor `isMilestoneBounded` would ever treat as a milestone marker.
|
||
// Fixed with `if (h.level > 3) continue;`, mirroring the depth-sanity cap
|
||
// `isBracketMilestoneBoundary` already applies to the bracket half.
|
||
describe('#612 PR-2 round-3 hardening: subtree child scan + level cap on preambleCutoff', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-r3h-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('RED (rv2-amend1): a genuine prior sibling with an intervening non-bracket subsection is still excluded — 2/1/50, not 3/2/67', () => {
|
||
const dirs = [['MSD.01-01-old', true], ['MSD.02-01-one', true], ['MSD.02-02-two', false]];
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.01] Setup
|
||
|
||
### Notes
|
||
|
||
Some prose about the prior milestone.
|
||
|
||
### [MSD.01] 01: Old
|
||
|
||
## [MSD.02] v2.0: Current
|
||
|
||
### [MSD.02] 01: One
|
||
|
||
### [MSD.02] 02: Two
|
||
`, 'bracket', dirs);
|
||
assert.equal(readTotal(), 2, 'pinned 3 before this fix — the immediate-next-heading-only check rejected [MSD.01] Setup as a boundary because its FIRST child (### Notes) is not bracket-shaped, even though its SECOND child (### [MSD.01] 01: Old) is');
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const f = rp.getMilestonePhaseFilter(tmpDir);
|
||
assert.equal(!!f('MSD.01-01-old'), false, 'pinned true before this fix — the leaked heading\'s qualified key wrongly admitted the prior milestone\'s own directory');
|
||
assert.equal(!!f('MSD.02-01-one'), true);
|
||
assert.equal(!!f('MSD.02-02-two'), true);
|
||
});
|
||
|
||
test('PIN (rv2-amend2): a level-4 version-bearing preamble heading is NOT a cutoff on the bracket branch — the preamble text survives unstripped', () => {
|
||
const roadmap = `# Roadmap
|
||
|
||
#### v2.0 notes
|
||
|
||
Some prose that happens to mention v2.0 in a deep heading.
|
||
|
||
## [MSD.02] Current
|
||
|
||
### [MSD.02] 01: One
|
||
|
||
### [MSD.02] 02: Two
|
||
`;
|
||
writeProject(roadmap, 'bracket', [['MSD.02-01-one', true], ['MSD.02-02-two', false]]);
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const scope = rp.extractCurrentMilestone(
|
||
fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8'),
|
||
tmpDir,
|
||
);
|
||
assert.ok(scope.includes('v2.0 notes'), 'pinned dropped before this fix — the level-4 heading wrongly won the earliest-of-either scan and truncated the preamble at itself');
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
|
||
test('PIN: the LEGACY control for the level-4 preamble heading is unchanged (raw content.match path untouched)', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
#### v2.0 notes
|
||
|
||
Some prose that happens to mention v2.0 in a deep heading.
|
||
|
||
## Milestone v2.0: Current
|
||
|
||
### Phase 01: One
|
||
|
||
### Phase 02: Two
|
||
`, undefined, [['01-one', true], ['02-two', false]]);
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
});
|
||
|
||
// ─── #2761 round-4 (team-lead re-verify of fbfd0fca): the child rule must ──
|
||
// ─── require a same-id PHASE, not merely a same-id heading ──────────────────
|
||
//
|
||
// bracketHeadingHasMatchingChild's subtree scan (fbfd0fca) proved SAME-ID-NESS
|
||
// but never asked whether the matching child was PHASE-shaped. Case F1
|
||
// reopens round-3's case D one heading later: `## [ADR.612] Heading
|
||
// convention` is followed by its OWN sub-heading `### [ADR.612] Examples` —
|
||
// same bracket id as the candidate, but MILESTONE-shaped (a name, no
|
||
// digit-then-colon), not a phase. Same-id-ness alone satisfied the subtree
|
||
// scan, re-cutting the preamble at exactly the shape the round-3 hardening
|
||
// was written to close.
|
||
function poisonTotalPhasesR4(dir) {
|
||
const statePath = path.join(dir, '.planning', 'STATE.md');
|
||
const raw = fs.readFileSync(statePath, 'utf-8');
|
||
fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8');
|
||
}
|
||
|
||
describe('#612 PR-2 round-4 Blocker 1: the same-id child must be PHASE-shaped', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-r4b1-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('RED (F1): a same-id MILESTONE-shaped child on a prose heading no longer satisfies the child rule — 2/1/50, not 1/0/0', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## [ADR.612] Heading convention
|
||
|
||
### [ADR.612] Examples
|
||
|
||
Prose about the convention.
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
## [MSD.02] v2.0: Foundation
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', [['MSD.02-01-one', true], ['MSD.02-02-two', false]]);
|
||
assert.equal(readTotal(), 2, 'pinned 1 before this fix — [ADR.612]\'s own MILESTONE-shaped sub-heading satisfied the same-id-only rule');
|
||
poisonTotalPhasesR4(tmpDir);
|
||
assert.equal(syncedTotal(), 2);
|
||
assert.equal(syncedPercent(), 50, 'pinned 0 before this fix — state sync reported "nothing to do" because its wrong 0% happened to equal the seed');
|
||
});
|
||
|
||
test('PIN (F11): a genuine prior sibling whose same-id child lacks the trailing colon is still correctly excluded (inert leak, not a boundary either way)', () => {
|
||
const dirs = [['MSD.01-01-old', true], ['MSD.02-01-one', true], ['MSD.02-02-two', false]];
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.01] Setup
|
||
|
||
### [MSD.01] 01 Old
|
||
|
||
## [MSD.02] v2.0: Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', dirs);
|
||
assert.equal(readTotal(), 2);
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const f = rp.getMilestonePhaseFilter(tmpDir);
|
||
assert.equal(!!f('MSD.01-01-old'), false, 'the colon-less heading forms no qualified key either, so the childless-degrade leak is inert');
|
||
});
|
||
|
||
test('PIN (F11b): a genuine prior sibling whose phases exist only as bullets is still correctly excluded', () => {
|
||
const dirs = [['MSD.01-01-old', true], ['MSD.02-01-one', true], ['MSD.02-02-two', false]];
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.01] Setup
|
||
|
||
- [x] **[MSD.01] 01: Old**
|
||
|
||
### [MSD.01] Retrospective
|
||
|
||
## [MSD.02] v2.0: Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', dirs);
|
||
assert.equal(readTotal(), 2);
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const f = rp.getMilestonePhaseFilter(tmpDir);
|
||
assert.equal(!!f('MSD.01-01-old'), false, 'a bracket bullet never forms a qualified key (BULLET_PHASE_LINE_PATTERN needs a literal **Phase )');
|
||
});
|
||
|
||
test('re-verify: the four existing child-rule pins (F2-F5 shapes) are unaffected by the phase-shape requirement', () => {
|
||
// F2: subtree closure — same-id child appears only after a same-level
|
||
// heading closes the subtree; must not count (over-inclusive degrade).
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.01] Setup
|
||
|
||
Prose, no children.
|
||
|
||
## [DOC.99] Reference
|
||
|
||
### [MSD.01] 01: Old
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
## [MSD.02] v2.0: Current
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', [['MSD.01-01-old', true], ['MSD.02-01-one', true], ['MSD.02-02-two', false]]);
|
||
assert.equal(readTotal(), 3, 'unchanged — the declared over-inclusive/safe degrade (Minor 1), not a regression');
|
||
|
||
// F3: deep wrong-id children then a same-id PHASE child inside the subtree — must count.
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.01] Setup
|
||
|
||
### Notes
|
||
|
||
#### Deep note
|
||
|
||
### [DOC.99] Aside
|
||
|
||
### [MSD.01] 01: Old
|
||
|
||
## [MSD.02] v2.0: Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', [['MSD.01-01-old', true], ['MSD.02-01-one', true], ['MSD.02-02-two', false]]);
|
||
assert.equal(readTotal(), 2);
|
||
|
||
// F4: the same-id PHASE child is itself level 4 — the child scan has no depth ceiling.
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.01] Setup
|
||
|
||
### Notes
|
||
|
||
#### [MSD.01] 01: Old
|
||
|
||
## [MSD.02] v2.0: Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', [['MSD.01-01-old', true], ['MSD.02-01-one', true], ['MSD.02-02-two', false]]);
|
||
assert.equal(readTotal(), 2);
|
||
|
||
// F5: trailing bracket sibling at document end (childless) — computeSectionEnd
|
||
// excludes it without needing the child rule at all.
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] v2.0: Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
|
||
## [MSD.03] Later
|
||
`, 'bracket', [['MSD.02-01-one', true], ['MSD.02-02-two', false], ['MSD.03-01-later', true]]);
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
});
|
||
|
||
// ─── #2761 round-4 Major 1: the four fence-blind sites known at round 4 ────
|
||
// ─── on the bracket path (a fifth — the retirement scan — was found at ────
|
||
// ─── round 5 and has its own block further below) ──────────────────────────
|
||
//
|
||
// The scope string extractCurrentMilestone returns is fence-BALANCED and
|
||
// fence-STRIPPED-by-tokenizeHeadings only when its CONSUMERS ask it that way.
|
||
// Four sites still read it (or the raw ROADMAP) with a plain regex `.exec`,
|
||
// blind to fences:
|
||
//
|
||
// (a) roadmapPhaseCount — TWO independent copies, buildStateFrontmatter
|
||
// (read path) and cmdStateSync (write path). A fenced EXAMPLE phase
|
||
// heading in the preamble inflated total_phases (F10); on a
|
||
// version-less roadmap the SAME fence-blindness compounds with (c)
|
||
// below (F9).
|
||
// (b) isMilestoneBounded — a raw `.test(roadmapRaw)`. A fenced-ONLY bracket
|
||
// heading (no real section for the asserted milestone at all) wrongly
|
||
// BOUNDED a milestone that isn't in the roadmap, un-suppressing a
|
||
// percent that should stay suppressed (F12).
|
||
// (c) the bracket-fallback SELECTOR (only reachable when the version-string
|
||
// selection found nothing) — a raw `content.matchAll`. A fenced example
|
||
// sharing the CURRENT project's own bracket id could be SELECTED as the
|
||
// current milestone, landing `sectionStart` inside a fence (F9).
|
||
//
|
||
// Fixed by extracting ONE shared counter (countRoadmapPhaseHeadings, in
|
||
// src/state.cts, immediately above buildStateFrontmatter) used by both (a)
|
||
// copies, and converting (b) and (c) to tokenizeHeadings-based scans. The
|
||
// PRODUCER (extractCurrentMilestone's returned scope string) is deliberately
|
||
// UNCHANGED — every other consumer of that string needs its full content
|
||
// fidelity, and legacy identity forbids touching the shared string. Legacy
|
||
// (non-bracket) behavior at all four sites is byte-identical; this is the
|
||
// ONLY commit in this arc that touches SELECTION.
|
||
describe('#612 PR-2 round-4 Major 1: the four fence-blind sites known at round 4 (a fifth was found at round 5, see its own block below)', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-r4m1-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
const D2 = [['MSD.02-01-one', true], ['MSD.02-02-two', false]];
|
||
|
||
function poisonTotalPhasesR4M1() {
|
||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||
const raw = fs.readFileSync(statePath, 'utf-8');
|
||
fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8');
|
||
}
|
||
|
||
test('RED (F10): a fenced SAME-id PHASE heading in the preamble no longer inflates roadmapPhaseCount — 2/1/50, not 3/1/33', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
Authoring guide:
|
||
|
||
\`\`\`markdown
|
||
### [MSD.02] 05: Example phase
|
||
\`\`\`
|
||
|
||
## [MSD.02] v2.0: Foundation
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', D2);
|
||
assert.equal(readTotal(), 2, 'pinned 3 before this fix — the fenced example phase heading was counted by the fence-blind raw .exec()');
|
||
poisonTotalPhasesR4M1();
|
||
assert.equal(syncedTotal(), 2);
|
||
assert.equal(syncedPercent(), 50, 'pinned 33 before this fix');
|
||
});
|
||
|
||
test('PIN (F10 LEGACY control): the same fenced-phase shape on a non-bracket repo is unchanged (correct on every build)', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
Authoring guide:
|
||
|
||
\`\`\`markdown
|
||
### Phase 05: Example phase
|
||
\`\`\`
|
||
|
||
## Milestone v2.0: Foundation
|
||
|
||
### Phase 01: One
|
||
**Goal:** a
|
||
|
||
### Phase 02: Two
|
||
**Goal:** b
|
||
`, undefined, [['01-one', true], ['02-two', false]]);
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
|
||
test('PIN (F10c): same document, fenced line is NOT phase-shaped — unaffected either way', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
Authoring guide:
|
||
|
||
\`\`\`markdown
|
||
### [MSD.02] Example section
|
||
\`\`\`
|
||
|
||
## [MSD.02] v2.0: Foundation
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', D2);
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
|
||
test('RED (F9): version-LESS roadmap + a fenced example sharing the SAME milestone id — both the selector and the counter must be fence-aware — 2/1/50, not 3/1/33', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
Authoring guide:
|
||
|
||
\`\`\`markdown
|
||
## [MSD.02] Example milestone heading
|
||
### [MSD.02] 05: Example phase
|
||
\`\`\`
|
||
|
||
## [MSD.02] Foundation
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', D2);
|
||
assert.equal(readTotal(), 2, 'pinned 3 before this fix');
|
||
poisonTotalPhasesR4M1();
|
||
assert.equal(syncedTotal(), 2);
|
||
// The roadmap is version-less while STATE pins v2.0, so upstream #3217
|
||
// withholds the percentage rather than treating the scope as complete.
|
||
assert.match(syncSkipReason(), /scope is "unscoped", not COMPLETE \(#3217\)/);
|
||
});
|
||
|
||
test('RED (F12): a fenced-ONLY bracket heading no longer bounds a milestone absent from the roadmap — percent stays suppressed', () => {
|
||
const dirs = [['MSD.01-01-old', true], ['MSD.02-01-one', true], ['MSD.02-02-two', false]];
|
||
writeProject(`# Roadmap
|
||
|
||
Authoring guide:
|
||
|
||
\`\`\`markdown
|
||
## [MSD.02] Example milestone heading
|
||
\`\`\`
|
||
|
||
## [MSD.01] v1.0: Prior
|
||
|
||
### [MSD.01] 01: Old
|
||
**Goal:** a
|
||
`, 'bracket', dirs);
|
||
const r = runMsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(r.success, `state json failed: ${r.error}`);
|
||
const progress = JSON.parse(r.output).progress;
|
||
assert.equal(progress?.percent, undefined, 'pinned 67 before this fix — a fenced-only [MSD.02] example wrongly bounded a milestone with no real section');
|
||
// state sync must not persist a percent either — the body stays at its seed.
|
||
const syncResult = runMsdTools(['state', 'sync'], tmpDir);
|
||
assert.ok(syncResult.success);
|
||
const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.match(raw, /\*\*Progress:\*\*[^\r\n]*?0%/, 'pinned 67% persisted before this fix');
|
||
});
|
||
|
||
test('PIN: unfenced bracket-fallback selection is byte-identical — first real milestone-shaped heading still wins', () => {
|
||
// No version anywhere, no fences — the selector's plain first-match-wins
|
||
// behavior over REAL headings must be completely unaffected by routing it
|
||
// through tokenizeHeadings.
|
||
const dirs = ['MSD.01-01-old', 'MSD.02-01-one', 'MSD.02-02-two', 'MSD.03-01-later'];
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.01] Prior Milestone
|
||
|
||
### [MSD.01] 01: Old
|
||
**Goal:** a
|
||
|
||
## [MSD.02] Current Milestone
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** c
|
||
|
||
## [MSD.03] Later Milestone
|
||
|
||
### [MSD.03] 01: Later
|
||
**Goal:** d
|
||
`, 'bracket', dirs);
|
||
const rp = require('../msd-core/bin/lib/roadmap-parser.cjs');
|
||
const f = rp.getMilestonePhaseFilter(tmpDir);
|
||
assert.deepEqual(Object.fromEntries(dirs.map((d) => [d, !!f(d)])), {
|
||
'MSD.01-01-old': false,
|
||
'MSD.02-01-one': true,
|
||
'MSD.02-02-two': true,
|
||
'MSD.03-01-later': false,
|
||
});
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
});
|
||
|
||
// ─── #2761 round-5 Blocker 1: 3be5c412's merge dropped the `bracketId &&` ───
|
||
// ─── guard countRoadmapPhaseHeadings' bracket branch needs ──────────────────
|
||
//
|
||
// Every other isSentinelPhaseId call site in src/ (roadmap-parser.cts,
|
||
// roadmap.cts, validate.cts ×2, verify.cts) guards the call with
|
||
// `bracketId &&`. The shared counter's bracket branch omitted it: when the
|
||
// LEGACY alternative of the intro grammar matches (a `### Phase 00:` heading
|
||
// in a `phase_id_convention: "bracket"` repo — the mid-migration shape this
|
||
// PR exists for), `m[1]` (bracketId) is `undefined`, and the call becomes
|
||
// `isSentinelPhaseId("undefined-00", 'bracket')` — which is TRUE, silently
|
||
// dropping a real phase from the denominator. `getMilestonePhaseFilter`
|
||
// still counts it and admits its directory, so the filter and the counter
|
||
// disagree — a half-done milestone reads as 100% complete.
|
||
describe('#612 PR-2 round-5 Blocker 1: countRoadmapPhaseHeadings restores the bracketId guard', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-r5b1-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
function poisonTotalPhasesR5(dir) {
|
||
const statePath = path.join(dir, '.planning', 'STATE.md');
|
||
const raw = fs.readFileSync(statePath, 'utf-8');
|
||
fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8');
|
||
}
|
||
|
||
test('RED (G3): a legacy `### Phase 00:` heading in a bracket repo is no longer dropped — 3/2/67, not 2/2/100', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## Milestone v2.0: Foundation
|
||
|
||
### Phase 00: Bootstrap
|
||
**Goal:** z
|
||
|
||
### Phase 01: One
|
||
**Goal:** a
|
||
|
||
### Phase 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', [['00-bootstrap', true], ['01-one', true]]);
|
||
assert.equal(readTotal(), 3, 'pinned 2 before this fix — "undefined-00" read as a sentinel');
|
||
poisonTotalPhasesR5(tmpDir);
|
||
assert.equal(syncedTotal(), 3);
|
||
assert.equal(syncedPercent(), 67, 'pinned 100 before this fix — a half-done milestone read as shipped');
|
||
});
|
||
|
||
test('PIN (G3 LEGACY control): the identical document under the legacy convention is unaffected', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## Milestone v2.0: Foundation
|
||
|
||
### Phase 00: Bootstrap
|
||
**Goal:** z
|
||
|
||
### Phase 01: One
|
||
**Goal:** a
|
||
|
||
### Phase 02: Two
|
||
**Goal:** b
|
||
`, undefined, [['00-bootstrap', true], ['01-one', true]]);
|
||
// Upstream #3185 canonically treats legacy 00 as a sentinel; the bracket
|
||
// branch deliberately remains narrower, which is the distinction above.
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
|
||
test('RED (G3d, mixed mid-migration): bracket headings PLUS one legacy `### Phase 00:` — 3/2/67, not 2/2/100', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] v2.0: Foundation
|
||
|
||
### Phase 00: Bootstrap
|
||
**Goal:** z
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', [['00-bootstrap', true], ['MSD.02-01-one', true]]);
|
||
assert.equal(readTotal(), 3, 'pinned 2 before this fix');
|
||
poisonTotalPhasesR5(tmpDir);
|
||
assert.equal(syncedTotal(), 3);
|
||
assert.equal(syncedPercent(), 67, 'pinned 100 before this fix');
|
||
});
|
||
|
||
test('PIN (G3b, isolates the counter): a `### Phase 000:` heading with no directory — total still 3, no dir to admit', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## Milestone v2.0: Foundation
|
||
|
||
### Phase 000: Bootstrap
|
||
**Goal:** z
|
||
|
||
### Phase 01: One
|
||
**Goal:** a
|
||
|
||
### Phase 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', [['01-one', true], ['02-two', false]]);
|
||
assert.equal(readTotal(), 3, 'pinned 2 before this fix');
|
||
});
|
||
|
||
test('PIN (G3c control): legacy `### Phase 01:`/`02:` only, no sentinel-shaped token — unaffected', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## Milestone v2.0: Foundation
|
||
|
||
### Phase 01: One
|
||
**Goal:** a
|
||
|
||
### Phase 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', [['01-one', true], ['02-two', false]]);
|
||
assert.equal(readTotal(), 2);
|
||
});
|
||
});
|
||
|
||
// ─── #2761 round-5 Major 1: extractRetiredPhaseNumbers is a FIFTH ───────────
|
||
// ─── fence-blind site on the bracket path ───────────────────────────────────
|
||
//
|
||
// The retirement gesture's own line scan iterates `scope.split(/\r?\n/)`
|
||
// with no fence awareness. Once the bracket alternative is compiled into
|
||
// `introSrc` (this PR's own change), a FENCED authoring EXAMPLE showing the
|
||
// #1514 retirement gesture in bracket spelling is indistinguishable from a
|
||
// real one — it retires a genuine phase, shrinking the denominator and
|
||
// jumping the percent to a confident 100%.
|
||
describe('#612 PR-2 round-5 Major 1: extractRetiredPhaseNumbers is fence-aware on the bracket path', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-r5m1-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
const D2 = [['MSD.02-01-one', true], ['MSD.02-02-two', false]];
|
||
|
||
test('RED (G2): a fenced retired-strikethrough bracket bullet in the preamble no longer retires a real phase — 2/1/50, not 1/1/100', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
Retiring a phase looks like this:
|
||
|
||
\`\`\`markdown
|
||
- [x] ~~**[MSD.02] 02: Two**~~ — folded into 03; number retired
|
||
\`\`\`
|
||
|
||
## [MSD.02] v2.0: Foundation
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', D2);
|
||
assert.equal(readTotal(), 2, 'pinned 1 before this fix — the fenced EXAMPLE retirement gesture wrongly retired phase 02');
|
||
});
|
||
|
||
test('RED (G2b): the same fenced retirement example placed INSIDE the milestone section — not a preamble-scoping artifact', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] v2.0: Foundation
|
||
|
||
Retiring a phase looks like this:
|
||
|
||
\`\`\`markdown
|
||
- [x] ~~**[MSD.02] 02: Two**~~ — folded; number retired
|
||
\`\`\`
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', D2);
|
||
assert.equal(readTotal(), 2, 'pinned 1 before this fix');
|
||
});
|
||
|
||
test('PIN (G2 LEGACY control): the same fenced-example shape on a non-bracket repo is unchanged (pre-existing, out of scope)', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
Retiring a phase looks like this:
|
||
|
||
\`\`\`markdown
|
||
- [x] ~~**Phase 02: Two**~~ — folded into 03; number retired
|
||
\`\`\`
|
||
|
||
## Milestone v2.0: Foundation
|
||
|
||
### Phase 01: One
|
||
**Goal:** a
|
||
|
||
### Phase 02: Two
|
||
**Goal:** b
|
||
`, undefined, [['01-one', true], ['02-two', false]]);
|
||
assert.equal(readTotal(), 1, 'pre-existing legacy hazard — deliberately unchanged, base is wrong here too');
|
||
});
|
||
});
|
||
|
||
// ─── #2761 round-5 Major 2: state sync's own counter now excludes the ──────
|
||
// ─── bare `999` icebox token like the other two derivations ─────────────────
|
||
//
|
||
// Under bracket, READING-B puts the sentinel in the BRACKET
|
||
// (isSentinelPhaseId), so `/^999\b/` on the bare TOKEN is the only thing
|
||
// excluding a `### [MSD.02] 999:` icebox heading — and it ran on the read
|
||
// path (buildStateFrontmatter) and getMilestonePhaseFilter, but not on
|
||
// cmdStateSync's own counter. One `state sync` call could leave a single
|
||
// STATE.md with its frontmatter (percent 50, from the read-path re-sync) and
|
||
// its body (percent 33, from the write-path counter that still counted the
|
||
// icebox heading) disagreeing.
|
||
describe('#612 PR-2 round-5 Major 2: state sync excludes the bracket 999 icebox token like the read path', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-r5m2-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('RED (G1): state sync\'s body percent now agrees with state json\'s percent on a bracket 999 icebox heading', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] v2.0: Foundation
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 999: Backlog item
|
||
**Goal:** later
|
||
`, 'bracket', [['MSD.02-01-one', true], ['MSD.02-02-two', false]]);
|
||
const readPercent = (() => {
|
||
const r = runMsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(r.success, `state json failed: ${r.error}`);
|
||
return JSON.parse(r.output).progress?.percent;
|
||
})();
|
||
assert.equal(readPercent, 50);
|
||
assert.equal(syncedPercent(), readPercent, 'pinned 33 (vs read-path 50) before this fix — one STATE.md, two disagreeing numbers');
|
||
});
|
||
|
||
test('PIN (G1 LEGACY control): the pre-existing legacy read/write divergence on the same shape is unchanged', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## Milestone v2.0: Foundation
|
||
|
||
### Phase 01: One
|
||
**Goal:** a
|
||
|
||
### Phase 02: Two
|
||
**Goal:** b
|
||
|
||
### Phase 999: Backlog item
|
||
**Goal:** later
|
||
`, undefined, [['01-one', true], ['02-two', false]]);
|
||
const r = runMsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(r.success, `state json failed: ${r.error}`);
|
||
assert.equal(JSON.parse(r.output).progress?.percent, 50);
|
||
assert.equal(syncedPercent(), 33, 'the legacy asymmetry is genuinely pre-existing — deliberately unchanged');
|
||
});
|
||
});
|
||
|
||
// ─── #2761 round-5 Minor 1: the two tokenizeHeadings reconstructions ───────
|
||
// ─── (bracket-fallback selector, isMilestoneBounded) accept indented ───────
|
||
// ─── headings their raw line-start-anchored predecessors never did ─────────
|
||
//
|
||
// `HeadingToken.offset` is `tokenizeHeadings`' LINE-START offset. For a
|
||
// ≤3-space-indented heading that is NOT the `#` character's own offset, so
|
||
// a token the raw `^#{1,3}\s+\[...` regex never matched (indentation moves
|
||
// it off the line-start anchor) was still accepted by the reconstruction,
|
||
// which then mis-parses (selectedBracketId null, a sibling milestone's
|
||
// phases leaking into the scope).
|
||
describe('#612 PR-2 round-5 Minor 1: bracket-fallback selector skips indented headings (raw parity)', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-r5min1-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('RED (G6): a 2-space-indented, version-less bracket milestone heading no longer leaks the NEXT milestone\'s phases in — total 2, not 3', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] Foundation
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
|
||
## [MSD.03] Later
|
||
|
||
### [MSD.03] 01: Later one
|
||
**Goal:** c
|
||
`, 'bracket', [['MSD.02-01-one', true], ['MSD.02-02-two', false], ['MSD.03-01-later-one', true]]);
|
||
assert.equal(readTotal(), 2, 'pinned 3 before this fix — MSD.03\'s phase leaked into scope');
|
||
assert.match(syncSkipReason(), /scope is "unscoped", not COMPLETE \(#3217\)/,
|
||
'upstream must withhold the unsafe version-less percentage');
|
||
});
|
||
|
||
test('PIN (G6c UNINDENTED control): the identical document with no leading indent is unaffected — total 2', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] Foundation
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
|
||
## [MSD.03] Later
|
||
|
||
### [MSD.03] 01: Later one
|
||
**Goal:** c
|
||
`, 'bracket', [['MSD.02-01-one', true], ['MSD.02-02-two', false], ['MSD.03-01-later-one', true]]);
|
||
assert.equal(readTotal(), 2);
|
||
assert.match(syncSkipReason(), /scope is "unscoped", not COMPLETE \(#3217\)/);
|
||
});
|
||
|
||
// G6 above happens to leave isMilestoneBounded's own verdict unchanged
|
||
// either way — the phase headings (`### [MSD.02] 01: One`, unindented)
|
||
// already satisfy its loose bracket-prefix regex, so it returns bounded=true
|
||
// both before and after this fix on that fixture. This second fixture
|
||
// isolates isMilestoneBounded specifically: the ONLY `[MSD.02]`-shaped
|
||
// heading anywhere in the document is indented, mirroring round-4's F12
|
||
// (fenced-ONLY) shape but with indentation as the parity gap instead of a
|
||
// fence.
|
||
test('RED (isMilestoneBounded site, indented-ONLY): an indented-only bracket heading no longer bounds a milestone absent from the roadmap — percent stays suppressed', () => {
|
||
const dirs = [['MSD.01-01-old', true], ['MSD.02-01-one', true], ['MSD.02-02-two', false]];
|
||
writeProject(`# Roadmap
|
||
|
||
Authoring guide:
|
||
|
||
## [MSD.02] Example milestone heading
|
||
|
||
## [MSD.01] v1.0: Prior
|
||
|
||
### [MSD.01] 01: Old
|
||
**Goal:** a
|
||
`, 'bracket', dirs);
|
||
const r = runMsdTools(['state', 'json'], tmpDir);
|
||
assert.ok(r.success, `state json failed: ${r.error}`);
|
||
const progress = JSON.parse(r.output).progress;
|
||
assert.equal(progress?.percent, undefined, 'pinned 100 before this fix — an indented-only [MSD.02] example wrongly bounded a milestone with no real section');
|
||
const syncResult = runMsdTools(['state', 'sync'], tmpDir);
|
||
assert.ok(syncResult.success);
|
||
const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
assert.match(raw, /\*\*Progress:\*\*[^\r\n]*?0%/, 'pinned 100% persisted before this fix; body must stay at its seed');
|
||
});
|
||
});
|
||
|
||
// ─── #2761 round-6 Blocker 1: countRoadmapPhaseHeadings' bracket-only ──────
|
||
// ─── `/^0\b/` sibling rule needs the SAME `bracketId &&` guard round-5's ───
|
||
// ─── Blocker 1 restored two lines above it ──────────────────────────────────
|
||
//
|
||
// Round-5's Blocker 1 was `isSentinelPhaseId("undefined-00")`. This is the
|
||
// identical failure one line down: when the phase-heading grammar's LEGACY
|
||
// alternative matches (`### Phase 0:` in a `phase_id_convention: "bracket"`
|
||
// repo — the mid-migration shape this PR exists for), `bracketId` is
|
||
// `undefined`, and the unguarded `/^0\b/` fires on the bare token anyway.
|
||
// Neither the LEGACY branch of this same function nor
|
||
// `getMilestonePhaseFilter` has a `/^0\b/` rule at all, so the filter counts
|
||
// the phase and admits its completed directory while the counter refuses to
|
||
// count its heading — a milestone with an unstarted phase 02 persists as a
|
||
// confident 100%. `/^0\b/` matches `0` and `0.5` (word boundary before the
|
||
// dot) but not `00` (no boundary between the two zeros) — which is exactly
|
||
// why round-5's G3/G3d fixtures (both spelled `Phase 00:`) never tripped
|
||
// this one.
|
||
describe('#612 PR-2 round-6 Blocker 1: countRoadmapPhaseHeadings\' bracket-only /^0\\b/ rule restores the bracketId guard', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-r6b1-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
function poisonTotalPhasesR6(dir) {
|
||
const statePath = path.join(dir, '.planning', 'STATE.md');
|
||
const raw = fs.readFileSync(statePath, 'utf-8');
|
||
fs.writeFileSync(statePath, raw.replace(/^---\r?\n/, '---\ntotal_phases: 999\n'), 'utf-8');
|
||
}
|
||
|
||
test('RED (T0): a legacy `### Phase 0:` heading in a bracket repo is no longer dropped — 3/2/67, not 2/2/100', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## Milestone v2.0: Foundation
|
||
|
||
### Phase 0: Bootstrap
|
||
**Goal:** z
|
||
|
||
### Phase 01: One
|
||
**Goal:** a
|
||
|
||
### Phase 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', [['0-bootstrap', true], ['01-one', true]]);
|
||
assert.equal(readTotal(), 3, 'pinned 2 before this fix — "0" read as a sentinel with no bracketId guard');
|
||
poisonTotalPhasesR6(tmpDir);
|
||
assert.equal(syncedTotal(), 3);
|
||
assert.equal(syncedPercent(), 67, 'pinned 100 before this fix — a milestone with an unstarted phase 02 read as shipped');
|
||
});
|
||
|
||
test('PIN (T0L LEGACY control): the identical document follows upstream legacy sentinel semantics', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## Milestone v2.0: Foundation
|
||
|
||
### Phase 0: Bootstrap
|
||
**Goal:** z
|
||
|
||
### Phase 01: One
|
||
**Goal:** a
|
||
|
||
### Phase 02: Two
|
||
**Goal:** b
|
||
`, undefined, [['0-bootstrap', true], ['01-one', true]]);
|
||
assert.equal(readTotal(), 2, 'upstream #3185 canonically excludes legacy phase 0');
|
||
});
|
||
|
||
test('RED (T05): a legacy `### Phase 0.5:` heading in a bracket repo — same defect, second spelling — 3/2/67, not 2/2/100', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## Milestone v2.0: Foundation
|
||
|
||
### Phase 0.5: Bootstrap
|
||
**Goal:** z
|
||
|
||
### Phase 01: One
|
||
**Goal:** a
|
||
|
||
### Phase 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', [['0.5-bootstrap', true], ['01-one', true]]);
|
||
assert.equal(readTotal(), 3, 'pinned 2 before this fix');
|
||
poisonTotalPhasesR6(tmpDir);
|
||
assert.equal(syncedTotal(), 3);
|
||
assert.equal(syncedPercent(), 67, 'pinned 100 before this fix');
|
||
});
|
||
|
||
test('PIN (T05L LEGACY control): the identical document follows upstream legacy sentinel semantics', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## Milestone v2.0: Foundation
|
||
|
||
### Phase 0.5: Bootstrap
|
||
**Goal:** z
|
||
|
||
### Phase 01: One
|
||
**Goal:** a
|
||
|
||
### Phase 02: Two
|
||
**Goal:** b
|
||
`, undefined, [['0.5-bootstrap', true], ['01-one', true]]);
|
||
assert.equal(readTotal(), 2, 'upstream #3185 canonically excludes legacy phase 0.5');
|
||
});
|
||
|
||
test('PIN (B0, bracket-spelled `0` token — Minor 1, NOT fixed this round): counter/filter disagreement is base-parity, deliberately unchanged', () => {
|
||
// `### [MSD.02] 0: Bootstrap` — the SAME token shape, spelled in bracket
|
||
// form rather than legacy form. `isSentinelPhaseId('MSD.02-0','bracket')`
|
||
// is false (READING-B puts the sentinel in the bracket, not the token),
|
||
// so `/^0\b/` is the only thing that could exclude it — and per round-6's
|
||
// review this shape reads 2/2/100 on base AND HEAD (never closed by any
|
||
// build in this arc), so it is a pre-existing gap, not a regression.
|
||
// Disclosed in the changeset; pinned here as a base-parity characterization.
|
||
writeProject(`# Roadmap
|
||
|
||
## [MSD.02] v2.0: Foundation
|
||
|
||
### [MSD.02] 0: Bootstrap
|
||
**Goal:** z
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
`, 'bracket', [['MSD.02-0-bootstrap', true], ['MSD.02-01-one', true]]);
|
||
assert.equal(readTotal(), 2, 'base-parity: this shape has never been closed by any build in this arc');
|
||
});
|
||
});
|
||
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
// #2761 round-11 BLOCKER (trek-e review): `listMilestonePhaseDirs`'s third
|
||
// param (`phaseIdConvention`) lost its `= null` default so a caller that
|
||
// omits it now gets "resolve from config" instead of "explicitly not
|
||
// bracket" — a deliberate flip, but the changeset claimed "the archival and
|
||
// milestone-completion paths are unchanged." False: `cmdMilestoneComplete`
|
||
// (src/milestone.cts) and `cmdStateUpdateProgress` (src/state.cts) both call
|
||
// the enumerator and neither file was in the PR-2 diff. Both now thread the
|
||
// convention explicitly (resolved once, ambiently, off `cwd`) instead of
|
||
// silently inheriting the lazy default. These two blocks PIN the enumerated
|
||
// set / write behavior on a bracket project so a future regression to a
|
||
// hardcoded (non-bracket) default — or to a different resolve-from-config
|
||
// answer — is a failing test, not a silent set change on a command that
|
||
// renames/archives directories or writes STATE.md.
|
||
// ═════════════════════════════════════════════════════════════════════════
|
||
|
||
describe('#2761 round-11 BLOCKER: cmdMilestoneComplete enumerates the bracket-scoped set', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-milestone-complete-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('PIN: --dry-run would_archive.phases is exactly the 3 bracket-declared phase dirs, not the whole phases/ directory', () => {
|
||
// BRACKET_ROADMAP (above) declares milestone v2.0 with phases 01, 05, 06
|
||
// under the [MSD.02] bracket tag. One directory per declared phase, PLUS
|
||
// a decoy that belongs to NO declared phase. Correct (convention-threaded)
|
||
// scoping excludes the decoy. A regression back to the pre-#612 hardcoded
|
||
// "not bracket" default would fail to recognize the bracket HEADINGS at
|
||
// all, `milestonePhaseNums` would come back empty, and
|
||
// `getMilestonePhaseFilter` degrades to a pass-all predicate — which
|
||
// would sweep the decoy in too. Either failure mode shows up as a
|
||
// mismatch against the pinned set below.
|
||
writeProject(BRACKET_ROADMAP, 'bracket', [
|
||
'MSD.02-01-setup',
|
||
'MSD.02-05-real-work',
|
||
'MSD.02-06-follow-up',
|
||
// Incomplete: the decoy carries no phase token, so it cannot be named
|
||
// by verificationNameFor's production-derived scheme (see its
|
||
// docstring) — and completeness is irrelevant to what this test pins,
|
||
// which is exclusion from would_archive.phases regardless.
|
||
['not-a-declared-phase', false],
|
||
]);
|
||
|
||
const r = runMsdTools(['milestone', 'complete', 'v2.0', '--dry-run', '--raw'], tmpDir);
|
||
assert.ok(r.success, `milestone complete --dry-run failed: ${r.error}`);
|
||
const parsed = JSON.parse(r.output);
|
||
|
||
assert.deepStrictEqual(
|
||
[...parsed.would_archive.phases].sort(),
|
||
['MSD.02-01-setup', 'MSD.02-05-real-work', 'MSD.02-06-follow-up'],
|
||
'would_archive.phases must be exactly the 3 declared bracket phases — never the decoy, ' +
|
||
'and never empty (the pre-#612 hardcoded-legacy failure mode)',
|
||
);
|
||
assert.strictEqual(parsed.stats.phases, 3, 'the read-only stats loop shares the same single enumeration');
|
||
});
|
||
|
||
test('CONTROL: the identical shape under the legacy (non-bracket) convention is unaffected', () => {
|
||
// Same directory/decoy shape, legacy headings instead of bracket ones —
|
||
// this is the "byte-identical for null/milestone-prefixed projects"
|
||
// guarantee the changeset makes; pinned here at the cmdMilestoneComplete
|
||
// consumer, not just at the enumerator's own unit level.
|
||
writeProject(`# Roadmap
|
||
|
||
## v2.0
|
||
|
||
### Phase 01: Setup
|
||
**Goal:** a
|
||
|
||
### Phase 05: Real work
|
||
**Goal:** b
|
||
|
||
### Phase 06: Follow-up
|
||
**Goal:** c
|
||
`, undefined, ['01-setup', '05-real-work', '06-follow-up', ['not-a-declared-phase', false]]);
|
||
|
||
const r = runMsdTools(['milestone', 'complete', 'v2.0', '--dry-run', '--raw'], tmpDir);
|
||
assert.ok(r.success, `milestone complete --dry-run failed: ${r.error}`);
|
||
const parsed = JSON.parse(r.output);
|
||
|
||
assert.deepStrictEqual(
|
||
[...parsed.would_archive.phases].sort(),
|
||
['01-setup', '05-real-work', '06-follow-up'],
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('#2761 round-11 BLOCKER: cmdStateUpdateProgress computes+writes a percent on bracket projects', () => {
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-update-progress-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
test('PIN: `state update-progress` writes a real (non-withheld) percent for a bracket-scoped milestone', () => {
|
||
// One fully-verified phase (01) and one plan-only phase (05) under the
|
||
// [MSD.02] v2.0 bracket milestone — mirrors writeProject's own
|
||
// PLAN+SUMMARY+VERIFICATION shape for "complete" entries. Round-11 named
|
||
// this exact command: "cmdStateUpdateProgress ... now computes/writes a
|
||
// completion percent on bracket projects where it previously withheld
|
||
// one." Pinning `updated: true` plus the exact percent/completed/total
|
||
// triple is what makes a future regression to the withheld shape (or to
|
||
// silently mis-scoped counts) a failing assertion instead of an
|
||
// unobserved behavior change.
|
||
writeProject(BRACKET_ROADMAP, 'bracket', [
|
||
['MSD.02-01-setup', true],
|
||
['MSD.02-05-real-work', false],
|
||
]);
|
||
|
||
const r = runMsdTools(['state', 'update-progress'], tmpDir);
|
||
assert.ok(r.success, `state update-progress failed: ${r.error}`);
|
||
const parsed = JSON.parse(r.output);
|
||
|
||
assert.strictEqual(parsed.updated, true,
|
||
'a bracket project with a resolvable milestone window must not withhold — got: ' + JSON.stringify(parsed));
|
||
assert.strictEqual(parsed.completed, 1);
|
||
assert.strictEqual(parsed.total, 2);
|
||
|
||
const raw = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||
// eslint-disable-next-line local/no-unbounded-quantifier -- parses the STATE.md this test just wrote, fixed-size fixture output, not adversarial input
|
||
const m = raw.match(/^\*\*Progress:\*\*[^\r\n]*?(\d+)%/m);
|
||
assert.ok(m, `state update-progress must have rendered a Progress percent into STATE.md; got:\n${raw}`);
|
||
assert.strictEqual(parseInt(m[1], 10), parsed.percent,
|
||
'the percent update-progress reports and the one it writes into the body must agree');
|
||
});
|
||
|
||
test('#2761 round-12 GAP: the enumerator .value THIS call site feeds (not the separately-scoped ' +
|
||
'percent) gates the #3233 zero-plans no-op — a decoy outside the bracket window must not count', () => {
|
||
// Round-11's PIN test above (and its own inline comment at state.cts
|
||
// ~:965) established that `cmdStateUpdateProgress`'s reported/written
|
||
// percent is IMMUNE to this call site's convention threading — it comes
|
||
// from computeUpdateProgressPreview -> buildStateFrontmatter, a separate,
|
||
// independently-scoped derivation. Mutation testing on the round-11
|
||
// response confirmed that empirically: reverting this call site's thread
|
||
// left the PIN test above still green.
|
||
//
|
||
// What THIS call site's enumerated `.value` actually feeds is
|
||
// `totalPlans` (summed across the enumerated dirs, just below) and the
|
||
// #3233 zero-plans no-op gate built on it — the ONLY place that value has
|
||
// any observable effect on this command's output. This fixture pins
|
||
// exactly that gate: BRACKET_ROADMAP declares milestone v2.0's phases
|
||
// (01/05/06 under [MSD.02]) but none of their directories are scaffolded
|
||
// on disk — zero plans exist for this milestone's real window either way.
|
||
// A directory that plainly does not belong to it ('not-a-declared-phase':
|
||
// no bracket tag, no phase token at all) sits alongside it WITH a plan.
|
||
//
|
||
// Correctly bracket-scoped, the decoy is excluded (it matches no
|
||
// MSD.02-qualified id) and totalPlans stays 0 -> the #3233 no-op fires.
|
||
// Degraded to the pass-all legacy reading (BRACKET_ROADMAP's headings are
|
||
// invisible under a non-bracket read — proven above by "a NON-bracket
|
||
// repo counts neither form of the same roadmap" — so
|
||
// `milestonePhaseNums` comes back empty and the filter admits
|
||
// everything), the decoy is swept in and totalPlans flips nonzero -> the
|
||
// no-op never fires and the command proceeds past it.
|
||
writeProject(BRACKET_ROADMAP, 'bracket', [['not-a-declared-phase', false]]);
|
||
|
||
const r = runMsdTools(['state', 'update-progress'], tmpDir);
|
||
assert.ok(r.success, `state update-progress failed: ${r.error}`);
|
||
const parsed = JSON.parse(r.output);
|
||
|
||
assert.strictEqual(parsed.updated, false,
|
||
'a decoy directory outside the bracket-declared milestone window must never contribute to ' +
|
||
'totalPlans for that milestone — got: ' + JSON.stringify(parsed));
|
||
assert.match(String(parsed.reason ?? ''), /no plans found/,
|
||
'must be the #3233 zero-plans no-op specifically (proves the enumerator excluded the decoy), ' +
|
||
'not some other withhold reason — got: ' + JSON.stringify(parsed));
|
||
});
|
||
|
||
test('CONTROL: the identical shape under the legacy (non-bracket) convention behaves the same way', () => {
|
||
writeProject(`# Roadmap
|
||
|
||
## v2.0
|
||
|
||
### Phase 01: Setup
|
||
**Goal:** a
|
||
|
||
### Phase 05: Real work
|
||
**Goal:** b
|
||
`, undefined, [
|
||
['01-setup', true],
|
||
['05-real-work', false],
|
||
]);
|
||
|
||
const r = runMsdTools(['state', 'update-progress'], tmpDir);
|
||
assert.ok(r.success, `state update-progress failed: ${r.error}`);
|
||
const parsed = JSON.parse(r.output);
|
||
|
||
assert.strictEqual(parsed.updated, true);
|
||
assert.strictEqual(parsed.completed, 1);
|
||
assert.strictEqual(parsed.total, 2);
|
||
});
|
||
});
|
||
|
||
describe('#612 PR-2: a cross-phase stray in a bracket dir is excluded — the #3511 seam is convention-aware', () => {
|
||
// Pins the seam fix ITSELF, independently of the phase-matched fixture
|
||
// names verificationNameFor now writes. With phase-matched names
|
||
// everywhere, an inert (include-everything) bracket seam and a scoped one
|
||
// are indistinguishable — every earlier assertion in this file would pass
|
||
// under both. The genuine stray written here is the one shape that tells
|
||
// them apart, so the ALERT'd bracket-inertness (#3511's `isPhaseArtifact`
|
||
// fail-safes swallowing every bracket dir) cannot silently return.
|
||
const phaseIdMod = require('../msd-core/bin/lib/phase-id.cjs');
|
||
|
||
beforeEach(() => { tmpDir = createTempProject('adr-612-stray-'); });
|
||
afterEach(() => { cleanup(tmpDir); });
|
||
|
||
const STRAY_ROADMAP_BRACKET = `# Roadmap
|
||
|
||
## [MSD.02] v2.0: Current
|
||
|
||
### [MSD.02] 01: One
|
||
**Goal:** a
|
||
|
||
### [MSD.02] 02: Two
|
||
**Goal:** b
|
||
|
||
### [MSD.02] 03: Three
|
||
**Goal:** c
|
||
`;
|
||
const STRAY_ROADMAP_LEGACY = `# Roadmap
|
||
|
||
## v2.0: Current
|
||
|
||
### Phase 01: One
|
||
**Goal:** a
|
||
|
||
### Phase 02: Two
|
||
**Goal:** b
|
||
|
||
### Phase 03: Three
|
||
**Goal:** c
|
||
`;
|
||
|
||
function plantStray(dir) {
|
||
// Another phase's PASSING report, misfiled into the INCOMPLETE phase 03's
|
||
// directory — exactly the hardcoded shape the pre-merge fixture wrote by
|
||
// accident. An inert seam folds it into phase 03's completion
|
||
// (3/3 -> 100); the scoped seam excludes it (2/3 -> 67).
|
||
fs.writeFileSync(
|
||
path.join(tmpDir, '.planning', 'phases', dir, '01-VERIFICATION.md'),
|
||
'---\nstatus: passed\n---\n# Verification\n', 'utf-8');
|
||
}
|
||
|
||
test('READ: a stray 01-VERIFICATION.md cannot complete bracket phase 03', () => {
|
||
writeProject(STRAY_ROADMAP_BRACKET, 'bracket',
|
||
['MSD.02-01-one', 'MSD.02-02-two', ['MSD.02-03-three', false]]);
|
||
plantStray('MSD.02-03-three');
|
||
assert.deepEqual(readProgress(), [3, 2, 3, 2, 67],
|
||
'pinned [3,3,3,2,67] before #612 threaded convention into the seam — the stray '
|
||
+ 'counted as phase 03\'s completion; percent is plan-derived, so percent alone '
|
||
+ 'cannot catch this: the full vector is the assertion');
|
||
});
|
||
|
||
test('READ: the legacy twin of the stray shape reads identically', () => {
|
||
writeProject(STRAY_ROADMAP_BRACKET, 'bracket',
|
||
['MSD.02-01-one', 'MSD.02-02-two', ['MSD.02-03-three', false]]);
|
||
plantStray('MSD.02-03-three');
|
||
const bracket = readProgress();
|
||
writeProject(STRAY_ROADMAP_LEGACY, undefined,
|
||
['01-one', '02-two', ['03-three', false]]);
|
||
plantStray('03-three');
|
||
const legacy = readProgress();
|
||
assert.deepEqual(bracket, legacy,
|
||
'bracket must exclude the stray exactly as its legacy twin does');
|
||
assert.deepEqual(legacy, [3, 2, 3, 2, 67]);
|
||
});
|
||
|
||
test('WRITE: sync agrees — the stray moves neither derivation', () => {
|
||
writeProject(STRAY_ROADMAP_BRACKET, 'bracket',
|
||
['MSD.02-01-one', 'MSD.02-02-two', ['MSD.02-03-three', false]]);
|
||
plantStray('MSD.02-03-three');
|
||
assert.equal(syncedPercent(), 67);
|
||
assert.deepEqual(syncedProgress(), [3, 2, 3, 2, 67],
|
||
'pinned [3,3,3,2,67] before this fix (completed_phases moved; the plan-derived percent did not)');
|
||
assert.deepEqual(readProgress(), syncedProgress());
|
||
});
|
||
|
||
test('SEAM (the ALERT repro): scopeToPhase scopes a 3+-letter-code bracket dir under convention', () => {
|
||
// 3+-letter codes (`MSD.02-…`) fell into the ZERO-TOKEN fail-safe —
|
||
// `PROJECT_CODE_PREFIX_CAPTURE_RE_I` strips `{CODE}-`, not `{CODE}.` —
|
||
// NOT the `firstLetterPrefixed` branch the pre-#612 docblock named for
|
||
// the bracket family. Both families are pinned here so a fixer patching
|
||
// one branch cannot green this by half.
|
||
const files = ['01-01-x-PLAN.md', '01-01-x-SUMMARY.md', '01-VERIFICATION.md'];
|
||
assert.deepEqual(phaseIdMod.scopeToPhase(files, '02-two'), [],
|
||
'legacy control: the twin scopes these to nothing');
|
||
assert.deepEqual(phaseIdMod.scopeToPhase(files, 'MSD.02-02-two', 'bracket'), [],
|
||
'pinned all-three-kept (scoping a structural no-op) before this fix');
|
||
assert.deepEqual(phaseIdMod.scopeToPhase(files, 'A1.02-02-two', 'bracket'), [],
|
||
'short digit-bearing codes hit the firstLetterPrefixed fail-safe pre-fix; same pin');
|
||
});
|
||
|
||
test('SEAM: a bracket dir keeps its own artifacts — dash, dot continuation, and token-less containment', () => {
|
||
const own = ['02-VERIFICATION.md', '02-01-PLAN.md', '02.1-CONTEXT.md', 'VERIFICATION.md'];
|
||
assert.deepEqual(phaseIdMod.scopeToPhase(own, 'MSD.02-02-two', 'bracket'), own,
|
||
'the scoped bracket dir must not drop its own files (over-exclusion is the defect class #3511 exists to fix)');
|
||
// Sub-phase dir: own dotted token matches exactly, its .10 sibling does not
|
||
// (mirror of the legacy 35.1/35.10 pin in tests/phase-id.test.cjs).
|
||
assert.equal(phaseIdMod.isPhaseArtifact('02.1-VERIFICATION.md', 'MSD.02-02.1-fix', 'bracket'), true);
|
||
assert.equal(phaseIdMod.isPhaseArtifact('02.10-VERIFICATION.md', 'MSD.02-02.1-fix', 'bracket'), false);
|
||
// Recognition is case-insensitive, like every bracket reader.
|
||
assert.equal(phaseIdMod.isPhaseArtifact('02-VERIFICATION.md', 'msd.02-02-two', 'bracket'), true);
|
||
});
|
||
|
||
test('SEAM: exact legacy-twin equality across a spread of filenames', () => {
|
||
const spread = ['01-VERIFICATION.md', '02-VERIFICATION.md', '2-VERIFICATION.md',
|
||
'02-CORRECTION-VERIFICATION.md', '02.1-CONTEXT.md', '02_VERIFICATION.md',
|
||
'03-UAT.md', 'VERIFICATION.md', 'notes.md'];
|
||
for (const f of spread) {
|
||
assert.equal(
|
||
phaseIdMod.isPhaseArtifact(f, 'MSD.02-02-two', 'bracket'),
|
||
phaseIdMod.isPhaseArtifact(f, '02-two'),
|
||
`bracket and legacy twins must agree on ${f}`);
|
||
}
|
||
});
|
||
|
||
test('SEAM: everything outside the well-formed-bracket-plus-convention gate is byte-unchanged', () => {
|
||
// No convention signal -> the documented include-everything fail-safe
|
||
// stands, even for a perfectly-formed bracket dir.
|
||
assert.deepEqual(phaseIdMod.scopeToPhase(['01-VERIFICATION.md'], 'MSD.02-02-two'),
|
||
['01-VERIFICATION.md'], 'convention-less callers keep pre-#612 behavior verbatim');
|
||
// Bracket-MALFORMED (1-digit milestone — the shape W005 reports) fails
|
||
// BRACKET_DIR_TOKEN_RE and degrades to the same fail-safe.
|
||
assert.deepEqual(phaseIdMod.scopeToPhase(['01-VERIFICATION.md'], 'MSD.2-02-two', 'bracket'),
|
||
['01-VERIFICATION.md'], 'malformed bracket dirs keep the include-everything degrade');
|
||
// A legacy-shaped dir in a bracket repo scopes by the LEGACY rule — the
|
||
// additive-read guarantee (the "carrying LEGACY-shaped dirs" test above,
|
||
// at the seam level).
|
||
assert.equal(phaseIdMod.isPhaseArtifact('01-VERIFICATION.md', '02-two', 'bracket'), false);
|
||
assert.equal(phaseIdMod.isPhaseArtifact('02-VERIFICATION.md', '02-two', 'bracket'), true);
|
||
// The letter-prefixed-decimal ambiguity (`P0.3-2` could be a legacy dir
|
||
// in a mid-migration bracket repo; its milestone field is not canonical
|
||
// bracket spelling) keeps upstream's pinned include-everything trade even
|
||
// WITH the convention threaded.
|
||
assert.equal(phaseIdMod.isPhaseArtifact('99-VERIFICATION.md', 'P0.3-2-slug', 'bracket'), true);
|
||
});
|
||
|
||
test('SEAM: qualified-stem filenames compare on the qualified key; M-NN stems ratify the twin-equal exclusion', () => {
|
||
// Review Major 2: the convention gates the FILENAME reading too.
|
||
assert.equal(phaseIdMod.isPhaseArtifact('MSD.02-05-VERIFICATION.md', 'MSD.02-05-real', 'bracket'), true);
|
||
assert.equal(phaseIdMod.isPhaseArtifact('MSD.02-01-VERIFICATION.md', 'MSD.02-05-real', 'bracket'), false,
|
||
'pinned true (token-less containment admitted it) before this fix');
|
||
assert.equal(phaseIdMod.isPhaseArtifact('MSD.01-05-VERIFICATION.md', 'MSD.02-05-real', 'bracket'), false,
|
||
'same phase digits, different milestone — the qualified key separates what no bare token can');
|
||
// Round-2 verify blocker: the qualified-key comparison must keep the
|
||
// dash-OR-dot continuation rule — a qualified dotted SUB-PHASE artifact is
|
||
// the parent phase's own file (see the DOTTED SUB-PHASE CONTINUATION
|
||
// docblock), exactly as its legacy twin (05.1-… in 05-parent) is. A strict
|
||
// equality here silently excluded it: over-exclusion, the dangerous
|
||
// direction for these aggregate scans.
|
||
assert.equal(phaseIdMod.isPhaseArtifact('MSD.02-05.1-VERIFICATION.md', 'MSD.02-05-parent', 'bracket'), true,
|
||
'pinned false (strict key equality dropped the dot arm) before the round-2 fix');
|
||
assert.equal(phaseIdMod.isPhaseArtifact('05.1-VERIFICATION.md', '05-parent'), true,
|
||
'the legacy-twin control for the line above');
|
||
assert.equal(phaseIdMod.isPhaseArtifact('MSD.02-01.1-VERIFICATION.md', 'MSD.02-05-parent', 'bracket'), false,
|
||
'a DIFFERENT phase\'s dotted sub-phase artifact is still a stray');
|
||
assert.equal(phaseIdMod.isPhaseArtifact('MSD.02-01-VERIFICATION.md', 'MSD.02-05-real'), true,
|
||
'convention-less: the include-everything fail-safe stands, byte-identical to pre-#612');
|
||
// Review Major 1, RATIFIED as the twin-equal trade: an M-NN-stem report in a
|
||
// bracket dir is excluded exactly as its legacy twin excludes the same file;
|
||
// migration-window artifact renaming is the migrator slice's job (ADR-612).
|
||
assert.equal(phaseIdMod.isPhaseArtifact('02-01-VERIFICATION.md', 'MSD.02-01-one', 'bracket'), false,
|
||
'twin-parity: isPhaseArtifact("02-01-VERIFICATION.md","01-one") is false for the same reason');
|
||
assert.equal(phaseIdMod.isPhaseArtifact('02-01-VERIFICATION.md', '01-one'), false,
|
||
'the legacy-twin control for the line above');
|
||
});
|
||
});
|