exadev-eslint-config 2.27.0 → 2.29.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 +202 -5
- package/dist/index.cjs +1171 -109
- package/dist/index.d.cts +31 -1
- package/dist/index.d.ts +31 -1
- package/dist/index.js +1172 -111
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -82,7 +82,6 @@ export default defineConfig(
|
|
|
82
82
|
- **[`prefer-readonly`](https://typescript-eslint.io/rules/prefer-readonly/)**, **[`promise-function-async`](https://typescript-eslint.io/rules/promise-function-async/)**, **[`require-array-sort-compare`](https://typescript-eslint.io/rules/require-array-sort-compare/)** — plain presence, no extra config.
|
|
83
83
|
- **[`strict-void-return`](https://typescript-eslint.io/rules/strict-void-return/)** — bans passing a value-returning function where a void-returning one is expected (e.g. `arr.forEach(x => otherArray.push(x))`).
|
|
84
84
|
- *Why:* not yet in any typescript-eslint preset; this typechecks today only because of TS's own void-return contravariance leniency.
|
|
85
|
-
- **[`switch-exhaustiveness-check`](https://typescript-eslint.io/rules/switch-exhaustiveness-check/)** — plain presence, no extra config.
|
|
86
85
|
- **[`strict-boolean-expressions`](https://typescript-eslint.io/rules/strict-boolean-expressions/)** at the rule's own bare defaults.
|
|
87
86
|
- *Why:* an unambiguous non-nullable truthy check stays allowed; an ambiguous nullable check does not.
|
|
88
87
|
- **[`no-magic-numbers`](https://typescript-eslint.io/rules/no-magic-numbers/)** — tuned to exempt array indexes, enum members, readonly class properties, default parameter values, numeric literal types (e.g. `type Indent = 2 | 4`), and the handful of universally-idiomatic bare numbers (`-1`, `0`, `1`, `2`).
|
|
@@ -92,6 +91,11 @@ export default defineConfig(
|
|
|
92
91
|
- *Why:* a deliberately loose backstop against a genuinely excessive number of REQUIRED parameters, distinct from `exadev/prefer-options-object-param` above, which already offers a real fix for the more common shape (a run of 2+ trailing OPTIONAL parameters) this rule structurally cannot see until the total count crosses its own threshold.
|
|
93
92
|
- **[`no-warning-comments`](https://eslint.org/docs/latest/rules/no-warning-comments)** — bans any comment containing `Stryker disable`.
|
|
94
93
|
- *Why:* that's Stryker's own mutation-testing suppression directive, invisible to `noInlineConfig` above since it isn't an eslint-disable comment.
|
|
94
|
+
- **[`explicit-module-boundary-types`](https://typescript-eslint.io/rules/explicit-module-boundary-types/)**: an exported function or class member writes out its parameter and return types.
|
|
95
|
+
- *Why:* an inferred return type changes silently when the body does; `isolatedDeclarations` is the compiler-side alternative and is a tsconfig matter.
|
|
96
|
+
- **[`switch-exhaustiveness-check`](https://typescript-eslint.io/rules/switch-exhaustiveness-check/)** set to `{ considerDefaultExhaustiveForUnions: false, requireDefaultForNonUnion: true }`.
|
|
97
|
+
- *Why:* a `default` branch cannot stand in for handling a newly added union member, and a switch over a non-union needs a `default` because the checker cannot prove its cases complete.
|
|
98
|
+
- **Core robustness rules**: `eqeqeq`, `no-implicit-coercion`, `no-param-reassign`, `no-await-in-loop`, `require-atomic-updates`, `default-case-last`, `no-return-assign` and `max-depth`. See [Robustness rules](#robustness-rules).
|
|
95
99
|
|
|
96
100
|
**Test files** (`**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`) get two narrow relaxations of this package's own additions, and only these two:
|
|
97
101
|
|
|
@@ -102,6 +106,30 @@ export default defineConfig(
|
|
|
102
106
|
|
|
103
107
|
Nothing else inherited from the presets is relaxed.
|
|
104
108
|
|
|
109
|
+
### Robustness rules
|
|
110
|
+
|
|
111
|
+
Core ESLint rules that nothing in the typescript-eslint presets enables. All need no type information, so `plugin.configs.recommended` carries them as well, with the same settings except `no-implicit-coercion` (below).
|
|
112
|
+
|
|
113
|
+
| Rule | Setting | Why |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| [`eqeqeq`](https://eslint.org/docs/latest/rules/eqeqeq) | `always` | `== null` hides which of `null` and `undefined` the code means, so there is no `null` carve-out. |
|
|
116
|
+
| [`no-implicit-coercion`](https://eslint.org/docs/latest/rules/no-implicit-coercion) | `{ boolean: false }` in the default export, defaults in `plugin.configs.recommended` | `!!value` is allowed where `strict-boolean-expressions` backs it, because that rule already rejects `!` on an operand whose truthiness is ambiguous (a nullable string or number). `plugin.configs.recommended` registers no typescript-eslint rules, so it keeps banning `!!`. `+value`, `'' + value` and similar stay banned in both. |
|
|
117
|
+
| [`no-param-reassign`](https://eslint.org/docs/latest/rules/no-param-reassign) | `{ props: true }` | A parameter is the caller's value; assigning to it or to one of its properties is a hidden side effect. |
|
|
118
|
+
| [`no-await-in-loop`](https://eslint.org/docs/latest/rules/no-await-in-loop) | on | Iterations that could run together run one after another. `for await` is allowed. |
|
|
119
|
+
| [`require-atomic-updates`](https://eslint.org/docs/latest/rules/require-atomic-updates) | on | `total += await p` reads `total` before the `await` and writes it after, losing any update made in between. |
|
|
120
|
+
| [`default-case-last`](https://eslint.org/docs/latest/rules/default-case-last) | on | A `default` that is not last hides the cases written after it. |
|
|
121
|
+
| [`no-return-assign`](https://eslint.org/docs/latest/rules/no-return-assign) | `always` | Even a parenthesised assignment cannot be the returned value. |
|
|
122
|
+
| [`max-depth`](https://eslint.org/docs/latest/rules/max-depth) | `{ max: 4 }` | The rule's own default, written out so a change to it cannot loosen the limit. |
|
|
123
|
+
|
|
124
|
+
A loop that is sequential by design (migrations that must run in order, a rate-limited API) is exempted by a `files`-scoped override placed after the shared config, never by an inline disable comment, since `noInlineConfig` is on:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
export default defineConfig(...exadevConfig(), {
|
|
128
|
+
files: ['src/migrations/**/*.ts'],
|
|
129
|
+
rules: { 'no-await-in-loop': 'off' },
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
105
133
|
## The lighter option: the `plugin` named export
|
|
106
134
|
|
|
107
135
|
For a project that wants only this package's own rules without the full type-checked bundle, import the named `plugin` export and wire rules individually:
|
|
@@ -134,7 +162,7 @@ export default defineConfig([
|
|
|
134
162
|
{
|
|
135
163
|
files: ['**/*.ts'],
|
|
136
164
|
plugins: { exadev: plugin },
|
|
137
|
-
extends: ['exadev/recommended'], // this plugin's own non-type-aware rules, plus linterOptions.noInlineConfig — no type-checked rules at all
|
|
165
|
+
extends: ['exadev/recommended'], // this plugin's own non-type-aware rules, the core robustness rules, plus linterOptions.noInlineConfig — no type-checked rules at all
|
|
138
166
|
// or: extends: ['exadev/barrel'], // just the barrel-discipline trio (no-non-barrel-index, no-non-barrel-reexport, no-side-effects-in-index)
|
|
139
167
|
},
|
|
140
168
|
]);
|
|
@@ -172,6 +200,7 @@ export default tseslint.config(
|
|
|
172
200
|
| [Pure modules](#pure-modules) | Off unless given (only a repository can say which modules are a functional core) | `exadevConfig({ pureModules })` / `pureModulesConfig(options)` |
|
|
173
201
|
| [Guard and conformance test hygiene](#guard-and-conformance-test-hygiene) | Off unless given (only a repository can say which tests are guards); needs the optional peer `@vitest/eslint-plugin` | `exadevConfig({ testHygiene })` / `testHygieneConfig(options)` |
|
|
174
202
|
| [Turbo environment variable checking](#environment-variables-read-in-source) | Auto-detected: on if `eslint-plugin-turbo` is installed | `exadevConfig({ turboEnv })` |
|
|
203
|
+
| [Required Markdown headings](#required-markdown-headings) | Off unless given (only a repository can say which documents need which headings); needs the optional peer `@eslint/markdown` | `exadevConfig({ markdownHeadings })` / `markdownHeadingsConfig(options)` |
|
|
175
204
|
|
|
176
205
|
Every tri-state option above (`true`/`false`/`undefined`) is passed through the named `exadevConfig(options, ...userConfigs)` factory export:
|
|
177
206
|
|
|
@@ -203,7 +232,7 @@ React/hooks/a11y and Next.js rule blocks fold in automatically, with no separate
|
|
|
203
232
|
pnpm add -D @next/eslint-plugin-next # Next.js support
|
|
204
233
|
```
|
|
205
234
|
If none of these resolve, `@exadev/eslint-config`'s default export is byte-for-byte identical to the plain TypeScript ruleset — nothing about the base package changes.
|
|
206
|
-
2. **For React specifically, the file must actually be `.jsx`/`.tsx`.** The React/hooks/a11y rule block is scoped to `files: ['**/*.jsx', '**/*.tsx']`, so even if `eslint-plugin-react` is resolvable only incidentally (e.g. hoisted as a transitive dependency of something unrelated in a monorepo, with zero real JSX anywhere in the linted project), its rules never match a file that isn't JSX. `@next/eslint-plugin-next`'s block carries no such glob: its own presence is already an unambiguous signal (nothing installs it except a real Next.js project).
|
|
235
|
+
2. **For React specifically, the file must actually be `.jsx`/`.tsx`.** The React/hooks/a11y rule block is scoped to `files: ['**/*.jsx', '**/*.tsx']`, so even if `eslint-plugin-react` is resolvable only incidentally (e.g. hoisted as a transitive dependency of something unrelated in a monorepo, with zero real JSX anywhere in the linted project), its rules never match a file that isn't JSX. `@next/eslint-plugin-next`'s own block carries no such glob: its own presence is already an unambiguous signal (nothing installs it except a real Next.js project). The [server component boundary](#server-component-boundary) rules added beside it are scoped to the same JSX files.
|
|
207
236
|
|
|
208
237
|
React support pairs `eslint-plugin-react`'s `flat/recommended` with its own `flat/jsx-runtime` config, turning [`react/react-in-jsx-scope`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/react-in-jsx-scope.md) and [`react/jsx-uses-react`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-uses-react.md) back off. `flat/recommended` alone assumes the classic runtime, where every file using JSX needs `import React` in scope; the automatic JSX runtime (the default since React 17, and the only mode Next.js's own compiler supports) needs no such import. Without this pairing, a consumer on the automatic runtime would see `react/react-in-jsx-scope` fire on every JSX file.
|
|
209
238
|
|
|
@@ -238,6 +267,34 @@ React support pairs `eslint-plugin-react`'s `flat/recommended` with its own `fla
|
|
|
238
267
|
|
|
239
268
|
**Compatibility note:** a consumer who already has `eslint-plugin-react`/`@next/eslint-plugin-next` resolvable for unrelated reasons (e.g. hoisted in a monorepo) and writes `.jsx`/`.tsx` files may see new rule activity the moment they upgrade to a version of this package that ships React/Next.js support, with zero action on their part. This is the normal, widely-accepted ESLint-ecosystem convention that adding rules to a shared/recommended config is a minor bump even though it can newly trip an existing `--max-warnings 0` gate — not a breaking change. Use the `react`/`nextjs` options above to force it off explicitly if needed.
|
|
240
269
|
|
|
270
|
+
#### Server component boundary
|
|
271
|
+
|
|
272
|
+
Props a server component passes to a client component must be serialisable, and two easy mistakes surface only at runtime in a production build. The Next.js preset (auto-detected, `exadevConfig({ nextjs: true })` or `plugin.configs.nextjs`) therefore adds a block for `.jsx` and `.tsx` files enabling two rules. Both apply only to a file without a top-level `"use client"` directive, so a client file is never reported; neither needs type information.
|
|
273
|
+
|
|
274
|
+
- **`exadev/no-non-serialisable-server-prop`** reports a JSX attribute named in `names` (default `['component']`) whose value is not data. Data is a literal (not a regular expression), a template literal, object or array of data, `undefined`, a unary expression (`-x`, `!x`, `typeof x`, which always yields a primitive), or a JSX element, which React serialises. An identifier, member access, call, function, class, spread, or object member holding a function is reported, since it may be a function or component reference. Passing `component={Icon}` to a client component fails; passing a string key or `component={<Icon />}` does not.
|
|
275
|
+
- **`exadev/no-external-member-jsx-tag`** reports every member tag (`<Lib.Icon>`, `<Lib.Icons.Home>`) whose root identifier is bound by an import from a non-relative source, whether a namespace, default, named or `import x = require()` binding. Whether a library attaches statics after export cannot be worked out from one file, so the rule is deliberately over-broad. A relative import is the project's own code and is not reported; an alias to project code such as `@/ui` looks like a package, so list it in `allowSources`.
|
|
276
|
+
|
|
277
|
+
Both are approximations. They report a file without the directive that is only ever imported by client code (add the directive), a server-safe `<Ctx.Provider>` from an imported context (list it in `allowTags`), and a component passed to an element that is itself a server component (list the element in `allowElements`). Options, by rule:
|
|
278
|
+
|
|
279
|
+
| Rule | Option | Meaning |
|
|
280
|
+
| --- | --- | --- |
|
|
281
|
+
| `no-non-serialisable-server-prop` | `names` | Prop names whose value must be data. Replaces the default `['component']`. |
|
|
282
|
+
| `no-non-serialisable-server-prop` | `allowElements` | JSX element names as written (`Link`, `Lib.Icon`) whose props are not checked. |
|
|
283
|
+
| `no-external-member-jsx-tag` | `allowSources` | Import specifiers, in the [specifier pattern](#specifier-patterns) dialect (each also selects everything beneath it), whose member tags are not reported. |
|
|
284
|
+
| `no-external-member-jsx-tag` | `allowTags` | Tags as written (`Ctx.Provider`) that are not reported. |
|
|
285
|
+
|
|
286
|
+
The preset enables both at `error` with the defaults. To tune them, add a later block that sets the rule again for the same files:
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
{
|
|
290
|
+
files: ['**/*.tsx'],
|
|
291
|
+
rules: {
|
|
292
|
+
'exadev/no-non-serialisable-server-prop': ['error', { names: ['component', 'icon'], allowElements: ['Link'] }],
|
|
293
|
+
'exadev/no-external-member-jsx-tag': ['error', { allowSources: ['@/ui'], allowTags: ['ThemeCtx.Provider'] }],
|
|
294
|
+
},
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
241
298
|
### Gitignore-derived ignores
|
|
242
299
|
|
|
243
300
|
`exadevConfig()`'s default output includes an `ignores` block derived directly from your project's own `.gitignore` (via [`@eslint/config-helpers`](https://www.npmjs.com/package/@eslint/config-helpers)'s `includeIgnoreFile`), so a generated directory your `.gitignore` already knows about (`dist/`, `coverage/`, a tool's own report output) is never linted, without hand-duplicating that list in `eslint.config.ts` too. This closes a real gap: a `.gitignore`d directory that nothing previously linted broadly enough to reach could still get linted the moment a wide-reaching rule (this package's own bundled RFC 8785 JSON canonicalization, say) started matching every file its glob covers.
|
|
@@ -343,6 +400,12 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
|
|
|
343
400
|
| `scoped-first-parameter` | | **Every method of the configured repository-like interfaces takes a scope parameter first.** Checks the signature only, not that the scope is used or that tenants are isolated. Requires type information. Opt-in: needs its `interfaces` and `parameter` options. See [Scoped first parameter](#scoped-first-parameter). |
|
|
344
401
|
| `injected-test-hygiene` | | **A conformance kit's injected test functions get the same hygiene as imported ones:** no `.only`, no `.skip`, and an assertion in every test body, for the `describe` and `it` a kit receives as parameters, which `@vitest/eslint-plugin` skips. Wired by `testHygieneConfig`. See [Guard and conformance test hygiene](#guard-and-conformance-test-hygiene). |
|
|
345
402
|
| `non-vacuous-guard` | | **A guard test must show it can fail:** an unconditional lower bound on what it discovered, and its pattern checked against an input it must catch and one it must not. Wired by `testHygieneConfig`. See [Guard and conformance test hygiene](#guard-and-conformance-test-hygiene). |
|
|
403
|
+
| `no-multiline-template-literal` | ✓ | **An untagged template literal whose value spans several lines should be an array of lines joined with `\n`.** Autofixes only when the rewrite provably yields the same string. See [Multi-line template literals](#multi-line-template-literals). |
|
|
404
|
+
| `markdown-required-heading` | | **A Markdown document must contain each configured heading:** a `{ depth, text }` per required heading, matched on the text as it renders. A Markdown-language rule (`@eslint/markdown`). Wired by `markdownHeadingsConfig`. See [Required Markdown headings](#required-markdown-headings). |
|
|
405
|
+
| `no-defensive-fallback` | | **An empty-literal fallback hides a value that should have been modelled as absent.** Reports `value ?? []`, `value || ''` and the logical-assignment forms for an empty array, object or string, `0`, `false` or `null`, and a `catch` clause or `.catch()` handler that discards the error and returns nothing or a fixed value. Opt-in, not part of `recommended` or the default export. Needs no type information. See [Defensive fallbacks](#defensive-fallbacks). |
|
|
406
|
+
| `timeout-aborts-request` | | **A `Promise.race` timeout must abort the request it raced against,** not only settle the race: the timer callback calls `.abort()` on an `AbortController` created in the same function, the timer id is cleared in a `finally`, and a `catch` returns early on `controller.signal.aborted`. Opt-in, needs no type information. See [Timeout races](#timeout-races). |
|
|
407
|
+
| `no-non-serialisable-server-prop` | | **A configured prop (default `component`) of a JSX element in a file without `"use client"` must be serialisable data.** A function or component reference cannot cross from a server component to a client component. Enabled by the Next.js preset. See [Server component boundary](#server-component-boundary). |
|
|
408
|
+
| `no-external-member-jsx-tag` | | **A member tag (`<Lib.Icon>`) rooted at an import from a package, in a file without `"use client"`, is reported.** Statics a library attaches after export may not survive the server component boundary. Enabled by the Next.js preset. See [Server component boundary](#server-component-boundary). |
|
|
346
409
|
|
|
347
410
|
## Barrel policy
|
|
348
411
|
|
|
@@ -601,6 +664,135 @@ An entry applies to files matching `files` (and, with `overLines`, to those with
|
|
|
601
664
|
|
|
602
665
|
Why not a dependency: [`eslint-plugin-check-file`](https://github.com/dukeluo/eslint-plugin-check-file) (`filename-naming-convention`, `folder-match-with-fex`, `filename-blocklist`, `folder-naming-convention`, `no-index`) and [`unicorn/filename-case`](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/filename-case.md) cover case styles and per-glob name patterns, and check-file also folder names and blocked names. Neither can express that another file must exist beside the linted one, or that a name depends on the file's line count, which are the two cases this rule exists for. The name-pattern case is the same as what check-file offers, so a project that already uses that plugin for case styles and folder layout can keep it alongside this rule.
|
|
603
666
|
|
|
667
|
+
## Multi-line template literals
|
|
668
|
+
|
|
669
|
+
`exadev/no-multiline-template-literal` reports an untagged template literal whose value contains a line feed, whether from a line break in the source or an `\n` escape, and rewrites it to an array of lines:
|
|
670
|
+
|
|
671
|
+
```ts
|
|
672
|
+
const prompt = `You are a reviewer.
|
|
673
|
+
Reply in ${language}.`;
|
|
674
|
+
|
|
675
|
+
// becomes
|
|
676
|
+
const prompt = [
|
|
677
|
+
'You are a reviewer.',
|
|
678
|
+
`Reply in ${language}.`
|
|
679
|
+
].join('\n');
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
A line with no substitution becomes a single-quoted string and a line with one stays a template literal on a single line. Substitutions are copied verbatim from the source, comments included, so their evaluation order and text do not change. The fix works on the template's raw source rather than its value: every escape carries over unchanged, since an escape means the same in a single-quoted string as in a template, and only an unescaped `'`, an escaped backtick and an escaped `$` are re-spelled for the literal that holds them. The array is indented one level deeper than the line the template starts on, using that line's own indentation style. Run the project's quote and trailing-comma rules afterwards if it prefers other styles.
|
|
683
|
+
|
|
684
|
+
The rule reports without offering a fix when it cannot show that the pieces join back to exactly the same value: a line continuation (a backslash before a line break), a carriage return or Unicode line separator in the source, a `\x` or `\u` escape that spells a line feed, or an octal-looking escape. A final check compares the line breaks it found with the line feeds in the cooked value, so a case the scanner mis-reads is refused rather than rewritten.
|
|
685
|
+
|
|
686
|
+
It also reports without a fix when the template sits where TypeScript needs its literal type, which the joined string (a plain `string`) does not have: under `as const`, `as`, `satisfies` or a type assertion (also through an enclosing array or object), as a string enum member's initialiser, or as the initialiser of a declaration with a type annotation. Without type information it cannot see the contextual type of a call argument or a return value, so those are still fixed.
|
|
687
|
+
|
|
688
|
+
A tagged template is never reported. The tag receives the literal's pieces, so joining them would change what it is called with, and multi-line `sql`, `css` or `markdown` templates are the point of the syntax. There is therefore no option listing tags to ignore. `allowFiles` takes globs in the [file glob dialect](#file-level-rules) for files whose multi-line strings are intended (fixtures, prompts):
|
|
689
|
+
|
|
690
|
+
```ts
|
|
691
|
+
'exadev/no-multiline-template-literal': ['error', { allowFiles: ['src/prompts/**', 'test/fixtures/**'] }]
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
The rule needs no type information and is not in `recommended`.
|
|
695
|
+
|
|
696
|
+
## Required Markdown headings
|
|
697
|
+
|
|
698
|
+
A documentation convention such as "every skill file has a `## Usage` heading" is otherwise enforced by a remark pipeline or by nothing. `@eslint/markdown` lets an ESLint rule visit the same heading nodes, so the check runs in the same lint as everything else. `markdownHeadingsConfig` (or `exadevConfig({ markdownHeadings })`) wires `exadev/markdown-required-heading` onto the Markdown files you name:
|
|
699
|
+
|
|
700
|
+
```ts
|
|
701
|
+
import { defineConfig } from 'eslint/config';
|
|
702
|
+
import { exadevConfig, markdownHeadingsConfig } from '@exadev/eslint-config';
|
|
703
|
+
|
|
704
|
+
export default defineConfig(
|
|
705
|
+
...exadevConfig(),
|
|
706
|
+
...markdownHeadingsConfig({
|
|
707
|
+
files: ['skills/**/SKILL.md'],
|
|
708
|
+
headings: [
|
|
709
|
+
{ depth: 2, text: 'Usage' },
|
|
710
|
+
{ depth: 2, text: 'Gotchas' },
|
|
711
|
+
],
|
|
712
|
+
}),
|
|
713
|
+
);
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
The preset needs `@eslint/markdown`, an optional peer (`pnpm add -D @eslint/markdown`), and throws with the install command when it cannot be resolved. It registers the plugin under `markdown` and sets the language to `markdown/gfm`. `files` follows the [file glob dialect](#file-level-rules) with a leading `!` excluding. Whether ESLint lints `**/*.md` at all is a repository-wide decision the preset cannot make: a `.gitignore`-derived or other `ignores` entry that hides those files still hides them.
|
|
717
|
+
|
|
718
|
+
Each entry is a `depth` from 1 to 6 (`#` to `######`) and the `text` of the heading. A document is missing an entry unless some heading of that depth has that text once the heading is flattened from its inline children and read the way a renderer shows it: emphasis, strong, links and code spans contribute their text, an image its alternative text, a hard break a space, and raw inline HTML nothing. The text is trimmed and each run of whitespace collapses to one space, on both sides, so a two-line Setext heading equals its one-line spelling. Matching is otherwise exact, case included. A heading anywhere in the document counts, inside a blockquote or a list as well. Each missing entry is reported once, at the top of the file, after the whole document has been read.
|
|
719
|
+
|
|
720
|
+
The entries are a list rather than a single `{ depth, text }` because flat config replaces a rule's options when a later block sets the same rule for the same files, so a second required heading could not be added by enabling the rule again.
|
|
721
|
+
|
|
722
|
+
Frontmatter is parsed as a node of its own (`yaml` by default, or `frontmatter: 'toml'` or `'json'`). Without that, a leading `---` block is read as a thematic break followed by a Setext heading made of the block's first line, which could satisfy or spoil a check; the preset always sets it. A repository that wires the rule by hand sets `languageOptions: { frontmatter: 'yaml' }` on its own block for the same reason. The frontmatter is never treated as a heading, so `title: Usage` does not satisfy a required `Usage`.
|
|
723
|
+
|
|
724
|
+
## Timeout races
|
|
725
|
+
|
|
726
|
+
A timeout that settles a `Promise.race` but leaves the request it raced against running is a leak: the caller moves on while the provider call keeps consuming budget and connections. `exadev/timeout-aborts-request` requires the shape that both settles the race and cancels the work:
|
|
727
|
+
|
|
728
|
+
```ts
|
|
729
|
+
async function complete(url: string): Promise<Response | undefined> {
|
|
730
|
+
const controller = new AbortController();
|
|
731
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
732
|
+
try {
|
|
733
|
+
return await Promise.race([
|
|
734
|
+
fetch(url, { signal: controller.signal }),
|
|
735
|
+
new Promise<never>((_, reject) => {
|
|
736
|
+
timer = setTimeout(() => {
|
|
737
|
+
controller.abort();
|
|
738
|
+
reject(new Error('timed out'));
|
|
739
|
+
}, TIMEOUT_MS);
|
|
740
|
+
}),
|
|
741
|
+
]);
|
|
742
|
+
} catch (error) {
|
|
743
|
+
if (controller.signal.aborted) return undefined;
|
|
744
|
+
throw error;
|
|
745
|
+
} finally {
|
|
746
|
+
clearTimeout(timer);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
For each `Promise.race([...])` with an arm that is a `new Promise(executor)` whose executor calls `setTimeout` (written in place or named by a `const` first), the rule reports:
|
|
752
|
+
|
|
753
|
+
- a timer callback that contains no `.abort()` call, including a callback it cannot read (an imported function, a bound method). Nothing aborts the request, so it keeps running after the race settles;
|
|
754
|
+
- an `.abort()` whose receiver is not a local `const controller = new AbortController()` of the function holding the race: a controller created at module level, in an outer function, received as a parameter or read off `this` is shared with other races or belongs to the caller, so aborting it cancels more than this request;
|
|
755
|
+
- a timer whose id is not kept in a variable and passed to `clearTimeout` in a `finally` around the race (a `try` statement's `finally`, or a `.finally(callback)` chained onto the race). A request that wins otherwise leaves the timer pending. A `try` counts only when it awaits the race, directly, through a type assertion or a chained `.then`, or through a `const` the race is stored in and later awaited inside the same block: a race that is returned or assigned without an await lets the `finally` run at once, clearing the timer before it can fire;
|
|
756
|
+
- a `catch` around the race (a `catch` clause, or a `.catch(callback)` chained onto it) that has no `if` testing `controller.signal.aborted` with a `return` in its consequent, so a timeout is handled apart from a failure of the request. The test must be `controller.signal.aborted` itself or an `&&` with that as one operand; its negation, or an `||`, is true without an abort. A `try` statement's `catch` counts only when the `try` block awaits the race, for the same reason as the `finally`;
|
|
757
|
+
- a controller that no other arm of the race refers to (directly or through a `const` the arm names), such as a request started with `other.signal`. The timer would abort a controller the raced request never received. The rule checks that the controller appears in the request's arm, not that a particular call forwards its signal to the underlying request.
|
|
758
|
+
|
|
759
|
+
The rule is syntactic and reads one function. It cannot see that a helper the race calls forwards `controller.signal` to the request, which is cross-file, and it does not check that a particular SDK call receives the signal, which is vendor-specific and belongs in the [import policy](#import-policy) or a recipe. A race over work that cannot be cancelled is a false positive; the escape hatch is a scoped block that turns the rule off for those files:
|
|
760
|
+
|
|
761
|
+
```ts
|
|
762
|
+
{ files: ['src/legacy/**'], rules: { 'exadev/timeout-aborts-request': 'off' } }
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
Off unless enabled (`'exadev/timeout-aborts-request': 'error'` with the plugin registered), and not in `recommended`, since a project chooses to adopt the shape.
|
|
766
|
+
|
|
767
|
+
## Defensive fallbacks
|
|
768
|
+
|
|
769
|
+
A fallback on a value that really can be absent turns the absence into a plausible answer, and the wrong result surfaces far from the cause. `@typescript-eslint/no-unnecessary-condition` reports only the fallbacks the types already prove unnecessary; `exadev/no-defensive-fallback` reports the ones they do not, by shape alone:
|
|
770
|
+
|
|
771
|
+
- a `??` or `||` (or `??=` / `||=`) whose right operand is an empty array, empty object, empty string (`''`, `""` or an empty template), `0`, `0n`, `false` or `null`, including one behind `as`, `satisfies` or `<T>`;
|
|
772
|
+
- a `catch` clause whose body is empty (a comment-only body included), or holds one `continue` or one `return` that gives back nothing or a fixed value: a literal, `undefined`, `void 0`, or an array or object built only from those;
|
|
773
|
+
- a `.catch(handler)` or `.then(onFulfilled, handler)` whose rejection handler does the same, as an expression-bodied arrow or a function body.
|
|
774
|
+
|
|
775
|
+
A catch that does anything else with the error (logs it, rethrows it, returns something computed from it) is not reported. The fix is to model absence as `T | undefined` and handle it where it is meaningful, or to let the failure reach the caller.
|
|
776
|
+
|
|
777
|
+
It is opt-in: it is in neither `plugin.configs.recommended` nor the default export, because a configuration reader legitimately treats a missing optional field as empty, and that boundary differs per repository. `allow` lists the files where the fallback is at such a boundary, each with a `reason` (required, so the exemption explains itself where it is configured, since `noInlineConfig` leaves no comment to do so). `files` follows the [file glob dialect](#file-level-rules), so a glob without a `/` names a file at any depth.
|
|
778
|
+
|
|
779
|
+
Two shapes are reported although they are sometimes right, and the fix for both is an `allow` entry. A predicate that turns a failure into `false` (`try { JSON.parse(text); return true; } catch { return false; }`) is reported as a catch that returns a fixed value. `.catch` is matched on any receiver, so a schema fallback such as Zod's `z.string().catch('')` is reported as a promise handler when it is written as a function returning a fixed value.
|
|
780
|
+
|
|
781
|
+
```ts
|
|
782
|
+
export default defineConfig(...exadevConfig(), {
|
|
783
|
+
files: ['src/**/*.ts'],
|
|
784
|
+
plugins: { exadev: plugin },
|
|
785
|
+
rules: {
|
|
786
|
+
'exadev/no-defensive-fallback': [
|
|
787
|
+
'error',
|
|
788
|
+
{ allow: [{ files: ['src/config/**'], reason: 'optional settings default to empty at the parsing boundary' }] },
|
|
789
|
+
],
|
|
790
|
+
},
|
|
791
|
+
});
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
Overlap: core `no-empty` already reports a catch block with no statements and no comment; this rule also reports the comment-only block, which `no-empty` accepts.
|
|
795
|
+
|
|
604
796
|
## Workspace architecture
|
|
605
797
|
|
|
606
798
|
Six rules (`no-uphill-dependency`, `no-dependency-cycle`, `package-name-mirrors-path`, `package-has-files`, `dev-dependency-only`, `required-scripts`) share one options object, `WorkspaceArchitectureOptions`, describing a pnpm workspace's own dependency-direction rules (which package is allowed to depend on which other, checked directly against every `package.json`'s declared `dependencies`) and the per-package requirements layered on the same groups. The last four rules are opt-in within it: each does nothing unless its own option is given. Off by default, since `groups` has no sensible default; enable it either through `exadevConfig({ workspaceArchitecture })` (the full bundle) or the standalone `workspaceArchitectureConfig(options)` export (for a consumer building its own config from the lighter `plugin` export, e.g. one that isn't using `exadevConfig()` at all):
|
|
@@ -976,7 +1168,7 @@ pnpm build
|
|
|
976
1168
|
- `buildOptionalPluginConfig` is the builder shared by the presets that fold in a single plugin's flat config ([`src/nextjs.ts`](src/nextjs.ts), [`src/turbo-env.ts`](src/turbo-env.ts)): the package, the config path, the feature name for the error and an optional `files` scope go in, and the tri-state `enabled` behaviour comes out the same for each.
|
|
977
1169
|
- [`src/react.ts`](src/react.ts)/[`src/nextjs.ts`](src/nextjs.ts) each export a `build*Config(options)` function: resolve the relevant optional peer(s) via `tryRequire`, extract their real flat config via `readFlatConfig`, and return an array of 0-or-more config blocks.
|
|
978
1170
|
- `[]` if unresolvable and not explicitly forced on; a thrown `Error` if explicitly forced on (`enabled: true`) and still unresolvable.
|
|
979
|
-
- `react.ts`'s blocks are scoped to `files: ['**/*.jsx', '**/*.tsx']`; `nextjs.ts`'s is not (see [Optional React and Next.js support](#optional-react-and-nextjs-support) for why).
|
|
1171
|
+
- `react.ts`'s blocks are scoped to `files: ['**/*.jsx', '**/*.tsx']`; `nextjs.ts`'s upstream block is not (see [Optional React and Next.js support](#optional-react-and-nextjs-support) for why). `buildNextjsConfig` also takes this package's own plugin and adds the [server component boundary](#server-component-boundary) rule block after the upstream config, only when that resolves; the plugin is a parameter because `plugin.ts` builds `plugin.configs.nextjs` from `nextjs.ts`, and importing it back would make the two modules import each other.
|
|
980
1172
|
- [`src/json-language-config.ts`](src/json-language-config.ts) is the shared groundwork for every rule that lints JSON files through `@eslint/json`, an optional peer.
|
|
981
1173
|
- `tryResolveJsonPlugin` and `requireJsonPlugin` are the silent and the throwing resolution paths, the second naming the feature that needs the peer and the install command.
|
|
982
1174
|
- `buildJsonLanguageBlock` assembles the `exadev` and `json` plugin registration for a `files` list under `json/json` or `json/jsonc`; the `json/jsonc` form also admits trailing commas, matching the JSONC config in [`src/json-canonical.ts`](src/json-canonical.ts). `JSONC_FILE_GLOBS` (`tsconfig*.json`, `turbo.json`, `*.jsonc`) lists the files that carry comments, so a rule targeting them must be wired under `json/jsonc`, and a rule meant for both languages declares both in `meta.languages`.
|
|
@@ -989,28 +1181,33 @@ pnpm build
|
|
|
989
1181
|
- [`src/import-policy.ts`](src/import-policy.ts) builds the `importPolicyConfig` block from the validated policies in [`src/rules/import-policy-options.ts`](src/rules/import-policy-options.ts), which the rule reads with the same reader.
|
|
990
1182
|
- [`src/pure-modules.ts`](src/pure-modules.ts) builds the `pureModulesConfig` block: `files` becomes the block's `files` and `ignores`, and the rule options are read by [`src/rules/pure-module-options.ts`](src/rules/pure-module-options.ts), which also holds the ban lists. [`src/rules/pure-module.ts`](src/rules/pure-module.ts) reuses `moduleReferenceOf` from `import-policy.ts` so every import syntax is recognised the same way.
|
|
991
1183
|
- [`src/test-hygiene.ts`](src/test-hygiene.ts) builds the `testHygieneConfig` blocks: it resolves the optional `@vitest/eslint-plugin` through `tryRequire`, checks the three rules it relies on exist, and emits the vitest rules and `exadev/injected-test-hygiene` per list of globs, plus `exadev/non-vacuous-guard` for guard files. Tests supply a plugin through `assembleTestHygieneConfig`, so the public options carry no resolver seam. [`src/config-globs.ts`](src/config-globs.ts) turns a validated glob list into a block's `files` and `ignores`, shared with the pure-module builder.
|
|
1184
|
+
- [`src/markdown-headings.ts`](src/markdown-headings.ts) builds the `markdownHeadingsConfig` block. It resolves the optional `@eslint/markdown` through [`src/markdown-plugin.ts`](src/markdown-plugin.ts), which unwraps the ES module namespace `require()` returns the way `json-plugin.ts` does for `@eslint/json`, and fixes the language and the frontmatter option the rule depends on. [`src/rules/markdown-required-heading.ts`](src/rules/markdown-required-heading.ts) is typed against `@eslint/markdown`'s own rule definition, like the JSON rules.
|
|
992
1185
|
- [`src/rules/file-reference.ts`](src/rules/file-reference.ts) is the option shape for a rule that reads another file: `{ path, relativeTo? }`, resolved against the linted file's directory (`file`, the default) or the workspace root (`root`, found the way the workspace architecture rules find it).
|
|
993
1186
|
- `readReferencedJson` parses the target as JSONC through [`src/rules/jsonc.ts`](src/rules/jsonc.ts) (comments, trailing commas and a leading byte order mark), returning `undefined` for a missing file and throwing, naming the path, for one that does not parse.
|
|
994
1187
|
- [`src/turbo-config.ts`](src/turbo-config.ts) builds the turbo blocks (`buildTurboConfig` internally, `turboConfig` publicly) from the one shared options object in [`src/rules/turbo-options.ts`](src/rules/turbo-options.ts).
|
|
995
1188
|
- The pure decisions live in [`src/rules/turbo-checks.ts`](src/rules/turbo-checks.ts), independent of ESLint, so they are tested against plain maps; the rules are thin visitors over them. [`src/rules/turbo-json.ts`](src/rules/turbo-json.ts) reads the parts of a `turbo.json` the rules need and finds the root one, [`src/rules/turbo-workspace.ts`](src/rules/turbo-workspace.ts) lists the packages and their scripts, and [`src/rules/turbo-commands.ts`](src/rules/turbo-commands.ts) reads a script command for a delegation, the tasks it invokes, or a boundaries run.
|
|
996
1189
|
- Rules that read sibling and ancestor files take a `WorkspaceFs` through their factory, and their tests use [`src/rules/memory-fs.ts`](src/rules/memory-fs.ts), an in-memory implementation, rather than fixture trees on disk.
|
|
997
1190
|
- [`src/create-config.ts`](src/create-config.ts) is config assembly's single source of truth.
|
|
998
|
-
- `exadevConfig(options, ...userConfigs)` concatenates, in order: `buildGitignoreConfig`, `recommendedTypeChecked`, `jsdocAndTsdoc`, `jsonCanonicalConfig`, `stylisticCommentsConfig`, `buildReactConfig`, `buildNextjsConfig`, `buildPackageJsonKeyOrderConfig` (each tri-state builder fed its matching option), `buildWorkspaceArchitectureConfig` (only when `workspaceArchitecture` is given; there is no auto-detected default), `buildTurboConfig` (only when `turbo` is given, likewise), `buildImportPolicyConfig` (only when `importPolicies` is given, likewise), `buildPureModulesConfig` (only when `pureModules` is given, likewise), `buildTestHygieneConfig` (only when `testHygiene` is given, likewise), and any trailing user configs.
|
|
1191
|
+
- `exadevConfig(options, ...userConfigs)` concatenates, in order: `buildGitignoreConfig`, `recommendedTypeChecked`, `jsdocAndTsdoc`, `jsonCanonicalConfig`, `stylisticCommentsConfig`, `buildReactConfig`, `buildNextjsConfig`, `buildPackageJsonKeyOrderConfig` (each tri-state builder fed its matching option), `buildWorkspaceArchitectureConfig` (only when `workspaceArchitecture` is given; there is no auto-detected default), `buildTurboConfig` (only when `turbo` is given, likewise), `buildImportPolicyConfig` (only when `importPolicies` is given, likewise), `buildPureModulesConfig` (only when `pureModules` is given, likewise), `buildTestHygieneConfig` (only when `testHygiene` is given, likewise), `buildMarkdownHeadingsConfig` (only when `markdownHeadings` is given, likewise), and any trailing user configs.
|
|
999
1192
|
- `defaultConfig` is `exadevConfig()` evaluated once, eagerly, at module load.
|
|
1193
|
+
- [`src/robustness-rules.ts`](src/robustness-rules.ts) holds `UNTYPED_ROBUSTNESS_RULES`, the core ESLint rules that need no type information, spread into both `plugin.configs.recommended` and the type-checked block so the two cannot drift apart.
|
|
1000
1194
|
- [`src/stylistic-comments.ts`](src/stylistic-comments.ts) builds `stylisticCommentsConfig`: the hand-picked `@stylistic/eslint-plugin` rules plus this package's own `exadev/prefer-doc-comment`, in two blocks (one scoped to every JS/TS file, one scoped to JSX files only for the three JSX-specific rules). See [Stylistic comment, class-member and JSX rules](#stylistic-comment-class-member-and-jsx-rules).
|
|
1001
1195
|
- [`src/index.ts`](src/index.ts) is the entry point, still a pure re-export barrel:
|
|
1002
1196
|
```ts
|
|
1003
1197
|
export { defaultConfig as default, exadevConfig } from './create-config';
|
|
1004
1198
|
export { importPolicyConfig } from './import-policy';
|
|
1199
|
+
export { markdownHeadingsConfig } from './markdown-headings';
|
|
1005
1200
|
export { publicPlugin as plugin } from './plugin';
|
|
1006
1201
|
export { pureModulesConfig } from './pure-modules';
|
|
1007
1202
|
export { testHygieneConfig } from './test-hygiene';
|
|
1008
1203
|
export { turboConfig } from './turbo-config';
|
|
1009
1204
|
export { workspaceArchitectureConfig } from './workspace-architecture';
|
|
1205
|
+
export type { MarkdownFrontmatter, MarkdownHeadingsOptions } from './markdown-headings';
|
|
1010
1206
|
export type { PureModulesOptions } from './pure-modules';
|
|
1011
1207
|
export type { TestHygieneOptions } from './test-hygiene';
|
|
1012
1208
|
export type { ImportConfine, ImportDeny, ImportExceptEdge, ImportPolicy } from './rules/import-policy-options';
|
|
1013
1209
|
export type { FilenamePatternEntry } from './rules/filename-pattern';
|
|
1210
|
+
export type { RequiredHeading } from './rules/markdown-required-heading';
|
|
1014
1211
|
export type { RequiredExportsEntry } from './rules/required-exports';
|
|
1015
1212
|
export type { RequiredImportsEntry } from './rules/required-imports';
|
|
1016
1213
|
export type { GroupSpec, NamingOptions, RankRule, RankSkipOptions, SliceSpec, WorkspaceArchitectureOptions } from './rules/workspace-options';
|