import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, writeFileSync, readFileSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; import * as api from '../src/index.mjs'; const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim(); async function fixture(t) { const root = mkdtempSync(join(tmpdir(), 'project-spec-read-')); t.after(() => rmSync(root, { recursive: true, force: true })); const context = { business: { id: 'demo', human: 'jason', projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') }, } }, project: 'stack', dataRoot: root }; const { repo, commit } = await api.initProject(context); const save = (path, bytes, author = 'jason') => { writeFileSync(join(repo, path), bytes); git(repo, 'add', '--', path); git(repo, '-c', `user.name=${author}`, '-c', 'user.email=fixture@mosaic.invalid', 'commit', '-m', 'Save ' + path); return git(repo, 'rev-parse', 'HEAD'); }; return { context, repo, commit, save, root }; } test('spec reads exact committed content and its path commit/log, not dirty bytes or a later docs commit', async t => { const { context, repo, save, commit } = await fixture(t); const spec = save('spec/north-star.md', '# Spec\n\nKeep this.\n\n'); const docs = save('docs/notes.md', '# Docs\n'); writeFileSync(join(repo, 'spec/north-star.md'), '# Unsaved human edit\n'); const result = await api.readSpec(context); assert.equal(result.content, '# Spec\n\nKeep this.\n\n'); assert.equal(result.repo, repo); assert.equal(result.path, 'spec/north-star.md'); assert.equal(result.head, docs); assert.equal(result.revision, docs); assert.equal(result.commit, spec); assert.equal(result.confirmed, true); assert.deepEqual(result.author, { name: 'jason', email: 'fixture@mosaic.invalid' }); assert.deepEqual(result.log.map(r => r.commit), [spec, commit]); assert.equal(result.truncated, false); assert.equal(readFileSync(join(repo, 'spec/north-star.md'), 'utf8'), '# Unsaved human edit\n'); assert.equal(git(repo, 'rev-parse', 'HEAD'), docs); for (const row of result.log) assert.equal(new Date(row.at).toISOString(), row.at); const historical = await api.readSpec({ ...context, commit }); assert.equal(historical.commit, commit); assert.deepEqual(historical.log.map(r => r.commit), [commit]); assert.equal(historical.content, readFileSync(new URL('../templates/north-star.md', import.meta.url), 'utf8')); }); test('latest agent spec edit is visible but never confirmed or replaced by an earlier human version', async t => { const { context, save } = await fixture(t); const commit = save('spec/north-star.md', '# Agent\n', 'coder.builder'); const result = await api.readSpec(context); assert.equal(result.commit, commit); assert.equal(result.confirmed, false); assert.equal(result.author.name, 'coder.builder'); }); test('shared file reader exposes committed docs and refuses path/revision injection or another history', async t => { const { context, repo, save } = await fixture(t); const commit = save('docs/design.md', '# Design\n'); assert.equal((await api.readProjectFile({ ...context, path: 'docs/design.md' })).commit, commit); for (const path of ['../outside.md', 'spec/other.md', '/spec/north-star.md', 'docs/a/b.md', 'requirements/R.json']) await assert.rejects(api.readProjectFile({ ...context, path }), { code: 'project-read-path' }); for (const commit of ['HEAD', '--help', 'a'.repeat(40), 'abc', git(repo, 'write-tree')]) await assert.rejects(api.readSpec({ ...context, commit }), { code: 'project-read-commit' }); const orphan = git(repo, '-c', 'user.name=jason', '-c', 'user.email=jason@mosaic.invalid', 'commit-tree', git(repo, 'write-tree'), '-m', 'Unreachable'); await assert.rejects(api.readSpec({ ...context, commit: orphan }), { code: 'project-read-commit' }); }); test('reader rejects missing paths, links, invalid UTF-8, NUL and oversized committed blobs', async t => { const { context, repo, save } = await fixture(t); await assert.rejects(api.readProjectFile({ ...context, path: 'docs/missing.md' }), { code: 'project-read-missing' }); for (const bytes of [Buffer.from([0xff]), 'x\0y', 'x'.repeat(1024 * 1024 + 1)]) { save('spec/north-star.md', bytes); await assert.rejects(api.readSpec(context), { code: 'project-read-content' }); } rmSync(join(repo, 'spec/north-star.md')); symlinkSync('../docs/README.md', join(repo, 'spec/north-star.md')); git(repo, 'add', '--', 'spec/north-star.md'); git(repo, '-c', 'user.name=jason', '-c', 'user.email=jason@mosaic.invalid', 'commit', '-m', 'Linked spec'); await assert.rejects(api.readSpec(context), { code: 'project-read-content' }); }); test('reader refuses mismatched committed project identity, unsafe config and unknown argument keys', async t => { const { context, repo, save } = await fixture(t); await assert.rejects(api.readSpec({ ...context, path: 'docs/README.md' }), { code: 'project-read-path' }); await assert.rejects(api.readSpec({ ...context, project: 'other' }), { code: 'project-not-declared' }); const metadata = JSON.parse(readFileSync(join(repo, 'project.json'))); metadata.business = 'other'; save('project.json', JSON.stringify(metadata)); await assert.rejects(api.readSpec(context), { code: 'project-invalid' }); git(repo, 'remote', 'add', 'origin', 'https://fixture.invalid/no-network'); await assert.rejects(api.readSpec(context), { code: 'project-remote' }); }); test('path history is bounded at 100 and explicitly marks truncation', async t => { const { context, save } = await fixture(t); for (let i = 0; i < 101; i++) save('spec/north-star.md', `# Spec ${i}\n`); const result = await api.readSpec(context); assert.equal(result.log.length, 100); assert.equal(result.truncated, true); assert.equal(result.log[0].commit, result.commit); }); test('committed UTF-8 BOM, Unicode and trailing newlines are returned without normalization', async t => { const { context, save } = await fixture(t); const value = '\uFEFF# North star 🧭\r\n\r\n'; save('spec/north-star.md', Buffer.from(value)); assert.equal((await api.readSpec(context)).content, value); }); test('D4: spec log refuses a commit subject larger than its 256 KiB read bound', async t => { const root = mkdtempSync(join(tmpdir(), 'spec-log-bound-')); t.after(() => rmSync(root, { recursive: true, force: true })); const business = { id: 'demo', human: 'jason', projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } } }; const { repo } = await api.initProject({ business, project: 'stack', dataRoot: root }); const message = join(root, 'commit-message.txt'); writeFileSync(message, 'X'.repeat(256 * 1024 + 1) + '\n'); writeFileSync(join(repo, 'spec/north-star.md'), '# Small spec\n'); execFileSync('git', ['-C', repo, 'add', '--', 'spec/north-star.md']); execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false', '-c', 'user.name=jason', '-c', 'user.email=jason@mosaic.invalid', '-C', repo, 'commit', '-F', message], { stdio: ['ignore', 'ignore', 'pipe'] }); await assert.rejects(api.readSpec({ business, project: 'stack', dataRoot: root }), { code: 'project-git' }); });