roast-my-design-system 4.2.5 → 4.3.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 +1 -1
- package/bin/roast.mjs +18 -3
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/harvest/index.mjs +8 -42
- package/skills/roast-my-design-system/scripts/harvest/summary.mjs +61 -0
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/rules/apply.mjs +43 -5
- package/skills/roast-my-design-system/scripts/rules/build.mjs +39 -14
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ One scan powers all of it; the flags decide what lands on disk. Combine freely.
|
|
|
26
26
|
|---|---|
|
|
27
27
|
| `npx roast-my-design-system` | The scan and `design-system-roast.html`, opened in your browser |
|
|
28
28
|
| `npx roast-my-design-system <path>` | Scan a different repo than the current directory |
|
|
29
|
-
| `... --apply` | The generated agent rules injected straight into
|
|
29
|
+
| `... --apply` | The generated agent rules injected straight into every agent file you have: `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.cursor/rules/`, `.windsurfrules` and `.github/copilot-instructions.md`, inside a marked block. Re-running replaces only that block, never your own text. Windsurf and Copilot get a compact variant sized for their limits |
|
|
30
30
|
| `... --rules` | The same rules written to `design-system-rules.md` instead, for pasting by hand |
|
|
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 |
|
package/bin/roast.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
12
12
|
import { mkdtempSync, rmSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
13
13
|
import { join, dirname, resolve } from 'node:path';
|
|
14
14
|
import { tmpdir } from 'node:os';
|
|
15
|
-
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
16
|
|
|
17
17
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
18
18
|
const SCRIPTS = join(HERE, '../skills/roast-my-design-system/scripts');
|
|
@@ -55,8 +55,10 @@ Usage: npx roast-my-design-system [path] [options]
|
|
|
55
55
|
--rules also write design-system-rules.md: agent rules (for
|
|
56
56
|
CLAUDE.md / .cursor/rules) generated from the scan
|
|
57
57
|
--apply inject the rules straight into your agent files (CLAUDE.md,
|
|
58
|
-
AGENTS.md, .cursorrules, .cursor/rules
|
|
59
|
-
|
|
58
|
+
AGENTS.md, .cursorrules, .cursor/rules/, .windsurfrules,
|
|
59
|
+
.github/copilot-instructions.md) inside a marked block;
|
|
60
|
+
re-running replaces only that block. Windsurf and Copilot
|
|
61
|
+
get a compact variant sized for their limits
|
|
60
62
|
--card also write roast-card.svg: a shareable 1200x630 card with
|
|
61
63
|
the score and worst findings (pure SVG, embeds in READMEs)
|
|
62
64
|
--sarif also write design-system-roast.sarif for GitHub code
|
|
@@ -115,6 +117,19 @@ say('');
|
|
|
115
117
|
run('diagnose/index.mjs', [harvestPath, '--out', outPath, '--theme', theme, '--summary', summaryPath,
|
|
116
118
|
...(commissionedBy ? ['--by', commissionedBy] : [])]);
|
|
117
119
|
|
|
120
|
+
// The verdict leads, the evidence follows: harvest details print here, after
|
|
121
|
+
// the diagnosis, rendered from harvest.json via the same lines the direct
|
|
122
|
+
// harvest run uses.
|
|
123
|
+
if (!asJson) {
|
|
124
|
+
try {
|
|
125
|
+
const harvestData = JSON.parse(readFileSync(harvestPath, 'utf8'));
|
|
126
|
+
const { detailLines } = await import(pathToFileURL(join(SCRIPTS, 'harvest/summary.mjs')).href);
|
|
127
|
+
console.log('');
|
|
128
|
+
for (const l of detailLines(harvestData)) console.log(l);
|
|
129
|
+
console.log(`\n scanned in ${harvestData.tookMs}ms`);
|
|
130
|
+
} catch { /* details are garnish; the report exists either way */ }
|
|
131
|
+
}
|
|
132
|
+
|
|
118
133
|
const rulesPath = resolve(join(target, 'design-system-rules.md'));
|
|
119
134
|
if (wantRules) {
|
|
120
135
|
say('');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"description": "Your AI can write the UI. This makes sure it writes your UI. A deterministic scanner counts every colour, spacing value and duplicate component, scores you 0-100 against 34 public repos, scopes the scan with .roastignore, injects agent rules with --apply.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"design-system",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { writeFileSync } from 'node:fs';
|
|
16
16
|
import { resolve } from 'node:path';
|
|
17
17
|
import { walkRepo, profileRepo } from './walk.mjs';
|
|
18
|
+
import { headerLines, detailLines } from './summary.mjs';
|
|
18
19
|
import { harvestComponents } from './components.mjs';
|
|
19
20
|
import { harvestTokens } from './tokens.mjs';
|
|
20
21
|
import { findDuplicates } from './duplicates.mjs';
|
|
@@ -157,47 +158,12 @@ harvest.tookMs = Date.now() - t0;
|
|
|
157
158
|
writeFileSync(outPath, JSON.stringify(harvest, null, 2));
|
|
158
159
|
|
|
159
160
|
// ---------- one-screen summary ----------
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
return m && !balanced ? `var(${m[1]})` : v;
|
|
168
|
-
};
|
|
169
|
-
// A "top" list is only news when something repeats; all-×1 says nothing.
|
|
170
|
-
const top = (list, n = 5) => list.some((e) => e.count > 1)
|
|
171
|
-
? ` top: ${list.slice(0, n).map((e) => `${showVal(e.value)} ×${e.count}`).join(', ')}`
|
|
172
|
-
: (list.length ? ', none repeated' : '');
|
|
173
|
-
|
|
174
|
-
console.log(`Harvest: ${profile.name ?? target}`);
|
|
175
|
-
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'}`);
|
|
176
|
-
console.log(` files: ${files.code.length} code, ${files.styles.length} style`);
|
|
177
|
-
if (exclusions.patterns.length) {
|
|
178
|
-
// Same voice as the report header: source named once, slashes on folders,
|
|
179
|
-
// and the total at the end because that is the number that lands.
|
|
180
|
-
const groups = [...new Set(exclusions.patterns.map((p) => p.source))].map((src) => {
|
|
181
|
-
const own = exclusions.patterns.filter((p) => p.source === src);
|
|
182
|
-
return `(${src}): ${own.map((p, i) => `${p.pattern}/ ${p.files}${i === 0 ? ' files' : ''}`).join(', ')}`;
|
|
183
|
-
});
|
|
184
|
-
const total = exclusions.patterns.reduce((sum, p) => sum + p.files, 0);
|
|
185
|
-
console.log(` excluded by you ${groups.join(' · ')} · ${total} files kept out of this scan`);
|
|
186
|
-
}
|
|
187
|
-
console.log(`\n components: ${components.length} defined (${nonPage.length} reusable, ${components.length - nonPage.length} pages)`);
|
|
188
|
-
console.log(` duplicates: ${duplicates.exactDuplicates.length} exact same-name, ${duplicates.families.length} name families`);
|
|
189
|
-
for (const d of duplicates.exactDuplicates.slice(0, 3)) console.log(` · ${d.name} defined in ${d.files.length} files`);
|
|
190
|
-
for (const f of duplicates.families.slice(0, 3)) console.log(` · ${f.root} family: ${f.members.map((m) => m.name).join(', ')}`);
|
|
191
|
-
console.log(`\n colours: ${tokens.colors.length} distinct (${hexColors.length} hex, of which ${tokens.greyCount} greys)${top(tokens.colors, 4)}`);
|
|
192
|
-
console.log(` spacing: ${tokens.spacing.length} distinct CSS values${top(tokens.spacing, 5)}`);
|
|
193
|
-
console.log(` radii: ${tokens.radii.length} font sizes: ${tokens.fontSizes.length} font families: ${tokens.fontFamilies.length} shadows: ${tokens.shadows.length}`);
|
|
194
|
-
console.log(` tailwind: ${tokens.tailwind.colors.length} colour utils, ${tokens.tailwind.spacing.length} spacing utils, ${tokens.tailwind.textSizes.length} text sizes`);
|
|
195
|
-
console.log(` inline styles: ${tokens.inlineStyles.count} blocks`);
|
|
196
|
-
console.log(` context files: ${context.map((c) => c.file).join(', ') || 'none'}`);
|
|
197
|
-
// The npx wrapper writes the harvest to a temp dir it deletes right after;
|
|
198
|
-
// pointing the user at a path that will not exist is noise there.
|
|
199
|
-
if (process.env.ROAST_EPHEMERAL_OUT === '1') {
|
|
200
|
-
console.log(`\n scanned in ${harvest.tookMs}ms`);
|
|
201
|
-
} else {
|
|
161
|
+
// The npx wrapper reorders the story (header, then the diagnosis, then these
|
|
162
|
+
// details), so the lines live in summary.mjs and the wrapper prints details
|
|
163
|
+
// itself from harvest.json. Run directly (the skill flow), everything prints
|
|
164
|
+
// here in one go, with the real output path at the end.
|
|
165
|
+
for (const l of headerLines(harvest)) console.log(l);
|
|
166
|
+
if (process.env.ROAST_EPHEMERAL_OUT !== '1') {
|
|
167
|
+
for (const l of detailLines(harvest)) console.log(l);
|
|
202
168
|
console.log(`\n → ${outPath} (${harvest.tookMs}ms)`);
|
|
203
169
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal summary lines for a harvest, split so the npx wrapper can change
|
|
3
|
+
* the running order: header first, diagnosis from step 2 next, details after.
|
|
4
|
+
* Running harvest/index.mjs directly (the skill flow) prints both in one go.
|
|
5
|
+
* Everything reads from the harvest object itself, so the wrapper can render
|
|
6
|
+
* details from harvest.json after the harvest process has exited.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// A truncated capture like "rgba(var(--ink-rgb)" reads as a glitch in the
|
|
10
|
+
// terminal; the token reference inside it is the real story, so show that.
|
|
11
|
+
const showVal = (v) => {
|
|
12
|
+
const m = /var\((--[A-Za-z0-9_-]+)/.exec(v);
|
|
13
|
+
const balanced = (v.match(/\(/g) ?? []).length === (v.match(/\)/g) ?? []).length;
|
|
14
|
+
return m && !balanced ? `var(${m[1]})` : v;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// A "top" list is only news when something repeats; all-×1 says nothing.
|
|
18
|
+
const top = (list, n = 5) => list.some((e) => e.count > 1)
|
|
19
|
+
? ` top: ${list.slice(0, n).map((e) => `${showVal(e.value)} ×${e.count}`).join(', ')}`
|
|
20
|
+
: (list.length ? ', none repeated' : '');
|
|
21
|
+
|
|
22
|
+
export function headerLines(h) {
|
|
23
|
+
const p = h.profile;
|
|
24
|
+
return [
|
|
25
|
+
`Harvest: ${p.name ?? h.repo}`,
|
|
26
|
+
` → framework: ${p.framework}${p.typescript ? ' + TS' : ''} design system: ${p.designSystem.kind}${p.designSystem.name ? ` (${p.designSystem.name})` : ''} styling: ${p.stylingDeps.join(', ') || 'none detected'}`,
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function detailLines(h) {
|
|
31
|
+
const lines = [];
|
|
32
|
+
const components = h.components ?? [];
|
|
33
|
+
const duplicates = h.duplicates ?? { exactDuplicates: [], families: [] };
|
|
34
|
+
const tokens = h.tokens;
|
|
35
|
+
const nonPage = components.filter((c) => !c.isPage);
|
|
36
|
+
const hexColors = tokens.colors.filter((c) => c.value.startsWith('#'));
|
|
37
|
+
|
|
38
|
+
lines.push(` files: ${h.files.code} code, ${h.files.styles} style`);
|
|
39
|
+
if (h.exclusions?.patterns?.length) {
|
|
40
|
+
// Same voice as the report header: source named once, slashes on folders,
|
|
41
|
+
// and the total at the end because that is the number that lands.
|
|
42
|
+
const groups = [...new Set(h.exclusions.patterns.map((p) => p.source))].map((src) => {
|
|
43
|
+
const own = h.exclusions.patterns.filter((p) => p.source === src);
|
|
44
|
+
return `(${src}): ${own.map((p, i) => `${p.pattern}/ ${p.files}${i === 0 ? ' files' : ''}`).join(', ')}`;
|
|
45
|
+
});
|
|
46
|
+
lines.push(` excluded by you ${groups.join(' · ')} · ${h.exclusions.filesExcluded} files kept out of this scan`);
|
|
47
|
+
}
|
|
48
|
+
lines.push('');
|
|
49
|
+
lines.push(` components: ${components.length} defined (${nonPage.length} reusable, ${components.length - nonPage.length} pages)`);
|
|
50
|
+
lines.push(` duplicates: ${duplicates.exactDuplicates.length} exact same-name, ${duplicates.families.length} name families`);
|
|
51
|
+
for (const d of duplicates.exactDuplicates.slice(0, 3)) lines.push(` · ${d.name} defined in ${d.files.length} files`);
|
|
52
|
+
for (const f of duplicates.families.slice(0, 3)) lines.push(` · ${f.root} family: ${f.members.map((m) => m.name).join(', ')}`);
|
|
53
|
+
lines.push('');
|
|
54
|
+
lines.push(` colours: ${tokens.colors.length} distinct (${hexColors.length} hex, of which ${tokens.greyCount} greys)${top(tokens.colors, 4)}`);
|
|
55
|
+
lines.push(` spacing: ${tokens.spacing.length} distinct CSS values${top(tokens.spacing, 5)}`);
|
|
56
|
+
lines.push(` radii: ${tokens.radii.length} font sizes: ${tokens.fontSizes.length} font families: ${tokens.fontFamilies.length} shadows: ${tokens.shadows.length}`);
|
|
57
|
+
lines.push(` tailwind: ${tokens.tailwind.colors.length} colour utils, ${tokens.tailwind.spacing.length} spacing utils, ${tokens.tailwind.textSizes.length} text sizes`);
|
|
58
|
+
lines.push(` inline styles: ${tokens.inlineStyles.count} blocks`);
|
|
59
|
+
lines.push(` context files: ${(h.context ?? []).map((c) => c.file).join(', ') || 'none'}`);
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* block; re-running replaces only our block, never touching their text.
|
|
10
10
|
* .cursor/rules/ (directory) -> design-system-rules.mdc written
|
|
11
11
|
* whole, with the frontmatter Cursor expects.
|
|
12
|
+
* .windsurfrules -> COMPACT rules injected (Windsurf
|
|
13
|
+
* caps rules files at a few thousand characters). Injected only when the
|
|
14
|
+
* file already exists; .windsurf/rules/ (directory) gets its own file.
|
|
15
|
+
* .github/copilot-instructions.md -> COMPACT rules injected; created
|
|
16
|
+
* when .github/ exists (Copilot chat and code review read this file, and
|
|
17
|
+
* Copilot's guidance prefers short instructions).
|
|
12
18
|
* If none exist, falls back to design-system-rules.md at the root and says so.
|
|
13
19
|
*
|
|
14
20
|
* node src/rules/apply.mjs <harvest.json> --target <repo-root>
|
|
@@ -30,17 +36,20 @@ const END = '<!-- roast-my-design-system:rules:end -->';
|
|
|
30
36
|
|
|
31
37
|
const h = JSON.parse(readFileSync(inPath, 'utf8'));
|
|
32
38
|
const { text, ruleCount } = rulesMarkdown(h);
|
|
33
|
-
|
|
39
|
+
// Size-aware variant for hosts with tight practical limits (Windsurf, Copilot)
|
|
40
|
+
const compactText = rulesMarkdown(h, { compact: true }).text;
|
|
41
|
+
const blockOf = (t) => `${BEGIN}\n\n${t.trim()}\n\n${END}`;
|
|
42
|
+
const block = blockOf(text);
|
|
34
43
|
|
|
35
44
|
/** Inject or replace our marked block in an existing file's content. */
|
|
36
|
-
function inject(content) {
|
|
45
|
+
function inject(content, b0 = block) {
|
|
37
46
|
const b = content.indexOf(BEGIN);
|
|
38
47
|
const e = content.indexOf(END);
|
|
39
48
|
if (b !== -1 && e !== -1 && e > b) {
|
|
40
|
-
return { next: content.slice(0, b) +
|
|
49
|
+
return { next: content.slice(0, b) + b0 + content.slice(e + END.length), how: 'replaced' };
|
|
41
50
|
}
|
|
42
51
|
const sep = content.length === 0 ? '' : content.endsWith('\n\n') ? '' : content.endsWith('\n') ? '\n' : '\n\n';
|
|
43
|
-
return { next: content + sep +
|
|
52
|
+
return { next: content + sep + b0 + '\n', how: 'added' };
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
const done = [];
|
|
@@ -60,10 +69,39 @@ if (existsSync(cursorDir) && statSync(cursorDir).isDirectory()) {
|
|
|
60
69
|
done.push(`.cursor/rules/design-system-rules.mdc (${existed ? 'rewritten' : 'created'})`);
|
|
61
70
|
}
|
|
62
71
|
|
|
72
|
+
// Windsurf: the compact variant, because .windsurfrules has a hard character
|
|
73
|
+
// cap. Injected only into a file the user already has; a .windsurf/rules/
|
|
74
|
+
// directory gets its own file, same as Cursor's.
|
|
75
|
+
const windsurfFile = join(target, '.windsurfrules');
|
|
76
|
+
if (existsSync(windsurfFile) && statSync(windsurfFile).isFile()) {
|
|
77
|
+
const { next, how } = inject(readFileSync(windsurfFile, 'utf8'), blockOf(compactText));
|
|
78
|
+
writeFileSync(windsurfFile, next);
|
|
79
|
+
done.push(`.windsurfrules (${how} the compact rules block)`);
|
|
80
|
+
}
|
|
81
|
+
const windsurfDir = join(target, '.windsurf', 'rules');
|
|
82
|
+
if (existsSync(windsurfDir) && statSync(windsurfDir).isDirectory()) {
|
|
83
|
+
const p = join(windsurfDir, 'design-system-rules.md');
|
|
84
|
+
const existed = existsSync(p);
|
|
85
|
+
writeFileSync(p, `${compactText.trim()}\n`);
|
|
86
|
+
done.push(`.windsurf/rules/design-system-rules.md (${existed ? 'rewritten' : 'created'})`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Copilot chat and code review read .github/copilot-instructions.md, and
|
|
90
|
+
// Copilot's guidance prefers short instructions: compact variant, created
|
|
91
|
+
// when .github/ exists (a strong signal the repo lives on GitHub).
|
|
92
|
+
const githubDir = join(target, '.github');
|
|
93
|
+
if (existsSync(githubDir) && statSync(githubDir).isDirectory()) {
|
|
94
|
+
const p = join(githubDir, 'copilot-instructions.md');
|
|
95
|
+
const existing = existsSync(p) && statSync(p).isFile() ? readFileSync(p, 'utf8') : '';
|
|
96
|
+
const { next, how } = inject(existing, blockOf(compactText));
|
|
97
|
+
writeFileSync(p, next);
|
|
98
|
+
done.push(`.github/copilot-instructions.md (${existing ? `${how} the compact rules block` : 'created'})`);
|
|
99
|
+
}
|
|
100
|
+
|
|
63
101
|
if (done.length === 0) {
|
|
64
102
|
const p = join(target, 'design-system-rules.md');
|
|
65
103
|
writeFileSync(p, text);
|
|
66
|
-
console.log(`No agent file found (CLAUDE.md, AGENTS.md, .cursorrules, .cursor/rules/).`);
|
|
104
|
+
console.log(`No agent file found (CLAUDE.md, AGENTS.md, .cursorrules, .cursor/rules/, .windsurfrules, .github/).`);
|
|
67
105
|
console.log(`✓ Wrote design-system-rules.md instead; point your agent at it.`);
|
|
68
106
|
} else {
|
|
69
107
|
console.log(`✓ ${ruleCount} rules applied, every one with a receipt:`);
|
|
@@ -9,7 +9,13 @@ import { nearColorPairs } from './../lib/nearpairs.mjs';
|
|
|
9
9
|
import { neverImportedComponents } from '../lib/neverimported.mjs';
|
|
10
10
|
import { VERSION } from '../lib/version.mjs';
|
|
11
11
|
|
|
12
|
-
export function rulesMarkdown(h) {
|
|
12
|
+
export function rulesMarkdown(h, opts = {}) {
|
|
13
|
+
// compact: the size-aware variant for agent files with tight practical
|
|
14
|
+
// limits (.windsurfrules caps out at a few thousand characters and Copilot's
|
|
15
|
+
// guidance prefers short instructions). Fewer receipts per rule, no prose
|
|
16
|
+
// preamble, and a hard character budget as a safety net. Same rules, tighter.
|
|
17
|
+
const compact = opts.compact === true;
|
|
18
|
+
const CAP = { components: compact ? 5 : 8, dupes: compact ? 3 : 6 };
|
|
13
19
|
const repoName = h.profile?.name ?? 'this repo';
|
|
14
20
|
const t = h.tokens ?? {};
|
|
15
21
|
const reusable = (h.components ?? []).filter((c) => !c.isPage);
|
|
@@ -21,18 +27,24 @@ const repoName = h.profile?.name ?? 'this repo';
|
|
|
21
27
|
|
|
22
28
|
lines.push('## Design system rules');
|
|
23
29
|
lines.push('');
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
if (!compact) {
|
|
31
|
+
lines.push(`<!-- Generated by roast-my-design-system from a scan of ${repoName} on ${scanDate}.`);
|
|
32
|
+
lines.push(' Paste into CLAUDE.md, .cursor/rules or AGENTS.md. Regenerate after big refactors:');
|
|
33
|
+
lines.push(' npx roast-my-design-system --rules -->');
|
|
34
|
+
lines.push('');
|
|
35
|
+
}
|
|
28
36
|
// Mirror of the report's "no design system" banner: with almost no colour or
|
|
29
37
|
// spacing signal there is nothing to derive receipts from, so the preamble
|
|
30
38
|
// must not claim any. Keep the two universal defaults, labelled as defaults.
|
|
31
39
|
const spacingSignal = (t.spacing ?? []).length + (t.tailwind?.spacing ?? []).filter((v) => v.value.startsWith('[')).length;
|
|
32
40
|
const noSystemLikely = (t.colors ?? []).length === 0 || ((t.colors ?? []).length < 3 && spacingSignal === 0);
|
|
33
41
|
lines.push(noSystemLikely
|
|
34
|
-
?
|
|
35
|
-
|
|
42
|
+
? (compact
|
|
43
|
+
? 'Too little styling found to derive repo-specific rules; these are universal defaults. Rescan once real UI lands.'
|
|
44
|
+
: 'The scan found too little styling in this repo to derive repo-specific rules; there is most likely no design system in here yet. The rules below are universal defaults, not findings. Rescan once real UI lands and they will be rebuilt from receipts.')
|
|
45
|
+
: (compact
|
|
46
|
+
? `Follow these rules when writing or editing UI in this repo; derived from a scan on ${scanDate}.`
|
|
47
|
+
: 'Follow these rules when writing or editing UI in this repo. Every rule below was derived from a scan of this codebase, with real paths and usage counts.'));
|
|
36
48
|
|
|
37
49
|
// ---------- tokens ----------
|
|
38
50
|
const strays = (t.colors ?? []).filter((c) => !c.isToken);
|
|
@@ -63,12 +75,12 @@ const repoName = h.profile?.name ?? 'this repo';
|
|
|
63
75
|
for (const c of reusable) nameCounts.set(c.name, (nameCounts.get(c.name) ?? 0) + 1);
|
|
64
76
|
const top = reusable
|
|
65
77
|
.filter((c) => c.usageCount > 0 && !FRAMEWORK_NAMES.has(c.name) && nameCounts.get(c.name) === 1)
|
|
66
|
-
.sort((a, b) => b.usageCount - a.usageCount).slice(0,
|
|
78
|
+
.sort((a, b) => b.usageCount - a.usageCount).slice(0, CAP.components);
|
|
67
79
|
if (top.length) {
|
|
68
80
|
section('Canonical components');
|
|
69
81
|
rule('Use these existing components instead of writing new ones:');
|
|
70
82
|
for (const c of top) {
|
|
71
|
-
const props = c.propsHint?.named?.length ? ` · props: ${c.propsHint.named.slice(0, 4).join(', ')}` : '';
|
|
83
|
+
const props = !compact && c.propsHint?.named?.length ? ` · props: ${c.propsHint.named.slice(0, 4).join(', ')}` : '';
|
|
72
84
|
lines.push(` - \`<${c.name}>\` from \`${c.file}\` (used ${c.usageCount}x${props})`);
|
|
73
85
|
}
|
|
74
86
|
}
|
|
@@ -81,7 +93,7 @@ const repoName = h.profile?.name ?? 'this repo';
|
|
|
81
93
|
// No per-file usage data here, so rank by how shared the location looks;
|
|
82
94
|
// when nothing stands out, do not guess a canonical.
|
|
83
95
|
const sharedScore = (f) => (f.includes('packages/') ? 4 : 0) + (/\/ui\//.test(f) ? 2 : 0) + (f.includes('components/') ? 1 : 0) - (/\bapp\//.test(f) ? 2 : 0) - (f.includes('/icons/') ? 4 : 0);
|
|
84
|
-
for (const d of dupes.slice(0,
|
|
96
|
+
for (const d of dupes.slice(0, CAP.dupes)) {
|
|
85
97
|
const files = d.files.map((f) => (typeof f === 'string' ? f : f.file));
|
|
86
98
|
const ranked = [...files].sort((a, b) => sharedScore(b) - sharedScore(a));
|
|
87
99
|
const clear = sharedScore(ranked[0]) > sharedScore(ranked[1]);
|
|
@@ -129,10 +141,23 @@ if (neverImported.length >= 3) {
|
|
|
129
141
|
rule('Before styling anything new, look at a neighbouring component and match how it does it. Consistency with the repo beats personal preference.');
|
|
130
142
|
|
|
131
143
|
lines.push('');
|
|
132
|
-
|
|
133
|
-
|
|
144
|
+
if (compact) {
|
|
145
|
+
lines.push(`*Compact rules by roast-my-design-system ver. ${VERSION}; the full set with receipts: npx roast-my-design-system --rules*`);
|
|
146
|
+
} else {
|
|
147
|
+
lines.push('---');
|
|
148
|
+
lines.push(`*Generated by [roast-my-design-system](https://github.com/pencilrebel/roast-my-design-system) ver. ${VERSION}. Rescan after refactors to keep these rules honest.*`);
|
|
149
|
+
}
|
|
134
150
|
lines.push('');
|
|
135
|
-
|
|
136
|
-
|
|
151
|
+
let text = lines.join('\n');
|
|
152
|
+
// Safety net for tight hosts: if a very messy repo still blows the budget,
|
|
153
|
+
// cut at the last section boundary under the cap and say the set continues.
|
|
154
|
+
const MAX_COMPACT = 5000;
|
|
155
|
+
if (compact && text.length > MAX_COMPACT) {
|
|
156
|
+
const cut = text.lastIndexOf('\n### ', MAX_COMPACT);
|
|
157
|
+
if (cut > 0) {
|
|
158
|
+
text = `${text.slice(0, cut).trimEnd()}\n\n*Trimmed to fit this file's limits; the full rules: npx roast-my-design-system --rules*\n`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const ruleCount = text.split('\n').filter((l) => l.startsWith('- ')).length;
|
|
137
162
|
return { text, ruleCount };
|
|
138
163
|
}
|