@rdjperron/lint 0.3.0 → 0.5.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
@@ -3,44 +3,38 @@
3
3
  One Oxlint JS plugin, `rdjperron`, with twenty rules against low-evidence code, the config data a
4
4
  project spreads into its own `vite.config.ts`, and the check that proves every rule still fires.
5
5
 
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. |
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` | The command that proves every rule still fires. No config, no arguments. |
12
11
 
13
12
  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
13
+ owned and edited here (`src/anti-slop/VENDORED.md` in this repository records the commit). The
15
14
  other three are written here.
16
15
 
17
- Tested on vite-plus 0.3.1, oxlint 1.81.0 and `@oxlint/plugins` 1.79.0 (2026-09-18).
16
+ 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
17
+ (2026-09-19). All twenty rules fired on all four.
18
18
 
19
19
  ## Install
20
20
 
21
21
  ```bash
22
- npm install --save-dev --save-exact @rdjperron/lint @oxlint/plugins@<version>
22
+ npm install --save-dev --save-exact @rdjperron/lint
23
23
  ```
24
24
 
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:
25
+ That is the whole installation. `@oxlint/plugins`, which the rules import `defineRule` and the AST
26
+ types from, is an ordinary dependency here and arrives with the package; there is no version of it
27
+ for you to pin or to move.
29
28
 
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.
29
+ `vite-plus` is an optional peer, `>=0.3.0`, so a project on plain oxlint can still load the plugin.
30
+ The floor is `react()`, not the rules: oxlint 1.77.0, which vite-plus 0.2.9 declares, has no
31
+ `react/immutability`, `react/purity` or `react/refs`, and refuses to build a config that names them.
38
32
 
39
33
  ## Use it
40
34
 
41
35
  ```ts
42
36
  // vite.config.ts
43
- import { fixtures, fmt, ignore, react, recommended, staged, tests } from "@rdjperron/lint/config";
37
+ import { fmt, ignore, react, recommended, staged, tests } from "@rdjperron/lint/config";
44
38
  import { defineConfig } from "vite-plus";
45
39
 
