diff --git a/cli.js b/cli.js index 28b5958..0431ff0 100755 --- a/cli.js +++ b/cli.js @@ -25,19 +25,19 @@ * in the exit code. Nothing important is whispered. */ -import process, { argv, stderr } from 'node:process' +import process, { argv, stderr } from 'node:process'; -import { messageWithCauses, stackWithCauses } from 'pony-cause' +import { messageWithCauses, stackWithCauses } from 'pony-cause'; -import { cli } from './lib/main.js' -import { InputError, ResultError } from './lib/utils/errors.js' -import { exitResultError } from './lib/utils/exit.js' +import { cli } from './lib/main.js'; +import { InputError, ResultError } from './lib/utils/errors.js'; +import { exitResultError } from './lib/utils/exit.js'; /** True if the user asked for machine-readable output. */ -const wantsJson = argv.includes('--json') || argv.includes('-j') +const wantsJson = argv.includes('--json') || argv.includes('-j'); try { - await cli(argv.slice(2)) + await cli(argv.slice(2)); } catch (err) { // AN IF/ELSE CHAIN, AND `process.exitCode` — NOT `process.exit()`. Both halves are load-bearing. // @@ -56,7 +56,7 @@ try { // // `exitResultError()` rather than a bare `exit(2)`: the code is reserved, and the name is what // reserves it. See lib/utils/exit.js — it is the only file allowed to write the number. - exitResultError() + exitResultError(); } else if (err instanceof InputError) { if (wantsJson) { // On stdout, WITH a code, so a machine consumer can branch without regexing a human @@ -73,22 +73,22 @@ try { // The same unsound guard crashed main.js on three user-error paths. This was its sibling // site, and hardening only the one that had already blown up would have been fixing the // instance and leaving the class. - const { code } = err - process.stdout.write(JSON.stringify({ error: err.message, ...(code ? { code } : {}) }, undefined, 2) + '\n') + const { code } = err; + process.stdout.write(JSON.stringify({ error: err.message, ...(code ? { code } : {}) }, undefined, 2) + '\n'); } else { - stderr.write(`diarie: ${err.message}\n`) - if (err.body) stderr.write('\n' + err.body + '\n') + stderr.write(`diarie: ${err.message}\n`); + if (err.body) stderr.write('\n' + err.body + '\n'); } - process.exitCode = 1 + process.exitCode = 1; } else { // Genuinely unexpected: a bug, not a user mistake. Show the whole cause chain — // this is the one place a stack trace is the honest answer. if (err instanceof Error) { - stderr.write(`diarie: unexpected error: ${messageWithCauses(err)}\n\n`) - stderr.write(stackWithCauses(err) + '\n') + stderr.write(`diarie: unexpected error: ${messageWithCauses(err)}\n\n`); + stderr.write(stackWithCauses(err) + '\n'); } else { - stderr.write('diarie: unexpected error with no details\n') + stderr.write('diarie: unexpected error with no details\n'); } - process.exitCode = 1 + process.exitCode = 1; } } diff --git a/eslint.config.js b/eslint.config.js index 350adc3..a8fef82 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,24 +1,9 @@ -import { voxpelli } from '@voxpelli/eslint-config' +import { voxpelli } from '@voxpelli/eslint-config'; -// diarie's OWN lint config. It did not have one: ESLint — the config AND the `eslint` / -// `@voxpelli/eslint-config` dependencies — lived only at the repo root, which reached in through -// a `cliFiles: ['diarie/**/*.js']` glob. So a `git subtree split --prefix=diarie` would have -// carried the source and left the linter behind entirely, and the extracted package would have -// shipped with no lint at all while reporting success. Same story for tsc, type-coverage and knip: -// the configs travelled, the SCRIPTS that invoke them did not. `diarie/package.json` had exactly -// one script, `test`. -// -// Now the workspace owns its gates and the root DELEGATES (`npm run check --workspace=diarie`) -// instead of reaching in. That inversion is what makes the split a no-op rather than an amputation. -// -// Options match the root's, deliberately: this package's style must not fork from the repo it -// still lives in. -// - noMocha: node:test, not Mocha. -// - semi:false neostandard's default, and what every file here already is. (On extraction to -// ~/Sites/node this flips to semicolons — a separate, deliberate decision.) -// - cliFiles: diarie is a library-with-a-bin, and `process.exit`/sync I/O are correct in the -// bin and in the migrator. Preserved verbatim from the root's treatment of -// `diarie/**/*.js`, so switching owners changes no rule. +// diarie's own lint config: neostandard via @voxpelli/eslint-config. Semicolons are the neostandard +// default; diarie dropped the `semi: false` it carried as a vp-beads workspace (the `~/Sites/ai` +// no-semicolons convention) when it became a published library, matching the node-* templates it is +// scaffolded from. Per-option rationale is inline below. export default [ { name: 'diarie/generated-declarations', @@ -38,7 +23,6 @@ export default [ }, ...voxpelli({ noMocha: true, - semi: false, // Exactly the root's old `diarie/**/*.js` glob, expressed from inside. Test files are included // deliberately: they were CLI-treated before this move, and switching owners must change no // rule. Dropping them re-armed `n/no-sync` across the suite, which builds its stores with @@ -57,4 +41,4 @@ export default [ 'security/detect-non-literal-regexp': 'off', }, }, -] +]; diff --git a/lib/commands/init.js b/lib/commands/init.js index 97f47ca..281d1f6 100644 --- a/lib/commands/init.js +++ b/lib/commands/init.js @@ -20,17 +20,17 @@ * — its flags are repeatable `key=value` pairs parsed by the migrator itself.) */ -import { existsSync } from 'node:fs' -import { mkdir, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; -import { peowly } from 'peowly' +import { peowly } from 'peowly'; -import { outputFlags, storeFlags } from '../flags/index.js' -import { jsonOut, textOut } from '../format.js' -import { TRACKER_DIR } from '../schema.js' -import { resolveInitRoot } from '../store.js' -import { InputError } from '../utils/errors.js' +import { outputFlags, storeFlags } from '../flags/index.js'; +import { jsonOut, textOut } from '../format.js'; +import { TRACKER_DIR } from '../schema.js'; +import { resolveInitRoot } from '../store.js'; +import { InputError } from '../utils/errors.js'; const flags = /** @satisfies {import('peowly').AnyFlags} */ ({ ...outputFlags, @@ -40,7 +40,7 @@ const flags = /** @satisfies {import('peowly').AnyFlags} */ ({ type: 'string', 'default': 'backlog', }, -}) +}); /** A new store. Not a template — just the shape. */ const STARTER = `# The task store. Plain YAML, committed, reviewed in PRs like any other file. @@ -59,19 +59,19 @@ const STARTER = `# The task store. Plain YAML, committed, reviewed in PRs like a # - how you will know it is done tasks: [] -` +`; /** @type {import('peowly-commands').CliCommand} */ export const init = { description: `Create a ${TRACKER_DIR}/ task store`, async run (argv, meta, { parentName }) { - const input = setupCommand(`${parentName} init`, init.description, argv, meta) - const workResult = await doTheWork(input) + const input = setupCommand(`${parentName} init`, init.description, argv, meta); + const workResult = await doTheWork(input); - formatWorkResult(workResult, input) + formatWorkResult(workResult, input); }, -} +}; /** * @typedef CommandContext @@ -95,9 +95,9 @@ function setupCommand (name, description, args, meta) { name, options: flags, usage: '[--slug ] [--root ]', - }) + }); - return { json: opts.json, root: resolveInitRoot({ root: opts.root }), slug: opts.slug } + return { json: opts.json, root: resolveInitRoot({ root: opts.root }), slug: opts.slug }; } /** @@ -114,7 +114,7 @@ function setupCommand (name, description, args, meta) { * @throws {InputError} when a store is already there */ export async function doTheWork ({ root, slug }) { - const store = join(root, TRACKER_DIR) + const store = join(root, TRACKER_DIR); // Refuse, always. Never merge, never overwrite, never "helpfully" back up. // @@ -128,17 +128,17 @@ export async function doTheWork ({ root, slug }) { `${TRACKER_DIR}/ already exists in ${root} — refusing to touch an existing store`, undefined, 'EEXIST' - ) + ); } - await mkdir(join(store, 'tasks'), { recursive: true }) - await mkdir(join(store, 'decisions'), { recursive: true }) - await writeFile(join(store, 'tasks', `tasks-${slug}.yml`), STARTER, 'utf8') + await mkdir(join(store, 'tasks'), { recursive: true }); + await mkdir(join(store, 'decisions'), { recursive: true }); + await writeFile(join(store, 'tasks', `tasks-${slug}.yml`), STARTER, 'utf8'); return { root, created: [`${TRACKER_DIR}/tasks/tasks-${slug}.yml`, `${TRACKER_DIR}/decisions/`], - } + }; } /** @@ -150,12 +150,12 @@ export async function doTheWork ({ root, slug }) { * @returns {void} */ function formatWorkResult ({ created, root }, { json }) { - if (json) return jsonOut({ root, created }) + if (json) return jsonOut({ root, created }); textOut([ `Created a task store in ${root}:`, ...created.map(f => ` ${f}`), '', 'Commit it. The store IS the repo — that is the whole idea.', - ].join('\n')) + ].join('\n')); } diff --git a/lib/commands/migrate.js b/lib/commands/migrate.js index 9a4ce76..c426cc4 100644 --- a/lib/commands/migrate.js +++ b/lib/commands/migrate.js @@ -13,10 +13,10 @@ * two to drift. */ -import { textOut } from '../format.js' -import { runMigration, USAGE } from '../migrate/bootstrap.js' -import { TRACKER_DIR } from '../schema.js' -import { InputError } from '../utils/errors.js' +import { textOut } from '../format.js'; +import { runMigration, USAGE } from '../migrate/bootstrap.js'; +import { TRACKER_DIR } from '../schema.js'; +import { InputError } from '../utils/errors.js'; /** @type {import('peowly-commands').CliCommand} */ export const migrate = { @@ -29,14 +29,14 @@ export const migrate = { // caller (a script, a CI step, a prose-command checker) that asking for help had // FAILED, and it made `--help` useless as a uniform way to interrogate the CLI. if (argv.includes('--help') || argv.includes('-h')) { - textOut(USAGE) - return + textOut(USAGE); + return; } if (!argv.length) { - throw new InputError('migrate needs a bd export file', USAGE, 'EUSAGE') + throw new InputError('migrate needs a bd export file', USAGE, 'EUSAGE'); } - await runMigration(argv) + await runMigration(argv); }, -} +}; diff --git a/lib/commands/ready.js b/lib/commands/ready.js index a94925c..0018fcb 100644 --- a/lib/commands/ready.js +++ b/lib/commands/ready.js @@ -22,17 +22,17 @@ * but the reference does not unit-test it). */ -import { peowly } from 'peowly' +import { peowly } from 'peowly'; -import { jsonOut, textOut, warn } from '../format.js' -import { loadTasks, strip } from '../store.js' -import { ResultError } from '../utils/errors.js' +import { jsonOut, textOut, warn } from '../format.js'; +import { loadTasks, strip } from '../store.js'; +import { ResultError } from '../utils/errors.js'; import { filterFlags, outputFlags, requireRoot, storeFlags, validateFilterFlags, -} from '../flags/index.js' +} from '../flags/index.js'; import { attentionLine, blockedLine, computeReady, line, -} from '../ready.js' +} from '../ready.js'; const flags = /** @satisfies {import('peowly').AnyFlags} */ ({ ...outputFlags, @@ -48,21 +48,21 @@ const flags = /** @satisfies {import('peowly').AnyFlags} */ ({ type: 'boolean', 'default': false, }, -}) +}); /** @type {import('peowly-commands').CliCommand} */ export const ready = { description: 'List the work that is ready to start', async run (argv, meta, { parentName }) { - const name = `${parentName} ready` + const name = `${parentName} ready`; - const input = setupCommand(name, ready.description, argv, meta) - const workResult = await doTheWork(input) + const input = setupCommand(name, ready.description, argv, meta); + const workResult = await doTheWork(input); - formatWorkResult(workResult, { parentName, ...input }) + formatWorkResult(workResult, { parentName, ...input }); }, -} +}; /** * @typedef CommandContext @@ -90,7 +90,7 @@ function setupCommand (name, description, args, meta) { // `--strict` was missing here for its whole life, and that was not merely a docs gap: it was // dead under `--filter`, so the usage string was accidentally honest. usage: '[--filter ] [--blocked] [--strict] [--json]', - }) + }); // ORDER IS LOAD-BEARING: the store resolves BEFORE the filter is validated. // @@ -102,11 +102,11 @@ function setupCommand (name, description, args, meta) { // `stats` resolves in the opposite order, and that asymmetry is likewise preserved // rather than tidied: both orders predate this refactor, and a refactor that quietly // reorders errors is not a refactor. Change either one deliberately, or not at all. - const root = requireRoot(opts.root) + const root = requireRoot(opts.root); // The status guard is a pure, exported validator living beside its own flag // declaration (`lib/flags/filter.js`), so the two cannot drift apart. - const { filter } = validateFilterFlags(opts) + const { filter } = validateFilterFlags(opts); return { blocked: opts.blocked, @@ -114,7 +114,7 @@ function setupCommand (name, description, args, meta) { json: opts.json, root, strict: opts.strict, - } + }; } /** @@ -152,8 +152,8 @@ function setupCommand (name, description, args, meta) { */ export async function doTheWork ({ filter, root }) { /** @type {string[]} */ - const warnings = [] - const tasks = await loadTasks(root, m => warnings.push(m)) + const warnings = []; + const tasks = await loadTasks(root, m => warnings.push(m)); // THE SOUNDNESS VERDICT IS COMPUTED ONCE, FOR BOTH SHAPES — and that is the whole point of // computing it here rather than in the formatter. @@ -168,7 +168,7 @@ export async function doTheWork ({ filter, root }) { // So the partition is computed even when the caller asked for a filter. It is cheap (the rows are // already in memory) and it is the only way both views can answer the same question about the same // store. - const partition = computeReady(tasks) + const partition = computeReady(tasks); // Nothing ready, yet work exists: SAY SO. Either everything is claimed, or there is a // cycle nobody has noticed — and the reader cannot tell those apart from an empty list. @@ -181,7 +181,7 @@ export async function doTheWork ({ filter, root }) { // has a cycle — exactly the ambiguity the dep case already warns about. The asymmetry // had no basis, and it made `--strict` exit 0 on a parent cycle: the one thing its own // flag description promises to catch. - const ambiguous = partition.ready.length === 0 && partition.blocked.length > 0 + const ambiguous = partition.ready.length === 0 && partition.blocked.length > 0; // Every reason `--strict` may refuse, in one place. A row the loader THREW AWAY (`warnings`), a // row it kept but could not make sense of (`needsAttention`, and the same broken row when it is @@ -189,13 +189,13 @@ export async function doTheWork ({ filter, root }) { const unsound = warnings.length > 0 || partition.needsAttention.length > 0 || ambiguous || - partition.blocked.some(t => t.attention?.length) + partition.blocked.some(t => t.attention?.length); if (filter !== undefined) { - return { mode: 'filter', tasks: tasks.filter(t => t.status === filter), warnings, unsound } + return { mode: 'filter', tasks: tasks.filter(t => t.status === filter), warnings, unsound }; } - return { mode: 'partition', partition, ambiguous, warnings, unsound } + return { mode: 'partition', partition, ambiguous, warnings, unsound }; } /** @@ -209,7 +209,7 @@ export async function doTheWork ({ filter, root }) { */ function formatWorkResult (workResult, { blocked, json, parentName, strict }) { // Replayed first, so the asides still precede the answer exactly as they always did. - for (const message of workResult.warnings) warn(message) + for (const message of workResult.warnings) warn(message); // ONE VERDICT, BOTH SHAPES — `doTheWork` computed it, because a store is broken or it is not, and // that cannot depend on which output the caller asked for. @@ -224,7 +224,7 @@ function formatWorkResult (workResult, { blocked, json, parentName, strict }) { // The `--filter` output is a PINNED ARRAY with nowhere to carry a `warnings` key, so the exit code // is the only channel that path has — and it is the path a session-start hook reads on EVERY run, // which is what makes a silent failure here cost a live in-progress claim. - const { unsound } = workResult + const { unsound } = workResult; if (workResult.mode === 'filter') { // A flat ARRAY — a plain list of rows, not a partition. Pinned: two hook call sites parse it as @@ -238,23 +238,23 @@ function formatWorkResult (workResult, { blocked, json, parentName, strict }) { // exit code is the only warning it gets, which is the second reason `--strict` must work here. // // strip(): `_slug`/`_file` are loader provenance, not part of the contract. - const { tasks } = workResult - if (json) jsonOut(tasks.map(t => strip(t))) - else textOut(tasks.length ? tasks.map(t => line(t)).join('\n') : ' (none)') + const { tasks } = workResult; + if (json) jsonOut(tasks.map(t => strip(t))); + else textOut(tasks.length ? tasks.map(t => line(t)).join('\n') : ' (none)'); // The answer is on stdout; NOW the verdict. (Order matters: a caller that reads stdout still // gets its rows, and learns from the exit code that the store is not sound.) - if (strict && unsound) throw new ResultError('the store is not sound') - return + if (strict && unsound) throw new ResultError('the store is not sound'); + return; } - const { ambiguous, partition } = workResult + const { ambiguous, partition } = workResult; if (ambiguous) { - const containers = partition.blocked.filter(t => t.children?.length).length - const deps = partition.blocked.length - containers - const what = [deps && `${deps} waiting on deps`, containers && `${containers} epic(s) in flight`].filter(Boolean).join(', ') - warn(`0 ready, ${partition.blocked.length} blocked (${what}) — everything is claimed, or the graph has a cycle. Run \`${parentName} validate\`.`) + const containers = partition.blocked.filter(t => t.children?.length).length; + const deps = partition.blocked.length - containers; + const what = [deps && `${deps} waiting on deps`, containers && `${containers} epic(s) in flight`].filter(Boolean).join(', '); + warn(`0 ready, ${partition.blocked.length} blocked (${what}) — everything is claimed, or the graph has a cycle. Run \`${parentName} validate\`.`); } if (json) { @@ -275,13 +275,13 @@ function formatWorkResult (workResult, { blocked, json, parentName, strict }) { needsAttention: partition.needsAttention.map(t => strip(t)), ...(ambiguous ? { hint: `possible dependency cycle — run \`${parentName} validate\`` } : {}), ...(workResult.warnings.length ? { warnings: workResult.warnings } : {}), - }) + }); } else { const lines = blocked ? partition.blocked.map(t => blockedLine(t)) - : (partition.ready.length ? partition.ready.map(t => line(t)) : [' (no ready tasks)']) - lines.push(...partition.needsAttention.map(t => attentionLine(t))) - textOut(lines.join('\n')) + : (partition.ready.length ? partition.ready.map(t => line(t)) : [' (no ready tasks)']); + lines.push(...partition.needsAttention.map(t => attentionLine(t))); + textOut(lines.join('\n')); } // The SAME verdict the filter branch used. `computeReady` skips any row whose `status` is not @@ -289,6 +289,6 @@ function formatWorkResult (workResult, { blocked, json, parentName, strict }) { // `status: open` is discarded entirely and shows up only as a loader warning. Two malformed // REQUIRED fields, opposite mechanisms, and `unsound` is what makes them one answer. if (strict && unsound) { - throw new ResultError('the store is not sound') + throw new ResultError('the store is not sound'); } } diff --git a/lib/commands/stats.js b/lib/commands/stats.js index 589c7e3..e17f028 100644 --- a/lib/commands/stats.js +++ b/lib/commands/stats.js @@ -11,32 +11,32 @@ * Four parts — see `commands/ready.js` for the shape and why `doTheWork` is exported. */ -import { peowly } from 'peowly' +import { peowly } from 'peowly'; -import { jsonOut, textOut, warn } from '../format.js' -import { computeStats, formatStats } from '../ready.js' -import { loadTasks } from '../store.js' +import { jsonOut, textOut, warn } from '../format.js'; +import { computeStats, formatStats } from '../ready.js'; +import { loadTasks } from '../store.js'; import { outputFlags, requireRoot, staleFlags, storeFlags, validateStaleFlags, -} from '../flags/index.js' +} from '../flags/index.js'; const flags = /** @satisfies {import('peowly').AnyFlags} */ ({ ...outputFlags, ...storeFlags, ...staleFlags, -}) +}); /** @type {import('peowly-commands').CliCommand} */ export const stats = { description: 'Summary counts: totals, ready, blocked, stale claims', async run (argv, meta, { parentName }) { - const input = setupCommand(`${parentName} stats`, stats.description, argv, meta) - const workResult = await doTheWork(input) + const input = setupCommand(`${parentName} stats`, stats.description, argv, meta); + const workResult = await doTheWork(input); - formatWorkResult(workResult, input) + formatWorkResult(workResult, input); }, -} +}; /** * @typedef CommandContext @@ -61,14 +61,14 @@ function setupCommand (name, description, args, meta) { name, options: flags, usage: '[--stale] [--days ] [--json]', - }) + }); // The `--days` coercion is a pure, exported validator living beside its own flag // declaration (`lib/flags/staleness.js`) — the kind of coercion that fails silently, // hence the kind worth testing without a process. - const { days, stale } = validateStaleFlags(opts) + const { days, stale } = validateStaleFlags(opts); - return { days, json: opts.json, root: requireRoot(opts.root), stale } + return { days, json: opts.json, root: requireRoot(opts.root), stale }; } /** @@ -85,10 +85,10 @@ function setupCommand (name, description, args, meta) { */ export async function doTheWork ({ days, root }) { /** @type {string[]} */ - const warnings = [] - const tasks = await loadTasks(root, m => warnings.push(m)) + const warnings = []; + const tasks = await loadTasks(root, m => warnings.push(m)); - return { summary: computeStats(tasks, days), warnings } + return { summary: computeStats(tasks, days), warnings }; } /** @@ -99,21 +99,21 @@ export async function doTheWork ({ days, root }) { * @returns {void} */ function formatWorkResult ({ summary, warnings }, { json, stale }) { - for (const message of warnings) warn(message) + for (const message of warnings) warn(message); if (stale) { // `warnings` here TOO. This early return sat four lines above the comment below explaining why // warnings must be appended — and skipped it. `stats --stale --json` answered `{"stale": []}` // with exit 0 against a store `validate` exits 2 on, which is the founding defect wearing the // one flag a sprint-review agent actually passes (`stats --stale --days 60 --json`). - if (json) return jsonOut(warnings.length ? { stale: summary.stale, warnings } : { stale: summary.stale }) - return textOut(summary.stale.length ? summary.stale.map(id => ` ${id}`).join('\n') : ' (none)') + if (json) return jsonOut(warnings.length ? { stale: summary.stale, warnings } : { stale: summary.stale }); + return textOut(summary.stale.length ? summary.stale.map(id => ` ${id}`).join('\n') : ' (none)'); } // `warnings` APPENDED, so the pinned key order stays intact for consumers that parse it. // Without it a malformed row is counted in `total` while every `byStatus` bucket reads 0 — // present in the sum, absent from every answer — and the only sentence saying so goes to stderr, // which no `--json` consumer reads. - if (json) jsonOut(warnings.length ? { ...summary, warnings } : summary) - else textOut(formatStats(summary)) + if (json) jsonOut(warnings.length ? { ...summary, warnings } : summary); + else textOut(formatStats(summary)); } diff --git a/lib/commands/validate.js b/lib/commands/validate.js index df784b5..ac4d89c 100644 --- a/lib/commands/validate.js +++ b/lib/commands/validate.js @@ -17,35 +17,35 @@ * lint warning somewhere `--json` consumers cannot see it. */ -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; -import { isObject } from '@voxpelli/typed-utils' -import yaml from 'js-yaml' -import { peowly } from 'peowly' +import { isObject } from '@voxpelli/typed-utils'; +import yaml from 'js-yaml'; +import { peowly } from 'peowly'; -import { outputFlags, requireRoot, storeFlags } from '../flags/index.js' -import { jsonOut, textOut, warn } from '../format.js' -import { listTaskFiles, slugOf } from '../store.js' -import { ResultError } from '../utils/errors.js' -import { lintTasks } from '../validate.js' +import { outputFlags, requireRoot, storeFlags } from '../flags/index.js'; +import { jsonOut, textOut, warn } from '../format.js'; +import { listTaskFiles, slugOf } from '../store.js'; +import { ResultError } from '../utils/errors.js'; +import { lintTasks } from '../validate.js'; const flags = /** @satisfies {import('peowly').AnyFlags} */ ({ ...outputFlags, ...storeFlags, -}) +}); /** @type {import('peowly-commands').CliCommand} */ export const validate = { description: 'Check the task store for dangling deps, bad enums, and cycles', async run (argv, meta, { parentName }) { - const input = setupCommand(`${parentName} validate`, validate.description, argv, meta) - const workResult = await doTheWork(input) + const input = setupCommand(`${parentName} validate`, validate.description, argv, meta); + const workResult = await doTheWork(input); - formatWorkResult(workResult, input) + formatWorkResult(workResult, input); }, -} +}; /** * @typedef CommandContext @@ -68,9 +68,9 @@ function setupCommand (name, description, args, meta) { name, options: flags, usage: '[--json]', - }) + }); - return { json: opts.json, root: requireRoot(opts.root) } + return { json: opts.json, root: requireRoot(opts.root) }; } /** @@ -88,32 +88,32 @@ function setupCommand (name, description, args, meta) { * @returns {Promise} */ export async function doTheWork ({ root }) { - const { ignored, names, tasksDir } = await listTaskFiles(root) + const { ignored, names, tasksDir } = await listTaskFiles(root); /** @type {string[]} */ - const notices = [] + const notices = []; // A dir of non-matching files is not an empty substrate — surface it. Silently // ignoring `tasks_old.yml` would mean validating nothing and calling it clean. if (ignored.length && !names.length) { - notices.push(`tasks/ holds ${ignored.length} file(s) not matching tasks-*.yml (ignored): ${ignored.join(', ')}`) + notices.push(`tasks/ holds ${ignored.length} file(s) not matching tasks-*.yml (ignored): ${ignored.join(', ')}`); } - const files = [] + const files = []; /** @type {string[]} */ - const parseErrors = [] + const parseErrors = []; for (const name of names) { - let doc + let doc; try { - doc = yaml.load(await readFile(join(tasksDir, name), 'utf8')) + doc = yaml.load(await readFile(join(tasksDir, name), 'utf8')); } catch (err) { // Collect; do NOT bail. Bailing to stderr meant `--json` emitted no JSON at // all for an unparseable store — so every consumer reading stdout saw empty // output and concluded there was nothing to say, on the single commonest // hand-edit mistake there is. `--json` ALWAYS emits JSON. That is the contract. - parseErrors.push(`${name}: invalid YAML — ${/** @type {Error} */ (err).message}`) - continue + parseErrors.push(`${name}: invalid YAML — ${/** @type {Error} */ (err).message}`); + continue; } // Pass the raw value through — do NOT `?? []` it. That default silently laundered // a broken file into a clean empty one: a `task:` typo, or a truncation down to @@ -123,17 +123,17 @@ export async function doTheWork ({ root }) { // // An empty store is a file that SAYS `tasks: []`. A file that says nothing is not // empty — it is broken, and only the linter gets to decide that. - files.push({ name: slugOf(name), tasks: isObject(doc) ? doc['tasks'] : undefined }) + files.push({ name: slugOf(name), tasks: isObject(doc) ? doc['tasks'] : undefined }); } - const lint = lintTasks(files) + const lint = lintTasks(files); return { errors: [...parseErrors, ...lint.errors], warnings: lint.warnings, notices, fileCount: files.length, - } + }; } /** @@ -145,17 +145,17 @@ export async function doTheWork ({ root }) { * @throws {ResultError} when the store is invalid */ function formatWorkResult ({ errors, fileCount, notices, warnings }, { json }) { - for (const message of notices) warn(message) + for (const message of notices) warn(message); if (json) { - jsonOut({ clean: errors.length === 0, errors, warnings }) + jsonOut({ clean: errors.length === 0, errors, warnings }); } else { - if (warnings.length) textOut('Warnings:\n' + warnings.map(w => ` ~ ${w}`).join('\n') + '\n') - if (errors.length) textOut('Task validation failed:\n\n' + errors.map(e => ` - ${e}`).join('\n') + `\n\n${errors.length} error(s).`) + if (warnings.length) textOut('Warnings:\n' + warnings.map(w => ` ~ ${w}`).join('\n') + '\n'); + if (errors.length) textOut('Task validation failed:\n\n' + errors.map(e => ` - ${e}`).join('\n') + `\n\n${errors.length} error(s).`); // An EMPTY store validating clean is correct and worth saying plainly — it is // an ABSENT store that is an error, and requireRoot already caught that. - else textOut(`Task validation passed (${fileCount} file(s)).`) + else textOut(`Task validation passed (${fileCount} file(s)).`); } - if (errors.length) throw new ResultError('invalid store') + if (errors.length) throw new ResultError('invalid store'); } diff --git a/lib/flags/filter.js b/lib/flags/filter.js index bddd7d9..7dc8e09 100644 --- a/lib/flags/filter.js +++ b/lib/flags/filter.js @@ -7,15 +7,15 @@ * of lifting it out of `run()`. */ -import { isStatus, VALID_STATUSES } from '../schema.js' -import { InputError } from '../utils/errors.js' +import { isStatus, VALID_STATUSES } from '../schema.js'; +import { InputError } from '../utils/errors.js'; export const filterFlags = /** @satisfies {import('peowly').AnyFlags} */ ({ filter: { description: `Show tasks in a given status (${[...VALID_STATUSES].join(', ')})`, type: 'string', }, -}) +}); /** * @typedef FilterFlags @@ -30,14 +30,14 @@ export const filterFlags = /** @satisfies {import('peowly').AnyFlags} */ ({ export function validateFilterFlags ({ filter }) { // Absent is not invalid — `--filter` is optional, and its absence selects the // partition view rather than the list view. Only a PRESENT-and-wrong value is an error. - if (filter === undefined) return { filter: undefined } + if (filter === undefined) return { filter: undefined }; // `isStatus`, not `VALID_STATUSES.has()`. A `Set` refuses a `string` argument // outright, so this check used to cast through `any` — an `any`, at the exact // validation boundary the Set existed to guard. The guard narrows instead. if (!isStatus(filter)) { - throw new InputError(`--filter must be one of: ${[...VALID_STATUSES].join(', ')}`, undefined, 'EUSAGE') + throw new InputError(`--filter must be one of: ${[...VALID_STATUSES].join(', ')}`, undefined, 'EUSAGE'); } - return { filter } + return { filter }; } diff --git a/lib/flags/index.js b/lib/flags/index.js index f1a4041..db659bc 100644 --- a/lib/flags/index.js +++ b/lib/flags/index.js @@ -12,7 +12,7 @@ * reading command wants two of them. */ -export { filterFlags, validateFilterFlags } from './filter.js' -export { outputFlags } from './output.js' -export { staleFlags, validateStaleFlags } from './staleness.js' -export { requireRoot, storeFlags } from './store.js' +export { filterFlags, validateFilterFlags } from './filter.js'; +export { outputFlags } from './output.js'; +export { staleFlags, validateStaleFlags } from './staleness.js'; +export { requireRoot, storeFlags } from './store.js'; diff --git a/lib/flags/output.js b/lib/flags/output.js index 32a31ca..d8043fd 100644 --- a/lib/flags/output.js +++ b/lib/flags/output.js @@ -17,4 +17,4 @@ export const outputFlags = /** @satisfies {import('peowly').AnyFlags} */ ({ 'default': false, 'short': 'j', }, -}) +}); diff --git a/lib/flags/staleness.js b/lib/flags/staleness.js index 85d1fa6..9b46f2a 100644 --- a/lib/flags/staleness.js +++ b/lib/flags/staleness.js @@ -10,7 +10,7 @@ * is the kind that fails silently (see below). */ -import { InputError } from '../utils/errors.js' +import { InputError } from '../utils/errors.js'; export const staleFlags = /** @satisfies {import('peowly').AnyFlags} */ ({ days: { @@ -23,7 +23,7 @@ export const staleFlags = /** @satisfies {import('peowly').AnyFlags} */ ({ type: 'boolean', 'default': false, }, -}) +}); /** * @typedef StaleFlags @@ -37,14 +37,14 @@ export const staleFlags = /** @satisfies {import('peowly').AnyFlags} */ ({ * @throws {InputError} when --days is not a non-negative number */ export function validateStaleFlags ({ days: rawDays, stale }) { - const days = Number(rawDays) + const days = Number(rawDays); // Loudly, not as a silent NaN. `--days abc` producing a cutoff of NaN would make // every comparison false and report zero stale tasks — a confident wrong answer, // which is this project's signature failure and the reason the coercion is tested. if (!Number.isFinite(days) || days < 0) { - throw new InputError(`--days must be a non-negative number (got ${JSON.stringify(rawDays)})`, undefined, 'EUSAGE') + throw new InputError(`--days must be a non-negative number (got ${JSON.stringify(rawDays)})`, undefined, 'EUSAGE'); } - return { days: Math.trunc(days), stale } + return { days: Math.trunc(days), stale }; } diff --git a/lib/flags/store.js b/lib/flags/store.js index e34bd6a..1912408 100644 --- a/lib/flags/store.js +++ b/lib/flags/store.js @@ -7,9 +7,9 @@ * do not hold this up as an example of that pattern. */ -import { TRACKER_DIR } from '../schema.js' -import { NoStoreError, resolveRoot } from '../store.js' -import { InputError } from '../utils/errors.js' +import { TRACKER_DIR } from '../schema.js'; +import { NoStoreError, resolveRoot } from '../store.js'; +import { InputError } from '../utils/errors.js'; export const storeFlags = /** @satisfies {import('peowly').AnyFlags} */ ({ root: { @@ -17,7 +17,7 @@ export const storeFlags = /** @satisfies {import('peowly').AnyFlags} */ ({ listGroup: 'Store options', type: 'string', }, -}) +}); /** * Resolve the store root, converting a NoStoreError into an InputError. @@ -36,11 +36,11 @@ export const storeFlags = /** @satisfies {import('peowly').AnyFlags} */ ({ */ export function requireRoot (root) { try { - return resolveRoot({ root }) + return resolveRoot({ root }); } catch (err) { // The code rides through so the --json branch can emit {code: 'ENOSTORE'} rather // than a human sentence a machine consumer would have to regex. - if (err instanceof NoStoreError) throw new InputError(err.message, undefined, err.code) - throw err + if (err instanceof NoStoreError) throw new InputError(err.message, undefined, err.code); + throw err; } } diff --git a/lib/format.js b/lib/format.js index e461ca1..6608e48 100644 --- a/lib/format.js +++ b/lib/format.js @@ -20,7 +20,7 @@ * reflected in the exit code. Nothing important is ever whispered. */ -import { stderr, stdout } from 'node:process' +import { stderr, stdout } from 'node:process'; /** * Print the answer as JSON. Two-space indent — these get read by humans in @@ -30,7 +30,7 @@ import { stderr, stdout } from 'node:process' * @returns {void} */ export function jsonOut (value) { - stdout.write(JSON.stringify(value, undefined, 2) + '\n') + stdout.write(JSON.stringify(value, undefined, 2) + '\n'); } /** @@ -40,7 +40,7 @@ export function jsonOut (value) { * @returns {void} */ export function textOut (text) { - stdout.write(text.endsWith('\n') ? text : text + '\n') + stdout.write(text.endsWith('\n') ? text : text + '\n'); } /** @@ -52,5 +52,5 @@ export function textOut (text) { * @returns {void} */ export function warn (message) { - stderr.write(`diarie: ${message}\n`) + stderr.write(`diarie: ${message}\n`); } diff --git a/lib/index.js b/lib/index.js index 5db0e29..f71d7b0 100644 --- a/lib/index.js +++ b/lib/index.js @@ -15,13 +15,13 @@ * error after a rename, it would silently stop guarding. */ -export * from './schema.js' +export * from './schema.js'; export { computeReady, computeStats, formatStats, line, -} from './ready.js' -export { lintTasks } from './validate.js' +} from './ready.js'; +export { lintTasks } from './validate.js'; export { listTaskFiles, loadTasks, NoStoreError, resolveInitRoot, resolveRoot, slugOf, -} from './store.js' +} from './store.js'; // `nsId` is NOT re-exported here — it comes from `export * from './schema.js'` above, // which is now its only home. It used to live in store.js as a second copy of the rule. diff --git a/lib/main.js b/lib/main.js index 0ace2b3..2c5e8c3 100644 --- a/lib/main.js +++ b/lib/main.js @@ -5,15 +5,15 @@ * layer on top. Each command owns its own flag set and parses it inside `run()`. */ -import { isErrorWithCode } from '@voxpelli/typed-utils' -import { PeowlyCommandMissingError, peowlyCommands } from 'peowly-commands' +import { isErrorWithCode } from '@voxpelli/typed-utils'; +import { PeowlyCommandMissingError, peowlyCommands } from 'peowly-commands'; -import { init } from './commands/init.js' -import { migrate } from './commands/migrate.js' -import { ready } from './commands/ready.js' -import { stats } from './commands/stats.js' -import { validate } from './commands/validate.js' -import { InputError, ResultError } from './utils/errors.js' +import { init } from './commands/init.js'; +import { migrate } from './commands/migrate.js'; +import { ready } from './commands/ready.js'; +import { stats } from './commands/stats.js'; +import { validate } from './commands/validate.js'; +import { InputError, ResultError } from './utils/errors.js'; /** @type {import('peowly-commands').CliCommands} */ const commands = { @@ -22,9 +22,9 @@ const commands = { validate, init, migrate, -} +}; -const COMMAND_LIST = `Commands: ${Object.keys(commands).join(', ')}\n\nRun \`diarie --help\` for the full usage.` +const COMMAND_LIST = `Commands: ${Object.keys(commands).join(', ')}\n\nRun \`diarie --help\` for the full usage.`; /** * Everything a *user* can get wrong is an `InputError` (exit 1). This function is where @@ -55,11 +55,11 @@ export async function cli (argv) { // // `--help`/`--version` are exempt — they ARE the request, peowly answers both with exit 0, and // they are honoured wherever they appear, not only in first position. - const wantsMeta = argv.includes('--help') || argv.includes('--version') - const names = !!argv[0] && !argv[0].startsWith('-') + const wantsMeta = argv.includes('--help') || argv.includes('--version'); + const names = !!argv[0] && !argv[0].startsWith('-'); if (!wantsMeta && !names) { - throw new InputError('no command given', COMMAND_LIST, 'EUSAGE') + throw new InputError('no command given', COMMAND_LIST, 'EUSAGE'); } try { @@ -73,7 +73,7 @@ export async function cli (argv) { args: argv, name: 'diarie', importMeta: import.meta, - }) + }); } catch (err) { // OUR OWN TAXONOMY PASSES THROUGH UNTOUCHED, and it must be checked FIRST. // @@ -88,14 +88,14 @@ export async function cli (argv) { // with a stack trace and, under `--json`, an EMPTY STDOUT. Which is verbatim the defect the // comment below was written to eliminate. The suite stayed 194/194 green, because the tests // covering those paths assert only `exit === 1`, and a crash exits 1 too. - if (err instanceof InputError || err instanceof ResultError) throw err + if (err instanceof InputError || err instanceof ResultError) throw err; // A TYPO IS NOT A BUG IN THE TOOL. Unnormalized, both cases below fall through to // cli.js's "genuinely unexpected: a bug, not a user mistake" branch, which answers with // a STACK TRACE — and, under `--json`, with an EMPTY STDOUT. `diarie frobnicate` deserves // a sentence, not a dump of `node:internal/util/parse_args` frames. if (err instanceof PeowlyCommandMissingError) { - throw new InputError(`unknown command: ${err.commandName}`, COMMAND_LIST, 'EUSAGE') + throw new InputError(`unknown command: ${err.commandName}`, COMMAND_LIST, 'EUSAGE'); } // node:util.parseArgs (which peowly wraps) throws these for an unknown flag, a bad flag @@ -107,9 +107,9 @@ export async function cli (argv) { // `cause` preserved: if this branch ever fires on OUR misconfiguration (a command that // forgot `allowPositionals`, say), the stack must remain recoverable — otherwise the tool // reports its own bug as the user's mistake. - throw new InputError(err.message, undefined, 'EUSAGE', { cause: err }) + throw new InputError(err.message, undefined, 'EUSAGE', { cause: err }); } - throw err + throw err; } } diff --git a/lib/migrate/bd-map.js b/lib/migrate/bd-map.js index abbcda6..9cdeeb7 100644 --- a/lib/migrate/bd-map.js +++ b/lib/migrate/bd-map.js @@ -33,18 +33,18 @@ * projection is scratch-only by construction, not an accident of discipline. */ -import { readFileSync, writeFileSync } from 'node:fs' -import { basename, resolve, sep } from 'node:path' -import { fileURLToPath } from 'node:url' +import { readFileSync, writeFileSync } from 'node:fs'; +import { basename, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { argv, exit, stderr, stdout, -} from 'node:process' +} from 'node:process'; -import { isObject, isStringArray } from '@voxpelli/typed-utils' -import yaml from 'js-yaml' +import { isObject, isStringArray } from '@voxpelli/typed-utils'; +import yaml from 'js-yaml'; -import { isNil, TRACKER_DIR, VALID_TYPES } from '../schema.js' -import { slugOf } from '../store.js' +import { isNil, TRACKER_DIR, VALID_TYPES } from '../schema.js'; +import { slugOf } from '../store.js'; /** * One row of a `bd export` JSONL snapshot — the shape we are migrating AWAY from. @@ -82,7 +82,7 @@ import { slugOf } from '../store.js' * @param {unknown} r * @returns {r is BdIssue} */ -const isBdIssue = (r) => isObject(r) && r['_type'] === 'issue' +const isBdIssue = (r) => isObject(r) && r['_type'] === 'issue'; /** * Parse a `bd export` JSONL snapshot into issue records. @@ -113,8 +113,8 @@ const isBdIssue = (r) => isObject(r) && r['_type'] === 'issue' */ export function parseBdExport (contents) { /** @type {unknown[]} */ - const parsed = contents.split('\n').filter(Boolean).map(l => JSON.parse(l)) - return parsed.filter(r => isBdIssue(r)) + const parsed = contents.split('\n').filter(Boolean).map(l => JSON.parse(l)); + return parsed.filter(r => isBdIssue(r)); } /** @@ -133,7 +133,7 @@ export const STATUS_MAP = { // dependent should see it and pause, not silently block forever). This is // an approximation, not a clean mapping — flagged as a named loss below. deferred: 'cancelled', -} +}; /** * bd numeric priority → task-schema priority string. Exported so the real @@ -141,7 +141,7 @@ export const STATUS_MAP = { * * @type {Record} */ -export const PRIORITY_MAP = { '0': 'critical', '1': 'high', '2': 'medium', '3': 'low', '4': 'backlog' } +export const PRIORITY_MAP = { '0': 'critical', '1': 'high', '2': 'medium', '3': 'low', '4': 'backlog' }; /** * bd's 9 issue_types → the 4-type model this schema settled on. @@ -167,7 +167,7 @@ export const TYPE_MAP = { // bd has no 'doc' type in its own vocabulary — nothing maps TO 'doc'. It // exists in the YAML model with zero bd-side source, which is itself a // fact worth recording (see loss report "typesWithNoBdSource"). -} +}; /** * Project a parsed bd export into the flat-YAML task rows, with a loss report. @@ -189,33 +189,33 @@ export const TYPE_MAP = { */ export function projectRecords (records) { /** @type {TaskRow[]} */ - const tasks = [] - const unknownStatuses = new Set() - const unknownTypes = new Set() - const untranslatedDepTypes = new Set() - const deferredIds = [] - const priorityDefaultedIds = [] - let acceptanceCriteriaSkipped = 0 - let idlessRecords = 0 - let malformedEdges = 0 + const tasks = []; + const unknownStatuses = new Set(); + const unknownTypes = new Set(); + const untranslatedDepTypes = new Set(); + const deferredIds = []; + const priorityDefaultedIds = []; + let acceptanceCriteriaSkipped = 0; + let idlessRecords = 0; + let malformedEdges = 0; /** @type {string[]} */ - const droppedLabels = [] + const droppedLabels = []; for (const r of records) { // An id-less bd row cannot become a task: the schema requires `id`, and every // blank one would collide with every other at load. Refuse it, and SAY SO — a // migrator that quietly drops rows is the worst kind of migrator. - if (!r.id) { idlessRecords++; continue } + if (!r.id) { idlessRecords++; continue; } - const status = r.status === undefined ? undefined : STATUS_MAP[r.status] - if (!status) { unknownStatuses.add(r.status); continue } - if (r.status === 'deferred') deferredIds.push(r.id) + const status = r.status === undefined ? undefined : STATUS_MAP[r.status]; + if (!status) { unknownStatuses.add(r.status); continue; } + if (r.status === 'deferred') deferredIds.push(r.id); - const mapped = r.issue_type === undefined ? undefined : TYPE_MAP[r.issue_type] - if (!mapped) { unknownTypes.add(r.issue_type); continue } + const mapped = r.issue_type === undefined ? undefined : TYPE_MAP[r.issue_type]; + if (!mapped) { unknownTypes.add(r.issue_type); continue; } // Drift guard: every TYPE_MAP value is schema-valid by construction today, // so this can't fire yet — it catches a future bad edit to TYPE_MAP. - if (!VALID_TYPES.has(mapped.type)) { unknownTypes.add(r.issue_type); continue } + if (!VALID_TYPES.has(mapped.type)) { unknownTypes.add(r.issue_type); continue; } // isStringArray, not Array.isArray: the latter narrows `unknown` to `any[]`, so a bd // export with `labels: [{...}]` would flow straight into `TaskRow.labels: string[]` @@ -229,35 +229,35 @@ export function projectRecords (records) { // was a real bug here, and losing the label re-creates it in someone else's repo, by the // migration itself. The guard turned a type leak into a SILENT one, inside the function // whose entire product is a loss report. - const rawLabels = r.labels + const rawLabels = r.labels; if (!isNil(rawLabels) && !isStringArray(rawLabels)) { - droppedLabels.push(`${r.id}: ${JSON.stringify(rawLabels)}`) + droppedLabels.push(`${r.id}: ${JSON.stringify(rawLabels)}`); } - const labels = [...(isStringArray(r.labels) ? r.labels : []), ...(mapped.label ? [mapped.label] : [])] + const labels = [...(isStringArray(r.labels) ? r.labels : []), ...(mapped.label ? [mapped.label] : [])]; /** @type {string[]} */ - const deps = [] + const deps = []; /** @type {string | undefined} */ - let parent + let parent; for (const d of r.dependencies ?? []) { // A bare `continue` would drop an edge with NO record — inside the one function // whose entire product IS a loss report. Count it, or the report lies by omission. - if (d.depends_on_id === undefined) { malformedEdges++; continue } - if (d.type === 'blocks') deps.push(d.depends_on_id) - else if (d.type === 'parent-child') parent = d.depends_on_id - else untranslatedDepTypes.add(d.type) + if (d.depends_on_id === undefined) { malformedEdges++; continue; } + if (d.type === 'blocks') deps.push(d.depends_on_id); + else if (d.type === 'parent-child') parent = d.depends_on_id; + else untranslatedDepTypes.add(d.type); } // The full migrator (bj7) extracts `## Acceptance Criteria` from // markdown description bodies. This spike doesn't — that's real parsing // work belonging to the lossless migrator, not this ready-semantics // dogfood. Record the skip; don't silently drop it. - if (/## Acceptance Criteria/i.test(r.description ?? '')) acceptanceCriteriaSkipped++ + if (/## Acceptance Criteria/i.test(r.description ?? '')) acceptanceCriteriaSkipped++; - let priority = PRIORITY_MAP[String(r.priority)] + let priority = PRIORITY_MAP[String(r.priority)]; if (!priority) { - priorityDefaultedIds.push({ id: r.id, priority: r.priority }) - priority = 'medium' + priorityDefaultedIds.push({ id: r.id, priority: r.priority }); + priority = 'medium'; } /** @type {TaskRow} */ @@ -274,13 +274,13 @@ export function projectRecords (records) { status, type: mapped.type, priority, - } - if (labels.length) task.labels = labels - if (parent) task.parent = parent - if (deps.length) task.deps = deps - if (r.updated_at) task.updated = r.updated_at.slice(0, 10) + }; + if (labels.length) task.labels = labels; + if (parent) task.parent = parent; + if (deps.length) task.deps = deps; + if (r.updated_at) task.updated = r.updated_at.slice(0, 10); - tasks.push(task) + tasks.push(task); } return { @@ -305,32 +305,32 @@ export function projectRecords (records) { 'range and was coerced to \'medium\'; because priority is the ready queue\'s sort key, a ' + 'nonempty list means the projected ready ordering may diverge from `bd ready`.', }, - } + }; } // --- CLI ------------------------------------------------------------------- if (argv[1] && fileURLToPath(import.meta.url) === argv[1]) { - const [inputPath, outputPath] = argv.slice(2) + const [inputPath, outputPath] = argv.slice(2); if (!inputPath || !outputPath) { // The path to THIS file, not to the CLI. `diarie migrate` routes to the full migrator; this // projector is reachable only by executing it directly, and printing the CLI's usage here sent // anyone who hit it to a command that does something else. - stderr.write('usage: node lib/migrate/bd-map.js \n') - exit(1) + stderr.write('usage: node lib/migrate/bd-map.js \n'); + exit(1); } // Segment-wise rather than a regex: the tracker dir is TRACKER_DIR-derived, and // splitting on `sep` needs no escaping of its leading dot. `some` (not indexOf) // so a repeated segment — /a/.diarie/b/.diarie/tasks — still trips the guard. - const segments = resolve(outputPath).split(sep) + const segments = resolve(outputPath).split(sep); if (segments.some((s, i) => s === TRACKER_DIR && segments[i + 1] === 'tasks')) { stderr.write(`refusing to write under ${TRACKER_DIR}/tasks/ — this projector is scratch-only ` + - '(regenerate, never hand-edit; the live store is owned by `diarie migrate` / Edit)\n') - exit(1) + '(regenerate, never hand-edit; the live store is owned by `diarie migrate` / Edit)\n'); + exit(1); } - const records = parseBdExport(readFileSync(inputPath, 'utf8')) - const { loss, tasks } = projectRecords(records) + const records = parseBdExport(readFileSync(inputPath, 'utf8')); + const { loss, tasks } = projectRecords(records); // DERIVED from the output filename, never fixed. The store's convention is // `tasks-.yml`, and the slug is what namespaces every id in the file (`/`) @@ -338,8 +338,8 @@ if (argv[1] && fileURLToPath(import.meta.url) === argv[1]) { // onto every projection anybody ever generates, and their ids come back namespaced to a // project they have never heard of. It shipped that way once. Naming the output file names // the slug; a filename that is not `tasks-.yml` falls back to a neutral one. - const slug = slugOf(basename(outputPath)) || 'shadow' - writeFileSync(outputPath, yaml.dump({ meta: { slug, title: 'Shadow projection of bd (read-only spike)' }, tasks })) - stdout.write(`projected ${tasks.length} issues → ${outputPath}\n`) - stdout.write(`loss report: ${JSON.stringify(loss, undefined, 2)}\n`) + const slug = slugOf(basename(outputPath)) || 'shadow'; + writeFileSync(outputPath, yaml.dump({ meta: { slug, title: 'Shadow projection of bd (read-only spike)' }, tasks })); + stdout.write(`projected ${tasks.length} issues → ${outputPath}\n`); + stdout.write(`loss report: ${JSON.stringify(loss, undefined, 2)}\n`); } diff --git a/lib/migrate/bootstrap.js b/lib/migrate/bootstrap.js index 268fc1d..8c6d326 100644 --- a/lib/migrate/bootstrap.js +++ b/lib/migrate/bootstrap.js @@ -57,30 +57,30 @@ * Dry-run: point `--root` at a scratch dir and diff before writing for real. */ -import { spawnSync } from 'node:child_process' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { parseArgs } from 'node:util' +import { spawnSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, -} from 'node:fs' +} from 'node:fs'; import { argv, cwd, exit, stderr, stdout, -} from 'node:process' +} from 'node:process'; -import { isStringArray } from '@voxpelli/typed-utils' -import yaml from 'js-yaml' +import { isStringArray } from '@voxpelli/typed-utils'; +import yaml from 'js-yaml'; -import { InputError } from '../utils/errors.js' +import { InputError } from '../utils/errors.js'; import { isNil, TRACKER_DIR, VALID_STATUSES, VALID_TYPES, -} from '../schema.js' +} from '../schema.js'; import { parseBdExport, PRIORITY_MAP, STATUS_MAP, TYPE_MAP, -} from './bd-map.js' +} from './bd-map.js'; /** A slug becomes a `tasks-.yml` filename — keep it filesystem-plain. */ -const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/ +const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; /** * The flags `runMigration` accepts, in ONE place — the parser reads it AND the USAGE-parity @@ -113,7 +113,7 @@ export const MIGRATE_OPTIONS = { title: { type: 'string', multiple: true, 'default': [] }, force: { type: 'boolean', 'default': false }, json: { type: 'boolean' }, -} +}; /** * The one canonical usage string. Lives here, beside the parser it describes — NOT in @@ -133,7 +133,7 @@ export const USAGE = `Usage: diarie migrate [options] Get the input with: bd export -o /tmp/bd-export.jsonl This is a BOOTSTRAP, not a sync. It runs once; afterwards the store is hand-edited -like any other file. An existing store is a hard stop unless you pass --force.` +like any other file. An existing store is a hard stop unless you pass --force.`; /** * Undo bd's create-time escaping artifact: some issue bodies store a literal @@ -151,7 +151,7 @@ like any other file. An existing store is a hard stop unless you pass --force.` * @returns {string} */ export function normalizeBody (body) { - return (body ?? '').replaceAll('\\n', '\n') + return (body ?? '').replaceAll('\\n', '\n'); } /** @@ -167,23 +167,23 @@ export function splitBody (body) { // Normalize first, or the `## Acceptance Criteria` heading is never line-anchored and // the criteria SILENTLY vanish — a real bug, and a quiet one: the body still migrates, // the task still lands, and only its acceptance criteria are gone. - const lines = normalizeBody(body).split('\n') - const acIdx = lines.findIndex(l => /^##\s+Acceptance Criteria\s*$/i.test(l)) - if (acIdx === -1) return { description: lines.join('\n').trim(), acceptanceCriteria: [] } + const lines = normalizeBody(body).split('\n'); + const acIdx = lines.findIndex(l => /^##\s+Acceptance Criteria\s*$/i.test(l)); + if (acIdx === -1) return { description: lines.join('\n').trim(), acceptanceCriteria: [] }; - let end = lines.length + let end = lines.length; for (let i = acIdx + 1; i < lines.length; i++) { - if (/^##\s+/.test(lines[i] ?? '')) { end = i; break } + if (/^##\s+/.test(lines[i] ?? '')) { end = i; break; } } const acceptanceCriteria = lines .slice(acIdx + 1, end) .filter(l => /^\s*(?:[-*]|\d+\.)\s+/.test(l)) // strip the bullet, then an optional `[ ]`/`[x]` task-list checkbox marker .map(l => l.replace(/^\s*(?:[-*]|\d+\.)\s+/, '').replace(/^\[[ x]\]\s*/i, '').trim()) - .filter(Boolean) + .filter(Boolean); - const description = [...lines.slice(0, acIdx), ...lines.slice(end)].join('\n').trim() - return { description, acceptanceCriteria } + const description = [...lines.slice(0, acIdx), ...lines.slice(end)].join('\n').trim(); + return { description, acceptanceCriteria }; } /** @@ -209,15 +209,15 @@ export function projectLive (r, liveIds, droppedEdges, priorityDefaulted = [], d // Typing `r` as BdIssue instead of `any` is what surfaced the rest of these: `id`, // `issue_type` and `depends_on_id` were all dereferenced as if guaranteed. bd's own // exports happen to carry them, so only a foreign export would ever have been bitten. - if (!r.id) throw new Error('bd record with no id — cannot become a task (the schema requires one)') + if (!r.id) throw new Error('bd record with no id — cannot become a task (the schema requires one)'); - const status = r.status === 'deferred' ? 'deferred' : (r.status === undefined ? undefined : STATUS_MAP[r.status]) + const status = r.status === 'deferred' ? 'deferred' : (r.status === undefined ? undefined : STATUS_MAP[r.status]); if (!status || !VALID_STATUSES.has(status)) { - throw new Error(`unmapped bd status for ${r.id}: "${r.status}" — add it to STATUS_MAP in bd-map.js`) + throw new Error(`unmapped bd status for ${r.id}: "${r.status}" — add it to STATUS_MAP in bd-map.js`); } - const mapped = r.issue_type === undefined ? undefined : TYPE_MAP[r.issue_type] + const mapped = r.issue_type === undefined ? undefined : TYPE_MAP[r.issue_type]; if (!mapped || !VALID_TYPES.has(mapped.type)) { - throw new Error(`bad type map for ${r.id}: ${r.issue_type}`) + throw new Error(`bad type map for ${r.id}: ${r.issue_type}`); } // isStringArray, not Array.isArray: the latter narrows `unknown` to `any[]`, so a bd @@ -232,27 +232,27 @@ export function projectLive (r, liveIds, droppedEdges, priorityDefaulted = [], d // was a real bug here, and losing the label re-creates it in someone else's repo, by the // migration itself. The guard turned a type leak into a SILENT one, inside the function // whose entire product is a loss report. - const rawLabels = r.labels + const rawLabels = r.labels; if (!isNil(rawLabels) && !isStringArray(rawLabels)) { - droppedLabels.push(`${r.id}: ${JSON.stringify(rawLabels)}`) + droppedLabels.push(`${r.id}: ${JSON.stringify(rawLabels)}`); } - const labels = [...(isStringArray(r.labels) ? r.labels : []), ...(mapped.label ? [mapped.label] : [])] + const labels = [...(isStringArray(r.labels) ? r.labels : []), ...(mapped.label ? [mapped.label] : [])]; /** @type {string[]} */ - const deps = [] + const deps = []; /** @type {string | undefined} */ - let parent + let parent; for (const d of r.dependencies ?? []) { // An edge pointing nowhere is not an edge. Report it rather than pushing `undefined` // into deps, where it would serialize as a null and dangle at validation. - if (!d.depends_on_id) { droppedEdges.push(`${r.id} → (no target) (${d.type ?? 'unknown'}; malformed edge)`); continue } - const live = liveIds.has(d.depends_on_id) + if (!d.depends_on_id) { droppedEdges.push(`${r.id} → (no target) (${d.type ?? 'unknown'}; malformed edge)`); continue; } + const live = liveIds.has(d.depends_on_id); if (d.type === 'blocks') { - if (live) deps.push(d.depends_on_id) - else droppedEdges.push(`${r.id} → ${d.depends_on_id} (blocks; satisfied: blocker not live)`) + if (live) deps.push(d.depends_on_id); + else droppedEdges.push(`${r.id} → ${d.depends_on_id} (blocks; satisfied: blocker not live)`); } else if (d.type === 'parent-child') { - if (live) parent = d.depends_on_id - else droppedEdges.push(`${r.id} → ${d.depends_on_id} (parent; dropped: epic not live)`) + if (live) parent = d.depends_on_id; + else droppedEdges.push(`${r.id} → ${d.depends_on_id} (parent; dropped: epic not live)`); } else { // bd has edge types beyond `blocks` and `parent-child` (`related`, // `discovered-from`, …). They have no analog in the schema, so dropping them is @@ -261,11 +261,11 @@ export function projectLive (r, liveIds, droppedEdges, priorityDefaulted = [], d // already accumulates them as `untranslatedDepTypes`. The export this was first // written against happened to carry none of these edge types — which is exactly why // the branch was missing, and exactly why a foreign export needs it. - droppedEdges.push(`${r.id} → ${d.depends_on_id} (${d.type ?? 'unknown'}; untranslated edge type — no schema analog)`) + droppedEdges.push(`${r.id} → ${d.depends_on_id} (${d.type ?? 'unknown'}; untranslated edge type — no schema analog)`); } } - const { acceptanceCriteria, description } = splitBody(r.description ?? '') + const { acceptanceCriteria, description } = splitBody(r.description ?? ''); // An unmappable priority is the ONE field here that degrades instead of halting, and // that is the right call — `medium` is a defensible default and priority affects only @@ -274,8 +274,8 @@ export function projectLive (r, liveIds, droppedEdges, priorityDefaulted = [], d // read-only spike this migrator replaced already reported it (bd-map.js's // `priorityDefaultedIds`) while the shipped tool did not. Report-and-continue is the // policy; reporting is not the optional half. - const priority = PRIORITY_MAP[String(r.priority)] - if (!priority) priorityDefaulted.push(`${r.id} (bd priority: ${JSON.stringify(r.priority)}) → medium`) + const priority = PRIORITY_MAP[String(r.priority)]; + if (!priority) priorityDefaulted.push(`${r.id} (bd priority: ${JSON.stringify(r.priority)}) → medium`); /** @type {import('../schema.js').TaskRow} */ const task = { @@ -291,14 +291,14 @@ export function projectLive (r, liveIds, droppedEdges, priorityDefaulted = [], d status, type: mapped.type, priority: priority ?? 'medium', - } - if (labels.length) task.labels = labels - if (parent) task.parent = parent - if (deps.length) task.deps = deps - if (acceptanceCriteria.length) task.acceptance_criteria = acceptanceCriteria - if (description) task.description = description - if (r.updated_at) task.updated = r.updated_at.slice(0, 10) - return task + }; + if (labels.length) task.labels = labels; + if (parent) task.parent = parent; + if (deps.length) task.deps = deps; + if (acceptanceCriteria.length) task.acceptance_criteria = acceptanceCriteria; + if (description) task.description = description; + if (r.updated_at) task.updated = r.updated_at.slice(0, 10); + return task; } /** @@ -312,7 +312,7 @@ export function projectLive (r, liveIds, droppedEdges, priorityDefaulted = [], d * @returns {Map} slug → tasks */ export function groupTasks (tasks, epicSlugs, defaultSlug) { - const byId = new Map(tasks.map(t => [t.id, t])) + const byId = new Map(tasks.map(t => [t.id, t])); /** * Walk up the parent chain to the first id with an explicit slug. @@ -322,29 +322,29 @@ export function groupTasks (tasks, epicSlugs, defaultSlug) { */ const slugFor = (task) => { /** @type {Set} */ - const seen = new Set() + const seen = new Set(); /** @type {import('../schema.js').TaskRow | undefined} */ - let cur = task + let cur = task; while (cur && !seen.has(cur.id)) { - seen.add(cur.id) // a parent cycle would otherwise spin here - const slug = epicSlugs.get(cur.id) - if (slug) return slug - cur = cur.parent ? byId.get(cur.parent) : undefined + seen.add(cur.id); // a parent cycle would otherwise spin here + const slug = epicSlugs.get(cur.id); + if (slug) return slug; + cur = cur.parent ? byId.get(cur.parent) : undefined; } - return defaultSlug - } + return defaultSlug; + }; /** @type {Map} */ - const grouped = new Map([[defaultSlug, []], ...[...epicSlugs.values()].map(s => /** @type {[string, any[]]} */ ([s, []]))]) + const grouped = new Map([[defaultSlug, []], ...[...epicSlugs.values()].map(s => /** @type {[string, any[]]} */ ([s, []]))]); for (const task of tasks) { - const slug = slugFor(task) - const bucket = grouped.get(slug) + const slug = slugFor(task); + const bucket = grouped.get(slug); // A `?.push()` here would silently DISCARD a migrated task if this invariant ever // broke — in a file whose stated failure mode is silent data loss. Refuse instead. - if (!bucket) throw new Error(`internal: no bucket for slug "${slug}" — refusing to drop task ${task.id}`) - bucket.push(task) + if (!bucket) throw new Error(`internal: no bucket for slug "${slug}" — refusing to drop task ${task.id}`); + bucket.push(task); } - return grouped + return grouped; } /** @@ -356,8 +356,8 @@ export function groupTasks (tasks, epicSlugs, defaultSlug) { * @returns {string} */ function dumpTasks (slug, title, tasks) { - const ordered = tasks.toSorted((a, b) => a.id.localeCompare(b.id)) - return yaml.dump({ meta: { slug, title }, tasks: ordered }, { lineWidth: 100, noRefs: true }) + const ordered = tasks.toSorted((a, b) => a.id.localeCompare(b.id)); + return yaml.dump({ meta: { slug, title }, tasks: ordered }, { lineWidth: 100, noRefs: true }); } /** @@ -368,11 +368,11 @@ function dumpTasks (slug, title, tasks) { * @returns {string} */ function dumpDecision (task, body) { - const { description, ...front } = task - const fm = yaml.dump(front, { lineWidth: 100, noRefs: true }).trimEnd() + const { description, ...front } = task; + const fm = yaml.dump(front, { lineWidth: 100, noRefs: true }).trimEnd(); // normalizeBody, not the raw body: a decision is ENTIRELY prose, so an unnormalized // escaping artifact turns the whole file into one line of `\n` gibberish. - return `---\n${fm}\n---\n\n${normalizeBody(body).trim()}\n` + return `---\n${fm}\n---\n\n${normalizeBody(body).trim()}\n`; } /** @@ -383,15 +383,15 @@ function dumpDecision (task, body) { * @returns {Map} */ function parsePairs (pairs, flag) { - const map = new Map() + const map = new Map(); for (const pair of pairs) { - const at = pair.indexOf('=') + const at = pair.indexOf('='); if (at < 1 || at === pair.length - 1) { - throw new InputError(`--${flag} expects =, got: ${pair}`, USAGE, 'EUSAGE') + throw new InputError(`--${flag} expects =, got: ${pair}`, USAGE, 'EUSAGE'); } - map.set(pair.slice(0, at), pair.slice(at + 1)) + map.set(pair.slice(0, at), pair.slice(at + 1)); } - return map + return map; } // --- CLI ------------------------------------------------------------------- @@ -411,109 +411,109 @@ export async function runMigration (args) { // parseArgs is strict: an unknown flag throws ERR_PARSE_ARGS_UNKNOWN_OPTION. That is a user // mistake, not a bug, so re-throw it as an InputError — otherwise it sails into cli.js's // "genuinely unexpected" branch and is answered with a stack trace, the exact defect this row fixes. - let positionals, values + let positionals, values; try { - ({ positionals, values } = parseArgs({ args, allowPositionals: true, options: MIGRATE_OPTIONS })) + ({ positionals, values } = parseArgs({ args, allowPositionals: true, options: MIGRATE_OPTIONS })); } catch (cause) { throw new InputError( cause instanceof Error ? cause.message : 'bad migrate arguments', USAGE, 'EUSAGE', { cause: cause instanceof Error ? cause : undefined } - ) + ); } - const [inputPath] = positionals + const [inputPath] = positionals; if (!inputPath) { - throw new InputError('migrate needs a bd export file', USAGE, 'EUSAGE') + throw new InputError('migrate needs a bd export file', USAGE, 'EUSAGE'); } // Default to CWD, never to this package's own checkout: the migrator is installed // somewhere and run against OTHER repos, so a script-relative default would make a // forgotten --root silently clobber the tracker of whatever tree the code happens to // live in — the installing package's own store, not the user's. - const root = resolve(values.root ?? cwd()) - const epicSlugs = parsePairs(values.epic, 'epic') - const titles = parsePairs(values.title, 'title') - const defaultSlug = values['default-slug'] + const root = resolve(values.root ?? cwd()); + const epicSlugs = parsePairs(values.epic, 'epic'); + const titles = parsePairs(values.title, 'title'); + const defaultSlug = values['default-slug']; // The migration is a BOOTSTRAP: after it runs, the store is hand-maintained with // Edit/Write. Re-running would silently overwrite that work with the export's // (stale) state, so an existing store is a hard stop rather than a doc warning. - const tasksDir = resolve(root, `${TRACKER_DIR}/tasks`) + const tasksDir = resolve(root, `${TRACKER_DIR}/tasks`); // `.ya?ml` — both readers accept either extension, so matching only `.yml` here // would let a `tasks-*.yaml` store slip past the guard and be silently clobbered. - const existing = existsSync(tasksDir) ? readdirSync(tasksDir).filter(f => /^tasks-.+\.ya?ml$/.test(f)) : [] + const existing = existsSync(tasksDir) ? readdirSync(tasksDir).filter(f => /^tasks-.+\.ya?ml$/.test(f)) : []; if (existing.length && !values.force) { throw new InputError( `refusing to overwrite an existing task store: ${tasksDir} already holds ${existing.join(', ')}`, 'This is a one-way bootstrap, not a sync — re-running replays the bd export over any hand-edits\n' + 'made since. Pass --force only to redo a botched migration, or --root to target elsewhere.', 'EEXIST' - ) + ); } for (const slug of [...epicSlugs.values(), defaultSlug]) { if (!SLUG_RE.test(slug)) { - throw new InputError(`invalid slug "${slug}" — must match ${SLUG_RE} (it becomes tasks-.yml)`, USAGE, 'EUSAGE') + throw new InputError(`invalid slug "${slug}" — must match ${SLUG_RE} (it becomes tasks-.yml)`, USAGE, 'EUSAGE'); } } const write = (/** @type {string} */ relPath, /** @type {string} */ content) => { - const abs = resolve(root, relPath) - mkdirSync(dirname(abs), { recursive: true }) - writeFileSync(abs, content) - return relPath - } + const abs = resolve(root, relPath); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + return relPath; + }; // Pointing at a file that is not there is the archetypal user mistake — the whole thesis of // this row. readFileSync throws a raw ENOENT, which cli.js can only answer with a stack trace; // convert it to an InputError. A non-ENOENT read failure (permissions, is-a-directory) IS // unexpected enough to keep its stack, so re-throw those untouched. - let raw + let raw; try { - raw = readFileSync(inputPath, 'utf8') + raw = readFileSync(inputPath, 'utf8'); } catch (cause) { if (cause instanceof Error && /** @type {NodeJS.ErrnoException} */ (cause).code === 'ENOENT') { - throw new InputError(`no such bd export file: ${inputPath}`, USAGE, 'EUSAGE', { cause }) + throw new InputError(`no such bd export file: ${inputPath}`, USAGE, 'EUSAGE', { cause }); } - throw cause + throw cause; } // One checked parse boundary, shared with the spike — see parseBdExport. Before it, the // rows were `any`, so `BdIssue` bound to nothing and tsc checked this migrator against // a shape it had never actually seen. - const records = parseBdExport(raw) + const records = parseBdExport(raw); - const live = records.filter(r => r.status !== 'closed') + const live = records.filter(r => r.status !== 'closed'); // `.filter(Boolean)` is not cosmetic: BdIssue.id is optional (bd's export is foreign and // we do not get to insist on it), so an id-less row would otherwise put `undefined` into // liveIds and make every edge-liveness check nonsense. projectLive throws on such a row // anyway — this just keeps the set honest. /** @type {Set} */ - const liveIds = new Set(live.flatMap(r => r.id ? [r.id] : [])) + const liveIds = new Set(live.flatMap(r => r.id ? [r.id] : [])); /** @type {string[]} */ - const droppedEdges = [] + const droppedEdges = []; /** @type {string[]} */ - const priorityDefaulted = [] + const priorityDefaulted = []; /** @type {string[]} */ - const droppedLabels = [] + const droppedLabels = []; for (const epicId of epicSlugs.keys()) { - if (!liveIds.has(epicId)) stderr.write(`warning: --epic ${epicId} is not a live issue — its slug will be empty\n`) + if (!liveIds.has(epicId)) stderr.write(`warning: --epic ${epicId} is not a live issue — its slug will be empty\n`); } - const tasks = [] - const decisions = [] + const tasks = []; + const decisions = []; for (const r of live) { - const task = projectLive(r, liveIds, droppedEdges, priorityDefaulted, droppedLabels) + const task = projectLive(r, liveIds, droppedEdges, priorityDefaulted, droppedLabels); // `?? ''` because BdIssue.description is optional and dumpDecision takes a string. This // was a live type lie until the parse boundary stopped being `any`: an undescribed bd // decision passed `undefined` into a `@param {string}`. It survived only because // normalizeBody does `body ?? ''` two calls down. - if (task.type === 'decision') decisions.push({ task, body: r.description ?? '' }) - else tasks.push(task) + if (task.type === 'decision') decisions.push({ task, body: r.description ?? '' }); + else tasks.push(task); } - const grouped = groupTasks(tasks, epicSlugs, defaultSlug) + const grouped = groupTasks(tasks, epicSlugs, defaultSlug); // Archive the FULL snapshot — but ONLY NOW, once every record has projected without // refusing. It used to be written first, "so bd's history is safe" — and that created @@ -531,8 +531,8 @@ export async function runMigration (args) { // An honest ENOSTORE ("this project has no tracker") converted into a confident, empty, // entirely fictional backlog — by the failure path of the migrator itself. A migration // that refuses must leave NO TRACE, so the next attempt starts from an honest absence. - mkdirSync(resolve(root, `${TRACKER_DIR}/_archive`), { recursive: true }) - copyFileSync(resolve(inputPath), resolve(root, `${TRACKER_DIR}/_archive/bd-final-export.jsonl`)) + mkdirSync(resolve(root, `${TRACKER_DIR}/_archive`), { recursive: true }); + copyFileSync(resolve(inputPath), resolve(root, `${TRACKER_DIR}/_archive/bd-final-export.jsonl`)); const written = [ ...[...grouped].map(([slug, slugTasks]) => write( @@ -540,41 +540,41 @@ export async function runMigration (args) { dumpTasks(slug, titles.get(slug) ?? `Live work migrated from bd (${slug})`, slugTasks) )), ...decisions.map(({ body, task }) => write(`${TRACKER_DIR}/decisions/${task.id}.md`, dumpDecision(task, body))), - ] + ]; - stdout.write(`migrated ${live.length} live issues (of ${records.length} total):\n`) - const tally = [...grouped].map(([slug, t]) => `${t.length} → tasks-${slug}.yml`).join(' · ') - stdout.write(` ${tally} · ${decisions.length} → ${TRACKER_DIR}/decisions/\n`) - stdout.write(` archived full snapshot → ${TRACKER_DIR}/_archive/bd-final-export.jsonl\n`) + stdout.write(`migrated ${live.length} live issues (of ${records.length} total):\n`); + const tally = [...grouped].map(([slug, t]) => `${t.length} → tasks-${slug}.yml`).join(' · '); + stdout.write(` ${tally} · ${decisions.length} → ${TRACKER_DIR}/decisions/\n`); + stdout.write(` archived full snapshot → ${TRACKER_DIR}/_archive/bd-final-export.jsonl\n`); if (droppedEdges.length) { - stdout.write(` dropped ${droppedEdges.length} edge(s) to non-live issues:\n`) - for (const d of droppedEdges) stdout.write(` - ${d}\n`) + stdout.write(` dropped ${droppedEdges.length} edge(s) to non-live issues:\n`); + for (const d of droppedEdges) stdout.write(` - ${d}\n`); } if (droppedLabels.length) { // Not cosmetic: a dropped `epic` label un-contains an epic in the MIGRATED repo. - stdout.write(` dropped ${droppedLabels.length} malformed labels list(s) — a lost \`epic\` label makes a container workable again:\n`) - for (const l of droppedLabels) stdout.write(` - ${l}\n`) + stdout.write(` dropped ${droppedLabels.length} malformed labels list(s) — a lost \`epic\` label makes a container workable again:\n`); + for (const l of droppedLabels) stdout.write(` - ${l}\n`); } if (priorityDefaulted.length) { // Priority is the ready-queue's sort key, so this is not cosmetic: it means the // migrated backlog may not come out in the order bd would have given it. - stdout.write(` coerced ${priorityDefaulted.length} unmappable priority/-ies to medium (affects ready ORDER):\n`) - for (const p of priorityDefaulted) stdout.write(` - ${p}\n`) + stdout.write(` coerced ${priorityDefaulted.length} unmappable priority/-ies to medium (affects ready ORDER):\n`); + for (const p of priorityDefaulted) stdout.write(` - ${p}\n`); } - stdout.write(`written:\n${written.map(w => ` ${w}`).join('\n')}\n`) + stdout.write(`written:\n${written.map(w => ` ${w}`).join('\n')}\n`); // `git add -A` skips an ignored file WITHOUT A WORD, and `*.jsonl` / `_archive/` // are unremarkable .gitignore lines — so ask git what it would actually commit // rather than trusting the layout. The two halves carry very different stakes. - const archive = `${TRACKER_DIR}/_archive/bd-final-export.jsonl` + const archive = `${TRACKER_DIR}/_archive/bd-final-export.jsonl`; const check = spawnSync('git', ['-C', root, 'check-ignore', '--stdin'], { input: [...written, archive].join('\n'), encoding: 'utf8', - }) + }); // exit 0 = something matched an ignore rule; 1 = nothing ignored; 128 = not a repo. - const ignored = check.status === 0 ? check.stdout.split('\n').filter(Boolean) : [] - const storeIgnored = ignored.filter(f => !f.includes('_archive')) - const archiveIgnored = ignored.some(f => f.includes('_archive')) + const ignored = check.status === 0 ? check.stdout.split('\n').filter(Boolean) : []; + const storeIgnored = ignored.filter(f => !f.includes('_archive')); + const archiveIgnored = ignored.some(f => f.includes('_archive')); // The live store being ignored is not a policy question — it means the migration // produced nothing durable. Hard stop. @@ -587,7 +587,7 @@ export async function runMigration (args) { // negation on the dir alone can never re-include the files under it. `/**` can. ` line works: !${TRACKER_DIR}/**`, 'EUSAGE' - ) + ); } // The ARCHIVE is a judgment call, and git already knows the answer. Whether the @@ -596,8 +596,8 @@ export async function runMigration (args) { // closed issues now would quietly reverse that. So speak only when the migration // would CHANGE the status quo — never to push a default. (The file is written to disk // regardless: it costs nothing and bd's Dolt DB may not stay readable.) - const beadsTracked = spawnSync('git', ['-C', root, 'ls-files', '.beads'], { encoding: 'utf8' }) - const historyWasVersioned = Boolean(beadsTracked.stdout?.trim()) + const beadsTracked = spawnSync('git', ['-C', root, 'ls-files', '.beads'], { encoding: 'utf8' }); + const historyWasVersioned = Boolean(beadsTracked.stdout?.trim()); if (archiveIgnored && historyWasVersioned) { // A real regression: they DID version bd history, and now it would stop. @@ -605,14 +605,14 @@ export async function runMigration (args) { `\nwarning: this project tracks \`.beads/\` in git, but ${archive} is gitignored —\n` + ' so the bd history you have been versioning would stop being versioned here.\n' + ` Add !${archive} (or !${TRACKER_DIR}/**) to keep it.\n` - ) + ); } else if (archiveIgnored) { stderr.write( `\nnote: ${archive} is gitignored — consistent with \`.beads/\`, which this project\n` + ' never tracked either. Nothing to do: closed issues record what was DONE (your git\n' + ' log and CHANGELOG already tell you that); the backlog is for what comes NEXT, and\n' + ' it commits normally. The archive still exists on disk if you ever want it.\n' - ) + ); } else if (!historyWasVersioned) { // Not ignored, so it WOULD commit — but they never versioned bd history before. // Flag it as a new choice rather than letting it slip in. @@ -621,7 +621,7 @@ export async function runMigration (args) { ' closed issues into git for the first time — this project never tracked `.beads/`.\n' + ' Fine if you want that history queryable; if not, drop the file or ignore it. Your call —\n' + ' git log and CHANGELOG usually already record what was done.\n' - ) + ); } } @@ -632,11 +632,11 @@ export async function runMigration (args) { if (argv[1] && fileURLToPath(import.meta.url) === argv[1]) { runMigration(argv.slice(2)).catch(err => { if (err instanceof InputError) { - stderr.write(`diarie: ${err.message}\n`) - if (err.body) stderr.write('\n' + err.body + '\n') + stderr.write(`diarie: ${err.message}\n`); + if (err.body) stderr.write('\n' + err.body + '\n'); } else { - stderr.write(String(err?.stack ?? err) + '\n') + stderr.write(String(err?.stack ?? err) + '\n'); } - exit(1) - }) + exit(1); + }); } diff --git a/lib/ready.js b/lib/ready.js index a0cd1bc..7899fc7 100644 --- a/lib/ready.js +++ b/lib/ready.js @@ -16,7 +16,7 @@ import { PRIORITY_RANK, VALID_PRIORITIES, VALID_STATUSES, VALID_TYPES, -} from './schema.js' +} from './schema.js'; /** @typedef {import('./store.js').Task} Task */ @@ -30,10 +30,10 @@ import { */ const tally = (keys) => { /** @type {Record} */ - const o = Object.create(null) - for (const k of keys) o[k] = 0 - return o -} + const o = Object.create(null); + for (const k of keys) o[k] = 0; + return o; +}; /** * Increment a tally slot, but only one that the tally already declared. @@ -49,10 +49,10 @@ const tally = (keys) => { * @returns {void} */ const bump = (counts, key) => { - if (key === undefined) return - const n = counts[key] - if (n !== undefined) counts[key] = n + 1 -} + if (key === undefined) return; + const n = counts[key]; + if (n !== undefined) counts[key] = n + 1; +}; /** * Index a parent's WORKABLE children by parent id, split exactly the way deps are. @@ -83,16 +83,16 @@ const bump = (counts, key) => { */ function childrenByParent (tasks) { /** @type {Map} */ - const kids = new Map() + const kids = new Map(); for (const t of tasks) { - if (!t.parent || t.type !== 'task') continue - if (t.status === 'completed') continue - let entry = kids.get(t.parent) - if (!entry) { entry = { active: [], stalled: [] }; kids.set(t.parent, entry) } - if (t.status === 'pending' || t.status === 'in_progress') entry.active.push(t.id) - else entry.stalled.push(`${t.id} (${t.status})`) + if (!t.parent || t.type !== 'task') continue; + if (t.status === 'completed') continue; + let entry = kids.get(t.parent); + if (!entry) { entry = { active: [], stalled: [] }; kids.set(t.parent, entry); } + if (t.status === 'pending' || t.status === 'in_progress') entry.active.push(t.id); + else entry.stalled.push(`${t.id} (${t.status})`); } - return kids + return kids; } /** @@ -121,17 +121,17 @@ function childrenByParent (tasks) { * @returns {{ ready: Task[], blocked: Array, needsAttention: Array }} */ export function computeReady (tasks) { - const byId = new Map(tasks.map(t => [t.id, t])) - const kids = childrenByParent(tasks) + const byId = new Map(tasks.map(t => [t.id, t])); + const kids = childrenByParent(tasks); /** @type {Task[]} */ - const ready = [] + const ready = []; /** @type {Array} */ - const blocked = [] + const blocked = []; /** @type {Array} */ - const needsAttention = [] + const needsAttention = []; for (const task of tasks) { - if (task.status !== 'pending') continue + if (task.status !== 'pending') continue; // A row with NO valid type is MALFORMED, not merely non-workable, and the difference // matters. `type` is required (schema.js REQUIRED_FIELDS), so `undefined` here means the @@ -144,24 +144,24 @@ export function computeReady (tasks) { // So: surface it. A `type: bug` typo (a bd fossil — framings live in `labels` now) is an // ordinary slip, and the tracker must say so rather than quietly rewrite the backlog. if (task.type === undefined) { - needsAttention.push({ ...task, reason: 'missing or invalid `type` — it belongs in no partition (run `diarie validate`)' }) - continue + needsAttention.push({ ...task, reason: 'missing or invalid `type` — it belongs in no partition (run `diarie validate`)' }); + continue; } // Only `task`-type items are workable — `doc`/`decision`/`milestone` are records or // markers (a milestone has "no effort, no assignment"). These ARE excluded silently, and // correctly: they are well-formed things that simply are not work. - if (task.type !== 'task') continue + if (task.type !== 'task') continue; - /** @type {string[]} */ const active = [] // deps still pending/in_progress → blocks - /** @type {string[]} */ const stalled = [] // missing, or terminal-but-not-completed + /** @type {string[]} */ const active = []; // deps still pending/in_progress → blocks + /** @type {string[]} */ const stalled = []; // missing, or terminal-but-not-completed for (const depId of task.deps ?? []) { - const dep = byId.get(depId) - if (!dep) stalled.push(`${depId} (missing)`) - else if (dep.status === 'completed') continue - else if (dep.status === 'pending' || dep.status === 'in_progress') active.push(depId) - else stalled.push(`${depId} (${dep.status})`) + const dep = byId.get(depId); + if (!dep) stalled.push(`${depId} (missing)`); + else if (dep.status === 'completed') continue; + else if (dep.status === 'pending' || dep.status === 'in_progress') active.push(depId); + else stalled.push(`${depId} (${dep.status})`); } // A `parent:` that resolves to nothing gets the same treatment as a dangling dep — @@ -172,14 +172,14 @@ export function computeReady (tasks) { // and every container quietly becomes workable again. No unit test can see that (it // writes both ids by hand, in one consistent space). This makes it scream instead: // one typo surfaces one row, an id-space divergence surfaces EVERY row at once. - if (task.parent && !byId.has(task.parent)) stalled.push(`parent ${task.parent} (missing)`) + if (task.parent && !byId.has(task.parent)) stalled.push(`parent ${task.parent} (missing)`); // Children fold into the SAME two buckets as deps, deliberately. An abandoned child // is not a blocker — it is a question for a human, exactly as an abandoned dep is. - const { active: kidsActive = [], stalled: kidsStalled = [] } = kids.get(task.id) ?? {} - const isEpic = task.labels?.includes('epic') ?? false + const { active: kidsActive = [], stalled: kidsStalled = [] } = kids.get(task.id) ?? {}; + const isEpic = task.labels?.includes('epic') ?? false; - const attention = [...stalled, ...kidsStalled] + const attention = [...stalled, ...kidsStalled]; if (active.length || kidsActive.length) { // `attention` rides ALONG on a blocked row — it is not an either/or. A row can be @@ -196,23 +196,23 @@ export function computeReady (tasks) { blockers: active, ...(kidsActive.length ? { children: kidsActive } : {}), ...(attention.length ? { attention } : {}), - }) + }); } else if (attention.length) { - needsAttention.push({ ...task, reason: attention.join(', ') }) + needsAttention.push({ ...task, reason: attention.join(', ') }); } else if (isEpic) { // Labelled a container, but nothing left inside it: either the work is done and // the epic wants closing, or nobody ever filed its children. Both are real states // a human should see — silently dropping it would hide an empty epic forever. - needsAttention.push({ ...task, reason: 'epic label, no open children — close it or add children' }) + needsAttention.push({ ...task, reason: 'epic label, no open children — close it or add children' }); } else { // A parent whose children are ALL completed is ready again on its own merits: // the container may still carry integration work. Only OPEN children block. - ready.push(task) + ready.push(task); } } - ready.sort((a, b) => (PRIORITY_RANK[a.priority ?? 'medium'] ?? 2) - (PRIORITY_RANK[b.priority ?? 'medium'] ?? 2)) - return { ready, blocked, needsAttention } + ready.sort((a, b) => (PRIORITY_RANK[a.priority ?? 'medium'] ?? 2) - (PRIORITY_RANK[b.priority ?? 'medium'] ?? 2)); + return { ready, blocked, needsAttention }; } /** @@ -224,29 +224,29 @@ export function computeReady (tasks) { * @returns {{ total: number, ready: number, blocked: number, stale: string[], malformedDates: string[], byStatus: Record, byPriority: Record, byType: Record }} */ export function computeStats (tasks, staleDays = 30, now = new Date()) { - const byStatus = tally(VALID_STATUSES) - const byPriority = tally(VALID_PRIORITIES) - const byType = tally(VALID_TYPES) - /** @type {string[]} */ const stale = [] - /** @type {string[]} */ const malformedDates = [] - const cutoff = now.getTime() - staleDays * 86_400_000 + const byStatus = tally(VALID_STATUSES); + const byPriority = tally(VALID_PRIORITIES); + const byType = tally(VALID_TYPES); + /** @type {string[]} */ const stale = []; + /** @type {string[]} */ const malformedDates = []; + const cutoff = now.getTime() - staleDays * 86_400_000; for (const t of tasks) { - bump(byStatus, t.status) - bump(byPriority, t.priority ?? 'medium') + bump(byStatus, t.status); + bump(byPriority, t.priority ?? 'medium'); // No `?? 'task'` default — mirrors computeReady's deliberate non-assumption for // malformed (type-less) input: an untallied key is left alone, so a type-less item // is simply not counted, never misclassified. - bump(byType, t.type) + bump(byType, t.type); if (t.status === 'in_progress' && t.updated) { - const u = Date.parse(t.updated) - if (Number.isNaN(u)) malformedDates.push(t.id) - else if (u < cutoff) stale.push(t.id) + const u = Date.parse(t.updated); + if (Number.isNaN(u)) malformedDates.push(t.id); + else if (u < cutoff) stale.push(t.id); } } - const { blocked, ready } = computeReady(tasks) - return { total: tasks.length, ready: ready.length, blocked: blocked.length, stale, malformedDates, byStatus, byPriority, byType } + const { blocked, ready } = computeReady(tasks); + return { total: tasks.length, ready: ready.length, blocked: blocked.length, stale, malformedDates, byStatus, byPriority, byType }; } /** @@ -255,7 +255,7 @@ export function computeStats (tasks, staleDays = 30, now = new Date()) { * @param {Task} t * @returns {string} */ -export const line = (t) => ` ${t.id} [${t.priority ?? 'medium'}] ${t.title ?? ''}`.trimEnd() +export const line = (t) => ` ${t.id} [${t.priority ?? 'medium'}] ${t.title ?? ''}`.trimEnd(); /** * Render one blocked task, saying WHY — which is the whole reason the view exists. @@ -276,8 +276,8 @@ export function blockedLine (t) { ...(t.blockers.length ? [`← blocked by ${t.blockers.join(', ')}`] : []), ...(t.children?.length ? [`← contains ${t.children.length} open: ${t.children.join(', ')}`] : []), ...(t.attention?.length ? [`! also needs attention: ${t.attention.join(', ')}`] : []), - ] - return `${line(t)} ${why.join(' ')}` + ]; + return `${line(t)} ${why.join(' ')}`; } /** @@ -286,7 +286,7 @@ export function blockedLine (t) { * @param {Task & { reason: string }} t * @returns {string} */ -export const attentionLine = (t) => `${line(t)} ! needs attention: ${t.reason}` +export const attentionLine = (t) => `${line(t)} ! needs attention: ${t.reason}`; /** * Render a tally object as `key=n` pairs, omitting zeros. @@ -294,7 +294,7 @@ export const attentionLine = (t) => `${line(t)} ! needs attention: ${t.reason}` * @param {Record} obj * @returns {string} */ -const tallyRow = (obj) => Object.entries(obj).filter(([, n]) => n).map(([k, n]) => `${k}=${n}`).join(' ') +const tallyRow = (obj) => Object.entries(obj).filter(([, n]) => n).map(([k, n]) => `${k}=${n}`).join(' '); /** * @param {ReturnType} s @@ -306,7 +306,7 @@ export function formatStats (s) { `status: ${tallyRow(s.byStatus)}`, `priority: ${tallyRow(s.byPriority)}`, `type: ${tallyRow(s.byType)}`, - ] - if (s.malformedDates.length) lines.push(`! malformed updated dates: ${s.malformedDates.join(' ')}`) - return lines.join('\n') + '\n' + ]; + if (s.malformedDates.length) lines.push(`! malformed updated dates: ${s.malformedDates.join(' ')}`); + return lines.join('\n') + '\n'; } diff --git a/lib/schema.js b/lib/schema.js index 9c4020d..718502e 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -78,7 +78,7 @@ * write-then-rename, not a lock daemon. */ -import { guardedArrayIncludes } from '@voxpelli/typed-utils' +import { guardedArrayIncludes } from '@voxpelli/typed-utils'; /** @typedef {'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled' | 'deferred'} Status */ /** @typedef {'task' | 'doc' | 'decision' | 'milestone'} TaskType */ @@ -150,22 +150,22 @@ import { guardedArrayIncludes } from '@voxpelli/typed-utils' * catch-all `else` into `stalled`). */ /** @type {Set} */ -export const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed', 'failed', 'cancelled', 'deferred']) +export const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed', 'failed', 'cancelled', 'deferred']); /** @type {Set} */ -export const VALID_TYPES = new Set(['task', 'doc', 'decision', 'milestone']) +export const VALID_TYPES = new Set(['task', 'doc', 'decision', 'milestone']); /** @type {Set} */ -export const VALID_PRIORITIES = new Set(['critical', 'high', 'medium', 'low', 'backlog']) +export const VALID_PRIORITIES = new Set(['critical', 'high', 'medium', 'low', 'backlog']); /** Priority sort order for the ready queue (lower = more urgent). */ -export const PRIORITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, backlog: 4 } +export const PRIORITY_RANK = { critical: 0, high: 1, medium: 2, low: 3, backlog: 4 }; /** Allowed shape of a task `id` (letters/digits then word chars, `.`, `-`). */ -export const ID_RE = /^[A-Z0-9][\w.-]*$/i +export const ID_RE = /^[A-Z0-9][\w.-]*$/i; /** Always-required fields on every task entry. */ -export const REQUIRED_FIELDS = ['id', 'title', 'status', 'type'] +export const REQUIRED_FIELDS = ['id', 'title', 'status', 'type']; /** * The per-repo directory that holds the flat-YAML task store, resolved relative @@ -175,7 +175,7 @@ export const REQUIRED_FIELDS = ['id', 'title', 'status', 'type'] * tool state). Every tool derives the store location from here — never hardcode * the segment. (Renamed from `backlog/` 2026-07-11.) */ -export const TRACKER_DIR = '.diarie' +export const TRACKER_DIR = '.diarie'; /** * Types whose completion should carry stated acceptance criteria (the @@ -186,7 +186,7 @@ export const TRACKER_DIR = '.diarie' * ADVISORY layer, never hard errors. */ /** @type {Set} */ -export const RATCHET_TYPES = new Set(['task']) +export const RATCHET_TYPES = new Set(['task']); /** * Nullish check (a missing YAML key → `undefined`; an explicit `key: null` → `null`). @@ -194,7 +194,7 @@ export const RATCHET_TYPES = new Set(['task']) * @param {unknown} v * @returns {boolean} */ -export const isNil = (v) => v === undefined || v === null +export const isNil = (v) => v === undefined || v === null; /** * Globalize a reference to a task: a bare id takes its file's slug (`slug/id`); an @@ -223,7 +223,7 @@ export const isNil = (v) => v === undefined || v === null */ export const nsId = (ref, slug) => /** @type {GlobalId} */ ( String(ref).includes('/') ? String(ref) : `${slug}/${ref}` -) +); /** * Membership tests that actually NARROW. @@ -240,16 +240,16 @@ export const nsId = (ref, slug) => /** @type {GlobalId} */ ( * @param {unknown} v * @returns {v is Status} */ -export const isStatus = (v) => guardedArrayIncludes(VALID_STATUSES, v) +export const isStatus = (v) => guardedArrayIncludes(VALID_STATUSES, v); /** * @param {unknown} v * @returns {v is TaskType} */ -export const isTaskType = (v) => guardedArrayIncludes(VALID_TYPES, v) +export const isTaskType = (v) => guardedArrayIncludes(VALID_TYPES, v); /** * @param {unknown} v * @returns {v is Priority} */ -export const isPriority = (v) => guardedArrayIncludes(VALID_PRIORITIES, v) +export const isPriority = (v) => guardedArrayIncludes(VALID_PRIORITIES, v); diff --git a/lib/store.js b/lib/store.js index 406b98f..8288f40 100644 --- a/lib/store.js +++ b/lib/store.js @@ -36,17 +36,19 @@ * the first run. */ -import { existsSync, realpathSync } from 'node:fs' -import { readdir, readFile } from 'node:fs/promises' -import { dirname, join, resolve, sep } from 'node:path' -import { env } from 'node:process' +import { existsSync, realpathSync } from 'node:fs'; +import { readdir, readFile } from 'node:fs/promises'; +import { env } from 'node:process'; +import { + dirname, join, resolve, sep, +} from 'node:path'; -import { isObject, isStringArray, isType } from '@voxpelli/typed-utils' -import yaml from 'js-yaml' +import { isObject, isStringArray, isType } from '@voxpelli/typed-utils'; +import yaml from 'js-yaml'; import { isNil, isPriority, isStatus, isTaskType, nsId, TRACKER_DIR, -} from './schema.js' +} from './schema.js'; /** * Thrown when the store root cannot be found. Carries `code` so machine @@ -55,10 +57,10 @@ import { */ export class NoStoreError extends Error { /** @override */ - name = 'NoStoreError' + name = 'NoStoreError'; /** @type {'ENOSTORE'} */ - code = 'ENOSTORE' + code = 'ENOSTORE'; /** * @param {string} from where we looked @@ -69,13 +71,13 @@ export class NoStoreError extends Error { // Say what was actually done. An explicit --root is not a search, and // reporting "searched upward" when we did not is its own small lie. ? `no ${TRACKER_DIR}/ found in ${from} or any parent — run \`diarie init\`, or pass --root ` - : `no ${TRACKER_DIR}/ in ${from} — run \`diarie init\` there, or point --root somewhere else`) + : `no ${TRACKER_DIR}/ in ${from} — run \`diarie init\` there, or point --root somewhere else`); /** @type {string} */ - this.from = from + this.from = from; /** @type {boolean} */ - this.searched = searched + this.searched = searched; } } @@ -102,22 +104,22 @@ export function resolveRoot ({ cwd = process.cwd(), root } = {}) { // the case that matters most, because automated callers (session hooks and the // like) ALWAYS pass `--root`. Trusting it unchecked would hand them back the // very empty-backlog lie this module deletes. - const explicit = root ?? env['TASKS_ROOT'] + const explicit = root ?? env['TASKS_ROOT']; if (explicit) { - const dir = resolve(explicit) - if (!existsSync(join(dir, TRACKER_DIR))) throw new NoStoreError(dir, false) - return dir + const dir = resolve(explicit); + if (!existsSync(join(dir, TRACKER_DIR))) throw new NoStoreError(dir, false); + return dir; } - let dir = resolve(cwd) + let dir = resolve(cwd); for (;;) { if (existsSync(join(dir, TRACKER_DIR))) { - assertNotPluginsOwnStore(dir) - return dir + assertNotPluginsOwnStore(dir); + return dir; } - const parent = dirname(dir) - if (parent === dir) throw new NoStoreError(resolve(cwd), true) // hit the filesystem root - dir = parent + const parent = dirname(dir); + if (parent === dir) throw new NoStoreError(resolve(cwd), true); // hit the filesystem root + dir = parent; } } @@ -147,21 +149,21 @@ export function resolveRoot ({ cwd = process.cwd(), root } = {}) { * @throws {NoStoreError} */ function assertNotPluginsOwnStore (dir) { - const pluginRoot = env['CLAUDE_PLUGIN_ROOT'] - if (!pluginRoot) return + const pluginRoot = env['CLAUDE_PLUGIN_ROOT']; + if (!pluginRoot) return; // realpath BOTH sides. `resolve()` normalizes but does not follow symlinks, and plugin // caches live under paths that are routinely symlinked (on macOS `/tmp` is a link to // `/private/tmp`). Comparing an unresolved prefix against a resolved one silently fails // to match — a containment check that never contains is a guard that never guards. - const plugin = realpath(pluginRoot) - const found = realpath(dir) - if (found !== plugin && !found.startsWith(plugin + sep)) return + const plugin = realpath(pluginRoot); + const found = realpath(dir); + if (found !== plugin && !found.startsWith(plugin + sep)) return; - const err = new NoStoreError(found, true) + const err = new NoStoreError(found, true); err.message = `refusing to serve the PLUGIN's own ${TRACKER_DIR}/ (${found}) as your project's backlog — ` + 'the walk-up from the current directory landed inside the installed plugin. ' + - 'Pass --root (which is what the hooks do), or run from your project root.' - throw err + 'Pass --root (which is what the hooks do), or run from your project root.'; + throw err; } /** @@ -174,7 +176,7 @@ function assertNotPluginsOwnStore (dir) { * @returns {string} */ export function resolveInitRoot ({ cwd = process.cwd(), root } = {}) { - return resolve(root ?? env['TASKS_ROOT'] ?? cwd) + return resolve(root ?? env['TASKS_ROOT'] ?? cwd); } /** @@ -228,7 +230,7 @@ export function resolveInitRoot ({ cwd = process.cwd(), root } = {}) { * @param {LoadedTask} t * @returns {Task} */ -export const strip = ({ _file, _slug, ...task }) => task +export const strip = ({ _file, _slug, ...task }) => task; /** * Coerce a YAML `deps` value to a safe namespaced string[]: an array → namespace @@ -243,10 +245,10 @@ export const strip = ({ _file, _slug, ...task }) => task * @returns {import('./schema.js').GlobalId[]} */ function safeDeps (raw, slug, file, taskId, warn) { - if (isNil(raw)) return [] - if (Array.isArray(raw)) return raw.map(d => nsId(d, slug)) - warn(`${file}: task ${taskId}: "deps" is not a list — treating as empty (run \`diarie validate\`)`) - return [] + if (isNil(raw)) return []; + if (Array.isArray(raw)) return raw.map(d => nsId(d, slug)); + warn(`${file}: task ${taskId}: "deps" is not a list — treating as empty (run \`diarie validate\`)`); + return []; } /** @@ -256,8 +258,8 @@ function safeDeps (raw, slug, file, taskId, warn) { * @returns {string} */ const realpath = (p) => { - try { return realpathSync(resolve(p)) } catch { return resolve(p) } -} + try { return realpathSync(resolve(p)); } catch { return resolve(p); } +}; /** * Build the "this field was rejected" reporter for one task. @@ -271,11 +273,11 @@ const realpath = (p) => { * @returns {(field: string, value: unknown, consequence: string) => void} */ const rejecter = (warn, file, id) => (field, value, consequence) => { - warn(`${file}: task ${id}: invalid ${field} ${JSON.stringify(value)} — ${consequence} (run \`diarie validate\`)`) -} + warn(`${file}: task ${id}: invalid ${field} ${JSON.stringify(value)} — ${consequence} (run \`diarie validate\`)`); +}; /** Matches the task files the store globs. Decisions and docs are deliberately outside it. */ -const TASKS_FILE_RE = /^tasks-.+\.ya?ml$/ +const TASKS_FILE_RE = /^tasks-.+\.ya?ml$/; /** * List the `tasks-.yml` files under a resolved root. @@ -287,16 +289,16 @@ const TASKS_FILE_RE = /^tasks-.+\.ya?ml$/ * @returns {Promise<{ tasksDir: string, names: string[], ignored: string[] }>} */ export async function listTaskFiles (root) { - const tasksDir = join(root, TRACKER_DIR, 'tasks') - if (!existsSync(tasksDir)) return { tasksDir, names: [], ignored: [] } - const entries = await readdir(tasksDir) + const tasksDir = join(root, TRACKER_DIR, 'tasks'); + if (!existsSync(tasksDir)) return { tasksDir, names: [], ignored: [] }; + const entries = await readdir(tasksDir); return { tasksDir, names: entries.filter(f => TASKS_FILE_RE.test(f)), // A dir of non-matching files is not the same as an empty substrate — // callers surface this rather than skip it silently. ignored: entries.filter(f => !f.startsWith('.') && !TASKS_FILE_RE.test(f)), - } + }; } /** @@ -305,7 +307,7 @@ export async function listTaskFiles (root) { * @param {string} file * @returns {string} */ -export const slugOf = (file) => file.replace(/^tasks-/, '').replace(/\.ya?ml$/, '') +export const slugOf = (file) => file.replace(/^tasks-/, '').replace(/\.ya?ml$/, ''); /** * Load and globalize every task under a resolved root. Bare dep ids are @@ -316,12 +318,12 @@ export const slugOf = (file) => file.replace(/^tasks-/, '').replace(/\.ya?ml$/, * @returns {Promise} */ export async function loadTasks (root, warn = () => {}) { - const { names, tasksDir } = await listTaskFiles(root) + const { names, tasksDir } = await listTaskFiles(root); /** @type {LoadedTask[]} */ - const all = [] + const all = []; for (const file of names) { - const slug = slugOf(file) + const slug = slugOf(file); // AN UNPARSEABLE FILE MUST NOT DELETE THE STORE. `yaml.load` throws on malformed YAML, and this // call had no guard — so ONE bad file (a stray unterminated quote) blew the whole read up, landed @@ -338,18 +340,18 @@ export async function loadTasks (root, warn = () => {}) { // `validate` remains the authority that rejects (it already reports `invalid YAML` and exits 2); // the reader's job is to be honest and keep going. Represent the malformed input; never delete it. /** @type {unknown} */ - let doc + let doc; try { - doc = yaml.load(await readFile(join(tasksDir, file), 'utf8')) + doc = yaml.load(await readFile(join(tasksDir, file), 'utf8')); } catch (err) { - warn(`${file}: invalid YAML — the whole file is skipped, so any rows in it are MISSING from every count (run \`diarie validate\`): ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`) - continue + warn(`${file}: invalid YAML — the whole file is skipped, so any rows in it are MISSING from every count (run \`diarie validate\`): ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`); + continue; } - const list = isObject(doc) ? doc['tasks'] : undefined + const list = isObject(doc) ? doc['tasks'] : undefined; if (!isNil(list) && !Array.isArray(list)) { - warn(`${file}: "tasks" is not a list — skipping file (run \`diarie validate\`)`) - continue + warn(`${file}: "tasks" is not a list — skipping file (run \`diarie validate\`)`); + continue; } for (const t of /** @type {unknown[]} */ (list ?? [])) { // `isObject` narrows `unknown` → `Record`, so every field below is @@ -358,10 +360,10 @@ export async function loadTasks (root, warn = () => {}) { // unconditionally, so `loadTasks(): Promise` was a promise the compiler // never checked. (`{...raw, id: 42, nonsense: true}` type-checked clean. Verified.) if (!isObject(t) || isNil(t['id'])) { - warn(`${file}: a task entry has no id — skipping it (run \`diarie validate\`)`) - continue + warn(`${file}: a task entry has no id — skipping it (run \`diarie validate\`)`); + continue; } - const raw = t + const raw = t; // Constructed field by field, never spread. The store's contract is that every task // it hands out lives in ONE id-space, and a spread cannot promise that — it copies @@ -373,7 +375,7 @@ export async function loadTasks (root, warn = () => {}) { // of: `parent` went un-globalized for the tracker's whole life and broke nothing, // because nothing read it. The container rule was the first code to trust it, and it // would have matched zero children for every epic and gone green. - const id = nsId(raw['id'], slug) + const id = nsId(raw['id'], slug); // The ONE knowing assertion in this loader, and it is deliberate. A store may hold // `status: bogus`, and the loader must be able to REPRESENT that — dropping the row @@ -381,9 +383,9 @@ export async function loadTasks (root, warn = () => {}) { // authority that reports it, not this. So: keep the value, and SAY SO. The loader // was previously silent here, which is how a bogus status could sit in `total` while // appearing in no partition and no tally at all. - const rawStatus = raw['status'] + const rawStatus = raw['status']; if (!isStatus(rawStatus)) { - warn(`${file}: task ${id}: invalid status ${JSON.stringify(rawStatus)} — it will not appear in ready/blocked (run \`diarie validate\`)`) + warn(`${file}: task ${id}: invalid status ${JSON.stringify(rawStatus)} — it will not appear in ready/blocked (run \`diarie validate\`)`); } /** @type {LoadedTask} */ @@ -393,7 +395,7 @@ export async function loadTasks (root, warn = () => {}) { deps: safeDeps(raw['deps'], slug, file, String(raw['id']), warn), _slug: slug, _file: file, - } + }; // EVERY rejected field is REPORTED. A guard that drops a value is not the same thing // as a guard that reports it, and conflating the two is how the loader's own type // safety turned into three silent bugs: @@ -416,30 +418,30 @@ export async function loadTasks (root, warn = () => {}) { // a wrong store — `validate` is the authority that rejects it — but it must never be // QUIET about one. Writing a task is a hand-edit (no CRUD helper, by design), so these // are ordinary slips, not exotic input. - const reject = rejecter(warn, file, id) + const reject = rejecter(warn, file, id); - if (!isNil(raw['parent'])) task.parent = nsId(raw['parent'], slug) - if (isType(raw['title'], 'string')) task.title = raw['title'] + if (!isNil(raw['parent'])) task.parent = nsId(raw['parent'], slug); + if (isType(raw['title'], 'string')) task.title = raw['title']; - if (isPriority(raw['priority'])) task.priority = raw['priority'] - else if (!isNil(raw['priority'])) reject('priority', raw['priority'], 'it will be treated as `medium`') + if (isPriority(raw['priority'])) task.priority = raw['priority']; + else if (!isNil(raw['priority'])) reject('priority', raw['priority'], 'it will be treated as `medium`'); - if (isTaskType(raw['type'])) task.type = raw['type'] - else if (!isNil(raw['type'])) reject('type', raw['type'], 'it will appear in NO partition and no tally') + if (isTaskType(raw['type'])) task.type = raw['type']; + else if (!isNil(raw['type'])) reject('type', raw['type'], 'it will appear in NO partition and no tally'); - if (isStringArray(raw['labels'])) task.labels = raw['labels'] - else if (!isNil(raw['labels'])) reject('labels', raw['labels'], 'ALL labels are dropped, including `epic` — a container could be offered as ready work') + if (isStringArray(raw['labels'])) task.labels = raw['labels']; + else if (!isNil(raw['labels'])) reject('labels', raw['labels'], 'ALL labels are dropped, including `epic` — a container could be offered as ready work'); - if (isStringArray(raw['acceptance_criteria'])) task.acceptance_criteria = raw['acceptance_criteria'] - else if (!isNil(raw['acceptance_criteria'])) reject('acceptance_criteria', raw['acceptance_criteria'], 'it is dropped') + if (isStringArray(raw['acceptance_criteria'])) task.acceptance_criteria = raw['acceptance_criteria']; + else if (!isNil(raw['acceptance_criteria'])) reject('acceptance_criteria', raw['acceptance_criteria'], 'it is dropped'); - if (isType(raw['agent'], 'string')) task.agent = raw['agent'] - if (isType(raw['updated'], 'string')) task.updated = raw['updated'] - if (isType(raw['description'], 'string')) task.description = raw['description'] + if (isType(raw['agent'], 'string')) task.agent = raw['agent']; + if (isType(raw['updated'], 'string')) task.updated = raw['updated']; + if (isType(raw['description'], 'string')) task.description = raw['description']; - all.push(task) + all.push(task); } } - return all + return all; } diff --git a/lib/utils/errors.js b/lib/utils/errors.js index c00cfe0..6776522 100644 --- a/lib/utils/errors.js +++ b/lib/utils/errors.js @@ -12,7 +12,7 @@ export class InputError extends Error { /** @override */ - name = 'InputError' + name = 'InputError'; /** * @param {string} message @@ -22,10 +22,10 @@ export class InputError extends Error { * re-thrown as one of ours, so a genuine bug wearing a user-error's clothes stays diagnosable */ constructor (message, body, code, options) { - super(message, options) + super(message, options); /** @type {string|undefined} */ - this.body = body + this.body = body; // A first-class field rather than a post-hoc `/** @type {any} */ (e).code = …` // cast. That cast was not just ugly: in no-semicolon style a line starting with @@ -34,11 +34,11 @@ export class InputError extends Error { // function, with `e` referenced inside its own initializer. It crashed the // ENOSTORE path, which is the one path this whole CLI exists to get right. /** @type {string|undefined} */ - this.code = code + this.code = code; } } export class ResultError extends Error { /** @override */ - name = 'ResultError' + name = 'ResultError'; } diff --git a/lib/utils/exit.js b/lib/utils/exit.js index 4f8a49e..4936449 100644 --- a/lib/utils/exit.js +++ b/lib/utils/exit.js @@ -31,7 +31,7 @@ * nobody checks is a comment, not a guarantee — and this one was already false once. */ -import process from 'node:process' +import process from 'node:process'; /** * Exit 2 — the operation ran and the answer is "no". @@ -54,5 +54,5 @@ import process from 'node:process' * @returns {void} */ export function exitResultError () { - process.exitCode = 2 + process.exitCode = 2; } diff --git a/lib/validate.js b/lib/validate.js index 33aea02..1eb8c01 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -26,11 +26,11 @@ import { isObject, isStringArray, isType, isUnknownArray, -} from '@voxpelli/typed-utils' +} from '@voxpelli/typed-utils'; import { ID_RE, isNil, isPriority, isStatus, isTaskType, nsId, RATCHET_TYPES, REQUIRED_FIELDS, -} from './schema.js' +} from './schema.js'; /** * A task entry exactly as YAML handed it over: known keys, UNKNOWN values. @@ -54,9 +54,9 @@ import { * @returns {string} */ function crossSlugHint (rawDep, resolved, all) { - if (String(rawDep).includes('/')) return '' - const match = [...all.keys()].find(k => k !== resolved && k.endsWith(`/${rawDep}`)) - return match ? ` (did you mean ${match}?)` : '' + if (String(rawDep).includes('/')) return ''; + const match = [...all.keys()].find(k => k !== resolved && k.endsWith(`/${rawDep}`)); + return match ? ` (did you mean ${match}?)` : ''; } /** @@ -66,88 +66,88 @@ function crossSlugHint (rawDep, resolved, all) { * @returns {{ errors: string[], warnings: string[] }} */ export function lintTasks (files) { - /** @type {string[]} */ const errors = [] - /** @type {string[]} */ const warnings = [] - const err = (/** @type {string} */ f, /** @type {string} */ m) => errors.push(`${f}: ${m}`) - const warn = (/** @type {string} */ f, /** @type {string} */ m) => warnings.push(`${f}: ${m}`) + /** @type {string[]} */ const errors = []; + /** @type {string[]} */ const warnings = []; + const err = (/** @type {string} */ f, /** @type {string} */ m) => errors.push(`${f}: ${m}`); + const warn = (/** @type {string} */ f, /** @type {string} */ m) => warnings.push(`${f}: ${m}`); /** @type {Map} */ - const all = new Map() + const all = new Map(); /** * Files whose top-level shape is broken — excluded from the value passes. * * @type {Set} */ - const badFiles = new Set() + const badFiles = new Set(); // --- Pass 0: shape guard (types, not values) — a wrong YAML shape would // otherwise char-split a scalar into nonsense or throw on a non-iterable. --- for (const { name, tasks } of files) { if (!Array.isArray(tasks)) { - err(name, `top-level "tasks" must be a list (got ${tasks === null ? 'null' : typeof tasks})`) - badFiles.add(name) - continue + err(name, `top-level "tasks" must be a list (got ${tasks === null ? 'null' : typeof tasks})`); + badFiles.add(name); + continue; } for (const [i, t] of tasks.entries()) { - if (!isObject(t)) { err(name, `task at index ${i} is not a mapping`); continue } - const label = t['id'] ?? `index ${i}` - if (!isNil(t['deps']) && !isUnknownArray(t['deps'])) err(name, `task ${label}: "deps" must be a list (got ${typeof t['deps']})`) + if (!isObject(t)) { err(name, `task at index ${i} is not a mapping`); continue; } + const label = t['id'] ?? `index ${i}`; + if (!isNil(t['deps']) && !isUnknownArray(t['deps'])) err(name, `task ${label}: "deps" must be a list (got ${typeof t['deps']})`); if (!isNil(t['acceptance_criteria'])) { - if (!isUnknownArray(t['acceptance_criteria'])) err(name, `task ${label}: "acceptance_criteria" must be a list (got ${typeof t['acceptance_criteria']})`) + if (!isUnknownArray(t['acceptance_criteria'])) err(name, `task ${label}: "acceptance_criteria" must be a list (got ${typeof t['acceptance_criteria']})`); // The ELEMENTS, not just the container. `labels` was checked this way and // `acceptance_criteria` was not — and the gap bit immediately: an unquoted `priority: 2` // inside a criterion made YAML parse that element as a MAP, the list stayed an Array, // and validate waved it through. Only the loader's reject-warn caught it, on stderr. - else if (!isStringArray(t['acceptance_criteria'])) err(name, `task ${label}: "acceptance_criteria" entries must all be strings (an unquoted \`key: value\` becomes a map — quote it)`) + else if (!isStringArray(t['acceptance_criteria'])) err(name, `task ${label}: "acceptance_criteria" entries must all be strings (an unquoted \`key: value\` becomes a map — quote it)`); } if (!isNil(t['labels'])) { - if (!isUnknownArray(t['labels'])) err(name, `task ${label}: "labels" must be a list (got ${typeof t['labels']})`) - else if (!isStringArray(t['labels'])) err(name, `task ${label}: "labels" entries must all be strings`) + if (!isUnknownArray(t['labels'])) err(name, `task ${label}: "labels" must be a list (got ${typeof t['labels']})`); + else if (!isStringArray(t['labels'])) err(name, `task ${label}: "labels" entries must all be strings`); } } } // --- Pass 1: per-file structural (field values) --- for (const { name, tasks } of files) { - if (badFiles.has(name)) continue - /** @type {Set} */ const seen = new Set() + if (badFiles.has(name)) continue; + /** @type {Set} */ const seen = new Set(); for (const t of isUnknownArray(tasks) ? tasks : []) { - if (!isObject(t)) continue // shape error already reported in Pass 0 - const label = t['id'] ?? '(no id)' + if (!isObject(t)) continue; // shape error already reported in Pass 0 + const label = t['id'] ?? '(no id)'; for (const field of REQUIRED_FIELDS) { - if (isNil(t[field]) || t[field] === '') err(name, `task ${label} missing required field: ${field}`) + if (isNil(t[field]) || t[field] === '') err(name, `task ${label} missing required field: ${field}`); } if (!isNil(t['id'])) { - if (!ID_RE.test(String(t['id']))) err(name, `task ${label}: invalid id (expected ${ID_RE.source})`) - if (seen.has(t['id'])) err(name, `duplicate id "${t['id']}" within file`) - seen.add(t['id']) + if (!ID_RE.test(String(t['id']))) err(name, `task ${label}: invalid id (expected ${ID_RE.source})`); + if (seen.has(t['id'])) err(name, `duplicate id "${t['id']}" within file`); + seen.add(t['id']); } - if (!isNil(t['status']) && !isStatus(t['status'])) err(name, `task ${label}: invalid status "${t['status']}"`) - if (!isNil(t['type']) && !isTaskType(t['type'])) err(name, `task ${label}: invalid type "${t['type']}"`) - if (!isNil(t['priority']) && !isPriority(t['priority'])) err(name, `task ${label}: invalid priority "${t['priority']}"`) + if (!isNil(t['status']) && !isStatus(t['status'])) err(name, `task ${label}: invalid status "${t['status']}"`); + if (!isNil(t['type']) && !isTaskType(t['type'])) err(name, `task ${label}: invalid type "${t['type']}"`); + if (!isNil(t['priority']) && !isPriority(t['priority'])) err(name, `task ${label}: invalid priority "${t['priority']}"`); if (!isNil(t['updated']) && (!isType(t['updated'], 'string') || !Number.isFinite(Date.parse(t['updated'])))) { - err(name, `task ${label}: invalid updated "${t['updated']}" (expected an ISO date string)`) + err(name, `task ${label}: invalid updated "${t['updated']}" (expected an ISO date string)`); } - if (!isNil(t['id'])) all.set(nsId(t['id'], name), { t, slug: name }) + if (!isNil(t['id'])) all.set(nsId(t['id'], name), { t, slug: name }); } } // --- Pass 2: dep graph (dangling, orphan parent, cycles) --- - /** @type {Map} */ const deps = new Map() - /** @type {Map} */ const parents = new Map() + /** @type {Map} */ const deps = new Map(); + /** @type {Map} */ const parents = new Map(); for (const [gid, { slug, t }] of all) { - const resolved = [] + const resolved = []; for (const d of isUnknownArray(t['deps']) ? t['deps'] : []) { - const gd = nsId(d, slug) - if (all.has(gd)) resolved.push(gd) - else err(slug, `task ${t['id']}: dep "${gd}" does not exist${crossSlugHint(d, gd, all)}`) + const gd = nsId(d, slug); + if (all.has(gd)) resolved.push(gd); + else err(slug, `task ${t['id']}: dep "${gd}" does not exist${crossSlugHint(d, gd, all)}`); } - deps.set(gid, resolved) + deps.set(gid, resolved); if (!isNil(t['parent'])) { - const gp = nsId(t['parent'], slug) - if (gp === gid) err(slug, `task ${t['id']}: parent "${gp}" is itself`) - else if (!all.has(gp)) err(slug, `task ${t['id']}: parent "${gp}" does not exist`) - else parents.set(gid, gp) + const gp = nsId(t['parent'], slug); + if (gp === gid) err(slug, `task ${t['id']}: parent "${gp}" is itself`); + else if (!all.has(gp)) err(slug, `task ${t['id']}: parent "${gp}" does not exist`); + else parents.set(gid, gp); } } // --- The BLOCKING graph. One check, over the union — not two, over the projections. --- @@ -174,46 +174,46 @@ export function lintTasks (files) { // is REVERSED relative to how `parents` stores it (child→parent): it is the parent that // gets blocked. /** @type {Map} */ - const blocking = new Map() + const blocking = new Map(); /** @type {Map} */ - const edgeKind = new Map() + const edgeKind = new Map(); const addEdge = (/** @type {string} */ from, /** @type {string} */ to, /** @type {'dep'|'child'} */ kind) => { - const list = blocking.get(from) - if (list) list.push(to) - else blocking.set(from, [to]) - edgeKind.set(`${from}\u0000${to}`, kind) - } - for (const [gid, targets] of deps) for (const d of targets) addEdge(gid, d, 'dep') - for (const [child, parent] of parents) addEdge(parent, child, 'child') - for (const gid of all.keys()) if (!blocking.has(gid)) blocking.set(gid, []) + const list = blocking.get(from); + if (list) list.push(to); + else blocking.set(from, [to]); + edgeKind.set(`${from}\u0000${to}`, kind); + }; + for (const [gid, targets] of deps) for (const d of targets) addEdge(gid, d, 'dep'); + for (const [child, parent] of parents) addEdge(parent, child, 'child'); + for (const gid of all.keys()) if (!blocking.has(gid)) blocking.set(gid, []); for (const cycle of findCycles(blocking)) { - const head = cycle[0] - const { slug } = (head === undefined ? undefined : all.get(head)) ?? { slug: '(graph)' } + const head = cycle[0]; + const { slug } = (head === undefined ? undefined : all.get(head)) ?? { slug: '(graph)' }; // Name the edge kinds in the path, so the reader can see WHICH relationship to break. const path = cycle.map((node, i) => { - const next = cycle[i + 1] - if (next === undefined) return node - return `${node} ${edgeKind.get(`${node}\u0000${next}`) === 'child' ? '⊃' : '→'} ` - }).join('') - const kinds = new Set(cycle.map((n, i) => edgeKind.get(`${n}\u0000${cycle[i + 1]}`)).filter(Boolean)) - const label = kinds.has('child') ? (kinds.has('dep') ? 'blocking cycle (deps ⨯ containment)' : 'parent cycle') : 'dependency cycle' - err(slug, `${label}: ${path} (→ depends on, ⊃ contains)`) + const next = cycle[i + 1]; + if (next === undefined) return node; + return `${node} ${edgeKind.get(`${node}\u0000${next}`) === 'child' ? '⊃' : '→'} `; + }).join(''); + const kinds = new Set(cycle.map((n, i) => edgeKind.get(`${n}\u0000${cycle[i + 1]}`)).filter(Boolean)); + const label = kinds.has('child') ? (kinds.has('dep') ? 'blocking cycle (deps ⨯ containment)' : 'parent cycle') : 'dependency cycle'; + err(slug, `${label}: ${path} (→ depends on, ⊃ contains)`); } // --- Pass 3: status-transition sanity --- for (const [, { slug, t }] of all) { if (t['status'] === 'in_progress') { for (const d of isUnknownArray(t['deps']) ? t['deps'] : []) { - const dep = all.get(nsId(d, slug))?.t - const depStatus = dep?.['status'] + const dep = all.get(nsId(d, slug))?.t; + const depStatus = dep?.['status']; if (depStatus === 'pending' || depStatus === 'in_progress') { - warn(slug, `task ${t['id']}: in_progress but dep ${nsId(d, slug)} is ${depStatus} (claimed before blockers resolved)`) + warn(slug, `task ${t['id']}: in_progress but dep ${nsId(d, slug)} is ${depStatus} (claimed before blockers resolved)`); } } } if (!isNil(t['agent']) && t['status'] === 'pending') { - warn(slug, `task ${t['id']}: agent "${t['agent']}" set but status is pending (ghost claim — clear agent or claim it)`) + warn(slug, `task ${t['id']}: agent "${t['agent']}" set but status is pending (ghost claim — clear agent or claim it)`); } } @@ -221,11 +221,11 @@ export function lintTasks (files) { for (const [, { slug, t }] of all) { if (t['status'] === 'completed' && isTaskType(t['type']) && RATCHET_TYPES.has(t['type']) && !(isUnknownArray(t['acceptance_criteria']) && t['acceptance_criteria'].length)) { - warn(slug, `task ${t['id']}: completed ${t['type']} with no acceptance_criteria (state done-ness before marking done)`) + warn(slug, `task ${t['id']}: completed ${t['type']} with no acceptance_criteria (state done-ness before marking done)`); } } - return { errors, warnings } + return { errors, warnings }; } /** @@ -240,42 +240,42 @@ export function lintTasks (files) { * @returns {string[][]} one representative path per disjoint cycle (`[]` if acyclic) */ function findCycles (deps) { - const indeg = new Map([...deps.keys()].map(k => [k, 0])) - for (const ds of deps.values()) for (const d of ds) indeg.set(d, (indeg.get(d) ?? 0) + 1) - const queue = [...indeg].filter(([, n]) => n === 0).map(([k]) => k) + const indeg = new Map([...deps.keys()].map(k => [k, 0])); + for (const ds of deps.values()) for (const d of ds) indeg.set(d, (indeg.get(d) ?? 0) + 1); + const queue = [...indeg].filter(([, n]) => n === 0).map(([k]) => k); /** @type {Set} */ - const removed = new Set() + const removed = new Set(); while (queue.length) { - const n = /** @type {string} */ (queue.shift()) - removed.add(n) + const n = /** @type {string} */ (queue.shift()); + removed.add(n); for (const d of deps.get(n) ?? []) { - indeg.set(d, /** @type {number} */ (indeg.get(d)) - 1) - if (indeg.get(d) === 0) queue.push(d) + indeg.set(d, /** @type {number} */ (indeg.get(d)) - 1); + if (indeg.get(d) === 0) queue.push(d); } } - const stuck = [...deps.keys()].filter(k => !removed.has(k)) - stuck.sort() // sort the fresh array in place for deterministic recovery order - if (!stuck.length) return [] + const stuck = [...deps.keys()].filter(k => !removed.has(k)); + stuck.sort(); // sort the fresh array in place for deterministic recovery order + if (!stuck.length) return []; - const inCycle = new Set(stuck) + const inCycle = new Set(stuck); /** @type {Set} */ - const covered = new Set() + const covered = new Set(); /** @type {string[][]} */ - const cycles = [] + const cycles = []; for (const start of stuck) { - if (covered.has(start)) continue - /** @type {string[]} */ const path = [] - /** @type {string | undefined} */ let cur = start + if (covered.has(start)) continue; + /** @type {string[]} */ const path = []; + /** @type {string | undefined} */ let cur = start; /** @type {Set} */ - const visited = new Set() + const visited = new Set(); while (cur && !visited.has(cur)) { - visited.add(cur) - path.push(cur) - cur = (deps.get(cur) ?? []).find(d => inCycle.has(d)) + visited.add(cur); + path.push(cur); + cur = (deps.get(cur) ?? []).find(d => inCycle.has(d)); } - if (cur) path.push(cur) // close the loop - for (const node of path) covered.add(node) - cycles.push(path) + if (cur) path.push(cur); // close the loop + for (const node of path) covered.add(node); + cycles.push(path); } - return cycles + return cycles; } diff --git a/scripts/check-ast-grep-floor.js b/scripts/check-ast-grep-floor.js index 6b48ea1..93d0699 100644 --- a/scripts/check-ast-grep-floor.js +++ b/scripts/check-ast-grep-floor.js @@ -23,41 +23,41 @@ * propagated `exit(r.status)`. */ -import { spawnSync } from 'node:child_process' -import process from 'node:process' +import { spawnSync } from 'node:child_process'; +import process from 'node:process'; -const FLOOR = 25 +const FLOOR = 25; // `--inspect entity` runs the full scan exactly as before (violations on stdout, exit non-zero on // an error) AND prints a per-run summary to stderr. One invocation does both jobs. -const result = spawnSync('ast-grep', ['scan', '--inspect', 'entity'], { encoding: 'utf8' }) +const result = spawnSync('ast-grep', ['scan', '--inspect', 'entity'], { encoding: 'utf8' }); -process.stdout.write(result.stdout ?? '') -process.stderr.write(result.stderr ?? '') +process.stdout.write(result.stdout ?? ''); +process.stderr.write(result.stderr ?? ''); // A spawn failure (ast-grep not on PATH, etc.) leaves status=null and stderr=null — surface the cause // instead of a bare exit-1 with no message. The comment below already anticipated this case. if (result.error) { - process.stderr.write(`check:ast-grep floor: could not run ast-grep — ${result.error.message}\n`) - process.exit(1) + process.stderr.write(`check:ast-grep floor: could not run ast-grep — ${result.error.message}\n`); + process.exit(1); } // A real lint violation (or ast-grep itself failing to run) fails regardless of the floor. if (result.status !== 0) { - process.exit(1) + process.exit(1); } -const match = /scannedFileCount=(\d+)/.exec(result.stderr ?? '') +const match = /scannedFileCount=(\d+)/.exec(result.stderr ?? ''); if (!match) { - process.stderr.write('check:ast-grep floor: could not read scannedFileCount from --inspect output — did the summary format change?\n') - process.exit(1) + process.stderr.write('check:ast-grep floor: could not read scannedFileCount from --inspect output — did the summary format change?\n'); + process.exit(1); } -const scanned = Number(match[1]) +const scanned = Number(match[1]); if (scanned < FLOOR) { process.stderr.write( `check:ast-grep floor: only ${scanned} file(s) scanned, floor is ${FLOOR}. A broad .gitignore line ` + 'likely blinded the bare scan (ast-grep honours .gitignore). See vp-beads-flr.\n' - ) - process.exit(1) + ); + process.exit(1); } diff --git a/test/cli.spec.js b/test/cli.spec.js index 6802085..705c7b2 100644 --- a/test/cli.spec.js +++ b/test/cli.spec.js @@ -14,35 +14,35 @@ * so cannot catch a regression of that bug. */ -import assert from 'node:assert/strict' -import { spawnSync } from 'node:child_process' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { env } from 'node:process' -import { describe, it } from 'node:test' -import { fileURLToPath } from 'node:url' +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { env } from 'node:process'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, -} from 'node:fs' +} from 'node:fs'; -import { TRACKER_DIR } from 'diarie/schema' +import { TRACKER_DIR } from 'diarie/schema'; /** The package root — `test/`'s parent. */ -const PKG = fileURLToPath(new URL('..', import.meta.url)) -const FIXTURES = join(PKG, 'test', 'fixtures') +const PKG = fileURLToPath(new URL('..', import.meta.url)); +const FIXTURES = join(PKG, 'test', 'fixtures'); /** * The container/epic cases live in their OWN store. Folding them into FIXTURES would * have shifted that suite's green count assertions ("total 9", "2 file(s)"), and * rewriting a passing assertion to accommodate your own change is how a regression * gets waved through. New behaviour, new fixture; the old guarantees stay untouched. */ -const EPICS = join(PKG, 'test', 'fixtures-epics') +const EPICS = join(PKG, 'test', 'fixtures-epics'); /** The real CLI, invoked exactly as every consumer invokes it. */ -const CLI = join(PKG, 'cli.js') -const READY = ['ready'] -const VALIDATE = ['validate'] -const STATS = ['stats'] +const CLI = join(PKG, 'cli.js'); +const READY = ['ready']; +const VALIDATE = ['validate']; +const STATS = ['stats']; /** * A temp dir that cleans itself up when the test ends. @@ -52,9 +52,9 @@ const STATS = ['stats'] * @returns {string} */ function tmpDir (t, prefix) { - const dir = mkdtempSync(join(tmpdir(), prefix)) - t.after(() => rmSync(dir, { recursive: true, force: true })) - return dir + const dir = mkdtempSync(join(tmpdir(), prefix)); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; } /** @@ -66,9 +66,9 @@ function tmpDir (t, prefix) { * @returns {string} dir */ function seedStore (dir, slug, body) { - mkdirSync(join(dir, TRACKER_DIR, 'tasks'), { recursive: true }) - if (body !== undefined) writeFileSync(join(dir, TRACKER_DIR, 'tasks', `tasks-${slug}.yml`), body) - return dir + mkdirSync(join(dir, TRACKER_DIR, 'tasks'), { recursive: true }); + if (body !== undefined) writeFileSync(join(dir, TRACKER_DIR, 'tasks', `tasks-${slug}.yml`), body); + return dir; } /** @@ -79,7 +79,7 @@ function seedStore (dir, slug, body) { */ function brokenRow (t) { return seedStore(tmpDir(t, 'diarie-warn-'), 'a', - 'tasks:\n - id: T-1\n title: malformed required status\n status: open\n type: task\n') + 'tasks:\n - id: T-1\n title: malformed required status\n status: open\n type: task\n'); } /** @@ -91,10 +91,10 @@ function brokenRow (t) { function bigStore (t) { const rows = Array.from({ length: 400 }, (_, i) => ` - id: T-${i}\n title: task ${i} with a reasonably long title to bulk out the payload\n status: pending\n type: task\n` - ).join('') + ).join(''); // `in-progress` (hyphen) is rejected by the loader — the row is dropped, so --strict exits 2. return seedStore(tmpDir(t, 'diarie-big-'), 'a', - `tasks:\n${rows} - id: BAD\n title: dropped\n status: in-progress\n type: task\n`) + `tasks:\n${rows} - id: BAD\n title: dropped\n status: in-progress\n type: task\n`); } /** @@ -105,9 +105,9 @@ function bigStore (t) { */ function halfBroken (t) { const dir = seedStore(tmpDir(t, 'diarie-badyaml-'), 'a', - 'tasks:\n - id: T-2\n title: THE LIVE CLAIM\n status: in_progress\n type: task\n') - writeFileSync(join(dir, TRACKER_DIR, 'tasks', 'tasks-b.yml'), 'tasks:\n - id: X\n title: "unterminated\n') - return dir + 'tasks:\n - id: T-2\n title: THE LIVE CLAIM\n status: in_progress\n type: task\n'); + writeFileSync(join(dir, TRACKER_DIR, 'tasks', 'tasks-b.yml'), 'tasks:\n - id: X\n title: "unterminated\n'); + return dir; } /** @@ -126,76 +126,76 @@ function halfBroken (t) { * @returns {{ code: number, out: string, err: string, both: string }} */ function run (command, args, tasksRoot, { cwd = PKG, extraEnv = {} } = {}) { - const seam = tasksRoot ? { TASKS_ROOT: tasksRoot } : {} + const seam = tasksRoot ? { TASKS_ROOT: tasksRoot } : {}; const r = spawnSync('node', [CLI, ...command, ...args], { cwd, env: { ...env, ...seam, ...extraEnv }, encoding: 'utf8', - }) - const out = r.stdout ?? '' - const err = r.stderr ?? '' - return { code: r.status ?? 1, out, err, both: out + err } + }); + const out = r.stdout ?? ''; + const err = r.stderr ?? ''; + return { code: r.status ?? 1, out, err, both: out + err }; } describe('ready CLI (against test/fixtures)', () => { it('default: exit 0', () => { - assert.equal(run(READY, [], FIXTURES).code, 0) - }) + assert.equal(run(READY, [], FIXTURES).code, 0); + }); it('default: shows the ready cross-file/no-dep tasks', () => { - const { both: out } = run(READY, [], FIXTURES) - assert.ok(/beta\/T-2/.test(out) && /beta\/T-1/.test(out) && /alpha\/T-2/.test(out)) - }) + const { both: out } = run(READY, [], FIXTURES); + assert.ok(/beta\/T-2/.test(out) && /beta\/T-1/.test(out) && /alpha\/T-2/.test(out)); + }); it('default: omits the blocked task (alpha/T-3)', () => { - assert.ok(!/alpha\/T-3 /.test(run(READY, [], FIXTURES).both)) - }) + assert.ok(!/alpha\/T-3 /.test(run(READY, [], FIXTURES).both)); + }); it('--json: exit 0 + parses', () => { - const { both: out, code } = run(READY, ['--json'], FIXTURES) - assert.ok(code === 0 && Array.isArray(JSON.parse(out).ready)) - }) + const { both: out, code } = run(READY, ['--json'], FIXTURES); + assert.ok(code === 0 && Array.isArray(JSON.parse(out).ready)); + }); it('--json: ready includes beta/T-2', () => { - const data = JSON.parse(run(READY, ['--json'], FIXTURES).both) - assert.ok(data.ready.some((/** @type {{ id: string }} */ t) => t.id === 'beta/T-2')) - }) + const data = JSON.parse(run(READY, ['--json'], FIXTURES).both); + assert.ok(data.ready.some((/** @type {{ id: string }} */ t) => t.id === 'beta/T-2')); + }); it('--json: provenance fields stripped', () => { - const { both: out } = run(READY, ['--json'], FIXTURES) - assert.ok(!out.includes('_slug') && !out.includes('_file')) - }) + const { both: out } = run(READY, ['--json'], FIXTURES); + assert.ok(!out.includes('_slug') && !out.includes('_file')); + }); it('stats: exit 0 + counts all 9 tasks (6 task + doc/decision/milestone)', () => { - const { both: out, code } = run(STATS, [], FIXTURES) - assert.ok(code === 0 && /total 9/.test(out)) - }) + const { both: out, code } = run(STATS, [], FIXTURES); + assert.ok(code === 0 && /total 9/.test(out)); + }); it('default ready set never includes the doc/decision/milestone fixtures (type gate, decision vp-beads-etm)', () => { - const { both: out, code } = run(READY, ['--json'], FIXTURES) - const data = JSON.parse(out) - assert.ok(code === 0 && !data.ready.some((/** @type {{ id: string }} */ t) => ['alpha/D-1', 'alpha/M-1', 'beta/DEC-1'].includes(t.id))) - }) + const { both: out, code } = run(READY, ['--json'], FIXTURES); + const data = JSON.parse(out); + assert.ok(code === 0 && !data.ready.some((/** @type {{ id: string }} */ t) => ['alpha/D-1', 'alpha/M-1', 'beta/DEC-1'].includes(t.id))); + }); it('--blocked: exit 0 + shows the genuinely blocked task', () => { - const { both: out, code } = run(READY, ['--blocked'], FIXTURES) - assert.ok(code === 0 && /alpha\/T-3/.test(out)) - }) + const { both: out, code } = run(READY, ['--blocked'], FIXTURES); + assert.ok(code === 0 && /alpha\/T-3/.test(out)); + }); it('--blocked: never includes the doc/decision/milestone fixtures either (type gate applies to all three buckets)', () => { - const { both: out } = run(READY, ['--blocked'], FIXTURES) - assert.ok(!['alpha/D-1', 'alpha/M-1', 'beta/DEC-1'].some(id => out.includes(id))) - }) + const { both: out } = run(READY, ['--blocked'], FIXTURES); + assert.ok(!['alpha/D-1', 'alpha/M-1', 'beta/DEC-1'].some(id => out.includes(id))); + }); it('stats --stale: flags the old in_progress task', () => { - const { both: out, code } = run(STATS, ['--stale', '--days', '30'], FIXTURES) - assert.ok(code === 0 && /alpha\/T-4/.test(out)) - }) + const { both: out, code } = run(STATS, ['--stale', '--days', '30'], FIXTURES); + assert.ok(code === 0 && /alpha\/T-4/.test(out)); + }); it('--filter in_progress: shows alpha/T-4', () => { - const { both: out, code } = run(READY, ['--filter', 'in_progress'], FIXTURES) - assert.ok(code === 0 && /alpha\/T-4/.test(out)) - }) + const { both: out, code } = run(READY, ['--filter', 'in_progress'], FIXTURES); + assert.ok(code === 0 && /alpha\/T-4/.test(out)); + }); // THESE TWO ASSERTED ONLY `code === 1`, AND THAT IS HOW THE BUG GOT THROUGH. // @@ -209,19 +209,19 @@ describe('ready CLI (against test/fixtures)', () => { // Assert the SENTENCE. it('--filter : exits 1 AND says which values are legal', () => { - const { code, err } = run(READY, ['--filter', 'bogus'], FIXTURES) - assert.equal(code, 1) - assert.match(err, /--filter must be one of:/) - assert.doesNotMatch(err, /unexpected error/) - }) + const { code, err } = run(READY, ['--filter', 'bogus'], FIXTURES); + assert.equal(code, 1); + assert.match(err, /--filter must be one of:/); + assert.doesNotMatch(err, /unexpected error/); + }); it('--days : exits 1 AND names the constraint', () => { - const { code, err } = run(STATS, ['--days', 'abc'], FIXTURES) - assert.equal(code, 1) - assert.match(err, /--days must be a non-negative number/) - assert.doesNotMatch(err, /unexpected error/) - }) -}) + const { code, err } = run(STATS, ['--days', 'abc'], FIXTURES); + assert.equal(code, 1); + assert.match(err, /--days must be a non-negative number/); + assert.doesNotMatch(err, /unexpected error/); + }); +}); // These MUST run through the real file-load path, not inline task arrays. The bug was // never in the ready rule; it was in the ID-SPACE. `loadTasks` globalized `id` and @@ -230,51 +230,51 @@ describe('ready CLI (against test/fixtures)', () => { // in one consistent id-space, and therefore CANNOT SEE THAT BUG — it would pass against // a tracker that still offers every epic as ready. Only a real store on disk can. describe('containers: an epic is not workable (vp-beads-epc) — THROUGH loadTasks', () => { - const { code, out } = run(READY, ['--json'], EPICS) - const j = JSON.parse(out) + const { code, out } = run(READY, ['--json'], EPICS); + const j = JSON.parse(out); /** * @param {string} id * @returns {boolean} */ - const inReady = (id) => j.ready.some((/** @type {{ id: string }} */ t) => t.id === id) + const inReady = (id) => j.ready.some((/** @type {{ id: string }} */ t) => t.id === id); /** * @param {string} id * @returns {{ id: string, blockers: string[], children?: string[] } | undefined} */ - const blockedRow = (id) => j.blocked.find((/** @type {{ id: string }} */ t) => t.id === id) + const blockedRow = (id) => j.blocked.find((/** @type {{ id: string }} */ t) => t.id === id); /** * @param {string} id * @returns {{ id: string, reason: string } | undefined} */ - const attnRow = (id) => j.needsAttention.find((/** @type {{ id: string }} */ t) => t.id === id) + const attnRow = (id) => j.needsAttention.find((/** @type {{ id: string }} */ t) => t.id === id); it('exit 0', () => { - assert.equal(code, 0) - }) + assert.equal(code, 0); + }); // The STRUCTURAL predicate, alone: open children, no epic label. it('plain parent with an open child is NOT ready', () => { - assert.ok(!inReady('alpha/P-OPEN')) - }) + assert.ok(!inReady('alpha/P-OPEN')); + }); it('...it is blocked, and says which children contain the work', () => { - assert.equal(blockedRow('alpha/P-OPEN')?.children?.includes('alpha/C-OPEN'), true) - }) + assert.equal(blockedRow('alpha/P-OPEN')?.children?.includes('alpha/C-OPEN'), true); + }); it('...and the CHILD is ready (you work the children, not the container)', () => { - assert.ok(inReady('alpha/C-OPEN')) - }) + assert.ok(inReady('alpha/C-OPEN')); + }); // The DECLARATIVE predicate, alone. This is the assertion the live store cannot make: // vp-beads-l9i is epic-labelled AND has open children, so a structural-only fix makes // it vanish from `ready` anyway and this criterion would pass UNIMPLEMENTED. it('epic label with NO open children is NOT ready (label predicate, isolated)', () => { - assert.ok(!inReady('alpha/E-EMPTY')) - }) + assert.ok(!inReady('alpha/E-EMPTY')); + }); it('...it surfaces in needsAttention rather than vanishing', () => { - assert.ok(/no open children/.test(attnRow('alpha/E-EMPTY')?.reason ?? '')) - }) + assert.ok(/no open children/.test(attnRow('alpha/E-EMPTY')?.reason ?? '')); + }); // Both at once — the vp-beads-l9i shape. Containment wins and names its children. // @@ -285,32 +285,32 @@ describe('containers: an epic is not workable (vp-beads-epc) — THROUGH loadTas // actually isolates the regression is the BARE child, which is also the only shape // the live store uses. it('epic WITH open children is BLOCKED (not merely absent from ready)', () => { - assert.notEqual(blockedRow('alpha/E-OPEN'), undefined) - }) + assert.notEqual(blockedRow('alpha/E-OPEN'), undefined); + }); it('...and its BARE-parent child is counted (the shape the live store uses)', () => { - assert.equal(blockedRow('alpha/E-OPEN')?.children?.includes('alpha/C-EPIC'), true) - }) + assert.equal(blockedRow('alpha/E-OPEN')?.children?.includes('alpha/C-EPIC'), true); + }); it('...blocked by its children, not by "blockers" (deps mean something else)', () => { - assert.ok(blockedRow('alpha/E-OPEN')?.blockers.length === 0 && (blockedRow('alpha/E-OPEN')?.children?.length ?? 0) === 2) - }) + assert.ok(blockedRow('alpha/E-OPEN')?.blockers.length === 0 && (blockedRow('alpha/E-OPEN')?.children?.length ?? 0) === 2); + }); // A cross-file parent is written slug-qualified, so it resolves WITHOUT the namespacing // fix and cannot guard it (mutation-tested). What it guards is idempotency: nsId must // pass `alpha/E-OPEN` through, not double-prefix it to `beta/alpha/E-OPEN`. it('an already-qualified cross-file parent resolves (nsId is idempotent)', () => { - assert.equal(blockedRow('alpha/E-OPEN')?.children?.includes('beta/C-CROSS'), true) - }) + assert.equal(blockedRow('alpha/E-OPEN')?.children?.includes('beta/C-CROSS'), true); + }); // The CONVERSE — guards a fix that over-excludes every parent forever. it('parent whose children are ALL completed is STILL ready', () => { - assert.ok(inReady('alpha/P-DONE')) - }) + assert.ok(inReady('alpha/P-DONE')); + }); it('a childless task is unaffected', () => { - assert.ok(inReady('alpha/PLAIN')) - }) + assert.ok(inReady('alpha/PLAIN')); + }); // The dependency-cycle hint fires on "0 ready, but things are blocked" — which now has // an innocent cause it never had before: a container blocked purely by its own open @@ -327,10 +327,10 @@ describe('containers: an epic is not workable (vp-beads-epc) — THROUGH loadTas const dir = seedStore(tmpDir(t, 'diarie-hint-'), 'x', 'tasks:\n' + ' - id: E\n title: container\n status: pending\n type: task\n labels: [epic]\n' + - ' - id: C\n title: its open child\n status: in_progress\n type: task\n parent: E\n') - const j = JSON.parse(run(READY, ['--json'], dir).out) - assert.ok(j.ready.length === 0 && j.blocked.length === 1) - }) + ' - id: C\n title: its open child\n status: in_progress\n type: task\n parent: E\n'); + const j = JSON.parse(run(READY, ['--json'], dir).out); + assert.ok(j.ready.length === 0 && j.blocked.length === 1); + }); // THIS ASSERTION WAS INVERTED, DELIBERATELY, ON EVIDENCE — read before "fixing" it back. // @@ -348,10 +348,10 @@ describe('containers: an epic is not workable (vp-beads-epc) — THROUGH loadTas const dir = seedStore(tmpDir(t, 'diarie-hint-'), 'x', 'tasks:\n' + ' - id: E\n title: container\n status: pending\n type: task\n labels: [epic]\n' + - ' - id: C\n title: its open child\n status: in_progress\n type: task\n parent: E\n') - const j = JSON.parse(run(READY, ['--json'], dir).out) - assert.equal(typeof j.hint, 'string') - }) + ' - id: C\n title: its open child\n status: in_progress\n type: task\n parent: E\n'); + const j = JSON.parse(run(READY, ['--json'], dir).out); + assert.equal(typeof j.hint, 'string'); + }); it('a REAL dep-block with 0 ready still warns (the filter must not suppress this)', (t) => { // The CONVERSE, and it is what stops the filter from over-suppressing: a GENUINE @@ -359,15 +359,15 @@ describe('containers: an epic is not workable (vp-beads-epc) — THROUGH loadTas const dir = seedStore(tmpDir(t, 'diarie-hint2-'), 'x', 'tasks:\n' + ' - id: T-1\n title: claimed blocker\n status: in_progress\n type: task\n' + - ' - id: T-2\n title: waits on T-1\n status: pending\n type: task\n deps: [T-1]\n') - const j = JSON.parse(run(READY, ['--json'], dir).out) - assert.ok(j.ready.length === 0 && j.blocked.length === 1 && j.hint !== undefined) - }) + ' - id: T-2\n title: waits on T-1\n status: pending\n type: task\n deps: [T-1]\n'); + const j = JSON.parse(run(READY, ['--json'], dir).out); + assert.ok(j.ready.length === 0 && j.blocked.length === 1 && j.hint !== undefined); + }); it('the container fixtures are themselves a valid store', () => { - assert.equal(run(VALIDATE, [], EPICS).code, 0) - }) -}) + assert.equal(run(VALIDATE, [], EPICS).code, 0); + }); +}); // Proven against a real installed plugin by an adversarial review: a plugin's `.diarie/` is // COMMITTED, so a marketplace install ships its tasks into every consumer's plugin cache. The @@ -390,32 +390,32 @@ describe('containers: an epic is not workable (vp-beads-epc) — THROUGH loadTas * @returns {string} */ const fakePlugin = (t) => seedStore(tmpDir(t, 'diarie-plugin-'), 'theirs', - 'tasks:\n - id: PLUG-1\n title: the plugin own task\n status: pending\n type: task\n') + 'tasks:\n - id: PLUG-1\n title: the plugin own task\n status: pending\n type: task\n'); describe('the CLI must never serve a PLUGIN\'s own backlog to a consumer', () => { it('refuses to serve the plugin\'s own store when cwd lands inside the plugin', (t) => { - const plugin = fakePlugin(t) + const plugin = fakePlugin(t); // No TASKS_ROOT: the walk-up from cwd (= the plugin) is exactly the consumer's accident. - const { code } = run(READY, [], '', { extraEnv: { CLAUDE_PLUGIN_ROOT: plugin }, cwd: plugin }) - assert.notEqual(code, 0) - }) + const { code } = run(READY, [], '', { extraEnv: { CLAUDE_PLUGIN_ROOT: plugin }, cwd: plugin }); + assert.notEqual(code, 0); + }); it('...and says exactly why, naming --root', (t) => { - const plugin = fakePlugin(t) - const { err } = run(READY, [], '', { extraEnv: { CLAUDE_PLUGIN_ROOT: plugin }, cwd: plugin }) - assert.ok(/refusing to serve the PLUGIN/.test(err) && /--root/.test(err)) - }) + const plugin = fakePlugin(t); + const { err } = run(READY, [], '', { extraEnv: { CLAUDE_PLUGIN_ROOT: plugin }, cwd: plugin }); + assert.ok(/refusing to serve the PLUGIN/.test(err) && /--root/.test(err)); + }); it('an explicit --root still works from inside the plugin (the hooks path)', (t) => { - const plugin = fakePlugin(t) + const plugin = fakePlugin(t); // The consumer's real path: run the plugin's CLI, but pointed at THEIR project. const mine = seedStore(tmpDir(t, 'diarie-consumer-'), 'mine', - 'tasks:\n - id: MINE-1\n title: the consumer own task\n status: pending\n type: task\n') - const { code, out } = run(READY, ['--json', '--root', mine], plugin, { extraEnv: { CLAUDE_PLUGIN_ROOT: plugin }, cwd: plugin }) - const j = JSON.parse(out) - assert.ok(code === 0 && j.ready.length === 1 && j.ready[0].id === 'mine/MINE-1') - }) -}) + 'tasks:\n - id: MINE-1\n title: the consumer own task\n status: pending\n type: task\n'); + const { code, out } = run(READY, ['--json', '--root', mine], plugin, { extraEnv: { CLAUDE_PLUGIN_ROOT: plugin }, cwd: plugin }); + const j = JSON.parse(out); + assert.ok(code === 0 && j.ready.length === 1 && j.ready[0].id === 'mine/MINE-1'); + }); +}); // Mutation-proven gap: replacing the `type` or `labels` guard with an unguarded assignment // left all 118 tests green. The centrepiece of the store rewrite had ZERO coverage, and it @@ -429,48 +429,48 @@ describe('the loader REPORTS every field it rejects (a guard that drops is not a ' - id: C\n title: its open child\n status: pending\n type: task\n parent: E\n' + // `type: bug` — a bd fossil; framings live in `labels` now. ' - id: B\n title: type is a bd framing, not a type\n status: pending\n type: bug\n' + - ' - id: P\n title: priority not in the enum\n status: pending\n type: task\n priority: urgent\n' + ' - id: P\n title: priority not in the enum\n status: pending\n type: task\n priority: urgent\n'; it('a scalar `labels:` is REPORTED, not silently dropped', (t) => { - const { err } = run(READY, ['--json'], seedStore(tmpDir(t, 'diarie-reject-'), 'x', BAD)) - assert.ok(/invalid labels/.test(err)) - }) + const { err } = run(READY, ['--json'], seedStore(tmpDir(t, 'diarie-reject-'), 'x', BAD)); + assert.ok(/invalid labels/.test(err)); + }); it('...and it says WHY it matters (a lost `epic` label re-arms the container bug)', (t) => { - const { err } = run(READY, ['--json'], seedStore(tmpDir(t, 'diarie-reject-'), 'x', BAD)) - assert.ok(/epic/.test(err)) - }) + const { err } = run(READY, ['--json'], seedStore(tmpDir(t, 'diarie-reject-'), 'x', BAD)); + assert.ok(/epic/.test(err)); + }); it('an invalid `type:` is REPORTED', (t) => { - const { err } = run(READY, ['--json'], seedStore(tmpDir(t, 'diarie-reject-'), 'x', BAD)) - assert.ok(/invalid type/.test(err)) - }) + const { err } = run(READY, ['--json'], seedStore(tmpDir(t, 'diarie-reject-'), 'x', BAD)); + assert.ok(/invalid type/.test(err)); + }); it('an invalid `priority:` is REPORTED', (t) => { - const { err } = run(READY, ['--json'], seedStore(tmpDir(t, 'diarie-reject-'), 'x', BAD)) - assert.ok(/invalid priority/.test(err)) - }) + const { err } = run(READY, ['--json'], seedStore(tmpDir(t, 'diarie-reject-'), 'x', BAD)); + assert.ok(/invalid priority/.test(err)); + }); it('a row with an invalid `type` surfaces in needsAttention rather than vanishing', (t) => { // The row with a broken type must not simply VANISH — it counts toward `total`, so it // has to appear in an answer. Silently absent from every partition is the whole bug. - const { out } = run(READY, ['--json'], seedStore(tmpDir(t, 'diarie-reject-'), 'x', BAD)) - const j = JSON.parse(out) - assert.ok(j.needsAttention.some((/** @type {{ id: string, reason: string }} */ t2) => t2.id === 'x/B' && /type/.test(t2.reason))) - }) -}) + const { out } = run(READY, ['--json'], seedStore(tmpDir(t, 'diarie-reject-'), 'x', BAD)); + const j = JSON.parse(out); + assert.ok(j.needsAttention.some((/** @type {{ id: string, reason: string }} */ t2) => t2.id === 'x/B' && /type/.test(t2.reason))); + }); +}); describe('the --blocked TEXT rendering (the default human output — JSON-only tests miss it)', () => { it('--blocked: a container names WHICH children hold it', () => { - const { code, out } = run(READY, ['--blocked'], EPICS) - assert.ok(code === 0 && /alpha\/E-OPEN.*← contains 2 open:/.test(out)) - }) + const { code, out } = run(READY, ['--blocked'], EPICS); + assert.ok(code === 0 && /alpha\/E-OPEN.*← contains 2 open:/.test(out)); + }); it('--blocked: a container is never mislabelled "blocked by" (that phrase means DEPS)', () => { - const { out } = run(READY, ['--blocked'], EPICS) - assert.ok(!/alpha\/E-OPEN.*← blocked by/.test(out)) - }) -}) + const { out } = run(READY, ['--blocked'], EPICS); + assert.ok(!/alpha\/E-OPEN.*← blocked by/.test(out)); + }); +}); // peowly-commands takes `args`, not `argv`. main.js passed `argv`, which is not in its // options type, so it was SILENTLY IGNORED and the parser fell back to `process.argv`. @@ -495,27 +495,27 @@ describe('driving cli() with its own args (no ordinary spawn can catch this)', ( it('cli(argv) parses ITS OWN argv, not process.argv', () => { // By path, not by package subpath: `lib/main.js` is deliberately NOT in diarie's // `exports` map (only `.` and `./schema` are public). The CLI entry is internal. - const main = new URL('../lib/main.js', import.meta.url).href + const main = new URL('../lib/main.js', import.meta.url).href; const script = `const { cli } = await import(${JSON.stringify(main)})\n` + - `await cli(['ready', '--json', '--root', ${JSON.stringify(FIXTURES)}])\n` - const r = spawnSync('node', ['--input-type=module', '-e', script], { encoding: 'utf8' }) - let parsed - try { parsed = JSON.parse(r.stdout ?? '') } catch { /* left undefined */ } - assert.ok(Array.isArray(parsed?.ready) && parsed.ready.some((/** @type {{ id: string }} */ t) => t.id === 'beta/T-2')) - }) -}) + `await cli(['ready', '--json', '--root', ${JSON.stringify(FIXTURES)}])\n`; + const r = spawnSync('node', ['--input-type=module', '-e', script], { encoding: 'utf8' }); + let parsed; + try { parsed = JSON.parse(r.stdout ?? ''); } catch { /* left undefined */ } + assert.ok(Array.isArray(parsed?.ready) && parsed.ready.some((/** @type {{ id: string }} */ t) => t.id === 'beta/T-2')); + }); +}); describe('validate CLI (against test/fixtures)', () => { it('clean fixtures: exit 0 + "passed (2 file(s))"', () => { - const { both: out, code } = run(VALIDATE, [], FIXTURES) - assert.ok(code === 0 && /passed \(2 file\(s\)\)/.test(out)) - }) + const { both: out, code } = run(VALIDATE, [], FIXTURES); + assert.ok(code === 0 && /passed \(2 file\(s\)\)/.test(out)); + }); it('--json clean: exit 0 + {clean:true}', () => { - const { both: out, code } = run(VALIDATE, ['--json'], FIXTURES) - assert.ok(code === 0 && JSON.parse(out).clean === true) - }) -}) + const { both: out, code } = run(VALIDATE, ['--json'], FIXTURES); + assert.ok(code === 0 && JSON.parse(out).clean === true); + }); +}); // An ABSENT store is an ERROR. These assertions used to read `exit 0 + skips` — they // PINNED the bug. If they ever go red again, do NOT "repair" them by restoring @@ -523,91 +523,91 @@ describe('validate CLI (against test/fixtures)', () => { // store, prints an empty backlog to stdout and its only complaint to a stderr that // ten call sites discard. describe('the absent-vs-empty distinction (the defect this contract exists to kill)', () => { - const nowhere = join(tmpdir(), 'vp-beads-nonexistent-xyz') + const nowhere = join(tmpdir(), 'vp-beads-nonexistent-xyz'); it('ready, absent store: exits NON-ZERO', () => { - assert.notEqual(run(READY, ['--json'], nowhere).code, 0) - }) + assert.notEqual(run(READY, ['--json'], nowhere).code, 0); + }); it('ready, absent store: ENOSTORE on STDOUT (never stderr — that stream is discarded)', () => { - const { err, out } = run(READY, ['--json'], nowhere) - let parsed - try { parsed = JSON.parse(out) } catch { /* stays undefined */ } - assert.ok(parsed?.code === 'ENOSTORE' && !err.includes('ENOSTORE')) - }) + const { err, out } = run(READY, ['--json'], nowhere); + let parsed; + try { parsed = JSON.parse(out); } catch { /* stays undefined */ } + assert.ok(parsed?.code === 'ENOSTORE' && !err.includes('ENOSTORE')); + }); it('ready, absent store: does NOT emit a fictional empty backlog', () => { - const { out } = run(READY, ['--json'], nowhere) - let parsed - try { parsed = JSON.parse(out) } catch { /* stays undefined */ } - assert.equal(parsed?.ready, undefined) - }) + const { out } = run(READY, ['--json'], nowhere); + let parsed; + try { parsed = JSON.parse(out); } catch { /* stays undefined */ } + assert.equal(parsed?.ready, undefined); + }); it('validate, absent store: exits NON-ZERO with ENOSTORE', () => { - const { code, out } = run(VALIDATE, ['--json'], nowhere) - let parsed - try { parsed = JSON.parse(out) } catch { /* stays undefined */ } - assert.ok(code !== 0 && parsed?.code === 'ENOSTORE') - }) + const { code, out } = run(VALIDATE, ['--json'], nowhere); + let parsed; + try { parsed = JSON.parse(out); } catch { /* stays undefined */ } + assert.ok(code !== 0 && parsed?.code === 'ENOSTORE'); + }); it('validate, absent store: never claims to be clean', () => { - const { out } = run(VALIDATE, ['--json'], nowhere) - let parsed - try { parsed = JSON.parse(out) } catch { /* stays undefined */ } - assert.notEqual(parsed?.clean, true) - }) + const { out } = run(VALIDATE, ['--json'], nowhere); + let parsed; + try { parsed = JSON.parse(out); } catch { /* stays undefined */ } + assert.notEqual(parsed?.clean, true); + }); it('validate: the `skipped` flag is GONE (it only existed to paper over exit-0-on-absent)', () => { - const { out } = run(VALIDATE, ['--json'], nowhere) - let parsed - try { parsed = JSON.parse(out) } catch { /* stays undefined */ } - assert.equal(parsed?.skipped, undefined) - }) + const { out } = run(VALIDATE, ['--json'], nowhere); + let parsed; + try { parsed = JSON.parse(out); } catch { /* stays undefined */ } + assert.equal(parsed?.skipped, undefined); + }); // ...and the CONVERSE. An EMPTY store is a perfectly legitimate, clean, exit-0 // answer. Absent and empty must never look alike again — in EITHER direction. it('ready, EMPTY-but-present store: exit 0 + an empty backlog', (t) => { - const dir = seedStore(tmpDir(t, 'vp-empty-'), 'x') - const ready = run(READY, ['--json'], dir) - assert.ok(ready.code === 0 && JSON.parse(ready.out).ready.length === 0) - }) + const dir = seedStore(tmpDir(t, 'vp-empty-'), 'x'); + const ready = run(READY, ['--json'], dir); + assert.ok(ready.code === 0 && JSON.parse(ready.out).ready.length === 0); + }); it('validate, EMPTY-but-present store: exit 0 + clean', (t) => { - const dir = seedStore(tmpDir(t, 'vp-empty-'), 'x') - const valid = run(VALIDATE, ['--json'], dir) - assert.ok(valid.code === 0 && JSON.parse(valid.out).clean === true) - }) -}) + const dir = seedStore(tmpDir(t, 'vp-empty-'), 'x'); + const valid = run(VALIDATE, ['--json'], dir); + assert.ok(valid.code === 0 && JSON.parse(valid.out).clean === true); + }); +}); describe('validate CLI (error paths, via tmpdir)', () => { it('malformed YAML: exits 2 (ResultError) with a clear message', (t) => { - const dir = seedStore(tmpDir(t, 'vp-tasks-'), 'x', 'tasks: [ : : not yaml') - const { both: out, code } = run(VALIDATE, [], dir) + const dir = seedStore(tmpDir(t, 'vp-tasks-'), 'x', 'tasks: [ : : not yaml'); + const { both: out, code } = run(VALIDATE, [], dir); // Exit 2, not 1. The CLI distinguishes "you got it wrong" (1: bad flag, no store) // from "it ran and the answer is no" (2: your store is invalid). A CI script can // now tell a misconfigured diarie from a broken backlog. - assert.ok(code === 2 && /invalid YAML/.test(out)) - }) + assert.ok(code === 2 && /invalid YAML/.test(out)); + }); it('--json on UNPARSEABLE yaml still emits JSON (the contract) with the error', (t) => { // `--json` MUST always emit JSON. The YAML-parse catch used to write to stderr and exit, // bypassing the --json branch entirely — so an unparseable store produced NO stdout, and // every consumer that reads stdout (both hooks) saw empty output and concluded there was // nothing to report. That is on the single commonest hand-edit mistake there is. - const dir = seedStore(tmpDir(t, 'vp-tasks-'), 'x', 'tasks:\n - id: T-1\n title: "unclosed\n') - const { both: out, code } = run(VALIDATE, ['--json'], dir) - let parsed - try { parsed = JSON.parse(out) } catch { /* stays undefined */ } - assert.ok(code === 2 && parsed?.clean === false && /invalid YAML/.test(String(parsed?.errors ?? ''))) - }) + const dir = seedStore(tmpDir(t, 'vp-tasks-'), 'x', 'tasks:\n - id: T-1\n title: "unclosed\n'); + const { both: out, code } = run(VALIDATE, ['--json'], dir); + let parsed; + try { parsed = JSON.parse(out); } catch { /* stays undefined */ } + assert.ok(code === 2 && parsed?.clean === false && /invalid YAML/.test(String(parsed?.errors ?? ''))); + }); it('duplicate id: exits 2 with "duplicate id"', (t) => { const dir = seedStore(tmpDir(t, 'vp-tasks-'), 'x', - 'tasks:\n - id: T-1\n title: a\n status: pending\n type: task\n - id: T-1\n title: b\n status: pending\n type: task\n') - const { both: out, code } = run(VALIDATE, [], dir) - assert.ok(code === 2 && /duplicate id/.test(out)) - }) -}) + 'tasks:\n - id: T-1\n title: a\n status: pending\n type: task\n - id: T-1\n title: b\n status: pending\n type: task\n'); + const { both: out, code } = run(VALIDATE, [], dir); + assert.ok(code === 2 && /duplicate id/.test(out)); + }); +}); describe('the exit-code taxonomy — a typo is not a bug, and 2 means only one thing', () => { // Exit 2 is ResultError: "it ran, and the answer is no" (a cyclic backlog, --strict). @@ -616,58 +616,58 @@ describe('the exit-code taxonomy — a typo is not a bug, and 2 means only one t // dependency cycle from a forgotten subcommand. Two meanings, one code, no way back. it('a bare `diarie` is an InputError (1), NOT the ResultError code (2)', () => { - const { code } = run([], [], FIXTURES) - assert.equal(code, 1) - }) + const { code } = run([], [], FIXTURES); + assert.equal(code, 1); + }); it('a bare `diarie` still SHOWS the commands — exiting 1 must not mean staying silent', () => { - const { both } = run([], [], FIXTURES) - assert.match(both, /ready/) - assert.match(both, /migrate/) - }) + const { both } = run([], [], FIXTURES); + assert.match(both, /ready/); + assert.match(both, /migrate/); + }); it('an unknown command is answered with a sentence, not a stack trace', () => { - const { code, err } = run(['frobnicate'], [], FIXTURES) - assert.equal(code, 1) - assert.match(err, /unknown command: frobnicate/) + const { code, err } = run(['frobnicate'], [], FIXTURES); + assert.equal(code, 1); + assert.match(err, /unknown command: frobnicate/); // The tell of the old behaviour: it fell through to cli.js's "genuinely unexpected" // branch, which prints the cause chain. A typo is not a bug in the tool. - assert.doesNotMatch(err, /unexpected error/) - assert.doesNotMatch(err, /at .*\.js:\d+/) - }) + assert.doesNotMatch(err, /unexpected error/); + assert.doesNotMatch(err, /at .*\.js:\d+/); + }); it('an unknown flag is answered with a sentence, not node:internal parse_args frames', () => { - const { code, err } = run(READY, ['--nosuchflag'], FIXTURES) - assert.equal(code, 1) + const { code, err } = run(READY, ['--nosuchflag'], FIXTURES); + assert.equal(code, 1); // The POSITIVE assertion comes first and is the point. Three `doesNotMatch`es alone would // pass just as happily against an EMPTY stderr — which is a different bug, and one this // project has shipped before. An absence-only test cannot tell "said the right thing" from // "said nothing at all". - assert.match(err, /diarie: Unknown option '--nosuchflag'/) - assert.doesNotMatch(err, /unexpected error/) - assert.doesNotMatch(err, /node:internal/) - }) + assert.match(err, /diarie: Unknown option '--nosuchflag'/); + assert.doesNotMatch(err, /unexpected error/); + assert.doesNotMatch(err, /node:internal/); + }); it('--json carries usage errors on STDOUT with a code — the whole point of this CLI', () => { // The defect this tool was built around: important things whispered to a stream that // ten call sites pipe to /dev/null. An unknown command used to leave stdout EMPTY and // dump a stack to stderr — silently violating the contract cli.js's own header states. for (const args of [['frobnicate', '--json'], ['ready', '--nosuchflag', '--json']]) { - const { code, out } = run([], args, FIXTURES) - assert.equal(code, 1) - const parsed = JSON.parse(out) - assert.equal(parsed.code, 'EUSAGE') - assert.ok(typeof parsed.error === 'string' && parsed.error.length > 0) + const { code, out } = run([], args, FIXTURES); + assert.equal(code, 1); + const parsed = JSON.parse(out); + assert.equal(parsed.code, 'EUSAGE'); + assert.ok(typeof parsed.error === 'string' && parsed.error.length > 0); } - }) + }); it('EUSAGE and ENOSTORE are DIFFERENT codes — "you typed it wrong" is not "there is no store"', () => { - const { out: typo } = run([], ['frobnicate', '--json'], FIXTURES) - const { out: nostore } = run(READY, ['--json', '--root', join(tmpdir(), 'diarie-nowhere-xyz')], '') - assert.equal(JSON.parse(typo).code, 'EUSAGE') - assert.equal(JSON.parse(nostore).code, 'ENOSTORE') - }) -}) + const { out: typo } = run([], ['frobnicate', '--json'], FIXTURES); + const { out: nostore } = run(READY, ['--json', '--root', join(tmpdir(), 'diarie-nowhere-xyz')], ''); + assert.equal(JSON.parse(typo).code, 'EUSAGE'); + assert.equal(JSON.parse(nostore).code, 'ENOSTORE'); + }); +}); describe('`--help` is a REQUEST, not a mistake (the oracle check:prose-commands stands on)', () => { // Every subcommand must answer --help on STDOUT with exit 0. `migrate` did not: it shared @@ -677,19 +677,19 @@ describe('`--help` is a REQUEST, not a mistake (the oracle check:prose-commands for (const sub of ['ready', 'stats', 'validate', 'init', 'migrate']) { it(`\`${sub} --help\` exits 0 and prints the usage on stdout`, () => { - const { code, err, out } = run([sub], ['--help'], FIXTURES) - assert.equal(code, 0) - assert.ok(out.length > 0, `${sub} --help wrote nothing to stdout`) - assert.equal(err, '') - }) + const { code, err, out } = run([sub], ['--help'], FIXTURES); + assert.equal(code, 0); + assert.ok(out.length > 0, `${sub} --help wrote nothing to stdout`); + assert.equal(err, ''); + }); } it('`migrate` with NO argument is still an error — help is not the same as forgetting the file', () => { - const { code, err } = run(['migrate'], [], FIXTURES) - assert.equal(code, 1) - assert.match(err, /needs a bd export file/) - }) -}) + const { code, err } = run(['migrate'], [], FIXTURES); + assert.equal(code, 1); + assert.match(err, /needs a bd export file/); + }); +}); describe('THE INVARIANT: a user mistake is never a crash, and never exit 2', () => { // Two reviewers, independently, gave the first cut of this taxonomy 22/100 and 3/100 — because @@ -716,24 +716,24 @@ describe('THE INVARIANT: a user mistake is never a crash, and never exit 2', () { what: 'an invalid enum value', argv: ['ready', '--filter', 'bogus'] }, { what: 'a non-numeric number', argv: ['stats', '--days', 'abc'] }, { what: 'a negative staleness window', argv: ['stats', '--days', '-5'] }, - ] + ]; for (const { argv, what } of MISTAKES) { it(`${what} — exits 1, not 2, and is never called "unexpected"`, () => { - const { code, err, out } = run([], argv, FIXTURES) + const { code, err, out } = run([], argv, FIXTURES); // 2 would mean "it ran, and your backlog is broken". None of these ran. - assert.notEqual(code, 2, `\`diarie ${argv.join(' ')}\` exited 2 — the ResultError code`) - assert.equal(code, 1) + assert.notEqual(code, 2, `\`diarie ${argv.join(' ')}\` exited 2 — the ResultError code`); + assert.equal(code, 1); // The tell of a crash reaching the user. It is never the right answer to a typo. - assert.doesNotMatch(err, /unexpected error/) - assert.doesNotMatch(err + out, /Cannot read properties of undefined/) - assert.doesNotMatch(err + out, /node:internal/) + assert.doesNotMatch(err, /unexpected error/); + assert.doesNotMatch(err + out, /Cannot read properties of undefined/); + assert.doesNotMatch(err + out, /node:internal/); // Silence is its own failure: the user must be told SOMETHING. - assert.ok((err + out).trim().length > 0, 'said nothing at all') - }) + assert.ok((err + out).trim().length > 0, 'said nothing at all'); + }); } for (const { argv, what } of MISTAKES) { @@ -742,16 +742,16 @@ describe('THE INVARIANT: a user mistake is never a crash, and never exit 2', () // ten call sites pipe to /dev/null. Before the fix, `diarie --json` printed 589 bytes of // HUMAN HELP PROSE to stdout under the flag that promises machine-readable output, and // `ready --filter bogus --json` printed nothing at all. - const { code, out } = run([], [...argv, '--json'], FIXTURES) - assert.equal(code, 1) - - const parsed = JSON.parse(out) // throws on prose, and on emptiness - assert.equal(typeof parsed.error, 'string') - assert.ok(parsed.error.length > 0) - assert.equal(parsed.code, 'EUSAGE') - }) + const { code, out } = run([], [...argv, '--json'], FIXTURES); + assert.equal(code, 1); + + const parsed = JSON.parse(out); // throws on prose, and on emptiness + assert.equal(typeof parsed.error, 'string'); + assert.ok(parsed.error.length > 0); + assert.equal(parsed.code, 'EUSAGE'); + }); } -}) +}); describe('THE COMPLEMENT: exit 2 must remain REACHABLE, or the taxonomy lies the other way', () => { // The invariant suite above proves no user mistake exits 2. On its own that is only half a @@ -766,38 +766,38 @@ describe('THE COMPLEMENT: exit 2 must remain REACHABLE, or the taxonomy lies the // `type: bug` is a bd fossil — not one of the four valid types — so the row is BROKEN, not // merely non-workable. It surfaces in needsAttention, and --strict must say so in its exit code. const dir = seedStore(tmpDir(t, 'diarie-strict-'), 'x', - 'tasks:\n - id: T-1\n title: fossil type\n status: pending\n type: bug\n') - assert.equal(run(READY, ['--strict'], dir).code, 2) - }) + 'tasks:\n - id: T-1\n title: fossil type\n status: pending\n type: bug\n'); + assert.equal(run(READY, ['--strict'], dir).code, 2); + }); it('`ready --strict` exits 0 on a healthy store — 2 must MEAN something', (t) => { const dir = seedStore(tmpDir(t, 'diarie-strict-ok-'), 'x', - 'tasks:\n - id: T-1\n title: fine\n status: pending\n type: task\n') - assert.equal(run(READY, ['--strict'], dir).code, 0) - }) + 'tasks:\n - id: T-1\n title: fine\n status: pending\n type: task\n'); + assert.equal(run(READY, ['--strict'], dir).code, 0); + }); it('the ONLY executable exit(2) in the package is exitResultError()', () => { // The module docstring in lib/utils/exit.js claims `git grep exitResultError` is an exhaustive // list of every way this process can exit 2. That claim is load-bearing for the whole taxonomy, // and a claim nobody checks is a comment, not a guarantee. So: check it. - const src = readFileSync(join(PKG, 'lib', 'utils', 'exit.js'), 'utf8') - assert.match(src, /export function exitResultError/) + const src = readFileSync(join(PKG, 'lib', 'utils', 'exit.js'), 'utf8'); + assert.match(src, /export function exitResultError/); - const offenders = [] + const offenders = []; for (const file of [join(PKG, 'cli.js'), ...walkJs(join(PKG, 'lib'))]) { - if (file.endsWith(join('utils', 'exit.js'))) continue + if (file.endsWith(join('utils', 'exit.js'))) continue; const body = readFileSync(file, 'utf8') .replaceAll(/\/\*[\s\S]*?\*\//g, '') // strip block comments - .replaceAll(/\/\/.*$/gm, '') // strip line comments + .replaceAll(/\/\/.*$/gm, ''); // strip line comments // ALL THREE FORMS. Checking only `exit(2)` would have let `process.exitCode = 2` through — // which is exactly how it got past the ast-grep rule, and it genuinely exits 2 // (`node -e 'process.exitCode = 2'; echo $?` → 2). A test that enforces two thirds of a // claim is a test that makes the remaining third look enforced. - if (/\bexit\(\s*2\s*\)|\bexitCode\s*=\s*2\b/.test(body)) offenders.push(file) + if (/\bexit\(\s*2\s*\)|\bexitCode\s*=\s*2\b/.test(body)) offenders.push(file); } - assert.deepEqual(offenders, [], `exit 2 written outside lib/utils/exit.js: ${offenders.join(', ')}`) - }) -}) + assert.deepEqual(offenders, [], `exit 2 written outside lib/utils/exit.js: ${offenders.join(', ')}`); + }); +}); /** * Every .js file under a directory, recursively. @@ -807,10 +807,10 @@ describe('THE COMPLEMENT: exit 2 must remain REACHABLE, or the taxonomy lies the */ function walkJs (dir) { return readdirSync(dir, { withFileTypes: true }).flatMap(e => { - const p = join(dir, e.name) - if (e.isDirectory()) return walkJs(p) - return e.name.endsWith('.js') ? [p] : [] - }) + const p = join(dir, e.name); + if (e.isDirectory()) return walkJs(p); + return e.name.endsWith('.js') ? [p] : []; + }); } describe('THE FOUNDING DEFECT, for a MALFORMED ROW: a --json consumer must never be told a broken store is empty', () => { @@ -824,16 +824,16 @@ describe('THE FOUNDING DEFECT, for a MALFORMED ROW: a --json consumer must never // different cause — and it survived the commit that fixed the other half. it('validate calls the store broken — establishing that it IS broken', (t) => { - assert.equal(run(VALIDATE, [], brokenRow(t)).code, 2) - }) + assert.equal(run(VALIDATE, [], brokenRow(t)).code, 2); + }); it('`ready --json` puts the loader\'s complaint IN THE ANSWER, not only on stderr', (t) => { - const { code, out } = run(READY, ['--json'], brokenRow(t)) - assert.equal(code, 0) - const parsed = JSON.parse(out) - assert.ok(Array.isArray(parsed.warnings), 'no `warnings` key — a JSON consumer is blind to it') - assert.match(parsed.warnings[0] ?? '', /invalid status "open"/) - }) + const { code, out } = run(READY, ['--json'], brokenRow(t)); + assert.equal(code, 0); + const parsed = JSON.parse(out); + assert.ok(Array.isArray(parsed.warnings), 'no `warnings` key — a JSON consumer is blind to it'); + assert.match(parsed.warnings[0] ?? '', /invalid status "open"/); + }); it('`ready --strict` exits 2 for a DROPPED row, exactly as it does for a broken type', (t) => { // The asymmetry this fixes: `computeReady` skips any row whose status is not `pending` BEFORE @@ -841,24 +841,24 @@ describe('THE FOUNDING DEFECT, for a MALFORMED ROW: a --json consumer must never // while `status: open` was silently discarded and --strict exited 0. Two malformed REQUIRED // fields, opposite behaviour — and CLAUDE.md says a malformed required field makes the row // BROKEN, not merely non-workable. - assert.equal(run(READY, ['--strict'], brokenRow(t)).code, 2) - }) + assert.equal(run(READY, ['--strict'], brokenRow(t)).code, 2); + }); it('`stats --json` carries it too — "present in the sum, absent from every answer"', (t) => { - const { out } = run(STATS, ['--json'], brokenRow(t)) - const parsed = JSON.parse(out) - assert.equal(parsed.total, 1) - assert.ok(Array.isArray(parsed.warnings)) - }) + const { out } = run(STATS, ['--json'], brokenRow(t)); + const parsed = JSON.parse(out); + assert.equal(parsed.total, 1); + assert.ok(Array.isArray(parsed.warnings)); + }); it('a HEALTHY store carries NO warnings key and --strict exits 0 — 2 must MEAN something', (t) => { const dir = seedStore(tmpDir(t, 'diarie-clean-'), 'a', - 'tasks:\n - id: T-1\n title: fine\n status: pending\n type: task\n') - const { code, out } = run(READY, ['--strict', '--json'], dir) - assert.equal(code, 0) - assert.equal(JSON.parse(out).warnings, undefined) - }) -}) + 'tasks:\n - id: T-1\n title: fine\n status: pending\n type: task\n'); + const { code, out } = run(READY, ['--strict', '--json'], dir); + assert.equal(code, 0); + assert.equal(JSON.parse(out).warnings, undefined); + }); +}); describe('THE TWO-FLAG CROSS: --strict was DEAD under --filter, and the suite could not see it', () => { // `grep "filter.*strict"` over this suite returned NOTHING before these tests. Every --strict and @@ -877,9 +877,9 @@ describe('THE TWO-FLAG CROSS: --strict was DEAD under --filter, and the suite co // `status: in-progress` (hyphen) is not in VALID_STATUSES, so the loader rejects the field and // the row disappears from every partition AND from every filter. A live claim, gone. const dir = seedStore(tmpDir(t, 'diarie-x-drop-'), 'a', - 'tasks:\n - id: T-9\n title: THE LIVE CLAIM\n status: in-progress\n type: task\n') - assert.equal(run(READY, ['--filter', 'in_progress', '--strict'], dir).code, 2) - }) + 'tasks:\n - id: T-9\n title: THE LIVE CLAIM\n status: in-progress\n type: task\n'); + assert.equal(run(READY, ['--filter', 'in_progress', '--strict'], dir).code, 2); + }); it('a BROKEN-TYPE row makes `--filter --strict` exit 2 — and is still SERVED in the array', (t) => { // The two views disagree on purpose: the partition quarantines this row in needsAttention, @@ -887,45 +887,45 @@ describe('THE TWO-FLAG CROSS: --strict was DEAD under --filter, and the suite co // reads the array and ignores the exit code gets a broken row as though it were healthy. The // exit code is its only warning. Pinning BOTH halves here so neither can drift. const dir = seedStore(tmpDir(t, 'diarie-x-type-'), 'a', - 'tasks:\n - id: T-1\n title: broken required type\n status: pending\n type: bug\n') - const { code, out } = run(READY, ['--filter', 'pending', '--strict', '--json'], dir) - assert.equal(code, 2) - assert.equal(JSON.parse(out).length, 1) - }) + 'tasks:\n - id: T-1\n title: broken required type\n status: pending\n type: bug\n'); + const { code, out } = run(READY, ['--filter', 'pending', '--strict', '--json'], dir); + assert.equal(code, 2); + assert.equal(JSON.parse(out).length, 1); + }); it('`--strict` stays OPT-IN — a broken store without it still exits 0', (t) => { const dir = seedStore(tmpDir(t, 'diarie-x-optin-'), 'a', - 'tasks:\n - id: T-1\n title: broken\n status: pending\n type: bug\n') - assert.equal(run(READY, ['--filter', 'pending', '--json'], dir).code, 0) - }) + 'tasks:\n - id: T-1\n title: broken\n status: pending\n type: bug\n'); + assert.equal(run(READY, ['--filter', 'pending', '--json'], dir).code, 0); + }); it('a HEALTHY store passes `--filter --strict` — 2 must MEAN something', (t) => { const dir = seedStore(tmpDir(t, 'diarie-x-ok-'), 'a', - 'tasks:\n - id: T-1\n title: fine\n status: pending\n type: task\n') - assert.equal(run(READY, ['--filter', 'pending', '--strict'], dir).code, 0) - }) + 'tasks:\n - id: T-1\n title: fine\n status: pending\n type: task\n'); + assert.equal(run(READY, ['--filter', 'pending', '--strict'], dir).code, 0); + }); it('`--strict` is in the usage string — it was missing, and it was also dead', () => { - const { out } = run(READY, ['--help'], FIXTURES) - assert.match(out, /--strict/) - }) + const { out } = run(READY, ['--help'], FIXTURES); + assert.match(out, /--strict/); + }); it('`stats --stale --json` carries the loader\'s complaint (it returned before the append)', (t) => { // The early return sat FOUR LINES above the comment explaining why warnings must be appended. // `agents/sprint-review.md` runs exactly `stats --stale --days 60 --json`. const dir = seedStore(tmpDir(t, 'diarie-x-stale-'), 'a', - 'tasks:\n - id: T-9\n title: dropped\n status: in-progress\n type: task\n') - const parsed = JSON.parse(run(STATS, ['--stale', '--json'], dir).out) - assert.ok(Array.isArray(parsed.warnings), 'no `warnings` key — a JSON consumer is blind to it') - assert.match(parsed.warnings[0] ?? '', /invalid status/) - }) + 'tasks:\n - id: T-9\n title: dropped\n status: in-progress\n type: task\n'); + const parsed = JSON.parse(run(STATS, ['--stale', '--json'], dir).out); + assert.ok(Array.isArray(parsed.warnings), 'no `warnings` key — a JSON consumer is blind to it'); + assert.match(parsed.warnings[0] ?? '', /invalid status/); + }); it('`stats --stale --json` on a HEALTHY store carries NO warnings key', (t) => { const dir = seedStore(tmpDir(t, 'diarie-x-stale-ok-'), 'a', - 'tasks:\n - id: T-1\n title: fine\n status: pending\n type: task\n') - assert.equal(JSON.parse(run(STATS, ['--stale', '--json'], dir).out).warnings, undefined) - }) -}) + 'tasks:\n - id: T-1\n title: fine\n status: pending\n type: task\n'); + assert.equal(JSON.parse(run(STATS, ['--stale', '--json'], dir).out).warnings, undefined); + }); +}); describe('exit 2 must not TRUNCATE the answer — process.exit() does not flush a pipe', () => { // Every fixture in this suite is small, so no test could ever have caught this. Found by running @@ -939,20 +939,20 @@ describe('exit 2 must not TRUNCATE the answer — process.exit() does not flush // to report it. `process.exitCode` lets Node drain stdout and exit naturally. it('`ready --strict --json` exits 2 AND emits complete, parseable JSON', (t) => { - const { code, out } = run(READY, ['--strict', '--json'], bigStore(t)) - assert.equal(code, 2) - assert.ok(out.length > 65536, `payload must exceed the 64KB pipe buffer to be a real test (got ${out.length})`) - const parsed = JSON.parse(out) // throws on a truncated document — that IS the assertion - assert.equal(parsed.ready.length, 400) - }) + const { code, out } = run(READY, ['--strict', '--json'], bigStore(t)); + assert.equal(code, 2); + assert.ok(out.length > 65536, `payload must exceed the 64KB pipe buffer to be a real test (got ${out.length})`); + const parsed = JSON.parse(out); // throws on a truncated document — that IS the assertion + assert.equal(parsed.ready.length, 400); + }); it('`ready --filter --strict --json` exits 2 AND emits complete, parseable JSON', (t) => { - const { code, out } = run(READY, ['--filter', 'pending', '--strict', '--json'], bigStore(t)) - assert.equal(code, 2) - assert.ok(out.length > 65536, `payload must exceed the 64KB pipe buffer (got ${out.length})`) - assert.equal(JSON.parse(out).length, 400) - }) -}) + const { code, out } = run(READY, ['--filter', 'pending', '--strict', '--json'], bigStore(t)); + assert.equal(code, 2); + assert.ok(out.length > 65536, `payload must exceed the 64KB pipe buffer (got ${out.length})`); + assert.equal(JSON.parse(out).length, 400); + }); +}); describe('THE FOURTH DOOR: one unreadable file must not delete the whole store', () => { // `loadTasks` called `yaml.load` with NO try/catch. So a single stray unterminated quote in ONE @@ -967,31 +967,31 @@ describe('THE FOURTH DOOR: one unreadable file must not delete the whole store', // was deleting the store. it('the live claim in the HEALTHY file survives', (t) => { - const { out } = run(READY, ['--filter', 'in_progress', '--json'], halfBroken(t)) - const rows = JSON.parse(out) - assert.equal(rows.length, 1) - assert.equal(rows[0].title, 'THE LIVE CLAIM') - }) + const { out } = run(READY, ['--filter', 'in_progress', '--json'], halfBroken(t)); + const rows = JSON.parse(out); + assert.equal(rows.length, 1); + assert.equal(rows[0].title, 'THE LIVE CLAIM'); + }); it('the unreadable file is REPORTED by name, and says what it cost', (t) => { - const { err } = run(READY, ['--json'], halfBroken(t)) - assert.match(err, /tasks-b\.yml: invalid YAML/) - assert.match(err, /MISSING from every count/) - }) + const { err } = run(READY, ['--json'], halfBroken(t)); + assert.match(err, /tasks-b\.yml: invalid YAML/); + assert.match(err, /MISSING from every count/); + }); it('it is an InputError-class store problem, NOT an "unexpected error" crash', (t) => { - const { err } = run(READY, [], halfBroken(t)) - assert.doesNotMatch(err, /unexpected error/) - }) + const { err } = run(READY, [], halfBroken(t)); + assert.doesNotMatch(err, /unexpected error/); + }); it('`--strict` exits 2 — so the session-start hook (which reads exit 1 as ENOSTORE) is not fooled', (t) => { - assert.equal(run(READY, ['--filter', 'in_progress', '--strict'], halfBroken(t)).code, 2) - }) + assert.equal(run(READY, ['--filter', 'in_progress', '--strict'], halfBroken(t)).code, 2); + }); it('`validate` still rejects it — the reader is honest, the validator is the authority', (t) => { - assert.equal(run(VALIDATE, [], halfBroken(t)).code, 2) - }) -}) + assert.equal(run(VALIDATE, [], halfBroken(t)).code, 2); + }); +}); describe('ONE VERDICT, BOTH SHAPES: --filter --strict must agree with the partition', () => { // The first `--filter --strict` fix threw only on a dropped row, leaving the cycle, the @@ -1007,22 +1007,22 @@ describe('ONE VERDICT, BOTH SHAPES: --filter --strict must agree with the partit { what: 'an ABSENT required type', yaml: 'tasks:\n - id: T-1\n title: no type\n status: pending\n' }, { what: 'a dangling dep', yaml: 'tasks:\n - id: T-1\n title: x\n status: pending\n type: task\n deps: [NOPE]\n' }, { what: 'a dropped row (bad status)', yaml: 'tasks:\n - id: T-9\n title: claim\n status: in-progress\n type: task\n' }, - ] + ]; for (const { what, yaml } of STORES) { it(`${what}: --filter --strict agrees with --strict`, (t) => { - const dir = seedStore(tmpDir(t, 'diarie-agree-'), 'a', yaml) - const filtered = run(READY, ['--filter', 'pending', '--strict'], dir).code - const partition = run(READY, ['--strict'], dir).code - assert.equal(filtered, partition, `--filter --strict exited ${filtered}, --strict exited ${partition}`) - assert.equal(filtered, 2) - }) + const dir = seedStore(tmpDir(t, 'diarie-agree-'), 'a', yaml); + const filtered = run(READY, ['--filter', 'pending', '--strict'], dir).code; + const partition = run(READY, ['--strict'], dir).code; + assert.equal(filtered, partition, `--filter --strict exited ${filtered}, --strict exited ${partition}`); + assert.equal(filtered, 2); + }); } it('a HEALTHY store: both shapes exit 0 — 2 must MEAN something', (t) => { const dir = seedStore(tmpDir(t, 'diarie-agree-ok-'), 'a', - 'tasks:\n - id: T-1\n title: fine\n status: pending\n type: task\n') - assert.equal(run(READY, ['--filter', 'pending', '--strict'], dir).code, 0) - assert.equal(run(READY, ['--strict'], dir).code, 0) - }) -}) + 'tasks:\n - id: T-1\n title: fine\n status: pending\n type: task\n'); + assert.equal(run(READY, ['--filter', 'pending', '--strict'], dir).code, 0); + assert.equal(run(READY, ['--strict'], dir).code, 0); + }); +}); diff --git a/test/commands.spec.js b/test/commands.spec.js index 1746fdf..5c6e1ba 100644 --- a/test/commands.spec.js +++ b/test/commands.spec.js @@ -15,28 +15,28 @@ * splits `audit.spec.js` from `audit.cli.spec.js` on exactly this line). */ -import assert from 'node:assert/strict' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { after, describe, it } from 'node:test' -import { fileURLToPath } from 'node:url' +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, -} from 'node:fs' +} from 'node:fs'; -import { doTheWork as initWork } from '../lib/commands/init.js' -import { doTheWork as readyWork } from '../lib/commands/ready.js' -import { doTheWork as statsWork } from '../lib/commands/stats.js' -import { doTheWork as validateWork } from '../lib/commands/validate.js' +import { doTheWork as initWork } from '../lib/commands/init.js'; +import { doTheWork as readyWork } from '../lib/commands/ready.js'; +import { doTheWork as statsWork } from '../lib/commands/stats.js'; +import { doTheWork as validateWork } from '../lib/commands/validate.js'; -const FIXTURES = fileURLToPath(new URL('fixtures', import.meta.url)) +const FIXTURES = fileURLToPath(new URL('fixtures', import.meta.url)); /** @type {string[]} */ -const scratch = [] +const scratch = []; after(() => { - for (const dir of scratch) rmSync(dir, { force: true, recursive: true }) -}) + for (const dir of scratch) rmSync(dir, { force: true, recursive: true }); +}); /** * Write a throwaway store and return its root. @@ -45,34 +45,34 @@ after(() => { * @returns {string} */ function storeWith (yaml) { - const root = mkdtempSync(join(tmpdir(), 'diarie-cmd-')) - scratch.push(root) - mkdirSync(join(root, '.diarie', 'tasks'), { recursive: true }) - writeFileSync(join(root, '.diarie', 'tasks', 'tasks-backlog.yml'), yaml, 'utf8') - return root + const root = mkdtempSync(join(tmpdir(), 'diarie-cmd-')); + scratch.push(root); + mkdirSync(join(root, '.diarie', 'tasks'), { recursive: true }); + writeFileSync(join(root, '.diarie', 'tasks', 'tasks-backlog.yml'), yaml, 'utf8'); + return root; } describe('ready — doTheWork', () => { it('returns the PARTITION as data, and says nothing', async () => { - const result = await readyWork({ filter: undefined, root: FIXTURES }) + const result = await readyWork({ filter: undefined, root: FIXTURES }); - assert.equal(result.mode, 'partition') - assert.ok(result.mode === 'partition') // narrows the union for the reads below - assert.equal(result.partition.ready.length, 3) - assert.equal(result.partition.blocked.length, 1) - assert.equal(result.partition.needsAttention.length, 0) - assert.equal(result.ambiguous, false) - assert.deepEqual(result.warnings, []) - }) + assert.equal(result.mode, 'partition'); + assert.ok(result.mode === 'partition'); // narrows the union for the reads below + assert.equal(result.partition.ready.length, 3); + assert.equal(result.partition.blocked.length, 1); + assert.equal(result.partition.needsAttention.length, 0); + assert.equal(result.ambiguous, false); + assert.deepEqual(result.warnings, []); + }); it('returns a flat LIST under --filter — a different shape, on purpose', async () => { - const result = await readyWork({ filter: 'completed', root: FIXTURES }) + const result = await readyWork({ filter: 'completed', root: FIXTURES }); - assert.equal(result.mode, 'filter') - assert.ok(result.mode === 'filter') - assert.ok(result.tasks.length > 0) - assert.ok(result.tasks.every(t => t.status === 'completed')) - }) + assert.equal(result.mode, 'filter'); + assert.ok(result.mode === 'filter'); + assert.ok(result.tasks.length > 0); + assert.ok(result.tasks.every(t => t.status === 'completed')); + }); it('COLLECTS the loader\'s complaints as data instead of writing them to stderr', async () => { // The seam, demonstrated. `priority: urgent` is not in the enum, so the loader @@ -85,16 +85,16 @@ describe('ready — doTheWork', () => { ' status: pending', ' type: task', ' priority: urgent', - ].join('\n') + '\n') + ].join('\n') + '\n'); - const result = await readyWork({ filter: undefined, root }) + const result = await readyWork({ filter: undefined, root }); - assert.equal(result.warnings.length, 1) - assert.match(result.warnings[0] ?? '', /urgent/) + assert.equal(result.warnings.length, 1); + assert.match(result.warnings[0] ?? '', /urgent/); // And the consequence is named, not merely the rejection — a guard that DROPS must // also REPORT (CLAUDE.md `### Reader conventions`). - assert.match(result.warnings[0] ?? '', /medium/) - }) + assert.match(result.warnings[0] ?? '', /medium/); + }); it('flags AMBIGUITY: nothing ready, yet work exists — claimed, or a cycle', async () => { const root = storeWith([ @@ -108,68 +108,68 @@ describe('ready — doTheWork', () => { ' title: Claimed, so T-1 can never be ready', ' status: in_progress', ' type: task', - ].join('\n') + '\n') + ].join('\n') + '\n'); - const result = await readyWork({ filter: undefined, root }) + const result = await readyWork({ filter: undefined, root }); - assert.ok(result.mode === 'partition') - assert.equal(result.partition.ready.length, 0) - assert.equal(result.ambiguous, true) - }) -}) + assert.ok(result.mode === 'partition'); + assert.equal(result.partition.ready.length, 0); + assert.equal(result.ambiguous, true); + }); +}); describe('stats — doTheWork', () => { it('returns the summary as data', async () => { - const { summary, warnings } = await statsWork({ days: 30, root: FIXTURES }) + const { summary, warnings } = await statsWork({ days: 30, root: FIXTURES }); - assert.equal(typeof summary.total, 'number') - assert.ok(summary.total > 0) - assert.equal(summary.ready, 3) - assert.ok(Array.isArray(summary.stale)) - assert.deepEqual(warnings, []) - }) + assert.equal(typeof summary.total, 'number'); + assert.ok(summary.total > 0); + assert.equal(summary.ready, 3); + assert.ok(Array.isArray(summary.stale)); + assert.deepEqual(warnings, []); + }); it('takes the staleness threshold as an already-validated NUMBER', async () => { // The coercion happened in setupCommand (validateStaleFlags). By the time the work // runs, `days` cannot be NaN — which is what stops the silent zero-stale answer. - const { summary } = await statsWork({ days: 0, root: FIXTURES }) - assert.ok(Array.isArray(summary.stale)) - }) -}) + const { summary } = await statsWork({ days: 0, root: FIXTURES }); + assert.ok(Array.isArray(summary.stale)); + }); +}); describe('validate — doTheWork', () => { it('reports a clean store with no errors', async () => { - const result = await validateWork({ root: FIXTURES }) + const result = await validateWork({ root: FIXTURES }); - assert.deepEqual(result.errors, []) - assert.equal(result.fileCount, 2) - }) + assert.deepEqual(result.errors, []); + assert.equal(result.fileCount, 2); + }); it('returns a YAML parse failure as an ERROR, not as a crash', async () => { - const root = storeWith('tasks:\n - id: T-1\n bad indent: [\n') + const root = storeWith('tasks:\n - id: T-1\n bad indent: [\n'); - const result = await validateWork({ root }) + const result = await validateWork({ root }); - assert.equal(result.errors.length, 1) - assert.match(result.errors[0] ?? '', /invalid YAML/) - }) + assert.equal(result.errors.length, 1); + assert.match(result.errors[0] ?? '', /invalid YAML/); + }); it('keeps NOTICES (stderr asides) apart from WARNINGS (part of the answer)', async () => { // Two channels, deliberately not merged: a lint warning belongs in the --json // payload, an aside about the store's shape does not. Collapsing them would hide a // lint warning from every machine consumer. - const root = mkdtempSync(join(tmpdir(), 'diarie-cmd-')) - scratch.push(root) - mkdirSync(join(root, '.diarie', 'tasks'), { recursive: true }) - writeFileSync(join(root, '.diarie', 'tasks', 'tasks_old.yml'), 'tasks: []\n', 'utf8') + const root = mkdtempSync(join(tmpdir(), 'diarie-cmd-')); + scratch.push(root); + mkdirSync(join(root, '.diarie', 'tasks'), { recursive: true }); + writeFileSync(join(root, '.diarie', 'tasks', 'tasks_old.yml'), 'tasks: []\n', 'utf8'); - const result = await validateWork({ root }) + const result = await validateWork({ root }); - assert.equal(result.fileCount, 0) - assert.equal(result.notices.length, 1) - assert.match(result.notices[0] ?? '', /not matching tasks-\*\.yml/) - }) -}) + assert.equal(result.fileCount, 0); + assert.equal(result.notices.length, 1); + assert.match(result.notices[0] ?? '', /not matching tasks-\*\.yml/); + }); +}); describe('init — doTheWork', () => { // The ONLY command whose work has a side effect, and the only one whose `doTheWork` was @@ -177,52 +177,52 @@ describe('init — doTheWork', () => { // A four-part command that nobody drives through the seam has the seam and none of the benefit. it('creates the store and REPORTS what it created', async () => { - const root = mkdtempSync(join(tmpdir(), 'diarie-init-')) - scratch.push(root) + const root = mkdtempSync(join(tmpdir(), 'diarie-init-')); + scratch.push(root); - const { created, root: where } = await initWork({ root, slug: 'backlog' }) + const { created, root: where } = await initWork({ root, slug: 'backlog' }); - assert.equal(where, root) - assert.ok(created.length > 0) - assert.ok(existsSync(join(root, '.diarie', 'tasks', 'tasks-backlog.yml'))) - assert.ok(existsSync(join(root, '.diarie', 'decisions'))) - }) + assert.equal(where, root); + assert.ok(created.length > 0); + assert.ok(existsSync(join(root, '.diarie', 'tasks', 'tasks-backlog.yml'))); + assert.ok(existsSync(join(root, '.diarie', 'decisions'))); + }); it('honours --slug — the first task file is named, not assumed', async () => { - const root = mkdtempSync(join(tmpdir(), 'diarie-init-slug-')) - scratch.push(root) + const root = mkdtempSync(join(tmpdir(), 'diarie-init-slug-')); + scratch.push(root); - await initWork({ root, slug: 'roadmap' }) + await initWork({ root, slug: 'roadmap' }); - assert.ok(existsSync(join(root, '.diarie', 'tasks', 'tasks-roadmap.yml'))) - }) + assert.ok(existsSync(join(root, '.diarie', 'tasks', 'tasks-roadmap.yml'))); + }); it('REFUSES an existing store, and the refusal carries EEXIST', async () => { // Never merge, never overwrite, never "helpfully" back up. The code matters as much as the // refusal: a --json consumer must be able to tell this apart from any other input error // without regexing a human sentence. It shipped with no code at all. - const root = mkdtempSync(join(tmpdir(), 'diarie-init-twice-')) - scratch.push(root) + const root = mkdtempSync(join(tmpdir(), 'diarie-init-twice-')); + scratch.push(root); - await initWork({ root, slug: 'backlog' }) + await initWork({ root, slug: 'backlog' }); await assert.rejects( () => initWork({ root, slug: 'backlog' }), (/** @type {Error & {code?: string}} */ err) => { - assert.equal(err.name, 'InputError') - assert.equal(err.code, 'EEXIST') - assert.match(err.message, /refusing to touch an existing store/) - return true + assert.equal(err.name, 'InputError'); + assert.equal(err.code, 'EEXIST'); + assert.match(err.message, /refusing to touch an existing store/); + return true; } - ) - }) + ); + }); it('the store it creates is one `validate` accepts — init must not produce a broken store', async () => { - const root = mkdtempSync(join(tmpdir(), 'diarie-init-valid-')) - scratch.push(root) + const root = mkdtempSync(join(tmpdir(), 'diarie-init-valid-')); + scratch.push(root); - await initWork({ root, slug: 'backlog' }) - const result = await validateWork({ root }) + await initWork({ root, slug: 'backlog' }); + const result = await validateWork({ root }); - assert.deepEqual(result.errors, []) - }) -}) + assert.deepEqual(result.errors, []); + }); +}); diff --git a/test/flags.spec.js b/test/flags.spec.js index 5a0a7ba..04e3b7d 100644 --- a/test/flags.spec.js +++ b/test/flags.spec.js @@ -10,62 +10,62 @@ * meaning cannot drift apart. */ -import assert from 'node:assert/strict' -import { describe, it } from 'node:test' +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; -import { validateFilterFlags } from '../lib/flags/filter.js' -import { validateStaleFlags } from '../lib/flags/staleness.js' -import { InputError } from '../lib/utils/errors.js' +import { validateFilterFlags } from '../lib/flags/filter.js'; +import { validateStaleFlags } from '../lib/flags/staleness.js'; +import { InputError } from '../lib/utils/errors.js'; describe('validateFilterFlags', () => { it('passes a status the store can actually hold', () => { - assert.deepEqual(validateFilterFlags({ filter: 'in_progress' }), { filter: 'in_progress' }) - }) + assert.deepEqual(validateFilterFlags({ filter: 'in_progress' }), { filter: 'in_progress' }); + }); it('accepts every member of the status enum', () => { for (const status of ['pending', 'in_progress', 'completed', 'failed', 'cancelled', 'deferred']) { - assert.deepEqual(validateFilterFlags({ filter: status }), { filter: status }) + assert.deepEqual(validateFilterFlags({ filter: status }), { filter: status }); } - }) + }); it('treats an ABSENT filter as valid — it selects the partition view, not an error', () => { - assert.deepEqual(validateFilterFlags({ filter: undefined }), { filter: undefined }) - }) + assert.deepEqual(validateFilterFlags({ filter: undefined }), { filter: undefined }); + }); it('rejects a status the schema does not know, and names the alternatives', () => { assert.throws( () => validateFilterFlags({ filter: 'bogus' }), (/** @type {unknown} */ err) => { - assert.ok(err instanceof InputError) - assert.match(err.message, /^--filter must be one of: pending, in_progress, completed, failed, cancelled, deferred$/) - return true + assert.ok(err instanceof InputError); + assert.match(err.message, /^--filter must be one of: pending, in_progress, completed, failed, cancelled, deferred$/); + return true; } - ) - }) + ); + }); it('rejects `closed` — a bd fossil that is NOT a diarie status', () => { // The exact value that made a blocked-review conditional dead code elsewhere: // it is not in VALID_STATUSES, so it can never match, and nothing ever said so. - assert.throws(() => validateFilterFlags({ filter: 'closed' }), InputError) - }) + assert.throws(() => validateFilterFlags({ filter: 'closed' }), InputError); + }); it('rejects the empty string rather than treating it as absent', () => { - assert.throws(() => validateFilterFlags({ filter: '' }), InputError) - }) -}) + assert.throws(() => validateFilterFlags({ filter: '' }), InputError); + }); +}); describe('validateStaleFlags', () => { it('coerces the string flag to a number', () => { - assert.deepEqual(validateStaleFlags({ days: '30', stale: false }), { days: 30, stale: false }) - }) + assert.deepEqual(validateStaleFlags({ days: '30', stale: false }), { days: 30, stale: false }); + }); it('truncates a fractional threshold to whole days', () => { - assert.deepEqual(validateStaleFlags({ days: '7.9', stale: true }), { days: 7, stale: true }) - }) + assert.deepEqual(validateStaleFlags({ days: '7.9', stale: true }), { days: 7, stale: true }); + }); it('accepts zero — "stale the moment it is claimed" is a coherent question to ask', () => { - assert.deepEqual(validateStaleFlags({ days: '0', stale: false }), { days: 0, stale: false }) - }) + assert.deepEqual(validateStaleFlags({ days: '0', stale: false }), { days: 0, stale: false }); + }); it('REJECTS a non-numeric threshold instead of silently yielding NaN', () => { // The failure this guard exists for: `Number('abc')` is NaN, every `<` comparison @@ -74,18 +74,18 @@ describe('validateStaleFlags', () => { assert.throws( () => validateStaleFlags({ days: 'abc', stale: false }), (/** @type {unknown} */ err) => { - assert.ok(err instanceof InputError) - assert.equal(err.message, '--days must be a non-negative number (got "abc")') - return true + assert.ok(err instanceof InputError); + assert.equal(err.message, '--days must be a non-negative number (got "abc")'); + return true; } - ) - }) + ); + }); it('rejects a negative threshold — a cutoff in the future makes every claim stale', () => { - assert.throws(() => validateStaleFlags({ days: '-1', stale: false }), InputError) - }) + assert.throws(() => validateStaleFlags({ days: '-1', stale: false }), InputError); + }); it('rejects Infinity', () => { - assert.throws(() => validateStaleFlags({ days: 'Infinity', stale: false }), InputError) - }) -}) + assert.throws(() => validateStaleFlags({ days: 'Infinity', stale: false }), InputError); + }); +}); diff --git a/test/migrate.spec.js b/test/migrate.spec.js index 456b9eb..4de1744 100644 --- a/test/migrate.spec.js +++ b/test/migrate.spec.js @@ -20,21 +20,21 @@ * export only partly did. */ -import assert from 'node:assert/strict' -import { spawnSync } from 'node:child_process' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, -} from 'node:fs' +} from 'node:fs'; import { after, before, describe, it, -} from 'node:test' +} from 'node:test'; import { groupTasks, MIGRATE_OPTIONS, normalizeBody, projectLive, splitBody, USAGE, -} from '../lib/migrate/bootstrap.js' +} from '../lib/migrate/bootstrap.js'; /** @typedef {import('../lib/migrate/bd-map.js').BdIssue} BdIssue */ @@ -44,36 +44,36 @@ import { * @param {Partial} [over] fields to override on the base issue * @returns {BdIssue} a bd-shaped issue record */ -const issue = (over) => ({ id: 'p-1', title: 't', status: 'open', issue_type: 'task', priority: 2, ...over }) +const issue = (over) => ({ id: 'p-1', title: 't', status: 'open', issue_type: 'task', priority: 2, ...over }); describe('splitBody', () => { it('extracts AC bullets and keeps the rest as description', () => { - const { acceptanceCriteria, description } = splitBody('Intro.\n\n## Acceptance Criteria\n\n- one\n- two\n') - assert.ok(acceptanceCriteria.join('|') === 'one|two' && description === 'Intro.') - }) + const { acceptanceCriteria, description } = splitBody('Intro.\n\n## Acceptance Criteria\n\n- one\n- two\n'); + assert.ok(acceptanceCriteria.join('|') === 'one|two' && description === 'Intro.'); + }); it('escaped-newline body (8d5): AC still extracted, not silently dropped', () => { // The vp-beads-8d5 bug: the body stored literal backslash-n, so the heading was // never line-anchored and the AC vanished with no error. Cost 1 of 10 carriers. - const { acceptanceCriteria } = splitBody(String.raw`Intro.\n\n## Acceptance Criteria\n\n- one\n- two`) - assert.equal(acceptanceCriteria.join('|'), 'one|two') - }) + const { acceptanceCriteria } = splitBody(String.raw`Intro.\n\n## Acceptance Criteria\n\n- one\n- two`); + assert.equal(acceptanceCriteria.join('|'), 'one|two'); + }); it('AC section ends at the next ## heading', () => { - const { acceptanceCriteria, description } = splitBody('## Acceptance Criteria\n- a\n\n## Notes\nkeep me') - assert.ok(acceptanceCriteria.join('|') === 'a' && description === '## Notes\nkeep me') - }) + const { acceptanceCriteria, description } = splitBody('## Acceptance Criteria\n- a\n\n## Notes\nkeep me'); + assert.ok(acceptanceCriteria.join('|') === 'a' && description === '## Notes\nkeep me'); + }); it('strips task-list checkbox markers', () => { - const { acceptanceCriteria } = splitBody('## Acceptance Criteria\n- [ ] unchecked\n- [x] checked') - assert.equal(acceptanceCriteria.join('|'), 'unchecked|checked') - }) + const { acceptanceCriteria } = splitBody('## Acceptance Criteria\n- [ ] unchecked\n- [x] checked'); + assert.equal(acceptanceCriteria.join('|'), 'unchecked|checked'); + }); it('no AC heading: empty list, body preserved', () => { - const { acceptanceCriteria, description } = splitBody('Just a body.') - assert.ok(acceptanceCriteria.length === 0 && description === 'Just a body.') - }) -}) + const { acceptanceCriteria, description } = splitBody('Just a body.'); + assert.ok(acceptanceCriteria.length === 0 && description === 'Just a body.'); + }); +}); describe('normalizeBody', () => { it('normalizeBody un-escapes literal backslash-n (the decision path needs it too)', () => { @@ -81,79 +81,79 @@ describe('normalizeBody', () => { // ENTIRELY prose, so its whole payload rendered as one line of `\n` gibberish. vp-beads // never saw it: its 6 decisions didn't carry the artifact and its 1 artifact-carrying // issue was a task. Only a sibling repo would have hit it. - const out = normalizeBody(String.raw`## Decision\nWe chose X.\n\n## Rationale\nBecause Y.`) - assert.ok(out.includes('\n## Rationale') && !out.includes('\\n')) - }) + const out = normalizeBody(String.raw`## Decision\nWe chose X.\n\n## Rationale\nBecause Y.`); + assert.ok(out.includes('\n## Rationale') && !out.includes('\\n')); + }); it('normalizeBody tolerates an absent body', () => { - assert.equal(normalizeBody(), '') - }) -}) + assert.equal(normalizeBody(), ''); + }); +}); describe('projectLive (edges to non-live issues)', () => { - const liveIds = new Set(['p-1', 'p-live']) + const liveIds = new Set(['p-1', 'p-live']); it('a blocks-dep on a CLOSED issue is dropped, not dangled', () => { /** @type {string[]} */ - const dropped = [] - const t = projectLive(issue({ dependencies: [{ depends_on_id: 'p-closed', type: 'blocks' }] }), liveIds, dropped) - assert.ok(t.deps === undefined && dropped.length === 1 && dropped[0]?.includes('blocks')) - }) + const dropped = []; + const t = projectLive(issue({ dependencies: [{ depends_on_id: 'p-closed', type: 'blocks' }] }), liveIds, dropped); + assert.ok(t.deps === undefined && dropped.length === 1 && dropped[0]?.includes('blocks')); + }); it('a blocks-dep on a LIVE issue is kept', () => { /** @type {string[]} */ - const dropped = [] - const t = projectLive(issue({ dependencies: [{ depends_on_id: 'p-live', type: 'blocks' }] }), liveIds, dropped) - assert.ok(t.deps?.join(',') === 'p-live' && dropped.length === 0) - }) + const dropped = []; + const t = projectLive(issue({ dependencies: [{ depends_on_id: 'p-live', type: 'blocks' }] }), liveIds, dropped); + assert.ok(t.deps?.join(',') === 'p-live' && dropped.length === 0); + }); it('a parent-child edge to a CLOSED epic is dropped, not dangled', () => { // vp-beads never hit this — every one of its parents was still live. A sibling // repo with a COMPLETED epic would emit a dangling parent and fail validate. /** @type {string[]} */ - const dropped = [] - const t = projectLive(issue({ dependencies: [{ depends_on_id: 'p-closed', type: 'parent-child' }] }), liveIds, dropped) - assert.ok(t.parent === undefined && dropped.length === 1 && dropped[0]?.includes('parent')) - }) + const dropped = []; + const t = projectLive(issue({ dependencies: [{ depends_on_id: 'p-closed', type: 'parent-child' }] }), liveIds, dropped); + assert.ok(t.parent === undefined && dropped.length === 1 && dropped[0]?.includes('parent')); + }); it('a parent-child edge to a LIVE epic is kept', () => { /** @type {string[]} */ - const dropped = [] - const t = projectLive(issue({ dependencies: [{ depends_on_id: 'p-live', type: 'parent-child' }] }), liveIds, dropped) - assert.ok(t.parent === 'p-live' && dropped.length === 0) - }) -}) + const dropped = []; + const t = projectLive(issue({ dependencies: [{ depends_on_id: 'p-live', type: 'parent-child' }] }), liveIds, dropped); + assert.ok(t.parent === 'p-live' && dropped.length === 0); + }); +}); describe('projectLive (type / status / priority mapping)', () => { - const liveIds = new Set(['p-1', 'p-live']) + const liveIds = new Set(['p-1', 'p-live']); it('deferred survives as deferred (the spike approximated it to cancelled)', () => { - assert.equal(projectLive(issue({ status: 'deferred' }), liveIds, []).status, 'deferred') - }) + assert.equal(projectLive(issue({ status: 'deferred' }), liveIds, []).status, 'deferred'); + }); it('a bd framing (bug) collapses to type=task + a label', () => { - const t = projectLive(issue({ issue_type: 'bug' }), liveIds, []) - assert.ok(t.type === 'task' && t.labels?.includes('bug')) - }) + const t = projectLive(issue({ issue_type: 'bug' }), liveIds, []); + assert.ok(t.type === 'task' && t.labels?.includes('bug')); + }); it('decision stays its own type (it is routed to decisions/, not a task row)', () => { - assert.equal(projectLive(issue({ issue_type: 'decision' }), liveIds, []).type, 'decision') - }) + assert.equal(projectLive(issue({ issue_type: 'decision' }), liveIds, []).type, 'decision'); + }); it('an unmapped priority defaults to medium rather than emitting an invalid enum', () => { - assert.equal(projectLive(issue({ priority: 99 }), liveIds, []).priority, 'medium') - }) + assert.equal(projectLive(issue({ priority: 99 }), liveIds, []).priority, 'medium'); + }); it('an unknown issue_type throws loudly (never a silently wrong type)', () => { - assert.throws(() => projectLive(issue({ issue_type: 'nonsense' }), liveIds, [])) - }) + assert.throws(() => projectLive(issue({ issue_type: 'nonsense' }), liveIds, [])); + }); it('an unmapped bd status (reopened) throws, never emits a status-less row', () => { // bd has statuses beyond STATUS_MAP's four (`reopened`, …). Unmapped → undefined → // js-yaml DROPS the key → a task row with no status. Silent corruption; must throw. - assert.throws(() => projectLive(issue({ status: 'reopened' }), liveIds, [])) - }) -}) + assert.throws(() => projectLive(issue({ status: 'reopened' }), liveIds, [])); + }); +}); /** * A migrated task row. Only `id` and `parent` steer the routing, but the row must @@ -163,46 +163,46 @@ describe('projectLive (type / status / priority mapping)', () => { * @param {string} [parent] * @returns {import('../lib/schema.js').TaskRow} */ -const row = (id, parent) => ({ id, title: id, status: 'pending', type: 'task', ...(parent ? { parent } : {}) }) +const row = (id, parent) => ({ id, title: id, status: 'pending', type: 'task', ...(parent ? { parent } : {}) }); /** * The epic, a child, a GRANDchild (must follow the epic transitively), and an outsider. * * @returns {import('../lib/schema.js').TaskRow[]} */ -const tree = () => [row('e-1'), row('c-1', 'e-1'), row('g-1', 'c-1'), row('o-1')] +const tree = () => [row('e-1'), row('c-1', 'e-1'), row('g-1', 'c-1'), row('o-1')]; describe('groupTasks (slug routing)', () => { - const epicSlugs = new Map([['e-1', 'migration']]) + const epicSlugs = new Map([['e-1', 'migration']]); it('the epic, its child, AND its grandchild land in the epic slug', () => { - const g = groupTasks(tree(), epicSlugs, 'backlog') - assert.equal(g.get('migration')?.map(t => t.id).join(','), 'e-1,c-1,g-1') - }) + const g = groupTasks(tree(), epicSlugs, 'backlog'); + assert.equal(g.get('migration')?.map(t => t.id).join(','), 'e-1,c-1,g-1'); + }); it('an unparented task falls to the default slug', () => { - const g = groupTasks(tree(), epicSlugs, 'backlog') - assert.equal(g.get('backlog')?.map(t => t.id).join(','), 'o-1') - }) + const g = groupTasks(tree(), epicSlugs, 'backlog'); + assert.equal(g.get('backlog')?.map(t => t.id).join(','), 'o-1'); + }); it('no --epic given: everything lands in one default-slug file', () => { - const g = groupTasks([row('o-1')], new Map(), 'backlog') - assert.ok(g.size === 1 && g.get('backlog')?.length === 1) - }) + const g = groupTasks([row('o-1')], new Map(), 'backlog'); + assert.ok(g.size === 1 && g.get('backlog')?.length === 1); + }); it('a parent cycle terminates and falls to the default slug', () => { // A malformed export could carry a parent cycle; routing must terminate. - const g = groupTasks([row('a', 'b'), row('b', 'a')], epicSlugs, 'backlog') - assert.equal(g.get('backlog')?.length, 2) - }) + const g = groupTasks([row('a', 'b'), row('b', 'a')], epicSlugs, 'backlog'); + assert.equal(g.get('backlog')?.length, 2); + }); it('an --epic with no live members yields an empty (still-written) slug', () => { - assert.equal(groupTasks([row('o-1')], epicSlugs, 'backlog').get('migration')?.length, 0) - }) -}) + assert.equal(groupTasks([row('o-1')], epicSlugs, 'backlog').get('migration')?.length, 0); + }); +}); -const SCRIPT = fileURLToPath(new URL('../lib/migrate/bootstrap.js', import.meta.url)) -const EXPORT = fileURLToPath(new URL('fixtures/bd-export.jsonl', import.meta.url)) +const SCRIPT = fileURLToPath(new URL('../lib/migrate/bootstrap.js', import.meta.url)); +const EXPORT = fileURLToPath(new URL('fixtures/bd-export.jsonl', import.meta.url)); /** * Run the migrator CLI against the synthetic export. @@ -212,9 +212,9 @@ const EXPORT = fileURLToPath(new URL('fixtures/bd-export.jsonl', import.meta.url * @returns {{ code: number|null, out: string }} */ const run = (args, wd) => { - const r = spawnSync('node', [SCRIPT, EXPORT, ...args], { cwd: wd, encoding: 'utf8' }) - return { code: r.status, out: (r.stdout ?? '') + (r.stderr ?? '') } -} + const r = spawnSync('node', [SCRIPT, EXPORT, ...args], { cwd: wd, encoding: 'utf8' }); + return { code: r.status, out: (r.stdout ?? '') + (r.stderr ?? '') }; +}; /** * A temp dir that cleans itself up when the test ends. @@ -223,9 +223,9 @@ const run = (args, wd) => { * @returns {string} */ function tmpDir (t) { - const dir = mkdtempSync(join(tmpdir(), 'vp-boot-')) - t.after(() => rmSync(dir, { recursive: true, force: true })) - return dir + const dir = mkdtempSync(join(tmpdir(), 'vp-boot-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; } describe('CLI guards (the two data-loss stops)', () => { @@ -234,97 +234,97 @@ describe('CLI guards (the two data-loss stops)', () => { // first populated it. Hence a shared dir and ordered `it`s, not three isolated cases. describe('the overwrite guard, in sequence', () => { /** @type {string} */ - let dir - before(() => { dir = mkdtempSync(join(tmpdir(), 'vp-boot-')) }) - after(() => rmSync(dir, { recursive: true, force: true })) + let dir; + before(() => { dir = mkdtempSync(join(tmpdir(), 'vp-boot-')); }); + after(() => rmSync(dir, { recursive: true, force: true })); it('an empty root migrates cleanly', () => { - const { code } = run(['--root', dir]) - assert.ok(code === 0 && existsSync(join(dir, '.diarie', 'tasks', 'tasks-backlog.yml'))) - }) + const { code } = run(['--root', dir]); + assert.ok(code === 0 && existsSync(join(dir, '.diarie', 'tasks', 'tasks-backlog.yml'))); + }); it('re-running over an existing store refuses (exit 1, names the files)', () => { // This is the guard that stands between a re-invocation and every hand-edit made // since the cutover. - const again = run(['--root', dir]) - assert.ok(again.code === 1 && /refusing to overwrite/.test(again.out) && again.out.includes('tasks-backlog.yml')) - }) + const again = run(['--root', dir]); + assert.ok(again.code === 1 && /refusing to overwrite/.test(again.out) && again.out.includes('tasks-backlog.yml')); + }); it('--force overrides the refusal (the deliberate redo path)', () => { - assert.equal(run(['--root', dir, '--force']).code, 0) - }) - }) + assert.equal(run(['--root', dir, '--force']).code, 0); + }); + }); it('a gitignored ARCHIVE still migrates and is mentioned (policy is the user\'s, not ours)', (t) => { // An ignored ARCHIVE is a judgment call — closed issues record what was DONE, which // git log/CHANGELOG usually already cover. Say something; do not refuse. Asserted on // BEHAVIOUR (migrated + spoke about the archive), not on the exact prose — an earlier // version of this test pinned a sentence and broke when the wording improved. - const dir = tmpDir(t) - spawnSync('git', ['-C', dir, 'init', '-q']) - writeFileSync(join(dir, '.gitignore'), '*.jsonl\n') - const { code, out } = run(['--root', dir]) + const dir = tmpDir(t); + spawnSync('git', ['-C', dir, 'init', '-q']); + writeFileSync(join(dir, '.gitignore'), '*.jsonl\n'); + const { code, out } = run(['--root', dir]); assert.ok( code === 0 && existsSync(join(dir, '.diarie', 'tasks', 'tasks-backlog.yml')) && /gitignored/.test(out) && /bd-final-export\.jsonl/.test(out) - ) - }) + ); + }); it('archive not ignored + bd history never tracked → flagged as a NEW choice', (t) => { // Revealed preference: a project that never tracked `.beads/` already decided bd // history is not worth versioning. Committing a JSONL of it now would quietly // reverse that — so when the archive WOULD commit, say so as a new choice. - const dir = tmpDir(t) - spawnSync('git', ['-C', dir, 'init', '-q']) - const { code, out } = run(['--root', dir]) - assert.ok(code === 0 && /first time/.test(out)) - }) + const dir = tmpDir(t); + spawnSync('git', ['-C', dir, 'init', '-q']); + const { code, out } = run(['--root', dir]); + assert.ok(code === 0 && /first time/.test(out)); + }); it('a gitignored STORE is a hard stop (the backlog itself would not commit)', (t) => { // An ignored STORE is not a judgment call — the migration produced nothing durable. - const dir = tmpDir(t) - spawnSync('git', ['-C', dir, 'init', '-q']) - writeFileSync(join(dir, '.gitignore'), '.diarie/\n') - const { code, out } = run(['--root', dir]) - assert.ok(code === 1 && /GITIGNORED/.test(out)) - }) + const dir = tmpDir(t); + spawnSync('git', ['-C', dir, 'init', '-q']); + writeFileSync(join(dir, '.gitignore'), '.diarie/\n'); + const { code, out } = run(['--root', dir]); + assert.ok(code === 1 && /GITIGNORED/.test(out)); + }); it('a non-git target still migrates (check-ignore absence is not a failure)', (t) => { // …but a plain non-git directory must not false-positive. - assert.equal(run(['--root', tmpDir(t)]).code, 0) - }) + assert.equal(run(['--root', tmpDir(t)]).code, 0); + }); it('a tasks-*.yaml store also trips the overwrite guard (not just .yml)', (t) => { // Both readers accept tasks-*.yaml too, so the guard must match that extension — // otherwise a .yaml store is invisible to it and gets clobbered. - const dir = tmpDir(t) - mkdirSync(join(dir, '.diarie', 'tasks'), { recursive: true }) - writeFileSync(join(dir, '.diarie', 'tasks', 'tasks-x.yaml'), 'tasks: []\n') - const { code, out } = run(['--root', dir]) - assert.ok(code === 1 && /refusing to overwrite/.test(out)) - }) + const dir = tmpDir(t); + mkdirSync(join(dir, '.diarie', 'tasks'), { recursive: true }); + writeFileSync(join(dir, '.diarie', 'tasks', 'tasks-x.yaml'), 'tasks: []\n'); + const { code, out } = run(['--root', dir]); + assert.ok(code === 1 && /refusing to overwrite/.test(out)); + }); it('a bare run (no --root) targets CWD, not the script\'s own repo', (t) => { // Without --root the target is CWD, never the plugin checkout — so a forgotten // --root cannot clobber the tracker of whatever repo happens to ship this script. - const dir = tmpDir(t) - const { code } = run([], dir) - assert.ok(code === 0 && existsSync(join(dir, '.diarie', 'tasks', 'tasks-backlog.yml'))) - }) -}) + const dir = tmpDir(t); + const { code } = run([], dir); + assert.ok(code === 0 && existsSync(join(dir, '.diarie', 'tasks', 'tasks-backlog.yml'))); + }); +}); describe('USAGE ⇔ parser parity (vp-beads-mig)', () => { // migrate is the ONE command whose --help is hand-written, not generated by peowly — so its // USAGE can silently drift from the flags parseArgs actually accepts, with nothing going red. // This asserts the single source, and it is what lets vp-beads-vcb's oracle trust migrate's USAGE. it('every flag listed in USAGE is a MIGRATE_OPTIONS key, and vice versa', () => { - const usageFlags = [...USAGE.matchAll(/^ {2}--([a-z-]+)/gm)].map(m => m[1]).toSorted() - assert.deepEqual(usageFlags, Object.keys(MIGRATE_OPTIONS).toSorted()) - }) -}) + const usageFlags = [...USAGE.matchAll(/^ {2}--([a-z-]+)/gm)].map(m => m[1]).toSorted(); + assert.deepEqual(usageFlags, Object.keys(MIGRATE_OPTIONS).toSorted()); + }); +}); -const CLI = fileURLToPath(new URL('../cli.js', import.meta.url)) +const CLI = fileURLToPath(new URL('../cli.js', import.meta.url)); describe('a missing input file is an InputError, not a crash (vp-beads-mig)', () => { // The archetypal user mistake: point at a file that is not there. It used to reach cli.js as a @@ -336,20 +336,20 @@ describe('a missing input file is an InputError, not a crash (vp-beads-mig)', () // repository now carries diarie's OWN `.diarie/` store at its root (as the extracted repo will // too) — so without isolation the EEXIST overwrite-guard fires FIRST and the missing-input case // this exercises is never reached. Same isolation the CWD-default test above uses. - const MISSING = join(tmpdir(), 'diarie-does-not-exist-xyzzy.jsonl') + const MISSING = join(tmpdir(), 'diarie-does-not-exist-xyzzy.jsonl'); it('plain: exit 1, a clear message, and NO stack trace', (t) => { - const r = spawnSync('node', [CLI, 'migrate', MISSING], { cwd: tmpDir(t), encoding: 'utf8' }) - assert.equal(r.status, 1) - assert.match(r.stderr, /no such bd export file/) - assert.doesNotMatch(r.stderr, /unexpected error|^\s*at /m) - }) + const r = spawnSync('node', [CLI, 'migrate', MISSING], { cwd: tmpDir(t), encoding: 'utf8' }); + assert.equal(r.status, 1); + assert.match(r.stderr, /no such bd export file/); + assert.doesNotMatch(r.stderr, /unexpected error|^\s*at /m); + }); it('--json: the error is on STDOUT with the machine code EUSAGE', (t) => { - const r = spawnSync('node', [CLI, 'migrate', MISSING, '--json'], { cwd: tmpDir(t), encoding: 'utf8' }) - assert.equal(r.status, 1) - const parsed = JSON.parse(r.stdout) - assert.equal(parsed.code, 'EUSAGE') - assert.match(parsed.error, /no such bd export file/) - }) -}) + const r = spawnSync('node', [CLI, 'migrate', MISSING, '--json'], { cwd: tmpDir(t), encoding: 'utf8' }); + assert.equal(r.status, 1); + const parsed = JSON.parse(r.stdout); + assert.equal(parsed.code, 'EUSAGE'); + assert.match(parsed.error, /no such bd export file/); + }); +}); diff --git a/test/ready.spec.js b/test/ready.spec.js index 0cdfc5c..001b630 100644 --- a/test/ready.spec.js +++ b/test/ready.spec.js @@ -8,10 +8,10 @@ * resolves through `exports` under moduleResolution node16. */ -import assert from 'node:assert/strict' -import { describe, it } from 'node:test' +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; -import { computeReady, computeStats, nsId } from 'diarie' +import { computeReady, computeStats, nsId } from 'diarie'; /** @typedef {import('diarie').GlobalId} GlobalId */ /** @typedef {import('../lib/store.js').Task} Task */ @@ -32,7 +32,7 @@ import { computeReady, computeStats, nsId } from 'diarie' * @param {string} id * @returns {GlobalId} */ -const gid = (id) => /** @type {GlobalId} */ (id) +const gid = (id) => /** @type {GlobalId} */ (id); /** * A LOADED task, as `loadTasks` would hand it to the walk. @@ -61,7 +61,7 @@ const task = ({ deps = [], id, parent, ...rest }) => /** @type {Task} */ ({ id: gid(id), deps: deps.map(d => gid(d)), ...(parent === undefined ? {} : { parent: gid(parent) }), -}) +}); /** * A DELIBERATELY malformed row. @@ -73,107 +73,107 @@ const task = ({ deps = [], id, parent, ...rest }) => /** @type {Task} */ ({ * @param {Record} row * @returns {Task} */ -const bad = (row) => /** @type {Task} */ (/** @type {unknown} */ (row)) +const bad = (row) => /** @type {Task} */ (/** @type {unknown} */ (row)); describe('computeReady', () => { it('pending with no deps is ready', () => { - assert.equal(computeReady([task({ id: 'T-1', status: 'pending', type: 'task' })]).ready.length, 1) - }) + assert.equal(computeReady([task({ id: 'T-1', status: 'pending', type: 'task' })]).ready.length, 1); + }); it('pending whose only dep is completed is ready', () => { assert.ok(computeReady([ task({ id: 'T-1', status: 'completed', type: 'task' }), task({ id: 'T-2', status: 'pending', type: 'task', deps: ['T-1'] }), - ]).ready.some(t => t.id === 'T-2')) - }) + ]).ready.some(t => t.id === 'T-2')); + }); it('pending dep blocks (T-2 blocked, not ready)', () => { const r = computeReady([ task({ id: 'T-1', status: 'pending', type: 'task' }), task({ id: 'T-2', status: 'pending', type: 'task', deps: ['T-1'] }), - ]) - assert.ok(r.blocked.some(t => t.id === 'T-2') && !r.ready.some(t => t.id === 'T-2')) - }) + ]); + assert.ok(r.blocked.some(t => t.id === 'T-2') && !r.ready.some(t => t.id === 'T-2')); + }); it('blocked task records its blocker id', () => { const r = computeReady([ task({ id: 'T-1', status: 'pending', type: 'task' }), task({ id: 'T-2', status: 'pending', type: 'task', deps: ['T-1'] }), - ]) - assert.equal(r.blocked.find(t => t.id === 'T-2')?.blockers.includes('T-1'), true) - }) + ]); + assert.equal(r.blocked.find(t => t.id === 'T-2')?.blockers.includes('T-1'), true); + }); it('in_progress dep blocks', () => { assert.ok(computeReady([ task({ id: 'T-1', status: 'in_progress', type: 'task' }), task({ id: 'T-2', status: 'pending', type: 'task', deps: ['T-1'] }), - ]).blocked.some(t => t.id === 'T-2')) - }) + ]).blocked.some(t => t.id === 'T-2')); + }); it('failed dep → needs attention, not ready/blocked', () => { const r = computeReady([ task({ id: 'T-1', status: 'failed', type: 'task' }), task({ id: 'T-2', status: 'pending', type: 'task', deps: ['T-1'] }), - ]) - assert.ok(r.needsAttention.some(t => t.id === 'T-2') && !r.ready.length && !r.blocked.length) - }) + ]); + assert.ok(r.needsAttention.some(t => t.id === 'T-2') && !r.ready.length && !r.blocked.length); + }); it('deferred dep → needs attention, not ready/blocked (deferred never resolves a dep)', () => { const r = computeReady([ task({ id: 'T-1', status: 'deferred', type: 'task' }), task({ id: 'T-2', status: 'pending', type: 'task', deps: ['T-1'] }), - ]) - assert.ok(r.needsAttention.some(t => t.id === 'T-2' && t.reason.includes('deferred')) && !r.ready.length && !r.blocked.length) - }) + ]); + assert.ok(r.needsAttention.some(t => t.id === 'T-2' && t.reason.includes('deferred')) && !r.ready.length && !r.blocked.length); + }); it('a deferred task is never ready (only pending is)', () => { - assert.equal(computeReady([task({ id: 'T-1', status: 'deferred', type: 'task' })]).ready.length, 0) - }) + assert.equal(computeReady([task({ id: 'T-1', status: 'deferred', type: 'task' })]).ready.length, 0); + }); it('missing dep → needs attention', () => { - assert.ok(computeReady([task({ id: 'T-2', status: 'pending', type: 'task', deps: ['T-99'] })]).needsAttention.some(t => t.reason.includes('missing'))) - }) + assert.ok(computeReady([task({ id: 'T-2', status: 'pending', type: 'task', deps: ['T-99'] })]).needsAttention.some(t => t.reason.includes('missing'))); + }); it('in_progress task is not ready (only pending is)', () => { - assert.equal(computeReady([task({ id: 'T-1', status: 'in_progress', type: 'task' })]).ready.length, 0) - }) + assert.equal(computeReady([task({ id: 'T-1', status: 'in_progress', type: 'task' })]).ready.length, 0); + }); it('ready is sorted by priority (critical before low)', () => { const r = computeReady([ task({ id: 'T-low', status: 'pending', type: 'task', priority: 'low' }), task({ id: 'T-crit', status: 'pending', type: 'task', priority: 'critical' }), - ]) - assert.equal(r.ready[0]?.id, 'T-crit') - }) -}) + ]); + assert.equal(r.ready[0]?.id, 'T-crit'); + }); +}); describe('computeReady — type gate (decision vp-beads-etm)', () => { it('a pending doc is never ready', () => { - assert.equal(computeReady([task({ id: 'D-1', status: 'pending', type: 'doc' })]).ready.length, 0) - }) + assert.equal(computeReady([task({ id: 'D-1', status: 'pending', type: 'doc' })]).ready.length, 0); + }); it('a pending decision is never ready', () => { - assert.equal(computeReady([task({ id: 'DEC-1', status: 'pending', type: 'decision' })]).ready.length, 0) - }) + assert.equal(computeReady([task({ id: 'DEC-1', status: 'pending', type: 'decision' })]).ready.length, 0); + }); it('a pending milestone is never ready', () => { - assert.equal(computeReady([task({ id: 'M-1', status: 'pending', type: 'milestone' })]).ready.length, 0) - }) + assert.equal(computeReady([task({ id: 'M-1', status: 'pending', type: 'milestone' })]).ready.length, 0); + }); it('a pending doc is never blocked or needs-attention either — it is simply excluded', () => { - const r = computeReady([task({ id: 'D-1', status: 'pending', type: 'doc', deps: ['T-99'] })]) - assert.ok(r.ready.length === 0 && r.blocked.length === 0 && r.needsAttention.length === 0) - }) + const r = computeReady([task({ id: 'D-1', status: 'pending', type: 'doc', deps: ['T-99'] })]); + assert.ok(r.ready.length === 0 && r.blocked.length === 0 && r.needsAttention.length === 0); + }); it('a pending task is unaffected by sibling non-task items in the same list', () => { const r = computeReady([ task({ id: 'T-1', status: 'pending', type: 'task' }), task({ id: 'D-1', status: 'pending', type: 'doc' }), task({ id: 'M-1', status: 'pending', type: 'milestone' }), - ]) - assert.ok(r.ready.length === 1 && r.ready[0]?.id === 'T-1') - }) -}) + ]); + assert.ok(r.ready.length === 1 && r.ready[0]?.id === 'T-1'); + }); +}); // These cover the RULE. They cannot cover the bug that made the rule necessary: the // half-globalized `parent` in loadTasks. Inline arrays let the author write `id` and @@ -187,94 +187,94 @@ describe('computeReady — containers (vp-beads-epc)', () => { const r = computeReady([ task({ id: 'P', status: 'pending', type: 'task' }), task({ id: 'C', status: 'pending', type: 'task', parent: 'P' }), - ]) + ]); assert.ok(!r.ready.some(t => t.id === 'P') && - r.blocked.find(t => t.id === 'P')?.children?.includes('C') === true) - }) + r.blocked.find(t => t.id === 'P')?.children?.includes('C') === true); + }); it('the CHILD stays ready (you work the children, not the container)', () => { assert.ok(computeReady([ task({ id: 'P', status: 'pending', type: 'task' }), task({ id: 'C', status: 'pending', type: 'task', parent: 'P' }), - ]).ready.some(t => t.id === 'C')) - }) + ]).ready.some(t => t.id === 'C')); + }); it('children go in `children`, never in `blockers` (a dep must FINISH FIRST; a child is CONTAINED)', () => { const p = computeReady([ task({ id: 'P', status: 'pending', type: 'task' }), task({ id: 'C', status: 'pending', type: 'task', parent: 'P' }), - ]).blocked.find(t => t.id === 'P') - assert.ok(p?.blockers.length === 0 && p?.children?.length === 1) - }) + ]).blocked.find(t => t.id === 'P'); + assert.ok(p?.blockers.length === 0 && p?.children?.length === 1); + }); it('a parent whose children are ALL completed is ready again (only OPEN children contain work)', () => { assert.ok(computeReady([ task({ id: 'P', status: 'pending', type: 'task' }), task({ id: 'C', status: 'completed', type: 'task', parent: 'P' }), - ]).ready.some(t => t.id === 'P')) - }) + ]).ready.some(t => t.id === 'P')); + }); // The DECLARATIVE predicate, isolated from the structural one. In the live store these // two always co-occur (vp-beads-l9i is epic-labelled AND has open children), so a fix // that never implements this check would still look correct there. it('an `epic`-labelled task with NO children is never ready — it needs attention', () => { - const r = computeReady([task({ id: 'E', status: 'pending', type: 'task', labels: ['epic'] })]) - assert.ok(r.ready.length === 0 && /no open children/.test(r.needsAttention[0]?.reason ?? '')) - }) + const r = computeReady([task({ id: 'E', status: 'pending', type: 'task', labels: ['epic'] })]); + assert.ok(r.ready.length === 0 && /no open children/.test(r.needsAttention[0]?.reason ?? '')); + }); it('an `epic`-labelled task whose children are all completed still is not ready (close it, do not work it)', () => { const r = computeReady([ task({ id: 'E', status: 'pending', type: 'task', labels: ['epic'] }), task({ id: 'C', status: 'completed', type: 'task', parent: 'E' }), - ]) - assert.ok(!r.ready.some(t => t.id === 'E') && r.needsAttention.some(t => t.id === 'E')) - }) + ]); + assert.ok(!r.ready.some(t => t.id === 'E') && r.needsAttention.some(t => t.id === 'E')); + }); it('an in_progress child still counts as OPEN (a claimed child is not a finished one)', () => { assert.ok(computeReady([ task({ id: 'P', status: 'pending', type: 'task' }), task({ id: 'C', status: 'in_progress', type: 'task', parent: 'P' }), - ]).blocked.some(t => t.id === 'P')) - }) + ]).blocked.some(t => t.id === 'P')); + }); it('a task can be blocked by deps AND by children at once, reported separately', () => { const p = computeReady([ task({ id: 'D', status: 'pending', type: 'task' }), task({ id: 'P', status: 'pending', type: 'task', deps: ['D'] }), task({ id: 'C', status: 'pending', type: 'task', parent: 'P' }), - ]).blocked.find(t => t.id === 'P') - assert.ok(p?.blockers.includes('D') === true && p?.children?.includes('C') === true) - }) + ]).blocked.find(t => t.id === 'P'); + assert.ok(p?.blockers.includes('D') === true && p?.children?.includes('C') === true); + }); it('a CANCELLED child does not entomb its parent in `blocked` — it needs a human, like a cancelled DEP', () => { const r = computeReady([ task({ id: 'P', status: 'pending', type: 'task' }), task({ id: 'C', status: 'cancelled', type: 'task', parent: 'P' }), - ]) + ]); // The bug this pins: "open child" was once `status !== 'completed'`, which swept the // terminal statuses into ACTIVE and left the parent blocked forever, by a dead child, // in the bucket nobody reads. A cancelled DEP had always routed to needsAttention. - assert.ok(!r.blocked.some(t => t.id === 'P') && r.needsAttention.some(t => t.id === 'P' && /cancelled/.test(t.reason))) - }) + assert.ok(!r.blocked.some(t => t.id === 'P') && r.needsAttention.some(t => t.id === 'P' && /cancelled/.test(t.reason))); + }); it('a MILESTONE child never blocks its parent (it is never worked, so it never completes)', () => { const r = computeReady([ task({ id: 'P', status: 'pending', type: 'task' }), task({ id: 'M', status: 'pending', type: 'milestone', parent: 'P' }), - ]) + ]); // A milestone lives in tasks-*.yml (unlike a decision), has "no effort, no assignment", // and therefore never reaches `completed`. Counting it as an open child would have held // its parent hostage forever. - assert.ok(r.ready.some(t => t.id === 'P')) - }) + assert.ok(r.ready.some(t => t.id === 'P')); + }); it('a DANGLING parent surfaces the child (the tripwire for an id-space divergence)', () => { - const r = computeReady([task({ id: 'C', status: 'pending', type: 'task', parent: 'NOPE' })]) + const r = computeReady([task({ id: 'C', status: 'pending', type: 'task', parent: 'NOPE' })]); // If `id` and `parent` ever fall into different id-spaces again, EVERY parent dangles // and every row lands here at once — instead of every epic silently becoming workable. - assert.ok(!r.ready.some(t => t.id === 'C') && r.needsAttention.some(t => /parent NOPE \(missing\)/.test(t.reason))) - }) -}) + assert.ok(!r.ready.some(t => t.id === 'C') && r.needsAttention.some(t => /parent NOPE \(missing\)/.test(t.reason))); + }); +}); describe('computeStats', () => { const tasks = [ @@ -282,73 +282,73 @@ describe('computeStats', () => { task({ id: 'T-2', status: 'pending', type: 'task', labels: ['bug'], priority: 'medium' }), task({ id: 'T-3', status: 'pending', type: 'task', priority: 'low', deps: ['T-2'] }), task({ id: 'T-4', status: 'in_progress', type: 'task', labels: ['feature'], priority: 'high', updated: '2026-01-01' }), - ] - const s = computeStats(tasks, 30, new Date('2026-06-09T00:00:00Z')) + ]; + const s = computeStats(tasks, 30, new Date('2026-06-09T00:00:00Z')); it('stats total counts all tasks', () => { - assert.equal(s.total, 4) - }) + assert.equal(s.total, 4); + }); it('stats byStatus tallies', () => { - assert.ok(s.byStatus['pending'] === 2 && s.byStatus['completed'] === 1 && s.byStatus['in_progress'] === 1) - }) + assert.ok(s.byStatus['pending'] === 2 && s.byStatus['completed'] === 1 && s.byStatus['in_progress'] === 1); + }); it('stats ready counts only unblocked pending', () => { - assert.equal(s.ready, 1) // T-2 ready, T-3 blocked by T-2 - }) + assert.equal(s.ready, 1); // T-2 ready, T-3 blocked by T-2 + }); it('stats blocked counts blocked pending', () => { - assert.equal(s.blocked, 1) - }) + assert.equal(s.blocked, 1); + }); it('stats flags an old in_progress task as stale', () => { - assert.ok(s.stale.includes('T-4')) - }) + assert.ok(s.stale.includes('T-4')); + }); it('stats does not flag a recent task as stale', () => { - assert.ok(!computeStats(tasks, 30, new Date('2026-01-15T00:00:00Z')).stale.includes('T-4')) - }) -}) + assert.ok(!computeStats(tasks, 30, new Date('2026-01-15T00:00:00Z')).stale.includes('T-4')); + }); +}); describe('computeStats — robustness', () => { it('a malformed updated date lands in malformedDates, not stale', () => { - const s = computeStats([task({ id: 'T-x', status: 'in_progress', updated: 'yesterday' })], 30, new Date('2026-06-09T00:00:00Z')) - assert.ok(s.malformedDates.includes('T-x') && !s.stale.includes('T-x')) - }) + const s = computeStats([task({ id: 'T-x', status: 'in_progress', updated: 'yesterday' })], 30, new Date('2026-06-09T00:00:00Z')); + assert.ok(s.malformedDates.includes('T-x') && !s.stale.includes('T-x')); + }); it('a prototype-name status does not pollute byStatus', () => { // `bad()`, not `task()`: `toString` is not a Status, and that is the entire point — // the row is a prototype-pollution probe. `task()` REFUSES to build it, which is // `task()` working; the cast is how the test says "yes, I mean an invalid row". - const s = computeStats([bad({ id: 'T-x', status: 'toString', type: 'task', priority: 'medium' })], 30, new Date('2026-06-09T00:00:00Z')) - assert.ok(!('toString' in s.byStatus)) - }) -}) + const s = computeStats([bad({ id: 'T-x', status: 'toString', type: 'task', priority: 'medium' })], 30, new Date('2026-06-09T00:00:00Z')); + assert.ok(!('toString' in s.byStatus)); + }); +}); describe('nsId', () => { it('nsId namespaces a bare id', () => { - assert.equal(nsId('T-1', 'alpha'), 'alpha/T-1') - }) + assert.equal(nsId('T-1', 'alpha'), 'alpha/T-1'); + }); it('nsId passes through a slug-qualified id', () => { - assert.equal(nsId('beta/T-2', 'alpha'), 'beta/T-2') - }) + assert.equal(nsId('beta/T-2', 'alpha'), 'beta/T-2'); + }); it('nsId stringifies a numeric id', () => { - assert.equal(nsId(1, 'alpha'), 'alpha/1') - }) -}) + assert.equal(nsId(1, 'alpha'), 'alpha/1'); + }); +}); describe('computeReady — cross-file & self-cycle', () => { it('a cross-file (slug/id) dep on a completed task is ready', () => { assert.ok(computeReady([ task({ id: 'a/T-1', status: 'completed', type: 'task' }), task({ id: 'b/T-2', status: 'pending', type: 'task', deps: ['a/T-1'] }), - ]).ready.some(t => t.id === 'b/T-2')) - }) + ]).ready.some(t => t.id === 'b/T-2')); + }); it('a self-dependent task is blocked, not ready (no infinite loop)', () => { - const r = computeReady([task({ id: 'a/T-1', status: 'pending', type: 'task', deps: ['a/T-1'] })]) - assert.ok(r.blocked.some(t => t.id === 'a/T-1') && !r.ready.length) - }) -}) + const r = computeReady([task({ id: 'a/T-1', status: 'pending', type: 'task', deps: ['a/T-1'] })]); + assert.ok(r.blocked.some(t => t.id === 'a/T-1') && !r.ready.length); + }); +}); diff --git a/test/validate.spec.js b/test/validate.spec.js index e3b7cdd..c7cfcf9 100644 --- a/test/validate.spec.js +++ b/test/validate.spec.js @@ -5,10 +5,10 @@ * must stay silent. Pure — inline data, no file IO. */ -import assert from 'node:assert/strict' -import { describe, it } from 'node:test' +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; -import { lintTasks } from 'diarie' +import { lintTasks } from 'diarie'; /** * Build a valid task, overridable per-field. @@ -16,7 +16,7 @@ import { lintTasks } from 'diarie' * @param {Record} [task] * @returns {Record} */ -const ok = (task) => ({ id: 'T-1', title: 'x', status: 'pending', type: 'task', ...task }) +const ok = (task) => ({ id: 'T-1', title: 'x', status: 'pending', type: 'task', ...task }); /** * Lint a single inline file of tasks. @@ -28,165 +28,165 @@ const ok = (task) => ({ id: 'T-1', title: 'x', status: 'pending', type: 'task', * @param {string} [name] * @returns {{ errors: string[], warnings: string[] }} */ -const lint = (tasks, name = 'demo') => lintTasks([{ name, tasks }]) +const lint = (tasks, name = 'demo') => lintTasks([{ name, tasks }]); describe('lintTasks — Pass 1 (structural)', () => { it('a well-formed task file is clean', () => { - const r = lint([ok({ id: 'T-1' }), ok({ id: 'T-2', status: 'completed', acceptance_criteria: ['done'] })]) - assert.ok(r.errors.length === 0 && r.warnings.length === 0) - }) + const r = lint([ok({ id: 'T-1' }), ok({ id: 'T-2', status: 'completed', acceptance_criteria: ['done'] })]); + assert.ok(r.errors.length === 0 && r.warnings.length === 0); + }); it('missing required field (status) errors', () => { - assert.ok(lint([{ id: 'T-1', title: 'x', type: 'task' }]).errors.some(e => /missing required field: status/.test(e))) - }) + assert.ok(lint([{ id: 'T-1', title: 'x', type: 'task' }]).errors.some(e => /missing required field: status/.test(e))); + }); it('invalid status enum errors', () => { - assert.ok(lint([ok({ status: 'doing' })]).errors.some(e => /invalid status/.test(e))) - }) + assert.ok(lint([ok({ status: 'doing' })]).errors.some(e => /invalid status/.test(e))); + }); it('invalid type enum errors', () => { - assert.ok(lint([ok({ type: 'widget' })]).errors.some(e => /invalid type/.test(e))) - }) + assert.ok(lint([ok({ type: 'widget' })]).errors.some(e => /invalid type/.test(e))); + }); it('invalid priority enum errors', () => { - assert.ok(lint([ok({ priority: 'urgent' })]).errors.some(e => /invalid priority/.test(e))) - }) + assert.ok(lint([ok({ priority: 'urgent' })]).errors.some(e => /invalid priority/.test(e))); + }); it('invalid id shape errors', () => { - assert.ok(lint([ok({ id: 'bad id!' })]).errors.some(e => /invalid id/.test(e))) - }) + assert.ok(lint([ok({ id: 'bad id!' })]).errors.some(e => /invalid id/.test(e))); + }); it('duplicate id within file errors', () => { - assert.ok(lint([ok({ id: 'T-1' }), ok({ id: 'T-1' })]).errors.some(e => /duplicate id/.test(e))) - }) -}) + assert.ok(lint([ok({ id: 'T-1' }), ok({ id: 'T-1' })]).errors.some(e => /duplicate id/.test(e))); + }); +}); describe('lintTasks — Pass 2 (dep graph)', () => { it('dangling dep errors', () => { - assert.ok(lint([ok({ id: 'T-1', deps: ['T-99'] })]).errors.some(e => /dep "demo\/T-99" does not exist/.test(e))) - }) + assert.ok(lint([ok({ id: 'T-1', deps: ['T-99'] })]).errors.some(e => /dep "demo\/T-99" does not exist/.test(e))); + }); it('orphan parent errors', () => { - assert.ok(lint([ok({ id: 'T-1', parent: 'T-99' })]).errors.some(e => /parent "demo\/T-99" does not exist/.test(e))) - }) + assert.ok(lint([ok({ id: 'T-1', parent: 'T-99' })]).errors.some(e => /parent "demo\/T-99" does not exist/.test(e))); + }); it('a real dep does not error', () => { - assert.equal(lint([ok({ id: 'T-1', status: 'completed', acceptance_criteria: ['x'] }), ok({ id: 'T-2', deps: ['T-1'] })]).errors.length, 0) - }) + assert.equal(lint([ok({ id: 'T-1', status: 'completed', acceptance_criteria: ['x'] }), ok({ id: 'T-2', deps: ['T-1'] })]).errors.length, 0); + }); it('a 2-cycle is detected', () => { - const r = lint([ok({ id: 'T-1', deps: ['T-2'] }), ok({ id: 'T-2', deps: ['T-1'] })]) - assert.ok(r.errors.some(e => /cycle/.test(e))) - }) + const r = lint([ok({ id: 'T-1', deps: ['T-2'] }), ok({ id: 'T-2', deps: ['T-1'] })]); + assert.ok(r.errors.some(e => /cycle/.test(e))); + }); it('cross-file dep (slug/id) resolves without dangling error', () => { const r = lintTasks([ { name: 'a', tasks: [ok({ id: 'T-1', status: 'completed', acceptance_criteria: ['x'] })] }, { name: 'b', tasks: [ok({ id: 'T-1', deps: ['a/T-1'] })] }, - ]) - assert.ok(!r.errors.some(e => /does not exist/.test(e))) - }) -}) + ]); + assert.ok(!r.errors.some(e => /does not exist/.test(e))); + }); +}); describe('lintTasks — Pass 3 (transition sanity)', () => { it('in_progress claimed before a pending blocker warns', () => { - const r = lint([ok({ id: 'T-1', status: 'pending' }), ok({ id: 'T-2', status: 'in_progress', deps: ['T-1'] })]) - assert.ok(r.warnings.some(w => /claimed before blockers resolved/.test(w))) - }) + const r = lint([ok({ id: 'T-1', status: 'pending' }), ok({ id: 'T-2', status: 'in_progress', deps: ['T-1'] })]); + assert.ok(r.warnings.some(w => /claimed before blockers resolved/.test(w))); + }); it('agent set on a pending task is a ghost-claim warning', () => { - assert.ok(lint([ok({ id: 'T-1', status: 'pending', agent: 'loop-1' })]).warnings.some(w => /ghost claim/.test(w))) - }) -}) + assert.ok(lint([ok({ id: 'T-1', status: 'pending', agent: 'loop-1' })]).warnings.some(w => /ghost claim/.test(w))); + }); +}); describe('lintTasks — Pass 4 (test-ratchet)', () => { it('completed task with no acceptance_criteria warns', () => { - assert.ok(lint([ok({ id: 'T-1', status: 'completed', type: 'task' })]).warnings.some(w => /no acceptance_criteria/.test(w))) - }) + assert.ok(lint([ok({ id: 'T-1', status: 'completed', type: 'task' })]).warnings.some(w => /no acceptance_criteria/.test(w))); + }); it('completed task WITH acceptance_criteria is clean', () => { - assert.equal(lint([ok({ id: 'T-1', status: 'completed', type: 'task', acceptance_criteria: ['ok'] })]).warnings.length, 0) - }) + assert.equal(lint([ok({ id: 'T-1', status: 'completed', type: 'task', acceptance_criteria: ['ok'] })]).warnings.length, 0); + }); it('completed doc (non-ratchet type) needs no acceptance_criteria', () => { - assert.equal(lint([ok({ id: 'T-1', status: 'completed', type: 'doc' })]).warnings.length, 0) - }) + assert.equal(lint([ok({ id: 'T-1', status: 'completed', type: 'doc' })]).warnings.length, 0); + }); it('a former bd type (chore) is no longer a valid type', () => { - assert.ok(lint([ok({ type: 'chore' })]).errors.some(e => /invalid type "chore"/.test(e))) - }) + assert.ok(lint([ok({ type: 'chore' })]).errors.some(e => /invalid type "chore"/.test(e))); + }); it('framings ride in labels, not type (task + bug label is clean)', () => { - assert.equal(lint([ok({ labels: ['bug'] })]).errors.length, 0) - }) + assert.equal(lint([ok({ labels: ['bug'] })]).errors.length, 0); + }); it('completed task with SCALAR acceptance_criteria still warns (not a list)', () => { - assert.ok(lint([ok({ id: 'T-1', status: 'completed', type: 'task', acceptance_criteria: 'done' })]).warnings.some(w => /no acceptance_criteria/.test(w))) - }) -}) + assert.ok(lint([ok({ id: 'T-1', status: 'completed', type: 'task', acceptance_criteria: 'done' })]).warnings.some(w => /no acceptance_criteria/.test(w))); + }); +}); describe('lintTasks — Pass 0 (shape guard)', () => { it('deps as a scalar string errors (not char-split)', () => { - assert.ok(lint([ok({ id: 'T-1', deps: 'T-2' })]).errors.some(e => /"deps" must be a list/.test(e))) - }) + assert.ok(lint([ok({ id: 'T-1', deps: 'T-2' })]).errors.some(e => /"deps" must be a list/.test(e))); + }); it('acceptance_criteria as a scalar string errors', () => { - assert.ok(lint([ok({ id: 'T-1', acceptance_criteria: 'done' })]).errors.some(e => /"acceptance_criteria" must be a list/.test(e))) - }) + assert.ok(lint([ok({ id: 'T-1', acceptance_criteria: 'done' })]).errors.some(e => /"acceptance_criteria" must be a list/.test(e))); + }); it('top-level tasks as a non-array errors cleanly', () => { - assert.ok(lintTasks([{ name: 'demo', tasks: 'oops' }]).errors.some(e => /"tasks" must be a list/.test(e))) - }) + assert.ok(lintTasks([{ name: 'demo', tasks: 'oops' }]).errors.some(e => /"tasks" must be a list/.test(e))); + }); it('a non-mapping task entry errors', () => { - assert.ok(lintTasks([{ name: 'demo', tasks: ['oops'] }]).errors.some(e => /is not a mapping/.test(e))) - }) + assert.ok(lintTasks([{ name: 'demo', tasks: ['oops'] }]).errors.some(e => /is not a mapping/.test(e))); + }); it('labels as a scalar string errors', () => { - assert.ok(lint([ok({ labels: 'bug' })]).errors.some(e => /"labels" must be a list/.test(e))) - }) + assert.ok(lint([ok({ labels: 'bug' })]).errors.some(e => /"labels" must be a list/.test(e))); + }); it('a non-string labels entry errors', () => { - assert.ok(lint([ok({ labels: ['bug', 7] })]).errors.some(e => /"labels" entries must all be strings/.test(e))) - }) + assert.ok(lint([ok({ labels: ['bug', 7] })]).errors.some(e => /"labels" entries must all be strings/.test(e))); + }); it('a scalar deps does not crash and skips graph use', () => { - assert.doesNotThrow(() => lint([ok({ id: 'T-1', deps: 'T-2' })])) - }) -}) + assert.doesNotThrow(() => lint([ok({ id: 'T-1', deps: 'T-2' })])); + }); +}); describe('lintTasks — Pass 2 (cycle exhaustiveness + hints)', () => { it('two disjoint 2-cycles each produce a distinct cycle error', () => { const r = lint([ ok({ id: 'T-1', deps: ['T-2'] }), ok({ id: 'T-2', deps: ['T-1'] }), ok({ id: 'T-3', deps: ['T-4'] }), ok({ id: 'T-4', deps: ['T-3'] }), - ]) - const cycles = r.errors.filter(e => /cycle/.test(e)) - assert.ok(cycles.length === 2 && cycles.some(e => /T-1|T-2/.test(e)) && cycles.some(e => /T-3|T-4/.test(e))) - }) + ]); + const cycles = r.errors.filter(e => /cycle/.test(e)); + assert.ok(cycles.length === 2 && cycles.some(e => /T-1|T-2/.test(e)) && cycles.some(e => /T-3|T-4/.test(e))); + }); it('a self-loop is detected as a cycle', () => { - assert.ok(lint([ok({ id: 'T-1', deps: ['T-1'] })]).errors.some(e => /cycle/.test(e))) - }) + assert.ok(lint([ok({ id: 'T-1', deps: ['T-1'] })]).errors.some(e => /cycle/.test(e))); + }); it('a dangling bare dep matching another slug suggests it', () => { const r = lintTasks([ { name: 'a', tasks: [ok({ id: 'T-1', status: 'completed', acceptance_criteria: ['x'] })] }, { name: 'b', tasks: [ok({ id: 'T-2', deps: ['T-1'] })] }, // bare 'T-1' → 'b/T-1' (dangling) - ]) - assert.ok(r.errors.some(e => /did you mean a\/T-1/.test(e))) - }) -}) + ]); + assert.ok(r.errors.some(e => /did you mean a\/T-1/.test(e))); + }); +}); describe('lintTasks — Pass 1 (updated date)', () => { it('malformed updated date errors', () => { - assert.ok(lint([ok({ id: 'T-1', updated: 'yesterday' })]).errors.some(e => /invalid updated/.test(e))) - }) + assert.ok(lint([ok({ id: 'T-1', updated: 'yesterday' })]).errors.some(e => /invalid updated/.test(e))); + }); it('valid ISO updated date is clean', () => { - assert.equal(lint([ok({ id: 'T-1', updated: '2026-01-01' })]).errors.length, 0) - }) -}) + assert.equal(lint([ok({ id: 'T-1', updated: '2026-01-01' })]).errors.length, 0); + }); +}); // THE WORST BUG THIS FILE HAS EVER PINNED. `computeReady` blocks on TWO edge kinds pointing // opposite ways: a dep blocks the DEPENDENT (task → dep), a child blocks the PARENT @@ -199,26 +199,26 @@ describe('the BLOCKING graph — one check over the union, not two over the proj assert.ok(lint([ { id: 'E', title: 'the epic', status: 'pending', type: 'task', labels: ['epic'] }, { id: 'T', title: 'depends on its own epic', status: 'pending', type: 'task', parent: 'E', deps: ['E'] }, - ]).errors.some(e => /cycle/.test(e))) - }) + ]).errors.some(e => /cycle/.test(e))); + }); it('a 3-node alternating ring is caught too (X deps Y; Y contains Z; Z deps X)', () => { assert.ok(lint([ { id: 'X', title: 'x', status: 'pending', type: 'task', deps: ['Y'] }, { id: 'Y', title: 'y', status: 'pending', type: 'task' }, { id: 'Z', title: 'z', status: 'pending', type: 'task', parent: 'Y', deps: ['X'] }, - ]).errors.some(e => /cycle/.test(e))) - }) + ]).errors.some(e => /cycle/.test(e))); + }); it('a LEGITIMATE deep parent chain is NOT a cycle (guards against over-rejecting)', () => { assert.equal(lint([ { id: 'GP', title: 'grandparent', status: 'pending', type: 'task' }, { id: 'P', title: 'parent', status: 'pending', type: 'task', parent: 'GP' }, { id: 'C', title: 'child', status: 'pending', type: 'task', parent: 'P' }, - ]).errors.length, 0) - }) + ]).errors.length, 0); + }); it('a self-parent still errors (the one-element case)', () => { - assert.ok(lint([{ id: 'S', title: 's', status: 'pending', type: 'task', parent: 'S' }]).errors.some(e => /itself/.test(e))) - }) -}) + assert.ok(lint([{ id: 'S', title: 's', status: 'pending', type: 'task', parent: 'S' }]).errors.some(e => /itself/.test(e))); + }); +});