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,189 @@
1
+ /**
2
+ * Component harvest — every component defined in the repo, with its REAL prop
3
+ * signature and where it is actually used. Parsers lifted from 1.0's
4
+ * compile-skill.mjs (proffer-spine/tools) — brace/string-aware regex parsing,
5
+ * no AST dependency — then extended to scan the whole repo, not one ui/ dir.
6
+ */
7
+ import { readFileSync } from 'node:fs';
8
+ import { join, basename } from 'node:path';
9
+
10
+ const QUOTES = new Set(['"', "'", '`']);
11
+
12
+ // ---- brace/string-aware helpers (verbatim from 1.0) ----
13
+ export function sliceObject(text, from) {
14
+ const open = text.indexOf('{', from);
15
+ if (open === -1) return null;
16
+ let depth = 0, str = null;
17
+ for (let i = open; i < text.length; i++) {
18
+ const c = text[i];
19
+ if (str) { if (c === str && text[i - 1] !== '\\') str = null; continue; }
20
+ if (QUOTES.has(c)) { str = c; continue; }
21
+ if (c === '{') depth++;
22
+ else if (c === '}') { depth--; if (depth === 0) return { inner: text.slice(open + 1, i), end: i }; }
23
+ }
24
+ return null;
25
+ }
26
+ function topLevelKeys(inner) {
27
+ const keys = [];
28
+ let depth = 0, str = null, i = 0;
29
+ while (i < inner.length) {
30
+ const c = inner[i];
31
+ if (str) { if (c === str && inner[i - 1] !== '\\') str = null; i++; continue; }
32
+ if (QUOTES.has(c)) { str = c; i++; continue; }
33
+ if (c === '{' || c === '(' || c === '[') depth++;
34
+ else if (c === '}' || c === ')' || c === ']') depth--;
35
+ else if (depth === 0 && (i === 0 || /[\s,{]/.test(inner[i - 1]))) {
36
+ const m = /^([A-Za-z_][\w-]*)\s*:/.exec(inner.slice(i));
37
+ if (m) { keys.push(m[1]); i += m[0].length; continue; }
38
+ }
39
+ i++;
40
+ }
41
+ return [...new Set(keys)];
42
+ }
43
+ function topLevelColon(s) {
44
+ let depth = 0, str = null;
45
+ for (let i = 0; i < s.length; i++) {
46
+ const c = s[i];
47
+ if (str) { if (c === str && s[i - 1] !== '\\') str = null; continue; }
48
+ if (QUOTES.has(c)) { str = c; continue; }
49
+ if ('{<(['.includes(c)) depth++;
50
+ else if ('}>)]'.includes(c)) depth--;
51
+ else if (c === ':' && depth === 0) return i;
52
+ }
53
+ return -1;
54
+ }
55
+
56
+ // ---- extraction (from 1.0, generalized) ----
57
+ function extractVariants(src) {
58
+ const idx = src.indexOf('variants:');
59
+ if (idx === -1) return {};
60
+ const block = sliceObject(src, idx);
61
+ if (!block) return {};
62
+ const out = {};
63
+ for (const group of topLevelKeys(block.inner)) {
64
+ const gi = block.inner.indexOf(`${group}:`);
65
+ const gb = sliceObject(block.inner, gi);
66
+ if (gb) out[group] = topLevelKeys(gb.inner);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ function extractPropsHint(src, name) {
72
+ const sig = new RegExp(`function\\s+${name}\\s*\\(([\\s\\S]*?)\\)\\s*\\{`).exec(src)
73
+ || new RegExp(`const\\s+${name}\\s*=[\\s\\S]{0,120}?\\(\\s*([\\s\\S]*?)\\)\\s*=>`).exec(src);
74
+ if (!sig) return null;
75
+ const params = sig[1].trim();
76
+ const colon = topLevelColon(params);
77
+ const destructure = colon > -1 ? params.slice(0, colon) : params;
78
+ const typeAnno = (colon > -1 ? params.slice(colon + 1) : '').trim().replace(/\s+/g, ' ');
79
+ const skip = new Set(['className', 'children', 'props', 'ref', 'key',
80
+ 'true', 'false', 'null', 'undefined']); // default-value literals, not props
81
+ const named = [...new Set([...destructure.matchAll(/([A-Za-z_]\w*)\s*(?=[,}=:])/g)].map((m) => m[1]))]
82
+ .filter((n) => !skip.has(n));
83
+ return (typeAnno || named.length) ? { type: typeAnno || null, named } : null;
84
+ }
85
+
86
+ /** Capitalized components DEFINED in a source file (declared, not just re-exported). */
87
+ function definedComponents(src) {
88
+ const names = new Set();
89
+ for (const m of src.matchAll(/export\s+(?:default\s+)?(?:async\s+)?function\s+([A-Z]\w*)/g)) names.add(m[1]);
90
+ for (const m of src.matchAll(/export\s+const\s+([A-Z]\w*)\s*(?::[^=]+)?=\s*([^\s;]{1,40})/g)) {
91
+ // ALL_CAPS names are constants; a value starting with a literal
92
+ // ({...}, [...], "...", 42) is data, not a component
93
+ if (/^[A-Z][A-Z0-9_]+$/.test(m[1])) continue;
94
+ if (/^[\[{'"`0-9]/.test(m[2])) continue;
95
+ names.add(m[1]);
96
+ }
97
+ // Class components (CRA/redux-era): `class Login extends React.Component`
98
+ for (const m of src.matchAll(/class\s+([A-Z]\w*)\s+extends\s+(?:React\.)?(?:Pure)?Component/g)) names.add(m[1]);
99
+ // `export default Foo` / `export default connect(...)(Foo)` — the LAST
100
+ // capitalized identifier on the line is the component being exported.
101
+ for (const m of src.matchAll(/export\s+default\s+([^\n;]{1,200})/g)) {
102
+ const ids = m[1].match(/\b[A-Z]\w*/g);
103
+ const last = ids?.[ids.length - 1];
104
+ if (last && new RegExp(`(?:function|const|class)\\s+${last}\\b`).test(src)) names.add(last);
105
+ }
106
+ // `function Foo(...)` declared then exported via `export { Foo }`
107
+ const exported = new Set();
108
+ for (const m of src.matchAll(/export\s*\{([^}]*)\}/g)) {
109
+ for (const part of m[1].split(',')) {
110
+ const n = part.trim().split(/\s+as\s+/).pop().trim().replace(/^type\s+/, '');
111
+ if (/^[A-Z]\w*$/.test(n)) exported.add(n);
112
+ }
113
+ }
114
+ for (const n of exported) {
115
+ if (new RegExp(`(?:function|const)\\s+${n}\\b`).test(src)) names.add(n);
116
+ }
117
+ // Only keep names that render JSX or wrap a primitive (heuristic: file has JSX at all)
118
+ return [...names];
119
+ }
120
+
121
+ const looksLikeJSXFile = (src) => /<[A-Za-z][\w.]*[\s/>]/.test(src);
122
+
123
+ /** Count real `<Name` tag usages of `name` in `src` (tag boundary checked). */
124
+ function countUsages(src, name) {
125
+ let count = 0, from = 0, open;
126
+ while ((open = src.indexOf(`<${name}`, from)) !== -1) {
127
+ const after = src[open + name.length + 1];
128
+ if (!after || /[\s/>]/.test(after)) count++;
129
+ from = open + name.length + 1;
130
+ }
131
+ return count;
132
+ }
133
+
134
+ /**
135
+ * Harvest all components: definitions (with variants/props) + usages across the
136
+ * repo. `codeFiles` are relative paths; returns { components, totalDefined }.
137
+ */
138
+ export function harvestComponents(root, codeFiles) {
139
+ // .js too — CRA-era repos (a big slice of the messy-repo audience) put JSX in
140
+ // plain .js files; requiring .tsx/.jsx made whole repos read as empty.
141
+ const jsxFiles = codeFiles.filter((f) => /\.(tsx|jsx|js)$/.test(f));
142
+ const sources = new Map();
143
+ for (const f of jsxFiles) {
144
+ try {
145
+ const src = readFileSync(join(root, f), 'utf8');
146
+ if (looksLikeJSXFile(src)) sources.set(f, src);
147
+ } catch { /* skip */ }
148
+ }
149
+
150
+ // Pass 1: definitions
151
+ const components = [];
152
+ for (const [file, src] of sources) {
153
+ if (!looksLikeJSXFile(src)) continue;
154
+ const isPage = /(^|\/)(app\/.*page|pages\/(?!_app|_document|api))/.test(file)
155
+ || /(^|\/)app\/.*\/(layout|template|error|loading)\.(tsx|jsx)$/.test(file);
156
+ for (const name of definedComponents(src)) {
157
+ components.push({
158
+ name,
159
+ file,
160
+ isPage,
161
+ variants: extractVariants(src),
162
+ propsHint: extractPropsHint(src, name),
163
+ usageCount: 0,
164
+ usedIn: [],
165
+ });
166
+ }
167
+ }
168
+
169
+ // Pass 2: usages (skip the defining file's own render of itself is fine to count)
170
+ const byName = new Map();
171
+ for (const c of components) {
172
+ if (!byName.has(c.name)) byName.set(c.name, []);
173
+ byName.get(c.name).push(c);
174
+ }
175
+ for (const [file, src] of sources) {
176
+ for (const [name, defs] of byName) {
177
+ const n = countUsages(src, name);
178
+ if (n === 0) continue;
179
+ for (const def of defs) {
180
+ if (def.file === file) continue; // internal render/recursion, not adoption
181
+ def.usageCount += n;
182
+ if (def.usedIn.length < 8) def.usedIn.push(file);
183
+ }
184
+ }
185
+ }
186
+
187
+ components.sort((a, b) => b.usageCount - a.usageCount || a.name.localeCompare(b.name));
188
+ return { components, totalDefined: components.length };
189
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Context-file harvest — the agent-facing and design-facing docs the repo
3
+ * already has. Step 5 (wire up their agent) needs to know what exists so it
4
+ * extends rather than clobbers; the diagnosis mentions what's missing.
5
+ */
6
+ import { existsSync, readFileSync, statSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ const CONTEXT_FILES = [
10
+ { file: 'CLAUDE.md', kind: 'agent-rules', tool: 'Claude Code' },
11
+ { file: 'AGENTS.md', kind: 'agent-rules', tool: 'generic (AGENTS.md standard)' },
12
+ { file: '.cursorrules', kind: 'agent-rules', tool: 'Cursor (legacy format)' },
13
+ { file: '.cursor/rules', kind: 'agent-rules', tool: 'Cursor', dir: true },
14
+ { file: '.github/copilot-instructions.md', kind: 'agent-rules', tool: 'GitHub Copilot' },
15
+ { file: '.windsurfrules', kind: 'agent-rules', tool: 'Windsurf' },
16
+ { file: 'components.json', kind: 'design-config', tool: 'shadcn/ui' },
17
+ { file: 'tailwind.config.js', kind: 'design-config', tool: 'Tailwind' },
18
+ { file: 'tailwind.config.ts', kind: 'design-config', tool: 'Tailwind' },
19
+ { file: 'design-system.md', kind: 'design-doc' },
20
+ { file: 'DESIGN.md', kind: 'design-doc' },
21
+ { file: 'STYLEGUIDE.md', kind: 'design-doc' },
22
+ ];
23
+
24
+ export function harvestContext(root) {
25
+ const found = [];
26
+ for (const c of CONTEXT_FILES) {
27
+ const p = join(root, c.file);
28
+ if (!existsSync(p)) continue;
29
+ let size = 0, mentionsDesign = false;
30
+ try {
31
+ const st = statSync(p);
32
+ if (c.dir || st.isDirectory()) { found.push({ ...c, size: null, mentionsDesign: null }); continue; }
33
+ size = st.size;
34
+ if (size < 200_000) {
35
+ const text = readFileSync(p, 'utf8').toLowerCase();
36
+ mentionsDesign = /design system|component|token|color|colour|styling|tailwind|css/.test(text);
37
+ }
38
+ } catch { /* unreadable */ }
39
+ found.push({ file: c.file, kind: c.kind, tool: c.tool ?? null, size, mentionsDesign });
40
+ }
41
+ return found;
42
+ }
@@ -0,0 +1,153 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Duplicate detection — the "4 Button implementations" finder. Two signals:
6
+ *
7
+ * 1. Same-name components defined in different files (Button in ui/button.tsx
8
+ * AND legacy/Button.jsx) — a straight duplicate.
9
+ * 2. Name families: components whose normalized names contain a shared root
10
+ * (Button, PrimaryButton, IconBtn) — likely re-implementations of one idea.
11
+ *
12
+ * Both are heuristics for the diagnosis; the proposal step decides what merges.
13
+ */
14
+
15
+ const norm = (name) => name.toLowerCase().replace(/[^a-z]/g, '');
16
+
17
+ // Common roots worth clustering on. Order matters: longest match wins.
18
+ const ROOTS = [
19
+ 'button', 'btn', 'input', 'textfield', 'select', 'dropdown', 'modal', 'dialog',
20
+ 'card', 'badge', 'chip', 'tag', 'tooltip', 'popover', 'table', 'list', 'menu',
21
+ 'nav', 'header', 'footer', 'sidebar', 'tabs', 'tab', 'accordion', 'alert',
22
+ 'toast', 'notification', 'avatar', 'spinner', 'loader', 'checkbox',
23
+ 'radio', 'switch', 'toggle', 'slider', 'progress', 'form', 'label', 'link',
24
+ ];
25
+ // Roots that are aliases of each other (btn → button, dialog ↔ modal…)
26
+ // NB: 'icon' is deliberately NOT a root — an icon set is a glyph library
27
+ // (Excalidraw has 100+), not duplication to consolidate.
28
+ const ALIASES = { btn: 'button', dialog: 'modal', loader: 'spinner', tab: 'tabs', notification: 'toast' };
29
+
30
+ // CamelCase words of a component name: "DownlinkPanel" → ["downlink","panel"].
31
+ // A root must match a WHOLE word — "link" must not match inside "Downlink".
32
+ const camelWords = (name) => name.split(/(?=[A-Z])/).map((w) => w.toLowerCase()).filter(Boolean);
33
+
34
+ function rootOf(name) {
35
+ const words = camelWords(name);
36
+ for (const r of ROOTS) {
37
+ if (words.includes(r)) return ALIASES[r] ?? r;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ // Framework-boilerplate exports that recur by design, not by accident.
43
+ const NOT_DUPLICATES = new Set(['Route', 'Layout', 'App', 'Providers', 'Provider',
44
+ // Next.js route-handler exports — HTTP verbs, never components.
45
+ 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
46
+
47
+ // Route-colocated page fragments (app/**/form.tsx, header.tsx…) share names by
48
+ // Next.js convention, not by duplication.
49
+ const ROUTE_FRAGMENT_RE = /(^|\/)app\/.*\/(form|header|footer|nav|page|layout|loading|error|route)\.[jt]sx?$/;
50
+
51
+ /**
52
+ * @param components output of harvestComponents (non-page components matter most)
53
+ * @param uiDir the design-system dir if one exists (e.g. "src/components/ui")
54
+ * @param root repo root; when given, same-name pairs where one file imports the
55
+ * name from the other are tagged wrapped:true (composition, not competition)
56
+ * @returns { exactDuplicates: [{name, files, wrapped?}], families: [{root, members: [{name, file, usageCount}]}] }
57
+ */
58
+ export function findDuplicates(components, uiDir = null, root = null) {
59
+ // Email templates legitimately mirror web component names (email Footer !=
60
+ // web Footer); cross-matching them manufactures duplicates.
61
+ const EMAIL_PATH_RE = /(^|\/)emails?(\/|-)/i;
62
+ const comps = components.filter((c) => !c.isPage && !NOT_DUPLICATES.has(c.name)
63
+ && !ROUTE_FRAGMENT_RE.test(c.file) && !EMAIL_PATH_RE.test(c.file));
64
+
65
+ // 1. exact same component name defined in >1 file
66
+ const byName = new Map();
67
+ for (const c of comps) {
68
+ if (!byName.has(c.name)) byName.set(c.name, []);
69
+ byName.get(c.name).push(c);
70
+ }
71
+ let exactDuplicates = [...byName.entries()]
72
+ .filter(([, defs]) => new Set(defs.map((d) => d.file)).size > 1)
73
+ .map(([name, defs]) => ({
74
+ name,
75
+ files: [...new Set(defs.map((d) => d.file))],
76
+ totalUsages: defs.reduce((s, d) => s + d.usageCount, 0),
77
+ }))
78
+ .sort((a, b) => b.files.length - a.files.length);
79
+
80
+ // Same-name components living in different icons/ directories are ONE
81
+ // problem — two icon libraries colliding — not N separate duplicates.
82
+ const ICON_DIR_RE = /(^|\/)icons?\//i;
83
+ const iconCollisions = exactDuplicates.filter((d) => d.files.every((f) => ICON_DIR_RE.test(f)));
84
+ exactDuplicates = exactDuplicates.filter((d) => !iconCollisions.includes(d));
85
+
86
+ // A "duplicate" where one file imports the same identifier from the other is
87
+ // a wrapper/composition (memoized variant, styled passthrough) — still an
88
+ // agent trap worth listing, but not a competing implementation.
89
+ const perFile = new Map();
90
+ for (const c of comps) perFile.set(c.file, (perFile.get(c.file) ?? 0) + 1);
91
+ if (root) {
92
+ for (const d of exactDuplicates) {
93
+ if (d.files.length !== 2) continue;
94
+ // A file defining 8+ components is an API surface (menu-item variants of
95
+ // real components elsewhere) — same-name there is deliberate, not a copy.
96
+ if (d.files.some((f) => (perFile.get(f) ?? 0) >= 8)) { d.wrapped = true; continue; }
97
+ const base = (f) => f.replace(/\.[jt]sx?$/, '').split('/').pop();
98
+ // Wrapper evidence must be strong: the same name imported ALIASED
99
+ // (`EmptyState as EmptyStateBlock`) — a bare same-name import next to a
100
+ // competing local definition would be a redeclaration error anyway, and
101
+ // a loose match here would excuse real duplicates.
102
+ const importsAliased = (fromFile, name, otherBase) => {
103
+ let src; try { src = readFileSync(join(root, fromFile), 'utf8'); } catch { return false; }
104
+ return new RegExp(`import\\s*(?:type\\s*)?{[^}]*\\b${name}\\s+as\\s+\\w+[^}]*}\\s*from`).test(src)
105
+ || new RegExp(`import\\s*(?:type\\s*)?{[^}]*\\b${name}\\b[^}]*}\\s*from\\s*['"][^'"]*${otherBase}['"]`).test(src);
106
+ };
107
+ const [a, b] = d.files;
108
+ if (importsAliased(a, d.name, base(b)) || importsAliased(b, d.name, base(a))) d.wrapped = true;
109
+ }
110
+ }
111
+
112
+ // 2. name families around a shared root (only interesting with >1 distinct component)
113
+ const byRoot = new Map();
114
+ for (const c of comps) {
115
+ const r = rootOf(c.name);
116
+ if (!r) continue;
117
+ if (!byRoot.has(r)) byRoot.set(r, []);
118
+ byRoot.get(r).push(c);
119
+ }
120
+ const families = [...byRoot.entries()]
121
+ .map(([root, members]) => {
122
+ const uniq = new Map();
123
+ for (const m of members) uniq.set(`${m.name}::${m.file}`, m);
124
+ return { root, members: [...uniq.values()].map((m) => ({ name: m.name, file: m.file, usageCount: m.usageCount })) };
125
+ })
126
+ // A compound component (DialogHeader/DialogTitle/… in ONE file) is good
127
+ // structure, not duplication — require multiple files. And when the repo
128
+ // HAS a ui/ dir, sibling primitives inside it (ContextMenu vs Menubar) are
129
+ // the library, not duplication: only flag families that CROSS the boundary
130
+ // (a re-implementation outside echoing a root that exists inside).
131
+ .filter((f) => {
132
+ if (new Set(f.members.map((m) => m.name)).size < 2) return false;
133
+ if (new Set(f.members.map((m) => m.file)).size < 2) return false;
134
+ if (!uiDir) return true;
135
+ const inside = f.members.filter((m) => m.file.startsWith(uiDir));
136
+ const outside = f.members.filter((m) => !m.file.startsWith(uiDir));
137
+ if (inside.length === 0 || outside.length === 0) return false;
138
+ // The inside anchor must BE the root primitive (Card, Form) — a compound
139
+ // part that merely contains the word (BreadcrumbList) is not a List
140
+ // primitive, and outside echoes of it are not duplication.
141
+ const canonical = inside.filter((m) => norm(m.name) === f.root
142
+ || Object.entries(ALIASES).some(([a, r]) => r === f.root && norm(m.name) === a));
143
+ if (canonical.length === 0) return false;
144
+ f.members = [...canonical, ...outside];
145
+ return true;
146
+ })
147
+ // Past ~8 members a "family" is a generic word (Input matching 37
148
+ // components), not a consolidation target — noise that costs credibility.
149
+ .filter((f) => f.members.length <= 8)
150
+ .sort((a, b) => b.members.length - a.members.length);
151
+
152
+ return { exactDuplicates, iconCollisions, families };
153
+ }
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Harvest — step 1 of the Proffer pipeline. Non-destructive scan of a repo:
4
+ * components (real props, real usages), all styling, implicit tokens with
5
+ * frequency counts, duplicates, context files. Writes harvest.json; prints a
6
+ * one-screen summary (the seed of the step-2 diagnosis).
7
+ *
8
+ * node src/harvest/index.mjs <repo-path> [--out harvest.json]
9
+ */
10
+ import { writeFileSync } from 'node:fs';
11
+ import { resolve } from 'node:path';
12
+ import { walkRepo, profileRepo } from './walk.mjs';
13
+ import { harvestComponents } from './components.mjs';
14
+ import { harvestTokens } from './tokens.mjs';
15
+ import { findDuplicates } from './duplicates.mjs';
16
+ import { harvestContext } from './context.mjs';
17
+
18
+ function arg(name, fallback) {
19
+ const i = process.argv.indexOf(`--${name}`);
20
+ return i > -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
21
+ }
22
+
23
+ const target = process.argv[2] && !process.argv[2].startsWith('--') ? resolve(process.argv[2]) : null;
24
+ if (!target) {
25
+ console.error('Usage: node src/harvest/index.mjs <repo-path> [--out harvest.json]');
26
+ process.exit(1);
27
+ }
28
+ const outPath = resolve(arg('out', 'harvest.json'));
29
+
30
+ const t0 = Date.now();
31
+ const files = walkRepo(target);
32
+ const profile = profileRepo(target, files);
33
+ const { components } = harvestComponents(target, files.code);
34
+ const tokens = harvestTokens(target, files.styles, files.code);
35
+ const duplicates = findDuplicates(components, profile.uiDir, target);
36
+ const context = harvestContext(target);
37
+
38
+ const harvest = {
39
+ repo: target,
40
+ harvestedAt: new Date().toISOString(),
41
+ tookMs: null, // set below
42
+ profile,
43
+ files: {
44
+ code: files.code.length,
45
+ styles: files.styles.length,
46
+ styleFiles: files.styles,
47
+ },
48
+ components,
49
+ tokens,
50
+ duplicates,
51
+ context,
52
+ };
53
+ harvest.tookMs = Date.now() - t0;
54
+
55
+ writeFileSync(outPath, JSON.stringify(harvest, null, 2));
56
+
57
+ // ---------- one-screen summary ----------
58
+ const nonPage = components.filter((c) => !c.isPage);
59
+ const hexColors = tokens.colors.filter((c) => c.value.startsWith('#'));
60
+ const top = (list, n = 5) => list.slice(0, n).map((e) => `${e.value} ×${e.count}`).join(', ');
61
+
62
+ console.log(`\nHarvest: ${profile.name ?? target}`);
63
+ console.log(` framework: ${profile.framework}${profile.typescript ? ' + TS' : ''} design system: ${profile.designSystem.kind}${profile.designSystem.name ? ` (${profile.designSystem.name})` : ''} styling: ${profile.stylingDeps.join(', ') || 'none detected'}`);
64
+ console.log(` files: ${files.code.length} code, ${files.styles.length} style`);
65
+ console.log(`\n components: ${components.length} defined (${nonPage.length} reusable, ${components.length - nonPage.length} pages)`);
66
+ console.log(` duplicates: ${duplicates.exactDuplicates.length} exact same-name, ${duplicates.families.length} name families`);
67
+ for (const d of duplicates.exactDuplicates.slice(0, 3)) console.log(` · ${d.name} defined in ${d.files.length} files`);
68
+ for (const f of duplicates.families.slice(0, 3)) console.log(` · ${f.root} family: ${f.members.map((m) => m.name).join(', ')}`);
69
+ console.log(`\n colours: ${tokens.colors.length} distinct (${hexColors.length} hex, of which ${tokens.greyCount} greys) top: ${top(tokens.colors, 4)}`);
70
+ console.log(` spacing: ${tokens.spacing.length} distinct CSS values top: ${top(tokens.spacing, 5)}`);
71
+ console.log(` radii: ${tokens.radii.length} font sizes: ${tokens.fontSizes.length} font families: ${tokens.fontFamilies.length} shadows: ${tokens.shadows.length}`);
72
+ console.log(` tailwind: ${tokens.tailwind.colors.length} colour utils, ${tokens.tailwind.spacing.length} spacing utils, ${tokens.tailwind.textSizes.length} text sizes`);
73
+ console.log(` inline styles: ${tokens.inlineStyles.count} blocks`);
74
+ console.log(` context files: ${context.map((c) => c.file).join(', ') || 'none'}`);
75
+ console.log(`\n → ${outPath} (${harvest.tookMs}ms)\n`);