Skip to content

Standards and Ratchets

Daniel Hokanson edited this page Aug 30, 2026 · 1 revision

This repo has a house style, and most of it is machine-checked. That was not always true: an audit found that every convention the build enforced sat at zero violations while every convention that lived only in prose had eroded — dozens of hardcoded colours, unjustified !importants, raw form controls in feature templates. The response was to turn the prose rules into scripts with a per-file ratchet rather than a flag day. This page is how that machinery works, what it will refuse, and how to satisfy it without weakening it.

The gate

.github/workflows/ci.yml runs on every pull request into main (and into the per-effort integration branches, which is deliberate — those PRs previously had no required checks and auto-merged past the gates). It runs five steps, in this order:

npm run lint
npm run lint:i18n
npm run lint:standards
npm run build
npm run test -- --watch=false

The order is cheapest-first, and each step catches something no earlier step can see. Run all five locally before you push; the first three take seconds.

npm run lint and the warning ceiling

The script is not bare ng lint. It is ng lint --max-warnings=<n>, with the number pinned in package.json.

  • Errors always fail. Every @angular-eslint/template accessibility rule is set to error in eslint.config.jsalt-text, label-has-associated-control, valid-aria, role-has-required-aria, click-events-have-key-events, interactive-supports-focus, table-scope, elements-content, no-positive-tabindex. WCAG conformance is not advisory here.
  • Warnings are capped, not banned. Two TypeScript rules are deliberately warn rather than error@typescript-eslint/no-unused-vars (with ^_ ignore patterns) and @typescript-eslint/no-explicit-any — because the existing debt is real and a hard failure would have meant a flag day.
  • The ceiling only moves down. Adding a warning fails the build. Removing warnings also changes the count, so when you clean some up, lower the number in package.json in the same commit. Never raise it.

npm run lint:a11y runs the template rules over src/**/*.html on their own. It is a faster feedback loop while working on a template; it is a subset of npm run lint, not an extra gate.

lint:standards: two classes of rule

scripts/lint-standards.mjs implements the conventions ESLint cannot express. It reads src/app directly and reports per file.

Hard rules must be zero everywhere. Today that is one rule: console.log in a non-spec .ts file. A genuine exception goes in scripts/.lint-standards-allow as <rule> <path> — <why>. That file is a claim that the rule is wrong for that specific file, not a permission slip, and a stale entry fails: allowlist a file, clean it up later, and the script tells you to remove the entry.

Ratchet rules are tracked per file in scripts/standards-baseline.json, as a count per path:

Rule What it catches
ngmodel-in-features FormsModule imported in a feature .ts, or any ngModel binding in a feature template — reactive forms only
hex-colours-in-scss a hex literal in any .scss under src/app (comments stripped first) — use the design tokens in src/styles/
unjustified-important !important with no comment on the same line or the line above
inline-templates template: in a component — templateUrl only
raw-form-controls-in-features <input>/<select>/<textarea> in a feature template, excluding genuinely raw types (file, checkbox, radio, hidden, range, color, submit, button) and anything already Material-wrapped
table-missing-a11y a feature <table> with neither a <caption> nor an aria-label/aria-labelledby
raw-table-in-features a raw <table> in a feature at all — entity lists should be <app-data-table>

The contract for a ratchet rule is: a file not in the baseline must be clean, and a baselined file may never get worse. New code follows the rule with no exceptions; old code is capped where it stands. table-missing-a11y has already drained to an empty baseline, which means it now behaves exactly like a hard rule — the next unlabelled table anywhere in features/ fails.

The four ways it fails

Message Means
NEW VIOLATION A file that was clean (or new) now violates the rule. Fix the code.
DEBT GREW A baselined file exceeded its recorded count. Fix the code.
RATCHET DOWN A baselined file improved. This is a failure on purpose — regenerate the baseline.
STALE ENTRY A baselined file is now clean or gone. Same fix.

RATCHET DOWN surprises people: you deleted a hardcoded colour and the build went red. That is the design. The baseline is the record of remaining debt, and a record that silently lags behind reality is worthless — the next person's NEW VIOLATION check would be measured against a stale, too-generous number.

Regenerating the baseline

FORGE_STANDARDS_UPDATE_BASELINE=1 npm run lint:standards

That rewrites scripts/standards-baseline.json from what is actually on disk and prints a per-rule summary. Commit the rewritten file in the same commit as the code change that caused it. Two things to know:

  • Update mode skips every check. With the variable set, the script records and exits without comparing, so it always "passes". Running it to make a red build go away is the one real abuse of this tool — it will happily record a worse number as the new baseline. The protection is the diff: a baseline entry going up in a PR is visible and should be challenged.
  • Never hand-edit a number upward. The file only tightens.

The habit that actually drains the register: when you touch a baselined file for any other reason, fix its violations while you are there. That is how the debt goes away without a dedicated cleanup effort, and it has already paid for itself — promoting the ngModel rule exposed a dead (ngModelChange) on a reactive control whose filter had silently stopped re-querying the server.

Why the production build is its own gate

npm run build is in the CI list as a check, not just as packaging, because it is the only step that runs three things:

  • AOT template type-checking under strictTemplates. ESLint's template parser is syntactic — it does not know your component's types. The unit-test runner compiles only the components a spec actually instantiates. The build is the only pass that compiles every template in the application, so a binding that names a property that no longer exists, or passes a string to a number input, surfaces here and nowhere earlier.
  • Bundle budgets. The production, demo and mobile configurations all set an initial-bundle warning and a hard error ceiling, plus a per-component-style ceiling. An accidental eager import of something that should have been lazy fails the build rather than quietly doubling first paint.
  • Service-worker manifest generation, which only happens on the production configuration.

The development configuration disables optimization and carries no budgets, so npm start being happy proves none of the above.

Where the rules are written down, and where those documents lie

CLAUDE.md at the repo root is the long-form source for the conventions; the README summarises them; CONTRIBUTING.md is a short setup guide. Where they disagree with the code, the code wins, and there are current disagreements worth knowing:

  • CONTRIBUTING.md lists four pre-PR commands — lint, build, test, e2e — and omits lint:i18n and lint:standards entirely, while marking e2e as not gated. CI runs the five above. Use the CI list.
  • Both README.md and CLAUDE.md name an Angular and Angular Material major version, and a TypeScript version, one release behind package.json. Read the dependency manifest, not the prose.
  • CLAUDE.md's "Testing Conventions" section describes Cypress custom commands (cy.login(role), cy.createJob(), cy.seedData()) that do not all exist — only a login command is registered, and it takes an email, not a role — and frames E2E as a Cypress story when Playwright is what CI runs. See Testing.

Adding a new enforced rule is cheap and is the right response to noticing the same review comment twice: write the detector in scripts/lint-standards.mjs (it returns a Map<relPath, count>), add it to HARD if it can be zero today or to RATCHET if it cannot, then run the update command once to seed its baseline. Project-wide contribution process is on the hub at Contributing.