roast-my-design-system 3.2.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.
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Implicit-token harvest — every colour, spacing, radius, font-size the repo
3
+ * ACTUALLY uses, with frequency counts and where. This is the heart of the
4
+ * diagnosis ("27 greys, 19 spacing values"): we count real occurrences across
5
+ * stylesheets, inline styles, and Tailwind utility classes.
6
+ *
7
+ * New in 2.0 — 1.0 only ever read a clean globals.css; this reads the mess.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ // ---------- counters ----------
13
+ class Tally {
14
+ constructor() { this.map = new Map(); }
15
+ add(value, file) {
16
+ let e = this.map.get(value);
17
+ if (!e) { e = { value, count: 0, files: new Map() }; this.map.set(value, e); }
18
+ e.count++;
19
+ e.files.set(file, (e.files.get(file) ?? 0) + 1);
20
+ }
21
+ toJSON(limitFiles = 5) {
22
+ return [...this.map.values()]
23
+ .sort((a, b) => b.count - a.count)
24
+ .map((e) => ({
25
+ value: e.value,
26
+ count: e.count,
27
+ files: [...e.files.entries()].sort((a, b) => b[1] - a[1]).slice(0, limitFiles)
28
+ .map(([file, count]) => ({ file, count })),
29
+ }));
30
+ }
31
+ }
32
+
33
+ // ---------- colour parsing ----------
34
+ const HEX_RE = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
35
+ const FUNC_COLOR_RE = /\b(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\(\s*[^)]{1,80}\)/g;
36
+
37
+ /** normalize: lowercase; expand #abc → #aabbcc so duplicates merge. */
38
+ export function normalizeHex(hex) {
39
+ let h = hex.toLowerCase();
40
+ if (h.length === 4 || h.length === 5) {
41
+ h = '#' + [...h.slice(1)].map((c) => c + c).join('');
42
+ }
43
+ return h;
44
+ }
45
+
46
+ /** Is this hex a grey (R≈G≈B within a small tolerance)? Diagnosis loves this. */
47
+ export function isGrey(hex) {
48
+ const h = normalizeHex(hex);
49
+ if (h.length < 7) return false;
50
+ const r = parseInt(h.slice(1, 3), 16), g = parseInt(h.slice(3, 5), 16), b = parseInt(h.slice(5, 7), 16);
51
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
52
+ return max - min <= 10;
53
+ }
54
+
55
+ // ---------- spacing / radius / font-size from CSS declarations ----------
56
+ const SPACING_PROPS = /(?:^|[^-\w])(margin(?:-(?:top|right|bottom|left))?|padding(?:-(?:top|right|bottom|left))?|gap|row-gap|column-gap|top|right|bottom|left|inset)\s*:\s*([^;{}]+)[;}]/g;
57
+ const RADIUS_PROPS = /border(?:-(?:top|bottom)-(?:left|right))?-radius\s*:\s*([^;{}]+)[;}]/g;
58
+ const FONTSIZE_PROPS = /font-size\s*:\s*([^;{}]+)[;}]/g;
59
+ const FONTFAMILY_PROPS = /font-family\s*:\s*([^;{}]+)[;}]/g;
60
+ const SHADOW_PROPS = /box-shadow\s*:\s*([^;{}]+)[;}]/g;
61
+ const LENGTH_RE = /-?\d*\.?\d+(?:px|rem|em|%|vh|vw|ch)\b/g;
62
+
63
+ // ---------- Tailwind utility classes ----------
64
+ // Colour-bearing utilities: bg-red-500, text-zinc-400, border-gray-200, arbitrary bg-[#1a1a1a]
65
+ const TW_COLOR_RE = /\b(?:bg|text|border|ring|fill|stroke|from|via|to|divide|outline|decoration|accent|caret|shadow)-(?:\[(#[0-9a-fA-F]{3,8}|rgba?\([^\]]*\)|hsla?\([^\]]*\)|oklch\([^\]]*\))\]|((?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|white|black)(?:-\d{2,3})?))(?=[\s"'`/}:]|$)/g;
66
+ // Spacing utilities: p-4 px-2 mt-6 gap-3 space-x-2 -mx-4, arbitrary p-[13px]
67
+ const TW_SPACING_RE = /\b-?(?:[mp][trblxy]?|gap(?:-[xy])?|space-[xy]|inset(?:-[xy])?|top|right|bottom|left)-(\[(?:[^\]]+)\]|\d+(?:\.\d+)?|px)(?=[\s"'`}:]|$)/g;
68
+ const TW_RADIUS_RE = /\brounded(?:-(?:t|b|l|r|tl|tr|bl|br))?(?:-(none|sm|md|lg|xl|2xl|3xl|full|\[[^\]]+\]))?(?=[\s"'`}:]|$)/g;
69
+ const TW_TEXTSIZE_RE = /\btext-(xs|sm|base|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl|\[[^\]]+\])(?=[\s"'`}:]|$)/g;
70
+
71
+ /** Extract string contents of className=/class= attributes + template classes. */
72
+ function classStrings(src) {
73
+ const out = [];
74
+ for (const m of src.matchAll(/class(?:Name)?\s*=\s*(?:"([^"]*)"|'([^']*)'|\{`([^`]*)`\})/g)) {
75
+ out.push(m[1] ?? m[2] ?? m[3] ?? '');
76
+ }
77
+ // cva()/cn()/clsx() string args also carry classes
78
+ for (const m of src.matchAll(/\b(?:cva|cn|clsx|classnames|twMerge)\s*\(([\s\S]{0,2000}?)\)/g)) {
79
+ for (const s of m[1].matchAll(/["'`]([^"'`]+)["'`]/g)) out.push(s[1]);
80
+ }
81
+ return out;
82
+ }
83
+
84
+ // An inline style that only pipes currentcolor/inherit through (the standard
85
+ // SVG icon-inheritance pattern, often 60x in one glyph file) is not "styling
86
+ // bypassing the system" — counting it hands a sharp dev the whole rebuttal.
87
+ const TRIVIAL_INLINE_RE = /^\{?\s*(?:(?:\.\.\.[\w.]+|(?:color|fill|stroke)\s*:\s*['"]?(?:currentcolor|inherit)['"]?)\s*,?\s*)*\}?$/i;
88
+
89
+ // A style block whose values are runtime expressions (translate(\`\${x}px\`),
90
+ // width: sidebarWidth, ternaries) CANNOT be static CSS — that's dynamic
91
+ // positioning/theming, not a style crime. Only all-literal blocks count.
92
+ function isStaticInline(block) {
93
+ const inner = block.trim().replace(/^\{/, '').replace(/\}$/, '');
94
+ if (inner.includes('${')) return false;
95
+ // every value must start like a literal: quote, number, negative, hex
96
+ for (const m of inner.matchAll(/[\w-]+\s*:\s*([^,{}]+)/g)) {
97
+ const v = m[1].trim();
98
+ if (!/^["'0-9#-]/.test(v)) return false;
99
+ }
100
+ return true;
101
+ }
102
+
103
+ // Render-to-image surfaces (satori OG cards, react-pdf invoices) have inline
104
+ // styles as their ONLY styling mechanism — same honesty rule as email.
105
+ const RENDER_TO_IMAGE_RE = /ImageResponse|from ['"]satori['"]|from ['"]@react-pdf|next\/og/;
106
+
107
+ /** Extract inline style objects: style={{ ... }} */
108
+ function inlineStyleBlocks(src) {
109
+ const out = [];
110
+ let from = 0, idx;
111
+ while ((idx = src.indexOf('style={{', from)) !== -1) {
112
+ const close = src.indexOf('}}', idx);
113
+ if (close === -1) break;
114
+ out.push(src.slice(idx + 7, close + 1));
115
+ from = close + 2;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /**
121
+ * Harvest tokens across style files + code files.
122
+ * Returns { colors, greys, spacing, radii, fontSizes, fontFamilies, shadows,
123
+ * tailwind: {...same buckets from tw classes...}, inlineStyleCount }
124
+ */
125
+ export function harvestTokens(root, styleFiles, codeFiles) {
126
+ const colors = new Tally(), spacing = new Tally(), radii = new Tally(),
127
+ fontSizes = new Tally(), fontFamilies = new Tally(), shadows = new Tally();
128
+ const twColors = new Tally(), twSpacing = new Tally(), twRadii = new Tally(), twTextSizes = new Tally();
129
+ const twArbitrary = new Tally();
130
+ let inlineStyleCount = 0;
131
+ const inlineStyleFiles = new Map();
132
+
133
+ // Colours that appear in a CSS custom-property DEFINITION (--grey-100: #f5f5f5)
134
+ // are deliberate tokens; everything else is a hardcoded stray. The diagnosis
135
+ // hinges on this split.
136
+ const tokenDefined = new Set();
137
+ const tokenDefsPerFile = new Map();
138
+
139
+ const scanCssText = (text, file) => {
140
+ for (const m of text.matchAll(/--[\w-]+\s*:\s*([^;{}]+)[;}]/g)) {
141
+ tokenDefsPerFile.set(file, (tokenDefsPerFile.get(file) ?? 0) + 1);
142
+ for (const c of m[1].matchAll(HEX_RE)) tokenDefined.add(normalizeHex(c[0]));
143
+ for (const c of m[1].matchAll(FUNC_COLOR_RE)) tokenDefined.add(c[0].replace(/\s+/g, ' ').toLowerCase());
144
+ }
145
+ for (const m of text.matchAll(HEX_RE)) colors.add(normalizeHex(m[0]), file);
146
+ for (const m of text.matchAll(FUNC_COLOR_RE)) colors.add(m[0].replace(/\s+/g, ' ').toLowerCase(), file);
147
+ for (const m of text.matchAll(SPACING_PROPS)) {
148
+ for (const len of (m[2].match(LENGTH_RE) ?? [])) spacing.add(len, file);
149
+ }
150
+ for (const m of text.matchAll(RADIUS_PROPS)) radii.add(m[1].trim(), file);
151
+ for (const m of text.matchAll(FONTSIZE_PROPS)) fontSizes.add(m[1].trim(), file);
152
+ for (const m of text.matchAll(FONTFAMILY_PROPS)) {
153
+ const v = m[1].trim().replace(/\s+/g, ' ');
154
+ // var(--x) and inherit are disciplined token usage, not declarations
155
+ if (/^(var\(--[\w-]+\)|inherit)$/i.test(v)) continue;
156
+ fontFamilies.add(v, file);
157
+ }
158
+ for (const m of text.matchAll(SHADOW_PROPS)) shadows.add(m[1].trim().replace(/\s+/g, ' '), file);
159
+ };
160
+
161
+ for (const f of styleFiles) {
162
+ if (/email|(^|[/.])print([/.]|$)/i.test(f)) continue;
163
+ let text; try { text = readFileSync(join(root, f), 'utf8'); } catch { continue; }
164
+ scanCssText(text, f);
165
+ }
166
+
167
+ // Email templates (and print styles) MUST inline their styling — that's
168
+ // correct practice, not mess. Counting them would hand a sharp dev an easy
169
+ // reason to discredit the whole report.
170
+ const EXEMPT_RE = /email|(^|[/.])print([/.]|$)/i;
171
+ // SVG artwork components (logos, badges, illustrated icons) carry hex that
172
+ // is drawing, not styling — same honesty rule as email templates.
173
+ const ARTWORK_RE = /(^|\/)[\w.-]*(icon|logo|badge|illustration|shield|artwork|graphic|background)[\w.-]*\.(tsx|jsx)$/i;
174
+ // A component that is mostly SVG markup (theme-preview mocks, decorative
175
+ // scenes) is drawing, not styling — regardless of its filename.
176
+ const svgHeavy = (src) => (src.match(/<(?:svg|path|rect|circle|ellipse|polygon|mask|defs)\b/g) ?? []).length >= 15;
177
+ // Canvas/scene renderers draw pixels; their colour literals are not UI styling.
178
+ const RENDERER_PATH_RE = /(^|\/)(renderers?|scene|canvas)\/|renderElement|DebugCanvas/i;
179
+ // Colour-picker palettes and design-field option lists are user-facing data.
180
+ const PALETTE_FILE_RE = /(color-picker|colour-picker|palette|design-fields|swatch)/i;
181
+
182
+ for (const f of codeFiles) {
183
+ if (!/\.(tsx|jsx|ts|js)$/.test(f)) continue;
184
+ if (EXEMPT_RE.test(f)) continue;
185
+ let src; try { src = readFileSync(join(root, f), 'utf8'); } catch { continue; }
186
+
187
+ // A Tailwind config's colours ARE the token layer (the tailwind-native
188
+ // equivalent of --var definitions). Its fontFamily block is the typeface
189
+ // declaration for the whole app.
190
+ if (/(^|\/)tailwind\.config\.[mc]?[jt]s$/.test(f)) {
191
+ for (const m of src.matchAll(HEX_RE)) { tokenDefined.add(normalizeHex(m[0])); colors.add(normalizeHex(m[0]), f); }
192
+ const famBlock = src.match(/fontFamily\s*:\s*\{([\s\S]*?)\n\s*\}/);
193
+ if (famBlock) {
194
+ // one declaration per stack: the first non-var name; fallbacks in the
195
+ // same array are not additional "ways" of declaring a font
196
+ for (const arr of famBlock[1].matchAll(/\[([^\]]+)\]/g)) {
197
+ const first = [...arr[1].matchAll(/["']([^"']+)["']/g)].map((x) => x[1])
198
+ .find((v) => !v.startsWith('var(') && !/^--/.test(v));
199
+ if (first) fontFamilies.add(first, f);
200
+ }
201
+ }
202
+ continue;
203
+ }
204
+ // next/font and geist load typefaces without a font-family declaration —
205
+ // reporting "0 typefaces" for a repo shipping .woff2 loaders is wrong.
206
+ for (const m of src.matchAll(/import\s*\{\s*([^}]+)\}\s*from\s*["']next\/font\/google["']/g)) {
207
+ for (const name of m[1].split(',')) { const t = name.trim().split(/\s+as\s+/)[0].trim(); if (t) fontFamilies.add(t.replace(/_/g, ' '), f); }
208
+ }
209
+ for (const m of src.matchAll(/localFont\s*\(\s*\{[\s\S]{0,600}?variable\s*:\s*["']--font-([\w-]+)["']/g)) {
210
+ fontFamilies.add(m[1].replace(/-/g, ' '), f);
211
+ }
212
+ if (/from\s*["']geist\/font/.test(src)) fontFamilies.add('Geist', f);
213
+ if (PALETTE_FILE_RE.test(f)) {
214
+ for (const m of src.matchAll(HEX_RE)) tokenDefined.add(normalizeHex(m[0]));
215
+ }
216
+ if (ARTWORK_RE.test(f) || RENDERER_PATH_RE.test(f) || svgHeavy(src)) continue;
217
+ const renderToImage = RENDER_TO_IMAGE_RE.test(src) || /(^|\/)api\/og\//.test(f);
218
+
219
+ // Tailwind classes
220
+ for (const cls of classStrings(src)) {
221
+ for (const m of cls.matchAll(TW_COLOR_RE)) twColors.add(m[1] ? normalizeHex(m[1].startsWith('#') ? m[1] : m[1]) : m[2], f);
222
+ for (const m of cls.matchAll(TW_SPACING_RE)) twSpacing.add(m[1], f);
223
+ for (const m of cls.matchAll(TW_RADIUS_RE)) twRadii.add(m[1] ?? 'default', f);
224
+ for (const m of cls.matchAll(TW_TEXTSIZE_RE)) twTextSizes.add(m[1], f);
225
+ // Arbitrary length values (p-[13px], text-[10px], w-[257px]) — the
226
+ // Tailwind-era way a scale erodes, one bracket at a time.
227
+ for (const m of cls.matchAll(/[a-z][\w-]*-\[(-?\d+(?:\.\d+)?(?:px|rem|em|%|vh|vw|pt))\]/g)) twArbitrary.add(`[${m[1]}]`, f);
228
+ }
229
+
230
+ // Inline styles: dynamic blocks and render-to-image surfaces are
231
+ // legitimate STYLING (not counted as blocks), but any hardcoded colour or
232
+ // length inside them is still a hardcoded value — harvest those from every
233
+ // non-trivial block; count only the all-literal blocks.
234
+ const allBlocks = inlineStyleBlocks(src).filter((b) => !TRIVIAL_INLINE_RE.test(b.trim()));
235
+ const staticBlocks = renderToImage ? [] : allBlocks.filter(isStaticInline);
236
+ if (staticBlocks.length) {
237
+ inlineStyleCount += staticBlocks.length;
238
+ inlineStyleFiles.set(f, (inlineStyleFiles.get(f) ?? 0) + staticBlocks.length);
239
+ }
240
+ for (const b of allBlocks) {
241
+ for (const m of b.matchAll(HEX_RE)) colors.add(normalizeHex(m[0]), f);
242
+ for (const m of b.matchAll(FUNC_COLOR_RE)) colors.add(m[0].replace(/\s+/g, ' ').toLowerCase(), f);
243
+ for (const m of b.matchAll(LENGTH_RE)) spacing.add(m[0], f);
244
+ }
245
+
246
+ // Raw hex colours in code outside class strings (styled-components, consts)
247
+ // — cheap approximation: any hex in a .ts/.js file counts as a colour in code.
248
+ if (/\.(ts|js)$/.test(f)) {
249
+ for (const m of src.matchAll(HEX_RE)) {
250
+ // avoid counting hashes that aren't colors (e.g. IDs): require 3/4/6/8 hex digits already enforced by regex
251
+ colors.add(normalizeHex(m[0]), f);
252
+ }
253
+ }
254
+ }
255
+
256
+ // A code file defining 15+ distinct colours is a deliberate palette
257
+ // (drawing swatches, avatar generators, chart scales) — the MUI lesson:
258
+ // definitions are architecture, not strays.
259
+ const perFileDistinct = new Map();
260
+ for (const e of colors.map.values()) {
261
+ for (const [file] of e.files) perFileDistinct.set(file, (perFileDistinct.get(file) ?? 0) + 1);
262
+ }
263
+ for (const e of colors.map.values()) {
264
+ if ([...e.files.keys()].some((f) => /\.[jt]sx?$/.test(f) && perFileDistinct.get(f) >= 15)) tokenDefined.add(e.value);
265
+ }
266
+
267
+ const colorList = colors.toJSON().map((c) => ({ ...c, isToken: tokenDefined.has(c.value) }));
268
+
269
+ // Worst offenders: files carrying the most stray styling (hardcoded colours
270
+ // that never appear in a --var definition, plus inline style blocks).
271
+ const offenders = new Map();
272
+ const bump = (file, key, by) => {
273
+ const e = offenders.get(file) ?? { file, strayColors: 0, inlineBlocks: 0 };
274
+ e[key] += by;
275
+ offenders.set(file, e);
276
+ };
277
+ for (const e of colors.map.values()) {
278
+ if (tokenDefined.has(e.value)) continue;
279
+ for (const [file, count] of e.files) bump(file, 'strayColors', count);
280
+ }
281
+ for (const [file, count] of inlineStyleFiles) bump(file, 'inlineBlocks', count);
282
+ const offenderList = [...offenders.values()]
283
+ .map((o) => ({ ...o, total: o.strayColors + o.inlineBlocks }))
284
+ .sort((a, b) => b.total - a.total)
285
+ .slice(0, 10);
286
+
287
+ const tokenFile = [...tokenDefsPerFile.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
288
+
289
+ return {
290
+ tokenFile,
291
+ colors: colorList,
292
+ offenders: offenderList,
293
+ greyCount: colorList.filter((c) => c.value.startsWith('#') && isGrey(c.value)).length,
294
+ spacing: spacing.toJSON(),
295
+ radii: radii.toJSON(),
296
+ fontSizes: fontSizes.toJSON(),
297
+ fontFamilies: fontFamilies.toJSON(),
298
+ shadows: shadows.toJSON(),
299
+ tailwind: {
300
+ colors: twColors.toJSON(),
301
+ spacing: twSpacing.toJSON(),
302
+ radii: twRadii.toJSON(),
303
+ textSizes: twTextSizes.toJSON(),
304
+ arbitrary: twArbitrary.toJSON(),
305
+ },
306
+ inlineStyles: {
307
+ count: inlineStyleCount,
308
+ files: [...inlineStyleFiles.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
309
+ .map(([file, count]) => ({ file, count })),
310
+ },
311
+ };
312
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * File walker + repo profile. Adapted from 1.0's repo-inspect (proffer-spine),
3
+ * widened: the harvester must see EVERYTHING that styles the app — code,
4
+ * stylesheets of any flavor, and config — not just a happy-path shadcn layout.
5
+ */
6
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
7
+ import { join, relative, extname } from 'node:path';
8
+
9
+ export function readJSON(p) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } }
10
+ export function read(p) { try { return readFileSync(p, 'utf8'); } catch { return null; } }
11
+
12
+ const SKIP_DIRS = new Set([
13
+ 'node_modules', '.next', '.git', 'dist', 'build', 'out', 'coverage',
14
+ '.turbo', '.vercel', '.cache', 'storybook-static', 'public',
15
+ // Documentation sites (Docusaurus and friends) carry their own theme and
16
+ // demo fixtures — that styling is not the product's design language.
17
+ 'docs', 'dev-docs', 'website', 'documentation',
18
+ // Example integrations and demo apps are not the product's design language.
19
+ 'examples', 'example', 'demos', 'demo', 'playground', 'fixtures',
20
+ // Test/fixture surfaces are not the product's design language — counting a
21
+ // story's `export const Default` 36× would poison the diagnosis numbers.
22
+ '__tests__', '__mocks__', '__fixtures__', '__snapshots__',
23
+ 'cypress', 'e2e', 'playwright', 'test', 'tests', '.storybook',
24
+ ]);
25
+
26
+ // Same idea at file granularity: Button.test.tsx / Button.stories.tsx / *.cy.ts
27
+ const TEST_FILE_RE = /\.(test|spec|stories|story|cy)\.[cm]?[jt]sx?$/;
28
+
29
+ const CODE_EXTS = new Set(['.tsx', '.jsx', '.ts', '.js', '.mjs', '.cjs']);
30
+ const STYLE_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
31
+
32
+ /** Walk the repo, returning relative paths bucketed by role. Depth-capped. */
33
+ export function walkRepo(root, maxDepth = 8) {
34
+ const files = { code: [], styles: [], other: [] };
35
+ const recurse = (dir, depth) => {
36
+ if (depth > maxDepth) return;
37
+ let entries = [];
38
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
39
+ for (const e of entries) {
40
+ if (e.name.startsWith('.') && e.name !== '.cursorrules') continue;
41
+ if (SKIP_DIRS.has(e.name)) continue;
42
+ const p = join(dir, e.name);
43
+ if (e.isDirectory()) { recurse(p, depth + 1); continue; }
44
+ if (TEST_FILE_RE.test(e.name)) continue;
45
+ const rel = relative(root, p).replaceAll('\\', '/');
46
+ const ext = extname(e.name);
47
+ if (STYLE_EXTS.has(ext)) files.styles.push(rel);
48
+ else if (CODE_EXTS.has(ext)) files.code.push(rel);
49
+ else files.other.push(rel);
50
+ }
51
+ };
52
+ recurse(root, 0);
53
+ return files;
54
+ }
55
+
56
+ // Component libraries we can name on sight (from 1.0's repo-inspect).
57
+ const KNOWN_LIBRARIES = [
58
+ { pkg: '@mui/material', name: 'Material UI' },
59
+ { pkg: '@chakra-ui/react', name: 'Chakra UI' },
60
+ { pkg: 'antd', name: 'Ant Design' },
61
+ { pkg: '@mantine/core', name: 'Mantine' },
62
+ { pkg: '@heroui/react', name: 'HeroUI' },
63
+ { pkg: '@nextui-org/react', name: 'NextUI' },
64
+ { pkg: '@radix-ui/themes', name: 'Radix Themes' },
65
+ { pkg: '@fluentui/react', name: 'Fluent UI' },
66
+ { pkg: 'react-bootstrap', name: 'React Bootstrap' },
67
+ { pkg: 'grommet', name: 'Grommet' },
68
+ ];
69
+
70
+ const STYLING_DEPS = [
71
+ { pkg: 'tailwindcss', label: 'Tailwind CSS' },
72
+ { pkg: 'styled-components', label: 'styled-components' },
73
+ { pkg: '@emotion/react', label: 'Emotion' },
74
+ { pkg: '@emotion/styled', label: 'Emotion (styled)' },
75
+ { pkg: 'sass', label: 'Sass' },
76
+ { pkg: 'less', label: 'Less' },
77
+ { pkg: '@vanilla-extract/css', label: 'vanilla-extract' },
78
+ { pkg: '@stitches/react', label: 'Stitches' },
79
+ { pkg: 'class-variance-authority', label: 'CVA' },
80
+ ];
81
+
82
+ /** Framework + design-system classification (1.0 logic, widened for monorepos). */
83
+ export function profileRepo(root, files) {
84
+ const pkg = readJSON(join(root, 'package.json')) || {};
85
+ // Monorepos keep real deps in workspace sub-packages — merge every
86
+ // package.json the walk saw (root values win on conflict).
87
+ const pkgFiles = files.other.filter((f) => f.endsWith('package.json'));
88
+ let deps = {};
89
+ for (const f of pkgFiles) {
90
+ const sub = readJSON(join(root, f));
91
+ if (sub) deps = { ...deps, ...sub.dependencies, ...sub.devDependencies };
92
+ }
93
+ deps = { ...deps, ...pkg.dependencies, ...pkg.devDependencies };
94
+ const monorepo = pkgFiles.some((f) => f !== 'package.json');
95
+
96
+ const framework =
97
+ deps.next ? 'next'
98
+ : deps['@remix-run/react'] || deps['@react-router/dev'] ? 'remix'
99
+ : deps.vite && deps.react ? 'vite-react'
100
+ : deps.react ? 'react'
101
+ : 'unknown';
102
+
103
+ const componentsJson = readJSON(join(root, 'components.json'));
104
+ const uiDir = ['src/components/ui', 'components/ui', 'app/components/ui'].find((d) => existsSync(join(root, d)));
105
+ const isShadcn = Boolean(componentsJson) || Boolean(uiDir);
106
+
107
+ const knownLib = KNOWN_LIBRARIES.find((d) => deps[d.pkg]);
108
+ const homegrown = files.code.filter((f) => /(^|\/)components\//.test(f) && !/\/components\/ui\//.test(f) && /\.(tsx|jsx)$/.test(f));
109
+
110
+ let designSystem;
111
+ if (isShadcn) designSystem = { kind: 'shadcn', name: 'shadcn/ui', confidence: 'high' };
112
+ else if (knownLib) designSystem = { kind: 'library', name: knownLib.name, pkg: knownLib.pkg, confidence: 'high' };
113
+ else if (homegrown.length >= 3) designSystem = { kind: 'custom', name: 'custom (unrecognized)', confidence: 'low' };
114
+ else designSystem = { kind: 'none', confidence: 'high' };
115
+
116
+ // Import alias from tsconfig paths or components.json
117
+ let alias = null;
118
+ const tsconfig = readJSON(join(root, 'tsconfig.json'));
119
+ const paths = tsconfig?.compilerOptions?.paths;
120
+ if (paths) {
121
+ const key = Object.keys(paths).find((k) => k.endsWith('/*'));
122
+ if (key) alias = key.replace('/*', '');
123
+ }
124
+
125
+ const styling = STYLING_DEPS.filter((s) => deps[s.pkg]).map((s) => s.label);
126
+
127
+ // The git remote is a far better identity than package.json's name field
128
+ // ("chatbot" vs "vercel/ai-chatbot"). Parsed from .git/config, no git exec.
129
+ let remoteSlug = null;
130
+ try {
131
+ const gitConfig = readFileSync(join(root, '.git/config'), 'utf8');
132
+ const m = gitConfig.match(/url\s*=\s*(?:https?:\/\/|git@)([^\/:]+)[\/:]([^\s\/]+\/[^\s\/]+?)(?:\.git)?\s*$/m);
133
+ if (m && /github|gitlab|bitbucket|codeberg/.test(m[1])) remoteSlug = m[2];
134
+ } catch { /* not a clone, or no remote: fall back to pkg name */ }
135
+
136
+ return {
137
+ name: remoteSlug || pkg.name || null,
138
+ pkgName: pkg.name || null,
139
+ monorepo,
140
+ framework,
141
+ typescript: Boolean(deps.typescript || existsSync(join(root, 'tsconfig.json'))),
142
+ designSystem,
143
+ stylingDeps: styling,
144
+ importAlias: componentsJson?.aliases?.components?.split('/')[0] || alias || null,
145
+ uiDir: uiDir || null,
146
+ };
147
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Normalize font-family declarations to real typeface names. "16 font
3
+ * families" overstates when var(--font-cal), "Cal Sans" and inherit all
4
+ * reference the same face — the honest number is distinct typefaces.
5
+ */
6
+ export const GENERIC_FONTS = new Set(['sans-serif', 'serif', 'monospace', 'system-ui', 'ui-sans-serif',
7
+ 'ui-serif', 'ui-monospace', 'inherit', 'initial', 'unset', 'cursive', 'fantasy', '-apple-system']);
8
+
9
+ export function typefaceOf(decl) {
10
+ for (let part of decl.split(',')) {
11
+ part = part.trim().replace(/^["']|["']$/g, '');
12
+ if (!part || part.startsWith('var(') || GENERIC_FONTS.has(part.toLowerCase())) continue;
13
+ return part;
14
+ }
15
+ return null;
16
+ }
17
+
18
+ export const distinctTypefaces = (fontFamilies) =>
19
+ [...new Set(fontFamilies.map((f) => typefaceOf(f.value)).filter(Boolean))];