roast-my-design-system 9.2.0 → 9.2.2
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 +5 -5
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/diagnose/index.mjs +33 -11
- package/skills/roast-my-design-system/scripts/harvest/components.mjs +28 -4
- package/skills/roast-my-design-system/scripts/harvest/duplicates.mjs +53 -10
- package/skills/roast-my-design-system/scripts/lib/guard-api.mjs +16 -1
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/mcp/engine.mjs +42 -22
- package/skills/roast-my-design-system/scripts/mcp/hook.mjs +3 -2
- package/skills/roast-my-design-system/scripts/mcp/knowledge.mjs +8 -1
- package/skills/roast-my-design-system/scripts/mcp/tools.mjs +9 -6
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.
|
|
20
|
+
**Current release: 9.2.** Version history in [CHANGELOG.md](CHANGELOG.md).
|
|
21
21
|
|
|
22
22
|
## The idea
|
|
23
23
|
|
|
@@ -73,7 +73,7 @@ The first thing the report says: the places where the repo has no answer yet, or
|
|
|
73
73
|
|
|
74
74
|
One gap is known for certain today, because the runs found it: charts hardcoding colours in a repo with no chart palette. 43 of 126 public repos look like that. A gap joins the list when it has been measured.
|
|
75
75
|
|
|
76
|
-
###
|
|
76
|
+
### Fixes you can make right now to increase the health score
|
|
77
77
|
|
|
78
78
|
The mess the agent will copy, ranked by what fixing it is worth.
|
|
79
79
|
|
|
@@ -142,11 +142,11 @@ Eleven reports, hosted exactly as the tool writes them. Every number determinist
|
|
|
142
142
|
|
|
143
143
|
The full report for vercel/ai-chatbot. The verdict answers what the agent will learn here, the gap section comes first, then "What the repo teaches the agent", Claude's read of the scan:
|
|
144
144
|
|
|
145
|
-

|
|
146
146
|
|
|
147
147
|
The same report in light mode (one file, built-in toggle):
|
|
148
148
|
|
|
149
|
-

