roast-my-design-system 4.1.1 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,6 +31,7 @@ One scan powers all of it; the flags decide what lands on disk. Combine freely.
31
31
  | `... --card` | `roast-card.svg`: a shareable 1200x630 card with the score and worst findings. Pure SVG, embeds in a README |
32
32
  | `... --sarif` | `design-system-roast.sarif` for GitHub code scanning: upload it in CI and findings appear in the Security tab, annotated on files |
33
33
  | `... --by "Ada Lovelace"` | A requester credit in the report header, next to the scan date |
34
+ | `... --exclude lab/` | Leave a folder out of the scan (repeat the flag or comma-separate). Or list folders in a `.roastignore` file at the repo root. Either way the report says so in the header; see [Scoping the scan](#scoping-the-scan) |
34
35
  | `... --json` | The scan summary as JSON on stdout, for scripts and pipelines |
35
36
  | `... --theme light` / `--out <file>` / `--no-open` | Light report, custom report path, don't open the browser |
36
37
  | `/roast-my-design-system` (in Claude Code) | The full experience: the roast in chat, the report, the rules offer, and the fix loop with Claude on your own numbers |
@@ -57,11 +58,29 @@ The same report in light mode (one file, built-in toggle):
57
58
  - **Deterministic scanner, not AI sampling.** A zero-dependency Node script reads *every* file (about a second on a normal repo, a few on a large monorepo) and returns the same numbers every run. Claude narrates; it never counts.
58
59
  - **Read-only.** Nothing in your repo is modified. The only outputs are a temp JSON and the HTML report.
59
60
  - **No network, no telemetry.** Everything runs locally. Nothing about your code leaves your machine.
60
- - **Honest exclusions.** Test files, Storybook stories, docs sites, example apps, SVG artwork, and email templates (which *must* inline styles) are excluded, so you can't discredit the numbers on a technicality.
61
+ - **Honest exclusions.** Test files, Storybook stories, docs sites, example apps, SVG artwork, and email templates (which *must* inline styles) are excluded, so you can't discredit the numbers on a technicality. Your own exclusions (`.roastignore`, `--exclude`) are printed in the report header with file counts, so a scoped scan can never pass itself off as the whole repo.
61
62
  - **Intent-aware counting (v3).** Runtime-computed inline styles, compound-component APIs and wrapper components are not crimes and are not counted as ones. Token-led repos are judged on their hardcoded strays, not their token architecture. Repeated arbitrary values are read as decisions without names, not drift.
62
63
  - **A real benchmark.** The "Avg Design System" yardstick comes from scanning 34 public React repos (cal.com, excalidraw, supabase, grafana, twenty, dub, langfuse…). Median: 130 colours, 17 greys, 20 duplicated components, 49 inline style blocks, 70 arbitrary Tailwind values.
63
64
  - **A second yardstick: reputable systems.** Curated, scoped scans of 10 well-known design systems (shadcn/ui, Primer, Polaris, Carbon, Material UI, Chakra, Ant Design, GOV.UK, Spectrum, Cloudscape) show what disciplined looks like at scale.
64
65
 
66
+ ## Scoping the scan
67
+
68
+ Some repos host more than one visual world on purpose: the product plus a marketing site, a playground, a batch of experiments. Blending them produces a score that describes none of them. Scope the scan to the design system you are actually judging:
69
+
70
+ ```bash
71
+ npx roast-my-design-system --exclude lab/ --exclude playground/
72
+ ```
73
+
74
+ Or make it permanent with a `.roastignore` file at the repo root, one repo-relative folder per line:
75
+
76
+ ```
77
+ # separate visual worlds, not the product's design system
78
+ lab/
79
+ playground/
80
+ ```
81
+
82
+ Both routes merge, and both are loud on purpose. The harvest JSON records every active pattern and how many files it removed, and the report prints a line in the header ("2 folders excluded by .roastignore (lab/, playground/) · 946 files kept out of this scan"). You can narrow the question, but the report always says which question was asked, so a scoped score can't be quietly gamed. There is no negation and no glob syntax: plain folder prefixes, nothing clever.
83
+
65
84
  ## Install
66
85
 
67
86
  **No install, no Claude needed — just try it:**
package/bin/roast.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  * write design-system-roast.html, open it, print the score.
7
7
  *
8
8
  * npx roast-my-design-system [path] [--theme dark|light] [--out report.html] [--no-open]
9
- * [--rules] [--json]
9
+ * [--rules] [--json] [--exclude <path>]
10
10
  */
11
11
  import { spawnSync } from 'node:child_process';
12
12
  import { mkdtempSync, rmSync, readFileSync, existsSync, statSync } from 'node:fs';
@@ -32,6 +32,15 @@ function opt(name, fallback) {
32
32
  argv.splice(i, 2);
33
33
  return v;
34
34
  }
35
+ // repeatable option: collects every occurrence, comma-separated values split
36
+ function optAll(name) {
37
+ const out = [];
38
+ let v;
39
+ while ((v = opt(name, null)) !== null) {
40
+ out.push(...v.split(',').map((s) => s.trim()).filter(Boolean));
41
+ }
42
+ return out;
43
+ }
35
44
 
36
45
  if (flag('version') || flag('v')) { console.log(VERSION); process.exit(0); }
37
46
  if (flag('help') || flag('h')) {
@@ -54,6 +63,11 @@ Usage: npx roast-my-design-system [path] [options]
54
63
  scanning: findings annotated on files in the Security tab
55
64
  --by <name> put a requester credit in the report header, next to the
56
65
  scan date ("commissioned by <name>")
66
+ --exclude <p> leave a folder out of the scan (repo-relative, e.g.
67
+ --exclude lab/ --exclude piglet/ or --exclude lab/,piglet/;
68
+ same as listing it in a .roastignore file at the repo root).
69
+ Every exclusion is printed in the report header with the
70
+ number of files it removed, so a scoped scan says so
57
71
  --json print the scan summary as JSON on stdout (implies --no-open)
58
72
 
59
73
  Read-only scan (--apply and --rules write only the files they name).
@@ -69,6 +83,7 @@ const asJson = flag('json') === true;
69
83
  const noOpen = flag('no-open') === true || asJson;
70
84
  const theme = opt('theme', 'dark');
71
85
  const commissionedBy = opt('by', null);
86
+ const excludes = optAll('exclude');
72
87
  const target = resolve(argv.find((a) => !a.startsWith('--')) || process.cwd());
73
88
  if (!existsSync(target) || !statSync(target).isDirectory()) {
74
89
  console.error(`Not a directory: ${target}`);
@@ -91,7 +106,8 @@ function run(script, args) {
91
106
  const say = (s) => { if (!asJson) console.log(s); };
92
107
 
93
108
  say(`roast-my-design-system ${VERSION} · read-only scan, nothing leaves your machine\n`);
94
- run('harvest/index.mjs', [target, '--out', harvestPath]);
109
+ run('harvest/index.mjs', [target, '--out', harvestPath,
110
+ ...excludes.flatMap((e) => ['--exclude', e])]);
95
111
  say('');
96
112
  run('diagnose/index.mjs', [harvestPath, '--out', outPath, '--theme', theme, '--summary', summaryPath,
97
113
  ...(commissionedBy ? ['--by', commissionedBy] : [])]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "4.1.1",
3
+ "version": "4.2.0",
4
4
  "description": "Your AI can write the UI. This makes sure it writes your UI. 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",
@@ -891,6 +891,22 @@ const stack = [
891
891
  h.profile.monorepo ? 'monorepo' : null,
892
892
  ].filter(Boolean);
893
893
 
894
+ // User exclusions are printed in the header, never hidden: a scoped scan must
895
+ // say it is scoped, or the score could be quietly gamed. Grouped by source
896
+ // (.roastignore vs --exclude), with the total number of files kept out.
897
+ function exclusionsLine() {
898
+ const patterns = h.exclusions?.patterns ?? [];
899
+ if (!patterns.length) return '';
900
+ const sources = [...new Set(patterns.map((p) => p.source))];
901
+ const parts = sources.map((src) => {
902
+ const own = patterns.filter((p) => p.source === src);
903
+ const list = own.map((p) => `<span class="mono">${esc(p.pattern)}/</span>`).join(', ');
904
+ return `${own.length} ${own.length === 1 ? 'folder' : 'folders'} excluded by ${esc(src)} (${list})`;
905
+ });
906
+ const total = h.exclusions.filesExcluded ?? 0;
907
+ return `<div class="excl">${parts.join(' · ')} · ${n(total)} files kept out of this scan</div>`;
908
+ }
909
+
894
910
  const html = `<!doctype html>
895
911
  <html lang="en" data-theme="${themeName}"><head><meta charset="utf-8">
896
912
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -942,6 +958,8 @@ const html = `<!doctype html>
942
958
  .chip { border:1px solid var(--line); background:var(--card); border-radius:99px;
943
959
  padding:4px 12px; font:500 11.5px/1.5 var(--sans); color:var(--text); }
944
960
  .chip-agent { background:var(--text); color:var(--bg); border-color:var(--text); font-weight:600; }
961
+ .excl { margin-top:10px; font:500 12px/1.6 var(--sans); color:var(--dim); }
962
+ .excl .mono { font-size:11.5px; color:var(--text); }
945
963
  .stale { margin-top:18px; }
946
964
  .chip-xs { display:inline-block; border:1px solid var(--line); background:var(--chip-bg); border-radius:99px;
947
965
  padding:1px 8px; font:500 10px/1.6 var(--mono); color:var(--text); }
@@ -1201,6 +1219,7 @@ const html = `<!doctype html>
1201
1219
  ${healthScore !== null ? `<div class="score${noSystemLikely ? ' muted' : ''}">${eyebrow('Health score')}<div class="val">${healthScore}<span class="slash">/</span><span class="of">100</span></div>${noSystemLikely ? '<div class="note">little here to score · see the note below</div>' : ''}</div>` : ''}
1202
1220
  </div>
1203
1221
  <div class="chips">${stack.map((s) => `<span class="chip">${esc(s)}</span>`).join('')}${agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)}</span>`).join('')}</div>
1222
+ ${exclusionsLine()}
1204
1223
  ${noSystemLikely ? `<div class="nods">${ICONS.warn}<span>There is most likely <b>no design system in this repo</b>: almost no colour or spacing values were found. Styling may live outside this codebase (CDN stylesheets, a parent repo, or generated output).</span></div>` : ''}
1205
1224
  <div class="glass verdict-card">
1206
1225
  <div class="blob b1"></div><div class="blob b2"></div>
@@ -1290,6 +1309,7 @@ if (summaryPath) {
1290
1309
  repo: repoName,
1291
1310
  version: VERSION,
1292
1311
  ...(commissionedBy ? { commissionedBy } : {}),
1312
+ ...(h.exclusions ? { exclusions: h.exclusions } : {}),
1293
1313
  score: healthScore,
1294
1314
  noSystemLikely,
1295
1315
  verdict,
@@ -6,6 +6,11 @@
6
6
  * one-screen summary (the seed of the step-2 diagnosis).
7
7
  *
8
8
  * node src/harvest/index.mjs <repo-path> [--out harvest.json]
9
+ * [--exclude <path>] (repeatable, comma-separated ok)
10
+ *
11
+ * Exclusions also come from a .roastignore file at the repo root (one
12
+ * repo-relative path per line). Every active pattern lands in the harvest
13
+ * JSON with the number of files it removed — visible, never silent.
9
14
  */
10
15
  import { writeFileSync } from 'node:fs';
11
16
  import { resolve } from 'node:path';
@@ -15,6 +20,7 @@ import { harvestTokens } from './tokens.mjs';
15
20
  import { findDuplicates } from './duplicates.mjs';
16
21
  import { harvestContext } from './context.mjs';
17
22
  import { resolveWorkspaces } from '../lib/workspaces.mjs';
23
+ import { loadExclusions } from '../lib/exclusions.mjs';
18
24
  import { nearColorPairs } from '../lib/nearpairs.mjs';
19
25
  import { ruleStaleness } from '../lib/staleness.mjs';
20
26
  import { neverImportedComponents } from '../lib/neverimported.mjs';
@@ -24,6 +30,17 @@ function arg(name, fallback) {
24
30
  return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
25
31
  }
26
32
 
33
+ // --exclude is repeatable and each value may be comma-separated
34
+ function argAll(name) {
35
+ const out = [];
36
+ for (let i = 2; i < process.argv.length; i++) {
37
+ if (process.argv[i] === `--${name}` && process.argv[i + 1]) {
38
+ out.push(...process.argv[i + 1].split(',').map((s) => s.trim()).filter(Boolean));
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
27
44
  const target = process.argv[2] && !process.argv[2].startsWith('--') ? resolve(process.argv[2]) : null;
28
45
  if (!target) {
29
46
  console.error('Usage: node src/harvest/index.mjs <repo-path> [--out harvest.json]');
@@ -32,7 +49,8 @@ if (!target) {
32
49
  const outPath = resolve(arg('out', 'harvest.json'));
33
50
 
34
51
  const t0 = Date.now();
35
- const files = walkRepo(target);
52
+ const exclusions = loadExclusions(target, argAll('exclude'));
53
+ const files = walkRepo(target, 14, exclusions);
36
54
  const profile = profileRepo(target, files);
37
55
  const { components } = harvestComponents(target, files.code);
38
56
  const tokens = harvestTokens(target, files.styles, files.code);
@@ -125,6 +143,14 @@ const harvest = {
125
143
  context,
126
144
  staleRules,
127
145
  packages,
146
+ // Active user exclusions with per-pattern removal counts. Present only when
147
+ // something was excluded, so downstream renderers can trust its presence.
148
+ ...(exclusions.patterns.length ? {
149
+ exclusions: {
150
+ patterns: exclusions.patterns,
151
+ filesExcluded: exclusions.patterns.reduce((sum, p) => sum + p.files, 0),
152
+ },
153
+ } : {}),
128
154
  };
129
155
  harvest.tookMs = Date.now() - t0;
130
156
 
@@ -138,6 +164,10 @@ const top = (list, n = 5) => list.slice(0, n).map((e) => `${e.value} ×${e.count
138
164
  console.log(`\nHarvest: ${profile.name ?? target}`);
139
165
  console.log(` framework: ${profile.framework}${profile.typescript ? ' + TS' : ''} design system: ${profile.designSystem.kind}${profile.designSystem.name ? ` (${profile.designSystem.name})` : ''} styling: ${profile.stylingDeps.join(', ') || 'none detected'}`);
140
166
  console.log(` files: ${files.code.length} code, ${files.styles.length} style`);
167
+ if (exclusions.patterns.length) {
168
+ const list = exclusions.patterns.map((p) => `${p.pattern} (${p.files} files, ${p.source})`).join(', ');
169
+ console.log(` excluded by you: ${list}`);
170
+ }
141
171
  console.log(`\n components: ${components.length} defined (${nonPage.length} reusable, ${components.length - nonPage.length} pages)`);
142
172
  console.log(` duplicates: ${duplicates.exactDuplicates.length} exact same-name, ${duplicates.families.length} name families`);
143
173
  for (const d of duplicates.exactDuplicates.slice(0, 3)) console.log(` · ${d.name} defined in ${d.files.length} files`);
@@ -56,8 +56,24 @@ const STYLE_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
56
56
  * twenty and formbricks. Measured across the fleet, file counts stop growing at
57
57
  * 12; 14 leaves headroom. Single-package repos are unaffected either way.
58
58
  */
59
- export function walkRepo(root, maxDepth = 14) {
59
+ export function walkRepo(root, maxDepth = 14, exclusions = null) {
60
60
  const files = { code: [], styles: [], other: [] };
61
+ const excluded = exclusions?.match ?? (() => null);
62
+ // An excluded directory is still walked once, just to count what the scan
63
+ // would otherwise have read — the harvest reports how many files each
64
+ // pattern removed, so an exclusion is always visible, never a silent trim.
65
+ const countExcluded = (dir, depth, hit) => {
66
+ if (depth > maxDepth) return;
67
+ let entries = [];
68
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
69
+ for (const e of entries) {
70
+ if (e.name.startsWith('.') && e.name !== '.cursorrules') continue;
71
+ if (SKIP_DIRS.has(e.name)) continue;
72
+ if (e.isDirectory()) { countExcluded(join(dir, e.name), depth + 1, hit); continue; }
73
+ if (TEST_FILE_RE.test(e.name)) continue;
74
+ hit.files += 1;
75
+ }
76
+ };
61
77
  const recurse = (dir, depth) => {
62
78
  if (depth > maxDepth) return;
63
79
  let entries = [];
@@ -67,9 +83,15 @@ export function walkRepo(root, maxDepth = 14) {
67
83
  if (SKIP_DIRS.has(e.name)) continue;
68
84
  const p = join(dir, e.name);
69
85
  if (e.name === 'public' && e.isDirectory() && !hasComponentSource(p)) continue;
86
+ const rel = relative(root, p).replaceAll('\\', '/');
87
+ const hit = excluded(rel);
88
+ if (hit) {
89
+ if (e.isDirectory()) countExcluded(p, depth + 1, hit);
90
+ else if (!TEST_FILE_RE.test(e.name)) hit.files += 1;
91
+ continue;
92
+ }
70
93
  if (e.isDirectory()) { recurse(p, depth + 1); continue; }
71
94
  if (TEST_FILE_RE.test(e.name)) continue;
72
- const rel = relative(root, p).replaceAll('\\', '/');
73
95
  const ext = extname(e.name);
74
96
  if (STYLE_EXTS.has(ext)) files.styles.push(rel);
75
97
  else if (CODE_EXTS.has(ext)) files.code.push(rel);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * User-declared scan exclusions. Two sources, merged: a .roastignore file at
3
+ * the scanned repo's root (one pattern per line, # comments and blank lines
4
+ * ignored) and --exclude values from the CLI. Patterns are repo-relative
5
+ * directory or file paths ("lab/", "piglet/", "apps/playground"); matching is
6
+ * a whole-segment prefix on the relative path, so "lab" excludes lab/ and
7
+ * everything under it but never labs.css. No negation, no globs: this scopes
8
+ * the scan to the design system being judged, it is not a gitignore clone.
9
+ *
10
+ * Honesty is the point: every active pattern is recorded in the harvest JSON
11
+ * with the number of files it removed, and the report prints them in the
12
+ * header, so an exclusion can narrow the question but never hide the answer.
13
+ */
14
+ import { readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ function normalize(raw) {
18
+ return raw.trim()
19
+ .replaceAll('\\', '/')
20
+ .replace(/^\.\//, '')
21
+ .replace(/^\/+/, '')
22
+ .replace(/\/+$/, '');
23
+ }
24
+
25
+ /** Parse a .roastignore body into normalized patterns. */
26
+ export function parseRoastignore(text) {
27
+ return (text ?? '').split('\n')
28
+ .map((line) => line.trim())
29
+ .filter((line) => line && !line.startsWith('#'))
30
+ .map(normalize)
31
+ .filter(Boolean);
32
+ }
33
+
34
+ /**
35
+ * Merge .roastignore (read from repoRoot) with CLI --exclude values into a
36
+ * pattern list plus a matcher. Each entry carries its source and a `files`
37
+ * counter the walker increments, so the harvest can report what each pattern
38
+ * actually removed. Duplicate patterns keep the first source seen.
39
+ */
40
+ export function loadExclusions(repoRoot, cliExcludes = []) {
41
+ const patterns = [];
42
+ const seen = new Set();
43
+ const add = (raw, source) => {
44
+ const p = normalize(raw);
45
+ if (!p || seen.has(p)) return;
46
+ seen.add(p);
47
+ patterns.push({ pattern: p, source, files: 0 });
48
+ };
49
+
50
+ let ignoreText = null;
51
+ try { ignoreText = readFileSync(join(repoRoot, '.roastignore'), 'utf8'); } catch { /* no file, fine */ }
52
+ for (const p of parseRoastignore(ignoreText)) add(p, '.roastignore');
53
+ for (const p of cliExcludes) add(p, '--exclude');
54
+
55
+ // Whole-segment prefix: pattern "lab" matches "lab" and "lab/…", never "labs".
56
+ const matcher = (rel) => patterns.find((e) => rel === e.pattern || rel.startsWith(`${e.pattern}/`)) ?? null;
57
+ return { patterns, match: patterns.length ? matcher : () => null };
58
+ }
@@ -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 = '4.1.1';
4
+ export const VERSION = '4.2.0';