From a73e04cc6df07c6bc9e38a9abe3b02640aaf4280 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:42:05 -0400 Subject: [PATCH] fix: recover failed ledger batches and preserve project state --- manifests/context-packs/skill-triggers@1.json | 2 +- scripts/hooks/ecc-statusline.js | 2 +- scripts/lib/statusline-render.js | 51 ++++++-- skills/story-lifecycle/scripts/ledger.js | 120 ++++++++++++------ tests/docs/story-lifecycle-errors.test.js | 108 ++++++++++++++++ .../story-lifecycle-table-projection.test.js | 22 ++++ .../ecc-statusline-project-plugins.test.js | 55 ++++++++ 7 files changed, 304 insertions(+), 56 deletions(-) create mode 100644 tests/docs/story-lifecycle-errors.test.js create mode 100644 tests/hooks/ecc-statusline-project-plugins.test.js diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index a24291bb7..086cf3d1c 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -10,7 +10,7 @@ "id": "hand-seeded", "source": "manual-curation-pending-regeneration" }, - "registryDigest": "988dbc0f5771edc78c56a3fa2206f5b63b7b5fcb22713f08349d3c469e9f26cf", + "registryDigest": "8f496cbd3561621f4727117c3358eddf3e350bcc545e0defd3dd529c14c6990f", "schemaVersion": 1, "triggers": { "skill:agent-runtime-gateway-smoke-test": [ diff --git a/scripts/hooks/ecc-statusline.js b/scripts/hooks/ecc-statusline.js index f57b348d2..5ccb959e8 100644 --- a/scripts/hooks/ecc-statusline.js +++ b/scripts/hooks/ecc-statusline.js @@ -147,7 +147,7 @@ function buildUsageLines(data, bridge, task) { task, }); - const plugins = render.readInstalledPlugins(); + const plugins = render.readInstalledPlugins(undefined, data.workspace?.current_dir || process.cwd()); const eccLine = render.buildEccLine({ eccVersion: getEccVersion(plugins), hooks: render.getHooksSummary(), diff --git a/scripts/lib/statusline-render.js b/scripts/lib/statusline-render.js index 74cdd11b8..6d145859d 100644 --- a/scripts/lib/statusline-render.js +++ b/scripts/lib/statusline-render.js @@ -217,30 +217,55 @@ function buildEccLine(info) { return parts.join(SEP); } +function pluginProjectRoot(workspace) { + const cwd = path.resolve(workspace || process.cwd()); + for (let candidate = cwd; ; candidate = path.dirname(candidate)) { + if (fs.existsSync(path.join(candidate, '.git'))) return candidate; + if (candidate === path.dirname(candidate)) return cwd; + } +} + +function pluginEnablement(files) { + return files.reduce((enabled, file) => { + try { + const value = JSON.parse(fs.readFileSync(file, 'utf8')).enabledPlugins; + return value && typeof value === 'object' && !Array.isArray(value) ? { ...enabled, ...value } : enabled; + } catch { return enabled; /* Missing or malformed optional display settings. */ } + }, {}); +} + /** - * Enabled plugins with versions, ecc first. - * Reads installed_plugins.json (v2) and settings.json enabledPlugins. - * @param {string} [claudeDir] - defaults to CLAUDE_CONFIG_DIR or ~/.claude + * User and applicable project/local plugin metadata for the current Git root. + * Project-local enablement overrides shared-project and selected user settings. + * This display helper does not reproduce managed or per-session host policy. + * @param {string} [claudeDir] selected profile root + * @param {string} [workspace] current working directory * @returns {Array<{name: string, version: string}>} */ -function readInstalledPlugins(claudeDir) { +function readInstalledPlugins(claudeDir, workspace) { try { const dir = claudeDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); const installed = JSON.parse( fs.readFileSync(path.join(dir, 'plugins', 'installed_plugins.json'), 'utf8') ); - let enabled = null; - try { - const settings = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8')); - if (settings.enabledPlugins && typeof settings.enabledPlugins === 'object') { - enabled = settings.enabledPlugins; - } - } catch { /* no settings \u2014 show all installed */ } + const project = pluginProjectRoot(workspace); + const normalized = value => process.platform === 'win32' ? path.resolve(value).toLowerCase() : path.resolve(value); + const enabled = pluginEnablement([path.join(dir, 'settings.json'), + path.join(project, '.claude', 'settings.json'), path.join(project, '.claude', 'settings.local.json')]); + const ranks = { user: 0, project: 1, local: 2 }; + const applicable = entry => entry && typeof entry === 'object' && + ((!entry.scope || entry.scope === 'user') || + (['project', 'local'].includes(entry.scope) && typeof entry.projectPath === 'string' && + normalized(entry.projectPath) === normalized(project))); const plugins = []; for (const [id, entries] of Object.entries(installed?.plugins || {})) { - if (enabled && enabled[id] === false) continue; - const entry = Array.isArray(entries) ? entries[0] : entries; + if (enabled[id] === false) continue; + const entry = (Array.isArray(entries) ? entries : [entries]).filter(applicable).sort((a, b) => + (ranks[b.scope || 'user'] - ranks[a.scope || 'user']) || + String(b.lastUpdated || b.installedAt || '').localeCompare(String(a.lastUpdated || a.installedAt || '')) || + String(b.version || '').localeCompare(String(a.version || '')))[0]; + if (!entry) continue; plugins.push({ name: id.split('@')[0], version: entry?.version || '' }); } plugins.sort((a, b) => (a.name === 'ecc' ? -1 : b.name === 'ecc' ? 1 : a.name.localeCompare(b.name))); diff --git a/skills/story-lifecycle/scripts/ledger.js b/skills/story-lifecycle/scripts/ledger.js index 1b240efed..b29035833 100644 --- a/skills/story-lifecycle/scripts/ledger.js +++ b/skills/story-lifecycle/scripts/ledger.js @@ -261,6 +261,9 @@ function recover(dir) { } rolledBack++; } + // Restoration is complete. Cleanup failure must never repeat a completed + // rollback after another owner has changed a restored file. + fs.rmSync(journalPath); } fs.rmSync(txn, { recursive: true, force: true }); return rolledBack; @@ -293,17 +296,22 @@ function replaceTable(md, rel, header, rows) { const skipBlanks = (at) => at + (md.slice(at).match(/^(?:[ \t]*\r?\n)+/) || [''])[0].length; const hasDerivedHeader = (at) => md.slice(at).split(/\r?\n/, 2).join('\n').replace(/[ \t]/g, '') === header.replace(/[ \t]/g, ''); const first = skipBlanks(afterMarker); - const start = md[first] === '|' ? first : afterMarker; + const start = hasDerivedHeader(first) ? first : afterMarker; let end = start; - while (md[end] === '|') { - while (md[end] === '|') { const nl = md.indexOf('\n', end); end = nl < 0 ? md.length : nl + 1; } + while (hasDerivedHeader(end)) { + while (md[end] === '|') { + const nextLine = md.slice(end).split(/\r?\n/, 2)[1] || ''; + if (end !== start && /^\|(?:\s*:?-{3,}:?\s*\|)+\s*$/.test(nextLine) && !hasDerivedHeader(end)) break; + const nl = md.indexOf('\n', end); end = nl < 0 ? md.length : nl + 1; + } // Older projections could insert a second table before a blank-separated // original. Only adjacent copies of this derived header belong to us. const next = skipBlanks(end); if (!hasDerivedHeader(next)) break; end = next; } - return md.slice(0, start) + (newline < 0 ? '\n' : '') + [header, ...rows].join('\n') + '\n' + md.slice(end); + const separator = start === end && md[start] === '|' ? '\n' : ''; + return md.slice(0, start) + (newline < 0 ? '\n' : '') + [header, ...rows].join('\n') + '\n' + separator + md.slice(end); } // Read the ledger with pending changes overlaid; return regenerated epic/sprint files that differ. @@ -377,38 +385,7 @@ function allocate(root, dir, epic, count) { return { ids }; } -// changes: { create: { rel: content }, update: { rel: content } } -function apply(root, dir, changes) { - const keys = new Set(); - const normalize = (obj = {}) => Object.fromEntries(Object.entries(obj).map(([rel, content]) => { - const key = rel.replace(/\\/g, '/'); - if (keys.has(key)) throw new LedgerError(`duplicate path in batch: ${key}`); - keys.add(key); - return [key, content]; - })); - const create = normalize(changes.create); - const update = normalize(changes.update); - const reg = registryDir(root, dir); - for (const rel of Object.keys(create)) { - if (fs.existsSync(resolveLedgerPath(dir, rel))) throw new LedgerError(`refusing to overwrite existing ${rel}`, 4); - const m = rel.match(STORY_FILE); - if (m && !fs.existsSync(path.join(reg, `${m[1]}-${m[2]}`))) throw new LedgerError(`${rel}: story ID was not allocated`); - if (m && parseStory(rel, create[rel]).status !== 'todo') throw new LedgerError(`${rel}: new stories start as todo`); - } - for (const rel of Object.keys(update)) { - const abs = resolveLedgerPath(dir, rel); - if (!fs.existsSync(abs)) throw new LedgerError(`cannot update missing ${rel}`); - if (!STORY_FILE.test(rel)) continue; - const from = parseStory(rel, readRegular(abs, 'utf8')).status; - const to = parseStory(rel, update[rel]).status; - if (to !== from && STATUSES.indexOf(to) !== STATUSES.indexOf(from) + 1) { - throw new LedgerError(`${rel}: status ${from} -> ${to} is not allowed; status moves forward one step at a time`); - } - } - const pending = { ...create, ...update }; - const all = { ...pending, ...project(dir, pending).derived }; - const txn = path.join(dir, '.txn'); - fs.mkdirSync(txn, { mode: 0o700 }); +function publishTransaction(dir, txn, all, create) { const entries = Object.keys(all).map((rel, i) => { const existed = fs.existsSync(resolveLedgerPath(dir, rel)); if (rel in create && existed) throw new LedgerError(`refusing to overwrite existing ${rel}`, 4); @@ -440,6 +417,70 @@ function apply(root, dir, changes) { return { ok: true, written: entries.map((e) => e.path) }; } +// changes: { create: { rel: content }, update: { rel: content } } +function apply(root, dir, changes) { + const keys = new Set(); + const normalize = (obj = {}) => Object.fromEntries(Object.entries(obj).map(([rel, content]) => { + const key = rel.replace(/\\/g, '/'); + if (keys.has(key)) throw new LedgerError(`duplicate path in batch: ${key}`); + keys.add(key); + return [key, content]; + })); + const create = normalize(changes.create); + const update = normalize(changes.update); + const reg = registryDir(root, dir); + for (const rel of Object.keys(create)) { + if (fs.existsSync(resolveLedgerPath(dir, rel))) throw new LedgerError(`refusing to overwrite existing ${rel}`, 4); + const m = rel.match(STORY_FILE); + if (m && !fs.existsSync(path.join(reg, `${m[1]}-${m[2]}`))) throw new LedgerError(`${rel}: story ID was not allocated`); + if (m && parseStory(rel, create[rel]).status !== 'todo') throw new LedgerError(`${rel}: new stories start as todo`); + } + for (const rel of Object.keys(update)) { + const abs = resolveLedgerPath(dir, rel); + if (!fs.existsSync(abs)) throw new LedgerError(`cannot update missing ${rel}`); + if (!STORY_FILE.test(rel)) continue; + const from = parseStory(rel, readRegular(abs, 'utf8')).status; + const to = parseStory(rel, update[rel]).status; + if (to !== from && STATUSES.indexOf(to) !== STATUSES.indexOf(from) + 1) { + throw new LedgerError(`${rel}: status ${from} -> ${to} is not allowed; status moves forward one step at a time`); + } + } + const pending = { ...create, ...update }; + const all = { ...pending, ...project(dir, pending).derived }; + return withTransaction(dir, txn => publishTransaction(dir, txn, all, create)); +} + +function describeError(error) { return `${error.code ? `${error.code}: ` : ''}${error.message}`; } + +function withOwnerLock(dir, waitMs, operation) { + const release = acquireLock(dir, waitMs); + let result, primaryError; + try { result = operation(); } catch (error) { primaryError = error; } + try { release(); } catch (releaseError) { + if (!primaryError) throw releaseError; + throw new LedgerError(`${describeError(primaryError)}; lock release failed: ${describeError(releaseError)}`, primaryError.exitCode || 1); + } + if (primaryError) throw primaryError; + return result; +} + +function withTransaction(dir, operation) { + const txn = path.join(dir, '.txn'); + fs.mkdirSync(txn, { mode: 0o700 }); + try { + return operation(txn); + } catch (error) { + // Caller retains the live owner lock throughout restoration. A removed + // journal marks a committed batch, so recover then only cleans debris. + try { recover(dir); } catch (recoveryError) { + const state = fs.existsSync(path.join(dir, '.txn', 'journal.json')) ? 'recovery pending' : 'transaction cleanup pending'; + throw new LedgerError(`${describeError(error)}; ${state}: ${describeError(recoveryError)}; ` + + 'resolve the cause, then run recover to retry without overwriting foreign-owned files.', error.exitCode || 1); + } + throw error; + } +} + function main() { const opts = parseArgs(process.argv.slice(2)); const cmd = opts._[0]; @@ -462,17 +503,14 @@ function main() { throw new LedgerError('--wait-ms must be an integer in 0..60000'); } requireHardlinks(dir); - const release = acquireLock(dir, waitMs); - try { + return withOwnerLock(dir, waitMs, () => { const rolledBack = recover(dir); if (cmd === 'recover') return { ok: true, rolledBack }; if (cmd === 'allocate') return allocate(root, dir, opts.epic, Number(opts.count || 1)); if (cmd === 'reconcile') return apply(root, dir, {}); if (cmd === 'apply') return apply(root, dir, JSON.parse(fs.readFileSync(opts.input, 'utf8'))); throw new LedgerError(`unknown command: ${cmd}`); - } finally { - release(); - } + }); } if (require.main === module) { diff --git a/tests/docs/story-lifecycle-errors.test.js b/tests/docs/story-lifecycle-errors.test.js new file mode 100644 index 000000000..2107ed186 --- /dev/null +++ b/tests/docs/story-lifecycle-errors.test.js @@ -0,0 +1,108 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync, spawnSync } = require('node:child_process'); +const LEDGER = path.join(__dirname, '../../skills/story-lifecycle/scripts/ledger.js'); +const temps = new Set(); +const marker = ''; +const epic = `# Epic: Auth\n\n${marker}\n| ID | Title | Status |\n| --- | --- | --- |\n`; +const story = (id, status = 'todo') => `# Story: ${id}\n\n**ID:** ${id}\n**Epic:** auth\n**Sprint:** unassigned\n**Status:** ${status}\n**Points:** 3\n`; +function run(root, args, preload) { + return spawnSync(process.execPath, [...(preload ? ['-r', preload] : []), LEDGER, ...args, '--root', root], + { encoding: 'utf8', timeout: 15000 }); +} +function ok(result) { assert.equal(result.status, 0, result.stderr); return JSON.parse(result.stdout); } +function input(root, changes) { const file = path.join(root, 'input.json'); fs.writeFileSync(file, JSON.stringify(changes)); return file; } +function snapshot(dir) { + return Object.fromEntries(fs.readdirSync(dir).sort().map(name => { + const file = path.join(dir, name);return [name, fs.lstatSync(file).isDirectory() ? snapshot(file) : fs.readFileSync(file).toString('base64')]; + })); +} +function fixture(update = false) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'story-ordinary-error-'));temps.add(root); + execFileSync('git', ['init', '-q', root]);ok(run(root, ['init'])); + fs.writeFileSync(path.join(root, '.stories/epics/auth.md'), epic); + const ids = ok(run(root, ['allocate', '--epic', 'auth', '--count', '2'])).ids; + if (update) ok(run(root, ['apply', '--input', input(root, { create: Object.fromEntries(ids.map(id => [`${id}.md`, story(id)])) })])); + const before = snapshot(path.join(root, '.stories')); + const changes = { [update ? 'update' : 'create']: Object.fromEntries(ids.map(id => [`${id}.md`, story(id, update ? 'in-progress' : 'todo')])) }; + return { root, ids, before, registryBefore: snapshot(path.join(root, '.git/story-lifecycle')), file: input(root, changes) }; +} +function fault(root, { _update = false, recoveryFailure = false, cleanupFailure = false, journalRemovalFailure = false, releaseFailure = false } = {}) { + const file = path.join(root, 'bounded-fault.cjs'); + fs.writeFileSync(file, `const fs=require('node:fs');const path=require('node:path'); +const dir=${JSON.stringify(path.join(root, '.stories'))};const trace=${JSON.stringify(path.join(root, 'trace.json'))}; +const state={published:0,recoveryLocks:[]};const save=()=>fs.writeFileSync(trace,JSON.stringify(state)); +const failure=(text,code)=>Object.assign(new Error(text),{code}); +for(const method of ['linkSync','renameSync']){const original=fs[method];fs[method]=function(source,target,...args){ + const publication=path.dirname(target)===dir && /^auth-[12]\\.md$/.test(path.basename(target)) && /^new-\\d+$/.test(path.basename(source)); + if(publication && state.published===1 && ${!cleanupFailure}){state.primary=true;state.firstBytes=fs.readFileSync(path.join(dir,'auth-1.md')).toString('base64');state.lock=JSON.parse(fs.readFileSync(path.join(dir,'.lock'),'utf8'));save();throw failure('PRIMARY ordinary second publication','EIO');} + if(path.basename(source).startsWith('backup-')){const holder=JSON.parse(fs.readFileSync(path.join(dir,'.lock'),'utf8'));state.recoveryLocks.push(holder.pid===process.pid);save(); + if(${recoveryFailure} && path.basename(target)==='restore.tmp' && path.basename(source)==='backup-1')throw failure('RECOVERY backup restore refused','EACCES');} + const result=original.call(fs,source,target,...args);if(publication){state.published++;save();}return result; +};} +${journalRemovalFailure ? "const rmJournal=fs.rmSync;fs.rmSync=function(target,...args){if(target===path.join(dir,'.txn','journal.json') && state.primary && state.recoveryLocks.length){throw failure('RECOVERY journal removal refused','EACCES');}return rmJournal.call(fs,target,...args);};" : ''} +${releaseFailure ? "const lstat=fs.lstatSync;fs.lstatSync=function(target,...args){if(target===path.join(dir,'.lock') && state.primary && state.recoveryLocks.length && !fs.existsSync(path.join(dir,'.txn'))){throw failure('RELEASE ownership read refused','EACCES');}return lstat.call(fs,target,...args);};" : ''} +${cleanupFailure ? "const rm=fs.rmSync;fs.rmSync=function(target,...args){if(target===path.join(dir,'.txn') && state.published===2 && fs.existsSync(target) && !fs.existsSync(path.join(dir,'.txn','journal.json'))){state.cleanup=true;save();throw failure('CLEANUP committed directory refused','EACCES');}return rm.call(fs,target,...args);};" : ''} +`); + return file; +} +function restored(f) { + assert.deepEqual(snapshot(path.join(f.root, '.stories')), f.before, 'every prior byte and new-file absence must match'); + assert.deepEqual(snapshot(path.join(f.root, '.git/story-lifecycle')), f.registryBefore); + const state = ok(run(f.root, ['status']));assert.equal(state.recoveryPending, false);assert.equal(state.projectionAvailable, undefined); + assert.deepEqual(state.drift, []); +} +const tests = [ + ['journal removal failure leaves restored bytes and a retryable journal', () => { + const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { journalRemovalFailure: true })); + assert.equal(result.status, 1);assert.match(result.stderr, /PRIMARY ordinary second publication/);assert.match(result.stderr, /RECOVERY journal removal refused/);assert.match(result.stderr, /recovery pending/i); + const current = snapshot(path.join(f.root, '.stories'));const { '.txn': transaction, ...data } = current;assert.ok(transaction['journal.json']);assert.deepEqual(data, f.before); + assert.equal(ok(run(f.root, ['status'])).recoveryPending, true);ok(run(f.root, ['recover']));restored(f); + }], + ['release failure preserves the primary error after successful rollback and permits stale-lock recovery', () => { + const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { releaseFailure: true })); + assert.equal(result.status, 1);assert.match(result.stderr, /PRIMARY ordinary second publication/);assert.match(result.stderr, /RELEASE ownership read refused/); + const { '.lock': lock, ...data } = snapshot(path.join(f.root, '.stories'));assert.ok(lock);assert.deepEqual(data, f.before); + ok(run(f.root, ['recover']));restored(f); + }], + ['failure after the committed journal marker does not undo a completed update', () => { + const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { update: true, cleanupFailure: true })); + assert.equal(result.status, 1);assert.match(result.stderr, /CLEANUP committed directory refused/); + for (const id of f.ids) assert.equal(fs.readFileSync(path.join(f.root, '.stories', `${id}.md`), 'utf8'), story(id, 'in-progress')); + assert.ok(!fs.existsSync(path.join(f.root, '.stories/.txn/journal.json'))); + assert.ok(!fs.existsSync(path.join(f.root, '.stories/.lock'))); + const trace = JSON.parse(fs.readFileSync(path.join(f.root, 'trace.json')));assert.equal(trace.published, 2);assert.deepEqual(trace.recoveryLocks, []); + ok(run(f.root, ['recover']));assert.deepEqual(ok(run(f.root, ['status'])).drift, []); + for (const id of f.ids) assert.equal(fs.readFileSync(path.join(f.root, '.stories', `${id}.md`), 'utf8'), story(id, 'in-progress')); + }], + ['crash70 preserves the pending journal for explicit recovery', () => { + const f = fixture();const preload = path.join(f.root, 'crash.cjs');fs.writeFileSync(preload, "process.env.STORY_LEDGER_CRASH_AFTER='1';"); + assert.equal(run(f.root, ['apply', '--input', f.file], preload).status, 70); + assert.ok(fs.existsSync(path.join(f.root, '.stories/.txn/journal.json'))); + ok(run(f.root, ['recover']));restored(f); + }], + ...[false, true].map(update => [`ordinary second ${update ? 'rename/update' : 'link/create'} failure restores the entire batch before returning`, () => { + const f = fixture(update);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { update })); + assert.equal(result.status, 1);assert.match(result.stderr, /PRIMARY ordinary second publication/); + const trace = JSON.parse(fs.readFileSync(path.join(f.root, 'trace.json')));assert.equal(trace.published, 1);assert.equal(trace.lock.pid > 0, true); + if (update) { assert.notEqual(trace.firstBytes, f.before['auth-1.md']);assert.ok(trace.recoveryLocks.length > 0);assert.ok(trace.recoveryLocks.every(Boolean)); } + else assert.ok(trace.firstBytes); + assert.ok(trace.recoveryLocks.length > 0);assert.ok(trace.recoveryLocks.every(Boolean)); + restored(f); + }]), + ['rollback failure reports both errors, retains journal, and explicit retry restores all bytes', () => { + const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { update: true, recoveryFailure: true })); + assert.equal(result.status, 1);assert.match(result.stderr, /PRIMARY ordinary second publication/);assert.match(result.stderr, /RECOVERY backup restore refused/);assert.match(result.stderr, /recovery pending/i); + assert.ok(fs.existsSync(path.join(f.root, '.stories/.txn/journal.json')));assert.ok(!fs.existsSync(path.join(f.root, '.stories/.lock'))); + const trace = JSON.parse(fs.readFileSync(path.join(f.root, 'trace.json')));assert.ok(trace.recoveryLocks.length > 0);assert.ok(trace.recoveryLocks.every(Boolean)); + assert.equal(ok(run(f.root, ['status'])).recoveryPending, true);ok(run(f.root, ['recover']));restored(f); + }], +]; +let passed = 0;let failed = 0; +for (const [name, test] of tests) { + try { test();console.log(`PASS ${name}`);passed++; } catch (error) { console.log(`FAIL ${name}: ${error.stack}`);failed++; } + finally { for (const dir of temps) fs.rmSync(dir, { recursive: true, force: true });temps.clear(); } +} +console.log(`Passed: ${passed}\nFailed: ${failed}`);process.exitCode = failed ? 1 : 0; diff --git a/tests/docs/story-lifecycle-table-projection.test.js b/tests/docs/story-lifecycle-table-projection.test.js index 13c8a09a4..ee7adef81 100644 --- a/tests/docs/story-lifecycle-table-projection.test.js +++ b/tests/docs/story-lifecycle-table-projection.test.js @@ -57,6 +57,28 @@ function assertStable(root, rel, expected) { } const tests = [ + ...['epic', 'sprint'].flatMap(kind => ['apply', 'reconcile'].flatMap(command => ['', '\n'].map(gap => [ + `${command} preserves a handwritten ${kind} table below an empty marker with gap ${JSON.stringify(gap)}`, () => { + const root = newRepo();const rel = kind === 'epic' ? 'epics/auth-flow.md' : 'sprints/sprint-1.md'; + const prefix = kind === 'epic' ? PREFIX : '# Sprint 1\n\n'; + const handwritten = '| Owner | Note |\n| --- | --- |\n| User | Keep every byte |\n'; + const suffix = `${gap}${handwritten}${SUFFIX}${kind === 'sprint' ? '\n## Total Points: 0\n' : ''}`; + write(root, rel, `${prefix}${MARKER}\n${suffix}`); + if (command === 'apply') { + const [id] = run(root, ['allocate', '--epic', 'auth-flow']).ids; + apply(root, { create: { [`${id}.md`]: story(id) } }); + } else run(root, ['reconcile']); + const actual = read(root, rel);assert.ok(actual.endsWith(suffix), 'handwritten table and prose must remain byte-identical'); + assert.equal(actual.split(handwritten).length, 2);assertStable(root, rel, actual); + }, + ]))), + ['an unrelated table immediately after derived rows survives repeat transitions', () => { + const root = newRepo();const [id] = run(root, ['allocate', '--epic', 'auth-flow']).ids; + const user = '| Owner | Note |\n| --- | --- |\n| User | Keep |\n'; + write(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${EPIC_HEADER}\n${user}${SUFFIX}`); + apply(root, { create: { [`${id}.md`]: story(id) } }); + assertStable(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}\n${EPIC_HEADER}\n| ${id} | Sign in | todo |\n${user}${SUFFIX}`); + }], ['an EOF marker without a newline appends its table after the marker', () => { const root = newRepo(); write(root, 'epics/auth-flow.md', `${PREFIX}${MARKER}`); diff --git a/tests/hooks/ecc-statusline-project-plugins.test.js b/tests/hooks/ecc-statusline-project-plugins.test.js new file mode 100644 index 000000000..a9bfb5d48 --- /dev/null +++ b/tests/hooks/ecc-statusline-project-plugins.test.js @@ -0,0 +1,55 @@ +'use strict'; +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { readInstalledPlugins } = require('../../scripts/lib/statusline-render'); +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-plugin-project-')); +const config = path.join(root, 'profile');const a = path.join(root, 'A');const b = path.join(root, 'B'); +for (const dir of [config, a, b]) fs.mkdirSync(path.join(dir, '.claude'), { recursive: true }); +fs.mkdirSync(path.join(config, 'plugins')); +const records = { 'a-only@fixture': [{ scope: 'local', projectPath: a, version: '9.9.9' }], + 'shared@fixture': [{ scope: 'project', projectPath: a, version: '1.1.0' }, { scope: 'project', projectPath: b, version: '2.2.0' }, { scope: 'user', version: '0.0.1' }], + 'global@fixture': [{ scope: 'user', version: '3.3.3' }] }; +const write = value => fs.writeFileSync(path.join(config, 'plugins/installed_plugins.json'), JSON.stringify({ version: 2, plugins: value })); +const settings = (dir, name, value) => fs.writeFileSync(path.join(dir, '.claude', name), JSON.stringify({ enabledPlugins: value })); +function cli(project) { + const result = spawnSync(process.execPath, [path.resolve(__dirname, '../../scripts/hooks/ecc-statusline.js')], { + input: JSON.stringify({ model: { display_name: 'Fixture' }, workspace: { current_dir: project } }), encoding: 'utf8', timeout: 5000, + env: { ...process.env, CLAUDE_CONFIG_DIR: config, ECC_STATUSLINE_USAGE: '1', ECC_STATUSLINE_COMPACT: '0' }, + });assert.equal(result.status, 0, result.stderr);return result.stdout; +} +const tests = [ + ['actual B statusline excludes A-only plugins and selects B shared version independent of record order', () => { + write(records);const first = cli(b);assert.doesNotMatch(first, /a-only|1\.1\.0/);assert.match(first, /shared.*2\.2\.0/); + write({ ...records, 'shared@fixture': [...records['shared@fixture']].reverse() });assert.equal(cli(b), first); + }], + ['project A gets its applicable local and shared records', () => { write(records);const output = cli(a);assert.match(output, /a-only.*9\.9\.9/);assert.match(output, /shared.*1\.1\.0/); }], + ['project settings disable a user-enabled plugin in the real statusline', () => { + write(records);fs.writeFileSync(path.join(config, 'settings.json'), JSON.stringify({ enabledPlugins: { 'global@fixture': true } })); + settings(b, 'settings.json', { 'global@fixture': false });assert.doesNotMatch(cli(b), /global/); + }], + ['local settings override shared project settings without changing their bytes', () => { + settings(b, 'settings.local.json', { 'global@fixture': true });const file = path.join(b, '.claude/settings.json');const before = fs.readFileSync(file);assert.match(cli(b), /global/);assert.deepEqual(fs.readFileSync(file), before); + }], + ['applicable local version overrides project and user records', () => { + write({ ...records, 'shared@fixture': [...records['shared@fixture'], { scope: 'local', projectPath: b, version: '4.4.4' }] }); + assert.equal(readInstalledPlugins(config, b).find(x => x.name === 'shared').version, '4.4.4'); + }], + ['nested Git workspaces use the project inventory and local settings', () => { + require('node:child_process').execFileSync('git', ['init', '-q', b]); + const nested = path.join(b, 'src');fs.mkdirSync(nested); + write(records);settings(b, 'settings.local.json', { 'global@fixture': false }); + const output = cli(nested);assert.match(output, /shared.*2\.2\.0/);assert.doesNotMatch(output, /global|a-only/); + }], + ['a similarly named sibling path does not inherit B-scoped installations', () => { + const sibling = path.join(root, 'B-other');fs.mkdirSync(sibling);write(records); + const plugins = readInstalledPlugins(config, sibling);assert.equal(plugins.find(x => x.name === 'shared').version, '0.0.1');assert.ok(!plugins.some(x => x.name === 'a-only')); + }], +]; +let passed = 0;let failed = 0; +try { + for (const [name, test] of tests) { try { test();console.log(`PASS ${name}`);passed++; } catch (error) { console.log(`FAIL ${name}: ${error.stack}`);failed++; } } +} finally { fs.rmSync(root, { recursive: true, force: true }); } +console.log(`Passed: ${passed}\nFailed: ${failed}`);process.exitCode = failed ? 1 : 0;