// tests/validators/brief-ceremony.test.mjs // K1 — the brief's size class: optional `ceremony: small | bounded | architectural`; // absent = architectural (heaviest when in doubt). Plus the optional // `artifact_store: git | local` (operator decision 2026-10-06); absent = local, // so nothing is published without a yes. Both are additive: no brief_version bump. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { spawnSync } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..'); const MODULE = join(ROOT, 'lib', 'validators', 'brief-validator.mjs'); const load = () => import(MODULE); const brief = (extra = '') => `--- type: trekbrief brief_version: "2.2" created: 2026-10-06 task: "Add a dark-mode toggle" slug: dark-mode-toggle project_dir: .claude/projects/2026-10-06-dark-mode-toggle/ research_topics: 0 research_status: complete framing: new-direction phase_signals_partial: true ${extra}--- # Task ## TL;DR A toggle. ## Intent Readers cook at night. ## Goal A toggle. ## Success Criteria - It persists. `; const codes = (r) => r.errors.map((e) => e.code); test('ceremony: huge is rejected (BRIEF_INVALID_CEREMONY)', async () => { const { validateBriefContent } = await load(); const r = validateBriefContent(brief('ceremony: huge\n')); assert.equal(r.valid, false); assert.deepEqual(codes(r), ['BRIEF_INVALID_CEREMONY']); }); test('ceremony: small, bounded and architectural are accepted', async () => { const { validateBriefContent } = await load(); for (const c of ['small', 'bounded', 'architectural']) { const r = validateBriefContent(brief(`ceremony: ${c}\n`)); assert.deepEqual(codes(r), [], c); } }); test('ceremony absent is valid and reads as architectural (heaviest in doubt)', async () => { const { validateBriefContent, briefCeremony, BRIEF_CEREMONY_DEFAULT } = await load(); const r = validateBriefContent(brief()); assert.deepEqual(codes(r), []); assert.equal(BRIEF_CEREMONY_DEFAULT, 'architectural'); assert.equal(briefCeremony(r.parsed.frontmatter), 'architectural'); assert.equal(briefCeremony({ ceremony: 'small' }), 'small'); assert.equal(briefCeremony({ ceremony: 'huge' }), 'architectural', 'an invalid value never reads lighter'); }); test('artifact_store: git and local accepted, anything else rejected, absent reads as local', async () => { const { validateBriefContent, briefArtifactStore } = await load(); assert.deepEqual(codes(validateBriefContent(brief('artifact_store: git\n'))), []); assert.deepEqual(codes(validateBriefContent(brief('artifact_store: local\n'))), []); assert.deepEqual(codes(validateBriefContent(brief('artifact_store: cloud\n'))), ['BRIEF_INVALID_ARTIFACT_STORE']); assert.equal(briefArtifactStore({}), 'local'); assert.equal(briefArtifactStore({ artifact_store: 'git' }), 'git'); }); test('no brief_version bump: the template still declares 2.2 and documents both fields', () => { const tpl = spawnSync('cat', [join(ROOT, 'templates', 'trekbrief-template.md')], { encoding: 'utf8' }).stdout; assert.match(tpl, /^brief_version: "2\.2"$/m); assert.match(tpl, /^ceremony: /m); assert.match(tpl, /^artifact_store: /m); }); test('the validator CLI runs through a symlinked path with a space (realpath main-module guard)', () => { const d = mkdtempSync(join(tmpdir(), 'brief-ceremony-')); try { const link = join(d, 'with space'); spawnSync('ln', ['-s', join(ROOT, 'lib'), link]); writeFileSync(join(d, 'b.md'), brief('ceremony: huge\n')); const r = spawnSync(process.execPath, [join(link, 'validators', 'brief-validator.mjs'), '--json', join(d, 'b.md')], { encoding: 'utf8' }); assert.equal(r.status, 1, r.stdout + r.stderr); assert.ok(JSON.parse(r.stdout).errors.some((e) => e.code === 'BRIEF_INVALID_CEREMONY')); const ia = spawnSync(process.execPath, [join(link, 'validators', 'intent-approval.mjs'), '--check', '--json', join(d, 'b.md')], { encoding: 'utf8' }); assert.equal(ia.status, 1, 'intent-approval runs (and stops on the unapproved brief) through the same path'); assert.ok(JSON.parse(ia.stdout).errors.some((e) => e.code === 'BRIEF_INTENT_NOT_APPROVED')); } finally { rmSync(d, { recursive: true, force: true }); } });