exadev-eslint-config 2.20.2 → 2.21.1

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
@@ -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
 
@@ -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, matching pnpm's own `pnpm-workspace.yaml` glob support (`*`, `**`, `[...]` character classes, `{...}` brace expansion, `!`-prefixed excludes; a wildcard segment never matches a name starting with "."). Defaults to that file's own top-level `packages:` block sequence, quoted or bare. |
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
@@ -372,6 +499,7 @@ pnpm build
372
499
  - [Husky](https://github.com/typicode/husky) hooks: `pre-commit` runs [lint-staged](https://github.com/lint-staged/lint-staged#readme) (`eslint --fix` on staged `*.ts`), `commit-msg` runs commitlint, `pre-push` runs typecheck + test + build.
373
500
  - The CI release job sets `HUSKY=0` (commit-msg hook skips the automated release commit) and blanks `NPM_TOKEN`/`NODE_AUTH_TOKEN` explicitly so an inherited token can't win over [OIDC trusted publishing](https://docs.npmjs.com/trusted-publishers).
374
501
  - A consumer who already has `eslint-plugin-react`/`@next/eslint-plugin-next` resolvable for unrelated reasons (e.g. hoisted in a monorepo) and writes `.jsx`/`.tsx` files may see new rule activity the moment they upgrade to a version of this package that ships React/Next.js support — with zero action on their part. See the compatibility note under [Optional React and Next.js support](#optional-react-and-nextjs-support).
502
+ - [Workspace architecture](#workspace-architecture)'s dependency graph is cached for the life of the process, keyed by workspace root plus the resolved options, with no invalidation of its own. A long-running ESLint process (an editor's language server, most notably) that renames a package or edits its declared dependencies after that root/options combination's first lint keeps serving the stale graph built before the edit, until the process restarts. Both prior local implementations this feature replaced (Novus hive's and the monorepo-template's own workspace rule sets) carried the identical limitation.
375
503
 
376
504
  ### Contributing
377
505