import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadRequirements, checkRequirements, RequirementsError, REQUIREMENTS_FILE } from '../src/requirements.mjs'; // Row 90 (#1584): requirements.json against the approved PRD it names. The PRD's **REQ-...** // bullets are the source; the list must hold the same ids in the same order, and the PRD's // status line must give the list's version. const list = loadRequirements(); const raw = () => JSON.parse(readFileSync(REQUIREMENTS_FILE, 'utf8')); const REPO = fileURLToPath(new URL('../../../', import.meta.url)); const listError = (message) => (e) => e instanceof RequirementsError && (message === undefined || e.message === message); test("the list holds the PRD's requirement ids, in its order, at its version", () => { const prd = readFileSync(join(REPO, list.prd), 'utf8'); const ids = [...prd.matchAll(/^- \*\*(REQ-[A-Z]+-[0-9]+)\.\*\*/gm)].map((m) => m[1]); // Any other mention of a bold id would be a bullet this pattern missed. assert.equal(ids.length, [...prd.matchAll(/\*\*REQ-/g)].length); assert.equal(ids.length, 24); assert.deepEqual(list.requirements.map((r) => r.id), ids); assert.match(prd, new RegExp(`^- Status: approved, version ${list.version.replace('.', '\\.')}\\.`, 'm')); assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.0']); }); test('the committed list loads frozen', () => { assert.ok(Object.isFrozen(list) && Object.isFrozen(list.requirements) && Object.isFrozen(list.requirements[0])); assert.deepEqual(checkRequirements(raw()), raw()); assert.deepEqual(loadRequirements(), list); }); test('the schema refuses any other shape', () => { const bad = { 'an unknown top key': (l) => (l.source = 'x'), 'no prd': (l) => delete l.prd, 'a prd outside docs/prd': (l) => (l.prd = 'docs/plans/x.md'), 'a prd that is not markdown': (l) => (l.prd = 'docs/prd/mosaic-stack.txt'), 'a prd with a parent step': (l) => (l.prd = 'docs/prd/../x.md'), 'no version': (l) => delete l.version, 'a numeric version': (l) => (l.version = 1.5), 'a version with no minor': (l) => (l.version = '1'), 'a version 0': (l) => (l.version = '0.4'), 'no requirements': (l) => delete l.requirements, 'an empty list': (l) => (l.requirements = []), 'requirements not a list': (l) => (l.requirements = {}), 'an entry not an object': (l) => (l.requirements[0] = 'REQ-ROLE-1'), 'an unknown entry key': (l) => (l.requirements[0].note = 'x'), 'no id': (l) => delete l.requirements[0].id, 'no title': (l) => delete l.requirements[0].title, 'an id in lower case': (l) => (l.requirements[0].id = 'REQ-role-1'), 'an id numbered 0': (l) => (l.requirements[0].id = 'REQ-ROLE-0'), 'an id with a suffix': (l) => (l.requirements[0].id = 'REQ-ROLE-1a'), 'an id twice': (l) => (l.requirements[1].id = l.requirements[0].id), 'a blank title': (l) => (l.requirements[0].title = ' '), 'a title over 120 characters': (l) => (l.requirements[0].title = 'x'.repeat(121)), 'a title of two lines': (l) => (l.requirements[0].title = 'a\nb'), 'a title that is not a string': (l) => (l.requirements[0].title = 1), }; for (const x of [[], null, 'list']) assert.throws(() => checkRequirements(x), listError('list: not an object')); for (const [what, edit] of Object.entries(bad)) { const l = raw(); edit(l); assert.throws(() => checkRequirements(l), listError(), what); } const ok = raw(); ok.requirements[0].title = 'x'.repeat(120); assert.equal(checkRequirements(ok), ok); const dir = mkdtempSync(join(tmpdir(), 'requirements-')); try { writeFileSync(join(dir, 'cut.json'), '{"prd":'); assert.throws(() => loadRequirements(join(dir, 'cut.json')), listError('requirements.json: not JSON')); assert.throws(() => loadRequirements(join(dir, 'absent.json')), listError('requirements.json: ENOENT')); const twice = raw(); twice.requirements.push(structuredClone(twice.requirements[0])); writeFileSync(join(dir, 'twice.json'), JSON.stringify(twice)); assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[24].id: REQ-ROLE-1 twice')); } finally { rmSync(dir, { recursive: true, force: true }); } });