feat: add max-lines guard with self-cleaning exemptions (RD-02)

The dashboard refactor proved a page can be 42 lines. This rule holds
the rest of the app to that budget, before the split work in RD-20
through RD-26 begins.

Add max-lines at 250 (skipBlankLines, skipComments), scoped to
{apps,libs}/**/*.{page,component,section,step}.ts. The glob includes
section and step, the file kinds the dashboard refactor invented, so
new files from this arc do not escape the guard.

Add linterOptions.reportUnusedDisableDirectives: 'error' repo-wide.
ESLint 9 only warns on an unused disable by default, so a stale
exemption would not fail the build. At 'error', every later file
split must delete its own exemption or the build goes red.

Add a dated /* eslint-disable max-lines */ header to each of the
seven files that exceed the budget today, each with a reason and the
ticket that removes it. letter-canvas keeps its disable after RD-26,
because most of its lines are CSS and the rest is one letter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-04 16:18:10 +02:00
co-authored by Claude Sonnet 5
parent 97d9697a84
commit b9d572cfdc
10 changed files with 145 additions and 1 deletions
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */ // 77 lines are CSS, the rest is one letter — RD-26 rewrites this reason, keeps the disable
import { import {
Component, Component,
DestroyRef, DestroyRef,
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */ // sample letter + labels + editor in one file — removed by RD-25
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { DatePipe } from '@angular/common'; import { DatePipe } from '@angular/common';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component';
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */ // single-step wizard shell — removed by RD-20
import { Component, computed, inject, input } from '@angular/core'; import { Component, computed, inject, input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */ // one wizard shell for the intake steps — removed by RD-22
import { Component, computed, effect, inject, input, untracked } from '@angular/core'; import { Component, computed, effect, inject, input, untracked } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */ // one wizard shell for 3 steps + upload — removed by RD-23
import { Component, computed, effect, inject, input, untracked } from '@angular/core'; import { Component, computed, effect, inject, input, untracked } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */ // teaching page covering every concept — removed by RD-24
import { Component, computed, signal } from '@angular/core'; import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import type { Resource } from '@angular/core'; import type { Resource } from '@angular/core';
@@ -0,0 +1,118 @@
# RD-02 — `max-lines` guard with a self-cleaning exemption list
Status: done
Source: PLAN.md section 3a
## Why
The dashboard refactor proved a page can be 42 lines. Nothing stops the next one being 340
again, and nothing stops this arc itself adding a new oversized file. The guard therefore
lands **before** the work it protects, not after.
Seven files exceed the budget today. Each becomes a visible, dated to-do rather than a
silent one.
## Read first
- `eslint.config.mjs` — a flat array of 4 config objects. You add a 5th.
- `docs/project/readable-codebase/README.md` — the session protocol and GREEN.
- CLAUDE.md, "Enforced, not just hoped-for".
## Decisions (pre-made, don't relitigate)
1. **The rule.** `max-lines` at 250, `skipBlankLines: true`, `skipComments: true`, scoped to
`['{apps,libs}/**/*.{page,component,section,step}.ts']`.
2. **The glob must include `section` and `step`.** `*.{page,component}.ts` does **not** match
`*.section.ts` — the file kind the dashboard refactor invented, and the kind RD-22 through
RD-26 create most of. Omitting them lets every new file this arc produces escape the guard.
This is the single most important line of the ticket.
3. **Add `linterOptions: { reportUnusedDisableDirectives: 'error' }`, in this same commit.**
ESLint 9 only _warns_ by default, and `npm run lint` does not fail on warnings. At `error`,
a disable that is no longer needed becomes a lint failure — so **every later split commit
is forced to delete its own exemption or go red.** The exemption list cannot rot into
permanent debt. Verified clean on the current tree, so it lands green.
4. **250 is chosen because the dashboard proves it is reachable**, not from a style guide.
5. **Seven files, not eight or nine.** The original plan counted with `wc -l`; this rule
counts without blank lines or comments. `behandel-scherm.component.ts` (232) and
`stamdata-table-editor.component.ts` (236) were on the old list and **already pass**
leave them alone.
6. **Each disable names its reason and the ticket that removes it**, per CLAUDE.md's standing
rule. Use the exact mapping in Files below. Note `letter-canvas` is the one file whose
disable is **expected to stay** — RD-26 rewrites its reason rather than deleting it,
because 77 of its lines are CSS and the rest is one letter (PLAN.md 3d).
## Files
Add the config block to `eslint.config.mjs`, then a `/* eslint-disable max-lines */` header
with a one-line reason to each of these seven. Verified counts under the rule:
| Rule lines | File | Removed by |
| ---------- | --------------------------------------------------------------------------------------------- | ----------------------- |
| 574 | `apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts` | RD-23 |
| 472 | `apps/ssp/src/app/showcase/concepts.page.ts` | RD-24 |
| 414 | `apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts` | RD-26 (rewrites, keeps) |
| 368 | `apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts` | RD-22 |
| 329 | `apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts` | RD-25 |
| 253 | `libs/shared/src/ui/rich-text-editor/rich-text-editor.component.ts` | RD-21 |
| 252 | `apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts` | RD-20 |
## Steps
1. Add the 5th config object to `eslint.config.mjs` with the rule from decision 1, and a
short comment saying why 250 and that the exemptions are the to-do list.
2. Add `linterOptions: { reportUnusedDisableDirectives: 'error' }` so it applies repo-wide.
3. Add the seven disable headers, each of the form
`/* eslint-disable max-lines */ // <why> — removed by RD-NN`.
4. Update this ticket's `Status:` to `done` and the README's RD-02 row to `done`.
5. Commit all of it together.
## Acceptance criteria
Commands, not judgements:
```bash
npm run lint # exits 0
npm run ci # exits 0
```
Then prove the guard actually bites, and that the glob covers section files:
```bash
# 1. A new oversized section file must FAIL. Expect a max-lines error, then delete the file.
printf '/* x */\nexport class X {\n%s\n}\n' "$(for i in $(seq 260); do echo " p$i = $i;"; done)" \
> apps/ssp/src/app/registratie/ui/dashboard/tmp-probe.section.ts
npx eslint apps/ssp/src/app/registratie/ui/dashboard/tmp-probe.section.ts # must report max-lines
rm apps/ssp/src/app/registratie/ui/dashboard/tmp-probe.section.ts
# 2. Removing any one disable must FAIL (proves all 7 are load-bearing, none is decoration).
```
## Verification
`npm run ci`. This ticket touches no story, no `.mdx` and no `libs/shared/src/ui/**`
_content_, so `--full` is not required — but `rich-text-editor.component.ts` does live under
`libs/shared/src/ui/`, and adding a comment line to it is harmless. Skip `--full`.
## Out of scope
- Splitting any of the seven files. That is RD-20 through RD-26.
- `behandel-scherm.component.ts` and `stamdata-table-editor.component.ts` — already compliant.
- The 7 non-component files over 250 lines (`brief.adapter.ts` 408, `upload.machine.spec.ts`
364, `brief.store.spec.ts` 357, `registratie-wizard.machine.spec.ts` 287,
`registratie-wizard.machine.ts` 285, `upload.machine.ts` 256). The glob deliberately does
not reach specs, adapters or machines — this budget is about components.
## Risks
- **The inline-template processor can make `max-lines` fire twice per file** (once for the
`.ts`, once for the extracted virtual `.html`). Measured: it does **not** with this glob,
because the virtual path ends in `.html` and the glob ends in `.ts`. If a doubled report
appears, the glob is wrong — do not "fix" it by raising the limit.
- **`reportUnusedDisableDirectives` is repo-wide**, so it also polices the generated
`api-client.ts` header. That file is already in `ignores`, so it is out of reach. Confirmed
clean before this change.
+1 -1
View File
@@ -96,7 +96,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
| ID | Ticket | Deps | `--full`? | Status | | ID | Ticket | Deps | `--full`? | Status |
| ----- | ---------------------------------------------------------------------------- | ---------- | --------- | ------ | | ----- | ---------------------------------------------------------------------------- | ---------- | --------- | ------ |
| RD-01 | Scaffold this backlog: README, PLAN, ticket template | — | | done | | RD-01 | Scaffold this backlog: README, PLAN, ticket template | — | | done |
| RD-02 | `max-lines` rule + `reportUnusedDisableDirectives` + 7 disables | 01 | | todo | | RD-02 | `max-lines` rule + `reportUnusedDisableDirectives` + 7 disables | 01 | | done |
| RD-03 | `overzicht` context: page + 2 nav sections, boundary edge, admin-links token | 02 | yes | todo | | RD-03 | `overzicht` context: page + 2 nav sections, boundary edge, admin-links token | 02 | yes | todo |
| RD-04 | Story titles to `Domein/<Context>/<Name>`; add the missing stories | 03 | yes | todo | | RD-04 | Story titles to `Domein/<Context>/<Name>`; add the missing stories | 03 | yes | todo |
| RD-05 | `createStore` gains the effect map + specs | 02 | | todo | | RD-05 | `createStore` gains the effect map + specs | 02 | | todo |
+19
View File
@@ -14,6 +14,13 @@ import angular from 'angular-eslint';
* `herregistratie` uncovered). * `herregistratie` uncovered).
*/ */
export default [ export default [
// A disable directive that no rule needs any more is a lint failure, repo-wide (no
// `files` key, so this applies everywhere — ESLint 9 flat config rule). This forces
// every later file split in the readable-codebase arc (RD-02) to delete its own
// `max-lines` exemption instead of leaving it as permanent debt.
{
linterOptions: { reportUnusedDisableDirectives: 'error' },
},
{ {
ignores: [ ignores: [
'dist/**', 'dist/**',
@@ -48,4 +55,16 @@ export default [
...c, ...c,
files: ['{apps,libs}/**/*.html'], files: ['{apps,libs}/**/*.html'],
})), })),
// The dashboard refactor proved a page can be 42 lines. This rule holds the rest of
// the app to that budget: 250 is reachable, not a style-guide default. The glob must
// include `section` and `step` — the dashboard refactor's own file kinds — or every
// file this arc creates escapes the guard. Each exemption below names its reason and
// the ticket that removes it, so the list is a dated to-do, not silent debt.
{
files: ['{apps,libs}/**/*.{page,component,section,step}.ts'],
rules: {
'max-lines': ['error', { max: 250, skipBlankLines: true, skipComments: true }],
},
},
]; ];
@@ -1,3 +1,4 @@
/* eslint-disable max-lines */ // toolbar + contenteditable logic in one component — removed by RD-21
import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core'; import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';
import { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text'; import { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';
import { adjacentChip, createChip, readBlock, renderInto } from './rich-text-dom'; import { adjacentChip, createChip, readBlock, renderInto } from './rich-text-dom';