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 metricsreports this) - Cons: new rules have a higher upfront cost — must fix all existing violations before merging; some rules (like
@twomore/no-korean-string-literalwith 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.mjshad reached 0 waivers across 0 rules as of commit7a5cf43(2026-05-04); later styling/native-surface rules reintroduced a measured baselineyarn metricsreports 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 metricsis 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
- AGENTS.md — full rule table with enforcement column
- agentic-project-foundation.md — Phase P2/P3 (ESLint plugin + ratchet)
- ADR 0001 — the enforcement substrate context
- rebuild-log.md — "Audit closure: all deferred items landed" (2026-05-04) for the ratchet-to-zero history