Repository navigation
chore(web): add ESLint with react-hooks rules and bulk suppressions - #1155
Conversation
typescript-eslint cannot load TypeScript 7, so the linter has its own install in web/lint with TypeScript 6. Existing violations go into a suppressions file that CI only lets shrink.
|
Review round 1 (Codex
The reviewer also checked:
No quality or over-engineering findings. |
Both are fixed with one new CI step, Check lint suppressions, which runs
Mutation proof, run locally at
The full table and method are in the PR body under "Review round 1 fixes". |
|
Review round 2 (Codex Both round 1 defects are fixed. The reviewer ran
The workflow Accepted boundaries, as documented:
|
Adds ESLint to the SPA so hook misuse and unawaited promises fail CI instead of reaching review.
Closes #1156
What it enforces
react-hooks/rules-of-hooksandreact-hooks/exhaustive-depsaterror.@typescript-eslint/no-floating-promises.@eslint/jsand typescript-eslintrecommended, minusprefer-constandpreserve-caught-error(style) andno-unused-vars(tsc'snoUnusedLocals/noUnusedParametersown it).eslint-disablecomments are errors. Six stalereact-refresh/only-export-componentscomments for a plugin that was never installed are deleted.Why the linter has its own install (
web/lint/)typescript-eslint 8.71 declares
typescript >=4.8.4 <6.1.0, and TypeScript 7.0.2 ships no classic compiler API (onlytscandunstable/sync). npm refuses the peer conflict inweb/, and a nested override does not resolve it. Soweb/lint/package.jsonpinstypescript ~6.0.3for the linter. The app still builds with 7, and the Dockerfile'snpm ciis untouched. ESLint 10 lints only files under its config's directory, soweb/eslint.config.jsis a one-line re-export ofweb/lint/eslint.config.js. Dependabot gets a/web/lintentry that ignores TypeScript majors until typescript-eslint supports 7.Suppressions may only shrink
ESLint 10.12.0 (bulk suppressions need 9.24 or later). Pre-existing violations are in
web/lint/eslint-suppressions.json, counted per file and rule: 21no-floating-promisesand 9exhaustive-deps. None were auto-fixed.--pass-on-unpruned-suppressionsskips that, and nothing passes it.npm run lint -- --prune-suppressionsshrinks the file. Entries for deleted files must be deleted by hand; see Review round 1 fixes.Wiring
npm run lintinweb/package.json. Runnpm ci --prefix lintonce first.Lintstep in thewebjob, after the audits and before the tests..githooks/pre-commitlints only stagedweb/src.ts/.tsxfiles (about 4 s; the full run takes about 18 s type-aware over 293 files).web/AGENTS.md.Checks run locally
npm run lintpasses.npm run typecheckpasses.npm audit --omit=devis clean inweb/(the CI: dependency-vulnerability and SAST gates #146 gate) and inweb/lint/. Every new package is a devDependency..githooks/pre-commit, which ran typecheck and the staged-file lint.SchemaDocsTests*ImagePin_IsOneIdenticalStringAcrossEveryTrackedFile(2 tests) pass.Review round 1 fixes
ESLint alone does not make the suppressions file shrink-only. It accepts any count the same PR writes, and it never checks an entry whose file is gone.
web/lint/check-suppressions.mjscovers both cases and runs in CI as Check lint suppressions, after Lint:64208a50: fails when an entry names a file that does not exist. Runs on every event.bc0f604e: on a pull request, fails when a file/rule entry is new or its count is higher than at the PR base (pull_request.base.sha). Removals and decreases pass. The step fetches the base at depth 1 and fails if the fetch fails. If the base has no suppressions file (this PR), only the path check runs.pushandworkflow_dispatchonly the path check runs. There is no PR base, andmaintakes changes only through PRs, which already ran the growth check.web/AGENTS.mdsays so.--prune-suppressionsdoes not close finding 2 on ESLint 10.12.0. With an entry forsrc/components/Deleted.tsx,npm run lint -- --prune-suppressionsexited 0 and left the file unchanged. So the check reads the file system directly.Mutation proof. Ran locally in
web/atbc0f604e. "Before fix" is the gate as ofa42b294e(npm run lint). "After fix" isnpm run lintfollowed by the CI step's ownrun:block, extracted fromci.ymlwithBASE_SHA=a42b294e…. Locally the step'sgit fetch --depth=1was replaced by a no-op, because the base commit was already present and a depth-1 fetch makes a full clone shallow. Each row was reverted before the next.StockBar+ addedrules-of-hooksentrysrc/components/StockBar.tsx react-hooks/rules-of-hooks: count 0 -> 1BrandSplash+ count raised 1 to 2src/components/BrandSplash.tsx react-hooks/exhaustive-deps: count 1 -> 2src/components/Deleted.tsx react-hooks/exhaustive-deps: file does not existfile does not existlineSalesPageentry removed (check only)Proof
Scratch commits on top of
a42b294e, never pushed. The branch was reset toa42b294eafterwards. Absolute paths in the output are shortened to repo-relative.1. A hook inside an
iffails. Addedif (data.segments.length === 0) { useState(0); }toStockBar, a file with no suppressions:2. Reverted, it passes:
3. A new
exhaustive-depsviolation fails, even in a file that already has a suppression for that rule.BrandSplash.tsxhas one suppressedexhaustive-depsviolation. AddeduseEffect(() => { document.title = farmName; }, []);:Once the count goes over the suppressed number, ESLint reports every violation of that rule in the file, including the one already suppressed. Line 27 is also why nothing was auto-fixed: the
bannerContentHashdependency is deliberate, because it re-reads the farm binding token when the banner changes. Removing it as the rule suggests would change behaviour.