@rdjperron/lint 0.3.0 → 0.6.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,46 +1,42 @@
1
1
  # @rdjperron/lint
2
2
 
3
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`, and the check that proves every rule still fires.
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.
5
6
 
6
- | Entry | What it gives you |
7
- | ---------------------------- | ------------------------------------------------------------------------------------------ |
8
- | `@rdjperron/lint` | The plugin. One `jsPlugins` entry, one prefix, twenty rules. |
9
- | `@rdjperron/lint/config` | `recommended`, the override builders, and the `fmt`, `ignore` and `staged` blocks. |
10
- | `@rdjperron/lint/verify` | `verifySuites`, `packageSuite`, `verifyPluginApiPin`, and the `rdjperron-lint-verify` bin. |
11
- | `@rdjperron/lint/fixtures/*` | One deliberately violating file per rule, the input the verify command lints. |
7
+ | Entry | What it gives you |
8
+ | ------------------------ | ---------------------------------------------------------------------------------- |
9
+ | `@rdjperron/lint` | The plugin. One `jsPlugins` entry, one prefix, twenty rules. |
10
+ | `@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
13
 
13
14
  Seventeen of the rules are a copy of [dmmulroy/anti-slop](https://github.com/dmmulroy/anti-slop),
14
- owned and edited here (`src/anti-slop/VENDORED.md` in the source repo records the commit). The
15
+ owned and edited here (`src/anti-slop/VENDORED.md` in this repository records the commit). The
15
16
  other three are written here.
16
17
 
17
- Tested on vite-plus 0.3.1, oxlint 1.81.0 and `@oxlint/plugins` 1.79.0 (2026-09-18).
18
+ 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.
18
20
 
19
21
  ## Install
20
22
 
21
23
  ```bash
22
- npm install --save-dev --save-exact @rdjperron/lint @oxlint/plugins@<version>
24
+ npm install --save-dev --save-exact @rdjperron/lint
23
25
  ```
24
26
 
25
- The rules import `defineRule` and the AST types from `@oxlint/plugins`, a peer dependency. Its
26
- version has to match the plugin API of the linter that loads the rules. Under Vite+ that linter
27
- is inside the `vp` binary, and the authority is the version `vite-plus` declares, not npm latest
28
- and not the bundled oxlint version:
27
+ That is the whole installation. `@oxlint/plugins`, which the rules import `defineRule` and the AST
28
+ types from, is an ordinary dependency here and arrives with the package; there is no version of it
29
+ for you to pin or to move.
29
30
 
30
- ```bash
31
- vp why @oxlint/plugins
32
- # or: node -p "require('vite-plus/package.json').dependencies['@oxlint/plugins']"
33
- ```
34
-
35
- Pin `@oxlint/plugins` exactly to that number. The peer range here is a caret, so an exact pin
36
- anywhere in the same major satisfies it. Move the pin every time `vp` moves; `rdjperron-lint-verify`
37
- fails when the two drift apart.
31
+ `vite-plus` is an optional peer, `>=0.3.0`, so a project on plain oxlint can still load the plugin.
32
+ The floor is `react()`, not the rules: oxlint 1.77.0, which vite-plus 0.2.9 declares, has no
33
+ `react/immutability`, `react/purity` or `react/refs`, and refuses to build a config that names them.
38
34
 
39
35
  ## Use it
40
36
 
41
37
  ```ts
42
38
  // vite.config.ts
43
- import { fixtures, fmt, ignore, react, recommended, staged, tests } from "@rdjperron/lint/config";
39
+ import { fmt, ignore, react, recommended, staged, tests } from "@rdjperron/lint/config";
44
40
  import { defineConfig } from "vite-plus";
45
41
 
46
42
  export default defineConfig({
@@ -48,22 +44,21 @@ export default defineConfig({
48
44
  fmt: { ...fmt, ignorePatterns: ignore },
49
45
  lint: {
50
46
  extends: [recommended],
51
- plugins: ["typescript", "unicorn", "oxc", "import", "promise", "node"],
52
- categories: { correctness: "error", suspicious: "warn", perf: "warn" },
53
47
  env: { builtin: true, node: true },
54
48
  overrides: [
55
49
  ...react(["app/**"], { useEffectAllowedIn: "hooks/use-mount-effect.ts" }),
56
50
  ...tests(["**/*.test.ts", "**/*.test.tsx"]),
57
- ...fixtures(),
58
51
  ],
59
- options: { typeAware: true, typeCheck: true },
52
+ options: { typeCheck: true },
60
53
  ignorePatterns: ignore,
61
54
  },
62
55
  });
63
56
  ```
64
57
 
65
- `jsPlugins` needs no entry: `recommended` carries it. Registering by hand works too, in either
66
- form, because the plugin declares its own `meta.name`:
58
+ `jsPlugins`, `plugins`, `categories` and `options.typeAware` need no entry of their own:
59
+ `recommended` carries all four, and they come through `extends`. `options` merges key by key, so
60
+ writing `options: { typeCheck: false }` keeps the inherited `typeAware`. Registering the plugin by
61
+ hand works too, in either form, because it declares its own `meta.name`:
67
62
 
68
63
  ```ts
69
64
  jsPlugins: ["@rdjperron/lint"];
@@ -80,57 +75,257 @@ Measured on vite-plus 0.3.1 and oxlint 1.81.0 (2026-09-17), with a base config o
80
75
  | `jsPlugins`, `rules`, `overrides`, `plugins`, `categories`, `options` | yes, and the consumer's own entries win |
81
76
  | `env`, `settings`, `ignorePatterns` | no, the consumer writes its own |
82
77
 
83
- So `recommended` carries a plugin entry and rules, nothing else. Write `plugins`, `categories`,
84
- `env`, `options` and `ignorePatterns` in your own config; `ignore` is an array you spread into
85
- `ignorePatterns`, not a field you inherit.
78
+ So `recommended` carries everything on the inherited side: the plugin entry, the rules, the plugin
79
+ set, the categories and `options.typeAware`. What is left to you is `env` and `ignorePatterns`;
80
+ `ignore` is an array you spread into `ignorePatterns`, not a field you inherit.
86
81
 
87
- Two more things oxlint does quietly. An override that changes a built-in plugin's rule must
88
- repeat that plugin in its own `plugins`, even when an earlier override already listed it, or the
89
- entry is dropped with no error. And a relative plugin specifier inside an `extends` object is
90
- refused; a package name is what works.
82
+ `options` is the one field that could read either way, so it was measured on its own (2026-09-19):
83
+ it merges key by key. A base carrying `{ typeAware: true }` and a consumer writing
84
+ `{ typeCheck: false }` leaves `typeAware` on. Only naming `typeAware` yourself overrides it.
85
+
86
+ Two more things oxlint does quietly. An override that names a rule of a plugin the config does not
87
+ enable has to add that plugin in its own `plugins`, or the entry is dropped with no error; a plugin
88
+ already in the top-level set needs no repeating, and an override's `plugins` adds to that set rather
89
+ than replacing it (measured on vite-plus 0.3.1 and oxlint 1.81.0, 2026-09-19). And a relative plugin
90
+ specifier inside an `extends` object is refused; a package name is what works.
91
91
 
92
92
  ### The config exports
93
93
 
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, and the built-in choices. |
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 the rule off for it. |
100
- | `i18n(files, options?)` | `rdjperron/no-hardcoded-jsx-text` at error. `options.pattern` sets the word pattern. |
101
- | `expo(files)` | `rdjperron/static-public-env` at error. |
102
- | `tests(files)` | `any`, `!`, `no-runtime-typeof` and the safety-comment rule off, with the reason on each. |
103
- | `fixtures(view?)` | Every rule at error on the verify view, `scripts/.lint-fixtures/**` by default. |
104
- | `fmt` | `printWidth` 100, double quotes, semicolons, two spaces. |
105
- | `ignore` | Dependencies, build output, the AI agent directories and the Vitest JSON report. |
106
- | `staged` | `vp check --fix` on JS and TS sources. |
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. |
107
108
 
108
109
  `recommended` puts every rule at `error` except `no-hardcoded-jsx-text` and `static-public-env`,
109
110
  which belong to a translated app and to an Expo app and are left to `i18n()` and `expo()`. The
110
111
  measurements behind those severities are in the comments beside them.
111
112
 
113
+ ### `restrictedImports`, one declaration for every file set
114
+
115
+ Two overrides matching one file do not merge. The later one replaces the rule's whole entry, so
116
+ every file set needs `no-restricted-imports` written out in full. A repo with ten file sets
117
+ restates the same list ten times, and a new ban has to be added to all ten. Forget one and that
118
+ file set silently keeps importing what the others may not.
119
+
120
+ Here each entry is named once and each scope says which scope it starts `from`, what it `add`s and
121
+ what it `drop`s. Nothing is matched by glob and nothing is guessed: one override comes out per
122
+ scope, in the order you declared them, so you declare broad to narrow.
123
+
124
+ ```ts
125
+ const RN_TEXT = { name: "react-native", importNames: ["Text"], message: "Text goes through <T role>." };
126
+ const RN_ALERT = { name: "react-native", importNames: ["Alert"], message: "lib/alert.ts answers on both targets." };
127
+ const SCHEMA = { group: ["**/convex/schema"], message: "convex/validators.ts is what the client reads." };
128
+
129
+ ...restrictedImports({
130
+ paths: { RN_TEXT, RN_ALERT },
131
+ patterns: { SCHEMA },
132
+ scopes: {
133
+ all: { files: ["**"], use: ["RN_TEXT", "RN_ALERT", "SCHEMA"] },
134
+ kit: { files: ["components/ui/**"], from: "all", drop: ["RN_TEXT"] },
135
+ },
136
+ }),
137
+ ```
138
+
139
+ That returns two overrides. The first bans all three everywhere, the second bans `RN_ALERT` and
140
+ `SCHEMA` under `components/ui/`, which is where `Text` is wrapped. A ban added to `paths` and to
141
+ `all` reaches every scope that descends from it, and `components/ui/` keeps its one exemption.
142
+
143
+ The broadest scope is an override on `files: ["**"]` rather than a top-level rule. Measured on
144
+ vite-plus 0.3.1 and oxlint 1.81.0, 2026-09-19: the two behave the same, files matched by no other
145
+ override included, so the whole list reads in one place.
146
+
147
+ Severity is `error`, always. Every message is yours.
148
+
149
+ TypeScript rejects four mistakes on the spot:
150
+
151
+ - a name in `use`, `add` or `drop` that is no key of `paths` or `patterns`,
152
+ - a `from` naming a scope that does not exist,
153
+ - `use` and `from` on the same scope,
154
+ - a scope with neither.
155
+
156
+ Three more depend on the order scopes resolve in, so they throw when the config loads, naming the
157
+ scope and the entry:
158
+
159
+ - a `from` naming a scope declared after this one,
160
+ - a `drop` of a name the parent does not restrict,
161
+ - an `add` of a name the parent already restricts.
162
+
163
+ The last two exist because both are a claim about the parent that is false, and a config that
164
+ states something false about its parent is one rename away from lifting a ban.
165
+
112
166
  ## Prove the rules still fire
113
167
 
114
168
  ```bash
