ADR-0008: One ESLint flat config for the workspace

Edit this page

Status: Accepted Date: 2026-08-14 Supersedes: ADR-0006

Context

ADR-0006 removed the CI lint step because the workspace had no ESLint configuration at all — next lint opened its interactive setup prompt, which CI has no terminal to answer. That record listed its own reversal criteria: a flat config, the dependencies in the lockfile, and the CI step restored and passing.

The blocker was environmental rather than technical. Adding a dependency means regenerating pnpm-lock.yaml, CI installs with --frozen-lockfile, and the session that found the problem could not run pnpm install — its node_modules had been installed from WSL2 and were unreadable to the Windows Node it had available.

That constraint is gone: the toolchain is now reachable through WSL, so the lockfile can be regenerated and the result verified before it is pushed.

Decision

A single flat config at the repository root, eslint.config.mjs, covering every package. The root lint script is eslint ., and the per-package next lint and eslint src scripts — none of which ever worked — are removed.

eslint-config-next is deliberately not used. pnpm isolates workspace dependencies, so a root-level config cannot resolve next, which that package requires for its parser. Rather than hoist next to the root purely to satisfy a linter, the rules worth having come from eslint-plugin-react-hooks directly, which resolves without help.

The rule set is small and each entry earns its place:

RuleWhy
react-hooks/rules-of-hooks, exhaustive-depsStale-closure bugs are otherwise found in production, and nothing else catches them
@typescript-eslint/no-explicit-any, ban-ts-commentThe codebase has zero of both today; this preserves that rather than describing it
no-consoleStructured logging is a project rule. A console.log in request-serving code writes to stdout with no fields and no redaction
no-unused-varsLeftovers from refactors, with ^_ as the documented opt-out

CLI entry points and build scripts are exempted from no-console — they are meant to talk to a terminal. Test files may use any.

What the first run found

Three errors across the whole repository, which is a good result for code that had never been linted.

The one that mattered: apps/server/server/auth/index.ts printed the magic link to stdout. That is a single-use login credential written to the container log, readable by anyone with log access — a finding the roadmap had already recorded independently, and the linter located it on its first execution.

It now refuses instead. There is no mail transport, so the honest failure is an error the caller can see, not a silent success for a message that was never sent. A development escape hatch, LOOMSCOPE_MAGIC_LINK_ECHO=true, prints it with an explicit warning; without that flag the link is never logged at any level.

The other two were a console.log in a build script and an unused caught error.

Alternatives considered

One config per package. Rejected. The rules that matter here are project-wide, and three configs is three places to look and three chances to drift.

Hoist next to the root to keep eslint-config-next. Rejected. It would put a framework dependency at the root of a workspace that also contains two Go services, for the sake of a parser.

Adopt the full next/core-web-vitals set including jsx-a11y. Deferred, not rejected. Accessibility rules are worth having, but turning them on in the same change would have mixed a lint bootstrap with an accessibility audit. A follow-up can enable them and work through what they find.

Consequences

Accepted costs.

  • Four more development dependencies, and pnpm install is slower.
  • Next-specific rules — image and font optimisation hints — are absent.
  • exhaustive-deps is a warning, not an error, so it can accumulate.

Gains.

  • make lint finally does what it has always claimed.
  • The no-console rule mechanically enforces a rule the project had written down and violated in eight places.
  • CI now checks lint, typecheck, tests, migrations and secrets. Every one of those does what its name says.