Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export default tseslint.config(
);
```

`plugin.configs.recommended`/`plugin.configs.barrel` carry no `files`/`ignores` and are safe unscoped — `no-side-effects-in-index` and `no-non-barrel-reexport` each check `context.filename` themselves (self-scoping). For a barrel not at `src/index.ts`, or a project-specific exception, layer an override on top (e.g. `{ files: ['lib/other.ts'], rules: { 'exadev/no-non-barrel-reexport': 'off' } }`) rather than wiring all four rules individually.
`plugin.configs.recommended`/`plugin.configs.barrel` carry no `files`/`ignores` and are safe unscoped — `no-side-effects-in-index` and `no-non-barrel-reexport` each check `context.filename` themselves (self-scoping). For a barrel not at `src/index.ts`, or a project-specific exception, layer an override on top (e.g. `{ files: ['lib/other.ts'], rules: { 'exadev/no-non-barrel-reexport': 'off' } }`) rather than wiring all four rules individually. A package that publishes several entry points declares them through `exadev/barrel-policy`'s `entries` option instead (see [Package entry points](#package-entry-points)), which keeps each entry held to the barrel rules rather than switching them off.

## Optional features

Expand Down Expand Up @@ -376,7 +376,7 @@ Bundled into `exadevConfig()`'s default output the same way React/Next.js auto-d

| Rule | Fixable | Description |
| --- | --- | --- |
| `barrel-policy` | | **Umbrella rule selecting a whole index-file barrel policy.** One `{ mode }` option covers the four barrel rules below. See [Barrel policy](#barrel-policy). |
| `barrel-policy` | | **Umbrella rule selecting a whole index-file barrel policy.** One `{ mode }` option covers the four barrel rules below, and an `entries` option declares a package's other entry files as barrels. See [Barrel policy](#barrel-policy). |
| `no-index-files` | | **Bans any `index.*` file outright** (mode 1). The strictest policy. |
| `no-non-barrel-index` | | **Only `src/index.ts` may be named `index.*`** — any other `index.ts`/`.js`/etc would be silently selected by a consumer's bare directory import. |
| `no-non-barrel-reexport` | ✓ | **Re-exports belong only in a barrel.** Catches the split form across two statements (`import { x } from './y'; export { x };` or `export default x;`) which no AST selector alone can match. Autofix deletes the export and the now-pointless import when it was the import's only use. Self-scopes away from any index file. |
Expand Down Expand Up @@ -465,6 +465,33 @@ Notes on `'auto'`:

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`.

### Package entry points

A package that publishes subpath exports (`"./layout"`, `"./store/node"`) has a module behind each one, and that module is an entry point exactly as `src/index.ts` is: its job is to re-export a surface, usually another package's. `single` treats only `src/index.ts` as a barrel and `siblings` only accepts re-exports from a direct sibling, so neither mode can describe such a package, and turning the rule off for the entry files would also stop checking that they hold nothing but re-exports. The `entries` option lists them instead:

```ts
export default defineConfig(...exadevConfig(), {
rules: {
'exadev/barrel-policy': ['error', { mode: 'single', entries: ['src/layout.ts', 'src/store/*.ts', 'src/*/index.ts'] }],
},
});
```

