exadev-eslint-config 2.25.0 → 2.27.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 +274 -3
- package/dist/index.cjs +3289 -1369
- package/dist/index.d.cts +115 -1
- package/dist/index.d.ts +116 -2
- package/dist/index.js +3292 -1375
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -168,6 +168,9 @@ export default tseslint.config(
|
|
|
168
168
|
| [package.json key ordering](#optional-packagejson-key-ordering) | On, unless the project already has a syncpack config | `exadevConfig({ packageJsonKeyOrder })` |
|
|
169
169
|
| [Workspace architecture rules](#workspace-architecture) | Off unless given (no sensible default for `groups`) | `exadevConfig({ workspaceArchitecture })` / `workspaceArchitectureConfig(options)` |
|
|
170
170
|
| [Turbo rules](#turbo) | Off unless given (only a repository can say it uses turbo) | `exadevConfig({ turbo })` / `turboConfig(options)` |
|
|
171
|
+
| [Import policy](#import-policy) | Off unless given (only a repository can say which imports it forbids) | `exadevConfig({ importPolicies })` / `importPolicyConfig(policies)` |
|
|
172
|
+
| [Pure modules](#pure-modules) | Off unless given (only a repository can say which modules are a functional core) | `exadevConfig({ pureModules })` / `pureModulesConfig(options)` |
|
|
173
|
+
| [Guard and conformance test hygiene](#guard-and-conformance-test-hygiene) | Off unless given (only a repository can say which tests are guards); needs the optional peer `@vitest/eslint-plugin` | `exadevConfig({ testHygiene })` / `testHygieneConfig(options)` |
|
|
171
174
|
| [Turbo environment variable checking](#environment-variables-read-in-source) | Auto-detected: on if `eslint-plugin-turbo` is installed | `exadevConfig({ turboEnv })` |
|
|
172
175
|
|
|
173
176
|
Every tri-state option above (`true`/`false`/`undefined`) is passed through the named `exadevConfig(options, ...userConfigs)` factory export:
|
|
@@ -298,7 +301,7 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
|
|
|
298
301
|
| `no-non-barrel-reexport` | ✓ | **Re-exports belong only in a barrel.** Catches the split form across two statements (`import { x } from './y'; export { x };` or `export default x;`) which no AST selector alone can match. Autofix deletes the export and the now-pointless import when it was the import's only use. Self-scopes away from any index file. |
|
|
299
302
|
| `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. |
|
|
300
303
|
| `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). |
|
|
301
|
-
| `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. |
|
|
304
|
+
| `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. See also [Pure modules](#pure-modules), which can add it to a block, and [a complexity ceiling](#a-complexity-ceiling-for-logic-free-modules) for a softer limit. |
|
|
302
305
|
| `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. |
|
|
303
306
|
| `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. |
|
|
304
307
|
| `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. |
|
|
@@ -332,6 +335,14 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
|
|
|
332
335
|
| `turbo-boundaries-script` | | **The root package has a `boundaries` script equal to `turbo boundaries`,** invoked from the configured aggregate script. See [Turbo boundaries](#turbo-boundaries). |
|
|
333
336
|
| `no-boundaries-ignore` | | **Bans the `@boundaries-ignore` comment** outside files listed with a reason. A JavaScript and TypeScript rule. See [Turbo boundaries](#turbo-boundaries). |
|
|
334
337
|
| `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. |
|
|
338
|
+
| `required-exports` | | **Files matching a glob must export the configured names.** `{ files, exports }` entries; presence only, no shape check. See [File-level rules](#file-level-rules). |
|
|
339
|
+
| `required-imports` | | **Files matching a glob must import, and optionally call, a module matching a specifier pattern.** See [File-level rules](#file-level-rules). |
|
|
340
|
+
| `import-policy` | | **Glob-scoped import policy:** deny lists, specifiers confined to named files, and exact exception edges, with a message that names the requirement. Wired by `importPolicyConfig`. See [Import policy](#import-policy). |
|
|
341
|
+
| `filename-pattern` | | **Filename conventions by glob:** a name regex, a required sibling file, and a naming scheme required once a file passes a line count. See [Filename patterns](#filename-patterns). |
|
|
342
|
+
| `pure-module` | | **Bans I/O, ambient state and `async` in the files it is wired onto:** imports of Node I/O modules, I/O and scheduling globals, argument-less `Date`, `Date.now`, `Math.random`, and `async`/`await`. Wired by `pureModulesConfig`. See [Pure modules](#pure-modules). |
|
|
343
|
+
| `scoped-first-parameter` | | **Every method of the configured repository-like interfaces takes a scope parameter first.** Checks the signature only, not that the scope is used or that tenants are isolated. Requires type information. Opt-in: needs its `interfaces` and `parameter` options. See [Scoped first parameter](#scoped-first-parameter). |
|
|
344
|
+
| `injected-test-hygiene` | | **A conformance kit's injected test functions get the same hygiene as imported ones:** no `.only`, no `.skip`, and an assertion in every test body, for the `describe` and `it` a kit receives as parameters, which `@vitest/eslint-plugin` skips. Wired by `testHygieneConfig`. See [Guard and conformance test hygiene](#guard-and-conformance-test-hygiene). |
|
|
345
|
+
| `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). |
|
|
335
346
|
|
|
336
347
|
## Barrel policy
|
|
337
348
|
|
|
@@ -355,6 +366,241 @@ Notes on `'auto'`:
|
|
|
355
366
|
|
|
356
367
|
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`.
|
|
357
368
|
|
|
369
|
+
## File-level rules
|
|
370
|
+
|
|
371
|
+
`required-exports`, `required-imports`, `import-policy` and `filename-pattern` check one file against a convention that depends on its path. Each self-scopes through `context.filename`, so it is a no-op on every other file and can sit in a shared config without a `files` array of its own. None needs type information, and none is in `recommended`, since each takes options only a project can supply.
|
|
372
|
+
|
|
373
|
+
`files` in these rules is a glob, or a list of globs, in the dialect used for every file glob in this package (braces, `*`, `?`, `[...]`, `**` as whole segments, a leading `!` to exclude, wildcards never matching dot-prefixed names), matched against the path relative to ESLint's working directory. A glob without a `/` names a file at any depth, so `fake.ts` and `**/fake.ts` are the same.
|
|
374
|
+
|
|
375
|
+
### Required exports
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
'exadev/required-exports': ['error', [
|
|
379
|
+
{ files: '**/contract/src/errors.ts', exports: ['ContractError', 'contractErrorSchema'] },
|
|
380
|
+
{ files: 'fake.ts', exports: ['createFake'] },
|
|
381
|
+
]]
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
A file matching `files` must export every name in `exports`. Counted: `export const`, `let`, `var` (destructuring included), functions, classes, enums, interfaces, type aliases and namespaces, `export { a, b as c }` with or without `from`, `export type { ... }`, `export * as ns from`, and `export default` as the name `default`. `export = x` (a CommonJS-style TypeScript module) counts nothing, since it exports the value rather than a name. A bare `export * from './x'` counts nothing, because the names it forwards live in a file this rule does not read; list the names in an explicit re-export if a barrel must satisfy the rule. When several entries match one file, the file is reported once, listing every missing name.
|
|
385
|
+
|
|
386
|
+
### Required imports
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
'exadev/required-imports': ['error', [
|
|
390
|
+
{
|
|
391
|
+
files: '**/adapters/*/src/**/*.test.ts',
|
|
392
|
+
from: ['**/contract/src/*conformance*', '@acme/contract/conformance'],
|
|
393
|
+
call: true,
|
|
394
|
+
},
|
|
395
|
+
]]
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
A file matching `files` must import at least one binding from a module whose specifier matches `from` (a pattern or a list). With `call: true` it must also call a binding it imported from there, which a bare import that is never used does not satisfy. A call counts wherever it sits (module top level or inside a callback), including through a namespace or default import (`kit.run(...)`), a non-null assertion (`kit!()`), a construction (`new Kit()`) and a tagged template (``kit`x` ``); passing the binding as an argument is not a call. Side-effect imports and type-only imports (`import type`, or every specifier inline `type`) bind no runtime value and do not count.
|
|
399
|
+
|
|
400
|
+
Resolution is by specifier pattern, not through the module graph, so no type information is needed and the package need not be installed. A `from` pattern follows the rules in [Specifier patterns](#specifier-patterns): a relative specifier such as `../../contract/src/run-conformance` is matched by the path it resolves to (the reference implementation's form), and a package specifier such as `@acme/contract/conformance/pg` is matched as written (the adapter's form). List both forms in `from` to cover a kit imported either way. The rule proves the kit is wired in, not that the kit is any good, and a skipped test still satisfies it.
|
|
401
|
+
|
|
402
|
+
Each unsatisfied entry reports once on the program node.
|
|
403
|
+
|
|
404
|
+
### Specifier patterns
|
|
405
|
+
|
|
406
|
+
`required-imports` (`from`) and [`import-policy`](#import-policy) (`specifiers`) match module specifiers the same way:
|
|
407
|
+
|
|
408
|
+
- Patterns use the file-glob dialect, and each also selects everything beneath it, so `fs` selects `fs/promises` and `@scope/pkg` selects `@scope/pkg/sub`.
|
|
409
|
+
- A leading `node:` is ignored on both sides, so `fs` and `node:fs` are one builtin.
|
|
410
|
+
- A relative specifier is resolved against the linted file's directory to a path relative to the working directory, then matched only by patterns containing a `/`. A bare pattern such as `fs` therefore never selects `./fs`. A relative specifier that leaves the working directory matches nothing.
|
|
411
|
+
- Nothing is resolved through `node_modules` or the TypeScript path map, so an alias such as `@/db` is matched as the string it is.
|
|
412
|
+
|
|
413
|
+
## Import policy
|
|
414
|
+
|
|
415
|
+
`importPolicyConfig` turns a list of policies into a flat-config block for `exadev/import-policy`, or pass the same list to `exadevConfig({ importPolicies })`.
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
import { defineConfig } from 'eslint/config';
|
|
419
|
+
import { exadevConfig, importPolicyConfig } from '@exadev/eslint-config';
|
|
420
|
+
|
|
421
|
+
export default defineConfig(
|
|
422
|
+
...exadevConfig(),
|
|
423
|
+
...importPolicyConfig([
|
|
424
|
+
{
|
|
425
|
+
files: ['src/worker/**'],
|
|
426
|
+
deny: [{ specifiers: ['fs', 'path', 'child_process'], message: 'worker code runs where Node builtins do not exist' }],
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
files: ['src/**'],
|
|
430
|
+
ignores: ['src/**/*.test.ts'],
|
|
431
|
+
confine: [{ specifiers: ['@anthropic-ai/sdk'], onlyIn: ['src/agent/adapter.ts'], allowTypeImports: true }],
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
files: ['src/routes/**'],
|
|
435
|
+
deny: [{ specifiers: ['src/db'], message: 'routes reach data through the service layer' }],
|
|
436
|
+
exceptEdges: [{ file: 'src/routes/legacy.ts', specifier: '../db/client', reason: 'predates the service layer' }],
|
|
437
|
+
},
|
|
438
|
+
]),
|
|
439
|
+
);
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
A policy selects files with `files` and `ignores` and holds them to:
|
|
443
|
+
|
|
444
|
+
- `deny`: `specifiers` the files may not import. `message` is required and should name the requirement (why, and what to do instead), not the rule. `importNames` limits the ban to those imported names (`default` for a default import); a namespace import, a dynamic import, `require` and `export *` take every name, so they are still reported. `allowTypeImports` leaves erased imports alone.
|
|
445
|
+
- `confine`: `specifiers` the files may import only in the files `onlyIn` selects, so the inverse case needs no hand-built complement. `allowTypeImports` and an optional `message` work as for `deny`; the default message lists `onlyIn`.
|
|
446
|
+
- `exceptEdges`: one exact `file` and `specifier` pair with a required `reason`. Neither may contain a glob character, so an exception cannot widen. The specifier is compared exactly as written in that file, and `file` is normalised, so `./src/a.ts` and `src/a.ts` name the same file. Creating the config throws for an exception that could never apply (a file the policy does not select, or a specifier that no `deny` entry selects and no `confine` entry forbids in that file, such as one inside the confine's `onlyIn`), so a stale exception fails instead of lingering.
|
|
447
|
+
|
|
448
|
+
Specifiers follow [Specifier patterns](#specifier-patterns). The rule is syntactic and covers `import`, `import type`, `export ... from`, `export * from`, `import x = require()`, dynamic `import()` with a static string, `require()` with a static string that is not shadowed, and `import('x')` type queries. A specifier built at runtime cannot be judged and is skipped.
|
|
449
|
+
|
|
450
|
+
ESLint's core `no-restricted-imports` covers only the static forms (`import`, `export ... from`, `export * from` and `import x = require()`); it has no check for dynamic `import()`, `require()` or `import('x')` type queries, and `no-restricted-modules` is deprecated since ESLint 7. A preset compiled to `no-restricted-imports` blocks would also inherit flat config's rule-level override: when two policies select the same file, the later block's option list replaces the earlier one's, and an exception edge would have to restate every other restriction of the file. So the preset compiles to one block for a purpose-built rule instead, where every policy that selects a file applies to it. Files outside a policy's `files` are untouched.
|
|
451
|
+
|
|
452
|
+
## Pure modules
|
|
453
|
+
|
|
454
|
+
A functional core should not touch I/O, read the clock, roll dice or wait on anything. `pureModulesConfig` (or `exadevConfig({ pureModules })`) wires `exadev/pure-module` onto the files that must stay pure:
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
import { defineConfig } from 'eslint/config';
|
|
458
|
+
import { exadevConfig, pureModulesConfig } from '@exadev/eslint-config';
|
|
459
|
+
|
|
460
|
+
export default defineConfig(
|
|
461
|
+
...exadevConfig(),
|
|
462
|
+
...pureModulesConfig({
|
|
463
|
+
files: ['src/core/**', '!src/core/**/*.gen.ts'],
|
|
464
|
+
allowImports: ['node:stream'],
|
|
465
|
+
noControlFlow: true,
|
|
466
|
+
}),
|
|
467
|
+
);
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
The same object goes to `exadevConfig({ pureModules: { files: ['src/core/**'] } })`. `files` follows the [file glob dialect](#file-level-rules) used across this package, with a leading `!` excluding. Like any flat-config `files` list, a glob that ends in `/**` or `/*` selects files only alongside a block that names their extension, which `exadevConfig()` provides for JavaScript and TypeScript.
|
|
471
|
+
|
|
472
|
+
In the selected files the rule reports:
|
|
473
|
+
|
|
474
|
+
- an import of a Node I/O module (`child_process`, `fs`, `http`, `net`, `os`, `process`, `stream`, `worker_threads` and their kin, the full list being `BANNED_MODULES` in [`src/rules/pure-module-options.ts`](src/rules/pure-module-options.ts)), through any syntax `import-policy` recognises. Subpaths and the `node:` form are covered by the module name. A type-only import is left alone, since it is erased before the module runs;
|
|
475
|
+
- a read of an I/O, scheduling or environment global (`fetch`, `process`, `console`, `setTimeout`, `WebSocket`, `Worker`, `localStorage`, `document` and the rest of `BANNED_GLOBALS`), including through `globalThis`;
|
|
476
|
+
- `Date.now`, `Math.random`, `performance.now`, `crypto.randomUUID`, `crypto.getRandomValues`, and `Date()` or `new Date()` with no argument. `new Date(value)` is pure and passes. These are reported when reached through `globalThis` too (`globalThis.Date.now()`). Node's `crypto` module is not banned as a whole, since hashing is pure, but its sources of randomness and key generation (`randomUUID`, `randomBytes`, `randomInt`, `randomFillSync`, the `generate*` functions, `getRandomValues`, `webcrypto`; the full list is `NODE_CRYPTO_NONDETERMINISTIC` in the options file) are reported as named imports from it and as members of a default or namespace import of it;
|
|
477
|
+
- an `async` function or method, `await`, and `for await`.
|
|
478
|
+
|
|
479
|
+
`allowImports` lists specifiers, in the [specifier pattern](#specifier-patterns) dialect, exempted from the module ban. Every entry must select a banned module, so an entry that could never apply fails when the config is created. `noControlFlow: true` adds `exadev/no-control-flow` to the same block, for a module that should hold lookup tables and nothing else.
|
|
480
|
+
|
|
481
|
+
It is one rule under one name, not a `no-restricted-imports`, `no-restricted-globals` and `no-restricted-syntax` recipe, because flat config replaces a rule's options when a later block sets the same rule for the same files. A consumer's own `no-restricted-syntax` block over the pure files would silently drop a recipe's entries; it cannot drop these. The bans are syntactic. They keep a module from reaching for ambient state directly, and they do not follow an alias (`const { random } = Math`) or prove the module deterministic. A repository needing a different list writes its own `no-restricted-*` blocks.
|
|
482
|
+
|
|
483
|
+
### A complexity ceiling for logic-free modules
|
|
484
|
+
|
|
485
|
+
`exadev/no-control-flow` is all or nothing: a module either may branch or may not. For view adapters and thin entry points that should hold almost no logic, a scoped `complexity` ceiling is the softer statement, and needs no new rule. Set it in a block for those globs, placed after the shared config:
|
|
486
|
+
|
|
487
|
+
```ts
|
|
488
|
+
import { defineConfig } from 'eslint/config';
|
|
489
|
+
import { exadevConfig } from '@exadev/eslint-config';
|
|
490
|
+
|
|
491
|
+
export default defineConfig(
|
|
492
|
+
...exadevConfig(),
|
|
493
|
+
{
|
|
494
|
+
files: ['src/views/**/*.ts', 'src/main.ts'],
|
|
495
|
+
rules: { complexity: ['error', { max: 2 }] },
|
|
496
|
+
},
|
|
497
|
+
);
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
`complexity` counts each function on its own, starting at 1 and adding one per branch, so `max: 2` allows a single `if`, ternary or `&&` per function. Flat config keeps the last value a block sets for a rule, so a broader `complexity` setting that follows this block over the same files replaces the ceiling; keep the scoped block last.
|
|
501
|
+
|
|
502
|
+
## Guard and conformance test hygiene
|
|
503
|
+
|
|
504
|
+
Source-scanning guard tests and conformance suites are worth having only if they cannot silently stop running. A `.skip`, a stray `.only` or a test with no assertion leaves CI green while checking nothing, and so does a scan that discovers no files. `testHygieneConfig` (or `exadevConfig({ testHygiene })`) holds both kinds of file to that:
|
|
505
|
+
|
|
506
|
+
```ts
|
|
507
|
+
import { defineConfig } from 'eslint/config';
|
|
508
|
+
import { exadevConfig } from '@exadev/eslint-config';
|
|
509
|
+
|
|
510
|
+
export default defineConfig(
|
|
511
|
+
...exadevConfig({
|
|
512
|
+
testHygiene: {
|
|
513
|
+
guardFiles: ['**/*.guard.test.ts'],
|
|
514
|
+
conformanceFiles: ['**/*conformance*.test.ts', 'packages/*/src/conformance.ts'],
|
|
515
|
+
assertFunctionNames: ['check*'],
|
|
516
|
+
skippableFiles: ['**/live.conformance.test.ts'],
|
|
517
|
+
},
|
|
518
|
+
}),
|
|
519
|
+
);
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
Every field is optional and `{}` enables the defaults. The preset needs `@vitest/eslint-plugin`, an optional peer (`pnpm add -D @vitest/eslint-plugin`). Giving the option without it throws with the install command, as does an installed version that lacks `expect-expect`, `no-disabled-tests` or `no-focused-tests`. The plugin is registered under `vitest`, the namespace its own documentation uses.
|
|
523
|
+
|
|
524
|
+
| Field | Meaning |
|
|
525
|
+
| --- | --- |
|
|
526
|
+
| `guardFiles` | Guard test globs. Default `**/*.guard.test.ts`. These get the three vitest rules below, `exadev/injected-test-hygiene` and `exadev/non-vacuous-guard`. |
|
|
527
|
+
| `conformanceFiles` | Conformance suite globs. Default `**/*conformance*.test.ts`. These get the three vitest rules and `exadev/injected-test-hygiene`. A kit that is not named `*.test.ts` is listed by its own name (`conformance.ts`). |
|
|
528
|
+
| `assertFunctionNames` | Helper names counted as assertions, besides `expect` and `assert`, by `expect-expect` and by `exadev/injected-test-hygiene`. A kit that asserts by throwing from local helpers never calls `expect`. `*` matches any run of characters (`check*`), and a name also matches its members (`assert` matches `assert.equal`). |
|
|
529
|
+
| `skippableFiles` | Globs of files that are deliberately skipped, exempted from `no-disabled-tests` and from the `.skip` report of `exadev/injected-test-hygiene`, and nothing else. Flat-config globs, so a bare name needs `**/`. |
|
|
530
|
+
|
|
531
|
+
A leading `!` in `guardFiles` or `conformanceFiles` excludes, for that list only. All three vitest rules are set to `error`: `no-focused-tests`, `expect-expect` and `no-disabled-tests`. `describe.skipIf`, `describe.runIf` and `it.todo` are not reported by `no-disabled-tests`, so an opt-in project is written as `describe.skipIf(!process.env['LIVE'])` and needs no exemption; `skippableFiles` is for a file that is skipped unconditionally. Only vitest is supported: eslint-plugin-jest has rules of the same names, which a jest repository wires by hand, and `non-vacuous-guard` reads `expect` chains that both frameworks share.
|
|
532
|
+
|
|
533
|
+
A conformance kit usually takes its test functions as parameters (`runConformance({ describe, it })`, or `run(t)` calling `t.it(...)`) so that each adapter can run it under its own runner. `@vitest/eslint-plugin` resolves such a parameter as a local binding and reports nothing inside the function, so `expect-expect`, `no-disabled-tests` and `no-focused-tests` alone would pass a kit full of `.skip`, `.only` and assertion-free tests. `exadev/injected-test-hygiene` covers that shape, in the same files and with the same `assertFunctionNames` and `skippableFiles`: it reports `.only`, `.skip` (`.skipIf` is not reported) and a test body with no assertion, for a parameter of the enclosing function named `describe`, `suite`, `it` or `test` (directly, destructured, or renamed in the destructuring) and for those names read off any parameter (`t.it`). It is syntactic: a helper the kit calls counts as an assertion only when listed in `assertFunctionNames`, and an imported or global `it` is left to the vitest rules. A kit that is not named `*.test.ts` is listed in `conformanceFiles` by its own name so that both sets of rules reach it.
|
|
534
|
+
|
|
535
|
+
`guard` and `conformance` are test kinds to [`test-file-kind`](#rules), which by default accepts `unit`, `integration` and `e2e` only, so a repository using the default globs adds them:
|
|
536
|
+
|
|
537
|
+
```ts
|
|
538
|
+
{ rules: { 'exadev/test-file-kind': ['error', { kinds: ['unit', 'integration', 'e2e', 'guard', 'conformance'] }] } }
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
### Non-vacuous guards
|
|
542
|
+
|
|
543
|
+
The vitest rules cannot catch the commonest way a guard goes quiet: a scan that discovers no files, or a pattern that matches nothing, passes every assertion with no skip, no focus and no assertion-free test. `exadev/non-vacuous-guard` requires each guard file to contain all three of:
|
|
544
|
+
|
|
545
|
+
- a lower bound on what it discovered: `toBeGreaterThan(n)` with `n` at least 0, `toBeGreaterThanOrEqual(n)` with `n` at least 1, `toHaveLength(n)` with `n` at least 1, or `not.toHaveLength(0)`, `not.toBe(0)`, `not.toEqual([])`. A bound written as a name (`MINIMUM_FILES`) is accepted, since its value cannot be read from the syntax;
|
|
546
|
+
- a check that its pattern flags an input it must: `toMatch`, or on a call subject `toBe(true)`, `toBeTruthy()`, `not.toBeNull()`, `toEqual([violation])` and the like, as in `expect(pattern.test(violation)).toBe(true)`. `toBeDefined()` and `toBeUndefined()` are not recognised, since a pattern match returns `null` and on a read they only state that a file exists. An assertion counts once: `not.toEqual([])` is the lower bound above and never also a check of the pattern;
|
|
547
|
+
- a check that it leaves alone an input it must not: `not.toMatch`, or on a call subject `toBe(false)`, `toBeFalsy()`, `toBeNull()`, `toEqual([])` and the like.
|
|
548
|
+
|
|
549
|
+
Only assertions that run unconditionally count. One inside a loop, a `forEach`/`map`/`filter` callback, the mapper of `Array.from`, the callback of an `it.each`, `describe.each` or `.for` table that may be empty (a table literal with a row always runs), a branch, a `switch` case or a `catch` never runs when what it iterates is empty, so `for (const file of files) expect(read(file)).not.toMatch(pattern)` is the scan itself and satisfies none of the three. A helper function that asserts counts, since whether it is called is not visible.
|
|
550
|
+
|
|
551
|
+
The rule is syntactic. It shows the guard file states each of the three, not that the pattern checked is the one the scan uses or that the discovered count is the scan's; a table-driven check whose expected values are variables (`toBe(expected)`) is not recognised, so write the catch and the pass as separate assertions.
|
|
552
|
+
|
|
553
|
+
## Scoped first parameter
|
|
554
|
+
|
|
555
|
+
In a multi-tenant codebase, every method of a repository or store interface should take the tenant scope first, so that a call cannot be written without one. `exadev/scoped-first-parameter` reports the methods that do not:
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
// eslint.config.ts
|
|
559
|
+
export default defineConfig(
|
|
560
|
+
...exadevConfig(),
|
|
561
|
+
{
|
|
562
|
+
files: ['src/**/*.ts'],
|
|
563
|
+
plugins: { exadev: plugin },
|
|
564
|
+
rules: {
|
|
565
|
+
'exadev/scoped-first-parameter': ['error', { interfaces: 'Repository$|Store$', parameter: { name: 'scope', type: 'TenantScope' } }],
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
);
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
```ts
|
|
572
|
+
interface OrderRepository {
|
|
573
|
+
find(scope: TenantScope, id: OrderId): Promise<Order | undefined>; // ok
|
|
574
|
+
list(): Promise<Order[]>; // reported: takes no parameters
|
|
575
|
+
findAll(ids: OrderId[]): Promise<Order[]>; // reported: first parameter is OrderId[], not TenantScope
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
`interfaces` is a regular expression tested against the name of each interface and type alias; only the members of a matching declaration are checked. `parameter.type` is the declared name of the type the first parameter must resolve to, and the optional `parameter.name` fixes the parameter's name. The annotation is followed through the checker to the declarations it names: an import rename, `import Scope = Auth.TenantScope` and a type alias of a type alias (`type Scope = TenantScope`) all lead back to `TenantScope`, and a scope declared as an alias of a generic instantiation (`type TenantScope = Readonly<{ id: string }>`) or of a primitive (`type TenantId = string`) is named by the alias that declares it. A type parameter constrained to the scope (`<S extends TenantScope>(scope: S)`) stands for the scope. A structurally identical type of another name does not count, and neither does a union that merely includes the scope, nor the scope wrapped in another type (`Readonly<TenantScope>`; declare the scope itself as the readonly alias instead). Only the members a matching interface or type alias declares are checked, not those it inherits through `extends` from a base that does not match `interfaces`. Method signatures and function-typed properties are both checked; an optional or rest first parameter is reported, since a call could then leave the scope out. TypeScript's `this` pseudo-parameter is skipped.
|
|
580
|
+
|
|
581
|
+
This checks signatures, not isolation. A method can accept a `TenantScope` and ignore it, or read across tenants anyway, so a clean run shows only that no call can be written without a scope. Showing that tenants are actually isolated takes conformance tests run against each implementation, which [`required-imports`](#required-imports) can require every adapter to wire in.
|
|
582
|
+
|
|
583
|
+
## Filename patterns
|
|
584
|
+
|
|
585
|
+
```ts
|
|
586
|
+
'exadev/filename-pattern': ['error', [
|
|
587
|
+
// A name convention: stories are PascalCase.stories.tsx.
|
|
588
|
+
{ files: '**/*.stories.tsx', pattern: '[A-Z][A-Za-z0-9]*\\.stories\\.tsx' },
|
|
589
|
+
// A relationship between two files: a component needs its stylesheet beside it.
|
|
590
|
+
{ files: ['src/components/*.tsx', '!**/*.stories.tsx'], sibling: ['{name}.module.css', '{name}.module.scss'] },
|
|
591
|
+
// A relationship between size and name: a file over 400 lines must be a numbered part.
|
|
592
|
+
{ files: 'src/**/*.ts', overLines: 400, pattern: '.+\\.part-\\d{2}\\.ts' },
|
|
593
|
+
]]
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
An entry applies to files matching `files` (and, with `overLines`, to those with more lines than that) and needs a `pattern`, a `sibling`, or both:
|
|
597
|
+
|
|
598
|
+
- `pattern` is a regular expression source that the whole file name (last path segment, extension included) must match. The source must be a valid regular expression on its own, so an unbalanced one such as `z)|(q` is rejected instead of escaping the anchoring.
|
|
599
|
+
- `sibling` is a path or list of paths relative to the linted file's directory; at least one must exist. `{name}` stands for the file's name without its final extension (`Button` for `Button.tsx`), and a path may enter a subdirectory (`styles/{name}.css`). Templates must be distinct, and a brace group other than `{name}` is rejected as a misspelling. To exempt other files, exclude them in `files`.
|
|
600
|
+
- `overLines` counts physical lines, ignoring the newline that ends the file. It is a naming requirement, so it does not replace ESLint's `max-lines`, which still reports the size; this rule requires that the parts an over-long file is split into follow the scheme. It does not check that the parts exist as a sequence.
|
|
601
|
+
|
|
602
|
+
Why not a dependency: [`eslint-plugin-check-file`](https://github.com/dukeluo/eslint-plugin-check-file) (`filename-naming-convention`, `folder-match-with-fex`, `filename-blocklist`, `folder-naming-convention`, `no-index`) and [`unicorn/filename-case`](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/filename-case.md) cover case styles and per-glob name patterns, and check-file also folder names and blocked names. Neither can express that another file must exist beside the linted one, or that a name depends on the file's line count, which are the two cases this rule exists for. The name-pattern case is the same as what check-file offers, so a project that already uses that plugin for case styles and folder layout can keep it alongside this rule.
|
|
603
|
+
|
|
358
604
|
## Workspace architecture
|
|
359
605
|
|
|
360
606
|
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):
|
|
@@ -738,23 +984,48 @@ pnpm build
|
|
|
738
984
|
- [`src/rules/file-scope.ts`](src/rules/file-scope.ts) lets a rule scope itself by filename, so it still behaves when a consumer wires it onto a wider `files` list.
|
|
739
985
|
- `createFileScope(globs)` returns a `(filename, cwd) => boolean` matching the file relative to `cwd` in the same glob dialect as the workspace `packages` globs (braces, `*`, `?`, `[...]`, `**`, `!` excludes, wildcards never matching dot-prefixed names). A file outside `cwd` (its relative path starts with `..`) is never in scope, whatever the patterns. Build it once per `create()`.
|
|
740
986
|
- `fileGlobsSchema` and `readFileGlobs` are the option schema and runtime validator for a glob list; a list needs at least one include and may not repeat a glob.
|
|
987
|
+
- [`src/rules/file-entry.ts`](src/rules/file-entry.ts) is the shared option handling for the per-file rules (`required-exports`, `required-imports`, `import-policy`, `filename-pattern`): `createEntryScope` widens a glob without a `/` to any depth on top of `createFileScope`, and the `read*` helpers validate entries and reject unknown keys.
|
|
988
|
+
- [`src/rules/specifier-match.ts`](src/rules/specifier-match.ts) matches import specifiers against patterns for `required-imports` and `import-policy`, reusing the path matcher `createPathMatcher` in `file-scope.ts`.
|
|
989
|
+
- [`src/import-policy.ts`](src/import-policy.ts) builds the `importPolicyConfig` block from the validated policies in [`src/rules/import-policy-options.ts`](src/rules/import-policy-options.ts), which the rule reads with the same reader.
|
|
990
|
+
- [`src/pure-modules.ts`](src/pure-modules.ts) builds the `pureModulesConfig` block: `files` becomes the block's `files` and `ignores`, and the rule options are read by [`src/rules/pure-module-options.ts`](src/rules/pure-module-options.ts), which also holds the ban lists. [`src/rules/pure-module.ts`](src/rules/pure-module.ts) reuses `moduleReferenceOf` from `import-policy.ts` so every import syntax is recognised the same way.
|
|
991
|
+
- [`src/test-hygiene.ts`](src/test-hygiene.ts) builds the `testHygieneConfig` blocks: it resolves the optional `@vitest/eslint-plugin` through `tryRequire`, checks the three rules it relies on exist, and emits the vitest rules and `exadev/injected-test-hygiene` per list of globs, plus `exadev/non-vacuous-guard` for guard files. Tests supply a plugin through `assembleTestHygieneConfig`, so the public options carry no resolver seam. [`src/config-globs.ts`](src/config-globs.ts) turns a validated glob list into a block's `files` and `ignores`, shared with the pure-module builder.
|
|
741
992
|
- [`src/rules/file-reference.ts`](src/rules/file-reference.ts) is the option shape for a rule that reads another file: `{ path, relativeTo? }`, resolved against the linted file's directory (`file`, the default) or the workspace root (`root`, found the way the workspace architecture rules find it).
|
|
742
993
|
- `readReferencedJson` parses the target as JSONC through [`src/rules/jsonc.ts`](src/rules/jsonc.ts) (comments, trailing commas and a leading byte order mark), returning `undefined` for a missing file and throwing, naming the path, for one that does not parse.
|
|
743
994
|
- [`src/turbo-config.ts`](src/turbo-config.ts) builds the turbo blocks (`buildTurboConfig` internally, `turboConfig` publicly) from the one shared options object in [`src/rules/turbo-options.ts`](src/rules/turbo-options.ts).
|
|
744
995
|
- The pure decisions live in [`src/rules/turbo-checks.ts`](src/rules/turbo-checks.ts), independent of ESLint, so they are tested against plain maps; the rules are thin visitors over them. [`src/rules/turbo-json.ts`](src/rules/turbo-json.ts) reads the parts of a `turbo.json` the rules need and finds the root one, [`src/rules/turbo-workspace.ts`](src/rules/turbo-workspace.ts) lists the packages and their scripts, and [`src/rules/turbo-commands.ts`](src/rules/turbo-commands.ts) reads a script command for a delegation, the tasks it invokes, or a boundaries run.
|
|
745
996
|
- Rules that read sibling and ancestor files take a `WorkspaceFs` through their factory, and their tests use [`src/rules/memory-fs.ts`](src/rules/memory-fs.ts), an in-memory implementation, rather than fixture trees on disk.
|
|
746
997
|
- [`src/create-config.ts`](src/create-config.ts) is config assembly's single source of truth.
|
|
747
|
-
- `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), and any trailing user configs.
|
|
998
|
+
- `exadevConfig(options, ...userConfigs)` concatenates, in order: `buildGitignoreConfig`, `recommendedTypeChecked`, `jsdocAndTsdoc`, `jsonCanonicalConfig`, `stylisticCommentsConfig`, `buildReactConfig`, `buildNextjsConfig`, `buildPackageJsonKeyOrderConfig` (each tri-state builder fed its matching option), `buildWorkspaceArchitectureConfig` (only when `workspaceArchitecture` is given; there is no auto-detected default), `buildTurboConfig` (only when `turbo` is given, likewise), `buildImportPolicyConfig` (only when `importPolicies` is given, likewise), `buildPureModulesConfig` (only when `pureModules` is given, likewise), `buildTestHygieneConfig` (only when `testHygiene` is given, likewise), and any trailing user configs.
|
|
748
999
|
- `defaultConfig` is `exadevConfig()` evaluated once, eagerly, at module load.
|
|
749
1000
|
- [`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).
|
|
750
1001
|
- [`src/index.ts`](src/index.ts) is the entry point, still a pure re-export barrel:
|
|
751
1002
|
```ts
|
|
752
1003
|
export { defaultConfig as default, exadevConfig } from './create-config';
|
|
1004
|
+
export { importPolicyConfig } from './import-policy';
|
|
753
1005
|
export { publicPlugin as plugin } from './plugin';
|
|
1006
|
+
export { pureModulesConfig } from './pure-modules';
|
|
1007
|
+
export { testHygieneConfig } from './test-hygiene';
|
|
754
1008
|
export { turboConfig } from './turbo-config';
|
|
755
1009
|
export { workspaceArchitectureConfig } from './workspace-architecture';
|
|
1010
|
+
export type { PureModulesOptions } from './pure-modules';
|
|
1011
|
+
export type { TestHygieneOptions } from './test-hygiene';
|
|
1012
|
+
export type { ImportConfine, ImportDeny, ImportExceptEdge, ImportPolicy } from './rules/import-policy-options';
|
|
1013
|
+
export type { FilenamePatternEntry } from './rules/filename-pattern';
|
|
1014
|
+
export type { RequiredExportsEntry } from './rules/required-exports';
|
|
1015
|
+
export type { RequiredImportsEntry } from './rules/required-imports';
|
|
756
1016
|
export type { GroupSpec, NamingOptions, RankRule, RankSkipOptions, SliceSpec, WorkspaceArchitectureOptions } from './rules/workspace-options';
|
|
757
|
-
export type {
|
|
1017
|
+
export type {
|
|
1018
|
+
AllowedEdge,
|
|
1019
|
+
ExemptTargetGroup,
|
|
1020
|
+
PackageSelector,
|
|
1021
|
+
PackageSelectorFields,
|
|
1022
|
+
RequiredFiles,
|
|
1023
|
+
RequiredScripts,
|
|
1024
|
+
ScriptContent,
|
|
1025
|
+
ScriptRequirement,
|
|
1026
|
+
} from './rules/workspace-constraint-options';
|
|
1027
|
+
export type { AggregateTaskOptions, AllowedBoundariesIgnore, BoundaryGroup, TaskGraphRequirement, TurboBoundariesOptions, TurboHygieneOptions, TurboOptions } from './rules/turbo-options';
|
|
1028
|
+
export type { TurboDelegate } from './rules/turbo-commands';
|
|
758
1029
|
```
|
|
759
1030
|
- The named export is `publicPlugin`, not `plugin`'s own internal default export: `plugin` (src/plugin.ts) is typed against `@typescript-eslint/utils`' `TSESLint.FlatConfig.Plugin` for full rule-option checking while this package assembles it, then cast once, at the very end of that same file, to `PublicPlugin` (see `src/to-public-plugin.ts`) before re-export, mirroring how `defaultConfig` is built against the internal `ConfigArrayValue` and cast to `PublicConfigArray` at `exadevConfig`'s own boundary.
|
|
760
1031
|
- Required by `no-side-effects-in-index`/`no-non-barrel-reexport`, both of which assume this file contains nothing but `export ... from ...`.
|