Files

253 lines
16 KiB
JavaScript

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(); }
});