Skip to content

build: migrate ESLint to flat config and upgrade to ESLint 10 - #305

Open
mfal wants to merge 2 commits into
masterfrom
claude/focused-shannon-b8306e
Open

mfal wants to merge 2 commits into
masterfrom
claude/focused-shannon-b8306e

Conversation

@mfal

@mfal mfal commented Sep 4, 2026

Copy link
Copy Markdown
Member

Why

Bumping eslint to v10 failed outright because ESLint 9 removed .eslintrc
support:

ESLint couldn't find an eslint.config.* file.

So the config format had to move first.

What changed

before after
shared config config/.eslintrc.yml config/eslint.config.base.js
per package 4 × .eslintrc.yml extending it 4 × one-line eslint.config.js re-exporting it
ignores 5 × .eslintignore (dist/) ignores: ["**/dist/"] in the shared config
eslint:recommended eslintrc string @eslint/js
plugin:@typescript-eslint/recommended eslintrc string typescript-eslint
plugin:prettier/recommended + prettier eslintrc strings eslint-plugin-prettier/recommended

Dependencies:

eslint                 ^8.57.1 -> ^10.9.1
eslint-config-prettier ^9.1.2  -> ^10.1.8
eslint-plugin-json     ^3.1.0  -> ^5.0.0
eslint-plugin-prettier ^5.5.4  -> ^5.5.6
@typescript-eslint/{eslint-plugin,parser} ^7.18.0 -> replaced by typescript-eslint ^8.68.0
+ @eslint/js ^10.0.1
+ globals ^17.11.0   (flat config has no `env:`)

All picked as the newest release older than 7 days, per npmMinimalAgeGate.
typescript-eslint had to come along: v7 peers on eslint ^8.56.0 only.
@typescript-eslint/eslint-plugin and @typescript-eslint/parser are dropped as
direct dependencies because typescript-eslint provides both — this makes #302
redundant.

eslint-plugin-json is bumped as part of the group, but note it is not
referenced by any config, before or after.

Rule-set evidence

yarn lint being green is not evidence that no rule was lost, so every rule was
diffed explicitly. eslint --print-config was captured for one file per package
under both setups and compared after normalising severities.

The legacy side was reproduced in an isolated install (eslint 8.57.1 +
@typescript-eslint 7.18.0 + the original config/.eslintrc.yml); it matches the
real pre-migration output rule-for-rule (0 differences), so it is also
trustworthy for the .js probe that the real tree could no longer produce after
the upgrade.

All four packages resolve to a byte-identical config, as they did before.

.ts files — 6 rules lost, 13 gained

lost why it is safe
@typescript-eslint/ban-types split in typescript-eslint v8; all three successors are enabled below
@typescript-eslint/no-loss-of-precision deprecated in v8 in favour of the base rule, which is enabled below
@typescript-eslint/no-var-requires superseded by @typescript-eslint/no-require-imports, enabled below
no-class-assign added to the typescript-eslint compat off-list in v8: it is ts(2629), a compile error. Still on for .js files.
no-with same — ts(1101) / ts(2410). Still on for .js files.
no-inner-declarations dropped from eslint:recommended in ESLint 9 (block-scoped function declarations are valid since ES2015). Genuine loss, upstream decision.

Gained: @typescript-eslint/no-empty-object-type, no-unsafe-function-type,
no-wrapper-object-types (the ban-types split), no-require-imports,
no-unused-expressions, prefer-namespace-keyword, plus the ESLint 9/10
recommended additions no-constant-binary-expression, no-empty-static-block,
no-loss-of-precision, no-unassigned-vars, no-unused-private-class-members,
no-useless-assignment, preserve-caught-error.

.js files — 5 lost, 14 gained

Same first three plus no-inner-declarations, plus no-new-symbol (deprecated
in ESLint 9, replaced by no-new-native-nonconstructor, which is gained).
no-class-assign and no-with stay on here, as they should.

The first attempt at this config did lose 17 rules on .js files:
tseslint.config() overwrites an extended config's own files, so putting the
extension glob on the object that extends the shared configs spilled the
TypeScript-only compatibility layer (no-undef off, no-const-assign off,
prefer-const on, …) onto plain JavaScript. The config is structured to avoid
this, and there is a comment saying so.

Severity changes

None. Zero rules changed severity in either direction.

Option changes

20 rules print different options, but all except two are ESLint 10 materialising
meta.defaultOptions (a print-config change introduced in 9.15) at values equal
to the ESLint 8 defaults — verified against the ESLint 8 rule sources. The two
real ones are upstream ESLint 9/10 decisions:

  • no-constant-condition: default checkLoops true → "allExceptWhileTrue", so while (true) is now allowed (loosening).
  • no-shadow-restricted-names: new reportGlobalThis, defaulting to true, so shadowing globalThis is now reported (tightening).

Not visible in the printed options but real: typescript-eslint v8 follows
ESLint 9 in defaulting no-unused-vars' caughtErrors to "all" instead of
"none". This surfaced two genuine unused catch bindings — see below.

Repo-specific rules

