@rdjperron/lint 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,22 +1,24 @@
1
1
  # @rdjperron/lint
2
2
 
3
- One Oxlint JS plugin, `rdjperron`, with twenty rules against low-evidence code, the config data a
4
- project spreads into its own `vite.config.ts`, the check that proves every rule still fires, and
5
- the repository checks that hold a repo's documents to their own conventions.
3
+ One Oxlint JS plugin, `rdjperron`, with twenty-two rules against low-evidence code and unchecked
4
+ Convex functions, the config data a project spreads into its own `vite.config.ts`, the check that
5
+ proves every rule still fires, and the repository checks that hold a repo's documents to their own
6
+ conventions.
6
7
 
7
8
  | Entry | What it gives you |
8
9
  | ------------------------ | ---------------------------------------------------------------------------------- |
9
- | `@rdjperron/lint` | The plugin. One `jsPlugins` entry, one prefix, twenty rules. |
10
+ | `@rdjperron/lint` | The plugin. One `jsPlugins` entry, one prefix, twenty-two rules. |
10
11
  | `@rdjperron/lint/config` | `recommended`, the override builders, and the `fmt`, `ignore` and `staged` blocks. |
11
- | `@rdjperron/lint/repo` | `checkRepo`, five checks on a repo's links, decision register and research names. |
12
- | `rdjperron-lint-verify` | The command that proves every rule still fires. No config, no arguments. |
12
+ | `@rdjperron/lint/repo` | `checkRepo`, three checks on a repo's Markdown links and research names. |
13
+ | `rdjperron-lint-verify` | The command that proves every rule still fires. No config, one optional flag. |
13
14
 
