exadev-eslint-config 2.24.0 → 2.26.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 CHANGED
@@ -168,6 +168,8 @@ export default tseslint.config(
168
168
  | [package.json key ordering](#optional-packagejson-key-ordering) | On, unless the project already has a syncpack config | `exadevConfig({ packageJsonKeyOrder })` |
169
169
  | [Workspace architecture rules](#workspace-architecture) | Off unless given (no sensible default for `groups`) | `exadevConfig({ workspaceArchitecture })` / `workspaceArchitectureConfig(options)` |
170
170
  | [Turbo rules](#turbo) | Off unless given (only a repository can say it uses turbo) | `exadevConfig({ turbo })` / `turboConfig(options)` |
171
+ | [Import policy](#import-policy) | Off unless given (only a repository can say which imports it forbids) | `exadevConfig({ importPolicies })` / `importPolicyConfig(policies)` |
172
+ | [Turbo environment variable checking](#environment-variables-read-in-source) | Auto-detected: on if `eslint-plugin-turbo` is installed | `exadevConfig({ turboEnv })` |
171
173
 
172
174
  Every tri-state option above (`true`/`false`/`undefined`) is passed through the named `exadevConfig(options, ...userConfigs)` factory export:
173
175
 
@@ -322,12 +324,19 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d
322
324
  | `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). |
323
325
  | `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). |
324
326
  | `turbo-task-outputs` | | **Cached tasks declare `outputs`, persistent tasks disable caching.** A task with no `outputs` key caches its log only. See [Turbo](#turbo). |
327
+ | `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). |
328
+ | `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). |
329
+ | `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). |
325
330
  | `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). |
326
331
  | `turbo-boundaries-config` | | **The root `turbo.json` opts in to `turbo boundaries`** with a `boundaries` key. See [Turbo boundaries](#turbo-boundaries). |
327
332
  | `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). |
328
333
  | `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). |
329
334
  | `no-boundaries-ignore` | | **Bans the `@boundaries-ignore` comment** outside files listed with a reason. A JavaScript and TypeScript rule. See [Turbo boundaries](#turbo-boundaries). |
330
335
  | `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. |
336
+ | `required-exports` | | **Files matching a glob must export the configured names.** `{ files, exports }` entries; presence only, no shape check. See [File-level rules](#file-level-rules). |
337
+ | `required-imports` | | **Files matching a glob must import, and optionally call, a module matching a specifier pattern.** See [File-level rules](#file-level-rules). |
338
+ | `import-policy` | | **Glob-scoped import policy:** deny lists, specifiers confined to named files, and exact exception edges, with a message that names the requirement. Wired by `importPolicyConfig`. See [Import policy](#import-policy). |
339
+ | `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). |
331
340
 
332
341
  ## Barrel policy
333
342
 
@@ -351,6 +360,110 @@ Notes on `'auto'`:
351
360
 
352
361
  In every mode, re-exports are banned outside a permitted barrel, and a permitted barrel may contain only re-export statements. The umbrella composes the identical predicates the standalone rules use (shared in [`src/rules/barrel-helpers.ts`](src/rules/barrel-helpers.ts)). It is non-fixable — the autofix lives on `no-non-barrel-reexport`.
353
362
 
363
+ ## File-level rules
364
+
365
+ `required-exports`, `required-imports`, `import-policy` and `filename-pattern` check one file against a convention that depends on its path. Each self-scopes through `context.filename`, so it is a no-op on every other file and can sit in a shared config without a `files` array of its own. None needs type information, and none is in `recommended`, since each takes options only a project can supply.
366
+
367
+ `files` in these rules is a glob, or a list of globs, in the dialect used for every file glob in this package (braces, `*`, `?`, `[...]`, `**` as whole segments, a leading `!` to exclude, wildcards never matching dot-prefixed names), matched against the path relative to ESLint's working directory. A glob without a `/` names a file at any depth, so `fake.ts` and `**/fake.ts` are the same.
368
+
369
+ ### Required exports
370
+
371
+ ```ts
372
+ 'exadev/required-exports': ['error', [
373
+ { files: '**/contract/src/errors.ts', exports: ['ContractError', 'contractErrorSchema'] },
374
+ { files: 'fake.ts', exports: ['createFake'] },
375
+ ]]
376
+ ```
377
+
378
+ A file matching `files` must export every name in `exports`. Counted: `export const`, `let`, `var` (destructuring included), functions, classes, enums, interfaces, type aliases and namespaces, `export { a, b as c }` with or without `from`, `export type { ... }`, `export * as ns from`, and `export default` as the name `default`. `export = x` (a CommonJS-style TypeScript module) counts nothing, since it exports the value rather than a name. A bare `export * from './x'` counts nothing, because the names it forwards live in a file this rule does not read; list the names in an explicit re-export if a barrel must satisfy the rule. When several entries match one file, the file is reported once, listing every missing name.
379
+
380
+ ### Required imports
381
+
382
+ ```ts
383
+ 'exadev/required-imports': ['error', [
384
+ {
385
+ files: '**/adapters/*/src/**/*.test.ts',
386
+ from: ['**/contract/src/*conformance*', '@acme/contract/conformance'],
387
+ call: true,
388
+ },
389
+ ]]
390
+ ```
391
+
392
+ A file matching `files` must import at least one binding from a module whose specifier matches `from` (a pattern or a list). With `call: true` it must also call a binding it imported from there, which a bare import that is never used does not satisfy. A call counts wherever it sits (module top level or inside a callback), including through a namespace or default import (`kit.run(...)`), a non-null assertion (`kit!()`), a construction (`new Kit()`) and a tagged template (``kit`x` ``); passing the binding as an argument is not a call. Side-effect imports and type-only imports (`import type`, or every specifier inline `type`) bind no runtime value and do not count.
393
+
394
+ Resolution is by specifier pattern, not through the module graph, so no type information is needed and the package need not be installed. A `from` pattern follows the rules in [Specifier patterns](#specifier-patterns): a relative specifier such as `../../contract/src/run-conformance` is matched by the path it resolves to (the reference implementation's form), and a package specifier such as `@acme/contract/conformance/pg` is matched as written (the adapter's form). List both forms in `from` to cover a kit imported either way. The rule proves the kit is wired in, not that the kit is any good, and a skipped test still satisfies it.
395
+
396
+ Each unsatisfied entry reports once on the program node.
397
+
398
+ ### Specifier patterns
399
+
400
+ `required-imports` (`from`) and [`import-policy`](#import-policy) (`specifiers`) match module specifiers the same way:
401
+
402
+ - Patterns use the file-glob dialect, and each also selects everything beneath it, so `fs` selects `fs/promises` and `@scope/pkg` selects `@scope/pkg/sub`.
403
+ - A leading `node:` is ignored on both sides, so `fs` and `node:fs` are one builtin.
404
+ - A relative specifier is resolved against the linted file's directory to a path relative to the working directory, then matched only by patterns containing a `/`. A bare pattern such as `fs` therefore never selects `./fs`. A relative specifier that leaves the working directory matches nothing.
405
+ - Nothing is resolved through `node_modules` or the TypeScript path map, so an alias such as `@/db` is matched as the string it is.
406
+
407
+ ## Import policy
408
+
409
+ `importPolicyConfig` turns a list of policies into a flat-config block for `exadev/import-policy`, or pass the same list to `exadevConfig({ importPolicies })`.
410
+
411
+ ```ts
412
+ import { defineConfig } from 'eslint/config';
413
+ import { exadevConfig, importPolicyConfig } from '@exadev/eslint-config';
414
+
415
+ export default defineConfig(
416
+ ...exadevConfig(),
417
+ ...importPolicyConfig([
418
+ {
419
+ files: ['src/worker/**'],
420
+ deny: [{ specifiers: ['fs', 'path', 'child_process'], message: 'worker code runs where Node builtins do not exist' }],
421
+ },
422
+ {
423
+ files: ['src/**'],
424
+ ignores: ['src/**/*.test.ts'],
425
+ confine: [{ specifiers: ['@anthropic-ai/sdk'], onlyIn: ['src/agent/adapter.ts'], allowTypeImports: true }],
426
+ },
427
+ {
428
+ files: ['src/routes/**'],
429
+ deny: [{ specifiers: ['src/db'], message: 'routes reach data through the service layer' }],
430
+ exceptEdges: [{ file: 'src/routes/legacy.ts', specifier: '../db/client', reason: 'predates the service layer' }],
431
+ },
432
+ ]),
433
+ );
434
+ ```
435
+
436
+ A policy selects files with `files` and `ignores` and holds them to:
437
+
438
+ - `deny`: `specifiers` the files may not import. `message` is required and should name the requirement (why, and what to do instead), not the rule. `importNames` limits the ban to those imported names (`default` for a default import); a namespace import, a dynamic import, `require` and `export *` take every name, so they are still reported. `allowTypeImports` leaves erased imports alone.
439
+ - `confine`: `specifiers` the files may import only in the files `onlyIn` selects, so the inverse case needs no hand-built complement. `allowTypeImports` and an optional `message` work as for `deny`; the default message lists `onlyIn`.
440
+ - `exceptEdges`: one exact `file` and `specifier` pair with a required `reason`. Neither may contain a glob character, so an exception cannot widen. The specifier is compared exactly as written in that file, and `file` is normalised, so `./src/a.ts` and `src/a.ts` name the same file. Creating the config throws for an exception that could never apply (a file the policy does not select, or a specifier that no `deny` entry selects and no `confine` entry forbids in that file, such as one inside the confine's `onlyIn`), so a stale exception fails instead of lingering.
441
+
442
+ Specifiers follow [Specifier patterns](#specifier-patterns). The rule is syntactic and covers `import`, `import type`, `export ... from`, `export * from`, `import x = require()`, dynamic `import()` with a static string, `require()` with a static string that is not shadowed, and `import('x')` type queries. A specifier built at runtime cannot be judged and is skipped.
443
+
444
+ ESLint's core `no-restricted-imports` covers only the static forms (`import`, `export ... from`, `export * from` and `import x = require()`); it has no check for dynamic `import()`, `require()` or `import('x')` type queries, and `no-restricted-modules` is deprecated since ESLint 7. A preset compiled to `no-restricted-imports` blocks would also inherit flat config's rule-level override: when two policies select the same file, the later block's option list replaces the earlier one's, and an exception edge would have to restate every other restriction of the file. So the preset compiles to one block for a purpose-built rule instead, where every policy that selects a file applies to it. Files outside a policy's `files` are untouched.
445
+
446
+ ## Filename patterns
447
+
448
+ ```ts
449
+ 'exadev/filename-pattern': ['error', [
450
+ // A name convention: stories are PascalCase.stories.tsx.
451
+ { files: '**/*.stories.tsx', pattern: '[A-Z][A-Za-z0-9]*\\.stories\\.tsx' },
452
+ // A relationship between two files: a component needs its stylesheet beside it.
453
+ { files: ['src/components/*.tsx', '!**/*.stories.tsx'], sibling: ['{name}.module.css', '{name}.module.scss'] },
454
+ // A relationship between size and name: a file over 400 lines must be a numbered part.
455
+ { files: 'src/**/*.ts', overLines: 400, pattern: '.+\\.part-\\d{2}\\.ts' },
456
+ ]]
457
+ ```
458
+
459
+ An entry applies to files matching `files` (and, with `overLines`, to those with more lines than that) and needs a `pattern`, a `sibling`, or both:
460
+
461
+ - `pattern` is a regular expression source that the whole file name (last path segment, extension included) must match. The source must be a valid regular expression on its own, so an unbalanced one such as `z)|(q` is rejected instead of escaping the anchoring.
462
+ - `sibling` is a path or list of paths relative to the linted file's directory; at least one must exist. `{name}` stands for the file's name without its final extension (`Button` for `Button.tsx`), and a path may enter a subdirectory (`styles/{name}.css`). Templates must be distinct, and a brace group other than `{name}` is rejected as a misspelling. To exempt other files, exclude them in `files`.
463
+ - `overLines` counts physical lines, ignoring the newline that ends the file. It is a naming requirement, so it does not replace ESLint's `max-lines`, which still reports the size; this rule requires that the parts an over-long file is split into follow the scheme. It does not check that the parts exist as a sequence.
464
+
465
+ Why not a dependency: [`eslint-plugin-check-file`](https://github.com/dukeluo/eslint-plugin-check-file) (`filename-naming-convention`, `folder-match-with-fex`, `filename-blocklist`, `folder-naming-convention`, `no-index`) and [`unicorn/filename-case`](https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/filename-case.md) cover case styles and per-glob name patterns, and check-file also folder names and blocked names. Neither can express that another file must exist beside the linted one, or that a name depends on the file's line count, which are the two cases this rule exists for. The name-pattern case is the same as what check-file offers, so a project that already uses that plugin for case styles and folder layout can keep it alongside this rule.
466
+
354
467
  ## Workspace architecture
355
468
 
356
469
  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):
@@ -574,6 +687,9 @@ Both need `@eslint/json` resolvable, the same optional peer the other JSON rules
574
687
  | `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`. |
575
688
  | `requireEmptyOutputs` | Also require `outputs` on graph-only and uncached tasks. |
576
689
  | `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. |
690
+ | `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. |
691
+ | `taskGraph` | `{ task, dependsOn }[]`. The [`dependsOn` edges](#required-dependson-edges) each named task must have. Nothing is required by default. |
692
+ | `hygiene` | `{ schemaHosts?, requireCiPassThrough?, aggregateTask? }`. See [turbo.json hygiene](#turbojson-hygiene). |
577
693
  | `boundaries` | `{ aggregateScript?, groups?, allowIgnore? }`. Giving it at all enables the [`turbo boundaries`](#turbo-boundaries) rules. |
578
694
 
579
695
  ### Scripts delegate to underscore tasks
@@ -594,6 +710,51 @@ A task with no `outputs` key, or with `"outputs": null` (which turbo's schema al
594
710
 
595
711
  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.
596
712
 
713
+ ### Cached tasks include their tool configs
714
+
715
+ 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.
716
+
717
+ `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.
718
+
719
+ 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.
720
+
721
+ ### Required dependsOn edges
722
+
723
+ 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:
724
+
725
+ ```ts
726
+ turboConfig({
727
+ taskGraph: [
728
+ { task: '_typecheck', dependsOn: ['^_typecheck'] },
729
+ { task: '_build', dependsOn: ['_typecheck', '^_build'] },
730
+ ],
731
+ });
732
+ ```
733
+
734
+ `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.
735
+
736
+ ### turbo.json hygiene
737
+
738
+ `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:
739
+
740
+ - `hygiene.requireCiPassThrough: true` requires `CI` in `globalPassThroughEnv`, so tasks that read it see it without it entering their cache key.
741
+ - `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'`.
742
+
743
+ ```ts
744
+ turboConfig({
745
+ hygiene: {
746
+ requireCiPassThrough: true,
747
+ aggregateTask: { name: '_prepush', includes: ['_lint', '_typecheck', '_test'] },
748
+ },
749
+ });
750
+ ```
751
+
752
+ ### Environment variables read in source
753
+
754
+ 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.
755
+
756
+ 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.
757
+
597
758
  ### Check and fix are separate tasks
598
759
 
599
760
  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.
@@ -675,6 +836,7 @@ pnpm build
675
836
  - `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.
676
837
  - `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.
677
838
  - 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.
839
+ - `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.
678
840
  - [`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.
679
841
  - `[]` if unresolvable and not explicitly forced on; a thrown `Error` if explicitly forced on (`enabled: true`) and still unresolvable.
680
842
  - `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).
@@ -685,23 +847,42 @@ pnpm build
685
847
  - [`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.
686
848
  - `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()`.
687
849
  - `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.
850
+ - [`src/rules/file-entry.ts`](src/rules/file-entry.ts) is the shared option handling for the per-file rules (`required-exports`, `required-imports`, `import-policy`, `filename-pattern`): `createEntryScope` widens a glob without a `/` to any depth on top of `createFileScope`, and the `read*` helpers validate entries and reject unknown keys.
851
+ - [`src/rules/specifier-match.ts`](src/rules/specifier-match.ts) matches import specifiers against patterns for `required-imports` and `import-policy`, reusing the path matcher `createPathMatcher` in `file-scope.ts`.
852
+ - [`src/import-policy.ts`](src/import-policy.ts) builds the `importPolicyConfig` block from the validated policies in [`src/rules/import-policy-options.ts`](src/rules/import-policy-options.ts), which the rule reads with the same reader.
688
853
  - [`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).
689
854
  - `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.
690
855
  - [`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).
691
856
  - 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.
692
857
  - 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.
693
858
  - [`src/create-config.ts`](src/create-config.ts) is config assembly's single source of truth.
694
- - `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.
859
+ - `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), and any trailing user configs.
695
860
  - `defaultConfig` is `exadevConfig()` evaluated once, eagerly, at module load.
696
861
  - [`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).
697
862
  - [`src/index.ts`](src/index.ts) is the entry point, still a pure re-export barrel:
698
863
  ```ts
699
864
  export { defaultConfig as default, exadevConfig } from './create-config';
865
+ export { importPolicyConfig } from './import-policy';
700
866
  export { publicPlugin as plugin } from './plugin';
701
867
  export { turboConfig } from './turbo-config';
702
868
  export { workspaceArchitectureConfig } from './workspace-architecture';
869
+ export type { ImportConfine, ImportDeny, ImportExceptEdge, ImportPolicy } from './rules/import-policy-options';
870
+ export type { FilenamePatternEntry } from './rules/filename-pattern';
871
+ export type { RequiredExportsEntry } from './rules/required-exports';
872
+ export type { RequiredImportsEntry } from './rules/required-imports';
703
873
  export type { GroupSpec, NamingOptions, RankRule, RankSkipOptions, SliceSpec, WorkspaceArchitectureOptions } from './rules/workspace-options';
704
- export type { AllowedEdge, ExemptTargetGroup, PackageSelector, PackageSelectorFields, RequiredFiles, RequiredScripts, ScriptContent, ScriptRequirement } from './rules/workspace-constraint-options';
874
+ export type {
875
+ AllowedEdge,
876
+ ExemptTargetGroup,
877
+ PackageSelector,
878
+ PackageSelectorFields,
879
+ RequiredFiles,
880
+ RequiredScripts,
881
+ ScriptContent,
882
+ ScriptRequirement,
883
+ } from './rules/workspace-constraint-options';
884
+ export type { AggregateTaskOptions, AllowedBoundariesIgnore, BoundaryGroup, TaskGraphRequirement, TurboBoundariesOptions, TurboHygieneOptions, TurboOptions } from './rules/turbo-options';
885
+ export type { TurboDelegate } from './rules/turbo-commands';
705
886
  ```
706
887
  - 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.
707
888
  - Required by `no-side-effects-in-index`/`no-non-barrel-reexport`, both of which assume this file contains nothing but `export ... from ...`.