roast-my-design-system 7.0.0 → 7.1.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
@@ -1,4 +1,4 @@
1
- <img src="https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/roaster_logo_300px.png?v=7.0.0" width="72" alt="roast-my-design-system">
1
+ <img src="https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/roaster_logo_300px.png?v=7.1.0" width="72" alt="roast-my-design-system">
2
2
 
3
3
  # roast-my-design-system
4
4
 
@@ -73,11 +73,11 @@ Not to be confused with each other: **"Why this matters"** is generic, ships wit
73
73
 
74
74
  The full report for vercel/ai-chatbot, top to bottom, including "What the numbers mean", Claude's read of the scan, embedded right under the verdict:
75
75
 
76
- ![The full diagnosis report for vercel/ai-chatbot in dark mode: health score, the What the numbers mean analysis written by Claude, priced Where to start moves each with its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles, the adoption map treemap, palette forensics, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-full-dark.png?v=7.0.0)
76
+ ![The full diagnosis report for vercel/ai-chatbot in dark mode: health score, the What the numbers mean analysis written by Claude, priced Where to start moves each with its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles, the adoption map treemap, palette forensics, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-full-dark.png?v=7.1.0)
77
77
 
78
78
  The same report in light mode (one file, built-in toggle):
79
79
 
80
- ![The diagnosis report in light mode](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-light-hero.png?v=7.0.0)
80
+ ![The diagnosis report in light mode](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-light-hero.png?v=7.1.0)
81
81
 
82
82
  ## What makes the numbers trustworthy
83
83
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "7.0.0",
3
+ "version": "7.1.0",
4
4
  "mcpName": "io.github.gregkozakiewicz/roast-my-design-system",
5
5
  "description": "Your AI can write the UI. This makes sure it writes your UI. A deterministic scanner scores your design system 0-100 against 34 public repos, reads React and web components (Stencil, Lit), hands you a copy-paste fix prompt for each top finding, writes rules for Claude, Cursor, Copilot and Windsurf with --apply, and runs as a local MCP server with --mcp.",
