whale-igniter 1.1.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/LICENSE +21 -0
- package/README.md +275 -0
- package/dist/analyzer/imports.js +88 -0
- package/dist/analyzer/insights.js +276 -0
- package/dist/commands/add.js +36 -0
- package/dist/commands/adopt.js +180 -0
- package/dist/commands/adoptReview.js +267 -0
- package/dist/commands/component.js +93 -0
- package/dist/commands/createComponent.js +207 -0
- package/dist/commands/decision.js +98 -0
- package/dist/commands/docs.js +34 -0
- package/dist/commands/ignite.js +212 -0
- package/dist/commands/init.js +66 -0
- package/dist/commands/insights.js +123 -0
- package/dist/commands/mcp.js +106 -0
- package/dist/commands/refine.js +36 -0
- package/dist/commands/selene.js +516 -0
- package/dist/commands/sync.js +43 -0
- package/dist/commands/validate.js +48 -0
- package/dist/commands/watch.js +150 -0
- package/dist/commands/wiki.js +21 -0
- package/dist/generators/markdownGenerator.js +112 -0
- package/dist/generators/reportGenerator.js +50 -0
- package/dist/generators/wikiGenerator.js +365 -0
- package/dist/index.js +213 -0
- package/dist/mcp/server.js +404 -0
- package/dist/scanner/componentScanner.js +522 -0
- package/dist/scanner/foundationInferrer.js +174 -0
- package/dist/scanner/tailwindMapper.js +58 -0
- package/dist/scanner/tailwindScanner.js +186 -0
- package/dist/selene/apiClient.js +168 -0
- package/dist/selene/cache.js +68 -0
- package/dist/selene/clipboard.js +56 -0
- package/dist/selene/promptBuilder.js +229 -0
- package/dist/selene/providers.js +67 -0
- package/dist/selene/responseParser.js +149 -0
- package/dist/ui/atoms.js +30 -0
- package/dist/ui/blocks.js +208 -0
- package/dist/ui/capabilities.js +64 -0
- package/dist/ui/index.js +13 -0
- package/dist/ui/symbols.js +41 -0
- package/dist/ui/theme.js +78 -0
- package/dist/utils/components.js +40 -0
- package/dist/utils/config.js +31 -0
- package/dist/utils/decisions.js +32 -0
- package/dist/utils/paths.js +4 -0
- package/dist/utils/proposals.js +61 -0
- package/dist/utils/refinements.js +81 -0
- package/dist/utils/registry.js +45 -0
- package/dist/utils/writeJson.js +6 -0
- package/dist/validators/cssValidator.js +204 -0
- package/dist/version.js +1 -0
- package/docs/ROADMAP.md +206 -0
- package/package.json +76 -0
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { glob } from "glob";
|
|
4
|
+
import { parse } from "@babel/parser";
|
|
5
|
+
import * as t from "@babel/types";
|
|
6
|
+
const DEFAULT_GLOB = "src/**/*.{tsx,jsx}";
|
|
7
|
+
const DEFAULT_IGNORE = [
|
|
8
|
+
"**/node_modules/**",
|
|
9
|
+
"**/dist/**",
|
|
10
|
+
"**/build/**",
|
|
11
|
+
"**/.next/**",
|
|
12
|
+
"**/coverage/**",
|
|
13
|
+
"**/*.test.{tsx,jsx}",
|
|
14
|
+
"**/*.spec.{tsx,jsx}",
|
|
15
|
+
"**/*.stories.{tsx,jsx}"
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* Heuristics used to decide whether a `PascalCase` identifier is a component:
|
|
19
|
+
*
|
|
20
|
+
* - Must contain JSX in its body (we walk the function/class body and look
|
|
21
|
+
* for any JSXElement). This is the strongest signal and rules out hooks
|
|
22
|
+
* like `useFoo` (lowercase anyway) and utility classes like `EventBus`.
|
|
23
|
+
* - Filename pattern matters: files in `/hooks/`, `/utils/`, `/lib/`,
|
|
24
|
+
* `/context/` are deprioritised even if they have PascalCase exports.
|
|
25
|
+
* - Identifier name must start with uppercase ASCII letter (React rule).
|
|
26
|
+
*
|
|
27
|
+
* Returning early via `false` keeps the scanner conservative; the v0.8
|
|
28
|
+
* done criterion explicitly says zero false positives in fixtures.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Identifiers that look like components but are almost always project
|
|
32
|
+
* entry points, not reusable components. We exclude them from the
|
|
33
|
+
* catalog by default. Users who really want them can add manually
|
|
34
|
+
* with `whale component add`.
|
|
35
|
+
*/
|
|
36
|
+
const ENTRY_POINT_NAMES = new Set([
|
|
37
|
+
"App", "Root", "Main", "Index", "Layout", "Page", "Document", "RootLayout",
|
|
38
|
+
"ErrorBoundary", "Providers", "Provider"
|
|
39
|
+
]);
|
|
40
|
+
function isLikelyComponentName(name) {
|
|
41
|
+
if (!/^[A-Z][A-Za-z0-9]*$/.test(name))
|
|
42
|
+
return false;
|
|
43
|
+
if (ENTRY_POINT_NAMES.has(name))
|
|
44
|
+
return false;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
function fileLooksLikeUtility(file) {
|
|
48
|
+
const normalized = file.replace(/\\/g, "/").toLowerCase();
|
|
49
|
+
return (/\/hooks?\//.test(normalized) ||
|
|
50
|
+
/\/utils?\//.test(normalized) ||
|
|
51
|
+
/\/lib\//.test(normalized) ||
|
|
52
|
+
/\/context\//.test(normalized) ||
|
|
53
|
+
/\/contexts\//.test(normalized) ||
|
|
54
|
+
/\/types?\//.test(normalized) ||
|
|
55
|
+
/\.types\.(t|j)sx?$/.test(normalized));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Walks an arbitrary AST node and returns true if it contains JSX. We use
|
|
59
|
+
* this instead of `path.traverse` because we sometimes need to inspect
|
|
60
|
+
* standalone expressions (default export of an arrow, for example).
|
|
61
|
+
*/
|
|
62
|
+
function containsJsx(node) {
|
|
63
|
+
if (!node)
|
|
64
|
+
return false;
|
|
65
|
+
// Manual stack walk. @babel/traverse needs a File/NodePath context that
|
|
66
|
+
// we don't always have for standalone subtrees (e.g. an arrow body),
|
|
67
|
+
// so we walk children ourselves. This is fast enough for v0.8 — each
|
|
68
|
+
// component body is small.
|
|
69
|
+
const stack = [node];
|
|
70
|
+
while (stack.length) {
|
|
71
|
+
const cur = stack.pop();
|
|
72
|
+
if (t.isJSXElement(cur) || t.isJSXFragment(cur))
|
|
73
|
+
return true;
|
|
74
|
+
for (const key of Object.keys(cur)) {
|
|
75
|
+
const child = cur[key];
|
|
76
|
+
if (!child)
|
|
77
|
+
continue;
|
|
78
|
+
if (Array.isArray(child)) {
|
|
79
|
+
for (const c of child)
|
|
80
|
+
if (c && typeof c.type === "string")
|
|
81
|
+
stack.push(c);
|
|
82
|
+
}
|
|
83
|
+
else if (typeof child.type === "string") {
|
|
84
|
+
stack.push(child);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Heuristic: does a string look like a Tailwind class list?
|
|
92
|
+
*
|
|
93
|
+
* Used to opportunistically capture classes that live in `const base = "..."`
|
|
94
|
+
* variables outside the JSX. We can't statically resolve template literals
|
|
95
|
+
* like `${base} ${variants[v]}` without scope analysis, so we accept some
|
|
96
|
+
* over-capture in exchange for not silently dropping all the styling.
|
|
97
|
+
*
|
|
98
|
+
* A string qualifies if it has at least two whitespace-separated tokens
|
|
99
|
+
* AND at least one of them matches a known Tailwind shape (prefix-suffix,
|
|
100
|
+
* arbitrary value, or variant:prefix-suffix).
|
|
101
|
+
*/
|
|
102
|
+
function looksLikeClassList(s) {
|
|
103
|
+
if (!s || s.length < 4)
|
|
104
|
+
return false;
|
|
105
|
+
const tokens = s.trim().split(/\s+/);
|
|
106
|
+
if (tokens.length < 2)
|
|
107
|
+
return false;
|
|
108
|
+
const tailwindish = /^(?:[a-z]+:)*-?[a-z]+(?:-[a-z0-9]+)*(?:\[[^\]]+\])?(?:\/\d+)?$/i;
|
|
109
|
+
let matched = 0;
|
|
110
|
+
for (const tok of tokens) {
|
|
111
|
+
if (tailwindish.test(tok))
|
|
112
|
+
matched += 1;
|
|
113
|
+
if (matched >= 2)
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Pull every literal class string out of a component subtree. We currently
|
|
120
|
+
* support:
|
|
121
|
+
* - `className="..."` (StringLiteral)
|
|
122
|
+
* - `className={"..."}` (StringLiteral inside JSXExpressionContainer)
|
|
123
|
+
* - `className={`...`}` (template literal — static fragments)
|
|
124
|
+
* - `const x = "..."` (any string in the body that looks like a class list)
|
|
125
|
+
*
|
|
126
|
+
* The last one is a heuristic — see `looksLikeClassList`. Without it,
|
|
127
|
+
* components that compose classes via local variables (a very common
|
|
128
|
+
* Tailwind pattern) would have most of their styling invisible to us.
|
|
129
|
+
*/
|
|
130
|
+
function extractClassNamesFromComponent(node) {
|
|
131
|
+
const out = [];
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
const push = (s) => {
|
|
134
|
+
const v = s.trim();
|
|
135
|
+
if (!v || seen.has(v))
|
|
136
|
+
return;
|
|
137
|
+
seen.add(v);
|
|
138
|
+
out.push(v);
|
|
139
|
+
};
|
|
140
|
+
const stack = [node];
|
|
141
|
+
while (stack.length) {
|
|
142
|
+
const cur = stack.pop();
|
|
143
|
+
// Direct className attributes — high confidence.
|
|
144
|
+
if (t.isJSXAttribute(cur) && t.isJSXIdentifier(cur.name) && cur.name.name === "className") {
|
|
145
|
+
const value = cur.value;
|
|
146
|
+
if (value && t.isStringLiteral(value)) {
|
|
147
|
+
push(value.value);
|
|
148
|
+
}
|
|
149
|
+
else if (value && t.isJSXExpressionContainer(value)) {
|
|
150
|
+
const expr = value.expression;
|
|
151
|
+
if (t.isStringLiteral(expr)) {
|
|
152
|
+
push(expr.value);
|
|
153
|
+
}
|
|
154
|
+
else if (t.isTemplateLiteral(expr)) {
|
|
155
|
+
// Static fragments only. We rely on the body-level string sweep
|
|
156
|
+
// below to capture the interpolated variables.
|
|
157
|
+
for (const q of expr.quasis)
|
|
158
|
+
push(q.value.cooked ?? q.value.raw);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Body-level string literals that look like class lists.
|
|
163
|
+
if (t.isStringLiteral(cur) && looksLikeClassList(cur.value)) {
|
|
164
|
+
push(cur.value);
|
|
165
|
+
}
|
|
166
|
+
for (const key of Object.keys(cur)) {
|
|
167
|
+
const child = cur[key];
|
|
168
|
+
if (!child)
|
|
169
|
+
continue;
|
|
170
|
+
if (Array.isArray(child)) {
|
|
171
|
+
for (const c of child)
|
|
172
|
+
if (c && typeof c.type === "string")
|
|
173
|
+
stack.push(c);
|
|
174
|
+
}
|
|
175
|
+
else if (typeof child.type === "string") {
|
|
176
|
+
stack.push(child);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
function extractJsxIdentifiers(node) {
|
|
183
|
+
const seen = new Set();
|
|
184
|
+
const stack = [node];
|
|
185
|
+
while (stack.length) {
|
|
186
|
+
const cur = stack.pop();
|
|
187
|
+
if (t.isJSXOpeningElement(cur)) {
|
|
188
|
+
const name = cur.name;
|
|
189
|
+
if (t.isJSXIdentifier(name) && /^[A-Z]/.test(name.name)) {
|
|
190
|
+
seen.add(name.name);
|
|
191
|
+
}
|
|
192
|
+
else if (t.isJSXMemberExpression(name)) {
|
|
193
|
+
// e.g. <Form.Field> — record the root identifier
|
|
194
|
+
let head = name;
|
|
195
|
+
while (t.isJSXMemberExpression(head))
|
|
196
|
+
head = head.object;
|
|
197
|
+
if (t.isJSXIdentifier(head) && /^[A-Z]/.test(head.name))
|
|
198
|
+
seen.add(head.name);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const key of Object.keys(cur)) {
|
|
202
|
+
const child = cur[key];
|
|
203
|
+
if (!child)
|
|
204
|
+
continue;
|
|
205
|
+
if (Array.isArray(child)) {
|
|
206
|
+
for (const c of child)
|
|
207
|
+
if (c && typeof c.type === "string")
|
|
208
|
+
stack.push(c);
|
|
209
|
+
}
|
|
210
|
+
else if (typeof child.type === "string") {
|
|
211
|
+
stack.push(child);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return Array.from(seen);
|
|
216
|
+
}
|
|
217
|
+
async function scanFile(absPath, relPath) {
|
|
218
|
+
let source;
|
|
219
|
+
try {
|
|
220
|
+
source = await fs.readFile(absPath, "utf8");
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
let ast;
|
|
226
|
+
try {
|
|
227
|
+
ast = parse(source, {
|
|
228
|
+
sourceType: "module",
|
|
229
|
+
plugins: ["typescript", "jsx", "decorators-legacy"],
|
|
230
|
+
errorRecovery: true
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// Skip files we can't parse — common with experimental syntax or non-standard plugins.
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
const found = [];
|
|
238
|
+
const isUtilityFile = fileLooksLikeUtility(relPath);
|
|
239
|
+
const candidates = [];
|
|
240
|
+
const bindings = new Map();
|
|
241
|
+
const recordBinding = (name, init) => {
|
|
242
|
+
if (t.isArrowFunctionExpression(init) || t.isFunctionExpression(init)) {
|
|
243
|
+
bindings.set(name, { name, declarationKind: "arrow", bodyNode: init.body });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (t.isCallExpression(init)) {
|
|
247
|
+
const callee = init.callee;
|
|
248
|
+
const calleeName = (t.isIdentifier(callee) && callee.name) ||
|
|
249
|
+
(t.isMemberExpression(callee) && t.isIdentifier(callee.property) && callee.property.name) ||
|
|
250
|
+
null;
|
|
251
|
+
if (calleeName === "forwardRef" || calleeName === "memo") {
|
|
252
|
+
const inner = init.arguments[0];
|
|
253
|
+
if (inner && (t.isArrowFunctionExpression(inner) || t.isFunctionExpression(inner))) {
|
|
254
|
+
bindings.set(name, {
|
|
255
|
+
name,
|
|
256
|
+
declarationKind: calleeName === "forwardRef" ? "forwardRef" : "memo",
|
|
257
|
+
bodyNode: inner.body
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
for (const node of ast.program.body) {
|
|
264
|
+
if (t.isVariableDeclaration(node)) {
|
|
265
|
+
for (const d of node.declarations) {
|
|
266
|
+
if (t.isIdentifier(d.id) && d.init)
|
|
267
|
+
recordBinding(d.id.name, d.init);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else if (t.isFunctionDeclaration(node) && node.id) {
|
|
271
|
+
bindings.set(node.id.name, { name: node.id.name, declarationKind: "function", bodyNode: node.body });
|
|
272
|
+
}
|
|
273
|
+
else if (t.isClassDeclaration(node) && node.id) {
|
|
274
|
+
bindings.set(node.id.name, { name: node.id.name, declarationKind: "class", bodyNode: node.body });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const node of ast.program.body) {
|
|
278
|
+
// export default function Foo() {...}
|
|
279
|
+
// export default () => ...
|
|
280
|
+
// export default Foo <- new: resolves via bindings
|
|
281
|
+
if (t.isExportDefaultDeclaration(node)) {
|
|
282
|
+
const decl = node.declaration;
|
|
283
|
+
if (t.isFunctionDeclaration(decl) && decl.id) {
|
|
284
|
+
candidates.push({
|
|
285
|
+
name: decl.id.name,
|
|
286
|
+
declarationKind: "function",
|
|
287
|
+
bodyNode: decl.body,
|
|
288
|
+
isExported: true,
|
|
289
|
+
isDefault: true
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
else if (t.isArrowFunctionExpression(decl) || t.isFunctionExpression(decl)) {
|
|
293
|
+
// Anonymous default — use the file's basename as the name.
|
|
294
|
+
const base = path.basename(relPath).replace(/\.[jt]sx$/, "");
|
|
295
|
+
if (isLikelyComponentName(base)) {
|
|
296
|
+
candidates.push({
|
|
297
|
+
name: base,
|
|
298
|
+
declarationKind: "arrow",
|
|
299
|
+
bodyNode: decl.body,
|
|
300
|
+
isExported: true,
|
|
301
|
+
isDefault: true
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
else if (t.isClassDeclaration(decl) && decl.id) {
|
|
306
|
+
candidates.push({
|
|
307
|
+
name: decl.id.name,
|
|
308
|
+
declarationKind: "class",
|
|
309
|
+
bodyNode: decl.body,
|
|
310
|
+
isExported: true,
|
|
311
|
+
isDefault: true
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
else if (t.isIdentifier(decl)) {
|
|
315
|
+
// `export default Foo` — resolve via earlier bindings.
|
|
316
|
+
const binding = bindings.get(decl.name);
|
|
317
|
+
if (binding) {
|
|
318
|
+
candidates.push({
|
|
319
|
+
name: binding.name,
|
|
320
|
+
declarationKind: binding.declarationKind,
|
|
321
|
+
bodyNode: binding.bodyNode,
|
|
322
|
+
isExported: true,
|
|
323
|
+
isDefault: true
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
else if (t.isCallExpression(decl)) {
|
|
328
|
+
// export default forwardRef(...) / memo(...)
|
|
329
|
+
const callee = decl.callee;
|
|
330
|
+
const calleeName = (t.isIdentifier(callee) && callee.name) ||
|
|
331
|
+
(t.isMemberExpression(callee) && t.isIdentifier(callee.property) && callee.property.name) ||
|
|
332
|
+
null;
|
|
333
|
+
if (calleeName === "forwardRef" || calleeName === "memo") {
|
|
334
|
+
const inner = decl.arguments[0];
|
|
335
|
+
if (inner &&
|
|
336
|
+
(t.isArrowFunctionExpression(inner) || t.isFunctionExpression(inner)) &&
|
|
337
|
+
inner.body) {
|
|
338
|
+
const base = path.basename(relPath).replace(/\.[jt]sx$/, "");
|
|
339
|
+
if (isLikelyComponentName(base)) {
|
|
340
|
+
candidates.push({
|
|
341
|
+
name: base,
|
|
342
|
+
declarationKind: calleeName === "forwardRef" ? "forwardRef" : "memo",
|
|
343
|
+
bodyNode: inner.body,
|
|
344
|
+
isExported: true,
|
|
345
|
+
isDefault: true
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// export function Foo() {...}
|
|
353
|
+
// export const Foo = () => {...}
|
|
354
|
+
// export const Foo = forwardRef(...) / memo(...)
|
|
355
|
+
if (t.isExportNamedDeclaration(node) && node.declaration) {
|
|
356
|
+
const decl = node.declaration;
|
|
357
|
+
if (t.isFunctionDeclaration(decl) && decl.id) {
|
|
358
|
+
candidates.push({
|
|
359
|
+
name: decl.id.name,
|
|
360
|
+
declarationKind: "function",
|
|
361
|
+
bodyNode: decl.body,
|
|
362
|
+
isExported: true,
|
|
363
|
+
isDefault: false
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
else if (t.isVariableDeclaration(decl)) {
|
|
367
|
+
for (const d of decl.declarations) {
|
|
368
|
+
if (!t.isIdentifier(d.id))
|
|
369
|
+
continue;
|
|
370
|
+
const init = d.init;
|
|
371
|
+
if (!init)
|
|
372
|
+
continue;
|
|
373
|
+
if (t.isArrowFunctionExpression(init) || t.isFunctionExpression(init)) {
|
|
374
|
+
candidates.push({
|
|
375
|
+
name: d.id.name,
|
|
376
|
+
declarationKind: "arrow",
|
|
377
|
+
bodyNode: init.body,
|
|
378
|
+
isExported: true,
|
|
379
|
+
isDefault: false
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
else if (t.isCallExpression(init)) {
|
|
383
|
+
const callee = init.callee;
|
|
384
|
+
const calleeName = (t.isIdentifier(callee) && callee.name) ||
|
|
385
|
+
(t.isMemberExpression(callee) && t.isIdentifier(callee.property) && callee.property.name) ||
|
|
386
|
+
null;
|
|
387
|
+
if (calleeName === "forwardRef" || calleeName === "memo") {
|
|
388
|
+
const inner = init.arguments[0];
|
|
389
|
+
if (inner &&
|
|
390
|
+
(t.isArrowFunctionExpression(inner) || t.isFunctionExpression(inner))) {
|
|
391
|
+
candidates.push({
|
|
392
|
+
name: d.id.name,
|
|
393
|
+
declarationKind: calleeName === "forwardRef" ? "forwardRef" : "memo",
|
|
394
|
+
bodyNode: inner.body,
|
|
395
|
+
isExported: true,
|
|
396
|
+
isDefault: false
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else if (t.isClassDeclaration(decl) && decl.id) {
|
|
404
|
+
candidates.push({
|
|
405
|
+
name: decl.id.name,
|
|
406
|
+
declarationKind: "class",
|
|
407
|
+
bodyNode: decl.body,
|
|
408
|
+
isExported: true,
|
|
409
|
+
isDefault: false
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Collect class-list-looking string literals at the module top level.
|
|
416
|
+
*
|
|
417
|
+
* Why: a common Tailwind pattern is to declare base classes as constants
|
|
418
|
+
* outside the component body:
|
|
419
|
+
*
|
|
420
|
+
* const baseClasses = "px-4 py-2 rounded-md ...";
|
|
421
|
+
* export function Button() { return <button className={baseClasses}/> }
|
|
422
|
+
*
|
|
423
|
+
* The per-component extractor only walks the body, so without this pass
|
|
424
|
+
* we'd miss the most important classes in the file. We collect them
|
|
425
|
+
* once per module and union them into every component's classNames.
|
|
426
|
+
*/
|
|
427
|
+
function collectModuleLevelClassStrings(programBody) {
|
|
428
|
+
const out = [];
|
|
429
|
+
const seen = new Set();
|
|
430
|
+
for (const node of programBody) {
|
|
431
|
+
if (!t.isVariableDeclaration(node))
|
|
432
|
+
continue;
|
|
433
|
+
for (const d of node.declarations) {
|
|
434
|
+
if (!d.init)
|
|
435
|
+
continue;
|
|
436
|
+
// const x = "...";
|
|
437
|
+
if (t.isStringLiteral(d.init) && looksLikeClassList(d.init.value)) {
|
|
438
|
+
if (!seen.has(d.init.value)) {
|
|
439
|
+
seen.add(d.init.value);
|
|
440
|
+
out.push(d.init.value);
|
|
441
|
+
}
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
// const variantClasses = { primary: "...", secondary: "..." }
|
|
445
|
+
if (t.isObjectExpression(d.init)) {
|
|
446
|
+
for (const prop of d.init.properties) {
|
|
447
|
+
if (!t.isObjectProperty(prop))
|
|
448
|
+
continue;
|
|
449
|
+
if (t.isStringLiteral(prop.value) && looksLikeClassList(prop.value.value)) {
|
|
450
|
+
if (!seen.has(prop.value.value)) {
|
|
451
|
+
seen.add(prop.value.value);
|
|
452
|
+
out.push(prop.value.value);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// const x = `template ${y} parts`
|
|
458
|
+
if (t.isTemplateLiteral(d.init)) {
|
|
459
|
+
for (const q of d.init.quasis) {
|
|
460
|
+
const raw = (q.value.cooked ?? q.value.raw).trim();
|
|
461
|
+
if (raw && looksLikeClassList(raw) && !seen.has(raw)) {
|
|
462
|
+
seen.add(raw);
|
|
463
|
+
out.push(raw);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return out;
|
|
470
|
+
}
|
|
471
|
+
// Pre-compute once per file. These classes get unioned into every
|
|
472
|
+
// component candidate found in this module.
|
|
473
|
+
const moduleLevelClasses = collectModuleLevelClassStrings(ast.program.body);
|
|
474
|
+
for (const c of candidates) {
|
|
475
|
+
if (!isLikelyComponentName(c.name))
|
|
476
|
+
continue;
|
|
477
|
+
if (!containsJsx(c.bodyNode))
|
|
478
|
+
continue;
|
|
479
|
+
if (isUtilityFile)
|
|
480
|
+
continue;
|
|
481
|
+
const bodyClasses = extractClassNamesFromComponent(c.bodyNode);
|
|
482
|
+
// Union module-level classes — same dedupe logic.
|
|
483
|
+
const all = new Set(bodyClasses);
|
|
484
|
+
for (const s of moduleLevelClasses)
|
|
485
|
+
all.add(s);
|
|
486
|
+
found.push({
|
|
487
|
+
name: c.name,
|
|
488
|
+
file: relPath,
|
|
489
|
+
exportKind: c.isDefault ? "default" : "named",
|
|
490
|
+
declarationKind: c.declarationKind,
|
|
491
|
+
classNames: Array.from(all),
|
|
492
|
+
jsxIdentifiers: extractJsxIdentifiers(c.bodyNode)
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
return found;
|
|
496
|
+
}
|
|
497
|
+
export async function scanComponents(target, options = {}) {
|
|
498
|
+
const pattern = options.pattern ?? DEFAULT_GLOB;
|
|
499
|
+
const ignore = [...DEFAULT_IGNORE, ...(options.ignore ?? [])];
|
|
500
|
+
const files = await glob(pattern, {
|
|
501
|
+
cwd: target,
|
|
502
|
+
ignore,
|
|
503
|
+
absolute: false,
|
|
504
|
+
nodir: true
|
|
505
|
+
});
|
|
506
|
+
const results = [];
|
|
507
|
+
for (const rel of files) {
|
|
508
|
+
const abs = path.join(target, rel);
|
|
509
|
+
const found = await scanFile(abs, rel);
|
|
510
|
+
results.push(...found);
|
|
511
|
+
}
|
|
512
|
+
// Deduplicate by name+file. If a component is both a named export and a
|
|
513
|
+
// re-export from elsewhere we'll catch it twice; keep the first.
|
|
514
|
+
const seen = new Set();
|
|
515
|
+
return results.filter((c) => {
|
|
516
|
+
const key = `${c.name}::${c.file}`;
|
|
517
|
+
if (seen.has(key))
|
|
518
|
+
return false;
|
|
519
|
+
seen.add(key);
|
|
520
|
+
return true;
|
|
521
|
+
});
|
|
522
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Greatest common divisor — used to find the most likely grid unit.
|
|
3
|
+
* Operates on the set of observed spacing pixel values.
|
|
4
|
+
*/
|
|
5
|
+
function gcd(a, b) {
|
|
6
|
+
a = Math.abs(a);
|
|
7
|
+
b = Math.abs(b);
|
|
8
|
+
while (b) {
|
|
9
|
+
[a, b] = [b, a % b];
|
|
10
|
+
}
|
|
11
|
+
return a;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Infer the grid unit. Strategy:
|
|
15
|
+
*
|
|
16
|
+
* 1. Collect all spacing observations with non-null px values, excluding 0.
|
|
17
|
+
* 2. Weight each by its count: a value used 50 times is much more evidence
|
|
18
|
+
* than one used twice.
|
|
19
|
+
* 3. Find the largest N (between 2 and 16) such that the *weighted majority*
|
|
20
|
+
* of pixels are multiples of N.
|
|
21
|
+
*
|
|
22
|
+
* Why weighted majority and not GCD: a single `p-[3px]` shouldn't drop the
|
|
23
|
+
* inferred grid from 8 to 1. GCD is brittle to outliers; coverage is robust.
|
|
24
|
+
*
|
|
25
|
+
* We bias upward: if both 4 and 8 explain ≥80% of usage, we pick 8 because
|
|
26
|
+
* the larger grid is more informative. If neither hits 80%, we fall back
|
|
27
|
+
* to 4 (the most common Tailwind default).
|
|
28
|
+
*/
|
|
29
|
+
function inferGrid(spacings) {
|
|
30
|
+
const totalWeight = spacings.reduce((sum, s) => sum + (s.pxValue && s.pxValue > 0 ? s.count : 0), 0);
|
|
31
|
+
if (totalWeight === 0) {
|
|
32
|
+
return {
|
|
33
|
+
value: 8,
|
|
34
|
+
confidence: "low",
|
|
35
|
+
evidence: "No spacing classes observed — defaulting to 8px."
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
let bestN = 4;
|
|
39
|
+
let bestCoverage = 0;
|
|
40
|
+
for (const n of [16, 12, 10, 8, 6, 5, 4, 3, 2]) {
|
|
41
|
+
const matching = spacings.reduce((sum, s) => {
|
|
42
|
+
if (s.pxValue && s.pxValue > 0 && s.pxValue % n === 0)
|
|
43
|
+
return sum + s.count;
|
|
44
|
+
return sum;
|
|
45
|
+
}, 0);
|
|
46
|
+
const coverage = matching / totalWeight;
|
|
47
|
+
if (coverage >= 0.8 && n > bestN) {
|
|
48
|
+
bestN = n;
|
|
49
|
+
bestCoverage = coverage;
|
|
50
|
+
}
|
|
51
|
+
else if (coverage > bestCoverage && bestCoverage < 0.8) {
|
|
52
|
+
// Keep tracking the best so we can report it if nothing hits 0.8.
|
|
53
|
+
bestN = n;
|
|
54
|
+
bestCoverage = coverage;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const confidence = bestCoverage >= 0.95 ? "high" : bestCoverage >= 0.8 ? "medium" : "low";
|
|
58
|
+
const evidence = bestCoverage >= 0.8
|
|
59
|
+
? `${Math.round(bestCoverage * 100)}% of ${totalWeight} spacing usages are multiples of ${bestN}px.`
|
|
60
|
+
: `No clean grid emerged. Best fit: ${bestN}px covers ${Math.round(bestCoverage * 100)}% of usage.`;
|
|
61
|
+
return { value: bestN, confidence, evidence };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Infer radii. Strategy:
|
|
65
|
+
*
|
|
66
|
+
* 1. Collect radius observations with px values > 0.
|
|
67
|
+
* 2. Group by px value and sort by frequency.
|
|
68
|
+
* 3. The most-used non-extreme value (excluding 0 and 9999/full) is likely
|
|
69
|
+
* one of the two radii. If there are two clearly-used values, the
|
|
70
|
+
* smaller is "control" (buttons, inputs) and the larger is "container"
|
|
71
|
+
* (cards, modals). If there's only one, it's used for both.
|
|
72
|
+
*
|
|
73
|
+
* We're cautious here: many projects use a single radius. We don't invent
|
|
74
|
+
* a second one — we report null and let the user fill it in during review.
|
|
75
|
+
*/
|
|
76
|
+
function inferRadii(radii) {
|
|
77
|
+
const usableValues = radii
|
|
78
|
+
.filter((r) => r.pxValue !== null && r.pxValue > 0 && r.pxValue < 100)
|
|
79
|
+
.reduce((map, r) => {
|
|
80
|
+
map.set(r.pxValue, (map.get(r.pxValue) ?? 0) + r.count);
|
|
81
|
+
return map;
|
|
82
|
+
}, new Map());
|
|
83
|
+
const observed = Array.from(usableValues.entries())
|
|
84
|
+
.map(([value, count]) => ({ value, count }))
|
|
85
|
+
.sort((a, b) => b.count - a.count);
|
|
86
|
+
if (observed.length === 0) {
|
|
87
|
+
return {
|
|
88
|
+
control: null,
|
|
89
|
+
container: null,
|
|
90
|
+
confidence: "low",
|
|
91
|
+
evidence: "No radius classes observed.",
|
|
92
|
+
observed: []
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// If one value dominates (≥70% of radius usage), it's the project radius.
|
|
96
|
+
// Otherwise, top two values are control and container.
|
|
97
|
+
const total = observed.reduce((sum, o) => sum + o.count, 0);
|
|
98
|
+
const dominant = observed[0].count / total;
|
|
99
|
+
let control = null;
|
|
100
|
+
let container = null;
|
|
101
|
+
let evidence = "";
|
|
102
|
+
let confidence = "medium";
|
|
103
|
+
if (dominant >= 0.7) {
|
|
104
|
+
control = observed[0].value;
|
|
105
|
+
container = observed[0].value;
|
|
106
|
+
evidence = `Single radius ${observed[0].value}px dominates (${Math.round(dominant * 100)}% of ${total} usages). Using it for both control and container — adjust in review if needed.`;
|
|
107
|
+
confidence = dominant >= 0.9 ? "high" : "medium";
|
|
108
|
+
}
|
|
109
|
+
else if (observed.length >= 2) {
|
|
110
|
+
const [a, b] = observed;
|
|
111
|
+
control = Math.min(a.value, b.value);
|
|
112
|
+
container = Math.max(a.value, b.value);
|
|
113
|
+
evidence = `Two main radii observed: ${a.value}px (${a.count}×) and ${b.value}px (${b.count}×). Smaller is control, larger is container.`;
|
|
114
|
+
confidence = "medium";
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
control = observed[0].value;
|
|
118
|
+
container = observed[0].value;
|
|
119
|
+
evidence = `Only ${observed[0].value}px observed (${observed[0].count}× across all radius usage).`;
|
|
120
|
+
confidence = "low";
|
|
121
|
+
}
|
|
122
|
+
return { control, container, confidence, evidence, observed };
|
|
123
|
+
}
|
|
124
|
+
function inferColors(colors) {
|
|
125
|
+
const grouped = new Map();
|
|
126
|
+
let arbitraryCount = 0;
|
|
127
|
+
for (const c of colors) {
|
|
128
|
+
if (c.colorToken === null)
|
|
129
|
+
continue;
|
|
130
|
+
if (c.isArbitrary) {
|
|
131
|
+
arbitraryCount += c.count;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
// Strip the property prefix already, but normalise opacity modifiers
|
|
135
|
+
// like `blue-500/50` → `blue-500`.
|
|
136
|
+
const token = c.colorToken.split("/")[0];
|
|
137
|
+
grouped.set(token, (grouped.get(token) ?? 0) + c.count);
|
|
138
|
+
}
|
|
139
|
+
const palette = Array.from(grouped.entries())
|
|
140
|
+
.map(([token, count]) => ({ token, count }))
|
|
141
|
+
.sort((a, b) => b.count - a.count)
|
|
142
|
+
.slice(0, 30);
|
|
143
|
+
const evidence = palette.length === 0
|
|
144
|
+
? `No color classes observed${arbitraryCount > 0 ? ` (${arbitraryCount} arbitrary values)` : ""}.`
|
|
145
|
+
: `${palette.length} distinct tokens observed${arbitraryCount > 0 ? `, plus ${arbitraryCount} arbitrary value(s) — consider tokenising them` : ""}.`;
|
|
146
|
+
return { palette, arbitraryCount, evidence };
|
|
147
|
+
}
|
|
148
|
+
export function inferFoundations(observations) {
|
|
149
|
+
const spacings = observations.filter((o) => o.kind === "spacing");
|
|
150
|
+
const radii = observations.filter((o) => o.kind === "radius");
|
|
151
|
+
const colors = observations.filter((o) => o.kind === "color");
|
|
152
|
+
const grid = inferGrid(spacings);
|
|
153
|
+
const radiiInferred = inferRadii(radii);
|
|
154
|
+
const colorsInferred = inferColors(colors);
|
|
155
|
+
const hints = [];
|
|
156
|
+
if (grid.confidence === "low") {
|
|
157
|
+
hints.push("Grid inference is uncertain. Review spacing usage and confirm the value manually.");
|
|
158
|
+
}
|
|
159
|
+
if (radiiInferred.control === null) {
|
|
160
|
+
hints.push("No radius observed. Whale will leave radii unset; set them via the review flow or whale.config.json.");
|
|
161
|
+
}
|
|
162
|
+
else if (radiiInferred.control === radiiInferred.container) {
|
|
163
|
+
hints.push(`Project uses a single radius (${radiiInferred.control}px). If controls and containers should differ, edit during review.`);
|
|
164
|
+
}
|
|
165
|
+
if (colorsInferred.arbitraryCount > 0) {
|
|
166
|
+
hints.push(`${colorsInferred.arbitraryCount} arbitrary color value(s) found. These bypass the design system — consider extracting them as tokens.`);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
grid,
|
|
170
|
+
radii: radiiInferred,
|
|
171
|
+
colors: colorsInferred,
|
|
172
|
+
hints
|
|
173
|
+
};
|
|
174
|
+
}
|