Files
msd-core/tests/loop-walk.qa.test.cjs
Tom Boucher 33fd203ccd test(#2966): loop QA walk — drive real scenarios across all five loop steps (#2976)
* test(#2966): loop QA walk — drive real scenarios across all five loop steps

Adds a headless walk that carries accumulating project state across
discuss -> plan -> execute -> verify -> ship against one temp project,
layered over the existing tests/helpers.cjs runGsdTools substrate.

Findings carry severity. A violation breaks a stated contract and fails
the build; a smell is legal under today's implementation but structurally
questionable, is recorded, and never reddens CI. Without that split an
oracle set derived from current behavior can only ever confirm current
behavior -- the harness could not say "this works and is still wrong".

The end-to-end test asserts the walk produces at least one smell: a QA
harness that reports nothing on a first run against a real engine is far
more likely mis-specified than the engine is perfect. It deliberately does
not pin smell ids or counts, which would re-freeze current behavior.

First run against the real engine: 0 violations, 3 smell classes --
init returns agents_dir outside the project tree; smart-entry emits prose
unconditionally so routing cannot be asserted; state-snapshot reports a
missing STATE.md through a payload key with exit 0.

Also fixes tests/fixtures/index.cjs: createFixture with git:true and
planning:false staged nothing, so the commit failed with "nothing to
commit". That combination was unreachable until greenfield needed it.

Extends RULESET.TESTS.feedback-loop-convergence from estimation to the
loop itself. Design lock: docs/adr/2966-loop-qa-walk.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): wire fault injection, make perturbations discriminating

Independent review found tests/qa/mutations.cjs entirely unwired: 462
lines exercised only by their own unit tests, with no mutation hook in
the scenario DSL and no scenario applying one, while the module header
and the ADR described fault injection in the present tense. Dead code
documented as live.

Adds a `mutate` step field, three perturbation scenarios, and a wiring
detector: a self-test scenario whose expectations are known-false and
which MUST fail. The previous anti-vacuity check asserted only that the
walk produced a smell, which passes on well-known engine behavior
regardless of whether the harness wiring works.

First perturbation attempt produced zero signal -- progress does not
structurally parse ROADMAP.md, so a corrupted roadmap sailed through. A
perturbation that cannot fail is the same defect in a new costume.
Probes now target roadmap get-phase, and each mutated step runs a clean
baseline first so `mutationObserved` records whether the corruption
changed anything at all.

Also clears four review findings: classify() returned PROSE for exit-0
with empty stdout; `warnings` was structurally unpopulatable on the
success path (execFileSync discards it) and is now documented as
error-path-only; read-only-idempotence passed vacuously when asked to
check idempotence without the data to check it; the ADR miscounted the
oracles.

Discrimination matrix across 8 mutations x 6 commands: bom,
duplicate-phase-id and escaped-pipes are absorbed silently by every
probed surface, and progress / smart-entry / roadmap validate never
reacted to any mutation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): add path-containment guard for scenario-supplied targets

Security review found scenario-supplied paths joined to the temp project
with no containment check. step.mutate.target and agent.write keys were
validated only as non-empty strings, so a target of ../../../../etc/hosts
reached fs.unlinkSync / fs.writeFileSync / fs.symlinkSync outside the
project. The symlink mutation was worst: it read the traversed file, wrote
a sibling copy, deleted the original and symlinked it back.

Not exploitable today -- all shipped scenarios target .planning/ROADMAP.md
and scenarios are repo-committed, not runtime input. Fixed anyway: it is a
live primitive any future scenario or copied helper can reach.

Adds tests/qa/paths.cjs with resolveWithin(): rejects absolute paths, NUL
bytes and empty input, normalizes separators unconditionally, and requires
containment by path segment so a sibling like <base>-evil is not treated as
inside. Non-existent targets resolve via nearest existing ancestor rather
than falling back to a lexical compare. Scenario load now rejects traversing
or absolute targets up front.

oracles.cjs previously carried its own copy of the containment logic; both
now share paths.cjs, since a duplicated containment check is exactly the
divergence class this repo calls out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): complete trajectory corpus, report emission, boundary-aware oracle

Adds the remaining trajectories and drives all 11 mutations end-to-end.
20 scenarios, 72 steps, 0 violations, 25 smells.

Adds qa-report.json with per-step verdicts and a copy-pasteable repro
command, plus --keep / GSD_QA_KEEP=1 to preserve a failing tree. A repro
line for a tree that was not preserved is marked NOT RUNNABLE rather than
emitting a command pointing at a deleted directory.

monotonic-progress is now boundary-aware. Two scenarios had been trimmed
to stop the oracle complaining at a milestone rollover, which destroys the
signal the trajectory exists to produce. Evidence: counters legitimately
reset to zero at milestone complete, but the payload milestone_version
lags until a new ROADMAP.md is written. So the oracle now scopes by
milestone plus workstream, keeps a same-scope decrease as a violation, and
records a boundary crossing as a smell. Both scenarios walk the real
boundary again.

Standards review fixes: oracle findings now carry a structured subject so
tests assert on typed fields instead of substring-matching the free-form
detail string, resolveWithin throws a typed EPATHESCAPE error, and the
absolute-path predicate scenario.cjs had re-implemented now comes from
paths.cjs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): fix silently-vacuous fixtures and guard the class

Every fixture carried its #2371 provenance comment BEFORE the frontmatter
block, and extractFrontmatter returns {} when anything precedes the opening
---. So every scenario reading status/phase/name was operating on an empty
object and reporting green. Nine fixtures repositioned; the comment stays,
it just moves below the closing ---.

Both UAT fixtures lacked a parser-recognized result block, so
evaluateUatPassed saw checks.length===0 and could never return passed:true.
The uat-fail-then-remediate scenario could not have proven a remediation.
Its expect block only inspected blockers, which is empty before AND after,
which is why the corpus never noticed. Both fixtures now carry real result
blocks and the scenario asserts passed and no_uat_artifacts on each side of
the flip.

The actual deliverable is the guard: a fixture-integrity block asserting
every fixture with a frontmatter shape parses to a non-empty object, that
every fixture carries its provenance marker, and that the two UAT fixtures
produce opposite verdicts through the real evaluateUatPassed. The first
guard written required --- at byte 0, which would never have fired on the
regression it exists to prevent; it was rewritten and proven by deliberately
re-breaking a fixture.

No engine defect here. no_uat_artifacts means no parsed check items, not no
UAT files, and it was reporting correctly on fixtures that had none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): make the walk report — smell ratchet, baseline, CI job

The harness computed smells into a gitignored qa-report.json that nothing
read. In CI it surfaced nothing at all: violations failed the build, but the
half of the tool that says "this works and is still wrong" was inert. A QA
tool nobody hears is decoration.

Adds a ratchet on the same idiom this repo already uses three times over
(the regression-test-name allowlist, the emitted-drift acks, the size
baseline): a committed smell-baseline.json, per-PR acknowledgment fragments
under tests/qa/smell-acks/, and a ratchet script wired into CI.

The design invariant is preserved exactly. A smell still never fails a build
on its own merits. What fails is an UNACKNOWLEDGED NEW smell -- the absence
of a decision -- leaving an author two honest exits: fix it, or record a
fragment with a real reason. An empty reason is rejected. The baseline is
shrink-only, so a fixed smell must prune its entry. Violations remain
unacknowledgeable.

Fingerprints are composed only from stable fields (oracle id, scenario,
argv, subject discriminator) -- never temp paths, timestamps or counts.
Verified byte-identical across two runs in separate temp dirs; an unstable
fingerprint would have false-positived every CI run.

CI gains a qa-loop-walk job that runs the suite and the ratchet, uploads the
report with `if: always()` (it matters most when it failed), and renders a
summary a reviewer reads without downloading anything.

Also fixes the report runner invoking main() unconditionally on require, so
importing it double-ran every scenario and clobbered its own output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): every smell terminates in a defect or a fixed detector

The baseline accepted a smell with a free-text reason. That is a mechanism
for designing smells in -- an allowlist nobody revisits. The harness is
brand new, so nothing it found is inherited legacy; every finding is a
FIRST finding. Each must now terminate in exactly one of two states:

  REAL           -> an assigned defect, entry carries the issue number
  FALSE POSITIVE -> the detector is wrong and gets fixed, never baselined

There is no third "accepted with a good explanation" state, so the ratchet
now requires a positive-integer `issue` on every entry. A reason may remain
as a human note but can never substitute. `--update` refuses to invent
issue numbers: a new smell is written with `issue: null` and a TODO, and
the next plain run rejects it, forcing triage rather than accumulation.

Working the 21 existing entries through that rule found 16 were my own
detectors being wrong:

value-hygiene (10) flagged $.agents_dir, a field whose entire contract is
to point at the install tree outside any project. Fixed with a leaf-key
allowlist of contractually-external fields, verified as the only such key
in the init payload. Genuinely unexpected out-of-project paths still smell.

