roast-my-design-system 9.2.0 → 9.2.1

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/README.md CHANGED
@@ -17,7 +17,7 @@ npx roast-my-design-system@latest
17
17
  Run it at the root of a UI repo. One second later, a report opens.
18
18
  No account. No network. No telemetry. Nothing in your repo changes.
19
19
 
20
- **Current release: 9.1.** Version history in [CHANGELOG.md](CHANGELOG.md).
20
+ **Current release: 9.2.** Version history in [CHANGELOG.md](CHANGELOG.md).
21
21
 
22
22
  ## The idea
23
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "9.2.0",
3
+ "version": "9.2.1",
4
4
  "mcpName": "io.github.gregkozakiewicz/roast-my-design-system",
5
5
  "description": "Your AI can write the UI. This makes sure it writes your UI. A deterministic scanner scores your design system 0-100 against 112 public repos, reads React and web components (Stencil, Lit), hands you a copy-paste fix prompt for each top finding, writes rules for Claude, Cursor, Copilot and Windsurf with --apply, and runs as a local MCP server with --mcp.",
6
6
  "keywords": [
@@ -158,7 +158,10 @@ export function definedComponents(src) {
158
158
  return [...names];
159
159
  }
160
160
 
161
- const looksLikeJSXFile = (src) => /<[A-Za-z][\w.]*[\s/>]/.test(src);
161
+ // A file with no markup defines no component the report can count: a stub
162
+ // returning null, a wrapper built with a factory call. Asked by the harvest
163
+ // and by the live checks, so both read the same files as component files.
164
+ export const looksLikeJSXFile = (src) => /<[A-Za-z][\w.]*[\s/>]/.test(src);
162
165
 
163
166
  // ---------- web components: the tag-registered world ----------
164
167
  // Stencil, Lit and raw customElements.define register components by TAG, and
@@ -241,6 +244,28 @@ function importedDef(src, file, name, defs, workspaces = [], aliases = []) {
241
244
  return null;
242
245
  }
243
246
 
247
+ const JSX_FILE_RE = /\.(tsx|jsx|js)$/;
248
+
249
+ /**
250
+ * Every component a file defines, by either door the harvest reads: a React
251
+ * component in a file with markup in it, and a web component registered by
252
+ * tag (Stencil, Lit, customElements.define), generated bindings left out.
253
+ * The live checks ask here, so a file defines for them what it defines for
254
+ * the report.
255
+ */
256
+ export function componentNamesIn(src, file = null) {
257
+ // .js too, never .ts: a TypeScript generic reads like a tag, and the
258
+ // harvest takes React components from the same three extensions
259
+ const react = (!file || JSX_FILE_RE.test(file)) && looksLikeJSXFile(src) ? definedComponents(src) : [];
260
+ const web = GENERATED_RE.test(src.slice(0, 2000)) ? [] : webComponentDefs(src).map((d) => d.name);
261
+ return [...new Set([...react, ...web])];
262
+ }
263
+
264
+ // A page is a route, not a reusable part. Asked by the harvest for every file
265
+ // it reads and by the live checks for the one file under review.
266
+ export const isPageFile = (file) => /(^|\/)(app\/.*page|pages\/(?!_app|_document|api))/.test(file)
267
+ || /(^|\/)app\/.*\/(layout|template|error|loading)\.(tsx|jsx)$/.test(file);
268
+
244
269
  /**
245
270
  * Harvest all components: definitions (with variants/props) + usages across the
246
271
  * repo. `codeFiles` are relative paths; returns { components, totalDefined }.
@@ -248,7 +273,7 @@ function importedDef(src, file, name, defs, workspaces = [], aliases = []) {
248
273
  export function harvestComponents(root, codeFiles) {
249
274
  // .js too — CRA-era repos (a big slice of the messy-repo audience) put JSX in
250
275
  // plain .js files; requiring .tsx/.jsx made whole repos read as empty.
251
- const jsxFiles = codeFiles.filter((f) => /\.(tsx|jsx|js)$/.test(f));
276
+ const jsxFiles = codeFiles.filter((f) => JSX_FILE_RE.test(f));
252
277
  const sources = new Map();
253
278
  for (const f of jsxFiles) {
254
279
  const src = readSource(join(root, f));
@@ -259,8 +284,7 @@ export function harvestComponents(root, codeFiles) {
259
284
  const components = [];
260
285
  for (const [file, src] of sources) {
261
286
  if (!looksLikeJSXFile(src)) continue;
262
- const isPage = /(^|\/)(app\/.*page|pages\/(?!_app|_document|api))/.test(file)
263
- || /(^|\/)app\/.*\/(layout|template|error|loading)\.(tsx|jsx)$/.test(file);
287
+ const isPage = isPageFile(file);
264
288
  for (const name of definedComponents(src)) {
265
289
  components.push({
266
290
  name,
@@ -54,8 +54,59 @@ const ROUTE_FRAGMENT_RE = /(^|\/)app\/.*\/(form|header|footer|nav|page|layout|lo
54
54
  // and _dashboard.analytics.tsx both export Page, 2026-09-17). A story is a
55
55
  // demo of a component, not a second implementation of it (medplum keeps a
56
56
  // MockDateWrapper in two stories folders).
57
- const ROUTE_FILE_RE = /(^|\/)routes\/[^/]+\.[jt]sx?$|(^|\/)(page|layout|route|loading|error|not-found|template|default)\.[jt]sx?$/;
57
+ // Next.js's crash page is one more of these: every app in a monorepo has its
58
+ // own global-error, and a starter keeps a second one in its templates folder
59
+ // (9.2.1).
60
+ const ROUTE_FILE_RE = /(^|\/)routes\/[^/]+\.[jt]sx?$|(^|\/)(page|layout|route|loading|error|global-error|not-found|template|default)\.[jt]sx?$/;
58
61
  const STORY_FILE_RE = /(^|\/)(stories|__stories__|\.storybook)\/|\.stories\.[jt]sx?$/;
62
+ // Email templates legitimately mirror web component names (email Footer !=
63
+ // web Footer); cross-matching them manufactures duplicates. An email folder
64
+ // or file is one whose name carries "email" as a word of its own: emails/,
65
+ // email-templates/, twenty-emails/, welcome-email.tsx. A screen about email
66
+ // (ConfirmUserEmail, EmailInput) is interface and stays in (9.2.1: the rule
67
+ // asked for the word at the start of the name and missed twenty-emails/).
68
+ const EMAIL_PATH_RE = /(^|\/)(?:[a-z0-9]+[-_.])*emails?(?:[-_][a-z0-9]+)*(?:\/|\.[jt]sx?$)/i;
69
+ // Same-name components living in different icons/ directories are ONE
70
+ // problem — two icon libraries colliding — not N separate duplicates.
71
+ const ICON_DIR_RE = /(^|\/)icons?\//i;
72
+
73
+ /**
74
+ * Whether a definition can be a duplicate at all. The report's finder and the
75
+ * live checks both ask here, so a name the report lets repeat by design is
76
+ * never a finding on the file an agent just wrote (9.2.1: every TanStack
77
+ * route file was told its Route already existed in 40 places).
78
+ */
79
+ export function canBeDuplicate({ name, file = '', isPage = false }) {
80
+ return !isPage && !NOT_DUPLICATES.has(name)
81
+ && !ROUTE_FRAGMENT_RE.test(file) && !ROUTE_FILE_RE.test(file)
82
+ && !STORY_FILE_RE.test(file) && !EMAIL_PATH_RE.test(file);
83
+ }
84
+
85
+ /**
86
+ * The other copies that make one definition a duplicate, or none. The live
87
+ * checks and the guard ask here, so their answer is the report's answer.
88
+ *
89
+ * A file the scan has read is settled by the report: the definition is a
90
+ * duplicate when the report counts the name and this file is one of the
91
+ * copies it counts. A wrapper, shadcn's own overlap inside the catalogue and
92
+ * two icon libraries colliding are listed by the report and never counted,
93
+ * so they are not findings on the file either. A file the scan has not read
94
+ * (content validated before it is saved) is judged on the copies that exist.
95
+ *
96
+ * @param def { name, file, isPage } the definition under review
97
+ * @param copies the ledger's entries for the name: [{ file, isPage, usageCount }]
98
+ * @param counted the files of the duplicate the report counts under the name,
99
+ * or null when it counts none
100
+ * @param same how two paths are compared (a guard matches on path tails)
101
+ */
102
+ export function duplicateCopies({ name, file = '', isPage = false }, copies, counted = null, same = (a, b) => a === b) {
103
+ if (!canBeDuplicate({ name, file, isPage })) return [];
104
+ const others = (copies ?? []).filter((c) => !same(c.file, file) && canBeDuplicate({ ...c, name }));
105
+ if (!others.length) return [];
106
+ if ((copies ?? []).some((c) => same(c.file, file))) return (counted ?? []).some((f) => same(f, file)) ? others : [];
107
+ if (ICON_DIR_RE.test(file) && others.every((c) => ICON_DIR_RE.test(c.file))) return [];
108
+ return others;
109
+ }
59
110
 
60
111
  /**
61
112
  * @param components output of harvestComponents (non-page components matter most)
@@ -66,12 +117,7 @@ const STORY_FILE_RE = /(^|\/)(stories|__stories__|\.storybook)\/|\.stories\.[jt]
66
117
  */
67
118
  export function findDuplicates(components, uiDir = null, root = null, uiDirs = null) {
68
119
  const catalogueDirs = uiDirs ?? (uiDir ? [uiDir] : []);
69
- // Email templates legitimately mirror web component names (email Footer !=
70
- // web Footer); cross-matching them manufactures duplicates.
71
- const EMAIL_PATH_RE = /(^|\/)emails?(\/|-)/i;
72
- const comps = components.filter((c) => !c.isPage && !NOT_DUPLICATES.has(c.name)
73
- && !ROUTE_FRAGMENT_RE.test(c.file) && !ROUTE_FILE_RE.test(c.file)
74
- && !STORY_FILE_RE.test(c.file) && !EMAIL_PATH_RE.test(c.file));
120
+ const comps = components.filter(canBeDuplicate);
75
121
 
76
122
  // 1. exact same component name defined in >1 file
77
123
  const byName = new Map();
@@ -88,9 +134,6 @@ export function findDuplicates(components, uiDir = null, root = null, uiDirs = n
88
134
  }))
