roast-my-design-system 9.1.2 → 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
 
@@ -245,7 +245,7 @@ Four calls, under 800 tokens, and the new component reads the theme instead of a
245
245
 
246
246
  Charts get their own rule, because a chart needs several colours that differ from each other and most design systems never name them. Where a repo keeps a chart palette, a colour written by hand in a chart file is a finding that names the palette. Where a repo has charts but no palette, a new chart that paints by hand gets one warning that names the existing chart doing the same and asks for the palette once. That warning is the gap report, live.
247
247
 
248
- The server reads the repo the way the report does. On a product built on MUI, Mantine, Chakra UI or Ant Design, the context names the theme file and the kit's own way of reading it, `roast_find_token` answers in spacing steps, and the checks flag a colour or a pixel size written onto a kit component where the theme has a value. On a Tailwind theme or a shadcn repo they flag a palette class such as `text-gray-500` where the theme names a colour of that kind.
248
+ The server reads the repo the way the report does. On a product built on MUI, Mantine, Chakra UI or Ant Design, the context names the theme file and the kit's own way of reading it, `roast_find_token` answers in spacing steps, and the checks flag a colour or a pixel size written onto a kit component where the theme has a value. On a Tailwind theme or a shadcn repo they flag a palette class such as `text-gray-500` where the theme names a colour of that kind. A token's raw value pasted into a component is flagged with the token's name, and a button built from scratch where the repo already has a well-used Button gets a warning that names the import line to use.
249
249
 
250
250
  The `roast-fix` prompt serves the top Where-to-start move from a fresh scan, byte-identical to the report's copy buttons. In Claude Code type `/mcp__roast__roast-fix`, add a number to jump the queue. Fix it, ask again, and the next move has risen to the top: the scan is the progress bar.
251
251
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "9.1.2",
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": [
@@ -6,6 +6,37 @@
6
6
  */
7
7
  import { join, basename, posix } from 'node:path';
8
8
  import { readSource } from './walk.mjs';
9
+ import { resolveWorkspaces } from '../lib/workspaces.mjs';
10
+
11
+ /**
12
+ * Import aliases a tsconfig declares: "@sentry/scraps/*": ["./static/app/
13
+ * components/core/*"] means a file importing @sentry/scraps/button means the
14
+ * copy under static/app/components/core. Read tolerantly (tsconfig is JSON
15
+ * with comments and trailing commas); a bare "*" catch-all is not an alias.
16
+ * @returns [{ prefix, dir }] longest prefix first
17
+ */
18
+ export function tsconfigAliases(root) {
19
+ let text;
20
+ try { text = readSource(join(root, 'tsconfig.json')); } catch { return []; }
21
+ if (!text) return [];
22
+ let cfg;
23
+ try {
24
+ cfg = JSON.parse(text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1'));
25
+ } catch { return []; }
26
+ const paths = cfg?.compilerOptions?.paths;
27
+ if (!paths || typeof paths !== 'object') return [];
28
+ const base = (cfg.compilerOptions.baseUrl ?? '.').replace(/^\.\/?/, '').replace(/\/$/, '');
29
+ const out = [];
30
+ for (const [key, targets] of Object.entries(paths)) {
31
+ if (key === '*' || !Array.isArray(targets) || typeof targets[0] !== 'string') continue;
32
+ const prefix = key.replace(/\/?\*$/, '');
33
+ if (!prefix) continue;
34
+ const target = targets[0].replace(/^\.\/?/, '').replace(/\/?\*$/, '').replace(/\/$/, '');
35
+ const dir = posix.normalize(base ? `${base}/${target}` : target).replace(/^\.\/?/, '');
36
+ if (dir && dir !== '.') out.push({ prefix, dir });
37
+ }
38
+ return out.sort((a, b) => b.prefix.length - a.prefix.length);
39
+ }
9
40
 
10
41
  const QUOTES = new Set(['"', "'", '`']);
11
42
 
@@ -127,7 +158,10 @@ export function definedComponents(src) {
127
158
  return [...names];
128
159
  }
129
160
 
130
- 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);
131
165
 
132
166
  // ---------- web components: the tag-registered world ----------
133
167
  // Stencil, Lit and raw customElements.define register components by TAG, and
@@ -169,7 +203,7 @@ const stripExt = (f) => f.replace(/\.[cm]?[jt]sx?$/, '');
169
203
  * (`../ui` → ui/index.ts) resolves to the copy inside that folder. A file that
170
204
  * defines the name itself means its own. Returns [def] or null when unsure.
171
205
  */
