ADR-0027: Linting and formatting via Biome

  • Status: Accepted
  • Date: 2026-05-28
  • Deciders: @karasu
  • Supersedes:
  • Superseded by:

Context

The repo has ~400 source files, 157 test files, and ~60,700 lines of TypeScript across 19 workspace packages. There is no linter or formatter configured (STATUS.md infrastructure table: "none configured"). This was an intentional early-stage gap — the codebase was growing fast and the priority was getting features working, not policing style.

That gap is now causing real problems:

  1. Inconsistent style across packages. Different packages use different quote styles, import ordering, trailing comma habits, and indentation depth in long type annotations. Some files use semicolons, some don't. There's no shared baseline.
  2. No automated catch for common mistakes. Unused variables, unreachable code, == instead of ===, missing return types on exported functions — all things a linter catches before review.
  3. Review friction. PRs mix style changes with semantic changes. Reviewers can't tell what matters.

The tool needs to be fast (60K+ lines, 19 packages, ARM64 targets), Bun-compatible, and require minimal config. A single tool for both lint and format is strongly preferred over separate linter + formatter.

Decision

kaged uses Biome for linting and formatting. A single biome.json at the repo root configures both. bun run lint checks; bun run format auto-fixes. CI will enforce both when configured.

Specifics

  1. Biome is installed as a workspace-root devDependency. It is the only linting/formatting tool in the repo. No ESLint, no Prettier, no Stylelint.

  2. biome.json at the repo root holds all configuration. It targets packages/, plugins/, and tests/. It excludes node_modules/, dist/, .home/, .config/, reference/, and generated files (routeTree.gen.ts).

  3. Formatter rules follow existing dominant patterns in the codebase:

    • Indent style: tabs (Biome default, matches most existing files)
    • Quote style: double quotes (matches tsconfig.base.json and most source)
    • Semicolons: always (matches strict TS patterns)
    • Trailing commas: all (matches most existing code)
    • Line width: 100 (practical for the dense table-style code common in this repo)
  4. Lint rules start from Biome's recommended set. Rules that produce excessive noise on existing code are downgraded to warnings or disabled in the initial config, with a documented rationale. The goal is a clean biome check exit from day one, with rules tightened incrementally.

  5. Scripts added to root package.json:

    • lint: biome check . (check lint + format without modifying files)
    • format: biome check --write . (auto-fix lint + format)
  6. No per-package config overrides. One config for the whole repo. If a package needs a different rule, the override goes in the root biome.json under an overrides entry scoped to that directory. Per-package .biome.json files are forbidden to prevent config sprawl.

  7. Generated files are excluded. routeTree.gen.ts and similar auto-generated files are not formatted or linted.

  8. ARM64 compatibility. Biome is a single Rust binary with native ARM64 Linux builds. No native addon compilation, no node-gyp, no platform-specific postinstall scripts.

Consequences

What this commits us to

  • All new code must pass biome check before commit. bun run lint is the canonical check command.
  • Formatting disagreements are settled by bun run format, not by humans in PR review.
  • When Biome's defaults conflict with existing code patterns, the config wins and the code is reformatted in bulk.
  • Adding new lint rules is a conscious config change. No surprise new rules from @biomejs/biome minor version bumps — Biome's recommended set is stable, and new rules default to off.

What this forecloses

  • ESLint plugin ecosystem. Biome has no plugin system. Rules that only exist as ESLint plugins (e.g., React-specific hooks rules, import/order enforcement) are not available. Biome's built-in rule set covers the vast majority of what we need; anything missing is addressed by TypeScript's own strict mode (which we already enforce per ADR-0004 and tsconfig.base.json).
  • Prettier-specific formatting options. Biome's formatter is Prettier-inspired but not Prettier-compatible. Any Prettier-specific option we wanted in the future would require switching formatters entirely, which is unlikely.

What becomes easier

  • Consistent style across all 19 packages with zero per-package config.
  • bun run format as a single command to fix style issues across the entire repo.
  • CI can enforce lint + format in a single fast pass (Biome checks ~60K lines in under 1 second).
  • Code review focuses on semantics, not style.

What becomes harder

  • If Biome's rule set proves insufficient for a specific domain (e.g., React accessibility), there is no plugin escape hatch. The options are: contribute the rule upstream, write a custom Biome plugin (not yet stable), or accept the gap.
  • Bulk reformatting of existing code will produce large diffs. This should be done in a single dedicated commit to avoid polluting git blame.

Alternatives considered

Alternative A — ESLint + Prettier

What it was: the traditional JS linting + formatting stack. ESLint for lint rules, Prettier for formatting, eslint-config-prettier to disable conflicting ESLint formatting rules.

Why it was tempting: maximum rule coverage via ESLint's plugin ecosystem. Industry standard. Every developer knows it.

Why we didn't pick it: two tools means two configs, two sets of ignored files, two CI steps, and eslint-config-prettier to bridge them. ESLint's Node.js runtime is slow on 60K+ lines (5-15 seconds vs Biome's sub-second). ESLint's flat config migration is still producing churn. The plugin ecosystem is a feature we don't need — TypeScript strict mode already catches the type-level errors, and Biome's recommended set covers the style/suspicious-code gap.

Alternative B — oxlint

What it was: a Rust-based ESLint-compatible linter from the Biome/Ruff ecosystem. Very fast, growing rule coverage.

Why it was tempting: same speed as Biome, with access to ESLint rule names/compatibility. Separate formatter (Prettier or dprint) could be paired.

Why we didn't pick it: still early. Rule coverage is incomplete (many rules return "not yet implemented"). No built-in formatter — requires pairing with a separate tool. Biome gives us lint + format in one binary with mature coverage today.

Alternative C — dprint

What it was: a Rust-based, plugin-driven formatter. Linting not included.

Why it was tempting: very fast, very configurable, good plugin ecosystem for specific languages.

Why we didn't pick it: formatter only. Would need to pair with a separate linter (ESLint, oxlint, or Biome's lint-only mode). If we're going to use Biome for linting anyway, using Biome for formatting too avoids the two-tool problem.

References