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