172
- function importedDef(src, file, name, defs) {
206
+ function importedDef(src, file, name, defs, workspaces = [], aliases = []) {
173
207
  const own = defs.find((d) => d.file === file);
174
208
  if (own) return [own];
175
209
  for (const m of src.matchAll(/import\s+(?:type\s+)?([^'";]*?)\s+from\s*['"]([^'"]+)['"]/g)) {
@@ -181,7 +215,25 @@ function importedDef(src, file, name, defs) {
181
215
  const spec = m[2];
182
216
  let target = null, suffix = null;
183
217
  if (spec.startsWith('.')) target = posix.normalize(posix.join(posix.dirname(file), spec));
184
- else { const a = /^(?:@|~|#)\/(.+)$/.exec(spec); if (a) suffix = a[1]; else return null; }
218
+ else {
219
+ const a = /^(?:@|~|#)\/(.+)$/.exec(spec);
220
+ // a tsconfig alias first: it is the repo's own statement of where the
221
+ // specifier points, and it can name a folder no workspace declares
222
+ const al = aliases.find((x) => spec === x.prefix || spec.startsWith(`${x.prefix}/`));
223
+ if (al) {
224
+ const rest = spec === al.prefix ? '' : spec.slice(al.prefix.length + 1);
225
+ target = posix.normalize(rest ? `${al.dir}/${rest}` : al.dir).replace(/\.[cm]?[jt]sx?$/, '');
226
+ } else if (a) suffix = a[1];
227
+ else {
228
+ // A workspace package by name (twenty-ui/input, @calcom/ui): the copy
229
+ // inside that package's folder. Until now every copy was credited,
230
+ // so Twenty's marketing-site Button carried the app's 274 uses too.
231
+ const ws = workspaces.find((w) => spec === w.name || spec.startsWith(`${w.name}/`));
232
+ if (!ws) return null;
233
+ const inside = defs.filter((d) => d.file.startsWith(`${ws.dir}/`));
234
+ return inside.length === 1 ? inside : null;
235
+ }
236
+ }
185
237
  const hit = defs.filter((d) => {
186
238
  const f = stripExt(d.file);
187
239
  if (target !== null) return f === target || f === `${target}/index` || d.file.startsWith(`${target}/`);
@@ -192,6 +244,28 @@ function importedDef(src, file, name, defs) {
192
244
  return null;
193
245
  }
194
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
+
195
269
  /**
196
270
  * Harvest all components: definitions (with variants/props) + usages across the
197
271
  * repo. `codeFiles` are relative paths; returns { components, totalDefined }.
@@ -199,7 +273,7 @@ function importedDef(src, file, name, defs) {
199
273
  export function harvestComponents(root, codeFiles) {
200
274
  // .js too — CRA-era repos (a big slice of the messy-repo audience) put JSX in
201
275
  // plain .js files; requiring .tsx/.jsx made whole repos read as empty.
202
- const jsxFiles = codeFiles.filter((f) => /\.(tsx|jsx|js)$/.test(f));
276
+ const jsxFiles = codeFiles.filter((f) => JSX_FILE_RE.test(f));
203
277
  const sources = new Map();
204
278
  for (const f of jsxFiles) {
205
279
  const src = readSource(join(root, f));
@@ -210,8 +284,7 @@ export function harvestComponents(root, codeFiles) {
210
284
  const components = [];
211
285
  for (const [file, src] of sources) {
212
286
  if (!looksLikeJSXFile(src)) continue;
213
- const isPage = /(^|\/)(app\/.*page|pages\/(?!_app|_document|api))/.test(file)
214
- || /(^|\/)app\/.*\/(layout|template|error|loading)\.(tsx|jsx)$/.test(file);
287
+ const isPage = isPageFile(file);
215
288
  for (const name of definedComponents(src)) {
216
289
  components.push({
217
290
  name,
@@ -226,6 +299,8 @@ export function harvestComponents(root, codeFiles) {
226
299
  }
227
300
 
228
301
  // Pass 2: usages (skip the defining file's own render of itself is fine to count)
302
+ const workspaces = resolveWorkspaces(root);
303
+ const aliases = tsconfigAliases(root);
229
304
  const byName = new Map();
230
305
  for (const c of components) {
231
306
  if (!byName.has(c.name)) byName.set(c.name, []);
@@ -244,7 +319,7 @@ export function harvestComponents(root, codeFiles) {
244
319
  // features/invoices each "used 12x" (8 and 4 in truth), so neither read
245
320
  // as the canon (Ledgerly, 2026-09-24). Unresolved imports keep the old
246
321
  // rule and credit every copy.
247
- const owners = defs.length > 1 ? importedDef(src, file, name, defs) ?? defs : defs;
322
+ const owners = defs.length > 1 ? importedDef(src, file, name, defs, workspaces, aliases) ?? defs : defs;
248
323
  for (const def of owners) {
249
324
  if (def.file === file) continue; // internal render/recursion, not adoption
250
325
  def.usageCount += n;
@@ -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
 
@@ -377,6 +377,7 @@ export function harvestTokens(root, styleFiles, codeFiles) {
377
377
  return owner;
378
378
  };
379
379
  const paletteFiles = new Set();
380
+ const tailwindConfigs = new Set();
380
381
  const listShare = new Map(); // code file → share of its colour literals inside 8+ colour arrays
381
382
  const nsDefs = new Map(); // --telekom-x: value → 'telekom' (definitions)
382
383
  const nsRefs = new Map(); // var(--telekom-x) → 'telekom' (references)
@@ -536,6 +537,7 @@ export function harvestTokens(root, styleFiles, codeFiles) {
536
537
  // equivalent of --var definitions). Its fontFamily block is the typeface
537
538
  // declaration for the whole app.
538
539
  if (/(^|\/)tailwind\.config\.[mc]?[jt]s$/.test(f)) {
540
+ tailwindConfigs.add(f);
539
541
  for (const m of src.matchAll(HEX_RE)) { tokenDefined.add(normalizeHex(m[0])); colors.add(normalizeHex(m[0]), f); }
540
542
  const famBlock = src.match(/fontFamily\s*:\s*\{([\s\S]*?)\n\s*\}/);
541
543
  if (famBlock) {
@@ -708,6 +710,11 @@ export function harvestTokens(root, styleFiles, codeFiles) {
708
710
  // stylesheet is never a list, whatever shape its values take.
709
711
  const isList = (f) => /\.[jt]sx?$/.test(f) && !/(^|\/)tailwind\.config\.[mc]?[jt]s$/.test(f) && (listShare.get(f) ?? 0) >= 0.5;
710
712
  const candidates = new Set([...tokenColorDefsPerFile.keys(), ...paletteFiles]);
713
+ // Every file that states a token colour: a stylesheet defining --vars, a
714
+ // Tailwind config, a palette file. A checker reading one of these must not
715
+ // tell it to use itself; anywhere else a token's raw value is a paste of
716
+ // the value where the name belongs (Twenty, Haiku with the hook, 2026-09-29).
717
+ const tokenSources = [...new Set([...candidates, ...tailwindConfigs])].sort();
711
718
  const tokenFile = [...candidates]
712
719
  .map((f) => ({ f, tokens: tokenColoursIn.get(f) ?? 0, strays: straysIn.get(f) ?? 0, list: isList(f) ? 1 : 0 }))
713
720
  .filter((c) => c.tokens >= 3)
@@ -773,6 +780,7 @@ export function harvestTokens(root, styleFiles, codeFiles) {
773
780
  }
774
781
  return {
775
782
  tokenFile,
783
+ tokenSources,
776
784
  foreignStyles,
777
785
  namespaces,
778
786
  colors: colorList,
@@ -19,12 +19,14 @@ import { KITS } from '../profiles/kit-common.mjs';
19
19
  import { kitPaintFindings } from './kitpaint.mjs';
20
20
  import { PALETTE_CLASS_RE, blankComments } from '../harvest/paint.mjs';
21
21
  import { harvestTokens, extractStyling, normalizeHex, isGrey } from '../harvest/tokens.mjs';
22
- import { harvestComponents, definedComponents } from '../harvest/components.mjs';
22
+ import { harvestComponents, definedComponents, tsconfigAliases } from '../harvest/components.mjs';
23
23
  import { findDuplicates } from '../harvest/duplicates.mjs';
24
24
  import { repoTokenDefs } from './tokentwins.mjs';
25
25
  import { dupeCopiesOf } from './avoidedimports.mjs';
26
26
  import { loadExclusions } from './exclusions.mjs';
27
27
  import { WIDGET_CONFIG_RE } from './exempt.mjs';
28
+ import { buttonCandidates } from './handmade.mjs';
29
+ import { resolveWorkspaces } from './workspaces.mjs';
28
30
  import { hexRgb } from './nearpairs.mjs';
29
31
  import { typefaceOf, GENERIC_FONTS } from './typefaces.mjs';
30
32
 
@@ -33,6 +35,21 @@ export { extractStyling, normalizeHex, isGrey, hexRgb, typefaceOf, GENERIC_FONTS
33
35
  // The components a piece of text defines. With the ledger below, a guard can
34
36
  // tell a second <Button> from an edit to the first one.
35
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';
36
53
 
37
54
  // The files no checker should judge. Exported so a guard reads the same list
38
55
  // as the engine rather than keeping a copy that drifts.
@@ -63,6 +80,9 @@ export { kitPaintFindings };
63
80
  export { tokenTwinFindings, tokenDefsOf } from './tokentwins.mjs';
64
81
  export { avoidedImportFindings, canonicalCopy } from './avoidedimports.mjs';
65
82
  export { isChartFile, chartSystemOf, chartTier, chartFindings } from './charts.mjs';
83
+ // a button from scratch where the repo has a Button (9.2.0); learnSystem
84
+ // hands over `buttons`, `workspaces` and `packageDeps` for buttonFor
85
+ export { handmadeButtons, handmadeButtonFindings, buttonCandidates, buttonFor, BUTTON_MIN_USES } from './handmade.mjs';
66
86
 
67
87
  // Radius, font size, shadow and typeface: the patterns, so both checkers agree
68
88
  // on what a declaration is and what counts as a disciplined value.
@@ -110,7 +130,7 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
110
130
  // the same two lists the MCP knowledge builds (mcp/knowledge.mjs), from the
111
131
  // same helpers, for tokenTwinFindings and avoidedImportFindings (8.6.1)
112
132
  const read = (f) => readSource(join(repoRoot, f));
113
- 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);
114
134
  const P = profileOf(profile);
115
135
  const installedNow = installedDirs(P);
116
136
  const charts = chartSystemOf(files, read, { own: (f) => !installedNow.some((d) => f === d || f.startsWith(`${d}/`)) });
@@ -137,8 +157,26 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
137
157
  }
138
158
  }
139
159
 
160
+ // the Button the repo answers with, per package (lib/handmade.mjs)
161
+ const workspaces = resolveWorkspaces(repoRoot);
162
+ const buttons = buttonCandidates(ledger, read, workspaces, tsconfigAliases(repoRoot));
163
+ const packageDeps = new Map();
164
+ for (const w of workspaces) {
165
+ let pj = null;
166
+ try { pj = JSON.parse(read(`${w.dir}/package.json`) ?? 'null'); } catch { /* not a package after all */ }
167
+ packageDeps.set(w.dir, new Set(Object.keys({ ...pj?.dependencies, ...pj?.devDependencies, ...pj?.peerDependencies })));
168
+ }
169
+
140
170
  return {
141
171
  tokenFile: t.tokenFile,
172
+ buttons,
173
+ workspaces,
174
+ packageDeps,
175
+ // The files that state the palette: token stylesheets, a Tailwind config,
176
+ // palette data, a kit theme. Only there is a token's raw value the
177
+ // definition; in any other file it is a paste of the value where the
178
+ // name belongs, and a guard should say so (9.1.3).
179
+ tokenSources: [...new Set([...(t.tokenSources ?? []), ...(kit?.themeFiles ?? []), t.tokenFile].filter(Boolean))],
142
180
  colors: t.colors,
143
181
  // Every named colour, variants included — not the counted palette. The
144
182
  // report counts a token's base statement only, because a dark theme is
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Hand-made buttons where the repo already has a Button.
3
+ *
4
+ * The bad-neighbour tests on Twenty (2026-09-29): with the edit hook on,
5
+ * Haiku reached zero value findings and still hand-made the button four
6
+ * times in five, with clean token colours. The value checks cannot see the
7
+ * choice; this one does. Buttons only, for now.
8
+ *
9
+ * Fleet probe, 204 clones (2026-09-29): 52 have a Button, 38 of those still
10
+ * hand-make buttons in page files, but most of those are clickable rows,
11
+ * tabs, toggles and "show more" links on a button tag for keyboard access,
12
+ * which nobody wants replaced. So the trigger is narrow: an element DRESSED
13
+ * as a button (a real background plus padding, a border or a radius), or
14
+ * one that calls itself a button by name and renders the raw tag. A reset
15
+ * (background: none; border: 0) is a row, not a button, and stays silent.
16
+ *
17
+ * The Button it points at must be visible from the file's own package. In
18
+ * Twenty the most-used Button is the marketing site's (313 uses); the app
19
+ * imports its own from twenty-ui. Pointing the app at the website's button
20
+ * would be worse than silence.
21
+ */
22
+
23
+ import { posix } from 'node:path';
24
+
25
+ const stripExt = (f) => f.replace(/\.[jt]sx?$/, '').replace(/\/index$/, '');
26
+
27
+ /** Imports of a Button below this are a stray, not the repo's answer. */
28
+ export const BUTTON_MIN_USES = 20;
29
+
30
+ const NOT_OWN_CODE_RE = /(^|\/)(stories|__stories__|__tests__|__mocks__|test|tests|spec|docs?|examples?|playground|storybook|showcase|sandbox|demo|fixtures?)\/|\.(stories|test|spec)\.[jt]sx?$|(Sandbox|Showcase|Demo|Playground)\.[jt]sx?$/;
31
+ // the system's own layer: a Button-ish thing here is the system being built
32
+ const SYSTEM_LAYER_RE = /(^|\/)(ui|design-system|primitives|core)\/|[\w.-]*button[\w.-]*\.[jt]sx?$/i;
33
+ // A name that says the element is not the action button: a close cross, a
34
+ // chevron, a tab, a chip, a row that happens to be focusable. The fleet
35
+ // probe (five repos, 2026-09-29) found these were most of the styled
36
+ // buttons in page files, and none of them wants the Button.
37
+ const OTHER_ROLE_RE = /(Icon|Close|Toggle|Expand|Collapse|Tab|Pill|Chip|Row|Item|Menu|Delete|Remove|Clear|Trigger|Header|Segment|Switch|Mode|Dot|Hidden|Tooltip|Info|Avatar|Handle|Link|Nav|Thumb|Step|Card|Tile|Option|Cell|Accordion|Summary|Tree|Folder|Image|Query|Checkbox|Radio|Swatch|Uploader|Container|Place|Bar|Control|Filter|Select|Value|Indicator)/;
38
+
39
+ // Dressed as a button: real padding, and a real background or a real border.
40
+ // A reset (background: none; border: 0; padding: 0) is a row, not a button.
41
+ // An outline button has a border and padding but no background, so either
42
+ // paint counts (Twenty's wizard SecondaryButton, 2026-09-29).
43
+ // `\s*(?!\s)` pins the value's start: without it the regex backtracks past
44
+ // the space after the colon and reads "background: none" as paint
45
+ const REAL_BG = /(?:^|[\s;{])background(?:-color)?\s*:\s*(?!\s)(?!(?:none|transparent|inherit|unset|initial)\s*[;}])[^;}]*[;}]/i;
46
+ const REAL_BORDER = /(?:^|[\s;{])border(?:-(?:top|bottom|left|right))?\s*:\s*(?!\s)(?!(?:none|0|0px|unset)\s*[;}])[^;}]*[;}]/i;
47
+ const REAL_PAD = /(?:^|[\s;{])padding(?:-(?:top|bottom|left|right|inline|block))?\s*:\s*(?!\s)(?!0(?:px)?\s*[;}])[^;}]*[;}]/i;
48
+ // a pseudo-state block (&:hover { background: ... }) is a state, not the
49
+ // resting paint; a class variant block (&.primary { ... }) still counts
50
+ // `${...}` interpolations carry braces of their own (arrow functions,
51
+ // destructured props), so they are blanked first, by brace count, and each
52
+ // reads as a value, not as an empty one
53
+ const blankInterpolations = (body) => {
54
+ let out = '', i = 0;
55
+ while (i < body.length) {
56
+ if (body[i] === '$' && body[i + 1] === '{') {
57
+ let depth = 0, j = i + 1;
58
+ for (; j < body.length; j++) { if (body[j] === '{') depth++; else if (body[j] === '}' && --depth === 0) break; }
59
+ out += '_'; i = j + 1;
60
+ } else out += body[i++];
61
+ }
62
+ return out;
63
+ };
64
+ const stripStates = (body) => blankInterpolations(body).replace(/&?(?::[\w-]+(?:\([^)]*\))?|\[[^\]]*\])+\s*\{[^{}]*\}/g, ' ');
65
+ // An action button carries a label the designer set (a weight, a size, a
66
+ // transform). A card, a select trigger or a list row on a button tag reads
67
+ // differently: text to the left, content spread apart, a column, a stretch
68
+ // to fill the row, or a fixed position. Fleet probe, fifteen repos
69
+ // (2026-09-29): those tells were nearly every innocent hit.
70
+ const LABEL_CSS = /(?:^|[\s;{])(?:font-weight|font-size|text-transform|font-family|letter-spacing)\s*:/i;
71
+ const OTHER_SHAPE_CSS = /(?:^|[\s;{])(?:text-align\s*:\s*left|justify-content\s*:\s*space-between|flex-direction\s*:\s*column|flex\s*:\s*1\b|width\s*:\s*100%|position\s*:\s*(?:absolute|fixed))/i;
72
+ const dressedCss = (raw) => {
73
+ const body = stripStates(raw);
74
+ return REAL_PAD.test(body) && (REAL_BG.test(body) || REAL_BORDER.test(body)) && LABEL_CSS.test(body) && !OTHER_SHAPE_CSS.test(body);
75
+ };
76
+
77
+ // class strings: a bare bg- or border class (a hover: or focus: variant is a
78
+ // state, not the resting paint) plus non-zero padding
79
+ const TW_BG = /(?:^|\s)bg-(?!transparent|inherit|none)[\w[\]#/.-]/;
80
+ const TW_BORDER = /(?:^|\s)border(?:\s|$|-(?!none|0(?:\s|$)|transparent)[\w[\]#/.-]*)/;
81
+ // vertical padding of a full step or more: a badge (py-0.5), an avatar ring
82
+ // (p-0.5) or a select trigger sized by height alone is not a button
83
+ const TW_PAD = /(?:^|\s)p(?:y)?-(?:[1-9]\d*(?:\.\d+)?|\[)/;
84
+ const TW_LABEL = /(?:^|\s)(?:font-(?:medium|semibold|bold|black)|text-white|text-[\w-]*foreground|uppercase)(?:\s|$)/;
85
+ const TW_OTHER_SHAPE = /(?:^|\s)(?:text-left|justify-between|flex-col|items-start|placeholder:[\w-]+|absolute|fixed|hidden|invisible|border-dashed|rounded-full|(?:h|w|size)-(?:\d+|\[[^\]]*\])(?=\s|$))/;
86
+ const dressedClasses = (cls) => TW_PAD.test(cls) && (TW_BG.test(cls) || TW_BORDER.test(cls)) && TW_LABEL.test(cls) && !TW_OTHER_SHAPE.test(cls);
87
+
88
+ const INLINE_BG = /background(?:Color)?\s*:\s*(?!\s)['"`](?!transparent|none|inherit)/;
89
+ const INLINE_BORDER = /border(?:Top|Bottom|Left|Right)?\s*:\s*(?!\s)['"`](?!none|0)/;
90
+ const INLINE_PAD = /padding(?:Top|Bottom|Left|Right)?\s*:\s*(?!\s)(?!0(?:[,\s}]|$)|['"`]0(?:px)?['"`])/;
91
+ const INLINE_LABEL = /(?:fontWeight|fontSize|textTransform|fontFamily)\s*:/;
92
+ const dressedInline = (style) => INLINE_PAD.test(style) && (INLINE_BG.test(style) || INLINE_BORDER.test(style)) && INLINE_LABEL.test(style);
93
+
94
+ /**
95
+ * The hand-made buttons in a piece of component code, each with where it
96
+ * starts and how it gave itself away.
97
+ * @returns [{ index, name, how }] how: 'styled' | 'tag'
98
+ */
99
+ export function handmadeButtons(text) {
100
+ const out = [];
101
+ const src = String(text ?? '');
102
+
103
+ // const StyledX = styled.button`...` / styled('button')({...})
104
+ for (const m of src.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*styled(?:\.button|\(\s*['"]button['"]\s*\))(?:<[^`(]*?>)?\s*(?:`([\s\S]*?)`|\(\s*\{([\s\S]*?)\}\s*\))/g)) {
105
+ const name = m[1];
106
+ if (OTHER_ROLE_RE.test(name)) continue;
107
+ // an object style ({ backgroundColor: '#fff', padding: 8 }) read as CSS
108
+ const body = m[2] ?? `${(m[3] ?? '').replace(/,/g, ';').replace(/([A-Z])/g, (c) => `-${c.toLowerCase()}`)};`;
109
+ if (dressedCss(body)) out.push({ index: m.index, name, how: 'styled' });
110
+ }
111
+
112
+ // <button className="bg-brand px-4 py-2"> / <button style={{ background: ..., padding: ... }}>
113
+ for (const m of src.matchAll(/<button\b([^>]*)>/g)) {
114
+ const attrs = m[1];
115
+ const cls = /className\s*=\s*(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/.exec(attrs);
116
+ const style = /style\s*=\s*\{\{([^}]*)\}\}/.exec(attrs);
117
+ if ((cls && dressedClasses(cls[1] ?? cls[2] ?? cls[3] ?? '')) || (style && dressedInline(style[1]))) out.push({ index: m.index, name: null, how: 'tag' });
118
+ }
119
+ return out.sort((a, b) => a.index - b.index);
120
+ }
121
+
122
+ /**
123
+ * How the repo imports THIS Button, read from the files that use it: the
124
+ * most common specifier that resolves to the definition. A relative path
125
+ * resolves by position, an alias (@/ui/Button) by its tail, a package name
126
+ * by the workspace it names. The harvest credits every same-named copy when
127
+ * it cannot resolve a package import, so its usedIn for the website's
128
+ * Button holds app files importing twenty-ui/input; asking "which spec
129
+ * resolves to this file" is what keeps the answer honest.
130
+ */
131
+ function importSpecOf(def, read, workspaces, aliases = []) {
132
+ const counts = new Map();
133
+ const stem = stripExt(def.file);
134
+ const resolves = (spec, from) => {
135
+ if (spec.startsWith('.')) {
136
+ const target = posix.normalize(posix.join(posix.dirname(from), spec));
137
+ return stem === target || stem === `${target}/index` || def.file.startsWith(`${target}/`);
138
+ }
139
+ // a tsconfig path alias (@sentry/scraps/button → static/app/components/core/button)
140
+ const al = aliases.find((x) => spec === x.prefix || spec.startsWith(`${x.prefix}/`));
141
+ if (al) {
142
+ const target = posix.normalize(spec === al.prefix ? al.dir : `${al.dir}/${spec.slice(al.prefix.length + 1)}`).replace(/\.[cm]?[jt]sx?$/, '');
143
+ return stem === target || stem === `${target}/index` || def.file.startsWith(`${target}/`);
144
+ }
145
+ const alias = /^[@~#]\/(.+)$/.exec(spec);
146
+ if (alias) return stem.endsWith(`/${alias[1]}`) || stem.endsWith(`/${alias[1]}/index`) || def.file.includes(`/${alias[1]}/`);
147
+ const ws = (workspaces ?? []).find((w) => spec === w.name || spec.startsWith(`${w.name}/`));
148
+ return !!ws && def.file.startsWith(`${ws.dir}/`);
149
+ };
150
+ for (const f of def.usedIn ?? []) {
151
+ const text = read(f);
152
+ if (!text) continue;
153
+ for (const m of text.matchAll(/import\s+(?:type\s+)?([^'";]*?)\s+from\s+['"]([^'"]+)['"]/g)) {
154
+ if (!/\bButton\b/.test(m[1])) continue;
155
+ if (resolves(m[2], f)) counts.set(m[2], (counts.get(m[2]) ?? 0) + 1);
156
+ break;
157
+ }
158
+ }
159
+ return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
160
+ }
161
+
162
+ /**
163
+ * The Buttons a repo could answer with: every reusable component called
164
+ * Button, in the team's own code, imported at least BUTTON_MIN_USES times.
165
+ * @returns [{ file, usageCount, spec }] spec: how the repo imports it, proven
166
+ */
167
+ export function buttonCandidates(components, read, workspaces = [], aliases = []) {
168
+ return components
169
+ .filter((c) => c.name === 'Button' && !c.isPage && c.usageCount >= BUTTON_MIN_USES && !NOT_OWN_CODE_RE.test(c.file))
170
+ .map((c) => ({ file: c.file, usageCount: c.usageCount, spec: importSpecOf(c, read, workspaces, aliases) }))
171
+ // no file provably imports it (an alias the repo resolves elsewhere, or a
172
+ // name the harvest credited from other copies): no answer to point at
173
+ .filter((c) => c.spec)
174
+ .sort((a, b) => b.usageCount - a.usageCount);
175
+ }
176
+
177
+ /** The workspace package a file belongs to, or null. */
178
+ export const packageOf = (file, workspaces) =>
179
+ (workspaces ?? []).filter((w) => file === w.dir || file.startsWith(`${w.dir}/`)).sort((a, b) => b.dir.length - a.dir.length)[0] ?? null;
180
+
181
+ /**
182
+ * The Button a file should reach for: the most-used candidate visible from
183
+ * the file's package (its own, one it depends on, or code outside every
184
+ * package). Null when the repo has no answer for this file.
185
+ */
186
+ export function buttonFor(file, { buttons = [], workspaces = [], packageDeps = new Map() } = {}) {
187
+ if (!buttons.length) return null;
188
+ const mine = file ? packageOf(file, workspaces) : null;
189
+ const deps = mine ? packageDeps.get(mine.dir) ?? new Set() : new Set();
190
+ const visible = buttons.filter((b) => {
191
+ const theirs = packageOf(b.file, workspaces);
192
+ if (!mine || !theirs) return true;
193
+ return theirs.dir === mine.dir || deps.has(theirs.name);
194
+ });
195
+ return visible[0] ?? null;
196
+ }
197
+
198
+ /**
199
+ * Findings for the hand-made buttons in a file, against the Button the repo
200
+ * answers with. Warnings: the check has not yet earned a violation.
201
+ */
202
+ export function handmadeButtonFindings(text, { file = null } = {}, k) {
203
+ if (!file || SYSTEM_LAYER_RE.test(file) || NOT_OWN_CODE_RE.test(file)) return [];
204
+ const button = buttonFor(file, k);
205
+ if (!button) return [];
206
+ const out = [];
207
+ for (const h of handmadeButtons(text)) {
208
+ const what = h.how === 'styled' ? `A styled button (${h.name})` : 'A button tag styled by hand';
209
+ out.push({
210
+ rule: 'handmade-button', severity: 'warning', index: h.index,
211
+ message: `${what} where the repo already has <Button> (imported ${button.usageCount}x${button.spec ? ` from ${button.spec}` : `, ${button.file}`}).`,
212
+ fix: `Use ${button.spec ? `import { Button } from '${button.spec}'` : `the Button in ${button.file}`}. If it needs a kind the Button lacks, add a variant there rather than a new button here.`,
213
+ });
214
+ }
215
+ return out;
216
+ }
@@ -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.1.2';
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.
@@ -57,14 +57,37 @@ const memo = new Map();
57
57
  const mtime = (p) => { try { return statSync(p).mtimeMs; } catch { return 0; } };
58
58
 
59
59
  export function resolveWorkspaces(root) {
60
- const key = `${mtime(join(root, 'package.json'))}:${mtime(join(root, 'pnpm-workspace.yaml'))}`;
60
+ const sub = workspaceSubroot(root);
61
+ const at = sub ? join(root, sub) : root;
62
+ const key = `${sub ?? ''}:${mtime(join(at, 'package.json'))}:${mtime(join(at, 'pnpm-workspace.yaml'))}`;
61
63
  const hit = memo.get(root);
62
64
  if (hit && hit.key === key) return hit.value.map((w) => ({ ...w }));
63
- const value = resolveWorkspacesUncached(root);
65
+ const value = resolveWorkspacesUncached(at).map((w) => (sub ? { ...w, dir: `${sub}/${w.dir}` } : w));
64
66
  memo.set(root, { key, value });
65
67
  return value.map((w) => ({ ...w }));
66
68
  }
67
69
 
70
+ const declaresWorkspaces = (dir) => {
71
+ const pkg = readJSON(join(dir, 'package.json'));
72
+ return !!(Array.isArray(pkg?.workspaces) || Array.isArray(pkg?.workspaces?.packages) || existsSync(join(dir, 'pnpm-workspace.yaml')));
73
+ };
74
+ /**
75
+ * A polyglot repo keeps its JavaScript one level down: Mattermost's root is
76
+ * Go and webapp/package.json declares the workspaces (2026-09-29). When the
77
+ * root declares none, the first immediate subfolder that does is the
78
+ * workspace root, and every package dir is reported under it.
79
+ */
80
+ function workspaceSubroot(root) {
81
+ if (declaresWorkspaces(root)) return null;
82
+ let entries;
83
+ try { entries = readdirSync(root, { withFileTypes: true }); } catch { return null; }
84
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
85
+ if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules') continue;
86
+ if (declaresWorkspaces(join(root, e.name))) return e.name;
87
+ }
88
+ return null;
89
+ }
90
+
68
91
  function resolveWorkspacesUncached(root) {
69
92
  const patterns = [];
70
93
  const pkg = readJSON(join(root, 'package.json'));
@@ -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';
@@ -23,6 +24,7 @@ import { oklab } from '../lib/color.mjs';
23
24
  import { tokenTwinFindings } from '../lib/tokentwins.mjs';
24
25
  import { avoidedImportFindings } from '../lib/avoidedimports.mjs';
25
26
  import { isChartFile, chartFindings } from '../lib/charts.mjs';
27
+ import { handmadeButtonFindings } from '../lib/handmade.mjs';
26
28
 
27
29
  // What this engine measures — shipped with every result, clean or not.
28
30
  export const CHECKS = [
@@ -43,10 +45,11 @@ export const CHECKS = [
43
45
  // MUI repo says the kit check ran.
44
46
  export const KIT_CHECK = 'colours and pixel sizes written onto kit components where the theme has a value';
45
47
  export const PALETTE_CHECK = 'palette classes where the theme names a colour';
48
+ export const BUTTON_CHECK = 'hand-made buttons where the repo has a Button';
46
49
  export function checksFor(k) {
47
- if (k?.kit) return [...CHECKS, KIT_CHECK];
48
- if (k?.tailwind || k?.shadcn) return [...CHECKS, PALETTE_CHECK];
49
- return CHECKS;
50
+ const list = k?.kit ? [...CHECKS, KIT_CHECK] : (k?.tailwind || k?.shadcn) ? [...CHECKS, PALETTE_CHECK] : [...CHECKS];
51
+ if (k?.buttons?.length) list.push(BUTTON_CHECK);
52
+ return list;
50
53
  }
51
54
  // "an MUI component", "an Ant Design component", "a Mantine component"
52
55
 
@@ -81,6 +84,29 @@ function nearestToken(value, k) {
81
84
  return best;
82
85
  }
83
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
+
84
110
  /**
85
111
  * Validate one piece of content against the repo's knowledge.
86
112
  * @param content { text, file?, before? } — file name decides css-vs-code
@@ -89,7 +115,8 @@ function nearestToken(value, k) {
89
115
  * unknown), so a token or an import the change ADDS can be told from one
90
116
  * that was already there
91
117
  * @returns { findings: [{ rule, severity, line, message, fix? }], checked,
92
- * 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
93
120
  */
94
121
  export function validateContent(content, k) {
95
122
  const { text, file = null, before } = content;
@@ -97,13 +124,20 @@ export function validateContent(content, k) {
97
124
  // checker earns its reputation for crying wolf, and a checker people
98
125
  // distrust gets switched off. Say nothing, and say why nothing was said.
99
126
  const exempt = exemptReason(file, text);
100
- if (exempt) return { findings: [], checked: checksFor(k), exempt };
101
127
  const css = file ? CSS_FILE_RE.test(file) : looksLikeCss(text);
102
- const got = extractStyling(text, { css });
103
128
  const findings = [];
104
129
  const add = (rule, severity, index, message, fix) => findings.push({
105
130
  rule, severity, line: lineOf(text, index), message, ...(fix ? { fix } : {}),
106
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 });
107
141
 
108
142
  // The knowledge scan includes the working tree, so a file under review has
109
143
  // already leaked its own values into the repo counts — without a discount,
@@ -197,18 +231,27 @@ export function validateContent(content, k) {
197
231
 
198
232
  // ---------- colours ----------
199
233
  const seenHere = new Set();
234
+ const statesTokens = !!file && k.tokenSources?.has(file);
200
235
  for (const c of got.colors) {
201
236
  if (seenHere.has(`${c.value}:${c.index}`)) continue;
202
237
  seenHere.add(`${c.value}:${c.index}`);
203
238
  if (kitOwnsColours || chartFile) continue; // the kit judge or the chart rule said it, or judged it not paint
204
239
  const info = k.colorInfo.get(c.value);
205
- if (info?.isToken) continue; // it IS the token's value — the system can see it
240
+ // A token's raw value is the definition only inside a file that states
241
+ // the palette (a token stylesheet, a Tailwind config, a kit theme). In a
242
+ // component it is the value pasted where the name belongs: the system
243
+ // cannot see it, and the next reader copies the hex. Until 9.1.3 every
244
+ // exact match walked free, and hooked agents on Twenty wrote
245
+ // background: #4a38f5 with a clean bill (2026-09-29).
246
+ if (info?.isToken && statesTokens) continue;
206
247
  const near = nearestToken(c.value, k);
207
248
  const priors = discount(info?.count, localColor, c.value);
208
249
  if (near && near.d === 0) {
250
+ const names = k.tokenNames?.get(c.value) ?? [];
251
+ const by = names.length ? `${css ? `var(${names[0]})` : names[0]}` : `the token that already holds ${near.value}`;
209
252
  add('hardcoded-colour', 'violation', c.index,
210
253
  `Hardcoded colour ${c.value} duplicates an existing token value.`,
211
- `Use the token that already holds ${near.value}${k.tokens.tokenFile ? ` (defined in ${k.tokens.tokenFile})` : ''}.`);
254
+ `Use ${by}${k.tokens.tokenFile ? ` (defined in ${k.tokens.tokenFile})` : ''}.`);
212
255
  } else if (near && near.d <= 8) {
213
256
  add('near-token-twin', 'violation', c.index,
214
257
  `${c.value} is visually identical to the token ${near.value}.`,
@@ -224,6 +267,11 @@ export function validateContent(content, k) {
224
267
  }
225
268
  }
226
269
 
270
+ // ---------- the choice, not the value: a button from scratch ----------
271
+ // The value checks above cannot see it. Hooked agents on Twenty passed
272
+ // them and still hand-made the button four times in five (2026-09-29).
273
+ if (!css) for (const f of handmadeButtonFindings(text, { file }, k)) add(f.rule, f.severity, f.index, f.message, f.fix);
274
+
227
275
  // ---------- spacing ----------
228
276
  for (const s of got.spacing) {
229
277
  if (kitPx.has(s.value)) continue; // the kit line above said it
@@ -242,9 +290,13 @@ export function validateContent(content, k) {
242
290
  // a raw value the repo already uses, in a non-Tailwind repo, is consistency,
243
291
  // not a new offence — silence
244
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}/`));
245
297
  const localArb = new Map();
246
298
  for (const a of got.arbitrary) localArb.set(a.value, (localArb.get(a.value) ?? 0) + 1);
247
- for (const a of got.arbitrary) {
299
+ for (const a of installed ? [] : got.arbitrary) {
248
300
  const repoArb = (k.tokens.tailwind?.arbitrary ?? []).find((x) => x.value === a.value);
249
301
  const priorArb = discount(repoArb?.count, localArb, a.value);
250
302
  add('arbitrary-value', 'violation', a.index,
@@ -329,23 +381,7 @@ export function validateContent(content, k) {
329
381
  }
330
382
 
331
383
  // ---------- duplicate components ----------
332
- if (!css) {
333
- for (const name of definedComponents(text)) {
334
- const existing = (k.byName.get(name) ?? []).filter((c) => !file || c.file !== file);
335
- if (!existing.length) continue;
336
- const dupe = k.dupeByName.get(name);
337
- // the scan may already include this very file — count the OTHER copies
338
- const otherCopies = dupe
339
- ? dupe.files.map((f) => (typeof f === 'string' ? f : f.file)).filter((f) => f !== file)
340
- : null;
341
- const best = [...existing].sort((a, b) => b.usageCount - a.usageCount)[0];
342
- add('duplicate-component', 'violation', text.indexOf(name),
343
- otherCopies && otherCopies.length > 1
344
- ? `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.`
345
- : `Defines <${name}>, but ${best.file} already defines it${best.usageCount ? ` (used ${best.usageCount}x)` : ''}.`,
346
- `Import ${best.file} instead of creating a copy.`);
347
- }
348
- }
384
+ if (!css) duplicateFindings({ text, file, k, add });
349
385
 
350
386
  // ---------- an import of the copy the canon replaces ----------
351
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
  }
@@ -19,6 +19,8 @@ import { findDuplicates } from '../harvest/duplicates.mjs';
19
19
  import { harvestContext } from '../harvest/context.mjs';
20
20
  import { loadExclusions } from '../lib/exclusions.mjs';
21
21
  import { resolveWorkspaces } from '../lib/workspaces.mjs';
22
+ import { buttonCandidates } from '../lib/handmade.mjs';
23
+ import { tsconfigAliases } from '../harvest/components.mjs';
22
24
  import { neverImportedComponents } from '../lib/neverimported.mjs';
23
25
  import { typefaceOf } from '../lib/typefaces.mjs';
24
26
  import { hexRgb } from '../lib/nearpairs.mjs';
@@ -72,7 +74,10 @@ export function loadKnowledge(root) {
72
74
  // same shape as a harvested colour, so every reader of `files` keeps working
73
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 });
74
76
  }
75
- 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);
76
81
  const context = harvestContext(root);
77
82
  const workspaces = resolveWorkspaces(root);
78
83
 
@@ -80,6 +85,14 @@ export function loadKnowledge(root) {
80
85
  const colorInfo = new Map(); // value → { count, isToken }
81
86
  for (const c of tokens.colors) colorInfo.set(c.value, { count: c.count, isToken: c.isToken });
82
87
  const tokenColors = [...colorInfo.entries()].filter(([, i]) => i.isToken).map(([v]) => v);
88
+ // value → the custom-property names that hold it, so a check can say
89
+ // "use var(--color-brand)" rather than only "use the token"
90
+ const tokenNames = new Map();
91
+ for (const c of tokens.colors) if (c.names?.length) tokenNames.set(c.value, c.names);
92
+ // the files that state the palette (token stylesheets, a Tailwind config,
93
+ // a kit theme, palette data): the only files where a token's raw value is
94
+ // the definition rather than a paste of it
95
+ const tokenSources = new Set([...(tokens.tokenSources ?? []), ...(kit?.themeFiles ?? []), tokens.tokenFile].filter(Boolean));
83
96
 
84
97
  // spacing the repo already uses: raw CSS values with counts, plus whether
85
98
  // the repo styles spacing through Tailwind at all (decides what "on-scale" means)
@@ -123,6 +136,18 @@ export function loadKnowledge(root) {
123
136
 
124
137
  const neverImported = neverImportedComponents(components, profile.uiDir);
125
138
 
139
+ // The Button the repo answers with, per package: the candidates, and what
140
+ // each workspace package depends on, so a file is pointed at a Button it
141
+ // can actually import (lib/handmade.mjs).
142
+ const readK = (f) => readSource(join(root, f));
143
+ const buttons = buttonCandidates(components, readK, workspaces, tsconfigAliases(root));
144
+ const packageDeps = new Map();
145
+ for (const w of workspaces) {
146
+ let pj = null;
147
+ try { pj = JSON.parse(readK(`${w.dir}/package.json`) ?? 'null'); } catch { /* not a package after all */ }
148
+ packageDeps.set(w.dir, new Set(Object.keys({ ...pj?.dependencies, ...pj?.devDependencies, ...pj?.peerDependencies })));
149
+ }
150
+
126
151
  // The copies of each duplicated name with their usage, and the colours each
127
152
  // copy hard-codes: what the avoided-import check names (lib/avoidedimports).
128
153
  const read = (file) => readSource(join(root, file));
@@ -152,6 +177,10 @@ export function loadKnowledge(root) {
152
177
  workspaces,
153
178
  // indexes
154
179
  colorInfo,
180
+ buttons,
181
+ packageDeps,
182
+ tokenNames,
183
+ tokenSources,
155
184
  tokenColors,
156
185
  tokenColorRgb: tokenColors.map((v) => ({ value: v, rgb: hexRgb(v) })).filter((t) => t.rgb),
157
186
  spacingSeen,
@@ -170,6 +199,10 @@ export function loadKnowledge(root) {
170
199
  neverImported,
171
200
  // stock, not debt: see profiles/index.mjs
172
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 : [],
173
206
  // the kit the product is built on, and the Tailwind theme it names its
174
207
  // colours in: null when the repo is neither (see profiles/)
175
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 };