* test(#3148): bound the long tail and delete the allowlist Migrates the final 170 unbounded sync spawn sites across 49 files, then removes the allowlist entirely. local/no-unbounded-spawn now runs with no exemption surface across tests/**: there is no file to add a name to. drift-detection's throw-native git() helper routes to gitOrThrow -- bare runGit would have taken 16 call sites quiet on failure. commands.test.cjs has two independently-scoped runGsdTools/runCli helpers, one already bounded and one not; they are kept distinct rather than unified, the same trap as the two same-named git() helpers in Wave 1. runNpm's bound was erasable. Its options spread callerOptions after the defaults, so an explicit timeout:undefined silently dropped the 180000ms bound -- the rule flagged it and was right; it was not a false positive. Fixed by destructuring with a default, with a test that fails when the default is removed. Two sites stay on a raw spawn with an explicit timeout because the seam cannot express them: one needs shell:true for npm.cmd on Windows, one redirects stdout to a real fd. Both are the rule's own documented second option, not an escape from it. Closure verified rather than asserted: the derivation scan reports 0 unbounded spawn helpers and 0 unbounded direct git call sites, and a temporary file carrying an unbounded spawn still errors with the allowlist gone. Closes #3064. * test(#3148): close a hole in the guard's own eslint-disable ban The ban listed only the top level of tests/, so it was blind to 37 .cjs files under tests/helpers, qa, observability, fixtures and dispatch. With the allowlist deleted this test is the sole remaining way to detect someone silencing the rule inline, so the gap was load-bearing: a nested file could carry an unbounded spawn plus an eslint-disable and pass everything. Proven before and after. A probe planted under tests/helpers with both was invisible to the guard and clean under eslint; after making the listing recursive the guard fails on it. The scanned set goes from 771 files to 808. Pre-existing since the guard shipped, but this wave is what promoted it to sole defense, so it is fixed here rather than filed. Also converts the last hand-rolled throw check to throwIfFailed and the last re-derived legacy shape to compose toLegacyResult, which makes the epic's none-remain claim true rather than nearly true. toLegacyResult itself is not widened -- eight callers depend on its shape and one consumer does not justify changing a shared contract. * fix(#3148): correct seam incoherence at the bound and a slow review-lane error path Two real failures from the remote runner, both fixed at the cause. The seam could return outcome TIMED_OUT together with exitCode 0. At the exact bound spawnSync reports ETIMEDOUT while the child has already exited with a real status, and toSeamResult classified on the error code while passing status straight through -- an incoherent pair its own boundary test was written to catch, and did. A status that is not null is direct evidence the child exited on its own, so it now decides the outcome before the error-code branches run. process-seam.cjs was deliberately untouched by every earlier wave; this is a defect in the module itself, kept surgical, with a unit test that fails against the old logic. review-lane with an unknown subcommand fell through to its usage error only after loading the capability registry and building a per-lane plan, which spawns one child process per lane -- up to twelve. The error path took ~1288ms instead of ~119ms, and under bench load it outran a caller's spawn timeout and was killed before writing anything, which is the empty stdout and stderr CI saw. It now fails fast before any of that work begins. This is the epic's first production change. It is user-facing, so it carries a changeset rather than a no-changelog label. * test(#3148): replace a real-race timeout test with a deterministic one E9 raced git rev-parse against a 1ms bound and assumed git always lost. On a warm container git finishes first, spawnSync returns status 0 with no error at all, the seam correctly classifies EXITED, and gitOrThrow correctly does not throw -- so the test failed on both lanes. A probe confirms a genuine timeout always carries status null, so this was never the seam misbehaving. Raising the bound would only lengthen the odds, which is the same defect with better luck. The test now drives gitOrThrow against a stubbed runGit that returns a synthetic TIMED_OUT result, so it asserts exactly what it always meant to -- that a timeout propagates as a throw -- with no timing dependence. Five consecutive runs are identical where the old one varied. I wrote this test in Wave 0; it is a real-race test by construction and CLAUDE.md says to replace those rather than re-run them. * chore(#3148): backfill changeset PR number 3192 --------- Co-authored-by: sim <sim@local>
657 lines
30 KiB
JavaScript
657 lines
30 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 no steps and 2 contributions (external-job executor + mempalace capture-problems)', () => {
|
|
const point = realRegistry.byLoopPoint['execute:wave:post'];
|
|
assert.strictEqual(point.steps.length, 0,
|
|
`execute:wave:post steps must be empty; got ${point.steps.length}`);
|
|
// #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(',')}`);
|
|
});
|
|
|
|
});
|