import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; const api = await import('../src/index.mjs').catch(e => { if (e.code === 'ERR_MODULE_NOT_FOUND') return {}; throw e; }); const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8' }).trim(); const actor = { definition: 'coder', instance: 'builder' }; const requirement = { recordVersion: 1, id: 'REQ-PROJECT-1', title: 'A requirement', text: 'Keep local evidence', status: 'draft' }; const decision = { recordVersion: 1, id: 'DEC-1', question: 'Which path?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', status: 'open', choice: null }; function context(t, repo) { const root = mkdtempSync(join(tmpdir(), 'project-record-')); t.after(() => rmSync(root, { recursive: true, force: true })); return { root, business: { id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code'), ...(repo ? { repo: join(root, repo) } : {}) } } }, project: 'stack', dataRoot: join(root, 'data') }; } async function ready(t, custom) { assert.equal(typeof api.initProject, 'function'); const c = context(t, custom); const initialized = await api.initProject(c); return { c, repo: initialized.repo, commit: initialized.commit }; } test('organization records refuse instance-root conflicts even with an explicit repository', t => { const c = context(t, 'records'), orgRoot = join(c.root, 'org'); const old = join(c.dataRoot, 'projects/acme/stack'), target = join(orgRoot, 'projects/acme/stack'); mkdirSync(old, { recursive: true }); mkdirSync(target, { recursive: true }); writeFileSync(join(old, 'keep'), 'old'); writeFileSync(join(target, 'keep'), 'org'); assert.throws(() => api.recordContext({ ...c, orgRoot }), e => e.code === 'project-records-conflict' && e.message.includes(old) && e.message.includes(target)); assert.equal(readFileSync(join(old, 'keep'), 'utf8'), 'old'); assert.equal(readFileSync(join(target, 'keep'), 'utf8'), 'org'); }); test('C2-04: record APIs refuse a library before creating a repository', async t => { const c = context(t); c.business.projects = { books: { kind: 'library' }, ...c.business.projects }; assert.equal(api.projectRepo(c), join(c.dataRoot, 'projects/acme/stack')); const library = { ...c, project: 'books' }; assert.throws(() => api.projectRepo(library), { code: 'project-invalid' }); for (const author of ['jason', 'tester']) await assert.rejects(api.initProject(library, { author }), { code: 'project-invalid' }); assert.equal(readdirSync(c.root).includes('data'), false); c.business.projects.stack.root = c.dataRoot; assert.throws(() => api.projectRepo(c), /overlap/); }); test('init creates a checked versioned layout, valid templates and human-authored local history', async t => { const { c, repo, commit } = await ready(t); assert.equal(repo, join(c.dataRoot, 'projects/acme/stack')); assert.deepEqual(await api.checkProject(c), { ok: true, repo, problems: [] }); const project = JSON.parse(readFileSync(join(repo, 'project.json'))); assert.equal(project.layoutVersion, 1); assert.equal(project.business, 'acme'); assert.equal(project.project, 'stack'); assert.equal(new Date(project.createdAt).toISOString(), project.createdAt); for (const kind of ['project', 'requirement', 'decision']) assert.deepEqual(api.validateRecord(kind, JSON.parse(readFileSync(join(repo, 'templates', kind + '.json')))), []); assert.equal(git(repo, 'show', '-s', '--format=%an', commit), 'jason'); assert.equal(git(repo, 'remote'), ''); assert.ok(git(repo, 'ls-files').includes('spec/north-star.md')); assert.ok(git(repo, 'ls-files').includes('requirements/.gitkeep')); }); test('init honors repo, refuses a non-empty directory and an undeclared project without touching files', async t => { const { c, repo } = await ready(t, 'records'); assert.equal(repo, join(c.root, 'records')); const before = git(repo, 'rev-parse', 'HEAD'); await assert.rejects(api.initProject(c), e => e.code === 'project-not-empty'); assert.equal(git(repo, 'rev-parse', 'HEAD'), before); await assert.rejects(api.initProject({ ...c, project: 'missing' }), e => e.code === 'project-not-declared'); const dirty = context(t, 'occupied'); mkdirSync(join(dirty.root, 'occupied')); writeFileSync(join(dirty.root, 'occupied/keep.txt'), 'keep'); await assert.rejects(api.initProject(dirty), e => e.code === 'project-not-empty'); assert.equal(readFileSync(join(dirty.root, 'occupied/keep.txt'), 'utf8'), 'keep'); }); test('check reports every bad record, filename mismatch and unknown top-level path', async t => { const { c, repo } = await ready(t); writeFileSync(join(repo, 'requirements/bad.json'), JSON.stringify({ ...requirement, id: 'OTHER', status: 'invalid' })); writeFileSync(join(repo, 'decisions/wrong.json'), JSON.stringify(decision)); writeFileSync(join(repo, 'unexpected.txt'), 'unknown'); const result = await api.checkProject(c); assert.equal(result.ok, false); assert.match(result.problems.join('\n'), /requirements\/bad.json.*status/); assert.match(result.problems.join('\n'), /decisions\/wrong.json.*id/); assert.match(result.problems.join('\n'), /unexpected.txt/); }); test('one writer commits validated docs, requirements and decisions with the bound agent author', async t => { const { c, repo } = await ready(t); for (const [path, record] of [['docs/design.md', '# Design\n\nKept here.\n'], ['requirements/REQ-PROJECT-1.json', requirement], ['decisions/DEC-1.json', decision]]) { const written = await api.writeRecord({ ...c, path, record, author: actor }); assert.equal(git(repo, 'show', '-s', '--format=%an', written.commit), 'coder.builder'); assert.deepEqual(git(repo, 'show', '--format=', '--name-only', written.commit).split('\n'), [path]); } assert.equal((await api.checkProject(c)).ok, true); assert.equal(git(repo, 'status', '--porcelain'), ''); }); test('writer refuses spec paths, bad schemas, mismatched IDs and invalid actor metadata before changing history', async t => { const { c, repo } = await ready(t), before = git(repo, 'rev-parse', 'HEAD'); for (const [path, record, author] of [ ['spec/north-star.md', '# Agent overwrite', actor], ['../outside.md', '# Escape', actor], ['docs/../../outside.md', '# Escape', actor], ['project.json', {}, actor], ['requirements/REQ-PROJECT-1.json', { ...requirement, extra: true }, actor], ['requirements/WRONG.json', requirement, actor], ['decisions/DEC-1.json', { ...decision, choice: 'yes' }, actor], ['docs/a.md', '# A', { definition: 'coder', instance: 'x\nHuman' }], ['docs/a.md', '# A', { ...actor, extra: true }], ]) await assert.rejects(api.writeRecord({ ...c, path, record, author }), path.startsWith('spec/') ? { code: 'project-spec-human-only' } : undefined); assert.equal(git(repo, 'rev-parse', 'HEAD'), before); assert.equal(git(repo, 'status', '--porcelain'), ''); }); test('writer refuses human pending edits and remotes instead of absorbing or contacting them', async t => { const { c, repo } = await ready(t); writeFileSync(join(repo, 'spec/north-star.md'), '# Pending human edit\n'); await assert.rejects(api.writeRecord({ ...c, path: 'docs/a.md', record: '# A', author: actor }), e => e.code === 'project-dirty'); assert.equal(readFileSync(join(repo, 'spec/north-star.md'), 'utf8'), '# Pending human edit\n'); git(repo, 'checkout', '--', 'spec/north-star.md'); git(repo, 'remote', 'add', 'origin', 'https://fixture.invalid/no-network'); await assert.rejects(api.writeRecord({ ...c, path: 'docs/a.md', record: '# A', author: actor }), e => e.code === 'project-remote'); }); test('links, repo/code overlap, identity mismatch and a busy writer refuse safely', async t => { const { c, repo } = await ready(t); const overlap = { ...c, business: { ...c.business, projects: { stack: { root: repo, repo } } } }; assert.throws(() => api.projectRepo(overlap), /overlap/); const external = join(c.root, 'outside.json'); writeFileSync(external, JSON.stringify(requirement)); symlinkSync(external, join(repo, 'requirements/REQ-PROJECT-1.json')); assert.equal((await api.checkProject(c)).ok, false); await assert.rejects(api.writeRecord({ ...c, path: 'requirements/REQ-PROJECT-1.json', record: requirement, author: actor })); rmSync(join(repo, 'requirements/REQ-PROJECT-1.json')); writeFileSync(join(repo, '.git/mosaic-record.lock'), 'another writer'); await assert.rejects(api.writeRecord({ ...c, path: 'docs/a.md', record: '# A', author: actor }), e => e.code === 'project-busy'); rmSync(join(repo, '.git/mosaic-record.lock')); const metadata = JSON.parse(readFileSync(join(repo, 'project.json'))); metadata.business = 'other'; writeFileSync(join(repo, 'project.json'), JSON.stringify(metadata)); assert.match((await api.checkProject(c)).problems.join('\n'), /project.json.*business/); assert.equal(readFileSync(external, 'utf8'), JSON.stringify(requirement)); }); test('schemas remain package-owned and reject invalid nested data and dates', async () => { assert.equal(typeof api.validateRecord, 'function'); assert.ok(api.validateRecord('requirement', { ...requirement, title: '' }).length); assert.ok(api.validateRecord('requirement', { ...requirement, recordVersion: '1' }).length); assert.ok(api.validateRecord('decision', { ...decision, options: [decision.options[0]] }).length); assert.ok(api.validateRecord('decision', { ...decision, options: [decision.options[0], decision.options[0]] }).length); assert.ok(api.validateRecord('decision', { ...decision, recommendation: 'unknown' }).length); assert.ok(api.validateRecord('project', { layoutVersion: 1, business: 'acme', project: 'stack', name: 'Name', createdAt: '2026-02-30T00:00:00.000Z' }).length); }); test('git commit failure restores only the attempted record and leaves human history unchanged', async t => { const { c, repo } = await ready(t); const { chmodSync } = await import('node:fs'); const realGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); const bin = join(c.root, 'bin'); mkdirSync(bin); writeFileSync(join(bin, 'git'), `#!/bin/sh\nfor arg do [ "$arg" = commit ] && exit 71; done\nexec '${realGit}' "$@"\n`); chmodSync(join(bin, 'git'), 0o755); const before = git(repo, 'rev-parse', 'HEAD'), saved = process.env.PATH; try { process.env.PATH = bin + ':' + saved; await assert.rejects(api.writeRecord({ ...c, path: 'docs/failure.md', record: '# Refused', author: actor }), e => e.code === 'project-git'); } finally { process.env.PATH = saved; } assert.equal(git(repo, 'rev-parse', 'HEAD'), before); assert.equal(git(repo, 'status', '--porcelain'), ''); assert.ok(!readdirSync(join(repo, 'docs')).includes('failure.md')); }); test('git hooks and ambient git identity cannot alter the record writer or execute outside actions', async t => { const { c, repo } = await ready(t); const { chmodSync } = await import('node:fs'); mkdirSync(join(repo, '.git/hooks')); const marker = join(c.root, 'hook-ran'); writeFileSync(join(repo, '.git/hooks/pre-commit'), `#!/bin/sh\nprintf hook > '${marker}'\n`); chmodSync(join(repo, '.git/hooks/pre-commit'), 0o755); const saved = process.env.GIT_AUTHOR_NAME; process.env.GIT_AUTHOR_NAME = 'wrong-author'; try { const result = await api.writeRecord({ ...c, path: 'docs/clean.md', record: '# Clean', author: actor }); assert.equal(git(repo, 'show', '-s', '--format=%an', result.commit), 'coder.builder'); assert.ok(!readdirSync(c.root).includes('hook-ran')); } finally { if (saved === undefined) delete process.env.GIT_AUTHOR_NAME; else process.env.GIT_AUTHOR_NAME = saved; } }); test('project-local schemas cannot weaken checks; invalid UTF-8 and linked git directories refuse', async t => { const { c, repo } = await ready(t); const { existsSync } = await import('node:fs'); writeFileSync(join(repo, 'templates/requirement.schema.json'), '{"type":"object"}'); writeFileSync(join(repo, 'docs/invalid.md'), Buffer.from([0xff])); assert.match((await api.checkProject(c)).problems.join('\n'), /unknown template/); assert.match((await api.checkProject(c)).problems.join('\n'), /invalid.md/); rmSync(join(repo, 'templates/requirement.schema.json')); rmSync(join(repo, 'docs/invalid.md')); const outside = join(c.root, 'linked-git'); mkdirSync(outside); rmSync(join(repo, '.git'), { recursive: true }); symlinkSync(outside, join(repo, '.git')); await assert.rejects(api.writeRecord({ ...c, path: 'docs/a.md', record: '# A', author: actor })); assert.ok(!existsSync(join(outside, 'mosaic-record.lock'))); }); test('schema constraints and layout omissions are reported independently', async t => { const { c, repo } = await ready(t); const { title, ...missingTitle } = requirement; for (const value of [missingTitle, { ...requirement, id: '../escape' }, { ...requirement, text: 3 }]) assert.ok(api.validateRecord('requirement', value).length); assert.ok(api.validateRecord('decision', { ...decision, options: Array.from({ length: 10 }, (_, i) => ({ key: 'k' + i, text: 'Option' })), recommendation: 'k0' }).length); const tooLarge = '# ' + 'x'.repeat(1024 * 1024); await assert.rejects(api.writeRecord({ ...c, path: 'docs/large.md', record: tooLarge, author: actor })); writeFileSync(join(repo, 'docs/large.md'), tooLarge); writeFileSync(join(repo, 'docs/nul.md'), 'bad\0content'); writeFileSync(join(repo, 'templates/requirement.json'), JSON.stringify({ ...requirement, status: 'invalid' })); rmSync(join(repo, 'templates/document.md')); const errors = (await api.checkProject(c)).problems.join('\n'); assert.match(errors, /large.md/); assert.match(errors, /nul.md/); assert.match(errors, /templates\/requirement.json.*status/); assert.match(errors, /templates\/document.md.*missing/); }); test('unsupported schema keywords, malformed contexts, ignored local config and nonempty markers refuse', async t => { const { c, repo } = await ready(t); const { cpSync } = await import('node:fs'); const { pathToFileURL } = await import('node:url'); assert.throws(() => api.projectRepo({ ...c, dataRoot: 'relative' }), /normalized absolute/); assert.ok(api.validateRecord('requirement', { ...requirement, title: 'x'.repeat(513) }).length); const moduleRoot = join(c.root, 'schema-fixture'); mkdirSync(join(moduleRoot, 'src'), { recursive: true }); cpSync(new URL('../src/validate.mjs', import.meta.url), join(moduleRoot, 'src/validate.mjs')); cpSync(new URL('../schemas/', import.meta.url), join(moduleRoot, 'schemas'), { recursive: true }); const schema = JSON.parse(readFileSync(join(moduleRoot, 'schemas/requirement.schema.json'))); schema.minProperties = 100; writeFileSync(join(moduleRoot, 'schemas/requirement.schema.json'), JSON.stringify(schema)); await assert.rejects(import(pathToFileURL(join(moduleRoot, 'src/validate.mjs'))), /unsupported schema keyword/); const included = join(c.root, 'included.conf'); writeFileSync(included, '[user]\nname = other\n'); git(repo, 'config', 'include.path', included); await assert.rejects(api.writeRecord({ ...c, path: 'docs/a.md', record: '# A', author: actor }), e => e.code === 'project-repository'); git(repo, 'config', '--unset', 'include.path'); writeFileSync(join(repo, 'requirements/.gitkeep'), 'not empty'); assert.match((await api.checkProject(c)).problems.join('\n'), /requirements\/\.gitkeep.*empty/); }); test('a linked git directory is refused before a writer lock can touch its target', async t => { const { c, repo } = await ready(t); const { watch } = await import('node:fs'); const outside = join(c.root, 'external-git'); mkdirSync(outside); rmSync(join(repo, '.git'), { recursive: true }); symlinkSync(outside, join(repo, '.git')); const events = [], watcher = watch(outside, (_event, name) => events.push(name)); try { await assert.rejects(api.writeRecord({ ...c, path: 'docs/a.md', record: '# A', author: actor })); await new Promise(resolve => setTimeout(resolve, 20)); assert.deepEqual(events, []); } finally { watcher.close(); } });