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 +20 -1
- package/bin/roast.mjs +18 -2
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/diagnose/index.mjs +20 -0
- package/skills/roast-my-design-system/scripts/harvest/index.mjs +31 -1
- package/skills/roast-my-design-system/scripts/harvest/walk.mjs +24 -2
- package/skills/roast-my-design-system/scripts/lib/exclusions.mjs +58 -0
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
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.
|
|
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
|
|
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
|
+
}
|