Files
msd-core/tests/qa/loop-walk.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

331 lines
15 KiB
JavaScript

'use strict';
/**
* loop-walk.cjs — a small stateful harness around `tests/helpers.cjs` for the
* loop QA walk. This module owns NONE of the subprocess mechanics itself
* (spawn, retry-on-kill, quote-splitting) — those live in `runMsdTools` /
* `createTempGitProject` and are reused verbatim. What this module adds is
* the walk-specific concerns: turning a raw helper result into the typed
* `RunResult` from `./result.cjs`, guaranteeing the child never inherits an
* ambient `MSD_*` variable that would silently redirect the engine, pinning
* the clock so output is reproducible run-to-run, and giving callers a
* content-free way to observe what the SUT wrote to disk.
*/
const fs = require('fs');
const path = require('path');
const { runMsdTools, cleanup } = require('../helpers.cjs');
const { classify } = require('./result.cjs');
const { createFixture } = require('../fixtures/index.cjs');
const { LOOP_HOST_CONTRACT } = require('../../msd-core/bin/lib/loop-host-contract.cjs');
const { resolveWithin } = require('./paths.cjs');
/**
* WHY re-derive rather than re-list: `loop-host-contract.cjs` is itself
* generated (see its own header) from workflow markers by
* `scripts/gen-loop-host-contract.cjs`. If this file hardcoded
* `['discuss', 'plan', 'execute', 'verify', 'ship']`, a future regeneration
* that renames, adds, or removes a step would leave the QA walk silently
* testing a stale step list — a second source of truth that can drift out
* from under the generated one with no signal anywhere. Deriving `LOOP_STEPS`
* from the same contract object the generator produced means drift is
* structurally impossible: this array always has exactly the steps the
* contract currently declares.
*
* @type {string[]}
*/
const LOOP_STEPS = LOOP_HOST_CONTRACT.map((entry) => entry.step);
/** Default pinned clock value (2026-01-01T00:00:00.000Z) — see `create({nowMs})`. */
const DEFAULT_NOW_MS = 1767225600000;
/**
* Starting-world builders keyed by `fixture` name, each a thin wrapper over
* `createFixture` (`tests/fixtures/index.cjs`).
*
* WHY this exists at all: `createFixture`'s `projectDoc` option defaults to
* `projectDoc = git` (`tests/fixtures/index.cjs:21`) — so any caller that
* only passes `git: true` silently also gets `.planning/PROJECT.md` seeded.
* A "greenfield" walk built that way would report `project_exists: true`
* from its very first step, which makes the greenfield trajectory
* meaningless: it can never exercise the create path (`/msd-new-project`)
* because a project already exists before the walk begins. Every entry
* below therefore states `projectDoc` explicitly rather than relying on
* that default, and `greenfield` is the harness default precisely because
* an empty git repo with no `.planning/` at all is what a real user's
* working tree looks like before running `/msd-new-project` for the first
* time.
*
* @type {Record<string, (prefix: string) => string>}
*/
const FIXTURE_BUILDERS = {
// A git repo with NO `.planning/` at all — what a user has before
// `/msd-new-project`.
greenfield: (prefix) => createFixture({ prefix, git: true, planning: false, projectDoc: false }),
// `.planning/phases/` exists, but no PROJECT.md yet.
planning: (prefix) => createFixture({ prefix, git: true, planning: true, projectDoc: false }),
// The old `createTempGitProject` behavior: a fully seeded project.
seeded: (prefix) => createFixture({ prefix, git: true, planning: true, projectDoc: true }),
};
/**
* Recursively collect `{size, mtimeMs}` stat facts for every regular file
* under `root`, excluding `.git/`.
*
* WHY stat-only, never read: `RULESET.TESTS.no-source-grep.tmp-file-traps`
* (see `result.cjs` header) forbids reading the content of files the SUT
* (system under test) wrote and then string-matching against it — that
* pattern is exactly the raw-text-matching anti-pattern the project's test
* conventions ban, just relocated from stdout to disk. `fs.statSync` proves
* a file exists, changed size, or changed mtime without ever opening its
* content, so a walk can assert "did this step write/touch a file" without
* ever being tempted into `readFileSync(...).includes(...)`.
*
* @param {string} root - absolute directory to walk.
* @returns {Map<string, {size: number, mtimeMs: number}>} keyed by
* POSIX-normalized path relative to `root`.
*/
function collectStatSnapshot(root) {
/** @type {Map<string, {size: number, mtimeMs: number}>} */
const out = new Map();
function walk(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === '.git') continue;
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(abs);
continue;
}
if (!entry.isFile()) continue;
const stat = fs.statSync(abs);
// WHY unconditional replace, not platform-conditional: `path.sep` is
// '/' on POSIX so a conditional swap looks like a no-op there, but a
// path segment can still literally contain a backslash character
// (e.g. an artifact file the SUT names with one) on Linux — so the
// normalization must run every time, not only when path.sep === '\\'.
const rel = path.relative(root, abs).replace(/\\/g, '/');
out.set(rel, { size: stat.size, mtimeMs: stat.mtimeMs });
}
}
walk(root);
return out;
}
class LoopWalk {
/**
* @param {string} dir - absolute project root (an already-created temp git project).
* @param {number} nowMs - pinned epoch ms passed to every `run()` as `MSD_NOW_MS`.
*/
constructor(dir, nowMs) {
this.dir = dir;
this.nowMs = nowMs;
}
/**
* Alias for `this.dir` under the name `oracles.cjs`'s `ctx.projectDir`
* expects (see `value-hygiene`'s absolute-path-leak smell check). Kept as
* a getter rather than a second stored field so the two can never drift.
*
* @returns {string}
*/
get projectDir() {
return this.dir;
}
/**
* Create a fresh temp git project and a `LoopWalk` bound to it.
*
* WHY `fixture` defaults to `'greenfield'`, not the old seeded behavior:
* see `FIXTURE_BUILDERS` above — a walk that starts with
* `.planning/PROJECT.md` already present can never exercise the
* project-creation path, which is the whole point of a "greenfield" walk.
* An unrecognized `fixture` name throws rather than silently falling back
* to a default, because a typo'd fixture name silently testing the wrong
* starting world is exactly the failure this harness exists to catch.
*
* @param {{prefix?: string, nowMs?: number, fixture?: 'greenfield'|'planning'|'seeded'}} [opts]
* @returns {LoopWalk}
*/
static create(opts = {}) {
const { prefix = 'msd-loop-walk-', nowMs = DEFAULT_NOW_MS, fixture = 'greenfield' } = opts;
const build = FIXTURE_BUILDERS[fixture];
if (!build) {
throw new Error(
`LoopWalk.create: unknown fixture "${fixture}" (expected one of: ${Object.keys(FIXTURE_BUILDERS).join(', ')})`
);
}
const dir = build(prefix);
return new LoopWalk(dir, nowMs);
}
/**
* Run a `msd-tools` invocation inside this walk's project and return the
* typed `RunResult` from `classify()`.
*
* SIGNATURE: `run(...argvTokens)` where the LAST argument, if it is a
* plain object (not a string), is stripped off and treated as an options
* bag rather than an argv token — so `walk.run('progress')` and
* `walk.run('progress', { jsonErrors: false })` both read naturally
* against every existing call site in this repo (`walk.run(...argv)` in
* `scenario.cjs`, `walk.run('progress')` in the self-tests) without
* requiring callers to restructure a spread argv array around a leading
* options object.
*
* WHY `jsonErrors` defaults to `true`: `--json-errors` (`docs/json-errors.md`)
* is a real CLI flag, but it is the TOOLING/TEST surface — a human or a real
* workflow invokes `msd_run <cmd>` directly, WITHOUT it. Defaulting to
* `true` keeps every pre-existing call site's behavior byte-for-byte
* unchanged (they all exercised `--json-errors` before this option
* existed), while `{ jsonErrors: false }` lets a scenario step opt into
* driving the human path instead — otherwise the harness would only ever
* prove the tooling surface works and could never catch a regression a
* real user would actually hit.
*
* WHY the env is built the way it is (ambient `MSD_*` sanitization):
* `runMsdTools` composes the child env as
* `{ ...process.env, ...TEST_ENV_BASE, ...env }` — so whatever `MSD_*`
* variables happen to be set in the *parent* shell (e.g. a developer or CI
* runner with `MSD_WORKSTREAM` / `MSD_PROJECT` exported for an unrelated
* reason) flow straight through into the child and can silently redirect
* the engine at a different workstream or project root than the one this
* walk created — an invisible, non-deterministic test-pollution vector.
* `runMsdTools`'s own merge order means the last object spread wins, so
* this method builds an `env` override that sets EVERY ambient `MSD_*` key
* (scanned live from `process.env`, not a hardcoded list — a new leaking
* var needs no code change here to be caught) to `undefined`, then layers
* the two intentionally-pinned vars on top.
*
* The `undefined` trick is deliberate, not a placeholder: Node's child
* process env normalization (`lib/child_process.js` `normalizeSpawnArgs`,
* exercised here via `execFileSync`) iterates `Object.keys(env)` and
* OMITS any key whose value is `undefined` from the actual `KEY=VALUE`
* pairs handed to the OS — it does not stringify it to the literal text
* `"undefined"`. That means `{ MSD_WORKSTREAM: undefined }` in the `env`
* option makes the child process behave exactly as if `MSD_WORKSTREAM`
* were never exported at all, even though `process.env.MSD_WORKSTREAM` is
* still set and non-empty in the parent. This was verified empirically
* (not assumed) — see the module verification transcript — because
* `delete`-based approaches were not available here (the merge is inside
* `runMsdTools`, not under this method's control) and a stringified
* `"undefined"` would have been a silent correctness bug indistinguishable
* from a passing run until an actual leak test caught it.
*
* ⚠️ KNOWN LIMIT — SUCCESS-PATH STDERR IS NOT OBSERVABLE THROUGH THIS
* SUBSTRATE, SO `result.warnings` IS ERROR-PATH-ONLY TODAY: this method
* builds `raw.stderr` as `result.success ? '' : (result.error ?? '')`
* (below), and `runMsdTools` (`tests/helpers.cjs`) invokes the child via
* `execFileSync`, which discards the child's stderr stream entirely on a
* clean (non-throwing) exit — Node never captures it, so there is no text
* to forward even if this method wanted to. The practical effect: for any
* exit-0 invocation, `classify()` always receives `stderr: ''`, so
* `result.warnings` can never be non-empty on the success path, no matter
* what the real `msd-tools` process actually wrote to stderr. `warnings`
* only ever populates on the exit-1 (error) path, where `result.error`
* (helpers.cjs's captured stderr-on-failure text) is threaded through.
* Capturing success-path stderr would require changing `runMsdTools` /
* `tests/helpers.cjs` (e.g. to `spawnSync`) — out of scope here because
* that helper is shared by ~131 test files. DO NOT build an oracle that
* assumes `.warnings` reflects success-path stderr; it structurally cannot
* today, and a check written against that assumption is silently vacuous.
*
* @param {...(string|{jsonErrors?: boolean})} args - argv tokens, optionally
* followed by a trailing `{jsonErrors?: boolean}` options object.
* @returns {ReturnType<typeof classify>}
*/
run(...args) {
const trailing = args[args.length - 1];
const hasOptions = trailing !== null && typeof trailing === 'object' && !Array.isArray(trailing);
const options = hasOptions ? trailing : {};
const argvTokens = hasOptions ? args.slice(0, -1) : args;
const { jsonErrors = true } = options;
const argv = jsonErrors ? ['--json-errors', ...argvTokens] : argvTokens;
/** @type {Record<string, string|undefined>} */
const sanitize = {};
for (const key of Object.keys(process.env)) {
if (key.startsWith('MSD_')) sanitize[key] = undefined;
}
const env = {
...sanitize,
MSD_TEST_MODE: '1',
MSD_NOW_MS: String(this.nowMs),
};
let raw;
try {
const result = runMsdTools(argv, this.dir, env);
raw = {
exitCode: result.exitCode,
stdout: result.output,
stderr: result.success ? '' : (result.error ?? ''),
timedOut: false,
argv,
};
} catch {
// `runMsdTools` throws only after a retried, persistent subprocess
// kill (host OOM / scheduler contention — see helpers.cjs
// `throwResourceStarvation`). That is a statement about the HOST, not
// the engine under test, so a walk must degrade to a TIMEOUT result
// rather than propagate and abort the whole walk over a transient
// resource condition it cannot control.
raw = { exitCode: null, stdout: '', stderr: '', timedOut: true, argv };
}
return classify(raw);
}
/**
* Write a planning artifact into this walk's project, standing in for what
* a real agent (researcher/planner/executor/...) would produce mid-loop.
* Creates parent directories as needed.
*
* `relPath` is resolved via `resolveWithin` before any I/O — a scenario- or
* caller-supplied path that escapes `this.dir` (e.g. `"../../escaped.md"`)
* throws rather than reaching `fs.writeFileSync` outside the temp project.
*
* @param {string} relPath - path relative to `this.dir`.
* @param {string} content
*/
writeArtifact(relPath, content) {
const abs = resolveWithin(this.dir, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, 'utf-8');
}
/**
* Content-free snapshot of every file under this walk's project (excluding
* `.git/`). See `collectStatSnapshot` for why this never reads file bytes.
*
* @returns {Map<string, {size: number, mtimeMs: number}>}
*/
statSnapshot() {
return collectStatSnapshot(this.dir);
}
/**
* Remove this walk's temp project. Safe to call multiple times.
*
* `opts.keep` (default `false`) skips the removal entirely — the caller
* (a QA-report run, typically via `--keep` / `MSD_QA_KEEP=1`) wants the
* failing/inspected tree left on disk for a human to `cd` into. When kept,
* this returns `this.dir` so the caller can record it (e.g. as
* `preservedDir` on a scenario report); when actually cleaned up, it
* returns `undefined`.
*
* @param {{keep?: boolean}} [opts]
* @returns {string|undefined}
*/
cleanup(opts = {}) {
const { keep = false } = opts;
if (keep) return this.dir;
cleanup(this.dir);
return undefined;
}
}
LoopWalk.LOOP_STEPS = LOOP_STEPS;
module.exports = { LoopWalk, LOOP_STEPS };