roast-my-design-system 4.1.0 → 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
@@ -2,9 +2,11 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/roast-my-design-system?color=2dd4bf&label=npm)](https://www.npmjs.com/package/roast-my-design-system) [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
4
4
 
5
- ## A Claude Code skill that roasts your repo's design system with real data.
5
+ ## Your AI can write the UI. This makes sure it writes *your* UI.
6
6
 
7
- Run this skill on your codebase and get, in about a second:
7
+ A free CLI tool (and Claude Code skill) that roasts your repo's design system with real data, then generates the rules that keep your AI agent on-system.
8
+
9
+ Run it on your codebase and get, in about a second:
8
10
 
9
11
  - **A health score you can defend in a meeting.** 0-100, deterministic, benchmarked against Ideal Design System norms, 34 scanned public repos and 10 reputable design systems (Primer, Polaris, Carbon, shadcn/ui…).
10
12
  - **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.
@@ -16,6 +18,26 @@ Run this skill on your codebase and get, in about a second:
16
18
 
17
19
  Your AI agent (Claude, Cursor, Copilot) builds UI by imitating what's already in your repo. If your repo has 112 colours and four Button implementations, your agent guesses which one is canonical, and it picks wrong half the time. That's why AI-generated UI looks *almost-but-not-quite* right. The first step to fixing it is seeing the mess measured.
18
20
 
21
+ ## Every command
22
+
23
+ One scan powers all of it; the flags decide what lands on disk. Combine freely.
24
+
25
+ | Command | What you get |
26
+ |---|---|
27
+ | `npx roast-my-design-system` | The scan and `design-system-roast.html`, opened in your browser |
28
+ | `npx roast-my-design-system <path>` | Scan a different repo than the current directory |
29
+ | `... --apply` | The generated agent rules injected straight into your `CLAUDE.md`, `AGENTS.md`, `.cursorrules` or `.cursor/rules/`, inside a marked block. Re-running replaces only that block, never your own text |
30
+ | `... --rules` | The same rules written to `design-system-rules.md` instead, for pasting by hand |
31
+ | `... --card` | `roast-card.svg`: a shareable 1200x630 card with the score and worst findings. Pure SVG, embeds in a README |
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
+ | `... --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) |
35
+ | `... --json` | The scan summary as JSON on stdout, for scripts and pipelines |
36
+ | `... --theme light` / `--out <file>` / `--no-open` | Light report, custom report path, don't open the browser |
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 |
38
+
39
+ Every scan also checks the agent rules you already have and flags stale references, no flag needed.
40
+
19
41
  ## Example use cases
20
42
 
21
43
  - **Pre-refactor audit.** Run `/roast-my-design-system` before a design-system cleanup to get the measured baseline: every colour, spacing value, duplicated component and inline style, with real file paths.
@@ -36,39 +58,38 @@ The same report in light mode (one file, built-in toggle):
36
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.
37
59
  - **Read-only.** Nothing in your repo is modified. The only outputs are a temp JSON and the HTML report.
38
60
  - **No network, no telemetry.** Everything runs locally. Nothing about your code leaves your machine.
39
- - **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.
40
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.
41
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.
42
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.
43
65
 
44
- ## Install
66
+ ## Scoping the scan
45
67
 
46
- **No install, no Claude needed — just try it:**
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:
47
69
 
48
70
  ```bash
49
- npx roast-my-design-system
71
+ npx roast-my-design-system --exclude lab/ --exclude playground/
50
72
  ```
51
73
 
52
- Run it inside any repo. Same scanner, same report, straight from npm. 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.
74
+ Or make it permanent with a `.roastignore` file at the repo root, one repo-relative folder per line:
53
75
 
54
- ## Every command
76
+ ```
77
+ # separate visual worlds, not the product's design system
78
+ lab/
79
+ playground/
80
+ ```
55
81
 
56
- One scan powers all of it; the flags decide what lands on disk. Combine freely.
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.
57
83
 
58
- | Command | What you get |
59
- |---|---|
60
- | `npx roast-my-design-system` | The scan and `design-system-roast.html`, opened in your browser |
61
- | `npx roast-my-design-system <path>` | Scan a different repo than the current directory |
62
- | `... --apply` | The generated agent rules injected straight into your `CLAUDE.md`, `AGENTS.md`, `.cursorrules` or `.cursor/rules/`, inside a marked block. Re-running replaces only that block, never your own text |
63
- | `... --rules` | The same rules written to `design-system-rules.md` instead, for pasting by hand |
64
- | `... --card` | `roast-card.svg`: a shareable 1200x630 card with the score and worst findings. Pure SVG, embeds in a README |
65
- | `... --sarif` | `design-system-roast.sarif` for GitHub code scanning: upload it in CI and findings appear in the Security tab, annotated on files |
66
- | `... --by "Ada Lovelace"` | A requester credit in the report header, next to the scan date |
67
- | `... --json` | The scan summary as JSON on stdout, for scripts and pipelines |
68
- | `... --theme light` / `--out <file>` / `--no-open` | Light report, custom report path, don't open the browser |
69
- | `/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 |
84
+ ## Install
70
85
 
71
- Every scan also checks the agent rules you already have and flags stale references, no flag needed.
86
+ **No install, no Claude needed — just try it:**
87
+
88
+ ```bash
89
+ npx roast-my-design-system
90
+ ```
91
+
92
+ Run it inside any repo. Same scanner, same report, straight from npm. 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.
72
93
 
73
94
  **Claude Code (recommended):**
74
95
 
@@ -146,6 +167,8 @@ Three real roasts of public repos, hosted as-is (the same self-contained HTML th
146
167
 
147
168
  Yes, the median repo is already a mess. That's the point.
148
169
 
170
+ **Your AI can write the UI. This makes sure it writes *your* UI.**
171
+
149
172
  ## License
150
173
 
151
174
  MIT. The code is yours to fork, modify and redistribute; the copyright notice travels with 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,7 +1,7 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "4.1.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).",
3
+ "version": "4.2.0",
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",
7
7
  "design-tokens",
@@ -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.0';
4
+ export const VERSION = '4.2.0';