exadev-eslint-config 2.20.1 → 2.21.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 +129 -2
- package/dist/index.cjs +919 -24
- package/dist/index.d.cts +58 -1
- package/dist/index.d.ts +58 -1
- package/dist/index.js +921 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
> A real ESLint plugin (not a shareable config) exposing custom rules shared across ExaDev projects. Also published under the unscoped alias `exadev-eslint-config`.
|
|
6
6
|
|
|
7
|
-
**Contents:** [Why](#why) · [Getting started](#getting-started) · [The lighter option](#the-lighter-option-the-plugin-named-export) · [Optional features](#optional-features) · [Rules](#rules) · [Barrel policy](#barrel-policy) · [Development](#development) · [License](#license)
|
|
7
|
+
**Contents:** [Why](#why) · [Getting started](#getting-started) · [The lighter option](#the-lighter-option-the-plugin-named-export) · [Optional features](#optional-features) · [Rules](#rules) · [Barrel policy](#barrel-policy) · [Workspace architecture](#workspace-architecture) · [Development](#development) · [License](#license)
|
|
8
8
|
|
|
9
9
|
## Why
|
|
10
10
|
|
|
@@ -163,6 +163,7 @@ export default tseslint.config(
|
|
|
163
163
|
| [Gitignore-derived ignores](#gitignore-derived-ignores) | On if the project has a `.gitignore` | `exadevConfig({ gitignore })` |
|
|
164
164
|
| [RFC 8785 canonical JSON formatting](#rfc-8785-canonical-json-formatting) | Always on | Not optional |
|
|
165
165
|
| [package.json key ordering](#optional-packagejson-key-ordering) | On, unless the project already has a syncpack config | `exadevConfig({ packageJsonKeyOrder })` |
|
|
166
|
+
| [Workspace architecture rules](#workspace-architecture) | Off unless given (no sensible default for `groups`) | `exadevConfig({ workspaceArchitecture })` / `workspaceArchitectureConfig(options)` |
|
|
166
167
|
|
|
167
168
|
Every tri-state option above (`true`/`false`/`undefined`) is passed through the named `exadevConfig(options, ...userConfigs)` factory export:
|
|
168
169
|
|
|
@@ -273,7 +274,7 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
|
|
|
273
274
|
| `no-side-effects-in-index` | | **A barrel may contain only re-export statements** — nothing that could execute at import time. Self-scopes to any index file. |
|
|
274
275
|
| `barrel-direct-siblings-only` | | **A barrel may re-export only from a direct sibling** (`./module`), never a nested path, parent, or bare package specifier (mode 3). |
|
|
275
276
|
| `no-control-flow` | | **Bans `if`/`switch`/loops/the ternary operator outright.** Not part of `recommended` or `barrel` — ordinary code legitimately needs control flow, so this is opt-in, wired via a consumer's own `files` glob for the specific packages that want it (a composition-root package selecting an adapter/strategy by a validated key, say): a lookup table replaces a branch, a declarative array method (`map`/`filter`/`some`/`every`/...) replaces a loop. Requires no type information. |
|
|
276
|
-
| `no-pointless-reassignment` | ✓ | **Flags a `const` alias that adds no transformation** (`const foo = bar` where both sides are plain identifiers). Autofix rewrites every read to the original name and deletes the declaration
|
|
277
|
+
| `no-pointless-reassignment` | ✓ | **Flags a `const` alias that adds no transformation** (`const foo = bar` where both sides are plain identifiers). Autofix rewrites every read to the original name and deletes the declaration. Still reported but deliberately not auto-fixable where collapsing the alias would change meaning: an explicit type annotation (`const exhaustive: never = item` — the annotation is the point), a read where the original name is shadowed, a read as a shorthand object property, more than one declarator in the statement, or a source that is written to anywhere. An alias that is itself part of the module's exported surface (`export const alias = original;`, a later `export { alias }`/`export { alias as other }`, or `export default alias;`) is neither reported nor fixed at all, since collapsing it would rename or delete a binding every importer of this module depends on. |
|
|
277
278
|
| `no-object-assign` | ✓/suggestion | **`Object.assign` skips the type-checking object spread gets** — it doesn't check a source object's properties against the target's declared types. A fresh object-literal target autofixes to `{ ...target, ...source }`; mutating an existing reassignable binding offers a suggestion only (changes the object's identity); a `const` binding or a non-statement call site gets a plain report with no fix. |
|
|
278
279
|
| `no-mutable-union-array-param` | ✓ | **A union-typed array parameter can be mutated with a value the caller's narrower array never declared.** A function parameter typed as an array of a union (`(string \| number)[]`) accepts a narrower caller array (`number[]`) by covariance; calling `push`/`unshift`/`splice`/`fill`/`copyWithin` on it can then insert a value the caller's own array was never declared to hold. Autofix marks the parameter `readonly`, turning the mutating call into a real compile error to resolve deliberately. Requires no type information. |
|
|
279
280
|
| `prefer-readonly-array-param` | ✓ | **Every non-readonly array/tuple parameter should be `readonly`.** A narrower, safely-autofixable sibling of [`@typescript-eslint/prefer-readonly-parameter-types`](https://typescript-eslint.io/rules/prefer-readonly-parameter-types/) scoped to array/tuple parameter shapes only: fires unconditionally on every non-readonly array or tuple parameter, regardless of whether the function body mutates it, in any parameter position (a plain identifier, a rest parameter, a default-valued parameter, or a constructor parameter property) and any function-like shape (a concrete function/arrow/method, or a declaration-only ambient function, interface method, function type alias, call/construct signature, or abstract/ambient class method). A union containing an array/tuple member is fixed on that member alone. Autofix prepends `readonly ` (or renames `Array<T>` to `ReadonlyArray<T>`), turning any resulting mutation into a real compile error to resolve deliberately. Requires no type information — registered in both `plugin.configs.recommended` and the default (type-checked) export. |
|
|
@@ -286,6 +287,9 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
|
|
|
286
287
|
| `prefer-numeric-sort-compare` | suggestion | **`.sort()` on a number array sorts lexicographically by default.** A deliberately narrow addition alongside [`@typescript-eslint/require-array-sort-compare`](https://typescript-eslint.io/rules/require-array-sort-compare/) (which already flags any bare `.sort()`/`.toSorted()` except on a plain string array, with no fix): when the array's element type is definitively `number`, a suggestion offers an ascending compare function (`(a, b) => a - b`), since the default comparator sorts lexicographically (`[1, 2, 10].sort()` becomes `[1, 10, 2]`). Not a full autofix — descending order is a real, if less common, alternative intent. Requires type information — only in the default (type-checked) export, not `plugin.configs.recommended`, since it needs the checker to confirm the array's element type. |
|
|
287
288
|
| `prefer-options-object-param` | suggestion | **A run of 2+ trailing optional parameters (`?`-marked or default-valued) should be bundled into one destructured `options` parameter.** Without this, a caller needing only the last optional parameter must still pass `undefined` for every optional parameter before it (the motivating case: a 10-parameter constructor with 8 trailing optional ones). The threshold is configurable (`{ minTrailingOptional }`, default `2`). A suggestion (not a full autofix) rewrites the parameter list and inserts a `const { ... } = options ?? {};` destructure as the body's first statement; call sites are never rewritten, since the signature edit alone turns every stale positional call site into a real compile error. Still reported, but with no suggestion offered, whenever collapsing the run would not be safe/mechanical: a parameter property in the run, a parameter with no simple resolvable name and explicit type of its own (including a destructured parameter, or one typed only through a separately-declared function-type alias), a decorated parameter, a rest parameter anywhere in the full parameter list, no `BlockStatement` body at all (an arrow's expression body, or any declaration-only signature — including an ambient `.d.ts` function, an interface method/construct signature, or an abstract/ambient class method), a `@param` JSDoc tag naming a parameter in the run, or a parameter/function-scope-local variable already named `options`. Complements `max-params` (see "Individual rule tuning" above), which catches the different shape (excessive REQUIRED parameters) this rule is deliberately blind to. Requires no type information — registered in both `plugin.configs.recommended` and the default (type-checked) export, like `prefer-readonly-array-param` above; unlike its own `prefer-readonly-object-param`/`prefer-numeric-sort-compare` siblings, which genuinely need the checker and so cannot be offered outside the default export at all. |
|
|
288
289
|
| `package-json-key-order` | ✓ | **Requires `package.json`'s keys to match `syncpack format`'s order.** See [Optional package.json key ordering](#optional-packagejson-key-ordering) — opt-in via `exadevConfig({ packageJsonKeyOrder: true })`, not part of `recommended`/`barrel`. A JSON-language rule (`@eslint/json`'s `json/json`), not a TSESLint one — needs no type information and doesn't apply to any `.ts`/`.js` file. |
|
|
290
|
+
| `no-uphill-dependency` | | **Enforces a configured workspace's rank, rank-skip, slice and group-isolation boundaries** on every `package.json`'s declared dependencies. See [Workspace architecture](#workspace-architecture). Opt-in via `exadevConfig({ workspaceArchitecture })` or the standalone `workspaceArchitectureConfig()`, not part of `recommended`/`barrel`. A JSON-language rule, needs no type information. |
|
|
291
|
+
| `no-dependency-cycle` | | **Disallows a workspace dependency that can reach back to the package declaring it.** A same-rank or same-slice dependency passes `no-uphill-dependency` while still forming a cycle, which this rule catches instead. See [Workspace architecture](#workspace-architecture). |
|
|
292
|
+
| `package-name-mirrors-path` | | **Requires a workspace package's declared name to match the name its own path derives**, under the configured naming scope and separator. Itself opt-in within workspace architecture: a no-op unless the shared `naming` option is given. See [Workspace architecture](#workspace-architecture). |
|
|
289
293
|
| `test-file-kind` | | **A test file's name must declare its own test kind.** A filename suffix immediately before `.test`/`.spec` (e.g. `foo.unit.test.ts`), one of a configurable `{ kinds }` set (default: `unit`, `integration`, `e2e`). A naming-discipline rule, not a content classifier — it checks only the filename, never what the file actually tests. Self-scoped to real test/spec files (`context.filename`), so it never misfires when applied unscoped and never relies on a consumer's own `files` config. Requires no type information. |
|
|
290
294
|
|
|
291
295
|
## Barrel policy
|
|
@@ -310,6 +314,129 @@ Notes on `'auto'`:
|
|
|
310
314
|
|
|
311
315
|
In every mode, re-exports are banned outside a permitted barrel, and a permitted barrel may contain only re-export statements. The umbrella composes the identical predicates the standalone rules use (shared in [`src/rules/barrel-helpers.ts`](src/rules/barrel-helpers.ts)). It is non-fixable — the autofix lives on `no-non-barrel-reexport`.
|
|
312
316
|
|
|
317
|
+
## Workspace architecture
|
|
318
|
+
|
|
319
|
+
Three rules (`no-uphill-dependency`, `no-dependency-cycle`, `package-name-mirrors-path`) 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`. 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):
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
// eslint.config.ts
|
|
323
|
+
import { defineConfig } from 'eslint/config';
|
|
324
|
+
import { exadevConfig } from '@exadev/eslint-config';
|
|
325
|
+
|
|
326
|
+
export default defineConfig(
|
|
327
|
+
{
|
|
328
|
+
languageOptions: {
|
|
329
|
+
parserOptions: { project: './tsconfig.json', tsconfigRootDir: import.meta.dirname },
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
...exadevConfig({
|
|
333
|
+
workspaceArchitecture: {
|
|
334
|
+
groups: [
|
|
335
|
+
{ name: 'core', rank: 0 },
|
|
336
|
+
{ name: 'features', rank: 1 },
|
|
337
|
+
{ name: 'targets', rank: 2 },
|
|
338
|
+
],
|
|
339
|
+
},
|
|
340
|
+
}),
|
|
341
|
+
);
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
Both entry points need `@eslint/json` resolvable (`pnpm add -D @eslint/json`), the same optional peer [package.json key ordering](#optional-packagejson-key-ordering) uses; unlike that feature, workspace architecture has no tri-state auto-detection at all, since there is nothing to detect it against, only whether the option was given.
|
|
345
|
+
|
|
346
|
+
### Options
|
|
347
|
+
|
|
348
|
+
| Field | Meaning |
|
|
349
|
+
| --- | --- |
|
|
350
|
+
| `root` | The workspace root directory. Defaults to the nearest ancestor of the linted file that owns a `pnpm-workspace.yaml`. |
|
|
351
|
+
| `packages` | Workspace package globs (`pnpm-workspace.yaml` dialect: `*`, `**`, `!`-prefixed excludes). Defaults to that file's own top-level `packages:` block sequence. |
|
|
352
|
+
| `dependencyFields` | `package.json` fields read as a package's declared dependencies. Defaults to `['dependencies']`. |
|
|
353
|
+
| `groups` | The workspace's own directory taxonomy: `{ name, path?, rank?, slice?, naming? }`. `path` defaults to `name` (a group rooted at a directory of the same name). |
|
|
354
|
+
| `nameRanks` | The name-role rank model: `{ pattern, rank }[]`, a package's declared name checked against each `pattern` in order, first match wins, ahead of its own group's `rank`. Omit entirely for a pure group-rank model. |
|
|
355
|
+
| `defaultRank` | The rank a package gets when neither `nameRanks` nor its own group resolves one. |
|
|
356
|
+
| `rankSkip` | `{ maxDistance, exemptRanks }`. A dependency more than `maxDistance` ranks below its dependant, and not itself one of `exemptRanks` (typically a foundational rank 0), is a `rankSkip` violation. Omitted entirely, that check never runs. |
|
|
357
|
+
| `isolatedGroups` | `[groupName, groupName][]` pairs forbidden from depending on each other in either direction, on top of the rank/slice checks (for two groups that share a rank but must still stay separate). |
|
|
358
|
+
| `naming` | `{ scope?, separator? }`. Enables `package-name-mirrors-path`; omitted entirely, that rule is a no-op. |
|
|
359
|
+
|
|
360
|
+
A group's own `slice` (`{ segment: N }` or `{ namePrefix: true }`) partitions it further: two packages in the same or a differently-ranked group but a different slice (two feature verticals, say) are still isolated from each other, checked by the `crossSlice` violation. `{ segment: N }` reads the slice value directly from the package's own path (the Nth segment after the group's own root); `{ namePrefix: true }` is for a group with no such structure of its own (a flat `targets/` directory, say), whose packages instead take their slice from whichever other group's already-observed segment-derived value prefixes their own declared name.
|
|
361
|
+
|
|
362
|
+
### Example: a group-ranked repo
|
|
363
|
+
|
|
364
|
+
Every group states its own `rank` directly; `test` keeps its own path segment in the derived name (`@novus/test-database`, not `@novus/database`), which every other group drops:
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
// eslint.config.ts
|
|
368
|
+
import { defineConfig } from 'eslint/config';
|
|
369
|
+
import { plugin, workspaceArchitectureConfig } from '@exadev/eslint-config';
|
|
370
|
+
|
|
371
|
+
export default defineConfig(
|
|
372
|
+
// ...your own config...
|
|
373
|
+
{
|
|
374
|
+
plugins: { exadev: plugin },
|
|
375
|
+
},
|
|
376
|
+
...workspaceArchitectureConfig({
|
|
377
|
+
groups: [
|
|
378
|
+
{ name: 'core', rank: 0 },
|
|
379
|
+
{ name: 'features', rank: 1 },
|
|
380
|
+
{ name: 'product', rank: 2 },
|
|
381
|
+
{ name: 'targets', rank: 3 },
|
|
382
|
+
{ name: 'test', rank: 4, naming: 'keep-group' },
|
|
383
|
+
],
|
|
384
|
+
naming: { scope: '@novus' },
|
|
385
|
+
}),
|
|
386
|
+
);
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### Example: a name-ranked repo
|
|
390
|
+
|
|
391
|
+
Rank comes from a package's own declared name, not its directory; `packages/targets` infers its slice from whichever feature/product vertical its own name is prefixed with, and `rankSkip` lets a target reach a rank-0 contract directly while still blocking it from skipping straight past `store-application-context` to an adapter:
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
// eslint.shared.ts
|
|
395
|
+
import { exadevConfig } from '@exadev/eslint-config';
|
|
396
|
+
|
|
397
|
+
export default exadevConfig({
|
|
398
|
+
workspaceArchitecture: {
|
|
399
|
+
groups: [
|
|
400
|
+
{ name: 'core', path: 'packages/core' },
|
|
401
|
+
{ name: 'features', path: 'packages/features', slice: { segment: 0 } },
|
|
402
|
+
{ name: 'product', path: 'packages/product', slice: { segment: 0 } },
|
|
403
|
+
{ name: 'targets', path: 'packages/targets', slice: { namePrefix: true } },
|
|
404
|
+
],
|
|
405
|
+
nameRanks: [
|
|
406
|
+
{ pattern: '-contract$', rank: 0 },
|
|
407
|
+
{ pattern: '-adapter-|-schema$|^store-api-router$', rank: 1 },
|
|
408
|
+
{ pattern: '^store-application-context$', rank: 2 },
|
|
409
|
+
],
|
|
410
|
+
defaultRank: 3,
|
|
411
|
+
rankSkip: { maxDistance: 1, exemptRanks: [0] },
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### Example: exchange-platform
|
|
417
|
+
|
|
418
|
+
`features` and `verticals` sit at the same rank, and both slice on their own first path segment, so a feature and a vertical for the same conceptual area (`store`, say) share a slice value and would otherwise pass the `crossSlice` check as though related; `isolatedGroups` is what actually keeps them from depending on each other, entirely independently of slice:
|
|
419
|
+
|
|
420
|
+
```ts
|
|
421
|
+
// eslint.shared.ts
|
|
422
|
+
import { exadevConfig } from '@exadev/eslint-config';
|
|
423
|
+
|
|
424
|
+
export default exadevConfig({
|
|
425
|
+
workspaceArchitecture: {
|
|
426
|
+
groups: [
|
|
427
|
+
{ name: 'core', rank: 0 },
|
|
428
|
+
{ name: 'features', rank: 1, slice: { segment: 0 } },
|
|
429
|
+
{ name: 'verticals', rank: 1, slice: { segment: 0 } },
|
|
430
|
+
{ name: 'product', rank: 2 },
|
|
431
|
+
{ name: 'targets', rank: 3 },
|
|
432
|
+
{ name: 'test', rank: 4, naming: 'keep-group' },
|
|
433
|
+
],
|
|
434
|
+
isolatedGroups: [['features', 'verticals']],
|
|
435
|
+
naming: { scope: '@exacap' },
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
```
|
|
439
|
+
|
|
313
440
|
## Development
|
|
314
441
|
|
|
315
442
|
### Build, test, and lint
|