`entries` is a list of globs in the [file glob form](#file-level-rules), relative to the directory of the `package.json` nearest the linted file rather than to ESLint's working directory, so one shared config serves every package in a workspace. A glob is matched as written, with no widening to any depth: `src/*.ts` selects the files directly in `src`, and `src/*/index.ts` one level of index files below it. A leading `!` excludes. A file with no `package.json` above it is no package's entry point.

A matched file is a permitted barrel in the mode's terms:

| `mode` | What an entry file may contain | Where its re-exports may come from |
| --- | --- | --- |
| `'single'` (and `'auto'` resolved to `single`) | only re-exports | anywhere, as for `src/index.ts` |
| `'siblings'` | only re-exports | a package (`pkg`, `@scope/pkg/sub`, `node:fs`) or a direct sibling (`./module`) |

An entry file that is an index file below `src` is not reported as a second index file in `single` mode. Every file the list does not match keeps the behaviour its mode gives it: a re-export from it is still reported, an index file it does not match is still reported in `single` mode, and a non-entry index file in `siblings` mode still re-exports only from its siblings. In `siblings` mode a relative path that is not a sibling (`../shared`, `./a/b`) and a subpath import (`#internal`, which resolves to a file of this package) are reported from an entry file too.

`entries` beside `mode: 'banned'` is an error when the rule is created, since that mode permits no barrel. Under `'auto'`, a package with neither `exports` nor `main` resolves to `banned` and publishes no entry point, so the list selects nothing there.

Why a list rather than reading `exports`: an `exports` target names the built file (`./dist/layout.js`), and which source file produced it depends on the build tool's configuration (tsdown or tsup entries, `tsc`'s `rootDir` and `outDir`, a bundler's input map), which a lint rule cannot read. Deriving the entries would therefore guess, and a wrong guess either reports a real entry point or lets an ordinary module re-export freely; the list states them exactly.

## File-level rules

`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.
Expand Down
13 changes: 9 additions & 4 deletions src/config-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,20 @@ export type ConfigArrayValue = Extract<ConfigValue, unknown[]>;
export type PublicConfigArray = ESLintCoreConfig[];

/**
* What each of PublicPlugin's four bundled configs actually resolves to at runtime (src/plugin.ts's own getters): `recommended`/`barrel` are each a single flat config object; `react`/`nextjs` are each an array, since building React/Next.js support composes more than one upstream config block (src/react.ts/src/nextjs.ts). Never a LegacyConfigObject: `@eslint/core`'s own `Plugin['configs']` allows one, but none of the four getters ever return one, so the public type doesn't need to describe one either.
* What `recommended` and `barrel` (src/plugin.ts's own getters) resolve to at runtime: each a single flat config object. Never a LegacyConfigObject: `@eslint/core`'s own `Plugin['configs']` allows one, but neither getter ever returns one, so the public type doesn't need to describe one either.
*/
export type PublicConfigObject = ESLintCoreConfigObject | ESLintCoreConfigObject[];
export type PublicConfigObject = ESLintCoreConfigObject;

/**
* What `react` and `nextjs` resolve to at runtime: each an array, since building React/Next.js support composes more than one upstream config block (src/react.ts/src/nextjs.ts). Typed as the array it is, so a consumer can spread it (`defineConfig(...plugin.configs.react)`) as well as pass it to `extends`.
*/
export type PublicConfigObjectArray = ESLintCoreConfigObject[];

/**
* Derived from ESLintCoreConfigObject's own `plugins` field (a real library type, not a hand-written interface), matching the derivation style ConfigValue above already uses. The named `plugin` export (this package's public entry point for a consumer wiring `plugins: { exadev: plugin }` straight into `defineConfig()`) needs `@eslint/core`'s own Plugin shape for the same reason PublicConfigArray needs ESLintCoreConfig: `plugin`'s internal build (src/plugin.ts) is typed against `@typescript-eslint/utils`' `TSESLint.FlatConfig.Plugin`, whose `configs` field (`Record<string, TSESLint.FlatConfig.Config | TSESLint.FlatConfig.ConfigArray>`) is not assignable to `@eslint/core`'s own `Record<string, LegacyConfigObject | ConfigObject | ConfigObject[]>` under `exactOptionalPropertyTypes: true` (confirmed directly: a bare `defineConfig({ plugins: { exadev: plugin } })` fails TS2375 on exactly this property before the cast in src/to-public-plugin.ts is applied). `@eslint/core` itself is deliberately not a direct import here, the same way PublicConfigArray avoids one for Config: indexing through `eslint/config`'s own re-exported `ConfigObject` keeps this package's peer surface at just `eslint`, which every consumer already has.
*
* PublicPlugin further narrows Plugin's own `configs` field down to the shape README.md actually documents: exactly the four literal keys src/plugin.ts's own object declares, each typed as PublicConfigObject above, in place of Plugin's own `configs?: Record<string, LegacyConfigObject | ConfigObject | ConfigObject[]> | undefined`. Without this narrowing, `plugin.configs.recommended` fails three ways at once under a plain `strict: true` consumer tsconfig plus this repo's own `noPropertyAccessFromIndexSignature`: TS4111 (Plugin['configs'] has no literal keys, only an index signature, so dot access is rejected), TS18048 even after switching to index access (`configs` itself is optional on the un-narrowed type), and TS2322 wherever the result is then used as an `extends` element (LegacyConfigObject's own `plugins` field is `string[]`, not the `Record<string, Plugin>` an `ExtendsElement`/`InfiniteArray<ConfigWithExtends>` requires): confirmed directly against `tseslint.config({ extends: [plugin.configs.recommended] })` and the equivalent `defineConfig()` React/Next.js examples (README.md's own "lighter option" and "Optional features" sections; see src/readme-examples.ts).
* PublicPlugin further narrows Plugin's own `configs` field down to the shape README.md actually documents: exactly the four literal keys src/plugin.ts's own object declares, each typed as what its getter returns (PublicConfigObject or PublicConfigObjectArray above), in place of Plugin's own `configs?: Record<string, LegacyConfigObject | ConfigObject | ConfigObject[]> | undefined`. Without this narrowing, `plugin.configs.recommended` fails three ways at once under a plain `strict: true` consumer tsconfig plus this repo's own `noPropertyAccessFromIndexSignature`: TS4111 (Plugin['configs'] has no literal keys, only an index signature, so dot access is rejected), TS18048 even after switching to index access (`configs` itself is optional on the un-narrowed type), and TS2322 wherever the result is then used as an `extends` element (LegacyConfigObject's own `plugins` field is `string[]`, not the `Record<string, Plugin>` an `ExtendsElement`/`InfiniteArray<ConfigWithExtends>` requires): confirmed directly against `tseslint.config({ extends: [plugin.configs.recommended] })` and the equivalent `defineConfig()` React/Next.js examples (README.md's own "lighter option" and "Optional features" sections; see src/readme-examples.ts).
*/
export type PublicPlugin = Omit<NonNullable<ESLintCoreConfigObject['plugins']>[string], 'configs'> & {
readonly configs: Readonly<Record<'recommended' | 'barrel' | 'react' | 'nextjs', PublicConfigObject>>;
readonly configs: Readonly<Record<'recommended' | 'barrel', PublicConfigObject> & Record<'react' | 'nextjs', PublicConfigObjectArray>>;
};
10 changes: 9 additions & 1 deletion src/consumer-compatibility.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig } from 'eslint/config';
import { defineConfig, type Config } from 'eslint/config';
import tseslint from 'typescript-eslint';
import { defaultConfig, exadevConfig } from './create-config';
import { publicPlugin } from './plugin';

/**
* Not imported by index.ts (see tsdown.config.ts's entry -- only src/index.ts is bundled), so this file contributes nothing to the published package. Its only job is to be included in `tsc -p tsconfig.json` (see tsconfig.json's own `include`), so `pnpm typecheck` fails the moment either consumption pattern below stops compiling -- a real regression test for PublicConfigArray's own compatibility claim (see config-types.ts), not just a comment asserting it.
Expand All @@ -9,3 +10,10 @@ import { defaultConfig, exadevConfig } from './create-config';
export const viaTseslintConfig = tseslint.config(...defaultConfig);
export const viaDefineConfig = defineConfig(defaultConfig);
export const viaDefineConfigSpread = defineConfig(...exadevConfig());

/**
* Spreads `plugin.configs.react` and `plugin.configs.nextjs` into `defineConfig()`, which the types allow only while they describe the arrays the getters return (a single-object type made this TS2488, ExaDev/eslint-config#134). A function rather than a constant so the getters, which resolve the optional React and Next.js peers, run only when a test calls it.
*/
export function viaSpreadOptionalConfigs(): Config[] {
return defineConfig(...publicPlugin.configs.react, ...publicPlugin.configs.nextjs);
}
21 changes: 20 additions & 1 deletion src/consumer-compatibility.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import type { Linter } from 'eslint';
import { Linter as LinterClass } from 'eslint';
import { describe, expect, it } from 'vitest';
import { exadevConfig } from './create-config';
import { viaDefineConfig, viaDefineConfigSpread, viaTseslintConfig } from './consumer-compatibility';
import { viaDefineConfig, viaDefineConfigSpread, viaSpreadOptionalConfigs, viaTseslintConfig } from './consumer-compatibility';
import { publicPlugin } from './plugin';

// This file's job is proving the documented consumer patterns keep working at runtime, not just typecheck — consumer-compatibility.ts is deliberately excluded from the published bundle (see its own comment), so nothing else in this package ever imports it.
describe('consumer compatibility patterns', () => {
Expand All @@ -21,6 +22,24 @@ describe('consumer compatibility patterns', () => {
expect(Array.isArray(viaDefineConfigSpread)).toBe(true);
expect(viaDefineConfigSpread.length).toBeGreaterThan(0);
});

it('defineConfig(...plugin.configs.react, ...plugin.configs.nextjs) spreads the blocks both getters return', () => {
// This repository installs the React and Next.js peers as dev dependencies, so both getters resolve and each contributes at least one block.
expect(viaSpreadOptionalConfigs().length).toBeGreaterThanOrEqual(publicPlugin.configs.react.length + publicPlugin.configs.nextjs.length);
expect(publicPlugin.configs.react.length).toBeGreaterThan(0);
expect(publicPlugin.configs.nextjs.length).toBeGreaterThan(0);
});

it('types plugin.configs.recommended and plugin.configs.barrel as single config objects, as their getters return them', () => {
expect(Array.isArray(publicPlugin.configs.recommended)).toBe(false);
expect(Array.isArray(publicPlugin.configs.barrel)).toBe(false);
expect(() => {
// @ts-expect-error: a single config object is not iterable, so the type must reject spreading it.
const spread: unknown[] = [...publicPlugin.configs.recommended];

return spread;
}).toThrow(TypeError);
});
});

// The README's own @stylistic migration note, pinned by a real Linter run: the bundled config registers @stylistic/eslint-plugin under the '@stylistic' key itself (stylistic-comments.ts), so a consumer appending a block that registers a DIFFERENT copy of the plugin (a separate install of another version resolving its own module instance; a shallow copy of the real one reproduces exactly that distinct identity) hard-crashes ESLint's flat-config plugin normalisation, the identical "Cannot redefine plugin" restriction the README's typescript-eslint note documents. The identical instance is accepted, which is what makes "configure further @stylistic/* rules directly in your own rules block" a real migration path rather than a workaround. A spread-out copy of a plugin object is the standard stand-in for a second install: flat config compares plugin identity by reference, so only the reference differs, exactly as it does for a duplicated package.
Expand Down
6 changes: 3 additions & 3 deletions src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { TSESLint } from '@typescript-eslint/utils';
import type { ConfigValue, PublicPlugin } from './config-types';
import type { ConfigArrayValue, ConfigValue, PublicPlugin } from './config-types';
import { version } from '../package.json';
import { buildNextjsConfig } from './nextjs';
import { buildReactConfig } from './react';
Expand Down Expand Up @@ -183,11 +183,11 @@ const plugin: TSESLint.FlatConfig.Plugin = {
};
},
// Explicitly selecting this config is itself an explicit request for React support — unlike recommended/barrel above, which only ever reference this package's own always-present rules and can never fail, `enabled: true` here means a missing eslint-plugin-react throws a clear, actionable error rather than silently returning nothing (see src/react.ts). A consumer who wants silent auto-detection instead uses the exadevConfig() factory (src/create-config.ts), which threads the same tri-state through without ever forcing the choice.
get react(): ConfigValue {
get react(): ConfigArrayValue {
return buildReactConfig({ enabled: true });
},
// Mirrors `react` above — explicit selection, throws if @next/eslint-plugin-next isn't installed (see src/nextjs.ts).
get nextjs(): ConfigValue {
get nextjs(): ConfigArrayValue {
return buildNextjsConfig({ enabled: true, plugin });
},
},
Expand Down
Loading