exadev-eslint-config 2.28.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 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
  ]);
@@ -374,6 +402,7 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
374
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). |
375
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). |
376
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). |
377
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). |
378
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). |
379
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). |
@@ -735,6 +764,35 @@ The rule is syntactic and reads one function. It cannot see that a helper the ra
735
764
 
736
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.
737
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
+
738
796
  ## Workspace architecture
739
797
 
740
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):
@@ -1132,6 +1190,7 @@ pnpm build
1132
1190
  - [`src/create-config.ts`](src/create-config.ts) is config assembly's single source of truth.
1133
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.
1134
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.
1135
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).
1136
1195
  - [`src/index.ts`](src/index.ts) is the entry point, still a pure re-export barrel:
1137
1196
  ```ts
package/dist/index.cjs CHANGED
@@ -184,7 +184,7 @@ function buildGitignoreConfig(options = {}) {
184
184
  }
185
185
  //#endregion
186
186
  //#region package.json
187
- var version = "2.28.0";
187
+ var version = "2.29.0";
188
188
  //#endregion
189
189
  //#region src/is-record.ts
190
190
  /**
@@ -320,6 +320,28 @@ function toPublicPlugin(built) {
320
320
  return built;
321
321
  }
322
322
  //#endregion
323
+ //#region src/robustness-rules.ts
324
+ /**
325
+ * Core ESLint robustness rules that need neither type information nor the typescript-eslint plugin, so both `plugin.configs.recommended` and the default export enable them with identical settings. `no-implicit-coercion` is deliberately absent: its best setting depends on whether `@typescript-eslint/strict-boolean-expressions` is also on, which only the default export can say.
326
+ *
327
+ * - `eqeqeq` is `always` with no `null` carve-out: `== null` hides which of `null` and `undefined` the code means.
328
+ * - `no-param-reassign` with `props: true` covers assignment to a parameter's properties as well as the parameter itself; a parameter is the caller's value, not a local to rewrite.
329
+ * - `no-await-in-loop` assumes iterations are independent. A loop that is sequential by design is exempted by a `files`-scoped override in the consumer's config (`noInlineConfig` rules out a disable comment), never by weakening the rule globally.
330
+ * - `require-atomic-updates` catches a read-modify-write that spans an `await`, where another task can change the value in between.
331
+ * - `default-case-last` keeps `default` from hiding the cases written after it.
332
+ * - `no-return-assign` is `always`, so even a parenthesised assignment cannot be the returned value.
333
+ * - `max-depth` is stated at the rule's own default of four nested blocks so a future change to that default cannot loosen it.
334
+ */
335
+ const UNTYPED_ROBUSTNESS_RULES = {
336
+ "default-case-last": "error",
337
+ eqeqeq: ["error", "always"],
338
+ "max-depth": ["error", { max: 4 }],
339
+ "no-await-in-loop": "error",
340
+ "no-param-reassign": ["error", { props: true }],
341
+ "no-return-assign": ["error", "always"],
342
+ "require-atomic-updates": "error"
343
+ };
344
+ //#endregion
323
345
  //#region src/rules/barrel-helpers.ts
324
346
  /**
325
347
  * Reusable across rules: the basename an index file has, matching ts/tsx/js/jsx/mjs/mts/cjs/cts. Identical to no-non-barrel-index's own INDEX_BASENAME — deliberately duplicated as the single declared constant both modules import, rather than each rule re-deriving the regex.
@@ -1380,7 +1402,7 @@ function readSelector(value, optionName, context) {
1380
1402
  ...typeof namePattern === "string" && { namePattern }
1381
1403
  };
1382
1404
  }
1383
- const ALLOW_KEYS = [
1405
+ const ALLOW_KEYS$1 = [
1384
1406
  "from",
1385
1407
  "to",
1386
1408
  "reason"
@@ -1388,7 +1410,7 @@ const ALLOW_KEYS = [
1388
1410
  function readAllow(value) {
1389
1411
  const seen = /* @__PURE__ */ new Set();
1390
1412
  return readArray$1(value, "allow").map((entry) => {
1391
- const fields = readEntry$1(entry, "allow", ALLOW_KEYS);
1413
+ const fields = readEntry$1(entry, "allow", ALLOW_KEYS$1);
1392
1414
  const from = readNonEmptyString$1(fields["from"], "allow", "\"from\"");
1393
1415
  const to = readNonEmptyString$1(fields["to"], "allow", "\"to\"");
1394
1416
  const reason = readNonEmptyString$1(fields["reason"], "allow", "\"reason\" (an exception must say why it exists)");
@@ -2225,18 +2247,18 @@ function createEntryScope(globs) {
2225
2247
  }
2226
2248
  //#endregion
2227
2249
  //#region src/rules/filename-pattern.ts
2228
- const OPTION_NAME$12 = "exadev/filename-pattern";
2250
+ const OPTION_NAME$13 = "exadev/filename-pattern";
2229
2251
  /**
2230
2252
  * The placeholder in a `sibling` template that stands for the linted file's name without its final extension (`Button` for `Button.tsx`).
2231
2253
  */
2232
2254
  const SIBLING_NAME_PLACEHOLDER = "{name}";
2233
2255
  function readPattern(value) {
2234
2256
  if (value === void 0) return void 0;
2235
- if (typeof value !== "string" || value.length === 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" needs "pattern" to be a non-empty regular expression source.`);
2257
+ if (typeof value !== "string" || value.length === 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$13}" needs "pattern" to be a non-empty regular expression source.`);
2236
2258
  try {
2237
2259
  new RegExp(value, "u");
2238
2260
  } catch (error) {
2239
- throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" needs "pattern" to be a valid regular expression, but "${value}" is not: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2261
+ throw new Error(`@exadev/eslint-config: "${OPTION_NAME$13}" needs "pattern" to be a valid regular expression, but "${value}" is not: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2240
2262
  }
2241
2263
  return {
2242
2264
  source: value,
@@ -2246,32 +2268,32 @@ function readPattern(value) {
2246
2268
  const UNKNOWN_PLACEHOLDER = /\{(?!name\})[^{}]*\}/u;
2247
2269
  function readSiblings(value) {
2248
2270
  if (value === void 0) return [];
2249
- const siblings = readRequiredStrings({ sibling: typeof value === "string" ? [value] : value }, "sibling", OPTION_NAME$12);
2271
+ const siblings = readRequiredStrings({ sibling: typeof value === "string" ? [value] : value }, "sibling", OPTION_NAME$13);
2250
2272
  const unknown = siblings.find((template) => UNKNOWN_PLACEHOLDER.test(template));
2251
- if (unknown !== void 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" has a "sibling" template "${unknown}" with a placeholder other than ${SIBLING_NAME_PLACEHOLDER}.`);
2273
+ if (unknown !== void 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$13}" has a "sibling" template "${unknown}" with a placeholder other than ${SIBLING_NAME_PLACEHOLDER}.`);
2252
2274
  return siblings;
2253
2275
  }
2254
2276
  function readOverLines(value) {
2255
2277
  if (value === void 0) return void 0;
2256
- if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" needs "overLines" to be a non-negative integer.`);
2278
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$13}" needs "overLines" to be a non-negative integer.`);
2257
2279
  return value;
2258
2280
  }
2259
2281
  /**
2260
2282
  * Validates the rule's option list, compiling each entry. Throws naming the offending field, including a `pattern` that is not a valid regular expression.
2261
2283
  */
