roast-my-design-system 3.12.0 → 4.0.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 +2 -2
- package/bin/roast.mjs +31 -3
- package/package.json +2 -2
- package/skills/roast-my-design-system/scripts/card/index.mjs +84 -0
- package/skills/roast-my-design-system/scripts/diagnose/index.mjs +22 -8
- package/skills/roast-my-design-system/scripts/harvest/index.mjs +5 -0
- package/skills/roast-my-design-system/scripts/lib/staleness.mjs +114 -0
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/rules/apply.mjs +71 -0
- package/skills/roast-my-design-system/scripts/sarif/index.mjs +91 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Run this skill on your codebase and get, in about a second:
|
|
|
10
10
|
- **Per-package scores for monorepos.** One blended number hides which package is the problem: `packages/ui` scores 80 while `apps/web` scores 40, and now you can see it.
|
|
11
11
|
- **The receipts behind it.** Every colour and its near-identical twin, every spacing value, typeface, duplicated or never-imported component, inline style and !important, with real file paths, in one self-contained HTML report you can open, Slack or email.
|
|
12
12
|
- **The first fixes ranked by payoff.** A "Where to start" list derived from your own numbers: keep the report as the audit, or hand it to Claude as the punch list for the fix.
|
|
13
|
-
- **Rules that stop the mess coming back.** A generated `design-system-rules.md` for your CLAUDE.md or .cursor/rules: canonical components, your token file, known duplicates to avoid, so your AI agent follows your system instead of guessing at it.
|
|
13
|
+
- **Rules that stop the mess coming back.** A generated `design-system-rules.md` for your CLAUDE.md or .cursor/rules: canonical components, your token file, known duplicates to avoid, so your AI agent follows your system instead of guessing at it. `--apply` injects them for you, and every scan checks the rules you already have for stale references: paths that no longer exist, components named canonical that nothing imports anymore.
|
|
14
14
|
|
|
15
15
|
## Why this exists
|
|
16
16
|
|
|
@@ -49,7 +49,7 @@ The same report in light mode (one file, built-in toggle):
|
|
|
49
49
|
npx roast-my-design-system
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
Run it inside any repo. Same scanner, same report, straight from npm. Add `--
|
|
52
|
+
Run it inside any repo. Same scanner, same report, straight from npm. Add `--apply` to inject the generated agent rules straight into your CLAUDE.md, AGENTS.md or .cursor/rules (inside a marked block; re-running replaces only that block), `--rules` to write them as a file instead, `--card` for a shareable SVG roast card, `--sarif` for GitHub code scanning, `--json` for a machine-readable summary, or `--theme light`, `--out <file>`, `--no-open` as you like. The Claude Code skill below adds the conversation on top: the roast in chat, then a punch list you can actually work through with Claude.
|
|
53
53
|
|
|
54
54
|
**Claude Code (recommended):**
|
|
55
55
|
|
package/bin/roast.mjs
CHANGED
|
@@ -45,13 +45,24 @@ Usage: npx roast-my-design-system [path] [options]
|
|
|
45
45
|
--no-open write the report without opening it
|
|
46
46
|
--rules also write design-system-rules.md: agent rules (for
|
|
47
47
|
CLAUDE.md / .cursor/rules) generated from the scan
|
|
48
|
+
--apply inject the rules straight into your agent files (CLAUDE.md,
|
|
49
|
+
AGENTS.md, .cursorrules, .cursor/rules/) inside a marked
|
|
50
|
+
block; re-running replaces only that block
|
|
51
|
+
--card also write roast-card.svg: a shareable 1200x630 card with
|
|
52
|
+
the score and worst findings (pure SVG, embeds in READMEs)
|
|
53
|
+
--sarif also write design-system-roast.sarif for GitHub code
|
|
54
|
+
scanning: findings annotated on files in the Security tab
|
|
48
55
|
--json print the scan summary as JSON on stdout (implies --no-open)
|
|
49
56
|
|
|
50
|
-
Read-only scan
|
|
57
|
+
Read-only scan (--apply and --rules write only the files they name).
|
|
58
|
+
No network, no telemetry, nothing leaves your machine.`);
|
|
51
59
|
process.exit(0);
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
const wantRules = flag('rules') === true;
|
|
63
|
+
const wantApply = flag('apply') === true;
|
|
64
|
+
const wantCard = flag('card') === true;
|
|
65
|
+
const wantSarif = flag('sarif') === true;
|
|
55
66
|
const asJson = flag('json') === true;
|
|
56
67
|
const noOpen = flag('no-open') === true || asJson;
|
|
57
68
|
const theme = opt('theme', 'dark');
|
|
@@ -86,6 +97,20 @@ if (wantRules) {
|
|
|
86
97
|
say('');
|
|
87
98
|
run('rules/index.mjs', [harvestPath, '--out', rulesPath]);
|
|
88
99
|
}
|
|
100
|
+
if (wantApply) {
|
|
101
|
+
say('');
|
|
102
|
+
run('rules/apply.mjs', [harvestPath, '--target', target]);
|
|
103
|
+
}
|
|
104
|
+
const cardPath = resolve(join(target, 'roast-card.svg'));
|
|
105
|
+
if (wantCard) {
|
|
106
|
+
say('');
|
|
107
|
+
run('card/index.mjs', [summaryPath, '--out', cardPath]);
|
|
108
|
+
}
|
|
109
|
+
const sarifPath = resolve(join(target, 'design-system-roast.sarif'));
|
|
110
|
+
if (wantSarif) {
|
|
111
|
+
say('');
|
|
112
|
+
run('sarif/index.mjs', [harvestPath, '--out', sarifPath]);
|
|
113
|
+
}
|
|
89
114
|
|
|
90
115
|
let summary = null;
|
|
91
116
|
try { summary = JSON.parse(readFileSync(summaryPath, 'utf8')); } catch { /* report still exists */ }
|
|
@@ -100,9 +125,12 @@ if (asJson) {
|
|
|
100
125
|
}
|
|
101
126
|
}
|
|
102
127
|
|
|
103
|
-
if (
|
|
128
|
+
if (wantApply && !asJson) {
|
|
129
|
+
console.log(`\n rules are in place: your agent reads them on its next run.`);
|
|
130
|
+
} else if (wantRules && !asJson) {
|
|
104
131
|
console.log(`\n design-system-rules.md is ready: paste it into CLAUDE.md or .cursor/rules
|
|
105
|
-
so your AI agent stops repeating this repo's mistakes
|
|
132
|
+
so your AI agent stops repeating this repo's mistakes. Or run --apply
|
|
133
|
+
next time and skip the paste.`);
|
|
106
134
|
} else if (!asJson) {
|
|
107
135
|
console.log(`\n there is a present wrapped inside the report: your agent rules file,
|
|
108
136
|
generated from this scan. Or run with --rules to write it straight to disk.`);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Roast your design system with real data. A deterministic, zero-dependency scanner that counts everything that betrays a design system, from colours and their near-identical twins to greys, spacing values, typefaces, duplicated and never-imported components, inline styles, !important and arbitrary Tailwind values, scores it 0-100 against Ideal Design System norms and a 34-repo benchmark, scores monorepos package by package from the workspace declaration, generates a shareable HTML diagnosis, and with --
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "Roast your design system with real data. A deterministic, zero-dependency scanner that counts everything that betrays a design system, from colours and their near-identical twins to greys, spacing values, typefaces, duplicated and never-imported components, inline styles, !important and arbitrary Tailwind values, scores it 0-100 against Ideal Design System norms and a 34-repo benchmark, scores monorepos package by package from the workspace declaration, generates a shareable HTML diagnosis, flags stale references in the agent rules you already have, and with --apply injects the generated rules file straight into CLAUDE.md, AGENTS.md or .cursor/rules so the mess stops coming back. Also ships --card (shareable SVG roast card) and --sarif (GitHub code scanning export).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"design-system",
|
|
7
7
|
"design-tokens",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Card — a shareable roast card from a scan summary. Pure SVG built at scan
|
|
4
|
+
* time: no browser, no server, no dependency, nothing leaves the machine.
|
|
5
|
+
* 1200x630 (the OG-image ratio), dark glass look matching the report.
|
|
6
|
+
*
|
|
7
|
+
* node src/card/index.mjs <summary.json> --out roast-card.svg
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
function arg(name, fallback) {
|
|
13
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
14
|
+
return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
|
15
|
+
}
|
|
16
|
+
const inPath = process.argv[2] && !process.argv[2].startsWith('--') ? resolve(process.argv[2]) : null;
|
|
17
|
+
if (!inPath) { console.error('Usage: node src/card/index.mjs <summary.json> --out roast-card.svg'); process.exit(1); }
|
|
18
|
+
const outPath = resolve(arg('out', 'roast-card.svg'));
|
|
19
|
+
|
|
20
|
+
const s = JSON.parse(readFileSync(inPath, 'utf8'));
|
|
21
|
+
const esc = (t) => String(t).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
22
|
+
|
|
23
|
+
const score = s.score ?? 0;
|
|
24
|
+
const band = score >= 85 ? 'good' : score >= 55 ? 'warn' : 'bad';
|
|
25
|
+
const COLORS = { good: '#2dd4bf', warn: '#fbbf24', bad: '#fb7185' };
|
|
26
|
+
const scoreColor = COLORS[band];
|
|
27
|
+
|
|
28
|
+
// top findings: bad tiles first, then warn, by how far past zero/ideal they read
|
|
29
|
+
const order = { bad: 0, warn: 1, good: 2, info: 3 };
|
|
30
|
+
const tiles = [...(s.tiles ?? [])].sort((a, b) => (order[a.health] ?? 9) - (order[b.health] ?? 9));
|
|
31
|
+
const findings = tiles.filter((t) => t.health === 'bad' || t.health === 'warn').slice(0, 3);
|
|
32
|
+
const allGood = findings.length === 0;
|
|
33
|
+
|
|
34
|
+
const repoName = esc((s.repo ?? 'this repo').split('/').slice(-2).join('/'));
|
|
35
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
36
|
+
const SANS = "-apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
|
|
37
|
+
const MONO = "'SF Mono', 'Cascadia Code', Consolas, Menlo, monospace";
|
|
38
|
+
|
|
39
|
+
const rows = allGood
|
|
40
|
+
? `<text x="620" y="300" font-family="${SANS}" font-size="30" font-weight="600" fill="#e6e9ef">nothing over the line</text>
|
|
41
|
+
<text x="620" y="344" font-family="${SANS}" font-size="22" fill="#8b93a7">this repo is in better shape than most of the internet</text>`
|
|
42
|
+
: findings.map((t, i) => {
|
|
43
|
+
const y = 262 + i * 74;
|
|
44
|
+
const c = COLORS[t.health] ?? '#8b93a7';
|
|
45
|
+
return `<circle cx="632" cy="${y - 9}" r="7" fill="${c}"/>
|
|
46
|
+
<text x="660" y="${y}" font-family="${SANS}" font-size="30" font-weight="700" fill="#e6e9ef">${esc(t.value)}</text>
|
|
47
|
+
<text x="660" y="${y + 32}" font-family="${SANS}" font-size="21" fill="#8b93a7">${esc(t.label)}</text>`;
|
|
48
|
+
}).join('\n');
|
|
49
|
+
|
|
50
|
+
const svg = `<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
|
|
51
|
+
<defs>
|
|
52
|
+
<radialGradient id="blob1" cx="50%" cy="50%" r="50%">
|
|
53
|
+
<stop offset="0%" stop-color="${scoreColor}" stop-opacity="0.16"/>
|
|
54
|
+
<stop offset="100%" stop-color="${scoreColor}" stop-opacity="0"/>
|
|
55
|
+
</radialGradient>
|
|
56
|
+
<radialGradient id="blob2" cx="50%" cy="50%" r="50%">
|
|
57
|
+
<stop offset="0%" stop-color="#6366f1" stop-opacity="0.14"/>
|
|
58
|
+
<stop offset="100%" stop-color="#6366f1" stop-opacity="0"/>
|
|
59
|
+
</radialGradient>
|
|
60
|
+
</defs>
|
|
61
|
+
<rect width="1200" height="630" fill="#0b0d11"/>
|
|
62
|
+
<circle cx="1080" cy="80" r="360" fill="url(#blob1)"/>
|
|
63
|
+
<circle cx="140" cy="580" r="330" fill="url(#blob2)"/>
|
|
64
|
+
<rect x="44" y="44" width="1112" height="542" rx="28" fill="#11141a" fill-opacity="0.72" stroke="#272c37" stroke-width="1.5"/>
|
|
65
|
+
|
|
66
|
+
<text x="96" y="132" font-family="${MONO}" font-size="21" letter-spacing="3" fill="#8b93a7">${repoName.toUpperCase()}</text>
|
|
67
|
+
<text x="1104" y="132" font-family="${MONO}" font-size="18" letter-spacing="2" fill="#5a6172" text-anchor="end">DESIGN SYSTEM ROAST</text>
|
|
68
|
+
|
|
69
|
+
<text x="96" y="360" font-family="${SANS}" font-size="190" font-weight="800" fill="${scoreColor}">${score}</text>
|
|
70
|
+
<text x="${96 + String(score).length * 105 + 14}" y="360" font-family="${SANS}" font-size="52" font-weight="600" fill="#5a6172">/100</text>
|
|
71
|
+
<text x="96" y="416" font-family="${SANS}" font-size="24" fill="#8b93a7">design system health</text>
|
|
72
|
+
|
|
73
|
+
<line x1="560" y1="200" x2="560" y2="430" stroke="#272c37" stroke-width="1.5"/>
|
|
74
|
+
<text x="620" y="212" font-family="${MONO}" font-size="17" letter-spacing="2.5" fill="#5a6172">${allGood ? 'THE VERDICT' : 'WHAT YOUR AGENT LEARNS FROM'}</text>
|
|
75
|
+
${rows}
|
|
76
|
+
|
|
77
|
+
<line x1="96" y1="486" x2="1104" y2="486" stroke="#272c37" stroke-width="1.5"/>
|
|
78
|
+
<text x="96" y="536" font-family="${MONO}" font-size="20" fill="#8b93a7">npx roast-my-design-system</text>
|
|
79
|
+
<text x="1104" y="536" font-family="${MONO}" font-size="17" fill="#5a6172" text-anchor="end">scanned ${date} · ver. ${esc(s.version ?? '')}</text>
|
|
80
|
+
</svg>
|
|
81
|
+
`;
|
|
82
|
+
writeFileSync(outPath, svg);
|
|
83
|
+
console.log(`✓ Roast card: ${outPath}`);
|
|
84
|
+
console.log(` 1200x630, pure SVG. Post it, or screenshot it for platforms that reject SVG.`);
|
|
@@ -234,8 +234,8 @@ const effGreys = tokenLed ? greyStrays : greys.length;
|
|
|
234
234
|
if (typefaces.length > 3) candidates.push({ ratio: typefaces.length / 3, text: `${typefaces.length} typefaces, brands use 2 or 3` });
|
|
235
235
|
else if (typefaces.length && fontFamilies.length > 6) candidates.push({ ratio: fontFamilies.length / 6, text: `${typefaces.length} typeface${typefaces.length > 1 ? 's' : ''} declared ${fontFamilies.length} different ways` });
|
|
236
236
|
if (effColors > 24 * 1.25) candidates.push({ ratio: effColors / 24, text: tokenLed
|
|
237
|
-
? `${n(colorStrays)} hardcoded colours outside the token set`
|
|
238
|
-
: `${n(colors.length)} distinct colours, a design system needs ~24` });
|
|
237
|
+
? `${n(colorStrays)} hardcoded colours outside the token set, your agent will happily copy them at random`
|
|
238
|
+
: `${n(colors.length)} distinct colours for your agent to pick from, a design system needs ~24` });
|
|
239
239
|
if (effGreys > 13 * 1.25) candidates.push({ ratio: effGreys / 13, text: tokenLed
|
|
240
240
|
? `${greyStrays} hardcoded greys outside the token set`
|
|
241
241
|
: `${greys.length} shades of grey doing the job of 13` });
|
|
@@ -243,10 +243,11 @@ if (spacingTotal > 15) candidates.push({ ratio: spacingTotal / 12, text: `${spac
|
|
|
243
243
|
if (nearPairs.length >= 3) candidates.push({ ratio: 1 + nearPairs.length / 6, text: `${nearPairs.length} colour pairs are nearly identical (${nearPairs[0].a.value} next to ${nearPairs[0].b.value}), copy-paste, not decisions` });
|
|
244
244
|
if (important.count >= 10) candidates.push({ ratio: 1 + important.count / 30, text: `${n(important.count)} !important declarations, the cascade admitting defeat` });
|
|
245
245
|
if (iconCollisions.length >= 5) candidates.push({ ratio: 1 + iconCollisions.length / 8, text: `two icon sets collide on ${iconCollisions.length} names` });
|
|
246
|
-
if (hardDupes.length > 0) candidates.push({ ratio: 1 + hardDupes.length / 5, text: `${hardDupes.length} component${hardDupes.length > 1 ? 's' : ''} implemented more than once` });
|
|
247
|
-
if (inline.count > 20) candidates.push({ ratio: inline.count / 20, text: `${n(inline.count)} inline style blocks bypassing every system` });
|
|
248
|
-
if (arbitraryCount >= 20) candidates.push({ ratio: arbitraryCount / 30, text: `${n(arbitraryCount)} arbitrary values like ${arbitrary[0].value} punched through the Tailwind scale` });
|
|
246
|
+
if (hardDupes.length > 0) candidates.push({ ratio: 1 + hardDupes.length / 5, text: `${hardDupes.length} component${hardDupes.length > 1 ? 's' : ''} implemented more than once, so an agent asked for a Button has several random options` });
|
|
247
|
+
if (inline.count > 20) candidates.push({ ratio: inline.count / 20, text: `${n(inline.count)} inline style blocks bypassing every system, and teaching your agent to do the same` });
|
|
248
|
+
if (arbitraryCount >= 20) candidates.push({ ratio: arbitraryCount / 30, text: `${n(arbitraryCount)} arbitrary values like ${arbitrary[0].value} punched through the Tailwind scale, each one a precedent your agent will follow` });
|
|
249
249
|
if (agentFiles.length === 0) candidates.push({ ratio: 1.3, text: `no agent rules, so your AI is guessing` });
|
|
250
|
+
if ((h.staleRules ?? []).length >= 2) candidates.push({ ratio: 1.2 + h.staleRules.length / 10, text: `${h.staleRules.length} references in your agent rules point at things this scan can no longer find` });
|
|
250
251
|
const findings = candidates.sort((a, b) => b.ratio - a.ratio).map((c) => c.text);
|
|
251
252
|
|
|
252
253
|
let verdict = findings.length === 0
|
|
@@ -489,7 +490,7 @@ function duplicatesSection() {
|
|
|
489
490
|
</div>
|
|
490
491
|
</div>`).join('');
|
|
491
492
|
return `<div class="glass pad half">
|
|
492
|
-
${sectionHead('Duplicated components', '
|
|
493
|
+
${sectionHead('Duplicated components', 'an agent asking which one is canonical gets several plausible answers. Paths open in VS Code')}
|
|
493
494
|
<div class="fams">${iconCard}${dupeCards}${famCards}</div></div>`;
|
|
494
495
|
}
|
|
495
496
|
|
|
@@ -624,7 +625,7 @@ function componentsSection() {
|
|
|
624
625
|
${sectionHead('What you actually use', `${n(reusable.length)} components defined · top by adoption · the real system, buried in here`)}
|
|
625
626
|
${top.length ? `<div class="tbl-wrap"><table><thead><tr><th>component</th><th>used</th><th>defined in</th><th>props</th></tr></thead><tbody>${rows}</tbody></table></div>` : ''}
|
|
626
627
|
${neverImported.length >= 2 ? `
|
|
627
|
-
<div class="receipts">${eyebrow(`${n(neverImported.length)} components defined but never imported ·
|
|
628
|
+
<div class="receipts">${eyebrow(`${n(neverImported.length)} components defined but never imported · they sit in the system as wrong answers waiting to be picked`)}
|
|
628
629
|
<div class="chips-row">${neverImported.slice(0, 8).map((c) => `<span class="vchip" title="${esc(c.file)}"><${esc(c.name)}></span>`).join('')}${neverImported.length > 8 ? `<span class="vchip dim">+${neverImported.length - 8} more</span>` : ''}</div>
|
|
629
630
|
<p class="sub" style="margin-top:8px">Routers, dynamic imports and barrel files can hide real usage, so treat this as a shortlist to check, not a demolition order.</p>` : ''}</section>`;
|
|
630
631
|
}
|
|
@@ -861,9 +862,21 @@ function agentSection() {
|
|
|
861
862
|
const msg = agentFiles.length
|
|
862
863
|
? `<p class="sub">Your agent reads ${agentFiles.map((c) => `<span class="mono">${esc(c.file)}</span>`).join(', ')}. But none of it points at a single source of truth for components and tokens, because there isn't one yet. The numbers below are what your agent actually works from.</p>`
|
|
863
864
|
: `<p class="sub">No <span class="mono">CLAUDE.md</span>, no <span class="mono">AGENTS.md</span>, no <span class="mono">.cursorrules</span>. Every time your AI builds UI here, it guesses, from everything below. This is why its output looks almost-but-not-quite right.</p>`;
|
|
865
|
+
const stale = h.staleRules ?? [];
|
|
866
|
+
const staleRows = stale.map((s) => `
|
|
867
|
+
<div class="ledger-row">
|
|
868
|
+
<span class="mono strong">${esc(s.ref)}</span>
|
|
869
|
+
<span class="dim">${s.problem === 'missing' ? 'named in' : 'named canonical in'} ${esc(s.file)}, ${s.problem === 'missing' ? 'but the path no longer exists' : 'but nothing imports it this scan'}</span>
|
|
870
|
+
</div>`).join('');
|
|
871
|
+
const staleBlock = stale.length ? `
|
|
872
|
+
<div class="stale">
|
|
873
|
+
${eyebrow(`${stale.length} rule reference${stale.length === 1 ? '' : 's'} gone stale · a rule your agent obeys is worse than no rule when the repo has moved on`)}
|
|
874
|
+
<div class="ledger">${staleRows}</div>
|
|
875
|
+
<p class="sub" style="margin-top:10px">Rules rot quietly: nobody edits them when a component is renamed or a file moves. Regenerate them from a fresh scan, or fix the lines by hand.</p>
|
|
876
|
+
</div>` : '';
|
|
864
877
|
return `<section class="glass pad agent">
|
|
865
878
|
${sectionHead('What your AI agent sees today', '')}
|
|
866
|
-
${msg}${have ? `<div class="chips">${have}</div>` : ''}</section>`;
|
|
879
|
+
${msg}${have ? `<div class="chips">${have}</div>` : ''}${staleBlock}</section>`;
|
|
867
880
|
}
|
|
868
881
|
|
|
869
882
|
// ---------- page ----------
|
|
@@ -926,6 +939,7 @@ const html = `<!doctype html>
|
|
|
926
939
|
.chip { border:1px solid var(--line); background:var(--card); border-radius:99px;
|
|
927
940
|
padding:4px 12px; font:500 11.5px/1.5 var(--sans); color:var(--text); }
|
|
928
941
|
.chip-agent { background:var(--text); color:var(--bg); border-color:var(--text); font-weight:600; }
|
|
942
|
+
.stale { margin-top:18px; }
|
|
929
943
|
.chip-xs { display:inline-block; border:1px solid var(--line); background:var(--chip-bg); border-radius:99px;
|
|
930
944
|
padding:1px 8px; font:500 10px/1.6 var(--mono); color:var(--text); }
|
|
931
945
|
|
|
@@ -16,6 +16,7 @@ import { findDuplicates } from './duplicates.mjs';
|
|
|
16
16
|
import { harvestContext } from './context.mjs';
|
|
17
17
|
import { resolveWorkspaces } from '../lib/workspaces.mjs';
|
|
18
18
|
import { nearColorPairs } from '../lib/nearpairs.mjs';
|
|
19
|
+
import { ruleStaleness } from '../lib/staleness.mjs';
|
|
19
20
|
import { neverImportedComponents } from '../lib/neverimported.mjs';
|
|
20
21
|
|
|
21
22
|
function arg(name, fallback) {
|
|
@@ -37,6 +38,9 @@ const { components } = harvestComponents(target, files.code);
|
|
|
37
38
|
const tokens = harvestTokens(target, files.styles, files.code);
|
|
38
39
|
const duplicates = findDuplicates(components, profile.uiDir, target);
|
|
39
40
|
const context = harvestContext(target);
|
|
41
|
+
const staleRules = ruleStaleness(target, components,
|
|
42
|
+
new Set(neverImportedComponents(components, profile.uiDir).map((c) => c.name)),
|
|
43
|
+
[...files.code, ...files.styles, ...files.other]);
|
|
40
44
|
|
|
41
45
|
// ---------- per-package pass (monorepos) ----------
|
|
42
46
|
// Styling is measured inside each package, but usage is counted repo-wide: a
|
|
@@ -119,6 +123,7 @@ const harvest = {
|
|
|
119
123
|
tokens,
|
|
120
124
|
duplicates,
|
|
121
125
|
context,
|
|
126
|
+
staleRules,
|
|
122
127
|
packages,
|
|
123
128
|
};
|
|
124
129
|
harvest.tookMs = Date.now() - t0;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule staleness — checks the agent rules a repo already has against what the
|
|
3
|
+
* scan just measured. Rules are written once and rot silently: a rule naming
|
|
4
|
+
* Button.tsx keeps mistraining every agent long after ButtonV2 took over.
|
|
5
|
+
* Deterministic on purpose: only claims we can verify are flagged —
|
|
6
|
+
* referenced paths that no longer exist, referenced components that no file
|
|
7
|
+
* defines, and referenced components that nothing imports anymore.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const AGENT_FILES = ['CLAUDE.md', 'AGENTS.md', '.cursorrules', '.windsurfrules', '.github/copilot-instructions.md', 'design-system-rules.md'];
|
|
13
|
+
const MAX_BYTES = 200_000;
|
|
14
|
+
const MAX_FINDINGS = 10;
|
|
15
|
+
|
|
16
|
+
// path-like token with a source extension; must contain a slash so bare
|
|
17
|
+
// filenames in prose ("edit package.json") don't fire
|
|
18
|
+
const PATH_RE = /(?:^|[\s`("'])([\w@.-]+(?:\/[\w@.-]+)+\.(?:tsx|jsx|ts|js|mjs|cjs|css|scss|sass|less|vue|svelte))(?=[\s`)"',.:]|$)/g;
|
|
19
|
+
// component reference: <Name ...> or `Name` in backticks, PascalCase, 4+ chars
|
|
20
|
+
const TAG_RE = /<([A-Z][A-Za-z0-9]{3,})[\s/>]/g;
|
|
21
|
+
const TICK_RE = /`<?([A-Z][A-Za-z0-9]{3,})>?`/g;
|
|
22
|
+
|
|
23
|
+
// Our own generated rules deliberately name never-imported components as
|
|
24
|
+
// warnings, so re-reading those mentions as "named canonical" would make
|
|
25
|
+
// --apply trigger the staleness alarm on itself. In our content, only path
|
|
26
|
+
// claims are checked (they rot for real); component-usage claims are not.
|
|
27
|
+
const OURS_RE = /roast-my-design-system/i;
|
|
28
|
+
const BLOCK_RE = /<!-- roast-my-design-system:rules:begin[\s\S]*?roast-my-design-system:rules:end -->/g;
|
|
29
|
+
|
|
30
|
+
function ruleTexts(root) {
|
|
31
|
+
const texts = [];
|
|
32
|
+
// `text` keeps everything (paths are checked everywhere); `handWritten` is
|
|
33
|
+
// the file minus our injected block, and component checks run only on it.
|
|
34
|
+
const push = (file, text) => {
|
|
35
|
+
const handWritten = text.replace(BLOCK_RE, '');
|
|
36
|
+
texts.push({ file, text, handWritten, ours: OURS_RE.test(handWritten) });
|
|
37
|
+
};
|
|
38
|
+
for (const f of AGENT_FILES) {
|
|
39
|
+
const p = join(root, f);
|
|
40
|
+
try { if (statSync(p).isFile() && statSync(p).size <= MAX_BYTES) push(f, readFileSync(p, 'utf8')); } catch { /* absent */ }
|
|
41
|
+
}
|
|
42
|
+
const dir = join(root, '.cursor', 'rules');
|
|
43
|
+
try {
|
|
44
|
+
for (const e of readdirSync(dir)) {
|
|
45
|
+
if (!/\.(md|mdc)$/.test(e)) continue;
|
|
46
|
+
const p = join(dir, e);
|
|
47
|
+
if (statSync(p).size <= MAX_BYTES) push(`.cursor/rules/${e}`, readFileSync(p, 'utf8'));
|
|
48
|
+
}
|
|
49
|
+
} catch { /* no cursor rules dir */ }
|
|
50
|
+
return texts;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Returns [{ file, ref, kind: 'path'|'component', problem: 'missing'|'unused' }]
|
|
55
|
+
* capped at MAX_FINDINGS across all rule files.
|
|
56
|
+
*/
|
|
57
|
+
// Placeholder segments from pattern docs ("packages/features/myfeature/…",
|
|
58
|
+
// "path/to/test.ts") are examples, not claims. Build outputs exist only after
|
|
59
|
+
// a build, so a fresh clone can't judge them.
|
|
60
|
+
const PLACEHOLDER_SEG = /^(path|to|my[\w.-]+|your[\w.-]*|example[\w.-]*|some[\w.-]+|foo|bar|baz)$/i;
|
|
61
|
+
// test/story files are excluded from the walk, so a package-relative
|
|
62
|
+
// reference to one can never be verified; stay silent rather than guess
|
|
63
|
+
const UNVERIFIABLE_RE = /\.(test|spec|stories|story|cy)\.[cm]?[jt]sx?$/;
|
|
64
|
+
const BUILD_DIRS = new Set(['dist', 'build', 'out', '.next', 'node_modules', 'coverage']);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param neverImportedNames names from the hardened never-imported detector;
|
|
68
|
+
* component claims are only flagged when that detector agrees, so routing
|
|
69
|
+
* and dynamic-import usage never produces a false "nothing imports it".
|
|
70
|
+
* @param fileSuffixes every walked file path; a rule path that does not exist
|
|
71
|
+
* at the root but matches the tail of a real file is written relative to a
|
|
72
|
+
* package dir, not stale.
|
|
73
|
+
*/
|
|
74
|
+
export function ruleStaleness(root, components, neverImportedNames = new Set(), fileSuffixes = []) {
|
|
75
|
+
const byName = new Map();
|
|
76
|
+
for (const c of components) {
|
|
77
|
+
const prev = byName.get(c.name);
|
|
78
|
+
if (!prev || c.usageCount > prev.usageCount) byName.set(c.name, c);
|
|
79
|
+
}
|
|
80
|
+
const findings = [];
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
const add = (file, ref, kind, problem) => {
|
|
83
|
+
const key = `${ref}|${problem}`;
|
|
84
|
+
if (seen.has(key) || findings.length >= MAX_FINDINGS) return;
|
|
85
|
+
seen.add(key);
|
|
86
|
+
findings.push({ file, ref, kind, problem });
|
|
87
|
+
};
|
|
88
|
+
const isPlaceholder = (ref) => ref.split('/').some((seg) => PLACEHOLDER_SEG.test(seg));
|
|
89
|
+
const existsAsSuffix = (ref) => fileSuffixes.some((f) => f === ref || f.endsWith(`/${ref}`));
|
|
90
|
+
|
|
91
|
+
for (const { file, text, handWritten, ours } of ruleTexts(root)) {
|
|
92
|
+
for (const m of text.matchAll(PATH_RE)) {
|
|
93
|
+
const ref = m[1].replace(/^\.\//, '');
|
|
94
|
+
if (isPlaceholder(ref)) continue;
|
|
95
|
+
if (BUILD_DIRS.has(ref.split('/')[0])) continue;
|
|
96
|
+
if (existsSync(join(root, ref))) continue;
|
|
97
|
+
if (existsAsSuffix(ref)) continue;
|
|
98
|
+
// a short test-file ref is almost surely package-relative and unverifiable;
|
|
99
|
+
// a deep root-relative one was checked by existsSync above and is real drift
|
|
100
|
+
if (UNVERIFIABLE_RE.test(ref) && ref.split('/').length < 4) continue;
|
|
101
|
+
add(file, ref, 'path', 'missing');
|
|
102
|
+
}
|
|
103
|
+
if (ours) continue; // fully generated file: paths checked above, component claims are ours to regenerate, not to second-guess
|
|
104
|
+
const compRefs = new Set();
|
|
105
|
+
for (const m of handWritten.matchAll(TAG_RE)) compRefs.add(m[1]);
|
|
106
|
+
for (const m of handWritten.matchAll(TICK_RE)) compRefs.add(m[1]);
|
|
107
|
+
for (const name of compRefs) {
|
|
108
|
+
const def = byName.get(name);
|
|
109
|
+
if (!def) continue; // never claim a component is missing from a name we can't resolve: could be a library import
|
|
110
|
+
if (!def.isPage && def.usageCount === 0 && neverImportedNames.has(name)) add(file, name, 'component', 'unused');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return findings;
|
|
114
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Apply — injects the generated agent rules straight into the files agents
|
|
4
|
+
* already read, so the npx path ends where the skill's conversational merge
|
|
5
|
+
* ends: rules in place, zero copy-paste.
|
|
6
|
+
*
|
|
7
|
+
* Targets, all that exist at the repo root:
|
|
8
|
+
* CLAUDE.md, AGENTS.md, .cursorrules -> rules injected inside a marked
|
|
9
|
+
* block; re-running replaces only our block, never touching their text.
|
|
10
|
+
* .cursor/rules/ (directory) -> design-system-rules.mdc written
|
|
11
|
+
* whole, with the frontmatter Cursor expects.
|
|
12
|
+
* If none exist, falls back to design-system-rules.md at the root and says so.
|
|
13
|
+
*
|
|
14
|
+
* node src/rules/apply.mjs <harvest.json> --target <repo-root>
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
|
|
17
|
+
import { resolve, join } from 'node:path';
|
|
18
|
+
import { rulesMarkdown } from './build.mjs';
|
|
19
|
+
|
|
20
|
+
function arg(name, fallback) {
|
|
21
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
22
|
+
return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
|
23
|
+
}
|
|
24
|
+
const inPath = process.argv[2] && !process.argv[2].startsWith('--') ? resolve(process.argv[2]) : null;
|
|
25
|
+
if (!inPath) { console.error('Usage: node src/rules/apply.mjs <harvest.json> --target <repo-root>'); process.exit(1); }
|
|
26
|
+
const target = resolve(arg('target', '.'));
|
|
27
|
+
|
|
28
|
+
const BEGIN = '<!-- roast-my-design-system:rules:begin (generated from a scan; this block is replaced by --apply, text outside it is never touched) -->';
|
|
29
|
+
const END = '<!-- roast-my-design-system:rules:end -->';
|
|
30
|
+
|
|
31
|
+
const h = JSON.parse(readFileSync(inPath, 'utf8'));
|
|
32
|
+
const { text, ruleCount } = rulesMarkdown(h);
|
|
33
|
+
const block = `${BEGIN}\n\n${text.trim()}\n\n${END}`;
|
|
34
|
+
|
|
35
|
+
/** Inject or replace our marked block in an existing file's content. */
|
|
36
|
+
function inject(content) {
|
|
37
|
+
const b = content.indexOf(BEGIN);
|
|
38
|
+
const e = content.indexOf(END);
|
|
39
|
+
if (b !== -1 && e !== -1 && e > b) {
|
|
40
|
+
return { next: content.slice(0, b) + block + content.slice(e + END.length), how: 'replaced' };
|
|
41
|
+
}
|
|
42
|
+
const sep = content.length === 0 ? '' : content.endsWith('\n\n') ? '' : content.endsWith('\n') ? '\n' : '\n\n';
|
|
43
|
+
return { next: content + sep + block + '\n', how: 'added' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const done = [];
|
|
47
|
+
for (const name of ['CLAUDE.md', 'AGENTS.md', '.cursorrules']) {
|
|
48
|
+
const p = join(target, name);
|
|
49
|
+
if (!existsSync(p) || !statSync(p).isFile()) continue;
|
|
50
|
+
const { next, how } = inject(readFileSync(p, 'utf8'));
|
|
51
|
+
writeFileSync(p, next);
|
|
52
|
+
done.push(`${name} (${how} the rules block)`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const cursorDir = join(target, '.cursor', 'rules');
|
|
56
|
+
if (existsSync(cursorDir) && statSync(cursorDir).isDirectory()) {
|
|
57
|
+
const p = join(cursorDir, 'design-system-rules.mdc');
|
|
58
|
+
const existed = existsSync(p);
|
|
59
|
+
writeFileSync(p, `---\ndescription: Design system rules generated by roast-my-design-system from a scan of this repo\nalwaysApply: true\n---\n\n${text.trim()}\n`);
|
|
60
|
+
done.push(`.cursor/rules/design-system-rules.mdc (${existed ? 'rewritten' : 'created'})`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (done.length === 0) {
|
|
64
|
+
const p = join(target, 'design-system-rules.md');
|
|
65
|
+
writeFileSync(p, text);
|
|
66
|
+
console.log(`No agent file found (CLAUDE.md, AGENTS.md, .cursorrules, .cursor/rules/).`);
|
|
67
|
+
console.log(`✓ Wrote design-system-rules.md instead; point your agent at it.`);
|
|
68
|
+
} else {
|
|
69
|
+
console.log(`✓ ${ruleCount} rules applied, every one with a receipt:`);
|
|
70
|
+
for (const d of done) console.log(` → ${d}`);
|
|
71
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SARIF export — the same findings, written in the format GitHub's code
|
|
4
|
+
* scanning tab and enterprise tooling ingest (SARIF 2.1.0). File-level
|
|
5
|
+
* results (the scanner counts per file, it does not keep line numbers), which
|
|
6
|
+
* code scanning renders as file annotations.
|
|
7
|
+
*
|
|
8
|
+
* node src/sarif/index.mjs <harvest.json> --out design-system-roast.sarif
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { resolve } from 'node:path';
|
|
12
|
+
import { VERSION } from '../lib/version.mjs';
|
|
13
|
+
import { nearColorPairs } from '../lib/nearpairs.mjs';
|
|
14
|
+
import { neverImportedComponents } from '../lib/neverimported.mjs';
|
|
15
|
+
|
|
16
|
+
function arg(name, fallback) {
|
|
17
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
18
|
+
return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
|
19
|
+
}
|
|
20
|
+
const inPath = process.argv[2] && !process.argv[2].startsWith('--') ? resolve(process.argv[2]) : null;
|
|
21
|
+
if (!inPath) { console.error('Usage: node src/sarif/index.mjs <harvest.json> --out design-system-roast.sarif'); process.exit(1); }
|
|
22
|
+
const outPath = resolve(arg('out', 'design-system-roast.sarif'));
|
|
23
|
+
|
|
24
|
+
const h = JSON.parse(readFileSync(inPath, 'utf8'));
|
|
25
|
+
const t = h.tokens;
|
|
26
|
+
|
|
27
|
+
const RULES = {
|
|
28
|
+
'duplicate-component': { name: 'Duplicated component', desc: 'A component implemented more than once. An agent asked for it has several plausible answers.' },
|
|
29
|
+
'never-imported-component': { name: 'Component nobody imports', desc: 'Defined in the system, imported by nothing. It sits there as a wrong answer waiting to be picked.' },
|
|
30
|
+
'inline-styles': { name: 'Inline style blocks', desc: 'Static values written as style attributes, bypassing the system and teaching the agent to do the same.' },
|
|
31
|
+
'important-styles': { name: '!important declarations', desc: 'The cascade admitting defeat. Fix specificity at the source and these stop being necessary.' },
|
|
32
|
+
'stray-colors': { name: 'Hardcoded colours', desc: 'Colour values no token names. Each one is a value the agent will happily copy.' },
|
|
33
|
+
'stale-rule-reference': { name: 'Stale agent-rule reference', desc: 'The agent rules reference something this scan can no longer find. A rule the agent obeys is worse than no rule when the repo has moved on.' },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const results = [];
|
|
37
|
+
const loc = (file) => [{ physicalLocation: { artifactLocation: { uri: file } } }];
|
|
38
|
+
const push = (ruleId, level, text, file) => results.push({ ruleId, level, message: { text }, locations: loc(file) });
|
|
39
|
+
|
|
40
|
+
for (const d of (h.duplicates?.exactDuplicates ?? [])) {
|
|
41
|
+
if (d.wrapped) continue;
|
|
42
|
+
for (const f of d.files) {
|
|
43
|
+
const file = typeof f === 'string' ? f : f.file;
|
|
44
|
+
push('duplicate-component', 'warning', `<${d.name}> is implemented in ${d.files.length} places. This is one of them; an agent has to guess which is canonical.`, file);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const c of neverImportedComponents(h.components ?? [], h.profile?.uiDir ?? null)) {
|
|
48
|
+
push('never-imported-component', 'note', `<${c.name}> is defined here and imported by nothing.`, c.file);
|
|
49
|
+
}
|
|
50
|
+
for (const f of (t.inlineStyles?.files ?? [])) {
|
|
51
|
+
push('inline-styles', 'warning', `${f.count} static inline style block${f.count === 1 ? '' : 's'} in this file. Dynamic positioning is already excluded, so these could be classes or tokens today.`, f.file);
|
|
52
|
+
}
|
|
53
|
+
for (const f of (t.important?.files ?? [])) {
|
|
54
|
+
push('important-styles', 'warning', `${f.count} !important declaration${f.count === 1 ? '' : 's'} in this file.`, f.file);
|
|
55
|
+
}
|
|
56
|
+
// stray colours: per file, counting only non-token values
|
|
57
|
+
const strayByFile = new Map();
|
|
58
|
+
for (const c of (t.colors ?? [])) {
|
|
59
|
+
if (c.isToken) continue;
|
|
60
|
+
for (const f of (c.files ?? [])) strayByFile.set(f.file, (strayByFile.get(f.file) ?? 0) + 1);
|
|
61
|
+
}
|
|
62
|
+
for (const [file, count] of [...strayByFile.entries()].sort((a, b) => b[1] - a[1]).slice(0, 50)) {
|
|
63
|
+
push('stray-colors', 'note', `${count} hardcoded colour value${count === 1 ? '' : 's'} no token names.`, file);
|
|
64
|
+
}
|
|
65
|
+
for (const s of (h.staleRules ?? [])) {
|
|
66
|
+
push('stale-rule-reference', 'warning', `${s.ref} is ${s.problem === 'missing' ? 'referenced here but no longer exists in the repo' : 'named here but nothing imports it this scan'}.`, s.file);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const sarif = {
|
|
70
|
+
$schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
|
|
71
|
+
version: '2.1.0',
|
|
72
|
+
runs: [{
|
|
73
|
+
tool: {
|
|
74
|
+
driver: {
|
|
75
|
+
name: 'roast-my-design-system',
|
|
76
|
+
version: VERSION,
|
|
77
|
+
informationUri: 'https://github.com/pencilrebel/roast-my-design-system',
|
|
78
|
+
rules: Object.entries(RULES).map(([id, r]) => ({
|
|
79
|
+
id, name: r.name,
|
|
80
|
+
shortDescription: { text: r.name },
|
|
81
|
+
fullDescription: { text: r.desc },
|
|
82
|
+
helpUri: 'https://github.com/pencilrebel/roast-my-design-system',
|
|
83
|
+
})),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
results,
|
|
87
|
+
}],
|
|
88
|
+
};
|
|
89
|
+
writeFileSync(outPath, JSON.stringify(sarif, null, 2));
|
|
90
|
+
console.log(`✓ SARIF: ${results.length} findings → ${outPath}`);
|
|
91
|
+
console.log(` Upload it in CI and they appear in GitHub's code scanning tab, annotated on files.`);
|