46
40
  export default defineConfig({
@@ -48,22 +42,21 @@ export default defineConfig({
48
42
  fmt: { ...fmt, ignorePatterns: ignore },
49
43
  lint: {
50
44
  extends: [recommended],
51
- plugins: ["typescript", "unicorn", "oxc", "import", "promise", "node"],
52
- categories: { correctness: "error", suspicious: "warn", perf: "warn" },
53
45
  env: { builtin: true, node: true },
54
46
  overrides: [
55
47
  ...react(["app/**"], { useEffectAllowedIn: "hooks/use-mount-effect.ts" }),
56
48
  ...tests(["**/*.test.ts", "**/*.test.tsx"]),
57
- ...fixtures(),
58
49
  ],
59
- options: { typeAware: true, typeCheck: true },
50
+ options: { typeCheck: true },
60
51
  ignorePatterns: ignore,
61
52
  },
62
53
  });
63
54
  ```
64
55
 
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`:
56
+ `jsPlugins`, `plugins`, `categories` and `options.typeAware` need no entry of their own:
57
+ `recommended` carries all four, and they come through `extends`. `options` merges key by key, so
58
+ writing `options: { typeCheck: false }` keeps the inherited `typeAware`. Registering the plugin by
59
+ hand works too, in either form, because it declares its own `meta.name`:
67
60
 
68
61
  ```ts
69
62
  jsPlugins: ["@rdjperron/lint"];
@@ -80,57 +73,116 @@ Measured on vite-plus 0.3.1 and oxlint 1.81.0 (2026-09-17), with a base config o
80
73
  | `jsPlugins`, `rules`, `overrides`, `plugins`, `categories`, `options` | yes, and the consumer's own entries win |
81
74
  | `env`, `settings`, `ignorePatterns` | no, the consumer writes its own |
82
75
 
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.
76
+ So `recommended` carries everything on the inherited side: the plugin entry, the rules, the plugin
77
+ set, the categories and `options.typeAware`. What is left to you is `env` and `ignorePatterns`;
78
+ `ignore` is an array you spread into `ignorePatterns`, not a field you inherit.
79
+
80
+ `options` is the one field that could read either way, so it was measured on its own (2026-09-19):
81
+ it merges key by key. A base carrying `{ typeAware: true }` and a consumer writing
82
+ `{ typeCheck: false }` leaves `typeAware` on. Only naming `typeAware` yourself overrides it.
86
83
 
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.
84
+ Two more things oxlint does quietly. An override that names a rule of a plugin the config does not
85
+ enable has to add that plugin in its own `plugins`, or the entry is dropped with no error; a plugin
86
+ already in the top-level set needs no repeating, and an override's `plugins` adds to that set rather
87
+ than replacing it (measured on vite-plus 0.3.1 and oxlint 1.81.0, 2026-09-19). And a relative plugin
88
+ specifier inside an `extends` object is refused; a package name is what works.
91
89
 
92
90
  ### The config exports
93
91
 
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. |
92
+ | Export | What it is |
93
+ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
94
+ | `plugin` | `{ name: "rdjperron", specifier: "@rdjperron/lint" }`. |
95
+ | `RULE_IDS`, `RuleId` | The twenty prefixed ids, as a readonly tuple and as a type. |
96
+ | `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`. |
97
+ | `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. |
98
+ | `i18n(files, options?)` | `rdjperron/no-hardcoded-jsx-text` at error. `options.pattern` sets the word pattern. |
99
+ | `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. |
100
+ | `restrictedImports(…)` | One `no-restricted-imports` override per file set, expanded from one declaration. See below. |
101
+ | `tests(files)` | `any`, `!`, `no-runtime-typeof` and the safety-comment rule off, with the reason on each. |
102
+ | `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. |
103
+ | `fmt` | `printWidth` 100, double quotes, semicolons, two spaces. |
104
+ | `ignore` | Dependencies, build output, the AI agent directories and the Vitest JSON report. |
105
+ | `staged` | `vp check --fix` on JS and TS sources. |
107
106
 
108
107
  `recommended` puts every rule at `error` except `no-hardcoded-jsx-text` and `static-public-env`,
109
108
  which belong to a translated app and to an Expo app and are left to `i18n()` and `expo()`. The
110
109
  measurements behind those severities are in the comments beside them.
111
110
 
111
+ ### `restrictedImports`, one declaration for every file set
112
+
113
+ Two overrides matching one file do not merge. The later one replaces the rule's whole entry, so
114
+ every file set needs `no-restricted-imports` written out in full. A repo with ten file sets
115
+ restates the same list ten times, and a new ban has to be added to all ten. Forget one and that
116
+ file set silently keeps importing what the others may not.
117
+
118
+ Here each entry is named once and each scope says which scope it starts `from`, what it `add`s and
119
+ what it `drop`s. Nothing is matched by glob and nothing is guessed: one override comes out per
120
+ scope, in the order you declared them, so you declare broad to narrow.
121
+
122
+ ```ts
123
+ const RN_TEXT = { name: "react-native", importNames: ["Text"], message: "Text goes through <T role>." };
124
+ const RN_ALERT = { name: "react-native", importNames: ["Alert"], message: "lib/alert.ts answers on both targets." };
125
+ const SCHEMA = { group: ["**/convex/schema"], message: "convex/validators.ts is what the client reads." };
126
+
127
+ ...restrictedImports({
128
+ paths: { RN_TEXT, RN_ALERT },
129
+ patterns: { SCHEMA },
130
+ scopes: {
131
+ all: { files: ["**"], use: ["RN_TEXT", "RN_ALERT", "SCHEMA"] },
132
+ kit: { files: ["components/ui/**"], from: "all", drop: ["RN_TEXT"] },
133
+ },
134
+ }),
135
+ ```
136
+
137
+ That returns two overrides. The first bans all three everywhere, the second bans `RN_ALERT` and
138
+ `SCHEMA` under `components/ui/`, which is where `Text` is wrapped. A ban added to `paths` and to
139
+ `all` reaches every scope that descends from it, and `components/ui/` keeps its one exemption.
140
+
141
+ The broadest scope is an override on `files: ["**"]` rather than a top-level rule. Measured on
142
+ vite-plus 0.3.1 and oxlint 1.81.0, 2026-09-19: the two behave the same, files matched by no other
143
+ override included, so the whole list reads in one place.
144
+
145
+ Severity is `error`, always. Every message is yours.
146
+
147
+ TypeScript rejects four mistakes on the spot:
148
+
149
+ - a name in `use`, `add` or `drop` that is no key of `paths` or `patterns`,
150
+ - a `from` naming a scope that does not exist,
151
+ - `use` and `from` on the same scope,
152
+ - a scope with neither.
153
+
154
+ Three more depend on the order scopes resolve in, so they throw when the config loads, naming the
155
+ scope and the entry:
156
+
157
+ - a `from` naming a scope declared after this one,
158
+ - a `drop` of a name the parent does not restrict,
159
+ - an `add` of a name the parent already restricts.
160
+
161
+ The last two exist because both are a claim about the parent that is false, and a config that
162
+ states something false about its parent is one rename away from lifting a ban.
163
+
112
164
  ## Prove the rules still fire
113
165
 
114
166
  ```bash
115
167
  npx rdjperron-lint-verify
116
168
  ```
117
169
 
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.
170
+ No configuration and no arguments. A rule can go silently dead after a linter upgrade, and "reports
171
+ nothing" looks exactly like "no violations", so nothing else in a repo notices. This asserts a
172
+ positive: every rule fired, each on the file named after it. It prints a table and exits non-zero on
173
+ the first rule that stayed quiet.
123
174
 
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.
175
+ The fixtures ship in the package, one deliberately violating `fixtures/<rule>.fixture.ts(x)` per
176
+ rule. The command copies them into a throwaway project under your system temp directory, beside a
177
+ generated config that puts every rule at error, with your `node_modules` symlinked in, and removes
178
+ the whole directory afterwards whether the run passed or failed. Your own config, your ignore
179
+ patterns and your severities take no part.
128
180
 
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.
181
+ The linter it runs is the oxlint your local `vite-plus` depends on, so the run that matters is the
182
+ one right after you move `vite-plus`, and in CI.
131
183
 
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.
184
+ One thing not to do: do not delete the fixtures. They are the only thing that catches a rule going
185
+ dead.
134
186
 
135
187
  ## The rules
136
188
 
@@ -167,12 +219,76 @@ scope it off; the messages, the options and the cases are in the rule and its te
167
219
  - `rdjperron/no-hardcoded-jsx-text` takes `{ pattern?: string }`, a regex source compiled with `u`. The
168
220
  default `\p{L}{3,}` says three letters in a row is a word; digits, separators and bullets are
169
221
  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.
222
+ - `rdjperron/no-runtime-typeof` takes `{ allowInTypeGuards?: boolean, hint?: string }`. `recommended`
223
+ turns the first on: a `typeof` inside a type predicate is the decode the rule asks for, named and
224
+ reusable. The hint is appended to every finding; `decoders(files)` sets it.
225
+ - `rdjperron/no-unknown-parameters` takes `{ hint?: string }`, appended to every finding, so a project
226
+ can name the modules where the parse belongs. `decoders(files)` sets it.
172
227
  - `rdjperron/static-public-env` has no options.
173
228
 
229
+ ## Name the file that holds the fix
230
+
231
+ A rule tells a reader what is wrong. When the fix is the same every time, the finding can tell them
232
+ where to go as well, which is what `useEffectAllowedIn` and `decoders(files)` are for: they put a
233
+ sentence naming the sanctioned file in every finding of the rule that will send someone there.
234
+ `decoders(["lib/api-error.ts", "convex/lib/json.ts"])` reads:
235
+
236
+ ```
237
+ Parameter `body` leaves input unparsed. Accept a named domain type; run the expected schema or
238
+ parser at the I/O boundary before calling this function. Decode the value in lib/api-error.ts or
239
+ convex/lib/json.ts, then call that decoder from here.
240
+ ```
241
+
242
+ The same sentence lands on `no-runtime-typeof`, and inside those files that rule is off, because a
243
+ `typeof` there is the parse rather than a narrowing in place of one.
244
+
174
245
  ## Severities are measurements
175
246
 
176
247
  Land a new rule at `warn`, fix or scope what fires, then promote it. When a rule fires, fix the
177
248
  code or add a narrowly scoped override with the measurement that justifies it. Never weaken the
178
249
  rule, add a cast, or rename a symbol to dodge the check.
250
+
251
+ ## Moving from 0.4
252
+
253
+ Nothing is removed. One builder changed what it does, and three are new.
254
+
255
+ `expo(files)` used to set `static-public-env` and nothing else. It now also puts
256
+ `typescript/no-require-imports` at error with the asset extensions allowed, and turns
257
+ `unicorn/no-array-sort` off. If your config carries either by hand, delete it, and pass
258
+ `assetExts` for what your `metro.config.js` adds. An asset `require()` is now allowed only in the
259
+ files you hand to `expo()`. If you allowed it everywhere before, a `require()` of an asset outside
260
+ those files reports.
261
+
262
+ | You write by hand today | 0.5.0 gives you |
263
+ | ------------------------------------------------------------------------ | --------------------------------------------------- |
264
+ | The same `no-restricted-imports` list in every override, minus one entry | `restrictedImports({ paths, patterns, scopes })` |
265
+ | `no-runtime-typeof` off for your decoder modules | `decoders(files)`, which also names them in the fix |
266
+ | `react/capitalized-calls` off, `react/style-prop-object` for `StatusBar` | `expo(files, { reactFiles })` |
267
+ | `react/exhaustive-deps` off for the `useEffectAllowedIn` file | `react()` does it |
268
+
269
+ Measured on rummy, 2026-09-19: `vite.config.ts` went from 447 lines to 366. 132 planted findings
270
+ across ten import scopes came out identical, position for position, before and after. The one
271
+ difference is the asset rule, which moved from warn to error and stopped allowing assets outside
272
+ the phone's files.
273
+
274
+ ## Moving from 0.3
275
+
276
+ 0.4.0 moved the rules' health out of the consumer. Everything below is something you delete, and
277
+ nothing replaces it.
278
+
279
+ | Delete | Why it is gone |
280
+ | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
281
+ | `@oxlint/plugins` from your `package.json` | An ordinary dependency here now. Nothing left to pin, and nothing to move on a `vp` upgrade. |
282
+ | `plugins: [...]` from `lint` | `recommended` carries the set its rules need. Keep yours only if you add a plugin of your own. |
283
+ | `categories: {...}` from `lint` | `recommended` carries `correctness: "error"`, `suspicious: "warn"`, `perf: "warn"`. |
284
+ | `typeAware: true` from `lint.options` | `recommended` carries it, and `options` merges key by key, so your `typeCheck` still applies. |
285
+ | `fixtures()` from your imports and your overrides | The verify command writes its own config now. The builder no longer exists. |
286
+ | Any comment about keeping `scripts/.lint-fixtures` un-ignored | The check runs under your system temp directory, where nothing is ignored. |
287
+ | Your `verify:lint-fixtures` script, if it passed arguments | `rdjperron-lint-verify` takes none. |
288
+
289
+ Two exports also went: `@rdjperron/lint/verify` (`verifySuites`, `packageSuite`,
290
+ `verifyPluginApiPin`) and `@rdjperron/lint/fixtures/*`. The fixtures still ship, the bin reads them
291
+ itself, and a repo with a second plugin to check now runs that plugin's own command beside this one.
292
+
293
+ New in 0.4.0: `oxc/no-accumulating-spread` at error, measured at zero hits on three repos. If it
294
+ fires on yours, that reducer is quadratic.