Files
msd-core/tests/section-manifest-init-facts.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

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'));
});
});