roast-my-design-system 5.6.2 → 5.7.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 +5 -5
- package/package.json +7 -3
- package/skills/roast-my-design-system/scripts/diagnose/index.mjs +69 -9
- package/skills/roast-my-design-system/scripts/harvest/components.mjs +53 -0
- package/skills/roast-my-design-system/scripts/harvest/index.mjs +32 -0
- package/skills/roast-my-design-system/scripts/harvest/tokens.mjs +89 -6
- package/skills/roast-my-design-system/scripts/harvest/walk.mjs +37 -1
- package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
- package/skills/roast-my-design-system/scripts/rules/build.mjs +5 -0
package/README.md
CHANGED
|
@@ -8,11 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
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.
|
|
10
10
|
|
|
11
|
-
> **New in 5.
|
|
12
|
-
|
|
13
|
-
> **New in 5.5: the adoption map.** The report now draws your real system to scale: a treemap where every adopted component's tile area is its import count, and the never-imported are grouped by the year git last saw them touched. The cupboard audit, with dates on the boxes.
|
|
11
|
+
> **New in 5.7: it reads web components, and it admits what it cannot read.** Stencil, Lit and plain custom elements are detected by their tag registrations (validated on telekom/scale, Shoelace, Ionic, Material Web, Adobe Spectrum, Siemens iX, Baloise and Lion); component libraries get composition maps and library language instead of adoption accusations; an earned token namespace like `--telekom-*` is named in the header. And when a repo's component pattern is beyond the scan, the tiles say "not measured" and the score takes no credit, because zeros the scanner never earned are not discipline.
|
|
14
12
|
|
|
15
|
-
> **New in 5.
|
|
13
|
+
> **New in 5.6: every fix comes with its prompt.** Each move in the report's Where to start now carries a copy button holding a ready-made fix prompt: the finding, the file paths, the expected score payoff, and the calm rules for fixing without steamrolling craft. Paste it into your agent, fix, re-run the scan, press the next button. View it first if you like: the prompt unfolds right in the report.
|
|
16
14
|
|
|
17
15
|
> **New in 5.0: it runs as a local MCP server.** One command, and your agent asks the design system before writing UI, then gets the work checked after: which Button is canonical, which token holds that colour, review my changes. Local, deterministic, nothing leaves your machine. See [Live answers over MCP](#live-answers-over-mcp).
|
|
18
16
|
|
|
@@ -20,6 +18,7 @@ Run it on your codebase and get, in about a second:
|
|
|
20
18
|
|
|
21
19
|
- **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…).
|
|
22
20
|
- **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.
|
|
21
|
+
- **Reads React and web components alike.** Stencil, Lit and custom elements detected by tag registration, counted by kebab tag; libraries get composition maps, not adoption accusations; earned token namespaces named in the header; and anything the scan cannot read is declared "not measured" instead of scored.
|
|
23
22
|
- **The receipts behind it.** Every colour and its near-identical twin, every spacing value, typeface, duplicated or never-imported component, inline style and !important, with real file paths, in one self-contained HTML report you can open, Slack or email.
|
|
24
23
|
- **The first fixes ranked by payoff, each with its prompt.** A "Where to start" list derived from your own numbers, and every move carries a copy button with a ready-made fix prompt for your agent: the finding, the files, the expected payoff, and rules that respect deliberate craft. Fix, re-run the scan, press the next button.
|
|
25
24
|
- **Rules that stop the mess coming back.** A generated `design-system-rules.md` with canonical components, your token file, and known duplicates to avoid, so your AI agent follows your system instead of guessing at it. `--apply` injects them into every agent file you have: Claude, Cursor, GitHub Copilot, and Windsurf. Every scan also checks the rules you already have for stale references: paths that no longer exist, components named canonical that nothing imports anymore.
|
|
@@ -61,7 +60,7 @@ One scan powers all of it; the flags decide what lands on disk. Combine freely.
|
|
|
61
60
|
|
|
62
61
|
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:
|
|
63
62
|
|
|
64
|
-