6
6
  "keywords": [
@@ -32,6 +32,7 @@ import { fixPrompt } from '../lib/fixprompt.mjs';
32
32
  import { WHY } from './why.mjs';
33
33
  import { parseColor, luminance, isGrey } from '../lib/color.mjs';
34
34
  import { loadBenchmark, benchHelpers, makeHealthOf, coreMetrics, tileHealths, scoreOfTiles, scorePackage as scorePackageOf, ZERO_IDEAL, WARN_TOLERANCE, SCORE_OF, SCHEMA_VERSION } from './score.mjs';
35
+ import { profileOf } from '../profiles/index.mjs';
35
36
 
36
37
  // The benchmark and every judgement made against it live in score.mjs; this
37
38
  // file only draws. The same numbers reach summary.json through scoreHarvest.
@@ -243,15 +244,16 @@ const nearPairs = nearColorPairs(colors);
243
244
  // rather than wallpaper. Theme variants are not in here; those are the system.
244
245
  const collisions = h.tokens.tokenCollisions ?? [];
245
246
 
246
- const neverImported = neverImportedComponents(h.components, h.profile?.uiDir);
247
+ const P = profileOf(h);
248
+ const neverImported = neverImportedComponents(h.components, P.uiDir);
247
249
 
248
- // Measurability and role, decided by the harvest (telekom/scale, 2026-09-01):
250
+ // Measurability and kind, decided once in profiles/ (telekom/scale, 2026-09-01):
249
251
  // when the component detector could not read this repo's stack, the component
250
252
  // metrics say so and take no score credit; when the repo is a published
251
253
  // library, usage means composition (how the system uses itself), never
252
254
  // adoption, and every accusation about orphans is softened to match.
253
255
  const { componentsMeasured, isLibrary, vendoredUi } = M;
254
- const notMeasuredReason = h.profile?.componentDetection?.reason ?? 'an unrecognised component pattern';
256
+ const notMeasuredReason = P.notMeasuredReason;
255
257
  // A shadcn ui folder is a vendored catalogue: `shadcn add` copies the source
256
258
  // in, people take the whole set at once, and an unused component there is
257
259
  // stock on a shelf rather than something a team built and abandoned. It is
@@ -1767,7 +1769,7 @@ if (summaryPath) {
1767
1769
  score: healthScore,
1768
1770
  noSystemLikely,
1769
1771
  verdict,
1770
- role: h.profile?.role ?? 'product',
1772
+ role: P.kind,
1771
1773
  componentsMeasured,
1772
1774
  metrics: (({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }) =>
1773
1775
  ({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }))(M),
@@ -26,6 +26,7 @@ import { isGrey } from '../lib/color.mjs';
26
26
  import { nearColorPairs } from '../lib/nearpairs.mjs';
27
27
  import { neverImportedComponents } from '../lib/neverimported.mjs';
28
28
  import { SCHEMA_VERSION } from '../lib/version.mjs';
29
+ import { profileOf } from '../profiles/index.mjs';
29
30
 
30
31
  export { SCHEMA_VERSION };
31
32
 
@@ -131,14 +132,14 @@ export function coreMetrics(h) {
131
132
  inlineStyles: h.tokens?.inlineStyles?.count ?? 0,
132
133
  nearPairs: nearColorPairs(colors).length,
133
134
  important: h.tokens?.important?.count ?? 0,
134
- neverImported: neverImportedComponents(h.components, h.profile?.uiDir).length,
135
+ neverImported: neverImportedComponents(h.components, profileOf(h).uiDir).length,
135
136
  arbitrary: (h.tokens?.tailwind?.arbitrary ?? []).reduce((sum, a) => sum + a.count, 0),
136
- // Measurability and role, decided by the harvest: an unreadable stack
137
+ // Measurability and kind, decided once in profiles/: an unreadable stack
137
138
  // takes no score credit; a library's orphans are shown, not judged; a
138
139
  // vendored shadcn catalogue is stock on a shelf, not abandonment.
139
- componentsMeasured: h.profile?.componentDetection?.measured !== false,
140
- isLibrary: h.profile?.role === 'library',
141
- vendoredUi: h.profile?.vendoredUi === true,
140
+ componentsMeasured: profileOf(h).componentsMeasured,
141
+ isLibrary: profileOf(h).isLibrary,
142
+ vendoredUi: profileOf(h).vendoredUi,
142
143
  };
143
144
  }
144
145
 
@@ -27,6 +27,7 @@ import { ruleStaleness } from '../lib/staleness.mjs';
27
27
  import { neverImportedComponents } from '../lib/neverimported.mjs';
28
28
  import { lastTouchedDates } from '../lib/lasttouched.mjs';
29
29
  import { SCHEMA_VERSION } from '../lib/version.mjs';
30
+ import { decideProfile } from '../profiles/index.mjs';
30
31
 
31
32
  function arg(name, fallback) {
32
33
  const i = process.argv.indexOf(`--${name}`);
@@ -63,37 +64,11 @@ const staleRules = ruleStaleness(target, components,
63
64
  new Set(neverImportedComponents(components, profile.uiDir).map((c) => c.name)),
64
65
  [...files.code, ...files.styles, ...files.other]);
65
66
 
66
- // Repo role and measurability, decided from what the scan actually found.
67
- // A published components package with no app pages is a LIBRARY: its real
68
- // consumers live in other repos, so usage counts mean composition, not
69
- // adoption, and the report's language follows. And when a component stack
70
- // the detector cannot read is present while it found almost nothing, the
71
- // component metrics are declared NOT MEASURED instead of scoring zeros as
72
- // discipline (the telekom/scale lesson, 2026-09-01).
73
- {
74
- const reusableCount = components.filter((c) => !c.isPage).length;
75
- // A web-components repo with many tag-registered components and no pages
76
- // is a library by construction, whatever its packages are named (Ionic's
77
- // @ionic/core and Material Web both dodge the name rule).
78
- // A handful of pages next to hundreds of components is a demo or test app
79
- // riding in the library's monorepo (Siemens iX ships two), not a product.
80
- const pageCount = components.length - reusableCount;
81
- const fewPages = pageCount <= reusableCount * 0.05;
82
- profile.role = (profile.libraryPkg && fewPages && reusableCount >= 5)
83
- || (String(profile.framework).startsWith('web components') && fewPages && reusableCount >= 10)
84
- ? 'library' : 'product';
85
- // The gate fires on the situation, never a list of known frameworks: a
86
- // repo with real code volume where the detector found almost nothing is a
87
- // repo we could not read, whatever it is written in (the Shoelace lesson:
88
- // its .define() idiom was invisible and the named-framework gate stayed
89
- // silent, which would have shipped blind zeros as discipline again).
90
- // Either trigger suffices: a named unreadable stack (Vue/Angular/Svelte
91
- // markers, however small the repo), or sheer volume the detector saw
92
- // nothing in (the Shoelace near-miss: unknown idiom, no marker).
93
- profile.componentDetection = reusableCount < 3 && (profile.unreadableComponentStack || files.code.length >= 40)
94
- ? { measured: false, reason: profile.unreadableComponentStack ?? 'a component pattern this scan cannot read' }
95
- : { measured: true };
96
- }
67
+ // Repo kind and measurability, decided once in profiles/ and read everywhere
68
+ // (a published components package with no app pages is a LIBRARY; a stack the
69
+ // detector cannot read is NOT MEASURED, never scored as zeros). The decision
70
+ // lands on the profile with its evidence, so the JSON says why.
71
+ decideProfile(profile, components, files);
97
72
 
98
73
  // Orphans carry a receipt: the last time git saw anyone touch the file.
99
74
  // Only never-imported components are dated (the adoption map's evidence);
@@ -1,7 +1,7 @@
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 = '7.0.0';
4
+ export const VERSION = '7.1.0';
5
5
  // The shape of harvest.json and summary.json. Bumped only when a field is
6
6
  // renamed, removed or changes meaning; a new field is not a new schema. A
7
7
  // tool comparing two scans compares like with like by this number, not by VERSION.
@@ -22,6 +22,7 @@ import { resolveWorkspaces } from '../lib/workspaces.mjs';
22
22
  import { neverImportedComponents } from '../lib/neverimported.mjs';
23
23
  import { typefaceOf } from '../lib/typefaces.mjs';
24
24
  import { hexRgb } from '../lib/nearpairs.mjs';
25
+ import { decideProfile, profileOf } from '../profiles/index.mjs';
25
26
 
26
27
  const MAX_DEPTH = 14; // same ruler as the harvest CLI
27
28
 
@@ -45,6 +46,9 @@ export function loadKnowledge(root) {
45
46
  const files = walkRepo(root, MAX_DEPTH, exclusions);
46
47
  const profile = profileRepo(root, files);
47
48
  const { components } = harvestComponents(root, files.code);
49
+ // The MCP path used to skip the kind decision the CLI harvest makes, so a
50
+ // library read as a product here. Same call, same answer, both doors.
51
+ decideProfile(profile, components, files);
48
52
  const tokens = harvestTokens(root, files.styles, files.code);
49
53
  const duplicates = findDuplicates(components, profile.uiDir, root);
50
54
  const context = harvestContext(root);
@@ -120,8 +124,8 @@ export function loadKnowledge(root) {
120
124
  canonical,
121
125
  dupeByName,
122
126
  neverImported,
123
- // stock, not debt: see harvest profile.vendoredUi
124
- vendoredUi: profile.vendoredUi === true,
127
+ // stock, not debt: see profiles/index.mjs
128
+ vendoredUi: profileOf(profile).vendoredUi,
125
129
  agentFiles: (context ?? []).filter((c) => c.kind === 'agent-rules'),
126
130
  };
127
131
  }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Profiles — what kind of repo is this, decided once and read everywhere.
3
+ *
4
+ * The scanner used to answer "is this a library?", "is this a vendored
5
+ * catalogue?", "can I measure components here?" in three separate places:
6
+ * the profiler in harvest/walk.mjs, a block inside harvest/index.mjs, and
7
+ * every consumer re-deriving flags from the raw profile. The MCP path never
8
+ * ran the harvest block at all, so it had no role. Each new kind of repo
9
+ * would have meant one more "unless" clause in each counter, and that is how
10
+ * a scanner stops meaning one thing.
11
+ *
12
+ * The shape now: one file per kind under profiles/, each answering the same
13
+ * questions in the same order. `decideProfile` picks the first kind that
14
+ * recognises the repo (product is the fallback and always matches), records
15
+ * how sure it is and why, and writes the decision onto the harvest profile.
16
+ * `profileOf` is the one accessor consumers read. No counter, report or rules
17
+ * builder asks the raw flags any more.
18
+ *
19
+ * Step 2 of the shadcn-profile plan: this move changes no number. Every
20
+ * fixture's expected output is byte-identical before and after.
21
+ */
22
+ import library from './library.mjs';
23
+ import product from './product.mjs';
24
+
25
+ // Specific kinds first; product is the fallback and must stay last.
26
+ export const PROFILES = [library, product];
27
+
28
+ /**
29
+ * Decide the repo's kind from the profiler's facts and what the scan found.
30
+ * Mutates `profile` (the harvest.profile object) so the JSON on disk carries
31
+ * the decision: `role` (the field consumers and summary.json already read),
32
+ * `componentDetection` (measurability), and the new `kind`, `kindConfidence`
33
+ * and `kindEvidence` (the receipt: why this kind, in words).
34
+ */
35
+ export function decideProfile(profile, components, files) {
36
+ const reusable = components.filter((c) => !c.isPage);
37
+ const counts = {
38
+ reusable: reusable.length,
39
+ pages: components.length - reusable.length,
40
+ codeFiles: files.code.length,
41
+ };
42
+ // A handful of pages next to hundreds of components is a demo or test app
43
+ // riding in a library's monorepo (Siemens iX ships two), not a product.
44
+ counts.fewPages = counts.pages <= counts.reusable * 0.05;
45
+
46
+ let picked = product, decision = null;
47
+ for (const p of PROFILES) {
48
+ const d = p.recognise(profile, counts);
49
+ if (d) { picked = p; decision = d; break; }
50
+ }
51
+ profile.role = picked.kind;
52
+ profile.kind = picked.kind;
53
+ profile.kindConfidence = decision.confidence;
54
+ profile.kindEvidence = decision.evidence;
55
+
56
+ // Measurability. The gate fires on the situation, never a list of known
57
+ // frameworks: a repo with real code volume where the detector found almost
58
+ // nothing is a repo we could not read, whatever it is written in (the
59
+ // Shoelace lesson: its .define() idiom was invisible and the named-framework
60
+ // gate stayed silent, which would have shipped blind zeros as discipline).
61
+ // Either trigger suffices: a named unreadable stack (Vue/Angular/Svelte
62
+ // markers, however small the repo), or sheer volume the detector saw
63
+ // nothing in (the Shoelace near-miss: unknown idiom, no marker).
64
+ profile.componentDetection = counts.reusable < 3 && (profile.unreadableComponentStack || counts.codeFiles >= 40)
65
+ ? { measured: false, reason: profile.unreadableComponentStack ?? 'a component pattern this scan cannot read' }
66
+ : { measured: true };
67
+ return profile;
68
+ }
69
+
70
+ /**
71
+ * The one accessor. Consumers read these fields and nothing else about the
72
+ * repo's kind. Works on a harvest (h.profile) or a bare profile object, and
73
+ * on harvests written before the profile layer existed (no `kind` field).
74
+ */
75
+ export function profileOf(h) {
76
+ const p = h?.profile ?? h ?? {};
77
+ const kind = p.kind ?? p.role ?? 'product';
78
+ return {
79
+ kind,
80
+ confidence: p.kindConfidence ?? null,
81
+ evidence: p.kindEvidence ?? [],
82
+ isLibrary: kind === 'library',
83
+ // A vendored shadcn catalogue is stock on a shelf, not abandonment.
84
+ vendoredUi: p.vendoredUi === true,
85
+ uiDir: p.uiDir ?? null,
86
+ componentsMeasured: p.componentDetection?.measured !== false,
87
+ notMeasuredReason: p.componentDetection?.reason ?? 'an unrecognised component pattern',
88
+ designSystem: p.designSystem ?? { kind: 'none' },
89
+ };
90
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Library — a repo whose consumers live in other repos entirely.
3
+ *
4
+ * A published components package with no app pages: usage counts mean
5
+ * composition, not adoption, orphans are showroom stock rather than dead
6
+ * weight, and the report's language follows. Two ways in, either sufficient:
7
+ *
8
+ * 1. A publishable package with a components-ish name (or the root package
9
+ * itself being publishable), found by the profiler as `libraryPkg`, next
10
+ * to at least 5 reusable components and almost no pages.
11
+ * 2. A web-components repo with many tag-registered components and no pages,
12
+ * whatever its packages are named (Ionic's @ionic/core and Material Web
13
+ * both dodge the name rule).
14
+ */
15
+ export default {
16
+ kind: 'library',
17
+
18
+ /** @returns {{ confidence: string, evidence: string[] } | null} */
19
+ recognise(profile, counts) {
20
+ const evidence = [];
21
+ if (profile.libraryPkg && counts.fewPages && counts.reusable >= 5) {
22
+ evidence.push(`publishable package ${profile.libraryPkg} with ${counts.reusable} reusable components and ${counts.pages} page${counts.pages === 1 ? '' : 's'}`);
23
+ }
24
+ if (String(profile.framework).startsWith('web components') && counts.fewPages && counts.reusable >= 10) {
25
+ evidence.push(`${counts.reusable} registered web components and ${counts.pages} page${counts.pages === 1 ? '' : 's'}`);
26
+ }
27
+ return evidence.length ? { confidence: 'high', evidence } : null;
28
+ },
29
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Product — the default. An app, a site, a monorepo of apps: the components
3
+ * are used where they are defined, so usage means adoption, an orphan is a
4
+ * wrong answer waiting to be picked, and every tile scores as written.
5
+ *
6
+ * Always matches. It is the fallback, and it must stay the last entry in
7
+ * profiles/index.mjs. It carries no special knowledge on purpose: anything a
8
+ * kind of repo needs read differently belongs in that kind's own file.
9
+ */
10
+ export default {
11
+ kind: 'product',
12
+
13
+ recognise(profile) {
14
+ return {
15
+ confidence: 'high',
16
+ evidence: [profile.libraryPkg
17
+ ? `publishable package ${profile.libraryPkg} present, but the repo has app pages next to its components`
18
+ : 'no publishable components package; components are used where they are defined'],
19
+ };
20
+ },
21
+ };
@@ -8,6 +8,7 @@ import { distinctTypefaces } from '../lib/typefaces.mjs';
8
8
  import { nearColorPairs } from './../lib/nearpairs.mjs';
9
9
  import { neverImportedComponents } from '../lib/neverimported.mjs';
10
10
  import { VERSION } from '../lib/version.mjs';
11
+ import { profileOf } from '../profiles/index.mjs';
11
12
 
12
13
  export function rulesMarkdown(h, opts = {}) {
13
14
  // compact: the size-aware variant for agent files with tight practical
@@ -75,7 +76,7 @@ const repoName = h.profile?.name ?? 'this repo';
75
76
  if (near.length) rule(`Never eyeball a colour from memory: the scan found ${near.length} nearly identical pair${near.length === 1 ? '' : 's'} (like ${near[0].a.value} next to ${near[0].b.value}). Look the exact value up, or better, use its token.`);
76
77
  const ds = h.profile?.designSystem;
77
78
  if (ds?.kind && !['none', 'custom'].includes(ds.kind) && ds.confidence !== 'low') {
78
- rule(`This repo uses ${ds.name ?? ds.kind}${h.profile.uiDir ? `; its components live in \`${h.profile.uiDir}\`` : ''}. Prefer extending it over building parallel pieces.`);
79
+ rule(`This repo uses ${ds.name ?? ds.kind}${profileOf(h).uiDir ? `; its components live in \`${profileOf(h).uiDir}\`` : ''}. Prefer extending it over building parallel pieces.`);
79
80
  }
80
81
  }
81
82
 
@@ -130,14 +131,14 @@ const repoName = h.profile?.name ?? 'this repo';
130
131
  }
131
132
 
132
133
  // ---------- never-imported components ----------
133
- const neverImported = neverImportedComponents(h.components, h.profile?.uiDir);
134
+ const neverImported = neverImportedComponents(h.components, profileOf(h).uiDir);
134
135
  if (neverImported.length >= 3) {
135
136
  // A vendored catalogue is stock, not debt: the rule points the agent AT it
136
137
  // rather than telling it to prune. An unused Calendar beats a hand-rolled
137
138
  // date picker written under time pressure.
138
- if (h.profile?.vendoredUi) {
139
+ if (profileOf(h).vendoredUi) {
139
140
  section('Catalogue components already installed');
140
- rule(`${neverImported.length} components sit installed and unused in \`${h.profile.uiDir}\` (${neverImported.slice(0, 3).map((c) => `\`<${c.name}>\``).join(', ')}…). Reach for one of these before building your own version of the same thing. Do not delete them to tidy up.`);
141
+ rule(`${neverImported.length} components sit installed and unused in \`${profileOf(h).uiDir}\` (${neverImported.slice(0, 3).map((c) => `\`<${c.name}>\``).join(', ')}…). Reach for one of these before building your own version of the same thing. Do not delete them to tidy up.`);
141
142
  } else {
142
143
  section('Components nobody imports');
143
144
  rule(`${neverImported.length} components are defined but never imported (${neverImported.slice(0, 3).map((c) => `\`<${c.name}>\``).join(', ')}…). Before writing any new component, check this list first; adopt one or flag it for deletion instead of adding another.`);
@@ -12,6 +12,7 @@ import { resolve } from 'node:path';
12
12
  import { VERSION } from '../lib/version.mjs';
13
13
  import { nearColorPairs } from '../lib/nearpairs.mjs';
14
14
  import { neverImportedComponents } from '../lib/neverimported.mjs';
15
+ import { profileOf } from '../profiles/index.mjs';
15
16
 
16
17
  function arg(name, fallback) {
17
18
  const i = process.argv.indexOf(`--${name}`);
@@ -46,8 +47,8 @@ for (const d of (h.duplicates?.exactDuplicates ?? [])) {
46
47
  }
47
48
  // A vendored shadcn catalogue is stock, not a defect: nothing to annotate in
48
49
  // code scanning, and a finding per component would bury the real ones.
49
- if (!h.profile?.vendoredUi) {
50
- for (const c of neverImportedComponents(h.components ?? [], h.profile?.uiDir ?? null)) {
50
+ if (!profileOf(h).vendoredUi) {
51
+ for (const c of neverImportedComponents(h.components ?? [], profileOf(h).uiDir)) {
51
52
  push('never-imported-component', 'note', `<${c.name}> is defined here and imported by nothing.`, c.file);
52
53
  }
53
54
  }