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.
168 lines
7.5 KiB
JavaScript
168 lines
7.5 KiB
JavaScript
'use strict';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #3807 — advance-plan must refuse a Current Position section carrying
|
|
// more than one `Phase:` entry instead of silently advancing the first.
|
|
//
|
|
// The #2956 fix scoped the milestone-conflict Phase read to the Current
|
|
// Position section, but advancePlanCore's plan fields still came from
|
|
// document-wide first-match stateExtractField — so a wave-log style section
|
|
// (one Phase: entry per completed wave, all under Current Position) had its
|
|
// FIRST entry's plan counter silently advanced — in the reporter's incident,
|
|
// a hard-gated final plan 7→8 of 8 — with advanced:true and
|
|
// milestone_conflict:null, no error, no ambiguity signal.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const { createTempProject, cleanup, runMsdTools } = require('./helpers.cjs');
|
|
|
|
const TWO_ENTRY_BODY = [
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 03.1 of 8 (some-phase)',
|
|
'Plan: 7 of 8 in current phase',
|
|
'Status: In progress',
|
|
'Last activity: 2026-08-24 — working',
|
|
'',
|
|
'Phase: 04 of 15 (other-phase)',
|
|
'Plan: 7 of 15 in current phase',
|
|
'Status: Phase complete',
|
|
'Last activity: 2026-08-24 — wave 4 done',
|
|
'',
|
|
].join('\n');
|
|
|
|
function writeState(tmpDir, positionBody) {
|
|
const content = [
|
|
'---',
|
|
'msd_state_version: 1.0',
|
|
'current_phase: 03',
|
|
'status: executing',
|
|
'progress:',
|
|
' total_phases: 2',
|
|
'---',
|
|
'',
|
|
positionBody,
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
|
}
|
|
|
|
function runAdvance(cwd) {
|
|
return runMsdTools(['state', 'advance-plan'], cwd);
|
|
}
|
|
|
|
describe('#3807: advance-plan refuses an ambiguous multi-entry Current Position', () => {
|
|
test('#3807: two Phase: entries under Current Position → ambiguous error, no mutation', (t) => {
|
|
const tmpDir = createTempProject('msd-3807-amb-');
|
|
t.after(() => cleanup(tmpDir));
|
|
writeState(tmpDir, TWO_ENTRY_BODY);
|
|
const before = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
|
|
|
|
const r = runAdvance(tmpDir);
|
|
// The CLI reports an error payload (exit success shape is the command's
|
|
// own convention for parse errors — assert on the payload, not the code).
|
|
const out = JSON.parse(r.output);
|
|
assert.ok(
|
|
out.error && out.reason === 'ambiguous_position_phase' && /more than one Phase/i.test(String(out.error)),
|
|
`#3807: the error must name the multi-Phase condition with the typed reason; got ${r.output}`,
|
|
);
|
|
assert.ok(
|
|
Array.isArray(out.phase_candidates) && out.phase_candidates.length === 2,
|
|
`#3807: both Phase: candidates must be named; got ${JSON.stringify(out.phase_candidates)}`,
|
|
);
|
|
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
|
|
assert.equal(after, before, '#3807: refusing must leave STATE.md byte-identical');
|
|
assert.ok(!/Plan: 8 of 8/.test(after), 'the first entry\'s plan counter must NOT advance');
|
|
});
|
|
|
|
test('#3807 control: a single-entry section advances exactly as before', (t) => {
|
|
const tmpDir = createTempProject('msd-3807-ctl-');
|
|
t.after(() => cleanup(tmpDir));
|
|
writeState(tmpDir, [
|
|
'## Current Position',
|
|
'',
|
|
'Phase: 03 of 8 (some-phase)',
|
|
'Plan: 3 of 8 in current phase',
|
|
'Status: In progress',
|
|
'Last activity: 2026-08-24 — working',
|
|
'',
|
|
].join('\n'));
|
|
|
|
const r = runAdvance(tmpDir);
|
|
const out = JSON.parse(r.output);
|
|
assert.equal(out.advanced, true, `single-entry advance still works; got ${r.output}`);
|
|
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
|
|
assert.match(after, /Plan: 4 of 8/, 'the plan counter advanced');
|
|
});
|
|
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
// #3784 x #3807 interaction. #3784 taught advancePlanCore a third value
|
|
// shape — the hybrid `Current Plan: N of M` (legacy field name, compound
|
|
// value, no `Total Plans in Phase` sibling). Both changes land on the same
|
|
// function, and the guard sits ABOVE the parse, so a document the guard
|
|
// refuses is never parsed at all. That ordering is the whole answer to
|
|
// "does the widened grammar bypass the refusal" — but ordering is a
|
|
// property of the source, and these two assert it as behaviour.
|
|
//
|
|
// Fail-first proven, not assumed: with the `phaseCandidates.length > 1`
|
|
// refusal disabled, the multi-entry case below advances the FIRST entry's
|
|
// `Current Plan: 04 of 06` to `05 of 06` and writes it — #3807's exact
|
|
// defect, reached through the shape #3784 added.
|
|
// ───────────────────────────────────────────────────────────────────────────
|
|
|
|
const HYBRID_ENTRY = (phase, plan) => [
|
|
`Phase: ${phase}`,
|
|
`Current Plan: ${plan}`,
|
|
'Status: In progress',
|
|
'Last activity: 2026-08-24 — working',
|
|
'',
|
|
];
|
|
|
|
test('#3784 x #3807: the hybrid `Current Plan: N of M` shape does not bypass the refusal', (t) => {
|
|
const tmpDir = createTempProject('msd-3807-hybrid-amb-');
|
|
t.after(() => cleanup(tmpDir));
|
|
writeState(tmpDir, [
|
|
'## Current Position',
|
|
'',
|
|
...HYBRID_ENTRY('03.1 of 8 (some-phase)', '04 of 06'),
|
|
...HYBRID_ENTRY('04 of 15 (other-phase)', '04 of 15'),
|
|
].join('\n'));
|
|
const before = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
|
|
|
|
const r = runAdvance(tmpDir);
|
|
const out = JSON.parse(r.output);
|
|
assert.equal(
|
|
out.reason,
|
|
'ambiguous_position_phase',
|
|
`#3807's refusal must fire on the hybrid shape too, not #3784's parse; got ${r.output}`,
|
|
);
|
|
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
|
|
assert.equal(after, before, 'refusing must leave STATE.md byte-identical on the hybrid shape');
|
|
assert.ok(
|
|
!/Current Plan: 05 of 06/.test(after),
|
|
"the first entry's hybrid plan counter must NOT advance",
|
|
);
|
|
});
|
|
|
|
test('#3784 x #3807 control: a single-entry hybrid section still advances, padding intact', (t) => {
|
|
const tmpDir = createTempProject('msd-3807-hybrid-ctl-');
|
|
t.after(() => cleanup(tmpDir));
|
|
writeState(tmpDir, [
|
|
'## Current Position',
|
|
'',
|
|
...HYBRID_ENTRY('03.1 of 8 (some-phase)', '04 of 06'),
|
|
].join('\n'));
|
|
|
|
const r = runAdvance(tmpDir);
|
|
const out = JSON.parse(r.output);
|
|
assert.equal(out.advanced, true, `the hybrid shape still advances when unambiguous; got ${r.output}`);
|
|
assert.equal(out.current_plan, 5, '#3784: the hybrid value supplies the plan number');
|
|
assert.equal(out.total_plans, 6, '#3784: the hybrid value supplies the total, with no sibling field');
|
|
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
|
|
assert.match(after, /Current Plan: 05 of 06/, '#3784: zero-padding survives the advance');
|
|
});
|
|
});
|