monotonic-progress (6) fired on legitimate boundary crossings -- milestone
v1.0 to v2.0, workstream beta to alpha -- and on one payload carrying no
scope fields at all, where a change cannot even be known. Scope changes now
reset silently and scope-less observations are skipped. The same-scope
decrease remains a violation; that is the real invariant and is regression-
guarded.

The five survivors are real and now tracked: soft-error-exit-zero (#2980),
untyped-success (#2979). Baseline 25 -> 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): keep the ratchet out of the tarball, unpin the qa CI job

The remote matrix returned failed -- 3 unique failures, identical on
node22 and node24, both root causes in this branch's own diff.

The ratchet lives under scripts/, which ships in the npm tarball, and it
requires three modules under tests/, which does not. In a published
install it is MODULE_NOT_FOUND at load. This is exactly the class the
#2858 guard was added to catch, and it caught it. Fixed the way #2858
fixed the same shape for its own repo-only CI script: a targeted files[]
negation, so the ratchet stays in the repo for CI and out of the tarball.
Not solved by moving or inlining the required modules -- the ratchet must
keep using the same code the harness uses, or the two drift.

Verified both directions: the script is no longer in the pack list, and
build-hooks.js, fix-slash-commands.cjs and gen-capability-registry.cjs are
all still shipped. Over-negating there would have broken installs, since
bin/install.js requires them.

The qa-loop-walk job also carried CI_REBASE_BASE_SHA copied from a
neighbouring job without the paired GSD_EMITTED_BASE, which the #2854
invariant forbids by name: diverging them makes the differential compare a
tree against a baseline from a different commit. The job runs only the qa
suite and the ratchet and invokes no emitted-attribution test, so it needs
no rebase-pinned base at all -- the step was removed rather than paired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#2966): stop monotonic-progress going blind on scope-less payloads

The full remote suite caught a false NEGATIVE I introduced while fixing a
false positive. Silencing the boundary-crossing noise had made the oracle
skip ANY observation lacking milestone fields -- so a minimal payload like
{total_summaries: n} produced no violation at all, and the oracle stopped
catching the exact defect it exists to catch. For a QA tool that is
strictly worse than the noise it replaced.

Scope is only indeterminate when the two observations DISAGREE about
having it:

  both scoped, same scope, decrease -> VIOLATION
  both scoped, different scope      -> reset silently
  NEITHER scoped, decrease          -> VIOLATION   (the regression)
  mixed                             -> skip the comparison

Implementing the mixed case surfaced a second blind spot: advancing the
reference point on a skipped pair lets a scope-less observation sitting
between two same-scope ones mask a real decrease. Mixed now leaves the
reference untouched. All four branches carry explicit coverage; only one
did before, which is why this shipped.

The self-test that failed was right and the code was wrong, so the code
moved. Corpus behavior is unchanged: still 5 smells, 0 new, 0 stale, 0
violations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 16:13:28 -04:00

1337 lines
58 KiB
JavaScript

'use strict';
/**
* loop-walk.qa.test.cjs — self-tests for the loop QA walk harness itself
* (`tests/qa/{result,oracles,loop-walk,mutations,scenario,fixtures/index}.cjs`).
*
* This file proves the harness's own building blocks behave as documented:
* the `RunResult` classifier, every oracle (both its pass AND its fail path —
* an oracle that cannot fail is decoration), the scenario DSL's validation,
* fixture-ref resolution, the mutation catalog, and finally a real end-to-end
* walk of the greenfield-happy-path scenario against the actual CLI.
*/
const { describe, test, before, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFile } = require('node:child_process');
const { promisify } = require('node:util');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { getLiveCommandTokens } = require('./helpers/live-command-registry.cjs');
const { KIND, classify } = require('./qa/result.cjs');
const { ORACLES, runOracles, SEVERITY } = require('./qa/oracles.cjs');
const { LoopWalk } = require('./qa/loop-walk.cjs');
const { MUTATIONS, apply, NOOP } = require('./qa/mutations.cjs');
const { loadScenario, runScenario, assertWiringIsLive } = require('./qa/scenario.cjs');
const { resolveRef } = require('./qa/fixtures/index.cjs');
const { resolveWithin, resolveForCompare } = require('./qa/paths.cjs');
const { LOOP_HOST_CONTRACT } = require('../gsd-core/bin/lib/loop-host-contract.cjs');
const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs');
const { evaluateUatPassed } = require('../gsd-core/bin/lib/uat-predicate.cjs');
const execFileAsync = promisify(execFile);
/**
* Recursively collect every `.md` file under `dir` (absolute paths).
*
* @param {string} dir
* @param {string[]} [out]
* @returns {string[]}
*/
function collectFixtureMarkdownFiles(dir, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
collectFixtureMarkdownFiles(abs, out);
} else if (entry.isFile() && abs.endsWith('.md')) {
out.push(abs);
}
}
return out;
}
/**
* Is `content` conceptually "a document with a frontmatter block", ignoring any
* leading blank lines and/or leading HTML comment(s) (e.g. the #2371 provenance
* marker)?
*
* WHY skip leading comments/blanks rather than requiring byte-0 `---`: the whole
* point of this predicate is to catch DEFECT 1's shape — a provenance comment
* placed BEFORE the frontmatter fence, which makes `extractFrontmatter` (which
* only recognizes `---` at byte 0, `gsd-core/bin/lib/frontmatter.cjs`) silently
* return `{}`. A predicate that itself required byte-0 `---` would only ever
* fire on already-correct fixtures and could never catch this regression class —
* it would be exactly as vacuous as the bug it exists to guard against.
*
* @param {string} content
* @returns {boolean}
*/
function hasFrontmatterShape(content) {
const lines = content.split(/\r?\n/);
let i = 0;
let inComment = false;
while (i < lines.length) {
const line = lines[i];
if (inComment) {
if (line.includes('-->')) inComment = false;
i += 1;
continue;
}
const trimmed = line.trim();
if (trimmed === '') {
i += 1;
continue;
}
if (trimmed.startsWith('<!--')) {
if (!trimmed.includes('-->')) inComment = true;
i += 1;
continue;
}
break;
}
return lines[i] === '---';
}
/** Looks up an oracle by id, failing loudly if the catalog ever drops one. */
function getOracle(id) {
const found = ORACLES.find((o) => o.id === id);
assert.ok(found, `test setup: oracle "${id}" not found in ORACLES`);
return found;
}
describe('RunResult classification', () => {
test('classifies a JSON object at exit 0 as JSON', () => {
const raw = { exitCode: 0, stdout: JSON.stringify({ total_plans: 3 }), stderr: '', argv: ['progress'] };
const result = classify(raw);
assert.strictEqual(result.kind, KIND.JSON);
assert.deepStrictEqual(result.json, { total_plans: 3 });
});
test('classifies non-JSON stdout at exit 0 as PROSE', () => {
const raw = { exitCode: 0, stdout: 'Project initialized successfully.', stderr: '', argv: ['init'] };
const result = classify(raw);
assert.strictEqual(result.kind, KIND.PROSE);
});
test('classifies empty stdout and stderr at exit 0 as EMPTY', () => {
const raw = { exitCode: 0, stdout: '', stderr: '', argv: ['noop'] };
const result = classify(raw);
assert.strictEqual(result.kind, KIND.EMPTY);
});
test('classifies a JSON payload carrying an "error" key at exit 0 as SOFT_ERROR', () => {
const raw = { exitCode: 0, stdout: JSON.stringify({ error: 'no phases found' }), stderr: '', argv: ['progress'] };
const result = classify(raw);
assert.strictEqual(result.kind, KIND.SOFT_ERROR);
});
test('classifies exit 1 with a warning line before the JSON envelope as STRUCTURED_ERROR', () => {
const stderr = [
'gsd-tools: warning: unknown config key(s) in .planning/config.json: foo',
JSON.stringify({ ok: false, reason: 'bad-config', message: 'config invalid' }),
].join('\n');
const raw = { exitCode: 1, stdout: '', stderr, argv: ['review-lane'] };
const result = classify(raw);
assert.strictEqual(result.kind, KIND.STRUCTURED_ERROR);
assert.strictEqual(result.err.reason, 'bad-config');
});
test('classifies non-JSON stderr at exit 1 as UNSTRUCTURED_ERROR', () => {
const raw = { exitCode: 1, stdout: '', stderr: 'Fatal: something went wrong', argv: ['bad'] };
const result = classify(raw);
assert.strictEqual(result.kind, KIND.UNSTRUCTURED_ERROR);
});
test('classifies an exit code outside {0,1} as UNEXPECTED_EXIT', () => {
const raw = { exitCode: 2, stdout: '', stderr: '', argv: ['weird'] };
const result = classify(raw);
assert.strictEqual(result.kind, KIND.UNEXPECTED_EXIT);
});
test('classifies a timed-out invocation as TIMEOUT regardless of exit code', () => {
const raw = { exitCode: null, stdout: '', stderr: '', timedOut: true, argv: ['slow'] };
const result = classify(raw);
assert.strictEqual(result.kind, KIND.TIMEOUT);
});
test('classifies a bare JSON number 0 as JSON (non-object scalar)', () => {
const raw = { exitCode: 0, stdout: '0', stderr: '', argv: ['x'] };
const result = classify(raw);
assert.strictEqual(result.kind, KIND.JSON);
assert.strictEqual(result.json, 0);
});
test('classifies a bare JSON string "s" as JSON (non-object scalar)', () => {
const raw = { exitCode: 0, stdout: '"s"', stderr: '', argv: ['x'] };
const result = classify(raw);
assert.strictEqual(result.kind, KIND.JSON);
assert.strictEqual(result.json, 's');
});
test('classifies a bare JSON array [] as JSON (not probed for an error key)', () => {
const raw = { exitCode: 0, stdout: '[]', stderr: '', argv: ['x'] };
const result = classify(raw);
assert.strictEqual(result.kind, KIND.JSON);
assert.deepStrictEqual(result.json, []);
});
test('classifies a bare JSON null as JSON, not SOFT_ERROR', () => {
const raw = { exitCode: 0, stdout: 'null', stderr: '', argv: ['x'] };
const result = classify(raw);
assert.strictEqual(result.kind, KIND.JSON);
assert.strictEqual(result.json, null);
});
test('classifies a bare JSON true as JSON', () => {
const raw = { exitCode: 0, stdout: 'true', stderr: '', argv: ['x'] };
const result = classify(raw);
assert.strictEqual(result.kind, KIND.JSON);
assert.strictEqual(result.json, true);
});
test('exit 1 with a healthy JSON-looking stdout still classifies as an error (exit code outranks payload)', () => {
const raw = { exitCode: 1, stdout: JSON.stringify({ ok: true, total_plans: 5 }), stderr: '', argv: ['progress'] };
const result = classify(raw);
assert.notStrictEqual(result.kind, KIND.JSON);
assert.strictEqual(result.kind, KIND.UNSTRUCTURED_ERROR);
});
test('warnings array captures all stderr lines except the last', () => {
const stderr = ['line1', 'line2', JSON.stringify({ ok: false, reason: 'r', message: 'm' })].join('\n');
const raw = { exitCode: 1, stdout: '', stderr, argv: ['x'] };
const result = classify(raw);
assert.deepStrictEqual(result.warnings, ['line1', 'line2']);
});
test('an @file: pointer is followed and its JSON payload parsed', (t) => {
const dir = createTempDir('gsd-runresult-pointer-');
t.after(() => cleanup(dir));
const payloadPath = path.join(dir, 'payload.json');
fs.writeFileSync(payloadPath, JSON.stringify({ ok: true, phases: 4 }), 'utf-8');
const raw = { exitCode: 0, stdout: `@file:${payloadPath}`, stderr: '', argv: ['big-output'] };
const result = classify(raw);
assert.strictEqual(result.kind, KIND.JSON);
assert.strictEqual(result.pointer, payloadPath);
assert.deepStrictEqual(result.json, { ok: true, phases: 4 });
});
test('an unreadable @file: pointer classifies as UNSTRUCTURED_ERROR rather than throwing', () => {
const pointerPath = '/definitely/not/a/real/path-xyz.json';
const raw = { exitCode: 0, stdout: `@file:${pointerPath}`, stderr: '', argv: ['big-output'] };
const io = {
readFileSync: () => {
throw new Error('injected: pointee unreadable');
},
};
const result = classify(raw, io);
assert.strictEqual(result.kind, KIND.UNSTRUCTURED_ERROR);
assert.strictEqual(result.pointer, pointerPath);
});
});
describe('oracle self-tests', () => {
test('ORACLES has exactly 10 entries (7 violation-severity + 3 smell-severity)', () => {
assert.strictEqual(ORACLES.length, 10);
});
test('exit-contract passes on a clean context', () => {
const outcome = getOracle('exit-contract').check({ result: { kind: KIND.JSON } });
assert.strictEqual(outcome.ok, true);
});
test('exit-contract fails on a broken context (TIMEOUT)', () => {
const outcome = getOracle('exit-contract').check({ result: { kind: KIND.TIMEOUT } });
assert.strictEqual(outcome.ok, false);
assert.strictEqual(typeof outcome.detail, 'string');
assert.ok(outcome.detail.length > 0);
});
test('json-contract passes on a clean context (well-formed STRUCTURED_ERROR)', () => {
const outcome = getOracle('json-contract').check({
result: { kind: KIND.STRUCTURED_ERROR, err: { ok: false, reason: 'bad-thing' } },
});
assert.strictEqual(outcome.ok, true);
});
test('json-contract fails on a broken context (UNSTRUCTURED_ERROR)', () => {
const outcome = getOracle('json-contract').check({ result: { kind: KIND.UNSTRUCTURED_ERROR } });
assert.strictEqual(outcome.ok, false);
assert.strictEqual(typeof outcome.detail, 'string');
assert.ok(outcome.detail.length > 0);
});
test('value-hygiene passes on a clean context', () => {
const outcome = getOracle('value-hygiene').check({ result: { json: { a: 1, note: 'fine' } } });
assert.strictEqual(outcome.ok, true);
});
test('value-hygiene VIOLATION on a NaN leaf', () => {
const outcome = getOracle('value-hygiene').check({ result: { json: { a: Number.NaN } } });
assert.strictEqual(outcome.ok, false);
assert.strictEqual(outcome.severity, SEVERITY.VIOLATION);
assert.strictEqual(typeof outcome.detail, 'string');
assert.ok(outcome.detail.length > 0);
});
test('value-hygiene VIOLATION on each coercion-artifact sentinel string', () => {
for (const sentinel of ['undefined', 'null', 'NaN', '[object Object]']) {
const outcome = getOracle('value-hygiene').check({ result: { json: { a: sentinel } } });
assert.strictEqual(outcome.ok, false, `sentinel ${JSON.stringify(sentinel)} should violate`);
assert.strictEqual(outcome.severity, SEVERITY.VIOLATION);
assert.strictEqual(typeof outcome.detail, 'string');
assert.ok(outcome.detail.length > 0);
}
});
test('value-hygiene SMELL (not a violation) on an absolute path outside ctx.projectDir', (t) => {
const projectDir = createTempDir('gsd-hygiene-outside-');
const outsideDir = createTempDir('gsd-hygiene-outside-sibling-');
t.after(() => {
cleanup(projectDir);
cleanup(outsideDir);
});
const outcome = getOracle('value-hygiene').check({
result: { json: { p: path.join(outsideDir, 'leak.md') } },
projectDir,
});
assert.strictEqual(outcome.ok, false);
assert.strictEqual(outcome.severity, SEVERITY.SMELL);
assert.strictEqual(typeof outcome.detail, 'string');
assert.ok(outcome.detail.length > 0);
// Confirm the severity split is honored end-to-end via runOracles too.
const { violations, smells } = runOracles({ result: { json: { p: path.join(outsideDir, 'leak.md') } }, projectDir });
assert.strictEqual(violations.some((v) => v.id === 'value-hygiene'), false);
assert.strictEqual(smells.some((s) => s.id === 'value-hygiene'), true);
});
test('value-hygiene has no finding for an in-project path, including one that does not exist yet', (t) => {
const projectDir = createTempDir('gsd-hygiene-inproject-');
t.after(() => cleanup(projectDir));
// `init` returns paths for files the agent has not written yet — realpath
// throws ENOENT on those, and the oracle must not crash or false-positive.
const notYetWritten = path.join(projectDir, '.planning', 'NOT-YET.md');
let outcome;
assert.doesNotThrow(() => {
outcome = getOracle('value-hygiene').check({ result: { json: { p: notYetWritten } }, projectDir });
});
assert.strictEqual(outcome.ok, true);
});
test('value-hygiene has no finding when ctx.projectDir is absent (skips, does not guess)', () => {
const outcome = getOracle('value-hygiene').check({ result: { json: { p: '/some/unrelated/absolute/path' } } });
assert.strictEqual(outcome.ok, true);
});
test('value-hygiene has no finding (not even a SMELL) for an allowlisted external-path key like agents_dir', (t) => {
const projectDir = createTempDir('gsd-hygiene-allowlist-');
const outsideDir = createTempDir('gsd-hygiene-allowlist-outside-');
t.after(() => {
cleanup(projectDir);
cleanup(outsideDir);
});
const outcome = getOracle('value-hygiene').check({
result: { json: { agents_dir: path.join(outsideDir, 'agents') } },
projectDir,
});
assert.strictEqual(outcome.ok, true);
const { violations, smells } = runOracles({
result: { json: { agents_dir: path.join(outsideDir, 'agents') } },
projectDir,
});
assert.strictEqual(violations.some((v) => v.id === 'value-hygiene'), false);
assert.strictEqual(smells.some((s) => s.id === 'value-hygiene'), false);
});
test('value-hygiene still SMELLs on a non-allowlisted out-of-project key even when agents_dir is also present', (t) => {
const projectDir = createTempDir('gsd-hygiene-mixed-');
const outsideDir = createTempDir('gsd-hygiene-mixed-outside-');
t.after(() => {
cleanup(projectDir);
cleanup(outsideDir);
});
const outcome = getOracle('value-hygiene').check({
result: {
json: {
agents_dir: path.join(outsideDir, 'agents'),
leaked_path: path.join(outsideDir, 'leak.md'),
},
},
projectDir,
});
assert.strictEqual(outcome.ok, false);
assert.strictEqual(outcome.severity, SEVERITY.SMELL);
assert.strictEqual(outcome.subject.key, '$.leaked_path');
});
test('value-hygiene SMELLs on a sibling-prefix path (containment is path-segment, not string-prefix)', (t) => {
const projectDir = createTempDir('gsd-hygiene-sibling-');
t.after(() => cleanup(projectDir));
// `${projectDir}-evil` starts with the exact same characters as `projectDir`,
// so a naive string-prefix/`.startsWith()` containment check would (wrongly)
// treat it as inside. Path-segment containment must not.
const siblingPath = path.join(`${projectDir}-evil`, 'x');
const outcome = getOracle('value-hygiene').check({ result: { json: { p: siblingPath } }, projectDir });
assert.strictEqual(outcome.ok, false);
assert.strictEqual(outcome.severity, SEVERITY.SMELL);
});
test('value-hygiene does not crash on a cyclic json object', () => {
const cyclic = {};
cyclic.self = cyclic;
let outcome;
assert.doesNotThrow(() => {
outcome = getOracle('value-hygiene').check({ result: { json: cyclic } });
});
assert.strictEqual(outcome.ok, true);
});
test('value-hygiene does not crash on non-object json', () => {
const outcome = getOracle('value-hygiene').check({ result: { json: 'just a plain string' } });
assert.strictEqual(outcome.ok, true);
});
test('read-only-idempotence passes on a clean context', () => {
const outcome = getOracle('read-only-idempotence').check({
readOnly: true,
result: { json: { a: 1 } },
repeatResult: { json: { a: 1 } },
statsBefore: new Map([['f.md', { size: 10, mtimeMs: 100 }]]),
statsAfter: new Map([['f.md', { size: 10, mtimeMs: 100 }]]),
});
assert.strictEqual(outcome.ok, true);
});
test('read-only-idempotence fails on a broken context (repeatResult.json diverges)', () => {
const outcome = getOracle('read-only-idempotence').check({
readOnly: true,
result: { json: { a: 1 } },
repeatResult: { json: { a: 2 } },
statsBefore: new Map([['f.md', { size: 10, mtimeMs: 100 }]]),
statsAfter: new Map([['f.md', { size: 10, mtimeMs: 100 }]]),
});
assert.strictEqual(outcome.ok, false);
assert.strictEqual(typeof outcome.detail, 'string');
assert.ok(outcome.detail.length > 0);
});
test('monotonic-progress passes on a clean context', () => {
const outcome = getOracle('monotonic-progress').check({
history: [{ json: { total_plans: 1 } }],
result: { json: { total_plans: 3 } },
});
assert.strictEqual(outcome.ok, true);
});
test('monotonic-progress fails on a broken context (value decreased)', () => {
// Neither observation carries milestone_version/milestone_name at all (branch 2 of the
// three-way scope rule, #2966 FIX 2b) — they share the same (absent) scope by construction,
// so this MUST still compare normally and violate. A prior fix over-broadened the "skip when
// scope is unknowable" rule to skip ANY scope-less payload, which silently disabled this exact
// check for a minimal payload like this one; only the full remote suite caught it.
const outcome = getOracle('monotonic-progress').check({
history: [{ json: { total_plans: 5 } }],
result: { json: { total_plans: 2 } },
});
assert.strictEqual(outcome.ok, false);
assert.strictEqual(outcome.severity, SEVERITY.VIOLATION);
assert.strictEqual(outcome.subject.key, 'total_plans');
assert.strictEqual(outcome.subject.from, 5);
assert.strictEqual(outcome.subject.to, 2);
assert.strictEqual(typeof outcome.detail, 'string');
assert.ok(outcome.detail.length > 0);
});
test('monotonic-progress: a decrease with total_summaries (the exact self-test shape) still violates when neither side has scope', () => {
// Regression coverage for the #2966 self-test payload shape reported by the full suite:
// `{ total_summaries: n }`, no milestone fields, no argv at all.
const outcome = getOracle('monotonic-progress').check({
history: [{ json: { total_summaries: 3 } }],
result: { json: { total_summaries: 1 } },
});
assert.strictEqual(outcome.ok, false);
assert.strictEqual(outcome.severity, SEVERITY.VIOLATION);
assert.strictEqual(outcome.subject.key, 'total_summaries');
assert.strictEqual(outcome.subject.from, 3);
assert.strictEqual(outcome.subject.to, 1);
});
test('monotonic-progress: a decrease WITHIN the same milestone_version is a VIOLATION', () => {
const outcome = getOracle('monotonic-progress').check({
history: [{ json: { total_plans: 5, milestone_version: 'v1.0', milestone_name: 'milestone' }, argv: ['progress'] }],
result: { json: { total_plans: 2, milestone_version: 'v1.0', milestone_name: 'milestone' }, argv: ['progress'] },
});
assert.strictEqual(outcome.ok, false);
assert.strictEqual(outcome.severity, SEVERITY.VIOLATION);
assert.strictEqual(outcome.subject.key, 'total_plans');
assert.strictEqual(outcome.subject.from, 5);
assert.strictEqual(outcome.subject.to, 2);
});
test('monotonic-progress: the SAME decrease ACROSS a milestone_version change is NOT reported at all (silent reset, not even a SMELL)', () => {
const ctx = {
history: [{ json: { total_plans: 5, milestone_version: 'v1.0', milestone_name: 'milestone' }, argv: ['progress'] }],
result: { json: { total_plans: 2, milestone_version: 'v2.0', milestone_name: 'Milestone Two' }, argv: ['progress'] },
};
const outcome = getOracle('monotonic-progress').check(ctx);
assert.strictEqual(outcome.ok, true, 'a milestone-version boundary crossing must reset silently, not fire at all');
// Confirm this never reaches `runOracles(ctx).failed` NOR `.smells` — a boundary
// crossing is expected behavior, not evidence worth a human look (#2966 FIX 2a).
const { failed, smells } = runOracles(ctx);
assert.strictEqual(failed.find((f) => f.id === 'monotonic-progress'), undefined);
assert.strictEqual(smells.find((s) => s.id === 'monotonic-progress'), undefined);
});
test('monotonic-progress: a decrease ACROSS a --ws workstream switch is NOT reported at all (silent reset, not even a SMELL)', () => {
const ctx = {
history: [{ json: { total_plans: 5 }, argv: ['--ws', 'alpha', 'progress'] }],
result: { json: { total_plans: 0 }, argv: ['--ws', 'beta', 'progress'] },
};
const outcome = getOracle('monotonic-progress').check(ctx);
assert.strictEqual(outcome.ok, true, 'a workstream boundary crossing must reset silently, not fire at all');
const { failed, smells } = runOracles(ctx);
assert.strictEqual(failed.find((f) => f.id === 'monotonic-progress'), undefined);
assert.strictEqual(smells.find((s) => s.id === 'monotonic-progress'), undefined);
});
test('monotonic-progress: a subsequent same-scope decrease AFTER a boundary crossing is still a VIOLATION (the reset re-arms the check)', () => {
const outcome = getOracle('monotonic-progress').check({
history: [
{ json: { total_plans: 5, milestone_version: 'v1.0', milestone_name: 'milestone' }, argv: ['progress'] },
{ json: { total_plans: 0, milestone_version: 'v2.0', milestone_name: 'Milestone Two' }, argv: ['progress'] },
{ json: { total_plans: 3, milestone_version: 'v2.0', milestone_name: 'Milestone Two' }, argv: ['progress'] },
],
result: { json: { total_plans: 1, milestone_version: 'v2.0', milestone_name: 'Milestone Two' }, argv: ['progress'] },
});
assert.strictEqual(outcome.ok, false);
assert.strictEqual(outcome.severity, SEVERITY.VIOLATION);
assert.strictEqual(outcome.subject.from, 3);
assert.strictEqual(outcome.subject.to, 1);
});
test('monotonic-progress: a MIXED pair (one scoped, one not) is skipped — genuinely indeterminate, not a boundary guess', () => {
// Mirrors `roadmap analyze`'s real payload shape sitting between two scoped `progress`
// observations: neither milestone_version nor milestone_name present on the middle entry.
// Branch 3 of the three-way rule (#2966 FIX 2c): that ONE pairing is skipped and does not
// become the new reference point, so the surrounding same-scope comparison still applies —
// see the next test.
const ctx = {
history: [
{ json: { total_plans: 5, milestone_version: 'v1.0', milestone_name: 'milestone' }, argv: ['progress'] },
{ json: { total_plans: 1 }, argv: ['roadmap', 'analyze'] },
],
result: { json: { total_plans: 5, milestone_version: 'v1.0', milestone_name: 'milestone' }, argv: ['progress'] },
};
const outcome = getOracle('monotonic-progress').check(ctx);
assert.strictEqual(outcome.ok, true, 'the scope-less entry must be invisible to the comparison, not a violation or a smell');
const { failed, smells } = runOracles(ctx);
assert.strictEqual(failed.find((f) => f.id === 'monotonic-progress'), undefined);
assert.strictEqual(smells.find((s) => s.id === 'monotonic-progress'), undefined);
});
test('monotonic-progress: a scope-less observation does not mask a real same-scope decrease around it', () => {
const outcome = getOracle('monotonic-progress').check({
history: [
{ json: { total_plans: 5, milestone_version: 'v1.0', milestone_name: 'milestone' }, argv: ['progress'] },
{ json: { total_plans: 1 }, argv: ['roadmap', 'analyze'] },
],
result: { json: { total_plans: 2, milestone_version: 'v1.0', milestone_name: 'milestone' }, argv: ['progress'] },
});
assert.strictEqual(outcome.ok, false);
assert.strictEqual(outcome.severity, SEVERITY.VIOLATION);
assert.strictEqual(outcome.subject.from, 5);
assert.strictEqual(outcome.subject.to, 2);
});
test('routing-validity passes on a clean context', () => {
const outcome = getOracle('routing-validity').check({
result: { json: { recommended: '/gsd-plan-phase' } },
liveCommands: ['/gsd-plan-phase'],
});
assert.strictEqual(outcome.ok, true);
});
test('routing-validity fails on a broken context (token not in liveCommands)', () => {
const outcome = getOracle('routing-validity').check({
result: { json: { recommended: '/gsd-plan-phase' } },
liveCommands: ['/gsd-something-else'],
});
assert.strictEqual(outcome.ok, false);
assert.strictEqual(typeof outcome.detail, 'string');
assert.ok(outcome.detail.length > 0);
});
test('determinism passes on a clean context', () => {
const outcome = getOracle('determinism').check({
result: { kind: KIND.JSON },
repeatResult: { kind: KIND.JSON },
});
assert.strictEqual(outcome.ok, true);
});
test('determinism fails on a broken context (repeat kind diverges)', () => {
const outcome = getOracle('determinism').check({
result: { kind: KIND.JSON },
repeatResult: { kind: KIND.PROSE },
});
assert.strictEqual(outcome.ok, false);
assert.strictEqual(typeof outcome.detail, 'string');
assert.ok(outcome.detail.length > 0);
});
test('soft-error-exit-zero passes on a clean context', () => {
const outcome = getOracle('soft-error-exit-zero').check({ result: { kind: KIND.JSON, argv: ['progress'] } });
assert.strictEqual(outcome.ok, true);
});
test('soft-error-exit-zero SMELLs (not a violation) on a SOFT_ERROR result', () => {
const ctx = { result: { kind: KIND.SOFT_ERROR, argv: ['progress'], json: { error: 'no phases found' } } };
const outcome = getOracle('soft-error-exit-zero').check(ctx);
assert.strictEqual(outcome.ok, false);
assert.strictEqual(outcome.severity, SEVERITY.SMELL);
assert.strictEqual(typeof outcome.detail, 'string');
assert.ok(outcome.detail.length > 0);
assert.deepEqual(outcome.subject.argv, ['progress'], 'subject.argv must name the offending command');
const { violations, smells } = runOracles(ctx);
assert.strictEqual(violations.some((v) => v.id === 'soft-error-exit-zero'), false);
assert.strictEqual(smells.some((s) => s.id === 'soft-error-exit-zero'), true);
});
test('untyped-success passes on a clean context', () => {
const outcome = getOracle('untyped-success').check({ result: { kind: KIND.JSON, argv: ['progress'] } });
assert.strictEqual(outcome.ok, true);
});
test('untyped-success SMELLs (not a violation) on a PROSE result', () => {
const ctx = { result: { kind: KIND.PROSE, argv: ['init'] } };
const outcome = getOracle('untyped-success').check(ctx);
assert.strictEqual(outcome.ok, false);
assert.strictEqual(outcome.severity, SEVERITY.SMELL);
assert.strictEqual(typeof outcome.detail, 'string');
assert.ok(outcome.detail.length > 0);
assert.deepEqual(outcome.subject.argv, ['init'], 'subject.argv must name the offending command');
const { violations, smells } = runOracles(ctx);
assert.strictEqual(violations.some((v) => v.id === 'untyped-success'), false);
assert.strictEqual(smells.some((s) => s.id === 'untyped-success'), true);
});
test('contract-conflict passes on a clean context', () => {
const outcome = getOracle('contract-conflict').check({
jsonErrorMode: true,
result: { kind: KIND.JSON, argv: ['progress'] },
});
assert.strictEqual(outcome.ok, true);
});
test('contract-conflict SMELLs (not a violation) when --json-errors still produced unstructured error text', () => {
const ctx = { jsonErrorMode: true, result: { kind: KIND.UNSTRUCTURED_ERROR, argv: ['bad-usage'] } };
const outcome = getOracle('contract-conflict').check(ctx);
assert.strictEqual(outcome.ok, false);
assert.strictEqual(outcome.severity, SEVERITY.SMELL);
assert.strictEqual(typeof outcome.detail, 'string');
assert.ok(outcome.detail.length > 0);
assert.deepEqual(outcome.subject.argv, ['bad-usage'], 'subject.argv must name the offending command');
const { violations, smells } = runOracles(ctx);
assert.strictEqual(violations.some((v) => v.id === 'contract-conflict'), false);
assert.strictEqual(smells.some((s) => s.id === 'contract-conflict'), true);
});
});
describe('severity model', () => {
test('a smell must never appear in failed (the contract that keeps smells from breaking builds)', () => {
const ctx = { result: { kind: KIND.PROSE, argv: ['init'] } };
const { failed, smells } = runOracles(ctx);
assert.ok(smells.some((s) => s.id === 'untyped-success'));
assert.strictEqual(failed.some((f) => f.id === 'untyped-success'), false);
});
test('failed.length === violations.length for a context producing both a violation and a smell', () => {
// value-hygiene fires a VIOLATION on the NaN leaf; untyped-success fires a
// SMELL on the PROSE kind. Both fire from the same ctx.
const ctx = { result: { kind: KIND.PROSE, argv: ['init'], json: { a: Number.NaN } } };
const { failed, violations, smells } = runOracles(ctx);
assert.ok(violations.length > 0);
assert.ok(smells.length > 0);
assert.strictEqual(failed.length, violations.length);
});
test('SEVERITY is frozen and has exactly the expected keys', () => {
assert.strictEqual(Object.isFrozen(SEVERITY), true);
assert.deepStrictEqual(Object.keys(SEVERITY).sort(), ['SMELL', 'VIOLATION'].sort());
assert.strictEqual(SEVERITY.VIOLATION, 'violation');
assert.strictEqual(SEVERITY.SMELL, 'smell');
});
});
describe('scenario DSL validation', () => {
let expectedPoints;
before(() => {
expectedPoints = new Set();
for (const entry of LOOP_HOST_CONTRACT) {
for (const point of entry.points) expectedPoints.add(point);
}
});
function writeScenarioFile(t, obj) {
const dir = createTempDir('gsd-scenario-dsl-');
t.after(() => cleanup(dir));
const file = path.join(dir, 'scenario.json');
fs.writeFileSync(file, JSON.stringify(obj), 'utf-8');
return file;
}
test('loadScenario rejects an empty steps array', (t) => {
const file = writeScenarioFile(t, { name: 'empty-steps', fixture: 'greenfield', steps: [] });
assert.throws(() => loadScenario(file), /"steps"/);
});
test('loadScenario rejects an unknown fixture', (t) => {
const file = writeScenarioFile(t, {
name: 'bad-fixture',
fixture: 'nonexistent-fixture',
steps: [{ at: 'discuss:pre' }],
});
assert.throws(() => loadScenario(file), /nonexistent-fixture/);
});
test('loadScenario rejects an "at" point not present in the generated loop contract', (t) => {
const file = writeScenarioFile(t, {
name: 'bad-point',
fixture: 'greenfield',
steps: [{ at: 'totally-bogus-point' }],
});
assert.throws(() => loadScenario(file), /totally-bogus-point/);
});
test('loadScenario rejects a non-boolean "jsonErrors" field, naming it', (t) => {
const file = writeScenarioFile(t, {
name: 'bad-json-errors',
fixture: 'greenfield',
steps: [{ at: 'discuss:pre', jsonErrors: 'yes' }],
});
assert.throws(() => loadScenario(file), /jsonErrors/);
});
test('loadScenario rejects a malformed expect entry', (t) => {
const file = writeScenarioFile(t, {
name: 'bad-expect',
fixture: 'greenfield',
steps: [{ at: 'discuss:pre', expect: [{ foo: 'bar' }] }],
});
assert.throws(() => loadScenario(file), /expect\[0\]/);
});
test('the legal point set derives from loop-host-contract.cjs: a known-good point loads', (t) => {
const knownGoodPoint = [...expectedPoints][0];
assert.strictEqual(expectedPoints.has(knownGoodPoint), true);
const file = writeScenarioFile(t, {
name: 'known-good',
fixture: 'greenfield',
steps: [{ at: knownGoodPoint }],
});
const scenario = loadScenario(file);
assert.strictEqual(scenario.steps[0].at, knownGoodPoint);
});
test('the legal point set derives from loop-host-contract.cjs: a fabricated point throws', (t) => {
const fabricatedPoint = 'zzz:not-a-real-point';
assert.strictEqual(expectedPoints.has(fabricatedPoint), false);
const file = writeScenarioFile(t, {
name: 'fabricated',
fixture: 'greenfield',
steps: [{ at: fabricatedPoint }],
});
assert.throws(() => loadScenario(file), /zzz:not-a-real-point/);
});
});
describe('fixture refs', () => {
test("resolveRef('@project/minimal') returns non-empty content", () => {
const text = resolveRef('@project/minimal');
assert.strictEqual(typeof text, 'string');
assert.ok(text.length > 0);
});
test('resolveRef throws on an unknown ref, naming the ref', () => {
assert.throws(() => resolveRef('@nope/nope'), /@nope\/nope/);
});
});
describe('mutations', () => {
const SAMPLE_ARTIFACT_TEXT = [
'---',
'title: sample',
'phase: 1',
'---',
'',
'## Phase 1',
'',
'| 1 | Task | Status |',
'| --- | --- | --- |',
'| 1 | Do the thing | pending |',
'',
'Some body text describing the phase.',
].join('\n');
test('MUTATIONS has exactly 11 entries', () => {
assert.strictEqual(MUTATIONS.length, 11);
});
test('every mutation id is unique', () => {
const ids = MUTATIONS.map((m) => m.id);
assert.strictEqual(new Set(ids).size, ids.length);
});
test('apply("truncate-frontmatter", ...) shortens text and drops the closing delimiter', () => {
const result = apply('truncate-frontmatter', SAMPLE_ARTIFACT_TEXT);
assert.notStrictEqual(result, SAMPLE_ARTIFACT_TEXT);
assert.ok(result.length < SAMPLE_ARTIFACT_TEXT.length);
});
test('apply("crlf", ...) converts line endings to CRLF', () => {
const result = apply('crlf', SAMPLE_ARTIFACT_TEXT);
assert.notStrictEqual(result, SAMPLE_ARTIFACT_TEXT);
assert.ok(result.includes('\r\n'));
});
test('apply("bom", ...) prefixes text with a byte-order-mark', () => {
const result = apply('bom', SAMPLE_ARTIFACT_TEXT);
assert.notStrictEqual(result, SAMPLE_ARTIFACT_TEXT);
assert.strictEqual(result.charCodeAt(0), 0xfeff);
});
test('apply("empty", ...) replaces text with an empty string', () => {
const result = apply('empty', SAMPLE_ARTIFACT_TEXT);
assert.strictEqual(result, '');
});
test('apply("duplicate-phase-id", ...) duplicates the first phase-id line', () => {
const result = apply('duplicate-phase-id', SAMPLE_ARTIFACT_TEXT);
assert.notStrictEqual(result, SAMPLE_ARTIFACT_TEXT);
});
test('apply("nonsequential-phases", ...) renumbers phase-id occurrences when present', () => {
const result = apply('nonsequential-phases', SAMPLE_ARTIFACT_TEXT);
assert.notStrictEqual(result, NOOP);
assert.notStrictEqual(result, SAMPLE_ARTIFACT_TEXT);
});
test('apply("nonsequential-phases", ...) returns the NOOP sentinel when no phase-id occurs', () => {
const noPhaseText = ['# Just a title', '', 'No phase markers in this document.'].join('\n');
const result = apply('nonsequential-phases', noPhaseText);
assert.strictEqual(result, NOOP);
});
test('apply("unicode-headings", ...) replaces every heading\'s text', () => {
const result = apply('unicode-headings', SAMPLE_ARTIFACT_TEXT);
assert.notStrictEqual(result, SAMPLE_ARTIFACT_TEXT);
});
test('apply("oversized", ...) pads text past a target larger than the current size', () => {
const targetBytes = Buffer.byteLength(SAMPLE_ARTIFACT_TEXT, 'utf8') + 50;
const result = apply('oversized', SAMPLE_ARTIFACT_TEXT, { targetBytes });
assert.notStrictEqual(result, SAMPLE_ARTIFACT_TEXT);
assert.ok(Buffer.byteLength(result, 'utf8') > targetBytes);
});
test('apply("escaped-pipes", ...) injects an escaped-pipe cell into the first table row', () => {
const result = apply('escaped-pipes', SAMPLE_ARTIFACT_TEXT);
assert.notStrictEqual(result, SAMPLE_ARTIFACT_TEXT);
});
test('apply("crlf", ...) is idempotent and never produces \\r\\r\\n', () => {
const once = apply('crlf', SAMPLE_ARTIFACT_TEXT);
const twice = apply('crlf', once);
assert.strictEqual(twice, once);
assert.strictEqual(twice.includes('\r\r\n'), false);
});
test('apply("oversized", ...) at targetBytes - 1 (input already over target) leaves input unchanged', () => {
const input = 'A'.repeat(10);
const result = apply('oversized', input, { targetBytes: 9 });
assert.strictEqual(result, input);
});
test('apply("oversized", ...) at targetBytes === input size pads to exactly one byte over', () => {
const input = 'A'.repeat(10);
const result = apply('oversized', input, { targetBytes: 10 });
assert.strictEqual(Buffer.byteLength(result, 'utf8'), 11);
});
test('apply("oversized", ...) at targetBytes + 1 (input under target) pads to one byte over', () => {
const input = 'A'.repeat(10);
const result = apply('oversized', input, { targetBytes: 11 });
assert.strictEqual(Buffer.byteLength(result, 'utf8'), 12);
});
test('apply(...) throws on an unknown mutation id', () => {
assert.throws(() => apply('not-a-real-mutation', 'x'), /not-a-real-mutation/);
});
describe('file mutations', () => {
let mutDir;
beforeEach(() => {
mutDir = createTempDir('gsd-mutations-file-');
});
afterEach(() => {
cleanup(mutDir);
});
test('apply("delete", ...) removes the file on disk', () => {
const relPath = 'artifact.md';
fs.writeFileSync(path.join(mutDir, relPath), SAMPLE_ARTIFACT_TEXT, 'utf-8');
apply('delete', { dir: mutDir, relPath });
assert.strictEqual(fs.existsSync(path.join(mutDir, relPath)), false);
});
test('apply("symlink", ...) replaces the file with a symlink or hardlink of identical size', () => {
const relPath = 'artifact.md';
const abs = path.join(mutDir, relPath);
fs.writeFileSync(abs, SAMPLE_ARTIFACT_TEXT, 'utf-8');
const sizeBefore = fs.statSync(abs).size;
apply('symlink', { dir: mutDir, relPath });
const lstat = fs.lstatSync(abs);
const sizeAfter = fs.statSync(abs).size;
assert.strictEqual(sizeAfter, sizeBefore);
assert.strictEqual(lstat.isSymbolicLink() || lstat.isFile(), true);
});
});
});
describe('path containment', () => {
test('resolveWithin rejects a traversing relPath, naming it and the base', (t) => {
const dir = createTempDir('gsd-pathguard-');
t.after(() => cleanup(dir));
assert.throws(() => resolveWithin(dir, '../../etc/hosts'), (err) => {
assert.ok(err instanceof Error);
assert.strictEqual(err.code, 'EPATHESCAPE');
assert.strictEqual(err.attemptedPath, '../../etc/hosts');
assert.strictEqual(err.base, dir);
return true;
});
});
test('resolveWithin rejects an absolute relPath', (t) => {
const dir = createTempDir('gsd-pathguard-abs-');
t.after(() => cleanup(dir));
assert.throws(() => resolveWithin(dir, '/etc/hosts'), (err) => {
assert.strictEqual(err.code, 'EPATHESCAPE');
assert.strictEqual(err.attemptedPath, '/etc/hosts');
assert.strictEqual(err.base, dir);
return true;
});
});
test('resolveWithin rejects a sibling-prefix escape (path-segment, not string-prefix, containment)', (t) => {
const dir = createTempDir('gsd-pathguard-sibling-');
t.after(() => cleanup(dir));
assert.throws(() => resolveWithin(dir, `../${path.basename(dir)}-evil/x`), /escapes/);
});
test('resolveWithin accepts an in-project path that does not exist yet', (t) => {
const dir = createTempDir('gsd-pathguard-notyet-');
t.after(() => cleanup(dir));
const resolved = resolveWithin(dir, 'deep/not/created/yet.md');
assert.strictEqual(typeof resolved, 'string');
assert.ok(resolved.length > 0);
});
test('LoopWalk#writeArtifact rejects a traversing relPath', (t) => {
const walk = LoopWalk.create({ fixture: 'greenfield', prefix: 'gsd-pathguard-write-' });
t.after(() => walk.cleanup());
assert.throws(() => walk.writeArtifact('../../escaped.md', 'x'), /escapes|absolute/);
assert.strictEqual(fs.existsSync(path.join(path.dirname(walk.dir), 'escaped.md')), false);
});
test('LoopWalk#writeArtifact still writes a legitimate in-project path after the guard', (t) => {
const walk = LoopWalk.create({ fixture: 'greenfield', prefix: 'gsd-pathguard-write-ok-' });
t.after(() => walk.cleanup());
walk.writeArtifact('.planning/PROJECT.md', '# ok\n');
assert.strictEqual(fs.existsSync(path.join(walk.dir, '.planning', 'PROJECT.md')), true);
});
test('mutations apply("delete", ...) rejects a traversing relPath', (t) => {
const mutDir = createTempDir('gsd-pathguard-delete-');
t.after(() => cleanup(mutDir));
assert.throws(() => apply('delete', { dir: mutDir, relPath: '../../../../etc/hosts' }), /escapes|absolute/);
});
test('mutations apply("symlink", ...) rejects a traversing relPath', (t) => {
const mutDir = createTempDir('gsd-pathguard-symlink-');
t.after(() => cleanup(mutDir));
assert.throws(() => apply('symlink', { dir: mutDir, relPath: '../../../../etc/hosts' }), /escapes|absolute/);
});
test('loadScenario rejects a mutate.target that traverses out of the project, at load time', (t) => {
const dir = createTempDir('gsd-pathguard-scenario-mutate-');
t.after(() => cleanup(dir));
const file = path.join(dir, 's.json');
fs.writeFileSync(file, JSON.stringify({
name: 'traversal-mutate',
fixture: 'greenfield',
steps: [{ at: 'plan:pre', mutate: { id: 'crlf', target: '../../evil.md' }, run: [['progress']] }],
}));
assert.throws(() => loadScenario(file), /evil|\.\./);
});
test('loadScenario rejects an absolute mutate.target, at load time', (t) => {
const dir = createTempDir('gsd-pathguard-scenario-mutate-abs-');
t.after(() => cleanup(dir));
const file = path.join(dir, 's.json');
fs.writeFileSync(file, JSON.stringify({
name: 'absolute-mutate',
fixture: 'greenfield',
steps: [{ at: 'plan:pre', mutate: { id: 'crlf', target: '/etc/evil.md' }, run: [['progress']] }],
}));
assert.throws(() => loadScenario(file), /absolute/);
});
test('loadScenario rejects an agent.write key that traverses out of the project, at load time', (t) => {
const dir = createTempDir('gsd-pathguard-scenario-write-');
t.after(() => cleanup(dir));
const file = path.join(dir, 's.json');
fs.writeFileSync(file, JSON.stringify({
name: 'traversal-write',
fixture: 'greenfield',
steps: [{ at: 'plan:pre', agent: { write: { '../../escaped.md': '@project/minimal' } }, run: [['progress']] }],
}));
assert.throws(() => loadScenario(file), /escaped|\.\./);
});
test('loadScenario rejects an absolute agent.write key, at load time', (t) => {
const dir = createTempDir('gsd-pathguard-scenario-write-abs-');
t.after(() => cleanup(dir));
const file = path.join(dir, 's.json');
fs.writeFileSync(file, JSON.stringify({
name: 'absolute-write',
fixture: 'greenfield',
steps: [{ at: 'plan:pre', agent: { write: { '/etc/evil.md': '@project/minimal' } }, run: [['progress']] }],
}));
assert.throws(() => loadScenario(file), /etc|absolute/i);
});
});
describe('greenfield walk (end-to-end)', () => {
let liveCommands;
before(() => {
// tests/helpers/live-command-registry.cjs's real API: getLiveCommandTokens()
// returns a memoized Set<string> of every live slash-command token derived
// from commands/gsd/*.md frontmatter (e.g. "/gsd-plan-phase"). The
// routing-validity oracle checks result.json.recommended against this set.
liveCommands = [...getLiveCommandTokens()];
});
test('runs every step of the greenfield-happy-path scenario clean', () => {
const scenarioPath = path.join(__dirname, 'qa', 'scenarios', 'greenfield-happy-path.json');
const scenario = loadScenario(scenarioPath);
const report = runScenario(scenario, { LoopWalk, runOracles, liveCommands });
assert.strictEqual(report.steps.length, scenario.steps.length);
for (const step of report.steps) {
assert.deepStrictEqual(step.expectFailures, []);
assert.deepStrictEqual(step.oracleFailures, []);
}
assert.strictEqual(report.ok, true);
// Anti-vacuity: a QA harness that reports NOTHING on a first real walk
// against the actual CLI is far more likely to be mis-specified (oracles
// that never fire, a wiring bug that drops ctx fields, a scenario that
// never exercises the paths that produce smells) than the engine is
// genuinely flawless. "Found nothing" is itself a failure signal for a
// harness whose whole job is to keep known trade-offs visible, so the
// walk must be able to speak at least once. Deliberately NOT asserting an
// exact smell count or exact oracle ids here — that would pin today's
// engine behavior into the test and defeat the point of a smell channel
// that is allowed to evolve without becoming a build break.
const totalSmells = report.steps.reduce((sum, step) => sum + step.smells.length, 0);
assert.ok(totalSmells > 0, 'expected the greenfield walk to surface at least one smell');
assert.ok(report.smellSummary.length > 0, 'expected a non-empty smellSummary');
});
});
describe('scenario discovery (mutations wired for real)', () => {
/**
* Every `.json` scenario file under `tests/qa/scenarios/`, EXCLUDING
* underscore-prefixed ones (`_selftest-must-fail.json`) — an
* underscore-prefixed scenario is deliberately broken (see
* `assertWiringIsLive`) and must never run as a normal walk.
*
* @returns {string[]} absolute file paths.
*/
function discoverScenarioFiles() {
const scenariosDir = path.join(__dirname, 'qa', 'scenarios');
return fs
.readdirSync(scenariosDir)
.filter((name) => name.endsWith('.json') && !name.startsWith('_'))
.sort()
.map((name) => path.join(scenariosDir, name));
}
test('discovery excludes underscore-prefixed self-test scenarios', () => {
const names = discoverScenarioFiles().map((p) => path.basename(p));
assert.ok(names.includes('greenfield-happy-path.json'));
assert.ok(names.includes('perturbation-crlf.json'));
assert.ok(names.includes('perturbation-truncated-frontmatter.json'));
assert.ok(names.includes('perturbation-delete-artifact.json'));
assert.strictEqual(names.includes('_selftest-must-fail.json'), false);
});
test('every discovered perturbation scenario applies its mutation and runs to completion without a harness crash', () => {
const liveCommands = [...getLiveCommandTokens()];
const perturbationFiles = discoverScenarioFiles().filter((p) => path.basename(p).startsWith('perturbation-'));
assert.ok(perturbationFiles.length >= 3, 'expected at least the crlf, truncated-frontmatter, and delete-artifact scenarios');
// Anti-vacuity for perturbations specifically: a mutation that changes
// nothing observable in ANY scenario is indistinguishable from a
// mutation that was never applied (see `scenario.cjs`'s
// `mutationObserved` computation). At least one mutated step across the
// whole perturbation set must show a genuinely different result from its
// own clean baseline — if none ever does, that is a finding about which
// engine surfaces are sensitive to corruption, not something to paper
// over by weakening this assertion.
let anyMutationObserved = false;
for (const file of perturbationFiles) {
const scenario = loadScenario(file);
const report = runScenario(scenario, { LoopWalk, runOracles, liveCommands });
assert.strictEqual(report.steps.length, scenario.steps.length, `${scenario.name}: a step went missing from the report`);
for (const step of report.steps) {
assert.strictEqual(
step.oracleFailures.some((f) => f.id === 'step-exception'),
false,
`${scenario.name}: step at "${step.at}" crashed the harness: ${JSON.stringify(step.oracleFailures)}`,
);
}
const mutatedStep = report.steps.find((s) => s.mutation);
assert.ok(mutatedStep, `${scenario.name}: no step recorded a mutation — mutations remain unwired`);
assert.strictEqual(mutatedStep.mutationNoop, false, `${scenario.name}: mutation no-oped on a real roadmap artifact`);
if (mutatedStep.mutationObserved) anyMutationObserved = true;
}
assert.strictEqual(
anyMutationObserved,
true,
'no perturbation scenario produced an observable mutation against its probed surface — '
+ 'every corruption was silently absorbed',
);
});
});
describe('wiring self-test (anti-vacuity)', () => {
test('the self-test scenario proves the expect/oracle assertion machinery actually fires', () => {
const liveCommands = [...getLiveCommandTokens()];
const report = assertWiringIsLive({ LoopWalk, runOracles, liveCommands });
assert.strictEqual(report.ok, false);
assert.ok(report.steps.some((s) => s.expectFailures.length > 0));
});
});
describe('fixture integrity', () => {
/**
* Absolute path to `tests/qa/fixtures/`, the corpus every QA scenario/fixture-ref
* draws from (see `tests/qa/fixtures/index.cjs`'s `resolveRef`).
*/
const FIXTURES_ROOT = path.join(__dirname, 'qa', 'fixtures');
/**
* Every fixture `.md` file, read once, keyed by absolute path — every test below
* reuses this instead of re-walking the tree.
*
* WHY module-content, not module-frontmatter: this guards against defect classes
* where a provenance comment (or any other byte-0 preamble) silently defeats
* `extractFrontmatter`, which only recognizes a frontmatter block that starts at
* byte 0 of the file (`gsd-core/bin/lib/frontmatter.cjs` — `content.startsWith('---\n')`).
*/
const fixtureFiles = collectFixtureMarkdownFiles(FIXTURES_ROOT);
test('discovered at least the known fixture directories (sanity, not vacuous)', () => {
assert.ok(fixtureFiles.length > 0, 'expected at least one fixture .md file under tests/qa/fixtures');
});
test('every fixture that is frontmatter-shaped (optionally preceded by a leading comment) parses to a NON-EMPTY object', () => {
const offenders = [];
for (const file of fixtureFiles) {
const content = fs.readFileSync(file, 'utf-8');
if (!hasFrontmatterShape(content)) continue;
const fm = extractFrontmatter(content, file);
if (Object.keys(fm).length === 0) {
offenders.push(path.relative(FIXTURES_ROOT, file));
}
}
assert.deepStrictEqual(
offenders,
[],
`fixture(s) are frontmatter-shaped but extractFrontmatter returned {} (frontmatter not at byte 0 — ` +
`likely a leading comment ahead of the fence — or malformed): ${JSON.stringify(offenders)}`,
);
});
test('every fixture carries the #2371 provenance marker somewhere in the file', () => {
const offenders = [];
for (const file of fixtureFiles) {
const content = fs.readFileSync(file, 'utf-8');
if (!content.includes('provenance:')) {
offenders.push(path.relative(FIXTURES_ROOT, file));
}
}
assert.deepStrictEqual(
offenders,
[],
`fixture(s) missing the #2371 provenance marker: ${JSON.stringify(offenders)}`,
);
});
describe('UAT fixtures flip the REAL evaluateUatPassed verdict', () => {
let dir;
beforeEach(() => {
dir = createTempDir('gsd-uat-fixture-integrity-');
});
afterEach(() => {
cleanup(dir);
});
test('the passing UAT fixture yields passed:true, no_uat_artifacts:false', () => {
const content = fs.readFileSync(path.join(FIXTURES_ROOT, 'uat', 'current-test-passing.md'), 'utf-8');
fs.writeFileSync(path.join(dir, 'feature-UAT.md'), content, 'utf-8');
const report = evaluateUatPassed(dir);
assert.strictEqual(report.passed, true);
assert.strictEqual(report.no_uat_artifacts, false);
assert.ok(report.checks.length > 0, 'expected at least one real parsed check');
});
test('the failing UAT fixture yields passed:false, no_uat_artifacts:false', () => {
const content = fs.readFileSync(path.join(FIXTURES_ROOT, 'uat', 'current-test-failing.md'), 'utf-8');
fs.writeFileSync(path.join(dir, 'feature-UAT.md'), content, 'utf-8');
const report = evaluateUatPassed(dir);
assert.strictEqual(report.passed, false);
assert.strictEqual(report.no_uat_artifacts, false);
assert.ok(report.checks.length > 0, 'expected at least one real parsed check');
});
});
});
describe('walk isolation', () => {
test('two LoopWalk instances never share a project directory', (t) => {
const walkA = LoopWalk.create({ fixture: 'greenfield', prefix: 'gsd-walk-isolation-a-' });
const walkB = LoopWalk.create({ fixture: 'greenfield', prefix: 'gsd-walk-isolation-b-' });
t.after(() => {
walkA.cleanup();
walkB.cleanup();
});
assert.notStrictEqual(walkA.dir, walkB.dir);
});
test('a read-only command does not change the stat snapshot', (t) => {
const walk = LoopWalk.create({ fixture: 'greenfield', prefix: 'gsd-walk-isolation-readonly-' });
t.after(() => walk.cleanup());
const statsBefore = walk.statSnapshot();
walk.run('progress');
const statsAfter = walk.statSnapshot();
assert.deepStrictEqual(statsBefore, statsAfter);
});
});
describe('worktree-concurrency (dedicated — trajectory 9 is not expressible as a single scenario)', () => {
/**
* WHY THIS IS A DEDICATED TEST, NOT A `scenarios/*.json` FILE
* ────────────────────────────────────────────────────────────
* `scenario.cjs#runScenario` drives exactly one `LoopWalk` through a
* strictly sequential `steps` array — `LoopWalk#run` itself is built on
* `execFileSync` (see `loop-walk.cjs`), so within the DSL there is no way to
* have two `gsd-tools` invocations genuinely in flight at the same wall-clock
* moment. "Two LoopWalk instances driving simultaneously" (issue #2966's
* ninth trajectory) is therefore implemented here directly against
* `child_process.execFile` (async) so both subprocesses are launched before
* either has resolved — real concurrency, not two sequential calls dressed
* up as one.
*/
/** Absolute path to the gsd-tools entry point, mirrored from `helpers.cjs`'s `TOOLS_PATH`. */
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
/**
* Runs a single `gsd-tools --json-errors <argv>` invocation asynchronously
* against `dir`, sanitizing ambient `GSD_*` env vars exactly as
* `LoopWalk#run` does (see that method's header for why), and pinning the
* clock so both concurrent invocations are reproducible.
*
* @param {string} dir
* @param {string[]} argv
* @returns {Promise<{stdout: string, startedAtMs: number, finishedAtMs: number}>}
*/
async function runConcurrent(dir, argv) {
/** @type {Record<string, string|undefined>} */
const sanitize = {};
for (const key of Object.keys(process.env)) {
if (key.startsWith('GSD_')) sanitize[key] = undefined;
}
const env = { ...sanitize, GSD_TEST_MODE: '1', GSD_NOW_MS: '1767225600000' };
const startedAtMs = Date.now();
const { stdout } = await execFileAsync(
process.execPath,
[TOOLS_PATH, '--json-errors', ...argv],
{ cwd: dir, encoding: 'utf-8', env, timeout: 60000 },
);
return { stdout: stdout.trim(), startedAtMs, finishedAtMs: Date.now() };
}
test('two LoopWalk projects driven via genuinely concurrent async subprocesses stay isolated', async (t) => {
const walkA = LoopWalk.create({ fixture: 'greenfield', prefix: 'gsd-concurrency-a-' });
const walkB = LoopWalk.create({ fixture: 'greenfield', prefix: 'gsd-concurrency-b-' });
t.after(() => {
walkA.cleanup();
walkB.cleanup();
});
walkA.writeArtifact('.planning/PROJECT.md', resolveRef('@project/minimal'));
walkA.writeArtifact('.planning/ROADMAP.md', resolveRef('@roadmap/three-phase'));
walkB.writeArtifact('.planning/PROJECT.md', resolveRef('@project/minimal'));
walkB.writeArtifact('.planning/ROADMAP.md', resolveRef('@roadmap/three-phase'));
// Both promises are created (and their subprocesses spawned) in the same
// synchronous tick, BEFORE either `await`s — this is what makes the two
// invocations genuinely concurrent rather than sequential-looking-parallel.
const promiseA = runConcurrent(walkA.dir, ['init', 'new-project']);
const promiseB = runConcurrent(walkB.dir, ['init', 'new-project']);
const [resultA, resultB] = await Promise.all([promiseA, promiseB]);
// Proof of genuine overlap: A's subprocess was still running when B's was
// launched (both started before either finished). If the harness had
// silently serialized these (e.g. a shared lock), one start time would be
// >= the other's finish time.
const overlapped = resultA.startedAtMs < resultB.finishedAtMs && resultB.startedAtMs < resultA.finishedAtMs;
assert.ok(
overlapped,
`expected the two subprocess invocations to overlap in wall-clock time (A: ${resultA.startedAtMs}-${resultA.finishedAtMs}, B: ${resultB.startedAtMs}-${resultB.finishedAtMs})`,
);
const jsonA = JSON.parse(resultA.stdout);
const jsonB = JSON.parse(resultB.stdout);
// Isolation: each concurrent run must observe and report its OWN project
// root, never the other's — a shared-state bug (e.g. a global cwd) would
// show up here as both reporting the same root.
assert.strictEqual(resolveForCompare(jsonA.project_root), resolveForCompare(walkA.dir));
assert.strictEqual(resolveForCompare(jsonB.project_root), resolveForCompare(walkB.dir));
assert.notStrictEqual(resolveForCompare(jsonA.project_root), resolveForCompare(jsonB.project_root));
// Both saw their own freshly-written PROJECT.md/ROADMAP.md, independent of
// the other walk's concurrent writes to a different temp directory.
assert.strictEqual(jsonA.project_exists, true);
assert.strictEqual(jsonB.project_exists, true);
});
});