ADR-006: Linting Strategy (Biome-First, ESLint for Complex Guards)
ADR-006: Linting Strategy (Biome-First, ESLint for Complex Guards)
Status
Accepted
Context
Review feedback across multiple pull requests has shown recurring,
machine-detectable issues (missing advanceTimers with fake timers, hanging
Promises in tests, accessibility interaction patterns) that cost review
round-trips. Epic 227 has repeatedly identified React rule gaps and called for
automation, but we have postponed those rules because we only had Biome. Issue
309 formalized the need to make these patterns fail fast and cheaply:
- In-editor feedback
- Pre-commit enforcement under 2 seconds
- CI as a safety net
- One tool per concern
We evaluated Biome and ESLint for enforcement and benchmarked the repo's linting steps. Biome is already used as the base linter. Prettier is used for formatting, including SQL and markdown. There is no repo-wide ESLint today.
Benchmarks (local, same repo)
- Biome lint: ~0.27s (
pnpm lint) - ESLint full parity (JS/TS/React/a11y): ~4.4s (
pnpm lint:eslint) - ESLint guards (a11y + test rules + custom): ~1.63s
- Biome + ESLint guards: ~1.90s combined
- Biome + GritQL plugins: ~0.50s (fast, but limited in context sensitivity)
Biome is roughly 15x faster than ESLint on the full repo (0.27s vs 4.4s).
We also tested Biome GritQL plugins for custom rules. Simple pattern checks work and are very fast, but file-level context (e.g., "only enforce advanceTimers when fake timers are used") is not reliable in GritQL today.
Decision
- Use Biome as much as possible for baseline linting and simple checks.
- Use ESLint only for complex guard rules that Biome cannot express well (contextual checks, a11y interaction rules, and test-specific guardrails).
- Keep Prettier for formatting because it covers SQL and Markdown, which Biome does not currently format.
- Continuously reassess Biome: when Biome gains coverage for current ESLint guard rules, migrate them to Biome and remove ESLint usage.
Rule Ownership (current target)
Biome (baseline lint, fast)
noExplicitAnynoNonNullAssertion- unused imports/variables
- standard correctness/style checks
ESLint (guard rules only)
- Test guardrails:
userEvent.setuprequiresadvanceTimerswhen fake timers are used- disallow
new Promise(() => {})in tests vitest/valid-expect- testing-library:
no-node-access,no-container(TSX tests only)
- A11y interaction rules for admin UI:
- no click handlers on non-interactive elements without keyboard support
- API safety in admin hooks (
apps/admin/src/**/use*.ts):toast/no-direct-fetch-in-hooks: disallow directfetch()calls; requireapiFetchfromlib/api
- Date handling safety (
apps/admin/src/**/*.{ts,tsx}):toast/no-date-string-split: disallow.split('T')[0],.substring(0, 10), and.slice(0, 10)on date strings; uselib/datehelpers
Alternatives Considered
-
ESLint-only (drop Biome)
- Rejected: significantly slower for repo-wide linting and higher config maintenance. Would duplicate baseline rules already covered by Biome.
-
Biome-only with GritQL
- Partially viable for simple patterns, but not reliable for contextual rules like fake-timer enforcement. Diagnostics are also harder to debug.
-
Biome + ESLint scoped to guards (chosen)
- Meets performance targets while enforcing complex patterns.
Consequences
Positive
- Fast feedback loops with targeted enforcement (<2s for lint + guards)
- Review issues are prevented before review
- Clear ownership per rule, minimal overlap
- Tooling stays lean: Biome for most lint, ESLint only where needed
Negative
- Two linting tools to maintain (Biome + ESLint)
- Custom ESLint rules require maintenance and testing
- GritQL exploration remains limited for contextual checks
Reassessment Plan
- Revisit Biome capabilities at least quarterly or when upgrading Biome.
- If a Biome rule can replace an ESLint guard rule, migrate it and reduce ESLint scope.
References
- Issue 309: Linting & formatting strategy
- PR 278 review feedback (test timers, hanging Promises, a11y interactions)
- Biome GritQL plugin documentation