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 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 `--rules` to also generate the agent rules file, `--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.
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. No network, no telemetry, nothing leaves your machine.`);
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 (wantRules && !asJson) {
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": "3.12.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, and with --rules writes the agent rules file (CLAUDE.md, .cursor/rules) that stops the mess coming back.",
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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', 'every duplicate is a place where your agent has to guess which one is canonical, and it picks wrong half the time. Paths open in VS Code')}
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 · candidates for deletion, or the system nobody found`)}
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)}">&lt;${esc(c.name)}&gt;</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
+ }
@@ -1,4 +1,4 @@
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 = '3.12.0';
4
+ export const VERSION = '4.0.0';
@@ -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.`);