roast-my-design-system 9.1.2 → 9.2.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/README.md +1 -1
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/harvest/components.mjs +54 -3
- package/skills/roast-my-design-system/scripts/harvest/tokens.mjs +8 -0
- package/skills/roast-my-design-system/scripts/lib/guard-api.mjs +24 -1
- package/skills/roast-my-design-system/scripts/lib/handmade.mjs +216 -0
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/lib/workspaces.mjs +25 -2
- package/skills/roast-my-design-system/scripts/mcp/engine.mjs +21 -5
- package/skills/roast-my-design-system/scripts/mcp/knowledge.mjs +26 -0
package/README.md
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "9.2.0",
|
|
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
|
|
|
@@ -169,7 +200,7 @@ const stripExt = (f) => f.replace(/\.[cm]?[jt]sx?$/, '');
|
|
|
169
200
|
* (`../ui` → ui/index.ts) resolves to the copy inside that folder. A file that
|
|
170
201
|
* defines the name itself means its own. Returns [def] or null when unsure.
|
|
171
202
|
*/
|
|
172
|
-
function importedDef(src, file, name, defs) {
|
|
203
|
+
function importedDef(src, file, name, defs, workspaces = [], aliases = []) {
|
|
173
204
|
const own = defs.find((d) => d.file === file);
|
|
174
205
|
if (own) return [own];
|
|
175
206
|
for (const m of src.matchAll(/import\s+(?:type\s+)?([^'";]*?)\s+from\s*['"]([^'"]+)['"]/g)) {
|
|
@@ -181,7 +212,25 @@ function importedDef(src, file, name, defs) {
|
|
|
181
212
|
const spec = m[2];
|
|
182
213
|
let target = null, suffix = null;
|
|
183
214
|
if (spec.startsWith('.')) target = posix.normalize(posix.join(posix.dirname(file), spec));
|
|
184
|
-
else {
|
|
215
|
+
else {
|
|
216
|
+
const a = /^(?:@|~|#)\/(.+)$/.exec(spec);
|
|
217
|
+
// a tsconfig alias first: it is the repo's own statement of where the
|
|
218
|
+
// specifier points, and it can name a folder no workspace declares
|
|
219
|
+
const al = aliases.find((x) => spec === x.prefix || spec.startsWith(`${x.prefix}/`));
|
|
220
|
+
if (al) {
|
|
221
|
+
const rest = spec === al.prefix ? '' : spec.slice(al.prefix.length + 1);
|
|
222
|
+
target = posix.normalize(rest ? `${al.dir}/${rest}` : al.dir).replace(/\.[cm]?[jt]sx?$/, '');
|
|
223
|
+
} else if (a) suffix = a[1];
|
|
224
|
+
else {
|
|
225
|
+
// A workspace package by name (twenty-ui/input, @calcom/ui): the copy
|
|
226
|
+
// inside that package's folder. Until now every copy was credited,
|
|
227
|
+
// so Twenty's marketing-site Button carried the app's 274 uses too.
|
|
228
|
+
const ws = workspaces.find((w) => spec === w.name || spec.startsWith(`${w.name}/`));
|
|
229
|
+
if (!ws) return null;
|
|
230
|
+
const inside = defs.filter((d) => d.file.startsWith(`${ws.dir}/`));
|
|
231
|
+
return inside.length === 1 ? inside : null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
185
234
|
const hit = defs.filter((d) => {
|
|
186
235
|
const f = stripExt(d.file);
|
|
187
236
|
if (target !== null) return f === target || f === `${target}/index` || d.file.startsWith(`${target}/`);
|
|
@@ -226,6 +275,8 @@ export function harvestComponents(root, codeFiles) {
|
|
|
226
275
|
}
|
|
227
276
|
|
|
228
277
|
// Pass 2: usages (skip the defining file's own render of itself is fine to count)
|
|
278
|
+
const workspaces = resolveWorkspaces(root);
|
|
279
|
+
const aliases = tsconfigAliases(root);
|
|
229
280
|
const byName = new Map();
|
|
230
281
|
for (const c of components) {
|
|
231
282
|
if (!byName.has(c.name)) byName.set(c.name, []);
|
|
@@ -244,7 +295,7 @@ export function harvestComponents(root, codeFiles) {
|
|
|
244
295
|
// features/invoices each "used 12x" (8 and 4 in truth), so neither read
|
|
245
296
|
// as the canon (Ledgerly, 2026-09-24). Unresolved imports keep the old
|
|
246
297
|
// rule and credit every copy.
|
|
247
|
-
const owners = defs.length > 1 ? importedDef(src, file, name, defs) ?? defs : defs;
|
|
298
|
+
const owners = defs.length > 1 ? importedDef(src, file, name, defs, workspaces, aliases) ?? defs : defs;
|
|
248
299
|
for (const def of owners) {
|
|
249
300
|
if (def.file === file) continue; // internal render/recursion, not adoption
|
|
250
301
|
def.usageCount += n;
|
|
@@ -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
|
|
|
@@ -63,6 +65,9 @@ export { kitPaintFindings };
|
|
|
63
65
|
export { tokenTwinFindings, tokenDefsOf } from './tokentwins.mjs';
|
|
64
66
|
export { avoidedImportFindings, canonicalCopy } from './avoidedimports.mjs';
|
|
65
67
|
export { isChartFile, chartSystemOf, chartTier, chartFindings } from './charts.mjs';
|
|
68
|
+
// a button from scratch where the repo has a Button (9.2.0); learnSystem
|
|
69
|
+
// hands over `buttons`, `workspaces` and `packageDeps` for buttonFor
|
|
70
|
+
export { handmadeButtons, handmadeButtonFindings, buttonCandidates, buttonFor, BUTTON_MIN_USES } from './handmade.mjs';
|
|
66
71
|
|
|
67
72
|
// Radius, font size, shadow and typeface: the patterns, so both checkers agree
|
|
68
73
|
// on what a declaration is and what counts as a disciplined value.
|
|
@@ -137,8 +142,26 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
|
|
|
137
142
|
}
|
|
138
143
|
}
|
|
139
144
|
|
|
145
|
+
// the Button the repo answers with, per package (lib/handmade.mjs)
|
|
146
|
+
const workspaces = resolveWorkspaces(repoRoot);
|
|
147
|
+
const buttons = buttonCandidates(ledger, read, workspaces, tsconfigAliases(repoRoot));
|
|
148
|
+
const packageDeps = new Map();
|
|
149
|
+
for (const w of workspaces) {
|
|
150
|
+
let pj = null;
|
|
151
|
+
try { pj = JSON.parse(read(`${w.dir}/package.json`) ?? 'null'); } catch { /* not a package after all */ }
|
|
152
|
+
packageDeps.set(w.dir, new Set(Object.keys({ ...pj?.dependencies, ...pj?.devDependencies, ...pj?.peerDependencies })));
|
|
153
|
+
}
|
|
154
|
+
|
|
140
155
|
return {
|
|
141
156
|
tokenFile: t.tokenFile,
|
|
157
|
+
buttons,
|
|
158
|
+
workspaces,
|
|
159
|
+
packageDeps,
|
|
160
|
+
// The files that state the palette: token stylesheets, a Tailwind config,
|
|
161
|
+
// palette data, a kit theme. Only there is a token's raw value the
|
|
162
|
+
// definition; in any other file it is a paste of the value where the
|
|
163
|
+
// name belongs, and a guard should say so (9.1.3).
|
|
164
|
+
tokenSources: [...new Set([...(t.tokenSources ?? []), ...(kit?.themeFiles ?? []), t.tokenFile].filter(Boolean))],
|
|
142
165
|
colors: t.colors,
|
|
143
166
|
// Every named colour, variants included — not the counted palette. The
|
|
144
167
|
// 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.
|
|
4
|
+
export const VERSION = '9.2.0';
|
|
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
|
|
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(
|
|
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'));
|
|
@@ -23,6 +23,7 @@ import { oklab } from '../lib/color.mjs';
|
|
|
23
23
|
import { tokenTwinFindings } from '../lib/tokentwins.mjs';
|
|
24
24
|
import { avoidedImportFindings } from '../lib/avoidedimports.mjs';
|
|
25
25
|
import { isChartFile, chartFindings } from '../lib/charts.mjs';
|
|
26
|
+
import { handmadeButtonFindings } from '../lib/handmade.mjs';
|
|
26
27
|
|
|
27
28
|
// What this engine measures — shipped with every result, clean or not.
|
|
28
29
|
export const CHECKS = [
|
|
@@ -43,10 +44,11 @@ export const CHECKS = [
|
|
|
43
44
|
// MUI repo says the kit check ran.
|
|
44
45
|
export const KIT_CHECK = 'colours and pixel sizes written onto kit components where the theme has a value';
|
|
45
46
|
export const PALETTE_CHECK = 'palette classes where the theme names a colour';
|
|
47
|
+
export const BUTTON_CHECK = 'hand-made buttons where the repo has a Button';
|
|
46
48
|
export function checksFor(k) {
|
|
47
|
-
|
|
48
|
-
if (k?.
|
|
49
|
-
return
|
|
49
|
+
const list = k?.kit ? [...CHECKS, KIT_CHECK] : (k?.tailwind || k?.shadcn) ? [...CHECKS, PALETTE_CHECK] : [...CHECKS];
|
|
50
|
+
if (k?.buttons?.length) list.push(BUTTON_CHECK);
|
|
51
|
+
return list;
|
|
50
52
|
}
|
|
51
53
|
// "an MUI component", "an Ant Design component", "a Mantine component"
|
|
52
54
|
|
|
@@ -197,18 +199,27 @@ export function validateContent(content, k) {
|
|
|
197
199
|
|
|
198
200
|
// ---------- colours ----------
|
|
199
201
|
const seenHere = new Set();
|
|
202
|
+
const statesTokens = !!file && k.tokenSources?.has(file);
|
|
200
203
|
for (const c of got.colors) {
|
|
201
204
|
if (seenHere.has(`${c.value}:${c.index}`)) continue;
|
|
202
205
|
seenHere.add(`${c.value}:${c.index}`);
|
|
203
206
|
if (kitOwnsColours || chartFile) continue; // the kit judge or the chart rule said it, or judged it not paint
|
|
204
207
|
const info = k.colorInfo.get(c.value);
|
|
205
|
-
|
|
208
|
+
// A token's raw value is the definition only inside a file that states
|
|
209
|
+
// the palette (a token stylesheet, a Tailwind config, a kit theme). In a
|
|
210
|
+
// component it is the value pasted where the name belongs: the system
|
|
211
|
+
// cannot see it, and the next reader copies the hex. Until 9.1.3 every
|
|
212
|
+
// exact match walked free, and hooked agents on Twenty wrote
|
|
213
|
+
// background: #4a38f5 with a clean bill (2026-09-29).
|
|
214
|
+
if (info?.isToken && statesTokens) continue;
|
|
206
215
|
const near = nearestToken(c.value, k);
|
|
207
216
|
const priors = discount(info?.count, localColor, c.value);
|
|
208
217
|
if (near && near.d === 0) {
|
|
218
|
+
const names = k.tokenNames?.get(c.value) ?? [];
|
|
219
|
+
const by = names.length ? `${css ? `var(${names[0]})` : names[0]}` : `the token that already holds ${near.value}`;
|
|
209
220
|
add('hardcoded-colour', 'violation', c.index,
|
|
210
221
|
`Hardcoded colour ${c.value} duplicates an existing token value.`,
|
|
211
|
-
`Use
|
|
222
|
+
`Use ${by}${k.tokens.tokenFile ? ` (defined in ${k.tokens.tokenFile})` : ''}.`);
|
|
212
223
|
} else if (near && near.d <= 8) {
|
|
213
224
|
add('near-token-twin', 'violation', c.index,
|
|
214
225
|
`${c.value} is visually identical to the token ${near.value}.`,
|
|
@@ -224,6 +235,11 @@ export function validateContent(content, k) {
|
|
|
224
235
|
}
|
|
225
236
|
}
|
|
226
237
|
|
|
238
|
+
// ---------- the choice, not the value: a button from scratch ----------
|
|
239
|
+
// The value checks above cannot see it. Hooked agents on Twenty passed
|
|
240
|
+
// them and still hand-made the button four times in five (2026-09-29).
|
|
241
|
+
if (!css) for (const f of handmadeButtonFindings(text, { file }, k)) add(f.rule, f.severity, f.index, f.message, f.fix);
|
|
242
|
+
|
|
227
243
|
// ---------- spacing ----------
|
|
228
244
|
for (const s of got.spacing) {
|
|
229
245
|
if (kitPx.has(s.value)) continue; // the kit line above said it
|
|
@@ -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';
|
|
@@ -80,6 +82,14 @@ export function loadKnowledge(root) {
|
|
|
80
82
|
const colorInfo = new Map(); // value → { count, isToken }
|
|
81
83
|
for (const c of tokens.colors) colorInfo.set(c.value, { count: c.count, isToken: c.isToken });
|
|
82
84
|
const tokenColors = [...colorInfo.entries()].filter(([, i]) => i.isToken).map(([v]) => v);
|
|
85
|
+
// value → the custom-property names that hold it, so a check can say
|
|
86
|
+
// "use var(--color-brand)" rather than only "use the token"
|
|
87
|
+
const tokenNames = new Map();
|
|
88
|
+
for (const c of tokens.colors) if (c.names?.length) tokenNames.set(c.value, c.names);
|
|
89
|
+
// the files that state the palette (token stylesheets, a Tailwind config,
|
|
90
|
+
// a kit theme, palette data): the only files where a token's raw value is
|
|
91
|
+
// the definition rather than a paste of it
|
|
92
|
+
const tokenSources = new Set([...(tokens.tokenSources ?? []), ...(kit?.themeFiles ?? []), tokens.tokenFile].filter(Boolean));
|
|
83
93
|
|
|
84
94
|
// spacing the repo already uses: raw CSS values with counts, plus whether
|
|
85
95
|
// the repo styles spacing through Tailwind at all (decides what "on-scale" means)
|
|
@@ -123,6 +133,18 @@ export function loadKnowledge(root) {
|
|
|
123
133
|
|
|
124
134
|
const neverImported = neverImportedComponents(components, profile.uiDir);
|
|
125
135
|
|
|
136
|
+
// The Button the repo answers with, per package: the candidates, and what
|
|
137
|
+
// each workspace package depends on, so a file is pointed at a Button it
|
|
138
|
+
// can actually import (lib/handmade.mjs).
|
|
139
|
+
const readK = (f) => readSource(join(root, f));
|
|
140
|
+
const buttons = buttonCandidates(components, readK, workspaces, tsconfigAliases(root));
|
|
141
|
+
const packageDeps = new Map();
|
|
142
|
+
for (const w of workspaces) {
|
|
143
|
+
let pj = null;
|
|
144
|
+
try { pj = JSON.parse(readK(`${w.dir}/package.json`) ?? 'null'); } catch { /* not a package after all */ }
|
|
145
|
+
packageDeps.set(w.dir, new Set(Object.keys({ ...pj?.dependencies, ...pj?.devDependencies, ...pj?.peerDependencies })));
|
|
146
|
+
}
|
|
147
|
+
|
|
126
148
|
// The copies of each duplicated name with their usage, and the colours each
|
|
127
149
|
// copy hard-codes: what the avoided-import check names (lib/avoidedimports).
|
|
128
150
|
const read = (file) => readSource(join(root, file));
|
|
@@ -152,6 +174,10 @@ export function loadKnowledge(root) {
|
|
|
152
174
|
workspaces,
|
|
153
175
|
// indexes
|
|
154
176
|
colorInfo,
|
|
177
|
+
buttons,
|
|
178
|
+
packageDeps,
|
|
179
|
+
tokenNames,
|
|
180
|
+
tokenSources,
|
|
155
181
|
tokenColors,
|
|
156
182
|
tokenColorRgb: tokenColors.map((v) => ({ value: v, rgb: hexRgb(v) })).filter((t) => t.rgb),
|
|
157
183
|
spacingSeen,
|