89
135
  .sort((a, b) => b.files.length - a.files.length);
90
136
 
91
- // Same-name components living in different icons/ directories are ONE
92
- // problem — two icon libraries colliding — not N separate duplicates.
93
- const ICON_DIR_RE = /(^|\/)icons?\//i;
94
137
  const iconCollisions = exactDuplicates.filter((d) => d.files.every((f) => ICON_DIR_RE.test(f)));
95
138
  exactDuplicates = exactDuplicates.filter((d) => !iconCollisions.includes(d));
96
139
 
@@ -35,6 +35,21 @@ export { extractStyling, normalizeHex, isGrey, hexRgb, typefaceOf, GENERIC_FONTS
35
35
  // The components a piece of text defines. With the ledger below, a guard can
36
36
  // tell a second <Button> from an edit to the first one.
37
37
  export { definedComponents };
38
+ // Duplicates, by the report's own answer (9.2.1). duplicateCopies(def,
39
+ // copies, counted, same) returns the other copies that make one definition a
40
+ // duplicate, or none: `copies` is the ledger's entries for the name
41
+ // (learnSystem's components), `counted` the files of the duplicate the report
42
+ // counts under it (the copies in learnSystem's duplicates), `same` how two
43
+ // paths compare. canBeDuplicate({ name, file, isPage }) is the first question
44
+ // it asks: a framework's Route, a page, a route file, the crash page, a story
45
+ // and an email template repeat by design. isPageFile(file) answers isPage for
46
+ // a file not in the ledger. componentNamesIn(text, file) lists what a whole
47
+ // file defines for the report: React components in a .tsx, .jsx or .js file
48
+ // with markup in it, and web components registered by tag. The exemptions
49
+ // (exemptReason) are about styling, so a duplicate is still a duplicate in
50
+ // an exempt file.
51
+ export { canBeDuplicate, duplicateCopies } from '../harvest/duplicates.mjs';
52
+ export { isPageFile, looksLikeJSXFile, componentNamesIn } from '../harvest/components.mjs';
38
53
 
39
54
  // The files no checker should judge. Exported so a guard reads the same list
40
55
  // as the engine rather than keeping a copy that drifts.
@@ -115,7 +130,7 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
115
130
  // the same two lists the MCP knowledge builds (mcp/knowledge.mjs), from the
116
131
  // same helpers, for tokenTwinFindings and avoidedImportFindings (8.6.1)
117
132
  const read = (f) => readSource(join(repoRoot, f));
118
- const hardDupes = (findDuplicates(ledger, profile.uiDir, repoRoot).exactDuplicates ?? []).filter((d) => !d.wrapped);
133
+ const hardDupes = (findDuplicates(ledger, profile.uiDir, repoRoot, profile.uiDirs ?? null).exactDuplicates ?? []).filter((d) => !d.wrapped);
119
134
  const P = profileOf(profile);
120
135
  const installedNow = installedDirs(P);
121
136
  const charts = chartSystemOf(files, read, { own: (f) => !installedNow.some((d) => f === d || f.startsWith(`${d}/`)) });
@@ -1,7 +1,7 @@
1
1
  // Single version constant for the engine — imported by diagnose (report
2
2
  // footer) and rules (generated-by line). This is the bump spot that used to
3
3
  // live as a const inside diagnose/index.mjs.
4
- export const VERSION = '9.2.0';
4
+ export const VERSION = '9.2.1';
5
5
  // The shape of harvest.json and summary.json. Bumped only when a field is
6
6
  // renamed, removed or changes meaning; a new field is not a new schema. A
7
7
  // tool comparing two scans compares like with like by this number, not by VERSION.
@@ -11,7 +11,8 @@
11
11
  * in the repo means saying so, not inventing one.
12
12
  */
13
13
  import { extractStyling } from '../harvest/tokens.mjs';
14
- import { definedComponents } from '../harvest/components.mjs';
14
+ import { componentNamesIn, isPageFile } from '../harvest/components.mjs';
15
+ import { duplicateCopies } from '../harvest/duplicates.mjs';
15
16
  import { hexRgb } from '../lib/nearpairs.mjs';
16
17
  import { exemptReason } from '../lib/exempt.mjs';
17
18
  import { extraDeclarations, fontDeclarations } from '../lib/declarations.mjs';
@@ -83,6 +84,29 @@ function nearestToken(value, k) {
83
84
  return best;
84
85
  }
85
86
 
87
+ /**
88
+ * The report's answer on duplicates, asked of one file: a name that repeats
89
+ * by design, a pair the report lists without counting and a file that
90
+ * defines nothing the report reads are not duplicates here either
91
+ * (harvest/duplicates.mjs).
92
+ */
93
+ function duplicateFindings({ text, file, k, add }) {
94
+ for (const name of componentNamesIn(text, file)) {
95
+ const dupe = k.dupeByName.get(name);
96
+ const counted = dupe ? dupe.files.map((f) => (typeof f === 'string' ? f : f.file)) : null;
97
+ const existing = duplicateCopies({ name, file: file ?? '', isPage: !!file && isPageFile(file) }, k.byName.get(name), counted);
98
+ if (!existing.length) continue;
99
+ // the scan may already include this very file — count the OTHER copies
100
+ const otherCopies = counted ? counted.filter((f) => f !== file) : null;
101
+ const best = [...existing].sort((a, b) => b.usageCount - a.usageCount)[0];
102
+ add('duplicate-component', 'violation', text.indexOf(name),
103
+ otherCopies && otherCopies.length > 1
104
+ ? `Defines <${name}>, which already exists in ${otherCopies.length} other places. This makes ${otherCopies.length + 1} competing copies, and every wrong pick becomes the example the next agent copies.`
105
+ : `Defines <${name}>, but ${best.file} already defines it${best.usageCount ? ` (used ${best.usageCount}x)` : ''}.`,
106
+ `Import ${best.file} instead of creating a copy.`);
107
+ }
108
+ }
109
+
86
110
  /**
87
111
  * Validate one piece of content against the repo's knowledge.
88
112
  * @param content { text, file?, before? } — file name decides css-vs-code
@@ -91,7 +115,8 @@ function nearestToken(value, k) {
91
115
  * unknown), so a token or an import the change ADDS can be told from one
92
116
  * that was already there
93
117
  * @returns { findings: [{ rule, severity, line, message, fix? }], checked,
94
- * exempt? } — exempt is a sentence saying why the file was not judged
118
+ * exempt? } — exempt is a sentence saying why the file's styling was not
119
+ * judged; its findings are then duplicates only, and checked says so
95
120
  */
96
121
  export function validateContent(content, k) {
97
122
  const { text, file = null, before } = content;
@@ -99,13 +124,20 @@ export function validateContent(content, k) {
99
124
  // checker earns its reputation for crying wolf, and a checker people
100
125
  // distrust gets switched off. Say nothing, and say why nothing was said.
101
126
  const exempt = exemptReason(file, text);
102
- if (exempt) return { findings: [], checked: checksFor(k), exempt };
103
127
  const css = file ? CSS_FILE_RE.test(file) : looksLikeCss(text);
104
- const got = extractStyling(text, { css });
105
128
  const findings = [];
106
129
  const add = (rule, severity, index, message, fix) => findings.push({
107
130
  rule, severity, line: lineOf(text, index), message, ...(fix ? { fix } : {}),
108
131
  });
132
+ // The exemption is about styling: what an email, a drawing or a crash page
133
+ // cannot take from the system. A second copy of a component is a second
134
+ // copy in any medium, and the report counts it, so that one check still
135
+ // runs and the result says it was the only one.
136
+ if (exempt) {
137
+ if (!css) duplicateFindings({ text, file, k, add });
138
+ return { findings, checked: css ? [] : ['duplicate component definitions'], exempt };
139
+ }
140
+ const got = extractStyling(text, { css });
109
141
 
110
142
  // The knowledge scan includes the working tree, so a file under review has
111
143
  // already leaked its own values into the repo counts — without a discount,
@@ -258,9 +290,13 @@ export function validateContent(content, k) {
258
290
  // a raw value the repo already uses, in a non-Tailwind repo, is consistency,
259
291
  // not a new offence — silence
260
292
  }
293
+ // Bracket values inside installed code (the shadcn catalogue, kit blocks,
294
+ // registries) are the kit's own choices. The report names them and keeps
295
+ // them out of the count; a file under review is judged by the same line.
296
+ const installed = !!file && (k.installedDirs ?? []).some((d) => file === d || file.startsWith(`${d}/`));
261
297
  const localArb = new Map();
262
298
  for (const a of got.arbitrary) localArb.set(a.value, (localArb.get(a.value) ?? 0) + 1);
263
- for (const a of got.arbitrary) {
299
+ for (const a of installed ? [] : got.arbitrary) {
264
300
  const repoArb = (k.tokens.tailwind?.arbitrary ?? []).find((x) => x.value === a.value);
265
301
  const priorArb = discount(repoArb?.count, localArb, a.value);
266
302
  add('arbitrary-value', 'violation', a.index,
@@ -345,23 +381,7 @@ export function validateContent(content, k) {
345
381
  }
346
382
 
347
383
  // ---------- duplicate components ----------
348
- if (!css) {
349
- for (const name of definedComponents(text)) {
350
- const existing = (k.byName.get(name) ?? []).filter((c) => !file || c.file !== file);
351
- if (!existing.length) continue;
352
- const dupe = k.dupeByName.get(name);
353
- // the scan may already include this very file — count the OTHER copies
354
- const otherCopies = dupe
355
- ? dupe.files.map((f) => (typeof f === 'string' ? f : f.file)).filter((f) => f !== file)
356
- : null;
357
- const best = [...existing].sort((a, b) => b.usageCount - a.usageCount)[0];
358
- add('duplicate-component', 'violation', text.indexOf(name),
359
- otherCopies && otherCopies.length > 1
360
- ? `Defines <${name}>, which already exists in ${otherCopies.length} other places. This makes ${otherCopies.length + 1} competing copies, and every wrong pick becomes the example the next agent copies.`
361
- : `Defines <${name}>, but ${best.file} already defines it${best.usageCount ? ` (used ${best.usageCount}x)` : ''}.`,
362
- `Import ${best.file} instead of creating a copy.`);
363
- }
364
- }
384
+ if (!css) duplicateFindings({ text, file, k, add });
365
385
 
366
386
  // ---------- an import of the copy the canon replaces ----------
367
387
  if (!css) {
@@ -114,7 +114,8 @@ function judge(real, root, k, warned = new Set()) {
114
114
  const text = readFileSync(real, 'utf8');
115
115
  const before = beforeOf(k, rel);
116
116
  const { findings: all, exempt } = validateContent({ text, file: rel, before }, k);
117
- if (exempt || !all.length) return null;
117
+ // an exempt file is judged on duplicates only; with none, there is nothing to say
118
+ if (!all.length) return null;
118
119
  // Only what this edit added. A legacy file carries its old findings on
119
120
  // every touch; repeating them after each edit is noise, and the review
120
121
  // (--check, roast_review) already reports them once at the end. Same rule
@@ -129,5 +130,5 @@ function judge(real, root, k, warned = new Set()) {
129
130
  return true;
130
131
  });
131
132
  if (!findings.length) return null;
132
- return { text: `Design-system check of ${rel} (roast-my-design-system, the same engine as roast_validate): ${findingLines(findings, k).join('\n')}`, total: findings.length };
133
+ return { text: `Design-system check of ${rel} (roast-my-design-system, the same engine as roast_validate): ${findingLines(findings, k, exempt).join('\n')}`, total: findings.length };
133
134
  }
@@ -74,7 +74,10 @@ export function loadKnowledge(root) {
74
74
  // same shape as a harvested colour, so every reader of `files` keeps working
75
75
  for (const v of themeSet) if (!tokens.colors.some((c) => c.value === v)) tokens.colors.push({ value: v, count: 1, files: [{ file: kit.themeFiles[0] ?? '', count: 1 }], isToken: true });
76
76
  }
77
- const duplicates = findDuplicates(components, profile.uiDir, root);
77
+ // every catalogue folder, as the report passes them (harvest/index.mjs): a
78
+ // monorepo with two installs has two, and shadcn's own overlap in the
79
+ // second is no more the team's than in the first
80
+ const duplicates = findDuplicates(components, profile.uiDir, root, profile.uiDirs ?? null);
78
81
  const context = harvestContext(root);
79
82
  const workspaces = resolveWorkspaces(root);
80
83
 
@@ -196,6 +199,10 @@ export function loadKnowledge(root) {
196
199
  neverImported,
197
200
  // stock, not debt: see profiles/index.mjs
198
201
  vendoredUi: P.vendoredUi,
202
+ // folders the team did not write, by the same line the guard doorway
203
+ // draws (lib/guard-api.mjs): only a shadcn kit has installed code, and a
204
+ // registry's published folders are its own work
205
+ installedDirs: P.isShadcn && !P.isRegistry ? installed : [],
199
206
  // the kit the product is built on, and the Tailwind theme it names its
200
207
  // colours in: null when the repo is neither (see profiles/)
201
208
  kit,
@@ -306,19 +306,22 @@ export function validate(k, { code, file = null } = {}) {
306
306
  if (file != null && typeof file !== 'string') return invalidInput('file is optional, but when sent it must be a repo-relative path as a string.');
307
307
  if (code.length > MAX_VALIDATE_CHARS) return invalidInput(`That is ${Math.round(code.length / 1000)}k characters; send the part you changed (up to ${MAX_VALIDATE_CHARS / 1000}k).`);
308
308
  const { findings, exempt } = validateContent({ text: code, file, before: beforeOf(k, file) }, k);
309
- if (exempt) return `Not judged: ${file} is exempt because ${exempt}. Nothing here was checked.`;
309
+ if (exempt && !findings.length) return `Not judged: ${file} is exempt because ${exempt}. Nothing here was checked.`;
310
310
  if (!findings.length) return cleanResultText(k);
311
- return findingLines(findings, k).join('\n');
311
+ return findingLines(findings, k, exempt).join('\n');
312
312
  }
313
313
 
314
314
  /** The finding list as roast_validate prints it; the edit hook prints the same. */
315
- export function findingLines(findings, k) {
315
+ export function findingLines(findings, k, exempt = null) {
316
316
  const L = [`${findings.length} finding${findings.length === 1 ? '' : 's'}:`];
317
317
  for (const f of findings.slice(0, 12)) {
318
318
  L.push(`${f.severity === 'violation' ? '✕' : '⚠'} L${f.line} ${f.message}${f.fix ? `\n Fix: ${f.fix}` : ''}`);
319
319
  }
320
320
  if (findings.length > 12) L.push(`(+${findings.length - 12} more of the same kinds)`);
321
- L.push(`Checked: ${checksFor(k).join(', ')}.`);
321
+ // an exempt file is judged on one thing, and the reader is told which
322
+ L.push(exempt
323
+ ? `Checked: duplicate component definitions. The styling was not judged, because ${exempt}.`
324
+ : `Checked: ${checksFor(k).join(', ')}.`);
322
325
  return L;
323
326
  }
324
327
 
@@ -355,12 +358,12 @@ export function reviewData(k) {
355
358
  try { text = readFileSync(join(gitRoot, f), 'utf8'); } catch { continue; }
356
359
  // knowledge paths are scanned-root-relative; git paths are toplevel-relative
357
360
  const { findings, exempt } = validateContent({ text, file: relative(realRoot, join(gitRoot, f)), before: headVersion(gitRoot, f) }, k);
358
- if (exempt) { exemptCount++; continue; }
361
+ if (exempt) exemptCount++;
359
362
  if (findings.length) { perFile.push({ f, findings }); total += findings.length; }
360
363
  }
361
364
  // Skipped is not the same as clean, and the reader is owed the difference.
362
365
  const exemptNote = exemptCount
363
- ? ` ${exemptCount} file${exemptCount === 1 ? ' was' : 's were'} left unjudged: email, print, artwork or pictures drawn with code.`
366
+ ? ` The styling of ${exemptCount} file${exemptCount === 1 ? ' was' : 's were'} left unjudged: email, print, artwork or pictures drawn with code.`
364
367
  : '';
365
368
  if (!total) {
366
369
  return { text: `Reviewed ${changed.length} changed file${changed.length === 1 ? '' : 's'}. ${cleanResultText(k)}${exemptNote}`, total: 0 };