ADR-3889 terminal phase. Generated docs/reference/exit-codes.md from the exit-code declaration with a --check drift arm; deleted the inert soft-error-exit-zero oracle; promoted untyped-success from SMELL to VIOLATION so it can fail a build; pruned all 5 smell-baseline entries. Fixed inline: two mis-scoped oracles (routing-validity, value-hygiene), a second source behind the band table, unescaped declaration strings reaching Markdown, and a pre-existing Windows 8.3 short-name path-comparison defect. Guard ledger corrected from a claimed net -4 to a measured net -1. Closes #3913
741 lines
35 KiB
JavaScript
741 lines
35 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* oracles.cjs — the QA-walk assertion set for `RunResult` values produced by
|
|
* `tests/qa/result.cjs`.
|
|
*
|
|
* WHY THIS FILE EXISTS
|
|
* ────────────────────
|
|
* `CONTRIBUTING.md` → "Prohibited: Raw Text Matching on Test Outputs" and
|
|
* `RULESET.TESTS.no-source-grep.tmp-file-traps` forbid two things an oracle
|
|
* might otherwise reach for: (1) inspecting a child process's raw stdout /
|
|
* stderr text, and (2) reading the *content* of a file the system under test
|
|
* wrote. `result.cjs` is the single place raw bytes are turned into the typed
|
|
* `RunResult` (`{kind, exitCode, argv, json, err, pointer, warnings}`); every
|
|
* oracle in this file therefore asserts ONLY on:
|
|
*
|
|
* - `RunResult.kind` (one of the frozen `KIND` values),
|
|
* - parsed `json` / `err` fields (already-structured data, never prose),
|
|
* - `fs.statSync` FACTS handed in via `ctx.statsBefore` / `ctx.statsAfter`
|
|
* (`size`, `mtimeMs`, `isFile()`) — never file *contents*.
|
|
*
|
|
* No oracle here calls `fs.readFileSync` on an SUT-written artifact, and none
|
|
* does substring/regex matching against a raw output string. Where a check
|
|
* looks like it could be tempted into substring matching (value-hygiene,
|
|
* below) it deliberately uses strict equality against a closed sentinel set
|
|
* instead, which is what keeps it honest.
|
|
*
|
|
* ORACLE 3 AND ABSOLUTE PATHS
|
|
* ───────────────────────────
|
|
* `value-hygiene` flags a string only when it is *exactly* one of
|
|
* `'undefined' | 'null' | 'NaN' | '[object Object]'` (the canonical
|
|
* stringification artifacts of a real bug — a coercion that dropped a value).
|
|
* It never does a substring/`.includes()` scan for a fragment like `null` or
|
|
* `undefined` inside a larger string. That distinction matters because
|
|
* `init` legitimately returns absolute filesystem paths, and an absolute path
|
|
* can coincidentally *contain* a directory or file segment that looks like
|
|
* one of those words (e.g. a user-named directory). A substring scan would
|
|
* flag those as defects; a strict-equality scan against the closed sentinel
|
|
* set structurally cannot, because a real path is never bit-for-bit equal to
|
|
* `'undefined'` etc. False positives are worse than a missed defect for a QA
|
|
* tool — they train operators to ignore the tool — so this oracle is
|
|
* deliberately conservative.
|
|
*
|
|
* SEVERITY MODEL: VIOLATION vs SMELL
|
|
* ───────────────────────────────────
|
|
* The original nine oracles encoded today's engine behavior as the spec:
|
|
* anything the engine currently does was, by construction, "legal" — the
|
|
* harness could confirm the status quo but never say "this works but is
|
|
* questionable." `SEVERITY` splits `check(ctx)` outcomes into two kinds:
|
|
*
|
|
* - `SEVERITY.VIOLATION` — the documented contract is broken. This is what
|
|
* `failed` (and therefore `failed.length === 0` build gates) has always
|
|
* meant, and it keeps meaning exactly that after this change.
|
|
* - `SEVERITY.SMELL` — behavior that is legal today, does not fail any
|
|
* documented contract, but is evidence worth a human's attention (a
|
|
* design trade-off, a doc/code disagreement, a leaking value). A smell
|
|
* is reported in `runOracles(ctx).smells` and MUST NEVER appear in
|
|
* `failed` — it must never break a build. Its only job is to keep a
|
|
* known trade-off visible instead of silently invisible.
|
|
*
|
|
* A `check(ctx)` that fails now returns `{ ok: false, severity, detail }`;
|
|
* `severity` defaults to `SEVERITY.VIOLATION` for every original oracle.
|
|
*/
|
|
|
|
const nodePath = require('node:path');
|
|
const { KIND } = require('./result.cjs');
|
|
const { resolveForCompare, isUnderProjectDir } = require('./paths.cjs');
|
|
const {
|
|
LIVE_COMMAND_TOKEN_PREFIXES,
|
|
getLiveCommandTokens,
|
|
firstToken,
|
|
isLiveCommandToken,
|
|
} = require('../helpers/live-command-registry.cjs');
|
|
|
|
/** Severity of a failed oracle outcome. A SMELL is evidence, not a verdict — it never fails a build. */
|
|
const SEVERITY = Object.freeze({ VIOLATION: 'violation', SMELL: 'smell' });
|
|
|
|
/** Exact-match sentinel strings that indicate a value was coerced by mistake. */
|
|
const SENTINEL_STRINGS = new Set(['undefined', 'null', 'NaN', '[object Object]']);
|
|
|
|
/**
|
|
* Leaf JSON-key names whose CONTRACT is to point OUTSIDE the project directory —
|
|
* `value-hygiene`'s absolute-path-leak sub-check allowlists these by LEAF key
|
|
* name only (never by value, never by full path), so a legitimately-external
|
|
* field never manufactures a finding. This is an allowlist of contractually
|
|
* external fields, NOT a general suppression: adding a key here requires
|
|
* actually knowing that field's contract — that EVERY value it ever holds is
|
|
* expected to live outside `ctx.projectDir` — not just that it happened to fire
|
|
* once. The sub-check still fires (as a SMELL) for any absolute path outside
|
|
* the project on a key NOT in this set — see #2966 FIX 1.
|
|
*
|
|
* - `agents_dir` — `agent-install-check.cts`'s `getAgentsDir` /
|
|
* `checkAgentsInstalled`, surfaced on every `init` subcommand's response via
|
|
* `init.cts`'s `withProjectRoot`. Points at the INSTALL tree (the runtime's
|
|
* global config dir, or — for the `claude` runtime — the `agents/` directory
|
|
* bundled as a sibling of `gsd-core/`), never at the project: agents are
|
|
* installed once, not per-project.
|
|
*/
|
|
const EXTERNAL_PATH_ALLOWED_KEYS = Object.freeze(new Set(['agents_dir']));
|
|
|
|
/**
|
|
* Strip the walk()-root prefix (`"$."`) from a `walk()`-built path, e.g.
|
|
* `"$.next.command"` -> `"next.command"`, `"$.actions[0].command"` ->
|
|
* `"actions[0].command"` — the field-naming convention `routing-validity`'s
|
|
* `subject.key` has always used, kept stable across the switch to a generic
|
|
* `walk()`-based scan (#3913 P9 SEC-2).
|
|
*
|
|
* @param {string} walkPath
|
|
* @returns {string}
|
|
*/
|
|
function stripRootPrefix(walkPath) {
|
|
return walkPath.startsWith('$.') ? walkPath.slice(2) : walkPath;
|
|
}
|
|
|
|
/**
|
|
* Extract the leaf key name from a `walk()`-built path (e.g. `"$.agents_dir"`
|
|
* -> `"agents_dir"`, `"$.foo.bar[3]"` -> `"bar"` for the array element itself,
|
|
* `"$.arr[3]"` -> `"arr"` is NOT how this parses — array indices are not key
|
|
* names, so a leaf under an array index has no matching allowlist entry by
|
|
* design; only a genuine object key can match `EXTERNAL_PATH_ALLOWED_KEYS`.
|
|
*
|
|
* @param {string} walkPath
|
|
* @returns {string}
|
|
*/
|
|
function leafKeyOf(walkPath) {
|
|
const segments = walkPath.split('.');
|
|
const last = segments[segments.length - 1];
|
|
const bracketIdx = last.indexOf('[');
|
|
return bracketIdx === -1 ? last : last.slice(0, bracketIdx);
|
|
}
|
|
|
|
/** `json` field names checked by `monotonic-progress`, in no particular order. */
|
|
const PROGRESS_KEYS = ['total_plans', 'total_summaries', 'phases_completed'];
|
|
|
|
/**
|
|
* Extract the active workstream id from an argv array, e.g.
|
|
* `['--json-errors', '--ws', 'alpha', 'progress']` -> `'alpha'`. Returns `null`
|
|
* when no `--ws` flag is present (the default/unnamed workstream) — never
|
|
* `undefined`, so two "no workstream" entries compare equal via `===`.
|
|
*
|
|
* @param {unknown} argv
|
|
* @returns {string | null}
|
|
*/
|
|
function workstreamFromArgv(argv) {
|
|
if (!Array.isArray(argv)) return null;
|
|
const idx = argv.indexOf('--ws');
|
|
if (idx === -1 || idx + 1 >= argv.length) return null;
|
|
const value = argv[idx + 1];
|
|
return typeof value === 'string' ? value : null;
|
|
}
|
|
|
|
/**
|
|
* The comparability scope for a `monotonic-progress` entry: two entries are
|
|
* comparable only when their milestone (`milestone_version` + `milestone_name`,
|
|
* the fields a real `progress`/`stats` payload actually exposes — see
|
|
* `roadmap-parser.cts` `getMilestoneInfo`) AND active workstream (derived from
|
|
* the invocation's own `argv`, since no payload observed in this codebase
|
|
* carries a workstream field) all match. `milestone_version` alone is NOT
|
|
* sufficient: a fixture/project whose ROADMAP.md never carries an explicit
|
|
* `vX.Y` marker keeps reporting the same fallback `"v1.0"`/`"milestone"` pair
|
|
* across a real milestone-complete boundary until a roadmap for the *next*
|
|
* milestone is actually written (verified empirically against `milestone
|
|
* complete` — see scenarios/milestone-rollover.json) — `milestone_name` is
|
|
* threaded in alongside version for the same reason value-hygiene documents
|
|
* elsewhere in this file: a cheap, purely-structural signal is preferred over
|
|
* inferring intent from command names.
|
|
*
|
|
* @param {{json?: unknown, argv?: unknown}} entry
|
|
* @returns {{milestoneVersion: unknown, milestoneName: unknown, workstream: string | null}}
|
|
*/
|
|
function progressScopeOf(entry) {
|
|
const json = entry && entry.json;
|
|
const isPlainObject = json !== null && typeof json === 'object' && !Array.isArray(json);
|
|
return {
|
|
milestoneVersion: isPlainObject ? json.milestone_version : undefined,
|
|
milestoneName: isPlainObject ? json.milestone_name : undefined,
|
|
workstream: workstreamFromArgv(entry && entry.argv),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {ReturnType<typeof progressScopeOf>} a
|
|
* @param {ReturnType<typeof progressScopeOf>} b
|
|
* @returns {boolean}
|
|
*/
|
|
function scopeEqual(a, b) {
|
|
return a.milestoneVersion === b.milestoneVersion
|
|
&& a.milestoneName === b.milestoneName
|
|
&& a.workstream === b.workstream;
|
|
}
|
|
|
|
/**
|
|
* Deep-walk an arbitrary JSON-ish value, invoking `visit(primitive, path)` for
|
|
* every non-object leaf. Cycle-safe via a `WeakSet` of visited objects/arrays,
|
|
* so a self-referential structure terminates instead of recursing forever.
|
|
*
|
|
* @param {unknown} value
|
|
* @param {(leaf: unknown, path: string) => void} visit
|
|
* @param {WeakSet<object>} seen
|
|
* @param {string} path
|
|
*/
|
|
function walk(value, visit, seen, path) {
|
|
if (value === null || typeof value !== 'object') {
|
|
visit(value, path);
|
|
return;
|
|
}
|
|
if (seen.has(value)) return;
|
|
seen.add(value);
|
|
if (Array.isArray(value)) {
|
|
value.forEach((item, i) => walk(item, visit, seen, `${path}[${i}]`));
|
|
return;
|
|
}
|
|
for (const key of Object.keys(value)) {
|
|
walk(value[key], visit, seen, `${path}.${key}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Structural equality for JSON-ish values. Cycle-tolerant via a `WeakMap` that
|
|
* pairs "already compared" object references, so a self-referential structure
|
|
* terminates instead of recursing forever.
|
|
*
|
|
* @param {unknown} a
|
|
* @param {unknown} b
|
|
* @param {WeakMap<object, unknown>} seen
|
|
* @returns {boolean}
|
|
*/
|
|
function deepEqual(a, b, seen) {
|
|
if (Object.is(a, b)) return true;
|
|
if (a === null || b === null) return false;
|
|
if (typeof a !== 'object' || typeof b !== 'object') return false;
|
|
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
if (seen.get(a) === b) return true;
|
|
seen.set(a, b);
|
|
const aKeys = Object.keys(a);
|
|
const bKeys = Object.keys(b);
|
|
if (aKeys.length !== bKeys.length) return false;
|
|
for (const key of aKeys) {
|
|
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
|
|
if (!deepEqual(a[key], b[key], seen)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @typedef {{ ok: true } | { ok: false, severity: string, detail: string, subject?: object }} OracleOutcome
|
|
*
|
|
* `subject` — OPTIONAL structured data backing `detail`, present where the oracle knows a
|
|
* machine-checkable shape (e.g. `{ argv: string[] }` for a command-scoped finding,
|
|
* `{ key: string, value: unknown }` for a value-hygiene leaf, `{ missing: string }` for
|
|
* read-only-idempotence's absent-input case). `detail` remains a human-readable string for
|
|
* console/report output; tests MUST assert on `subject`, never on substrings of `detail`
|
|
* (CONTRIBUTING.md "Prohibited: Raw Text Matching on Test Outputs").
|
|
*/
|
|
|
|
/**
|
|
* Frozen array of `{ id, describe, check(ctx) -> OracleOutcome }` oracles.
|
|
*
|
|
* `ctx` shape (all optional except `result`):
|
|
* { result, prevResult, repeatResult, statsBefore, statsAfter, history,
|
|
* liveCommands, readOnly }
|
|
*
|
|
* Every `check` is wrapped in its own try/catch so a bug in one oracle can
|
|
* never take the whole walk down — an oracle that throws is converted into a
|
|
* `{ok:false}` naming the exception rather than crashing `runOracles`.
|
|
*/
|
|
const ORACLES = Object.freeze([
|
|
Object.freeze({
|
|
id: 'exit-contract',
|
|
describe: 'result.kind must not be UNEXPECTED_EXIT or TIMEOUT.',
|
|
check(ctx) {
|
|
try {
|
|
const kind = ctx && ctx.result && ctx.result.kind;
|
|
if (kind === KIND.UNEXPECTED_EXIT || kind === KIND.TIMEOUT) {
|
|
return { ok: false, severity: SEVERITY.VIOLATION, detail: `result.kind is "${kind}"` };
|
|
}
|
|
return { ok: true };
|
|
} catch (err) {
|
|
return { ok: false, severity: SEVERITY.VIOLATION, detail: `exit-contract threw: ${err && err.message}` };
|
|
}
|
|
},
|
|
}),
|
|
|
|
Object.freeze({
|
|
id: 'json-contract',
|
|
describe:
|
|
'result.kind must not be UNSTRUCTURED_ERROR; a STRUCTURED_ERROR must carry err.ok===false and a non-empty err.reason.',
|
|
check(ctx) {
|
|
try {
|
|
const result = ctx && ctx.result;
|
|
const kind = result && result.kind;
|
|
if (kind === KIND.UNSTRUCTURED_ERROR) {
|
|
return {
|
|
ok: false,
|
|
severity: SEVERITY.VIOLATION,
|
|
detail: 'result.kind is "unstructured-error" (raw error text; --json-errors was ignored)',
|
|
};
|
|
}
|
|
if (kind === KIND.STRUCTURED_ERROR) {
|
|
const err = result.err;
|
|
if (!err || typeof err !== 'object') {
|
|
return {
|
|
ok: false,
|
|
severity: SEVERITY.VIOLATION,
|
|
detail: `result.err is ${JSON.stringify(err)}, expected an object`,
|
|
};
|
|
}
|
|
if (err.ok !== false) {
|
|
return {
|
|
ok: false,
|
|
severity: SEVERITY.VIOLATION,
|
|
detail: `result.err.ok is ${JSON.stringify(err.ok)}, expected false`,
|
|
};
|
|
}
|
|
if (typeof err.reason !== 'string' || err.reason === '') {
|
|
return {
|
|
ok: false,
|
|
severity: SEVERITY.VIOLATION,
|
|
detail: `result.err.reason is ${JSON.stringify(err.reason)}, expected a non-empty string`,
|
|
};
|
|
}
|
|
}
|
|
return { ok: true };
|
|
} catch (err) {
|
|
return { ok: false, severity: SEVERITY.VIOLATION, detail: `json-contract threw: ${err && err.message}` };
|
|
}
|
|
},
|
|
}),
|
|
|
|
Object.freeze({
|
|
id: 'value-hygiene',
|
|
describe:
|
|
'result.json must not contain a NaN number or a string exactly equal to a coercion-artifact sentinel ' +
|
|
'(VIOLATION). When ctx.projectDir is supplied, an absolute-path string outside it is reported as a SMELL — ' +
|
|
'never a violation — UNLESS its leaf key name is in EXTERNAL_PATH_ALLOWED_KEYS (a field whose contract is to ' +
|
|
'point outside the project, e.g. agents_dir) or the string\'s FIRST WHITESPACE-DELIMITED WORD is an EXACT ' +
|
|
'member of getLiveCommandTokens() (e.g. `/gsd:progress`, or `/gsd-plan-phase 2` where the token carries ' +
|
|
'trailing arguments — see isLiveCommandToken in tests/helpers/live-command-registry.cjs, the single predicate ' +
|
|
'shared with routing-validity), either of which is skipped entirely: not a smell, not a violation. Exact ' +
|
|
'membership, never a `startsWith` prefix test with an unconstrained remainder — a string merely SHARING a ' +
|
|
'prefix with a real token (e.g. `/gsd-x/../../../etc/passwd`) is not exempted and still surfaces as a SMELL. ' +
|
|
'Without ctx.projectDir the path check is skipped rather than guessed.',
|
|
check(ctx) {
|
|
try {
|
|
/** @type {{message: string, key: string, value: unknown}[]} */
|
|
const violations = [];
|
|
/** @type {{message: string, key: string, value: unknown}[]} */
|
|
const smells = [];
|
|
const seen = new WeakSet();
|
|
const json = ctx && ctx.result ? ctx.result.json : undefined;
|
|
const projectDir = ctx && typeof ctx.projectDir === 'string' ? ctx.projectDir : null;
|
|
// Resolved once per runOracles call (this check runs exactly once per ctx), not once
|
|
// per candidate string below — projectDir is the same value for every leaf in the walk.
|
|
const resolvedProjectDir = projectDir !== null ? resolveForCompare(projectDir) : null;
|
|
// Resolved once per check() call, not once per leaf — getLiveCommandTokens() is itself
|
|
// memoized, but there is no reason to re-look-up the memo per candidate string.
|
|
const liveTokens = getLiveCommandTokens();
|
|
walk(json, (leaf, path) => {
|
|
if (typeof leaf === 'number' && Number.isNaN(leaf)) {
|
|
violations.push({ message: `NaN at ${path}`, key: path, value: leaf });
|
|
} else if (typeof leaf === 'string' && SENTINEL_STRINGS.has(leaf)) {
|
|
violations.push({ message: `sentinel string ${JSON.stringify(leaf)} at ${path}`, key: path, value: leaf });
|
|
} else if (
|
|
resolvedProjectDir !== null &&
|
|
typeof leaf === 'string' &&
|
|
nodePath.isAbsolute(leaf) &&
|
|
!isUnderProjectDir(resolveForCompare(leaf), resolvedProjectDir) &&
|
|
!EXTERNAL_PATH_ALLOWED_KEYS.has(leafKeyOf(path)) &&
|
|
!isLiveCommandToken(leaf, liveTokens)
|
|
) {
|
|
smells.push({
|
|
message: `absolute path ${JSON.stringify(leaf)} at ${path} is outside ctx.projectDir ${JSON.stringify(projectDir)}`,
|
|
key: path,
|
|
value: leaf,
|
|
});
|
|
}
|
|
}, seen, '$');
|
|
if (violations.length) {
|
|
return {
|
|
ok: false,
|
|
severity: SEVERITY.VIOLATION,
|
|
subject: { key: violations[0].key, value: violations[0].value },
|
|
detail: violations.map((v) => v.message).join('; '),
|
|
};
|
|
}
|
|
if (smells.length) {
|
|
return {
|
|
ok: false,
|
|
severity: SEVERITY.SMELL,
|
|
subject: { key: smells[0].key, value: smells[0].value },
|
|
detail: smells.map((s) => s.message).join('; '),
|
|
};
|
|
}
|
|
return { ok: true };
|
|
} catch (err) {
|
|
return { ok: false, severity: SEVERITY.VIOLATION, detail: `value-hygiene threw: ${err && err.message}` };
|
|
}
|
|
},
|
|
}),
|
|
|
|
Object.freeze({
|
|
id: 'read-only-idempotence',
|
|
describe:
|
|
'For commands documented as read-only: repeatResult.json must deep-equal result.json, and statsBefore/statsAfter must be identical on every key. ' +
|
|
'An oracle asked to check idempotence without the data to check it FAILS (VIOLATION) rather than passing vacuously — missing repeatResult, ' +
|
|
'statsBefore, or statsAfter is itself a finding, named explicitly in the detail.',
|
|
check(ctx) {
|
|
try {
|
|
if (!ctx || !ctx.readOnly) return { ok: true };
|
|
const findings = [];
|
|
const missing = [];
|
|
if (!ctx.repeatResult) {
|
|
missing.push('repeatResult');
|
|
findings.push('ctx.readOnly is true but ctx.repeatResult is missing — idempotence cannot be checked');
|
|
} else if (ctx.result) {
|
|
if (!deepEqual(ctx.result.json, ctx.repeatResult.json, new WeakMap())) {
|
|
findings.push('repeatResult.json is not deep-equal to result.json');
|
|
}
|
|
}
|
|
if (!(ctx.statsBefore instanceof Map)) {
|
|
missing.push('statsBefore');
|
|
findings.push('ctx.readOnly is true but ctx.statsBefore is missing — idempotence cannot be checked');
|
|
}
|
|
if (!(ctx.statsAfter instanceof Map)) {
|
|
missing.push('statsAfter');
|
|
findings.push('ctx.readOnly is true but ctx.statsAfter is missing — idempotence cannot be checked');
|
|
}
|
|
const before = ctx.statsBefore instanceof Map ? ctx.statsBefore : new Map();
|
|
const after = ctx.statsAfter instanceof Map ? ctx.statsAfter : new Map();
|
|
const beforeKeys = new Set(before.keys());
|
|
const afterKeys = new Set(after.keys());
|
|
const sameKeys =
|
|
beforeKeys.size === afterKeys.size && [...beforeKeys].every((k) => afterKeys.has(k));
|
|
if (!sameKeys) {
|
|
findings.push(
|
|
`statsBefore/statsAfter key sets differ: before=[${[...beforeKeys].join(',')}] after=[${[...afterKeys].join(',')}]`,
|
|
);
|
|
} else {
|
|
for (const key of beforeKeys) {
|
|
const b = before.get(key);
|
|
const a = after.get(key);
|
|
if (b && a && b.size !== a.size) {
|
|
findings.push(`stat "${key}".size changed: ${b.size} -> ${a.size}`);
|
|
}
|
|
if (b && a && b.mtimeMs !== a.mtimeMs) {
|
|
findings.push(`stat "${key}".mtimeMs changed: ${b.mtimeMs} -> ${a.mtimeMs}`);
|
|
}
|
|
}
|
|
}
|
|
if (!findings.length) return { ok: true };
|
|
const subject = missing.length ? { missing: missing.join(', ') } : { mismatches: findings };
|
|
return { ok: false, severity: SEVERITY.VIOLATION, subject, detail: findings.join('; ') };
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
severity: SEVERITY.VIOLATION,
|
|
detail: `read-only-idempotence threw: ${err && err.message}`,
|
|
};
|
|
}
|
|
},
|
|
}),
|
|
|
|
Object.freeze({
|
|
id: 'monotonic-progress',
|
|
describe:
|
|
'Numeric total_plans/total_summaries/phases_completed fields must never decrease across history + result, ' +
|
|
'in walk order, WITHIN a comparable scope (VIOLATION). A scope is the pair of {milestone_version + ' +
|
|
'milestone_name} from the payload and the active workstream derived from the invocation\'s own argv ' +
|
|
'(`--ws <name>`, see `progressScopeOf`). Two consecutive observations are compared using a THREE-WAY rule ' +
|
|
'keyed on whether each one\'s payload carries a milestone scope at all (`hasScope`, true when the payload ' +
|
|
'has `milestone_version` and/or `milestone_name`; see below):\n' +
|
|
' 1. BOTH have scope -> compare via `scopeEqual` (milestone_version + milestone_name + workstream). Same ' +
|
|
' scope: a decrease is a VIOLATION. Different scope: a milestone/workstream boundary crossing legally ' +
|
|
' resets the counters, so this resets the comparison SILENTLY — expected behavior, never reported (not ' +
|
|
' a violation, not a smell). See #2966 FIX 2(a). This observation becomes the new reference point.\n' +
|
|
' 2. NEITHER has scope -> they share the same (absent) milestone scope by construction, so the SAME ' +
|
|
' `scopeEqual` comparison applies (it degenerates to comparing `undefined === undefined` plus ' +
|
|
' workstream, e.g. a `--ws` switch still resets silently even with no milestone fields at all): a ' +
|
|
' same-scope decrease is a VIOLATION. This is the branch a prior fix got wrong (see WHY THE BLANKET ' +
|
|
' SKIP WAS WRONG below) and is the one #2966 FIX 2(b) restores. This observation becomes the new ' +
|
|
' reference point.\n' +
|
|
' 3. MIXED (one has scope, the other does not) -> genuinely indeterminate; this pair is not compared, and ' +
|
|
' — unlike branches 1/2 — the mixed observation does NOT replace the reference point, so the NEXT ' +
|
|
' entry is compared against the last observation whose scope-category matched. This is the `roadmap ' +
|
|
' analyze` (no milestone fields) sitting between two `progress` observations (milestone fields) case ' +
|
|
' that motivated scope-awareness in the first place: it must not mask a real same-scope decrease on ' +
|
|
' either side of it.\n\n' +
|
|
'WHY THE BLANKET SKIP WAS WRONG: an earlier version of this oracle skipped ANY entry lacking both milestone ' +
|
|
'fields entirely — it neither compared it nor let it become the reference point. That was meant to fix ' +
|
|
'branch 3 above, but it silently also disabled branch 2: a minimal payload like `{ total_summaries: n }` ' +
|
|
'(no milestone fields at all, e.g. the oracle\'s own self-test fixtures) could never trigger a violation no ' +
|
|
'matter how far it decreased, because it could never be compared against anything. That is a false ' +
|
|
'NEGATIVE in the exact defect this oracle exists to catch, and it is strictly worse than the false ' +
|
|
'POSITIVE the blanket skip was trying to fix — it was caught only by the full remote suite\'s self-tests, ' +
|
|
'not by any local check. Do not re-broaden this to a blanket "no scope fields -> skip" rule; keep the ' +
|
|
'three-way branch above, where "neither has scope" is fully comparable and only a genuine MIX is skipped.',
|
|
check(ctx) {
|
|
try {
|
|
const history = ctx && Array.isArray(ctx.history) ? ctx.history : [];
|
|
const entries = ctx && ctx.result ? [...history, ctx.result] : history;
|
|
/** @type {{key:string, from:number, to:number, fromIndex:number, toIndex:number}[]} */
|
|
const violations = [];
|
|
for (const key of PROGRESS_KEYS) {
|
|
/** @type {{value:number, index:number, scope:ReturnType<typeof progressScopeOf>, hasScope:boolean} | undefined} */
|
|
let prev;
|
|
entries.forEach((entry, index) => {
|
|
const json = entry && entry.json;
|
|
if (json === null || typeof json !== 'object' || Array.isArray(json)) return;
|
|
const value = json[key];
|
|
if (typeof value !== 'number' || Number.isNaN(value)) return;
|
|
const scope = progressScopeOf(entry);
|
|
const hasScope = scope.milestoneVersion !== undefined || scope.milestoneName !== undefined;
|
|
if (prev === undefined) {
|
|
prev = { value, index, scope, hasScope };
|
|
return;
|
|
}
|
|
if (prev.hasScope !== hasScope) {
|
|
// Branch 3: mixed — genuinely indeterminate. Skip this comparison AND leave
|
|
// `prev` untouched, so the next entry is still compared against the last
|
|
// scope-category-matching observation (see JSDoc above).
|
|
return;
|
|
}
|
|
// Branch 1 (both scoped) and Branch 2 (neither scoped) are the SAME check:
|
|
// `scopeEqual` naturally degenerates to comparing undefined===undefined plus
|
|
// workstream when neither side carries milestone fields.
|
|
if (scopeEqual(prev.scope, scope) && value < prev.value) {
|
|
violations.push({
|
|
key, from: prev.value, to: value, fromIndex: prev.index, toIndex: index,
|
|
});
|
|
}
|
|
prev = { value, index, scope, hasScope };
|
|
});
|
|
}
|
|
if (violations.length) {
|
|
const first = violations[0];
|
|
return {
|
|
ok: false,
|
|
severity: SEVERITY.VIOLATION,
|
|
subject: { key: first.key, from: first.from, to: first.to, fromIndex: first.fromIndex, toIndex: first.toIndex },
|
|
detail: violations
|
|
.map((v) => `${v.key} decreased from ${v.from} (entry ${v.fromIndex}) to ${v.to} (entry ${v.toIndex})`)
|
|
.join('; '),
|
|
};
|
|
}
|
|
return { ok: true };
|
|
} catch (err) {
|
|
return { ok: false, severity: SEVERITY.VIOLATION, detail: `monotonic-progress threw: ${err && err.message}` };
|
|
}
|
|
},
|
|
}),
|
|
|
|
Object.freeze({
|
|
id: 'routing-validity',
|
|
describe:
|
|
'Every command token actually carried by the payload must name a command present in ctx.liveCommands. ' +
|
|
'Rather than enumerating fixed field paths (`actions[].command` / `next.command`), this walks the ENTIRE ' +
|
|
'payload for any string whose first whitespace-delimited word starts with one of LIVE_COMMAND_TOKEN_PREFIXES ' +
|
|
'(`/gsd-`, `/gsd:`, `$gsd-`) — catching the token wherever it is actually carried: `recommended_command`, a ' +
|
|
'bare-string `next`, `next` as an array of `{command}`, `actions` as an object map, `steps[].command`, ' +
|
|
'`actions[].next.command`, etc (#3913 P9 SEC-2 — the fixed-path version missed all of these). A candidate ' +
|
|
'passes only when its first word is an EXACT member of ctx.liveCommands (via the shared ' +
|
|
'`isLiveCommandToken` predicate, tests/helpers/live-command-registry.cjs — the SAME predicate ' +
|
|
'value-hygiene\'s command-token exemption uses, so the two can never drift on what a live token is); a ' +
|
|
'pleasing consequence is that value-hygiene therefore exempts exactly the strings routing-validity vouches ' +
|
|
'for. A bare `recommended` id (no `/gsd-`-style prefix — e.g. `discuss-phase`, by design ' +
|
|
'`src/smart-entry.cts` `actions.find(a => a.recommended)?.id`, an action id, not a command token) is never a ' +
|
|
'candidate. Engages only when at least one prefix-shaped string is present; a payload with no such string is ' +
|
|
'not engaged (ok).',
|
|
check(ctx) {
|
|
try {
|
|
const json = ctx && ctx.result ? ctx.result.json : undefined;
|
|
if (json === null || typeof json !== 'object' || Array.isArray(json)) return { ok: true };
|
|
const liveCommands = ctx && Array.isArray(ctx.liveCommands) ? ctx.liveCommands : [];
|
|
const liveTokens = new Set(liveCommands);
|
|
const seen = new WeakSet();
|
|
/** @type {{field: string, value: string} | null} */
|
|
let violation = null;
|
|
walk(json, (leaf, path) => {
|
|
if (violation !== null || typeof leaf !== 'string') return;
|
|
const word = firstToken(leaf);
|
|
const looksLikeCommandToken = LIVE_COMMAND_TOKEN_PREFIXES.some((prefix) => word.startsWith(prefix));
|
|
if (!looksLikeCommandToken) return;
|
|
if (!isLiveCommandToken(leaf, liveTokens)) {
|
|
violation = { field: stripRootPrefix(path), value: leaf };
|
|
}
|
|
}, seen, '$');
|
|
if (violation !== null) {
|
|
return {
|
|
ok: false,
|
|
severity: SEVERITY.VIOLATION,
|
|
subject: { key: violation.field, value: violation.value },
|
|
detail: `${violation.field}=${JSON.stringify(violation.value)} is not in ctx.liveCommands`,
|
|
};
|
|
}
|
|
return { ok: true };
|
|
} catch (err) {
|
|
return { ok: false, severity: SEVERITY.VIOLATION, detail: `routing-validity threw: ${err && err.message}` };
|
|
}
|
|
},
|
|
}),
|
|
|
|
Object.freeze({
|
|
id: 'determinism',
|
|
describe: 'When a repeatResult is present, its kind must match the original result.kind.',
|
|
check(ctx) {
|
|
try {
|
|
if (!ctx || !ctx.repeatResult || !ctx.result) return { ok: true };
|
|
if (ctx.repeatResult.kind !== ctx.result.kind) {
|
|
return {
|
|
ok: false,
|
|
severity: SEVERITY.VIOLATION,
|
|
detail: `repeatResult.kind "${ctx.repeatResult.kind}" !== result.kind "${ctx.result.kind}"`,
|
|
};
|
|
}
|
|
return { ok: true };
|
|
} catch (err) {
|
|
return { ok: false, severity: SEVERITY.VIOLATION, detail: `determinism threw: ${err && err.message}` };
|
|
}
|
|
},
|
|
}),
|
|
|
|
Object.freeze({
|
|
id: 'untyped-success',
|
|
describe:
|
|
'VIOLATION: result.kind === KIND.PROSE means the command only emits rendered text with no typed surface to ' +
|
|
'assert on. CONTRIBUTING.md forbids asserting on rendered text, so a prose-only command is permanently ' +
|
|
'unassertable by this harness. This is enforced, not merely tracked: the corpus PROSE count is 0 (#3913) — ' +
|
|
'every executed step across every scenario emits a typed surface, so this oracle can now fire without ever ' +
|
|
'having fired against the shipped corpus. A future prose-only step reddens the build immediately.',
|
|
check(ctx) {
|
|
try {
|
|
const result = ctx && ctx.result;
|
|
if (!result || result.kind !== KIND.PROSE) return { ok: true };
|
|
const argv = Array.isArray(result.argv) ? result.argv : [];
|
|
const argvDisplay = argv.length ? argv.join(' ') : '(no argv)';
|
|
return {
|
|
ok: false,
|
|
severity: SEVERITY.VIOLATION,
|
|
subject: { argv },
|
|
detail: `command "${argvDisplay}" emits KIND.PROSE only; no typed surface exists to assert on`,
|
|
};
|
|
} catch (err) {
|
|
return { ok: false, severity: SEVERITY.VIOLATION, detail: `untyped-success threw: ${err && err.message}` };
|
|
}
|
|
},
|
|
}),
|
|
|
|
Object.freeze({
|
|
id: 'contract-conflict',
|
|
describe:
|
|
'SMELL, not a violation: fires only when ctx.jsonErrorMode === true and result.kind === KIND.UNSTRUCTURED_ERROR. ' +
|
|
'Deliberately overlaps json-contract, which reports the same observation as a VIOLATION of the documented ' +
|
|
'contract in docs/json-errors.md ("On any error, exactly one JSON line is written to stderr and the process ' +
|
|
'exits with code 1"). This oracle instead reports that the two controlling documents disagree: src/cli-exit.cts ' +
|
|
'runMain returns for an ExitError before reaching the json-error branch, so CLI usage errors emit plain text by ' +
|
|
'design. A reader of both oracles sees the breach (json-contract) and the reason it is ambiguous (contract-conflict).',
|
|
check(ctx) {
|
|
try {
|
|
const result = ctx && ctx.result;
|
|
if (!ctx || ctx.jsonErrorMode !== true) return { ok: true };
|
|
if (!result || result.kind !== KIND.UNSTRUCTURED_ERROR) return { ok: true };
|
|
const argv = Array.isArray(result.argv) ? result.argv : [];
|
|
const argvDisplay = argv.length ? argv.join(' ') : '(no argv)';
|
|
return {
|
|
ok: false,
|
|
severity: SEVERITY.SMELL,
|
|
subject: { argv },
|
|
detail:
|
|
`command "${argvDisplay}" emitted unstructured-error text under --json-errors, conflicting docs: ` +
|
|
'docs/json-errors.md says "On any error, exactly one JSON line is written to stderr and the process ' +
|
|
'exits with code 1", but src/cli-exit.cts runMain returns for an ExitError before the json-error branch, ' +
|
|
'so CLI usage errors emit plain text by design',
|
|
};
|
|
} catch (err) {
|
|
return { ok: false, severity: SEVERITY.SMELL, detail: `contract-conflict threw: ${err && err.message}` };
|
|
}
|
|
},
|
|
}),
|
|
]);
|
|
|
|
/**
|
|
* Run every oracle against `ctx` and bucket the outcomes by severity.
|
|
*
|
|
* Each `check` is additionally guarded here (belt-and-suspenders on top of
|
|
* each oracle's own try/catch) so a defect in one oracle can never abort the
|
|
* walk for the rest. An outcome with no recognized `severity` is treated as
|
|
* `SEVERITY.VIOLATION` — the conservative default, so a bug in an oracle can
|
|
* never silently downgrade a break into a smell.
|
|
*
|
|
* The returned object carries a `failed` GETTER that is an alias for
|
|
* `violations` ONLY — it deliberately excludes `smells`. This preserves the
|
|
* meaning every existing caller already relies on (`failed.length === 0` as
|
|
* a build gate): a SMELL must never fail a build. Smells are evidence for a
|
|
* human, surfaced separately in `.smells`, never folded into the pass/fail
|
|
* verdict.
|
|
*
|
|
* @param {object} ctx
|
|
* @returns {{
|
|
* passed: string[],
|
|
* violations: { id: string, detail: string, subject?: object }[],
|
|
* smells: { id: string, detail: string, subject?: object }[],
|
|
* readonly failed: { id: string, detail: string, subject?: object }[],
|
|
* }}
|
|
*/
|
|
function runOracles(ctx) {
|
|
const passed = [];
|
|
const violations = [];
|
|
const smells = [];
|
|
for (const oracle of ORACLES) {
|
|
let outcome;
|
|
try {
|
|
outcome = oracle.check(ctx);
|
|
} catch (err) {
|
|
outcome = { ok: false, severity: SEVERITY.VIOLATION, detail: `${oracle.id} threw: ${err && err.message}` };
|
|
}
|
|
if (outcome && outcome.ok) {
|
|
passed.push(oracle.id);
|
|
continue;
|
|
}
|
|
const finding = { id: oracle.id, detail: (outcome && outcome.detail) || 'oracle failed with no detail' };
|
|
if (outcome && outcome.subject !== undefined) {
|
|
finding.subject = outcome.subject;
|
|
}
|
|
if (outcome && outcome.severity === SEVERITY.SMELL) {
|
|
smells.push(finding);
|
|
} else {
|
|
violations.push(finding);
|
|
}
|
|
}
|
|
return {
|
|
passed,
|
|
violations,
|
|
smells,
|
|
get failed() {
|
|
return violations;
|
|
},
|
|
};
|
|
}
|
|
|
|
module.exports = { ORACLES, runOracles, SEVERITY };
|