diff --git a/.ast-grep/rule-tests/__snapshots__/no-commonjs-require-snapshot.yml b/.ast-grep/rule-tests/__snapshots__/no-commonjs-require-snapshot.yml new file mode 100644 index 0000000..1947d56 --- /dev/null +++ b/.ast-grep/rule-tests/__snapshots__/no-commonjs-require-snapshot.yml @@ -0,0 +1,14 @@ +id: no-commonjs-require +snapshots: + const fs = require('fs'): + labels: + - source: require('fs') + style: primary + start: 11 + end: 24 + const { readFile } = require('node:fs/promises'): + labels: + - source: require('node:fs/promises') + style: primary + start: 21 + end: 48 diff --git a/.ast-grep/rule-tests/__snapshots__/no-computed-exit-code-snapshot.yml b/.ast-grep/rule-tests/__snapshots__/no-computed-exit-code-snapshot.yml new file mode 100644 index 0000000..b851b80 --- /dev/null +++ b/.ast-grep/rule-tests/__snapshots__/no-computed-exit-code-snapshot.yml @@ -0,0 +1,60 @@ +id: no-computed-exit-code +snapshots: + ? | + const TWO = 2 + exit(TWO) + : labels: + - source: exit(TWO) + style: primary + start: 14 + end: 23 + ? | + const TWO = 2 + process.exitCode = TWO + : labels: + - source: process.exitCode = TWO + style: primary + start: 14 + end: 36 + ? | + exit(code) + : labels: + - source: exit(code) + style: primary + start: 0 + end: 10 + ? | + exit(ok ? 0 : 1) + : labels: + - source: 'exit(ok ? 0 : 1)' + style: primary + start: 0 + end: 16 + ? | + process.exit(0x2) + : labels: + - source: process.exit(0x2) + style: primary + start: 0 + end: 17 + ? | + process.exit(2.0) + : labels: + - source: process.exit(2.0) + style: primary + start: 0 + end: 17 + ? | + process.exit(Number(x)) + : labels: + - source: process.exit(Number(x)) + style: primary + start: 0 + end: 23 + ? | + process.exit(someVar) + : labels: + - source: process.exit(someVar) + style: primary + start: 0 + end: 21 diff --git a/.ast-grep/rule-tests/__snapshots__/no-hardcoded-tracker-dir-snapshot.yml b/.ast-grep/rule-tests/__snapshots__/no-hardcoded-tracker-dir-snapshot.yml new file mode 100644 index 0000000..8c10306 --- /dev/null +++ b/.ast-grep/rule-tests/__snapshots__/no-hardcoded-tracker-dir-snapshot.yml @@ -0,0 +1,30 @@ +id: no-hardcoded-tracker-dir +snapshots: + ? | + const p = `${root}/.diarie/tasks/tasks-x.yml` + : labels: + - source: '`${root}/.diarie/tasks/tasks-x.yml`' + style: primary + start: 10 + end: 45 + ? | + const tasksDir = join(root, '.diarie', 'tasks') + : labels: + - source: '''.diarie''' + style: primary + start: 28 + end: 37 + ? | + const tasksDir = join(root, 'backlog/tasks') + : labels: + - source: '''backlog/tasks''' + style: primary + start: 28 + end: 43 + ? | + stderr.write('refusing to write under .diarie/tasks/\n') + : labels: + - source: '''refusing to write under .diarie/tasks/\n''' + style: primary + start: 13 + end: 55 diff --git a/.ast-grep/rule-tests/__snapshots__/no-identical-test-title-snapshot.yml b/.ast-grep/rule-tests/__snapshots__/no-identical-test-title-snapshot.yml new file mode 100644 index 0000000..a6cc4af --- /dev/null +++ b/.ast-grep/rule-tests/__snapshots__/no-identical-test-title-snapshot.yml @@ -0,0 +1,107 @@ +id: no-identical-test-title +snapshots: + ? | + describe('computeReady', () => { + it('a', () => {}) + it('b', () => {}) + it('a', () => {}) + }) + : labels: + - source: it('a', () => {}) + style: primary + start: 75 + end: 92 + - source: it('a', () => {}) + style: secondary + start: 75 + end: 92 + - source: it('a', () => {}) + style: secondary + start: 35 + end: 52 + - source: it('a', () => {}) + style: secondary + start: 35 + end: 52 + ? | + describe('computeReady', () => { + it('pending with no deps is ready', () => {}) + it('pending with no deps is ready', () => {}) + }) + : labels: + - source: it('pending with no deps is ready', () => {}) + style: primary + start: 83 + end: 128 + - source: it('pending with no deps is ready', () => {}) + style: secondary + start: 83 + end: 128 + - source: it('pending with no deps is ready', () => {}) + style: secondary + start: 35 + end: 80 + - source: it('pending with no deps is ready', () => {}) + style: secondary + start: 35 + end: 80 + ? | + describe('twin', () => {}) + describe('twin', () => {}) + : labels: + - source: describe('twin', () => {}) + style: primary + start: 27 + end: 53 + - source: describe('twin', () => {}) + style: secondary + start: 27 + end: 53 + - source: describe('twin', () => {}) + style: secondary + start: 0 + end: 26 + - source: describe('twin', () => {}) + style: secondary + start: 0 + end: 26 + ? | + it('top level', () => {}) + it('top level', () => {}) + : labels: + - source: it('top level', () => {}) + style: primary + start: 26 + end: 51 + - source: it('top level', () => {}) + style: secondary + start: 26 + end: 51 + - source: it('top level', () => {}) + style: secondary + start: 0 + end: 25 + - source: it('top level', () => {}) + style: secondary + start: 0 + end: 25 + ? | + test('alias', async () => {}) + test('alias', async () => {}) + : labels: + - source: test('alias', async () => {}) + style: primary + start: 30 + end: 59 + - source: test('alias', async () => {}) + style: secondary + start: 30 + end: 59 + - source: test('alias', async () => {}) + style: secondary + start: 0 + end: 29 + - source: test('alias', async () => {}) + style: secondary + start: 0 + end: 29 diff --git a/.ast-grep/rule-tests/__snapshots__/no-identifier-shadow-call-snapshot.yml b/.ast-grep/rule-tests/__snapshots__/no-identifier-shadow-call-snapshot.yml new file mode 100644 index 0000000..780078a --- /dev/null +++ b/.ast-grep/rule-tests/__snapshots__/no-identifier-shadow-call-snapshot.yml @@ -0,0 +1,14 @@ +id: no-identifier-shadow-call +snapshots: + const config = config(options): + labels: + - source: const config = config(options) + style: primary + start: 0 + end: 30 + const isAdmin = isAdmin(request): + labels: + - source: const isAdmin = isAdmin(request) + style: primary + start: 0 + end: 32 diff --git a/.ast-grep/rule-tests/__snapshots__/no-jsdoc-any-type-snapshot.yml b/.ast-grep/rule-tests/__snapshots__/no-jsdoc-any-type-snapshot.yml new file mode 100644 index 0000000..c045a3e --- /dev/null +++ b/.ast-grep/rule-tests/__snapshots__/no-jsdoc-any-type-snapshot.yml @@ -0,0 +1,26 @@ +id: no-jsdoc-any-type +snapshots: + ? | + /** + * @param {any} x + */ + : labels: + - source: |- + /** + * @param {any} x + */ + style: primary + start: 0 + end: 25 + ? | + /** + * @returns {any} + */ + : labels: + - source: |- + /** + * @returns {any} + */ + style: primary + start: 0 + end: 25 diff --git a/.ast-grep/rule-tests/__snapshots__/no-jsdoc-object-typedef-snapshot.yml b/.ast-grep/rule-tests/__snapshots__/no-jsdoc-object-typedef-snapshot.yml new file mode 100644 index 0000000..f5b86ff --- /dev/null +++ b/.ast-grep/rule-tests/__snapshots__/no-jsdoc-object-typedef-snapshot.yml @@ -0,0 +1,21 @@ +id: no-jsdoc-object-typedef +snapshots: + ? | + /** + * @typedef {object} Foo + * @property {string} bar + */ + : fixed: | + /** + * @typedef Foo + * @property {string} bar + */ + labels: + - source: |- + /** + * @typedef {object} Foo + * @property {string} bar + */ + style: primary + start: 0 + end: 58 diff --git a/.ast-grep/rule-tests/__snapshots__/no-unsanctioned-exit-2-snapshot.yml b/.ast-grep/rule-tests/__snapshots__/no-unsanctioned-exit-2-snapshot.yml new file mode 100644 index 0000000..dc883b1 --- /dev/null +++ b/.ast-grep/rule-tests/__snapshots__/no-unsanctioned-exit-2-snapshot.yml @@ -0,0 +1,84 @@ +id: no-unsanctioned-exit-2 +snapshots: + ? | + function alsoBad () { + process.exit(2) + } + : labels: + - source: process.exit(2) + style: primary + start: 24 + end: 39 + ? | + function bad () { + exit(2) + } + : labels: + - source: exit(2) + style: primary + start: 20 + end: 27 + ? | + function elseArm (err) { + if (err instanceof ResultError) { + exitResultError() + } else { + exit(2) + } + } + : labels: + - source: exit(2) + style: primary + start: 98 + end: 105 + ? | + function modern () { + process.exitCode = 2 + } + : labels: + - source: process.exitCode = 2 + style: primary + start: 23 + end: 43 + ? | + function nested (err) { + if (err instanceof ResultError) { + const handler = () => { process.exit(2) } + handler() + } + } + : labels: + - source: process.exit(2) + style: primary + start: 88 + end: 103 + ? | + function wrapped (err) { + if (wantsJson) { + if (err instanceof ResultError) exitResultError() + exit(2) + } + } + : labels: + - source: exit(2) + style: primary + start: 102 + end: 109 + ? | + if (!argv.length) { + exit(2) + } + : labels: + - source: exit(2) + style: primary + start: 22 + end: 29 + ? | + if (err instanceof InputError) { + exit(2) + } + : labels: + - source: exit(2) + style: primary + start: 35 + end: 42 diff --git a/.ast-grep/rule-tests/no-commonjs-require-test.yml b/.ast-grep/rule-tests/no-commonjs-require-test.yml new file mode 100644 index 0000000..94fd959 --- /dev/null +++ b/.ast-grep/rule-tests/no-commonjs-require-test.yml @@ -0,0 +1,7 @@ +id: no-commonjs-require +valid: +- "import fs from 'node:fs'" +- "import { readFile } from 'node:fs/promises'" +invalid: +- "const fs = require('fs')" +- "const { readFile } = require('node:fs/promises')" diff --git a/.ast-grep/rule-tests/no-computed-exit-code-test.yml b/.ast-grep/rule-tests/no-computed-exit-code-test.yml new file mode 100644 index 0000000..d9479e3 --- /dev/null +++ b/.ast-grep/rule-tests/no-computed-exit-code-test.yml @@ -0,0 +1,47 @@ +id: no-computed-exit-code +valid: + # Literals, all of them. The number is right there. + - | + exit(0) + exit(1) + process.exit(1) + # The one sanctioned exit 2 — a literal, so `no-unsanctioned-exit-2` can still see and judge it. + # That is the whole point of this rule: it keeps the sibling's field of view complete. + - | + if (err instanceof ResultError) exit(2) + # No argument at all means 0. Nothing is hidden, so nothing to forbid. + - | + exit() + # A literal status on the assignment form is fine — it is exit 2 specifically that is reserved, + # and its sibling rule judges that. This rule only insists the number be visible. + - | + process.exitCode = 0 + process.exitCode = 1 +invalid: + # THE HOLE THIS RULE EXISTS FOR. `no-unsanctioned-exit-2` matches syntax, so it cannot see the + # 2 through the binding — the reserved code would be reintroduced in silence. + - | + const TWO = 2 + exit(TWO) + # A variable exit code: the reader cannot tell what this process says to its caller. + - | + exit(code) + # Namespaced form, same evasion. + - | + process.exit(someVar) + # Computed at the call site — including the ternary, which is the tempting one. + - | + exit(ok ? 0 : 1) + # A coercion is still not a literal. + - | + process.exit(Number(x)) + # tree-sitter calls these `number` too. Both exit 2, and both slip past a literal-`2` match — + # which is why `kind: number` alone was not enough and the regex pins a plain decimal integer. + - | + process.exit(0x2) + - | + process.exit(2.0) + # The assignment form must obey the same rule, or the indirection just moves house. + - | + const TWO = 2 + process.exitCode = TWO diff --git a/.ast-grep/rule-tests/no-hardcoded-tracker-dir-test.yml b/.ast-grep/rule-tests/no-hardcoded-tracker-dir-test.yml new file mode 100644 index 0000000..4deae57 --- /dev/null +++ b/.ast-grep/rule-tests/no-hardcoded-tracker-dir-test.yml @@ -0,0 +1,29 @@ +id: no-hardcoded-tracker-dir +valid: + # The sanctioned form — the constant, never the literal. + - | + import { TRACKER_DIR } from './task-schema.mjs' + const tasksDir = join(root, TRACKER_DIR, 'tasks') + - | + stdout.write(`No ${TRACKER_DIR}/tasks/ directory found — skipping.\n`) + # `backlog` is ALSO a priority value. It is not a path; do not flag it. + - | + export const VALID_PRIORITIES = new Set(['critical', 'high', 'medium', 'low', 'backlog']) + - | + export const PRIORITY_MAP = { '4': 'backlog' } + # A tasks-.yml filename that merely contains the word. + - | + write(`${TRACKER_DIR}/tasks/tasks-backlog.yml`, body) +invalid: + # A hardcoded segment in path construction. + - | + const tasksDir = join(root, '.diarie', 'tasks') + # A hardcoded path inside a template literal. + - | + const p = `${root}/.diarie/tasks/tasks-x.yml` + # A guard message that would drift out of sync after a rename. + - | + stderr.write('refusing to write under .diarie/tasks/\n') + # The pre-rename legacy segment. + - | + const tasksDir = join(root, 'backlog/tasks') diff --git a/.ast-grep/rule-tests/no-identical-test-title-test.yml b/.ast-grep/rule-tests/no-identical-test-title-test.yml new file mode 100644 index 0000000..f9872a7 --- /dev/null +++ b/.ast-grep/rule-tests/no-identical-test-title-test.yml @@ -0,0 +1,55 @@ +id: no-identical-test-title +valid: + # Distinct titles among siblings — the ordinary case. + - | + describe('computeReady', () => { + it('pending with no deps is ready', () => {}) + it('blocked by an open dep is not ready', () => {}) + }) + # The SAME title in a DIFFERENT sibling describe. node:test scopes titles per parent, + # so this is legal — and `follows` only matches siblings, so it is not flagged. + - | + describe('computeReady', () => { + it('is ready', () => {}) + }) + describe('computeStats', () => { + it('is ready', () => {}) + }) + # The same title in a NESTED describe shadows rather than collides. Also legal. + - | + describe('outer', () => { + it('same name', () => {}) + describe('inner', () => { + it('same name', () => {}) + }) + }) + # A non-test call that merely takes a string first argument. + - | + assert.equal(nsId('T-1', 'alpha'), 'alpha/T-1') + assert.equal(nsId('T-1', 'beta'), 'beta/T-1') +invalid: + # Two sibling it()s with one title — node:test runs BOTH, silently. + - | + describe('computeReady', () => { + it('pending with no deps is ready', () => {}) + it('pending with no deps is ready', () => {}) + }) + # Not adjacent: `stopBy: end` walks every preceding sibling, not just the last. + - | + describe('computeReady', () => { + it('a', () => {}) + it('b', () => {}) + it('a', () => {}) + }) + # describe() collides the same way. + - | + describe('twin', () => {}) + describe('twin', () => {}) + # The test() alias, and the async form. + - | + test('alias', async () => {}) + test('alias', async () => {}) + # At top level, with no describe at all. + - | + it('top level', () => {}) + it('top level', () => {}) diff --git a/.ast-grep/rule-tests/no-identifier-shadow-call-test.yml b/.ast-grep/rule-tests/no-identifier-shadow-call-test.yml new file mode 100644 index 0000000..f8c8b82 --- /dev/null +++ b/.ast-grep/rule-tests/no-identifier-shadow-call-test.yml @@ -0,0 +1,7 @@ +id: no-identifier-shadow-call +valid: +- "const result = isAdmin(request)" +- "const isAdminResult = isAdmin(request)" +invalid: +- "const isAdmin = isAdmin(request)" +- "const config = config(options)" diff --git a/.ast-grep/rule-tests/no-jsdoc-any-type-test.yml b/.ast-grep/rule-tests/no-jsdoc-any-type-test.yml new file mode 100644 index 0000000..b526196 --- /dev/null +++ b/.ast-grep/rule-tests/no-jsdoc-any-type-test.yml @@ -0,0 +1,19 @@ +id: no-jsdoc-any-type +valid: +- | + /** + * @param {unknown} x + */ +- | + /** + * @param {string} x + */ +invalid: +- | + /** + * @param {any} x + */ +- | + /** + * @returns {any} + */ diff --git a/.ast-grep/rule-tests/no-jsdoc-object-typedef-test.yml b/.ast-grep/rule-tests/no-jsdoc-object-typedef-test.yml new file mode 100644 index 0000000..6d388ea --- /dev/null +++ b/.ast-grep/rule-tests/no-jsdoc-object-typedef-test.yml @@ -0,0 +1,17 @@ +id: no-jsdoc-object-typedef +valid: +- | + /** + * @typedef Foo + * @property {string} bar + */ +- | + /** + * @typedef {string} Bar + */ +invalid: +- | + /** + * @typedef {object} Foo + * @property {string} bar + */ diff --git a/.ast-grep/rule-tests/no-unsanctioned-exit-2-test.yml b/.ast-grep/rule-tests/no-unsanctioned-exit-2-test.yml new file mode 100644 index 0000000..b2ec9e1 --- /dev/null +++ b/.ast-grep/rule-tests/no-unsanctioned-exit-2-test.yml @@ -0,0 +1,64 @@ +id: no-unsanctioned-exit-2 +valid: + # The sanctioned path is a NAME, not a shape. Nothing here writes the number. + - | + if (err instanceof ResultError) exitResultError() + # Every other exit code is unconstrained; only 2 carries a reserved meaning. + - | + exit(1) + exit(0) + process.exit(1) + # An InputError is the correct answer to anything the user got wrong — exit 1, with a code. + - | + if (err instanceof InputError) { + exit(1) + } +invalid: + # A bare exit(2) — a second meaning for the one code that must keep only one. + - | + function bad () { + exit(2) + } + # The `process.` form is the same collision wearing a namespace. + - | + function alsoBad () { + process.exit(2) + } + # THE THREE EVASIONS THAT KILLED THE OLD `if (err instanceof ResultError)` EXEMPTION. + # Each was silently exempted by it; each must now be caught. (1) The else-arm: + - | + function elseArm (err) { + if (err instanceof ResultError) { + exitResultError() + } else { + exit(2) + } + } + # (2) A closure nested INSIDE the sanctioned branch — `inside` walks every ancestor. + - | + function nested (err) { + if (err instanceof ResultError) { + const handler = () => { process.exit(2) } + handler() + } + } + # (3) An `if` merely WRAPPING the sanctioned branch. `has: stopBy: end` matched any ancestor + # whose whole subtree merely MENTIONED the condition — so this second exit(2) went free. + - | + function wrapped (err) { + if (wantsJson) { + if (err instanceof ResultError) exitResultError() + exit(2) + } + } + # The peowly shape that started all of this: "incorrect usage" reaching for the reserved code. + - | + if (!argv.length) { + exit(2) + } + # `process.exitCode = 2` — the IDIOMATIC modern form, and the one a careful contributor reaches + # for to avoid process.exit() truncating stdout. It evaded both rules until a reviewer planted it. + - | + function modern () { + process.exitCode = 2 + } diff --git a/.ast-grep/rules/no-commonjs-require.yml b/.ast-grep/rules/no-commonjs-require.yml new file mode 100644 index 0000000..cef362a --- /dev/null +++ b/.ast-grep/rules/no-commonjs-require.yml @@ -0,0 +1,6 @@ +id: no-commonjs-require +language: javascript +message: ESM-only project (CLAUDE.md MUST) — use `import`, not `require()` +severity: error +rule: + pattern: require($ARG) diff --git a/.ast-grep/rules/no-computed-exit-code.yml b/.ast-grep/rules/no-computed-exit-code.yml new file mode 100644 index 0000000..ee560f5 --- /dev/null +++ b/.ast-grep/rules/no-computed-exit-code.yml @@ -0,0 +1,33 @@ +id: no-computed-exit-code +language: javascript +message: >- + An exit code must be a LITERAL, readable at the call site. `exit(code)`, `exit(TWO)`, + `exit(ok ? 0 : 1)`, `process.exit(Number(x))` — all banned. Write the number. + + This rule exists to close the hole in its sibling, `no-unsanctioned-exit-2`. That rule reserves + exit 2 for `ResultError` ("it ran, and the answer is no") — the one exit code this tool exists + to make trustworthy. But ast-grep matches SYNTAX, not values, so it can only ever see the + literal `2`. `const TWO = 2; exit(TWO)` walks straight past it, and the invariant quietly dies. + + Rather than reach for the type system to chase the value, forbid the indirection: if every exit + argument must be a literal, then every exit-2 IS the literal 2, and the sibling rule sees all of + them. The two rules together make exit 2 structurally unreachable outside its one sanctioned + branch — which neither achieves alone. + + The cost is small and the benefit is not only mechanical: an exit code you have to go and look + up is an exit code nobody checks. `exit()` with no argument is untouched (it means 0). +severity: error +rule: + any: + - pattern: exit($CODE) + - pattern: process.exit($CODE) + - pattern: process.exitCode = $CODE +constraints: + CODE: + not: + all: + # A PLAIN DECIMAL INTEGER, and nothing else. `kind: number` alone was not enough: tree-sitter + # calls `0x2` and `2.0` numbers too, and both evade the literal `2` its sibling matches while + # exiting 2 all the same. The regex is what makes "the number is right there" actually true. + - kind: number + - regex: '^\d+$' diff --git a/.ast-grep/rules/no-hardcoded-tracker-dir.yml b/.ast-grep/rules/no-hardcoded-tracker-dir.yml new file mode 100644 index 0000000..396ddc7 --- /dev/null +++ b/.ast-grep/rules/no-hardcoded-tracker-dir.yml @@ -0,0 +1,33 @@ +id: no-hardcoded-tracker-dir +language: javascript +message: >- + Don't hardcode the tracker directory — import `TRACKER_DIR` from + `diarie/schema` and build the path from it (`join(root, TRACKER_DIR, + 'tasks')`). The whole point of the constant is that renaming the store is a + one-line change; a literal re-forks it. This bites hardest in *guard* code — + bd-map.js's refuse-to-write check is a safety net, so a literal there would + not error after a rename, it would silently stop guarding. The legacy + `backlog/` segment (renamed to `.diarie/` 2026-07-11) is flagged too; the bare + `backlog` PRIORITY value is not a path and is left alone. +severity: error +# THIS RULE IS CROSS-BOUNDARY, AND THEREFORE LIVES ON BOTH SIDES. It guards diarie's own +# `lib/**` (here) AND the plugin's `scripts/*.mjs` + `validate-plugin.mjs` (the root copy), which +# import `TRACKER_DIR` from `diarie/schema` precisely because this rule makes them. The task row +# said the rule should "move"; that was wrong — moving it would have left the plugin's own tooling +# free to re-fork the constant the moment diarie left. Copies drift; a copy is still the only +# option, because ast-grep sgconfig has no `extends`/`include`. +# +# No `files:` here — this config's `ast-grep scan` walks the whole package, so a glob could only +# create a way for the rule to point at nothing. +ignores: + # The definition itself — `export const TRACKER_DIR = '.diarie'`. + - "lib/schema.js" + # A test that asserts the literal path is the POINT: migrate.spec.js asserts the migrator WROTE + # `.diarie/tasks/tasks-backlog.yml`, and an assertion that builds that path from TRACKER_DIR + # proves only that the constant equals itself. + - "test/**/*.js" +rule: + any: + - kind: string + - kind: template_string + regex: '\.diarie|backlog/' diff --git a/.ast-grep/rules/no-identical-test-title.yml b/.ast-grep/rules/no-identical-test-title.yml new file mode 100644 index 0000000..d398e17 --- /dev/null +++ b/.ast-grep/rules/no-identical-test-title.yml @@ -0,0 +1,33 @@ +id: no-identical-test-title +language: javascript +message: >- + Two sibling `it`/`test`/`describe` blocks share this title, and `node:test` will + silently run BOTH — no warning, no error, no dedup. The suite simply reports one more + passing test than it has, which is the failure this project keeps shipping: a green + check over work not done. Rename one. Scoping is free: `follows` only matches SIBLINGS, + so reusing a title inside a NESTED `describe` is legal and is not flagged. + + The tool for this is `eslint-node-test` (`no-identical-title`), and it is currently + UNINSTALLABLE here: its peer range is `eslint >=10.4`, while `@voxpelli/eslint-config@25` + pins `^9.38.0` and neostandard `^9.0.0` — a disjoint intersection. Retire this rule in + favour of the plugin once neostandard moves to ESLint 10 (voxpelli/eslint-config#476, + neostandard/neostandard#387). + + Known limitation: `TITLE: kind: string` means template-literal titles are not covered. +severity: error +# No `files:` — the pattern is self-scoping (it only matches `it`/`test`/`describe` calls, which +# exist nowhere but the suite), so a glob would add no precision and one more way to go inert. +rule: + kind: expression_statement + has: + pattern: $FN($TITLE, $$$) + follows: + kind: expression_statement + has: + pattern: $FN($TITLE, $$$) + stopBy: end +constraints: + FN: + regex: "^(it|test|describe)$" + TITLE: + kind: string diff --git a/.ast-grep/rules/no-identifier-shadow-call.yml b/.ast-grep/rules/no-identifier-shadow-call.yml new file mode 100644 index 0000000..edf394c --- /dev/null +++ b/.ast-grep/rules/no-identifier-shadow-call.yml @@ -0,0 +1,8 @@ +id: no-identifier-shadow-call +language: javascript +message: >- + Local declaration shadows an identically-named call — e.g. `const isAdmin = + isAdmin(request)` breaks the file (CLAUDE.md NEVER rule) +severity: error +rule: + pattern: const $NAME = $NAME($$$ARGS) diff --git a/.ast-grep/rules/no-jsdoc-any-type.yml b/.ast-grep/rules/no-jsdoc-any-type.yml new file mode 100644 index 0000000..fef354f --- /dev/null +++ b/.ast-grep/rules/no-jsdoc-any-type.yml @@ -0,0 +1,8 @@ +id: no-jsdoc-any-type +language: javascript +message: >- + Prefer `unknown` over `any` (CLAUDE.md SHOULD) and narrow with type guards +severity: warning +rule: + kind: comment + regex: '@(param|returns|type|property)\s*\{[^}]*\bany\b' diff --git a/.ast-grep/rules/no-jsdoc-object-typedef.yml b/.ast-grep/rules/no-jsdoc-object-typedef.yml new file mode 100644 index 0000000..664e0cd --- /dev/null +++ b/.ast-grep/rules/no-jsdoc-object-typedef.yml @@ -0,0 +1,17 @@ +id: no-jsdoc-object-typedef +language: javascript +message: >- + Drop `{object}` from `@typedef` when `@property` tags follow — use + `@typedef Name`, not `@typedef {object} Name` +severity: warning +rule: + pattern: $COMMENT + kind: comment + regex: '@typedef\s+\{object\}' +transform: + FIXED: + replace: + source: $COMMENT + replace: '\{object\}\s*' + by: '' +fix: $FIXED diff --git a/.ast-grep/rules/no-unsanctioned-exit-2.yml b/.ast-grep/rules/no-unsanctioned-exit-2.yml new file mode 100644 index 0000000..522d88b --- /dev/null +++ b/.ast-grep/rules/no-unsanctioned-exit-2.yml @@ -0,0 +1,45 @@ +id: no-unsanctioned-exit-2 +language: javascript +message: >- + Exit code 2 is SPOKEN FOR: it means `ResultError` — "it ran, and the answer is no" (an invalid + store, a cyclic backlog, `--strict`). It is the one exit code this tool exists to make + trustworthy, and a second meaning destroys it: a CI job branching on 2 could no longer tell a + dependency cycle from a typo. + + Call `exitResultError()` from `lib/utils/exit.js` — the sole sanctioned site, and this rule's only + exemption. Everything a user can get WRONG is an `InputError`: exit 1, with a machine-readable + code (`ENOSTORE` no store, `EUSAGE` typed it wrong, `EEXIST` store already there). If you need a + new failure mode, give it an InputError code. Do not reach for exit 2. + + THE COLLISION IS EASY AND SILENT. `peowly`'s `showHelp()` defaults to `process.exit(2)` on + purpose — "Default to code 2 for incorrect usage" (peowly#47) — so a bare `diarie` exited 2 for + months, meaning "you forgot the subcommand" in the code reserved for "your backlog is broken". + + WHY THERE IS NO CLEVER EXEMPTION HERE. This rule first tried to exempt `exit(2)` when it sat + inside an `if (err instanceof ResultError)`. Two reviewers broke that in three ways within + minutes: an `exit(2)` in the ELSE arm, one in a closure nested inside the sanctioned branch, and + one in an `if` merely wrapped by it — all silently exempted, because `inside`+`has` with + `stopBy: end` matches any ancestor whose whole subtree merely MENTIONS the condition. The + exemption was harder to reason about than the invariant it protected. So it is gone: exit 2 is + banned outright, and the single legal call hides behind a NAME instead of behind a rule. + + Its sibling `no-computed-exit-code` closes the other half — every exit code must be a literal, so + `const TWO = 2; exit(TWO)` cannot slip past this syntax match. Neither rule is sufficient alone. + Delete either and the other stops holding. +severity: error +# No `files:` — bare `ast-grep scan` walks the whole package from sgconfig.yml, so scoping by +# glob would only create a way for this rule to point at nothing. `ignores:` is the one real +# exemption, and it is a single file. +ignores: + # The definition itself. One file, one function, one call — `git grep exitResultError` is an + # exhaustive list of every way this process can exit 2. + - "lib/utils/exit.js" +rule: + any: + - pattern: exit(2) + - pattern: process.exit(2) + # `process.exitCode = 2` is the idiomatic MODERN way to set a status — you reach for it + # precisely to avoid `process.exit()` truncating a pending stdout write. So it is what a + # careful contributor writes, and it evaded both rules until a reviewer planted it. It really + # does exit 2 (`node -e 'process.exitCode = 2'; echo $?` → 2). + - pattern: process.exitCode = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e58f266 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# diarie's own .gitignore. It had none — the repo root's covered it, and that file does not +# travel with `git subtree split --prefix=diarie`. +# +# THIS IS LOAD-BEARING FOR THE LINT, not just for git. `check:ast-grep` runs `ast-grep scan` with +# NO path arguments, deliberately: a rule cannot be scoped outside what is scanned if everything is +# scanned. But "everything" is bounded by .gitignore — ast-grep respects it, as ripgrep does. With +# no .gitignore, a standalone `ast-grep scan` walks node_modules and reports 24,894 errors from +# other people's code. +# +# Inside the vp-beads workspace this was invisible: npm hoists dependencies to the repo root, so +# `diarie/node_modules` holds almost nothing and the scan came back green. The bug only exists in +# the configuration diarie is actually shipped in — which is why it took building the extracted +# tree to see it, and why the check that proves this package splittable has to install and run it +# for real rather than reason about it. +node_modules/ + +# Emitted declarations (`prepack` builds them; they are published, never committed). +*.d.ts +*.d.ts.map + +# Local tool caches. +.impeccable/ +.cache/ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..c7942db --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,44 @@ +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. +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 + // mkdtempSync — a gate change smuggled in under a packaging change. + cliFiles: ['**/*.js'], + }), + { + name: 'diarie/repo-style', + rules: { + // Uniform NAMED imports for node builtins (`import { join } from 'node:path'`). + // import-style would force node:path alone to a default import. + 'unicorn/import-style': 'off', + // Paths here are computed from argv, import.meta.url, or a --root the user passed — + // never untrusted external input. The non-literal-fs "taint" warnings are inherent noise. + 'security/detect-non-literal-fs-filename': 'off', + 'security/detect-non-literal-regexp': 'off', + }, + }, +] diff --git a/knip.jsonc b/knip.jsonc new file mode 100644 index 0000000..c777b30 --- /dev/null +++ b/knip.jsonc @@ -0,0 +1,25 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + + "entry": ["lib/index.js", "lib/schema.js"], + "project": ["cli.js", "lib/**/*.js"], + + // `@planned` marks an export that is deliberately kept without a caller. It must SAY WHY in the + // JSDoc — a silenced knip warning that implies somebody still needs the export is worse than no + // warning at all. See `projectRecords` in lib/migrate/bd-map.js. + "tags": ["-planned"], + + // ⚠️ WORKSPACE-HOISTING WORKAROUND — DELETE BOTH LISTS ON EXTRACTION. + // + // npm hoists `.bin` to the monorepo root, so while diarie is a workspace of vp-beads there is no + // `diarie/node_modules/.bin`, and knip — running with diarie as its project root — cannot map + // these binaries back to the devDependencies that provide them. It then reports the tools as + // "unlisted binaries" AND their packages as "unused devDependencies", which are both false. + // + // MEASURED (2026-07-14): in a real extracted repo with its own node_modules, knip resolves all + // six and passes with BOTH lists removed. So these are artifacts of the workspace, not of the + // package — and they are the exact shape of silencing that rots: leave them in after the split + // and knip can never again tell you that a dev dependency has become genuinely unused. + "ignoreBinaries": ["run-p", "ast-grep", "knip", "eslint", "tsc", "type-coverage"], + "ignoreDependencies": ["@ast-grep/cli", "eslint", "npm-run-all2", "type-coverage"] +} diff --git a/lib/migrate/bd-map.js b/lib/migrate/bd-map.js index 54b6ed3..949dfc3 100644 --- a/lib/migrate/bd-map.js +++ b/lib/migrate/bd-map.js @@ -161,6 +161,19 @@ export const TYPE_MAP = { } /** + * Project a parsed bd export into the flat-YAML task rows, with a loss report. + * + * `@planned` — exported for a caller that never arrived. The file header says this is "exported + * for reuse by vp-beads-bj7's eventual test suite when that migrator is built". That migrator IS + * built — it is `migrate/bootstrap.js` — and it does not use this. The stated reason is stale, and + * knip is right that nothing outside this file calls it. + * + * Kept rather than deleted because deleting a knip-flagged export is a decision, not a cleanup + * (CLAUDE.md ASK FIRST), and because the honest fix is bigger than the symptom: this whole file is + * a read-only SPIKE whose one-time job is done, and retiring it is its own row. The tag says so out + * loud, instead of letting a silenced warning imply somebody still needs this. + * + * @planned * @param {BdIssue[]} records parsed bd export lines (regular issues only) * @returns {{ tasks: TaskRow[], loss: object }} */ diff --git a/package.json b/package.json index 750aa24..1e377b1 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,13 @@ "lib/**/*.js" ], "scripts": { + "check": "run-p check:*", + "check:ast-grep": "ast-grep scan", + "check:ast-grep-test": "ast-grep test", + "check:knip": "knip", + "check:lint": "eslint", + "check:tsc": "tsc", + "check:type-coverage": "type-coverage --detail --strict --at-least 98 --ignore-files 'test/*'", "test": "node --test" }, "dependencies": { @@ -31,9 +38,12 @@ "pony-cause": "^2.1.11" }, "devDependencies": { + "@ast-grep/cli": "^0.44.1", "@types/js-yaml": "^4.0.9", "@types/node": "^20.19.43", + "@voxpelli/eslint-config": "^25.1.0", "@voxpelli/tsconfig": "^16.2.1", + "eslint": "^9.39.4", "knip": "^5.88.1", "npm-run-all2": "^7.0.2", "type-coverage": "^2.29.7", diff --git a/sgconfig.yml b/sgconfig.yml new file mode 100644 index 0000000..1a7abd0 --- /dev/null +++ b/sgconfig.yml @@ -0,0 +1,15 @@ +# diarie's OWN structural lint. It travels with `git subtree split --prefix=diarie`; the +# repo-root config does not, and neither did these rules before Wave 2 — the extracted package +# would have shipped green with every one of its guards silently gone, which is precisely the +# failure mode `no-hardcoded-tracker-dir` exists to prevent, applied to the rules themselves. +# +# NOTE THE ABSENCE OF A PATH LIST. `ast-grep scan` with no path arguments walks the whole project +# from this config's directory, so there is no "outer bound" to keep in step with the rules — and +# therefore no way for a rule to end up scoped to a directory nobody scans. The plugin's root +# runner has to maintain such a list (it lints a subset of a bigger repo) and it has already been +# bitten: `ast-grep scan` on a missing path prints an error and EXITS 0, so a stale entry made the +# check pass while scanning nothing. Here the bug is not guarded against; it is unrepresentable. +ruleDirs: + - .ast-grep/rules +testConfigs: + - testDir: .ast-grep/rule-tests diff --git a/test/commands.spec.js b/test/commands.spec.js index d092d03..1746fdf 100644 --- a/test/commands.spec.js +++ b/test/commands.spec.js @@ -21,9 +21,10 @@ import { join } from 'node:path' import { after, describe, it } from 'node:test' import { fileURLToPath } from 'node:url' import { - mkdirSync, mkdtempSync, rmSync, writeFileSync, + existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, } 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' @@ -169,3 +170,59 @@ describe('validate — doTheWork', () => { 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 + // exported and never tested — knip found it the day diarie started running its own gates. + // 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 { 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'))) + }) + + it('honours --slug — the first task file is named, not assumed', async () => { + const root = mkdtempSync(join(tmpdir(), 'diarie-init-slug-')) + scratch.push(root) + + await initWork({ root, slug: 'roadmap' }) + + 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) + + 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 + } + ) + }) + + 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) + + await initWork({ root, slug: 'backlog' }) + const result = await validateWork({ root }) + + assert.deepEqual(result.errors, []) + }) +})