|
|
150
150
|
|
|
151
151
|
## Why the numbers hold
|
|
152
152
|
|
|
@@ -347,7 +347,7 @@ You get the roast in chat plus `design-system-roast.html` at your repo root, a s
|
|
|
347
347
|
- stat tiles comparing you to all 3 yardsticks
|
|
348
348
|
- a **light/dark theme toggle** in one file
|
|
349
349
|
- the usage-weighted palette bar, the grey ramp, the off-scale spacing receipts, the duplicate-component receipts with clickable file paths, and the worst-offenders ledger
|
|
350
|
-
- a **
|
|
350
|
+
- a **Fixes you can make right now to increase the health score** list: up to 3 moves derived from your repo's own numbers, each with a file-path receipt
|
|
351
351
|
- **Where will your agent have to guess?** opens the report: the gaps where the repo has no answer yet, with the files that prove each one and the move that closes it, or "No gaps found"
|
|
352
352
|
- **Give the agent the answers**: `design-system-rules.md` wrapped inside the report as a present. Unwrap, then copy or download the agent rules generated from your scan
|
|
353
353
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "9.2.
|
|
3
|
+
"version": "9.2.2",
|
|
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": [
|
|
@@ -1201,17 +1201,22 @@ function whereToStartSection() {
|
|
|
1201
1201
|
}
|
|
1202
1202
|
|
|
1203
1203
|
// What each move is actually worth, then rank by payoff for real.
|
|
1204
|
+
// A move with no points says why, so the list never promises what the
|
|
1205
|
+
// score cannot give: the thing it fixes is not scored, or its tile is
|
|
1206
|
+
// already green, or the move lands inside the band it started in.
|
|
1204
1207
|
for (const item of c) {
|
|
1205
|
-
item.delta = 0; item.target = null;
|
|
1208
|
+
item.delta = 0; item.target = null; item.why = 'not part of the score';
|
|
1206
1209
|
if (!item.metric) continue;
|
|
1207
|
-
const
|
|
1208
|
-
|
|
1210
|
+
const tile = bigStats.find((st) => st.metric === item.metric);
|
|
1211
|
+
const now = tile?.healthValue;
|
|
1212
|
+
if (now === undefined || !(tile.health in SCORE_OF)) continue;
|
|
1209
1213
|
if (item.after !== undefined) {
|
|
1210
1214
|
const gain = bandPoints(item.metric, now, item.after);
|
|
1211
1215
|
if (gain > 0) { item.delta = gain; continue; }
|
|
1212
1216
|
}
|
|
1213
1217
|
const nb = nextBand(item.metric, now);
|
|
1214
1218
|
if (nb && nb.gain > 0) item.target = nb;
|
|
1219
|
+
else item.why = healthOf(item.metric, now) === 'good' ? 'already green' : 'no points on its own';
|
|
1215
1220
|
}
|
|
1216
1221
|
const top3 = c.sort((a, b) => (b.delta - a.delta) || (b.score - a.score)).slice(0, 3);
|
|
1217
1222
|
if (!top3.length) return '';
|
|
@@ -1220,16 +1225,32 @@ function whereToStartSection() {
|
|
|
1220
1225
|
const applied = new Map();
|
|
1221
1226
|
for (const item of top3) if (item.metric && item.after !== undefined && item.delta > 0) applied.set(item.metric, item.after);
|
|
1222
1227
|
const after = projectedScore(applied);
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
+
// The line under the heading counts only the moves that pay, and says what
|
|
1229
|
+
// the others are. "Three fixes to increase your score" stood over a list
|
|
1230
|
+
// where 1 of the 3 could, by 4 points (shadcn-admin, 2026-09-29).
|
|
1231
|
+
const paying = healthScore !== null && after > healthScore ? top3.filter((item) => item.delta > 0) : [];
|
|
1232
|
+
const later = top3.filter((item) => !paying.includes(item) && item.target);
|
|
1233
|
+
startProjection = { count: paying.length, after };
|
|
1234
|
+
const COUNT = ['one', 'two', 'three'];
|
|
1235
|
+
const cap = (s) => s[0].toUpperCase() + s.slice(1);
|
|
1236
|
+
const total = top3.length;
|
|
1237
|
+
const none = total - paying.length - later.length;
|
|
1238
|
+
const ofThese = (k) => (k === total
|
|
1239
|
+
? `${cap(COUNT[k - 1])} ${k === 1 ? 'fix' : 'fixes'}`
|
|
1240
|
+
: `${cap(COUNT[k - 1])} of these ${COUNT[total - 1]} fixes`);
|
|
1241
|
+
const inFull = (k) => `once ${k === 1 ? 'it is' : 'they are'} done in full`;
|
|
1242
|
+
const rest = none ? ` The remaining ${none === 1 ? 'one does' : `${COUNT[none - 1]} do`} not move the score.` : '';
|
|
1243
|
+
const head = paying.length
|
|
1244
|
+
? `${ofThese(paying.length)} could raise the design system health score from <b class="proj">${healthScore} to ${after}</b>.${later.length ? ` ${cap(COUNT[later.length - 1])} more ${later.length === 1 ? 'pays' : 'pay'} ${inFull(later.length)}.` : ''}${rest}`
|
|
1245
|
+
: later.length
|
|
1246
|
+
? `${ofThese(later.length)} ${later.length === 1 ? `raises the score, by ${later[0].target.gain},` : 'raise the score'} ${inFull(later.length)}.${rest}`
|
|
1247
|
+
: `${total === 1 ? 'It does' : 'They do'} not move the score. ${total === 1 ? 'It is' : 'They are'} here because ${total === 1 ? 'it stops' : 'each one stops'} the agent guessing.`;
|
|
1248
|
+
const title = paying.length || later.length ? 'Fixes you can make right now to increase the health score' : 'Fixes you can make right now';
|
|
1228
1249
|
const chip = (item) => item.delta > 0
|
|
1229
1250
|
? `<span class="delta" title="once all of them are done">+${item.delta}</span>`
|
|
1230
1251
|
: item.target
|
|
1231
1252
|
? `<span class="delta delta-target">${item.target.target === 0 ? 'clear them all' : `under ${n(item.target.target)}`} · +${item.target.gain}</span>`
|
|
1232
|
-
:
|
|
1253
|
+
: `<span class="delta delta-none">${item.why}</span>`;
|
|
1233
1254
|
// The copy button beside each move hands the finding to whatever agent the
|
|
1234
1255
|
// reader pastes it into: one move per prompt, so progress stays visible and
|
|
1235
1256
|
// finishable. Prompt text is composed from the same title/sub the row shows
|
|
@@ -1251,7 +1272,7 @@ function whereToStartSection() {
|
|
|
1251
1272
|
delta: item.delta || 0, prompt: promptFor(item),
|
|
1252
1273
|
}));
|
|
1253
1274
|
return `<section class="glass pad">
|
|
1254
|
-
${sectionHead(
|
|
1275
|
+
${sectionHead(title, head)}
|
|
1255
1276
|
<div class="ledger">${top3.map((item, i) => `
|
|
1256
1277
|
<div class="ledger-row start-row">
|
|
1257
1278
|
<span class="ledger-idx">${String(i + 1).padStart(2, '0')}</span>
|
|
@@ -1651,7 +1672,7 @@ function exceptionsBlock() {
|
|
|
1651
1672
|
function addIndex(page) {
|
|
1652
1673
|
// heading text is already HTML-escaped; strip tags and leading counts only
|
|
1653
1674
|
const short = (t) => t.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().replace(/^[\d,]+ /, '')
|
|
1654
|
-
.replace(/^What your AI agent sees today$/, 'What your agent sees').replace(/^Give the agent the answers$/, 'Agent rules').replace(/^What the repo teaches the agent$/, 'What it teaches').replace(/^
|
|
1675
|
+
.replace(/^What your AI agent sees today$/, 'What your agent sees').replace(/^Give the agent the answers$/, 'Agent rules').replace(/^What the repo teaches the agent$/, 'What it teaches').replace(/^Fixes you can make right now( to increase the health score)?$/, 'What to fix')
|
|
1655
1676
|
.replace(/^The shadcn theme and the 2 shadcn checks$/, 'shadcn theme and checks')
|
|
1656
1677
|
.replace(/^Your Tailwind theme, and what goes around it$/, 'Your Tailwind theme').replace(/^Your (\w+) theme, and what is written around it$/, 'Your $1 theme').replace(/, declared .*$/, '')
|
|
1657
1678
|
.replace(/^off-scale spacing values$/, 'Off-scale spacing').replace(/^inline style blocks?$/, 'Inline styles').replace(/^typefaces?$/, 'Typefaces');
|
|
@@ -2047,6 +2068,7 @@ ${ogTags()}
|
|
|
2047
2068
|
.delta { font:700 11px/1 var(--sans); letter-spacing:.04em; padding:5px 9px; border-radius:99px;
|
|
2048
2069
|
background:var(--ok-soft); color:var(--ok); white-space:nowrap; }
|
|
2049
2070
|
.delta-target { background:var(--amber-soft); color:var(--amber); font-weight:600; }
|
|
2071
|
+
.delta-none { background:transparent; box-shadow:inset 0 0 0 1px var(--line-soft); color:var(--dim); font-weight:500; letter-spacing:0; }
|
|
2050
2072
|
.proj { color:var(--accent); font-weight:700; }
|
|
2051
2073
|
.ndot { display:inline-block; width:9px; height:9px; border-radius:3px; margin-right:4px; vertical-align:-1px; box-shadow:inset 0 0 0 1px var(--cell-ring); }
|
|
2052
2074
|
.nsim { color:var(--dim2); font-weight:400; padding:0 2px; }
|
|
@@ -158,7 +158,10 @@ export function definedComponents(src) {
|
|
|
158
158
|
return [...names];
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
// A file with no markup defines no component the report can count: a stub
|
|
162
|
+
// returning null, a wrapper built with a factory call. Asked by the harvest
|
|
163
|
+
// and by the live checks, so both read the same files as component files.
|
|
164
|
+
export const looksLikeJSXFile = (src) => /<[A-Za-z][\w.]*[\s/>]/.test(src);
|
|
162
165
|
|
|
163
166
|
// ---------- web components: the tag-registered world ----------
|
|
164
167
|
// Stencil, Lit and raw customElements.define register components by TAG, and
|
|
@@ -241,6 +244,28 @@ function importedDef(src, file, name, defs, workspaces = [], aliases = []) {
|
|
|
241
244
|
return null;
|
|
242
245
|
}
|
|
243
246
|
|
|
247
|
+
const JSX_FILE_RE = /\.(tsx|jsx|js)$/;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Every component a file defines, by either door the harvest reads: a React
|
|
251
|
+
* component in a file with markup in it, and a web component registered by
|
|
252
|
+
* tag (Stencil, Lit, customElements.define), generated bindings left out.
|
|
253
|
+
* The live checks ask here, so a file defines for them what it defines for
|
|
254
|
+
* the report.
|
|
255
|
+
*/
|
|
256
|
+
export function componentNamesIn(src, file = null) {
|
|
257
|
+
// .js too, never .ts: a TypeScript generic reads like a tag, and the
|
|
258
|
+
// harvest takes React components from the same three extensions
|
|
259
|
+
const react = (!file || JSX_FILE_RE.test(file)) && looksLikeJSXFile(src) ? definedComponents(src) : [];
|
|
260
|
+
const web = GENERATED_RE.test(src.slice(0, 2000)) ? [] : webComponentDefs(src).map((d) => d.name);
|
|
261
|
+
return [...new Set([...react, ...web])];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// A page is a route, not a reusable part. Asked by the harvest for every file
|
|
265
|
+
// it reads and by the live checks for the one file under review.
|
|
266
|
+
export const isPageFile = (file) => /(^|\/)(app\/.*page|pages\/(?!_app|_document|api))/.test(file)
|
|
267
|
+
|| /(^|\/)app\/.*\/(layout|template|error|loading)\.(tsx|jsx)$/.test(file);
|
|
268
|
+
|
|
244
269
|
/**
|
|
245
270
|
* Harvest all components: definitions (with variants/props) + usages across the
|
|
246
271
|
* repo. `codeFiles` are relative paths; returns { components, totalDefined }.
|
|
@@ -248,7 +273,7 @@ function importedDef(src, file, name, defs, workspaces = [], aliases = []) {
|
|
|
248
273
|
export function harvestComponents(root, codeFiles) {
|
|
249
274
|
// .js too — CRA-era repos (a big slice of the messy-repo audience) put JSX in
|
|
250
275
|
// plain .js files; requiring .tsx/.jsx made whole repos read as empty.
|
|
251
|
-
const jsxFiles = codeFiles.filter((f) =>
|
|
276
|
+
const jsxFiles = codeFiles.filter((f) => JSX_FILE_RE.test(f));
|
|
252
277
|
const sources = new Map();
|
|
253
278
|
for (const f of jsxFiles) {
|
|
254
279
|
const src = readSource(join(root, f));
|
|
@@ -259,8 +284,7 @@ export function harvestComponents(root, codeFiles) {
|
|
|
259
284
|
const components = [];
|
|
260
285
|
for (const [file, src] of sources) {
|
|
261
286
|
if (!looksLikeJSXFile(src)) continue;
|
|
262
|
-
const isPage =
|
|
263
|
-
|| /(^|\/)app\/.*\/(layout|template|error|loading)\.(tsx|jsx)$/.test(file);
|
|
287
|
+
const isPage = isPageFile(file);
|
|
264
288
|
for (const name of definedComponents(src)) {
|
|
265
289
|
components.push({
|
|
266
290
|
name,
|
|
@@ -54,8 +54,59 @@ const ROUTE_FRAGMENT_RE = /(^|\/)app\/.*\/(form|header|footer|nav|page|layout|lo
|
|
|
54
54
|
// and _dashboard.analytics.tsx both export Page, 2026-09-17). A story is a
|
|
55
55
|
// demo of a component, not a second implementation of it (medplum keeps a
|
|
56
56
|
// MockDateWrapper in two stories folders).
|
|
57
|
-
|
|
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
|
-
|
|
70
|
-
// web Footer); cross-matching them manufactures duplicates.
|
|
71
|
-
const EMAIL_PATH_RE = /(^|\/)emails?(\/|-)/i;
|
|
72
|
-
const comps = components.filter((c) => !c.isPage && !NOT_DUPLICATES.has(c.name)
|
|
73
|
-
&& !ROUTE_FRAGMENT_RE.test(c.file) && !ROUTE_FILE_RE.test(c.file)
|
|
74
|
-
&& !STORY_FILE_RE.test(c.file) && !EMAIL_PATH_RE.test(c.file));
|
|
120
|
+
const comps = components.filter(canBeDuplicate);
|
|
75
121
|
|
|
76
122
|
// 1. exact same component name defined in >1 file
|
|
77
123
|
const byName = new Map();
|
|
@@ -88,9 +134,6 @@ export function findDuplicates(components, uiDir = null, root = null, uiDirs = n
|
|
|
88
134
|
}))
|
|
89
135
|
.sort((a, b) => b.files.length - a.files.length);
|
|
90
136
|
|
|
91
|
-
// Same-name components living in different icons/ directories are ONE
|
|
92
|
-
// problem — two icon libraries colliding — not N separate duplicates.
|
|
93
|
-
const ICON_DIR_RE = /(^|\/)icons?\//i;
|
|
94
137
|
const iconCollisions = exactDuplicates.filter((d) => d.files.every((f) => ICON_DIR_RE.test(f)));
|
|
95
138
|
exactDuplicates = exactDuplicates.filter((d) => !iconCollisions.includes(d));
|
|
96
139
|
|
|
@@ -35,6 +35,21 @@ export { extractStyling, normalizeHex, isGrey, hexRgb, typefaceOf, GENERIC_FONTS
|
|
|
35
35
|
// The components a piece of text defines. With the ledger below, a guard can
|
|
36
36
|
// tell a second <Button> from an edit to the first one.
|
|
37
37
|
export { definedComponents };
|
|
38
|
+
// Duplicates, by the report's own answer (9.2.1). duplicateCopies(def,
|
|
39
|
+
// copies, counted, same) returns the other copies that make one definition a
|
|
40
|
+
// duplicate, or none: `copies` is the ledger's entries for the name
|
|
41
|
+
// (learnSystem's components), `counted` the files of the duplicate the report
|
|
42
|
+
// counts under it (the copies in learnSystem's duplicates), `same` how two
|
|
43
|
+
// paths compare. canBeDuplicate({ name, file, isPage }) is the first question
|
|
44
|
+
// it asks: a framework's Route, a page, a route file, the crash page, a story
|
|
45
|
+
// and an email template repeat by design. isPageFile(file) answers isPage for
|
|
46
|
+
// a file not in the ledger. componentNamesIn(text, file) lists what a whole
|
|
47
|
+
// file defines for the report: React components in a .tsx, .jsx or .js file
|
|
48
|
+
// with markup in it, and web components registered by tag. The exemptions
|
|
49
|
+
// (exemptReason) are about styling, so a duplicate is still a duplicate in
|
|
50
|
+
// an exempt file.
|
|
51
|
+
export { canBeDuplicate, duplicateCopies } from '../harvest/duplicates.mjs';
|
|
52
|
+
export { isPageFile, looksLikeJSXFile, componentNamesIn } from '../harvest/components.mjs';
|
|
38
53
|
|
|
39
54
|
// The files no checker should judge. Exported so a guard reads the same list
|
|
40
55
|
// as the engine rather than keeping a copy that drifts.
|
|
@@ -115,7 +130,7 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
|
|
|
115
130
|
// the same two lists the MCP knowledge builds (mcp/knowledge.mjs), from the
|
|
116
131
|
// same helpers, for tokenTwinFindings and avoidedImportFindings (8.6.1)
|
|
117
132
|
const read = (f) => readSource(join(repoRoot, f));
|
|
118
|
-
const hardDupes = (findDuplicates(ledger, profile.uiDir, repoRoot).exactDuplicates ?? []).filter((d) => !d.wrapped);
|
|
133
|
+
const hardDupes = (findDuplicates(ledger, profile.uiDir, repoRoot, profile.uiDirs ?? null).exactDuplicates ?? []).filter((d) => !d.wrapped);
|
|
119
134
|
const P = profileOf(profile);
|
|
120
135
|
const installedNow = installedDirs(P);
|
|
121
136
|
const charts = chartSystemOf(files, read, { own: (f) => !installedNow.some((d) => f === d || f.startsWith(`${d}/`)) });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Single version constant for the engine — imported by diagnose (report
|
|
2
2
|
// footer) and rules (generated-by line). This is the bump spot that used to
|
|
3
3
|
// live as a const inside diagnose/index.mjs.
|
|
4
|
-
export const VERSION = '9.2.
|
|
4
|
+
export const VERSION = '9.2.2';
|
|
5
5
|
// The shape of harvest.json and summary.json. Bumped only when a field is
|
|
6
6
|
// renamed, removed or changes meaning; a new field is not a new schema. A
|
|
7
7
|
// tool comparing two scans compares like with like by this number, not by VERSION.
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* in the repo means saying so, not inventing one.
|
|
12
12
|
*/
|
|
13
13
|
import { extractStyling } from '../harvest/tokens.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import { componentNamesIn, isPageFile } from '../harvest/components.mjs';
|
|
15
|
+
import { duplicateCopies } from '../harvest/duplicates.mjs';
|
|
15
16
|
import { hexRgb } from '../lib/nearpairs.mjs';
|
|
16
17
|
import { exemptReason } from '../lib/exempt.mjs';
|
|
17
18
|
import { extraDeclarations, fontDeclarations } from '../lib/declarations.mjs';
|
|
@@ -83,6 +84,29 @@ function nearestToken(value, k) {
|
|
|
83
84
|
return best;
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The report's answer on duplicates, asked of one file: a name that repeats
|
|
89
|
+
* by design, a pair the report lists without counting and a file that
|
|
90
|
+
* defines nothing the report reads are not duplicates here either
|
|
91
|
+
* (harvest/duplicates.mjs).
|
|
92
|
+
*/
|
|
93
|
+
function duplicateFindings({ text, file, k, add }) {
|
|
94
|
+
for (const name of componentNamesIn(text, file)) {
|
|
95
|
+
const dupe = k.dupeByName.get(name);
|
|
96
|
+
const counted = dupe ? dupe.files.map((f) => (typeof f === 'string' ? f : f.file)) : null;
|
|
97
|
+
const existing = duplicateCopies({ name, file: file ?? '', isPage: !!file && isPageFile(file) }, k.byName.get(name), counted);
|
|
98
|
+
if (!existing.length) continue;
|
|
99
|
+
// the scan may already include this very file — count the OTHER copies
|
|
100
|
+
const otherCopies = counted ? counted.filter((f) => f !== file) : null;
|
|
101
|
+
const best = [...existing].sort((a, b) => b.usageCount - a.usageCount)[0];
|
|
102
|
+
add('duplicate-component', 'violation', text.indexOf(name),
|
|
103
|
+
otherCopies && otherCopies.length > 1
|
|
104
|
+
? `Defines <${name}>, which already exists in ${otherCopies.length} other places. This makes ${otherCopies.length + 1} competing copies, and every wrong pick becomes the example the next agent copies.`
|
|
105
|
+
: `Defines <${name}>, but ${best.file} already defines it${best.usageCount ? ` (used ${best.usageCount}x)` : ''}.`,
|
|
106
|
+
`Import ${best.file} instead of creating a copy.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
86
110
|
/**
|
|
87
111
|
* Validate one piece of content against the repo's knowledge.
|
|
88
112
|
* @param content { text, file?, before? } — file name decides css-vs-code
|
|
@@ -91,7 +115,8 @@ function nearestToken(value, k) {
|
|
|
91
115
|
* unknown), so a token or an import the change ADDS can be told from one
|
|
92
116
|
* that was already there
|
|
93
117
|
* @returns { findings: [{ rule, severity, line, message, fix? }], checked,
|
|
94
|
-
* exempt? } — exempt is a sentence saying why the file was not
|
|
118
|
+
* exempt? } — exempt is a sentence saying why the file's styling was not
|
|
119
|
+
* judged; its findings are then duplicates only, and checked says so
|
|
95
120
|
*/
|
|
96
121
|
export function validateContent(content, k) {
|
|
97
122
|
const { text, file = null, before } = content;
|
|
@@ -99,13 +124,20 @@ export function validateContent(content, k) {
|
|
|
99
124
|
// checker earns its reputation for crying wolf, and a checker people
|
|
100
125
|
// distrust gets switched off. Say nothing, and say why nothing was said.
|
|
101
126
|
const exempt = exemptReason(file, text);
|
|
102
|
-
if (exempt) return { findings: [], checked: checksFor(k), exempt };
|
|
103
127
|
const css = file ? CSS_FILE_RE.test(file) : looksLikeCss(text);
|
|
104
|
-
const got = extractStyling(text, { css });
|
|
105
128
|
const findings = [];
|
|
106
129
|
const add = (rule, severity, index, message, fix) => findings.push({
|
|
107
130
|
rule, severity, line: lineOf(text, index), message, ...(fix ? { fix } : {}),
|
|
108
131
|
});
|
|
132
|
+
// The exemption is about styling: what an email, a drawing or a crash page
|
|
133
|
+
// cannot take from the system. A second copy of a component is a second
|
|
134
|
+
// copy in any medium, and the report counts it, so that one check still
|
|
135
|
+
// runs and the result says it was the only one.
|
|
136
|
+
if (exempt) {
|
|
137
|
+
if (!css) duplicateFindings({ text, file, k, add });
|
|
138
|
+
return { findings, checked: css ? [] : ['duplicate component definitions'], exempt };
|
|
139
|
+
}
|
|
140
|
+
const got = extractStyling(text, { css });
|
|
109
141
|
|
|
110
142
|
// The knowledge scan includes the working tree, so a file under review has
|
|
111
143
|
// already leaked its own values into the repo counts — without a discount,
|
|
@@ -258,9 +290,13 @@ export function validateContent(content, k) {
|
|
|
258
290
|
// a raw value the repo already uses, in a non-Tailwind repo, is consistency,
|
|
259
291
|
// not a new offence — silence
|
|
260
292
|
}
|
|
293
|
+
// Bracket values inside installed code (the shadcn catalogue, kit blocks,
|
|
294
|
+
// registries) are the kit's own choices. The report names them and keeps
|
|
295
|
+
// them out of the count; a file under review is judged by the same line.
|
|
296
|
+
const installed = !!file && (k.installedDirs ?? []).some((d) => file === d || file.startsWith(`${d}/`));
|
|
261
297
|
const localArb = new Map();
|
|
262
298
|
for (const a of got.arbitrary) localArb.set(a.value, (localArb.get(a.value) ?? 0) + 1);
|
|
263
|
-
for (const a of got.arbitrary) {
|
|
299
|
+
for (const a of installed ? [] : got.arbitrary) {
|
|
264
300
|
const repoArb = (k.tokens.tailwind?.arbitrary ?? []).find((x) => x.value === a.value);
|
|
265
301
|
const priorArb = discount(repoArb?.count, localArb, a.value);
|
|
266
302
|
add('arbitrary-value', 'violation', a.index,
|
|
@@ -345,23 +381,7 @@ export function validateContent(content, k) {
|
|
|
345
381
|
}
|
|
346
382
|
|
|
347
383
|
// ---------- duplicate components ----------
|
|
348
|
-
if (!css) {
|
|
349
|
-
for (const name of definedComponents(text)) {
|
|
350
|
-
const existing = (k.byName.get(name) ?? []).filter((c) => !file || c.file !== file);
|
|
351
|
-
if (!existing.length) continue;
|
|
352
|
-
const dupe = k.dupeByName.get(name);
|
|
353
|
-
// the scan may already include this very file — count the OTHER copies
|
|
354
|
-
const otherCopies = dupe
|
|
355
|
-
? dupe.files.map((f) => (typeof f === 'string' ? f : f.file)).filter((f) => f !== file)
|
|
356
|
-
: null;
|
|
357
|
-
const best = [...existing].sort((a, b) => b.usageCount - a.usageCount)[0];
|
|
358
|
-
add('duplicate-component', 'violation', text.indexOf(name),
|
|
359
|
-
otherCopies && otherCopies.length > 1
|
|
360
|
-
? `Defines <${name}>, which already exists in ${otherCopies.length} other places. This makes ${otherCopies.length + 1} competing copies, and every wrong pick becomes the example the next agent copies.`
|
|
361
|
-
: `Defines <${name}>, but ${best.file} already defines it${best.usageCount ? ` (used ${best.usageCount}x)` : ''}.`,
|
|
362
|
-
`Import ${best.file} instead of creating a copy.`);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
384
|
+
if (!css) duplicateFindings({ text, file, k, add });
|
|
365
385
|
|
|
366
386
|
// ---------- an import of the copy the canon replaces ----------
|
|
367
387
|
if (!css) {
|
|
@@ -114,7 +114,8 @@ function judge(real, root, k, warned = new Set()) {
|
|
|
114
114
|
const text = readFileSync(real, 'utf8');
|
|
115
115
|
const before = beforeOf(k, rel);
|
|
116
116
|
const { findings: all, exempt } = validateContent({ text, file: rel, before }, k);
|
|
117
|
-
|
|
117
|
+
// an exempt file is judged on duplicates only; with none, there is nothing to say
|
|
118
|
+
if (!all.length) return null;
|
|
118
119
|
// Only what this edit added. A legacy file carries its old findings on
|
|
119
120
|
// every touch; repeating them after each edit is noise, and the review
|
|
120
121
|
// (--check, roast_review) already reports them once at the end. Same rule
|
|
@@ -129,5 +130,5 @@ function judge(real, root, k, warned = new Set()) {
|
|
|
129
130
|
return true;
|
|
130
131
|
});
|
|
131
132
|
if (!findings.length) return null;
|
|
132
|
-
return { text: `Design-system check of ${rel} (roast-my-design-system, the same engine as roast_validate): ${findingLines(findings, k).join('\n')}`, total: findings.length };
|
|
133
|
+
return { text: `Design-system check of ${rel} (roast-my-design-system, the same engine as roast_validate): ${findingLines(findings, k, exempt).join('\n')}`, total: findings.length };
|
|
133
134
|
}
|
|
@@ -74,7 +74,10 @@ export function loadKnowledge(root) {
|
|
|
74
74
|
// same shape as a harvested colour, so every reader of `files` keeps working
|
|
75
75
|
for (const v of themeSet) if (!tokens.colors.some((c) => c.value === v)) tokens.colors.push({ value: v, count: 1, files: [{ file: kit.themeFiles[0] ?? '', count: 1 }], isToken: true });
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
// every catalogue folder, as the report passes them (harvest/index.mjs): a
|
|
78
|
+
// monorepo with two installs has two, and shadcn's own overlap in the
|
|
79
|
+
// second is no more the team's than in the first
|
|
80
|
+
const duplicates = findDuplicates(components, profile.uiDir, root, profile.uiDirs ?? null);
|
|
78
81
|
const context = harvestContext(root);
|
|
79
82
|
const workspaces = resolveWorkspaces(root);
|
|
80
83
|
|
|
@@ -196,6 +199,10 @@ export function loadKnowledge(root) {
|
|
|
196
199
|
neverImported,
|
|
197
200
|
// stock, not debt: see profiles/index.mjs
|
|
198
201
|
vendoredUi: P.vendoredUi,
|
|
202
|
+
// folders the team did not write, by the same line the guard doorway
|
|
203
|
+
// draws (lib/guard-api.mjs): only a shadcn kit has installed code, and a
|
|
204
|
+
// registry's published folders are its own work
|
|
205
|
+
installedDirs: P.isShadcn && !P.isRegistry ? installed : [],
|
|
199
206
|
// the kit the product is built on, and the Tailwind theme it names its
|
|
200
207
|
// colours in: null when the repo is neither (see profiles/)
|
|
201
208
|
kit,
|
|
@@ -306,19 +306,22 @@ export function validate(k, { code, file = null } = {}) {
|
|
|
306
306
|
if (file != null && typeof file !== 'string') return invalidInput('file is optional, but when sent it must be a repo-relative path as a string.');
|
|
307
307
|
if (code.length > MAX_VALIDATE_CHARS) return invalidInput(`That is ${Math.round(code.length / 1000)}k characters; send the part you changed (up to ${MAX_VALIDATE_CHARS / 1000}k).`);
|
|
308
308
|
const { findings, exempt } = validateContent({ text: code, file, before: beforeOf(k, file) }, k);
|
|
309
|
-
if (exempt) return `Not judged: ${file} is exempt because ${exempt}. Nothing here was checked.`;
|
|
309
|
+
if (exempt && !findings.length) return `Not judged: ${file} is exempt because ${exempt}. Nothing here was checked.`;
|
|
310
310
|
if (!findings.length) return cleanResultText(k);
|
|
311
|
-
return findingLines(findings, k).join('\n');
|
|
311
|
+
return findingLines(findings, k, exempt).join('\n');
|
|
312
312
|
}
|
|
313
313
|
|
|
314
314
|
/** The finding list as roast_validate prints it; the edit hook prints the same. */
|
|
315
|
-
export function findingLines(findings, k) {
|
|
315
|
+
export function findingLines(findings, k, exempt = null) {
|
|
316
316
|
const L = [`${findings.length} finding${findings.length === 1 ? '' : 's'}:`];
|
|
317
317
|
for (const f of findings.slice(0, 12)) {
|
|
318
318
|
L.push(`${f.severity === 'violation' ? '✕' : '⚠'} L${f.line} ${f.message}${f.fix ? `\n Fix: ${f.fix}` : ''}`);
|
|
319
319
|
}
|
|
320
320
|
if (findings.length > 12) L.push(`(+${findings.length - 12} more of the same kinds)`);
|
|
321
|
-
|
|
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)
|
|
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 };
|