exadev-eslint-config 2.29.0 → 2.31.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 +240 -3
- package/dist/index.cjs +1901 -225
- package/dist/index.d.cts +196 -44
- package/dist/index.d.ts +196 -44
- package/dist/index.js +1899 -226
- package/package.json +2 -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) · [Workspace architecture](#workspace-architecture) · [Turbo](#turbo) · [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) · [Tool config files](#tool-config-files) · [Tooling wiring](#tooling-wiring) · [Verifying ESLint is applied](#verifying-eslint-is-applied) · [Workspace architecture](#workspace-architecture) · [Turbo](#turbo) · [Development](#development) · [License](#license)
|
|
8
8
|
|
|
9
9
|
## Why
|
|
10
10
|
|
|
@@ -199,6 +199,7 @@ export default tseslint.config(
|
|
|
199
199
|
| [Import policy](#import-policy) | Off unless given (only a repository can say which imports it forbids) | `exadevConfig({ importPolicies })` / `importPolicyConfig(policies)` |
|
|
200
200
|
| [Pure modules](#pure-modules) | Off unless given (only a repository can say which modules are a functional core) | `exadevConfig({ pureModules })` / `pureModulesConfig(options)` |
|
|
201
201
|
| [Guard and conformance test hygiene](#guard-and-conformance-test-hygiene) | Off unless given (only a repository can say which tests are guards); needs the optional peer `@vitest/eslint-plugin` | `exadevConfig({ testHygiene })` / `testHygieneConfig(options)` |
|
|
202
|
+
| [Tooling wiring](#tooling-wiring) | Off unless given (only a repository can say it wants its publish checks, root tooling and hooks enforced); the package.json sections need the optional peer `@eslint/json` | `exadevConfig({ toolingWiring })` / `toolingWiringConfig(options)` |
|
|
202
203
|
| [Turbo environment variable checking](#environment-variables-read-in-source) | Auto-detected: on if `eslint-plugin-turbo` is installed | `exadevConfig({ turboEnv })` |
|
|
203
204
|
| [Required Markdown headings](#required-markdown-headings) | Off unless given (only a repository can say which documents need which headings); needs the optional peer `@eslint/markdown` | `exadevConfig({ markdownHeadings })` / `markdownHeadingsConfig(options)` |
|
|
204
205
|
|
|
@@ -379,6 +380,7 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
|
|
|
379
380
|
| `package-has-files` | | **Requires configured files to exist inside every matching workspace package.** ESLint cannot report a file that does not exist, so the diagnostic is anchored on the package's own `package.json`, listing the missing paths. Entries may be globs. Opt-in: a no-op unless the shared `requiredFiles` option is given. See [Required files](#required-files). |
|
|
380
381
|
| `dev-dependency-only` | | **A package may only appear under `devDependencies` of other workspace packages.** Reports a restricted package listed under `dependencies`, `peerDependencies` or `optionalDependencies`, at the offending entry. Opt-in: a no-op unless the shared `devOnly` option is given. See [Dev-only packages](#dev-only-packages). |
|
|
381
382
|
| `required-scripts` | | **Requires configured `scripts` in every matching workspace package,** optionally with an exact command or required and forbidden flags. Opt-in: a no-op unless the shared `requiredScripts` option is given. See [Required scripts](#required-scripts). |
|
|
383
|
+
| `package-requirements` | | **Requires configured fields, files and scripts in each `package.json` a condition selects:** private or publishable, the repository root, a name pattern, a declared dependency. The graph-free counterpart of `package-has-files` and `required-scripts`, so it applies to a single-package repository and to the root manifest. Wired by `toolingWiringConfig`. See [Tooling wiring](#tooling-wiring). |
|
|
382
384
|
| `turbo-script-convention` | | **Public scripts delegate to turbo.** In the root package every prefixed script (`_lint`) needs a public counterpart (`lint`) that is `turbo run _lint`, with only flags after it; in any other package a bare script named after a task the root orchestrates is reported. See [Turbo](#turbo). Opt-in via `exadevConfig({ turbo })` or `turboConfig()`. A JSON-language rule. |
|
|
383
385
|
| `turbo-script-has-task` | | **Every prefixed script is configured as a turbo task,** since turbo runs a script only when a task names it. See [Turbo](#turbo). |
|
|
384
386
|
| `turbo-task-has-script` | | **Every task in the root `turbo.json` is implemented and reachable.** A task no package implements is skipped silently by turbo; a `//#` task or an aggregate nothing depends on or invokes never runs. See [Turbo](#turbo). |
|
|
@@ -398,6 +400,12 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
|
|
|
398
400
|
| `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). |
|
|
399
401
|
| `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). |
|
|
400
402
|
| `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). |
|
|
403
|
+
| `require-compiler-options` | | **The effective compiler options of the tsconfig ESLint type-checks against must have the configured values.** `extends` is resolved the way `tsc` resolves it. Reports once per tsconfig and run, and names the tsconfig it resolved. Requires type information. Opt-in. See [Compiler options](#compiler-options). |
|
|
404
|
+
| `vitest-config` | | **A Vitest config must not stop the run from failing:** `passWithNoTests: true`, `allowOnly: true`, a bare `node_modules` exclude, and coverage thresholds under a key Vitest reads as a file glob. Self-scopes to `vitest*.config.*` and `vitest.workspace.*`. Opt-in. See [Tool config files](#tool-config-files). |
|
|
405
|
+
| `vitest-coverage-config` | | **The shared base Vitest config must spell out a complete `coverage` block:** `provider`, `include` and all four `thresholds` keys. A no-op unless `files` names the base config files. See [Tool config files](#tool-config-files). |
|
|
406
|
+
| `stryker-break-threshold` | | **A Stryker config must set `thresholds.break` to a number,** optionally no lower than a minimum and no lower than the value in a shared base config. Self-scopes to `stryker*.config.*`. See [Tool config files](#tool-config-files). |
|
|
407
|
+
| `stryker-thresholds-order` | | **Stryker `thresholds` must satisfy `break <= low <= high`,** using Stryker's defaults for a key the config leaves out. See [Tool config files](#tool-config-files). |
|
|
408
|
+
| `playwright-config` | | **A Playwright config must set `forbidOnly`** to `true` or a non-literal expression such as `!!process.env.CI`, with optional checks that `fullyParallel` and `workers` are set. Self-scopes to `playwright*.config.*`. See [Tool config files](#tool-config-files). |
|
|
401
409
|
| `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). |
|
|
402
410
|
| `non-vacuous-guard` | | **A guard test must show it can fail:** an unconditional lower bound on what it discovered, and its pattern checked against an input it must catch and one it must not. Wired by `testHygieneConfig`. See [Guard and conformance test hygiene](#guard-and-conformance-test-hygiene). |
|
|
403
411
|
| `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). |
|
|
@@ -793,6 +801,227 @@ export default defineConfig(...exadevConfig(), {
|
|
|
793
801
|
|
|
794
802
|
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
803
|
|
|
804
|
+
## Compiler options
|
|
805
|
+
|
|
806
|
+
Several enforcement patterns hold only while the compiler is strict: an exhaustive `satisfies Record<Command['type'], Handler>` map, `noUncheckedIndexedAccess` narrowing, `exactOptionalPropertyTypes`. Turning `strict` off in a tsconfig silently disables all of them, and nothing in the config notices. `exadev/require-compiler-options` reports it.
|
|
807
|
+
|
|
808
|
+
```ts
|
|
809
|
+
// eslint.config.ts
|
|
810
|
+
import { defineConfig } from 'eslint/config';
|
|
811
|
+
import { exadevConfig } from '@exadev/eslint-config';
|
|
812
|
+
|
|
813
|
+
export default defineConfig(
|
|
814
|
+
...exadevConfig(),
|
|
815
|
+
{
|
|
816
|
+
files: ['**/*.ts'],
|
|
817
|
+
rules: {
|
|
818
|
+
'exadev/require-compiler-options': [
|
|
819
|
+
'error',
|
|
820
|
+
{
|
|
821
|
+
strict: true,
|
|
822
|
+
noUncheckedIndexedAccess: true,
|
|
823
|
+
exactOptionalPropertyTypes: true,
|
|
824
|
+
noImplicitReturns: true,
|
|
825
|
+
noImplicitOverride: true,
|
|
826
|
+
noFallthroughCasesInSwitch: true,
|
|
827
|
+
noPropertyAccessFromIndexSignature: true,
|
|
828
|
+
noUncheckedSideEffectImports: true,
|
|
829
|
+
noUnusedLocals: true,
|
|
830
|
+
noUnusedParameters: true,
|
|
831
|
+
verbatimModuleSyntax: true,
|
|
832
|
+
isolatedModules: true,
|
|
833
|
+
strictBuiltinIteratorReturn: true,
|
|
834
|
+
forceConsistentCasingInFileNames: true,
|
|
835
|
+
allowUnreachableCode: false,
|
|
836
|
+
allowUnusedLabels: false,
|
|
837
|
+
target: ['es2022', 'es2023', 'es2024', 'esnext'],
|
|
838
|
+
},
|
|
839
|
+
],
|
|
840
|
+
},
|
|
841
|
+
},
|
|
842
|
+
);
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
Each key is a compiler option and each value says what the effective option must be: `true`, `false`, or a list of accepted values. An enum-valued option such as `target`, `module` or `moduleResolution` takes its tsconfig spelling, case-insensitively, and the rule converts it with the compiler's own tsconfig parser, so a list of accepted values is also how "at least" is written. An unknown option, a value the compiler rejects and a list-valued option such as `lib` throw when the rule is created, quoting the compiler.
|
|
846
|
+
|
|
847
|
+
The rule asks the program ESLint already has which tsconfig governs the file (`program.getCompilerOptions().configFilePath`, so `parserOptions.project` and `projectService` both work) and reads that configuration through TypeScript's own config parser, which follows the whole `extends` chain exactly as `tsc` does. A flag inherited from a shared base counts, and a flag the base sets but a package overrides does not. It re-reads the tsconfig rather than trusting `program.getCompilerOptions()` because typescript-eslint overrides `noEmit`, `noUnusedLocals`, `noUnusedParameters`, `allowJs` and `checkJs` in the programs it builds, so those options would look satisfied whatever the tsconfig says. A program with no tsconfig behind it, and none attributed to it as described below, has nothing to re-read and is judged on its own options, in which those five are the overridden values.
|
|
848
|
+
|
|
849
|
+
The value compared is the one the compiler acts on: a flag that follows `strict` (`strictNullChecks`, `noImplicitAny`) is judged on its effective value, and an option that is unset takes the plain default the installed compiler documents for it. An option whose unset state is neither true nor false, such as `allowUnreachableCode`, is reported as not set even when `false` is required, because `false` is a different setting from leaving it out. This reads the compiler's own option metadata, which TypeScript exports at run time without publishing in its typings, and needs a release that has it; the rule throws a clear error naming the requirement otherwise.
|
|
850
|
+
|
|
851
|
+
A tsconfig governs every file of its program, so the rule reports once per tsconfig and rule configuration per run, on the first file ESLint lints under it, and not once per source file. ESLint gives a rule no signal that a run has started, so the rule reads it from the order of the files it lints: a file it has already linted since the last report starts a new run, which a long-lived process (an editor integration, an ESLint instance that lints more than once) meets when it lints that file again in its next run, so the finding is reported again while the tsconfig still violates the requirement. A file linted again straight after itself with different text is ESLint applying fixes, which re-lints the fixed text and keeps only the last pass's messages, or an editor re-linting an edit; that lint repeats the file's previous outcome, so a run that applies fixes still reports once, on the same file. A run that lints only files not linted since the last report cannot be told apart from the same run and reports nothing.
|
|
852
|
+
|
|
853
|
+
The rule keeps this per tsconfig and not per TypeScript program, because typescript-eslint replaces programs. It rebuilds a watch or project service program after an edit, and when it infers a single run (`TSESTREE_SINGLE_RUN=true`, or by default `CI=true` or the `eslint` binary, either without `--fix`) it builds one program per tsconfig ahead of time and parses a file it has already parsed in the process, as in a fix pass through ESLint's API, with a program of that one file and no tsconfig. Such a program is attributed to the tsconfig the file was last linted under, as long as that tsconfig still lists the file, so the file is judged against that tsconfig and not against the throwaway program's defaults.
|
|
854
|
+
|
|
855
|
+
The message names the tsconfig it resolved and lists each option whose effective value differs together with the value required. `parserOptions.project` or `projectService` may select a different tsconfig from the one the build uses (a `tsconfig.eslint.json`, say), in which case the rule checks the lint tsconfig and says nothing about the build; the two need to agree for the check to mean anything. A root tsconfig that covers only tooling files, with each package carrying its own, makes the same point: the tsconfig the rule sees is the one that contains the file being linted.
|
|
856
|
+
|
|
857
|
+
Keep the strictness flags in a base config that compiles nothing and have the root and every package extend it. `skipLibCheck` is common in shared bases and weakens the checking of declaration files, so requiring it `false` is possible (`skipLibCheck: false`) but belongs in a repository that wants it, not in a default set. `isolatedDeclarations` is the compiler-side way to require explicit types on exported declarations and can be added to the set as `isolatedDeclarations: true`; `@typescript-eslint/explicit-module-boundary-types` in the default export is the lint-side alternative.
|
|
858
|
+
|
|
859
|
+
The rule needs type information, so it is in neither `plugin.configs.recommended` nor the default export. ESLint's `--cache` keys on a file's content and the ESLint configuration, not on tsconfig contents, so clear the cache after changing a tsconfig or the option set.
|
|
860
|
+
|
|
861
|
+
## Tool config files
|
|
862
|
+
|
|
863
|
+
Tool configs decide whether the tools they configure enforce anything, and several can be set so that they enforce nothing: a test runner that passes when it finds no tests, `allowOnly` left on so a focused test reaches CI, coverage with no thresholds, a mutation run with no `break` threshold. These rules lint the config files as ordinary TypeScript. They read the object the file exports and run no tool.
|
|
864
|
+
|
|
865
|
+
Each rule scopes itself by filename, so it can be switched on for every JavaScript and TypeScript file and acts only on the files it knows:
|
|
866
|
+
|
|
867
|
+
| Rule | Built-in scope |
|
|
868
|
+
| --- | --- |
|
|
869
|
+
| `vitest-config` | `vitest*.config.*` and `vitest.workspace.*`, so `vitest.mutation.config.ts` is covered |
|
|
870
|
+
| `vitest-coverage-config` | none: only the files named in its `files` option |
|
|
871
|
+
| `stryker-break-threshold`, `stryker-thresholds-order` | `stryker*.config.*`, `stryker*.conf.*` and the dot-prefixed `.stryker.config.*` and `.stryker.conf.*` |
|
|
872
|
+
| `playwright-config` | `playwright*.config.*` |
|
|
873
|
+
|
|
874
|
+
Every rule takes a `files` option, a glob or list of globs in the [file glob dialect](#file-level-rules), that replaces the built-in scope; a glob without a `/` names a file at any depth.
|
|
875
|
+
|
|
876
|
+
**Only what the file spells out is judged.** A rule follows the default export (`export default`, `export =` or `module.exports =`) through an object literal, an array, a call to a config helper (`defineConfig`, `defineProject`, `defineWorkspace`, `mergeConfig`), the returned value of a function, either branch of a conditional, and a top-level `const`. A value that is the result of any other call, an import, a spread or a computed key is not visible, and the rule stays silent about it instead of guessing. A package config that merges a shared base and sets only a few keys is therefore judged only on those keys, which is why the checks that need a complete picture run on the base file itself (`vitest-coverage-config`, and the `base` option of `stryker-break-threshold`).
|
|
877
|
+
|
|
878
|
+
```ts
|
|
879
|
+
// eslint.config.ts
|
|
880
|
+
import { defineConfig } from 'eslint/config';
|
|
881
|
+
import { exadevConfig } from '@exadev/eslint-config';
|
|
882
|
+
|
|
883
|
+
export default defineConfig(
|
|
884
|
+
...exadevConfig(),
|
|
885
|
+
{
|
|
886
|
+
files: ['**/*.config.{ts,mts,cts,js,mjs,cjs}'],
|
|
887
|
+
rules: {
|
|
888
|
+
'exadev/vitest-config': 'error',
|
|
889
|
+
'exadev/vitest-coverage-config': ['error', { files: ['vitest.base.config.ts'] }],
|
|
890
|
+
'exadev/stryker-break-threshold': ['error', { min: 80, base: { path: 'stryker.base.config.ts', relativeTo: 'root' } }],
|
|
891
|
+
'exadev/stryker-thresholds-order': 'error',
|
|
892
|
+
'exadev/playwright-config': 'error',
|
|
893
|
+
},
|
|
894
|
+
},
|
|
895
|
+
);
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
### Vitest
|
|
899
|
+
|
|
900
|
+
`exadev/vitest-config` reports, in the `test` object of a config and of every inline project in `test.projects` (at any depth) and in the project objects of a `vitest.workspace.*` file:
|
|
901
|
+
|
|
902
|
+
- `passWithNoTests: true`, which makes the run pass when no test file is found, so a mistyped include glob or a deleted suite goes unnoticed;
|
|
903
|
+
- `allowOnly: true`, which lets a committed `.only` through (Vitest's own default already allows it only outside CI);
|
|
904
|
+
- the bare string `node_modules` (or `node_modules/`) in `test.exclude`, which excludes only a top-level directory of that name; `**/node_modules/**` is the form that also excludes nested copies. `test.coverage.exclude` is not checked: coverage filtering matches with `contains: true`, so a bare name there also ignores nested copies;
|
|
905
|
+
- a key under `test.coverage.thresholds` that Vitest does not read. Verified against the installed Vitest source, which skips `statements`, `branches`, `functions`, `lines`, `perFile`, `autoUpdate` and `100` and treats every other key as a file glob matched against the covered files, so thresholds nested under a word such as `global` match no file and enforce nothing. A key counts as intended for a glob or a file when it contains a path separator, a dot (so a root file name such as `index.ts` is accepted) or one of `*`, `?`, `[`, `]`, `{`, `}`, `!`; anything else is reported. This was checked against Vitest 4 only.
|
|
906
|
+
|
|
907
|
+
The option `forbidNumericMaxWorkers: true` also reports a numeric `test.maxWorkers`, for a repository whose task runner already parallelises across packages and where a fixed worker count in each package multiplies. A percentage string or a computed value passes. The same setting in a `package.json` script is the concern of [`required-scripts`](#required-scripts): `{ name: 'test', excludes: ['--passWithNoTests'] }` reports the flag on the command line.
|
|
908
|
+
|
|
909
|
+
`exadev/vitest-coverage-config` takes `files`, the shared base config files, and does nothing without it. In those files, every `test.coverage` block that is an object literal must set `provider`, `include` and a `thresholds` object with `statements`, `branches`, `functions` and `lines`, and one diagnostic lists everything missing. Per-package configs merge the base with `mergeConfig` and often set only `coverage.exclude`, so requiring thresholds in every file would report each of them for what it inherits.
|
|
910
|
+
|
|
911
|
+
### Stryker
|
|
912
|
+
|
|
913
|
+
`exadev/stryker-break-threshold` requires `thresholds.break` to be a number, since a run with no `break` (the default is `null`) never fails however low the mutation score falls. A missing `thresholds`, a missing `break`, `null`, `undefined` and a non-number are reported. A mutation job may deliberately run without `break` until a real threshold has been measured, so the rule is its own rule: set it to `'warn'`, or switch it off for those files, without touching the ordering check.
|
|
914
|
+
|
|
915
|
+
A `break` is a gate only when it is high enough to fail something, and a value of 2 never does. Two options set a floor:
|
|
916
|
+
|
|
917
|
+
- `min`, a number from 0 to 100;
|
|
918
|
+
- `base`, a file reference (`{ path, relativeTo? }`, resolved against the linted file's directory or the workspace root) to the shared Stryker config. The rule parses that file, takes its `thresholds.break`, and reports a package whose own value is lower, so a package cannot quietly undercut the base. It throws, naming the file, when the base does not exist or spells out no numeric `break`, rather than comparing against nothing.
|
|
919
|
+
|
|
920
|
+
With both, the higher floor is the one named in the message.
|
|
921
|
+
|
|
922
|
+
`exadev/stryker-thresholds-order` requires `break <= low <= high`. A key left out takes Stryker's documented default (`high` 80, `low` 60) and `break` has none, so `thresholds: { break: 70 }` is reported because it exceeds the default `low`: the low and high marks are then unreachable on a passing run, which usually means they were left at their defaults when the break was raised. Equal marks are allowed, since `{ high: 100, low: 100, break: 100 }` is the strictest valid configuration. Stryker itself rejects only a `low` above `high`.
|
|
923
|
+
|
|
924
|
+
### Playwright
|
|
925
|
+
|
|
926
|
+
`exadev/playwright-config` requires `forbidOnly` to be set, because Playwright defaults it to false and a committed `test.only` then runs one test and passes. `true` and any non-literal expression pass, so `forbidOnly: !!process.env.CI` is accepted; an absent key, `false`, `undefined` and `null` are reported. Two options add checks that a setting is present: `fullyParallel: true` requires `fullyParallel` to be set and not false, and `workers: true` requires `workers` to be set to anything, so the worker count on CI and locally is a stated choice. Only the top level of the config is read.
|
|
927
|
+
|
|
928
|
+
## Tooling wiring
|
|
929
|
+
|
|
930
|
+
A repository that publishes a package or runs a shared toolchain wires in the same auxiliary tools: package-shape checks before publishing, dead-code detection, dependency-version consistency, commit hooks. Each is dropped one repository at a time without anything noticing, and a package that stops running its shape checks before publishing looks identical to one that never had them. `toolingWiringConfig` (or `exadevConfig({ toolingWiring })`) holds a repository to the wiring and wires the [tool config rules](#tool-config-files) beside it. Every section is on unless given as `false`, and `{}` enables all of them:
|
|
931
|
+
|
|
932
|
+
```ts
|
|
933
|
+
// eslint.config.ts
|
|
934
|
+
import { defineConfig } from 'eslint/config';
|
|
935
|
+
import { exadevConfig } from '@exadev/eslint-config';
|
|
936
|
+
|
|
937
|
+
export default defineConfig(
|
|
938
|
+
...exadevConfig({
|
|
939
|
+
toolingWiring: {
|
|
940
|
+
publish: { tools: ['publint', 'attw'], smokeProject: 'smoke' },
|
|
941
|
+
root: { tools: ['knip', 'syncpack'] },
|
|
942
|
+
hooks: true,
|
|
943
|
+
toolConfigs: { stryker: { min: 80 }, vitest: { coverageFiles: ['vitest.base.config.ts'] } },
|
|
944
|
+
},
|
|
945
|
+
}),
|
|
946
|
+
);
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
| Section | Held to | Applies to |
|
|
950
|
+
| --- | --- | --- |
|
|
951
|
+
| `publish` | `prepublishOnly` runs each of `tools` (default `publint` and `attw`), `engines` is set, and the optional `smokeProject` directory exists (only its presence is checked, not what it imports). | Every `package.json` that does not set `"private": true`. |
|
|
952
|
+
| `root` | A configuration for each of `tools` (default `knip` and `syncpack`), a script of the same name that runs the tool, and `packageManager` is set. | The `package.json` at the repository root: the nearest ancestor of the manifest that holds a `pnpm-workspace.yaml`, a `package.json` listing `workspaces`, or a `.git` entry. |
|
|
953
|
+
| `hooks` | `prepare` runs `husky` and the `.husky` directory exists. | Every `package.json` that declares `husky` under `dependencies`, `devDependencies`, `peerDependencies` or `optionalDependencies`. |
|
|
954
|
+
| `toolConfigs` | The [tool config rules](#tool-config-files) for Vitest, Stryker and Playwright, each given the options of its tool (`forbidNumericMaxWorkers` and `coverageFiles`, `min` and `base`, `fullyParallel` and `workers`), or `false` to leave a tool out. | The JavaScript and TypeScript config files each rule scopes itself to. |
|
|
955
|
+
|
|
956
|
+
`prepublishOnly` is checked as the command written, split into whole tokens, so `tsdown && publint && attw --pack` and `pnpm run publint && pnpm run attw` both satisfy `tools: ['publint', 'attw']`, and no script named after the tool is needed. A root tool's configuration is accepted as any of the file names the tool reads (`knip.json`, `knip.jsonc`, `.knip.json`, `.knip.jsonc`, `knip.ts`, `knip.js`, `knip.config.ts`, `knip.config.js` for knip; `.syncpackrc` and its `.json`, `.yaml`, `.yml`, `.js`, `.ts`, `.mjs` and `.cjs` forms, and `syncpack.config.js`, `.ts`, `.mjs` and `.cjs` for syncpack), or as a `knip` property (knip) or a `syncpack` or `config.syncpack` property (syncpack) of `package.json`. The root script must run the tool: its command, split into whole tokens, contains the tool's name, so `knip --production` counts and `echo skip` does not. The names were read from the tools' own source and binary, so a release that reads a further name needs this list extended. The root is found from the manifest's location, not from where ESLint runs, so `eslint .` run from inside a workspace package (as a turbo `_lint` task does) does not hold that package to the root requirements; a tree with none of those markers is rooted where ESLint runs. A repository with no knip or syncpack setup gives `root: false` or lists the tools it has, and one that uses another hook manager gives `hooks: false`.
|
|
957
|
+
|
|
958
|
+
The package.json sections need `@eslint/json`, an optional peer (`pnpm add -D @eslint/json`), and throw with the install command when it cannot be resolved. `toolConfigs` needs nothing, and a tool whose config files the repository does not have is left alone, because each rule scopes itself by file name. The tool config blocks cover only `.ts`, `.mts`, `.cts`, `.js`, `.mjs` and `.cjs` files, so a `vitest.workspace.json` is never read as a script.
|
|
959
|
+
|
|
960
|
+
### Package requirements
|
|
961
|
+
|
|
962
|
+
The three package.json sections are one `exadev/package-requirements` entry each. The rule is the graph-free counterpart of [`package-has-files`](#required-files) and [`required-scripts`](#required-scripts): those two select workspace packages by group and need a `pnpm-workspace.yaml`, so they cannot reach a single-package repository or the root manifest, which no group owns. This rule reads only the manifest and its directory, and a condition picks the manifests it applies to. Wire it directly for requirements the preset does not state:
|
|
963
|
+
|
|
964
|
+
```ts
|
|
965
|
+
{
|
|
966
|
+
files: ['**/package.json'],
|
|
967
|
+
language: 'json/json',
|
|
968
|
+
plugins: { exadev: plugin, json },
|
|
969
|
+
rules: {
|
|
970
|
+
'exadev/package-requirements': [
|
|
971
|
+
'error',
|
|
972
|
+
{
|
|
973
|
+
requirements: [
|
|
974
|
+
{ when: { private: false }, fields: ['engines', 'files'], scripts: [{ name: 'prepublishOnly', includes: ['publint'] }] },
|
|
975
|
+
{ when: { root: true }, files: ['.husky', { glob: 'knip.{json,ts}', orFields: ['knip'] }] },
|
|
976
|
+
{ when: { namePattern: '-contract$', declares: ['zod'] }, scripts: ['typecheck'] },
|
|
977
|
+
],
|
|
978
|
+
},
|
|
979
|
+
],
|
|
980
|
+
},
|
|
981
|
+
}
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
Each requirement lists `scripts`, `files` or `fields`, and an optional `when` whose stated conditions must all hold:
|
|
985
|
+
|
|
986
|
+
- `private`: `true` for a manifest that sets `"private": true`, `false` for any other.
|
|
987
|
+
- `root`: `true` for the manifest at the repository root (the nearest ancestor holding a `pnpm-workspace.yaml`, a `package.json` listing `workspaces`, or a `.git` entry; where ESLint runs when there is none), `false` for any other.
|
|
988
|
+
- `namePattern`: a regular expression the declared `name` matches, in the dialect of the [package selectors](#package-selectors). A manifest with no `name` never matches.
|
|
989
|
+
- `declares`: dependency names; at least one must appear under `dependencies`, `devDependencies`, `peerDependencies` or `optionalDependencies`.
|
|
990
|
+
|
|
991
|
+
`scripts` are checked as for [`requiredScripts`](#required-scripts), on the command as written. `files` are paths or globs relative to the package directory, resolved as for [`requiredFiles`](#required-files); an entry written `{ glob, orFields }` is also satisfied by any one of the listed manifest properties being set, for a tool that reads its configuration from either place; a property is a top-level field name, or the keys leading to a nested one (`['config', 'syncpack']`). `fields` must be set: present, and not `null`, an empty string, an empty object or an empty array. Requirements that select the same manifest merge, and one diagnostic lists everything missing of each kind, on the manifest, or on the `scripts` entry for a missing script.
|
|
992
|
+
|
|
993
|
+
## Verifying ESLint is applied
|
|
994
|
+
|
|
995
|
+
Every check in this package runs inside ESLint, so none of them can notice that a repository's `eslint.config.ts` no longer includes this package, has set its rules to `off`, or has been narrowed with `ignores` until nothing is linted. The repository looks configured and enforces nothing. `verifyEslintConfig` and `assertEslintConfig` check from outside the configuration: they resolve it through ESLint's Node API (`calculateConfigForFile`, the object `--print-config` prints, with `files` and `ignores` applied) for a sample file of each kind the repository lints, and report each file that is not linted and each required rule that is missing, off or weaker than required. They are plain async functions, so a test runs them:
|
|
996
|
+
|
|
997
|
+
```ts
|
|
998
|
+
// eslint-config.unit.test.ts
|
|
999
|
+
import { assertEslintConfig } from '@exadev/eslint-config';
|
|
1000
|
+
import { it } from 'vitest';
|
|
1001
|
+
|
|
1002
|
+
it('applies the shared ESLint config to every kind of file', async () => {
|
|
1003
|
+
await assertEslintConfig({
|
|
1004
|
+
samples: ['src/index.ts', 'src/index.unit.test.ts', 'package.json', { path: 'eslint.config.ts', except: ['@typescript-eslint/no-unsafe-assignment'] }],
|
|
1005
|
+
rules: { 'exadev/require-compiler-options': 'error' },
|
|
1006
|
+
except: ['exadev/barrel-policy'],
|
|
1007
|
+
});
|
|
1008
|
+
});
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
`assertEslintConfig` throws one error listing a line per violation, each naming the rule and the file; `verifyEslintConfig` returns the violations (`{ kind, file, rule?, required?, actual?, message }`, `kind` being `not-linted`, `missing`, `off` or `too-weak`) for a caller that wants to report them differently.
|
|
1012
|
+
|
|
1013
|
+
| Option | Meaning |
|
|
1014
|
+
| --- | --- |
|
|
1015
|
+
| `samples` | The files to resolve, relative to `cwd`: one per kind of source the repository lints. A bare string is `{ path }`; an object may add `rules` and `except` for that file alone. A sample need not exist, since ESLint resolves its configuration from the path. |
|
|
1016
|
+
| `rules` | Rules required for every sample, each with the severity it must at least have (`warn` or `error`). They replace a baseline requirement for the same rule. |
|
|
1017
|
+
| `except` | Baseline rules not required. |
|
|
1018
|
+
| `baseline` | The configuration whose enabled rules every sample must also have enabled, each at least at the severity it gives. Defaults to this package's default export, so a repository lists only its exceptions. `false` requires only `rules`. |
|
|
1019
|
+
| `cwd`, `configFile` | The directory ESLint resolves from (default: the working directory), and the config file under test (default: the one ESLint finds). |
|
|
1020
|
+
|
|
1021
|
+
A required rule passes when its resolved severity is at least the required one, so `error` satisfies a `warn` requirement. Severities are normalised first, since a resolved rule entry is `[severity, ...options]` with the severity a number or a string. A file that is ignored, or that no configuration block matches, is reported as `not-linted`: ESLint resolves no configuration for either.
|
|
1022
|
+
|
|
1023
|
+
The default baseline is the default export as it resolves in this process, including the React and Next.js rules it detects from the installed peers. A repository that configured `exadevConfig(options)` passes the same call as `baseline` (`baseline: exadevConfig({ react: false })`), or the shared options object both read, so the verifier requires what the repository meant to enable and not what auto-detection would add. The check proves the rules are active for the sample files; it does not run them.
|
|
1024
|
+
|
|
796
1025
|
## Workspace architecture
|
|
797
1026
|
|
|
798
1027
|
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):
|
|
@@ -1187,8 +1416,14 @@ pnpm build
|
|
|
1187
1416
|
- [`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).
|
|
1188
1417
|
- 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.
|
|
1189
1418
|
- 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.
|
|
1419
|
+
- [`src/tooling-wiring.ts`](src/tooling-wiring.ts) builds the `toolingWiringConfig` blocks: it turns the `publish`, `root` and `hooks` sections into the requirements of one `exadev/package-requirements` entry and the `toolConfigs` section into one block of the tool config rules over the union of their file scopes, each glob paired with the script extensions in a nested `files` array (flat config reads that as "both must match"). [`src/rules/package-requirements.ts`](src/rules/package-requirements.ts) reads the manifest facts, [`src/rules/package-requirements-checks.ts`](src/rules/package-requirements-checks.ts) holds the pure decisions and [`src/rules/package-requirements-options.ts`](src/rules/package-requirements-options.ts) the option schema and reader. Script requirements go through `checkScriptRequirements` in [`src/rules/workspace-requirements.ts`](src/rules/workspace-requirements.ts), shared with `required-scripts`.
|
|
1420
|
+
- [`src/verify-eslint.ts`](src/verify-eslint.ts) is the out-of-band verifier. It loads `eslint` with a dynamic `import()` on first use, since the module is re-exported from the entry point every `eslint.config.ts` imports and only a verification run needs the Node API.
|
|
1421
|
+
- [`src/rules/static-config.ts`](src/rules/static-config.ts) is the shared reader for the tool config rules. `createStaticConfig(program)` answers only what a config file's source states: which object literals the default export reaches, whether a key is `present`, `absent` or `opaque` (a later spread or non-literal computed key could supply it), and the literal a value stands for. [`src/rules/tool-config-options.ts`](src/rules/tool-config-options.ts) holds the option handling they share, and [`src/rules/vitest-test-objects.ts`](src/rules/vitest-test-objects.ts) finds the `test` objects of a Vitest config, projects included. `stryker-break-threshold` parses its `base` file with `@typescript-eslint/typescript-estree` and reads it through the same `createStaticConfig`.
|
|
1422
|
+
- [`src/rules/compiler-option-values.ts`](src/rules/compiler-option-values.ts) is where `require-compiler-options` agrees with `tsc`. `tsconfigPathOf` finds the tsconfig a program was created from, and `readTsconfig` re-reads it, with the files it lists, because typescript-eslint overrides some options in its programs. TypeScript exports `computedOptions` (how it derives an effective option from the others) and `optionDeclarations` (every option with its documented default) at run time but not in its typings; this module reads them through `Reflect.get` and narrows with guards, converts a required value with the compiler's own `convertCompilerOptionsFromJson`, and throws when the installed compiler does not export them.
|
|
1423
|
+
- [`src/rules/run-tracker.ts`](src/rules/run-tracker.ts) decides which lint carries a finding that belongs to a set of files rather than to one, so `require-compiler-options` reports once per run; it reads run boundaries from the order of the lints, as [Compiler options](#compiler-options) describes.
|
|
1424
|
+
- [`src/rules/tsconfig-attribution.ts`](src/rules/tsconfig-attribution.ts) works out which tsconfig governs a linted file, attributing a program with no tsconfig to the one the file was last linted under while that tsconfig still lists it.
|
|
1190
1425
|
- [`src/create-config.ts`](src/create-config.ts) is config assembly's single source of truth.
|
|
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.
|
|
1426
|
+
- `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), `buildToolingWiringConfig` (only when `toolingWiring` is given, likewise), and any trailing user configs.
|
|
1192
1427
|
- `defaultConfig` is `exadevConfig()` evaluated once, eagerly, at module load.
|
|
1193
1428
|
- [`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.
|
|
1194
1429
|
- [`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).
|
|
@@ -1200,7 +1435,9 @@ pnpm build
|
|
|
1200
1435
|
export { publicPlugin as plugin } from './plugin';
|
|
1201
1436
|
export { pureModulesConfig } from './pure-modules';
|
|
1202
1437
|
export { testHygieneConfig } from './test-hygiene';
|
|
1438
|
+
export { toolingWiringConfig } from './tooling-wiring';
|
|
1203
1439
|
export { turboConfig } from './turbo-config';
|
|
1440
|
+
export { assertEslintConfig, verifyEslintConfig } from './verify-eslint';
|
|
1204
1441
|
export { workspaceArchitectureConfig } from './workspace-architecture';
|
|
1205
1442
|
export type { MarkdownFrontmatter, MarkdownHeadingsOptions } from './markdown-headings';
|
|
1206
1443
|
export type { PureModulesOptions } from './pure-modules';
|
|
@@ -1234,7 +1471,7 @@ pnpm build
|
|
|
1234
1471
|
|
|
1235
1472
|
### Conventions
|
|
1236
1473
|
|
|
1237
|
-
- [`eslint.config.ts`](eslint.config.ts) dogfoods this package's own factory export on itself (`import { exadevConfig } from './src/index'`), spreading `exadevConfig({ react: false, nextjs: false })
|
|
1474
|
+
- [`eslint.config.ts`](eslint.config.ts) dogfoods this package's own factory export on itself (`import { exadevConfig } from './src/index'`), spreading `exadevConfig({ react: false, nextjs: false, ... })`, with the [tooling wiring](#tooling-wiring) preset on for the sections it uses. React and Next.js are forced off explicitly, not left to the plain auto-detecting default, since `eslint-plugin-react`/`@next/eslint-plugin-next` are real devDependencies of *this* repo (needed to test [`src/react.ts`](src/react.ts)/[`src/nextjs.ts`](src/nextjs.ts)'s own "package is resolvable" branch) even though this repo is neither a React nor a Next.js project. `no-side-effects-in-index` and `no-non-barrel-reexport` self-scope to [`src/index.ts`](src/index.ts) internally, so no `files`/`ignores` wiring is needed here. Plugin construction lives in [`src/plugin.ts`](src/plugin.ts) specifically so `src/index.ts` stays a pure re-export point.
|
|
1238
1475
|
- [`tsconfig.json`](tsconfig.json) enables [`verbatimModuleSyntax`](https://www.typescriptlang.org/tsconfig/#verbatimModuleSyntax) (`import type`/`export type` required for type-only imports — also enforced by `consistent-type-imports`) and [`noUncheckedIndexedAccess`](https://www.typescriptlang.org/tsconfig/#noUncheckedIndexedAccess) (narrow indexed access before use rather than asserting).
|
|
1239
1476
|
- Conventional commits are enforced by [commitlint](https://commitlint.js.org), restricted to the type-enum defined once in [`release.config.ts`](release.config.ts)'s `commitTypes` — both commitlint and semantic-release derive from that single list.
|
|
1240
1477
|
|