roast-my-design-system 3.8.0 → 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;
|
|
@@ -792,6 +794,61 @@ function whereToStartSection() {
|
|
|
792
794
|
</div>`).join('')}</div></section>`;
|
|
793
795
|
}
|
|
794
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
|
+
|
|
795
852
|
function agentSection() {
|
|
796
853
|
const have = agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)} · ${esc(c.tool ?? c.kind)}</span>`).join('');
|
|
797
854
|
const msg = agentFiles.length
|
|
@@ -1031,6 +1088,11 @@ const html = `<!doctype html>
|
|
|
1031
1088
|
footer .brand { color:var(--accent); font-weight:700; text-decoration:none; }
|
|
1032
1089
|
footer .brand:hover { text-decoration:underline; }
|
|
1033
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); }
|
|
1034
1096
|
.spec { margin-top:18px; }
|
|
1035
1097
|
.spec-rows { display:grid; gap:2px; margin-top:8px; }
|
|
1036
1098
|
.spec-type { display:flex; align-items:baseline; gap:16px; padding:5px 0; border-bottom:1px solid var(--line-soft); }
|
|
@@ -1126,6 +1188,8 @@ ${giftSection()}
|
|
|
1126
1188
|
|
|
1127
1189
|
<div class="stats">${bigStats.map((s) => statTile(s)).join('')}</div>
|
|
1128
1190
|
|
|
1191
|
+
${packagesSection()}
|
|
1192
|
+
|
|
1129
1193
|
${agentSection()}
|
|
1130
1194
|
|
|
1131
1195
|
<section style="margin-top:16px">${paletteSection()}</section>
|
|
@@ -1202,6 +1266,10 @@ if (summaryPath) {
|
|
|
1202
1266
|
noSystemLikely,
|
|
1203
1267
|
verdict,
|
|
1204
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),
|
|
1205
1273
|
report: outPath,
|
|
1206
1274
|
}, null, 2));
|
|
1207
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
|
+
}
|