Files
msd-core/tests/execute-wave-post-gate-pipeline-e2e.test.cjs
Tom Boucher 14679b866b enhance(#2856): add default-off live-DOM UAT capability (#3716)
* test(#2856): add failing-first suite for the live-dom-uat capability

Binds the approved triage shape before any of it exists:

- containment — the execute:wave:post hook must not render unless
  workflow.live_dom_uat is true AND the capability resolves active
  (fail-closed on a missing state entry, and on a non-boolean value)
- criterion 4 — agents/gsd-executor.md carries no browser MCP family;
  asserted as an absence, which is the only way it is observable
- Hyrum guard — the pre-existing mcp__playwright__* branch must stay
  outside the key-gated block, or upgrading silently removes working
  automated UI verification for every current Playwright-MCP user
- parity — the browser glob list now lives in two surfaces (agent
  frontmatter + workflow detection block); the assertion fails if
  either gains or loses a family without the other

Red by construction: the capability, agent and workflow block do not
exist yet. Verified on the remote runner.

Refs #2856

* enhance(#2856): add default-off live-DOM UAT capability

A phase whose acceptance criteria needed a live DOM could not be
finished by the agent that executed it: gsd-executor carries no browser
tools, so it correctly returned checkpoint:human-action even though the
work was not human-only, just tool-less. Every such phase degraded to
"executed, then finished by hand in the orchestrator", and autonomous:
false could not distinguish "a human must judge this" from "the executor
lacks the tool".

Implements the shape approved at triage, not the one reported. The
executor's tools: line is NOT widened, in any configuration: for a
first-party agent the static list is the only control that exists
(ADR-1244 D2, ADR-857 D4, no per-dispatch override). Instead one
default-off capability owns the key, the agent, and the step:

- capabilities/live-dom-uat/ — activationKey workflow.live_dom_uat
  (boolean, default false), one additive step at execute:wave:post
  (onError: skip, gates: []), so it can never halt a wave
- agents/gsd-dom-verifier.md — the only GSD agent carrying browser MCP
  globs, in its own tools: line, with no Bash
- verify-work automated_ui_verification — a gsd:live-dom-families block
  naming both new families AND the key; presence alone never activates

Two independent fail-closed gates: isCapabilityActive renders a hook
only on state.active === true, plus the step's own `when`.

The pre-existing mcp__playwright__* branch keeps the gating it already
had and stays outside the new block. Pulling it behind a default-off key
would have silently removed working automated UI verification from every
current Playwright-MCP user on upgrade.

Also closes a host gap this surfaced: execute:wave:post dispatched only
contribution + gate, so ANY registered step was declared and silently
never run — exactly the single-kind hand-roll loop-hook-dispatch.md
names. Step 5.75 now dispatches every kind == "step".

The browser-profile lock is tolerated, not coordinated: --isolated is a
flag on the operator's own MCP-server registration that GSD neither
launches nor parameterizes, so the verifier reports could_not_look /
profile_locked, names the flag, and stops. DOM-VERIFY.md keeps
could_not_look and nothing_to_report distinct behind a closed reason
enum — collapsing them is the ambiguous-run-notes defect reported.

Verified on the remote runner.

Closes #2856

* fix(#2856): apply review findings from the orthogonal passes

Correctness pass (blocker):
- delete detectionBlockIsCrlfSafe. It was pass-always: it read the file,
  replaced LF with CRLF, then indexOf'd marker strings that contain no
  newline, so the replacement could not change the result and the
  assertion could never fail for the reason it stated. There is no real
  CRLF risk on this surface either — the gsd:live-dom-families block has
  no parser, only human and agent readers. Deleted rather than replaced,
  per the repo's pass-always-test rule.

Isolated security pass (two minors, both real):
- execute-phase.md step 5.75: this change is what first activates
  kind == "step" dispatch at execute:wave:post, which newly opens the
  ref.command shell path at that loop point. Our own step uses ref.agent
  and never touches it, but the door is now open, so the step-dispatch
  line carries the same in-context validate-before-shell warning the
  sibling gate-dispatch line directly below it already carries.
- gsd-dom-verifier: quoted page text in DOM-VERIFY.md is attacker
  influenced. Require it wrapped in inline code or a fence, kept short,
  and never left reading as a directive to the next reader.

Verified on the remote runner.

Refs #2856

* fix(#2856): settle the new-agent roster ripple

Checkpoint 2 returned 28 failures, none in the new suite — all of them
the guards that exist to make adding an agent a deliberate act. Each is
a real boundary that had to move:

- docs/AGENTS.md: Tools row must copy the frontmatter verbatim (#2526),
  so the browser globs lose their backticks; primary-agent counts 21->22,
  roster 33/34->34/35, Verifiers category 1->2
- docs/INVENTORY.md: roster completeness requires every agents/gsd-*.md
  to be classified exactly once
- gsd-dom-verifier: add the anti-heredoc instruction and the commented
  hooks: frontmatter pattern both agent gates require
- gsd-core/bin/shared/model-catalog.json: every shipped agent needs a
  profile entry (#3229)
- copilot-install / kilo-upgrades / qwen-upgrades: expected agent list
  and the 34->35 roster boundary
- execute-wave-post-gate-pipeline-e2e: execute:wave:post legitimately
  carries one step now. Asserted as an exact shape — one step, capId
  live-dom-uat, ref.agent gsd-dom-verifier, onError skip — so it stays a
  real guard against accidental change rather than being relaxed

Two findings worth naming:

mcp-tool-inheritance (#2526) rejected the agent for documenting
mcp__playwright__* while its tools: line withholds it — a dead
instruction that invites the agent to claim a path it cannot take. The
prose now names the Playwright MCP family without the dispatchable
token, in both the agent and the capability fragment.

runtime-launcher-parity rejected the new gsd_run call: each fenced block
is its own shell, so a workflow step file invoking gsd_run needs its own
canonical preamble. Propagated with scripts/sync-runtime-launcher.cjs.
That script also normalizes explore.md, which is unrelated pre-existing
drift the parity check tolerates, so it is reverted to keep this diff
scoped.

The emitted-drift ack supersedes the spent #3370 entry for
execute-phase.md — it is merged into next, so its ripple is absorbed at
the base and it can no longer clear anything. That is the same supersede
the #3370 entry itself performed on the spent #3324 fragment. Its
unrelated execute-plan.md entry is untouched.

Verified on the remote runner.

Refs #2856

* fix(#2856): drop the stale emitted-drift ack entry

The automated-ui-verification.md entry was written speculatively rather
than from a reported growth, and the check names that precisely: an ack
"written or reworded in THIS diff, but nothing here needed it, so it
explains nothing".

The growth tier keys on the bare filename as it appears under
gsd-core/workflows/ or agents/. automated-ui-verification.md is nested
under verify-work/steps/, so it was never in the tracked set — only
execute-phase.md was ever reported, both before and after the launcher
preamble landed.

Only ack what the check actually reports.

Verified on the remote runner.

Refs #2856

* chore(#2856): backfill changeset pr number

pr:0 -> 3716. The placeholder fails both changeset-lint
(fail_invalid_fragment) and docs-lint (fail_malformed_fragment) by
design and can only be resolved once the PR number exists. Both now
report ok against GITHUB_BASE_REF=next.

Refs #2856

---------

Co-authored-by: sim <sim@local>
2026-08-20 15:07:21 -04:00

664 lines
31 KiB
JavaScript

'use strict';
/**
* execute-wave-post-gate-pipeline-e2e.test.cjs
*
* ADR-857 Phase 6 capstone E2E content tests for the execute:wave:post hook pipeline.
*
* Hook: execute:wave:post
* Three gates registered in the real capability-registry.cjs:
* 1. drift / verify.schema-drift — blocking=true, onError=skip
* 2. drift / verify.codebase-drift — blocking=false, onError=skip
* 3. ui / ui.safety-gate — blocking=true, onError=halt
*
* Focus areas:
* A. loop render-hooks execute:wave:post — resolution full/partial/none
* B. check verify.schema-drift — no-schema/block/GSD_SKIP_SCHEMA_CHECK bypass
* C. check verify.codebase-drift — BVA threshold-1/threshold/auto-remap/no-STRUCTURE.md
* D. check ui.safety-gate — frontend+UI-file/+spec/missing-arg
* E. Full pipeline chain (render-hooks → dispatch each gate)
*
* All tests drive real CLI commands or real resolver functions.
* No readFileSync source-grep.
*/
const { describe, test, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
// ─── Test-local git helper ───────────────────────────────────────────────────
// Inline — NOT modifying tests/helpers.cjs per task rules.
function gitSync(args, cwd) {
return gitOrThrow(args, { cwd, env: { ...process.env, GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@test.com', GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@test.com' } }).trim();
}
function initGitRepo(dir) {
gitSync(['init'], dir);
gitSync(['config', 'user.email', 'test@test.com'], dir);
gitSync(['config', 'user.name', 'Test'], dir);
gitSync(['config', 'commit.gpgsign', 'false'], dir);
}
function gitAddCommit(dir, message) {
gitSync(['add', '-A'], dir);
gitSync(['commit', '--allow-empty', '-m', message], dir);
}
// ─── GSD CLI runner ──────────────────────────────────────────────────────────
/**
* Run gsd-tools and return { status, stdout, stderr, parsed? }.
* When raw=true the tool emits JSON; parsed is set on success.
*/
function runTool(args, { cwd, env = {} } = {}) {
const childEnv = {
...process.env,
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
...env,
};
const r = spawnSync(process.execPath, [GSD_TOOLS, ...args], {
cwd: cwd || os.tmpdir(),
encoding: 'utf8',
env: childEnv,
timeout: 60000,
});
const result = { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
if (r.stdout && r.stdout.trim().startsWith('{')) {
try { result.parsed = JSON.parse(r.stdout.trim()); } catch { /* non-JSON or partial */ }
}
return result;
}
// ─── Shared fixture teardown ─────────────────────────────────────────────────
const tmpDirs = [];
function makeTmpDir() {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wave-post-'));
tmpDirs.push(d);
return d;
}
after(() => { for (const d of tmpDirs) { try { cleanup(d); } catch { /* best-effort */ } } });
// ─── Section A: loop render-hooks execute:wave:post ──────────────────────────
describe('A. loop render-hooks execute:wave:post — resolution', () => {
test('[happy] full resolution: all 3 gates present with default config', () => {
const dir = makeTmpDir();
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
// default config — schema_drift_gate and ui_safety_gate both default to true
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), '{}');
const r = runTool(['loop', 'render-hooks', 'execute:wave:post', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const env = r.parsed;
assert.strictEqual(env.point, 'execute:wave:post');
assert.ok(Array.isArray(env.activeHooks), 'activeHooks must be an array');
// Real registry: 3 gates (schema-drift blocking, codebase-drift non-blocking, ui-safety blocking)
assert.strictEqual(env.activeHooks.length, 3,
`expected 3 gates; got ${env.activeHooks.length}: ${JSON.stringify(env.activeHooks.map(h => h.capId || h.check?.query))}`);
// Verify the three expected gate queries
const queries = env.activeHooks.map(h => h.check?.query);
assert.ok(queries.includes('verify.schema-drift'), 'verify.schema-drift gate must be present');
assert.ok(queries.includes('verify.codebase-drift'), 'verify.codebase-drift gate must be present');
assert.ok(queries.includes('ui.safety-gate'), 'ui.safety-gate gate must be present');
});
test('[negative] no gates returned when schema_drift_gate=false AND ui_safety_gate=false — all suppressed', () => {
const dir = makeTmpDir();
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(dir, '.planning', 'config.json'),
JSON.stringify({ workflow: { schema_drift_gate: false, ui_safety_gate: false } }),
);
const r = runTool(['loop', 'render-hooks', 'execute:wave:post', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const env = r.parsed;
assert.strictEqual(env.point, 'execute:wave:post');
// This is the SPECIFIC differing value — must be 0, not 1, 2, or 3
assert.strictEqual(env.activeHooks.length, 0,
`expected 0 active hooks when both gates suppressed; got ${env.activeHooks.length}`);
assert.strictEqual(env.rendered, '_No active hooks at execute:wave:post._');
});
test('[bva] partial suppression: schema_drift_gate=false → only ui gate present (1 hook)', () => {
const dir = makeTmpDir();
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
// schema_drift_gate=false suppresses BOTH drift gates (both use this when key)
// ui_safety_gate defaults to true so ui.safety-gate stays active
fs.writeFileSync(
path.join(dir, '.planning', 'config.json'),
JSON.stringify({ workflow: { schema_drift_gate: false, ui_safety_gate: true } }),
);
const r = runTool(['loop', 'render-hooks', 'execute:wave:post', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const env = r.parsed;
// Specific differing value: exactly 1 hook, not 3 or 0
assert.strictEqual(env.activeHooks.length, 1,
`expected 1 hook (only ui); got ${env.activeHooks.length}: ${JSON.stringify(env.activeHooks.map(h => h.check?.query))}`);
assert.strictEqual(env.activeHooks[0].check?.query, 'ui.safety-gate',
`remaining hook must be ui.safety-gate, got ${env.activeHooks[0].check?.query}`);
assert.strictEqual(env.activeHooks[0].capId, 'ui');
});
});
// ─── Section B: check verify.schema-drift ────────────────────────────────────
describe('B. check verify.schema-drift — CLI route', () => {
// Helper: build a minimal git repo with a phase dir containing a PLAN.md
function buildSchemaDriftFixture({ hasSchemaFile = false } = {}) {
const dir = makeTmpDir();
initGitRepo(dir);
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-setup'), { recursive: true });
// Write a PLAN.md with files_modified
const schemaEntry = hasSchemaFile ? 'prisma/schema.prisma' : 'src/index.ts';
const planContent = [
'# 01 Plan',
'',
`files_modified: [${schemaEntry}]`,
'',
].join('\n');
fs.writeFileSync(path.join(dir, '.planning', 'phases', '01-setup', '01-PLAN.md'), planContent);
// Write README so git has something to commit
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
gitAddCommit(dir, 'initial commit');
return dir;
}
test('[happy] block:false when no schema files in PLAN.md — happy path', () => {
const dir = buildSchemaDriftFixture({ hasSchemaFile: false });
const r = runTool(['check', 'verify.schema-drift', '1', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
// Specific typed fields
assert.strictEqual(result.block, false, `block must be false for non-schema file; got ${result.block}`);
assert.strictEqual(result.drift_detected, false,
`drift_detected must be false; got ${result.drift_detected}`);
assert.strictEqual(result.skipped, false,
`skipped must be false; got ${result.skipped}`);
});
test('[negative] block:true when schema file in PLAN.md and no push executed — fail-closed', () => {
const dir = buildSchemaDriftFixture({ hasSchemaFile: true });
// No SUMMARY.md with push evidence is written — so schema drift detected
const r = runTool(['check', 'verify.schema-drift', '1', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
// Specific: block must be TRUE here (not false) — FAIL if block is still false
assert.strictEqual(result.block, true, `block must be true when schema file has no push; got ${result.block}`);
assert.strictEqual(result.drift_detected, true,
`drift_detected must be true; got ${result.drift_detected}`);
// unpushed_orms must contain 'prisma'
assert.ok(Array.isArray(result.unpushed_orms), 'unpushed_orms must be an array');
assert.ok(result.unpushed_orms.includes('prisma'),
`unpushed_orms must include 'prisma'; got ${JSON.stringify(result.unpushed_orms)}`);
});
test('[negative] GSD_SKIP_SCHEMA_CHECK=true → block:false, skipped:true even with schema drift', () => {
const dir = buildSchemaDriftFixture({ hasSchemaFile: true });
const r = runTool(['check', 'verify.schema-drift', '1', '--raw'], {
cwd: dir,
env: { GSD_SKIP_SCHEMA_CHECK: 'true' },
});
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
// Specific: block must be FALSE (bypassed) even though drift was detected
assert.strictEqual(result.block, false,
`block must be false with GSD_SKIP_SCHEMA_CHECK=true; got ${result.block}`);
assert.strictEqual(result.skipped, true,
`skipped must be true; got ${result.skipped}`);
// drift_detected should still be true (bypass doesn't mask detection)
assert.strictEqual(result.drift_detected, true,
`drift_detected must be true even when bypassed; got ${result.drift_detected}`);
});
});
// ─── Section C: check verify.codebase-drift — BVA ────────────────────────────
describe('C. check verify.codebase-drift — BVA at threshold', () => {
/**
* Build a git repo with STRUCTURE.md stamped at an initial commit,
* then add N new barrel exports in a second commit to trigger drift detection.
*/
function buildCodebaseDriftFixture({ barrelCount = 0, driftAction = 'warn', threshold = 3 } = {}) {
const dir = makeTmpDir();
initGitRepo(dir);
fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true });
fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
// Write config.json
const config = {
workflow: {
drift_threshold: threshold,
drift_action: driftAction,
},
};
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(config));
// Initial commit with STRUCTURE.md + config
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
// Write STRUCTURE.md stub — will be stamped after initial commit
fs.writeFileSync(
path.join(dir, '.planning', 'codebase', 'STRUCTURE.md'),
'# Structure\n\nInitial layout.\n',
);
gitAddCommit(dir, 'initial commit');
// Stamp STRUCTURE.md with last_mapped_commit = HEAD of initial commit
const headSha = gitSync(['rev-parse', 'HEAD'], dir);
const stampedContent = `---\nlast_mapped_commit: ${headSha}\n---\n# Structure\n\nInitial layout.\n`;
fs.writeFileSync(path.join(dir, '.planning', 'codebase', 'STRUCTURE.md'), stampedContent);
gitAddCommit(dir, 'stamp STRUCTURE.md with last_mapped_commit');
// Add the new barrel exports in a third commit (these are "new" since last map)
if (barrelCount > 0) {
for (let i = 0; i < barrelCount; i++) {
const pkgName = `pkg-${i}`;
fs.mkdirSync(path.join(dir, 'packages', pkgName, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'packages', pkgName, 'src', 'index.ts'),
`export const val${i} = ${i};\n`,
);
}
gitAddCommit(dir, `add ${barrelCount} new barrel exports`);
// Re-read head sha and update STRUCTURE.md stamp to the pre-barrel commit
// (so all the barrel files are "new" relative to last_mapped_commit)
// Actually: we want the stamp to be at the commit BEFORE the barrels were added,
// so we need to get the second commit's SHA.
// We already have stamped at the second commit. The third commit added barrels.
// The stamp still points to the initial commit, so diff = all new barrel files.
}
return dir;
}
test('[bva] threshold-1 (2 elements) → block:false, action_required:false — just-under boundary', () => {
// threshold=3, barrelCount=2 → 2 < 3 → no block
const dir = buildCodebaseDriftFixture({ barrelCount: 2, threshold: 3 });
const r = runTool(['check', 'verify.codebase-drift', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
// Specific: block must be FALSE at threshold-1
assert.strictEqual(result.block, false,
`block must be false at threshold-1 (2 elements); got ${result.block}`);
assert.strictEqual(result.action_required, false,
`action_required must be false; got ${result.action_required}`);
assert.ok(Array.isArray(result.elements),
`elements must be an array; got ${typeof result.elements}`);
assert.strictEqual(result.elements.length, 2,
`elements.length must be exactly 2; got ${result.elements.length}`);
assert.strictEqual(result.directive, 'none',
`directive must be 'none'; got ${result.directive}`);
assert.strictEqual(result.skipped, false,
`skipped must be false; got ${result.skipped}`);
});
test('[bva] threshold exactly (3 elements) → block:true, action_required:true — at boundary', () => {
// threshold=3, barrelCount=3 → 3 >= 3 → block
const dir = buildCodebaseDriftFixture({ barrelCount: 3, threshold: 3 });
const r = runTool(['check', 'verify.codebase-drift', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
// Specific: block must be TRUE at exactly threshold — FAIL if still false
assert.strictEqual(result.block, true,
`block must be true at threshold (3 elements); got ${result.block}`);
assert.strictEqual(result.action_required, true,
`action_required must be true; got ${result.action_required}`);
assert.strictEqual(result.elements.length, 3,
`elements.length must be exactly 3; got ${result.elements.length}`);
assert.strictEqual(result.directive, 'warn',
`directive must be 'warn'; got ${result.directive}`);
assert.strictEqual(result.spawn_mapper, false,
`spawn_mapper must be false for warn action; got ${result.spawn_mapper}`);
});
test('[happy] drift_action=auto-remap + threshold exceeded → block:true, spawn_mapper:true', () => {
const dir = buildCodebaseDriftFixture({ barrelCount: 3, driftAction: 'auto-remap', threshold: 3 });
const r = runTool(['check', 'verify.codebase-drift', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
assert.strictEqual(result.block, true,
`block must be true; got ${result.block}`);
assert.strictEqual(result.action_required, true,
`action_required must be true; got ${result.action_required}`);
// Specific: spawn_mapper must be TRUE for auto-remap action
assert.strictEqual(result.spawn_mapper, true,
`spawn_mapper must be true for auto-remap; got ${result.spawn_mapper}`);
assert.strictEqual(result.directive, 'auto-remap',
`directive must be 'auto-remap'; got ${result.directive}`);
});
test('[empty-resolution] STRUCTURE.md absent → block:false, skipped:true, reason:no-structure-md', () => {
const dir = makeTmpDir();
initGitRepo(dir);
// Create .planning/codebase/ dir but NO STRUCTURE.md
fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true });
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
gitAddCommit(dir, 'initial commit');
const r = runTool(['check', 'verify.codebase-drift', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
assert.strictEqual(result.block, false,
`block must be false when STRUCTURE.md absent; got ${result.block}`);
assert.strictEqual(result.skipped, true,
`skipped must be true; got ${result.skipped}`);
assert.strictEqual(result.reason, 'no-structure-md',
`reason must be 'no-structure-md'; got ${result.reason}`);
assert.strictEqual(result.action_required, false,
`action_required must be false; got ${result.action_required}`);
});
});
// ─── Section D: check ui.safety-gate ─────────────────────────────────────────
describe('D. check ui.safety-gate — CLI subprocess route', () => {
/**
* Build a git repo fixture for ui.safety-gate tests.
*
* Sequence:
* commit 1: initial commit with README
* commit 2: add src/components/Button.tsx (UI file)
* Optional: create .planning/phases/01-phase/01-UI-SPEC.md
*/
function buildUiSafetyGateFixture({ hasUiSpec = false, frontend = true } = {}) {
const dir = makeTmpDir();
initGitRepo(dir);
// Create planning dirs
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-phase'), { recursive: true });
// Write ROADMAP.md with a frontend Phase 1 section.
// getRoadmapPhaseWithFallback requires ## or ### heading (not #) for phase lookup.
const phaseText = frontend
? '## Phase 1: dashboard frontend\n\nBuild the user-facing dashboard UI component.\n'
: '## Phase 1: backend api\n\nBuild the backend API endpoints only.\n';
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), phaseText);
// Initial commit
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
gitAddCommit(dir, 'initial commit');
// Optionally add UI-SPEC before the UI file commit
if (hasUiSpec) {
fs.writeFileSync(
path.join(dir, '.planning', 'phases', '01-phase', '01-UI-SPEC.md'),
'# UI Spec\n\nDesign contract for Phase 1.\n',
);
gitAddCommit(dir, 'add UI-SPEC');
}
// Second commit: add a UI file (matches UI_FILE_EXTENSIONS_RE: .tsx)
fs.mkdirSync(path.join(dir, 'src', 'components'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'components', 'Button.tsx'),
'export const Button = () => null;\n',
);
gitAddCommit(dir, 'add Button.tsx component');
return dir;
}
test('[negative] block:true when frontend phase + UI file changed + no UI-SPEC — live block path', () => {
const dir = buildUiSafetyGateFixture({ hasUiSpec: false, frontend: true });
const r = runTool(['check', 'ui.safety-gate', '1', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
// Specific: block must be TRUE — fails if block is false
assert.strictEqual(result.block, true,
`block must be true: frontend=${result.frontend} hasUiFiles=${result.hasUiFiles} hasUiSpec=${result.hasUiSpec}`);
assert.strictEqual(result.frontend, true,
`frontend must be true; got ${result.frontend}`);
assert.strictEqual(result.hasUiFiles, true,
`hasUiFiles must be true; got ${result.hasUiFiles}`);
assert.strictEqual(result.hasUiSpec, false,
`hasUiSpec must be false; got ${result.hasUiSpec}`);
});
test('[happy] block:false when frontend phase + UI file changed + UI-SPEC present — gate passes', () => {
const dir = buildUiSafetyGateFixture({ hasUiSpec: true, frontend: true });
const r = runTool(['check', 'ui.safety-gate', '1', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
// Specific: block must be FALSE when spec is present
assert.strictEqual(result.block, false,
`block must be false when UI-SPEC exists; got block=${result.block}`);
assert.strictEqual(result.frontend, true,
`frontend must be true; got ${result.frontend}`);
assert.strictEqual(result.hasUiFiles, true,
`hasUiFiles must be true; got ${result.hasUiFiles}`);
assert.strictEqual(result.hasUiSpec, true,
`hasUiSpec must be true; got ${result.hasUiSpec}`);
});
test('[negative] exits non-zero with error message when phase argument is missing', () => {
const dir = makeTmpDir();
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
const r = runTool(['check', 'ui.safety-gate', '--raw'], { cwd: dir });
// Specific: exit must be NON-ZERO — FAIL if 0
assert.notStrictEqual(r.status, 0,
`expected non-zero exit for missing phase arg; got ${r.status}`);
const combined = r.stdout + r.stderr;
assert.ok(
combined.includes('ui-safety-gate requires a phase argument'),
`error message must mention 'ui-safety-gate requires a phase argument'; got: ${combined}`,
);
});
});
// ─── Section E: Full execute:wave:post pipeline chain ────────────────────────
describe('E. Full execute:wave:post pipeline — render-hooks then dispatch gates', () => {
test('[happy] Full chain: render-hooks discovers 3 gates → schema-drift block:false → codebase-drift block:false', () => {
// Build fixture: git repo, non-frontend ROADMAP, fresh STRUCTURE.md stamped at current HEAD
const dir = makeTmpDir();
initGitRepo(dir);
fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true });
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-setup'), { recursive: true });
// Non-frontend ROADMAP so ui.safety-gate doesn't block (no UI files changed).
// Use ## heading — getRoadmapPhaseWithFallback requires ## or ### (not #).
fs.writeFileSync(
path.join(dir, '.planning', 'ROADMAP.md'),
'## Phase 1: backend setup\n\nConfigure server-side services.\n',
);
// PLAN.md with only non-schema files
fs.writeFileSync(
path.join(dir, '.planning', 'phases', '01-setup', '01-PLAN.md'),
'files_modified: [src/server.ts, package.json]\n',
);
// Write STRUCTURE.md stub (no frontmatter stamp initially)
fs.writeFileSync(
path.join(dir, '.planning', 'codebase', 'STRUCTURE.md'),
'# Structure\n\nInitial codebase layout.\n',
);
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
gitAddCommit(dir, 'initial commit');
// Stamp STRUCTURE.md with current HEAD so there is no drift since last map
const headSha = gitSync(['rev-parse', 'HEAD'], dir);
fs.writeFileSync(
path.join(dir, '.planning', 'codebase', 'STRUCTURE.md'),
`---\nlast_mapped_commit: ${headSha}\n---\n# Structure\n\nInitial codebase layout.\n`,
);
gitAddCommit(dir, 'stamp STRUCTURE.md');
// --- Step 1: render-hooks → discover gates ---
const step1 = runTool(['loop', 'render-hooks', 'execute:wave:post', '--raw'], { cwd: dir });
assert.strictEqual(step1.status, 0, `step1 exit non-zero: ${step1.stderr}`);
assert.ok(step1.parsed, `step1 not JSON: ${step1.stdout}`);
const envelope = step1.parsed;
assert.strictEqual(envelope.point, 'execute:wave:post');
assert.strictEqual(envelope.activeHooks.length, 3,
`step1: expected 3 gates, got ${envelope.activeHooks.length}`);
// Confirm schema-drift gate is present and has correct metadata
const schemaDriftHook = envelope.activeHooks.find(h => h.check?.query === 'verify.schema-drift');
assert.ok(schemaDriftHook, 'verify.schema-drift gate must be in activeHooks');
assert.strictEqual(schemaDriftHook.blocking, true, 'schema-drift gate must be blocking');
assert.strictEqual(schemaDriftHook.onError, 'skip', 'schema-drift onError must be skip');
// --- Step 2: dispatch schema-drift gate ---
const step2 = runTool(['check', 'verify.schema-drift', '1', '--raw'], { cwd: dir });
assert.strictEqual(step2.status, 0, `step2 exit non-zero: ${step2.stderr}`);
assert.ok(step2.parsed, `step2 not JSON: ${step2.stdout}`);
assert.strictEqual(step2.parsed.block, false,
`step2 schema-drift block must be false; got ${step2.parsed.block}`);
// --- Step 3: dispatch codebase-drift gate ---
const step3 = runTool(['check', 'verify.codebase-drift', '--raw'], { cwd: dir });
assert.strictEqual(step3.status, 0, `step3 exit non-zero: ${step3.stderr}`);
assert.ok(step3.parsed, `step3 not JSON: ${step3.stdout}`);
// After stamping and committing with no new barrel/migration files, no drift
// (the stamp commit itself is just config changes — not drift categories)
assert.strictEqual(step3.parsed.block, false,
`step3 codebase-drift block must be false; got ${step3.parsed.block}`);
});
});
// ─── Section F: real registry shape assertions (pure-function) ────────────────
describe('F. Real registry execute:wave:post shape — guard against accidental changes', () => {
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
test('[happy] real registry execute:wave:post has exactly 3 gates with correct queries', () => {
const point = realRegistry.byLoopPoint['execute:wave:post'];
assert.ok(point, 'byLoopPoint must have execute:wave:post key');
assert.ok(Array.isArray(point.gates), 'gates must be an array');
// Specific: exactly 3 gates — fails if someone adds or removes one
assert.strictEqual(point.gates.length, 3,
`execute:wave:post must have exactly 3 gates; got ${point.gates.length}`);
const queries = point.gates.map(g => g.check?.query);
assert.ok(queries.includes('verify.schema-drift'), 'verify.schema-drift gate must exist');
assert.ok(queries.includes('verify.codebase-drift'), 'verify.codebase-drift gate must exist');
assert.ok(queries.includes('ui.safety-gate'), 'ui.safety-gate gate must exist');
});
test('[happy] real registry: schema-drift gate is blocking=true, codebase-drift is blocking=false', () => {
const gates = realRegistry.byLoopPoint['execute:wave:post'].gates;
const schemaDrift = gates.find(g => g.check?.query === 'verify.schema-drift');
const codebaseDrift = gates.find(g => g.check?.query === 'verify.codebase-drift');
assert.strictEqual(schemaDrift.blocking, true,
`schema-drift gate must be blocking=true; got ${schemaDrift.blocking}`);
assert.strictEqual(codebaseDrift.blocking, false,
`codebase-drift gate must be blocking=false; got ${codebaseDrift.blocking}`);
});
test('[happy] real registry: ui.safety-gate is blocking=true, onError=halt', () => {
const gates = realRegistry.byLoopPoint['execute:wave:post'].gates;
const uiGate = gates.find(g => g.check?.query === 'ui.safety-gate');
assert.ok(uiGate, 'ui.safety-gate gate must exist');
assert.strictEqual(uiGate.blocking, true,
`ui.safety-gate must be blocking=true; got ${uiGate.blocking}`);
assert.strictEqual(uiGate.onError, 'halt',
`ui.safety-gate onError must be 'halt'; got ${uiGate.onError}`);
});
test('[happy] real registry: execute:wave:post has exactly 1 step (#2856 live-dom-uat gsd-dom-verifier, onError:skip) and 2 contributions (external-job executor + mempalace capture-problems)', () => {
const point = realRegistry.byLoopPoint['execute:wave:post'];
assert.strictEqual(point.steps.length, 1,
`execute:wave:post must have exactly 1 step; got ${point.steps.length}`);
const [step] = point.steps;
assert.strictEqual(step.capId, 'live-dom-uat',
`execute:wave:post step capId must be 'live-dom-uat'; got ${step.capId}`);
assert.deepStrictEqual(step.ref, { agent: 'gsd-dom-verifier' },
`execute:wave:post step ref must be { agent: 'gsd-dom-verifier' }; got ${JSON.stringify(step.ref)}`);
assert.strictEqual(step.onError, 'skip',
`execute:wave:post step onError must be 'skip'; got ${step.onError}`);
// #2285: claude-orchestration's dispatch-backend-selector contribution moved
// from execute:wave:post to execute:wave:pre — wave:post fires AFTER the
// wave already dispatched inline, too late to select a dispatch backend.
assert.strictEqual(point.contributions.length, 2,
`execute:wave:post must have 2 contributions (external-job + mempalace); got ${point.contributions.length}`);
const capIds = point.contributions.map(c => c.capId).sort();
assert.deepStrictEqual(capIds, ['external-job', 'mempalace'],
`execute:wave:post contributions must be external-job + mempalace; got ${capIds.join(',')}`);
});
test('[happy] real registry: execute:wave:pre has 1 contribution (claude-orchestration dispatch-backend selector, #2285)', () => {
const point = realRegistry.byLoopPoint['execute:wave:pre'];
assert.strictEqual(point.steps.length, 0,
`execute:wave:pre steps must be empty; got ${point.steps.length}`);
assert.strictEqual(point.contributions.length, 1,
`execute:wave:pre must have 1 contribution (claude-orchestration); got ${point.contributions.length}`);
const capIds = point.contributions.map(c => c.capId).sort();
assert.deepStrictEqual(capIds, ['claude-orchestration'],
`execute:wave:pre contributions must be claude-orchestration; got ${capIds.join(',')}`);
});
});