@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/rules.mjs ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * G-51 — the violation rules, written once against the normalised form.
3
+ *
4
+ * WHAT IS DIFFERENT ABOUT THESE.
5
+ *
6
+ * Every rule here takes a `Decl` (see normalise.mjs), never a line of source.
7
+ * That is the whole design: `rounded-lg`, `border-radius: 8px`, `borderRadius: 8`
8
+ * and a cva variant string all arrive as the same radius decision, so one rule
9
+ * covers three repositories written in three idioms.
10
+ *
11
+ * TWO STRUCTURAL GUARANTEES, both learned from rules that failed silently.
12
+ *
13
+ * 1. **A rule with no failing fixture does not load.** System A's
14
+ * phrasing is the standard: "A rule without one is assumed broken." Here it
15
+ * is not a convention — `loadRules()` throws. A rule nobody proved against a
16
+ * known-bad example is worse than no rule, because it reports zero.
17
+ *
18
+ * 2. **A rule with no passing fixture does not load either.** The fix for a rule
19
+ * that catches nothing is to widen it, and the natural end of widening is a
20
+ * rule that catches everything. A noisy rule gets switched off exactly as
21
+ * fast as a silent one, so both directions are pinned.
22
+ *
23
+ * WHAT THE RULES DELIBERATELY DO NOT DO.
24
+ *
25
+ * They never name a product's component, token, route or brand value. If a rule
26
+ * cannot be stated without one, it is a leak and belongs in per-project
27
+ * configuration (G-55's genericity check enforces this). The thresholds and the
28
+ * allowed token prefixes are configuration; the concepts are not.
29
+ */
30
+
31
+ import { PROP } from "./normalise.mjs";
32
+
33
+ /**
34
+ * Classifications that are not violations, and why that matters.
35
+ *
36
+ * System A finished its remediation with 33 violations, of which three were
37
+ * "containment rules for foreign content — the iframe's column, the iframe, a
38
+ * screenshot of someone else's page. Not a visual vocabulary."
39
+ *
40
+ * A rule set that cannot tell foreign-content containment from bespoke styling
41
+ * reports those three forever, and a number that never reaches zero stops being
42
+ * read. So the category exists, it is declared per project, and code inside it
43
+ * is COUNTED AND REPORTED SEPARATELY rather than dropped — the same discipline
44
+ * as the exemption registry in boundaries.mjs.
45
+ */
46
+ export const CLASSIFICATION = {
47
+ SYSTEM: "system", // the design system's own source — it IS the tokens
48
+ FOREIGN: "foreign", // containment for content we do not control
49
+ PRODUCT: "product", // ordinary product code: the rules apply in full
50
+ };
51
+
52
+ export const DEFAULTS = {
53
+ /** Prefixes that mark a class as belonging to a declared vocabulary rather than being bespoke. */
54
+ classPrefixes: ["ds-", "dx-", "cx-"],
55
+ /** How many distinct type sizes a project may spell before it has a scale problem. */
56
+ maxTypeSizes: 6,
57
+ /** Shadows are a deliberate, countable decision. System A shipped with exactly one. */
58
+ maxShadows: 1,
59
+ };
60
+
61
+ /**
62
+ * Every rule: `{ id, summary, property, test }`.
63
+ *
64
+ * `test(decl, config)` returns true when the declaration VIOLATES the rule.
65
+ * Keeping the predicate positive ("this is wrong") rather than negative reads
66
+ * the same way the finding does, which is a small thing that stops sign errors.
67
+ */
68
+ const RULES = [
69
+ {
70
+ id: "untokenised-radius",
71
+ property: PROP.RADIUS,
72
+ summary: "A radius spelled at the call site rather than taken from the dictionary.",
73
+ // Both mature systems here independently closed radius to exactly three
74
+ // semantic values (card, control, pill), and System A measured
75
+ // that radius had NOT drifted even while type and space had — 2 distinct
76
+ // values across 807 lines. So this is the cheapest rule to adopt and the
77
+ // one most likely to already pass.
78
+ test: (d) => !d.tokenised && !d.neutral && d.px !== null,
79
+ },
80
+ {
81
+ id: "untokenised-space",
82
+ property: PROP.SPACE,
83
+ summary: "A padding, margin or gap spelled at the call site.",
84
+ test: (d) => !d.tokenised && !d.neutral && d.px !== null,
85
+ },
86
+ {
87
+ id: "untokenised-type",
88
+ property: PROP.TYPE,
89
+ summary: "A font size spelled at the call site.",
90
+ test: (d) => !d.tokenised && !d.neutral && d.px !== null,
91
+ },
92
+ {
93
+ id: "untokenised-colour",
94
+ property: PROP.COLOR,
95
+ summary: "A colour literal, or a palette colour chosen at the call site.",
96
+ test: (d) => !d.tokenised && !d.neutral,
97
+ },
98
+ {
99
+ id: "arbitrary-shadow",
100
+ property: PROP.SHADOW,
101
+ summary: "A shadow that is not from the dictionary. Depth is a semantic decision, not a garnish.",
102
+ test: (d) => !d.tokenised && !d.neutral,
103
+ },
104
+ ];
105
+
106
+ export const RULES_BY_ID = new Map(RULES.map((r) => [r.id, r]));
107
+
108
+ /**
109
+ * Fixtures. Every rule needs both lists, and `loadRules` enforces it.
110
+ *
111
+ * The `bad` entries are deliberately spelled in DIFFERENT IDIOMS from each
112
+ * other. That is the point of the whole module: if `untokenised-radius` is
113
+ * tested only against `border-radius: 8px`, it is being validated against
114
+ * exactly the rule we are trying not to write again.
115
+ */
116
+ export const FIXTURES = {
117
+ "untokenised-radius": {
118
+ bad: [
119
+ { css: ".a { border-radius: 8px }" },
120
+ { source: '<div className="rounded-lg" />', filename: "a.tsx" },
121
+ { source: '<div className="rounded-3xl" />', filename: "a.tsx" }, // the spelling one hand-written audit missed
122
+ { source: "const s = { borderRadius: 8 }", filename: "a.ts" },
123
+ { source: '<div className="rounded-[7px]" />', filename: "a.tsx" },
124
+ ],
125
+ good: [
126
+ { css: ".a { border-radius: var(--radius-card) }" },
127
+ { css: ".a { border-radius: 0 }" },
128
+ { source: "const s = { borderRadius: radius.control }", filename: "a.ts" },
129
+ { source: '<div className="rounded-card" />', filename: "a.tsx" }, // project semantic radius
130
+ ],
131
+ },
132
+ "untokenised-space": {
133
+ bad: [
134
+ { css: ".a { padding: 12px }" },
135
+ { css: ".a { margin: 0 auto 12px }" }, // only the third value is wrong
136
+ { source: '<div className="px-4" />', filename: "a.tsx" },
137
+ { source: "const s = { gap: 16 }", filename: "a.ts" },
138
+ ],
139
+ good: [
140
+ { css: ".a { padding: var(--space-3) }" },
141
+ { css: ".a { padding: 0 var(--space-2) }" },
142
+ { css: ".a { margin: 0 }" },
143
+ ],
144
+ },
145
+ "untokenised-type": {
146
+ bad: [
147
+ { css: ".a { font-size: 13px }" },
148
+ { css: ".a { font-size: 1.125rem }" },
149
+ { source: '<span className="text-3xl" />', filename: "a.tsx" },
150
+ ],
151
+ good: [
152
+ { css: ".a { font-size: var(--text-body) }" },
153
+ { source: "const s = { fontSize: text.body }", filename: "a.ts" },
154
+ ],
155
+ },
156
+ "untokenised-colour": {
157
+ bad: [
158
+ { css: ".a { color: #ff0000 }" },
159
+ { css: ".a { background: rgb(12 14 16) }" },
160
+ { css: ".a { border-color: red }" },
161
+ { source: '<div className="bg-red-500" />', filename: "a.tsx" },
162
+ { source: 'el.style.background = "#fff"', filename: "a.ts" },
163
+ ],
164
+ good: [
165
+ { css: ".a { color: var(--color-text) }" },
166
+ { css: ".a { background: transparent }" },
167
+ { source: '<div className="bg-primary" />', filename: "a.tsx" }, // shadcn semantic utility
168
+ { source: '<div className="text-muted-foreground" />', filename: "a.tsx" },
169
+ { css: ":root { --color-surface: #0b0d10 }" }, // a second palette, legitimately
170
+ ],
171
+ },
172
+ "arbitrary-shadow": {
173
+ bad: [
174
+ { css: ".a { box-shadow: 0 1px 2px rgba(0,0,0,.2) }" },
175
+ { source: '<div className="shadow-lg" />', filename: "a.tsx" },
176
+ ],
177
+ good: [
178
+ { css: ".a { box-shadow: var(--shadow-overlay) }" },
179
+ { css: ".a { box-shadow: none }" },
180
+ ],
181
+ },
182
+ };
183
+
184
+ /**
185
+ * Load the rules, refusing any that is not proved in both directions.
186
+ *
187
+ * Throwing rather than warning is deliberate. A warning at load time is read
188
+ * once and then lives in a log nobody opens; the failure mode we are guarding
189
+ * against is precisely a rule that is present, believed, and not working.
190
+ */
191
+ export function loadRules({ fixtures = FIXTURES } = {}) {
192
+ const missing = [];
193
+ for (const rule of RULES) {
194
+ const f = fixtures[rule.id];
195
+ if (!f || !Array.isArray(f.bad) || f.bad.length === 0) missing.push(`${rule.id}: no failing fixture`);
196
+ if (!f || !Array.isArray(f.good) || f.good.length === 0) missing.push(`${rule.id}: no passing fixture`);
197
+ }
198
+ if (missing.length) {
199
+ throw new Error(
200
+ "Refusing to load rules that are not proved in both directions:\n " + missing.join("\n ") +
201
+ "\nA rule with no failing fixture reports zero and looks identical to a rule that works.",
202
+ );
203
+ }
204
+ return RULES;
205
+ }
206
+
207
+ /**
208
+ * Run every rule over a set of normalised declarations.
209
+ *
210
+ * `classification` decides whether findings apply: the design system's own
211
+ * source defines the tokens rather than consuming them, and foreign-content
212
+ * containment is not a visual vocabulary. Both are reported in their own
213
+ * buckets rather than filtered away, so the denominator never moves silently.
214
+ */
215
+ export function run(decls, { config = DEFAULTS, classification = CLASSIFICATION.PRODUCT } = {}) {
216
+ const rules = loadRules();
217
+ const findings = [];
218
+
219
+ if (classification === CLASSIFICATION.PRODUCT) {
220
+ for (const d of decls) {
221
+ for (const rule of rules) {
222
+ if (rule.property !== d.property) continue;
223
+ if (!rule.test(d, config)) continue;
224
+ findings.push({
225
+ rule: rule.id,
226
+ property: d.property,
227
+ raw: d.raw,
228
+ line: d.line ?? null,
229
+ source: d.source,
230
+ // Carried through so a reviewer can tell a measured violation from an
231
+ // inferred one. A finding derived from an assumed Tailwind scale is
232
+ // still a finding, but it is not the same evidence as a literal.
233
+ confidence: d.confidence,
234
+ scaleHint: d.scaleHint,
235
+ });
236
+ }
237
+ }
238
+ }
239
+
240
+ return { classification, findings, counted: decls.length };
241
+ }
242
+
243
+ /**
244
+ * Coverage: which rules matched nothing anywhere.
245
+ *
246
+ * "A rule that matched zero files across an entire repository is reported as
247
+ * SUSPICIOUS, not as clean." This is the direct countermeasure to the
248
+ * failure, where `no-rounded-lg` reported zero against 178 real hits and nobody
249
+ * could tell. Silence and success must not render the same.
250
+ */
251
+ export function coverage(allFindings, { rules = loadRules() } = {}) {
252
+ const seen = new Set(allFindings.map((f) => f.rule));
253
+ return rules.map((r) => ({
254
+ rule: r.id,
255
+ matched: seen.has(r.id),
256
+ verdict: seen.has(r.id) ? "firing" : "suspicious — matched nothing; verify it can still match at all",
257
+ }));
258
+ }
package/scan.mjs ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * G-51/G-52 — walk a project, normalise it, run the rules, report honestly.
3
+ *
4
+ * THE MEASUREMENT BUGS THIS IS BUILT AGAINST.
5
+ *
6
+ * Both prior implementations shipped a correct rule set and still reported the
7
+ * wrong number, twice, for reasons that had nothing to do with the rules:
8
+ *
9
+ * - System B measured only `apps/pubs/src` for a long stretch, then widened. Adding
10
+ * a newly-measured app moved the headline DOWN while no file got worse, so
11
+ * their gate had to compare per-app rather than on the average.
12
+ * - `apps/map` and `apps/pints` reported **0% adoption** while composing a
13
+ * shared package that renders design-system UI, because the import graph was
14
+ * resolved at barrel level rather than per name.
15
+ *
16
+ * Both are scope errors, not rule errors. So this module's contract is that
17
+ * **scope is always explicit and always reported**: every file walked is
18
+ * counted, every file skipped is named with a reason, and a percentage is never
19
+ * emitted without the denominator beside it.
20
+ */
21
+
22
+ import { readFileSync, readdirSync, statSync } from "node:fs";
23
+ import { join, relative, extname, sep } from "node:path";
24
+
25
+ import { normaliseSource } from "./normalise.mjs";
26
+ import { run, coverage, CLASSIFICATION, DEFAULTS, loadRules } from "./rules.mjs";
27
+ import { optionalProps, requiredNullableGuards, RULE as OPTIONAL_PROP_RULE, GUARD_RULE } from "./props.mjs";
28
+ import { orphanedParts, RULE as COMPOUND_RULE } from "./compound.mjs";
29
+ import { stalePaths, rootDirectories, PATH_RULE } from "./docdrift.mjs";
30
+ import { markupAdoption } from "./markup.mjs";
31
+ import { reconcile, formatAdoption } from "./adoption.mjs";
32
+ import { tailwindInCss } from "./tailwind.mjs";
33
+
34
+ const SKIP_DIRS = new Set([
35
+ "node_modules", "dist", "build", ".next", ".turbo", ".git", "coverage",
36
+ "test-results", "__pycache__", ".venv", "e2e", "fixtures", "snapshots",
37
+ ]);
38
+
39
+ const READ = new Set([".css", ".scss", ".ts", ".tsx", ".js", ".jsx", ".mjs"]);
40
+
41
+ /** A file whose string literals are source for a DIFFERENT repository. */
42
+ const TEMPLATE_MARKER = "@gyde-emits-source-for-another-repo";
43
+
44
+ /**
45
+ * A test file describes behaviour; it is not a surface anybody sees.
46
+ *
47
+ * `[jt]sx?` missed `.mjs` and `.cjs`, so `foo.test.mjs` was walked as product
48
+ * code. It cost nothing while the rules only read styling — a test file has
49
+ * little — and cost forty false findings the moment a rule started reading
50
+ * string literals, because a test's fixtures are the one place paths and
51
+ * component APIs are written to be wrong on purpose.
52
+ */
53
+ const isTest = (p) => /\.(test|spec)\.(?:[cm]?[jt]sx?)$/.test(p) || /__tests__/.test(p);
54
+
55
+ /**
56
+ * How a file is classified, and why the answer must be declared rather than guessed.
57
+ *
58
+ * `system` and `foreign` both suppress findings, so a wrong classification is a
59
+ * silent pass — the failure mode this whole product exists to prevent. They are
60
+ * therefore taken from configuration the project commits, never inferred from a
61
+ * path heuristic, and every classified path is echoed in the report so a
62
+ * reviewer can see what was excused and disagree with it.
63
+ */
64
+ export function classify(relPath, config) {
65
+ const match = (globs = []) => globs.some((g) => {
66
+ if (g.endsWith("/**")) return relPath.startsWith(g.slice(0, -3));
67
+ if (g.endsWith("/")) return relPath.startsWith(g);
68
+ return relPath === g || relPath.startsWith(g + "/");
69
+ });
70
+
71
+ if (match(config.systemPaths)) return CLASSIFICATION.SYSTEM;
72
+ if (match(config.foreignPaths)) return CLASSIFICATION.FOREIGN;
73
+ return CLASSIFICATION.PRODUCT;
74
+ }
75
+
76
+ function walk(dir, root, out) {
77
+ let entries;
78
+ try { entries = readdirSync(dir); } catch { return; }
79
+ for (const name of entries) {
80
+ if (SKIP_DIRS.has(name) || name.startsWith(".")) continue;
81
+ const full = join(dir, name);
82
+ let st;
83
+ try { st = statSync(full); } catch { continue; }
84
+ if (st.isDirectory()) { walk(full, root, out); continue; }
85
+ if (!READ.has(extname(name))) continue;
86
+ if (isTest(full)) continue;
87
+ out.push(relative(root, full).split(sep).join("/"));
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Scan a directory tree.
93
+ *
94
+ * `config.systemPaths` / `config.foreignPaths` are project configuration: the
95
+ * design system's own source, and the places that legitimately contain styling
96
+ * for content we do not control. Everything else is product code.
97
+ */
98
+ export function scan(root, { config = {}, ruleConfig = DEFAULTS } = {}) {
99
+ const cfg = { systemPaths: [], foreignPaths: [], componentRoots: null, ...config };
100
+
101
+ const files = [];
102
+ walk(root, root, files);
103
+ files.sort();
104
+
105
+ // G-70. Computed once: the anchor for deciding whether a path-shaped string
106
+ // is a claim about THIS repository.
107
+ const rootDirs = rootDirectories(root);
108
+ let pathsSkipped = 0;
109
+
110
+ // G-73. Collected during the walk rather than re-read afterwards, so the
111
+ // scope decisions above — product code only, no templates written for another
112
+ // repository — are made once and cannot drift between the two adoption
113
+ // numbers. A metric measured over a different file set than the one it is
114
+ // printed beside is the scope error this module's header exists about.
115
+ const markupFiles = [];
116
+
117
+ // G-98. Counted here because normalise.mjs is the one place that knows what a
118
+ // Tailwind utility is. Counting them again in tailwind.mjs would be a second
119
+ // implementation of the same question, free to disagree with the first.
120
+ let tailwindClasses = 0;
121
+ let tailwindCssDirectives = 0;
122
+
123
+ const findings = [];
124
+ const perFile = [];
125
+ const byClassification = { system: 0, foreign: 0, product: 0 };
126
+
127
+ for (const rel of files) {
128
+ let text;
129
+ try { text = readFileSync(join(root, rel), "utf8"); } catch { continue; }
130
+
131
+ const classification = classify(rel, cfg);
132
+ byClassification[classification]++;
133
+
134
+ // G-68. Before the styling short-circuit below: a file can declare a
135
+ // component API and contain no styling at all, and that file is exactly
136
+ // where a Props type lives.
137
+ // A generator holds another repository's source as string data. Reading it
138
+ // as this repository's own code is how Gyde scanning itself reported ten
139
+ // optional props that live in its emitted TEMPLATES, and thirty-six stale
140
+ // paths that resolve perfectly well in the repository they are written for.
141
+ //
142
+ // The marker is the convention paths.test.mjs and clientboundary.mjs
143
+ // already use. It is an opt-out a file must DECLARE, not a heuristic — and
144
+ // it does not weaken the checks in a consumer repository, where the
145
+ // emitted components are real files that say no such thing.
146
+ const isTemplateSource = text.includes(TEMPLATE_MARKER);
147
+
148
+ if (classification === CLASSIFICATION.PRODUCT && !isTemplateSource) {
149
+ for (const f of optionalProps(text, { filename: rel })) findings.push(f);
150
+ // G-69. The defect G-68 creates. The ban pushes optional props towards
151
+ // required-nullable, and every guard already written against undefined
152
+ // becomes always-true at that moment, with no compiler error.
153
+ for (const f of requiredNullableGuards(text, { filename: rel })) findings.push(f);
154
+ // G-72. `componentRoots` is the design system's own exports, passed in
155
+ // rather than read here — scan walks a tree, it does not resolve barrels.
156
+ // Absent, the check is skipped rather than run against every `Foo.Bar` in
157
+ // JSX position, because a rule with no allow-list here would be guessing.
158
+ if (cfg.componentRoots) {
159
+ for (const f of orphanedParts(text, { filename: rel, roots: cfg.componentRoots })) findings.push(f);
160
+ }
161
+ // G-70, second half. A path rendered to a reader is documentation
162
+ // whatever file it lives in — a consumer lost six of twenty registry
163
+ // entries to a migration because their doc gate only read markdown.
164
+ const paths = stalePaths(root, rel, text, { rootDirs });
165
+ for (const f of paths.found) findings.push(f);
166
+ pathsSkipped += paths.skipped.length;
167
+ if (/\.(tsx|jsx)$/.test(rel)) markupFiles.push({ file: rel, text });
168
+ }
169
+
170
+ const decls = normaliseSource(text, { filename: rel });
171
+ if (decls.length === 0) continue;
172
+
173
+ for (const d of decls) if (d.source === "tailwind") tailwindClasses++;
174
+ // G-98, fourth level. A stylesheet can carry the whole vocabulary with no
175
+ // class in any component, and it breaks on removal the same silent way.
176
+ if (/\.(css|scss)$/.test(rel)) tailwindCssDirectives += tailwindInCss(text, { filename: rel }).length;
177
+
178
+ const result = run(decls, { config: ruleConfig, classification });
179
+ const tokenised = decls.filter((d) => d.tokenised || d.neutral).length;
180
+
181
+ perFile.push({
182
+ file: rel,
183
+ classification,
184
+ declarations: decls.length,
185
+ tokenised,
186
+ findings: result.findings.length,
187
+ });
188
+ for (const f of result.findings) findings.push({ ...f, file: rel });
189
+ }
190
+
191
+ const product = perFile.filter((f) => f.classification === CLASSIFICATION.PRODUCT);
192
+ const declarations = product.reduce((n, f) => n + f.declarations, 0);
193
+ const tokenised = product.reduce((n, f) => n + f.tokenised, 0);
194
+
195
+ return {
196
+ root,
197
+ // Scope, always reported beside the numbers it produced.
198
+ scope: {
199
+ filesWalked: files.length,
200
+ filesWithStyling: perFile.length,
201
+ byClassification,
202
+ systemPaths: cfg.systemPaths,
203
+ foreignPaths: cfg.foreignPaths,
204
+ },
205
+ // Adoption as a fraction with its denominator visible, never a bare percent.
206
+ adoption: { tokenised, of: declarations, percent: declarations ? Math.round((tokenised / declarations) * 100) : null },
207
+ /**
208
+ * G-73. A different question, deliberately reported separately rather than
209
+ * folded into the number above.
210
+ *
211
+ * Declaration adoption asks what fraction of style decisions are tokenised.
212
+ * A page built entirely from bare divs with semantic utility classes scores
213
+ * beautifully on it and is not using the component set at all. Markup
214
+ * adoption asks what fraction of the elements that made a visual decision
215
+ * are the system's.
216
+ *
217
+ * Neither is wrong and neither replaces the other. Averaging them would
218
+ * produce a third number describing nothing. Reconciling them is G-74.
219
+ */
220
+ markup: markupAdoption(markupFiles, { systemNames: cfg.componentRoots }),
221
+ findings,
222
+ /**
223
+ * Every rule this scan RAN, whether or not it found anything.
224
+ *
225
+ * The ledger stamps this (G-68), and the first version stamped
226
+ * `loadRules()` instead — which knows only the five Decl rules. The two
227
+ * Props rules were therefore never in the stamp, so they were unknown to
228
+ * every ledger, adopted on every run, and could never ratchet. A rule that
229
+ * reports forever and gates never is the exact silence this product
230
+ * exists to prevent, and it was introduced by the mechanism meant to let
231
+ * rules arrive safely.
232
+ *
233
+ * Derived here rather than assembled by the caller, so a rule added to
234
+ * this function cannot be left out of the stamp.
235
+ */
236
+ rulesRun: [...new Set([
237
+ ...loadRules().map((r) => r.id),
238
+ OPTIONAL_PROP_RULE, GUARD_RULE,
239
+ // Only when it actually ran. Stamping a rule the scan SKIPPED is the same
240
+ // bug in reverse: the ledger would claim to know it, so the day roots
241
+ // become available its first finding fails instead of adopting — the
242
+ // surprise red build the stamp exists to prevent.
243
+ PATH_RULE,
244
+ ...(cfg.componentRoots ? [COMPOUND_RULE] : []),
245
+ ])].sort(),
246
+ pathsSkipped,
247
+ tailwindClasses,
248
+ tailwindCssDirectives,
249
+ byRule: tally(findings, (f) => f.rule),
250
+ byFile: tally(findings, (f) => f.file),
251
+ // Which rules never fired. Silence and success must not render the same.
252
+ coverage: coverage(findings),
253
+ perFile,
254
+ };
255
+ }
256
+
257
+ function tally(items, key) {
258
+ const out = {};
259
+ for (const i of items) out[key(i)] = (out[key(i)] || 0) + 1;
260
+ return Object.fromEntries(Object.entries(out).sort((a, b) => b[1] - a[1]));
261
+ }
262
+
263
+ /**
264
+ * A short human report.
265
+ *
266
+ * Prints the denominator, the classified-out paths, and the rules that matched
267
+ * nothing — in that order, because those are the three things that make a
268
+ * headline number either trustworthy or meaningless.
269
+ */
270
+ export function format(result) {
271
+ const L = [];
272
+ const { scope, adoption } = result;
273
+ L.push(`scope ${scope.filesWithStyling} files with styling, of ${scope.filesWalked} walked`);
274
+ L.push(` product ${scope.byClassification.product}, system ${scope.byClassification.system}, foreign ${scope.byClassification.foreign}`);
275
+ // G-74. One figure, defined as the weakest layer, with both layers under it.
276
+ // Printing two headline percentages was the state this replaces: a reader had
277
+ // to decide which one meant "adopted", and two teams reading the same output
278
+ // reached different answers.
279
+ L.push(formatAdoption(reconcile({
280
+ token: { ...adoption, used: adoption.tokenised },
281
+ markup: result.markup?.unknown ? null : result.markup?.adoption,
282
+ })));
283
+ L.push(`findings ${result.findings.length}`);
284
+ for (const [rule, n] of Object.entries(result.byRule)) L.push(` ${String(n).padStart(5)} ${rule}`);
285
+ const silent = result.coverage.filter((c) => !c.matched);
286
+ if (silent.length) {
287
+ L.push(`suspicious ${silent.length} rule(s) matched nothing anywhere:`);
288
+ for (const s of silent) L.push(` ${s.rule}`);
289
+ }
290
+ return L.join("\n");
291
+ }