|
|
65
64
|
|
|
66
65
|
The same report in light mode (one file, built-in toggle):
|
|
67
66
|
|
|
@@ -73,6 +72,7 @@ The same report in light mode (one file, built-in toggle):
|
|
|
73
72
|
- **Read-only.** Nothing in your repo is modified. The only outputs are a temp JSON and the HTML report.
|
|
74
73
|
- **No network, no telemetry.** Everything runs locally. Nothing about your code leaves your machine.
|
|
75
74
|
- **Zero dependencies, enforced by the test suite.** The package installs nothing but itself, and that is a tested promise rather than a habit: the suite fails if package.json ever declares a dependency, and the exact file list npm ships is a photographed contract, so nothing can stow away in a release.
|
|
75
|
+
- **Honest gaps.** When a repo's components register in a pattern the scan cannot read, the component tiles say "not measured" and drop out of the score. A zero the scanner never earned is presented as blindness, not discipline.
|
|
76
76
|
- **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.
|
|
77
77
|
- **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.
|
|
78
78
|
- **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.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.7.0",
|
|
4
4
|
"mcpName": "io.github.gregkozakiewicz/roast-my-design-system",
|
|
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, 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.",
|
|
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": [
|
|
7
7
|
"design-system",
|
|
8
8
|
"mcp",
|
|
@@ -19,7 +19,11 @@
|
|
|
19
19
|
"linter",
|
|
20
20
|
"code-quality",
|
|
21
21
|
"cursor",
|
|
22
|
-
"windsurf"
|
|
22
|
+
"windsurf",
|
|
23
|
+
"web-components",
|
|
24
|
+
"stencil",
|
|
25
|
+
"lit",
|
|
26
|
+
"devin"
|
|
23
27
|
],
|
|
24
28
|
"homepage": "https://gregkozakiewicz.github.io/roast-my-design-system/",
|
|
25
29
|
"bugs": "https://github.com/gregkozakiewicz/roast-my-design-system/issues",
|
|
@@ -228,6 +228,15 @@ const nearPairs = nearColorPairs(colors);
|
|
|
228
228
|
|
|
229
229
|
const neverImported = neverImportedComponents(h.components, h.profile?.uiDir);
|
|
230
230
|
|
|
231
|
+
// Measurability and role, decided by the harvest (telekom/scale, 2026-09-01):
|
|
232
|
+
// when the component detector could not read this repo's stack, the component
|
|
233
|
+
// metrics say so and take no score credit; when the repo is a published
|
|
234
|
+
// library, usage means composition (how the system uses itself), never
|
|
235
|
+
// adoption, and every accusation about orphans is softened to match.
|
|
236
|
+
const componentsMeasured = h.profile?.componentDetection?.measured !== false;
|
|
237
|
+
const notMeasuredReason = h.profile?.componentDetection?.reason ?? 'an unrecognised component pattern';
|
|
238
|
+
const isLibrary = h.profile?.role === 'library';
|
|
239
|
+
|
|
231
240
|
// Benchmark helpers: for a metric value, where does this repo sit among the
|
|
232
241
|
// scanned fleet? ("more colours than 90% of scanned repos")
|
|
233
242
|
function percentile(metric, value) {
|
|
@@ -283,8 +292,8 @@ if (effGreys > 13 * 1.25) candidates.push({ ratio: effGreys / 13, text: tokenLed
|
|
|
283
292
|
if (spacingTotal > 15) candidates.push({ ratio: spacingTotal / 12, text: `${spacingTotal} off-scale spacing values where a dozen would do` });
|
|
284
293
|
if (nearPairs.length >= 3) candidates.push({ ratio: 1 + nearPairs.length / 6, text: `${nearPairs.length} colour pairs are nearly identical (${nearPairs[0].a.value} next to ${nearPairs[0].b.value}), copy-paste, not decisions` });
|
|
285
294
|
if (important.count >= 10) candidates.push({ ratio: 1 + important.count / 30, text: `${n(important.count)} !important declarations, the cascade admitting defeat` });
|
|
286
|
-
if (iconCollisions.length >= 5) candidates.push({ ratio: 1 + iconCollisions.length / 8, text: `two icon sets collide on ${iconCollisions.length} names` });
|
|
287
|
-
if (hardDupes.length > 0) candidates.push({ ratio: 1 + hardDupes.length / 5, text: `${hardDupes.length} component${hardDupes.length > 1 ? 's' : ''} implemented more than once, so an agent asked for a Button has several random options` });
|
|
295
|
+
if (componentsMeasured && iconCollisions.length >= 5) candidates.push({ ratio: 1 + iconCollisions.length / 8, text: `two icon sets collide on ${iconCollisions.length} names` });
|
|
296
|
+
if (componentsMeasured && hardDupes.length > 0) candidates.push({ ratio: 1 + hardDupes.length / 5, text: `${hardDupes.length} component${hardDupes.length > 1 ? 's' : ''} implemented more than once, so an agent asked for a Button has several random options` });
|
|
288
297
|
if (inline.count > 20) candidates.push({ ratio: inline.count / 20, text: `${n(inline.count)} inline style blocks bypassing every system, and teaching your agent to do the same` });
|
|
289
298
|
if (arbitraryCount >= 20) candidates.push({ ratio: arbitraryCount / 30, text: `${n(arbitraryCount)} arbitrary values like ${arbitrary[0].value} punched through the Tailwind scale, each one a precedent your agent will follow` });
|
|
290
299
|
if (agentFiles.length === 0) candidates.push({ ratio: 1.3, text: `no agent rules, so your AI is guessing` });
|
|
@@ -339,6 +348,7 @@ const ICONS = {
|
|
|
339
348
|
warn: '<svg class="ic ic-warn" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12" y2="16.01"/></svg>',
|
|
340
349
|
bad: '<svg class="ic ic-bad" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
|
341
350
|
info: '',
|
|
351
|
+
na: '<svg class="ic ic-na" viewBox="0 0 24 24"><line x1="7" y1="12" x2="17" y2="12"/></svg>',
|
|
342
352
|
};
|
|
343
353
|
|
|
344
354
|
// Feather "activity", the pulse line, points reversed so it traces left to
|
|
@@ -380,6 +390,21 @@ const bigStats = [
|
|
|
380
390
|
tile(arbitraryCount, 'arbitrary bracket values', 'arbitrary', 'a handful of deliberate exceptions'),
|
|
381
391
|
];
|
|
382
392
|
|
|
393
|
+
// Component metrics the detector could not measure render as such and drop
|
|
394
|
+
// out of the score ('na' is not a scored health); a library's never-imported
|
|
395
|
+
// count stays visible but unscored, with the reason on the tile.
|
|
396
|
+
if (!componentsMeasured) {
|
|
397
|
+
for (const m of ['exactDuplicates', 'neverImported']) {
|
|
398
|
+
const i = bigStats.findIndex((st) => st.metric === m);
|
|
399
|
+
bigStats[i] = { num: '—', label: bigStats[i].label, health: 'na', metric: m, healthValue: null,
|
|
400
|
+
rows: [{ label: `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
|
|
401
|
+
}
|
|
402
|
+
} else if (isLibrary) {
|
|
403
|
+
const t = bigStats.find((st) => st.metric === 'neverImported');
|
|
404
|
+
t.health = 'info';
|
|
405
|
+
t.rows.push({ label: 'library: internal use only, downstream consumers invisible', val: '', dir: '' });
|
|
406
|
+
}
|
|
407
|
+
|
|
383
408
|
// Health score for the hero: the scored tiles averaged (good 100 / warn 55 / bad 10).
|
|
384
409
|
const SCORE_OF = { good: 100, warn: 55, bad: 10 };
|
|
385
410
|
const scoredTiles = bigStats.filter((s) => s.health in SCORE_OF);
|
|
@@ -500,6 +525,7 @@ function trapBox(text) {
|
|
|
500
525
|
return `<div class="trap">${ICONS.warn}<span><b>Agent trap.</b> ${text}</span></div>`;
|
|
501
526
|
}
|
|
502
527
|
function dupesTrap() {
|
|
528
|
+
if (!componentsMeasured) return '';
|
|
503
529
|
const hard = exactDupes.filter((d) => !d.wrapped);
|
|
504
530
|
if (hard.length < 2) return '';
|
|
505
531
|
const top = hard[0];
|
|
@@ -530,6 +556,7 @@ function importantTrap() {
|
|
|
530
556
|
return trapBox(`${n(important.count)} !important declarations, and each one is a shouting match a previous developer decided to win by force. An agent whose style will not apply does what the repo taught it and shouts louder. The volume in this codebase only goes up.`);
|
|
531
557
|
}
|
|
532
558
|
function orphansTrap() {
|
|
559
|
+
if (!componentsMeasured || isLibrary) return '';
|
|
533
560
|
if (neverImported.length < 10) return '';
|
|
534
561
|
return trapBox(`${neverImported.length} components are never imported anywhere. An agent searching for a Button finds the abandoned ones alongside the canonical one with nothing to tell them apart, so yesterday's dead end becomes today's example.`);
|
|
535
562
|
}
|
|
@@ -780,6 +807,14 @@ function orphanRows() {
|
|
|
780
807
|
}
|
|
781
808
|
|
|
782
809
|
function componentsSection() {
|
|
810
|
+
// The wing the inspector could not enter gets named, never skipped: a
|
|
811
|
+
// missing section claims "nothing worth mapping", which is a different
|
|
812
|
+
// claim from "I could not see them".
|
|
813
|
+
if (!componentsMeasured) {
|
|
814
|
+
return `<section class="glass pad">
|
|
815
|
+
${sectionHead('The component ledger', 'not measured in this repo')}
|
|
816
|
+
<p class="sub">Components here are built as ${esc(notMeasuredReason)}, a pattern this scan cannot read yet. Nothing on this page makes claims about component count, duplication, usage or adoption, and the score takes no credit for those tiles. Everything measured from stylesheets stands unaffected: colours, spacing, typography, !important.</p></section>`;
|
|
817
|
+
}
|
|
783
818
|
const adopted = reusable.filter((c) => c.usageCount > 0);
|
|
784
819
|
const top = adopted.slice(0, 10);
|
|
785
820
|
if (!top.length && neverImported.length < 2) return '';
|
|
@@ -793,13 +828,13 @@ function componentsSection() {
|
|
|
793
828
|
<td class="path">${esc(c.file)}</td>
|
|
794
829
|
<td>${c.propsHint?.named?.length ? c.propsHint.named.slice(0, 5).map((p) => `<span class="chip chip-xs">${esc(p)}</span>`).join(' ') : '<span class="dim">none</span>'}</td></tr>`).join('');
|
|
795
830
|
return `<section class="glass pad">
|
|
796
|
-
${sectionHead('The adoption map', `${n(reusable.length)} components defined · tile area is import count · the real system, drawn to scale`)}
|
|
831
|
+
${sectionHead(isLibrary ? 'The composition map' : 'The adoption map', `${n(reusable.length)} components defined · tile area is ${isLibrary ? 'internal use: how the system builds from itself, downstream consumers invisible from here' : 'import count'} · the real system, drawn to scale`)}
|
|
797
832
|
${mapped}
|
|
798
833
|
${overflow > 0 ? `<p class="sub">${overflow} more adopted component${overflow === 1 ? '' : 's'} below the top 24, not drawn.</p>` : ''}
|
|
799
834
|
${onceUsed.length ? `<p class="sub">${n(onceUsed.length)} component${onceUsed.length === 1 ? ' is' : 's are'} imported exactly once: ${onceUsed.slice(0, 6).map((c) => `<${esc(c.name)}>`).join(', ')}${onceUsed.length > 6 ? ` and ${onceUsed.length - 6} more` : ''}. Quiet corners, not yet a system.</p>` : ''}
|
|
800
835
|
${top.length ? `<div class="tbl-wrap"><table><thead><tr><th>component</th><th>used</th><th>defined in</th><th>props</th></tr></thead><tbody>${rows}</tbody></table></div>` : ''}
|
|
801
836
|
${neverImported.length >= 2 ? `
|
|
802
|
-
<div class="receipts">${eyebrow(`${n(neverImported.length)} components defined but never imported · they sit in the system as wrong answers waiting to be picked`)}
|
|
837
|
+
<div class="receipts">${eyebrow(isLibrary ? `${n(neverImported.length)} components unused internally · showroom stock to review, not dead weight: consumers in other repos are invisible from here` : `${n(neverImported.length)} components defined but never imported · they sit in the system as wrong answers waiting to be picked`)}
|
|
803
838
|
${orphanRows()}
|
|
804
839
|
<p class="sub" style="margin-top:8px">Routers, dynamic imports and barrel files can hide real usage, so treat this as a shortlist to check, not a demolition order.</p>` : ''}</section>`;
|
|
805
840
|
}
|
|
@@ -900,7 +935,7 @@ function whereToStartSection() {
|
|
|
900
935
|
const c = [];
|
|
901
936
|
if (agentFiles.length === 0) c.push({ score: 60, metric: null, title: 'Write the agent rules file',
|
|
902
937
|
sub: `No CLAUDE.md, no AGENTS.md. One page naming the canonical components and the tokens file stops your agent guessing on every UI change. Cheapest fix on this list.` });
|
|
903
|
-
if (hardDupes.length > 0) {
|
|
938
|
+
if (componentsMeasured && hardDupes.length > 0) {
|
|
904
939
|
// Pick the pair with the strongest copy-paste evidence: sibling files in
|
|
905
940
|
// app code (same basename, neither in a shared package) are true
|
|
906
941
|
// duplicates; a library-vs-app pair may be an intentional override, and
|
|
@@ -918,7 +953,7 @@ function whereToStartSection() {
|
|
|
918
953
|
title: `Crown the canonical <${esc(d.name)}>`,
|
|
919
954
|
sub: `${hardDupes.length} component name${hardDupes.length > 1 ? 's have' : ' has'} competing implementations. Start with <${esc(d.name)}>: ${esc(d.files[0])} vs ${esc(d.files[1] ?? '')}. Decide which one is canonical, re-export it from one home, and rename or fold in the other.` });
|
|
920
955
|
}
|
|
921
|
-
if (iconCollisions.length >= 5) c.push({ score: 20 + iconCollisions.length * 3, metric: null, title: 'Merge the two icon sets',
|
|
956
|
+
if (componentsMeasured && iconCollisions.length >= 5) c.push({ score: 20 + iconCollisions.length * 3, metric: null, title: 'Merge the two icon sets',
|
|
922
957
|
sub: `${iconCollisions.length} icon names exist in both sets, so every import is a coin flip. Pick one home and rename or delete the rest. Receipt: ${esc(iconCollisions[0].files[0])} vs ${esc(iconCollisions[0].files[1])}.` });
|
|
923
958
|
const strayOffender = offenders.find((o) => o.strayColors > 0);
|
|
924
959
|
if (colorStrays > 12) {
|
|
@@ -978,7 +1013,7 @@ function whereToStartSection() {
|
|
|
978
1013
|
title: `Unwind the ${n(important.count)} !important declarations`,
|
|
979
1014
|
sub: `Each one is a selector losing an argument with another selector${worst ? `; ${esc(basename(worst.file))} alone carries ${worst.count}` : ''}. Fix the specificity at the source and they stop being necessary.` });
|
|
980
1015
|
}
|
|
981
|
-
if (neverImported.length >= 3) {
|
|
1016
|
+
if (componentsMeasured && !isLibrary && neverImported.length >= 3) {
|
|
982
1017
|
c.push({ score: 15 + neverImported.length, metric: 'neverImported', after: 0,
|
|
983
1018
|
title: `Decide about the ${n(neverImported.length)} components nobody imports`,
|
|
984
1019
|
sub: `${neverImported.slice(0, 3).map((x) => `<${esc(x.name)}>`).join(', ')}${neverImported.length > 3 ? ' and others' : ''} sit in the system with no callers. Adopt them or delete them: either answer is better than a system with rooms nobody enters.` });
|
|
@@ -1129,13 +1164,33 @@ function agentSection() {
|
|
|
1129
1164
|
}
|
|
1130
1165
|
|
|
1131
1166
|
// ---------- page ----------
|
|
1167
|
+
// One chip owns the design-system slot, and bigger titles are earned, not
|
|
1168
|
+
// assumed (Greg's rule, 2026-09-01): a recognised library by name; a token
|
|
1169
|
+
// NAMESPACE upgrades to "custom design system (--brand-*)" because
|
|
1170
|
+
// components-plus-tokens is the system definition this tool preaches; a
|
|
1171
|
+
// library with tokens but no namespace gets the plain title; a tokenless
|
|
1172
|
+
// library stays "component library"; a product repo we cannot read gets the
|
|
1173
|
+
// quiet unrecognised chip, because not knowing IS a finding here.
|
|
1174
|
+
const ns = h.tokens.namespaces ?? null;
|
|
1175
|
+
const dsChip =
|
|
1176
|
+
ds.kind === 'shadcn' ? 'shadcn/ui'
|
|
1177
|
+
: ds.kind === 'library' ? ds.name
|
|
1178
|
+
: ns ? `custom design system (--${ns.primary}-*${ns.partner ? ` + --${ns.partner}-*` : ''})`
|
|
1179
|
+
// A tokenFile alone is a technicality (Lion's is one drawer style file);
|
|
1180
|
+
// the plain title needs a token LAYER: ten defined colour tokens.
|
|
1181
|
+
: isLibrary && h.tokens.tokenFile && (h.tokens.colors ?? []).filter((c) => c.isToken).length >= 10 ? 'custom design system'
|
|
1182
|
+
: isLibrary ? 'component library'
|
|
1183
|
+
: ds.name === 'CSS tokens' ? 'CSS tokens'
|
|
1184
|
+
: null;
|
|
1132
1185
|
const stack = [
|
|
1133
1186
|
h.profile.framework !== 'unknown' ? h.profile.framework : null,
|
|
1134
1187
|
h.profile.typescript ? 'TypeScript' : null,
|
|
1135
1188
|
...(h.profile.stylingDeps ?? []),
|
|
1136
|
-
|
|
1189
|
+
dsChip,
|
|
1137
1190
|
h.profile.monorepo ? 'monorepo' : null,
|
|
1138
1191
|
].filter(Boolean);
|
|
1192
|
+
const dsUnrecognised = !dsChip && !noSystemLikely;
|
|
1193
|
+
const legacyChip = ns?.others?.length ? `also present: ${ns.others.map((l) => `--${l}-*`).join(', ')}` : null;
|
|
1139
1194
|
|
|
1140
1195
|
// User exclusions are printed in the header, never hidden: a scoped scan must
|
|
1141
1196
|
// say it is scoped, or the score could be quietly gamed. Grouped by source
|
|
@@ -1364,6 +1419,9 @@ const html = `<!doctype html>
|
|
|
1364
1419
|
border-radius:99px; padding:2px 10px; }
|
|
1365
1420
|
.vchip.bad { color:var(--coral); border-color:var(--coral-soft); background:var(--coral-soft); }
|
|
1366
1421
|
.vchip.dim { color:var(--dim2); }
|
|
1422
|
+
.chip-dim { opacity:.6; font-style:italic; }
|
|
1423
|
+
.st-na .num { color:var(--dim2); }
|
|
1424
|
+
.ic-na { stroke:var(--dim2); }
|
|
1367
1425
|
.amap { width:100%; height:auto; display:block; margin:10px 0 4px; }
|
|
1368
1426
|
.atile { fill:var(--ok-soft); stroke:var(--bg, #0f172a); stroke-width:2; }
|
|
1369
1427
|
.orow { display:flex; align-items:baseline; gap:12px; margin-top:8px; }
|
|
@@ -1511,7 +1569,7 @@ const html = `<!doctype html>
|
|
|
1511
1569
|
</div>
|
|
1512
1570
|
${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>` : ''}
|
|
1513
1571
|
</div>
|
|
1514
|
-
<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>
|
|
1572
|
+
<div class="chips">${stack.map((s) => `<span class="chip">${esc(s)}</span>`).join('')}${dsUnrecognised ? '<span class="chip chip-dim">design system: unrecognised</span>' : ''}${legacyChip ? `<span class="chip chip-dim">${esc(legacyChip)}</span>` : ''}${agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)}</span>`).join('')}</div>
|
|
1515
1573
|
${exclusionsLine()}
|
|
1516
1574
|
${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>` : ''}
|
|
1517
1575
|
<div class="glass verdict-card">
|
|
@@ -1645,6 +1703,8 @@ if (summaryPath) {
|
|
|
1645
1703
|
score: healthScore,
|
|
1646
1704
|
noSystemLikely,
|
|
1647
1705
|
verdict,
|
|
1706
|
+
role: h.profile?.role ?? 'product',
|
|
1707
|
+
componentsMeasured,
|
|
1648
1708
|
tiles: bigStats.map((s) => ({ label: s.label, value: s.num, health: s.health })),
|
|
1649
1709
|
...(startMoves.length ? { moves: startMoves } : {}),
|
|
1650
1710
|
packages: (h.packages ?? []).filter((p) => p.scored && p.metrics)
|
|
@@ -122,6 +122,31 @@ export function definedComponents(src) {
|
|
|
122
122
|
|
|
123
123
|
const looksLikeJSXFile = (src) => /<[A-Za-z][\w.]*[\s/>]/.test(src);
|
|
124
124
|
|
|
125
|
+
// ---------- web components: the tag-registered world ----------
|
|
126
|
+
// Stencil, Lit and raw customElements.define register components by TAG, and
|
|
127
|
+
// pages consume them as <kebab-tags> — invisible to the JSX pass, which is
|
|
128
|
+
// how 93 Telekom components once scanned as one (telekom/scale, 2026-09-01).
|
|
129
|
+
// Generated framework wrappers (Stencil's react/vue/angular output targets)
|
|
130
|
+
// are skipped for definitions AND usage, or every component would gain
|
|
131
|
+
// phantom adoption from its own machine-made bindings.
|
|
132
|
+
const GENERATED_RE = /(@stencil\/(?:react|vue|angular)-output-target|output-target|auto-?generated|generated file|do not edit)/i;
|
|
133
|
+
const TAG_RE = String.raw`([a-z][\w]*(?:-[\w]+)+)`;
|
|
134
|
+
export function webComponentDefs(src) {
|
|
135
|
+
const defs = [];
|
|
136
|
+
const classAfter = (idx) => src.slice(idx).match(/(?:export\s+)?class\s+([A-Za-z]\w*)/)?.[1];
|
|
137
|
+
for (const m of src.matchAll(new RegExp(String.raw`@Component\(\s*\{[^{}]*?tag\s*:\s*['"]${TAG_RE}['"]`, 'g')))
|
|
138
|
+
defs.push({ name: classAfter(m.index) ?? m[1], tag: m[1] });
|
|
139
|
+
for (const m of src.matchAll(new RegExp(String.raw`@customElement\(\s*['"]${TAG_RE}['"]\s*\)\s*(?:export\s+)?class\s+([A-Za-z]\w*)`, 'g')))
|
|
140
|
+
defs.push({ name: m[2], tag: m[1] });
|
|
141
|
+
for (const m of src.matchAll(new RegExp(String.raw`customElements\.define\(\s*['"]${TAG_RE}['"]\s*,\s*([A-Za-z]\w*)`, 'g')))
|
|
142
|
+
defs.push({ name: m[2], tag: m[1] });
|
|
143
|
+
// Shoelace-style static registration: SlButton.define('sl-button')
|
|
144
|
+
for (const m of src.matchAll(new RegExp(String.raw`\b([A-Z]\w*)\.define\(\s*['"]${TAG_RE}['"]`, 'g')))
|
|
145
|
+
defs.push({ name: m[1], tag: m[2] });
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
return defs.filter((d) => !seen.has(d.tag) && seen.add(d.tag));
|
|
148
|
+
}
|
|
149
|
+
|
|
125
150
|
/** Count real `<Name` tag usages of `name` in `src` (tag boundary checked). */
|
|
126
151
|
function countUsages(src, name) {
|
|
127
152
|
let count = 0, from = 0, open;
|
|
@@ -186,6 +211,34 @@ export function harvestComponents(root, codeFiles) {
|
|
|
186
211
|
}
|
|
187
212
|
}
|
|
188
213
|
|
|
214
|
+
// Web-component pass: definitions by tag registration (any code file, JSX
|
|
215
|
+
// or not — Lit lives in plain .ts), usage counted as the kebab tag across
|
|
216
|
+
// every non-generated source.
|
|
217
|
+
const wcSources = new Map();
|
|
218
|
+
for (const f of codeFiles.filter((x) => /\.(tsx|jsx|ts|js|mjs)$/.test(x))) {
|
|
219
|
+
let src = sources.get(f);
|
|
220
|
+
if (src === undefined) { try { src = readFileSync(join(root, f), 'utf8'); } catch { continue; } }
|
|
221
|
+
if (GENERATED_RE.test(src.slice(0, 2000))) continue;
|
|
222
|
+
wcSources.set(f, src);
|
|
223
|
+
}
|
|
224
|
+
const wcComponents = [];
|
|
225
|
+
for (const [file, src] of wcSources) {
|
|
226
|
+
for (const def of webComponentDefs(src)) {
|
|
227
|
+
wcComponents.push({ name: def.name, tag: def.tag, file, isPage: false,
|
|
228
|
+
variants: extractVariants(src), propsHint: null, usageCount: 0, usedIn: [] });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
for (const [file, src] of wcSources) {
|
|
232
|
+
for (const def of wcComponents) {
|
|
233
|
+
if (def.file === file) continue;
|
|
234
|
+
const u = countUsages(src, def.tag);
|
|
235
|
+
if (!u) continue;
|
|
236
|
+
def.usageCount += u;
|
|
237
|
+
if (def.usedIn.length < 8) def.usedIn.push(file);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
components.push(...wcComponents);
|
|
241
|
+
|
|
189
242
|
components.sort((a, b) => b.usageCount - a.usageCount || a.name.localeCompare(b.name));
|
|
190
243
|
|
|
191
244
|
// Pass 3: golden examples — for the most-used components, quote the repo's
|
|
@@ -62,6 +62,38 @@ const staleRules = ruleStaleness(target, components,
|
|
|
62
62
|
new Set(neverImportedComponents(components, profile.uiDir).map((c) => c.name)),
|
|
63
63
|
[...files.code, ...files.styles, ...files.other]);
|
|
64
64
|
|
|
65
|
+
// Repo role and measurability, decided from what the scan actually found.
|
|
66
|
+
// A published components package with no app pages is a LIBRARY: its real
|
|
67
|
+
// consumers live in other repos, so usage counts mean composition, not
|
|
68
|
+
// adoption, and the report's language follows. And when a component stack
|
|
69
|
+
// the detector cannot read is present while it found almost nothing, the
|
|
70
|
+
// component metrics are declared NOT MEASURED instead of scoring zeros as
|
|
71
|
+
// discipline (the telekom/scale lesson, 2026-09-01).
|
|
72
|
+
{
|
|
73
|
+
const reusableCount = components.filter((c) => !c.isPage).length;
|
|
74
|
+
// A web-components repo with many tag-registered components and no pages
|
|
75
|
+
// is a library by construction, whatever its packages are named (Ionic's
|
|
76
|
+
// @ionic/core and Material Web both dodge the name rule).
|
|
77
|
+
// A handful of pages next to hundreds of components is a demo or test app
|
|
78
|
+
// riding in the library's monorepo (Siemens iX ships two), not a product.
|
|
79
|
+
const pageCount = components.length - reusableCount;
|
|
80
|
+
const fewPages = pageCount <= reusableCount * 0.05;
|
|
81
|
+
profile.role = (profile.libraryPkg && fewPages && reusableCount >= 5)
|
|
82
|
+
|| (String(profile.framework).startsWith('web components') && fewPages && reusableCount >= 10)
|
|
83
|
+
? 'library' : 'product';
|
|
84
|
+
// The gate fires on the situation, never a list of known frameworks: a
|
|
85
|
+
// repo with real code volume where the detector found almost nothing is a
|
|
86
|
+
// repo we could not read, whatever it is written in (the Shoelace lesson:
|
|
87
|
+
// its .define() idiom was invisible and the named-framework gate stayed
|
|
88
|
+
// silent, which would have shipped blind zeros as discipline again).
|
|
89
|
+
// Either trigger suffices: a named unreadable stack (Vue/Angular/Svelte
|
|
90
|
+
// markers, however small the repo), or sheer volume the detector saw
|
|
91
|
+
// nothing in (the Shoelace near-miss: unknown idiom, no marker).
|
|
92
|
+
profile.componentDetection = reusableCount < 3 && (profile.unreadableComponentStack || files.code.length >= 40)
|
|
93
|
+
? { measured: false, reason: profile.unreadableComponentStack ?? 'a component pattern this scan cannot read' }
|
|
94
|
+
: { measured: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
65
97
|
// Orphans carry a receipt: the last time git saw anyone touch the file.
|
|
66
98
|
// Only never-imported components are dated (the adoption map's evidence);
|
|
67
99
|
// outside a git repo the field is simply absent and no claim is made.
|
|
@@ -32,7 +32,12 @@ class Tally {
|
|
|
32
32
|
|
|
33
33
|
// ---------- colour parsing ----------
|
|
34
34
|
const HEX_RE = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
|
|
35
|
-
const FUNC_COLOR_RE = /\b(?:rgba?|hsla?|oklch|oklab|lab|lch
|
|
35
|
+
const FUNC_COLOR_RE = /\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(\s*[^)]{1,80}\)|\bcolor\(\s*(?:from|srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|xyz(?:-d(?:50|65))?)\b[^)]{0,80}\)/g;
|
|
36
|
+
|
|
37
|
+
/** Fully transparent values are the CSS word for "nothing here" — counting
|
|
38
|
+
* rgba(0,0,0,0) as a palette colour 581 times (Siemens iX) inflates every
|
|
39
|
+
* colour metric with non-decisions. */
|
|
40
|
+
export const isTransparent = (v) => /^(?:rgba|hsla)\(\s*[\d.,%\s]+,\s*0(?:\.0+)?\s*\)$/.test(v) || /\/\s*0(?:\.0+)?\s*\)$/.test(v);
|
|
36
41
|
|
|
37
42
|
/** normalize: lowercase; expand #abc → #aabbcc so duplicates merge. */
|
|
38
43
|
export function normalizeHex(hex) {
|
|
@@ -197,17 +202,28 @@ export function harvestTokens(root, styleFiles, codeFiles) {
|
|
|
197
202
|
// hinges on this split.
|
|
198
203
|
const tokenDefined = new Set();
|
|
199
204
|
const tokenDefsPerFile = new Map();
|
|
205
|
+
const nsDefs = new Map(); // --telekom-x: value → 'telekom' (definitions)
|
|
206
|
+
const nsRefs = new Map(); // var(--telekom-x) → 'telekom' (references)
|
|
200
207
|
|
|
201
208
|
const scanCssText = (text, file) => {
|
|
202
209
|
const imp = (text.match(/!\s*important/gi) ?? []).length;
|
|
203
210
|
if (imp) importantFiles.set(file, (importantFiles.get(file) ?? 0) + imp);
|
|
204
|
-
for (const m of text.matchAll(/--[\w-]
|
|
211
|
+
for (const m of text.matchAll(/--([\w-]+)\s*:\s*([^;{}]+)[;}]/g)) {
|
|
205
212
|
tokenDefsPerFile.set(file, (tokenDefsPerFile.get(file) ?? 0) + 1);
|
|
206
|
-
|
|
207
|
-
|
|
213
|
+
const stem = m[1].split('-')[0].toLowerCase();
|
|
214
|
+
if (stem) nsDefs.set(stem, (nsDefs.get(stem) ?? 0) + 1);
|
|
215
|
+
for (const c of m[2].matchAll(HEX_RE)) tokenDefined.add(normalizeHex(c[0]));
|
|
216
|
+
for (const c of m[2].matchAll(FUNC_COLOR_RE)) tokenDefined.add(c[0].replace(/\s+/g, ' ').toLowerCase());
|
|
217
|
+
}
|
|
218
|
+
// References count too: a system whose tokens are defined in a package
|
|
219
|
+
// dependency still answers to its namespace in every var(--telekom-...)
|
|
220
|
+
// (telekom/scale defines few tokens in-repo but references hundreds).
|
|
221
|
+
for (const m of text.matchAll(/var\(\s*--([\w-]+)/g)) {
|
|
222
|
+
const stem = m[1].split('-')[0].toLowerCase();
|
|
223
|
+
if (stem) nsRefs.set(stem, (nsRefs.get(stem) ?? 0) + 1);
|
|
208
224
|
}
|
|
209
225
|
for (const m of text.matchAll(HEX_RE)) colors.add(normalizeHex(m[0]), file);
|
|
210
|
-
for (const m of text.matchAll(FUNC_COLOR_RE))
|
|
226
|
+
for (const m of text.matchAll(FUNC_COLOR_RE)) { const v = m[0].replace(/\s+/g, ' ').toLowerCase(); if (!isTransparent(v)) colors.add(v, file); }
|
|
211
227
|
for (const m of text.matchAll(SPACING_PROPS)) {
|
|
212
228
|
for (const len of (m[2].match(LENGTH_RE) ?? [])) spacing.add(len, file);
|
|
213
229
|
}
|
|
@@ -215,8 +231,12 @@ export function harvestTokens(root, styleFiles, codeFiles) {
|
|
|
215
231
|
for (const m of text.matchAll(FONTSIZE_PROPS)) fontSizes.add(m[1].trim(), file);
|
|
216
232
|
for (const m of text.matchAll(FONTFAMILY_PROPS)) {
|
|
217
233
|
const v = m[1].trim().replace(/\s+/g, ' ');
|
|
218
|
-
// var(--x) and inherit are disciplined token usage, not declarations
|
|
234
|
+
// var(--x) and inherit are disciplined token usage, not declarations —
|
|
235
|
+
// and so are Sass token references ($label-text-font, map.get($tokens,
|
|
236
|
+
// ...)), Material Web's whole idiom: 59 fake "typefaces" once counted
|
|
237
|
+
// where one Roboto lived behind tokens (caught 2026-09-02).
|
|
219
238
|
if (/^(var\(--[\w-]+\)|inherit)$/i.test(v)) continue;
|
|
239
|
+
if (v.startsWith('$') || /\bmap[.-]get\b/.test(v)) continue;
|
|
220
240
|
fontFamilies.add(v, file);
|
|
221
241
|
}
|
|
222
242
|
for (const m of text.matchAll(SHADOW_PROPS)) shadows.add(m[1].trim().replace(/\s+/g, ' '), file);
|
|
@@ -248,6 +268,24 @@ export function harvestTokens(root, styleFiles, codeFiles) {
|
|
|
248
268
|
if (EXEMPT_RE.test(f)) continue;
|
|
249
269
|
let src; try { src = readFileSync(join(root, f), 'utf8'); } catch { continue; }
|
|
250
270
|
|
|
271
|
+
// CSS-in-tagged-templates (Lit css``, styled-components css``) IS the
|
|
272
|
+
// stylesheet in those worlds: Shoelace keeps its entire component styling
|
|
273
|
+
// in .styles.ts files, and skipping them scored a mostly-unread repo 94
|
|
274
|
+
// (caught by Greg, 2026-09-02). Each block runs through the same CSS
|
|
275
|
+
// scanner as a stylesheet; interpolations are blanked first (dynamic
|
|
276
|
+
// values are not static styling); the block text is then removed from
|
|
277
|
+
// the raw-hex pass below so nothing is counted twice.
|
|
278
|
+
{
|
|
279
|
+
let cssBlocks = 0;
|
|
280
|
+
for (const m of src.matchAll(/(?:^|[\s=(,:])css\s*`([^`]*)`/gs)) {
|
|
281
|
+
const text = m[1].replace(/\$\{[^}]*\}/g, ' ');
|
|
282
|
+
if (text.length < 20) continue;
|
|
283
|
+
scanCssText(text, f);
|
|
284
|
+
cssBlocks++;
|
|
285
|
+
}
|
|
286
|
+
if (cssBlocks) src = src.replace(/(?:^|[\s=(,:])css\s*`([^`]*)`/gs, ' ');
|
|
287
|
+
}
|
|
288
|
+
|
|
251
289
|
// A Tailwind config's colours ARE the token layer (the tailwind-native
|
|
252
290
|
// equivalent of --var definitions). Its fontFamily block is the typeface
|
|
253
291
|
// declaration for the whole app.
|
|
@@ -350,8 +388,53 @@ export function harvestTokens(root, styleFiles, codeFiles) {
|
|
|
350
388
|
|
|
351
389
|
const tokenFile = [...tokenDefsPerFile.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
|
|
352
390
|
|
|
391
|
+
// Token namespace: the brand stem the repo's custom properties answer to
|
|
392
|
+
// (--telekom-*), named only when earned — enough definitions, a dominant
|
|
393
|
+
// share, not a generic category word. A second qualifying stem is the
|
|
394
|
+
// fossil of a migration, named as legacy. Below the bar: no claim.
|
|
395
|
+
const NS_GENERIC = new Set(['color', 'colors', 'colour', 'font', 'fonts', 'text', 'spacing', 'space',
|
|
396
|
+
'size', 'sizes', 'bg', 'background', 'border', 'shadow', 'shadows', 'radius', 'radii', 'grey', 'gray',
|
|
397
|
+
'theme', 'base', 'global', 'primary', 'secondary', 'brand', 'var', 'css', 'root', 'light', 'dark',
|
|
398
|
+
'transition', 'duration', 'animation', 'ease', 'focus', 'hover', 'active', 'disabled', 'gap', 'grid', 'index',
|
|
399
|
+
'container', 'content', 'item', 'label', 'icon', 'surface', 'outline', 'track', 'thumb', 'state', 'input',
|
|
400
|
+
'width', 'height', 'margin', 'padding', 'opacity', 'line', 'letter']);
|
|
401
|
+
let namespaces = null;
|
|
402
|
+
{
|
|
403
|
+
// The contest is namespace vs namespace, never namespace vs category
|
|
404
|
+
// words: --telekom-* competes with --scl-*, not with --color-*. Primary
|
|
405
|
+
// must carry a real share of all custom-property traffic AND clearly beat
|
|
406
|
+
// its nearest rival; every other qualifying rival is a legacy namespace,
|
|
407
|
+
// the fossil of a migration, named as such.
|
|
408
|
+
const nsAll = new Map();
|
|
409
|
+
for (const [st, c] of nsDefs) nsAll.set(st, c);
|
|
410
|
+
for (const [st, c] of nsRefs) nsAll.set(st, (nsAll.get(st) ?? 0) + c);
|
|
411
|
+
const total = [...nsAll.values()].reduce((a, b) => a + b, 0);
|
|
412
|
+
const candidates = [...nsAll.entries()]
|
|
413
|
+
.filter(([st, c]) => st.length >= 2 && !NS_GENERIC.has(st) && c >= 10 && c / total >= 0.05)
|
|
414
|
+
.sort((a, b) => b[1] - a[1]);
|
|
415
|
+
const [top, second] = candidates;
|
|
416
|
+
if (top && top[1] / total >= 0.25) {
|
|
417
|
+
// The machine names namespaces; it never judges them. "Legacy" failed
|
|
418
|
+
// the admission test across seven real systems (2026-09-02): scale's
|
|
419
|
+
// dead --scl-* and Baloise's live --mod-* tier share the exact same
|
|
420
|
+
// defs/refs shape, so no ratio can tell abandonment from architecture.
|
|
421
|
+
// Facts only: a co-equal partner joins the title; every other
|
|
422
|
+
// qualifying namespace is reported as present, verdict-free. The roast
|
|
423
|
+
// notes, which can read deprecation notices and docs, make the legacy
|
|
424
|
+
// call per repo — judgment stays where context lives.
|
|
425
|
+
if (!second || top[1] >= second[1] * 2) {
|
|
426
|
+
const others = candidates.slice(1).map(([st]) => st);
|
|
427
|
+
namespaces = { primary: top[0], count: top[1], totalRefs: total, ...(others.length ? { others } : {}) };
|
|
428
|
+
} else if (second[1] / total >= 0.15) {
|
|
429
|
+
const others = candidates.slice(2).map(([st]) => st);
|
|
430
|
+
namespaces = { primary: top[0], partner: second[0], count: top[1], totalRefs: total, ...(others.length ? { others } : {}) };
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
353
435
|
return {
|
|
354
436
|
tokenFile,
|
|
437
|
+
namespaces,
|
|
355
438
|
colors: colorList,
|
|
356
439
|
offenders: offenderList,
|
|
357
440
|
greyCount: colorList.filter((c) => c.value.startsWith('#') && isGrey(c.value)).length,
|
|
@@ -145,14 +145,42 @@ export function profileRepo(root, files) {
|
|
|
145
145
|
// Hand-built sites are a real category, not a detection failure: "unknown"
|
|
146
146
|
// next to a good score reads like a shrug, so name what is actually there.
|
|
147
147
|
const htmlFiles = files.other.filter((f) => /\.html?$/.test(f)).length;
|
|
148
|
+
// Web-component builders come FIRST: a Stencil monorepo ships generated
|
|
149
|
+
// React/Vue/Angular wrappers whose deps would otherwise win, and calling
|
|
150
|
+
// Telekom's Stencil system "react" is how a report loses its reader in one
|
|
151
|
+
// glance (learned on telekom/scale, 2026-09-01).
|
|
148
152
|
const framework =
|
|
149
|
-
deps
|
|
153
|
+
deps['@stencil/core'] ? 'web components (Stencil)'
|
|
154
|
+
: deps.lit || deps['lit-element'] ? 'web components (Lit)'
|
|
155
|
+
: deps.next ? 'next'
|
|
150
156
|
: deps['@remix-run/react'] || deps['@react-router/dev'] ? 'remix'
|
|
151
157
|
: deps.vite && deps.react ? 'vite-react'
|
|
152
158
|
: deps.react ? 'react'
|
|
159
|
+
: deps.vue ? 'vue'
|
|
160
|
+
: deps['@angular/core'] ? 'angular'
|
|
161
|
+
: deps.svelte ? 'svelte'
|
|
153
162
|
: htmlFiles > 0 ? 'static HTML/CSS'
|
|
154
163
|
: 'unknown';
|
|
155
164
|
|
|
165
|
+
// A published components package marks a LIBRARY: a repo whose consumers
|
|
166
|
+
// live in other repos entirely. The distinction changes what usage counts
|
|
167
|
+
// mean (composition, not adoption) and how orphans may be judged.
|
|
168
|
+
// Two honest signals: the ROOT package being publishable (private !== true
|
|
169
|
+
// with a main/module/exports) marks the whole repo as the product being
|
|
170
|
+
// shipped, whatever it is named (Shoelace); a publishable SUB-package needs
|
|
171
|
+
// a components-ish name, or every monorepo publishing anything would flip.
|
|
172
|
+
let libraryPkg = null;
|
|
173
|
+
if (pkg.private !== true && (pkg.main || pkg.module || pkg.exports)) libraryPkg = pkg.name ?? 'root package';
|
|
174
|
+
if (!libraryPkg) {
|
|
175
|
+
for (const f of pkgFiles) {
|
|
176
|
+
if (f === 'package.json') continue;
|
|
177
|
+
const p = readJSON(join(root, f));
|
|
178
|
+
if (!p || p.private === true) continue;
|
|
179
|
+
if (!(p.main || p.module || p.exports)) continue;
|
|
180
|
+
if (/components|design.?system|design.?tokens|ui-kit|\bui\b/i.test(p.name ?? '')) { libraryPkg = p.name; break; }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
156
184
|
const componentsJson = readJSON(join(root, 'components.json'));
|
|
157
185
|
const uiDir = ['src/components/ui', 'components/ui', 'app/components/ui'].find((d) => existsSync(join(root, d)));
|
|
158
186
|
const isShadcn = Boolean(componentsJson) || Boolean(uiDir);
|
|
@@ -208,5 +236,13 @@ export function profileRepo(root, files) {
|
|
|
208
236
|
stylingDeps: styling,
|
|
209
237
|
importAlias: componentsJson?.aliases?.components?.split('/')[0] || alias || null,
|
|
210
238
|
uiDir: uiDir || null,
|
|
239
|
+
libraryPkg,
|
|
240
|
+
// markers the component detector cannot read (yet): used by the harvest
|
|
241
|
+
// to declare component metrics "not measured" instead of scoring zeros
|
|
242
|
+
unreadableComponentStack:
|
|
243
|
+
deps.vue || files.other.some((f) => f.endsWith('.vue')) ? 'Vue single-file components'
|
|
244
|
+
: deps['@angular/core'] ? 'Angular components'
|
|
245
|
+
: deps.svelte || files.other.some((f) => f.endsWith('.svelte')) ? 'Svelte components'
|
|
246
|
+
: null,
|
|
211
247
|
};
|
|
212
248
|
}
|
|
@@ -51,6 +51,11 @@ const repoName = h.profile?.name ?? 'this repo';
|
|
|
51
51
|
const tokenCount = (t.colors ?? []).filter((c) => c.isToken).length;
|
|
52
52
|
if (t.tokenFile || (t.colors ?? []).length) {
|
|
53
53
|
section('Colours and tokens');
|
|
54
|
+
if (t.namespaces?.primary) {
|
|
55
|
+
const nsLabel = t.namespaces.partner ? `\`--${t.namespaces.primary}-*\` and \`--${t.namespaces.partner}-*\` namespaces` : `\`--${t.namespaces.primary}-*\` namespace`;
|
|
56
|
+
rule(`This system's tokens answer to the ${nsLabel}. Reach for an existing \`var(--...)\` from them before inventing any value.`);
|
|
57
|
+
if (t.namespaces.others?.length) rule(`${t.namespaces.others.map((l) => `\`--${l}-*\``).join(', ')} ${t.namespaces.others.length === 1 ? 'is also' : 'are also'} present in the code. Before writing new references to ${t.namespaces.others.length === 1 ? 'it' : 'them'}, check whether this repo treats ${t.namespaces.others.length === 1 ? 'it' : 'them'} as current or as a migration source; when unsure, prefer \`--${t.namespaces.primary}-*\`.`);
|
|
58
|
+
}
|
|
54
59
|
if (t.tokenFile) {
|
|
55
60
|
rule(`Design tokens live in \`${t.tokenFile}\`. Reach for an existing token before inventing any value.`);
|
|
56
61
|
if (tokenCount && strays.length) rule(`Never hardcode colour values in components. The palette already has ${tokenCount} tokens; the scan still found ${strays.length} hardcoded colours sitting next to them. Do not add more.`);
|