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.
321 lines
17 KiB
JavaScript
321 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Prod-shape integration tests for `src/init.cts`'s `buildSectionManifestField`
|
|
* seam (compiled into `msd-core/bin/lib/init.cjs`) — #2994 (epic #1671 Phase
|
|
* 6.3), `.msd/phase/chore-2994-fragment-model-workflows/50-test-matrix.md`
|
|
* sections B (compound-fact folding), D (flag value shapes), and E (the
|
|
* `null` vs `[]` distinction).
|
|
*
|
|
* Every test drives the REAL CLI (`runMsdTools`, spawns `msd-tools.cjs`) —
|
|
* never a direct `require()` of `cmdInit*`, since `buildSectionManifestField`
|
|
* itself is not exported and the whole point of this suite is proving the
|
|
* flag/config plumbing that only exists at the CLI seam (`init-command-router.cjs`'s
|
|
* `parseNamedArgs` calls) actually reaches `section_manifest`.
|
|
*
|
|
* Group E uses the `MSD_SECTION_MANIFEST` env override
|
|
* (`msd-core/bin/lib/init.cjs`'s `_sectionManifestCandidatePath`) to point the
|
|
* CLI at a temp fixture manifest instead of the shipped
|
|
* `msd-core/workflows/section-manifest.json` — this is the ONLY way to
|
|
* exercise "workflow absent from the manifest" today, because every
|
|
* `cmdInit*` this repo currently wires (#2994's own completion) already has
|
|
* an explicit key in the shipped artifact; Groups B and D deliberately do NOT
|
|
* override it, driving the shipped manifest directly per the matrix's own
|
|
* "drive from the shipped artifact wherever possible" rule.
|
|
*/
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const { runMsdTools, cleanup, createTempDir, createTempProject } = require('./helpers.cjs');
|
|
const { seedPhase } = require('./fixtures/index.cjs');
|
|
|
|
function writeConfig(tmpDir, config) {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify(config, null, 2));
|
|
}
|
|
|
|
function runInitJson(argv, cwd, env = {}) {
|
|
const result = runMsdTools(argv, cwd, env);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
return JSON.parse(result.output);
|
|
}
|
|
|
|
// ─── Group E: the null vs [] distinction (matrix rows E1/E2/E3/E5/E6) ──────
|
|
|
|
describe('section_manifest: null (degraded) vs [] (computed, nothing applicable) — matrix §E', () => {
|
|
let tmpDir;
|
|
let manifestDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject('section-manifest-e-');
|
|
manifestDir = createTempDir('section-manifest-e-manifest-');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
cleanup(manifestDir);
|
|
});
|
|
|
|
test('a manifest with no "quick" key degrades section_manifest to null (row E1)', () => {
|
|
const manifestPath = path.join(manifestDir, 'manifest.json');
|
|
fs.writeFileSync(manifestPath, JSON.stringify({ workflows: {} }));
|
|
|
|
const output = runInitJson(['init', 'quick', 'desc'], tmpDir, { MSD_SECTION_MANIFEST: manifestPath });
|
|
assert.equal(output.section_manifest, null, 'an absent workflow key must degrade to null, never []');
|
|
});
|
|
|
|
test('a manifest with an explicit empty "quick" section list yields a computed [] (row E2)', () => {
|
|
const manifestPath = path.join(manifestDir, 'manifest.json');
|
|
fs.writeFileSync(manifestPath, JSON.stringify({ workflows: { quick: [] } }));
|
|
|
|
const output = runInitJson(['init', 'quick', 'desc'], tmpDir, { MSD_SECTION_MANIFEST: manifestPath });
|
|
assert.notEqual(output.section_manifest, null, 'a present (even empty) workflow key must NOT degrade to null');
|
|
assert.deepEqual(output.section_manifest.included, []);
|
|
assert.deepEqual(output.section_manifest.excluded, []);
|
|
assert.deepEqual(output.section_manifest.read, []);
|
|
});
|
|
|
|
test('null and the computed [] shape are DIFFERENT values, not interchangeable (row E3)', () => {
|
|
const absentKeyManifest = path.join(manifestDir, 'absent.json');
|
|
fs.writeFileSync(absentKeyManifest, JSON.stringify({ workflows: {} }));
|
|
const emptyListManifest = path.join(manifestDir, 'empty.json');
|
|
fs.writeFileSync(emptyListManifest, JSON.stringify({ workflows: { quick: [] } }));
|
|
|
|
const degraded = runInitJson(['init', 'quick', 'desc'], tmpDir, { MSD_SECTION_MANIFEST: absentKeyManifest });
|
|
const computed = runInitJson(['init', 'quick', 'desc'], tmpDir, { MSD_SECTION_MANIFEST: emptyListManifest });
|
|
|
|
assert.equal(degraded.section_manifest, null);
|
|
assert.notEqual(computed.section_manifest, null);
|
|
assert.notDeepEqual(degraded.section_manifest, computed.section_manifest, 'null must never compare equal to the computed {included:[],excluded:[],...} shape');
|
|
});
|
|
|
|
test('a section list where every when= evaluates false is still the computed [] shape, not null (row E2, second case)', () => {
|
|
// "Applicable" is a FACTS question, not a shape question: a real section
|
|
// whose gating flag was simply never passed on this invocation must still
|
|
// be the computed {included:[],...} object, never collapse to null.
|
|
const manifestPath = path.join(manifestDir, 'manifest.json');
|
|
fs.writeFileSync(manifestPath, JSON.stringify({
|
|
workflows: {
|
|
quick: [{ id: 'discussion-phase', when: 'flag:--discuss', read: 'msd-core/workflows/quick/steps/discussion-phase.md' }],
|
|
},
|
|
}));
|
|
|
|
const output = runInitJson(['init', 'quick', 'desc'], tmpDir, { MSD_SECTION_MANIFEST: manifestPath });
|
|
assert.notEqual(output.section_manifest, null);
|
|
assert.deepEqual(output.section_manifest.included, []);
|
|
assert.deepEqual(output.section_manifest.excluded, ['discussion-phase']);
|
|
});
|
|
|
|
test('a missing manifest file degrades to null without throwing (row E5)', () => {
|
|
const neverCreatedPath = path.join(manifestDir, 'does-not-exist.json');
|
|
assert.equal(fs.existsSync(neverCreatedPath), false, 'sanity: file must not exist');
|
|
|
|
const result = runMsdTools(['init', 'quick', 'desc'], tmpDir, { MSD_SECTION_MANIFEST: neverCreatedPath });
|
|
assert.ok(result.success, `a missing manifest file must not crash the command: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.equal(output.section_manifest, null);
|
|
});
|
|
|
|
test('a malformed-JSON manifest file degrades to null without throwing (row E6)', () => {
|
|
const manifestPath = path.join(manifestDir, 'malformed.json');
|
|
fs.writeFileSync(manifestPath, '{ this is not valid JSON');
|
|
|
|
const result = runMsdTools(['init', 'quick', 'desc'], tmpDir, { MSD_SECTION_MANIFEST: manifestPath });
|
|
assert.ok(result.success, `a malformed manifest file must not crash the command: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.equal(output.section_manifest, null);
|
|
});
|
|
});
|
|
|
|
// ─── Group B: compound-fact folding lives in init.cts, never the grammar ──
|
|
|
|
describe('compound-fact folding at the init seam — matrix §B', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject('section-manifest-b-');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// B1-B4: cmdInitQuick folds --full into --discuss/--research/--validate BEFORE
|
|
// buildSectionManifestField evaluates flag:--discuss/flag:--research/flag:--validate.
|
|
describe('cmdInitQuick folds --full into each mode fact (rows B1-B4)', () => {
|
|
test('--discuss alone includes discussion-phase (row B1, baseline)', () => {
|
|
const output = runInitJson(['init', 'quick', 'desc', '--discuss'], tmpDir);
|
|
assert.ok(output.section_manifest.included.includes('discussion-phase'));
|
|
});
|
|
|
|
test('--full alone (no --discuss) STILL includes discussion-phase — the fold this PR exists to prove (row B2)', () => {
|
|
const output = runInitJson(['init', 'quick', 'desc', '--full'], tmpDir);
|
|
assert.ok(
|
|
output.section_manifest.included.includes('discussion-phase'),
|
|
'--full must imply --discuss for section-gating purposes, folded in cmdInitQuick before evaluation',
|
|
);
|
|
assert.ok(output.section_manifest.included.includes('research-phase'), '--full must also imply --research');
|
|
assert.ok(output.section_manifest.included.includes('plan-checker-loop'), '--full must also imply --validate (plan-checker-loop)');
|
|
assert.ok(output.section_manifest.included.includes('quick-verification'), '--full must also imply --validate (quick-verification)');
|
|
});
|
|
|
|
test('neither --discuss nor --full excludes discussion-phase (row B3)', () => {
|
|
const output = runInitJson(['init', 'quick', 'desc'], tmpDir);
|
|
assert.ok(output.section_manifest.excluded.includes('discussion-phase'));
|
|
});
|
|
|
|
test('--full AND --discuss together still include discussion-phase exactly once, no double-counting (row B4)', () => {
|
|
const output = runInitJson(['init', 'quick', 'desc', '--full', '--discuss'], tmpDir);
|
|
const occurrences = output.section_manifest.included.filter((id) => id === 'discussion-phase');
|
|
assert.equal(occurrences.length, 1, 'discussion-phase must appear exactly once in included, never duplicated by the fold');
|
|
});
|
|
});
|
|
|
|
// B5-B6: cmdInitAutonomous folds --converge OR --cross-ai into one plan_strategy_converge fact.
|
|
describe('cmdInitAutonomous folds --converge OR --cross-ai (rows B5-B6)', () => {
|
|
test('--converge alone drives state:plan-strategy-converge sections into included (row B5)', () => {
|
|
const output = runInitJson(['init', 'autonomous', '--converge'], tmpDir);
|
|
assert.equal(output.plan_strategy_converge, true);
|
|
assert.ok(output.section_manifest.included.includes('converge-fail-fast'));
|
|
assert.ok(output.section_manifest.included.includes('converge-banner'));
|
|
});
|
|
|
|
test('--cross-ai alone (the documented alias, no --converge) ALSO drives inclusion (row B6)', () => {
|
|
const output = runInitJson(['init', 'autonomous', '--cross-ai'], tmpDir);
|
|
assert.equal(output.plan_strategy_converge, true);
|
|
assert.ok(output.section_manifest.included.includes('converge-fail-fast'));
|
|
assert.ok(output.section_manifest.included.includes('converge-loop'));
|
|
});
|
|
|
|
test('neither flag excludes every converge-* section', () => {
|
|
const output = runInitJson(['init', 'autonomous'], tmpDir);
|
|
assert.equal(output.plan_strategy_converge, false);
|
|
for (const id of ['converge-fail-fast', 'converge-banner', 'converge-dispatch-bg', 'converge-dispatch-inline', 'converge-loop']) {
|
|
assert.ok(output.section_manifest.excluded.includes(id), `expected "${id}" excluded when neither --converge nor --cross-ai is set`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// B7: cmdInitUpdate folds --next OR --rc into one next_channel fact.
|
|
describe('cmdInitUpdate folds --next OR --rc (row B7)', () => {
|
|
test('--rc alone (not --next) drives channel-banner into included', () => {
|
|
const output = runInitJson(['init', 'update', '--rc'], tmpDir);
|
|
assert.equal(output.next_channel, true);
|
|
assert.ok(output.section_manifest.included.includes('channel-banner'));
|
|
});
|
|
|
|
test('--next alone also drives channel-banner into included (the other half of the OR)', () => {
|
|
const output = runInitJson(['init', 'update', '--next'], tmpDir);
|
|
assert.equal(output.next_channel, true);
|
|
assert.ok(output.section_manifest.included.includes('channel-banner'));
|
|
});
|
|
|
|
test('neither flag excludes channel-banner', () => {
|
|
const output = runInitJson(['init', 'update'], tmpDir);
|
|
assert.equal(output.next_channel, false);
|
|
assert.ok(output.section_manifest.excluded.includes('channel-banner'));
|
|
});
|
|
});
|
|
|
|
// B8: cmdInitDiscussPhaseAssumptions folds --auto OR consolidated auto-mode config.
|
|
describe('cmdInitDiscussPhaseAssumptions folds --auto flag OR config auto-mode (row B8)', () => {
|
|
test('no --auto flag, but config workflow._auto_chain_active=true, still includes auto-advance-dispatch', () => {
|
|
writeConfig(tmpDir, { workflow: { _auto_chain_active: true } });
|
|
const output = runInitJson(['init', 'discuss-phase-assumptions', '99'], tmpDir);
|
|
assert.ok(output.section_manifest.included.includes('auto-advance-dispatch'));
|
|
});
|
|
|
|
test('no --auto flag, but config workflow.auto_advance=true, still includes auto-advance-dispatch (the other config source)', () => {
|
|
writeConfig(tmpDir, { workflow: { auto_advance: true } });
|
|
const output = runInitJson(['init', 'discuss-phase-assumptions', '99'], tmpDir);
|
|
assert.ok(output.section_manifest.included.includes('auto-advance-dispatch'));
|
|
});
|
|
|
|
test('--auto flag present with no config also includes auto-advance-dispatch', () => {
|
|
const output = runInitJson(['init', 'discuss-phase-assumptions', '99', '--auto'], tmpDir);
|
|
assert.ok(output.section_manifest.included.includes('auto-advance-dispatch'));
|
|
});
|
|
|
|
test('neither --auto nor either config key excludes auto-advance-dispatch', () => {
|
|
const output = runInitJson(['init', 'discuss-phase-assumptions', '99'], tmpDir);
|
|
assert.ok(output.section_manifest.excluded.includes('auto-advance-dispatch'));
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── Group D: flag value shapes — token presence, not value truthiness ────
|
|
|
|
describe('flag value shapes drive section_manifest by truthiness, not semantic value — matrix §D', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject('section-manifest-d-');
|
|
seedPhase(tmpDir, '01-sample', { '01-CONTEXT.md': '# Phase Context\n' });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('absent boolean flag (--reviews never passed) does not mark reviews-prerequisite present (row D1)', () => {
|
|
const output = runInitJson(['init', 'plan-phase', '1'], tmpDir);
|
|
assert.ok(output.section_manifest.excluded.includes('reviews-prerequisite'));
|
|
assert.ok(!output.section_manifest.included.includes('reviews-prerequisite'));
|
|
});
|
|
|
|
test('empty-string value flag (--prd "") excludes prd-express-gate — an empty value is not a branch selection (row D2)', () => {
|
|
const output = runInitJson(['init', 'plan-phase', '1', '--prd', ''], tmpDir);
|
|
assert.ok(output.section_manifest.excluded.includes('prd-express-gate'));
|
|
});
|
|
|
|
test('string-"0" value flag (--prd "0") INCLUDES prd-express-gate — token-presence semantics, not value truthiness (row D3)', () => {
|
|
const output = runInitJson(['init', 'plan-phase', '1', '--prd', '0'], tmpDir);
|
|
assert.ok(output.section_manifest.included.includes('prd-express-gate'), '--prd "0" is a non-empty string and must be treated as present');
|
|
});
|
|
|
|
test('whitespace-only value flag (--prd " ") includes prd-express-gate, asserted deliberately (row D4)', () => {
|
|
const output = runInitJson(['init', 'plan-phase', '1', '--prd', ' '], tmpDir);
|
|
assert.ok(output.section_manifest.included.includes('prd-express-gate'));
|
|
});
|
|
|
|
test('duplicate flag tokens (--prd a --prd b) resolve to a single membership, no crash (row D5)', () => {
|
|
const result = runMsdTools(['init', 'plan-phase', '1', '--prd', 'a', '--prd', 'b'], tmpDir);
|
|
assert.ok(result.success, `duplicate --prd tokens must not crash the command: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.ok(output.section_manifest.included.includes('prd-express-gate'));
|
|
const occurrences = output.section_manifest.included.filter((id) => id === 'prd-express-gate');
|
|
assert.equal(occurrences.length, 1, 'must be a single membership, not one entry per duplicate token');
|
|
});
|
|
|
|
test('flag-shaped value (--prd --reviews) does not crash and deterministically excludes prd-express-gate (row D6)', () => {
|
|
// parseNamedArgs treats a token starting with "--" as the NEXT flag, never
|
|
// as this flag's value — so --prd here resolves to null (absent), not the
|
|
// literal string "--reviews".
|
|
//
|
|
// Corrected after the first full verification run: the original choice of
|
|
// `--weird` here predates ADR-3473 §8.4's strict unrecognized-flag
|
|
// rejection (this file is from #2994/epic #1671, before #3358's strict
|
|
// parseNamedArgs) and is itself an unrecognized flag for `plan-phase` —
|
|
// it now correctly fails with exit 1 / "unknown flag --weird" instead of
|
|
// proving the "flag-shaped value" point this test exists for. Swapped for
|
|
// `--reviews`, a real declared plan-phase boolean flag, matching the same
|
|
// substitution already used by the sibling row
|
|
// tests/init.test.cjs:emptyPrdValueIsFalsyAndTreatedAsAbsent (row B5).
|
|
const result = runMsdTools(['init', 'plan-phase', '1', '--prd', '--reviews'], tmpDir);
|
|
assert.ok(result.success, `a flag-shaped value must not crash the command: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.ok(output.section_manifest.excluded.includes('prd-express-gate'), '--prd immediately followed by another --flag token must resolve to absent, per parseNamedArgs');
|
|
});
|
|
|
|
test('unicode / very long value does not crash and is included as present (row D7)', () => {
|
|
const longUnicodeValue = `产品需求文档-🚀-${'x'.repeat(2000)}`;
|
|
const result = runMsdTools(['init', 'plan-phase', '1', '--prd', longUnicodeValue], tmpDir);
|
|
assert.ok(result.success, `a unicode/very-long --prd value must not crash the command: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.ok(output.section_manifest.included.includes('prd-express-gate'));
|
|
});
|
|
});
|