A real ESLint plugin (not a shareable config) exposing custom rules shared across ExaDev projects. Also published under the unscoped alias
exadev-eslint-config.
Multiple ExaDev repos carried identical copies of a handful of custom ESLint rules (barrel/index discipline, re-export placement, pointless-alias detection). This package is the single source of truth for those rules. Only the rules are centralized -- not a consumer's whole eslint.config.ts, since file-scoping, tsconfig wiring, and runtime-isomorphism import bans are genuinely project-specific. Each consumer keeps its own eslint.config.ts, importing rule implementations from here.
Consumers need eslint >=10.0.0 and typescript-eslint >=8.0.0 as required peer dependencies. Importing anything from this package resolves typescript-eslint, since both the default export and plugin share the same root module -- ESM/CJS module evaluation runs a module's entire top-level import graph regardless of which export the caller reads (see Architecture).
pnpm add -D @exadev/eslint-config typescript-eslint eslintThe default export is the full, type-checked ruleset: typescript-eslint's recommendedTypeChecked + stylisticTypeChecked presets, exadev/barrel-policy at mode: 'banned' (see Barrel policy), exadev/no-pointless-reassignment, linterOptions.noInlineConfig, @typescript-eslint/consistent-type-assertions banning all type assertions, and @typescript-eslint/ban-ts-comment banning @ts-expect-error outright -- the last two relaxed in test files (see below). Spread it directly into tseslint.config(...):
// eslint.config.ts
import exadev from '@exadev/eslint-config';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
languageOptions: {
parserOptions: { project: './tsconfig.json', tsconfigRootDir: import.meta.dirname },
},
},
...exadev,
// ...your own config on top...
);A published package whose src/index.ts is its package entry point overrides banned to single in one line (flat-config later blocks override earlier rule settings), since deleting its barrel would break every downstream importer:
...exadev,
{ rules: { 'exadev/barrel-policy': ['error', { mode: 'single' }] } }, // this package keeps its barrelrecommendedTypeChecked subsumes typescript-eslint's plain recommended outright (all 46 of its rules are a subset of recommendedTypeChecked's 73), and its base config registers the @typescript-eslint plugin and sets languageOptions.parser itself. That is why you must remove your own ...tseslint.configs.recommended/recommendedTypeChecked/stylisticTypeChecked spreads -- flat config rejects two different plugin object instances registered under the same namespace. You still supply languageOptions.parserOptions.project/projectService pointing at your own tsconfig(s).
Test files (**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}) get two narrow relaxations of this package's own additions, and only those two. @ts-expect-error reverts to allow-with-description (a compile-time-only assertion of a type failure is a legitimate test pattern; @ts-ignore/@ts-nocheck stay banned since @ts-expect-error is strictly better). consistent-type-assertions relaxes to assertionStyle: 'as' (the legacy <Type>value form stays banned everywhere). Nothing inherited from the presets is relaxed.
For a project that wants only this package's own rules without the full type-checked bundle, import the named plugin export and wire rules individually:
// eslint.config.ts
import { plugin } from '@exadev/eslint-config';
import tseslint from 'typescript-eslint';
export default tseslint.config(
// ...your own config...
{
files: ['src/**/*.ts'],
ignores: ['src/index.ts'],
plugins: { exadev: plugin },
rules: {
'exadev/no-non-barrel-reexport': 'error',
},
},
);Or use one of plugin's two bundled configs to enable a whole set at once:
import { plugin } from '@exadev/eslint-config';
import { defineConfig } from 'eslint/config';
export default defineConfig([
{
files: ['**/*.ts'],
plugins: { exadev: plugin },
extends: ['exadev/recommended'], // this plugin's own four rules, plus linterOptions.noInlineConfig -- no type-checked rules at all
// or: extends: ['exadev/barrel'], // just the barrel-discipline trio (no-non-barrel-index, no-non-barrel-reexport, no-side-effects-in-index)
},
]);tseslint.config() does not accept string extends (only defineConfig() does); pass the config value directly instead:
import { plugin } from '@exadev/eslint-config';
import tseslint from 'typescript-eslint';
export default tseslint.config(
// ...your own config...
{
files: ['**/*.ts'],
plugins: { exadev: plugin },
extends: [plugin.configs.recommended], // or plugin.configs.barrel
},
);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.
| Rule | Fixable | Description |
|---|---|---|
barrel-policy |
Umbrella over the four barrel rules below: one { mode } option selecting a whole index-file policy. See 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. |
no-side-effects-in-index |
A barrel file may contain only re-export statements -- nothing that could execute at import time. Self-scopes to any index file. | |
barrel-direct-siblings-only |
A barrel may re-export only from a direct sibling (./module), never a nested path, parent, or bare package specifier (mode 3). |
|
no-pointless-reassignment |
✓ | const foo = bar where both sides are plain identifiers and the alias adds no transformation. |
exadev/barrel-policy is the convenience layer: one rule id, one { mode } option selecting a complete index-file policy. Use EITHER this umbrella OR the individual rules (not both -- they double-report).
mode |
Which files may be barrels | What a barrel may contain | Where re-exports may come from |
|---|---|---|---|
'banned' (default/recommended) |
none | — | — |
'single' |
exactly src/index.ts |
only re-exports | anywhere |
'siblings' |
any index.ts |
only re-exports | a direct sibling only (./module) |
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). It is non-fixable -- the autofix lives on no-non-barrel-reexport.
pnpm install # requires Node >=20 and pnpm 11.6.0 (pinned via packageManager)
pnpm lint
pnpm typecheck
pnpm test
pnpm buildEach rule has a co-located *.test.ts exercising it with ESLint's RuleTester under Vitest. 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.
pnpm test always measures coverage (@vitest/coverage-v8), scoped to src/**/*.ts excluding *.test.ts. Text output in terminal; html/lcov in coverage/ (gitignored alongside .eslintcache and dist/).
The lint/typecheck/test/build npm scripts wrap turbo tasks named _lint/_typecheck/_test/_build -- run pnpm build, not turbo run build.
pnpm build runs tsdown from src/index.ts, bundling the whole module graph into ESM + CJS + declarations. prepublishOnly re-runs lint, typecheck, test, tsdown, publint, and attw --pack.
src/plugin.ts builds an ESLint.Plugin (ESLint's own type) combining src/rules/ into a flat rules map. configs.recommended and configs.barrel are getters in the object literal -- each references the fully-built plugin (plugins: { exadev: plugin }), which a plain property initializer can't do mid-construction. recommended ships barrel-policy at mode: 'banned'; barrel at mode: 'single'.
src/recommended-type-checked.ts bundles typescript-eslint's recommendedTypeChecked + stylisticTypeChecked alongside this plugin's rules into a flat config array. Its value is typed as ConfigArrayValue = Extract<ConfigValue, unknown[]> (the array-only member of ESLint's own config-value union), because annotating with the wider union broke ...exadev with TS2488.
src/index.ts is the entry point: export { default } from './recommended-type-checked'; export { default as plugin } from './plugin';. Both exports share one root module, so importing { plugin } alone still resolves typescript-eslint via the sibling re-export -- an accepted trade-off (an earlier separate-subpath split proved more awkward in practice).
pnpm-workspace.yaml declares an empty packages: [] -- not a real workspace, just giving turbo a root for local task caching.
eslint.config.ts dogfoods the default export on itself (import exadev from './src/index'), spreading it exactly as a real consumer would. no-side-effects-in-index and no-non-barrel-reexport self-scope to src/index.ts internally, so no files/ignores wiring is needed here. Plugin construction lives in src/plugin.ts specifically so src/index.ts stays a pure re-export point.
tsconfig.json enables verbatimModuleSyntax (import type/export type required for type-only imports -- also enforced by consistent-type-imports) and noUncheckedIndexedAccess (narrow indexed access before use rather than asserting).
Conventional commits are enforced by commitlint, restricted to the type-enum defined once in release.config.ts's commitTypes -- both commitlint and semantic-release derive from that single list.
.attw.jsonignoresfalse-export-default: tsdown/rolldown's CJS output for this plugin's sole default export doesn't emit theexport =formarethetypeswrongwants under legacynode10resolution. The modes ESLint flat config uses (node16,bundler) are unaffected, so the rule is suppressed rather than changing the default-export shape.src/index.tsmixing a default export with a named one triggers rolldown'sMIXED_EXPORTSwarning: a raw CommonJSrequire()would see the raw exports object instead of the default. ESMimport(the actual consumer path) resolves both correctly;attw --packandpublintreport no problems, so the warning is accepted (seetsdown.config.ts).- Husky hooks:
pre-commitruns lint-staged (eslint --fixon staged*.ts),commit-msgruns commitlint,pre-pushruns typecheck + test + build. - The CI release job sets
HUSKY=0(commit-msg hook skips the automated release commit) and blanksNPM_TOKEN/NODE_AUTH_TOKENexplicitly so an inherited token can't win over OIDC trusted publishing.
Conventional commits are enforced by a husky commit-msg hook and re-checked in CI. CI runs commitlint, lint, and typecheck+test+build+attw on every push and pull request; the release job runs only on push to main, after all pass.
Conventional commits drive semantic-release on every push to main: version bump, CHANGELOG.md, GitHub Release, and npm publish via OIDC (no stored token). A second CI job republishes the identical build under the unscoped alias exadev-eslint-config.
MIT