Files
msd-core/tests/phase-resolution-parity.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
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.
2026-10-06 01:47:40 +02:00

593 lines
26 KiB
JavaScript

'use strict';
/**
* phase-resolution-parity.test.cjs — #2528 resolution-path parity gate
*
* The phase-directory matching logic historically existed in three independent
* copies that had already diverged (different scan idioms, different ambiguity
* handling): the shared locator (`phase-locator.cjs :: searchPhaseInDir`, used
* by `findPhaseInternal` and the `init.*` queries), the `find-phase` command
* scan, and the `phase-plan-index` command scan. #2043/#2232 fixed the shared
* tokenizer, but any fix needing resolution-level context had to be applied
* per copy — which is how this bug class kept resurfacing (#2528 is the third
* instance).
*
* A FOURTH copy survived the first pass of that consolidation and was caught in
* review: `smart-entry.cjs :: detectVerifyFailed`, which resolves the current
* phase's directory to decide whether its verification failed. It is the worst
* of the four to get wrong — an unresolved directory reports "not failed",
* which is indistinguishable from a healthy phase, so the bug is silent by
* construction. Its absence from this gate is exactly why it was missed.
*
* The selection now delegates to one owner (`phase-id.cjs :: matchPhaseDirs`).
* This gate is the durable guard the #2528 triage asked for: for every corpus
* scenario, the four resolution paths MUST agree on the same directory for
* the same bare input — found, not-found, and ambiguous alike. It fails the
* moment any path re-implements selection and drifts.
*/
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 { findPhaseInternal } = require('../msd-core/bin/lib/phase-locator.cjs');
const { detectSignals } = require('../msd-core/bin/lib/smart-entry.cjs');
// Path 4 has no JSON resolution surface to read: `detectVerifyFailed` resolves a
// directory and then reports a boolean about its contents. So selection is
// observed indirectly — plant the failing verification artifact in exactly one
// directory and see whether the signal fires. `verify_failed === true` means
// that directory is the one smart-entry chose; `false` means it chose another
// or resolved nothing.
const FAILED_SUMMARY = '# Summary\n\nSTATUS: failed\n';
const PASSED_SUMMARY = '# Summary\n\nSTATUS: passed\n';
function writeState(tmpDir, currentPhase) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`---\nstatus: executing\ntotal_phases: 99\ncurrent_phase: ${currentPhase}\n---\n\n# State\n\n**Status:** executing\n`,
);
}
function smartEntrySeesFailureIn(tmpDir, dirs, failingDirs) {
for (const d of dirs) {
// Every directory always gets a summary — a passing one where the failure
// is not planted. Deleting instead would let "resolved a dir with no
// artifact" pass for the same reason as "resolved the right dir".
const summary = path.join(tmpDir, '.planning', 'phases', d, 'SUMMARY.md');
fs.writeFileSync(summary, failingDirs.includes(d) ? FAILED_SUMMARY : PASSED_SUMMARY);
}
return detectSignals(tmpDir).verify_failed;
}
// Each scenario: phase dirs on disk, the user's bare input, and the expected
// resolution ('10-24-7-autonomy' → that dir; null → not found; 'AMBIGUOUS' →
// every path must surface the ambiguity instead of silently picking one).
const SCENARIOS = [
{
name: '#2528 tokenizer fix: 2-digit slug word + 1-digit word ("24/7 Autonomy")',
dirs: ['10-24-7-autonomy', '11-other'],
query: '10',
expect: '10-24-7-autonomy',
},
{
name: '#2528 bare-integer fallback: 2-digit slug run with non-digit tail ("80/20 Cleanup")',
dirs: ['05-80-20-cleanup', '11-other'],
query: '5',
expect: '05-80-20-cleanup',
},
{
name: '#2528 bare-integer fallback: "12-Factor Refactor"',
dirs: ['30-12-factor-refactor'],
query: '30',
expect: '30-12-factor-refactor',
},
{
name: '#2528 prefixed fallback preserves phase number and phase name boundaries',
dirs: ['MEM-05-80-20-cleanup'],
query: '5',
expect: 'MEM-05-80-20-cleanup',
expectPhaseNumber: 'MEM-05',
expectPhaseName: '80-20-cleanup',
},
{
name: '#2232 regression stays green: year-leading slug',
dirs: ['14-2026-photos-performance'],
query: '14',
expect: '14-2026-photos-performance',
},
{
name: '#2043 regression stays green: 1-digit slug word',
dirs: ['46-6-rs-pipeline-orchestrator'],
query: '46',
expect: '46-6-rs-pipeline-orchestrator',
},
{
name: 'genuine sub-phase is still resolvable by its full id',
dirs: ['10-24-setup'],
query: '10-24',
expect: '10-24-setup',
},
{
// #2528 re-review: the regression pin. A genuine sub-phase whose slug starts
// with a bare digit ("7-Zip Integration") is string-identical to a phase
// named "24/7 Autonomy", and must stay resolvable by its OWN id on every
// path — the property an earlier tokenizer-side rewind silently broke.
name: 'a sub-phase with a digit-leading slug resolves by its full id',
dirs: ['10-24-7-zip'],
query: '10-24',
expect: '10-24-7-zip',
},
{
// The fallback is strictly second: a directory that carries the number in
// its token wins outright, and the digit-leading NAME is not a rival
// candidate for it. (Fallback-vs-fallback collisions DO go ambiguous — see
// the next scenario.)
name: 'a primary token match is never shadowed by a digit-leading phase name',
dirs: ['10-24-7-autonomy', '10-second'],
query: '10',
expect: '10-second',
},
{
name: 'fallback collisions are ambiguous, never a silent first match',
dirs: ['05-80-20-a', '05-90-till-late'],
query: '5',
expect: 'AMBIGUOUS',
},
{
name: 'a missing phase stays not-found on every path',
dirs: ['10-24-7-autonomy'],
query: '99',
expect: null,
},
];
describe('#2528 resolution-path parity — locator / find-phase / phase-plan-index / smart-entry', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
tmpDir = null;
});
for (const { name, dirs, query, expect, expectPhaseNumber, expectPhaseName } of SCENARIOS) {
test(name, () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
for (const d of dirs) {
const dir = path.join(phasesDir, d);
fs.mkdirSync(dir, { recursive: true });
// One canonical plan per dir so a resolved phase-plan-index proves it
// actually read the directory (plans: [] was the reported symptom).
const leadingDigits = d.match(/^\d+/);
const padded = leadingDigits ? leadingDigits[0] : '01';
fs.writeFileSync(path.join(dir, `${padded}-01-PLAN.md`), '---\nwave: 1\n---\n');
}
// ── Path 1: the shared locator (findPhaseInternal → searchPhaseInDir) ─
const located = findPhaseInternal(tmpDir, query);
const locatorDir =
located && located.found ? path.basename(located.directory) : null;
const locatorAmbiguous = Boolean(located && located.ambiguous_matches);
// ── Path 2: find-phase ────────────────────────────────────────────────
const findRes = runMsdTools(`find-phase ${query}`, tmpDir);
assert.ok(findRes.success, `find-phase failed: ${findRes.error}`);
const findOut = JSON.parse(findRes.output);
const findDir = findOut.found ? path.basename(findOut.directory) : null;
const findAmbiguous = Boolean(findOut.ambiguous_matches);
// ── Path 3: phase-plan-index ──────────────────────────────────────────
const idxRes = runMsdTools(`phase-plan-index ${query}`, tmpDir);
assert.ok(idxRes.success, `phase-plan-index failed: ${idxRes.error}`);
const idxOut = JSON.parse(idxRes.output);
const idxAmbiguous = Boolean(idxOut.ambiguous_matches);
const idxResolved = !idxOut.error && idxOut.plans.length > 0;
// ── Path 4: smart-entry (detectSignals → detectVerifyFailed) ──────────
// Not a resolution API — it answers "did the current phase fail
// verification". But it resolves the same directory from the same bare
// input, and a miss here is SILENT: an unresolved phase reports
// "not failed", which is byte-identical to a healthy phase. That is why
// it belongs in this gate and not merely in its own unit test.
writeState(tmpDir, query);
if (expect === 'AMBIGUOUS') {
assert.ok(locatorAmbiguous, 'locator must surface ambiguity');
assert.ok(findAmbiguous, 'find-phase must surface ambiguity');
assert.ok(idxAmbiguous, 'phase-plan-index must surface ambiguity');
assert.deepStrictEqual(
[...(located.ambiguous_matches || [])].sort(),
[...(findOut.ambiguous_matches || [])].sort(),
'locator and find-phase must list the same candidates',
);
assert.deepStrictEqual(
[...(findOut.ambiguous_matches || [])].sort(),
[...(idxOut.ambiguous_matches || [])].sort(),
'find-phase and phase-plan-index must list the same candidates',
);
// Path 4 deliberately does NOT fail loud on ambiguity: it is a routing
// signal with no way to ask the user, so it keeps the first candidate
// in the already-sorted list, exactly as its prior `.find()` did. What
// parity still requires is that it picks from the SAME candidate set —
// so a failure in any ambiguous candidate must be reachable, and a
// failure outside the set must not be.
const candidates = [...(located.ambiguous_matches || [])].map((c) => path.basename(c));
assert.ok(
smartEntrySeesFailureIn(tmpDir, dirs, candidates),
'smart-entry must resolve into the ambiguous candidate set',
);
const outsiders = dirs.filter((d) => !candidates.includes(d));
if (outsiders.length > 0) {
assert.ok(
!smartEntrySeesFailureIn(tmpDir, dirs, outsiders),
'smart-entry must not resolve to a directory outside the candidate set',
);
}
} else if (expect === null) {
assert.strictEqual(locatorDir, null, 'locator must report not-found');
assert.strictEqual(findDir, null, 'find-phase must report not-found');
assert.strictEqual(idxOut.error, 'Phase not found', 'phase-plan-index must report not-found');
assert.ok(
!smartEntrySeesFailureIn(tmpDir, dirs, dirs),
'smart-entry must report not-found too — a failing artifact in every '
+ 'directory must still not be attributed to an unresolvable phase',
);
} else {
assert.strictEqual(locatorDir, expect, 'locator resolved the wrong dir');
if (expectPhaseNumber) {
assert.strictEqual(located.phase_number, expectPhaseNumber);
assert.strictEqual(located.phase_name, expectPhaseName);
}
assert.strictEqual(findDir, expect, 'find-phase resolved the wrong dir');
assert.ok(
idxResolved,
`phase-plan-index must resolve and index plans, got: ${idxRes.output}`,
);
assert.ok(
smartEntrySeesFailureIn(tmpDir, dirs, [expect]),
`smart-entry resolved a different dir — it did not see the failure planted in ${expect}`,
);
for (const other of dirs.filter((d) => d !== expect)) {
assert.ok(
!smartEntrySeesFailureIn(tmpDir, dirs, [other]),
`smart-entry resolved ${other} instead of ${expect}`,
);
}
}
});
}
});
// ─── #2528 consumer parity ───────────────────────────────────────────────────
/**
* The four paths above are the resolution APIs. Review found eight further call
* sites that had each re-implemented the same "resolve a phase directory from a
* bare number" step by hand — `dirs.find/some(d => phaseTokenMatches(d, n))` —
* and so reproduced the #2528 symptom in full even after the owner existed.
*
* They are covered here rather than in their own files because the failure this
* gate exists to catch is not "command X is broken" but "a consumer stopped
* agreeing with the owner". Splitting them up is how the first four drifted.
*
* Every path is observed through the surface a user actually sees, never
* through the matcher:
*
* 1. `phases list --phase N` → `error: 'Phase not found'` vs listed files
* 2. `phase next-decimal N` → `found`
* 3. `phase remove N --force` → `directory_deleted`
* 4. `verify schema-drift N` → `Phase directory not found` message
* 5. `validate health` (W021) → milestone-complete-vs-roadmap consistency
* 6. `init manager` → the overview table's `disk_status`
* 7. `milestone complete vX` → the unstarted-phase completion guard
* 8. `roadmap analyze` → per-phase `disk_status`
*
* Paths 1-4 take the phase as a query. Paths 5-8 never see one: they walk the
* ROADMAP and ask the disk about each phase in turn, so their "query" is the
* roadmap heading and their answer is whether the phase looks started.
*/
const CONSUMER_SCENARIOS = [
{
name: '#2528 bare-integer fallback ("80/20 Cleanup")',
dirs: ['05-80-20-cleanup', '11-other'],
query: '5',
resolvesTo: '05-80-20-cleanup',
},
{
name: '#2528 tokenizer fix ("24/7 Autonomy")',
dirs: ['10-24-7-autonomy', '11-other'],
query: '10',
resolvesTo: '10-24-7-autonomy',
},
{
name: '#2528 bare-integer fallback ("12-Factor Refactor")',
dirs: ['30-12-factor-refactor'],
query: '30',
resolvesTo: '30-12-factor-refactor',
},
{
// Control. Without it every assertion below could be satisfied by a
// consumer that resolves unconditionally.
name: 'a phase with no directory stays unresolved on every consumer',
dirs: ['11-other'],
query: '99',
resolvesTo: null,
},
];
/**
* #2528 re-review: the AMBIGUOUS row the rows above cannot express.
*
* Every scenario in CONSUMER_SCENARIOS is binary — a query either resolves to
* one directory or to none — so a query that resolves to TWO fell through the
* gate entirely. That gap is what let the destructive path regress unseen:
* `phase remove` took `matches[0]` while every guarded sibling refuses, turning
* "resolve nothing, delete nothing" at base into "delete one of two candidates,
* and renumber every phase after it".
*
* This is a fallback ambiguity specifically: neither directory's TOKEN is `05`
* (`05-80-20-a` tokenizes to `05-80-20`), so both are reached only by the
* bare-integer fallback this PR adds — i.e. the ambiguity is one this PR
* created, which is why the PR owes it a guard.
*/
const AMBIGUOUS_SCENARIO = {
dirs: ['05-80-20-a', '05-90-till-late'],
query: '5',
};
/**
* #2528 re-review: sub-phase-shaped directories, pinned in BOTH directions.
*
* `05-01-auth` is a genuine deep-decomposition directory for phase 5.1, and it
* has the same `NN-NN-<slug>` shape as `30-12-factor-refactor` (phase 30 named
* "12-Factor Refactor"). No rule over directory names alone separates them —
* "is the second segment a valid decimal sub-phase" accepts `5.1` and `30.12`
* equally — so the bare-integer fallback necessarily reaches both, and a bare
* `5` now resolves a lone `05-01-auth` where base found nothing.
*
* Both halves are pinned here because the docblock's claim about scope is only
* true of the QUERY side, and nothing previously observed the directory side:
* - one such directory → resolves, and the display number is the leading run
* - two such directories → ambiguous, and the destructive path deletes nothing
*/
const SUBPHASE_DIRS = ['05-01-auth', '05-02-api'];
describe('#2528 consumer parity — the eight sites migrated to matchPhaseDirs', () => {
const projects = [];
afterEach(() => {
for (const dir of projects.splice(0)) cleanup(dir);
});
// Each mutating path needs its own project: `phase remove` deletes and
// renumbers, `milestone complete` archives the whole phases tree.
function project(dirs, roadmapPhase, status = 'executing') {
const tmpDir = createTempProject();
projects.push(tmpDir);
const phasesDir = path.join(tmpDir, '.planning', 'phases');
for (const d of dirs) {
const dir = path.join(phasesDir, d);
fs.mkdirSync(dir, { recursive: true });
const padded = (d.match(/^\d+/) || ['01'])[0];
fs.writeFileSync(path.join(dir, `${padded}-01-PLAN.md`), '---\nwave: 1\n---\n');
}
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap\n\n## Phase ${roadmapPhase}: Target\n`,
);
// `milestone:` is load-bearing: milestone-complete only runs its
// unstarted-phase guard when STATE names the version being completed.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`---\nstatus: ${status}\nmilestone: v1.0\ntotal_phases: 99\ncurrent_phase: ${roadmapPhase}\n---\n\n# State\n\n**Status:** ${status}\n`,
);
return tmpDir;
}
function json(cmd, cwd) {
const res = runMsdTools(cmd, cwd);
assert.ok(res.success, `${cmd} failed: ${res.error}`);
return JSON.parse(res.output);
}
for (const { name, dirs, query, resolvesTo } of CONSUMER_SCENARIOS) {
const resolves = resolvesTo !== null;
test(`${name} — query-driven consumers`, () => {
const tmpDir = project(dirs, query);
// 1. phases list
const listed = json(`phases list --phase ${query} --type plans`, tmpDir);
if (resolves) {
assert.ok(!listed.error, `phases list: ${listed.error}`);
assert.deepStrictEqual(
listed.files,
[`${resolvesTo.match(/^\d+/)[0]}-01-PLAN.md`],
'phases list resolved a different directory',
);
} else {
assert.strictEqual(listed.error, 'Phase not found');
}
// 2. phase next-decimal — `found` is the base-phase existence check
assert.strictEqual(
json(`phase next-decimal ${query}`, tmpDir).found,
resolves,
'next-decimal disagreed on whether the base phase exists',
);
// 3. verify schema-drift
const drift = json(`verify schema-drift ${query}`, tmpDir);
assert.strictEqual(
drift.message === `Phase directory not found: ${query}`,
!resolves,
`schema-drift disagreed: ${drift.message}`,
);
// 4. phase remove — mutating, so it runs last and on its own project
const removeProject = project(dirs, query);
assert.strictEqual(
json(`phase remove ${query} --force`, removeProject).directory_deleted,
resolvesTo,
'phase remove deleted the wrong directory (or none)',
);
});
test(`${name} — roadmap-driven consumers`, () => {
// 5. validate health, W026 (Phase 11, #3309 — split off the
// pre-migration 'W021' site for this exact subject; the OTHER W021
// subject, phase_id_convention mismatch, kept its code): STATE must
// claim the milestone is done for the roadmap-vs-disk consistency
// check to run at all.
const health = json('validate health', project(dirs, query, 'milestone complete'));
const w026 = health.warnings.filter((w) => w.code === 'W026');
assert.strictEqual(
w026.length > 0,
!resolves,
`W026 disagreed on whether Phase ${query} is started: ${JSON.stringify(w026)}`,
);
const tmpDir = project(dirs, query);
// 6. init manager overview table
const manager = json('init manager', tmpDir);
const managed = manager.phases.find((p) => p.number === query);
assert.ok(managed, `init manager did not list Phase ${query}`);
assert.strictEqual(
managed.disk_status === 'no_directory',
!resolves,
'init manager disagreed on disk_status',
);
// 7. roadmap analyze
const analyzed = json('roadmap analyze', tmpDir).phases.find((p) => p.number === query);
assert.ok(analyzed, `roadmap analyze did not list Phase ${query}`);
assert.strictEqual(
analyzed.disk_status === 'no_directory',
!resolves,
'roadmap analyze disagreed on disk_status',
);
assert.strictEqual(
analyzed.disk_status,
managed.disk_status,
'roadmap analyze and init manager disagreed with each other',
);
// 8. milestone complete — mutating, own project. The guard blocks
// completion while any roadmap phase has no directory.
const completion = runMsdTools('milestone complete v1.0 --confirm', project(dirs, query));
assert.strictEqual(
completion.success,
resolves,
`milestone-complete guard disagreed: ${completion.error || completion.output}`,
);
if (!resolves) {
assert.match(completion.error, /Cannot mark milestone complete/);
}
});
}
test('two directories claiming one bare phase number — the destructive path deletes neither', () => {
const { dirs, query } = AMBIGUOUS_SCENARIO;
const tmpDir = project(dirs, query);
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const removed = json(`phase remove ${query} --force`, tmpDir);
assert.strictEqual(removed.directory_deleted, null, 'phase remove chose a directory');
assert.deepStrictEqual(
removed.ambiguous_matches,
dirs,
'phase remove did not surface both candidates',
);
assert.match(removed.error, /ambiguous/i);
// The load-bearing assertion: the refusal is about the FILESYSTEM, not the
// report. A `directory_deleted: null` printed after an `rmSync` would pass
// every check above.
assert.deepStrictEqual(
fs.readdirSync(phasesDir).sort(),
[...dirs].sort(),
'phase remove deleted a directory it reported refusing to choose',
);
assert.deepStrictEqual(removed.renamed_directories, [], 'phase remove renumbered anyway');
});
test('a lone sub-phase-shaped directory resolves, and its two-directory twin does not', () => {
const [first, second] = SUBPHASE_DIRS;
// One directory: the fallback reaches it, and the displayed number is the
// leading digit run — NOT the mis-absorbed `05-01` token.
const lone = findPhaseInternal(project([first], '5'), '5');
assert.ok(lone && lone.found, 'a lone sub-phase-shaped directory did not resolve');
assert.strictEqual(lone.phase_number, '05');
assert.strictEqual(lone.phase_name, '01-auth');
assert.strictEqual(path.basename(lone.directory), first);
// Two directories: the same shape is now ambiguous, and the destructive
// path must delete neither — this is the case the reviewer measured as
// "deletes 05-01-auth and renumbers 06-next → 05-next".
const tmpDir = project(SUBPHASE_DIRS, '5');
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const removed = json('phase remove 5 --force', tmpDir);
assert.strictEqual(removed.directory_deleted, null);
assert.deepStrictEqual(removed.ambiguous_matches, [first, second]);
assert.deepStrictEqual(fs.readdirSync(phasesDir).sort(), [...SUBPHASE_DIRS].sort());
});
test('validate health pairs a digit-leading directory with its roadmap phase (W006/W007)', () => {
// #2528 re-review, the ninth site. W006/W007 resolve roadmap↔disk by
// intersecting token SETS, which is a dir→token labelling rather than the
// query→dir selection matchPhaseDirs owns — so the canonical fixture used
// to emit BOTH halves of the contradiction at once: "Phase 5 … no directory
// on disk" and "Phase 05-80-20 exists on disk but not in ROADMAP.md".
const codes = (dirs, roadmapPhase) => json('validate health', project(dirs, roadmapPhase))
.warnings.filter((w) => w.code === 'W006' || w.code === 'W007')
.map((w) => w.code)
.sort();
assert.deepStrictEqual(
codes(['05-80-20-cleanup'], '5'),
[],
'validate health still reports phase 5 as both missing and orphaned',
);
// Controls, so the assertion above cannot be satisfied by a check that
// stopped reporting anything: a roadmap phase with no directory at all must
// still raise W006, and a directory no roadmap phase resolves to must still
// raise W007.
assert.deepStrictEqual(codes(['07-orphan'], '5'), ['W006', 'W007']);
});
test('phase remove counts the surviving phases by identity, not by re-matching the query', () => {
// #2640 (landed on `next` while this branch was open) resyncs STATE.md's
// phase count after a removal by filtering `subdirs` for the directory that
// was deleted. Re-deriving that directory from the QUERY is a tenth site of
// the #2528 defect: the bare-integer fallback resolves `05-80-20-cleanup`
// for query `5`, but `phaseTokenMatches` (whose token is the mis-absorbed
// `05-80-20`) does not — so the just-deleted directory is counted as still
// present and the written total is one too high. `targetDir` is already the
// directory that was removed, so identity answers the question exactly.
const total = (dirs, query) => {
const tmpDir = project(dirs, query);
json(`phase remove ${query} --force`, tmpDir);
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
const m = state.match(/^Total Phases:\s*(\d+)/m);
assert.ok(m, 'phase remove did not resync a phase count into STATE.md');
return Number(m[1]);
};
assert.strictEqual(total(['05-80-20-cleanup', '11-other'], '5'), 1);
// Control: on a directory the tokenizer reads correctly, identity and token
// re-derivation agree — so the assertion above is about the digit-leading
// shape, not about the counting rule changing for everything.
assert.strictEqual(total(['05-cleanup', '11-other'], '5'), 1);
});
});