115
169
  npx rdjperron-lint-verify
116
170
  ```
117
171
 
118
- The fixtures ship in the package: `fixtures/<rule>.fixture.ts(x)` is one deliberately violating
119
- file per rule. The command lints them through your own `vp lint` and asserts a positive, that
120
- every rule fired on the file named after it, then checks the `@oxlint/plugins` pin. A rule can
121
- go silently dead after a linter upgrade or a plugin API skew, and "reports nothing" looks exactly
122
- like "no violations". Nothing else notices.
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
174
+ positive: every rule fired, each on the file named after it. It prints a table and exits non-zero on
175
+ the first rule that stayed quiet.
176
+
177
+ The fixtures ship in the package, one deliberately violating `fixtures/<rule>.fixture.ts(x)` per
178
+ rule. The command copies them into a throwaway project under your system temp directory, beside a
179
+ generated config that puts every rule at error, with your `node_modules` symlinked in, and removes
180
+ the whole directory afterwards whether the run passed or failed. Your own config, your ignore
181
+ patterns and your severities take no part.
182
+
183
+ The linter it runs is the oxlint your local `vite-plus` depends on, so the run that matters is the
184
+ one right after you move `vite-plus`, and in CI.
185
+
186
+ One thing not to do: do not delete the fixtures. They are the only thing that catches a rule going
187
+ dead.
188
+
189
+ ## Repository checks
190
+
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.
195
+
196
+ It is plain Node and imports no test runner. You call it from a test of your own:
123
197
 
124
- Your config has to carry `fixtures()` in its overrides, on the view path the command uses
125
- (`scripts/.lint-fixtures/rdjperron` sits under the default `scripts/.lint-fixtures/**`). This is not a
126
- convenience: `-A`, `-W` and `-D` reach built-in rules only and cannot switch a JS plugin rule on,
127
- so the override is the only way the rules you leave off on your own code get proven.
198
+ ```ts
199
+ // lib/repo.test.ts
200
+ import { fileURLToPath } from "node:url";
201
+
202
+ import { CHECKS, checkRepo, HINTS, TITLES } from "@rdjperron/lint/repo";
203
+ import { describe, expect, it } from "vitest";
204
+
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
+ });
128
210
 
129
- Two things not to do. Do not gitignore the view: oxlint honours `.gitignore`, so an ignored view
130
- reports zero files instead of a dead rule. And do not delete the fixtures.
211
+ describe("the tree", () => {
212
+ for (const check of CHECKS) {
213
+ it(TITLES[check], () => {
214
+ expect(faults[check], HINTS[check]).toEqual([]);
215
+ });
216
+ }
217
+ });
218
+ ```
131
219
 
132
- A repo with a second plugin to check imports `verifySuites` and describes both suites itself; the
133
- source repo's `scripts/verify-lint-fixtures.mjs` is the example.
220
+ Loop over `CHECKS` rather than listing the checks by hand. A check added in a later version then
221
+ runs in your repo the day you move the pin, and nobody has to remember to assert it.
222
+
223
+ `checkRepo` returns one array of faults per check, empty where the check holds. A fault is one
224
+ line that opens with a path relative to the root, with the line number when the fault has one:
225
+
226
+ ```
227
+ README.md:63 points at docs/not-there.md, which is not there
228
+ src/main.tsx:43 cites D16, which is retired
229
+ _research/notes.md is not named YYYYMMDD.HHMM.topic.md or YYYYMMDD.topic/
230
+ src, named by `codeRoots`, is not there
231
+ ```
232
+
233
+ `TITLES` holds what each check promises, for the test's name. `HINTS` holds one sentence per check
234
+ 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`.
236
+
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`.
240
+
241
+ ### What each check holds
242
+
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. |
250
+
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.
254
+
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.
258
+
259
+ 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.
264
+
265
+ 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.
274
+
275
+ ### The config
276
+
277
+ Every path is relative to the root, and `lib`, `./lib` and `lib/` name the same one.
278
+
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. |
291
+
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:
294
+
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.
308
+
309
+ ### What the checks do not do
310
+
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.
314
+
315
+ They also leave these alone:
316
+
317
+ - An anchor is not checked against the headings of its target.
318
+ - A link written as bare text, as `<path>`, inside an HTML tag, with a title in single quotes or
319
+ with parentheses in its path is not read as a link, or is read wrong.
320
+ - 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.
324
+ - Any file directly under the research directory that is not a dated note is a fault, a
325
+ `README.md` or a `.DS_Store` included.
326
+ - A linked directory is not entered, since it may lead back to one of its own parents.
327
+
328
+ Reading the rows against the code is still a review's job.
134
329
 
135
330
  ## The rules
136
331
 
@@ -167,12 +362,82 @@ scope it off; the messages, the options and the cases are in the rule and its te
167
362
  - `rdjperron/no-hardcoded-jsx-text` takes `{ pattern?: string }`, a regex source compiled with `u`. The
168
363
  default `\p{L}{3,}` says three letters in a row is a word; digits, separators and bullets are
169
364
  not. It looks at JSX text and at string literals placed as children, never at attribute values.
170
- - `rdjperron/no-runtime-typeof` takes `{ allowInTypeGuards?: boolean }`. `recommended` turns it on: a
171
- `typeof` inside a type predicate is the decode the rule asks for, named and reusable.
365
+ - `rdjperron/no-runtime-typeof` takes `{ allowInTypeGuards?: boolean, hint?: string }`. `recommended`
366
+ turns the first on: a `typeof` inside a type predicate is the decode the rule asks for, named and
367
+ reusable. The hint is appended to every finding; `decoders(files)` sets it.
368
+ - `rdjperron/no-unknown-parameters` takes `{ hint?: string }`, appended to every finding, so a project
369
+ can name the modules where the parse belongs. `decoders(files)` sets it.
172
370
  - `rdjperron/static-public-env` has no options.
173
371
 
372
+ ## Name the file that holds the fix
373
+
374
+ A rule tells a reader what is wrong. When the fix is the same every time, the finding can tell them
375
+ where to go as well, which is what `useEffectAllowedIn` and `decoders(files)` are for: they put a
376
+ sentence naming the sanctioned file in every finding of the rule that will send someone there.
377
+ `decoders(["lib/api-error.ts", "convex/lib/json.ts"])` reads:
378
+
379
+ ```
380
+ Parameter `body` leaves input unparsed. Accept a named domain type; run the expected schema or
381
+ parser at the I/O boundary before calling this function. Decode the value in lib/api-error.ts or
382
+ convex/lib/json.ts, then call that decoder from here.
383
+ ```
384
+
385
+ The same sentence lands on `no-runtime-typeof`, and inside those files that rule is off, because a
386
+ `typeof` there is the parse rather than a narrowing in place of one.
387
+
174
388
  ## Severities are measurements
175
389
 
176
390
  Land a new rule at `warn`, fix or scope what fires, then promote it. When a rule fires, fix the
177
391
  code or add a narrowly scoped override with the measurement that justifies it. Never weaken the
178
392
  rule, add a cast, or rename a symbol to dodge the check.
393
+
394
+ ## Moving from 0.5
395
+
396
+ Nothing is removed and nothing changes. 0.6.0 adds `@rdjperron/lint/repo`. A repo that carries the
397
+ checks as a test file of its own replaces that file with the example under "Repository checks",
398
+ and keeps only its config.
399
+
400
+ ## Moving from 0.4
401
+
402
+ Nothing is removed. One builder changed what it does, and three are new.
403
+
404
+ `expo(files)` used to set `static-public-env` and nothing else. It now also puts
405
+ `typescript/no-require-imports` at error with the asset extensions allowed, and turns
406
+ `unicorn/no-array-sort` off. If your config carries either by hand, delete it, and pass
407
+ `assetExts` for what your `metro.config.js` adds. An asset `require()` is now allowed only in the
408
+ files you hand to `expo()`. If you allowed it everywhere before, a `require()` of an asset outside
409
+ those files reports.
410
+
411
+ | You write by hand today | 0.5.0 gives you |
412
+ | ------------------------------------------------------------------------ | --------------------------------------------------- |
413
+ | The same `no-restricted-imports` list in every override, minus one entry | `restrictedImports({ paths, patterns, scopes })` |
414
+ | `no-runtime-typeof` off for your decoder modules | `decoders(files)`, which also names them in the fix |
415
+ | `react/capitalized-calls` off, `react/style-prop-object` for `StatusBar` | `expo(files, { reactFiles })` |
416
+ | `react/exhaustive-deps` off for the `useEffectAllowedIn` file | `react()` does it |
417
+
418
+ Measured on rummy, 2026-09-19: `vite.config.ts` went from 447 lines to 366. 132 planted findings
419
+ across ten import scopes came out identical, position for position, before and after. The one
420
+ difference is the asset rule, which moved from warn to error and stopped allowing assets outside
421
+ the phone's files.
422
+
423
+ ## Moving from 0.3
424
+
425
+ 0.4.0 moved the rules' health out of the consumer. Everything below is something you delete, and
426
+ nothing replaces it.
427
+
428
+ | Delete | Why it is gone |
429
+ | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
430
+ | `@oxlint/plugins` from your `package.json` | An ordinary dependency here now. Nothing left to pin, and nothing to move on a `vp` upgrade. |
431
+ | `plugins: [...]` from `lint` | `recommended` carries the set its rules need. Keep yours only if you add a plugin of your own. |
432
+ | `categories: {...}` from `lint` | `recommended` carries `correctness: "error"`, `suspicious: "warn"`, `perf: "warn"`. |
433
+ | `typeAware: true` from `lint.options` | `recommended` carries it, and `options` merges key by key, so your `typeCheck` still applies. |
434
+ | `fixtures()` from your imports and your overrides | The verify command writes its own config now. The builder no longer exists. |
435
+ | Any comment about keeping `scripts/.lint-fixtures` un-ignored | The check runs under your system temp directory, where nothing is ignored. |
436
+ | Your `verify:lint-fixtures` script, if it passed arguments | `rdjperron-lint-verify` takes none. |
437
+
438
+ Two exports also went: `@rdjperron/lint/verify` (`verifySuites`, `packageSuite`,
439
+ `verifyPluginApiPin`) and `@rdjperron/lint/fixtures/*`. The fixtures still ship, the bin reads them
440
+ itself, and a repo with a second plugin to check now runs that plugin's own command beside this one.
441
+
442
+ New in 0.4.0: `oxc/no-accumulating-spread` at error, measured at zero hits on three repos. If it
443
+ fires on yours, that reducer is quadratic.