How to replace ESLint with @hyperfrontend/linter
You will move a project's linting from ESLint to @hyperfrontend/linter, keep the checks the project relies on, prove that they still run, and remove ESLint, its parser and its plugins. The point is fewer packages in every install: the replacement has no dependencies of its own. How the hyperfrontend repository made the same move, including what it cost, is told in I Replaced ESLint. The First Attempt Worked. I Threw It Away.
Before you begin
This is not a drop-in replacement for every ESLint configuration or plugin. Check that the project fits before you start:
- Every rule it relies on is in the rule catalogue, with the options it uses, or is one you will write. The catalogue lists what is reimplemented from ESLint, typescript-eslint, eslint-plugin-jsdoc, eslint-plugin-react, eslint-plugin-react-hooks and eslint-plugin-package-json, and the options each rule reads; anything else becomes a rule of your own or is dropped on purpose.
- No rule needs the type checker. typescript-eslint's type-aware rules, the ones
parserOptions.projectenables, have no counterpart. - Lint runs from the terminal, a hook or CI. The configuration names rules only: files are read by extension as TypeScript and JavaScript (JSX included), JSON and Markdown, with no plugins, parsers, processors, shareable configs or editor integration.
If the project depends on any of these, decide now whether to accept the gap or keep ESLint.
1. Record what ESLint enforces today
Print the effective configuration for one representative file of each kind, and keep a JSON report of a full run as the baseline:
npx eslint --print-config src/index.ts > eslint-ts.json
npx eslint --print-config src/view.tsx > eslint-tsx.json
npx eslint --format json . > eslint-baseline.json
From the printed configurations, list every rule that is not off, with its severity and options, and note the plugins, parser options, globals, settings and ignore patterns. Search the code for inline directives too: grep -rn "eslint-disable\|eslint-enable\|/\* global" src.
2. Map each rule
Look each rule up in the rule catalogue. Ids carry over with one change of prefix:
| ESLint rule | @hyperfrontend/linter rule |
|---|---|
no-var (core) | no-var |
@typescript-eslint/<name> | typescript/<name> |
jsdoc/<name> | jsdoc/<name> |
react/<name> | react/<name> |
react-hooks/<name> | react-hooks/<name> |
package-json/<name> | package-json/<name> |
Compare the options you use with those the catalogue entry lists. Sort every rule into one of three lists: mapped as it is, needing a custom rule, or deliberately dropped. Do not substitute a different rule because its name is similar; an unmatched rule is a decision to record.
3. Write the configuration
Install the package and create lint.config.ts; the project's package.json must declare "type": "module", so that Node reads the configuration as an ES module. ESLint's flat configuration translates block by block: files, ignores and rules keep their meaning, settings passes through, and languageOptions.globals becomes language.globals. Drop plugins, languageOptions.parser and parserOptions.
npm install --save-dev @hyperfrontend/linter
import { BROWSER_AND_NODE_GLOBALS, CODE, CODE_RULES, JAVASCRIPT, REACT_RULES } from '@hyperfrontend/linter'
export default [
{ ignores: ['dist/', 'coverage/'] },
{ files: CODE, rules: CODE_RULES },
{ files: JAVASCRIPT, language: { globals: BROWSER_AND_NODE_GLOBALS } },
{
files: ['src/**/*.tsx'],
rules: REACT_RULES,
settings: { react: { version: '18.3' } },
},
{ files: ['src/**/*.ts'], rules: { 'typescript/no-explicit-any': 'error' } },
]
CODE_RULES stands in for eslint:recommended with typescript-eslint's recommended; REACT_RULES for the React and hooks recommended sets. Lint a project with @hyperfrontend/linter covers the rest of the configuration.
4. Convert the directives
Rename every inline directive and rule id:
| ESLint | @hyperfrontend/linter |
|---|---|
// eslint-disable-next-line no-var | // lint-disable-next-line no-var |
// eslint-disable-line @typescript-eslint/no-explicit-any | // lint-disable-line typescript/no-explicit-any |
/* eslint-disable react/display-name */ | /* lint-disable react/display-name */ |
/* eslint-enable */ | /* lint-enable */ |
/* global analytics */ | language: { globals: { analytics: 'readonly' } } in a block |
lint-disable and lint-enable must be block comments. A directive naming a rule the linter does not know is an error, and one that suppresses nothing is reported, so the first run lists every directive the conversion missed.
5. Port the custom rules
For each rule on the custom list, write a rule with defineRule and register it under a prefix of your own with families; Write and test a custom lint rule walks through one. The listener model is ESLint's, by node kind over an ESTree-shaped tree; a report takes offsets (start, end) where ESLint takes a node or a location, and a fix is a single { start, end, text } replacement. Port each rule's ESLint test cases into testRule cases before porting its code.
6. Compare the two
Keep ESLint installed until this step passes. Run both on the same files and compare what each reports, by file, rule, line and column:
npx hf-lint --format json . > hf-lint-report.json
import { readFileSync } from 'node:fs'
type Report = { filePath: string; messages: { ruleId: string | null; line: number; column: number }[] }[]
const read = (file: string): Report => JSON.parse(readFileSync(file, 'utf8'))
const keys = (report: Report, rename: (id: string) => string): Set<string> =>
new Set(report.flatMap((r) => r.messages.map((m) => `${r.filePath}:${m.line}:${m.column} ${rename(m.ruleId ?? '')}`)))
const before = keys(read('eslint-baseline.json'), (id) => id.replace('@typescript-eslint/', 'typescript/'))
const after = keys(read('hf-lint-report.json'), (id) => id)
console.log(
'only ESLint:',
[...before].filter((key) => !after.has(key))
)
console.log(
'only hf-lint:',
[...after].filter((key) => !before.has(key))
)
Every line in either list is either a rule you dropped on purpose or a difference to investigate. A clean codebase compares nothing, so also lint files with deliberate violations: for each mapped rule, one snippet it must report. Compare fixes the same way, with --fix-dry-run --format json on both tools and the output of each file.
7. Switch the scripts and CI
Replace eslint in package.json scripts, hooks and CI steps with hf-lint, keeping --max-warnings 0, dropping --cache, and keeping --format only for stylish or json. Remove the ESLint extension's workspace settings; with no inline feedback in the editor, a pre-commit hook is where the team catches problems before CI does.
8. Remove ESLint
npm uninstall eslint @eslint/js typescript-eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsdoc eslint-plugin-package-json globals
Uninstall the packages your project actually lists. Then delete eslint.config.*, .eslintrc* and .eslintignore, and move any ignore pattern still in use into the first block of lint.config.ts.
Check it worked
npx hf-lint exits with 0 on the project and with 1 on each deliberate violation from step 6, npm ls eslint finds nothing, and the comparison lists only the rules you chose to drop.
Reference and background
Next steps
How to lint a project with @hyperfrontend/linter
A project needs its code checked and fixed against agreed rules, without bringing a parser, plugins and their dependency trees into every install.
How to write and test a custom lint rule
A team needs a check the packaged lint rules do not make, with a fix where one is safe, and needs it to keep behaving as intended as the code around it changes.