linebreak-style, quotes, semi, @typescript-eslint/no-unused-vars and
@typescript-eslint/no-explicit-any resolve identically, including options, and
still win over eslint-config-prettier as they did under eslintrc.

The test-types override

@typescript-eslint/no-unused-expressions is off for *.test-types.ts{,x}.
It resolves to off for those files and forcing it on produces 6 errors in the
tsd-style type tests, so the override is both effective and load-bearing.

Globals

env: {browser, es2021, node} becomes globals.browser + globals.node;
the ES globals come from languageOptions.ecmaVersion: "latest" automatically.
Verified empirically — Array, Promise, Intl, Error, globalThis,
window and Buffer all resolve, and no-undef still catches genuinely
undefined names.

22 names present under eslint 8 no longer resolve. All are obsolete or
worklet-scope-only web APIs removed from the globals package between v13 and
v17 (ApplicationCache, openDatabase, HTMLShadowElement, defaultStatus,
AudioWorkletGlobalScope, registerProcessor, …); 456 modern ones were added.
This only affects no-undef, which is off for TypeScript anyway.

Which files get linted

Under eslintrc the extension set came from the default (.js) plus the
overrides[].files patterns contributed by
plugin:@typescript-eslint/recommended. Flat config collects nothing
implicitly, so the glob is spelled out — without it eslint . would lint
JavaScript only and still report success.

Per-package file lists are identical before and after, except for two additions
each: the new eslint.config.js, and .prettierrc.js, which ESLint 8 skipped as
a dotfile and flat config no longer does. Both lint clean.

reportUnusedDisableDirectives is pinned to "off". eslintrc never reported
these; flat config defaults to "warn", which warns on 6 generated client files
because those carry a blanket /* eslint-disable */. Turning it on is a
reasonable follow-up, but it needs a generator change and would be a separate
decision.

Source change

caughtErrors: "all" and the new preserve-caught-error rule flagged
UniversalContentLoader.tryParseUnknown, which swallowed both parse errors and
threw a bare "Content is not of supported format JSON/YAML." with no indication
of what went wrong. Committed separately as fix(generator): — the YAML error is
now attached as cause.

Verification

  • yarn lint — 4 projects, clean (also with --skip-nx-cache)
  • yarn nx run-many -t build --skip-nx-cache — 4 projects
  • yarn nx run-many -t test --skip-nx-cache — 4 projects + 9 dependent tasks, including test:client-generation-clean on a committed tree
  • yarn test:licenses — exit 0

Note for the reviewer

The task description said the no-unused-expressions override for
*.test-types.ts already existed. It did not — nothing in the repo referenced
that rule. Under typescript-eslint v7 the rule was not part of recommended, so
nothing suppressed anything; v8 added it, which is exactly when the type tests
would have started failing. The override is added here.

🤖 Generated with Claude Code

mfal and others added 2 commits September 4, 2026 16:00
`tryParseUnknown` swallowed both the JSON and the YAML parse error, so a
malformed spec produced a bare "Content is not of supported format
JSON/YAML." with no indication of what actually went wrong. Attach the
YAML error as `cause` and drop the unused outer binding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ESLint 9 dropped `.eslintrc` support, so the format had to move before
the major could be adopted. The five `.eslintrc.yml` files become a
shared `config/eslint.config.base.js` re-exported by a one-line
`eslint.config.js` per package; the five `.eslintignore` files become the
`ignores` entry in the shared config.

The extends chain is re-expressed as `@eslint/js`, the `typescript-eslint`
package and `eslint-plugin-prettier/recommended`. `@typescript-eslint/
eslint-plugin` and `@typescript-eslint/parser` are no longer direct
dependencies -- `typescript-eslint` provides both.

Dependency group:

  eslint                 ^8.57.1 -> ^10.9.1
  eslint-config-prettier ^9.1.2  -> ^10.1.8
  eslint-plugin-json     ^3.1.0  -> ^5.0.0
  eslint-plugin-prettier ^5.5.4  -> ^5.5.6
  @typescript-eslint/*   ^7.18.0 -> typescript-eslint ^8.68.0
  + @eslint/js ^10.0.1, globals ^17.11.0

Two flat-config specifics worth calling out:

- The linted extension set is spelled out explicitly. eslintrc derived it
  from the default (`.js`) plus the `overrides[].files` patterns that
  `plugin:@typescript-eslint/recommended` contributed; flat config
  collects nothing implicitly, so without it `eslint .` would silently
  lint JavaScript only and still pass.
- That glob is deliberately not the `files` of a config that `extends`
  the shared configs. `tseslint.config()` overwrites an extended
  config's own `files`, which would spill the TypeScript-only
  compatibility layer (`no-undef` off, `prefer-const` on, ...) onto
  plain JavaScript.

`reportUnusedDisableDirectives` is pinned to "off" to match eslintrc;
flat config defaults it to "warn", which would warn on every generated
client file because those carry a blanket `/* eslint-disable */`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mfal
mfal marked this pull request as ready for review September 7, 2026 08:11
@mfal
mfal requested a review from maaaathis September 22, 2026 09:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant