@weatherboard/gyde-design 0.3.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.
package/props.mjs ADDED
@@ -0,0 +1,255 @@
1
+ /**
2
+ * G-68 — the optional-props ban.
3
+ *
4
+ * Absorbed from a product that wrote it by hand, under the promotion route in
5
+ * contract.md §7. It passes the test there: it can be stated without naming a
6
+ * component, a token, a route or a brand value, and a second product on a
7
+ * different stack would want it.
8
+ *
9
+ * WHAT IT IS FOR.
10
+ *
11
+ * `emit.mjs` states the principle on the one prop it mattered most for:
12
+ *
13
+ * "Required and nullable rather than optional. An omittable onClick on a real
14
+ * button compiles, renders, and does nothing — pass null to say the no-op is
15
+ * deliberate."
16
+ *
17
+ * That is the whole rule generalised. An optional prop lets a caller omit a
18
+ * decision, and the component then makes it silently. The omission and the
19
+ * decision are indistinguishable afterwards: nobody reading the call site can
20
+ * tell whether `size` was left out because medium was wanted, or because the
21
+ * author did not think about it. Required-and-nullable forces the author to say
22
+ * which, once, in the place where it is legible.
23
+ *
24
+ * WHY IT COUNTS RATHER THAN BLOCKS.
25
+ *
26
+ * Every real component set has these already — Gyde's own emitted set has ten.
27
+ * A ban that fails on all of them on day one is an outage, so this enters the
28
+ * allowance ledger like any other finding and ratchets down from wherever a
29
+ * repository starts. That is CHARTER §4 exactly: debt is recorded, never
30
+ * amnestied, and the list may only get shorter.
31
+ *
32
+ * The mechanism that lets it arrive at all is G-68's other half — a rule a
33
+ * ledger predates is adopted as existing debt rather than failing the build
34
+ * (ratchet.mjs, `rulesKnownTo`). Without it this rule could not ship to an
35
+ * existing consumer without turning their gate red for code they did not
36
+ * change.
37
+ *
38
+ * WHAT IT DELIBERATELY DOES NOT DO.
39
+ *
40
+ * It does not read defaults. `type = "button"` in a destructure is a real
41
+ * default and a reasonable argument for `type?`, but reading destructures to
42
+ * excuse declarations would make the rule depend on two places agreeing, and a
43
+ * rule that needs two places to agree fires on the disagreement rather than on
44
+ * the thing it is about. The ratchet already handles "this one is fine" — it
45
+ * is recorded, and it stops being counted the day somebody makes it required.
46
+ *
47
+ * @gyde-emits-source-for-another-repo — the fixtures in the test beside this
48
+ * file are TypeScript written as data for a repository that is not this one.
49
+ */
50
+
51
+ import { readFileSync } from "node:fs";
52
+ import { join } from "node:path";
53
+
54
+ export const RULE = "optional-prop";
55
+
56
+ /** A Props type: `export type XProps = {`, `export interface XProps {`, and the non-exported forms. */
57
+ const PROPS_DECL = /\b(?:type|interface)\s+(\w*Props)\b[^{]*\{/;
58
+
59
+ const stripComments = (text) =>
60
+ text.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
61
+ .replace(/\/\/[^\n]*/g, "");
62
+
63
+ /**
64
+ * An optional member declaration.
65
+ *
66
+ * Anchored to the start of a line so a `?` inside a type expression — a
67
+ * conditional type, an optional tuple element, a nested object's own member —
68
+ * cannot be read as a member of THIS type. Missing one of those is a miss; a
69
+ * false positive on a repository's existing code is a rule people switch off.
70
+ */
71
+ const OPTIONAL_MEMBER = /^\s*(?:readonly\s+)?(\w+)\s*\?\s*:/;
72
+
73
+ /** Any member declaration: `name: T`, `name?: T`, `readonly name: T`. */
74
+ const MEMBER = /^\s*(?:readonly\s+)?(\w+)\s*(\?)?\s*:\s*(.+?)\s*;?\s*$/;
75
+
76
+ /**
77
+ * Does this type include `null` as its own alternative?
78
+ *
79
+ * Union members only — `A | null`, `(() => void) | null`. A `null` appearing
80
+ * inside a generic argument (`Map<string, null>`) is that generic's business,
81
+ * not this member's, so the split is on top-level `|` with bracket depth zero.
82
+ */
83
+ function unionIncludesNull(type) {
84
+ let depth = 0, current = "", prev = "";
85
+ const parts = [];
86
+ for (const ch of type) {
87
+ // `>` closes a generic — unless it is the tail of an arrow. Counting the
88
+ // arrow in `(() => void) | null` drove the depth negative, so the top-level
89
+ // `|` was never seen and the most important required-nullable shape in the
90
+ // emitted set read as non-nullable.
91
+ if ("<([{".includes(ch)) depth++;
92
+ else if (")]}".includes(ch)) depth--;
93
+ else if (ch === ">" && prev !== "=") depth--;
94
+
95
+ if (ch === "|" && depth === 0) { parts.push(current); current = ""; prev = ch; continue; }
96
+ current += ch;
97
+ prev = ch;
98
+ }
99
+ parts.push(current);
100
+ return parts.some((p) => p.trim() === "null");
101
+ }
102
+
103
+ /**
104
+ * Find every optional member of every Props type in a source file.
105
+ *
106
+ * Brace-depth tracked rather than regex-matched to the closing brace: a member
107
+ * whose type is itself an object literal (`options: { value: string }[]`) would
108
+ * otherwise end the block early and hide every member after it.
109
+ */
110
+ export function propMembers(text, { filename = "" } = {}) {
111
+ const lines = stripComments(text).split("\n");
112
+ const found = [];
113
+
114
+ let depth = 0;
115
+ let typeName = null;
116
+
117
+ for (let i = 0; i < lines.length; i++) {
118
+ const line = lines[i];
119
+
120
+ if (typeName === null) {
121
+ const m = line.match(PROPS_DECL);
122
+ if (!m) continue;
123
+ typeName = m[1];
124
+ depth = countBraces(line.slice(line.indexOf("{")));
125
+ if (depth <= 0) typeName = null; // a one-line type, already closed
126
+ continue;
127
+ }
128
+
129
+ // Only members at the type's own top level count. A nested object's members
130
+ // are that object's shape, not this component's API.
131
+ if (depth === 1) {
132
+ const optional = OPTIONAL_MEMBER.test(line);
133
+ const m = line.match(MEMBER);
134
+ if (m) {
135
+ found.push({
136
+ file: filename, line: i + 1, prop: m[1], type: typeName,
137
+ optional,
138
+ nullable: unionIncludesNull(m[3]),
139
+ declared: m[3],
140
+ });
141
+ }
142
+ }
143
+
144
+ depth += countBraces(line);
145
+ if (depth <= 0) typeName = null;
146
+ }
147
+
148
+ return found;
149
+ }
150
+
151
+ export function optionalProps(text, { filename = "" } = {}) {
152
+ return propMembers(text, { filename })
153
+ .filter((m) => m.optional)
154
+ .map((m) => ({ file: m.file, rule: RULE, line: m.line, prop: m.prop, type: m.type }));
155
+ }
156
+
157
+ function countBraces(s) {
158
+ let n = 0;
159
+ for (const ch of s) { if (ch === "{") n++; else if (ch === "}") n--; }
160
+ return n;
161
+ }
162
+
163
+ /** Read one file from disk and report its optional props. Returns [] for anything unreadable. */
164
+ export function optionalPropsIn(root, rel) {
165
+ let text;
166
+ try { text = readFileSync(join(root, rel), "utf8"); } catch { return []; }
167
+ if (!/\bProps\b/.test(text)) return []; // cheap reject before the line walk
168
+ return optionalProps(text, { filename: rel });
169
+ }
170
+
171
+ export const GUARD_RULE = "required-nullable-guard";
172
+
173
+ /**
174
+ * G-69 — a guard against `undefined` on a prop that can never be undefined.
175
+ *
176
+ * This is the defect the optional-props ban CREATES if nobody looks for it.
177
+ * Converting `onClick?: () => void` to `onClick: (() => void) | null` is the
178
+ * fix G-68 asks for, and it silently turns every existing
179
+ * `if (onClick !== undefined)` into a condition that is always true. The
180
+ * component keeps compiling. The branch keeps running. The case the guard was
181
+ * written to exclude is now included, and nothing anywhere reports it.
182
+ *
183
+ * TypeScript will not catch it: the comparison is legal, and with
184
+ * `strictNullChecks` the narrowed type is simply unchanged. A linter's
185
+ * no-unnecessary-condition rule can catch it, but it requires type information,
186
+ * which means it is off in most repositories that most need it.
187
+ *
188
+ * The three spellings are all here because they are all the same mistake, and a
189
+ * rule that catches two of three teaches people which spelling is safe:
190
+ *
191
+ * x !== undefined x === undefined typeof x === "undefined"
192
+ *
193
+ * `x ?? fallback` and `x?.y` are NOT findings. Nullish coalescing and optional
194
+ * chaining both test null and undefined together, so they stay correct across
195
+ * exactly the change that breaks the explicit comparisons. They are the shape
196
+ * people should be moving to.
197
+ *
198
+ * SCOPE, and its honest limit. A bare identifier is matched, because props
199
+ * arrive destructured and that is how they are written. A local variable that
200
+ * shares a prop's name would be a false positive. That is accepted: the guard
201
+ * would still be reading a name the file declares as required-nullable, which
202
+ * is worth a second look either way.
203
+ */
204
+ export function requiredNullableGuards(text, { filename = "" } = {}) {
205
+ const members = propMembers(text, { filename }).filter((m) => !m.optional && m.nullable);
206
+ if (members.length === 0) return [];
207
+
208
+ const byName = new Map(members.map((m) => [m.prop, m]));
209
+ const src = stripComments(text);
210
+ const found = [];
211
+
212
+ src.split("\n").forEach((line, i) => {
213
+ for (const [name, member] of byName) {
214
+ const compare = new RegExp(`(?<![\\w$.])${name}\\s*[!=]==\\s*undefined`);
215
+ const typeofCmp = new RegExp(`typeof\\s+${name}\\s*[!=]==\\s*["']undefined["']`);
216
+ if (!compare.test(line) && !typeofCmp.test(line)) continue;
217
+
218
+ found.push({
219
+ file: filename, rule: GUARD_RULE, line: i + 1, prop: name, type: member.type,
220
+ declared: member.declared, source: line.trim(),
221
+ });
222
+ }
223
+ });
224
+
225
+ return found;
226
+ }
227
+
228
+ export function formatRequiredNullableGuards(findings) {
229
+ if (findings.length === 0) return "no guards compare a required-nullable prop against undefined";
230
+ const L = [`${findings.length} always-true guard(s):`];
231
+ for (const f of findings) {
232
+ L.push(` ${f.file}:${f.line} ${f.type}.${f.prop} is \`${f.declared}\` — required, so never undefined`);
233
+ L.push(` ${f.source}`);
234
+ }
235
+ L.push("");
236
+ L.push(" The prop is required, so it cannot be undefined and this condition is always");
237
+ L.push(" true. The case it was written to exclude is being included. Compare against");
238
+ L.push(" null, or use ?? / ?. which test both and stay correct.");
239
+ return L.join("\n");
240
+ }
241
+
242
+ export function formatOptionalProps(findings) {
243
+ if (findings.length === 0) return "no optional props in any Props type";
244
+ const byFile = {};
245
+ for (const f of findings) (byFile[f.file] ??= []).push(f);
246
+ const L = [`${findings.length} optional prop(s) across ${Object.keys(byFile).length} file(s):`];
247
+ for (const [file, fs] of Object.entries(byFile)) {
248
+ L.push(` ${file} ${fs.map((f) => `${f.type}.${f.prop}`).join(", ")}`);
249
+ }
250
+ L.push("");
251
+ L.push(" An optional prop lets a caller omit a decision and the component makes it");
252
+ L.push(" silently. Afterwards nobody can tell an omission from a choice. Required and");
253
+ L.push(" nullable says which, once, where it is legible.");
254
+ return L.join("\n");
255
+ }
package/ratchet.mjs ADDED
@@ -0,0 +1,290 @@
1
+ /**
2
+ * G-52 — the allowance ledger, and the gate that reads it.
3
+ *
4
+ * A gate that blocks on every existing violation is an outage. System B had ~100
5
+ * pre-existing hits and System C has 4,061, so the only usable shape is
6
+ * to record what is already wrong and judge a change on what it introduced.
7
+ *
8
+ * THE SENTENCE THAT MAKES IT WORK, carried from System B almost verbatim,
9
+ * because without it an allowance file becomes a to-do list nobody reads:
10
+ *
11
+ * "This list may only ever get shorter: a new violation in any of these files
12
+ * fails the gate, and so does re-adding an entry that has been cleared. It is
13
+ * not a permission to leave them — it is the record of what was already there
14
+ * when the measurement became honest."
15
+ *
16
+ * THE MECHANISM WORTH COPYING EXACTLY. A cleared entry is **deleted** from the
17
+ * ledger, never zeroed. That single choice makes "a new violation" and "a
18
+ * violation somebody re-added" the same code path, so the third failure mode
19
+ * needs no code of its own and cannot be forgotten.
20
+ *
21
+ * WHAT System B ADDS. Compare **per scope**, never on the headline. Their gate had
22
+ * to be rewritten because widening the measurement to a newly-included
23
+ * app moved the average down while no file had got worse. An average is a
24
+ * number that can regress with nothing behind it regressing; a per-file tally
25
+ * cannot.
26
+ */
27
+
28
+ export const LEDGER_NOTE =
29
+ "This list may only ever get shorter: a new violation in any of these files " +
30
+ "fails the gate, and so does re-adding an entry that has been cleared. It is " +
31
+ "not a permission to leave them — it is the record of what was already there " +
32
+ "when the measurement became honest. Regenerate ONLY after clearing " +
33
+ "violations, never to make a failure go away.";
34
+
35
+ /** Tally findings into `{ file: { rule: count } }`. */
36
+ export function tally(findings) {
37
+ const out = {};
38
+ for (const f of findings) {
39
+ (out[f.file] ??= {});
40
+ out[f.file][f.rule] = (out[f.file][f.rule] || 0) + 1;
41
+ }
42
+ return sortDeep(out);
43
+ }
44
+
45
+ function sortDeep(obj) {
46
+ const out = {};
47
+ for (const k of Object.keys(obj).sort()) {
48
+ out[k] = Object.fromEntries(Object.entries(obj[k]).sort(([a], [b]) => a.localeCompare(b)));
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * Record the current state as the baseline.
55
+ *
56
+ * CHARTER §4: *"Amnesty never: adoption of an existing codebase records current
57
+ * debt as allowances and ratchets from there; it does not reset anything to zero
58
+ * by declaration."* This function is that sentence — it is the only sanctioned
59
+ * way debt enters the ledger, and it always enters as a recorded number rather
60
+ * than as an exemption.
61
+ */
62
+ export function record(findings, { recorded, ticket = null, rules = null } = {}) {
63
+ const allowed = tally(findings);
64
+ const total = Object.values(allowed).reduce(
65
+ (n, rules) => n + Object.values(rules).reduce((m, c) => m + c, 0), 0);
66
+ return {
67
+ note: LEDGER_NOTE,
68
+ // Passed in rather than read from the clock: a generated file whose content
69
+ // depends on when it ran cannot be diffed against its generator.
70
+ recorded: recorded ?? null,
71
+ ticket,
72
+ // G-68. Which rules this ledger was measured against — NOT which ones
73
+ // found something. Without it, "a rule that did not exist when this was
74
+ // recorded" and "a rule whose every entry has been cleared" are the same
75
+ // observation: absent from `allowed`. They must not be, because one has to
76
+ // be recorded and the other has to fail. See `rulesKnownTo`.
77
+ rules: rules ? [...new Set(rules)].sort() : null,
78
+ total,
79
+ allowed,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * The rule set that existed before ledgers recorded their own.
85
+ *
86
+ * A ledger written before G-68 has no `rules` field, and the question "what did
87
+ * it know?" has to be answered somehow. The first attempt inferred it from the
88
+ * entries the ledger carries — and that is wrong in a way the existing tests
89
+ * caught immediately: a ledger recorded on a clean repository carries no
90
+ * entries, so it would appear to know nothing, and the first real violation
91
+ * would be adopted as pre-existing debt instead of failing.
92
+ *
93
+ * That is enforcement shrinking without anybody choosing it, which is the exact
94
+ * failure contract.md §7 forbids. So the answer is not inferred, it is
95
+ * recorded: these are the five rules that existed when the field did not.
96
+ * Anything on this list is enforced against a legacy ledger exactly as before.
97
+ */
98
+ export const LEGACY_RULES = [
99
+ "arbitrary-shadow",
100
+ "untokenised-colour",
101
+ "untokenised-radius",
102
+ "untokenised-space",
103
+ "untokenised-type",
104
+ ];
105
+
106
+ /**
107
+ * The rules a ledger was measured against.
108
+ *
109
+ * Fails closed. A ledger that does not say is treated as knowing every rule
110
+ * that existed before ledgers said — never as knowing none — so adoption can
111
+ * only ever apply to a rule that genuinely postdates it.
112
+ */
113
+ export function rulesKnownTo(ledger) {
114
+ if (Array.isArray(ledger?.rules)) return { rules: new Set(ledger.rules), inferred: false };
115
+ return { rules: new Set(LEGACY_RULES), inferred: true };
116
+ }
117
+
118
+ /**
119
+ * Fold adopted entries into a ledger, and record which rules it now knows.
120
+ *
121
+ * Separate from `record` because it is not the same act. `record` takes a
122
+ * measurement of everything; this takes an existing measurement and adds the
123
+ * rules that did not exist when it was made, leaving every other number exactly
124
+ * as it was. A rule arriving must not silently re-baseline a file whose debt
125
+ * somebody has been paying down.
126
+ *
127
+ * `rules` is the full set the scan ran, not just the adopted ones — otherwise a
128
+ * rule that found nothing this time stays unknown, and its first violation
129
+ * would be adopted instead of failed.
130
+ */
131
+ export function adopt(ledger, adopted, { rules }) {
132
+ const allowed = JSON.parse(JSON.stringify(ledger?.allowed ?? {}));
133
+ for (const a of adopted) {
134
+ (allowed[a.file] ??= {});
135
+ allowed[a.file][a.rule] = a.count;
136
+ }
137
+ const known = new Set([...(rulesKnownTo(ledger).rules), ...rules]);
138
+ const total = Object.values(allowed).reduce(
139
+ (n, rs) => n + Object.values(rs).reduce((m, c) => m + c, 0), 0);
140
+ return {
141
+ ...ledger,
142
+ note: LEDGER_NOTE,
143
+ rules: [...known].sort(),
144
+ total,
145
+ allowed: sortDeep(allowed),
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Compare a fresh scan against the ledger.
151
+ *
152
+ * Returns `{ ok, failures, cleared, unchanged }`. `cleared` is a *result*, not a
153
+ * pass: a count that went down must be committed, or the next run silently
154
+ * allows the violation to come back. Progress that is not written down is not
155
+ * ratcheted.
156
+ */
157
+ export function compare(findings, ledger) {
158
+ const current = tally(findings);
159
+ const allowed = ledger?.allowed ?? {};
160
+ const known = rulesKnownTo(ledger);
161
+
162
+ const failures = [];
163
+ const cleared = [];
164
+ const adopted = [];
165
+ let unchanged = 0;
166
+
167
+ for (const [file, rules] of Object.entries(current)) {
168
+ for (const [rule, count] of Object.entries(rules)) {
169
+ const budget = allowed[file]?.[rule];
170
+
171
+ // G-68. A rule this ledger was never measured against did not exist when
172
+ // the measurement was taken, so its hits are pre-existing debt rather
173
+ // than something this change introduced. Recording them is CHARTER §4
174
+ // working as written — debt enters as a recorded allowance, never as zero
175
+ // by declaration — and it is what contract.md §5.2 promises a customer:
176
+ // a new rule arrives as an allowance-backed ratchet, not a red build.
177
+ //
178
+ // This deliberately does NOT weaken the delete-not-zero guarantee. A rule
179
+ // the ledger knows about, with an entry that has been cleared, is still
180
+ // absent from `allowed` and still fails below.
181
+ if (budget === undefined && !known.rules.has(rule)) {
182
+ adopted.push({ file, rule, count, inferred: known.inferred });
183
+ continue;
184
+ }
185
+
186
+ if (budget === undefined) {
187
+ // Covers BOTH "never seen" and "cleared and re-added", because a
188
+ // cleared entry is deleted rather than zeroed. One code path, so the
189
+ // re-adding case cannot be the one somebody forgets to write.
190
+ failures.push({
191
+ file, rule, count, allowed: 0, kind: "new",
192
+ why: "not in the ledger — either new, or cleared once and re-added. Both fail.",
193
+ });
194
+ continue;
195
+ }
196
+ if (count > budget) {
197
+ failures.push({
198
+ file, rule, count, allowed: budget, kind: "increased",
199
+ why: `was ${budget}, now ${count}. The ledger may only ever get shorter.`,
200
+ });
201
+ continue;
202
+ }
203
+ if (count < budget) {
204
+ cleared.push({ file, rule, count, allowed: budget });
205
+ continue;
206
+ }
207
+ unchanged++;
208
+ }
209
+ }
210
+
211
+ // An entry in the ledger with nothing behind it any more. Reported so a stale
212
+ // allowance cannot sit there forever quietly permitting a file that was
213
+ // deleted — the ledger has to track reality in both directions or it stops
214
+ // describing anything.
215
+ for (const [file, rules] of Object.entries(allowed)) {
216
+ for (const [rule, budget] of Object.entries(rules)) {
217
+ if (current[file]?.[rule] === undefined) cleared.push({ file, rule, count: 0, allowed: budget });
218
+ }
219
+ }
220
+
221
+ return { ok: failures.length === 0, failures, cleared, adopted, unchanged };
222
+ }
223
+
224
+ /**
225
+ * The gate's verdict over one or more scopes.
226
+ *
227
+ * Scopes are compared independently and the worst result wins. This is System B's
228
+ * lesson made structural: there is no averaging step for a newly-measured
229
+ * scope to dilute, because there is no average.
230
+ *
231
+ * `unmeasured` is a first-class outcome, not an empty result. CHARTER §5 — a
232
+ * surface Gyde could not review must fail, never pass quietly. "Gyde did not run
233
+ * here" and "Gyde found nothing here" must never render the same.
234
+ */
235
+ export function gate(scopes) {
236
+ const results = [];
237
+ for (const s of scopes) {
238
+ if (s.unmeasured) {
239
+ results.push({
240
+ scope: s.name, ok: false, unmeasured: true,
241
+ why: s.why || "could not be measured, which fails by default rather than passing quietly",
242
+ failures: [], cleared: [],
243
+ });
244
+ continue;
245
+ }
246
+ results.push({ scope: s.name, unmeasured: false, ...compare(s.findings, s.ledger) });
247
+ }
248
+
249
+ return {
250
+ ok: results.every((r) => r.ok),
251
+ results,
252
+ failures: results.flatMap((r) => r.failures.map((f) => ({ ...f, scope: r.scope }))),
253
+ adopted: results.flatMap((r) => (r.adopted || []).map((a) => ({ ...a, scope: r.scope }))),
254
+ unmeasured: results.filter((r) => r.unmeasured).map((r) => r.scope),
255
+ };
256
+ }
257
+
258
+ /** A short report. Failures first — they are the only part that changes an exit code. */
259
+ export function formatGate(verdict) {
260
+ const L = [];
261
+ for (const r of verdict.results) {
262
+ if (r.unmeasured) { L.push(`✖ ${r.scope}: UNMEASURED — ${r.why}`); continue; }
263
+ if (r.ok && r.cleared.length === 0 && (r.adopted || []).length === 0) {
264
+ L.push(`✔ ${r.scope}: held (${r.unchanged} allowed entries unchanged)`); continue;
265
+ }
266
+ if (r.ok && r.cleared.length === 0) { L.push(`✔ ${r.scope}: held (${r.unchanged} allowed entries unchanged)`); continue; }
267
+ if (r.ok) { L.push(`✔ ${r.scope}: held, and ${r.cleared.length} entry(ies) improved — commit the ledger to keep the ground`); continue; }
268
+ L.push(`✖ ${r.scope}: ${r.failures.length} failure(s)`);
269
+ for (const f of r.failures.slice(0, 10)) L.push(` ${f.file} ${f.rule} ${f.why}`);
270
+ if (r.failures.length > 10) L.push(` … and ${r.failures.length - 10} more`);
271
+ }
272
+ // Adoption is reported at the volume of a failure without being one. A rule
273
+ // that arrives, records silently and is never mentioned is a rule the product
274
+ // never learns it now has — and an unmentioned allowance is the to-do list
275
+ // nobody reads that LEDGER_NOTE exists to prevent.
276
+ if ((verdict.adopted || []).length) {
277
+ const byRule = {};
278
+ for (const a of verdict.adopted) (byRule[a.rule] ??= []).push(a);
279
+ L.push("", `${verdict.adopted.length} entry(ies) adopted for ${Object.keys(byRule).length} rule(s) this ledger predates:`);
280
+ for (const [rule, entries] of Object.entries(byRule)) {
281
+ const n = entries.reduce((s, e) => s + e.count, 0);
282
+ L.push(` ${rule}: ${n} across ${entries.length} file(s) — recorded as existing debt, not judged`);
283
+ }
284
+ L.push(" Commit gyde-allowance.json, or the next run adopts them again and the rule never ratchets.");
285
+ }
286
+ if (verdict.unmeasured.length) {
287
+ L.push("", `${verdict.unmeasured.length} scope(s) could not be measured. That is a failure, not a pass.`);
288
+ }
289
+ return L.join("\n");
290
+ }