@konceptware/eslint-config
v0.3.0
Published
Konceptware's shared ESLint flat configs: prod, test, vitest, jest, playwright, react.
Readme
@konceptware/eslint-config
Konceptware's ESLint flat configs. Every rule is set explicitly and chosen by us: no
plugin recommended sets are inherited, so a plugin upgrade never changes behaviour
without a decision recorded here. See docs/rules/ for each rule's setting
and purpose.
Presets
| Import | Use for | Extra peer dependencies |
| --------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------- |
| @konceptware/eslint-config | Prod code (TS/JS, type-aware) | — |
| @konceptware/eslint-config/test | Relaxations of the prod rules for tests | — |
| @konceptware/eslint-config/vitest | Vitest rules | @vitest/eslint-plugin |
| @konceptware/eslint-config/jest | Jest rules | eslint-plugin-jest |
| @konceptware/eslint-config/playwright | Playwright rules | eslint-plugin-playwright |
| @konceptware/eslint-config/react | React components and hooks | @eslint-react/eslint-plugin, eslint-plugin-react-hooks, eslint-plugin-jsx-a11y-x |
Formatting
ESLint here checks code quality only; formatting belongs to Prettier, run separately
(prettier --check . in CI). No rule that eslint-config-prettier lists as conflicting
with Prettier is enabled, so you don't need eslint-config-prettier on top of these
presets. tests/prettier-compat.test.ts enforces that.
Run eslint --fix before prettier --write (e.g. in lint-staged): some autofixes, such
as the braces curly adds, are left for Prettier to format.
Usage
pnpm add -D eslint typescript @konceptware/eslint-config// eslint.config.js
import { defineConfig } from 'eslint/config'
import base from '@konceptware/eslint-config'
import test from '@konceptware/eslint-config/test'
import vitest from '@konceptware/eslint-config/vitest'
import playwright from '@konceptware/eslint-config/playwright'
import react from '@konceptware/eslint-config/react'
export default defineConfig([
{ ignores: ['dist/**', 'coverage/**'] },
...base,
...react,
{ files: ['src/**/*.test.ts'], extends: [test, vitest] },
{ files: ['e2e/**/*.ts'], extends: [test, playwright] },
])The presets define rules, not your layout. /test and the runner presets set no file
globs: scope each one to your test files with extends, or it applies to every file.
Nothing is ignored for you either; add your build output, or your .gitignore with
includeIgnoreFile from @eslint/compat. The only globs the presets carry are about
syntax: type-aware rules stay off JS files, .cjs parses as CommonJS, and the JSX rules
cover .tsx/.jsx. Under extends those intersect with your files.
Type-aware rules need strict: true (at least strictNullChecks) in your tsconfig, and
every linted TS file covered by a tsconfig.json.
Order matters: base first, the scoped /test and runner presets after it so they
override it.
import-x/no-extraneous-dependencies bans importing devDependencies, including
import type; /test lifts the ban wherever you scope it. Scripts and tool configs run
only in development too, so lift it for them yourself:
{
files: ['scripts/**', '*.config.ts'],
rules: {
'import-x/no-extraneous-dependencies': [
'error',
{ devDependencies: true, optionalDependencies: false, includeTypes: true },
],
},
}No barrels: re-export members by name (export { parse, format } from './dates'), never
export * from, and use import * as only for external packages (import * as z from 'zod'),
not the repo's own modules (relative, @/, ~/ or # paths).
no-restricted-syntax is a list, and setting it replaces ours rather than adding to it.
Spread ours in to keep the for…in, enum, interface, export * and local import * as
bans:
import base, { restrictedSyntax } from '@konceptware/eslint-config'
export default [
...base,
{ rules: { 'no-restricted-syntax': ['error', ...restrictedSyntax, 'WithStatement'] } },
]Vitest
Import the test API explicitly:
import { describe, expect, it, vi } from 'vitest'. The preset does not assumeglobals: true.An assertion helper counts as an assertion when its name starts with
expect(expectValidOrder(order)).A bare
toHaveBeenCalled()is reported bykonceptware-vitest/prefer-called-with, which isvitest/prefer-called-withwithout its autofix: the upstream fix rewrites it to a zero-argumenttoHaveBeenCalledWith()and turns passing tests red.vitest/prefer-to-beis replaced bykonceptware-vitest/prefer-to-be, the same check minus one fix: upstream rewrites.not.toBe(undefined)totoBeUndefined(), inverting the assertion. That case is reported and left for you to fix.vitest/require-hookreports calls at module ordescribelevel. A helper that only declaresdescribe/itblocks is allowed by name:{ files: ['src/**/*.test.ts'], rules: { 'vitest/require-hook': ['error', { allowedFunctionCalls: ['describeCases'] }] }, }
Jest
- Import the test API explicitly:
import { describe, expect, it, jest } from '@jest/globals', and add the package itself (pnpm add -D @jest/globals); the preset declares no Jest globals. - An assertion helper counts as an assertion when its name starts with
expect, and a test must end with an assertion (jest/prefer-ending-with-an-expect): cleanup goes inafterEach. - Type mock factories in TS:
jest.mock<typeof import('./db')>('./db', () => …). - Use
toBe(true)/toBe(false);toBeTruthy()/toBeFalsy()are banned throughjest/no-restricted-matchers. - The preset sets
settings.jest.version: 30sojest/no-deprecated-functionsdoesn't have to findjestfrom the plugin's directory. Override it with your ownsettings.jest.
Playwright
- Locate elements the way a user does:
getByRole,getByLabel,getByTestId.playwright/no-raw-locatorsreportspage.locator('.btn'); a selector held in a named constant (page.locator(CANVAS)) is the deliberate escape. - A timeout that differs from
playwright.configis a named constant (const UPLOAD_MS = 60_000), andtoPass()always gets one. test.skip,test.fixmeandtest.sloware allowed only with a condition and a reason:test.skip(isMobile, 'no hover on touch screens').- Four rules are re-registered as
konceptware-playwright/*without their autofix, because the upstream fix rewrites assertions or code incorrectly:prefer-web-first-assertions: turnstoBeFalsy()ongetAttributeinto a positivetoHaveAttribute.prefer-to-have-count: rewritesconst c = await l.count()toconst c = l.no-useless-await: strips theawaitfrom page-object methods named like locator ones.no-unsafe-references: writes duplicate or shiftedpage.evaluatearguments.
React
On React 19 and later, pass
refas a prop;forwardRefis reported (@eslint-react/no-forward-ref). The rule reads the installed React version and stays silent below 19, whereforwardRefis still required.Hook rules (
rules-of-hooks,exhaustive-deps, …) also run on.tsfiles, where custom hooks live. Where@eslint-reactandreact-hooksimplement the same rule, the@eslint-reactone is used and the other is off.An effect that should run after every render says so:
useEffect(fn, undefined).Type renderable values as
ReactNode;ReactElementis banned through@typescript-eslint/no-restricted-types. Setting that rule replaces the list, so spread ours in:{ types: { ...restrictedReactTypes, Foo: 'use Bar' } }, withrestrictedReactTypesimported from@konceptware/eslint-config/react.Wrap a declared component, not an inline one:
export const Row = memo(RowView).memo(() => …)has no display name, andmemo(function Row() {…})breaksprefer-arrow-callback.With React Compiler, set
settings['react-x'].compilationModeto'infer'or'all';@eslint-react/no-unstable-context-valuethen stops asking for auseMemothe compiler adds itself.Map your wrapper components to the HTML they render, so the accessibility rules check them too:
{ settings: { 'jsx-a11y-x': { components: { Link: 'a', Image: 'img', TextInput: 'input' } } } }jsx-a11y-x/prefer-tag-over-rolejudges therolealone, so a few correct patterns take a disable comment with their reason:<canvas role="img" aria-label="…">for a chart, a hand-built<ul role="listbox">, and a focusable scroll region. So doesautoFocuson the first field of a dialog (jsx-a11y-x/no-autofocus).
Developing
| Command | Does |
| ----------------- | ----------------------------------------------------------------------------- |
| pnpm test | Preset wiring tests and a coverage test that fails on any rule left undecided |
| pnpm docs:rules | Regenerates docs/rules/*.md from the resolved configs |
| pnpm inspect | Opens @eslint/config-inspector on this repo's own config |
| pnpm changeset | Records a release note; required for every rule change |
Rule decisions live in src/rules/*.ts, one module per rule source. Comments use the names in docs/terminology.md. When a source is
fully reviewed, add it to REVIEWED_SOURCES in scripts/rule-sources.ts; from then on
a rule the plugin adds without a decision fails the coverage test.
