207 lines
12 KiB
JavaScript
207 lines
12 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs, { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, symlinkSync } from 'node:fs';
|
|
import { syncBuiltinESMExports } from 'node:module';
|
|
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', 'maintenance.auto=false', '-c', 'gc.auto=0', '-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', '[email protected]', '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: '[email protected]' });
|
|
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', '[email protected]',
|
|
'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', '[email protected]', '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);
|
|
});
|
|
|
|
// Row 780: git can write and rename pack temp files while the metadata walk is running. The walk
|
|
// calls readdirSync and lstatSync through the builtin's live bindings, so the test replaces
|
|
// fs.readdirSync for one path and removes the named entry itself. The product then sees exactly
|
|
// what a concurrent git process produces, with no hook in production code.
|
|
test('a git metadata entry that vanishes during inspection refuses as project-repository, never raw ENOENT', async t => {
|
|
const { context, repo, commit } = await fixture(t);
|
|
const pack = join(repo, '.git/objects/pack'), temp = join(pack, 'tmp_idx_vanishes');
|
|
const directory = join(repo, '.git/objects/tmp_dir_vanishes');
|
|
writeFileSync(temp, 'x'); mkdirSync(directory);
|
|
assert.equal((await api.readSpec(context)).commit, commit, 'control: the extra entries alone do not refuse');
|
|
const real = fs.readdirSync, unlink = fs.unlinkSync, rmdir = fs.rmdirSync;
|
|
let armed = null;
|
|
fs.readdirSync = function (path, ...rest) {
|
|
if (armed === 'file' && String(path) === pack) { const names = real.call(this, path, ...rest); unlink(temp); return names; }
|
|
if (armed === 'directory' && String(path) === directory) rmdir(directory);
|
|
return real.call(this, path, ...rest);
|
|
};
|
|
syncBuiltinESMExports();
|
|
t.after(() => { fs.readdirSync = real; syncBuiltinESMExports(); });
|
|
for (const kind of ['file', 'directory']) {
|
|
armed = kind;
|
|
await assert.rejects(api.readSpec(context), error => {
|
|
assert.equal(error.code, 'project-repository', `${kind}: ${error.message}`);
|
|
assert.match(error.message, /changed during inspection/);
|
|
return true;
|
|
});
|
|
assert.equal(armed, kind);
|
|
armed = null;
|
|
writeFileSync(temp, 'x'); if (!fs.existsSync(directory)) mkdirSync(directory);
|
|
}
|
|
assert.equal(git(repo, 'rev-parse', 'HEAD'), commit);
|
|
});
|
|
|
|
// Row 780 R2: the walk stats a listed directory, then must stat it again when it enters it. The
|
|
// test replaces fs.lstatSync for one path and swaps the directory for something else inside the
|
|
// call that stats it as a listed entry, after the real stat is taken. A walk that trusts that
|
|
// stale stat would read through a link or leak ENOTDIR; the fresh stat refuses both.
|
|
async function replaceListedDirectory(t, replace) {
|
|
const { context, repo, commit, root } = await fixture(t);
|
|
const directory = join(repo, '.git/objects/tmp_dir_replaced');
|
|
const outside = join(root, 'outside');
|
|
mkdirSync(directory); mkdirSync(outside); writeFileSync(join(outside, 'private'), 'x');
|
|
assert.equal((await api.readSpec(context)).commit, commit, 'control: the extra directory alone does not refuse');
|
|
const real = fs.lstatSync;
|
|
let armed = true;
|
|
fs.lstatSync = function (path, ...rest) {
|
|
if (!armed || String(path) !== directory) return real.call(this, path, ...rest);
|
|
armed = false;
|
|
const stat = real.call(this, path, ...rest);
|
|
fs.rmdirSync(directory); replace(directory, outside);
|
|
return stat;
|
|
};
|
|
syncBuiltinESMExports();
|
|
try {
|
|
await assert.rejects(api.readSpec(context), error => {
|
|
assert.equal(error.code, 'project-repository', error.message);
|
|
assert.match(error.message, /expected local \.git directory/);
|
|
return true;
|
|
});
|
|
} finally { fs.lstatSync = real; syncBuiltinESMExports(); }
|
|
assert.equal(armed, false, 'the listed directory was replaced during inspection');
|
|
assert.equal(git(repo, 'rev-parse', 'HEAD'), commit);
|
|
}
|
|
|
|
test('a git metadata directory replaced by a symlink after its listed stat refuses, never reads through it', async t => {
|
|
await replaceListedDirectory(t, (directory, outside) => symlinkSync(outside, directory));
|
|
});
|
|
|
|
test('a git metadata directory replaced by a file after its listed stat refuses, never raw ENOTDIR', async t => {
|
|
await replaceListedDirectory(t, directory => writeFileSync(directory, 'x'));
|
|
});
|
|
|
|
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', 'maintenance.auto=false', '-c', 'gc.auto=0', '-c', 'commit.gpgsign=false',
|
|
'-c', 'user.name=jason', '-c', '[email protected]', '-C', repo, 'commit', '-F', message],
|
|
{ stdio: ['ignore', 'ignore', 'pipe'] });
|
|
await assert.rejects(api.readSpec({ business, project: 'stack', dataRoot: root }), { code: 'project-git' });
|
|
});
|
|
|
|
test('missing record repository has a named refusal while a damaged repository still refuses', async t => {
|
|
const root = mkdtempSync(join(tmpdir(), 'project-missing-records-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const context = { business: { id: 'demo', human: 'jason', projects: { stack: { root: join(root, 'code') } } }, project: 'stack', dataRoot: root };
|
|
await assert.rejects(api.listProjectFiles(context), { code: 'project-records-missing' });
|
|
mkdirSync(join(root, 'projects', 'demo', 'stack'), { recursive: true });
|
|
await assert.rejects(api.listProjectFiles(context), { code: 'project-repository' });
|
|
});
|