253 lines
16 KiB
JavaScript
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(); }
|
|
});
|