Files
ECC/tests/docs/story-lifecycle-ledger.test.js
T

778 lines
43 KiB
JavaScript

const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawn, spawnSync } = require('child_process');
const LEDGER = path.join(__dirname, '..', '..', 'skills', 'story-lifecycle', 'scripts', 'ledger.js');
const { resolveLedgerPath } = require(LEDGER);
const EPIC = `# Epic: Auth flow
**Slug:** auth-flow
**Status:** ready
## Stories
<!-- Derived from story files — regenerated by status and on every transition -->
| ID | Title | Status |
| --- | --- | --- |
`;
function story(id, title, { sprint = 'unassigned', status = 'todo', points = 3 } = {}) {
return `# Story: ${title}\n\n**ID:** ${id}\n**Epic:** auth-flow\n**Sprint:** ${sprint}\n**Status:** ${status}\n**Points:** ${points}\n`;
}
function run(root, args, env = {}) {
const r = spawnSync(process.execPath, [LEDGER, ...args, '--root', root], { encoding: 'utf8', env: { ...process.env, ...env } });
return { code: r.status, out: r.stdout ? JSON.parse(r.stdout) : null, err: r.stderr };
}
function runAsync(root, args) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [LEDGER, ...args, '--root', root]);
let out = '';
child.stdout.on('data', (d) => { out += d; });
child.on('close', (code) => resolve({ code, out }));
});
}
function writeJson(root, name, value) {
const file = path.join(root, name);
fs.writeFileSync(file, JSON.stringify(value));
return file;
}
const ownedTemps = new Set();
function tempDir(prefix) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
ownedTemps.add(dir);
return dir;
}
function newRepo() {
const root = tempDir('story-ledger-');
execFileSync('git', ['init', '-q', root]);
assert.strictEqual(run(root, ['init']).code, 0);
const input = writeJson(root, 'epic.json', { create: { 'epics/auth-flow.md': EPIC } });
assert.strictEqual(run(root, ['apply', '--input', input]).code, 0);
return root;
}
function createStories(root, titles, tag = 'batch') {
const { ids } = run(root, ['allocate', '--epic', 'auth-flow', '--count', String(titles.length)]).out;
const create = Object.fromEntries(ids.map((id, i) => [`${id}.md`, story(id, titles[i])]));
return { ids, input: writeJson(root, `${tag}.json`, { create }) };
}
// Windows allows symlinks only with admin rights or Developer Mode; skip rather than fail there.
class Skip extends Error {}
function symlink(target, file, type) {
try {
fs.symlinkSync(target, file, type);
} catch (err) {
if (err.code === 'EPERM' && process.platform === 'win32') throw new Skip('symlinks not permitted');
throw err;
}
}
const read = (root, rel) => fs.readFileSync(path.join(root, '.stories', rel), 'utf8');
const storyFiles = (root) => fs.readdirSync(path.join(root, '.stories')).filter((f) => /-\d+\.md$/.test(f)).sort();
const LOCK_DENIAL_MARKER = 'FOREIGN_LOCK_CREATE_DENIED ';
function windowsLockCreateDenied(result, lock, platform = process.platform) {
if (platform !== 'win32' || result.status !== 1) return false;
try {
const line = result.stderr.split('\n').find(text => text.startsWith(LOCK_DENIAL_MARKER));
if (!line) return false;
const denied = JSON.parse(line.slice(LOCK_DENIAL_MARKER.length));
return ['EPERM', 'EACCES', 'EBUSY'].includes(denied.code) && denied.syscall === 'open' && denied.path === lock &&
result.stderr.split('\n').some(text => /(?:EPERM|EACCES|EBUSY).*\b(?:open|lstat)\b/.test(text) && text.includes(`'${lock}'`));
} catch { return false; }
}
function assertRefusedLockWriter(root, ids, input) {
const filename = `${ids[0]}.md`;
assert.deepStrictEqual(storyFiles(root), [filename]);
assert.strictEqual(read(root, filename), JSON.parse(fs.readFileSync(input, 'utf8')).create[filename]);
assert.ok(!fs.readdirSync(path.join(root, '.stories')).some(name => name === '.lock' || name === '.txn'));
const state = run(root, ['status']);
assert.strictEqual(state.code, 0, state.err);
assert.strictEqual(state.out.recoveryPending, false);
assert.deepStrictEqual(state.out.drift, []);
}
function snapshot(dir) {
if (!fs.existsSync(dir)) return null;
return Object.fromEntries(fs.readdirSync(dir, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
const name = entry.name;
const file = path.join(dir, name);
if (entry.isDirectory()) return [name, snapshot(file)];
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
try {
assert.ok(fs.fstatSync(fd).isFile(), 'snapshot requires a regular file');
return [name, fs.readFileSync(fd, 'base64')];
} finally { fs.closeSync(fd); }
}));
}
function withoutHardlinks(root, args, { expectedAncestor = null, code = 'ENOTSUP' } = {}) {
const preload = path.join(root, 'unsupported-links.js');
fs.writeFileSync(preload, `const fs = require('fs'); const path = require('path');
fs.linkSync = function (source) {
const ancestor = ${JSON.stringify(expectedAncestor)};
if (ancestor && path.dirname(path.dirname(source)) !== ancestor) {
throw new Error('probe used wrong filesystem: ' + source);
}
throw Object.assign(new Error('injected unsupported hardlinks'), { code: ${JSON.stringify(code)} });
};`);
const r = spawnSync(process.execPath, ['-r', preload, LEDGER, ...args], { encoding: 'utf8' });
return { code: r.status, out: r.stdout ? JSON.parse(r.stdout) : null, err: r.stderr };
}
// One full create-stories run (allocate + apply) in a separate process.
const WRITER = `
const { execFileSync } = require('child_process');
const fs = require('fs');
const [ledger, root, tag] = process.argv.slice(1);
const run = (...a) => JSON.parse(execFileSync(process.execPath, [ledger, ...a, '--root', root]));
const { ids } = run('allocate', '--epic', 'auth-flow', '--count', '3');
const create = Object.fromEntries(ids.map((id) => [id + '.md',
'# Story: ' + tag + ' ' + id + '\\n\\n**ID:** ' + id + '\\n**Epic:** auth-flow\\n**Sprint:** unassigned\\n**Status:** todo\\n**Points:** 1\\n']));
fs.writeFileSync(root + '/' + tag + '.json', JSON.stringify({ create }));
run('apply', '--input', root + '/' + tag + '.json');
`;
const tests = [
['unsupported storage refuses init on the nearest existing ancestor without creating the ledger', () => {
const root = tempDir('story-storage-');
const intended = path.join(root, 'missing', 'repo');
const result = withoutHardlinks(root, ['init', '--root', intended], { expectedAncestor: root });
assert.strictEqual(result.code, 2, result.err);
assert.match(result.err, /hardlink.*ENOTSUP/);
assert.deepStrictEqual(fs.readdirSync(root), ['unsupported-links.js']);
assert.ok(!fs.existsSync(path.join(root, 'missing')));
}],
['unsupported storage refuses every write before touching ledger files or ID reservations', () => {
const root = newRepo();
const { input } = createStories(root, ['Unpublished']);
fs.writeFileSync(path.join(root, '.stories', '.hardlink-probe-user'), 'keep user file');
const ledgerBefore = snapshot(path.join(root, '.stories'));
const idsBefore = snapshot(path.join(root, '.git', 'story-lifecycle'));
for (const args of [['init'], ['allocate', '--epic', 'auth-flow'],
['apply', '--input', input], ['reconcile'], ['recover']]) {
const result = withoutHardlinks(root, [...args, '--root', root], {
expectedAncestor: path.join(root, '.stories'), code: 'EPERM',
});
assert.strictEqual(result.code, 2, `${args}: ${result.err}`);
assert.match(result.err, /hardlink.*EPERM/);
assert.deepStrictEqual(snapshot(path.join(root, '.stories')), ledgerBefore);
assert.deepStrictEqual(snapshot(path.join(root, '.git', 'story-lifecycle')), idsBefore);
}
}],
['storage change leaves pending recovery untouched and status truthful until supported recovery', () => {
const root = newRepo();
const { ids, input } = createStories(root, ['Partially published']);
assert.strictEqual(run(root, ['apply', '--input', input], { STORY_LEDGER_CRASH_AFTER: '1' }).code, 70);
const before = snapshot(path.join(root, '.stories'));
const result = withoutHardlinks(root, ['recover', '--root', root]);
assert.strictEqual(result.code, 2, result.err);
assert.match(result.err, /hardlink.*ENOTSUP/);
assert.deepStrictEqual(snapshot(path.join(root, '.stories')), before, 'no partial rollback or lock cleanup');
const status = withoutHardlinks(root, ['status', '--root', root]);
assert.strictEqual(status.code, 0, status.err);
assert.strictEqual(status.out.recoveryPending, true);
assert.strictEqual(status.out.projectionAvailable, false);
assert.match(status.out.recoveryAdvice, /storage.*manual/i);
assert.deepStrictEqual(snapshot(path.join(root, '.stories')), before, 'status must be read-only');
assert.ok(fs.existsSync(path.join(root, '.stories', `${ids[0]}.md`)));
assert.strictEqual(run(root, ['recover']).code, 0);
assert.strictEqual(run(root, ['apply', '--input', input]).code, 0);
assert.deepStrictEqual(run(root, ['status']).out.drift, []);
}],
['create-stories writes story files and regenerates the epic table', () => {
const root = newRepo();
const { ids, input } = createStories(root, ['Login', 'Logout']);
assert.deepStrictEqual(ids, ['auth-flow-1', 'auth-flow-2']);
assert.strictEqual(run(root, ['apply', '--input', input]).code, 0);
assert.ok(read(root, 'epics/auth-flow.md').includes('| auth-flow-2 | Logout | todo |'));
assert.ok(fs.existsSync(path.join(root, '.stories', '.gitignore')));
}],
['two concurrent writers get distinct IDs and neither overwrites the other', async () => {
const root = newRepo();
const codes = await Promise.all(['a', 'b'].map((tag) => new Promise((resolve) => {
spawn(process.execPath, ['-e', WRITER, LEDGER, root, tag], { stdio: 'inherit' }).on('close', resolve);
})));
assert.deepStrictEqual(codes, [0, 0]);
const files = storyFiles(root);
assert.strictEqual(files.length, 6, `expected 6 distinct story files, got ${files}`);
const epic = read(root, 'epics/auth-flow.md');
for (const f of files) assert.ok(epic.includes(`| ${f.slice(0, -3)} |`), `epic table missing ${f}`);
assert.deepStrictEqual(run(root, ['status']).out.drift, []);
}],
['a held lock makes the second writer wait, fail cleanly, then succeed on retry', async () => {
const root = newRepo();
const { input } = createStories(root, ['Login']);
const lock = path.join(root, '.stories', '.lock');
fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, host: os.hostname(), at: 'now' }));
fs.mkdirSync(path.join(root, '.stories', '.txn'));
assert.strictEqual(run(root, ['status']).out.recoveryPending, false, 'in-flight batch of a live writer is not interrupted');
const blocked = await runAsync(root, ['apply', '--input', input, '--wait-ms', '200']);
assert.strictEqual(blocked.code, 3, 'expected lock-timeout exit code');
assert.deepStrictEqual(storyFiles(root), []);
fs.rmSync(lock);
assert.strictEqual(run(root, ['apply', '--input', input]).code, 0);
assert.deepStrictEqual(storyFiles(root), ['auth-flow-1.md']);
}],
['creating an existing story ID is refused without overwrite', () => {
const root = newRepo();
const { ids, input } = createStories(root, ['Login']);
assert.strictEqual(run(root, ['apply', '--input', input]).code, 0);
const again = writeJson(root, 'again.json', { create: { [`${ids[0]}.md`]: story(ids[0], 'Clobber') } });
assert.strictEqual(run(root, ['apply', '--input', again]).code, 4);
assert.ok(read(root, `${ids[0]}.md`).includes('Login'));
}],
['unallocated story IDs are refused', () => {
const root = newRepo();
const input = writeJson(root, 'raw.json', { create: { 'auth-flow-9.md': story('auth-flow-9', 'Raw') } });
assert.notStrictEqual(run(root, ['apply', '--input', input]).code, 0);
assert.deepStrictEqual(storyFiles(root), []);
}],
['an interrupted batch is rolled back completely by the next writer', () => {
const root = newRepo();
const before = read(root, 'epics/auth-flow.md');
const { input } = createStories(root, ['A', 'B', 'C']);
const crashed = run(root, ['apply', '--input', input], { STORY_LEDGER_CRASH_AFTER: '2' });
assert.strictEqual(crashed.code, 70);
assert.strictEqual(storyFiles(root).length, 2, 'fixture must leave a partial batch on disk');
assert.strictEqual(run(root, ['status']).out.recoveryPending, true);
assert.strictEqual(run(root, ['recover']).out.rolledBack, 4);
assert.deepStrictEqual(storyFiles(root), []);
assert.strictEqual(read(root, 'epics/auth-flow.md'), before);
assert.ok(!fs.existsSync(path.join(root, '.stories', '.lock')));
}],
['plan-sprint assigns stories and derives the sprint table and total', () => {
const root = newRepo();
const { ids, input } = createStories(root, ['Login', 'Logout']);
run(root, ['apply', '--input', input]);
const sprint = '# Sprint 1\n\n## Stories\n\n<!-- Derived from story files — regenerated by status and on every transition -->\n| ID | Title | Points | Status |\n| --- | --- | --- | --- |\n\n## Total Points: 0\n';
const plan = writeJson(root, 'plan.json', {
create: { 'sprints/sprint-1.md': sprint },
update: Object.fromEntries(ids.map((id, i) => [`${id}.md`, story(id, ['Login', 'Logout'][i], { sprint: '1', points: 5 })])),
});
assert.strictEqual(run(root, ['apply', '--input', plan]).code, 0);
const s = read(root, 'sprints/sprint-1.md');
assert.ok(s.includes('| auth-flow-1 | Login | 5 | todo |'));
assert.ok(s.includes('## Total Points: 10'));
}],
['status reports drift read-only; reconcile repairs the derived tables', () => {
const root = newRepo();
const { ids, input } = createStories(root, ['Login']);
run(root, ['apply', '--input', input]);
fs.writeFileSync(path.join(root, '.stories', `${ids[0]}.md`), story(ids[0], 'Login', { status: 'in-progress' }));
const epicBefore = read(root, 'epics/auth-flow.md');
assert.deepStrictEqual(run(root, ['status']).out.drift, ['epics/auth-flow.md']);
assert.strictEqual(read(root, 'epics/auth-flow.md'), epicBefore, 'status must not write');
assert.strictEqual(run(root, ['reconcile']).code, 0);
assert.ok(read(root, 'epics/auth-flow.md').includes('| auth-flow-1 | Login | in-progress |'));
}],
['git worktrees of one clone share ID allocation', () => {
const root = newRepo();
execFileSync('git', ['-C', root, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init']);
const wt = path.join(tempDir('story-worktree-'), 'checkout');
execFileSync('git', ['-C', root, 'worktree', 'add', '-q', wt]);
run(wt, ['init']);
run(wt, ['apply', '--input', writeJson(wt, 'epic.json', { create: { 'epics/auth-flow.md': EPIC } })]);
const a = run(root, ['allocate', '--epic', 'auth-flow', '--count', '2']).out.ids;
const b = run(wt, ['allocate', '--epic', 'auth-flow', '--count', '2']).out.ids;
assert.deepStrictEqual([...a, ...b], ['auth-flow-1', 'auth-flow-2', 'auth-flow-3', 'auth-flow-4']);
}],
['concurrent allocation from two worktrees never hands out the same ID', async () => {
const root = newRepo();
execFileSync('git', ['-C', root, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-q', '--allow-empty', '-m', 'init']);
const wt = path.join(tempDir('story-worktree-'), 'checkout');
execFileSync('git', ['-C', root, 'worktree', 'add', '-q', wt]);
run(wt, ['init']);
run(wt, ['apply', '--input', writeJson(wt, 'epic.json', { create: { 'epics/auth-flow.md': EPIC } })]);
// Each worktree has its own lock, so only the shared exclusive-create markers prevent duplicates.
const results = await Promise.all(Array.from({ length: 32 }, (_, i) => runAsync(i % 2 ? wt : root, ['allocate', '--epic', 'auth-flow', '--count', '5'])));
const ids = results.flatMap((r) => JSON.parse(r.out).ids);
assert.strictEqual(ids.length, 160);
assert.strictEqual(new Set(ids).size, 160, 'duplicate story IDs allocated across worktrees');
}],
['symlinked ledger directories are refused before any write', () => {
const root = newRepo();
const outside = tempDir('story-outside-');
fs.rmSync(path.join(root, '.stories', 'sprints'), { recursive: true });
symlink(outside, path.join(root, '.stories', 'sprints'), 'dir');
const input = writeJson(root, 's.json', { create: { 'sprints/sprint-1.md': '# Sprint 1\n' } });
const r = run(root, ['apply', '--input', input]);
assert.strictEqual(r.code, 2);
assert.match(r.err, /symlinked ledger directory/);
assert.deepStrictEqual(fs.readdirSync(outside), []);
}],
['recover rejects journal backups outside .txn/', () => {
const root = newRepo();
const txn = path.join(root, '.stories', '.txn');
fs.writeFileSync(path.join(root, 'private.txt'), 'SECRET');
fs.mkdirSync(txn);
fs.writeFileSync(path.join(txn, 'journal.json'), JSON.stringify({ entries: [{ path: 'epics/auth-flow.md', backup: '../../private.txt' }] }));
assert.strictEqual(run(root, ['recover']).code, 2);
assert.ok(!read(root, 'epics/auth-flow.md').includes('SECRET'));
}],
['recover never writes through a symlinked ledger target', () => {
const root = newRepo();
const txn = path.join(root, '.stories', '.txn');
const victim = path.join(root, 'victim.txt');
fs.writeFileSync(victim, 'ORIGINAL');
symlink(victim, path.join(root, '.stories', 'auth-flow-1.md'));
fs.mkdirSync(txn);
fs.writeFileSync(path.join(txn, 'backup-0'), 'PLANTED');
fs.writeFileSync(path.join(txn, 'journal.json'), JSON.stringify({ entries: [{ path: 'auth-flow-1.md', backup: 'backup-0' }] }));
assert.strictEqual(run(root, ['recover']).code, 2);
assert.strictEqual(fs.readFileSync(victim, 'utf8'), 'ORIGINAL');
}],
['symlinked ledger files are never read into or written through the ledger', () => {
const root = newRepo();
const { ids, input } = createStories(root, ['Login', 'Logout']);
run(root, ['apply', '--input', input]);
const outside = path.join(root, 'other-repo-story.md');
fs.writeFileSync(outside, story(ids[1], 'PRIVATE-TITLE'));
fs.rmSync(path.join(root, '.stories', `${ids[1]}.md`));
symlink(outside, path.join(root, '.stories', `${ids[1]}.md`));
const status = run(root, ['status']);
assert.strictEqual(status.code, 2);
assert.match(status.err, /symlinked ledger file/);
assert.strictEqual(run(root, ['reconcile']).code, 2);
const update = writeJson(root, 'u.json', { update: { [`${ids[1]}.md`]: story(ids[1], 'Logout', { status: 'in-progress' }) } });
assert.strictEqual(run(root, ['apply', '--input', update]).code, 2);
assert.ok(!read(root, 'epics/auth-flow.md').includes('PRIVATE-TITLE'));
assert.strictEqual(fs.readFileSync(outside, 'utf8'), story(ids[1], 'PRIVATE-TITLE'));
}],
['recover never follows symlinks planted in .txn/', () => {
const root = newRepo();
const txn = path.join(root, '.stories', '.txn');
const outside = path.join(root, 'outside.txt');
fs.writeFileSync(outside, 'ORIGINAL');
fs.mkdirSync(txn);
fs.writeFileSync(path.join(txn, 'journal.json'), JSON.stringify({ entries: [{ path: 'auth-flow-1.md', backup: 'backup-0' }] }));
symlink(outside, path.join(txn, 'backup-0'));
assert.strictEqual(run(root, ['recover']).code, 2, 'symlinked backup must be refused');
assert.ok(!fs.existsSync(path.join(root, '.stories', 'auth-flow-1.md')));
fs.rmSync(path.join(txn, 'backup-0'));
fs.writeFileSync(path.join(txn, 'backup-0'), 'GOOD');
symlink(outside, path.join(txn, 'restore.tmp'));
assert.strictEqual(run(root, ['recover']).code, 0);
assert.strictEqual(fs.readFileSync(outside, 'utf8'), 'ORIGINAL');
assert.ok(fs.lstatSync(path.join(root, '.stories', 'auth-flow-1.md')).isFile());
}],
['a leftover .lock.break makes writers time out instead of spinning', () => {
const root = newRepo();
fs.writeFileSync(path.join(root, '.stories', '.lock'), JSON.stringify({ pid: 2 ** 22 + 1, host: os.hostname(), at: 'stale' }));
fs.writeFileSync(path.join(root, '.stories', '.lock.break'), '1');
const r = spawnSync(process.execPath, [LEDGER, 'reconcile', '--root', root, '--wait-ms', '200'], { encoding: 'utf8', timeout: 5000 });
assert.strictEqual(r.status, 3, `expected lock timeout, got ${r.status} ${r.signal}`);
assert.match(r.stderr, /\.lock\.break is held/);
}],
['symlinked .ids registry is refused outside git', () => {
const root = newRepo();
fs.rmSync(path.join(root, '.git'), { recursive: true });
const outside = tempDir('story-ids-');
symlink(outside, path.join(root, '.stories', '.ids'), 'dir');
assert.strictEqual(run(root, ['allocate', '--epic', 'auth-flow']).code, 2);
assert.deepStrictEqual(fs.readdirSync(outside), []);
}],
['a batch naming the same file twice is refused', () => {
const root = newRepo();
const input = writeJson(root, 'dup.json', { create: { 'sprints\\sprint-1.md': '# A\n', 'sprints/sprint-1.md': '# B\n' } });
const r = run(root, ['apply', '--input', input]);
assert.strictEqual(r.code, 2);
assert.match(r.err, /duplicate path/);
assert.ok(!fs.existsSync(path.join(root, '.stories', 'sprints', 'sprint-1.md')));
}],
['a waiter that saw a stale lock never deletes the live lock that replaced it', () => {
const root = newRepo();
const lock = path.join(root, '.stories', '.lock');
fs.writeFileSync(lock, JSON.stringify({ pid: 2 ** 22 + 1, host: os.hostname(), at: 'stale' }));
// Preload: right after the waiter reads the stale lock, another writer wins and holds a live lock.
const preload = path.join(root, 'race.js');
fs.writeFileSync(preload, `const fs = require('fs'); const orig = fs.openSync; let raced = false; const observed = new Map();
fs.openSync = function (p, ...a) { const fd = orig.call(fs, p, ...a);
if (String(p).endsWith('.lock') && a[0] === (fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0))) observed.set(fd, p);
return fd; };
const close = fs.closeSync; fs.closeSync = function (fd) { const p = observed.get(fd); observed.delete(fd); close.call(fs, fd);
if (!raced && p) { raced = true; fs.unlinkSync(p);
fs.writeFileSync(p, JSON.stringify({ pid: ${process.pid}, host: require('os').hostname(), at: 'live' }), { flag: 'wx' }); }
};`);
const r = spawnSync(process.execPath, ['-r', preload, LEDGER, 'allocate', '--epic', 'auth-flow', '--root', root, '--wait-ms', '200'], { encoding: 'utf8' });
assert.strictEqual(r.status, 3, `expected lock timeout, got ${r.status}: ${r.stderr}`);
assert.ok(fs.readFileSync(lock, 'utf8').includes('live'), 'live lock was deleted');
}],
['story status moves forward one step at a time and new stories start as todo', () => {
const root = newRepo();
const [id] = run(root, ['allocate', '--epic', 'auth-flow']).out.ids;
const put = (kind, status) => run(root, ['apply', '--input', writeJson(root, 't.json', { [kind]: { [`${id}.md`]: story(id, 'Login', { status }) } })]).code;
assert.strictEqual(put('create', 'done'), 2);
assert.strictEqual(put('create', 'todo'), 0);
assert.strictEqual(put('update', 'done'), 2, 'todo -> done skips steps');
for (const status of ['in-progress', 'in-progress', 'review', 'done']) assert.strictEqual(put('update', status), 0, status);
assert.strictEqual(put('update', 'todo'), 2, 'done -> todo moves backward');
assert.ok(read(root, 'epics/auth-flow.md').includes(`| ${id} | Login | done |`));
}],
['stories can only be assigned to an existing sprint', () => {
const root = newRepo();
const { ids, input } = createStories(root, ['Login']);
run(root, ['apply', '--input', input]);
for (const sprint of ['2', 'bogus']) {
const plan = writeJson(root, 'p.json', { update: { [`${ids[0]}.md`]: story(ids[0], 'Login', { sprint }) } });
assert.strictEqual(run(root, ['apply', '--input', plan]).code, 2, sprint);
}
fs.writeFileSync(path.join(root, '.stories', `${ids[0]}.md`), story(ids[0], 'Login', { sprint: '7' }));
const status = run(root, ['status']);
assert.strictEqual(status.code, 2);
assert.match(status.err, /sprint 7 does not exist/);
}],
['story points must be one of 1, 2, 3, 5, 8 so sprint totals stay exact', () => {
const root = newRepo();
const [id] = run(root, ['allocate', '--epic', 'auth-flow']).out.ids;
for (const points of ['bogus', '4', '-3', '1e3', '0', '']) {
const input = writeJson(root, 'pts.json', { create: { [`${id}.md`]: story(id, 'Login', { points }) } });
assert.strictEqual(run(root, ['apply', '--input', input]).code, 2, `Points=${points}`);
}
assert.deepStrictEqual(storyFiles(root), []);
fs.writeFileSync(path.join(root, '.stories', `${id}.md`), story(id, 'Login', { points: 'bogus' }));
const status = run(root, ['status']);
assert.strictEqual(status.code, 2);
assert.match(status.err, /Points must be one of/);
}],
['git-common allocation registry refuses a symlinked parent', () => {
const root = newRepo();
const outside = tempDir('story-outside-');
symlink(outside, path.join(root, '.git', 'story-lifecycle'), 'dir');
const result = run(root, ['allocate', '--epic', 'auth-flow']);
assert.strictEqual(result.code, 2, result.err);
assert.deepStrictEqual(fs.readdirSync(outside), []);
}],
['allocation count and lock wait must be bounded integers', () => {
const root = newRepo();
for (const count of ['1.5', 'NaN', 'Infinity']) {
assert.strictEqual(run(root, ['allocate', '--epic', 'auth-flow', '--count', count]).code, 2);
}
for (const value of ['NaN', 'Infinity', '-1', '1.5', '60001']) {
assert.strictEqual(run(root, ['allocate', '--epic', 'auth-flow', '--wait-ms', value]).code, 2);
}
}],
['allocation rejects unsafe existing numeric IDs instead of looping forever', () => {
const root = newRepo();
const registry = path.join(root, '.git', 'story-lifecycle', 'ids');
fs.mkdirSync(registry, { recursive: true });
for (const n of ['9007199254740992', '9007199254740991']) {
const marker = path.join(registry, `auth-flow-${n}`);
fs.writeFileSync(marker, '');
const result = spawnSync(process.execPath, [LEDGER, 'allocate', '--root', root,
'--epic', 'auth-flow', '--count', '2'], { encoding: 'utf8', timeout: 2000 });
assert.strictEqual(result.status, 2, result.stderr || result.error?.message);
assert.match(result.stderr, /safe integer/);
assert.deepStrictEqual(fs.readdirSync(registry), [`auth-flow-${n}`]);
fs.rmSync(marker);
}
}],
['init never follows a symlink planted at the ignore-file write boundary', () => {
const root = tempDir('story-init-race-');
const victim = path.join(root, 'victim.txt');
fs.writeFileSync(victim, 'ORIGINAL');
const preload = path.join(root, 'race.js');
fs.writeFileSync(preload, `const fs = require('fs'); let planted = false;
const plant = (p) => { if (!planted && String(p).split(require('path').sep).join('/').endsWith('/.stories/.gitignore')) {
planted = true; fs.symlinkSync(${JSON.stringify(victim)}, p); } };
const exists = fs.existsSync; fs.existsSync = function (p) { const result = exists.call(fs, p);
if (!result) plant(p); return result; };
const open = fs.openSync; fs.openSync = function (p, ...args) { plant(p); return open.call(fs, p, ...args); };`);
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'init', '--root', root], { encoding: 'utf8' });
assert.strictEqual(fs.readFileSync(victim, 'utf8'), 'ORIGINAL', result.stderr);
assert.notStrictEqual(result.status, 0, 'a planted symlink must fail closed');
}],
['interrupted story-before-new-sprint batch reports recovery without projecting partial state', () => {
for (const interrupted of [false, true]) {
const root = newRepo();
const [id] = run(root, ['allocate', '--epic', 'auth-flow']).out.ids;
const before = read(root, 'epics/auth-flow.md');
const input = writeJson(root, 'sprint-batch.json', { create: {
[`${id}.md`]: story(id, 'Assigned', { sprint: '1' }),
'sprints/sprint-1.md': '# Sprint 1\n\n<!-- Derived from story files -->\n| ID | Title | Points | Status |\n| --- | --- | --- | --- |\n\n## Total Points: 0\n',
} });
const result = run(root, ['apply', '--input', input], interrupted ? { STORY_LEDGER_CRASH_AFTER: '1' } : {});
assert.strictEqual(result.code, interrupted ? 70 : 0, result.err);
if (interrupted) {
const partialStory = read(root, `${id}.md`);
const status = run(root, ['status']);
assert.strictEqual(status.code, 0, status.err);
assert.strictEqual(status.out.recoveryPending, true);
assert.strictEqual(status.out.projectionAvailable, false);
assert.strictEqual(read(root, `${id}.md`), partialStory, 'status must be read-only');
assert.strictEqual(run(root, ['recover']).code, 0);
assert.deepStrictEqual(storyFiles(root), []);
assert.strictEqual(read(root, 'epics/auth-flow.md'), before);
assert.strictEqual(run(root, ['apply', '--input', input]).code, 0);
}
const status = run(root, ['status']);
assert.strictEqual(status.code, 0, status.err);
assert.strictEqual(status.out.recoveryPending, false);
assert.deepStrictEqual(status.out.drift, []);
assert.ok(read(root, 'sprints/sprint-1.md').includes(`| ${id} | Assigned | 3 | todo |`));
}
}],
['a competing create at publication is preserved through failure and recovery', () => {
const root = newRepo();
const { ids, input } = createStories(root, ['Ours']);
const target = path.join(root, '.stories', `${ids[0]}.md`);
const preload = path.join(root, 'publish-race.js');
fs.writeFileSync(preload, `const fs = require('fs'); let planted = false;
const plant = (target) => { if (!planted && target === ${JSON.stringify(target)}) {
planted = true; fs.writeFileSync(target, 'COMPETING OWNER', {flag:'wx'}); } };
for (const name of ['renameSync', 'linkSync']) { const original = fs[name]; fs[name] = function (source, target, ...a) {
if (String(source).split(require('path').sep).join('/').includes('/.txn/new-')) plant(target); return original.call(fs, source, target, ...a); }; }`);
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'apply', '--input', input, '--root', root], { encoding: 'utf8' });
assert.notStrictEqual(result.status, 0, 'publication must not overwrite another owner');
assert.strictEqual(fs.readFileSync(target, 'utf8'), 'COMPETING OWNER');
assert.strictEqual(run(root, ['recover']).code, 0);
assert.strictEqual(fs.readFileSync(target, 'utf8'), 'COMPETING OWNER', 'rollback must not delete another owner');
}],
['staged writes refuse symlinks planted just before open', () => {
const root = newRepo();
const { input } = createStories(root, ['Ours']);
const victim = path.join(root, 'victim.txt'); fs.writeFileSync(victim, 'ORIGINAL');
const preload = path.join(root, 'stage-race.js');
fs.writeFileSync(preload, `const fs = require('fs'); let planted = false;
const plant = (p) => { if (!planted && String(p).split(require('path').sep).join('/').endsWith('/.txn/new-0')) {
planted = true; fs.symlinkSync(${JSON.stringify(victim)}, p); } };
const open = fs.openSync; fs.openSync = function (p, ...a) { plant(p); return open.call(fs, p, ...a); };
const write = fs.writeFileSync; fs.writeFileSync = function (p, ...a) { plant(p); return write.call(fs, p, ...a); };`);
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'apply', '--input', input, '--root', root], { encoding: 'utf8' });
assert.notStrictEqual(result.status, 0);
assert.strictEqual(fs.readFileSync(victim, 'utf8'), 'ORIGINAL');
assert.strictEqual(run(root, ['recover']).code, 0);
assert.deepStrictEqual(storyFiles(root), []);
}],
['concurrent attempts to publish one allocated story have one owner', async () => {
const root = newRepo();
const { ids, input } = createStories(root, ['Single owner']);
const results = await Promise.all([runAsync(root, ['apply', '--input', input]), runAsync(root, ['apply', '--input', input])]);
assert.deepStrictEqual(results.map((r) => r.code).sort(), [0, 4]);
assert.deepStrictEqual(storyFiles(root), [`${ids[0]}.md`]);
assert.ok(read(root, `${ids[0]}.md`).includes('Single owner'));
assert.deepStrictEqual(run(root, ['status']).out.drift, []);
}],
['release leaves a replacement lock owned by another writer intact', () => {
const root = newRepo();
const { ids, input } = createStories(root, ['Ours']);
const lock = path.join(root, '.stories', '.lock');
const preload = path.join(root, 'lock-owner-race.js');
fs.writeFileSync(preload, `const fs = require('fs'); const original = fs.rmSync; let replaced = false;
fs.rmSync = function (p, ...args) { const existed = fs.existsSync(p); const result = original.call(fs, p, ...args);
if (existed && !replaced && String(p).split(require('path').sep).join('/').endsWith('/.stories/.txn')) { replaced = true; fs.unlinkSync(${JSON.stringify(lock)});
try { fs.writeFileSync(${JSON.stringify(lock)}, JSON.stringify({pid:${process.pid},host:require('os').hostname(),at:'replacement'}), {flag:'wx'}); }
catch (error) { process.stderr.write(${JSON.stringify(LOCK_DENIAL_MARKER)} + JSON.stringify({code:error.code,syscall:error.syscall,path:error.path}) + '\\n'); throw error; } }
return result; };`);
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'apply', '--input', input, '--root', root], { encoding: 'utf8' });
// Some Windows Node versions prevent re-creating an open delete-pending
// lock. The injected foreign writer is rejected before it can take ownership.
if (windowsLockCreateDenied(result, lock)) return assertRefusedLockWriter(root, ids, input);
assert.strictEqual(result.status, 0, result.stderr);
const replacement = fs.readFileSync(lock, 'utf8');
assert.deepStrictEqual(JSON.parse(replacement), { pid: process.pid, host: os.hostname(), at: 'replacement' });
assert.strictEqual(run(root, ['status']).code, 0);
assert.strictEqual(fs.readFileSync(lock, 'utf8'), replacement);
}],
['lock release survives inode reuse after an early descriptor close', () => {
const root = newRepo();
const { ids, input } = createStories(root, ['Inode owner']);
const lock = path.join(root, '.stories', '.lock');
const preload = path.join(root, 'lock-inode-reuse.js');
fs.writeFileSync(preload, `const fs = require('fs'); let fd, oldStat, closed = false, replaced = false;
const open = fs.openSync; fs.openSync = function (p, flags, ...a) {
const result = open.call(fs, p, flags, ...a);
if (String(p) === ${JSON.stringify(lock)} && (flags & fs.constants.O_EXCL)) {
fd = result; oldStat = fs.fstatSync(result); closed = false;
} return result; };
const close = fs.closeSync; fs.closeSync = function (value) {
if (value === fd) closed = true; return close.call(fs, value); };
const remove = fs.rmSync; fs.rmSync = function (p, ...a) {
const existed = fs.existsSync(p); const result = remove.call(fs, p, ...a);
if (existed && !replaced && String(p).split(require('path').sep).join('/').endsWith('/.stories/.txn')) {
replaced = true; fs.unlinkSync(${JSON.stringify(lock)});
try { fs.writeFileSync(${JSON.stringify(lock)}, JSON.stringify({ pid: ${process.pid}, host: require('os').hostname(), at: 'replacement' }), { flag: 'wx' }); }
catch (error) { process.stderr.write(${JSON.stringify(LOCK_DENIAL_MARKER)} + JSON.stringify({code:error.code,syscall:error.syscall,path:error.path}) + '\\n'); throw error; }
} return result; };
const stat = fs.lstatSync; fs.lstatSync = function (p, ...a) {
const result = stat.call(fs, p, ...a);
return replaced && closed && String(p) === ${JSON.stringify(lock)} ? oldStat : result;
};`);
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'apply', '--input', input, '--root', root], { encoding: 'utf8' });
// Some Windows Node versions prevent re-creating an open delete-pending
// lock. The injected foreign writer is rejected before it can take ownership.
if (windowsLockCreateDenied(result, lock)) return assertRefusedLockWriter(root, ids, input);
assert.strictEqual(result.status, 0, result.stderr);
const replacement = fs.readFileSync(lock, 'utf8');
assert.deepStrictEqual(JSON.parse(replacement), { pid: process.pid, host: os.hostname(), at: 'replacement' });
assert.strictEqual(run(root, ['status']).code, 0);
assert.strictEqual(fs.readFileSync(lock, 'utf8'), replacement);
}],
['delete-pending cleanup may mask a proven foreign-open denial without losing the owner close', () => {
const root = newRepo();
const { ids, input } = createStories(root, ['Committed owner']);
const lock = path.join(root, '.stories', '.lock');
const proof = path.join(root, 'owner-close.json');
const preload = path.join(root, 'delete-pending.js');
fs.writeFileSync(preload, `const fs = require('fs'); let owner, pending = false, ownerClosed = false;
const lock = ${JSON.stringify(lock)};
const error = syscall => Object.assign(new Error("EPERM: operation not permitted, " + syscall + " '" + lock + "'"), {code:'EPERM',syscall,path:lock});
const open = fs.openSync; fs.openSync = function (p, flags, ...args) {
const fd = open.call(fs, p, flags, ...args); if (p === lock && (flags & fs.constants.O_EXCL)) owner = fd; return fd; };
const remove = fs.rmSync; fs.rmSync = function (p, ...args) {
const existed = fs.existsSync(p); const result = remove.call(fs, p, ...args);
if (existed && !pending && String(p).split(require('path').sep).join('/').endsWith('/.stories/.txn')) {
fs.unlinkSync(lock); pending = true; const denied = error('open');
process.stderr.write(${JSON.stringify(LOCK_DENIAL_MARKER)} + JSON.stringify(denied) + '\\n'); throw denied;
} return result; };
const stat = fs.lstatSync; fs.lstatSync = function (p, ...args) { if (pending && p === lock) throw error('lstat'); return stat.call(fs, p, ...args); };
const close = fs.closeSync; fs.closeSync = function (fd) {
const result = close.call(fs, fd); if (pending && fd === owner && !ownerClosed) {
ownerClosed = true;
let code; try { fs.fstatSync(fd); } catch (closed) { code = closed.code; }
fs.writeFileSync(${JSON.stringify(proof)}, JSON.stringify({closed:code}));
} return result; };`);
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'apply', '--input', input, '--root', root], { encoding: 'utf8' });
assert.strictEqual(result.status, 1, result.stderr);
assert.match(result.stderr, /EPERM: operation not permitted, lstat/);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(proof, 'utf8')), { closed: 'EBADF' });
assert.strictEqual(windowsLockCreateDenied(result, lock, 'win32'), true, result.stderr);
assert.strictEqual(windowsLockCreateDenied(result, lock, 'linux'), false);
assert.strictEqual(windowsLockCreateDenied(result, `${lock}.break`, 'win32'), false);
const unmarked = { ...result, stderr: result.stderr.split('\n').filter(line => !line.startsWith(LOCK_DENIAL_MARKER)).join('\n') };
assert.strictEqual(windowsLockCreateDenied(unmarked, lock, 'win32'), false);
assertRefusedLockWriter(root, ids, input);
}],
['recovery can itself be interrupted and retried without losing rollback ownership', () => {
const root = newRepo();
const { ids, input } = createStories(root, ['Before']);
assert.strictEqual(run(root, ['apply', '--input', input]).code, 0);
const before = read(root, `${ids[0]}.md`);
const change = writeJson(root, 'update.json', { update: { [`${ids[0]}.md`]: story(ids[0], 'After', { status: 'in-progress' }) } });
assert.strictEqual(run(root, ['apply', '--input', change], { STORY_LEDGER_CRASH_AFTER: '1' }).code, 70);
const preload = path.join(root, 'recover-crash.js');
fs.writeFileSync(preload, `const fs = require('fs'); const original = fs.renameSync;
fs.renameSync = function (source, ...args) { const result = original.call(fs, source, ...args);
if (String(source).split(require('path').sep).join('/').endsWith('/.txn/restore.tmp')) process.exit(70); return result; };`);
const recovery = spawnSync(process.execPath, ['-r', preload, LEDGER, 'recover', '--root', root], { encoding: 'utf8' });
assert.strictEqual(recovery.status, 70);
const retry = run(root, ['recover']);
assert.strictEqual(retry.code, 0, retry.err);
assert.strictEqual(read(root, `${ids[0]}.md`), before);
assert.deepStrictEqual(run(root, ['status']).out.drift, []);
}],
['Windows sharing denial during lock publication is retried within the deadline', () => {
const root = newRepo();
const preload = path.join(root, 'sharing-retry.js');
fs.writeFileSync(preload, `require('path'); Object.defineProperty(process, 'platform', { value: 'win32' });
const fs = require('fs'); const original = fs.openSync; let attempts = 0;
fs.openSync = function (p, flags, ...args) {
if (String(p).endsWith('.lock') && (flags & fs.constants.O_EXCL) && attempts++ < 2) {
throw Object.assign(new Error('simulated delete-pending sharing denial'), { code: 'EPERM' });
} return original.call(fs, p, flags, ...args); };`);
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'allocate', '--root', root,
'--epic', 'auth-flow', '--wait-ms', '1000'], { encoding: 'utf8', timeout: 3000 });
assert.strictEqual(result.status, 0, result.stderr);
assert.deepStrictEqual(JSON.parse(result.stdout).ids, ['auth-flow-1']);
}],
['permanent Windows sharing denial expires without creating an allocation', () => {
const root = newRepo();
const preload = path.join(root, 'sharing-denied.js');
fs.writeFileSync(preload, `require('path'); Object.defineProperty(process, 'platform', { value: 'win32' });
const fs = require('fs'); const original = fs.openSync;
fs.openSync = function (p, flags, ...args) {
if (String(p).endsWith('.lock') && (flags & fs.constants.O_EXCL)) {
throw Object.assign(new Error('simulated permanent sharing denial'), { code: 'EACCES' });
} return original.call(fs, p, flags, ...args); };`);
const result = spawnSync(process.execPath, ['-r', preload, LEDGER, 'allocate', '--root', root,
'--epic', 'auth-flow', '--wait-ms', '40'], { encoding: 'utf8', timeout: 2000 });
assert.strictEqual(result.status, 3, result.stderr || result.error?.message);
assert.match(result.stderr, /Windows file access EACCES/);
assert.ok(!fs.existsSync(path.join(root, '.git', 'story-lifecycle', 'ids', 'auth-flow-1')));
}],
['ledger paths are confined to .stories/ file shapes', () => {
const dir = path.join(os.tmpdir(), '.stories');
for (const bad of ['../x-1.md', 'epics/../../x.md', 'Auth-1.md', 'a/b-1.md', 'epics/a.txt', 'sprints/sprint-0.md', '/abs-1.md']) {
assert.throws(() => resolveLedgerPath(dir, bad), /invalid ledger path/, bad);
}
assert.ok(resolveLedgerPath(dir, 'epics\\auth-flow.md').endsWith(path.join('epics', 'auth-flow.md')));
}],
];
(async () => {
console.log('\n=== Testing story-lifecycle ledger behaviour ===\n');
let passed = 0;
let failed = 0;
for (const [name, fn] of tests) {
try {
await fn();
console.log(` ✓ ${name}`);
passed++;
} catch (error) {
if (error instanceof Skip) {
console.log(` - ${name} (skipped: ${error.message})`);
continue;
}
console.log(` ✗ ${name}`);
console.log(` Error: ${error.message}`);
failed++;
} finally {
for (const dir of ownedTemps) fs.rmSync(dir, { recursive: true, force: true });
ownedTemps.clear();
}
}
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
})();