* feat(#956): add MemPalace memory capability (ADR-857 feature plug-in) Adds an opt-in, default-resilient ADR-857 feature capability that wires MemPalace (local-first memory: MCP server + CLI) into the GSD loop: deliberate recall before discuss/plan and verbatim + temporal-KG capture at phase boundaries. Three memory modes (augment default; kg_backend and replace forward-declared). Master gate mempalace.enabled (default off); every hook onError:skip, zero gates; absent/disabled MemPalace => loop unchanged. Transport is rendered-markdown only — MemPalace runs out-of-process, no third-party code in gsd-core (ADR-857 §7). Capability: capabilities/mempalace/ (manifest + 2 fragments), skills commands/gsd/mempalace-{recall,capture}.md, agent agents/gsd-mempalace-curator.md. Registration: ns-context router, utility cluster, KNOWN_SKILLS, help full.md, model-catalog, copilot install list, size baselines; regenerated capability-registry + inventory manifest. ship:post wired into ship.md (wire-on-demand). HELD on #1196: this capability also declares hooks at discuss:pre and discuss:post, which are structurally un-wireable until the host-loop conformance model covers the discuss phase (discuss-phase.md is not in HOST_LOOP_FILES). The phase6-capstone-conformance gate therefore fails on exactly those two orphaned points by design — see #1196. Once #1196 lands, rebase onto next and the gate goes green with no further change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(#956): backfill changeset PR number (#1201) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
643 lines
29 KiB
JavaScript
643 lines
29 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 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) {
|
|
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@test.com', GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@test.com' } });
|
|
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`);
|
|
return r.stdout.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 1 contribution (mempalace capture-problems) — gate point with mempalace contribution', () => {
|
|
const point = realRegistry.byLoopPoint['execute:wave:post'];
|
|
assert.strictEqual(point.steps.length, 0,
|
|
`execute:wave:post steps must be empty; got ${point.steps.length}`);
|
|
assert.strictEqual(point.contributions.length, 1,
|
|
`execute:wave:post must have 1 contribution (mempalace); got ${point.contributions.length}`);
|
|
assert.strictEqual(point.contributions[0].capId, 'mempalace',
|
|
`execute:wave:post contribution must be from mempalace; got ${point.contributions[0].capId}`);
|
|
});
|
|
|
|
});
|