eslint-config-shalev
eslint-config-shalev
Shalev Shushy's shared ESLint flat config (ESLint 9). It encodes the mechanical half of the global
coding standard — null discipline, immutability, guard clauses, the no-restricted-syntax selector set,
prettier formatting, and import sorting — so every project extends one authoritative rule set instead of
re-embedding ~350 lines of config.
Rationale for every rule lives in ~/.claude/docs/lint-index.md (rule id → principle → why → what to do instead). The message on each selector is the derived terse view.
Install
pnpm add -D eslint-config-shalev 'eslint@^9' 'typescript@~6.0.0'
In a pnpm workspace root, add -w (pnpm add -D -w …) — without it pnpm refuses with
ERR_PNPM_ADDING_TO_ROOT.
The config bundles its own plugins (typescript-eslint, import, perfectionist, react, prettier) — you only
provide eslint (^9) and typescript. The version pins are load-bearing under pnpm (see below).
Node ≥20.11 is required for the documented import.meta.dirname usage.
Requirements & install traps
Peer deps: eslint ^9.0.0, typescript >=4.8.4 <6.1.0.
- TypeScript must stay
<6.1.0. The npmlatesttypescript is7.x(the native-port preview), which crashes typescript-eslint v8.npm install typescript(unpinned) resolves to the highest in-range version because npm's resolver honors the peer constraint — but pnpm does not (verified 2026-07-18, pnpm 11: a barepnpm add eslint typescriptresolved eslint 10 + typescript 7, both out of range, with only a peer warning; the crash comes at lint time). Always pin in-range under pnpm:'eslint@^9'plus any in-range typescript (~6.0.0is the current default; an existing in-range 5.x is fine). - pnpm build-script approval (
ERR_PNPM_IGNORED_BUILDS).eslint-import-resolver-typescriptdepends onunrs-resolver, whose postinstall build pnpm blocks by default — until approved,pnpm lint's pre-run dependency check fails. Approve it inpnpm-workspace.yaml(pnpm ≥11 syntax; pnpm ≤10 usesonlyBuiltDependencies:with a plain list):allowBuilds: unrs-resolver: true - pnpm
minimumReleaseAgequarantine. If your workspace enables pnpm's new-package quarantine, a fresh install within the release-age window (default ~7 days after a version is published) is refused until the version ages out. In pnpm's loose mode (default) the install proceeds and pnpm auto-adds the exact version tominimumReleaseAgeExcludeinpnpm-workspace.yaml(verified live 2026-07-18 — expected, not an error); in strict mode the install is refused and you add the entry manually:minimumReleaseAgeExclude: - eslint-config-shalev@1.0.0
Usage
Create eslint.config.mjs at the repo root:
import shalev, { typeAwareRules } from 'eslint-config-shalev';
export default [
...shalev,
// Import resolver — required for the import-graph rules (import/no-cycle, path aliases) to
// resolve TS paths; tsconfig globs are project-relative, so it lives in the consumer.
{
settings: {
'import/resolver': {
typescript: { alwaysTryTypes: true, project: ['packages/*/tsconfig.json', 'tsconfig.base.json'] },
node: true,
},
},
},
// Type-aware rules — scoped to your source glob; wire the parser project here (project-relative).
{
files: ['packages/*/src/**/*.ts', 'packages/*/src/**/*.tsx'],
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
rules: typeAwareRules,
},
// Project-specific overrides (add only what applies — never pre-populate):
// • Boundary files that must speak null — turn off no-restricted-syntax. Composition roots
// and tool configs are boundaries too:
// { files: ['**/http-client.ts', '**/row-mappers.utils.ts', '**/main.tsx', '**/cypress.config.ts'], rules: { 'no-restricted-syntax': 'off' } }
// • Wrapped third-party imports — ban the raw import, exempt the wrapper. Add one entry per
// wrapped library WHEN its wrapper file is created:
// { rules: { 'no-restricted-imports': ['error', { paths: [{ name: 'axios', message: 'import httpClient from ~/api/http-client' }] }] } }
// { files: ['**/api/http-client.ts'], rules: { 'no-restricted-imports': 'off' } }
// • React Native (Metro) only — inline require('./asset.png') is how static assets bundle,
// and typescript-eslint v8's no-require-imports flags it:
// { rules: { '@typescript-eslint/no-require-imports': 'off' } }
// • Structurally-pure test zones beyond **/*.utils.test.ts (exempt from the expect() ban by
// the shared config since 1.1.0) — compose from the exported list, never hand-copy it, and
// state the structural precondition in a comment (e.g. a zero-RN-dep engine package whose
// purity the module graph enforces). See real-blackjack docs/adr/adr-0004:
// import shalev, { pureFunctionTestSyntaxSelectors, typeAwareRules } from 'eslint-config-shalev';
// { files: ['packages/common/**/*.test.ts'], rules: { 'no-restricted-syntax': ['error', ...pureFunctionTestSyntaxSelectors] } }
];
Non-monorepo repos: drop the resolver project array to ['tsconfig.json'] and scope the
type-aware block to src/**/*.ts, src/**/*.tsx.
What the consumer wires (not baked into the shared config)
- The import resolver (
settings['import/resolver']): tsconfig paths are project-relative; without it the import-graph rules (import/no-cycle, aliased imports) silently under-check. - Type-aware rules (
typeAwareRules):prefer-nullish-coalescing,strict-boolean-expressions,no-unnecessary-condition,consistent-type-exports. They needparserOptions.project/projectService, which is project-relative — apply them in a config object scoped to your source files. - Boundary-file exemptions and wrapped-import bans — the file lists differ per project.
Versioning & the publish flow
This section is the authoritative home for the publish flow. The project-init skill and the
eslint-sync-reminder hook both restate it in terser form; if they ever disagree with this, this wins.
Semver. Breaking rule changes (a new error-level rule, a removed exemption) are major bumps — they turn previously-green consumer code red. Changing a rule set means shipping it, in this order:
- Edit
index.mjs— the shared standard lives here and never in a consumer stub. Only project-specific seams (boundary files, wrapped-import bans, resolver paths) belong in a stub. - Add the
CHANGELOG.mdentry and bump the version inpackage.json, in the same commit as theindex.mjschange. A commit gate enforces this: shipped code (anything infiles[]except markdown) cannot be committed without a version bump, because a consumer resolving a caret range would otherwise silently keep the old package with nothing failing. npm publish— requires a Classic Automation token in~/.npmrc. Granular tokens and browser-session--otpboth hit npm's "bypass 2fa required" 403 on this account; the automation token is the sanctioned non-interactive route (or publish interactively from a real terminal for the browser 2FA handshake).- Sync
~/.claude/docs/lint-index.md— the rule → principle → why → instead index is the rationale home and goes stale silently otherwise. - Bump the dependency in each consumer and re-lint. A fresh version may be held by pnpm's
minimumReleaseAgequarantine — see the install traps above.