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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +275 -0
  3. package/dist/analyzer/imports.js +88 -0
  4. package/dist/analyzer/insights.js +276 -0
  5. package/dist/commands/add.js +36 -0
  6. package/dist/commands/adopt.js +180 -0
  7. package/dist/commands/adoptReview.js +267 -0
  8. package/dist/commands/component.js +93 -0
  9. package/dist/commands/createComponent.js +207 -0
  10. package/dist/commands/decision.js +98 -0
  11. package/dist/commands/docs.js +34 -0
  12. package/dist/commands/ignite.js +212 -0
  13. package/dist/commands/init.js +66 -0
  14. package/dist/commands/insights.js +123 -0
  15. package/dist/commands/mcp.js +106 -0
  16. package/dist/commands/refine.js +36 -0
  17. package/dist/commands/selene.js +516 -0
  18. package/dist/commands/sync.js +43 -0
  19. package/dist/commands/validate.js +48 -0
  20. package/dist/commands/watch.js +150 -0
  21. package/dist/commands/wiki.js +21 -0
  22. package/dist/generators/markdownGenerator.js +112 -0
  23. package/dist/generators/reportGenerator.js +50 -0
  24. package/dist/generators/wikiGenerator.js +365 -0
  25. package/dist/index.js +213 -0
  26. package/dist/mcp/server.js +404 -0
  27. package/dist/scanner/componentScanner.js +522 -0
  28. package/dist/scanner/foundationInferrer.js +174 -0
  29. package/dist/scanner/tailwindMapper.js +58 -0
  30. package/dist/scanner/tailwindScanner.js +186 -0
  31. package/dist/selene/apiClient.js +168 -0
  32. package/dist/selene/cache.js +68 -0
  33. package/dist/selene/clipboard.js +56 -0
  34. package/dist/selene/promptBuilder.js +229 -0
  35. package/dist/selene/providers.js +67 -0
  36. package/dist/selene/responseParser.js +149 -0
  37. package/dist/ui/atoms.js +30 -0
  38. package/dist/ui/blocks.js +208 -0
  39. package/dist/ui/capabilities.js +64 -0
  40. package/dist/ui/index.js +13 -0
  41. package/dist/ui/symbols.js +41 -0
  42. package/dist/ui/theme.js +78 -0
  43. package/dist/utils/components.js +40 -0
  44. package/dist/utils/config.js +31 -0
  45. package/dist/utils/decisions.js +32 -0
  46. package/dist/utils/paths.js +4 -0
  47. package/dist/utils/proposals.js +61 -0
  48. package/dist/utils/refinements.js +81 -0
  49. package/dist/utils/registry.js +45 -0
  50. package/dist/utils/writeJson.js +6 -0
  51. package/dist/validators/cssValidator.js +204 -0
  52. package/dist/version.js +1 -0
  53. package/docs/ROADMAP.md +206 -0
  54. 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
+ }