2262
2284
  function readFilenamePatternEntries(value) {
2263
- return readEntryRecords(value, OPTION_NAME$12).map((entry) => {
2285
+ return readEntryRecords(value, OPTION_NAME$13).map((entry) => {
2264
2286
  assertOnlyKeys(entry, [
2265
2287
  "files",
2266
2288
  "pattern",
2267
2289
  "sibling",
2268
2290
  "overLines"
2269
- ], OPTION_NAME$12);
2291
+ ], OPTION_NAME$13);
2270
2292
  const pattern = readPattern(entry["pattern"]);
2271
2293
  const siblings = readSiblings(entry["sibling"]);
2272
- if (pattern === void 0 && siblings.length === 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" needs every entry to have a "pattern" or a "sibling".`);
2294
+ if (pattern === void 0 && siblings.length === 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$13}" needs every entry to have a "pattern" or a "sibling".`);
2273
2295
  return {
2274
- inScope: createEntryScope(readEntryFiles(entry["files"], `${OPTION_NAME$12}.files`)),
2296
+ inScope: createEntryScope(readEntryFiles(entry["files"], `${OPTION_NAME$13}.files`)),
2275
2297
  pattern,
2276
2298
  siblings,
2277
2299
  overLines: readOverLines(entry["overLines"])
@@ -2408,7 +2430,7 @@ function stripNodePrefixFromPattern(pattern) {
2408
2430
  }
2409
2431
  //#endregion
2410
2432
  //#region src/rules/import-policy-options.ts
2411
- const OPTION_NAME$11 = "importPolicies";
2433
+ const OPTION_NAME$12 = "importPolicies";
2412
2434
  const GLOB_CHARACTERS$1 = /[*?[\]{}]/u;
2413
2435
  const stringList$1 = {
2414
2436
  type: "array",
@@ -2553,28 +2575,28 @@ function readExceptEdge(entry, name) {
2553
2575
  }
2554
2576
  function assertEdgeIsLive(edge, policy) {
2555
2577
  const root = "/";
2556
- if (!createFileScope([...policy.files, ...(policy.ignores ?? []).map((glob) => `!${glob}`)])(`${root}${edge.file}`, root)) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$11}" exception for "${edge.file}" names a file the policy's files do not select, so it can never apply.`);
2578
+ if (!createFileScope([...policy.files, ...(policy.ignores ?? []).map((glob) => `!${glob}`)])(`${root}${edge.file}`, root)) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" exception for "${edge.file}" names a file the policy's files do not select, so it can never apply.`);
2557
2579
  const file = `${root}${edge.file}`;
2558
- if (!((policy.deny ?? []).some((deny) => createSpecifierMatcher(deny.specifiers)(edge.specifier, file, root)) || (policy.confine ?? []).some((confine) => createSpecifierMatcher(confine.specifiers)(edge.specifier, file, root) && !createFileScope(confine.onlyIn)(file, root)))) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$11}" exception "${edge.specifier}" in "${edge.file}" is not forbidden there by any deny entry or by any confine entry whose onlyIn excludes the file, so it can never apply.`);
2580
+ if (!((policy.deny ?? []).some((deny) => createSpecifierMatcher(deny.specifiers)(edge.specifier, file, root)) || (policy.confine ?? []).some((confine) => createSpecifierMatcher(confine.specifiers)(edge.specifier, file, root) && !createFileScope(confine.onlyIn)(file, root)))) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" exception "${edge.specifier}" in "${edge.file}" is not forbidden there by any deny entry or by any confine entry whose onlyIn excludes the file, so it can never apply.`);
2559
2581
  }
2560
2582
  /**
2561
2583
  * Validates and normalises an import-policy option value at runtime, for the rule and for `importPolicyConfig` alike. Beyond the schema it requires: at least one include glob in `files`; at least one of `deny` and `confine` per policy; no unknown key; and that every exception edge names a file the policy selects and a specifier one of its restrictions forbids in that file (a confine entry forbids nothing in the files of its `onlyIn`). The edge's `file` is stored in its normalised, forward-slash form. Throws naming the offending field.
2562
2584
  */
2563
2585
  function readImportPolicies(value) {
2564
- return readEntryRecords(value, OPTION_NAME$11).map((entry) => {
2586
+ return readEntryRecords(value, OPTION_NAME$12).map((entry) => {
2565
2587
  assertOnlyKeys(entry, [
2566
2588
  "files",
2567
2589
  "ignores",
2568
2590
  "deny",
2569
2591
  "confine",
2570
2592
  "exceptEdges"
2571
- ], OPTION_NAME$11);
2572
- const files = readFileGlobs(entry["files"], `${OPTION_NAME$11}.files`);
2573
- const ignores = entry["ignores"] === void 0 ? void 0 : readFileGlobs(entry["ignores"], `${OPTION_NAME$11}.ignores`);
2574
- const deny = readOptionalList(entry, "deny", OPTION_NAME$11, readDeny);
2575
- const confine = readOptionalList(entry, "confine", OPTION_NAME$11, readConfine);
2576
- const exceptEdges = readOptionalList(entry, "exceptEdges", OPTION_NAME$11, readExceptEdge);
2577
- if ((deny === void 0 || deny.length === 0) && (confine === void 0 || confine.length === 0)) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$11}" needs every policy to have at least one "deny" or "confine" entry.`);
2593
+ ], OPTION_NAME$12);
2594
+ const files = readFileGlobs(entry["files"], `${OPTION_NAME$12}.files`);
2595
+ const ignores = entry["ignores"] === void 0 ? void 0 : readFileGlobs(entry["ignores"], `${OPTION_NAME$12}.ignores`);
2596
+ const deny = readOptionalList(entry, "deny", OPTION_NAME$12, readDeny);
2597
+ const confine = readOptionalList(entry, "confine", OPTION_NAME$12, readConfine);
2598
+ const exceptEdges = readOptionalList(entry, "exceptEdges", OPTION_NAME$12, readExceptEdge);
2599
+ if ((deny === void 0 || deny.length === 0) && (confine === void 0 || confine.length === 0)) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" needs every policy to have at least one "deny" or "confine" entry.`);
2578
2600
  const policy = {
2579
2601
  files,
2580
2602
  ...ignores !== void 0 && { ignores },
@@ -2724,7 +2746,7 @@ const importPolicy = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) =>
2724
2746
  });
2725
2747
  //#endregion
2726
2748
  //#region src/rules/injected-test-hygiene.ts
2727
- const OPTION_NAME$10 = "injected-test-hygiene";
2749
+ const OPTION_NAME$11 = "injected-test-hygiene";
2728
2750
  /**
2729
2751
  * The rule's option schema.
2730
2752
  */
@@ -2759,9 +2781,9 @@ function wildcardToRegExp(pattern) {
2759
2781
  * Validates the options and compiles the assertion names. Throws naming the rule for anything the schema lets through: options that are not an object, an unknown key, or an `assertFunctionNames` or `reportDisabled` of the wrong type.
2760
2782
  */
2761
2783
  function readInjectedTestHygieneOptions(value) {
2762
- const prefix = `@exadev/eslint-config: "${OPTION_NAME$10}"`;
2784
+ const prefix = `@exadev/eslint-config: "${OPTION_NAME$11}"`;
2763
2785
  if (!isRecord(value)) throw new Error(`${prefix} must be an object.`);
2764
- assertOnlyKeys(value, ["assertFunctionNames", "reportDisabled"], OPTION_NAME$10);
2786
+ assertOnlyKeys(value, ["assertFunctionNames", "reportDisabled"], OPTION_NAME$11);
2765
2787
  const { assertFunctionNames, reportDisabled } = value;
2766
2788
  if (reportDisabled !== void 0 && typeof reportDisabled !== "boolean") throw new Error(`${prefix} needs "reportDisabled" to be a boolean when given.`);
2767
2789
  if (assertFunctionNames !== void 0 && (!Array.isArray(assertFunctionNames) || assertFunctionNames.length === 0 || !assertFunctionNames.every((name) => typeof name === "string" && name.length > 0))) throw new Error(`${prefix} needs "assertFunctionNames" to be a non-empty array of non-empty strings when given.`);
@@ -2772,7 +2794,7 @@ function readInjectedTestHygieneOptions(value) {
2772
2794
  reportDisabled: reportDisabled ?? true
2773
2795
  };
2774
2796
  }
2775
- const createRule$14 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
2797
+ const createRule$15 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
2776
2798
  const API_KINDS = /* @__PURE__ */ new Map([
2777
2799
  ["describe", "describe"],
2778
2800
  ["suite", "describe"],
@@ -2838,8 +2860,8 @@ function isCalleeOfCall(node) {
2838
2860
  /**
2839
2861
  * Requires the test functions a conformance kit receives as parameters (`runConformance({ describe, it })`, or `run(t)` calling `t.it(...)`) to meet the hygiene `@vitest/eslint-plugin` asks of imported ones: no `.only`, no `.skip`, and an assertion in every test body. That plugin resolves a parameter named like a test function as a local binding and skips it, so its `expect-expect`, `no-disabled-tests` and `no-focused-tests` never fire inside such a kit. A test function is recognised as a parameter of the enclosing function named `describe`, `suite`, `it` or `test` (directly, or destructured under its own or a renamed local name), or as the `describe`, `suite`, `it` or `test` property of any parameter. The check is syntactic: a helper called from a test body counts as an assertion only when its name is listed in `assertFunctionNames`.
2840
2862
  */
2841
- const injectedTestHygiene = createRule$14({
2842
- name: OPTION_NAME$10,
2863
+ const injectedTestHygiene = createRule$15({
2864
+ name: OPTION_NAME$11,
2843
2865
  meta: {
2844
2866
  type: "problem",
2845
2867
  docs: { description: "Require the test functions a kit receives as parameters to be free of .only and .skip and to assert, since @vitest/eslint-plugin cannot see them." },
@@ -3016,7 +3038,7 @@ const MUTATING_INSERT_METHODS$1 = /* @__PURE__ */ new Set([
3016
3038
  "fill",
3017
3039
  "copyWithin"
3018
3040
  ]);
3019
- const createRule$13 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
3041
+ const createRule$14 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
3020
3042
  function isArrayIsArrayCall(node) {
3021
3043
  return node.type === _typescript_eslint_utils.AST_NODE_TYPES.CallExpression && node.callee.type === _typescript_eslint_utils.AST_NODE_TYPES.MemberExpression && !node.callee.computed && node.callee.object.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && node.callee.object.name === "Array" && node.callee.property.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && node.callee.property.name === "isArray";
3022
3044
  }
@@ -3028,7 +3050,7 @@ function definitelyExits$2(statement) {
3028
3050
  }
3029
3051
  return false;
3030
3052
  }
3031
- const noArrayIsArrayMutation = createRule$13({
3053
+ const noArrayIsArrayMutation = createRule$14({
3032
3054
  name: "no-array-isarray-mutation",
3033
3055
  meta: {
3034
3056
  type: "problem",
@@ -3504,6 +3526,175 @@ const noControlFlow = {
3504
3526
  }
3505
3527
  };
3506
3528
  //#endregion
3529
+ //#region src/rules/type-only-wrapper.ts
3530
+ function isTypeOnlyWrapper(node) {
3531
+ return node.type === _typescript_eslint_utils.AST_NODE_TYPES.TSAsExpression || node.type === _typescript_eslint_utils.AST_NODE_TYPES.TSSatisfiesExpression || node.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTypeAssertion;
3532
+ }
3533
+ /**
3534
+ * The expression that `node` stands for at runtime once every enclosing type-only wrapper is removed.
3535
+ */
3536
+ function unwrapTypeOnly(node) {
3537
+ return isTypeOnlyWrapper(node) ? unwrapTypeOnly(node.expression) : node;
3538
+ }
3539
+ //#endregion
3540
+ //#region src/rules/no-defensive-fallback.ts
3541
+ const OPTION_NAME$10 = "exadev/no-defensive-fallback";
3542
+ const OPTION_KEYS = ["allow"];
3543
+ const ALLOW_KEYS = ["files", "reason"];
3544
+ const createRule$13 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
3545
+ /**
3546
+ * Reads the `allow` option into one `FileScope` per entry. Each entry needs `files` (globs, relative to ESLint's working directory; a glob without a `/` names a file at any depth) and a `reason`, which is not used at lint time: it is required so every exemption explains itself where it is configured, as `noInlineConfig` leaves no comment to do so.
3547
+ */
3548
+ function readAllowedScopes(options) {
3549
+ if (!isRecord(options)) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$10}" options must be an object.`);
3550
+ assertOnlyKeys(options, OPTION_KEYS, OPTION_NAME$10);
3551
+ const { allow } = options;
3552
+ if (allow === void 0) return [];
3553
+ return readEntryRecords(allow, `${OPTION_NAME$10} allow`).map((entry) => {
3554
+ assertOnlyKeys(entry, ALLOW_KEYS, `${OPTION_NAME$10} allow entry`);
3555
+ readRequiredString(entry, "reason", `${OPTION_NAME$10} allow entry`);
3556
+ return createEntryScope(readEntryFiles(entry["files"], `${OPTION_NAME$10} allow files`));
3557
+ });
3558
+ }
3559
+ /**
3560
+ * Whether `node` is a literal for "nothing here": an empty array, empty object, empty string (quoted or an untagged template with no content), `0`, `0n`, `false` or `null`.
3561
+ */
3562
+ function isEmptyLiteral(node) {
3563
+ const target = unwrapTypeOnly(node);
3564
+ if (target.type === _typescript_eslint_utils.AST_NODE_TYPES.ArrayExpression) return target.elements.length === 0;
3565
+ if (target.type === _typescript_eslint_utils.AST_NODE_TYPES.ObjectExpression) return target.properties.length === 0;
3566
+ if (target.type === _typescript_eslint_utils.AST_NODE_TYPES.TemplateLiteral) return target.expressions.length === 0 && target.quasis.every((quasi) => quasi.value.cooked === "");
3567
+ return target.type === _typescript_eslint_utils.AST_NODE_TYPES.Literal && (target.raw === "null" || target.value === "" || target.value === 0 || target.value === BigInt(0) || target.value === false);
3568
+ }
3569
+ /**
3570
+ * Whether `node` is a fixed value known from the source alone, with no identifier but `undefined` and no call, so a function returning it cannot be reporting anything about the error it caught.
3571
+ */
3572
+ function isConstantValue(node) {
3573
+ const target = unwrapTypeOnly(node);
3574
+ if (target.type === _typescript_eslint_utils.AST_NODE_TYPES.Literal) return true;
3575
+ if (target.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier) return target.name === "undefined";
3576
+ if (target.type === _typescript_eslint_utils.AST_NODE_TYPES.TemplateLiteral) return target.expressions.length === 0;
3577
+ if (target.type === _typescript_eslint_utils.AST_NODE_TYPES.UnaryExpression) return (target.operator === "-" || target.operator === "+" || target.operator === "!" || target.operator === "void") && isConstantValue(target.argument);
3578
+ if (target.type === _typescript_eslint_utils.AST_NODE_TYPES.ArrayExpression) return target.elements.every((element) => element === null || element.type !== _typescript_eslint_utils.AST_NODE_TYPES.SpreadElement && isConstantValue(element));
3579
+ if (target.type === _typescript_eslint_utils.AST_NODE_TYPES.ObjectExpression) return target.properties.every((property) => property.type === _typescript_eslint_utils.AST_NODE_TYPES.Property && !property.computed && !property.method && isConstantValue(property.value));
3580
+ return false;
3581
+ }
3582
+ /**
3583
+ * How a block that stands in for error handling ends without handling anything, or `undefined` when it does something else. A block is swallowing when it is empty (a comment-only block included) or holds exactly one `continue`, or one `return` that gives back nothing or a constant value.
3584
+ */
3585
+ function swallowOutcome(body) {
3586
+ const [only] = body;
3587
+ if (only === void 0) return "does nothing";
3588
+ if (only.type === _typescript_eslint_utils.AST_NODE_TYPES.ContinueStatement) return "skips to the next iteration";
3589
+ if (only.type !== _typescript_eslint_utils.AST_NODE_TYPES.ReturnStatement) return void 0;
3590
+ if (only.argument === null) return "returns nothing";
3591
+ return isConstantValue(only.argument) ? "returns a fixed value" : void 0;
3592
+ }
3593
+ function isHandlerFunction(node) {
3594
+ return node.type === _typescript_eslint_utils.AST_NODE_TYPES.ArrowFunctionExpression || node.type === _typescript_eslint_utils.AST_NODE_TYPES.FunctionExpression;
3595
+ }
3596
+ /**
3597
+ * How a function passed to `.catch(...)` swallows the rejection, or `undefined` when it does not. An arrow with an expression body swallows when that expression is a constant value.
3598
+ */
3599
+ function rejectionHandlerOutcome(handler) {
3600
+ if (handler.body.type === _typescript_eslint_utils.AST_NODE_TYPES.BlockStatement) return swallowOutcome(handler.body.body);
3601
+ return isConstantValue(handler.body) ? "returns a fixed value" : void 0;
3602
+ }
3603
+ function isMethodCall(node, name) {
3604
+ const { callee } = node;
3605
+ return callee.type === _typescript_eslint_utils.AST_NODE_TYPES.MemberExpression && callee.property.type === _typescript_eslint_utils.AST_NODE_TYPES.Identifier && callee.property.name === name;
3606
+ }
3607
+ /**
3608
+ * The rejection handler of a promise call and what to call it in a report: the only argument of `.catch(handler)`, or the second of `.then(onFulfilled, handler)`. `undefined` for any other call, or when the argument list is not exactly that shape.
3609
+ */
3610
+ function rejectionHandlerOf(node) {
3611
+ const [first, second, ...rest] = node.arguments;
3612
+ if (isMethodCall(node, "catch") && first !== void 0 && second === void 0) return {
3613
+ handler: first,
3614
+ construct: ".catch() handler"
3615
+ };
3616
+ if (isMethodCall(node, "then") && second !== void 0 && rest.length === 0) return {
3617
+ handler: second,
3618
+ construct: ".then() rejection handler"
3619
+ };
3620
+ }
3621
+ const noDefensiveFallback = createRule$13({
3622
+ name: "no-defensive-fallback",
3623
+ meta: {
3624
+ type: "suggestion",
3625
+ docs: { description: "Disallow an empty-literal fallback after ?? or ||, and a catch that discards the error and returns nothing or a fixed value." },
3626
+ schema: [{
3627
+ type: "object",
3628
+ properties: { allow: {
3629
+ type: "array",
3630
+ items: {
3631
+ type: "object",
3632
+ properties: {
3633
+ files: entryFilesSchema,
3634
+ reason: {
3635
+ type: "string",
3636
+ minLength: 1
3637
+ }
3638
+ },
3639
+ required: ["files", "reason"],
3640
+ additionalProperties: false
3641
+ }
3642
+ } },
3643
+ additionalProperties: false
3644
+ }],
3645
+ messages: {
3646
+ emptyFallback: "A fallback of `{{ fallback }}` after `{{ operator }}` turns a missing value into a plausible one, so the absence never reaches the code that could handle it. Model the value as `T | undefined` and handle the absence where it is meaningful, or list this file under \"allow\" with a reason.",
3647
+ swallowedError: "This {{ construct }} discards the error and {{ outcome }}, so a failure becomes indistinguishable from success. Rethrow it, handle it, or let the caller see it, or list this file under \"allow\" with a reason."
3648
+ },
3649
+ defaultOptions: [{}]
3650
+ },
3651
+ create(context, [options]) {
3652
+ if (readAllowedScopes(options).some((inScope) => inScope(context.filename, context.cwd))) return {};
3653
+ function reportFallback(operator, right) {
3654
+ if (isEmptyLiteral(right)) context.report({
3655
+ node: right,
3656
+ messageId: "emptyFallback",
3657
+ data: {
3658
+ operator,
3659
+ fallback: context.sourceCode.getText(right)
3660
+ }
3661
+ });
3662
+ }
3663
+ return {
3664
+ LogicalExpression(node) {
3665
+ if (node.operator === "||" || node.operator === "??") reportFallback(node.operator, node.right);
3666
+ },
3667
+ AssignmentExpression(node) {
3668
+ if (node.operator === "||=" || node.operator === "??=") reportFallback(node.operator, node.right);
3669
+ },
3670
+ CatchClause(node) {
3671
+ const outcome = swallowOutcome(node.body.body);
3672
+ if (outcome !== void 0) context.report({
3673
+ node,
3674
+ messageId: "swallowedError",
3675
+ data: {
3676
+ construct: "catch clause",
3677
+ outcome
3678
+ }
3679
+ });
3680
+ },
3681
+ CallExpression(node) {
3682
+ const rejection = rejectionHandlerOf(node);
3683
+ if (rejection === void 0 || !isHandlerFunction(rejection.handler)) return;
3684
+ const outcome = rejectionHandlerOutcome(rejection.handler);
3685
+ if (outcome !== void 0) context.report({
3686
+ node: rejection.handler,
3687
+ messageId: "swallowedError",
3688
+ data: {
3689
+ construct: rejection.construct,
3690
+ outcome
3691
+ }
3692
+ });
3693
+ }
3694
+ };
3695
+ }
3696
+ });
3697
+ //#endregion
3507
3698
  //#region src/rules/no-dependency-cycle.ts
3508
3699
  /**
3509
3700
  * Reports a workspace dependency that can reach back to the declaring package: a cycle. This is NOT covered by no-uphill-dependency, and the distinction matters: that rule permits a same-rank (or, within a slice, a same-slice) dependency, so two packages at the same rank depending on each other passes it while still being a cycle. Turborepo does reject a cyclic task graph, but only once the whole graph is assembled, by which point a large refactor has already moved everything; reporting it at the manifest, where the edge is declared, names the two packages and the direction to reverse.
@@ -4677,7 +4868,7 @@ function renderLine(line) {
4677
4868
  function needsLiteralType(node) {
4678
4869
  let child = node;
4679
4870
  for (let parent = node.parent; parent !== void 0; child = parent, parent = parent.parent) {
4680
- if (parent.type === _typescript_eslint_utils.AST_NODE_TYPES.TSAsExpression || parent.type === _typescript_eslint_utils.AST_NODE_TYPES.TSSatisfiesExpression || parent.type === _typescript_eslint_utils.AST_NODE_TYPES.TSTypeAssertion) return true;
4871
+ if (isTypeOnlyWrapper(parent)) return true;
4681
4872
  if (parent.type === _typescript_eslint_utils.AST_NODE_TYPES.TSEnumMember) return parent.initializer === child;
4682
4873
  if (parent.type === _typescript_eslint_utils.AST_NODE_TYPES.VariableDeclarator) return parent.init === child && parent.id.typeAnnotation !== void 0;
4683
4874
  if (parent.type === _typescript_eslint_utils.AST_NODE_TYPES.PropertyDefinition) return parent.value === child && parent.typeAnnotation !== void 0;
@@ -4948,7 +5139,7 @@ function isSerialisableData(node) {
4948
5139
  if (node.type === _typescript_eslint_utils.AST_NODE_TYPES.UnaryExpression) return true;
4949
5140
  if (node.type === _typescript_eslint_utils.AST_NODE_TYPES.ArrayExpression) return node.elements.every((element) => element === null || element.type !== _typescript_eslint_utils.AST_NODE_TYPES.SpreadElement && isSerialisableData(element));
4950
5141
  if (node.type === _typescript_eslint_utils.AST_NODE_TYPES.ObjectExpression) return node.properties.every((property) => property.type === _typescript_eslint_utils.AST_NODE_TYPES.Property && (!property.computed || isSerialisableData(property.key)) && isSerialisableData(property.value));
4951
- if (node.type === _typescript_eslint_utils.AST_NODE_TYPES.TSAsExpression || node.type === _typescript_eslint_utils.AST_NODE_TYPES.TSSatisfiesExpression || node.type === _typescript_eslint_utils.AST_NODE_TYPES.TSNonNullExpression) return isSerialisableData(node.expression);
5142
+ if (isTypeOnlyWrapper(node) || node.type === _typescript_eslint_utils.AST_NODE_TYPES.TSNonNullExpression) return isSerialisableData(node.expression);
4952
5143
  return false;
4953
5144
  }
4954
5145
  const createRule$10 = _typescript_eslint_utils.ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
@@ -5335,7 +5526,7 @@ function isShorthandPropertyRead(sourceCode, identifier) {
5335
5526
  function isExportedAlias(declarationParentType, programBody, resolve, aliasVariable) {
5336
5527
  if (declarationParentType === "ExportNamedDeclaration") return true;
5337
5528
  return programBody.some((statement) => {
5338
- if (statement.type === "ExportNamedDeclaration" && statement.source == null) return (statement.specifiers ?? []).some((specifier) => specifier.local.type === "Identifier" && specifier.local.name !== void 0 && resolve(specifier.local.name) === aliasVariable);
5529
+ if (statement.type === "ExportNamedDeclaration" && (statement.source === void 0 || statement.source === null)) return (statement.specifiers ?? []).some((specifier) => specifier.local.type === "Identifier" && specifier.local.name !== void 0 && resolve(specifier.local.name) === aliasVariable);
5339
5530
  const target = statement.type === "ExportDefaultDeclaration" ? statement.declaration : void 0;
5340
5531
  return target?.type === "Identifier" && target.name !== void 0 && resolve(target.name) === aliasVariable;
5341
5532
  });
@@ -8509,6 +8700,7 @@ const plugin = {
8509
8700
  "no-array-isarray-mutation": noArrayIsArrayMutation,
8510
8701
  "no-boundaries-ignore": noBoundariesIgnore,
8511
8702
  "no-control-flow": noControlFlow,
8703
+ "no-defensive-fallback": noDefensiveFallback,
8512
8704
  "no-dependency-cycle": no_dependency_cycle_default,
8513
8705
  "no-enum-number-widening": noEnumNumberWidening,
8514
8706
  "no-enum-reverse-lookup-widening": noEnumReverseLookupWidening,
@@ -8559,6 +8751,7 @@ const plugin = {
8559
8751
  plugins: { exadev: plugin },
8560
8752
  linterOptions: { noInlineConfig: true },
8561
8753
  rules: {
8754
+ ...UNTYPED_ROBUSTNESS_RULES,
8562
8755
  "exadev/barrel-policy": ["error", { mode: "banned" }],
8563
8756
  "exadev/no-mutable-union-array-param": "error",
8564
8757
  "exadev/no-object-assign": "error",
@@ -8566,7 +8759,8 @@ const plugin = {
8566
8759
  "exadev/prefer-options-object-param": "error",
8567
8760
  "exadev/prefer-readonly-array-param": "error",
8568
8761
  "exadev/test-file-kind": "error",
8569
- "max-params": ["error", { max: 4 }]
8762
+ "max-params": ["error", { max: 4 }],
8763
+ "no-implicit-coercion": "error"
8570
8764
  }
8571
8765
  };
8572
8766
  },
@@ -8992,6 +9186,7 @@ const recommendedTypeChecked = [
8992
9186
  plugins: { exadev: plugin },
8993
9187
  linterOptions: { noInlineConfig: true },
8994
9188
  rules: {
9189
+ ...UNTYPED_ROBUSTNESS_RULES,
8995
9190
  "exadev/barrel-policy": "error",
8996
9191
  "exadev/no-array-isarray-mutation": "error",
8997
9192
  "exadev/no-enum-number-widening": "error",
@@ -9034,7 +9229,12 @@ const recommendedTypeChecked = [
9034
9229
  "@typescript-eslint/require-array-sort-compare": "error",
9035
9230
  "@typescript-eslint/strict-boolean-expressions": "error",
9036
9231
  "@typescript-eslint/strict-void-return": "error",
9037
- "@typescript-eslint/switch-exhaustiveness-check": "error",
9232
+ "@typescript-eslint/explicit-module-boundary-types": "error",
9233
+ "@typescript-eslint/switch-exhaustiveness-check": ["error", {
9234
+ considerDefaultExhaustiveForUnions: false,
9235
+ requireDefaultForNonUnion: true
9236
+ }],
9237
+ "no-implicit-coercion": ["error", { boolean: false }],
9038
9238
  "max-lines": ["error", {
9039
9239
  max: 800,
9040
9240
  skipBlankLines: true,
package/dist/index.js CHANGED
@@ -149,7 +149,7 @@ function buildGitignoreConfig(options = {}) {
149
149
  }
150
150
  //#endregion
151
151
  //#region package.json
152
- var version = "2.28.0";
152
+ var version = "2.29.0";
153
153
  //#endregion
154
154
  //#region src/is-record.ts
155
155
  /**
@@ -285,6 +285,28 @@ function toPublicPlugin(built) {
285
285
  return built;
286
286
  }
287
287
  //#endregion
288
+ //#region src/robustness-rules.ts
289
+ /**
290
+ * Core ESLint robustness rules that need neither type information nor the typescript-eslint plugin, so both `plugin.configs.recommended` and the default export enable them with identical settings. `no-implicit-coercion` is deliberately absent: its best setting depends on whether `@typescript-eslint/strict-boolean-expressions` is also on, which only the default export can say.
291
+ *
292
+ * - `eqeqeq` is `always` with no `null` carve-out: `== null` hides which of `null` and `undefined` the code means.
293
+ * - `no-param-reassign` with `props: true` covers assignment to a parameter's properties as well as the parameter itself; a parameter is the caller's value, not a local to rewrite.
294
+ * - `no-await-in-loop` assumes iterations are independent. A loop that is sequential by design is exempted by a `files`-scoped override in the consumer's config (`noInlineConfig` rules out a disable comment), never by weakening the rule globally.
295
+ * - `require-atomic-updates` catches a read-modify-write that spans an `await`, where another task can change the value in between.
296
+ * - `default-case-last` keeps `default` from hiding the cases written after it.
297
+ * - `no-return-assign` is `always`, so even a parenthesised assignment cannot be the returned value.
298
+ * - `max-depth` is stated at the rule's own default of four nested blocks so a future change to that default cannot loosen it.
299
+ */
300
+ const UNTYPED_ROBUSTNESS_RULES = {
301
+ "default-case-last": "error",
302
+ eqeqeq: ["error", "always"],
303
+ "max-depth": ["error", { max: 4 }],
304
+ "no-await-in-loop": "error",
305
+ "no-param-reassign": ["error", { props: true }],
306
+ "no-return-assign": ["error", "always"],
307
+ "require-atomic-updates": "error"
308
+ };
309
+ //#endregion
288
310
  //#region src/rules/barrel-helpers.ts
289
311
  /**
290
312
  * Reusable across rules: the basename an index file has, matching ts/tsx/js/jsx/mjs/mts/cjs/cts. Identical to no-non-barrel-index's own INDEX_BASENAME — deliberately duplicated as the single declared constant both modules import, rather than each rule re-deriving the regex.
@@ -1345,7 +1367,7 @@ function readSelector(value, optionName, context) {
1345
1367
  ...typeof namePattern === "string" && { namePattern }
1346
1368
  };
1347
1369
  }
1348
- const ALLOW_KEYS = [
1370
+ const ALLOW_KEYS$1 = [
1349
1371
  "from",
1350
1372
  "to",
1351
1373
  "reason"
@@ -1353,7 +1375,7 @@ const ALLOW_KEYS = [
1353
1375
  function readAllow(value) {
1354
1376
  const seen = /* @__PURE__ */ new Set();
1355
1377
  return readArray$1(value, "allow").map((entry) => {
1356
- const fields = readEntry$1(entry, "allow", ALLOW_KEYS);
1378
+ const fields = readEntry$1(entry, "allow", ALLOW_KEYS$1);
1357
1379
  const from = readNonEmptyString$1(fields["from"], "allow", "\"from\"");
1358
1380
  const to = readNonEmptyString$1(fields["to"], "allow", "\"to\"");
1359
1381
  const reason = readNonEmptyString$1(fields["reason"], "allow", "\"reason\" (an exception must say why it exists)");
@@ -2190,18 +2212,18 @@ function createEntryScope(globs) {
2190
2212
  }
2191
2213
  //#endregion
2192
2214
  //#region src/rules/filename-pattern.ts
2193
- const OPTION_NAME$12 = "exadev/filename-pattern";
2215
+ const OPTION_NAME$13 = "exadev/filename-pattern";
2194
2216
  /**
2195
2217
  * The placeholder in a `sibling` template that stands for the linted file's name without its final extension (`Button` for `Button.tsx`).
2196
2218
  */
2197
2219
  const SIBLING_NAME_PLACEHOLDER = "{name}";
2198
2220
  function readPattern(value) {
2199
2221
  if (value === void 0) return void 0;
2200
- if (typeof value !== "string" || value.length === 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" needs "pattern" to be a non-empty regular expression source.`);
2222
+ if (typeof value !== "string" || value.length === 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$13}" needs "pattern" to be a non-empty regular expression source.`);
2201
2223
  try {
2202
2224
  new RegExp(value, "u");
2203
2225
  } catch (error) {
2204
- throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" needs "pattern" to be a valid regular expression, but "${value}" is not: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2226
+ throw new Error(`@exadev/eslint-config: "${OPTION_NAME$13}" needs "pattern" to be a valid regular expression, but "${value}" is not: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
2205
2227
  }
2206
2228
  return {
2207
2229
  source: value,
@@ -2211,32 +2233,32 @@ function readPattern(value) {
2211
2233
  const UNKNOWN_PLACEHOLDER = /\{(?!name\})[^{}]*\}/u;
2212
2234
  function readSiblings(value) {
2213
2235
  if (value === void 0) return [];
2214
- const siblings = readRequiredStrings({ sibling: typeof value === "string" ? [value] : value }, "sibling", OPTION_NAME$12);
2236
+ const siblings = readRequiredStrings({ sibling: typeof value === "string" ? [value] : value }, "sibling", OPTION_NAME$13);
2215
2237
  const unknown = siblings.find((template) => UNKNOWN_PLACEHOLDER.test(template));
2216
- if (unknown !== void 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" has a "sibling" template "${unknown}" with a placeholder other than ${SIBLING_NAME_PLACEHOLDER}.`);
2238
+ if (unknown !== void 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$13}" has a "sibling" template "${unknown}" with a placeholder other than ${SIBLING_NAME_PLACEHOLDER}.`);
2217
2239
  return siblings;
2218
2240
  }
2219
2241
  function readOverLines(value) {
2220
2242
  if (value === void 0) return void 0;
2221
- if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" needs "overLines" to be a non-negative integer.`);
2243
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$13}" needs "overLines" to be a non-negative integer.`);
2222
2244
  return value;
2223
2245
  }
2224
2246
  /**
2225
2247
  * Validates the rule's option list, compiling each entry. Throws naming the offending field, including a `pattern` that is not a valid regular expression.
2226
2248
  */
2227
2249
  function readFilenamePatternEntries(value) {
2228
- return readEntryRecords(value, OPTION_NAME$12).map((entry) => {
2250
+ return readEntryRecords(value, OPTION_NAME$13).map((entry) => {
2229
2251
  assertOnlyKeys(entry, [
2230
2252
  "files",
2231
2253
  "pattern",
2232
2254
  "sibling",
2233
2255
  "overLines"
2234
- ], OPTION_NAME$12);
2256
+ ], OPTION_NAME$13);
2235
2257
  const pattern = readPattern(entry["pattern"]);
2236
2258
  const siblings = readSiblings(entry["sibling"]);
2237
- if (pattern === void 0 && siblings.length === 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" needs every entry to have a "pattern" or a "sibling".`);
2259
+ if (pattern === void 0 && siblings.length === 0) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$13}" needs every entry to have a "pattern" or a "sibling".`);
2238
2260
  return {
2239
- inScope: createEntryScope(readEntryFiles(entry["files"], `${OPTION_NAME$12}.files`)),
2261
+ inScope: createEntryScope(readEntryFiles(entry["files"], `${OPTION_NAME$13}.files`)),
2240
2262
  pattern,
2241
2263
  siblings,
2242
2264
  overLines: readOverLines(entry["overLines"])
@@ -2373,7 +2395,7 @@ function stripNodePrefixFromPattern(pattern) {
2373
2395
  }
2374
2396
  //#endregion
2375
2397
  //#region src/rules/import-policy-options.ts
2376
- const OPTION_NAME$11 = "importPolicies";
2398
+ const OPTION_NAME$12 = "importPolicies";
2377
2399
  const GLOB_CHARACTERS$1 = /[*?[\]{}]/u;
2378
2400
  const stringList$1 = {
2379
2401
  type: "array",
@@ -2518,28 +2540,28 @@ function readExceptEdge(entry, name) {
2518
2540
  }
2519
2541
  function assertEdgeIsLive(edge, policy) {
2520
2542
  const root = "/";
2521
- if (!createFileScope([...policy.files, ...(policy.ignores ?? []).map((glob) => `!${glob}`)])(`${root}${edge.file}`, root)) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$11}" exception for "${edge.file}" names a file the policy's files do not select, so it can never apply.`);
2543
+ if (!createFileScope([...policy.files, ...(policy.ignores ?? []).map((glob) => `!${glob}`)])(`${root}${edge.file}`, root)) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" exception for "${edge.file}" names a file the policy's files do not select, so it can never apply.`);
2522
2544
  const file = `${root}${edge.file}`;
2523
- if (!((policy.deny ?? []).some((deny) => createSpecifierMatcher(deny.specifiers)(edge.specifier, file, root)) || (policy.confine ?? []).some((confine) => createSpecifierMatcher(confine.specifiers)(edge.specifier, file, root) && !createFileScope(confine.onlyIn)(file, root)))) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$11}" exception "${edge.specifier}" in "${edge.file}" is not forbidden there by any deny entry or by any confine entry whose onlyIn excludes the file, so it can never apply.`);
2545
+ if (!((policy.deny ?? []).some((deny) => createSpecifierMatcher(deny.specifiers)(edge.specifier, file, root)) || (policy.confine ?? []).some((confine) => createSpecifierMatcher(confine.specifiers)(edge.specifier, file, root) && !createFileScope(confine.onlyIn)(file, root)))) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" exception "${edge.specifier}" in "${edge.file}" is not forbidden there by any deny entry or by any confine entry whose onlyIn excludes the file, so it can never apply.`);
2524
2546
  }
2525
2547
  /**
2526
2548
  * Validates and normalises an import-policy option value at runtime, for the rule and for `importPolicyConfig` alike. Beyond the schema it requires: at least one include glob in `files`; at least one of `deny` and `confine` per policy; no unknown key; and that every exception edge names a file the policy selects and a specifier one of its restrictions forbids in that file (a confine entry forbids nothing in the files of its `onlyIn`). The edge's `file` is stored in its normalised, forward-slash form. Throws naming the offending field.
2527
2549
  */
2528
2550
  function readImportPolicies(value) {
2529
- return readEntryRecords(value, OPTION_NAME$11).map((entry) => {
2551
+ return readEntryRecords(value, OPTION_NAME$12).map((entry) => {
2530
2552
  assertOnlyKeys(entry, [
2531
2553
  "files",
2532
2554
  "ignores",
2533
2555
  "deny",
2534
2556
  "confine",
2535
2557
  "exceptEdges"
2536
- ], OPTION_NAME$11);
2537
- const files = readFileGlobs(entry["files"], `${OPTION_NAME$11}.files`);
2538
- const ignores = entry["ignores"] === void 0 ? void 0 : readFileGlobs(entry["ignores"], `${OPTION_NAME$11}.ignores`);
2539
- const deny = readOptionalList(entry, "deny", OPTION_NAME$11, readDeny);
2540
- const confine = readOptionalList(entry, "confine", OPTION_NAME$11, readConfine);
2541
- const exceptEdges = readOptionalList(entry, "exceptEdges", OPTION_NAME$11, readExceptEdge);
2542
- if ((deny === void 0 || deny.length === 0) && (confine === void 0 || confine.length === 0)) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$11}" needs every policy to have at least one "deny" or "confine" entry.`);
2558
+ ], OPTION_NAME$12);
2559
+ const files = readFileGlobs(entry["files"], `${OPTION_NAME$12}.files`);
2560
+ const ignores = entry["ignores"] === void 0 ? void 0 : readFileGlobs(entry["ignores"], `${OPTION_NAME$12}.ignores`);
2561
+ const deny = readOptionalList(entry, "deny", OPTION_NAME$12, readDeny);
2562
+ const confine = readOptionalList(entry, "confine", OPTION_NAME$12, readConfine);
2563
+ const exceptEdges = readOptionalList(entry, "exceptEdges", OPTION_NAME$12, readExceptEdge);
2564
+ if ((deny === void 0 || deny.length === 0) && (confine === void 0 || confine.length === 0)) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$12}" needs every policy to have at least one "deny" or "confine" entry.`);
2543
2565
  const policy = {
2544
2566
  files,
2545
2567
  ...ignores !== void 0 && { ignores },
@@ -2689,7 +2711,7 @@ const importPolicy = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDe
2689
2711
  });
2690
2712
  //#endregion
2691
2713
  //#region src/rules/injected-test-hygiene.ts
2692
- const OPTION_NAME$10 = "injected-test-hygiene";
2714
+ const OPTION_NAME$11 = "injected-test-hygiene";
2693
2715
  /**
2694
2716
  * The rule's option schema.
2695
2717
  */
@@ -2724,9 +2746,9 @@ function wildcardToRegExp(pattern) {
2724
2746
  * Validates the options and compiles the assertion names. Throws naming the rule for anything the schema lets through: options that are not an object, an unknown key, or an `assertFunctionNames` or `reportDisabled` of the wrong type.
2725
2747
  */
2726
2748
  function readInjectedTestHygieneOptions(value) {
2727
- const prefix = `@exadev/eslint-config: "${OPTION_NAME$10}"`;
2749
+ const prefix = `@exadev/eslint-config: "${OPTION_NAME$11}"`;
2728
2750
  if (!isRecord(value)) throw new Error(`${prefix} must be an object.`);
2729
- assertOnlyKeys(value, ["assertFunctionNames", "reportDisabled"], OPTION_NAME$10);
2751
+ assertOnlyKeys(value, ["assertFunctionNames", "reportDisabled"], OPTION_NAME$11);
2730
2752
  const { assertFunctionNames, reportDisabled } = value;
2731
2753
  if (reportDisabled !== void 0 && typeof reportDisabled !== "boolean") throw new Error(`${prefix} needs "reportDisabled" to be a boolean when given.`);
2732
2754
  if (assertFunctionNames !== void 0 && (!Array.isArray(assertFunctionNames) || assertFunctionNames.length === 0 || !assertFunctionNames.every((name) => typeof name === "string" && name.length > 0))) throw new Error(`${prefix} needs "assertFunctionNames" to be a non-empty array of non-empty strings when given.`);
@@ -2737,7 +2759,7 @@ function readInjectedTestHygieneOptions(value) {
2737
2759
  reportDisabled: reportDisabled ?? true
2738
2760
  };
2739
2761
  }
2740
- const createRule$14 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
2762
+ const createRule$15 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
2741
2763
  const API_KINDS = /* @__PURE__ */ new Map([
2742
2764
  ["describe", "describe"],
2743
2765
  ["suite", "describe"],
@@ -2803,8 +2825,8 @@ function isCalleeOfCall(node) {
2803
2825
  /**
2804
2826
  * Requires the test functions a conformance kit receives as parameters (`runConformance({ describe, it })`, or `run(t)` calling `t.it(...)`) to meet the hygiene `@vitest/eslint-plugin` asks of imported ones: no `.only`, no `.skip`, and an assertion in every test body. That plugin resolves a parameter named like a test function as a local binding and skips it, so its `expect-expect`, `no-disabled-tests` and `no-focused-tests` never fire inside such a kit. A test function is recognised as a parameter of the enclosing function named `describe`, `suite`, `it` or `test` (directly, or destructured under its own or a renamed local name), or as the `describe`, `suite`, `it` or `test` property of any parameter. The check is syntactic: a helper called from a test body counts as an assertion only when its name is listed in `assertFunctionNames`.
2805
2827
  */
2806
- const injectedTestHygiene = createRule$14({
2807
- name: OPTION_NAME$10,
2828
+ const injectedTestHygiene = createRule$15({
2829
+ name: OPTION_NAME$11,
2808
2830
  meta: {
2809
2831
  type: "problem",
2810
2832
  docs: { description: "Require the test functions a kit receives as parameters to be free of .only and .skip and to assert, since @vitest/eslint-plugin cannot see them." },
@@ -2981,7 +3003,7 @@ const MUTATING_INSERT_METHODS$1 = /* @__PURE__ */ new Set([
2981
3003
  "fill",
2982
3004
  "copyWithin"
2983
3005
  ]);
2984
- const createRule$13 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
3006
+ const createRule$14 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
2985
3007
  function isArrayIsArrayCall(node) {
2986
3008
  return node.type === AST_NODE_TYPES.CallExpression && node.callee.type === AST_NODE_TYPES.MemberExpression && !node.callee.computed && node.callee.object.type === AST_NODE_TYPES.Identifier && node.callee.object.name === "Array" && node.callee.property.type === AST_NODE_TYPES.Identifier && node.callee.property.name === "isArray";
2987
3009
  }
@@ -2993,7 +3015,7 @@ function definitelyExits$2(statement) {
2993
3015
  }
2994
3016
  return false;
2995
3017
  }
2996
- const noArrayIsArrayMutation = createRule$13({
3018
+ const noArrayIsArrayMutation = createRule$14({
2997
3019
  name: "no-array-isarray-mutation",
2998
3020
  meta: {
2999
3021
  type: "problem",
@@ -3469,6 +3491,175 @@ const noControlFlow = {
3469
3491
  }
3470
3492
  };
3471
3493
  //#endregion
3494
+ //#region src/rules/type-only-wrapper.ts
3495
+ function isTypeOnlyWrapper(node) {
3496
+ return node.type === AST_NODE_TYPES.TSAsExpression || node.type === AST_NODE_TYPES.TSSatisfiesExpression || node.type === AST_NODE_TYPES.TSTypeAssertion;
3497
+ }
3498
+ /**
3499
+ * The expression that `node` stands for at runtime once every enclosing type-only wrapper is removed.
3500
+ */
3501
+ function unwrapTypeOnly(node) {
3502
+ return isTypeOnlyWrapper(node) ? unwrapTypeOnly(node.expression) : node;
3503
+ }
3504
+ //#endregion
3505
+ //#region src/rules/no-defensive-fallback.ts
3506
+ const OPTION_NAME$10 = "exadev/no-defensive-fallback";
3507
+ const OPTION_KEYS = ["allow"];
3508
+ const ALLOW_KEYS = ["files", "reason"];
3509
+ const createRule$13 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
3510
+ /**
3511
+ * Reads the `allow` option into one `FileScope` per entry. Each entry needs `files` (globs, relative to ESLint's working directory; a glob without a `/` names a file at any depth) and a `reason`, which is not used at lint time: it is required so every exemption explains itself where it is configured, as `noInlineConfig` leaves no comment to do so.
3512
+ */
3513
+ function readAllowedScopes(options) {
3514
+ if (!isRecord(options)) throw new Error(`@exadev/eslint-config: "${OPTION_NAME$10}" options must be an object.`);
3515
+ assertOnlyKeys(options, OPTION_KEYS, OPTION_NAME$10);
3516
+ const { allow } = options;
3517
+ if (allow === void 0) return [];
3518
+ return readEntryRecords(allow, `${OPTION_NAME$10} allow`).map((entry) => {
3519
+ assertOnlyKeys(entry, ALLOW_KEYS, `${OPTION_NAME$10} allow entry`);
3520
+ readRequiredString(entry, "reason", `${OPTION_NAME$10} allow entry`);
3521
+ return createEntryScope(readEntryFiles(entry["files"], `${OPTION_NAME$10} allow files`));
3522
+ });
3523
+ }
3524
+ /**
3525
+ * Whether `node` is a literal for "nothing here": an empty array, empty object, empty string (quoted or an untagged template with no content), `0`, `0n`, `false` or `null`.
3526
+ */
3527
+ function isEmptyLiteral(node) {
3528
+ const target = unwrapTypeOnly(node);
3529
+ if (target.type === AST_NODE_TYPES.ArrayExpression) return target.elements.length === 0;
3530
+ if (target.type === AST_NODE_TYPES.ObjectExpression) return target.properties.length === 0;
3531
+ if (target.type === AST_NODE_TYPES.TemplateLiteral) return target.expressions.length === 0 && target.quasis.every((quasi) => quasi.value.cooked === "");
3532
+ return target.type === AST_NODE_TYPES.Literal && (target.raw === "null" || target.value === "" || target.value === 0 || target.value === BigInt(0) || target.value === false);
3533
+ }
3534
+ /**
3535
+ * Whether `node` is a fixed value known from the source alone, with no identifier but `undefined` and no call, so a function returning it cannot be reporting anything about the error it caught.
3536
+ */
3537
+ function isConstantValue(node) {
3538
+ const target = unwrapTypeOnly(node);
3539
+ if (target.type === AST_NODE_TYPES.Literal) return true;
3540
+ if (target.type === AST_NODE_TYPES.Identifier) return target.name === "undefined";
3541
+ if (target.type === AST_NODE_TYPES.TemplateLiteral) return target.expressions.length === 0;
3542
+ if (target.type === AST_NODE_TYPES.UnaryExpression) return (target.operator === "-" || target.operator === "+" || target.operator === "!" || target.operator === "void") && isConstantValue(target.argument);
3543
+ if (target.type === AST_NODE_TYPES.ArrayExpression) return target.elements.every((element) => element === null || element.type !== AST_NODE_TYPES.SpreadElement && isConstantValue(element));
3544
+ if (target.type === AST_NODE_TYPES.ObjectExpression) return target.properties.every((property) => property.type === AST_NODE_TYPES.Property && !property.computed && !property.method && isConstantValue(property.value));
3545
+ return false;
3546
+ }
3547
+ /**
3548
+ * How a block that stands in for error handling ends without handling anything, or `undefined` when it does something else. A block is swallowing when it is empty (a comment-only block included) or holds exactly one `continue`, or one `return` that gives back nothing or a constant value.
3549
+ */
3550
+ function swallowOutcome(body) {
3551
+ const [only] = body;
3552
+ if (only === void 0) return "does nothing";
3553
+ if (only.type === AST_NODE_TYPES.ContinueStatement) return "skips to the next iteration";
3554
+ if (only.type !== AST_NODE_TYPES.ReturnStatement) return void 0;
3555
+ if (only.argument === null) return "returns nothing";
3556
+ return isConstantValue(only.argument) ? "returns a fixed value" : void 0;
3557
+ }
3558
+ function isHandlerFunction(node) {
3559
+ return node.type === AST_NODE_TYPES.ArrowFunctionExpression || node.type === AST_NODE_TYPES.FunctionExpression;
3560
+ }
3561
+ /**
3562
+ * How a function passed to `.catch(...)` swallows the rejection, or `undefined` when it does not. An arrow with an expression body swallows when that expression is a constant value.
3563
+ */
3564
+ function rejectionHandlerOutcome(handler) {
3565
+ if (handler.body.type === AST_NODE_TYPES.BlockStatement) return swallowOutcome(handler.body.body);
3566
+ return isConstantValue(handler.body) ? "returns a fixed value" : void 0;
3567
+ }
3568
+ function isMethodCall(node, name) {
3569
+ const { callee } = node;
3570
+ return callee.type === AST_NODE_TYPES.MemberExpression && callee.property.type === AST_NODE_TYPES.Identifier && callee.property.name === name;
3571
+ }
3572
+ /**
3573
+ * The rejection handler of a promise call and what to call it in a report: the only argument of `.catch(handler)`, or the second of `.then(onFulfilled, handler)`. `undefined` for any other call, or when the argument list is not exactly that shape.
3574
+ */
3575
+ function rejectionHandlerOf(node) {
3576
+ const [first, second, ...rest] = node.arguments;
3577
+ if (isMethodCall(node, "catch") && first !== void 0 && second === void 0) return {
3578
+ handler: first,
3579
+ construct: ".catch() handler"
3580
+ };
3581
+ if (isMethodCall(node, "then") && second !== void 0 && rest.length === 0) return {
3582
+ handler: second,
3583
+ construct: ".then() rejection handler"
3584
+ };
3585
+ }
3586
+ const noDefensiveFallback = createRule$13({
3587
+ name: "no-defensive-fallback",
3588
+ meta: {
3589
+ type: "suggestion",
3590
+ docs: { description: "Disallow an empty-literal fallback after ?? or ||, and a catch that discards the error and returns nothing or a fixed value." },
3591
+ schema: [{
3592
+ type: "object",
3593
+ properties: { allow: {
3594
+ type: "array",
3595
+ items: {
3596
+ type: "object",
3597
+ properties: {
3598
+ files: entryFilesSchema,
3599
+ reason: {
3600
+ type: "string",
3601
+ minLength: 1
3602
+ }
3603
+ },
3604
+ required: ["files", "reason"],
3605
+ additionalProperties: false
3606
+ }
3607
+ } },
3608
+ additionalProperties: false
3609
+ }],
3610
+ messages: {
3611
+ emptyFallback: "A fallback of `{{ fallback }}` after `{{ operator }}` turns a missing value into a plausible one, so the absence never reaches the code that could handle it. Model the value as `T | undefined` and handle the absence where it is meaningful, or list this file under \"allow\" with a reason.",
3612
+ swallowedError: "This {{ construct }} discards the error and {{ outcome }}, so a failure becomes indistinguishable from success. Rethrow it, handle it, or let the caller see it, or list this file under \"allow\" with a reason."
3613
+ },
3614
+ defaultOptions: [{}]
3615
+ },
3616
+ create(context, [options]) {
3617
+ if (readAllowedScopes(options).some((inScope) => inScope(context.filename, context.cwd))) return {};
3618
+ function reportFallback(operator, right) {
3619
+ if (isEmptyLiteral(right)) context.report({
3620
+ node: right,
3621
+ messageId: "emptyFallback",
3622
+ data: {
3623
+ operator,
3624
+ fallback: context.sourceCode.getText(right)
3625
+ }
3626
+ });
3627
+ }
3628
+ return {
3629
+ LogicalExpression(node) {
3630
+ if (node.operator === "||" || node.operator === "??") reportFallback(node.operator, node.right);
3631
+ },
3632
+ AssignmentExpression(node) {
3633
+ if (node.operator === "||=" || node.operator === "??=") reportFallback(node.operator, node.right);
3634
+ },
3635
+ CatchClause(node) {
3636
+ const outcome = swallowOutcome(node.body.body);
3637
+ if (outcome !== void 0) context.report({
3638
+ node,
3639
+ messageId: "swallowedError",
3640
+ data: {
3641
+ construct: "catch clause",
3642
+ outcome
3643
+ }
3644
+ });
3645
+ },
3646
+ CallExpression(node) {
3647
+ const rejection = rejectionHandlerOf(node);
3648
+ if (rejection === void 0 || !isHandlerFunction(rejection.handler)) return;
3649
+ const outcome = rejectionHandlerOutcome(rejection.handler);
3650
+ if (outcome !== void 0) context.report({
3651
+ node: rejection.handler,
3652
+ messageId: "swallowedError",
3653
+ data: {
3654
+ construct: rejection.construct,
3655
+ outcome
3656
+ }
3657
+ });
3658
+ }
3659
+ };
3660
+ }
3661
+ });
3662
+ //#endregion
3472
3663
  //#region src/rules/no-dependency-cycle.ts
3473
3664
  /**
3474
3665
  * Reports a workspace dependency that can reach back to the declaring package: a cycle. This is NOT covered by no-uphill-dependency, and the distinction matters: that rule permits a same-rank (or, within a slice, a same-slice) dependency, so two packages at the same rank depending on each other passes it while still being a cycle. Turborepo does reject a cyclic task graph, but only once the whole graph is assembled, by which point a large refactor has already moved everything; reporting it at the manifest, where the edge is declared, names the two packages and the direction to reverse.
@@ -4642,7 +4833,7 @@ function renderLine(line) {
4642
4833
  function needsLiteralType(node) {
4643
4834
  let child = node;
4644
4835
  for (let parent = node.parent; parent !== void 0; child = parent, parent = parent.parent) {
4645
- if (parent.type === AST_NODE_TYPES.TSAsExpression || parent.type === AST_NODE_TYPES.TSSatisfiesExpression || parent.type === AST_NODE_TYPES.TSTypeAssertion) return true;
4836
+ if (isTypeOnlyWrapper(parent)) return true;
4646
4837
  if (parent.type === AST_NODE_TYPES.TSEnumMember) return parent.initializer === child;
4647
4838
  if (parent.type === AST_NODE_TYPES.VariableDeclarator) return parent.init === child && parent.id.typeAnnotation !== void 0;
4648
4839
  if (parent.type === AST_NODE_TYPES.PropertyDefinition) return parent.value === child && parent.typeAnnotation !== void 0;
@@ -4913,7 +5104,7 @@ function isSerialisableData(node) {
4913
5104
  if (node.type === AST_NODE_TYPES.UnaryExpression) return true;
4914
5105
  if (node.type === AST_NODE_TYPES.ArrayExpression) return node.elements.every((element) => element === null || element.type !== AST_NODE_TYPES.SpreadElement && isSerialisableData(element));
4915
5106
  if (node.type === AST_NODE_TYPES.ObjectExpression) return node.properties.every((property) => property.type === AST_NODE_TYPES.Property && (!property.computed || isSerialisableData(property.key)) && isSerialisableData(property.value));
4916
- if (node.type === AST_NODE_TYPES.TSAsExpression || node.type === AST_NODE_TYPES.TSSatisfiesExpression || node.type === AST_NODE_TYPES.TSNonNullExpression) return isSerialisableData(node.expression);
5107
+ if (isTypeOnlyWrapper(node) || node.type === AST_NODE_TYPES.TSNonNullExpression) return isSerialisableData(node.expression);
4917
5108
  return false;
4918
5109
  }
4919
5110
  const createRule$10 = ESLintUtils.RuleCreator((name) => `https://github.com/ExaDev/eslint-config/blob/main/src/rules/${name}.ts`);
@@ -5300,7 +5491,7 @@ function isShorthandPropertyRead(sourceCode, identifier) {
5300
5491
  function isExportedAlias(declarationParentType, programBody, resolve, aliasVariable) {
5301
5492
  if (declarationParentType === "ExportNamedDeclaration") return true;
5302
5493
  return programBody.some((statement) => {
5303
- if (statement.type === "ExportNamedDeclaration" && statement.source == null) return (statement.specifiers ?? []).some((specifier) => specifier.local.type === "Identifier" && specifier.local.name !== void 0 && resolve(specifier.local.name) === aliasVariable);
5494
+ if (statement.type === "ExportNamedDeclaration" && (statement.source === void 0 || statement.source === null)) return (statement.specifiers ?? []).some((specifier) => specifier.local.type === "Identifier" && specifier.local.name !== void 0 && resolve(specifier.local.name) === aliasVariable);
5304
5495
  const target = statement.type === "ExportDefaultDeclaration" ? statement.declaration : void 0;
5305
5496
  return target?.type === "Identifier" && target.name !== void 0 && resolve(target.name) === aliasVariable;
5306
5497
  });
@@ -8474,6 +8665,7 @@ const plugin = {
8474
8665
  "no-array-isarray-mutation": noArrayIsArrayMutation,
8475
8666
  "no-boundaries-ignore": noBoundariesIgnore,
8476
8667
  "no-control-flow": noControlFlow,
8668
+ "no-defensive-fallback": noDefensiveFallback,
8477
8669
  "no-dependency-cycle": no_dependency_cycle_default,
8478
8670
  "no-enum-number-widening": noEnumNumberWidening,
8479
8671
  "no-enum-reverse-lookup-widening": noEnumReverseLookupWidening,
@@ -8524,6 +8716,7 @@ const plugin = {
8524
8716
  plugins: { exadev: plugin },
8525
8717
  linterOptions: { noInlineConfig: true },
8526
8718
  rules: {
8719
+ ...UNTYPED_ROBUSTNESS_RULES,
8527
8720
  "exadev/barrel-policy": ["error", { mode: "banned" }],
8528
8721
  "exadev/no-mutable-union-array-param": "error",
8529
8722
  "exadev/no-object-assign": "error",
@@ -8531,7 +8724,8 @@ const plugin = {
8531
8724
  "exadev/prefer-options-object-param": "error",
8532
8725
  "exadev/prefer-readonly-array-param": "error",
8533
8726
  "exadev/test-file-kind": "error",
8534
- "max-params": ["error", { max: 4 }]
8727
+ "max-params": ["error", { max: 4 }],
8728
+ "no-implicit-coercion": "error"
8535
8729
  }
8536
8730
  };
8537
8731
  },
@@ -8957,6 +9151,7 @@ const recommendedTypeChecked = [
8957
9151
  plugins: { exadev: plugin },
8958
9152
  linterOptions: { noInlineConfig: true },
8959
9153
  rules: {
9154
+ ...UNTYPED_ROBUSTNESS_RULES,
8960
9155
  "exadev/barrel-policy": "error",
8961
9156
  "exadev/no-array-isarray-mutation": "error",
8962
9157
  "exadev/no-enum-number-widening": "error",
@@ -8999,7 +9194,12 @@ const recommendedTypeChecked = [
8999
9194
  "@typescript-eslint/require-array-sort-compare": "error",
9000
9195
  "@typescript-eslint/strict-boolean-expressions": "error",
9001
9196
  "@typescript-eslint/strict-void-return": "error",
9002
- "@typescript-eslint/switch-exhaustiveness-check": "error",
9197
+ "@typescript-eslint/explicit-module-boundary-types": "error",
9198
+ "@typescript-eslint/switch-exhaustiveness-check": ["error", {
9199
+ considerDefaultExhaustiveForUnions: false,
9200
+ requireDefaultForNonUnion: true
9201
+ }],
9202
+ "no-implicit-coercion": ["error", { boolean: false }],
9003
9203
  "max-lines": ["error", {
9004
9204
  max: 800,
9005
9205
  skipBlankLines: true,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "exadev-eslint-config",
3
3
  "description": "Shared custom ESLint rules and plugin for ExaDev projects",
4
- "version": "2.28.0",
4
+ "version": "2.29.0",
5
5
  "dependencies": {
6
6
  "@eslint/js": "^10.0.1",
7
7
  "@microsoft/tsdoc": "0.17.0",