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
- ![The full diagnosis report for vercel/ai-chatbot in dark mode: health score, three-yardstick tiles, palette forensics, spacing receipts, typography, offenders, duplicates, and the where-to-start close](https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/report-full-dark.png?v=3.8.0)
28
+ ![The full diagnosis report for vercel/ai-chatbot in dark mode: health score, three-yardstick tiles, palette forensics, spacing receipts, typography, offenders, duplicates, and the where-to-start close](https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/report-full-dark.png?v=3.9.0)
28
29
 
29
30
  The same report in light mode (one file, built-in toggle):
30
31
 
31
- ![The diagnosis report in light mode](https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/report-light-hero.png?v=3.8.0)
32
+ ![The diagnosis report in light mode](https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/report-light-hero.png?v=3.9.0)
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 5,000-file monorepo takes ~1.5s) and returns the same numbers every run. Claude narrates; it never counts.
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.8.0"><img src="https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/gk-mark.png?v=3.8.0" height="15" alt="GK mark"></picture> Greg Kozakiewicz</a>.
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.8.0",
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.8.0';
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
- if (!NO_ABSENCE_RULE.has(metric) && value < Math.max(1, iv * 0.05)) return 'bad';
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
+ }