roast-my-design-system 6.0.0 → 7.0.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 +8 -5
- package/bin/roast.mjs +5 -1
- package/package.json +1 -1
- package/skills/roast-my-design-system/scripts/diagnose/index.mjs +61 -142
- package/skills/roast-my-design-system/scripts/diagnose/score.mjs +214 -0
- package/skills/roast-my-design-system/scripts/harvest/components.mjs +48 -32
- package/skills/roast-my-design-system/scripts/harvest/duplicates.mjs +7 -3
- package/skills/roast-my-design-system/scripts/harvest/index.mjs +4 -2
- package/skills/roast-my-design-system/scripts/harvest/tokens.mjs +72 -22
- package/skills/roast-my-design-system/scripts/harvest/walk.mjs +19 -2
- package/skills/roast-my-design-system/scripts/lib/color.mjs +1 -1
- package/skills/roast-my-design-system/scripts/lib/declarations.mjs +12 -2
- package/skills/roast-my-design-system/scripts/lib/guard-api.mjs +3 -3
- package/skills/roast-my-design-system/scripts/lib/version.mjs +5 -1
- package/skills/roast-my-design-system/scripts/lib/workspaces.mjs +21 -1
- package/skills/roast-my-design-system/scripts/mcp/engine.mjs +5 -2
- package/skills/roast-my-design-system/scripts/mcp/server.mjs +3 -0
- package/skills/roast-my-design-system/scripts/mcp/tools.mjs +2 -0
- package/skills/roast-my-design-system/scripts/rules/build.mjs +3 -1
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=
|
|
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">
|
|
2
2
|
|
|
3
3
|
# roast-my-design-system
|
|
4
4
|
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
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.
|
|
12
12
|
|
|
13
|
+
> **New in 7.0.** The scoring code is now a separate file that other tools can import, so a CI check gets the same score as the report. Every `summary.json` now records its schema version and which benchmark it was scored against. Five counting bugs are fixed: a bracket spacing class like `p-[13px]` was counted twice, a hex colour inside a CSS comment was counted, and three smaller ones. Most repos lose a few counts. shadcn/ui goes from 325 colours to 323 and keeps its score of 65. Large monorepos scan about ten times faster. If you use the score as a CI threshold, run a fresh scan before comparing. If you read the JSON, a tile's `value` is now a number and the printed text is in `display`.
|
|
14
|
+
|
|
13
15
|
> **New in 6.0: a dark mode is not sprawl, and the ruler has been rebuilt.** A system with a dark theme states most of its colours twice. One with a density switch states its spacing twice again. Every restatement was counted, so the systems doing the most work scored the worst. Now only a token's first statement counts. A factory-fresh shadcn install goes from 20 colours to 16, and Shoelace from 420 to 219. Two related faults went with it. `border-radius: var(--radius)` was counted as a radius value, so Polaris showed 49 radii of which 36 were references to its own tokens, and Telekom showed 52 font sizes of which 48 were. A new finding names one token holding two different colours in two packages of a monorepo, which fires 8 times across 19 real repos. The benchmark was rebuilt with all of it, so every score is measured against a ruler that counts the same way: cal.com 15 to 20, Cloudscape 35 to 40, MUI 50 to 55. If you have a score from 5.x, take a fresh one before you compare.
|
|
14
16
|
|
|
15
17
|
Run it on your codebase and get, in about a second:
|
|
@@ -43,7 +45,7 @@ Type these in a terminal and you get a result.
|
|
|
43
45
|
| `... --sarif` | `design-system-roast.sarif` for GitHub code scanning: upload it in CI and findings appear in the Security tab, annotated on files |
|
|
44
46
|
| `... --check` | The working tree's changed files checked against the design system, in the terminal. Exits 1 on findings, so it slots into scripts |
|
|
45
47
|
| <code>... --exclude lab/</code> | Leave a folder out of the scan (repeat the flag or comma-separate). Or list folders in a `.roastignore` file at the repo root. Either way the report says so in the header; see [Scoping the scan](#scoping-the-scan) |
|
|
46
|
-
| `... --json` | The scan summary as JSON on stdout, for scripts and pipelines |
|
|
48
|
+
| `... --json` | The scan summary as JSON on stdout, for scripts and pipelines. Includes `schemaVersion`, the benchmark used, and every metric as a number, so two scans can be compared |
|
|
47
49
|
| <code>... --by "Dwayne Hicks"</code> | Puts a name in the report header, for when you ran it for someone else |
|
|
48
50
|
| <code>... --theme light</code> / <code>--out <file></code> / <code>--no-open</code> / <code>--open</code> | Light report, custom report path, never open the browser, always open it |
|
|
49
51
|
|
|
@@ -71,11 +73,11 @@ Not to be confused with each other: **"Why this matters"** is generic, ships wit
|
|
|
71
73
|
|
|
72
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:
|
|
73
75
|
|
|
74
|
-

|
|
75
77
|
|
|
76
78
|
The same report in light mode (one file, built-in toggle):
|
|
77
79
|
|
|
78
|
-

|
|
79
81
|
|
|
80
82
|
## What makes the numbers trustworthy
|
|
81
83
|
|
|
@@ -86,7 +88,8 @@ The same report in light mode (one file, built-in toggle):
|
|
|
86
88
|
- **Honest gaps.** When a repo's components register in a pattern the scan can not 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.
|
|
87
89
|
- **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.
|
|
88
90
|
- **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.
|
|
89
|
-
- **
|
|
91
|
+
- **The scoring code can be imported.** `scoreHarvest(harvest)` in `diagnose/score.mjs` returns the score, the nine tile results and the metrics as plain data. The report uses the same function. A CI check gets the same numbers as the page, and every `summary.json` records its schema version and the benchmark used.
|
|
92
|
+
- **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. The builder and the repo list are in `tools/benchmark/`, so the ruler can be checked, not just quoted.
|
|
90
93
|
- **A second yardstick: reputable systems.** Curated, scoped scans of 10 well-known design systems (shadcn/ui, Primer, Polaris, Carbon, Material UI, Chakra, Ant Design, GOV.UK, Spectrum, Cloudscape) show what disciplined looks like at scale.
|
|
91
94
|
|
|
92
95
|
## Scoping the scan
|
package/bin/roast.mjs
CHANGED
|
@@ -171,12 +171,16 @@ for (const [flagName, file] of [
|
|
|
171
171
|
process.exit(1);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// Every value-taking flag is consumed before the path is looked for, so
|
|
175
|
+
// `--out report.html .` no longer reads report.html as the repo (it did,
|
|
176
|
+
// until 6.0.1: flags in the "wrong" order died with "Not a directory").
|
|
177
|
+
const outOpt = opt('out', null);
|
|
174
178
|
const target = resolve(argv.find((a) => !a.startsWith('--')) || process.cwd());
|
|
175
179
|
if (!existsSync(target) || !statSync(target).isDirectory()) {
|
|
176
180
|
console.error(`Not a directory: ${target}`);
|
|
177
181
|
process.exit(1);
|
|
178
182
|
}
|
|
179
|
-
const outPath = resolve(
|
|
183
|
+
const outPath = resolve(outOpt ?? join(target, 'design-system-roast.html'));
|
|
180
184
|
|
|
181
185
|
const tmp = mkdtempSync(join(tmpdir(), 'roast-'));
|
|
182
186
|
const harvestPath = join(tmp, 'harvest.json');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.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": [
|
|
@@ -31,11 +31,14 @@ import { feedbackUrl, FEEDBACK_ASK, FEEDBACK_CTA } from '../lib/feedback.mjs';
|
|
|
31
31
|
import { fixPrompt } from '../lib/fixprompt.mjs';
|
|
32
32
|
import { WHY } from './why.mjs';
|
|
33
33
|
import { parseColor, luminance, isGrey } from '../lib/color.mjs';
|
|
34
|
+
import { loadBenchmark, benchHelpers, makeHealthOf, coreMetrics, tileHealths, scoreOfTiles, scorePackage as scorePackageOf, ZERO_IDEAL, WARN_TOLERANCE, SCORE_OF, SCHEMA_VERSION } from './score.mjs';
|
|
34
35
|
|
|
35
|
-
// The benchmark
|
|
36
|
-
//
|
|
37
|
-
const
|
|
38
|
-
const
|
|
36
|
+
// The benchmark and every judgement made against it live in score.mjs; this
|
|
37
|
+
// file only draws. The same numbers reach summary.json through scoreHarvest.
|
|
38
|
+
const bench = loadBenchmark();
|
|
39
|
+
const B = benchHelpers(bench);
|
|
40
|
+
const { percentile, cleanerPct, ideal, median, displayAvg, refMedian } = B;
|
|
41
|
+
const healthOf = makeHealthOf(B);
|
|
39
42
|
|
|
40
43
|
function arg(name, fallback) {
|
|
41
44
|
const i = process.argv.indexOf(`--${name}`);
|
|
@@ -46,6 +49,7 @@ if (!inPath) { console.error('Usage: node src/diagnose/index.mjs <harvest.json>
|
|
|
46
49
|
const outPath = resolve(arg('out', 'diagnosis.html'));
|
|
47
50
|
|
|
48
51
|
const h = JSON.parse(readFileSync(inPath, 'utf8'));
|
|
52
|
+
const M = coreMetrics(h);
|
|
49
53
|
|
|
50
54
|
// The GK mark (32px favicon, also gregkozakiewicz.com). Reused in the footer
|
|
51
55
|
// as a CSS mask filled with currentColor so it works in both themes.
|
|
@@ -201,9 +205,8 @@ const nonGreys = colors.filter((c) => !isGrey(c.value));
|
|
|
201
205
|
const twColorUtils = (h.tokens.tailwind?.colors ?? []).length;
|
|
202
206
|
const spacing = h.tokens.spacing ?? [];
|
|
203
207
|
const twSpacing = h.tokens.tailwind?.spacing ?? [];
|
|
204
|
-
// Off-scale spacing only
|
|
205
|
-
|
|
206
|
-
const spacingTotal = spacing.length + twSpacing.filter((v) => v.value.startsWith('[')).length;
|
|
208
|
+
// Off-scale spacing only (formula in score.mjs, shared with the packages pass).
|
|
209
|
+
const spacingTotal = M.spacing;
|
|
207
210
|
const exactDupes = h.duplicates.exactDuplicates ?? [];
|
|
208
211
|
// A wrapped pair (one file imports the name from the other) is composition,
|
|
209
212
|
// not competition — listed with a badge, but not counted as a duplicate.
|
|
@@ -224,14 +227,11 @@ const typefaces = distinctTypefaces(fontFamilies);
|
|
|
224
227
|
// Token-led repos (shadcn/Tailwind semantic setups) hold most of their colours
|
|
225
228
|
// as deliberate CSS-variable tokens; judging them on the total punishes the
|
|
226
229
|
// exact architecture the ideal recommends. Health rides on the strays instead.
|
|
227
|
-
const colorTokens
|
|
228
|
-
const colorStrays = colors.length - colorTokens;
|
|
229
|
-
const tokenLed = colorTokens >= colorStrays && colorTokens > 0;
|
|
230
|
-
const greyStrays = greys.filter((c) => !c.isToken).length;
|
|
230
|
+
const { colorTokens, colorStrays, tokenLed, greyStrays } = M;
|
|
231
231
|
|
|
232
232
|
// Arbitrary bracket values (p-[13px], text-[10px]) — scale erosion, counted.
|
|
233
233
|
const arbitrary = h.tokens.tailwind?.arbitrary ?? [];
|
|
234
|
-
const arbitraryCount = arbitrary
|
|
234
|
+
const arbitraryCount = M.arbitrary;
|
|
235
235
|
|
|
236
236
|
// !important declarations: the cascade admitting defeat.
|
|
237
237
|
const important = h.tokens.important ?? { count: 0, files: [] };
|
|
@@ -250,45 +250,13 @@ const neverImported = neverImportedComponents(h.components, h.profile?.uiDir);
|
|
|
250
250
|
// metrics say so and take no score credit; when the repo is a published
|
|
251
251
|
// library, usage means composition (how the system uses itself), never
|
|
252
252
|
// adoption, and every accusation about orphans is softened to match.
|
|
253
|
-
const componentsMeasured
|
|
253
|
+
const { componentsMeasured, isLibrary, vendoredUi } = M;
|
|
254
254
|
const notMeasuredReason = h.profile?.componentDetection?.reason ?? 'an unrecognised component pattern';
|
|
255
|
-
const isLibrary = h.profile?.role === 'library';
|
|
256
255
|
// A shadcn ui folder is a vendored catalogue: `shadcn add` copies the source
|
|
257
256
|
// in, people take the whole set at once, and an unused component there is
|
|
258
257
|
// stock on a shelf rather than something a team built and abandoned. It is
|
|
259
258
|
// also protective — the agent reaches for it instead of writing its own worse
|
|
260
259
|
// version. So it is counted and shown, never scored and never a fix to make.
|
|
261
|
-
const vendoredUi = h.profile?.vendoredUi === true;
|
|
262
|
-
|
|
263
|
-
// Benchmark helpers: for a metric value, where does this repo sit among the
|
|
264
|
-
// scanned fleet? ("more colours than 90% of scanned repos")
|
|
265
|
-
function percentile(metric, value) {
|
|
266
|
-
const vals = bench?.stats?.[metric]?.values;
|
|
267
|
-
if (!vals?.length) return null;
|
|
268
|
-
const below = vals.filter((v) => v < value).length;
|
|
269
|
-
return Math.round((below / vals.length) * 100);
|
|
270
|
-
}
|
|
271
|
-
// The flattering twin: what share of the scanned fleet is messier than you.
|
|
272
|
-
function cleanerPct(metric, value) {
|
|
273
|
-
const vals = bench?.stats?.[metric]?.values;
|
|
274
|
-
if (!vals?.length) return null;
|
|
275
|
-
const above = vals.filter((v) => v > value).length;
|
|
276
|
-
return Math.round((above / vals.length) * 100);
|
|
277
|
-
}
|
|
278
|
-
const ideal = (metric) => bench?.ideal2026?.[metric]?.value ?? null;
|
|
279
|
-
const median = (metric) => bench?.stats?.[metric]?.median ?? null;
|
|
280
|
-
// A zero median reads as broken data ("Avg: 0 typefaces"), when it really
|
|
281
|
-
// means "the median repo declares none". Fall back to the fleet mean there;
|
|
282
|
-
// it stays an honest "Avg" and only zeroes out if literally every repo does.
|
|
283
|
-
function displayAvg(metric) {
|
|
284
|
-
const mv = median(metric);
|
|
285
|
-
if (mv === null) return null;
|
|
286
|
-
if (mv !== 0) return mv;
|
|
287
|
-
const vals = bench?.stats?.[metric]?.values ?? [];
|
|
288
|
-
const mean = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
|
|
289
|
-
return mean >= 0.5 ? Math.round(mean) : null;
|
|
290
|
-
}
|
|
291
|
-
const refMedian = (metric) => bench?.referenceSystems?.stats?.[metric]?.median ?? null;
|
|
292
260
|
|
|
293
261
|
// Clickable file paths — vscode:// opens the file straight in the editor.
|
|
294
262
|
const fileLink = (f) => `<a class="path" href="vscode://file/${encodeURI(`${h.repo}/${f}`)}">${esc(f)}</a>`;
|
|
@@ -336,36 +304,8 @@ if (bench && findings.length) {
|
|
|
336
304
|
}
|
|
337
305
|
|
|
338
306
|
// ---------- health scoring ----------
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
// median. Health drives the animated icon and colours:
|
|
342
|
-
// good = at or under the ideal → green checkmark (draws in)
|
|
343
|
-
// warn = over the ideal but better than the 27-repo median: "over the
|
|
344
|
-
// target, better than the average repo" → amber circle (pulses)
|
|
345
|
-
// bad = worse than the median (and the median is already a mess)
|
|
346
|
-
// → coral cross (shakes)
|
|
347
|
-
// Where the median is missing or sits at/below ideal, warn caps at 1.5x ideal.
|
|
348
|
-
// Zero-ideal metrics (duplicates, inline styles): 0 is good; a small tolerance
|
|
349
|
-
// is warn; beyond that bad.
|
|
350
|
-
// There used to be an absence rule here (basically nothing found = bad, on
|
|
351
|
-
// the theory that an empty count means no design system). The noSystemLikely
|
|
352
|
-
// guard now owns that judgement at report level, and for a repo with a real
|
|
353
|
-
// system a zero is discipline, not absence — all spacing on tokens scored red.
|
|
354
|
-
const ZERO_IDEAL = new Set(['exactDuplicates', 'inlineStyles', 'nearPairs', 'important', 'neverImported']);
|
|
355
|
-
const WARN_TOLERANCE = { exactDuplicates: 2, inlineStyles: 10, nearPairs: 2, important: 5, neverImported: 2 };
|
|
356
|
-
function healthOf(metric, value) {
|
|
357
|
-
const iv = ideal(metric);
|
|
358
|
-
if (iv === null) return 'info';
|
|
359
|
-
if (ZERO_IDEAL.has(metric)) {
|
|
360
|
-
if (value === 0) return 'good';
|
|
361
|
-
return value <= WARN_TOLERANCE[metric] ? 'warn' : 'bad';
|
|
362
|
-
}
|
|
363
|
-
if (value <= iv) return 'good';
|
|
364
|
-
const mv = median(metric);
|
|
365
|
-
const warnCap = mv && mv > iv ? mv : iv * 1.5;
|
|
366
|
-
if (value <= warnCap) return 'warn';
|
|
367
|
-
return 'bad';
|
|
368
|
-
}
|
|
307
|
+
// The bands, the tolerances and healthOf itself live in score.mjs (imported
|
|
308
|
+
// above), so a guard or a CI check judges with the same ruler as this page.
|
|
369
309
|
// Our own marks, drawn by hand for this report: a tick that starts at the
|
|
370
310
|
// left foot, a full-bleed cross, a round exclamation, a quiet dash. Generic
|
|
371
311
|
// glyphs on purpose, and our own coordinates on purpose: nothing here is
|
|
@@ -390,59 +330,49 @@ function row(label, valText, refVal, value) {
|
|
|
390
330
|
if (value === refVal) return { label, val: valText, dir: 'eq' };
|
|
391
331
|
return { label, val: valText, dir: value < refVal ? 'down' : 'up' };
|
|
392
332
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
333
|
+
const FALLBACK_TARGET = {
|
|
334
|
+
colors: 'a system needs ~24', greys: 'a scale has up to 13', spacing: 'a dozen deliberate exceptions',
|
|
335
|
+
exactDuplicates: 'should be 0', inlineStyles: 'invisible to any system', nearPairs: 'copy-paste, not decisions',
|
|
336
|
+
important: 'the cascade admitting defeat', neverImported: 'the system nobody found', arbitrary: 'a handful of deliberate exceptions',
|
|
337
|
+
};
|
|
338
|
+
// Dress one judged tile for the page: formatted number, comparison rows.
|
|
339
|
+
// Accepts a judged tile from score.mjs, or the positional form the
|
|
340
|
+
// typography section uses for its unscored tiles: (value, label, metric, fallback).
|
|
341
|
+
function tile(t, pLabel, pMetric, pFallback) {
|
|
342
|
+
if (typeof t === 'number') {
|
|
343
|
+
return tile({ metric: pMetric, label: pLabel, value: t, healthValue: t, health: healthOf(pMetric, t), fallbackTarget: pFallback });
|
|
344
|
+
}
|
|
345
|
+
const { metric, label, value, healthValue, health } = t;
|
|
346
|
+
if (health === 'na') {
|
|
347
|
+
return { num: '—', label, health, metric, healthValue: null,
|
|
348
|
+
rows: [{ label: `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
|
|
349
|
+
}
|
|
350
|
+
const iv = ideal(metric), rm = refMedian(metric);
|
|
396
351
|
const pct = percentile(metric, value);
|
|
397
352
|
const rows = [];
|
|
398
353
|
if (iv !== null) rows.push(row('Ideal Design System', ZERO_IDEAL.has(metric) ? String(iv) : `~${n(iv)}`, iv, value));
|
|
399
|
-
else rows.push({ label: fallbackTarget, val: '', dir: '' });
|
|
354
|
+
else rows.push({ label: t.fallbackTarget ?? FALLBACK_TARGET[metric], val: '', dir: '' });
|
|
400
355
|
const clean = cleanerPct(metric, value);
|
|
401
356
|
const avgNote = pct !== null && pct >= 60 && health !== 'good' ? ` · messier than ${pct}%`
|
|
402
357
|
: clean !== null && clean >= 60 ? ` · cleaner than ${clean}%` : '';
|
|
403
358
|
const av = displayAvg(metric);
|
|
404
359
|
if (av !== null) rows.push(row('Avg Design System', `${n(av)}${avgNote}`, av, value));
|
|
405
360
|
if (rm !== null && (rm > 0 || ZERO_IDEAL.has(metric) || metric === 'arbitrary')) rows.push(row('Reputable systems', n(rm), rm, value));
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
tile(hardDupes.length, 'duplicated components', 'exactDuplicates', 'should be 0'),
|
|
413
|
-
tile(inline.count, 'inline style blocks', 'inlineStyles', 'invisible to any system'),
|
|
414
|
-
tile(nearPairs.length, 'near-identical colour pairs', 'nearPairs', 'copy-paste, not decisions'),
|
|
415
|
-
tile(important.count, '!important declarations', 'important', 'the cascade admitting defeat'),
|
|
416
|
-
tile(neverImported.length, 'components never imported', 'neverImported', 'the system nobody found'),
|
|
417
|
-
tile(arbitraryCount, 'arbitrary bracket values', 'arbitrary', 'a handful of deliberate exceptions'),
|
|
418
|
-
];
|
|
419
|
-
|
|
420
|
-
// Component metrics the detector could not measure render as such and drop
|
|
421
|
-
// out of the score ('na' is not a scored health); a library's never-imported
|
|
422
|
-
// count stays visible but unscored, with the reason on the tile.
|
|
423
|
-
if (!componentsMeasured) {
|
|
424
|
-
for (const m of ['exactDuplicates', 'neverImported']) {
|
|
425
|
-
const i = bigStats.findIndex((st) => st.metric === m);
|
|
426
|
-
bigStats[i] = { num: '—', label: bigStats[i].label, health: 'na', metric: m, healthValue: null,
|
|
427
|
-
rows: [{ label: `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
|
|
361
|
+
// A vendored catalogue is never judged for what it left on the shelf, and a
|
|
362
|
+
// library's internal use says nothing about adoption: shown, not scored.
|
|
363
|
+
if (health === 'info' && metric === 'neverImported') {
|
|
364
|
+
rows.push({ label: isLibrary
|
|
365
|
+
? 'library: internal use only, downstream consumers invisible'
|
|
366
|
+
: 'catalogue stock: installed by the shadcn CLI, not used yet', val: '', dir: '' });
|
|
428
367
|
}
|
|
429
|
-
|
|
430
|
-
// but a repo that used everything it installed keeps the credit it earned, so
|
|
431
|
-
// the tile only drops out of the score when it would otherwise accuse.
|
|
432
|
-
} else if (isLibrary || (vendoredUi && neverImported.length > 0)) {
|
|
433
|
-
const t = bigStats.find((st) => st.metric === 'neverImported');
|
|
434
|
-
t.health = 'info';
|
|
435
|
-
t.rows.push({ label: isLibrary
|
|
436
|
-
? 'library: internal use only, downstream consumers invisible'
|
|
437
|
-
: 'catalogue stock: installed by the shadcn CLI, not used yet', val: '', dir: '' });
|
|
368
|
+
return { num: n(value), label, health, rows, metric, healthValue };
|
|
438
369
|
}
|
|
370
|
+
const judgedTiles = tileHealths(M, healthOf);
|
|
371
|
+
const bigStats = judgedTiles.map(tile);
|
|
439
372
|
|
|
440
373
|
// Health score for the hero: the scored tiles averaged (good 100 / warn 55 / bad 10).
|
|
441
|
-
const SCORE_OF = { good: 100, warn: 55, bad: 10 };
|
|
442
374
|
const scoredTiles = bigStats.filter((s) => s.health in SCORE_OF);
|
|
443
|
-
const healthScore =
|
|
444
|
-
? Math.round(scoredTiles.reduce((sum, s) => sum + SCORE_OF[s.health], 0) / scoredTiles.length)
|
|
445
|
-
: null;
|
|
375
|
+
const healthScore = scoreOfTiles(judgedTiles);
|
|
446
376
|
|
|
447
377
|
// ---------- what a fix is worth ----------
|
|
448
378
|
// The score is the average of the scored tiles, so moving one tile across a
|
|
@@ -522,12 +452,12 @@ function paletteSection() {
|
|
|
522
452
|
const rest = sorted.slice(60);
|
|
523
453
|
const cells = shown.map((c) => {
|
|
524
454
|
const src = c.files?.[0] ? ` in ${c.files[0].file}` : '';
|
|
525
|
-
return `<div class="uc${c.isToken ? '' : ' stray'}" style="background:${
|
|
455
|
+
return `<div class="uc${c.isToken ? '' : ' stray'}" style="background:${swatch(c.value)};flex-grow:${Math.max(c.count, 1)}" title="${esc(c.value)} ×${c.count}${esc(src)}">${c.isToken ? '' : '<i></i>'}</div>`;
|
|
526
456
|
}).join('');
|
|
527
457
|
const restCell = rest.length ? `<div class="uc uc-rest" style="flex-grow:${Math.max(rest.reduce((s, c) => s + c.count, 0), 1)}" title="${rest.length} more colours">+${rest.length}</div>` : '';
|
|
528
458
|
|
|
529
459
|
const rampFor = (list) => `
|
|
530
|
-
<div class="grey-strip">${list.map((c) => `<div class="grey-cell" style="background:${
|
|
460
|
+
<div class="grey-strip">${list.map((c) => `<div class="grey-cell" style="background:${swatch(c.value)}" title="${esc(c.value)} ×${c.count}"></div>`).join('')}</div>
|
|
531
461
|
${list.length <= 8 ? `<div class="ramp-labels">${list.map((c) => `<span>${esc(c.value)}</span>`).join('')}</div>` : ''}`;
|
|
532
462
|
const ramp = greys.length >= 4 ? `
|
|
533
463
|
<div class="ramp-head">${eyebrow('Grey ramp · sorted dark → light · how many can you tell apart?')}</div>
|
|
@@ -557,7 +487,7 @@ function paletteSection() {
|
|
|
557
487
|
${ramp}
|
|
558
488
|
${nearPairs.length ? `
|
|
559
489
|
<div class="receipts">${eyebrow(`${nearPairs.length} nearly identical pair${nearPairs.length === 1 ? '' : 's'} · copy-paste, not decisions`)}
|
|
560
|
-
<div class="chips-row">${nearPairs.slice(0, 8).map((pr) => `<span class="vchip" title="every channel within ${pr.d} of its twin"><i class="ndot" style="background:${
|
|
490
|
+
<div class="chips-row">${nearPairs.slice(0, 8).map((pr) => `<span class="vchip" title="every channel within ${pr.d} of its twin"><i class="ndot" style="background:${swatch(pr.a.value)}"></i>${esc(pr.a.value)} ×${pr.a.count} <b class="nsim">≈</b> <i class="ndot" style="background:${swatch(pr.b.value)}"></i>${esc(pr.b.value)} ×${pr.b.count}</span>`).join('')}${nearPairs.length > 8 ? `<span class="vchip dim">+${nearPairs.length - 8} more</span>` : ''}</div>
|
|
561
491
|
${whyToggle('nearPairs')}</div>` : ''}
|
|
562
492
|
</div>
|
|
563
493
|
</section>`;
|
|
@@ -676,6 +606,9 @@ function duplicatesSection() {
|
|
|
676
606
|
// a font size from someone else's repo). Allow only a conservative CSS charset
|
|
677
607
|
// so a malformed or hostile value cannot break out of the attribute.
|
|
678
608
|
const cssSafe = (v) => (typeof v === 'string' && /^[-#0-9a-z%.,()\s\/]+$/i.test(v) && !/[;{}<>"']/.test(v) ? v.trim() : null);
|
|
609
|
+
// A colour swatch is painted with the value itself, so the value must look
|
|
610
|
+
// like a colour and nothing else; anything odd paints grey rather than the page.
|
|
611
|
+
const swatch = (v) => cssSafe(v) ?? '#888';
|
|
679
612
|
// px equivalent of a length, for sorting and for rendering type at real size.
|
|
680
613
|
function toPx(v) {
|
|
681
614
|
const m = /^(-?\d*\.?\d+)\s*(px|rem|em|pt)?$/i.exec(String(v).trim());
|
|
@@ -1151,29 +1084,7 @@ const PKG_LABELS = {
|
|
|
1151
1084
|
nearPairs: 'near-identical colour pairs', important: '!important declarations',
|
|
1152
1085
|
neverImported: 'components nobody imports', arbitrary: 'arbitrary bracket values',
|
|
1153
1086
|
};
|
|
1154
|
-
|
|
1155
|
-
const tokenLed = m.colorTokens >= m.colorStrays && m.colorTokens > 0;
|
|
1156
|
-
const vals = {
|
|
1157
|
-
colors: tokenLed ? m.colorStrays : m.colors,
|
|
1158
|
-
greys: tokenLed ? m.greyStrays : m.greys,
|
|
1159
|
-
spacing: m.spacing,
|
|
1160
|
-
exactDuplicates: m.exactDuplicates,
|
|
1161
|
-
inlineStyles: m.inlineStyles,
|
|
1162
|
-
nearPairs: m.nearPairs,
|
|
1163
|
-
important: m.important,
|
|
1164
|
-
neverImported: m.neverImported,
|
|
1165
|
-
arbitrary: m.arbitrary,
|
|
1166
|
-
};
|
|
1167
|
-
const rows = Object.entries(vals).map(([metric, v]) => ({ metric, value: v, health: healthOf(metric, v) }));
|
|
1168
|
-
const scored = rows.filter((r) => r.health in SCORE_OF);
|
|
1169
|
-
if (!scored.length) return null;
|
|
1170
|
-
const score = Math.round(scored.reduce((sum, r) => sum + SCORE_OF[r.health], 0) / scored.length);
|
|
1171
|
-
// the worst finding: furthest past its ideal among the failing tiles
|
|
1172
|
-
const over = (r) => { const iv = ideal(r.metric); return iv ? r.value / Math.max(iv, 1) : r.value; };
|
|
1173
|
-
const worst = scored.filter((r) => r.health === 'bad').sort((a, b) => over(b) - over(a))[0]
|
|
1174
|
-
?? scored.filter((r) => r.health === 'warn').sort((a, b) => over(b) - over(a))[0];
|
|
1175
|
-
return { score, worst };
|
|
1176
|
-
}
|
|
1087
|
+
const scorePackage = (m) => scorePackageOf(m, healthOf, B);
|
|
1177
1088
|
function packagesSection() {
|
|
1178
1089
|
const pkgs = (h.packages ?? []).filter((p) => p.scored && p.metrics);
|
|
1179
1090
|
const rated = pkgs.map((p) => ({ ...p, ...scorePackage(p.metrics) })).filter((p) => p.score !== undefined && p.score !== null);
|
|
@@ -1737,7 +1648,7 @@ ${componentsSection()}
|
|
|
1737
1648
|
|
|
1738
1649
|
<footer>
|
|
1739
1650
|
<div class="foot-left">
|
|
1740
|
-
<div>Generated by <a class="brand" href="https://github.com/gregkozakiewicz/roast-my-design-system">roast-my-design-system</a> <span class="ver">ver. ${VERSION}</span> · free to run: <
|
|
1651
|
+
<div>Generated by <a class="brand" href="https://github.com/gregkozakiewicz/roast-my-design-system">roast-my-design-system</a> <span class="ver">ver. ${VERSION}</span> · free to run: <span class="mono" id="npx-cmd">npx roast-my-design-system@latest</span> <button type="button" class="copy-pill" id="npx-copy">copy</button> · also a Claude Code + Codex skill and a local MCP server</div>
|
|
1741
1652
|
<div class="foot-sub">Designed and built by <a class="author" href="https://gregkozakiewicz.com/?utm_source=roast-report"><span class="gk-mark"></span>Greg Kozakiewicz</a> · <span class="scan-id" title="scan id">${scanId}</span> · ${FEEDBACK_ASK} <a class="feedback" href="${feedbackUrl(VERSION).replace(/&/g, '&')}">${PULSE_ICON}${FEEDBACK_CTA}</a></div>
|
|
1742
1653
|
</div>
|
|
1743
1654
|
<span class="creds"><span>Non-destructive scan</span><span>Read-only</span><span>Paths are real</span></span>
|
|
@@ -1845,6 +1756,10 @@ if (summaryPath) {
|
|
|
1845
1756
|
writeFileSync(resolve(summaryPath), JSON.stringify({
|
|
1846
1757
|
repo: repoName,
|
|
1847
1758
|
version: VERSION,
|
|
1759
|
+
// The contract two scans are compared by: schemaVersion for the
|
|
1760
|
+
// shape, benchmark for the ruler each scan was measured against.
|
|
1761
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1762
|
+
benchmark: bench ? { builtAt: bench.builtAt, repoCount: bench.repoCount, referenceSystems: bench.referenceSystems?.count ?? 0 } : null,
|
|
1848
1763
|
...(commissionedBy ? { commissionedBy } : {}),
|
|
1849
1764
|
...(notesText ? { notesEmbedded: true } : {}),
|
|
1850
1765
|
...(extraSections.length ? { sectionsEmbedded: extraSections.map((s) => s.title) } : {}),
|
|
@@ -1854,7 +1769,11 @@ if (summaryPath) {
|
|
|
1854
1769
|
verdict,
|
|
1855
1770
|
role: h.profile?.role ?? 'product',
|
|
1856
1771
|
componentsMeasured,
|
|
1857
|
-
|
|
1772
|
+
metrics: (({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }) =>
|
|
1773
|
+
({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }))(M),
|
|
1774
|
+
// value is the number as counted (null when not measured); display is
|
|
1775
|
+
// what the page prints. Until 7.0.0 value was the formatted string.
|
|
1776
|
+
tiles: judgedTiles.map((t, i) => ({ metric: t.metric, label: t.label, value: t.value, display: bigStats[i].num, health: t.health })),
|
|
1858
1777
|
...(startMoves.length ? { moves: startMoves } : {}),
|
|
1859
1778
|
packages: (h.packages ?? []).filter((p) => p.scored && p.metrics)
|
|
1860
1779
|
.map((p) => ({ dir: p.dir, name: p.name, ...(scorePackage(p.metrics) ?? {}) }))
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Score — the arithmetic behind the number in the hero, on its own so it can
|
|
3
|
+
* be imported (a guard, a CI step, any tool comparing two scans) without
|
|
4
|
+
* rendering a page. The report imports this; nothing here knows about HTML.
|
|
5
|
+
*
|
|
6
|
+
* One harvest.json in, one plain object out:
|
|
7
|
+
* { schemaVersion, benchmark, metrics, tiles, score }
|
|
8
|
+
*
|
|
9
|
+
* The rules of the game, in one place:
|
|
10
|
+
* - Each metric is judged against the Ideal Design System target and the
|
|
11
|
+
* scanned-fleet median. good = at or under the ideal; warn = over the
|
|
12
|
+
* ideal but no worse than the median (or 1.5x the ideal when the median
|
|
13
|
+
* is missing or sits under the ideal); bad = worse than that.
|
|
14
|
+
* - Zero-ideal metrics (duplicates, inline styles, twins, !important,
|
|
15
|
+
* orphans): 0 is good, a small tolerance is warn, beyond it bad.
|
|
16
|
+
* - The score is the scored tiles averaged: good 100, warn 55, bad 10.
|
|
17
|
+
* - Token-led repos are judged on their strays, not their totals, because
|
|
18
|
+
* a palette held as tokens is the architecture the ideal asks for.
|
|
19
|
+
* - A metric the detector could not measure ('na') and a metric that is
|
|
20
|
+
* shown but deliberately unscored ('info') take no part in the average.
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
23
|
+
import { join, dirname } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { isGrey } from '../lib/color.mjs';
|
|
26
|
+
import { nearColorPairs } from '../lib/nearpairs.mjs';
|
|
27
|
+
import { neverImportedComponents } from '../lib/neverimported.mjs';
|
|
28
|
+
import { SCHEMA_VERSION } from '../lib/version.mjs';
|
|
29
|
+
|
|
30
|
+
export { SCHEMA_VERSION };
|
|
31
|
+
|
|
32
|
+
// The benchmark (Ideal Design System norms + scanned-repo stats) ships next
|
|
33
|
+
// to the code so everything works offline; degrade gracefully when absent.
|
|
34
|
+
const BENCH_PATH = join(dirname(fileURLToPath(import.meta.url)), '../benchmark/benchmark.json');
|
|
35
|
+
export function loadBenchmark() {
|
|
36
|
+
return existsSync(BENCH_PATH) ? JSON.parse(readFileSync(BENCH_PATH, 'utf8')) : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The yardstick readers for one benchmark object (null-safe). */
|
|
40
|
+
export function benchHelpers(bench) {
|
|
41
|
+
// where does this value sit among the scanned fleet? ("more colours than 90%")
|
|
42
|
+
const percentile = (metric, value) => {
|
|
43
|
+
const vals = bench?.stats?.[metric]?.values;
|
|
44
|
+
if (!vals?.length) return null;
|
|
45
|
+
return Math.round((vals.filter((v) => v < value).length / vals.length) * 100);
|
|
46
|
+
};
|
|
47
|
+
// the flattering twin: what share of the fleet is messier than you
|
|
48
|
+
const cleanerPct = (metric, value) => {
|
|
49
|
+
const vals = bench?.stats?.[metric]?.values;
|
|
50
|
+
if (!vals?.length) return null;
|
|
51
|
+
return Math.round((vals.filter((v) => v > value).length / vals.length) * 100);
|
|
52
|
+
};
|
|
53
|
+
const ideal = (metric) => bench?.ideal2026?.[metric]?.value ?? null;
|
|
54
|
+
const median = (metric) => bench?.stats?.[metric]?.median ?? null;
|
|
55
|
+
// A zero median reads as broken data ("Avg: 0 typefaces") when it means
|
|
56
|
+
// "the median repo declares none". Fall back to the fleet mean there; it
|
|
57
|
+
// stays an honest "Avg" and only zeroes out if literally every repo does.
|
|
58
|
+
const displayAvg = (metric) => {
|
|
59
|
+
const mv = median(metric);
|
|
60
|
+
if (mv === null) return null;
|
|
61
|
+
if (mv !== 0) return mv;
|
|
62
|
+
const vals = bench?.stats?.[metric]?.values ?? [];
|
|
63
|
+
const mean = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
|
|
64
|
+
return mean >= 0.5 ? Math.round(mean) : null;
|
|
65
|
+
};
|
|
66
|
+
const refMedian = (metric) => bench?.referenceSystems?.stats?.[metric]?.median ?? null;
|
|
67
|
+
return { percentile, cleanerPct, ideal, median, displayAvg, refMedian };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const ZERO_IDEAL = new Set(['exactDuplicates', 'inlineStyles', 'nearPairs', 'important', 'neverImported']);
|
|
71
|
+
export const WARN_TOLERANCE = { exactDuplicates: 2, inlineStyles: 10, nearPairs: 2, important: 5, neverImported: 2 };
|
|
72
|
+
export const SCORE_OF = { good: 100, warn: 55, bad: 10 };
|
|
73
|
+
|
|
74
|
+
/** healthOf(metric, value) for one benchmark: 'good' | 'warn' | 'bad' | 'info'. */
|
|
75
|
+
export function makeHealthOf(b) {
|
|
76
|
+
return (metric, value) => {
|
|
77
|
+
const iv = b.ideal(metric);
|
|
78
|
+
if (iv === null) return 'info';
|
|
79
|
+
if (ZERO_IDEAL.has(metric)) {
|
|
80
|
+
if (value === 0) return 'good';
|
|
81
|
+
return value <= WARN_TOLERANCE[metric] ? 'warn' : 'bad';
|
|
82
|
+
}
|
|
83
|
+
if (value <= iv) return 'good';
|
|
84
|
+
const mv = b.median(metric);
|
|
85
|
+
const warnCap = mv && mv > iv ? mv : iv * 1.5;
|
|
86
|
+
if (value <= warnCap) return 'warn';
|
|
87
|
+
return 'bad';
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The tiles, in report order: metric key, the label the report prints. */
|
|
92
|
+
export const TILES = [
|
|
93
|
+
['colors', 'distinct colours'],
|
|
94
|
+
['greys', 'shades of grey'],
|
|
95
|
+
['spacing', 'off-scale spacing values'],
|
|
96
|
+
['exactDuplicates', 'duplicated components'],
|
|
97
|
+
['inlineStyles', 'inline style blocks'],
|
|
98
|
+
['nearPairs', 'near-identical colour pairs'],
|
|
99
|
+
['important', '!important declarations'],
|
|
100
|
+
['neverImported', 'components never imported'],
|
|
101
|
+
['arbitrary', 'arbitrary bracket values'],
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The numbers every judgement rests on, read once from a harvest. Plain
|
|
106
|
+
* integers and flags; the same formulas the per-package pass uses.
|
|
107
|
+
*/
|
|
108
|
+
export function coreMetrics(h) {
|
|
109
|
+
const colors = h.tokens?.colors ?? [];
|
|
110
|
+
const greys = colors.filter((c) => isGrey(c.value));
|
|
111
|
+
const colorTokens = colors.filter((c) => c.isToken).length;
|
|
112
|
+
const colorStrays = colors.length - colorTokens;
|
|
113
|
+
const spacing = h.tokens?.spacing ?? [];
|
|
114
|
+
const twSpacing = h.tokens?.tailwind?.spacing ?? [];
|
|
115
|
+
const hardDupes = (h.duplicates?.exactDuplicates ?? []).filter((d) => !d.wrapped);
|
|
116
|
+
return {
|
|
117
|
+
colors: colors.length,
|
|
118
|
+
colorTokens,
|
|
119
|
+
colorStrays,
|
|
120
|
+
// Token-led repos hold most colours as deliberate CSS-variable tokens;
|
|
121
|
+
// judging them on the total punishes the architecture the ideal asks for.
|
|
122
|
+
tokenLed: colorTokens >= colorStrays && colorTokens > 0,
|
|
123
|
+
greys: greys.length,
|
|
124
|
+
greyStrays: greys.filter((c) => !c.isToken).length,
|
|
125
|
+
// Off-scale spacing only: CSS-declared values plus bracket utilities.
|
|
126
|
+
// Many steps of the sanctioned Tailwind scale is health, not sprawl.
|
|
127
|
+
spacing: spacing.length + twSpacing.filter((v) => v.value.startsWith('[')).length,
|
|
128
|
+
// A wrapped pair (one file imports the name from the other) is
|
|
129
|
+
// composition, not competition: listed with a badge, never counted.
|
|
130
|
+
exactDuplicates: hardDupes.length,
|
|
131
|
+
inlineStyles: h.tokens?.inlineStyles?.count ?? 0,
|
|
132
|
+
nearPairs: nearColorPairs(colors).length,
|
|
133
|
+
important: h.tokens?.important?.count ?? 0,
|
|
134
|
+
neverImported: neverImportedComponents(h.components, h.profile?.uiDir).length,
|
|
135
|
+
arbitrary: (h.tokens?.tailwind?.arbitrary ?? []).reduce((sum, a) => sum + a.count, 0),
|
|
136
|
+
// Measurability and role, decided by the harvest: an unreadable stack
|
|
137
|
+
// takes no score credit; a library's orphans are shown, not judged; a
|
|
138
|
+
// 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,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Judge the nine tiles from a metric set. Each tile carries the value shown,
|
|
147
|
+
* the value judged (strays for token-led colour tiles), and its health.
|
|
148
|
+
*/
|
|
149
|
+
export function tileHealths(m, healthOf) {
|
|
150
|
+
const shown = {
|
|
151
|
+
colors: m.colors, greys: m.greys, spacing: m.spacing, exactDuplicates: m.exactDuplicates,
|
|
152
|
+
inlineStyles: m.inlineStyles, nearPairs: m.nearPairs, important: m.important,
|
|
153
|
+
neverImported: m.neverImported, arbitrary: m.arbitrary,
|
|
154
|
+
};
|
|
155
|
+
const judged = { ...shown, colors: m.tokenLed ? m.colorStrays : m.colors, greys: m.tokenLed ? m.greyStrays : m.greys };
|
|
156
|
+
return TILES.map(([metric, label]) => {
|
|
157
|
+
let health = healthOf(metric, judged[metric]);
|
|
158
|
+
let value = shown[metric], healthValue = judged[metric];
|
|
159
|
+
if (!m.componentsMeasured && (metric === 'exactDuplicates' || metric === 'neverImported')) {
|
|
160
|
+
health = 'na'; value = null; healthValue = null;
|
|
161
|
+
} else if (metric === 'neverImported' && (m.isLibrary || (m.vendoredUi && m.neverImported > 0))) {
|
|
162
|
+
health = 'info';
|
|
163
|
+
}
|
|
164
|
+
return { metric, label, value, healthValue, health };
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The scored tiles averaged (good 100 / warn 55 / bad 10); null when none. */
|
|
169
|
+
export function scoreOfTiles(tiles) {
|
|
170
|
+
const scored = tiles.filter((t) => t.health in SCORE_OF);
|
|
171
|
+
return scored.length ? Math.round(scored.reduce((sum, t) => sum + SCORE_OF[t.health], 0) / scored.length) : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A package's score and its worst finding from its harvested metrics. */
|
|
175
|
+
export function scorePackage(m, healthOf, b) {
|
|
176
|
+
const tokenLed = m.colorTokens >= m.colorStrays && m.colorTokens > 0;
|
|
177
|
+
const vals = {
|
|
178
|
+
colors: tokenLed ? m.colorStrays : m.colors,
|
|
179
|
+
greys: tokenLed ? m.greyStrays : m.greys,
|
|
180
|
+
spacing: m.spacing,
|
|
181
|
+
exactDuplicates: m.exactDuplicates,
|
|
182
|
+
inlineStyles: m.inlineStyles,
|
|
183
|
+
nearPairs: m.nearPairs,
|
|
184
|
+
important: m.important,
|
|
185
|
+
neverImported: m.neverImported,
|
|
186
|
+
arbitrary: m.arbitrary,
|
|
187
|
+
};
|
|
188
|
+
const rows = Object.entries(vals).map(([metric, v]) => ({ metric, value: v, health: healthOf(metric, v) }));
|
|
189
|
+
const scored = rows.filter((r) => r.health in SCORE_OF);
|
|
190
|
+
if (!scored.length) return null;
|
|
191
|
+
const score = Math.round(scored.reduce((sum, r) => sum + SCORE_OF[r.health], 0) / scored.length);
|
|
192
|
+
// the worst finding: furthest past its ideal among the failing tiles
|
|
193
|
+
const over = (r) => { const iv = b.ideal(r.metric); return iv ? r.value / Math.max(iv, 1) : r.value; };
|
|
194
|
+
const worst = scored.filter((r) => r.health === 'bad').sort((a, b2) => over(b2) - over(a))[0]
|
|
195
|
+
?? scored.filter((r) => r.health === 'warn').sort((a, b2) => over(b2) - over(a))[0];
|
|
196
|
+
return { score, worst };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The whole judgement of one harvest, as data. */
|
|
200
|
+
export function scoreHarvest(h, bench = loadBenchmark()) {
|
|
201
|
+
const b = benchHelpers(bench);
|
|
202
|
+
const healthOf = makeHealthOf(b);
|
|
203
|
+
const metrics = coreMetrics(h);
|
|
204
|
+
const tiles = tileHealths(metrics, healthOf);
|
|
205
|
+
return {
|
|
206
|
+
schemaVersion: SCHEMA_VERSION,
|
|
207
|
+
// A rebuilt ruler moves every score with no change in the repo, so a
|
|
208
|
+
// history must know which ruler each scan was measured with.
|
|
209
|
+
benchmark: bench ? { builtAt: bench.builtAt, repoCount: bench.repoCount, referenceSystems: bench.referenceSystems?.count ?? 0 } : null,
|
|
210
|
+
metrics,
|
|
211
|
+
tiles,
|
|
212
|
+
score: scoreOfTiles(tiles),
|
|
213
|
+
};
|
|
214
|
+
}
|