Skip to content

Repository files navigation

@exadev/eslint-config

GitHub npm Release CI

A real ESLint plugin (not a shareable config) exposing custom rules shared across ExaDev projects. Also published under the unscoped alias exadev-eslint-config.

Why

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.

Getting started

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 eslint

The default export is the full, type-checked ruleset: typescript-eslint's strictTypeChecked + stylisticTypeChecked presets (strictTypeChecked subsumes recommendedTypeChecked, so it already includes no-deprecated, no-misused-spread, no-mixed-enums, no-unnecessary-condition, use-unknown-in-catch-callback-variable, return-await, related-getter-setter-pairs, no-unnecessary-type-parameters, and more -- those are no longer re-listed below), exadev/barrel-policy at mode: 'banned' (see Barrel policy), exadev/no-object-assign, exadev/no-mutable-union-array-param, exadev/no-array-isarray-mutation, exadev/no-enum-number-widening, exadev/no-enum-reverse-lookup-widening, exadev/no-map-instanceof-mutation, exadev/no-set-instanceof-mutation, exadev/prefer-readonly-array-param, exadev/prefer-readonly-object-param, exadev/prefer-numeric-sort-compare, exadev/no-pointless-reassignment, linterOptions.noInlineConfig, @typescript-eslint/consistent-type-assertions banning all type assertions, @typescript-eslint/consistent-type-imports, @typescript-eslint/consistent-type-exports, @typescript-eslint/consistent-return (a function that implicitly returns undefined on one path and a real value on another -- a common real bug, not a deliberate design), @typescript-eslint/no-non-null-assertion banning the ! operator (the same manual-override escape hatch as a type assertion, under a different spelling), @typescript-eslint/no-redeclare, @typescript-eslint/no-shadow, @typescript-eslint/no-use-before-define set to { functions: false } (a genuine temporal-dead-zone crash risk for let/const/class/enum bindings, but exempting function declarations, which are fully hoisted and therefore runtime-safe to call before their point of textual declaration -- this codebase's own rule files consistently define their helper functions after the logic that calls them), @typescript-eslint/ban-ts-comment banning @ts-expect-error outright, @typescript-eslint/method-signature-style set to 'property' (method-shorthand signatures are checked bivariantly under strictFunctionTypes, which is unsound), @typescript-eslint/prefer-readonly, @typescript-eslint/promise-function-async, @typescript-eslint/require-array-sort-compare, @typescript-eslint/strict-void-return (not yet in any typescript-eslint preset -- disallows passing a value-returning function where a void-returning one is expected, e.g. arr.forEach(x => otherArray.push(x)), which typechecks today under TS's own void-return contravariance leniency), @typescript-eslint/switch-exhaustiveness-check, @typescript-eslint/strict-boolean-expressions at the rule's own bare defaults (an unambiguous non-nullable truthy check stays allowed; an ambiguous nullable check does not), and @typescript-eslint/no-magic-numbers tuned to exempt array indexes, enum members, readonly class properties, default parameter values, and the handful of universally-idiomatic bare numbers (-1, 0, 1, 2) -- the type-assertion and ts-comment rules are relaxed in test files, and no-magic-numbers is not (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 barrel

strictTypeChecked subsumes both typescript-eslint's plain recommended and recommendedTypeChecked outright (every rule in each is also present in strictTypeChecked), 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/strictTypeChecked/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.

The lighter option: the plugin named export

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 non-type-aware 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.

Rules

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. Autofix rewrites every read to the original name and deletes the declaration (including its export keyword, when exported). Still reported but deliberately not auto-fixable where collapsing the alias would change meaning: an explicit type annotation (const exhaustive: never = item -- the annotation is the point), a read where the original name is shadowed, a read as a shorthand object property, more than one declarator in the statement, or a source that is written to anywhere.
no-object-assign ✓/suggestion Object.assign does not check a source object's properties against the target's declared types, unlike object spread. A fresh object-literal target autofixes to { ...target, ...source }; mutating an existing reassignable binding offers a suggestion only (changes the object's identity); a const binding or a non-statement call site gets a plain report with no fix.
no-mutable-union-array-param A function parameter typed as an array of a union ((string | number)[]) accepts a narrower caller array (number[]) by covariance; calling push/unshift/splice/fill/copyWithin on it can then insert a value the caller's own array was never declared to hold. Autofix marks the parameter readonly, turning the mutating call into a real compile error to resolve deliberately. Requires no type information.
prefer-readonly-array-param A narrower, safely-autofixable sibling of @typescript-eslint/prefer-readonly-parameter-types scoped to array/tuple parameter shapes only: fires unconditionally on every non-readonly array or tuple parameter, regardless of whether the function body mutates it, in any parameter position (a plain identifier, a rest parameter, a default-valued parameter, or a constructor parameter property) and any function-like shape (a concrete function/arrow/method, or a declaration-only ambient function, interface method, function type alias, call/construct signature, or abstract/ambient class method). A union containing an array/tuple member is fixed on that member alone. Autofix prepends readonly (or renames Array<T> to ReadonlyArray<T>), turning any resulting mutation into a real compile error to resolve deliberately. Requires no type information -- registered in both plugin.configs.recommended and the default (type-checked) export.
prefer-readonly-object-param The object-shape sibling of prefer-readonly-array-param above, scoped to "flat" object parameters where a shallow fix is provably sufficient: an inline { ... } literal or a reference to a plain named type/interface where every property (and index-signature value, if any) is itself a primitive, a literal/union of primitives, or a callback -- with no nested object, array, tuple, Map, Set, class instance, union, intersection, or unconstrained type parameter anywhere in the shape. Autofix wraps the parameter's own type annotation in Readonly<...>, which TypeScript's own deep-readonly check accepts as fully sufficient for a shape this flat. Requires type information -- only in the default (type-checked) export, not plugin.configs.recommended -- to resolve each property's real type via the checker.
no-array-isarray-mutation Array.isArray's own type declaration narrows to plain any[], discarding the readonly guarantee of any array type in the narrowed parameter's or local variable's real type -- a bare readonly T[], a ReadonlyArray<T>, one behind a type alias, or one alongside other union members -- inside the guarded branch; calling push/unshift/splice/fill/copyWithin there can mutate a caller's genuinely readonly array. Recognises the direct if (Array.isArray(x)) guard (braced or not), the early-return/early-throw idiom, &&, the ternary form, and the else-of-a-negated-test form. No autofix: re-adding readonly is a no-op (the guard already discarded it) and rewriting the mutating call into a copy-first pattern is not safely mechanical in the presence of aliasing. Requires type information -- only in the default (type-checked) export, not plugin.configs.recommended -- specifically to see through a type alias and to catch a bare, non-union readonly array parameter or local variable, neither visible from its own syntax alone.
no-map-instanceof-mutation Map is declared as extending ReadonlyMap, so instanceof Map narrows a parameter or local variable whose real type includes a ReadonlyMap -- bare, unioned, or reached through a type alias -- straight past the readonly guarantee to the full mutable interface; calling set/delete/clear there can mutate a caller's genuinely read-only map. Recognises the direct if (input instanceof Map) guard (braced or not), the early-return/early-throw idiom, &&, the ternary form, and the else-of-a-negated-test form. No autofix: rewriting the mutating call into a copy-first pattern is not safely mechanical in the presence of aliasing. Requires type information -- only in the default (type-checked) export, not plugin.configs.recommended.
no-set-instanceof-mutation instanceof Set narrows a parameter or local variable whose real type includes a ReadonlySet -- bare, unioned, or reached through a type alias -- straight to the fully mutable Set interface, with no way to preserve the read-only guarantee through the narrowing; calling add/delete/clear there can mutate a caller's genuinely read-only set. Recognises the same guard idioms as no-map-instanceof-mutation above. No autofix, for the same aliasing reason. Requires type information -- only in the default (type-checked) export, not plugin.configs.recommended.
no-enum-number-widening A bare (non-literal) number is accepted anywhere a numeric enum is expected, without checking it is actually one of the enum's members -- only a numeric literal gets range-checked by tsc. No autofix: the only provably safe fix is a genuine runtime membership check against the enum's own values, which is a behavioural choice a mechanical fix cannot responsibly make. Requires type information -- only in the default (type-checked) export, not plugin.configs.recommended.
no-enum-reverse-lookup-widening suggestion Indexing a numeric enum's reverse mapping (Direction[n]) with a bare (non-literal) number, or with a different enum's member, types as plain string for any index, including one outside the enum's actual members, where it genuinely returns undefined at runtime -- tsc does not range-check even a numeric literal index here. When the indexed expression is the init of a variable with an explicit : string annotation, a suggestion widens it to : string | undefined, forcing later uses as a bare string to surface as real compile errors; every other syntactic position gets a plain report with no fix, and no case gets a full --fix autofix. Requires type information -- only in the default (type-checked) export, not plugin.configs.recommended.
prefer-numeric-sort-compare suggestion A deliberately narrow addition alongside @typescript-eslint/require-array-sort-compare (which already flags any bare .sort()/.toSorted() except on a plain string array, with no fix): when the array's element type is definitively number, a suggestion offers an ascending compare function ((a, b) => a - b), since the default comparator sorts lexicographically ([1, 2, 10].sort() becomes [1, 10, 2]). Not a full autofix -- descending order is a real, if less common, alternative intent. Requires type information -- only in the default (type-checked) export, not plugin.configs.recommended, since it needs the checker to confirm the array's element type.

Barrel policy

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.

Build, test, and lint

pnpm install    # requires Node >=20 and pnpm 11.6.0 (pinned via packageManager)
pnpm lint
pnpm typecheck
pnpm test
pnpm build

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

Architecture

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 strictTypeChecked + 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.

Conventions

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.

Gotchas and quirks

  • .attw.json ignores false-export-default: tsdown/rolldown's CJS output for this plugin's sole default export doesn't emit the export = form arethetypeswrong wants under legacy node10 resolution. The modes ESLint flat config uses (node16, bundler) are unaffected, so the rule is suppressed rather than changing the default-export shape.
  • src/index.ts mixing a default export with a named one triggers rolldown's MIXED_EXPORTS warning: a raw CommonJS require() would see the raw exports object instead of the default. ESM import (the actual consumer path) resolves both correctly; attw --pack and publint report no problems, so the warning is accepted (see tsdown.config.ts).
  • Husky hooks: pre-commit runs lint-staged (eslint --fix on staged *.ts), commit-msg runs commitlint, pre-push runs typecheck + test + build.
  • The CI release job sets HUSKY=0 (commit-msg hook skips the automated release commit) and blanks NPM_TOKEN/NODE_AUTH_TOKEN explicitly so an inherited token can't win over OIDC trusted publishing.

Contributing

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.

Release

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.

License

MIT

About

Shared custom ESLint rules and plugin for ExaDev projects

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages