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.
211 lines
8.7 KiB
JavaScript
211 lines
8.7 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* result.cjs — the typed `RunResult` IR for the loop QA walk.
|
|
*
|
|
* WHY THIS FILE EXISTS
|
|
* ────────────────────
|
|
* `CONTRIBUTING.md` → "Prohibited: Raw Text Matching on Test Outputs" forbids
|
|
* asserting on a child process's stdout/stderr text, and
|
|
* `RULESET.TESTS.no-source-grep.tmp-file-traps` forbids reading tmp files the
|
|
* SUT wrote. An oracle therefore may never look at raw output. This module is
|
|
* the single place where raw bytes are turned into a typed value; everything
|
|
* downstream asserts on `kind` and on parsed fields, never on prose.
|
|
*
|
|
* The classification is deliberately strict (Postel inverted): every observed
|
|
* shape gets an explicit `kind`, so oracles need no leniency. Leniency in an
|
|
* oracle is how a QA tool reports green on a broken engine.
|
|
*
|
|
* Every KIND below corresponds to a shape the real CLI actually produces; each
|
|
* was observed against `msd-tools`, not imagined. See
|
|
* `.msd/phase/test-2966-loop-qa-walk/40-design.md` for the behavior table.
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
|
|
/**
|
|
* Frozen classification of a single `msd-tools` invocation.
|
|
*
|
|
* Tests assert on these constants, never on their string values, so renaming a
|
|
* value is a one-line change here rather than a sweep through the suite.
|
|
*/
|
|
const KIND = Object.freeze({
|
|
/** exit 0, stdout parsed as JSON (object OR scalar OR array). */
|
|
JSON: 'json',
|
|
/** exit 0, stdout non-empty but not JSON — an untyped success. NOT an error. Requires non-empty stdout. */
|
|
PROSE: 'prose',
|
|
/** exit 0, no stdout payload. NOT a crash. stderr may still carry warnings. */
|
|
EMPTY: 'empty',
|
|
/** exit 0, stdout JSON carrying an `error` key — the documented soft-failure idiom. */
|
|
SOFT_ERROR: 'soft-error',
|
|
/** exit 1, last stderr line parsed as `{ok:false,reason,message}`. */
|
|
STRUCTURED_ERROR: 'structured-error',
|
|
/** exit 1, stderr present but not parseable as the structured envelope. */
|
|
UNSTRUCTURED_ERROR: 'unstructured-error',
|
|
/** exit code outside {0,1}. */
|
|
UNEXPECTED_EXIT: 'unexpected-exit',
|
|
/** subprocess exceeded its timeout — degraded, never fatal to the walk. */
|
|
TIMEOUT: 'timeout',
|
|
});
|
|
|
|
/**
|
|
* `io.cjs` `output()` diverts payloads above this many characters to a temp
|
|
* file and writes `@file:<path>` to stdout instead. A classifier that does not
|
|
* know this reports NOT-JSON for perfectly healthy commands as soon as a
|
|
* scenario's payload grows past the threshold.
|
|
*/
|
|
const FILE_POINTER_PREFIX = '@file:';
|
|
|
|
/**
|
|
* Parse text as JSON, returning a sentinel rather than throwing.
|
|
*
|
|
* @param {string} text
|
|
* @returns {{ ok: true, value: unknown } | { ok: false }}
|
|
*/
|
|
function tryParseJson(text) {
|
|
if (typeof text !== 'string' || text.trim() === '') return { ok: false };
|
|
try {
|
|
return { ok: true, value: JSON.parse(text) };
|
|
} catch {
|
|
return { ok: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the last non-empty line of a stream.
|
|
*
|
|
* Load-bearing: `msd-tools` writes operator warnings to stderr *before* the
|
|
* structured error envelope — e.g.
|
|
* `msd-tools: warning: unknown config key(s) in .planning/config.json: …`
|
|
* followed by `{"ok":false,…}`. Parsing the whole stderr blob therefore fails
|
|
* on a command that is in fact conforming. During research this exact shape
|
|
* produced a false positive against `review-lane`, which is why this function
|
|
* exists instead of a bare `JSON.parse(stderr)`.
|
|
*
|
|
* @param {string} text
|
|
* @returns {string} last non-empty line, or '' when there is none
|
|
*/
|
|
function lastNonEmptyLine(text) {
|
|
if (typeof text !== 'string') return '';
|
|
const lines = text.split('\n');
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const line = lines[i].trim();
|
|
if (line !== '') return line;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Resolve stdout that may be an `@file:` pointer into the payload text.
|
|
*
|
|
* IO failure here is reported, never thrown: a walk that dies because a
|
|
* pointee vanished tells you nothing about the engine under test.
|
|
*
|
|
* @param {string} stdout
|
|
* @param {{ readFileSync?: typeof fs.readFileSync }} [io] injection seam for
|
|
* fault tests — `chmod 0o000` is a no-op under root and is banned.
|
|
* @returns {{ text: string, pointer: string | null, unreadable: boolean }}
|
|
*/
|
|
function resolveFilePointer(stdout, io = {}) {
|
|
const readFileSync = io.readFileSync || fs.readFileSync;
|
|
const trimmed = typeof stdout === 'string' ? stdout.trim() : '';
|
|
if (!trimmed.startsWith(FILE_POINTER_PREFIX)) {
|
|
return { text: stdout, pointer: null, unreadable: false };
|
|
}
|
|
const pointer = trimmed.slice(FILE_POINTER_PREFIX.length);
|
|
try {
|
|
return { text: readFileSync(pointer, 'utf-8'), pointer, unreadable: false };
|
|
} catch {
|
|
return { text: '', pointer, unreadable: true };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Classify a raw invocation into the typed IR.
|
|
*
|
|
* Precedence is deliberate and asserted by the test matrix:
|
|
* timeout > unexpected exit > exit 1 (error family) > exit 0 (success family)
|
|
* An exit-1 run with a healthy-looking stdout payload is still an error — the
|
|
* exit code outranks the payload.
|
|
*
|
|
* CLASSIFICATION KEYS OFF STDOUT, NOT STDERR (exit-0 family): whether a
|
|
* successful run is EMPTY, PROSE, JSON, or SOFT_ERROR depends solely on the
|
|
* (file-pointer-resolved) stdout payload. stderr on the exit-0 path is never
|
|
* used to pick a `kind` — including when stdout is empty and stderr is not —
|
|
* it is only ever surfaced through `warnings` (see below).
|
|
*
|
|
* @param {{ exitCode: number|null, stdout: string, stderr: string, timedOut?: boolean, argv?: string[] }} raw
|
|
* @param {{ readFileSync?: typeof fs.readFileSync }} [io]
|
|
* @returns {{
|
|
* kind: string, exitCode: number|null, argv: string[],
|
|
* json: unknown, err: object|null, pointer: string|null,
|
|
* warnings: string[],
|
|
* }}
|
|
* `warnings` is populated only when the substrate actually supplied stderr
|
|
* text — today that means the exit-1 (error) family, since `loop-walk.cjs`'s
|
|
* `run()` discards stderr on a clean exit (`execFileSync` swallows it). See
|
|
* the JSDoc on `warnings` usage in `tests/qa/loop-walk.cjs` `run()` for the
|
|
* full explanation; do not build an oracle assuming success-path stderr is
|
|
* observable through that substrate.
|
|
*/
|
|
function classify(raw, io = {}) {
|
|
const argv = Array.isArray(raw.argv) ? raw.argv : [];
|
|
const stderr = typeof raw.stderr === 'string' ? raw.stderr : '';
|
|
const base = { exitCode: raw.exitCode, argv, json: null, err: null, pointer: null, warnings: [] };
|
|
|
|
if (raw.timedOut) return { ...base, kind: KIND.TIMEOUT };
|
|
if (raw.exitCode !== 0 && raw.exitCode !== 1) return { ...base, kind: KIND.UNEXPECTED_EXIT };
|
|
|
|
const stderrLines = stderr.split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
|
|
if (raw.exitCode === 1) {
|
|
// Warnings are every stderr line except the last: the last line is
|
|
// consumed below as the candidate structured-error envelope, so it is
|
|
// not itself a warning.
|
|
const warnings = stderrLines.slice(0, Math.max(0, stderrLines.length - 1));
|
|
const parsed = tryParseJson(lastNonEmptyLine(stderr));
|
|
const envelope = parsed.ok && parsed.value !== null && typeof parsed.value === 'object'
|
|
&& parsed.value.ok === false && typeof parsed.value.reason === 'string';
|
|
return envelope
|
|
? { ...base, kind: KIND.STRUCTURED_ERROR, err: parsed.value, warnings }
|
|
: { ...base, kind: KIND.UNSTRUCTURED_ERROR, warnings };
|
|
}
|
|
|
|
// exit 0: no envelope is ever parsed out of stderr, so every stderr line is
|
|
// a warning candidate — none of it is consumed the way exit-1's last line is.
|
|
const warnings = stderrLines;
|
|
|
|
const resolved = resolveFilePointer(raw.stdout, io);
|
|
if (resolved.unreadable) {
|
|
return { ...base, kind: KIND.UNSTRUCTURED_ERROR, pointer: resolved.pointer, warnings };
|
|
}
|
|
const text = typeof resolved.text === 'string' ? resolved.text : '';
|
|
// Classification keys off STDOUT ONLY: a non-empty stderr with empty stdout
|
|
// is still EMPTY (see module header + KIND.EMPTY docstring above), and the
|
|
// stderr content is preserved in `warnings`, never dropped.
|
|
if (text.trim() === '') return { ...base, kind: KIND.EMPTY, warnings };
|
|
|
|
const parsed = tryParseJson(text);
|
|
if (!parsed.ok) return { ...base, kind: KIND.PROSE, pointer: resolved.pointer, warnings };
|
|
|
|
// `typeof null === 'object'`, and an array is an object too — both must fall
|
|
// through to JSON rather than be probed for an `error` key.
|
|
const isPlainObject = parsed.value !== null
|
|
&& typeof parsed.value === 'object'
|
|
&& !Array.isArray(parsed.value);
|
|
const kind = isPlainObject && Object.prototype.hasOwnProperty.call(parsed.value, 'error')
|
|
? KIND.SOFT_ERROR
|
|
: KIND.JSON;
|
|
|
|
return { ...base, kind, json: parsed.value, pointer: resolved.pointer, warnings };
|
|
}
|
|
|
|
module.exports = {
|
|
KIND,
|
|
FILE_POINTER_PREFIX,
|
|
classify,
|
|
lastNonEmptyLine,
|
|
resolveFilePointer,
|
|
tryParseJson,
|
|
};
|