Files
msd-core/tests/unreachable-shell-guard.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

391 lines
19 KiB
JavaScript

// allow-test-rule: source-text-is-the-product — see #3409
// Workflow markdown is the installed orchestration contract; the snippets
// below are extracted from the shipped .md files and EXECUTED (not
// re-typed), so the test binds to the deployed contract rather than a copy
// that could silently drift from it.
'use strict';
/**
* Failing-first regression tests for #3409 (design:
* .msd/phase/feat-3409-unreachable-shell-guard-lint/40-design.md; matrix:
* .msd/phase/feat-3409-unreachable-shell-guard-lint/50-test-matrix.md,
* section "Regression — the three defects this PR fixes", rows G1-G4).
*
* Root cause (40-design.md): `msd-tools.cjs`'s `--pick <field>` extractor
* coerces a missing/absent field to the empty string and exits 0. So
* `X=$(msd_run query V --pick F 2>/dev/null || echo D)` can NEVER reach its
* `|| echo D` arm on field absence — only on a typo in the verb name. Three
* shipped shell guards silently rely on that unreachable arm:
*
* G1/G2 — plan-phase.md's Walking Skeleton gate reads a
* `phases.list --pick summaries_total` field that does not exist
* (#3365), so `PRIOR_SUMMARIES` is always `""`, never `"0"`, and
* the gate can never fire — not even for a genuinely fresh
* project (G1). G2 is the load-bearing negative-space case: it
* proves a bad fix that merely treats "no answer" as "zero"
* (making the gate fire unconditionally) is rejected, by pinning
* BOTH that the resolved count is a real nonzero integer AND that
* the gate stays off.
* G3 — plan-phase.md's `PHASE_REQ_IDS` site: on a phase with zero
* requirements, `query init.plan-phase <N> --pick phase_req_ids`
* exits 0 with empty stdout, so `|| echo TBD` never fires and
* `PHASE_REQ_IDS` resolves to `""` instead of the documented
* `TBD` sentinel (gate step reads "Skip if phase_req_ids is null
* or TBD").
* G4 — complete-milestone.md's bare `cat` over an unmatched-capable
* SUMMARY.md glob: under a `nullglob`
* left set by an earlier block in the SAME shell session (the
* `extract_accomplishments` step, a few hundred lines earlier in
* this same file), an unmatched glob expands to zero operands,
* so `cat` reads from stdin instead of erroring — and blocks
* forever if that stdin is not already at EOF.
*
* Each test below extracts the LIVE fenced-bash / single-line snippet out of
* the shipped workflow markdown (never a hand-typed copy — see
* `extractFencedBashAfterAnchor` / `extractAssignmentBlockFor`) and executes it
* with `runHook(..., { interpreter: 'bash' })`
* (`tests/helpers/process-seam.cjs`), against a temp project fixture, driving
* the real CLI at `msd-core/bin/msd-tools.cjs` through the real `msd_run`
* shell function sourced from the shipped
* `msd-core/workflows/_runtime-launcher.snippet.sh` preamble.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempDir, cleanup, readWorkflowCombined } = require('./helpers.cjs');
const { runHook, OUTCOME } = require('./helpers/process-seam.cjs');
const { HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const PLAN_PHASE_PATH = path.join(REPO_ROOT, 'msd-core', 'workflows', 'plan-phase.md');
const COMPLETE_MILESTONE_PATH = path.join(REPO_ROOT, 'msd-core', 'workflows', 'complete-milestone.md');
const LAUNCHER_PATH = path.join(REPO_ROOT, 'msd-core', 'workflows', '_runtime-launcher.snippet.sh');
const MSD_TOOLS_PATH = path.join(REPO_ROOT, 'msd-core', 'bin', 'msd-tools.cjs');
// A `cat`-under-blocked-stdin hang (G4) must be bounded well under this, but
// give the CI-shape headroom PROBE_TIMEOUT_MS documents for a short CLI call.
const G4_TIMEOUT_MS = 5000; // short and explicit per the test-matrix note (G4 must assert on
// `outcome`, never `signal` — a real timeout and a maxBuffer overflow both
// report SIGTERM; PROBE_TIMEOUT_MS (15000ms) would work too but a tight,
// named bound makes a genuine hang fail fast instead of eating the suite's
// time budget on every RED run.
// ─── extraction (source-text-is-the-product) ─────────────────────────────
/**
* Extract the first ```bash fence appearing AFTER `anchor` in `content`.
* Mirrors the extraction convention already established by
* tests/plan-phase-stall-detection.test.cjs's extractStallHelpersBash(): walk
* forward from the anchor to the next fence open, then to its close. Throws
* with a message naming the anchor and file so a relocated/renamed anchor
* fails loudly instead of silently extracting the wrong block.
*/
function extractFencedBashAfterAnchor(content, anchor, sourcePath) {
const anchorIdx = content.indexOf(anchor);
if (anchorIdx === -1) {
throw new Error(`extractFencedBashAfterAnchor: could not find anchor "${anchor}" in ${sourcePath}`);
}
const after = content.slice(anchorIdx);
const fenceOpen = after.match(/```bash\r?\n/);
if (!fenceOpen) {
throw new Error(`extractFencedBashAfterAnchor: no \`\`\`bash fence found after anchor "${anchor}" in ${sourcePath}`);
}
const bodyStart = anchorIdx + fenceOpen.index + fenceOpen[0].length;
const closeIdx = content.indexOf('```', bodyStart);
if (closeIdx === -1) {
throw new Error(`extractFencedBashAfterAnchor: unterminated \`\`\`bash fence after anchor "${anchor}" in ${sourcePath}`);
}
return content.slice(bodyStart, closeIdx);
}
/**
* Extract the CONTIGUOUS RUN of source lines beginning with `prefix` (e.g.
* `PHASE_REQ_IDS=`) — from the first matching line, keep consuming
* subsequent lines while they ALSO start with `prefix`, and join them with
* `\n`. A single-line extraction would silently test only half a
* multi-line contract (e.g. the capture line of `X=$(...)` / `X="${X:-D}"`
* without its fallback-default line), which is exactly the "guard that
* cannot observe its own failure" class this suite exists to catch. Throws
* with a message naming the prefix and file if no matching line is found,
* so a rename/relocation fails loudly rather than silently testing nothing.
*/
function extractAssignmentBlockFor(content, prefix, sourcePath) {
const lines = content.split('\n');
const startIdx = lines.findIndex((l) => l.startsWith(prefix));
if (startIdx === -1) {
throw new Error(`extractAssignmentBlockFor: no line starting with "${prefix}" found in ${sourcePath}`);
}
const block = [];
for (let i = startIdx; i < lines.length; i += 1) {
if (!lines[i].startsWith(prefix)) break;
block.push(lines[i]);
}
return block.join('\n');
}
// ─── shared bash-script runner ────────────────────────────────────────────
/**
* Write `script` to a fresh temp file and run it via the process seam's
* `runHook(..., { interpreter: 'bash' })` — a script PATH, not a `bash -c`
* argv string, matching tests/plan-phase-stall-detection.test.cjs's
* runBashScript() (#2650: a quote-dense multi-line script passed as a single
* `-c` argv element does not survive Windows argv serialization).
*
* @param {import('node:test').TestContext} t
* @param {string} script - full script body (a shebang + `set -e` are
* prepended).
* @param {object} [options] - forwarded to runHook (cwd, env, timeoutMs).
*/
function runBashScript(t, script, options = {}) {
const scriptDir = createTempDir('msd-3409-sh-');
t.after(() => cleanup(scriptDir));
const scriptPath = path.join(scriptDir, 'script.sh');
fs.writeFileSync(scriptPath, `#!/usr/bin/env bash\nset -e\n${script}`, { mode: 0o755 });
return runHook(scriptPath, [], { interpreter: 'bash', ...options });
}
/**
* Parse `KEY=value` lines (one per line, as emitted by this file's own
* `echo "KEY=$VAR"` trailers) out of a script's stdout. Values may
* legitimately be the empty string (that IS the RED condition G1/G2/G3
* assert against), so this returns `''` rather than `undefined` when the key
* is present with nothing after `=`.
*/
function parseKeyValueStdout(stdout) {
const result = {};
for (const line of stdout.split('\n')) {
const eq = line.indexOf('=');
if (eq === -1) continue;
result[line.slice(0, eq)] = line.slice(eq + 1).replace(/\r$/, '');
}
return result;
}
// ─── fixtures ──────────────────────────────────────────────────────────────
/**
* A minimal `.planning/phases/01-foundation/` project fixture — enough for
* `phases.list` and `init.plan-phase` to resolve phase 01 without error.
* `withSummary` seeds one real `*-SUMMARY.md` file when the negative-space
* (G2) case needs a nonzero prior-summary count.
*/
function buildPhase01Fixture({ withSummary }) {
const root = createTempDir('msd-3409-fixture-');
const phaseDir = path.join(root, '.planning', 'phases', '01-foundation');
fs.mkdirSync(phaseDir, { recursive: true });
if (withSummary) {
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n\nDone.\n');
}
return root;
}
// ─── G1 / G2 — Walking Skeleton gate (plan-phase.md, #3365) ───────────────
describe('#3409 G1/G2 — plan-phase.md Walking Skeleton gate observes a real summary count', () => {
const anchor = 'Walking Skeleton gate.';
function runWalkingSkeletonGate(t, projectRoot) {
const snippet = extractFencedBashAfterAnchor(
readWorkflowCombined(PLAN_PHASE_PATH),
anchor,
PLAN_PHASE_PATH,
);
const script = [
`. "${LAUNCHER_PATH}"`,
snippet,
'echo "MSD_TEST_WALKING_SKELETON=$WALKING_SKELETON"',
'echo "MSD_TEST_PRIOR_SUMMARIES=$PRIOR_SUMMARIES"',
].join('\n');
const result = runBashScript(t, script, {
cwd: projectRoot,
env: {
...process.env,
RUNTIME_DIR: REPO_ROOT,
MVP_MODE: 'true',
padded_phase: '01',
},
// Bash FAN-OUT: the sourced `_runtime-launcher.snippet.sh` preamble
// defines the real `msd_run` function, which the extracted snippet
// then calls — a bash + node invocation, not a single CLI probe. Same
// class as the observed CI failures in
// tests/quick-branching.test.cjs (PR #3787 run 32668773524) and
// tests/worktree-safety.test.cjs (`next` run 32608945654). See
// HOOK_FANOUT_TIMEOUT_MS in ./helpers/timeouts.cjs for the class
// rationale.
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
});
assert.equal(result.outcome, OUTCOME.EXITED, `gate script did not exit cleanly: ${result.stderr}`);
assert.equal(result.exitCode, 0, `gate script exited non-zero: ${result.stderr}`);
return parseKeyValueStdout(result.stdout);
}
test('G1: zero prior summaries — the gate observes a real integer 0, and fires', (t) => {
const root = buildPhase01Fixture({ withSummary: false });
t.after(() => cleanup(root));
const { MSD_TEST_WALKING_SKELETON, MSD_TEST_PRIOR_SUMMARIES } = runWalkingSkeletonGate(t, root);
// RED on the current tree: `--pick summaries_total` names a field that
// does not exist, so msd-tools exits 0 with EMPTY stdout and the
// unreachable `|| echo "0"` arm never fires — PRIOR_SUMMARIES is `""`,
// not the integer `"0"` this asserts.
assert.equal(MSD_TEST_PRIOR_SUMMARIES, '0', 'prior-summary count must resolve to the integer 0, not empty string');
assert.equal(MSD_TEST_WALKING_SKELETON, 'true', 'a fresh phase-01 project must enter Walking Skeleton mode');
});
test('G2 (load-bearing negative space): a project WITH prior summaries does not enter skeleton mode', (t) => {
const root = buildPhase01Fixture({ withSummary: true });
t.after(() => cleanup(root));
const { MSD_TEST_WALKING_SKELETON, MSD_TEST_PRIOR_SUMMARIES } = runWalkingSkeletonGate(t, root);
// RED on the current tree: PRIOR_SUMMARIES is `""` here too (same
// unreachable-arm defect), which fails this integer check even though
// WALKING_SKELETON happens to read 'false' on the current, doubly-broken
// gate (it never fires for ANY input). This is what rejects a bad fix
// that treats "no answer" as "zero": such a fix would make
// WALKING_SKELETON fire unconditionally, which the second assertion
// below also catches.
assert.match(
MSD_TEST_PRIOR_SUMMARIES,
/^[1-9][0-9]*$/,
`prior-summary count must resolve to a nonzero integer, got ${JSON.stringify(MSD_TEST_PRIOR_SUMMARIES)}`,
);
assert.equal(MSD_TEST_WALKING_SKELETON, 'false', 'a project with prior summaries must NOT enter Walking Skeleton mode');
});
});
// ─── G3 — PHASE_REQ_IDS falls back to TBD (plan-phase.md) ─────────────────
test('#3409 G3: an empty phase_req_ids falls back to TBD, not the empty string', (t) => {
const root = createTempDir('msd-3409-g3-');
t.after(() => cleanup(root));
fs.mkdirSync(path.join(root, '.planning', 'phases', '01-foundation'), { recursive: true });
// Deliberately no REQUIREMENTS.md / ROADMAP.md — phase 01 with zero
// requirements mapped to it, so `init.plan-phase --pick phase_req_ids`
// resolves `phase_req_ids: null` and `--pick` renders that as empty stdout
// (probe-confirmed: exit 0, empty stdout).
const block = extractAssignmentBlockFor(
readWorkflowCombined(PLAN_PHASE_PATH),
'PHASE_REQ_IDS=',
PLAN_PHASE_PATH,
);
const script = [
`. "${LAUNCHER_PATH}"`,
block,
'echo "MSD_TEST_PHASE_REQ_IDS=$PHASE_REQ_IDS"',
].join('\n');
// Bash FAN-OUT: same class as runWalkingSkeletonGate above — the sourced
// launcher's `msd_run` shells out to node. See HOOK_FANOUT_TIMEOUT_MS in
// ./helpers/timeouts.cjs for the class rationale.
const result = runBashScript(t, script, {
cwd: root,
env: { ...process.env, RUNTIME_DIR: REPO_ROOT, PHASE: '01' },
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
});
assert.equal(result.outcome, OUTCOME.EXITED, `PHASE_REQ_IDS script did not exit cleanly: ${result.stderr}`);
assert.equal(result.exitCode, 0, `PHASE_REQ_IDS script exited non-zero: ${result.stderr}`);
const { MSD_TEST_PHASE_REQ_IDS } = parseKeyValueStdout(result.stdout);
// RED on the current tree: `--pick phase_req_ids` exits 0 with empty
// stdout on a `null` field, so the unreachable `|| echo TBD` arm never
// fires and PHASE_REQ_IDS resolves to `""` instead of the documented
// `TBD` sentinel (plan-phase.md: "Skip if phase_req_ids is null or TBD").
assert.equal(MSD_TEST_PHASE_REQ_IDS, 'TBD');
});
// ─── G4 — complete-milestone.md bare `cat <glob>` does not block on stdin ──
test('#3409 G4: the milestone summary read does not hang with no summaries', (t) => {
// The blocked-stdin mechanism below is a read-write FIFO opened via
// `mkfifo` — POSIX-only, and unavailable/non-functional on the
// `windows-latest` CI lane. Under `set -e` an unsupported `mkfifo` fails
// the script during setup, before the `cat` under test ever runs, so a
// Windows run would exercise nothing and must be skipped, not weakened.
if (process.platform === 'win32') {
t.skip('mkfifo-blocked-stdin reproduction is POSIX-only; unreachable on Windows');
return;
}
const root = createTempDir('msd-3409-g4-');
t.after(() => cleanup(root));
// Zero-summary milestone: a phase dir exists, but no *-SUMMARY.md file
// anywhere under it — the exact condition that makes the glob unmatched.
fs.mkdirSync(path.join(root, '.planning', 'phases', '01-foundation'), { recursive: true });
const snippet = extractFencedBashAfterAnchor(
readWorkflowCombined(COMPLETE_MILESTONE_PATH),
'Read all phase summaries:',
COMPLETE_MILESTONE_PATH,
);
// Two things this script must reproduce, both faithfully, neither
// confounded with the other:
//
// 1. `nullglob` set — not by this fenced block itself (it sets nothing),
// but by an EARLIER block in the SAME workflow file/shell session
// (`extract_accomplishments`'s `shopt -s nullglob`, a few hundred
// lines above this one). 40-design.md's B11 names this exact
// "latent option from a different block" hazard as why Detector B
// flags this site even though it never sets the option locally.
// 2. stdin genuinely blocked, not just closed. Node's spawnSync closes
// an unwritten stdin immediately (EOF) when no `input` option is
// given, which would make a zero-operand `cat` return instantly
// instead of reproducing the real hang — so this opens a FIFO
// read-write on fd 3 (a read-write open never sees EOF, because the
// process holds its own write end) and redirects fd 0 there. This
// avoids `<(process substitution)`, which would leave a background
// job holding the CAPTURED STDOUT pipe open instead — a different,
// confounding hang unrelated to the stdin defect under test. The FIFO
// lives inside a private `mktemp -d` directory (created atomically
// with mode 0700) rather than at a bare `mktemp -u` path: `-u` only
// RESERVES a name without creating it, leaving a window between the
// reservation and `mkfifo` in which another process on a shared /tmp
// could create that same path first (a symlink-race primitive) — the
// directory removes the race entirely.
const script = [
'shopt -s nullglob',
'FIFO_DIR=$(mktemp -d)',
'mkfifo "$FIFO_DIR/f"',
'exec 3<> "$FIFO_DIR/f"',
'rm -rf "$FIFO_DIR"',
'exec 0<&3',
snippet,
].join('\n');
const result = runBashScript(t, script, { cwd: root, timeoutMs: G4_TIMEOUT_MS });
// RED on the current tree: the bare `cat <glob>` reads from the blocked
// stdin and never returns within G4_TIMEOUT_MS, so `outcome` is
// TIMED_OUT. Asserting on `outcome` (never `signal`) per CONTRIBUTING.md's
// process-seam guidance — a timeout and a maxBuffer overflow both report
// SIGTERM, and only `outcome` discriminates them.
assert.equal(
result.outcome,
OUTCOME.EXITED,
`expected the summary read to complete, got outcome=${result.outcome} stderr=${result.stderr}`,
);
// A nonzero exit here means the script's own setup (mkfifo/exec/mktemp)
// failed under `set -e` and the process exited immediately — which also
// reports outcome=EXITED, so it would silently pass the assertion above
// without ever reaching the `cat` under test. Pinning exitCode===0
// distinguishes "setup failed" from "the blocked read actually completed".
assert.equal(
result.exitCode,
0,
`expected setup (mkfifo/exec/mktemp) to succeed and the read to complete cleanly, got exitCode=${result.exitCode} stderr=${result.stderr}`,
);
});
// Sanity: the module under test actually exists at the path every fixture
// above points `RUNTIME_DIR`/`msd_run` at — a moved/renamed CLI would
// otherwise make every test above fail with a confusing "msd-tools.cjs not
// found" error deep inside a bash script instead of a clear assertion here.
test('#3409: msd-tools.cjs exists at the path this suite drives msd_run through', () => {
assert.equal(fs.existsSync(MSD_TOOLS_PATH), true, `expected ${MSD_TOOLS_PATH} to exist`);
});