roast-my-design-system 3.7.1 → 3.9.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
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
Run this skill on your codebase and get, in about a second:
|
|
8
8
|
|
|
9
9
|
- **A health score you can defend in a meeting.** 0-100, deterministic, benchmarked against Ideal Design System norms, 30 scanned public repos and 10 reputable design systems (Primer, Polaris, Carbon, shadcn/ui…).
|
|
10
|
+
- **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.
|
|
10
11
|
- **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.
|
|
11
12
|
- **The first fixes ranked by payoff.** A "Where to start" list derived from your own numbers: keep the report as the audit, or hand it to Claude as the punch list for the fix.
|
|
12
13
|
- **Rules that stop the mess coming back.** A generated `design-system-rules.md` for your CLAUDE.md or .cursor/rules: canonical components, your token file, known duplicates to avoid, so your AI agent follows your system instead of guessing at it.
|
|
@@ -24,15 +25,15 @@ Your AI agent (Claude, Cursor, Copilot) builds UI by imitating what's already in
|
|
|
24
25
|
|
|
25
26
|
The full report for vercel/ai-chatbot, top to bottom:
|
|
26
27
|
|
|
27
|
-

|
|
28
29
|
|
|
29
30
|
The same report in light mode (one file, built-in toggle):
|
|
30
31
|
|
|
31
|
-

