exadev-eslint-config 2.23.3 → 2.25.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 +250 -5
- package/dist/index.cjs +4474 -1973
- package/dist/index.d.cts +123 -2
- package/dist/index.d.ts +123 -2
- package/dist/index.js +4474 -1974
- package/package.json +9 -2
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) · [Development](#development) · [License](#license)
|
|
7
|
+
**Contents:** [Why](#why) · [Getting started](#getting-started) · [The lighter option](#the-lighter-option-the-plugin-named-export) · [Optional features](#optional-features) · [Rules](#rules) · [Barrel policy](#barrel-policy) · [Workspace architecture](#workspace-architecture) · [Turbo](#turbo) · [Development](#development) · [License](#license)
|
|
8
8
|
|
|
9
9
|
## Why
|
|
10
10
|
|
|
@@ -167,6 +167,8 @@ export default tseslint.config(
|
|
|
167
167
|
| [RFC 8785 canonical JSON formatting](#rfc-8785-canonical-json-formatting) | Always on | Not optional |
|
|
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
|
+
| [Turbo rules](#turbo) | Off unless given (only a repository can say it uses turbo) | `exadevConfig({ turbo })` / `turboConfig(options)` |
|
|
171
|
+
| [Turbo environment variable checking](#environment-variables-read-in-source) | Auto-detected: on if `eslint-plugin-turbo` is installed | `exadevConfig({ turboEnv })` |
|
|
170
172
|
|
|
171
173
|
Every tri-state option above (`true`/`false`/`undefined`) is passed through the named `exadevConfig(options, ...userConfigs)` factory export:
|
|
172
174
|
|
|
@@ -311,9 +313,24 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
|
|
|
311
313
|
| `prefer-options-object-param` | suggestion | **A run of 2+ trailing optional parameters (`?`-marked or default-valued) should be bundled into one destructured `options` parameter.** Without this, a caller needing only the last optional parameter must still pass `undefined` for every optional parameter before it (the motivating case: a 10-parameter constructor with 8 trailing optional ones). The threshold is configurable (`{ minTrailingOptional }`, default `2`). A suggestion (not a full autofix) rewrites the parameter list and inserts a `const { ... } = options ?? {};` destructure as the body's first statement; call sites are never rewritten, since the signature edit alone turns every stale positional call site into a real compile error. Still reported, but with no suggestion offered, whenever collapsing the run would not be safe/mechanical: a parameter property in the run, a parameter with no simple resolvable name and explicit type of its own (including a destructured parameter, or one typed only through a separately-declared function-type alias), a decorated parameter, a rest parameter anywhere in the full parameter list, no `BlockStatement` body at all (an arrow's expression body, or any declaration-only signature — including an ambient `.d.ts` function, an interface method/construct signature, or an abstract/ambient class method), a `@param` JSDoc tag naming a parameter in the run, or a parameter/function-scope-local variable already named `options`. Complements `max-params` (see "Individual rule tuning" above), which catches the different shape (excessive REQUIRED parameters) this rule is deliberately blind to. Requires no type information — registered in both `plugin.configs.recommended` and the default (type-checked) export, like `prefer-readonly-array-param` above; unlike its own `prefer-readonly-object-param`/`prefer-numeric-sort-compare` siblings, which genuinely need the checker and so cannot be offered outside the default export at all. |
|
|
312
314
|
| `prefer-doc-comment` | ✓ | **A substantial leading comment on an exported declaration should be a doc comment, not a plain `//`/`/* */` comment.** "Substantial" means 2 or more comment lines, or a single line whose text exceeds a configurable length (`{ maxLineLength }`, default `80`). Covers an exported function declaration, an exported function's own signature (a body-less `TSDeclareFunction`, an ordinary overload signature and a genuinely ambient one alike, `declare function foo(): void;` included, since TypeScript never shows either one's own implementation to callers, only the signature itself), an exported `const` declaration, whatever its initialiser (a function expression or arrow function, an ordinary value such as `export const TIMEOUT_MS = 5000;`, or none at all in an ambient `export declare const x: number;`; reported once even when the statement declares several, e.g. `export const h1 = () => {}, h2 = () => {};`), an exported class declaration, ambient (`export declare class`) or ordinary alike (TypeScript strips only an ambient class's own MEMBER bodies, never its declaration shape, so its leading comment documents the identical public contract an ordinary exported class's does), an exported class's public (non-`private`/`protected`/`#`-private) method (covering both a method's own overload signature and its trailing implementation) and public abstract method (`abstract m(): void;`, needing no equivalent overload-implementation exemption of its own, since TypeScript's own grammar never lets an abstract method carry a body at all), an exported class's public function/arrow-valued property (the class-member equivalent of the function-initialised `const` shape above), an exported interface/type-alias declaration, an exported enum declaration (`const enum` alike; never an individual member, the identical reason an interface's members are never individually checked), an exported namespace/module declaration (ambient or ordinary alike, at any nesting depth whose enclosing namespaces are all exported themselves), and a default-exported expression (`export default (): number => 1;`, `export default 42;`; a default-exported `function`/`class` declaration or body-less signature is covered by its own shape above and reported exactly once, never twice); a non-exported declaration is never reported, regardless of its comment. A TypeScript function/method overload set's own body-carrying IMPLEMENTATION signature is never reported either, however substantial its own comment: TypeScript never shows that signature to callers at all, only the separate overload signatures above it (a body-less `TSDeclareFunction` for a function, written without `declare`, or a `MethodDefinition` whose own `value` is `TSEmptyBodyFunctionExpression` for a class method) are ever checked against a call site, and both of those are themselves reported like any other declaration in this rule's scope, as the overload set's own real public contract; a comment directly above the implementation is instead internal reasoning about how the overloads are actually realised, never a public contract. The implementation is detected via ESLint's own scope analysis for a function (the overload signatures and the implementation all share one Variable) and, for a class method, via an EARLIER (source-order), same-`static`-ness, same-key signature sibling: a same-named `static`/instance pair is never one overload set to TypeScript itself, and a same-named signature declared AFTER the implementation belongs to a different, later member, never the one the implementation itself realises, so neither is ever mistaken for the implementation's own overload signature. A trailing comment on the previous line of code (`const x = 1; // note`) is never treated as the next declaration's own leading comment. A directive-shaped line (the full ESLint directive/config-comment family, `eslint-disable`/`eslint-disable-line`/`eslint-disable-next-line`/`eslint-enable`/`eslint`/`eslint-env`/`global`/`globals`/`exported`, matched case-sensitively, exactly as ESLint's own directive parser matches them, each requiring real whitespace or end-of-string immediately after the keyword rather than a bare word boundary, so an ordinary compound word such as `eslint-plugin-x` is never mistaken for one, and, on a `//` Line comment specifically, recognised only as `eslint-disable-line`/`eslint-disable-next-line`, the two labels ESLint itself honours there; every other member of the family is a directive only in Block form, matching what ESLint itself does; `@ts-expect-error`/`@ts-ignore`/`@ts-nocheck`/`@ts-check`, the `ts-*` markers matched with or without their own leading `@`; `TODO`/`FIXME`, matched case-insensitively when all-uppercase or immediately followed by `:`/`(` (`todo:`, `fixme(scope):`), never a `Todo`/`Fixme` opening ordinary prose; `prettier-ignore`; `c8 ignore`/`v8 ignore`/`istanbul ignore`/`node:coverage ignore`/`node:coverage disable`/`node:coverage enable`; `cspell:disable`/`cspell:disable-line`/`cspell:disable-next-line`/`cspell:enable` (never cspell's own `ignore`/`words` directives, which name dictionary words rather than suppressing a stretch of text); `biome-ignore`; and `#region`/`#endregion`, an editor folding marker) found anywhere in a `//` run is never itself merged into the fix: only the prose segment directly adjacent to the declaration, the run of lines after the LAST such directive, is ever reported and converted; every earlier segment, and every directive line itself, is left completely untouched, since an earlier segment sits on the far side of a directive from the declaration and documents whatever the directive itself demarcates, never the declaration below it; a directive seated directly above the declaration itself is transparent to this split rather than a boundary of it, since group assembly skips past it before the run is ever collected, so the prose above it is still reported and converted, with the directive preserved untouched in place, still functional, directly above the declaration; a directive found inside an already-consolidated Block comment leaves the whole comment alone, since a partial fix cannot safely be sliced out of one physical comment node. A `///` triple-slash reference/AMD directive (`/// <reference types="..." />`) is recognised separately, by its own un-stripped comment value rather than this same pattern, and excluded from the fix the same way. An existing `/**`-opening doc comment, or a `/*!` license/banner block, is left alone regardless of length. Autofix rewraps the comment's existing text into a `/** ... */` block, one line per original logical line as this rule's own fixer writes it, preserving each line's own indentation beyond the delimiter's single conventional space (never flattening a nested example or sub-list to its neighbours' level), and unwraps a hand-written starred block's (`/* ... */` with its own leading `* ` per line) marker rather than doubling it against the fixer's own. This describes this rule's own fixer alone, not a guarantee that `exadevConfig()`'s combined output preserves that text verbatim: a bundled sibling rule's own fixer can still rewrite it further in the same `--fix` run, which is exactly why the fix is withheld below for the two such collisions this rule already guards against. Handles the leading comment whether it is a run of `//` lines or already a bare block: a bare block may be hand-written, or, for a consumer who separately enables `@stylistic/eslint-plugin`'s own `multiline-comment-style` themselves, produced by its own bare-block fixer. Always reported when substantial, but the fix withheld, rather than silently skipping the violation, whenever the exact resulting text would contain a literal closing-comment delimiter or fails to parse as valid TSDoc (checked directly against `@microsoft/tsdoc`'s own parser, the same one `eslint-plugin-tsdoc`'s `tsdoc/syntax` validates against). Also withheld when any considered line itself begins with a literal `*` once its own leading whitespace is trimmed (a hand-written markdown bullet, `* item`, or a bare block comment whose own lines are only partially starred): spliced verbatim into this fixer's own template, that reads as a repeated delimiter on a middle line, which eslint-plugin-jsdoc's own `jsdoc/no-multi-asterisks` rule (bundled unconditionally alongside this rule, see [`jsdoc.ts`](src/jsdoc.ts)) would then strip in the very same `--fix` run, silently losing real content in a way neither this rule nor `@microsoft/tsdoc`'s own parser has any way to detect on its own. Also withheld when any considered line carries an unescaped tag-shaped `@word` mention, at the line's own start (a genuine TSDoc block or modifier tag, `@remarks`, `@param foo`, `@internal`, `@typeParam T`, `@virtual`, `@override`, ...) or immediately after whitespace anywhere further into the line (a bare mid-line mention, `tseslint.config() is @deprecated upstream`), the identical shape `jsdoc/escape-inline-tags` itself keys on: a line-start tag withholds because jsdoc.ts spreads `flat/recommended-tsdoc-error` with no `settings.jsdoc` override of its own, so eslint-plugin-jsdoc's own tag-vocabulary rules (bundled unconditionally alongside this rule too) validate a doc comment's tags against JSDoc/TypeScript's own vocabulary rather than TSDoc's, and confirmed directly (a real combined-config run, not this rule in isolation) to delete, rewrite or otherwise fight over a genuine TSDoc-only tag in the very same `--fix` run: `jsdoc/empty-tags` deletes an `@internal` tag's own description text, `jsdoc/check-tag-names` deletes `@public`/`@readonly` outright and rewrites `@typeParam` to `@template` (which then fails `tsdoc/syntax`, since `@template` is not itself valid TSDoc) and `@virtual` to `@abstract`, and `@override` triggers a live fight between `check-tag-names` and `empty-tags`. A bare mid-line mention withholds because it is worse than a sibling-fixer collision: it parses as perfectly valid TSDoc, but confirmed directly (linting a scratch file with this repo's own bundled config) that TypeScript's own JSDoc parser reads the converted mention as a REAL tag on the symbol, so a converted `is @deprecated upstream` makes every use of the symbol fail `@typescript-eslint/no-deprecated`, changing what the symbol means rather than merely its formatting. A mention wrapped in a backtick code span (`@deprecated` inside one) is exempt from both and converts safely, the backtick immediately before the `@` breaking the whitespace-preceded shape for TypeScript's tag scanning and `escape-inline-tags` alike (both confirmed directly). Withholding on every tag-shaped line, not merely a blank line adjacent to one, is deliberate: this rule has no way to enumerate in advance which further TSDoc-only tag eslint-plugin-jsdoc's own vocabulary will next disagree with, so it withholds structurally rather than adding a further one-off guard each time a new collision is found. `jsdoc.ts`'s own `check-tag-names`/TSDoc-vocabulary disagreement is itself a pre-existing gap on `main`, not caused by this rule, tracked separately. Requires no type information. Wired only through `stylisticCommentsConfig` into the default (type-checked) `exadevConfig()` output (see [Stylistic comment, class-member and JSX rules](#stylistic-comment-class-member-and-jsx-rules)), never registered in `plugin.configs.recommended`/`.barrel`, so a consumer using the lighter `extends: [plugin.configs.recommended]` option (see [The lighter option](#the-lighter-option-the-plugin-named-export)) never gets it. |
|
|
313
315
|
| `package-json-key-order` | ✓ | **Requires `package.json`'s keys to match `syncpack format`'s order.** See [Optional package.json key ordering](#optional-packagejson-key-ordering) — opt-in via `exadevConfig({ packageJsonKeyOrder: true })`, not part of `recommended`/`barrel`. A JSON-language rule (`@eslint/json`'s `json/json`), not a TSESLint one — needs no type information and doesn't apply to any `.ts`/`.js` file. |
|
|
314
|
-
| `no-uphill-dependency` | | **Enforces a configured workspace's rank, rank-skip, slice and group-isolation boundaries** on every `package.json`'s declared dependencies. See [Workspace architecture](#workspace-architecture). Opt-in via `exadevConfig({ workspaceArchitecture })` or the standalone `workspaceArchitectureConfig()`, not part of `recommended`/`barrel`. A JSON-language rule, needs no type information. |
|
|
316
|
+
| `no-uphill-dependency` | | **Enforces a configured workspace's rank, rank-skip, slice and group-isolation boundaries** on every `package.json`'s declared dependencies, with documented per-edge exceptions (`allow`) and exempt target groups (`exemptTargetGroups`). See [Workspace architecture](#workspace-architecture). Opt-in via `exadevConfig({ workspaceArchitecture })` or the standalone `workspaceArchitectureConfig()`, not part of `recommended`/`barrel`. A JSON-language rule, needs no type information. |
|
|
315
317
|
| `no-dependency-cycle` | | **Disallows a workspace dependency that can reach back to the package declaring it.** A same-rank or same-slice dependency passes `no-uphill-dependency` while still forming a cycle, which this rule catches instead. See [Workspace architecture](#workspace-architecture). |
|
|
316
318
|
| `package-name-mirrors-path` | | **Requires a workspace package's declared name to match the name its own path derives**, under the configured naming scope and separator. A package that declares no name at all is reported too (it plainly cannot mirror its path when it names nothing). Itself opt-in within workspace architecture: a no-op unless the shared `naming` option is given. See [Workspace architecture](#workspace-architecture). |
|
|
319
|
+
| `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). |
|
|
320
|
+
| `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). |
|
|
321
|
+
| `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). |
|
|
322
|
+
| `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. |
|
|
323
|
+
| `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). |
|
|
324
|
+
| `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). |
|
|
325
|
+
| `turbo-task-outputs` | | **Cached tasks declare `outputs`, persistent tasks disable caching.** A task with no `outputs` key caches its log only. See [Turbo](#turbo). |
|
|
326
|
+
| `turbo-task-config-inputs` | | **A cached task's key includes the config files of the tools its script runs** (`eslint`, `tsc`, `vitest` by default). See [Cached tasks include their tool configs](#cached-tasks-include-their-tool-configs). |
|
|
327
|
+
| `turbo-task-graph` | | **Tasks list the `dependsOn` edges the `taskGraph` option requires,** including in `package#task` entries and package `turbo.json` overrides. A no-op without the option. See [Required dependsOn edges](#required-dependson-edges). |
|
|
328
|
+
| `turbo-json-hygiene` | | **`turbo.json` declares a known `$schema`,** and optionally `CI` in `globalPassThroughEnv` and an aggregate pre-push task. See [turbo.json hygiene](#turbojson-hygiene). |
|
|
329
|
+
| `no-fix-in-cached-task-script` | | **A script that runs as a cached turbo task passes no fixing flag** (`--fix`, `--write` by default). See [Turbo](#check-and-fix-are-separate-tasks). |
|
|
330
|
+
| `turbo-boundaries-config` | | **The root `turbo.json` opts in to `turbo boundaries`** with a `boundaries` key. See [Turbo boundaries](#turbo-boundaries). |
|
|
331
|
+
| `turbo-package-tags` | | **Every workspace package has a `turbo.json` that extends the root and carries tags,** including its group name when groups are configured. See [Turbo boundaries](#turbo-boundaries). |
|
|
332
|
+
| `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
|
+
| `no-boundaries-ignore` | | **Bans the `@boundaries-ignore` comment** outside files listed with a reason. A JavaScript and TypeScript rule. See [Turbo boundaries](#turbo-boundaries). |
|
|
317
334
|
| `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. |
|
|
318
335
|
|
|
319
336
|
## Barrel policy
|
|
@@ -340,7 +357,7 @@ In every mode, re-exports are banned outside a permitted barrel, and a permitted
|
|
|
340
357
|
|
|
341
358
|
## Workspace architecture
|
|
342
359
|
|
|
343
|
-
|
|
360
|
+
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):
|
|
344
361
|
|
|
345
362
|
```ts
|
|
346
363
|
// eslint.config.ts
|
|
@@ -380,9 +397,80 @@ Both entry points need `@eslint/json` resolvable (an optional peer dependency of
|
|
|
380
397
|
| `rankSkip` | `{ maxDistance, exemptRanks }`. A dependency more than `maxDistance` ranks below its dependant, and not itself one of `exemptRanks` (typically a foundational rank 0), is a `rankSkip` violation. Omitted entirely, that check never runs. |
|
|
381
398
|
| `isolatedGroups` | `[groupName, groupName][]` pairs forbidden from depending on each other in either direction, on top of the rank/slice checks (for two groups that share a rank but must still stay separate). |
|
|
382
399
|
| `naming` | `{ scope?, separator? }`. Enables `package-name-mirrors-path`; omitted entirely, that rule is a no-op. |
|
|
400
|
+
| `allow` | `{ from, to, reason }[]`, documented exceptions to `no-uphill-dependency`, one per edge. See [Documented exceptions](#documented-exceptions-and-exempt-groups). |
|
|
401
|
+
| `exemptTargetGroups` | `{ group, fields }[]`. Edges into a package of `group`, declared only under `fields`, skip every `no-uphill-dependency` check. See [Documented exceptions](#documented-exceptions-and-exempt-groups). |
|
|
402
|
+
| `requiredFiles` | `{ packages, files }[]`. Enables `package-has-files`. See [Required files](#required-files). |
|
|
403
|
+
| `devOnly` | Selectors for packages that may appear only under `devDependencies`. Enables `dev-dependency-only`. See [Dev-only packages](#dev-only-packages). |
|
|
404
|
+
| `requiredScripts` | `{ match, scripts }[]`. Enables `required-scripts`. See [Required scripts](#required-scripts). |
|
|
383
405
|
|
|
384
406
|
A group's own `slice` (`{ segment: N }` or `{ namePrefix: true }`) partitions it further: two packages in the same or a differently-ranked group but a different slice (two feature verticals, say) are still isolated from each other, checked by the `crossSlice` violation. `{ segment: N }` reads the slice value directly from the package's own path (the Nth segment after the group's own root); `{ namePrefix: true }` is for a group with no such structure of its own (a flat `targets/` directory, say), whose packages instead take their slice from whichever other group's already-observed segment-derived value prefixes their own declared name.
|
|
385
407
|
|
|
408
|
+
### Package selectors
|
|
409
|
+
|
|
410
|
+
`requiredFiles`, `devOnly` and `requiredScripts` pick packages with one selector shape. A string is a regular expression tested against the package's declared name, in the same dialect as `nameRanks` (so `-testkit$`, not a glob such as `*-testkit`). An object selects by `group` (one of the declared `groups`), by `namePattern` (the same regular expression), or by both, in which case both must hold. A package that declares no name is matched by its group only; a name pattern never matches it. An invalid pattern or an undeclared group fails when the options are read, naming the option.
|
|
411
|
+
|
|
412
|
+
### Documented exceptions and exempt groups
|
|
413
|
+
|
|
414
|
+
`allow` names single edges by declared package name. `reason` is required and must be non-empty, so an exception always says why it exists. It excuses that edge from every `no-uphill-dependency` check (rank, rank skip, slice and isolated groups); it does not affect `no-dependency-cycle`, so an allowed edge that closes a cycle is still reported there.
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
allow: [
|
|
418
|
+
{ from: '@scope/store-adapter-db', to: '@scope/orders-contract', reason: 'the adapter persists the contract types and owns their table mapping' },
|
|
419
|
+
],
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
An entry that stops excusing anything is itself reported, so the list cannot rot. It is reported on the `package.json` of the `from` package: on the dependency entry when the checks would have passed the edge anyway, and on the manifest when the dependency is no longer declared at all. When the `from` package no longer exists, it is reported on the workspace root `package.json`, which therefore needs to be linted by the rule (`**/package.json` includes it).
|
|
423
|
+
|
|
424
|
+
`exemptTargetGroups` covers the case a per-edge list handles badly: a shared test-database package consumed as a `devDependency` by every layer, so dozens of edges point from lower ranks up to a test rank by design. It applies once `dependencyFields` is widened to read the field at all, and each listed field must be one of those read, otherwise the exemption could never apply and the options fail to load. A name also declared under any other read field is not exempt, so a runtime edge cannot hide behind a dev entry.
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
dependencyFields: ['dependencies', 'devDependencies'],
|
|
428
|
+
exemptTargetGroups: [{ group: 'test', fields: ['devDependencies'] }],
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
### Required files
|
|
432
|
+
|
|
433
|
+
`requiredFiles` makes `package-has-files` report every matching package whose directory lacks a listed path. Paths are relative to the package directory and may be globs (braces, `*`, `?`, `[...]`, `**`; a wildcard never matches a dot-prefixed name, and `node_modules` is never searched). A literal path must exist; a glob must match at least one existing path. The diagnostic is anchored on the package's `package.json` and lists the missing entries in declaration order, across every requirement that selects the package. Pair it with a rule that checks a file's exports to cover a whole convention (file exists, then file exports the right names).
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
requiredFiles: [
|
|
437
|
+
{ packages: '-contract$', files: ['src/errors.ts', 'src/fake.ts', 'src/**/*.conformance.ts'] },
|
|
438
|
+
],
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### Dev-only packages
|
|
442
|
+
|
|
443
|
+
`devOnly` lists selectors for packages that exist to support tests (fixtures, fakes, a conformance kit). `dev-dependency-only` reports any workspace package that lists a matching package under `dependencies`, `peerDependencies` or `optionalDependencies`, at the offending entry; `devDependencies` is the only permitted place. It reads those three fields regardless of `dependencyFields`, which narrows what the rank checks read and must not hide a field this rule forbids. Only workspace members are restricted, so a third-party package whose name happens to match is ignored.
|
|
444
|
+
|
|
445
|
+
```ts
|
|
446
|
+
devOnly: ['-testkit$', { group: 'test' }],
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### Required scripts
|
|
450
|
+
|
|
451
|
+
`requiredScripts` makes `required-scripts` report each matching package that lacks a listed key under `scripts`, naming every missing script in one diagnostic on the `scripts` entry (or on the manifest when it has none). A script may be a bare name, which checks presence only, or an object that also constrains the command as written (never what running it does):
|
|
452
|
+
|
|
453
|
+
- `equals`: the command must be exactly this string.
|
|
454
|
+
- `includes`: each entry must appear in the command.
|
|
455
|
+
- `excludes`: no entry may appear in the command.
|
|
456
|
+
|
|
457
|
+
`includes` and `excludes` compare tokens, not substrings. The command is split on whitespace, one layer of surrounding quotes is removed, and a `--flag=value` option is split so it equals `--flag value`. An entry matches when its tokens appear as a contiguous run anywhere in the command, so `--max-warnings 0` is found in a chained command and in `--max-warnings=0`, while `--passWithNoTests` never matches `--passWithNoTestsFoo`. Shell operators are ordinary tokens and are not interpreted.
|
|
458
|
+
|
|
459
|
+
```ts
|
|
460
|
+
requiredScripts: [
|
|
461
|
+
{ match: { group: 'features' }, scripts: ['typecheck', 'test'] },
|
|
462
|
+
{ match: '-schema$', scripts: ['generate', 'verify-generated'] },
|
|
463
|
+
{
|
|
464
|
+
match: { group: 'features' },
|
|
465
|
+
scripts: [
|
|
466
|
+
{ name: 'boundaries', equals: 'turbo boundaries' },
|
|
467
|
+
{ name: 'lint', includes: ['--max-warnings 0'] },
|
|
468
|
+
{ name: 'test', excludes: ['--passWithNoTests'] },
|
|
469
|
+
],
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
```
|
|
473
|
+
|
|
386
474
|
### Example: a group-ranked repo
|
|
387
475
|
|
|
388
476
|
Every group states its own `rank` directly; `test` keeps its own group name in the derived name (`@novus/test-database`, not `@novus/database`), which every other group drops:
|
|
@@ -460,6 +548,148 @@ export default exadevConfig({
|
|
|
460
548
|
});
|
|
461
549
|
```
|
|
462
550
|
|
|
551
|
+
## Turbo
|
|
552
|
+
|
|
553
|
+
These rules keep a repository that uses [turbo](https://turborepo.dev) honest about what turbo actually runs. Turbo skips a task silently when no package has a script of that name, restores only a task's log when `outputs` is missing, and caches a task under a hash taken before the task runs; none of that shows up as an error. They share one options object, `TurboOptions`, and all are off unless the `turbo` option is given, since only a repository can say it uses turbo. Enable them through `exadevConfig({ turbo })` or the standalone `turboConfig(options)`, which returns the blocks to spread into `defineConfig(...)`:
|
|
554
|
+
|
|
555
|
+
```ts
|
|
556
|
+
// eslint.config.ts
|
|
557
|
+
import { defineConfig } from 'eslint/config';
|
|
558
|
+
import { turboConfig } from '@exadev/eslint-config';
|
|
559
|
+
|
|
560
|
+
export default defineConfig(
|
|
561
|
+
// ...your own config...
|
|
562
|
+
...turboConfig(),
|
|
563
|
+
);
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
A `turbo.json` value of the wrong type (a `dependsOn` that is not a list of strings, a `tasks` that is not an object) throws an error naming the file and the value, since reading it as absent would misreport the task with no hint of the cause.
|
|
567
|
+
|
|
568
|
+
Both need `@eslint/json` resolvable, the same optional peer the other JSON rules use. `turbo.json` is linted as JSONC because turbo accepts comments there. Every rule does nothing in a `package.json` that belongs to no turbo repository, that is, one with no `turbo.json` that does not `extends` another at or above it (the nearest one in a workspace root wins, so a package configuration that forgot `extends` is not mistaken for the root).
|
|
569
|
+
|
|
570
|
+
### Options
|
|
571
|
+
|
|
572
|
+
| Field | Meaning |
|
|
573
|
+
| --- | --- |
|
|
574
|
+
| `root` | The repository root. Defaults to the nearest ancestor holding a root `turbo.json`. |
|
|
575
|
+
| `packages` | Workspace package globs, in the dialect of the [workspace architecture](#workspace-architecture) `packages` option. Defaults to `pnpm-workspace.yaml`'s `packages`, then `package.json`'s `workspaces`. A repository declaring neither is a single package. An empty `packages: []` in `pnpm-workspace.yaml`, the usual way to give turbo a root in a single-package repository, is read as no members. |
|
|
576
|
+
| `prefix` | What marks a script as the implementation of a task. Defaults to `_`. |
|
|
577
|
+
| `delegate` | The command a public script uses: `'turbo run'` (default) or `'turbo'`. |
|
|
578
|
+
| `exemptTasks` | Task names the checks skip, applied to script names as well as task keys, so it also exempts a script from `turbo-script-convention`, `turbo-script-has-task` and `no-fix-in-cached-task-script`. A name exempts the task in every form: `lint` covers `lint`, `//#lint` and `web#lint`. |
|
|
579
|
+
| `requireEmptyOutputs` | Also require `outputs` on graph-only and uncached tasks. |
|
|
580
|
+
| `fixFlags` | The flags `no-fix-in-cached-task-script` looks for, compared as whole words. Defaults to `--fix` and `--write`; a short form such as `-w` is not included by default, since it also means `--watch` or `--workspace-root` in other tools, so add it here where it means write. |
|
|
581
|
+
| `toolConfigs` | Tool command word to the config file globs it reads, for [`turbo-task-config-inputs`](#cached-tasks-include-their-tool-configs). Defaults to `eslint` with `eslint.config.*`, `tsc` with `tsconfig*.json` and `vitest` with `vitest.config.*`. A tool you give replaces its default; an empty list stops checking that tool. |
|
|
582
|
+
| `taskGraph` | `{ task, dependsOn }[]`. The [`dependsOn` edges](#required-dependson-edges) each named task must have. Nothing is required by default. |
|
|
583
|
+
| `hygiene` | `{ schemaHosts?, requireCiPassThrough?, aggregateTask? }`. See [turbo.json hygiene](#turbojson-hygiene). |
|
|
584
|
+
| `boundaries` | `{ aggregateScript?, groups?, allowIgnore? }`. Giving it at all enables the [`turbo boundaries`](#turbo-boundaries) rules. |
|
|
585
|
+
|
|
586
|
+
### Scripts delegate to underscore tasks
|
|
587
|
+
|
|
588
|
+
Repositories that use turbo tend to keep the real commands in underscore-prefixed scripts (`_lint`, `_typecheck`, `_test`, `_build`) and expose thin public scripts that delegate (`"lint": "turbo run _lint"`). `turbo-script-convention` checks that the convention holds. In the root package every prefixed script needs a public script of the same name without the prefix, and that script must be the delegating command followed by nothing but flags (`turbo run _lint --force` passes; `eslint .`, `tsc --noEmit && turbo run _typecheck`, `turbo run _lint && tsc` and `turbo run _lint _typecheck` do not). A word after the task must start with a dash or directly follow a word that does, since the command line does not say which flags take a value, so a flag's value passes and a second task or other positional word does not; a multi-task public script belongs in a junction task that `dependsOn` the others. In any other package a prefixed script needs no public counterpart, and a bare script named after a task the root orchestrates (`lint` when the root has `_lint`) is reported, since the public name belongs to the root. The rule reads the command line as written and never what running it does.
|
|
589
|
+
|
|
590
|
+
`turbo-script-has-task` is the other direction for scripts: a prefixed script must be configured as a task, either in the root `turbo.json` (under its own name, as `//#name` in the root package, or as `package#name` in a member) or in the member's own `turbo.json`. A prefixed script that no task names is never run by turbo.
|
|
591
|
+
|
|
592
|
+
### Tasks and scripts match
|
|
593
|
+
|
|
594
|
+
`turbo-task-has-script` lints the root `turbo.json`. A task that no package implements is reported on its key, with these shapes exempt because turbo treats them differently. A junction task, one with `dependsOn` and no script anywhere, only groups other tasks. A `//#name` task is implemented by the root package's script `name`, whatever the script holds (a `":"` no-op or an unprefixed name such as `test:coverage` both count). A `package#name` task is implemented by the package of that name. Any other task is implemented by a workspace member's script, or by the root package's when the repository has no members, since turbo runs the root package for a task only then.
|
|
595
|
+
|
|
596
|
+
It also reports dead configuration: a `//#name` task or an aggregate (a task with `dependsOn`) that no other task lists in `dependsOn` or `with`, and that no root script invokes through turbo (`turbo run x`, `turbo x`, behind `pnpm` or by path), never runs. A root task that is only reachable from a CI workflow is not seen, because ESLint cannot read workflow files; give it a root script or list it in `exemptTasks`. A package's `turbo.json`, which extends the root, is not checked here.
|
|
597
|
+
|
|
598
|
+
### Cached tasks declare outputs
|
|
599
|
+
|
|
600
|
+
A task with no `outputs` key, or with `"outputs": null` (which turbo's schema allows), caches its log only, the same as `outputs: []` ([turbo configuration reference](https://turborepo.dev/docs/reference/configuration#outputs), [latest archived copy](https://web.archive.org/web/https://turborepo.dev/docs/reference/configuration)). A build task that forgot the key looks configured but restores nothing on a cache hit, and nothing separates it from a lint task that legitimately produces no files. `turbo-task-outputs` requires every cached task to declare `outputs`, using `[]` where there are none, so the absence is always a stated choice, and reports on the task key. A task that sets `cache: false`, or that only wires other tasks together (nothing but `dependsOn` and `description`), is exempt unless `requireEmptyOutputs` is set. A persistent task never completes, so it must set `cache: false`; that is reported instead of the outputs problem.
|
|
601
|
+
|
|
602
|
+
In a package's `turbo.json` a task is judged as merged over the root task of the same name, so a partial override that inherits `outputs` passes, and a problem the root already has is reported at the root only.
|
|
603
|
+
|
|
604
|
+
### Cached tasks include their tool configs
|
|
605
|
+
|
|
606
|
+
A cached task is stored under a key hashed from its `inputs`, which default to the files of its own package that are checked into source control ([`inputs` reference](https://turborepo.dev/docs/reference/configuration#inputs), [latest archived copy](https://web.archive.org/web/https://turborepo.dev/docs/reference/configuration)); the sources of the internal packages it depends on reach the key only through the `dependsOn` edges that run their tasks first. A shared config at the repository root (`eslint.config.ts`, `tsconfig.base.json`, `vitest.config.ts`) is not among them unless `globalDependencies` or an `$TURBO_ROOT$/` input lists it, so changing the root ESLint config can restore the previous lint result. A task with explicit `inputs` and no `$TURBO_DEFAULT$` replaces the default, so its own package's config files must be listed too.
|
|
607
|
+
|
|
608
|
+
`turbo-task-config-inputs` checks this for every task in the root `turbo.json`. It finds the packages whose script implements the task (the same way `turbo-task-has-script` does), reads the script's command for the tools in `toolConfigs`, and for each tool looks for the files its globs match directly inside the package and, for a package other than the root package, at the repository root. Each such file must be in the cache key: through `globalDependencies`, through the package's own files (unset `inputs`, or `$TURBO_DEFAULT$`), or through an `inputs` glob (relative to the package, or to the root behind `$TURBO_ROOT$/`), and not removed by a `!` glob. A file that a `!` glob drops is reported on its own, naming the glob, because the fix differs: an exclusion wins over `$TURBO_DEFAULT$` and over a glob that lists the file, so only narrowing or removing the `!` glob restores it. A package's own `turbo.json` is laid over the task first, so an override that replaces `inputs` is caught, and reported on the task's key in the root file with the packages named. Uncached tasks are skipped, and so is a script that names none of the tools (`pnpm run lint`, `tsdown`): the rule reads the command line as written and does not guess. A root file counts whenever its name matches the globs, whether or not the tool actually loads it, so narrow the globs (`tsconfig.base.json` rather than `tsconfig*.json`) where a root file is not an input of the package tasks.
|
|
609
|
+
|
|
610
|
+
Two failure modes need a filesystem walk or an execution and stay guidance: a task whose input globs match no file in its package hashes as unchanging and replays its first green result, and a task that reads an environment variable to select a backend must list it in `env`, since a hash over files cannot see it (the [environment variable check](#environment-variables-read-in-source) covers the variables read in source). A single-package repository needs no `packages:` glob: its root package is checked against its own files.
|
|
611
|
+
|
|
612
|
+
### Required dependsOn edges
|
|
613
|
+
|
|
614
|
+
Repositories disagree on how tasks depend on each other. Some make `_build` depend on `_typecheck` so a type error stops the build, some make `_typecheck` depend on `^_build` so it checks against built dependencies, and a workspace whose packages resolve each other's source directly needs `_typecheck` to depend on `^_typecheck`, since that edge brings a dependency's sources into the consumer's cache key. Which is right is a policy, so nothing is required unless the `taskGraph` option states it:
|
|
615
|
+
|
|
616
|
+
```ts
|
|
617
|
+
turboConfig({
|
|
618
|
+
taskGraph: [
|
|
619
|
+
{ task: '_typecheck', dependsOn: ['^_typecheck'] },
|
|
620
|
+
{ task: '_build', dependsOn: ['_typecheck', '^_build'] },
|
|
621
|
+
],
|
|
622
|
+
});
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
`turbo-task-graph` reports each edge a named task lacks, on the task's key, comparing entries as written. A requirement for `_build` also applies to each `package#_build` entry of the root `turbo.json`, because turbo does not merge such an entry with the generic task: checked against turbo 2.10.8 with `turbo run --dry=json`, a `web#_build` entry that lists only `inputs` and `outputs` resolves with no `dependsOn` and no `env` at all, dropping what the generic `_build` declared. That is the case the option exists to catch, and it holds for `dependsOn` in any entry, so a package entry has to repeat every edge. A requirement never applies to the root package's own `//#_build` task unless written with that key. In a package's `turbo.json`, which does merge with the root ([package configurations](https://turborepo.dev/docs/reference/package-configurations), [latest archived copy](https://web.archive.org/web/https://turborepo.dev/docs/reference/package-configurations)), a key the package sets replaces the root's array unless it lists `$TURBO_EXTENDS$` to keep the root entries; the rule judges the merged task and reports an override that drops an edge the inherited task had. An edge the inherited task already lacks is reported at the root only. A requirement whose task matches no entry of the root `turbo.json` (a name written `_buidl`, or a `web#_build` for a package with no such entry) could never apply, so it is reported on the `tasks` key rather than passing silently; a generic name such as `_build` is met by the generic entry or any `package#_build` entry, but not by `//#_build` alone. `exemptTasks` skips a task here too, including its unmatched requirement.
|
|
626
|
+
|
|
627
|
+
### turbo.json hygiene
|
|
628
|
+
|
|
629
|
+
`turbo-json-hygiene` is a set of presence checks on `turbo.json`. Every `turbo.json` needs a `$schema`, so editors validate the file. The accepted forms are `https://<host>/schema.json`; the same URL on a versioned subdomain such as `https://v2-10-8.turborepo.dev/schema.json`; and a relative path to the schema the `turbo` package ships, such as `./node_modules/turbo/schema.json` or `../../node_modules/turbo/schema.json` in a package ([editor integration](https://turborepo.dev/docs/getting-started/editor-integration), [latest archived copy](https://web.archive.org/web/https://turborepo.dev/docs/getting-started/editor-integration)). The hosts default to the three turbo has published the URL under (`turborepo.com`, `turborepo.dev` and `turbo.build`), and `hygiene.schemaHosts` narrows them, so one host makes it canonical and the other hosts, with or without a version, are reported. The local path names no host, so it is accepted whatever `schemaHosts` holds. Two checks apply to the root `turbo.json` only and are off unless asked for:
|
|
630
|
+
|
|
631
|
+
- `hygiene.requireCiPassThrough: true` requires `CI` in `globalPassThroughEnv`, so tasks that read it see it without it entering their cache key.
|
|
632
|
+
- `hygiene.aggregateTask: { name, includes }` requires a task called `name` (for example `_prepush`) whose `dependsOn` lists every task in `includes`. Its name varies between repositories, hence the option. A graph-only task and one kept invocable with a `":"` root script and `cache: false` both pass, since the check reads the task entry and not the scripts. The name is matched against the task key exactly as written, so an aggregate kept invocable as a root script in a monorepo, which turbo knows as the root package's task, is given as `name: '//#_prepush'`.
|
|
633
|
+
|
|
634
|
+
```ts
|
|
635
|
+
turboConfig({
|
|
636
|
+
hygiene: {
|
|
637
|
+
requireCiPassThrough: true,
|
|
638
|
+
aggregateTask: { name: '_prepush', includes: ['_lint', '_typecheck', '_test'] },
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
### Environment variables read in source
|
|
644
|
+
|
|
645
|
+
Turbo's strict env mode strips variables a task has not declared, so a `process.env.X` read in source that no `turbo.json` lists in `env` or `globalEnv` fails at runtime or is missing from the cache key. Vercel's [`eslint-plugin-turbo`](https://github.com/vercel/turborepo/tree/main/packages/eslint-plugin-turbo) ([latest archived copy](https://web.archive.org/web/https://github.com/vercel/turborepo/tree/main/packages/eslint-plugin-turbo)) reports exactly that with `turbo/no-undeclared-env-vars`, and this package folds it in the way it does React and Next.js: `eslint-plugin-turbo` is an optional peer dependency, `exadevConfig()` uses it when it is resolvable, `exadevConfig({ turboEnv: true })` forces it on and throws when the plugin is missing, and `turboEnv: false` never loads it. It is a separate option from `turbo`, which configures this package's own rules and is off unless given.
|
|
646
|
+
|
|
647
|
+
Checked against the published package before adopting it: it ships `configs['flat/recommended']` and a flat-config usage section in its README, it declares peers `eslint >6.6.0` and `turbo >2.0.0`, and it is released alongside turbo itself. The peer range starts at 2.3.1, the first release whose published build exports `configs['flat/recommended']` (2.3.0 and every earlier release export only the legacy `recommended`, read from the published tarballs), so a repository on an older turbo 2 minor is not warned and none is held to a recent release. It finds the repository through ESLint's working directory: the root `turbo.json` and the `turbo.json` of every workspace package that extends it, so a variable declared in a package configuration counts for that package. Its config carries no `files`, so it is scoped here to JavaScript and TypeScript sources, and it is one block that is only added when the plugin resolves.
|
|
648
|
+
|
|
649
|
+
### Check and fix are separate tasks
|
|
650
|
+
|
|
651
|
+
Turbo hashes a task's inputs before the task runs. A task that then rewrites those inputs is stored under a key that no longer describes the files. This was confirmed against the installed turbo with a cached task whose script rewrites one of its inputs: the first run misses and rewrites, the second run misses again because the rewritten file hashes differently, the third hits; and restoring the file to its earlier content hits the first run's entry, replaying its log while leaving the file unfixed. So a cached check that passes `--fix` reports success on a cache hit without applying the fix its log describes, and rewrites files during CI.
|
|
652
|
+
|
|
653
|
+
`no-fix-in-cached-task-script` reports a script that turbo.json configures as a cached task (any script with a task entry that is not `cache: false`, found the way `turbo-script-has-task` finds it) and that passes one of `fixFlags`. The fix is an uncached task beside a fix-free check:
|
|
654
|
+
|
|
655
|
+
```json
|
|
656
|
+
{
|
|
657
|
+
"scripts": {
|
|
658
|
+
"_lint": "eslint . --cache --max-warnings 0",
|
|
659
|
+
"_lint:fix": "eslint . --fix --cache --max-warnings 0",
|
|
660
|
+
"lint": "turbo run _lint",
|
|
661
|
+
"lint:fix": "turbo run _lint:fix"
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
with `"_lint:fix": { "cache": false }` in `turbo.json`.
|
|
667
|
+
|
|
668
|
+
### Turbo boundaries
|
|
669
|
+
|
|
670
|
+
[`turbo boundaries`](https://turborepo.dev/docs/reference/boundaries) (experimental) checks source imports against package directories, declared dependencies and per-package tag rules. It only applies tag rules once the root `turbo.json` has a `boundaries` key and packages carry `tags`. These rules check that a repository has opted in; they do not check that the command passes. Trialled on a workspace with a large number of packages, `turbo boundaries` failed on every package whose `eslint.config.ts` or `stryker.config.ts` imported a shared file from the workspace root, reporting each as an import leaving the package. The command is therefore unusable on a workspace that shares configuration through root imports unless it uses `@boundaries-ignore` comments or another way of sharing configuration, and a passing rule set here does not imply a passing `turbo boundaries`. Never run `turbo boundaries --ignore=all`: on a copy it rewrote an import into a comment followed by an orphaned string literal, and the rerun passed only because the import had gone.
|
|
671
|
+
|
|
672
|
+
Tags cover allow and deny relations but not rank, rank skipping or cycles, so `no-uphill-dependency` and `no-dependency-cycle` remain useful alongside them.
|
|
673
|
+
|
|
674
|
+
- `turbo-boundaries-config` requires the root `turbo.json` to have a `boundaries` key; `"boundaries": {}` is enough to opt in.
|
|
675
|
+
- `turbo-package-tags` requires every workspace package to have its own `turbo.json` with `"extends": ["//"]` and a non-empty top-level `tags` list (the shape the [package configuration reference](https://turborepo.dev/docs/reference/package-configurations) documents). With `boundaries.groups` (`{ name, path? }`, `path` defaulting to `name`, relative to the repository root), the tags must also include the name of the group the package's directory falls under, the longest matching path, so the layout is declared once and the tags cannot drift from it: `exadevConfig({ workspaceArchitecture, turbo })` takes `boundaries.groups` from `workspaceArchitecture.groups` (and throws if `boundaries.groups` is given as well), while the standalone `turboConfig()` needs `boundaries.groups` and `workspaceArchitectureConfig()` needs `groups` passed from one shared array, since neither can see the other. A workspace package under no group throws rather than being skipped. Diagnostics land on the package's `package.json`, the one file every package has.
|
|
676
|
+
- `turbo-boundaries-script` requires the root package's `boundaries` script to be exactly `turbo boundaries`, and, with `boundaries.aggregateScript`, that script (the one run before pushing) to invoke it, directly or as `pnpm boundaries`, `npm run boundaries` or `pnpm run -s boundaries`. Package manager flags that take no value (`-s`, `--silent`, `-q`, `--quiet`, `--if-present`, `-w`, `--workspace-root`) may sit before the script name; a flag that takes a value cannot be told from a script name in a command line, so an aggregate that uses one (`pnpm --filter x boundaries`) is not recognised.
|
|
677
|
+
- `no-boundaries-ignore` bans the `@boundaries-ignore` comment, the same stance `noInlineConfig` takes on `eslint-disable`. A comment counts the way turbo reads it: its text, trimmed, starts with the directive. `turbo boundaries --ignore=all` inserts one above every import it reports, so the check could otherwise be silenced wholesale without anyone deciding to. Where a few reasoned exceptions are wanted, `boundaries.allowIgnore` lists `{ files, reason }` entries; `files` are globs relative to ESLint's working directory and `reason` is required.
|
|
678
|
+
|
|
679
|
+
```ts
|
|
680
|
+
const groups = [{ name: 'core' }, { name: 'features' }, { name: 'targets' }];
|
|
681
|
+
|
|
682
|
+
// Standalone: one shared array. exadevConfig({ workspaceArchitecture: { groups }, turbo }) derives it.
|
|
683
|
+
workspaceArchitectureConfig({ groups });
|
|
684
|
+
turboConfig({
|
|
685
|
+
boundaries: {
|
|
686
|
+
aggregateScript: 'check',
|
|
687
|
+
groups,
|
|
688
|
+
allowIgnore: [{ files: ['scripts/**'], reason: 'build scripts import the workspace root config' }],
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
```
|
|
692
|
+
|
|
463
693
|
## Development
|
|
464
694
|
|
|
465
695
|
### Build, test, and lint
|
|
@@ -478,7 +708,7 @@ pnpm build
|
|
|
478
708
|
- Each rule has a co-located `*.unit.test.ts` exercising it with ESLint's [`RuleTester`](https://eslint.org/docs/latest/integrate/nodejs-api#ruletester) under [Vitest](https://vitest.dev). [`vitest.setup.ts`](vitest.setup.ts) wires `RuleTester.describe`/`.it`/`.itOnly` to Vitest's `describe`/`it` explicitly (no `test.globals`). Each test uses typescript-eslint's parser for TypeScript-only fixtures; none need type information.
|
|
479
709
|
- Every test file's own name declares its kind via a filename suffix immediately before `.test`/`.spec` — `.unit`, `.integration`, or `.e2e` by default (`exadev/test-file-kind`, part of `recommended`; see [Rules](#rules)) — so a file's test kind is always visible from its name alone, without opening it, and downstream tooling (e.g. a Vitest project split by test kind) can select by filename glob rather than by convention nobody enforces. This package's own tests are exclusively `.unit.test.ts` today (a `.internal.unit.test.ts` variant exists for a handful of files that also test non-exported internals directly, `internal` just being an ordinary extra name segment — see [`no-mutable-union-array-param.internal.unit.test.ts`](src/rules/no-mutable-union-array-param.internal.unit.test.ts)).
|
|
480
710
|
- `pnpm test` always measures [coverage](https://vitest.dev/guide/coverage) (`@vitest/coverage-v8`), scoped to `src/**/*.ts` excluding `*.test.ts`. Text output in terminal; `html`/`lcov` in `coverage/` (gitignored alongside `.eslintcache` and `dist/`).
|
|
481
|
-
- The `lint`/`typecheck`/`test`/`build` npm scripts wrap [turbo](https://turborepo.dev) tasks named `_lint`/`_typecheck`/`_test`/`_build` — run `pnpm build`, not `turbo run build`.
|
|
711
|
+
- The `lint`/`typecheck`/`test`/`build` npm scripts wrap [turbo](https://turborepo.dev) tasks named `_lint`/`_typecheck`/`_test`/`_build` — run `pnpm build`, not `turbo run build`. `pnpm lint:fix` runs the uncached `_lint:fix` task, which is the only one that rewrites files; this repo lints its own scripts with the [Turbo](#turbo) rules.
|
|
482
712
|
- `pnpm build` runs [`tsdown`](https://tsdown.dev) from [`src/index.ts`](src/index.ts), bundling the whole module graph into ESM + CJS + declarations. `prepublishOnly` re-runs lint, typecheck, `test`, `tsdown`, [`publint`](https://publint.dev), and [`attw`](https://github.com/arethetypeswrong/arethetypeswrong.github.io) `--pack`.
|
|
483
713
|
|
|
484
714
|
### Architecture
|
|
@@ -497,19 +727,34 @@ pnpm build
|
|
|
497
727
|
- `tryRequire` wraps [`createRequire(import.meta.url)`](https://nodejs.org/api/module.html#modulecreaterequirefilename) in try/catch, returning `unknown` (never a cast) so every call site narrows explicitly before use.
|
|
498
728
|
- `readFlatConfig` walks a property path through that `unknown` value via a real type guard, normalizing a stray legacy top-level `parserOptions` key into `languageOptions.parserOptions` along the way.
|
|
499
729
|
- Confirmed necessary: `eslint-plugin-jsx-a11y`'s own `configs.recommended` export carries exactly this legacy shape, which flat config's schema rejects outright rather than ignores.
|
|
730
|
+
- `buildOptionalPluginConfig` is the builder shared by the presets that fold in a single plugin's flat config ([`src/nextjs.ts`](src/nextjs.ts), [`src/turbo-env.ts`](src/turbo-env.ts)): the package, the config path, the feature name for the error and an optional `files` scope go in, and the tri-state `enabled` behaviour comes out the same for each.
|
|
500
731
|
- [`src/react.ts`](src/react.ts)/[`src/nextjs.ts`](src/nextjs.ts) each export a `build*Config(options)` function: resolve the relevant optional peer(s) via `tryRequire`, extract their real flat config via `readFlatConfig`, and return an array of 0-or-more config blocks.
|
|
501
732
|
- `[]` if unresolvable and not explicitly forced on; a thrown `Error` if explicitly forced on (`enabled: true`) and still unresolvable.
|
|
502
733
|
- `react.ts`'s blocks are scoped to `files: ['**/*.jsx', '**/*.tsx']`; `nextjs.ts`'s is not (see [Optional React and Next.js support](#optional-react-and-nextjs-support) for why).
|
|
734
|
+
- [`src/json-language-config.ts`](src/json-language-config.ts) is the shared groundwork for every rule that lints JSON files through `@eslint/json`, an optional peer.
|
|
735
|
+
- `tryResolveJsonPlugin` and `requireJsonPlugin` are the silent and the throwing resolution paths, the second naming the feature that needs the peer and the install command.
|
|
736
|
+
- `buildJsonLanguageBlock` assembles the `exadev` and `json` plugin registration for a `files` list under `json/json` or `json/jsonc`; the `json/jsonc` form also admits trailing commas, matching the JSONC config in [`src/json-canonical.ts`](src/json-canonical.ts). `JSONC_FILE_GLOBS` (`tsconfig*.json`, `turbo.json`, `*.jsonc`) lists the files that carry comments, so a rule targeting them must be wired under `json/jsonc`, and a rule meant for both languages declares both in `meta.languages`.
|
|
737
|
+
- `buildPackageJsonKeyOrderConfig` and `buildWorkspaceArchitectureConfig` are built on it; a new JSON rule family follows the same shape, taking its tri-state `enabled` option from `exadevConfig` and choosing `requireJsonPlugin` (forced on) or `tryResolveJsonPlugin` (auto-detected).
|
|
738
|
+
- [`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
|
+
- `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
|
+
- `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.
|
|
741
|
+
- [`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
|
+
- `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
|
+
- [`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
|
+
- 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
|
+
- 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.
|
|
503
746
|
- [`src/create-config.ts`](src/create-config.ts) is config assembly's single source of truth.
|
|
504
|
-
- `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), and any trailing user configs.
|
|
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.
|
|
505
748
|
- `defaultConfig` is `exadevConfig()` evaluated once, eagerly, at module load.
|
|
506
749
|
- [`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).
|
|
507
750
|
- [`src/index.ts`](src/index.ts) is the entry point, still a pure re-export barrel:
|
|
508
751
|
```ts
|
|
509
752
|
export { defaultConfig as default, exadevConfig } from './create-config';
|
|
510
753
|
export { publicPlugin as plugin } from './plugin';
|
|
754
|
+
export { turboConfig } from './turbo-config';
|
|
511
755
|
export { workspaceArchitectureConfig } from './workspace-architecture';
|
|
512
756
|
export type { GroupSpec, NamingOptions, RankRule, RankSkipOptions, SliceSpec, WorkspaceArchitectureOptions } from './rules/workspace-options';
|
|
757
|
+
export type { AllowedEdge, ExemptTargetGroup, PackageSelector, PackageSelectorFields, RequiredFiles, RequiredScripts, ScriptContent, ScriptRequirement } from './rules/workspace-constraint-options';
|
|
513
758
|
```
|
|
514
759
|
- 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.
|
|
515
760
|
- Required by `no-side-effects-in-index`/`no-non-barrel-reexport`, both of which assume this file contains nothing but `export ... from ...`.
|