14
15
  Seventeen of the rules are a copy of [dmmulroy/anti-slop](https://github.com/dmmulroy/anti-slop),
15
16
  owned and edited here (`src/anti-slop/VENDORED.md` in this repository records the commit). The
16
- other three are written here.
17
+ other five are written here.
17
18
 
18
19
  Measured on vite-plus 0.3.0, 0.3.1, 0.3.2 and 0.3.3, which is oxlint 1.79.0 through 1.83.0
19
- (2026-09-19). All twenty rules fired on all four.
20
+ (2026-10-12). All twenty-two rules fired on all four, and so did Convex's own plugin loaded through
21
+ `convex()`.
20
22
 
21
23
  ## Install
22
24
 
@@ -28,6 +30,13 @@ That is the whole installation. `@oxlint/plugins`, which the rules import `defin
28
30
  types from, is an ordinary dependency here and arrives with the package; there is no version of it
29
31
  for you to pin or to move.
30
32
 
33
+ `@convex-dev/eslint-plugin` is an optional peer, `^5.0.0`. Install it beside this package in a
34
+ project that calls `convex()`, which loads it:
35
+
36
+ ```bash
37
+ npm install --save-dev @convex-dev/eslint-plugin
38
+ ```
39
+
31
40
  `vite-plus` is an optional peer, `>=0.3.0`, so a project on plain oxlint can still load the plugin.
32
41
  The floor is `react()`, not the rules: oxlint 1.77.0, which vite-plus 0.2.9 declares, has no
33
42
  `react/immutability`, `react/purity` or `react/refs`, and refuses to build a config that names them.
@@ -91,20 +100,22 @@ specifier inside an `extends` object is refused; a package name is what works.
91
100
 
92
101
  ### The config exports
93
102
 
94
- | Export | What it is |
95
- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
96
- | `plugin` | `{ name: "rdjperron", specifier: "@rdjperron/lint" }`. |
97
- | `RULE_IDS`, `RuleId` | The twenty prefixed ids, as a readonly tuple and as a type. |
98
- | `recommended` | The config object for `lint.extends`: the plugin entry, the rules below, the built-in choices, the plugin set, the categories and `options.typeAware`. |
99
- | `react(files, options?)` | The hooks rules, the compiler's rules and the globals. `options.env` is the environment those files run in, `{ browser: true }` by default, so a React Native consumer names its own. `options.useEffectAllowedIn` names the one file allowed to call `useEffect`, puts its name in every finding, and switches both `no-use-effect` and `react/exhaustive-deps` off for it. |
100
- | `i18n(files, options?)` | `rdjperron/no-hardcoded-jsx-text` at error. `options.pattern` sets the word pattern. |
101
- | `expo(files, options?)` | The React Native facts: `static-public-env` at error, `typescript/no-require-imports` relaxed for asset extensions, `unicorn/no-array-sort` off. `options.assetExts` adds your `metro.config.js` extensions, `options.reactFiles` adds the two `react/*` rules an Expo app needs. |
102
- | `restrictedImports(…)` | One `no-restricted-imports` override per file set, expanded from one declaration. See below. |
103
- | `tests(files)` | `any`, `!`, `no-runtime-typeof` and the safety-comment rule off, with the reason on each. |
104
- | `decoders(files, options?)` | `no-runtime-typeof` and `no-unknown-parameters` at error everywhere, each hinting at the files in `files`, and `no-runtime-typeof` off inside them. `options.hint` replaces the generated sentence. |
105
- | `fmt` | `printWidth` 100, double quotes, semicolons, two spaces. |
106
- | `ignore` | Dependencies, build output, the AI agent directories and the Vitest JSON report. |
107
- | `staged` | `vp check --fix` on JS and TS sources. |
103
+ | Export | What it is |
104
+ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
105
+ | `plugin` | `{ name: "rdjperron", specifier: "@rdjperron/lint" }`. |
106
+ | `RULE_IDS`, `RuleId` | The twenty-two prefixed ids, as a readonly tuple and as a type. |
107
+ | `recommended` | The config object for `lint.extends`: the plugin entry, the rules below, the built-in choices, the plugin set, the categories and `options.typeAware`. |
108
+ | `react(files, options?)` | The hooks rules, the compiler's rules and the globals. `options.env` is the environment those files run in, `{ browser: true }` by default, so a React Native consumer names its own. `options.useEffectAllowedIn` names the one file allowed to call `useEffect`, puts its name in every finding, and switches both `no-use-effect` and `react/exhaustive-deps` off for it. |
109
+ | `i18n(files, options?)` | `rdjperron/no-hardcoded-jsx-text` at error. `options.pattern` sets the word pattern. |
110
+ | `expo(files, options?)` | The React Native facts: `static-public-env` at error, `typescript/no-require-imports` relaxed for asset extensions, `unicorn/no-array-sort` off. `options.assetExts` adds your `metro.config.js` extensions, `options.reactFiles` adds the two `react/*` rules an Expo app needs. |
111
+ | `restrictedImports(…)` | One `no-restricted-imports` override per file set, expanded from one declaration. See below. |
112
+ | `tests(files)` | `any`, `!`, `no-runtime-typeof` and the safety-comment rule off, with the reason on each. |
113
+ | `convex(files, options?)` | Convex's own rules from `@convex-dev/eslint-plugin`, loaded by the override itself, and the two `rdjperron/convex-*` rules. `options.wrapper` names the module that builds the checked constructors and `options.wrapperImports` its alias spellings, `options.returns: false` stops asking for `returns`, `options.tests` names test code that may `.collect()` and `.filter()`, `options.hint` adds the project's reason to each checked-constructor finding. See below. |
114
+ | `convexPlugin` | `{ name: "@convex-dev", specifier: "@convex-dev/eslint-plugin" }`, the entry `convex()` puts in its override. |
115
+ | `decoders(files, options?)` | `no-runtime-typeof` and `no-unknown-parameters` at error everywhere, each hinting at the files in `files`, and `no-runtime-typeof` off inside them. `options.hint` replaces the generated sentence. |
116
+ | `fmt` | `printWidth` 100, double quotes, semicolons, two spaces. |
117
+ | `ignore` | Dependencies, build output, the AI agent directories and the Vitest JSON report. |
118
+ | `staged` | `vp check --fix` on JS and TS sources. |
108
119
 
109
120
  `recommended` puts every rule at `error` except `no-hardcoded-jsx-text` and `static-public-env`,
110
121
  which belong to a translated app and to an Expo app and are left to `i18n()` and `expo()`. The
@@ -163,14 +174,115 @@ scope and the entry:
163
174
  The last two exist because both are a claim about the parent that is false, and a config that
164
175
  states something false about its parent is one rename away from lifting a ban.
165
176
 
177
+ ### `convex`, the backend's rules
178
+
179
+ ```ts
180
+ ...convex(["convex/**"], {
181
+ wrapper: "convex/lib/functions.ts",
182
+ tests: ["convex/**/*.test.ts", "convex/**/*.helpers.ts"],
183
+ }),
184
+ ```
185
+
186
+ That returns two overrides. The first, on `files`, loads `@convex-dev/eslint-plugin` through its
187
+ own `jsPlugins` and sets eleven of its rules, eight at error and three off. The second, on
188
+ `tests`, switches `no-collect-in-query` and `no-filter-in-query` off, because a test reads a
189
+ table it filled itself.
190
+
191
+ | Rule | `convex()` | Why |
192
+ | ----------------------------------------------- | ---------- | ------------------------------------------------------------------------------------ |
193
+ | `@convex-dev/no-old-registered-function-syntax` | error | A bare handler has nowhere to put a validator. |
194
+ | `@convex-dev/explicit-table-ids` | error | `db.get(id)` without its table is the old form; the table goes first. |
195
+ | `@convex-dev/no-filter-in-query` | error | `.filter()` reads every row an index would have skipped. |
196
+ | `@convex-dev/no-top-of-hour-crons` | error | Minute 0 UTC is the busiest point on the clock. |
197
+ | `@convex-dev/no-schema-import-cycle` | error | A file the schema imports that reads the schema finds it undefined. |
198
+ | `@convex-dev/no-duplicate-indexes` | error | An index that prefixes another answers no new query and slows every write. |
199
+ | `@convex-dev/no-process-env` | error | The typed `env` from `_generated/server` knows which variables exist. |
200
+ | `@convex-dev/no-collect-in-query` | error | `.collect()` fails once a table outgrows the query limits. |
201
+ | `@convex-dev/require-args-validator` | off | `rdjperron/convex-require-validators` reports the same omissions, on more functions. |
202
+ | `@convex-dev/import-wrong-runtime` | off | It reads `use node` anywhere in a file's first 100 characters, comments included. |
203
+ | `@convex-dev/require-access-control` | off | It cannot see a function built by a checked constructor. |
204
+
205
+ oxlint gives a JS plugin no type information, so `explicit-table-ids` and `no-collect-in-query`
206
+ run on their syntax fallbacks. They match `x.db.get(id)` and `x.db.query(...)...collect()` by
207
+ shape, and report without the autofix the type checker would give them. The measurements behind
208
+ each severity are beside it in `src/config.ts`.
209
+
210
+ `rdjperron/convex-require-validators` is on in every case, asking for `args` and `returns`. With
211
+ `wrapper`, `rdjperron/convex-checked-constructors` is on too, and both rules know the wrapper.
212
+
213
+ Both rules recognise a Convex function by where its constructor comes from, not by its name. A
214
+ name counts when its binding in the file leads to `_generated/server`, to the generic constructors
215
+ in `convex/server`, or to the wrapper. A `query` imported from any other module is not Convex's,
216
+ and a name no binding in the file declares is taken by its spelling, the way Convex's own rules
217
+ take it.
218
+
219
+ The analysis stays inside one file and reads syntax and bindings. It follows a renamed import; a
220
+ namespace import read member by member, destructured, collected with `...rest` or held in a
221
+ `const`; an object literal, its own keys and its spreads, in the order they run, so a later key
222
+ replaces an earlier one; and `await import()` of a string or a template with no expression. A
223
+ value it cannot read this way is unknown, and nothing unknown is reported.
224
+
225
+ - Outside the wrapper, `convex-checked-constructors` reports `query`, `mutation` and `action` from
226
+ `_generated/server`, the generic ones from `convex/server`, and `getAuthUserId` and
227
+ `getAuthSessionId` from `@convex-dev/auth/server`, however they reach the file. Copying one of
228
+ those modules whole, by spread or by `...rest`, reports too unless every such name was taken out
229
+ beside it. It also reports reading `auth` off a context. A context is the first parameter of
230
+ the handler Convex will run, the last one the definition sets, given inline, by the name of a
231
+ function or a `const`, or in a spread the file can read. A parameter named `ctx` anywhere is one
232
+ too, since that is Convex's own name for it, and so is a `const` alias of either.
233
+ - `convex-require-validators` checks every value an export can hold: a `const`, a `let` with each
234
+ value it is assigned, and an alias of either. A validator set to `undefined`, `void 0` or a
235
+ `const` holding `undefined` declares nothing, as Convex reads it. A spread or definition it
236
+ cannot read, or a computed key, leaves that function unchecked rather than guessed at.
237
+
238
+ Where a value is not definitely known, the rules stay silent rather than guess. The analysis
239
+ stops, by choice rather than for want of types, at these forms, which are left unchecked:
240
+
241
+ - a context passed into a helper call, even one in the same file, or kept in a class field;
242
+ - a constructor returned by a factory, or read from another module, a barrel included;
243
+ - a computed key or import specifier;
244
+ - an export taken by destructuring, such as `export const { f } = { f: query({ … }) }` or a
245
+ property of a call's result;
246
+ - a `...rest` copy of an object literal, such as `const { ...t } = { q }`;
247
+ - a `const` copy of a `let` that is assigned more than once, since which value it copied depends
248
+ on when it ran.
249
+
250
+ The wrapper is one file. An absolute path pins it. A relative one is looked for from the working
251
+ directory upward, and the first directory that holds it is the root, so `convex/lib/functions.ts`
252
+ is the same file whether `vp lint` runs from the root or from `convex/`, and
253
+ `other/convex/lib/functions.ts` is not it. oxlint tells a rule the process's working directory,
254
+ not the config's, so a monorepo, or any project that may lint from elsewhere, passes the absolute
255
+ path: `fileURLToPath(new URL("./convex/lib/functions.ts", import.meta.url))`.
256
+
257
+ An import names the wrapper when a relative path resolves to that file: spelled with its own
258
+ extension, with none, or with the `.js` TypeScript uses for a `.ts` file. A directory's `/index` is
259
+ never collapsed, so `./lib/functions/index` is a different module from `lib/functions.ts`. Any
260
+ other spelling, such as `@/convex/lib/functions` from a tsconfig alias, names it only when
261
+ `wrapperImports` lists it exactly as written: the rules cannot read a bundler's or a tsconfig's
262
+ aliases, and guessing from the path would make the answer depend on where `vp lint` runs. With
263
+ an absolute `wrapper`, nothing about either rule depends on the working directory.
264
+
265
+ A later override replaces the entry of each rule it names, options included, and leaves the
266
+ others alone, so an exemption goes after `convex()`. A probe that reads `ctx.auth` on purpose:
267
+
268
+ ```ts
269
+ ...convex(["convex/**"], { wrapper: "convex/lib/functions.ts" }),
270
+ { files: ["convex/probe.fixture.ts"], rules: { "rdjperron/convex-checked-constructors": "off" } },
271
+ ```
272
+
273
+ That second override needs no `jsPlugins` of its own, and neither does one that changes an
274
+ `@convex-dev/*` rule: once any override loads a JS plugin, its rules resolve in every override
275
+ (measured on vite-plus 0.3.0 to 0.3.3, 2026-10-12).
276
+
166
277
  ## Prove the rules still fire
167
278
 
168
279
  ```bash
169
280
  npx rdjperron-lint-verify
170
281
  ```
171
282
 
172
- No configuration and no arguments. A rule can go silently dead after a linter upgrade, and "reports
173
- nothing" looks exactly like "no violations", so nothing else in a repo notices. This asserts a
283
+ No configuration, and one optional flag, `--require-convex`. A rule can go silently dead after a
284
+ linter upgrade, and "reports nothing" looks exactly like "no violations", so nothing else in a repo
285
+ notices. This asserts a
174
286
  positive: every rule fired, each on the file named after it. It prints a table and exits non-zero on
175
287
  the first rule that stayed quiet.
176
288
 
@@ -180,6 +292,14 @@ generated config that puts every rule at error, with your `node_modules` symlink
180
292
  the whole directory afterwards whether the run passed or failed. Your own config, your ignore
181
293
  patterns and your severities take no part.
182
294
 
295
+ Where `@convex-dev/eslint-plugin` is installed, the same run lints `fixtures/convex/` under the
296
+ overrides `convex()` returns and asserts that the Convex rule each file is named after fired. That
297
+ plugin loads through the override, not through anything you wrote, so this is what turns red when
298
+ the load stops working. Installed means its directory is in `node_modules`: a broken package still
299
+ runs the check, and fails it. A package your `package.json` declares but that is not installed
300
+ fails the run. With no plugin at all the command says it skipped that part, and
301
+ `--require-convex` turns that skip into a failure, which is what a Convex project's CI wants.
302
+
183
303
  The linter it runs is the oxlint your local `vite-plus` depends on, so the run that matters is the
184
304
  one right after you move `vite-plus`, and in CI.
185
305
 
@@ -188,25 +308,21 @@ dead.
188
308
 
189
309
  ## Repository checks
190
310
 
191
- `@rdjperron/lint/repo` is for a repo that keeps a decision register in `docs/decisions.md` and
192
- dated research in `_research/`. Prose can state those conventions and cannot enforce them. `checkRepo`
193
- reads the tree and returns what breaks them, so a test fails on a dead link or on a citation of a
194
- retired decision.
311
+ `@rdjperron/lint/repo` is for a repo that keeps living documents at its root and under `docs/`, and
312
+ dated research in `_research/`. Prose can state those conventions and cannot enforce them.
313
+ `checkRepo` reads the tree and returns what breaks them, so a test fails on a dead link or on a
314
+ research entry with no date in its name.
195
315
 
196
316
  It is plain Node and imports no test runner. You call it from a test of your own:
197
317
 
198
318
  ```ts
199
319
  // lib/repo.test.ts
200
- import { fileURLToPath } from "node:url";
320
+ import { fileURLToPath, URL } from "node:url";
201
321
 
202
322
  import { CHECKS, checkRepo, HINTS, TITLES } from "@rdjperron/lint/repo";
203
323
  import { describe, expect, it } from "vitest";
204
324
 
205
- const faults = checkRepo({
206
- root: fileURLToPath(new URL("..", import.meta.url)),
207
- codeRoots: ["app", "components", "lib", "convex", "e2e"],
208
- linked: ["e2e/README.md"],
209
- });
325
+ const faults = checkRepo({ root: fileURLToPath(new URL("..", import.meta.url)) });
210
326
 
211
327
  describe("the tree", () => {
212
328
  for (const check of CHECKS) {
@@ -225,92 +341,73 @@ line that opens with a path relative to the root, with the line number when the
225
341
 
226
342
  ```
227
343
  README.md:63 points at docs/not-there.md, which is not there
228
- src/main.tsx:43 cites D16, which is retired
229
344
  _research/notes.md is not named YYYYMMDD.HHMM.topic.md or YYYYMMDD.topic/
230
- src, named by `codeRoots`, is not there
345
+ e2e/README.md, named by `linked`, is not there
231
346
  ```
232
347
 
233
348
  `TITLES` holds what each check promises, for the test's name. `HINTS` holds one sentence per check
234
349
  saying how to fix it, which the example passes to `expect` as the failure message. `CHECKS` is the
235
- five names: `scans`, `links`, `register`, `citations`, `research`.
350
+ three names: `scans`, `links`, `research`.
236
351
 
237
- `checkRepo` does not throw on a tree or a config it cannot read. A code root that is a file, a
238
- register that is a directory, a symbolic link that leads nowhere and a root that does not exist
239
- are each a fault in `scans`, and a link whose percent-escapes do not decode is a fault in `links`.
352
+ `checkRepo` does not throw on a tree or a config it cannot read. A linked document that is a
353
+ directory, a symbolic link that leads nowhere and a root that does not exist are each a fault in
354
+ `scans`, and a link whose percent-escapes do not decode is a fault in `links`.
240
355
 
241
356
  ### What each check holds
242
357
 
243
- | Check | What it holds |
244
- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
245
- | `links` | Every relative Markdown link in the living documents and in each research folder's `README.md` points at a path that exists. A URL with a scheme, a `//host` and a bare `#anchor` are skipped, and an anchor after a path is dropped before the path is tested. |
246
- | `register` | Every row is one line, `- **Dn** Sentence. (YYYY-MM-DD)` or `- **Dn** Sentence. (YYYY-MM-DD, source)`. Each number is given once and is not also in the retired list. No number is missing up to the highest ever given. No other line names a number in bold. A `## Retired` section exists, and it may name no number yet. |
247
- | `citations` | Every `D<n>` in the scanned files, and in the register's own rows after their number, is a live row. A retired number fails as retired and a number never given fails as unknown. |
248
- | `research` | Each entry of `_research/` is a file `YYYYMMDD.HHMM.topic.md` or a folder `YYYYMMDD.topic/` that holds a `README.md`. The topic is lowercase letters, digits and hyphens. |
249
- | `scans` | Every path the config names is there and is the right kind, a file or a directory, defaults included. Every code root yields a file to scan. The register has a row. No symbolic link a walk meets leads nowhere. |
358
+ | Check | What it holds |
359
+ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
360
+ | `links` | Every relative Markdown link in the living documents points at a path that exists. So does every link in a research folder's `README.md` that stays inside the research directory. A URL with a scheme, a `//host` and a bare `#anchor` are skipped, and an anchor after a path is dropped before the path is tested. |
361
+ | `research` | Each entry of `_research/` is a file `YYYYMMDD.HHMM.topic.md` or a folder `YYYYMMDD.topic/` that holds a `README.md`. The topic is lowercase letters, digits and hyphens. |
362
+ | `scans` | Every path the config names is there and is the right kind, a file or a directory. A default that is there is the right kind too. No symbolic link at one of those paths, or that a walk meets, leads nowhere. |
250
363
 
251
- `scans` exists because a check that reads nothing passes. Rename `lib/` to `src/` without touching
252
- the config and the citation scan would cover one directory fewer and stay green. With `scans` the
253
- missing root is a fault.
364
+ `scans` exists because a check that reads nothing passes. Move `e2e/README.md` without touching
365
+ the config and its links would stop being read while the test stays green. With `scans` the
366
+ missing document is a fault.
254
367
 
255
- `scans` holds paths and files, not matches. A repo with no research directory, an empty one, no
256
- relative link or no citation yet is clean, so a repository can turn the test on from its first
257
- commit. The one path that may be absent is the research directory.
368
+ `scans` holds paths, not matches. A repo with no relative link yet is clean, so a repository can
369
+ turn the test on from its first commit, with its root alone for a config. A default that is absent
370
+ is not a fault: a repo may have no `BACKLOG.md`, no `docs` directory and no research yet. A path
371
+ the config names must be there, in `documents`, `docs` or `linked`. The research directory is the
372
+ one exception, and may be absent even when the config names it. Absent means nothing is at the
373
+ path: a symbolic link that leads nowhere is a fault, at a default path too.
258
374
 
259
375
  The living documents are `README.md`, `AGENTS.md` and `BACKLOG.md` at the root, every `.md` file
260
- under `docs/`, and the documents `linked` names. Their links are held and they are scanned for
261
- citations, the register excepted. The citation scan also reads every file under a code root with a
262
- scanned extension, and every file at the root with one. A lockfile is never scanned, meaning a
263
- name that ends in `-lock.json`, `-lock.yaml` or the like.
376
+ under `docs/`, and the documents `linked` names. Every relative link in them is held, a link into
377
+ the research included.
264
378
 
265
379
  The research is frozen once written, so the checks read little of it. The body of a research
266
- entry is checked for neither links nor citations, since it names paths and numbers as they stood on
267
- its date. Only a research folder's `README.md` has its links held, and nothing under the research
268
- directory is scanned for citations, even if you name it as a code root or keep it under `docs/`.
269
- The retired numbers are every `D<n>` in the `## Retired` section, up to the next `##` heading. The
270
- prose around the register's rows may name any number.
271
-
272
- When the register cannot be read, `scans` says so once, and `register` and `citations` stay empty
273
- rather than failing every citation in the repo.
380
+ entry is not checked for links, since it names paths as they stood on its date. Only a research
381
+ folder's `README.md` has its links held, and only the links that stay inside the research
382
+ directory. A link out of it, to `../../docs/product.md` say, pointed at a document of its day, and
383
+ the living tree has moved on since, so it is not held. This goes for the research directory
384
+ wherever it is, under `docs/` included, and for a document inside it that `linked` names.
274
385
 
275
386
  ### The config
276
387
 
277
- Every path is relative to the root, and `lib`, `./lib` and `lib/` name the same one.
388
+ Every path is relative to the root, and `docs`, `./docs` and `docs/` name the same one.
278
389
 
279
- | Field | Default | What it is |
280
- | ------------ | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
281
- | `root` | required | The repository root. A relative one is resolved against the working directory. |
282
- | `codeRoots` | required | The directories scanned for citations. `["."]` scans the whole root. |
283
- | `extensions` | `ts` `tsx` `mts` `cts` `js` `jsx` `mjs` `cjs` `md` `json` `jsonc` `yml` `yaml` `html` | The extensions scanned for citations. Yours replace the default list. |
284
- | `skip` | `node_modules` `_generated` `dist` | Names no walk enters, wherever they sit. Yours are added to the default list. |
285
- | `leftOut` | none | Paths no walk enters, as `{ path: reason }`. The reason is for the reader. |
286
- | `documents` | `README.md` `AGENTS.md` `BACKLOG.md` | The living documents at the root. Yours replace the default list, so a repo with no backlog names the two it has. |
287
- | `docs` | `docs` | The directory whose every `.md` file is a living document. |
288
- | `linked` | none | More living documents, such as `e2e/README.md`. |
289
- | `register` | `decisions.md` in the `docs` directory | The decision register. It follows `docs`: with `docs: "handbook"` it is `handbook/decisions.md`. |
290
- | `research` | `_research` | The research directory. It may be absent. |
390
+ | Field | Default | What it is |
391
+ | ----------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
392
+ | `root` | required | The repository root. A relative one is resolved against the working directory. |
393
+ | `skip` | `node_modules` `_generated` `dist` | Names the walk of `docs` does not enter, wherever they sit. Yours are added to the default list. |
394
+ | `documents` | `README.md` `AGENTS.md` `BACKLOG.md` | The living documents at the root. A default that is absent is passed over. Yours replace the default list, and each one you name must be there. |
395
+ | `docs` | `docs` | The directory whose every `.md` file is a living document. Left at its default, it may be absent. |
396
+ | `linked` | none | More living documents, such as `e2e/README.md`. Each must be there. |
397
+ | `research` | `_research` | The research directory. It may be absent. |
291
398
 
292
- `skip` matches a name at any depth, so `skip: ["fixtures"]` leaves out every `fixtures` directory.
293
- `leftOut` matches one path, for a directory whose name is legitimate elsewhere:
399
+ `skip` matches a name at any depth, so `skip: ["fixtures"]` leaves out every `fixtures` directory
400
+ under `docs`. It acts on what the walk finds under `docs`, and removes neither a document you name
401
+ in `documents` or `linked` nor an entry of the research directory. To stop holding a named
402
+ document, take it off its list.
294
403
 
295
- ```ts
296
- leftOut: { "site/.well-known": "certificate fingerprints read like decision numbers" },
297
- ```
298
-
299
- Both act on what a walk finds under `docs`, under a code root and at the root. Neither removes a
300
- document you name in `documents` or `linked`. To stop holding one of those, take it off its list.
301
-
302
- A citation is any `D` followed by digits and standing as a word of its own, so recorded data full
303
- of ids such as `D12` is the usual reason to leave a path out. Leave it out with `skip` or
304
- `leftOut`, or keep its extension off the list, as with `csv`.
305
-
306
- `DEFAULT_DOCUMENTS`, `DEFAULT_EXTENSIONS` and `DEFAULT_SKIP` are exported, for a config that adds
307
- to a list the field replaces.
404
+ `DEFAULT_DOCUMENTS` and `DEFAULT_SKIP` are exported, for a config that adds to a list the field
405
+ replaces.
308
406
 
309
407
  ### What the checks do not do
310
408
 
311
- They hold form, not truth. A link that resolves may point at the wrong document. A row may be well
312
- formed and state a rule the code broke last week. A citation of a live number may cite the wrong
313
- rule. A research entry with a correct name may be empty.
409
+ They hold form, not truth. A link that resolves may point at the wrong document. A research entry
410
+ with a correct name may be empty.
314
411
 
315
412
  They also leave these alone:
316
413
 
@@ -318,42 +415,43 @@ They also leave these alone:
318
415
  - A link written as bare text, as `<path>`, inside an HTML tag, with a title in single quotes or
319
416
  with parentheses in its path is not read as a link, or is read wrong.
320
417
  - A link that opens with `/` is tested against the filesystem root, not the repository's.
321
- - The date in a row or in a research name is not checked to be a real date.
322
- - `**D11.**` and `**D12a**` are neither a row nor row-like, so the register check does not see them.
323
- - `#D12` and `-D16` count as citations, since the `D` still opens a word.
418
+ - A link out of the research is not tested at all, so one that was already dead on its day passes.
419
+ - The date in a research name is not checked to be a real date.
324
420
  - Any file directly under the research directory that is not a dated note is a fault, a
325
421
  `README.md` or a `.DS_Store` included.
326
422
  - A linked directory is not entered, since it may lead back to one of its own parents.
327
423
 
328
- Reading the rows against the code is still a review's job.
424
+ Reading the documents against the code is still a review's job.
329
425
 
330
426
  ## The rules
331
427
 
332
428
  Each rule file opens with the reason the rule exists and the case where it is legitimate to
333
429
  scope it off; the messages, the options and the cases are in the rule and its tests.
334
430
 
335
- | Rule | What it rejects |
336
- | ----------------------------------------------------- | --------------------------------------------------------------------- |
337
- | `rdjperron/no-array-filter-map` | `filter().map()`, two passes and an intermediate array. |
338
- | `rdjperron/no-chained-type-assertions` | `x as unknown as T`, an assertion laundered through a second one. |
339
- | `rdjperron/no-conditional-empty-object-spread` | `...(cond ? { a } : {})`, omission hidden behind an empty object. |
340
- | `rdjperron/no-hardcoded-jsx-text` | Words written straight into JSX children. |
341
- | `rdjperron/no-known-value-widening` | An annotation that throws away what inference already knew. |
342
- | `rdjperron/no-module-mocking` | `vi.mock` and friends, a test of the mock rather than the module. |
343
- | `rdjperron/no-object-parameters` | The bare `object` type on a function input. |
344
- | `rdjperron/no-reduce-accumulator-copy` | Spreading the accumulator inside `reduce`, quadratic by construction. |
345
- | `rdjperron/no-reflect-apply` | `Reflect.apply`, a call the types cannot follow. |
346
- | `rdjperron/no-reflect-get` | `Reflect.get`, a property read the types cannot follow. |
347
- | `rdjperron/no-runtime-typeof` | `typeof` as a substitute for parsing input at its boundary. |
348
- | `rdjperron/no-shape-in-symbol-names` | The word "shape" in a symbol name, structure in place of a role. |
349
- | `rdjperron/no-unknown-parameters` | `unknown` on a function input. |
350
- | `rdjperron/no-unknown-returns` | `unknown` on a function output. |
351
- | `rdjperron/no-unknown-type-aliases` | A type alias that resolves to `unknown`. |
352
- | `rdjperron/no-unsafe-dictionary-type` | A dictionary whose values are `unknown`, `any` or `object`. |
353
- | `rdjperron/no-use-effect` | Every `useEffect` and `useLayoutEffect` call or import. |
354
- | `rdjperron/no-widen-then-assert` | Widening a value and asserting it back. |
355
- | `rdjperron/require-safety-comment-for-type-assertion` | An assertion with no `SAFETY:` line naming the invariant. |
356
- | `rdjperron/static-public-env` | A `process.env` read Expo's bundler does not replace at build time. |
431
+ | Rule | What it rejects |
432
+ | ----------------------------------------------------- | ------------------------------------------------------------------------------ |
433
+ | `rdjperron/convex-checked-constructors` | A raw public Convex constructor, or a read of the caller, outside the wrapper. |
434
+ | `rdjperron/convex-require-validators` | An exported Convex function without `args`, or without `returns`. |
435
+ | `rdjperron/no-array-filter-map` | `filter().map()`, two passes and an intermediate array. |
436
+ | `rdjperron/no-chained-type-assertions` | `x as unknown as T`, an assertion laundered through a second one. |
437
+ | `rdjperron/no-conditional-empty-object-spread` | `...(cond ? { a } : {})`, omission hidden behind an empty object. |
438
+ | `rdjperron/no-hardcoded-jsx-text` | Words written straight into JSX children. |
439
+ | `rdjperron/no-known-value-widening` | An annotation that throws away what inference already knew. |
440
+ | `rdjperron/no-module-mocking` | `vi.mock` and friends, a test of the mock rather than the module. |
441
+ | `rdjperron/no-object-parameters` | The bare `object` type on a function input. |
442
+ | `rdjperron/no-reduce-accumulator-copy` | Spreading the accumulator inside `reduce`, quadratic by construction. |
443
+ | `rdjperron/no-reflect-apply` | `Reflect.apply`, a call the types cannot follow. |
444
+ | `rdjperron/no-reflect-get` | `Reflect.get`, a property read the types cannot follow. |
445
+ | `rdjperron/no-runtime-typeof` | `typeof` as a substitute for parsing input at its boundary. |
446
+ | `rdjperron/no-shape-in-symbol-names` | The word "shape" in a symbol name, structure in place of a role. |
447
+ | `rdjperron/no-unknown-parameters` | `unknown` on a function input. |
448
+ | `rdjperron/no-unknown-returns` | `unknown` on a function output. |
449
+ | `rdjperron/no-unknown-type-aliases` | A type alias that resolves to `unknown`. |
450
+ | `rdjperron/no-unsafe-dictionary-type` | A dictionary whose values are `unknown`, `any` or `object`. |
451
+ | `rdjperron/no-use-effect` | Every `useEffect` and `useLayoutEffect` call or import. |
452
+ | `rdjperron/no-widen-then-assert` | Widening a value and asserting it back. |
453
+ | `rdjperron/require-safety-comment-for-type-assertion` | An assertion with no `SAFETY:` line naming the invariant. |
454
+ | `rdjperron/static-public-env` | A `process.env` read Expo's bundler does not replace at build time. |
357
455
 
358
456
  ### Options
359
457
 
@@ -368,6 +466,18 @@ scope it off; the messages, the options and the cases are in the rule and its te
368
466
  - `rdjperron/no-unknown-parameters` takes `{ hint?: string }`, appended to every finding, so a project
369
467
  can name the modules where the parse belongs. `decoders(files)` sets it.
370
468
  - `rdjperron/static-public-env` has no options.
469
+ - `rdjperron/convex-checked-constructors` takes
470
+ `{ wrapper?: string, wrapperImports?: string[], hint?: string }`. `wrapper` exempts that one
471
+ file and names it in every finding. An absolute path pins it; a relative one is looked for from
472
+ the working directory upward. `wrapperImports` lists the alias spellings that import it. Without
473
+ `wrapper` every file is outside the wrapper, so the rule reports the wrapper's own imports too. `internalQuery`,
474
+ `internalMutation`, `internalAction` and `httpAction` stay allowed everywhere.
475
+ - `rdjperron/convex-require-validators` takes
476
+ `{ wrapper?: string, wrapperImports?: string[], returns?: boolean, hint?: string }`. `returns`
477
+ defaults to true. Convex's docs call a return validator optional, but its own `convex-expert`
478
+ skill asks for one on every function, and bidraft, atlas and rummy already declare one on every
479
+ function they deploy (2026-10-12). `httpAction` is left alone, since it takes a request.
480
+ - Both convex rules skip a file under `_generated/`, which `convex codegen` writes.
371
481
 
372
482
  ## Name the file that holds the fix
373
483
 
@@ -391,6 +501,57 @@ Land a new rule at `warn`, fix or scope what fires, then promote it. When a rule
391
501
  code or add a narrowly scoped override with the measurement that justifies it. Never weaken the
392
502
  rule, add a cast, or rename a symbol to dodge the check.
393
503
 
504
+ ## Moving from 0.7
505
+
506
+ Nothing is removed and nothing changes for a project without Convex. 0.8.0 adds `convex()`, the two
507
+ `rdjperron/convex-*` rules and the optional peer `@convex-dev/eslint-plugin`.
508
+
509
+ A Convex project that bans the raw constructors by hand today deletes those bans when it adopts
510
+ `convex(files, { wrapper })`:
511
+
512
+ | You write by hand today | 0.8.0 gives you |
513
+ | --------------------------------------------------------------------------------------------------------- | --------------------------------------- |
514
+ | `query`, `mutation`, `action` from `_generated/server` in `no-restricted-imports`, lifted for the wrapper | `rdjperron/convex-checked-constructors` |
515
+ | `queryGeneric`, `mutationGeneric`, `actionGeneric` from `convex/server` | the same rule |
516
+ | `getAuthUserId`, `getAuthSessionId` from `@convex-dev/auth/server` | the same rule |
517
+ | `no-restricted-properties` on `ctx.auth` | the same rule |
518
+
519
+ The rule also sees what those bans missed: `const c = ctx; c.auth`, a handler whose context is
520
+ not named `ctx`, and a narrower override that restates `no-restricted-imports` and so drops the
521
+ Convex entries.
522
+
523
+ ## Moving from 0.6
524
+
525
+ 0.7.0 takes the decision register out of the repository checks. `checkRepo` no longer reads a
526
+ register, and no longer scans code for the `D<n>` numbers that cited its rows. A repo that keeps a
527
+ register stays on 0.6.0, which stays published.
528
+
529
+ | Gone | What it was for |
530
+ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
531
+ | The `register` check | It held `docs/decisions.md` to one well-formed line per row, each number given once. |
532
+ | The `citations` check | It held every `D<n>` in the scanned files to a live row of the register. |
533
+ | `codeRoots` | The directories scanned for citations. It was required, so delete it from your config. |
534
+ | `extensions` and `DEFAULT_EXTENSIONS` | The extensions the citation scan read. |
535
+ | `register` | The path of the register. |
536
+ | `leftOut` | Paths left out of the citation scan, for files whose contents read like `D<n>` numbers. Under `docs`, `skip` does the same by name. |
537
+
538
+ `CHECKS` is now `scans`, `links`, `research`, and `TITLES`, `HINTS` and the returned faults carry
539
+ those three keys. A test that loops over `CHECKS` needs no other edit. A test that filtered the
540
+ faults by hand to live without a register deletes the filter.
541
+
542
+ Three things change in what stays:
543
+
544
+ - `scans` no longer asks for a register or for code roots, and a default that is absent is not a
545
+ fault: a `docs` directory the config does not name, and any of `README.md`, `AGENTS.md` and
546
+ `BACKLOG.md` when the config leaves `documents` alone. A path the config names must still be
547
+ there.
548
+ - A research folder's `README.md` is held only for the links that stay inside the research
549
+ directory. A link from it to a living document that has since moved or gone no longer fails.
550
+ The same goes for a document inside the research directory that `linked` names: its links out
551
+ of the research are no longer held.
552
+ - `skip` acts on the walk of `docs` alone, since no code root and no file at the root is walked
553
+ any more.
554
+
394
555
  ## Moving from 0.5
395
556
 
396
557
  Nothing is removed and nothing changes. 0.6.0 adds `@rdjperron/lint/repo`. A repo that carries the