|
|
32
33
|
|
|
33
34
|
## What makes the numbers trustworthy
|
|
34
35
|
|
|
35
|
-
- **Deterministic scanner, not AI sampling.** A zero-dependency Node script reads *every* file (a
|
|
36
|
+
- **Deterministic scanner, not AI sampling.** A zero-dependency Node script reads *every* file (about a second on a normal repo, a few on a large monorepo) and returns the same numbers every run. Claude narrates; it never counts.
|
|
36
37
|
- **Read-only.** Nothing in your repo is modified. The only outputs are a temp JSON and the HTML report.
|
|
37
38
|
- **No network, no telemetry.** Everything runs locally. Nothing about your code leaves your machine.
|
|
38
39
|
- **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.
|
|
@@ -132,4 +133,4 @@ MIT. The code is yours to fork, modify and redistribute; the copyright notice tr
|
|
|
132
133
|
|
|
133
134
|
**roast-my-design-system**™ and the GK mark are trademarks of Greg Kozakiewicz. Forking is welcome, republishing under this name is not: see [brand and attribution](https://pencilrebel.github.io/roast-my-design-system/brand.html).
|
|
134
135
|
|
|
135
|
-
Built and designed by <a href="https://gregkozakiewicz.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/gk-mark-dark.png?v=3.
|
|
136
|
+
Built and designed by <a href="https://gregkozakiewicz.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/gk-mark-dark.png?v=3.9.0"><img src="https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/gk-mark.png?v=3.9.0" height="15" alt="GK mark"></picture> Greg Kozakiewicz</a>.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.0",
|
|
4
4
|
"description": "Roast your design system with real data. A deterministic, zero-dependency scanner that counts everything that betrays a design system, from colours and their near-identical twins to greys, spacing values, typefaces, duplicated and never-imported components, inline styles, !important and arbitrary Tailwind values, scores it 0-100 against Ideal Design System norms and a 30-repo benchmark, generates a shareable HTML diagnosis, and with --rules writes the agent rules file (CLAUDE.md, .cursor/rules) that stops the mess coming back.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"design-system",
|
|
@@ -36,7 +36,7 @@ const GK_MASK = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqa
|
|
|
36
36
|
const GK_MARK = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAACXBIWXMAAAAAAAAAAQCEeRdzAAACPklEQVR4nOVWTYh5URR/PlJYEKV8LKwkZalsmI2dZmMnK8pKmaZ87JSanexEkayUhSxsZyfJNBQ7UYqVhSShfM+v/6mXmtU81+p/Frfze+/d3++ce88993Hcf2cikehZ1GazWafTPUVDLBZjLBQKnU6HMTVvcrl8Op3ebrdsNgsokUiYUVP4Pp/v9s96vd79cwZGK95sNkkgGAwC2u12lhrY3u12C/b1ei2TyV5eXjabjV6vZ6BBa51MJin8arUKWKlU4H99fSkUCo5JUQ2HQxJwuVzIYLVawcdIVSs8CQoNpMQ+Go0Aw+EwwXw+D+h2u4Vr0LRSqUSMqVQKsN1uE3Q4HEql8ng8BgIBTnDhqlSqxWIBuvP5bDAYLBbL5XIBHAwGeBuJRODvdjun0/nnPOjraDRK8TYaDcBMJkMwFosBfn9/E0wkEn9OgjYgnU6fTidQwAGcTCbw9/u9VqvFUYB/vV5RskajkRNcTiaTKR6PI9j5fE7xlstlPM/lcgTr9ToneJ/5oOB4PB70O8Tb7/f9fv9sNqPkXl9fhQsQ9f1ktVqNvUVCxA6ZRxsf5pMAxnsuVBSOwvv7+6Phk0PsBNn0OOLC9haLReqdvBL/wUNKNPnt7Q0LjeVutVqhUEij0TwU9W8BVA4JUEUul8tarcZGhpbIarWiSeA0HQ4Hkvn4+GB56VMvov6DA+z1eplR89btdsE+Ho9tNhv3jOv+8/MTFz06M3ypVMqMnTfcwPhn4djG/tue+Mf4RGom9gMt6lAx16huIwAAAABJRU5ErkJggg==';
|
|
37
37
|
|
|
38
38
|
// Shown in the report footer; keep in step with plugin.json when releasing.
|
|
39
|
-
const VERSION = '3.
|
|
39
|
+
const VERSION = '3.9.0';
|
|
40
40
|
|
|
41
41
|
// Report identity. The scan id is a stable digest of this scan's shape, salted
|
|
42
42
|
// with the report namespace, so the same repo scanned twice reads the same and
|
|
@@ -280,14 +280,16 @@ const WARN_TOLERANCE = { exactDuplicates: 2, inlineStyles: 10, nearPairs: 2, imp
|
|
|
280
280
|
// Absence rule also skipped for arbitrary values: zero brackets is discipline
|
|
281
281
|
// (nine of ten reputable systems sit at 0, including Tailwind-native shadcn/ui).
|
|
282
282
|
const NO_ABSENCE_RULE = new Set(['colors', 'greys', 'arbitrary']);
|
|
283
|
-
function healthOf(metric, value) {
|
|
283
|
+
function healthOf(metric, value, opts = {}) {
|
|
284
284
|
const iv = ideal(metric);
|
|
285
285
|
if (iv === null) return 'info';
|
|
286
286
|
if (ZERO_IDEAL.has(metric)) {
|
|
287
287
|
if (value === 0) return 'good';
|
|
288
288
|
return value <= WARN_TOLERANCE[metric] ? 'warn' : 'bad';
|
|
289
289
|
}
|
|
290
|
-
|
|
290
|
+
// "almost nothing found" reads as a missing design system for a whole repo,
|
|
291
|
+
// but a single package with no spacing values is just a small package.
|
|
292
|
+
if (!opts.noAbsence && !NO_ABSENCE_RULE.has(metric) && value < Math.max(1, iv * 0.05)) return 'bad';
|
|
291
293
|
if (value <= iv) return 'good';
|
|
292
294
|
const mv = median(metric);
|
|
293
295
|
const warnCap = mv && mv > iv ? mv : iv * 1.5;
|
|
@@ -321,7 +323,7 @@ function tile(value, label, metric, fallbackTarget, healthValue = value) {
|
|
|
321
323
|
const av = displayAvg(metric);
|
|
322
324
|
if (av !== null) rows.push(row('Avg Design System', `${n(av)}${avgNote}`, av, value));
|
|
323
325
|
if (rm !== null && (rm > 0 || ZERO_IDEAL.has(metric) || metric === 'arbitrary')) rows.push(row('Reputable systems', n(rm), rm, value));
|
|
324
|
-
return { num: n(value), label, health, rows };
|
|
326
|
+
return { num: n(value), label, health, rows, metric, healthValue };
|
|
325
327
|
}
|
|
326
328
|
const bigStats = [
|
|
327
329
|
tile(colors.length, 'distinct colours', 'colors', 'a system needs ~24', tokenLed ? colorStrays : colors.length),
|
|
@@ -342,6 +344,42 @@ const healthScore = scoredTiles.length
|
|
|
342
344
|
? Math.round(scoredTiles.reduce((sum, s) => sum + SCORE_OF[s.health], 0) / scoredTiles.length)
|
|
343
345
|
: null;
|
|
344
346
|
|
|
347
|
+
// ---------- what a fix is worth ----------
|
|
348
|
+
// The score is the average of the scored tiles, so moving one tile across a
|
|
349
|
+
// band is worth an exact number of points: red to green 10, red to amber 5,
|
|
350
|
+
// amber to green 5 (on nine tiles). Deltas are only claimed when a move
|
|
351
|
+
// actually crosses a band — a cleanup that lands inside the same band is real
|
|
352
|
+
// work worth zero points, and saying otherwise would be a lie.
|
|
353
|
+
const TILE_COUNT = scoredTiles.length || 1;
|
|
354
|
+
const scoreOfHealth = (hh) => SCORE_OF[hh] ?? 0;
|
|
355
|
+
function bandPoints(metric, from, to) {
|
|
356
|
+
const a = healthOf(metric, from), b = healthOf(metric, to);
|
|
357
|
+
if (!(a in SCORE_OF) || !(b in SCORE_OF)) return 0;
|
|
358
|
+
return Math.round((scoreOfHealth(b) - scoreOfHealth(a)) / TILE_COUNT);
|
|
359
|
+
}
|
|
360
|
+
// The nearest number that moves this metric up a band, and what it pays.
|
|
361
|
+
function nextBand(metric, value) {
|
|
362
|
+
const iv = ideal(metric);
|
|
363
|
+
if (iv === null) return null;
|
|
364
|
+
const cur = healthOf(metric, value);
|
|
365
|
+
if (cur === 'good') return null;
|
|
366
|
+
if (cur === 'warn') {
|
|
367
|
+
const target = ZERO_IDEAL.has(metric) ? 0 : iv;
|
|
368
|
+
return { target, gain: bandPoints(metric, value, target) };
|
|
369
|
+
}
|
|
370
|
+
const warnTarget = ZERO_IDEAL.has(metric) ? WARN_TOLERANCE[metric] : Math.min(median(metric) ?? iv * 1.5, iv * 1.5);
|
|
371
|
+
return { target: Math.round(warnTarget), gain: bandPoints(metric, value, Math.round(warnTarget)) };
|
|
372
|
+
}
|
|
373
|
+
// The score this repo would have with a set of metrics at new values.
|
|
374
|
+
function projectedScore(applied) {
|
|
375
|
+
const scored = bigStats.filter((st) => st.health in SCORE_OF);
|
|
376
|
+
if (!scored.length) return healthScore;
|
|
377
|
+
return Math.round(scored.reduce((sum, st) => {
|
|
378
|
+
const v = applied.has(st.metric) ? applied.get(st.metric) : st.healthValue;
|
|
379
|
+
return sum + scoreOfHealth(healthOf(st.metric, v));
|
|
380
|
+
}, 0) / scored.length);
|
|
381
|
+
}
|
|
382
|
+
|
|
345
383
|
// A repo with essentially no colour/spacing signal most likely has no design
|
|
346
384
|
// system in it at all; say that up front instead of quietly scoring zeros.
|
|
347
385
|
const noSystemLikely = colors.length === 0 || (colors.length < 3 && spacingTotal === 0);
|
|
@@ -459,6 +497,20 @@ function duplicatesSection() {
|
|
|
459
497
|
<div class="fams">${iconCard}${dupeCards}${famCards}</div></div>`;
|
|
460
498
|
}
|
|
461
499
|
|
|
500
|
+
// Scanned values get injected into style attributes below (a radius, a shadow,
|
|
501
|
+
// a font size from someone else's repo). Allow only a conservative CSS charset
|
|
502
|
+
// so a malformed or hostile value cannot break out of the attribute.
|
|
503
|
+
const cssSafe = (v) => (typeof v === 'string' && /^[-#0-9a-z%.,()\s\/]+$/i.test(v) && !/[;{}<>"']/.test(v) ? v.trim() : null);
|
|
504
|
+
// px equivalent of a length, for sorting and for rendering type at real size.
|
|
505
|
+
function toPx(v) {
|
|
506
|
+
const m = /^(-?\d*\.?\d+)\s*(px|rem|em|pt)?$/i.exec(String(v).trim());
|
|
507
|
+
if (!m) return null;
|
|
508
|
+
const num = parseFloat(m[1]);
|
|
509
|
+
const unit = (m[2] ?? 'px').toLowerCase();
|
|
510
|
+
if (!Number.isFinite(num) || num <= 0) return null;
|
|
511
|
+
return unit === 'px' ? num : unit === 'pt' ? num * 1.333 : num * 16;
|
|
512
|
+
}
|
|
513
|
+
|
|
462
514
|
function typographySection() {
|
|
463
515
|
if (!fontFamilies.length && !fontSizeTotal && !radiiTotal) return '';
|
|
464
516
|
const mini = [
|
|
@@ -475,13 +527,59 @@ function typographySection() {
|
|
|
475
527
|
.sort((a, b) => b.count - a.count).slice(0, 14);
|
|
476
528
|
const receipts = (title, chips) => chips.length < 2 ? '' :
|
|
477
529
|
`<div class="receipts">${eyebrow(title)}<div class="chips-row">${chips.map((c) => `<span class="vchip">${esc(String(c.label))} ×${c.count}</span>`).join('')}</div></div>`;
|
|
530
|
+
|
|
531
|
+
// ---------- specimens: the values rendered, not counted ----------
|
|
532
|
+
// Counting says "47 font sizes". Rendering them says it better: the eye
|
|
533
|
+
// finds the near-duplicates on its own, the way the grey ramp works.
|
|
534
|
+
const typeSpecimens = (() => {
|
|
535
|
+
const rows = (h.tokens.fontSizes ?? [])
|
|
536
|
+
.map((f) => ({ label: f.value, count: f.count, px: toPx(f.value) }))
|
|
537
|
+
.filter((f) => f.px && f.px >= 6 && f.px <= 200)
|
|
538
|
+
.sort((a, b) => b.px - a.px);
|
|
539
|
+
if (rows.length < 4) return '';
|
|
540
|
+
const shown = rows.slice(0, 16);
|
|
541
|
+
const capped = shown.some((r) => r.px > 52);
|
|
542
|
+
return `<div class="spec">${eyebrow(`the type scale, at its real sizes${capped ? ' · the largest are capped to fit' : ''} · how many of these are the same decision?`)}
|
|
543
|
+
<div class="spec-rows">${shown.map((r) => `
|
|
544
|
+
<div class="spec-type">
|
|
545
|
+
<span class="spec-val">${esc(r.label)} ×${r.count}</span>
|
|
546
|
+
<span class="spec-sample" style="font-size:${Math.min(r.px, 52)}px">Almost but not quite</span>
|
|
547
|
+
</div>`).join('')}</div>
|
|
548
|
+
${rows.length > 16 ? `<div class="spec-more">and ${rows.length - 16} more</div>` : ''}</div>`;
|
|
549
|
+
})();
|
|
550
|
+
|
|
551
|
+
const radiiSpecimens = (() => {
|
|
552
|
+
const cells = (h.tokens.radii ?? [])
|
|
553
|
+
.map((r) => ({ label: r.value, count: r.count, css: cssSafe(r.value) }))
|
|
554
|
+
.filter((r) => r.css && !/^0$|^0px$/.test(r.css) && !/var\(|calc\(/i.test(r.css))
|
|
555
|
+
.slice(0, 14);
|
|
556
|
+
if (cells.length < 3) return '';
|
|
557
|
+
return `<div class="spec">${eyebrow(`the corners, drawn · ${n(radiiTotal)} radii in one interface`)}
|
|
558
|
+
<div class="spec-grid">${cells.map((r) => `
|
|
559
|
+
<div class="spec-cell"><div class="spec-box" style="border-radius:${r.css}"></div>
|
|
560
|
+
<span class="spec-cap">${esc(r.label)} ×${r.count}</span></div>`).join('')}</div></div>`;
|
|
561
|
+
})();
|
|
562
|
+
|
|
563
|
+
const shadowSpecimens = (() => {
|
|
564
|
+
const cells = (h.tokens.shadows ?? [])
|
|
565
|
+
.map((sh) => ({ label: sh.value, count: sh.count, css: cssSafe(sh.value) }))
|
|
566
|
+
.filter((sh) => sh.css && !/^(none|initial|inherit|unset|revert)$/i.test(sh.css) && !/var\(/i.test(sh.css))
|
|
567
|
+
.slice(0, 10);
|
|
568
|
+
if (cells.length < 2) return '';
|
|
569
|
+
const short = (v) => (v.length > 20 ? `${v.slice(0, 19)}…` : v);
|
|
570
|
+
return `<div class="spec">${eyebrow(`the elevations, cast${cells.length < shadows.length ? `, ${cells.length} of ${n(shadows.length)}` : ` · ${n(shadows.length)} shadow styles`} · on the light surface they were drawn for`)}
|
|
571
|
+
<div class="spec-grid spec-shadows">${cells.map((sh) => `
|
|
572
|
+
<div class="spec-cell"><div class="spec-card" style="box-shadow:${sh.css}"></div>
|
|
573
|
+
<span class="spec-cap" title="${esc(sh.label)}">${esc(short(sh.label))} ×${sh.count}</span></div>`).join('')}</div></div>`;
|
|
574
|
+
})();
|
|
478
575
|
const fams = fontFamilies.slice(0, 8).map((f) =>
|
|
479
576
|
`<div class="mini-card"><span class="mc-path">${esc(f.value.slice(0, 70))}</span><span class="pill${f.count > 2 ? ' pill-coral' : ' pill-mint'}">×${f.count}</span></div>`).join('');
|
|
480
577
|
return `<section>
|
|
481
578
|
${sectionHead('Typography & shape', '')}
|
|
482
579
|
<div class="stats minis">${mini.map((s) => statTile(s)).join('')}</div>
|
|
483
|
-
${receipts('the font sizes, by use', sizeChips)}
|
|
484
|
-
${receipts('the radii, by use', radiiChips)}
|
|
580
|
+
${typeSpecimens || receipts('the font sizes, by use', sizeChips)}
|
|
581
|
+
${radiiSpecimens || receipts('the radii, by use', radiiChips)}
|
|
582
|
+
${shadowSpecimens}
|
|
485
583
|
${fontFamilies.length > 1 ? `<div class="glass pad" style="margin-top:16px">${sectionHead(`${typefaces.length} typeface${typefaces.length === 1 ? '' : 's'}, declared ${fontFamilies.length} different ways`, 'every distinct declaration is a chance for the next one to be wrong')}<div class="fam-rows">${fams}</div></div>` : ''}
|
|
486
584
|
</section>`;
|
|
487
585
|
}
|
|
@@ -572,7 +670,7 @@ function giftSection() {
|
|
|
572
670
|
|
|
573
671
|
function whereToStartSection() {
|
|
574
672
|
const c = [];
|
|
575
|
-
if (agentFiles.length === 0) c.push({ score: 60, title: 'Write the agent rules file',
|
|
673
|
+
if (agentFiles.length === 0) c.push({ score: 60, metric: null, title: 'Write the agent rules file',
|
|
576
674
|
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.` });
|
|
577
675
|
if (hardDupes.length > 0) {
|
|
578
676
|
// Pick the pair with the strongest copy-paste evidence: sibling files in
|
|
@@ -588,10 +686,11 @@ function whereToStartSection() {
|
|
|
588
686
|
return sc;
|
|
589
687
|
};
|
|
590
688
|
const d = [...hardDupes].sort((a, b) => strength(b) - strength(a))[0];
|
|
591
|
-
c.push({ score: 25 + hardDupes.length * 4,
|
|
689
|
+
c.push({ score: 25 + hardDupes.length * 4, metric: 'exactDuplicates', after: hardDupes.length - 1,
|
|
690
|
+
title: `Crown the canonical <${esc(d.name)}>`,
|
|
592
691
|
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.` });
|
|
593
692
|
}
|
|
594
|
-
if (iconCollisions.length >= 5) c.push({ score: 20 + iconCollisions.length * 3, title: 'Merge the two icon sets',
|
|
693
|
+
if (iconCollisions.length >= 5) c.push({ score: 20 + iconCollisions.length * 3, metric: null, title: 'Merge the two icon sets',
|
|
595
694
|
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])}.` });
|
|
596
695
|
const strayOffender = offenders.find((o) => o.strayColors > 0);
|
|
597
696
|
if (colorStrays > 12) {
|
|
@@ -601,13 +700,14 @@ function whereToStartSection() {
|
|
|
601
700
|
const besideTokens = strayOffender && tf && (strayOffender.file === tf
|
|
602
701
|
|| strayOffender.file.split('/').slice(0, -1).join('/') === tf.split('/').slice(0, -1).join('/')
|
|
603
702
|
|| /variables|tokens|theme/i.test(strayOffender.file));
|
|
604
|
-
c.push({ score: colorStrays, title: `Tokenise the ${n(colorStrays)} stray colours`,
|
|
703
|
+
c.push({ score: colorStrays, metric: 'colors', after: 0, title: `Tokenise the ${n(colorStrays)} stray colours`,
|
|
605
704
|
sub: `They are hardcoded where no token names them${strayOffender ? (besideTokens
|
|
606
705
|
? `, including ${strayOffender.strayColors} in ${esc(strayOffender.file)}, sitting right next to the token definitions. Those are the cheapest wins on this page`
|
|
607
706
|
: `; ${esc(strayOffender.file)} alone carries ${strayOffender.strayColors}`) : ''}. Every one is a value your agent will happily copy.` });
|
|
608
707
|
}
|
|
609
708
|
if (inline.count > 10) { const worst = inline.files[0];
|
|
610
|
-
c.push({ score: inline.count / 2,
|
|
709
|
+
c.push({ score: inline.count / 2, metric: 'inlineStyles', after: 0,
|
|
710
|
+
title: `Fold ${n(inline.count)} inline style blocks back into the system`,
|
|
611
711
|
sub: `These are static values written as style attributes${worst ? `; start with ${esc(worst.file)} (${worst.count} blocks)` : ''}. Dynamic positioning was already excluded, so all of these could be classes or tokens today.` }); }
|
|
612
712
|
if (arbitraryCount >= 20) {
|
|
613
713
|
// Repeated brackets read as decisions; singletons are worth a pass. Small
|
|
@@ -618,7 +718,10 @@ function whereToStartSection() {
|
|
|
618
718
|
const singles = arbitrary.filter((a) => a.count === 1).length;
|
|
619
719
|
const top = arbitrary[0];
|
|
620
720
|
const tokenFile = h.tokens.tokenFile;
|
|
621
|
-
|
|
721
|
+
// naming the repeats removes their counts; the one-offs are left alone on purpose
|
|
722
|
+
const afterNaming = arbitraryCount - repeats.reduce((sum, a) => sum + a.count, 0);
|
|
723
|
+
c.push({ score: arbitraryCount / 3, metric: 'arbitrary', after: afterNaming,
|
|
724
|
+
title: `Give your repeated bracket values names`,
|
|
622
725
|
sub: repeats.length
|
|
623
726
|
? `${esc(top.value)} appears ${top.count} times, which reads as a decision, not drift. If it is one, name it${tokenFile ? ` in ${esc(tokenFile)}` : ' in the scale'} so the next component (and your agent) can reach for it${repeats.length > 1 ? `; ${repeats.length - 1} more repeated value${repeats.length > 2 ? 's' : ''} deserve the same look` : ''}. The ${singles} values used once are worth a pass: keep the deliberate ones (optical nudges${nudges ? ` like the ${nudges} under 4px` : ''}, one-off layout widths) and round the accidents to a neighbouring step.`
|
|
624
727
|
: `${n(arbitraryCount)} bracket values sit outside the scale, almost all used once. Keep the deliberate ones (optical nudges, one-off widths) and round the rest to a neighbouring step.` });
|
|
@@ -628,24 +731,124 @@ function whereToStartSection() {
|
|
|
628
731
|
// the first move is to define it, next to the tokens the repo already has.
|
|
629
732
|
const hasScale = twSpacing.length >= 5;
|
|
630
733
|
const tf = h.tokens.tokenFile;
|
|
631
|
-
c.push({ score: spacingTotal / 2, title: hasScale
|
|
734
|
+
c.push({ score: spacingTotal / 2, metric: 'spacing', title: hasScale
|
|
632
735
|
? `Fold ${n(spacingTotal)} off-scale spacing values back to the scale`
|
|
633
736
|
: `Define a spacing scale, then fold ${n(spacingTotal)} values into it`,
|
|
634
737
|
sub: hasScale
|
|
635
738
|
? `${n(spacingTotal)} values sit outside the scale you already use. Keep the deliberate exceptions and round the accidents to a neighbouring step.`
|
|
636
739
|
: `${n(spacingTotal)} distinct spacing values and no named scale to hold them. Define ~8 steps${tf ? ` in ${esc(tf)}, next to the tokens you already keep there` : ''}, then migrate values as you touch each file. No mass rounding.` });
|
|
637
740
|
}
|
|
638
|
-
|
|
741
|
+
if (nearPairs.length >= 3) {
|
|
742
|
+
const p0 = nearPairs[0];
|
|
743
|
+
c.push({ score: 30 + nearPairs.length * 2, metric: 'nearPairs', after: 0,
|
|
744
|
+
title: `Collapse the ${n(nearPairs.length)} near-identical colours`,
|
|
745
|
+
sub: `${esc(p0.a.value)} and ${esc(p0.b.value)} are the same colour to any eye${p0.a.count + p0.b.count > 3 ? `, and they are used ${n(p0.a.count + p0.b.count)} times between them` : ''}. Nobody chose to have both. Pick the one that is already a token, point the other at it, and the pair stops multiplying.` });
|
|
746
|
+
}
|
|
747
|
+
if (important.count >= 10) {
|
|
748
|
+
const worst = important.files[0];
|
|
749
|
+
c.push({ score: 20 + important.count, metric: 'important', after: 0,
|
|
750
|
+
title: `Unwind the ${n(important.count)} !important declarations`,
|
|
751
|
+
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.` });
|
|
752
|
+
}
|
|
753
|
+
if (neverImported.length >= 3) {
|
|
754
|
+
c.push({ score: 15 + neverImported.length, metric: 'neverImported', after: 0,
|
|
755
|
+
title: `Decide about the ${n(neverImported.length)} components nobody imports`,
|
|
756
|
+
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.` });
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// What each move is actually worth, then rank by payoff for real.
|
|
760
|
+
for (const item of c) {
|
|
761
|
+
item.delta = 0; item.target = null;
|
|
762
|
+
if (!item.metric) continue;
|
|
763
|
+
const now = bigStats.find((st) => st.metric === item.metric)?.healthValue;
|
|
764
|
+
if (now === undefined) continue;
|
|
765
|
+
if (item.after !== undefined) {
|
|
766
|
+
const gain = bandPoints(item.metric, now, item.after);
|
|
767
|
+
if (gain > 0) { item.delta = gain; continue; }
|
|
768
|
+
}
|
|
769
|
+
const nb = nextBand(item.metric, now);
|
|
770
|
+
if (nb && nb.gain > 0) item.target = nb;
|
|
771
|
+
}
|
|
772
|
+
const top3 = c.sort((a, b) => (b.delta - a.delta) || (b.score - a.score)).slice(0, 3);
|
|
639
773
|
if (!top3.length) return '';
|
|
774
|
+
// The headline is the score with every move applied together, not the sum of
|
|
775
|
+
// the parts: two moves on the same tile must not be counted twice.
|
|
776
|
+
const applied = new Map();
|
|
777
|
+
for (const item of top3) if (item.metric && item.after !== undefined && item.delta > 0) applied.set(item.metric, item.after);
|
|
778
|
+
const after = projectedScore(applied);
|
|
779
|
+
const words = ['One tweak', 'Two tweaks', 'Three tweaks'][top3.length - 1];
|
|
780
|
+
const head = healthScore !== null && after > healthScore
|
|
781
|
+
? `${words} · <b class="proj">${healthScore} → ${after}</b>`
|
|
782
|
+
: `${words} to increase your score.`;
|
|
783
|
+
const chip = (item) => item.delta > 0
|
|
784
|
+
? `<span class="delta">+${item.delta}</span>`
|
|
785
|
+
: item.target
|
|
786
|
+
? `<span class="delta delta-target">${item.target.target === 0 ? 'clear them all' : `under ${n(item.target.target)}`} · +${item.target.gain}</span>`
|
|
787
|
+
: '';
|
|
640
788
|
return `<section class="glass pad">
|
|
641
|
-
${sectionHead('Where to start',
|
|
789
|
+
${sectionHead('Where to start', head)}
|
|
642
790
|
<div class="ledger">${top3.map((item, i) => `
|
|
643
791
|
<div class="ledger-row start-row">
|
|
644
792
|
<span class="ledger-idx">${String(i + 1).padStart(2, '0')}</span>
|
|
645
|
-
<div class="start-body"><div class="start-title">${item.title}</div><div class="sub">${item.sub}</div></div>
|
|
793
|
+
<div class="start-body"><div class="start-head"><div class="start-title">${item.title}</div>${chip(item)}</div><div class="sub">${item.sub}</div></div>
|
|
646
794
|
</div>`).join('')}</div></section>`;
|
|
647
795
|
}
|
|
648
796
|
|
|
797
|
+
// ---------- package by package ----------
|
|
798
|
+
// One number for a monorepo hides which package is the problem. Each package
|
|
799
|
+
// is judged on the same nine tiles, with usage counted repo-wide, so a shared
|
|
800
|
+
// component another package imports is adopted rather than dead.
|
|
801
|
+
const PKG_LABELS = {
|
|
802
|
+
colors: 'stray colours', greys: 'greys', spacing: 'off-scale spacing values',
|
|
803
|
+
exactDuplicates: 'duplicated components', inlineStyles: 'inline style blocks',
|
|
804
|
+
nearPairs: 'near-identical colour pairs', important: '!important declarations',
|
|
805
|
+
neverImported: 'components nobody imports', arbitrary: 'arbitrary bracket values',
|
|
806
|
+
};
|
|
807
|
+
function scorePackage(m) {
|
|
808
|
+
const tokenLed = m.colorTokens >= m.colorStrays && m.colorTokens > 0;
|
|
809
|
+
const vals = {
|
|
810
|
+
colors: tokenLed ? m.colorStrays : m.colors,
|
|
811
|
+
greys: tokenLed ? m.greyStrays : m.greys,
|
|
812
|
+
spacing: m.spacing,
|
|
813
|
+
exactDuplicates: m.exactDuplicates,
|
|
814
|
+
inlineStyles: m.inlineStyles,
|
|
815
|
+
nearPairs: m.nearPairs,
|
|
816
|
+
important: m.important,
|
|
817
|
+
neverImported: m.neverImported,
|
|
818
|
+
arbitrary: m.arbitrary,
|
|
819
|
+
};
|
|
820
|
+
const rows = Object.entries(vals).map(([metric, v]) => ({ metric, value: v, health: healthOf(metric, v, { noAbsence: true }) }));
|
|
821
|
+
const scored = rows.filter((r) => r.health in SCORE_OF);
|
|
822
|
+
if (!scored.length) return null;
|
|
823
|
+
const score = Math.round(scored.reduce((sum, r) => sum + SCORE_OF[r.health], 0) / scored.length);
|
|
824
|
+
// the worst finding: furthest past its ideal among the failing tiles
|
|
825
|
+
const over = (r) => { const iv = ideal(r.metric); return iv ? r.value / Math.max(iv, 1) : r.value; };
|
|
826
|
+
const worst = scored.filter((r) => r.health === 'bad').sort((a, b) => over(b) - over(a))[0]
|
|
827
|
+
?? scored.filter((r) => r.health === 'warn').sort((a, b) => over(b) - over(a))[0];
|
|
828
|
+
return { score, worst };
|
|
829
|
+
}
|
|
830
|
+
function packagesSection() {
|
|
831
|
+
const pkgs = (h.packages ?? []).filter((p) => p.scored && p.metrics);
|
|
832
|
+
if (pkgs.length < 2) return '';
|
|
833
|
+
const rated = pkgs.map((p) => ({ ...p, ...scorePackage(p.metrics) })).filter((p) => p.score !== undefined && p.score !== null);
|
|
834
|
+
if (rated.length < 2) return '';
|
|
835
|
+
rated.sort((a, b) => a.score - b.score);
|
|
836
|
+
const shown = rated.slice(0, 10);
|
|
837
|
+
const band = (sc) => (sc >= 85 ? 'good' : sc >= 55 ? 'warn' : 'bad');
|
|
838
|
+
const skipped = (h.packages ?? []).length - rated.length;
|
|
839
|
+
const rows = shown.map((p) => `
|
|
840
|
+
<tr><td class="mono strong">${esc(p.dir)}</td>
|
|
841
|
+
<td><span class="pkg-score pkg-${band(p.score)}">${p.score}</span></td>
|
|
842
|
+
<td>${p.worst ? `${n(p.worst.value)} ${esc(PKG_LABELS[p.worst.metric] ?? p.worst.metric)}` : '<span class="dim">nothing over the line</span>'}</td>
|
|
843
|
+
<td class="dim">${n(p.codeFiles)} files</td></tr>`).join('');
|
|
844
|
+
return `<section class="glass pad">
|
|
845
|
+
${sectionHead('Package by package', `${rated.length} package${rated.length === 1 ? '' : 's'} with enough UI to judge · the repo score above is the whole thing blended${skipped > 0 ? `, and ${skipped} package${skipped === 1 ? ' was' : 's were'} too small or too backend to score` : ''}`)}
|
|
846
|
+
<div class="tbl-wrap"><table><thead><tr><th>package</th><th>score</th><th>worst finding</th><th>size</th></tr></thead><tbody>${rows}</tbody></table></div>
|
|
847
|
+
${rated.length > 10 ? `<div class="spec-more">and ${rated.length - 10} more</div>` : ''}
|
|
848
|
+
${healthScore !== null && rated.length && Math.min(...rated.map((p) => p.score)) > healthScore
|
|
849
|
+
? `<p class="sub" style="margin-top:12px">A repo scores below its own packages by arithmetic, not by accident: distinct values add up across packages, so the whole always carries more than any part. Read the package scores for where each team stands, and the repo score for what your agent sees when it looks at everything at once.</p>` : ''}</section>`;
|
|
850
|
+
}
|
|
851
|
+
|
|
649
852
|
function agentSection() {
|
|
650
853
|
const have = agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)} · ${esc(c.tool ?? c.kind)}</span>`).join('');
|
|
651
854
|
const msg = agentFiles.length
|
|
@@ -885,6 +1088,31 @@ const html = `<!doctype html>
|
|
|
885
1088
|
footer .brand { color:var(--accent); font-weight:700; text-decoration:none; }
|
|
886
1089
|
footer .brand:hover { text-decoration:underline; }
|
|
887
1090
|
footer .creds { display:flex; gap:16px; font:700 9.5px/1.6 var(--sans); letter-spacing:.16em; text-transform:uppercase; color:var(--dim2); }
|
|
1091
|
+
.pkg-score { display:inline-block; min-width:38px; text-align:center; font:700 12.5px/1 var(--sans);
|
|
1092
|
+
padding:6px 8px; border-radius:8px; }
|
|
1093
|
+
.pkg-good { background:var(--ok-soft); color:var(--ok); }
|
|
1094
|
+
.pkg-warn { background:var(--amber-soft); color:var(--amber); }
|
|
1095
|
+
.pkg-bad { background:var(--coral-soft); color:var(--coral); }
|
|
1096
|
+
.spec { margin-top:18px; }
|
|
1097
|
+
.spec-rows { display:grid; gap:2px; margin-top:8px; }
|
|
1098
|
+
.spec-type { display:flex; align-items:baseline; gap:16px; padding:5px 0; border-bottom:1px solid var(--line-soft); }
|
|
1099
|
+
.spec-type:last-child { border-bottom:0; }
|
|
1100
|
+
.spec-val { font:600 11px/1.4 var(--mono); color:var(--dim2); min-width:104px; flex-shrink:0; }
|
|
1101
|
+
.spec-sample { color:var(--text); line-height:1.15; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
|
1102
|
+
font-family:var(--disp); letter-spacing:-.01em; }
|
|
1103
|
+
.spec-more { font:600 10.5px/1 var(--mono); color:var(--dim2); margin-top:10px; }
|
|
1104
|
+
.spec-grid { display:flex; flex-wrap:wrap; gap:16px; margin-top:12px; }
|
|
1105
|
+
.spec-cell { display:grid; gap:7px; justify-items:center; }
|
|
1106
|
+
.spec-box { width:46px; height:46px; background:var(--deep); box-shadow:inset 0 0 0 1px var(--cell-ring); }
|
|
1107
|
+
.spec-shadows { background:#f2f2f5; border-radius:14px; padding:20px 18px 14px; gap:22px; }
|
|
1108
|
+
.spec-card { width:84px; height:50px; border-radius:10px; background:#ffffff; }
|
|
1109
|
+
.spec-shadows .spec-cap { color:#71718a; max-width:104px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1110
|
+
.spec-cap { font:500 10.5px/1 var(--mono); color:var(--dim2); }
|
|
1111
|
+
.start-head { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
|
|
1112
|
+
.delta { font:700 11px/1 var(--sans); letter-spacing:.04em; padding:5px 9px; border-radius:99px;
|
|
1113
|
+
background:var(--ok-soft); color:var(--ok); white-space:nowrap; }
|
|
1114
|
+
.delta-target { background:var(--amber-soft); color:var(--amber); font-weight:600; }
|
|
1115
|
+
.proj { color:var(--accent); font-weight:700; }
|
|
888
1116
|
.ndot { display:inline-block; width:9px; height:9px; border-radius:3px; margin-right:4px; vertical-align:-1px; box-shadow:inset 0 0 0 1px var(--cell-ring); }
|
|
889
1117
|
.nsim { color:var(--dim2); font-weight:400; padding:0 2px; }
|
|
890
1118
|
.gift-sec { margin-top:16px; }
|
|
@@ -960,6 +1188,8 @@ ${giftSection()}
|
|
|
960
1188
|
|
|
961
1189
|
<div class="stats">${bigStats.map((s) => statTile(s)).join('')}</div>
|
|
962
1190
|
|
|
1191
|
+
${packagesSection()}
|
|
1192
|
+
|
|
963
1193
|
${agentSection()}
|
|
964
1194
|
|
|
965
1195
|
<section style="margin-top:16px">${paletteSection()}</section>
|
|
@@ -975,8 +1205,8 @@ ${componentsSection()}
|
|
|
975
1205
|
|
|
976
1206
|
<footer>
|
|
977
1207
|
<div class="foot-left">
|
|
978
|
-
<div>Generated by <a class="brand" href="https://github.com/pencilrebel/roast-my-design-system">roast-my-design-system</a>,
|
|
979
|
-
<div class="foot-sub"
|
|
1208
|
+
<div>Generated by <a class="brand" href="https://github.com/pencilrebel/roast-my-design-system">roast-my-design-system</a> <span class="ver">ver. ${VERSION}</span>, free on npm and as a Claude Code + Codex skill</div>
|
|
1209
|
+
<div class="foot-sub">Designed and built by <a class="author" href="https://gregkozakiewicz.com"><span class="gk-mark"></span>Greg Kozakiewicz</a> · <span class="scan-id" title="scan id">${scanId}</span></div>
|
|
980
1210
|
</div>
|
|
981
1211
|
<span class="creds"><span>Non-destructive scan</span><span>Read-only</span><span>Paths are real</span></span>
|
|
982
1212
|
</footer>
|
|
@@ -1036,6 +1266,10 @@ if (summaryPath) {
|
|
|
1036
1266
|
noSystemLikely,
|
|
1037
1267
|
verdict,
|
|
1038
1268
|
tiles: bigStats.map((s) => ({ label: s.label, value: s.num, health: s.health })),
|
|
1269
|
+
packages: (h.packages ?? []).filter((p) => p.scored && p.metrics)
|
|
1270
|
+
.map((p) => ({ dir: p.dir, name: p.name, ...(scorePackage(p.metrics) ?? {}) }))
|
|
1271
|
+
.filter((p) => p.score !== undefined)
|
|
1272
|
+
.sort((a, b) => a.score - b.score),
|
|
1039
1273
|
report: outPath,
|
|
1040
1274
|
}, null, 2));
|
|
1041
1275
|
}
|
|
@@ -14,6 +14,9 @@ import { harvestComponents } from './components.mjs';
|
|
|
14
14
|
import { harvestTokens } from './tokens.mjs';
|
|
15
15
|
import { findDuplicates } from './duplicates.mjs';
|
|
16
16
|
import { harvestContext } from './context.mjs';
|
|
17
|
+
import { resolveWorkspaces } from '../lib/workspaces.mjs';
|
|
18
|
+
import { nearColorPairs } from '../lib/nearpairs.mjs';
|
|
19
|
+
import { neverImportedComponents } from '../lib/neverimported.mjs';
|
|
17
20
|
|
|
18
21
|
function arg(name, fallback) {
|
|
19
22
|
const i = process.argv.indexOf(`--${name}`);
|
|
@@ -35,6 +38,70 @@ const tokens = harvestTokens(target, files.styles, files.code);
|
|
|
35
38
|
const duplicates = findDuplicates(components, profile.uiDir, target);
|
|
36
39
|
const context = harvestContext(target);
|
|
37
40
|
|
|
41
|
+
// ---------- per-package pass (monorepos) ----------
|
|
42
|
+
// Styling is measured inside each package, but usage is counted repo-wide: a
|
|
43
|
+
// component another package imports is adopted, not dead. Files are already
|
|
44
|
+
// walked, so this re-reads each package's own files once, not the whole repo
|
|
45
|
+
// per package. Packages with too little UI to judge are listed, never scored.
|
|
46
|
+
const workspaces = resolveWorkspaces(target);
|
|
47
|
+
const packages = [];
|
|
48
|
+
if (workspaces.length > 1) {
|
|
49
|
+
const inDir = (f, dir) => f === dir || f.startsWith(`${dir}/`);
|
|
50
|
+
// deepest-first so apps/web/sub is attributed to itself, not to apps/web
|
|
51
|
+
const byDepth = [...workspaces].sort((a, b) => b.dir.split('/').length - a.dir.split('/').length);
|
|
52
|
+
const claimed = new Map();
|
|
53
|
+
const claim = (f) => byDepth.find((w) => inDir(f, w.dir))?.dir ?? null;
|
|
54
|
+
for (const f of files.code) { const d = claim(f); if (d) (claimed.get(d) ?? claimed.set(d, { code: [], styles: [] }).get(d)).code.push(f); }
|
|
55
|
+
for (const f of files.styles) { const d = claim(f); if (d) (claimed.get(d) ?? claimed.set(d, { code: [], styles: [] }).get(d)).styles.push(f); }
|
|
56
|
+
|
|
57
|
+
// Only packages that actually contain UI are worth reading twice: a backend
|
|
58
|
+
// or config package has no styling to judge, and scanning it would cost time
|
|
59
|
+
// to produce a meaningless perfect score. Biggest UI packages first, capped,
|
|
60
|
+
// so a 100-package monorepo cannot blow the scan budget.
|
|
61
|
+
const UI_RE = /\.(tsx|jsx|vue|svelte)$/;
|
|
62
|
+
const uiCount = (w) => {
|
|
63
|
+
const own = claimed.get(w.dir);
|
|
64
|
+
return own ? own.code.filter((f) => UI_RE.test(f)).length + own.styles.length : 0;
|
|
65
|
+
};
|
|
66
|
+
const candidates = workspaces
|
|
67
|
+
.map((w) => ({ w, ui: uiCount(w) }))
|
|
68
|
+
.sort((a, b) => b.ui - a.ui);
|
|
69
|
+
const scanning = new Set(candidates.filter((c) => c.ui >= 8).slice(0, 30).map((c) => c.w.dir));
|
|
70
|
+
|
|
71
|
+
for (const w of workspaces) {
|
|
72
|
+
const own = claimed.get(w.dir);
|
|
73
|
+
if (!own) continue;
|
|
74
|
+
const codeCount = own.code.length, styleCount = own.styles.length;
|
|
75
|
+
const entry = { name: w.name, dir: w.dir, codeFiles: codeCount, styleFiles: styleCount, scored: false };
|
|
76
|
+
if (!scanning.has(w.dir)) { packages.push(entry); continue; }
|
|
77
|
+
const t = harvestTokens(target, own.styles, own.code);
|
|
78
|
+
const comps = components.filter((c) => inDir(c.file, w.dir));
|
|
79
|
+
const dupes = duplicates.exactDuplicates.filter((d) => !d.wrapped
|
|
80
|
+
&& d.files.every((f) => inDir(typeof f === 'string' ? f : f.file, w.dir)));
|
|
81
|
+
const colorTokens = t.colors.filter((c) => c.isToken).length;
|
|
82
|
+
const signal = t.colors.length + t.spacing.length + t.inlineStyles.count
|
|
83
|
+
+ (t.tailwind.colors.length + t.tailwind.spacing.length);
|
|
84
|
+
if (signal < 5) { packages.push(entry); continue; } // nothing to judge, so no verdict
|
|
85
|
+
entry.scored = true;
|
|
86
|
+
entry.metrics = {
|
|
87
|
+
colors: t.colors.length,
|
|
88
|
+
colorTokens,
|
|
89
|
+
colorStrays: t.colors.length - colorTokens,
|
|
90
|
+
greys: t.greyCount,
|
|
91
|
+
greyStrays: t.colors.filter((c) => !c.isToken && c.value.startsWith('#')).length,
|
|
92
|
+
spacing: t.spacing.length + t.tailwind.spacing.filter((v) => v.value.startsWith('[')).length,
|
|
93
|
+
exactDuplicates: dupes.length,
|
|
94
|
+
inlineStyles: t.inlineStyles.count,
|
|
95
|
+
nearPairs: nearColorPairs(t.colors).length,
|
|
96
|
+
important: t.important?.count ?? 0,
|
|
97
|
+
neverImported: neverImportedComponents(comps, null).length,
|
|
98
|
+
arbitrary: (t.tailwind.arbitrary ?? []).reduce((sum, a) => sum + a.count, 0),
|
|
99
|
+
components: comps.filter((c) => !c.isPage).length,
|
|
100
|
+
};
|
|
101
|
+
packages.push(entry);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
38
105
|
const harvest = {
|
|
39
106
|
repo: target,
|
|
40
107
|
harvestedAt: new Date().toISOString(),
|
|
@@ -49,6 +116,7 @@ const harvest = {
|
|
|
49
116
|
tokens,
|
|
50
117
|
duplicates,
|
|
51
118
|
context,
|
|
119
|
+
packages,
|
|
52
120
|
};
|
|
53
121
|
harvest.tookMs = Date.now() - t0;
|
|
54
122
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace resolution. A monorepo declares its packages in one of a few
|
|
3
|
+
* places and always as globs, never as a fixed folder shape — guessing from
|
|
4
|
+
* folder depth misses most of them (cal.com keeps 23 packages that a naive
|
|
5
|
+
* two-level scan reports as zero). Read the declaration instead.
|
|
6
|
+
*
|
|
7
|
+
* Supports: package.json `workspaces` (array or { packages: [] }),
|
|
8
|
+
* pnpm-workspace.yaml, and negation patterns.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const readJSON = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
|
|
14
|
+
|
|
15
|
+
/** Expand one glob ('packages/*', 'apps/**', 'tools/x') to existing directories. */
|
|
16
|
+
function expand(root, pattern) {
|
|
17
|
+
if (pattern === '.' || pattern === './') return [];
|
|
18
|
+
const parts = pattern.replace(/\/+$/, '').split('/').filter(Boolean);
|
|
19
|
+
let dirs = [''];
|
|
20
|
+
for (const part of parts) {
|
|
21
|
+
const next = [];
|
|
22
|
+
for (const d of dirs) {
|
|
23
|
+
const abs = join(root, d);
|
|
24
|
+
if (part === '*' || part === '**') {
|
|
25
|
+
let entries;
|
|
26
|
+
try { entries = readdirSync(abs, { withFileTypes: true }); } catch { continue; }
|
|
27
|
+
for (const e of entries) {
|
|
28
|
+
if (!e.isDirectory() || e.name.startsWith('.') || e.name === 'node_modules') continue;
|
|
29
|
+
const rel = d ? `${d}/${e.name}` : e.name;
|
|
30
|
+
next.push(rel);
|
|
31
|
+
// '**' also matches deeper levels
|
|
32
|
+
if (part === '**') dirs.push(rel);
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
const rel = d ? `${d}/${part}` : part;
|
|
36
|
+
if (existsSync(join(root, rel))) next.push(rel);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
dirs = next;
|
|
40
|
+
}
|
|
41
|
+
return dirs.filter((d) => { try { return statSync(join(root, d)).isDirectory(); } catch { return false; } });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve a repo's workspace packages.
|
|
46
|
+
* Returns [{ name, dir }] with dir relative to the repo root, or [] when the
|
|
47
|
+
* repo is not a workspace monorepo.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveWorkspaces(root) {
|
|
50
|
+
const patterns = [];
|
|
51
|
+
const pkg = readJSON(join(root, 'package.json'));
|
|
52
|
+
const ws = pkg?.workspaces;
|
|
53
|
+
if (Array.isArray(ws)) patterns.push(...ws);
|
|
54
|
+
else if (Array.isArray(ws?.packages)) patterns.push(...ws.packages);
|
|
55
|
+
|
|
56
|
+
// pnpm-workspace.yaml: the "packages:" block, read line by line. Comments and
|
|
57
|
+
// blank lines are legal inside it (langfuse opens with one), and sibling
|
|
58
|
+
// top-level keys such as "catalog:" end it.
|
|
59
|
+
try {
|
|
60
|
+
const lines = readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8').split('\n');
|
|
61
|
+
let inBlock = false;
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
if (/^packages:\s*$/.test(line)) { inBlock = true; continue; }
|
|
64
|
+
if (!inBlock) continue;
|
|
65
|
+
if (/^\s*(#.*)?$/.test(line)) continue; // comment or blank
|
|
66
|
+
if (/^\S/.test(line)) break; // next top-level key
|
|
67
|
+
const m = /^\s*-\s*["']?([^"'#]+?)["']?\s*$/.exec(line);
|
|
68
|
+
if (m) patterns.push(m[1].trim());
|
|
69
|
+
}
|
|
70
|
+
} catch { /* not a pnpm workspace */ }
|
|
71
|
+
|
|
72
|
+
if (!patterns.length) return [];
|
|
73
|
+
|
|
74
|
+
const negations = patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1).replace(/\/+$/, ''));
|
|
75
|
+
const isNegated = (dir) => negations.some((neg) => {
|
|
76
|
+
if (neg.includes('*')) return expand(root, neg).includes(dir);
|
|
77
|
+
return dir === neg || dir.startsWith(`${neg}/`);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const seen = new Map();
|
|
81
|
+
for (const pattern of patterns) {
|
|
82
|
+
if (pattern.startsWith('!')) continue;
|
|
83
|
+
for (const dir of expand(root, pattern)) {
|
|
84
|
+
if (!dir || dir === '.' || isNegated(dir) || seen.has(dir)) continue;
|
|
85
|
+
// a workspace package is one that declares itself
|
|
86
|
+
const own = readJSON(join(root, dir, 'package.json'));
|
|
87
|
+
if (!own) continue;
|
|
88
|
+
seen.set(dir, { name: own.name || dir, dir });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return [...seen.values()].sort((a, b) => a.dir.localeCompare(b.dir));
|
|
92
|
+
}
|