clearotron 0.3.1-beta.4 → 0.3.2-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,526 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // WHAT A CUSTOMER SURFACE MUST NOT ACQUIRE, AS ONE TABLE.
5
+ //
6
+ // `scripts/writing-standard-check.mjs` refuses these in a diff, so a change cannot add one. The floor
7
+ // beside `driver/test/fixtures/writing-standard-backlog.json` counts what is already here, so the
8
+ // standing population can only fall. Two readers, one table — the same arrangement
9
+ // `shared/reference-guard-classes.mjs` uses, and for the same reason: a class the diff guard refuses and
10
+ // the census does not count is a class whose number nobody can act on.
11
+ //
12
+ // The standard these hold is `docs/writing-standard.md`, and the prose rules under it are
13
+ // `docs/writing-rules.md`. This file enforces the part a machine can judge. Tone is not here and cannot
14
+ // be: no word list catches a sentence that explains what the reader can already see. That stays with the
15
+ // review step in CONTRIBUTING.md.
16
+ //
17
+ // ── THE SITE RULE IS THE WHOLE DESIGN, AND IT IS THE TABLE'S OWN WORDS ──────────────────────────
18
+ //
19
+ // `engineering-identifier` matches "in text that reaches report output". That clause is not decoration,
20
+ // and dropping it was measured rather than argued: read as a plain path rule over `docs/**` it fires
21
+ // 1,995 times on backticked identifiers and 1,198 times on capitalised ones, across 50 files — almost
22
+ // every one of them a file path or an environment variable named correctly in developer documentation,
23
+ // which is what that documentation is for. A guard that fires on correct prose is one whose next reader
24
+ // deletes it from the workflow.
25
+ //
26
+ // So the site is where the text is PRINTED, not where the file lives:
27
+ //
28
+ // in a renderer — the literal chunks of its string literals. Not a comment, not an identifier in
29
+ // code, and not the expression inside an interpolation. `${STOP_VAR[i]}` is a variable reference in a
30
+ // style attribute; the reader never sees those characters.
31
+ //
32
+ // in rendered output — every line, because the file IS what the reader was given.
33
+ //
34
+ // ── WHY THIS CLASS HAS ALMOST NO POPULATION IN RENDERER SOURCE, WHICH IS NOT A FAULT ────────────
35
+ //
36
+ // Measured on this tree: applying the site rule to `driver/publish/**` leaves nothing. That is the
37
+ // design, not a hole in the check. No engineering identifier is hard-coded in either renderer — the
38
+ // connection codes, the slice and routing identifiers and the tool narration all arrive in the engine's
39
+ // own text and pass through untouched. A check reading only renderer source sees none of them, which is
40
+ // why rendered output is a scanned path and not an afterthought.
41
+ //
42
+ // SAID HERE SO AN EMPTY RESULT IS NOT READ AS A PASS. This class is prospective: it stops a new
43
+ // identifier being written INTO a renderer's printed text. What arrives at runtime in the engine's
44
+ // prose is outside any diff guard, and the review step is what covers it.
45
+
46
+ import { readFileSync } from 'node:fs'
47
+ import { fileURLToPath } from 'node:url'
48
+ import { saysSomethingNew } from './says-something-new.mjs'
49
+
50
+ // ── THE TWO EXEMPT PATHS ────────────────────────────────────────────────────────────────────────
51
+ //
52
+ // QUOTING A BAD LINE IS STILL WRITING IT, so there is no exemption for backticks and no exemption for a
53
+ // span. Two documents cannot avoid quoting what they forbid: the writing standard, whose before-and-after
54
+ // pairs ARE the banned sentences, and the enforcement note, whose table shows the identifier shapes. Both
55
+ // are exempt from every class by this list and are reviewed by hand.
56
+ //
57
+ // That is a hole, so it is written down rather than hidden inside a pattern. Two paths, listed. A third is
58
+ // a change somebody has to argue for.
59
+ //
60
+ // THE SECOND PATH NAMES A DOCUMENT THIS REPOSITORY DOES NOT SHIP. The enforcement note specifies the check
61
+ // and stays with the design record. The exemption is a rule about a path, not a read of a file, so it costs
62
+ // nothing and it is here because the day that note is vendored in is not the day to rediscover why it
63
+ // refuses its own table.
64
+ export const EXEMPT_PATHS = [
65
+ 'docs/writing-standard.md',
66
+ 'docs/enforcement.md',
67
+ ]
68
+
69
+ export const isExempt = (path) => EXEMPT_PATHS.includes(path)
70
+
71
+ // ── WHERE EACH CLASS LOOKS ──────────────────────────────────────────────────────────────────────
72
+
73
+ const RENDERER = (p) => /^driver\/publish\/.*\.(mjs|css)$/.test(p)
74
+ /** Rendered output committed to the tree: what a reader was actually given. */
75
+ const RENDERED = (p) => /^mcp-server\/test\/fixtures\/.*\.(md|html)$/.test(p) && !/README\.md$/.test(p)
76
+ const SCREEN = (p) => /^portal-ui\/src\/.*\.tsx?$/.test(p)
77
+
78
+ // ── THE PRINTED SITE ────────────────────────────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * The printed text of one source line: the literal chunks of its string literals, with interpolated
82
+ * expressions dropped and comments contributing nothing.
83
+ *
84
+ * ── PER LINE, DELIBERATELY, AFTER THE CROSS-LINE VERSION WAS MEASURED AND ABANDONED ─────────────
85
+ *
86
+ * A template literal spans lines, so a line in the MIDDLE of one carries no backtick and its markup
87
+ * attributes read as ordinary strings. The obvious repair is to carry quote state across lines — and
88
+ * that was built, run, and thrown away, which is recorded here so nobody rebuilds it.
89
+ *
90
+ * It cannot be done without parsing JavaScript properly, because of REGEX LITERALS. A pattern such as
91
+ * `/\bcan't\b/` contains a quote, and a hand-rolled walker takes it as the start of a string; every
92
+ * line after it in the file then reads as printed text until the next matching quote. Measured on this
93
+ * tree: the standing population went from 22 to 55, two files appeared that print nothing, one renderer
94
+ * went from 4 hits to 28 — AND the knockout's real caveats fell from 6 to 4, because the same desync ran
95
+ * the other way and swallowed them. A guard that gains false hits is annoying; one that silently loses
96
+ * true ones joins the floor's silence, and the floor only falls, so nobody looks again.
97
+ *
98
+ * Telling a regex literal from a division needs the parser's context, and the one parser in this tree is
99
+ * a development dependency — importing it from `shared/`, which ships, would fail at import time on an
100
+ * installed copy.
101
+ *
102
+ * SO THE FAILURE IS BOUNDED INSTEAD OF UNBOUNDED. Read per line, quote state resets every line, and the
103
+ * only thing misread is a mid-template line — whose over-read is an interpolation, which the next rule
104
+ * removes by shape rather than by parsing.
105
+ *
106
+ * @param {string} line
107
+ * @returns {string} the printed chunks, space-joined; empty when the line prints nothing
108
+ */
109
+ export function printedText(line) {
110
+ const t = String(line ?? '').trim()
111
+ if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return ''
112
+ const s = String(line ?? '')
113
+ let out = ''
114
+ let i = 0
115
+ while (i < s.length) {
116
+ const ch = s[i]
117
+ if (ch === '\\') { i += 2; continue }
118
+ if (ch === '`' || ch === "'" || ch === '"') {
119
+ const q = ch
120
+ i++
121
+ while (i < s.length && s[i] !== q) {
122
+ // AN ESCAPED CHARACTER IS EMITTED, NOT SKIPPED. Skipping it drops the character the reader
123
+ // sees: `store\'s` became "store s", which breaks a caveat match on any sentence with an
124
+ // apostrophe — silently, and in the direction that loses hits. A real escape sequence becomes
125
+ // whitespace, which is what it prints as.
126
+ if (s[i] === '\\') {
127
+ const e = s[i + 1]
128
+ out += (e === 'n' || e === 't' || e === 'r') ? ' ' : (e ?? '')
129
+ i += 2
130
+ continue
131
+ }
132
+ out += s[i]
133
+ i++
134
+ }
135
+ i++
136
+ out += ' '
137
+ continue
138
+ }
139
+ i++
140
+ }
141
+ return out
142
+ }
143
+
144
+ /**
145
+ * Remove any interpolation left in extracted text, by shape.
146
+ *
147
+ * THIS IS WHAT MAKES THE PER-LINE READ SAFE. On a mid-template line the walker has no backtick to tell
148
+ * it where it is, so `style="left:${STOP_LEFT[i]}"` yields `left:${STOP_LEFT[i]}` — and the array name
149
+ * inside it is not something a reader ever sees. A literal `${` does not occur in prose a client reads,
150
+ * so its presence in extracted text means exactly one thing.
151
+ *
152
+ * Braces are counted rather than matched with a regex: `${fn({ a: 1 })}` closes at the second `}`, and a
153
+ * lazy pattern closes at the first, leaving `)}` behind and the name still in the text.
154
+ */
155
+ export const withoutInterpolations = (text) => {
156
+ const s = String(text)
157
+ let out = ''
158
+ let i = 0
159
+ while (i < s.length) {
160
+ if (s[i] === '$' && s[i + 1] === '{') {
161
+ let depth = 1
162
+ i += 2
163
+ while (i < s.length && depth > 0) {
164
+ if (s[i] === '{') depth++
165
+ else if (s[i] === '}') depth--
166
+ i++
167
+ }
168
+ out += ' '
169
+ continue
170
+ }
171
+ out += s[i]
172
+ i++
173
+ }
174
+ return out
175
+ }
176
+
177
+ /** A line whose text is aimed at a developer or the operator, not at a client. */
178
+ const DIAGNOSTIC_SITE = /\bthrow\b|new Error\(|console\.(?:log|warn|error|info|debug)\(/
179
+
180
+ /**
181
+ * The lines covered by each diagnostic statement, not just the line its keyword is on.
182
+ *
183
+ * A thrown message is usually several lines of concatenation and the keyword sits on the first, so a
184
+ * per-line test excuses the `throw` and then refuses the sentence it throws. Measured: the operator
185
+ * message naming an unset setting was excused on its own line and refused on the next two. The statement
186
+ * is followed to where its parentheses balance instead.
187
+ */
188
+ const diagnosticLines = (raw) => {
189
+ const covered = new Set()
190
+ for (let i = 0; i < raw.length; i++) {
191
+ if (!DIAGNOSTIC_SITE.test(raw[i])) continue
192
+ let depth = 0
193
+ let seen = false
194
+ for (let j = i; j < raw.length; j++) {
195
+ covered.add(j)
196
+ for (const ch of raw[j]) {
197
+ if (ch === '(') { depth++; seen = true }
198
+ else if (ch === ')') depth--
199
+ }
200
+ if (seen && depth <= 0) break
201
+ if (!seen && /;\s*$/.test(raw[j])) break
202
+ }
203
+ }
204
+ return covered
205
+ }
206
+
207
+ /** Strip the spans where text is an address rather than prose. */
208
+ const withoutUrls = (t) => String(t).replace(/https?:\/\/\S+/g, ' ').replace(/[a-z]+:\/\/\S+/gi, ' ')
209
+
210
+ /**
211
+ * Every line's readable site in a file, keyed by line number.
212
+ *
213
+ * In a renderer this is the printed text of its string literals; in committed rendered output it is the
214
+ * line itself, because that file IS what the reader was given.
215
+ */
216
+ export function sitesFor(path, text) {
217
+ const map = new Map()
218
+ const raw = String(text).split('\n')
219
+ if (RENDERED(path)) {
220
+ raw.forEach((l, i) => { if (l.trim()) map.set(i + 1, withoutUrls(l)) })
221
+ return map
222
+ }
223
+ if (!RENDERER(path) && !SCREEN(path)) return map
224
+ const diagnostic = diagnosticLines(raw)
225
+ raw.forEach((l, i) => {
226
+ if (diagnostic.has(i)) return
227
+ const site = withoutInterpolations(withoutUrls(printedText(l)))
228
+ if (site.trim()) map.set(i + 1, site)
229
+ })
230
+ return map
231
+ }
232
+
233
+ // ── THE CAVEAT SENTENCES ARE DATA ───────────────────────────────────────────────────────────────
234
+ //
235
+ // Held in a fixture beside this module rather than spelled here, because a class list that reprints its
236
+ // own specimens counts itself — and this module is read by the census that counts them. The fixture is
237
+ // the row that matters for this class: most caveats arrive in the engine's text rather than a renderer's
238
+ // source, so the sentences are matched wherever they are printed rather than where they were authored.
239
+ //
240
+ // BESIDE THIS MODULE MEANS IN THIS DIRECTORY, AND THAT IS A PACKAGING CONSTRAINT AS WELL AS A TIDINESS
241
+ // ONE. `shared/` ships; `**/fixtures/` is excluded from the published package by `package.json`. A
242
+ // top-level read of a fixture under `driver/test/` would resolve here and throw on the installed copy —
243
+ // an import-time failure in a module the runtime loads, found by whoever installed it rather than by us.
244
+ export const CAVEATS = JSON.parse(readFileSync(
245
+ fileURLToPath(new URL('./writing-standard-caveats.json', import.meta.url)), 'utf8')).sentences
246
+
247
+ /** Normalised for comparison: a caveat that was re-wrapped or re-spaced is the same caveat. */
248
+ const flatten = (s) => String(s ?? '').toLowerCase().replace(/\s+/g, ' ').trim()
249
+
250
+ // ── THE LINE CLASSES ────────────────────────────────────────────────────────────────────────────
251
+ //
252
+ // Each is `{ id, why, paths, find }`. `find` returns every offending token on the line's SITE, so a
253
+ // class cannot be written in a way that reintroduces a comment or an interpolation by accident.
254
+ //
255
+ // No pattern here carries the `g` flag at rest. A shared `g`-flagged regex carries `lastIndex` between
256
+ // calls, so the same instance answers differently on its second use. `every` adds the flag per call.
257
+ const every = (re, text) => [...String(text).matchAll(new RegExp(re.source, re.flags.replace('g', '') + 'g'))]
258
+
259
+ const SCREAMING = /\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/
260
+ const TOOL_NAME = /\b[a-z][a-z0-9]*__[a-z0-9_]+\b/
261
+ const BACKTICKED = /`([a-z][a-zA-Z0-9]*(?:[._/-][a-zA-Z0-9]+)+)`/
262
+ const BESIDE_SOURCE = /\b(?:adapter|connector|tool server)\b/i
263
+
264
+ /** @type {{id: string, why: string, paths: (p: string) => boolean, find: (site: string) => string[]}[]} */
265
+ export const LINE_CLASSES = [
266
+ {
267
+ id: 'engineering-identifier',
268
+ paths: (p) => RENDERER(p) || RENDERED(p),
269
+ why: 'an engineering identifier in text a client reads. Error codes, source names and internal '
270
+ + 'identifiers are not the reader\'s vocabulary. State the limit and its consequence: '
271
+ + '"Case-law research could not be completed for Japan."',
272
+ find: (site) => [
273
+ ...every(SCREAMING, site).map((m) => m[0]),
274
+ ...every(TOOL_NAME, site).map((m) => m[0]),
275
+ ...every(BACKTICKED, site).map((m) => m[1]),
276
+ ...every(BESIDE_SOURCE, site).map((m) => m[0]),
277
+ ],
278
+ },
279
+ {
280
+ id: 'internal-marker',
281
+ paths: (p) => RENDERER(p) || RENDERED(p),
282
+ why: 'a reviewer-only marker in rendered output. The rule that hides it is declared once, for print '
283
+ + 'only — so the exported file is clean and the browser copy, which is what gets sent on, is not. '
284
+ + 'Mark it where it is built, not where it is displayed.',
285
+ find: (site) => every(
286
+ /class="[^"]*\binternal\b[^"]*"|\breview-note\b|\brv-(?:bar|head|tag|spacer)\b|For the reviewing lawyer|Internal review copy/,
287
+ site).map((m) => m[0]),
288
+ },
289
+ {
290
+ // ── EVALUATED OVER THE FILE, NOT THE LINE, AND THAT IS NOT AN OPTIMISATION ────────────────────
291
+ //
292
+ // A caveat in a renderer is built by concatenation: the knockout's scope block is six source lines
293
+ // of `+ '...'`, and the sentence the standard forbids straddles two of them. Read line by line it
294
+ // matches nothing — the same defect the reference guard records as a citation that wrapped, where
295
+ // four went in, three came out and the count returned to its floor with the fourth still in the
296
+ // tree. Worse than a miscount, because the floor only falls and a silent zero reads as repaired.
297
+ //
298
+ // So `find` is never called for this class. `fileOffences` joins the printed chunks and searches
299
+ // the join, attributing each hit to the line its match begins on.
300
+ id: 'known-caveat',
301
+ paths: (p) => RENDERER(p) || RENDERED(p) || SCREEN(p),
302
+ why: 'a caveat sentence. Say what the search did and what happens next; never define the product by '
303
+ + 'negation, and never hand the reader a limit they cannot act on.',
304
+ find: () => [],
305
+ },
306
+ ]
307
+
308
+ /**
309
+ * Every line class this ONE line offends, as `{ id, token, why }`.
310
+ *
311
+ * Reads the line as a one-line file through the same walker `fileOffences` uses, so there is one
312
+ * definition of "what a reader sees". A line taken out of its file cannot know it sits inside a template
313
+ * literal, which is a real limit of asking the question this way: `fileOffences` is the authoritative
314
+ * reading, and this is here for an arm driving a single line.
315
+ *
316
+ * `known-caveat` is not answered here — it is a property of the joined text.
317
+ */
318
+ export function offendingClasses(path, line) {
319
+ if (isExempt(path)) return []
320
+ const site = sitesFor(path, String(line ?? '')).get(1)
321
+ if (!site || !site.trim()) return []
322
+ const out = []
323
+ for (const c of LINE_CLASSES) {
324
+ if (!c.paths(path)) continue
325
+ for (const token of c.find(site)) out.push({ id: c.id, token, why: c.why })
326
+ }
327
+ return out
328
+ }
329
+
330
+ /**
331
+ * THE ONE ENTRY POINT. Every offence in one file, as `{ id, token, why, line }`.
332
+ *
333
+ * Three readings, because the classes are three shapes and pretending otherwise is what loses hits: the
334
+ * per-line classes over each line's site; `known-caveat` over the joined printed text, so a sentence
335
+ * built by concatenation across source lines is still one sentence; and the block classes over the whole
336
+ * file, because "this screen writes its own heading" is not a property any line has.
337
+ *
338
+ * @param {string} path repo-relative
339
+ * @param {string} text the file's contents
340
+ */
341
+ export function fileOffences(path, text) {
342
+ if (isExempt(path)) return []
343
+ const out = []
344
+ const sites = sitesFor(path, text)
345
+
346
+ for (const [line, site] of sites) {
347
+ for (const c of LINE_CLASSES) {
348
+ if (!c.paths(path)) continue
349
+ for (const token of c.find(site)) out.push({ id: c.id, token, why: c.why, line })
350
+ }
351
+ }
352
+
353
+ // ── THE JOINED PRINTED TEXT, WITH A MAP BACK TO LINES ──
354
+ //
355
+ // Flattened in step with the map rather than by rewriting the string: collapsing whitespace with a
356
+ // replace would desynchronise the index from the line it came from, and every caveat would then be
357
+ // reported against the wrong line — which reads as a correct finding and sends the reader to the wrong
358
+ // place.
359
+ const caveatClass = LINE_CLASSES.find((c) => c.id === 'known-caveat')
360
+ if (caveatClass.paths(path)) {
361
+ const flat = []
362
+ const flatAt = []
363
+ let lastSpace = true
364
+ for (const [line, site] of sites) {
365
+ for (const raw of site + ' ') {
366
+ const ch = raw.toLowerCase()
367
+ if (/\s/.test(ch)) { if (!lastSpace) { flat.push(' '); flatAt.push(line) } lastSpace = true; continue }
368
+ flat.push(ch); flatAt.push(line); lastSpace = false
369
+ }
370
+ }
371
+ const hay = flat.join('')
372
+ for (const c of CAVEATS) {
373
+ const needle = flatten(c)
374
+ if (!needle) continue
375
+ let from = 0
376
+ for (;;) {
377
+ const idx = hay.indexOf(needle, from)
378
+ if (idx === -1) break
379
+ out.push({ id: 'known-caveat', token: c, why: caveatClass.why, line: flatAt[idx] ?? 1 })
380
+ from = idx + needle.length
381
+ }
382
+ }
383
+ }
384
+
385
+ for (const b of blockOffences(path, text)) out.push(b)
386
+ return out
387
+ }
388
+
389
+ // ── THE BLOCK CLASSES ───────────────────────────────────────────────────────────────────────────
390
+ //
391
+ // TWO CLASSES THAT NO LINE PREDICATE CAN EXPRESS, and the table knew it: "a screen that renders a page
392
+ // title without going through PageHeader" is a property of a FILE, and "a lede whose content words are
393
+ // all already in its title" needs both lines at once. So these read the file, and the diff decides only
394
+ // whether this change is answerable for it.
395
+ //
396
+ // THE EYEBROW PREDICATES ARE THE ONES THE SUITE ALREADY HOLDS. `portal-ui/test/oneHeaderPerPage.test.ts`
397
+ // has enforced both halves of this class as a corpus guard since the double headers were removed. They
398
+ // are lifted here and that test imports them, because two definitions of one rule is one definition and
399
+ // one imitation of it — and the imitation is whichever the reader did not run. The population floors in
400
+ // that test stay there: they are the test's own business, not the class's.
401
+
402
+ /** A file that draws a screen, read off its markup rather than its directory. */
403
+ export const drawsScreen = (src) => /className="screen/.test(src) && !/className="topbar/.test(src)
404
+
405
+ /**
406
+ * An eyebrow standing above a heading, as `{ line, eyebrow }` pairs.
407
+ *
408
+ * Read over a WINDOW rather than adjacent lines: the pair on one screen had a component and a four-line
409
+ * note between the two, and an adjacency check called that screen clean while a reader saw two stacked
410
+ * lines.
411
+ */
412
+ export function eyebrowOverHeading(src) {
413
+ const lines = String(src).split('\n')
414
+ const out = []
415
+ let eyebrow = -99
416
+ lines.forEach((l, i) => {
417
+ if (/className="eyebrow"/.test(l)) eyebrow = i
418
+ if (/<h1\b/.test(l) && i - eyebrow <= 8) out.push({ line: i + 1, eyebrow: eyebrow + 1 })
419
+ })
420
+ return out
421
+ }
422
+
423
+ /**
424
+ * How a screen writes its own page heading instead of opening with the shared component, or null.
425
+ *
426
+ * THE PROPERTY, NOT A FONT SIZE. An earlier cut of this rule matched an `<h1>` carrying the inline style
427
+ * the hand-written headers happened to share; a bare `<h1>About</h1>` passed it, and three of those were
428
+ * live on reachable screens while the arm read green. What may not happen is a screen OPENING with a
429
+ * heading it wrote itself — a heading inside a page, for an empty state or a run's own mark, is sized for
430
+ * where it sits and is not the page naming itself.
431
+ */
432
+ export function writesItsOwnHeader(src) {
433
+ const header = String(src).indexOf('<PageHeader')
434
+ const h1 = String(src).indexOf('<h1')
435
+ if (header < 0 && h1 < 0) return null
436
+ if (header < 0) return 'writes a heading and never the shared component'
437
+ if (h1 >= 0 && h1 < header) return 'opens with a heading of its own, before the shared component'
438
+ return null
439
+ }
440
+
441
+ /**
442
+ * Every `PageHeader` whose lede restates its title, as `{ line, title, lede }`.
443
+ *
444
+ * ONE DEFINITION OF "SAYS NOTHING NEW", shared with the knockout renderer's caveat filter in
445
+ * `shared/says-something-new.mjs`. The renderer asks it of a caveat against the scope block; this asks it
446
+ * of a lede against its title. Same rule, one function.
447
+ *
448
+ * A lede with no content words at all is not a restatement — it is a lede saying nothing, which is a
449
+ * different fault and not this class's.
450
+ */
451
+ export function restatingLede(src) {
452
+ const out = []
453
+ const text = String(src)
454
+ for (const m of text.matchAll(/<PageHeader\b([\s\S]*?)\/?>/g)) {
455
+ const block = m[1]
456
+ const title = /title=(?:"([^"]*)"|\{`([^`]*)`\})/.exec(block)
457
+ const lede = /lede=(?:"([^"]*)"|\{`([^`]*)`\})/.exec(block)
458
+ if (!title || !lede) continue
459
+ const t = title[1] ?? title[2] ?? ''
460
+ const l = lede[1] ?? lede[2] ?? ''
461
+ if (!l.trim()) continue
462
+ if (!saysSomethingNew(l, t)) out.push({ line: text.slice(0, m.index).split('\n').length, title: t, lede: l })
463
+ }
464
+ return out
465
+ }
466
+
467
+ /** @type {{id: string, why: string, paths: (p: string) => boolean}[]} */
468
+ export const BLOCK_CLASSES = [
469
+ {
470
+ id: 'eyebrow-heading',
471
+ paths: SCREEN,
472
+ why: 'a page names itself once. An eyebrow over a heading is a header and its echo, and a screen '
473
+ + 'writing its own heading drifts from every other screen the day after it is written.',
474
+ },
475
+ {
476
+ id: 'restating-lede',
477
+ paths: SCREEN,
478
+ why: 'a line under the title that says what the title already said. Omit it rather than restate it.',
479
+ },
480
+ ]
481
+
482
+ /** Every block-class offence in one file, as `{ id, token, why, line }`. */
483
+ export function blockOffences(path, src) {
484
+ if (isExempt(path) || !SCREEN(path)) return []
485
+ const out = []
486
+ const [eyebrowWhy, ledeWhy] = BLOCK_CLASSES.map((c) => c.why)
487
+ if (drawsScreen(src)) {
488
+ const own = writesItsOwnHeader(src)
489
+ if (own) out.push({ id: 'eyebrow-heading', token: own, why: eyebrowWhy, line: 1 })
490
+ }
491
+ for (const e of eyebrowOverHeading(src))
492
+ out.push({ id: 'eyebrow-heading', token: `an eyebrow at line ${e.eyebrow} stands above this heading`, why: eyebrowWhy, line: e.line })
493
+ for (const r of restatingLede(src))
494
+ out.push({ id: 'restating-lede', token: `"${r.lede}" under "${r.title}"`, why: ledeWhy, line: r.line })
495
+ return out
496
+ }
497
+
498
+ // ── THE CENSUS ──────────────────────────────────────────────────────────────────────────────────
499
+
500
+ export const CLASSES = [...LINE_CLASSES, ...BLOCK_CLASSES]
501
+ const COLUMN = new Map(CLASSES.map((c, i) => [c.id, i]))
502
+
503
+ /**
504
+ * The standing population, per file, as `{ total, files: { path: number[] } }` — one count per class in
505
+ * `CLASSES` order, for every file carrying at least one.
506
+ *
507
+ * A file with no hit is absent rather than zero-filled, so the fixture shrinks as the tree is repaired
508
+ * instead of recording a growing list of clean files.
509
+ *
510
+ * @param {string[]} files repo-relative paths
511
+ * @param {(path: string) => string} read
512
+ */
513
+ export function censusOf(files, read) {
514
+ const out = { total: 0, files: {}, exempt: 0 }
515
+ for (const path of files) {
516
+ if (isExempt(path)) { out.exempt++; continue }
517
+ let text
518
+ try { text = read(path) } catch { continue }
519
+ if (text.includes('\0')) continue
520
+ const counts = CLASSES.map(() => 0)
521
+ for (const { id } of fileOffences(path, text)) counts[COLUMN.get(id)]++
522
+ const sum = counts.reduce((a, b) => a + b, 0)
523
+ if (sum) { out.files[path] = counts; out.total += sum }
524
+ }
525
+ return out
526
+ }