Skip to content

0006 — ESLint ratchet: waiver discipline for new rules

Status: Accepted Date: 2026-05 Deciders: TwoMore mobile team

Context

The project maintains a custom ESLint plugin (@twomore/eslint-plugin, 52 rules as of 2026-06-27) alongside the standard typescript-eslint recommended preset. As each new rule shipped, existing violations were sometimes grandfathered into an auto-generated eslint.ratchet.config.mjs baseline. The baseline disables each rule only for the specific files that violated it at generation time — new files that violate the rule fail lint immediately.

Current measured baseline (yarn metrics, 2026-06-27): 4 ratchet rules with 87 waivers:

  • @twomore/no-inline-style-with-vars: 29
  • @twomore/no-stylesheet-create: 16
  • @twomore/no-padding-top-in-features: 40
  • @twomore/no-raw-rn-pressable-in-features: 2

The risk: ratchet baselines accumulate. A rule with 40 waivers at launch looks "fully enforced" from the outside but is only enforced for new code. If the waivered files are never touched, the violations live forever. Each new rule added on top of an existing waivered rule compounds the illusion of enforcement.

A specific incident: the @twomore/require-freshness-contract rule (DATA-10) launched with 61 waivers. After three months and deliberate backfill sessions, it reached 0. During that window, every query hook added after launch had a freshness contract; every query hook added before launch did not. The inconsistency made the rule feel optional.

The @twomore/require-mutation-key rule (DATA-2) launched with 8 waivers; manual backfill reduced it to 1 (the create-mutation-hook.ts factory, which has no domain-specific key to declare and is the legitimate permanent exception).

Options considered

Option A: Status quo — ship rule with waivers, clean up incrementally

  • Pros: rules ship faster; waivers document exactly which files violate the rule
  • Cons: "clean up incrementally" empirically means "clean up never unless someone schedules it"; waivered files represent real violations that persist in production code; the ratchet loses credibility as a signal
  • Effort: 0
  • Reversibility: high

Option B: Block new rules at CI until all violations are fixed first

All violations for the new rule must be fixed before the rule's ESLint config entry is merged. The ratchet file must show zero waivers for the new rule.

  • Pros: every rule enforces from day one; no waiver debt accumulates; the ratchet file's size is a direct measure of enforcement health (yarn metrics reports this)
  • Cons: new rules have a higher upfront cost — must fix all existing violations before merging; some rules (like @twomore/no-korean-string-literal with 19 files) require significant backfill; the rule cannot land incrementally
  • Effort: per-rule — ranges from 0 (born-mechanical, no existing violations) to ~1 week (rules that touch deeply embedded patterns)
  • Reversibility: high — we can always revert to Option A for a specific rule if the backfill is unreasonable

Option C: Per-rule waiver lifetime limit (e.g., 90-day TTL on waivers)

Each waiver in eslint.ratchet.config.mjs carries a date comment; a lint-time check fails if any waiver is older than 90 days.

  • Pros: creates time pressure to clean up waivers without blocking the rule launch
  • Cons: 90-day TTL is arbitrary; waivers in untouched legacy files will expire even if the file has no reason to be modified; creates noisy CI failures on stable code; the TTL mechanism requires additional tooling to implement
  • Effort: medium (tooling required)
  • Reversibility: medium

Decision

Option B as the target posture — zero waivers is the default precondition for shipping a new ESLint rule.

Primary reason: a rule that grandfathers violations from day one sends the signal "this is aspirational, not enforced". Agentic contributors optimise for the rules that are enforced today; rules with waiver baselines are treated as advisory.

Dissenting consideration: some patterns are deeply embedded and genuinely require weeks to backfill before the rule can ship. The current compromise is: new rules should be born clean whenever feasible; any ratchet baseline must be explicit, measured by yarn metrics, named in docs, and treated as shrinkable debt or as a documented native escape path. "Born-mechanical" rules (rules that have zero existing violations when first written, because the pattern is new) bypass the backfill problem naturally.

One legitimate permanent exception class: infrastructure files that explicitly implement the pattern the rule guards against (e.g., create-mutation-hook.ts for @twomore/require-mutation-key, mutation-queue.test.ts for @twomore/require-executor-for-queue-key). These receive a single-line eslint-disable with an explanatory comment — not a ratchet waiver.

Consequences

Positive

  • eslint.ratchet.config.mjs had reached 0 waivers across 0 rules as of commit 7a5cf43 (2026-05-04); later styling/native-surface rules reintroduced a measured baseline
  • yarn metrics reports the live waiver count — drift is measurable, not anecdotal
  • The ratchet file documents current waivers separately from new drift; the 2026-06-27 baseline is 4 rules / 87 waivers
  • New contributors see a lint system that means what it says

Negative

  • Rules touching embedded patterns require significant upfront backfill effort before they can merge — the @twomore/no-korean-string-literal (DATA-7) rule required fixing 19 files before landing
  • Rules cannot be shipped incrementally; the choice is "fix everything now" or "don't ship the rule yet"
  • A rule that would be valuable to enforce but whose backfill is genuinely unreasonable stays as a DOC (planned: ESLint …) entry in AGENTS.md indefinitely — DATA-3 (no-empty-catch-in-adapter) is the current example

Neutral

  • The current 4-rule / 87-waiver baseline mixes RN-native escape paths and shrinkable migration debt. yarn metrics is the source of truth for the current count.

Implementation notes

  • Ratchet file: eslint.ratchet.config.mjs (auto-generated; committed)
  • Regeneration command: yarn lint:ratchet
  • Metrics command: yarn metrics (reports live waiver count in JSON output)
  • Gate: yarn lint:strict (max-warnings 0) runs in pre-push and CI — fails on any new waiver or violation

References

Markdown remains the source of truth. Run yarn docs:check before handoff.