Skip to Content

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.

Last updated on