The api linter runs again, and two bugs it was hiding
Shipped 2026-09-22
npm run lint in api/ was npx eslint . with eslint, globals and
@eslint/js all undeclared. npx therefore fetched whatever ESLint was newest —
10.x, which the project’s pinned Node 20.11.1 does not support, and which could
not resolve the config’s own imports either. It failed identically for everyone,
which is how 57 errors accumulated with nobody noticing.
A linter that cannot run is worse than no linter, because the script’s presence reads as a gate that passes. The version it fetched also depended on the day you ran it.
All three packages are now declared, pinned to the ESLint 9 line — 10 needs
Node >= 20.19. www declares only eslint and lets the other two arrive
transitively through ESLint’s own dependency tree; that works today and breaks
the day ESLint drops them, so this one names what it imports.
Three rule options account for 24 of the 57 errors, all following www’s
precedent: args: "none", because Express hands every handler a next it may
not use; caughtErrors: "none", restoring the pre-v9 default, since a catch
that ignores its binding is a deliberate swallow rather than dead code; and
ignoreRestSiblings for the const { a: _, ...rest } omit idiom.
Two were real
features/public/contact/service caught error and logged e. The error
handler threw a ReferenceError out of itself, so a transient reCAPTCHA
verification failure escaped as a 500 instead of the clean return false the
code intended.
features/admin/cms/costs/service called new MemberService(...) without
importing it. setMember had been copied from LogbookService — typo memerId
included — but its import was left behind, so any call threw.
The rest
Two catch (e) { throw e } wrappers, a duplicated insertCategory (both the
static method and its export key, byte-identical copies), and unused imports
across thirteen files.
deleteNotificationForAllLanguages looped every language issuing the same
entry_id DELETE. The table does hold one row per language, but the query has no
lang predicate, so the first pass removed all three and the rest removed
nothing — confirmed against the database. Its sibling
insertNotificationForAllLanguages genuinely needs its loop, which is where the
pattern was copied from.
The two payment-reminder promise executors became plain async functions:
resolve and reject to return and throw, identical under await.
What it found in production
no-unreachable flagged a bare return null opening getRedisValue, which had
been disabling every Redis read across the app since #911 on 2026-08-29 —
over three weeks. Writes still ran, so the cache warm-up logged success and the
Redis health check passed; nothing failed, because a read returning null is
indistinguishable from a cache miss.
That is the whole argument for this ticket. One line of dead code concealed a three-week production regression, and the tool that would have caught it on day one had been silently broken for months.
It was fixed separately in TUN-876
(#942), which landed first — so the FIXME and scoped eslint-disable this PR
originally carried are gone, and the file is untouched here.