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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Whale Igniter contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,275 @@
1
+ # Whale Igniter
2
+
3
+ **CLI-first operational intelligence for AI-assisted product workflows.**
4
+
5
+ Whale gives your project a machine-readable memory so AI agents like
6
+ Claude Code, Codex, Cursor and Copilot understand your design system,
7
+ conventions and decisions from the first commit — without you having
8
+ to re-explain everything every session.
9
+
10
+ > AI-native, not AI-dependent. The core is deterministic and runs
11
+ > offline. AI is an enrichment layer — opt-in via API key, or via the
12
+ > built-in MCP server.
13
+
14
+ ---
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install -g whale-igniter
20
+ # or use it directly without installing
21
+ npx whale-igniter ignite my-app
22
+ ```
23
+
24
+ Requires Node 20 or newer.
25
+
26
+ ---
27
+
28
+ ## The two-minute version
29
+
30
+ ```bash
31
+ # Brand new project
32
+ whale ignite my-app
33
+
34
+ # Existing React + Tailwind codebase
35
+ cd my-existing-app
36
+ whale adopt
37
+ whale adopt review # accept/reject scanner proposals
38
+ ```
39
+
40
+ Either path produces a `CLAUDE.md` at the project root plus a
41
+ `llm-wiki/` folder. Open the project in Claude Code (or any AI
42
+ assistant) and it picks up the context automatically.
43
+
44
+ ---
45
+
46
+ ## What Whale does
47
+
48
+ ### 1. Bootstraps AI context for new projects
49
+
50
+ `whale ignite` creates a workspace with foundations, a config, the
51
+ intelligence stores, and a fully-generated `CLAUDE.md`. Three modes:
52
+ opinionated default, `--minimal` skeleton, or `--interactive` wizard.
53
+
54
+ ### 2. Adopts existing projects
55
+
56
+ `whale adopt` parses your React/Tailwind source with a real AST, infers
57
+ foundations (grid, radii, paleta), detects components, and stages
58
+ proposals you can review and accept. Idempotent — re-runs don't
59
+ duplicate or overwrite your decisions.
60
+
61
+ ### 3. Records operational context as you work
62
+
63
+ Three structured stores capture what would otherwise live in your head:
64
+
65
+ - **`intelligence/components.json`** — component catalog
66
+ - **`intelligence/decisions.json`** — architectural and product decisions
67
+ - **`intelligence/refinements.json`** — approved exceptions to the system
68
+
69
+ Every write auto-regenerates `CLAUDE.md` so agents always read current state.
70
+
71
+ ### 4. Surfaces accountable insights
72
+
73
+ `whale insights` runs deterministic analyzers over your stores and code:
74
+ refinement clusters, decision tension, orphan components, token drift,
75
+ grid drift, catalog coverage gaps. No AI in the loop — local,
76
+ explainable, machine-checkable.
77
+
78
+ ### 5. Generates code that respects your foundations
79
+
80
+ `whale create component Card --variants primary,outline` produces a
81
+ typed React component using your actual grid, radii, and accent color.
82
+ No invented values. Auto-registers in the catalog.
83
+
84
+ ### 6. Bridges to any AI agent (Selene)
85
+
86
+ Three composable commands — `describe`, `audit`, `suggest` — package
87
+ your full project context into a structured prompt. They work three ways:
88
+
89
+ - **Prompt mode** (default, offline): writes the prompt to clipboard;
90
+ paste into Claude/ChatGPT/Cursor; paste response back with
91
+ `whale selene apply`.
92
+ - **API mode** (opt-in): if `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`
93
+ is set, Whale calls the provider directly and auto-applies the result.
94
+ Transparent fallback to prompt mode on any failure.
95
+ - **MCP mode** (new in v1.0): Whale exposes its capabilities as MCP
96
+ tools that Claude Code, Cursor, Zed and any MCP client use natively.
97
+
98
+ ### 7. Stays in sync automatically
99
+
100
+ `whale watch` regenerates `CLAUDE.md` and the wiki when your foundations
101
+ or stores change. Plays well with pre-commit hooks and CI.
102
+
103
+ ---
104
+
105
+ ## MCP server
106
+
107
+ The most powerful integration in v1.0. Wire Whale into your AI tool:
108
+
109
+ ```bash
110
+ whale mcp config --client claude-code
111
+ # Prints the JSON snippet for ~/Library/Application Support/Claude/claude_desktop_config.json
112
+ ```
113
+
114
+ Once configured, the agent gets tools like:
115
+
116
+ - `whale_project_overview` — read foundations, packs, counts in one call
117
+ - `whale_list_components` — see what already exists before creating duplicates
118
+ - `whale_list_decisions` — understand why the project is structured this way
119
+ - `whale_register_component` — record components the agent creates
120
+ - `whale_record_decision` — record non-obvious choices as they happen
121
+ - `whale_validate`, `whale_insights` — check state mid-session
122
+
123
+ Available client snippets: `--client claude-code | cursor | zed | raw`.
124
+
125
+ ---
126
+
127
+ ## Command reference
128
+
129
+ | Command | What it does |
130
+ | --- | --- |
131
+ | `whale ignite [name]` | Bootstrap a workspace. Flags: `--interactive`, `--minimal`. |
132
+ | `whale adopt [target]` | Scan an existing project and stage proposals. |
133
+ | `whale adopt review` | Walk through pending proposals interactively. |
134
+ | `whale adopt status` | List proposals without entering review. |
135
+ | `whale sync` | Regenerate CLAUDE.md + wiki from `intelligence/*.json`. |
136
+ | `whale watch` | Auto-regenerate on changes. Flags: `--once`, `--verbose`, `--debounce`. |
137
+ | `whale validate` | Run validators. Non-zero exit on errors. |
138
+ | `whale insights` | Local analyzers + recommendations. Flags: `--json`, `--category`, `--min-severity`. |
139
+ | `whale refine "<note>"` | Record a validator override. |
140
+ | `whale decision` | Record an architectural / product decision. |
141
+ | `whale component add <name>` | Register a component. |
142
+ | `whale create component <name>` | Generate a typed component scaffold. |
143
+ | `whale selene describe <component>` | AI-assisted catalog description. |
144
+ | `whale selene audit <file>` | AI-assisted audit against foundations. |
145
+ | `whale selene suggest` | AI-suggested patterns and decisions. |
146
+ | `whale selene apply <kind>` | Apply pasted LLM response. |
147
+ | `whale selene status` | Show provider state, mode, cache. |
148
+ | `whale mcp serve` | Start the MCP server on stdio. |
149
+ | `whale mcp config` | Print client configuration snippets. |
150
+ | `whale docs` | Generate human-facing reports. |
151
+
152
+ ---
153
+
154
+ ## Project structure
155
+
156
+ ```
157
+ my-app/
158
+ ├── whale.config.json foundations, packs, AI targets
159
+ ├── CLAUDE.md AI entry point (auto-generated)
160
+ ├── AGENTS.md if "codex" in aiTargets
161
+ ├── .cursorrules if "cursor" in aiTargets
162
+ ├── intelligence/ source of truth (JSON)
163
+ │ ├── refinements.json
164
+ │ ├── decisions.json
165
+ │ └── components.json
166
+ ├── llm-wiki/ rendered view (markdown)
167
+ │ ├── FOUNDATIONS.md
168
+ │ ├── CONVENTIONS.md
169
+ │ ├── DECISIONS.md
170
+ │ ├── COMPONENTS.md
171
+ │ └── WORKFLOWS.md
172
+ └── .whale/ runtime caches and Selene stash
173
+ └── selene/
174
+ ├── cache/
175
+ └── *.prompt.md / *.response.md
176
+ ```
177
+
178
+ **JSON is the source of truth. Markdown is rendered.** Edit a JSON
179
+ store by hand, run `whale sync` (or have `whale watch` running), and
180
+ everything else catches up.
181
+
182
+ ---
183
+
184
+ ## Selene configuration
185
+
186
+ Optional block in `whale.config.json` for API mode:
187
+
188
+ ```json
189
+ {
190
+ "selene": {
191
+ "provider": "anthropic",
192
+ "model": "claude-sonnet-4-6",
193
+ "temperature": 0.2,
194
+ "maxTokens": 1500,
195
+ "autoCall": true,
196
+ "confirmCost": false,
197
+ "noCache": false
198
+ }
199
+ }
200
+ ```
201
+
202
+ Everything is optional. With no config and no key, Selene runs in
203
+ prompt mode forever. Adding a key (env or here) flips it to API mode.
204
+ Set `autoCall: false` to keep prompt mode even with a key available.
205
+
206
+ ---
207
+
208
+ ## Presentation
209
+
210
+ Whale's terminal output is built on a semantic UI layer: commands describe
211
+ *intent* (success, warning, section header, key/value pair) and the active
212
+ theme decides what each intent looks like. One result is that the same
213
+ command produces premium output in a real terminal and clean, grep-friendly
214
+ output in CI.
215
+
216
+ Three environment variables control presentation:
217
+
218
+ | Variable | Effect |
219
+ | --- | --- |
220
+ | `WHALE_PLAIN=1` | Disable color *and* Unicode glyphs. The output becomes ASCII-only and grep-friendly. Recommended for CI logs and pipelines. |
221
+ | `WHALE_UNICODE=0` | Keep color, fall back to ASCII glyphs (`*`, `v`, `x`, `->`). Useful for terminals that render color but mangle Unicode. |
222
+ | `NO_COLOR=1` / `FORCE_COLOR=0` | Standard environment overrides — chalk respects them, so does Whale. |
223
+
224
+ In normal terminals Whale auto-detects capabilities and uses Unicode + color.
225
+ In non-TTY contexts (piping, CI, scripts) it strips both automatically. You
226
+ don't need to opt in unless you want to override the default.
227
+
228
+ ---
229
+
230
+ ## CI usage
231
+
232
+ ```yaml
233
+ - run: npx whale-igniter validate . # exits non-zero on errors
234
+ - run: npx whale-igniter sync # ensure CLAUDE.md is current
235
+ - run: git diff --exit-code # fail if sync produced uncommitted changes
236
+ ```
237
+
238
+ This turns "is the AI context current?" into a checkable property of
239
+ the repo. Combine with `whale insights --json --min-severity warning`
240
+ for automated quality reports.
241
+
242
+ ---
243
+
244
+ ## Design principles
245
+
246
+ 1. **CLI-first.** Whale is a CLI and the MCP server. No dashboard, no SaaS.
247
+ 2. **Local-first.** Intelligence lives in your repo, in git. No accounts.
248
+ 3. **AI-native, not AI-dependent.** Core works offline. AI is opt-in at
249
+ every level.
250
+ 4. **Source of truth in JSON; rendered views in Markdown.** One direction.
251
+ 5. **Idempotent everywhere.** Re-running adopt, sync, watch, or
252
+ register doesn't duplicate or corrupt anything.
253
+ 6. **Honest fallbacks.** API down? Falls back to prompt mode. Scan
254
+ fails? Insights skip orphan/drift but still run. Watcher recursive
255
+ not supported? Falls back to non-recursive. The user is never stuck.
256
+
257
+ ---
258
+
259
+ ## What v1.1 doesn't do (and why)
260
+
261
+ - **Vue / Svelte parsing.** Scanner is React + Tailwind only. Other
262
+ frameworks need their own parsers; that's post-v1.0.
263
+ - **Hosted SaaS.** Whale is a tool, not a service. By design.
264
+ - **Real-time team sync.** Today everything is local + git. Team
265
+ workflows happen through PRs against `intelligence/*.json`.
266
+ - **Generic linting.** ESLint and Stylelint exist. Whale only checks
267
+ what's tied to operational context.
268
+
269
+ See [docs/ROADMAP.md](docs/ROADMAP.md) for what's planned next.
270
+
271
+ ---
272
+
273
+ ## License
274
+
275
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Collect every relative file path imported in the project's source tree.
3
+ * Returns a Set of normalized paths (relative to project root) without
4
+ * extensions, since imports usually omit them. The caller normalizes
5
+ * the catalog's `files` entries the same way before checking.
6
+ */
7
+ import fs from "fs-extra";
8
+ import path from "node:path";
9
+ import { glob } from "glob";
10
+ import { parse } from "@babel/parser";
11
+ import * as t from "@babel/types";
12
+ const IGNORE = [
13
+ "**/node_modules/**",
14
+ "**/dist/**",
15
+ "**/build/**",
16
+ "**/.next/**",
17
+ "**/coverage/**"
18
+ ];
19
+ function stripExt(p) {
20
+ return p.replace(/\.(t|j)sx?$/, "");
21
+ }
22
+ /**
23
+ * Resolve an import specifier relative to the importing file, returning
24
+ * a project-relative path without extension. Returns null for bare
25
+ * specifiers (`react`, `lodash`) and `node:*` imports.
26
+ */
27
+ function resolveSpecifier(spec, fromFileRel, root) {
28
+ if (!spec.startsWith(".") && !spec.startsWith("/"))
29
+ return null; // bare specifier
30
+ const fromDir = path.dirname(path.join(root, fromFileRel));
31
+ const abs = path.resolve(fromDir, spec);
32
+ const rel = path.relative(root, abs).replace(/\\/g, "/");
33
+ if (rel.startsWith(".."))
34
+ return null; // outside the project
35
+ return stripExt(rel);
36
+ }
37
+ export async function collectReferencedFiles(target, pattern = "src/**/*.{ts,tsx,js,jsx}") {
38
+ const files = await glob(pattern, { cwd: target, ignore: IGNORE, nodir: true });
39
+ const refs = new Set();
40
+ for (const rel of files) {
41
+ let src;
42
+ try {
43
+ src = await fs.readFile(path.join(target, rel), "utf8");
44
+ }
45
+ catch {
46
+ continue;
47
+ }
48
+ let ast;
49
+ try {
50
+ ast = parse(src, {
51
+ sourceType: "module",
52
+ plugins: ["typescript", "jsx", "decorators-legacy"],
53
+ errorRecovery: true
54
+ });
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ for (const node of ast.program.body) {
60
+ // import x from "..." / import {x} from "..."
61
+ if (t.isImportDeclaration(node)) {
62
+ const resolved = resolveSpecifier(node.source.value, rel, target);
63
+ if (resolved)
64
+ refs.add(resolved);
65
+ }
66
+ // export {x} from "..."
67
+ if (t.isExportNamedDeclaration(node) && node.source) {
68
+ const resolved = resolveSpecifier(node.source.value, rel, target);
69
+ if (resolved)
70
+ refs.add(resolved);
71
+ }
72
+ // export * from "..."
73
+ if (t.isExportAllDeclaration(node)) {
74
+ const resolved = resolveSpecifier(node.source.value, rel, target);
75
+ if (resolved)
76
+ refs.add(resolved);
77
+ }
78
+ }
79
+ }
80
+ return refs;
81
+ }
82
+ /**
83
+ * Normalise a path the same way collectReferencedFiles does. Useful to
84
+ * check whether a catalog entry's `files` are in the referenced set.
85
+ */
86
+ export function normalizePath(p) {
87
+ return stripExt(p.replace(/\\/g, "/"));
88
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Insight engine.
3
+ *
4
+ * Pure functions: takes the intelligence stores in memory and returns
5
+ * a list of insights. No filesystem IO, no console output. The command
6
+ * layer (commands/insights.ts) wires this to the real data.
7
+ *
8
+ * Why pure? Two reasons. First, it's trivially testable — pass in a
9
+ * fixture state, assert on the output. Second, the same engine will be
10
+ * reused by future tools (Selene prompt composition, MCP server,
11
+ * `whale docs` enrichment) where the inputs aren't necessarily disk
12
+ * state.
13
+ */
14
+ // ---------------------------------------------------------------------------
15
+ // Individual analyzers. Each returns 0+ insights.
16
+ // ---------------------------------------------------------------------------
17
+ /**
18
+ * Repeated refinements with the same inferred issue type may indicate a
19
+ * convention that should be promoted to config, not patched per-site.
20
+ *
21
+ * We look at issueType (the strongest scope signal) and bucket by it.
22
+ * Threshold of 3 is deliberate — 2 is coincidence, 3 is a pattern.
23
+ */
24
+ function analyzeRefinementClusters(refinements) {
25
+ const buckets = new Map();
26
+ for (const r of refinements) {
27
+ const type = r.scope?.issueType;
28
+ if (!type)
29
+ continue;
30
+ if (!buckets.has(type))
31
+ buckets.set(type, []);
32
+ buckets.get(type).push(r);
33
+ }
34
+ const insights = [];
35
+ for (const [type, items] of buckets) {
36
+ if (items.length < 3)
37
+ continue;
38
+ insights.push({
39
+ id: `refinements.cluster.${type}`,
40
+ category: "refinements",
41
+ severity: "warning",
42
+ title: `${items.length} refinements share the same issue type (${type})`,
43
+ detail: `Recurring overrides suggest the foundation may not match how the project is actually built. ` +
44
+ `Consider adjusting \`whale.config.json\` instead of patching individual cases.`,
45
+ evidence: items.map((r) => `${r.timestamp.slice(0, 10)} — ${r.note}`).slice(0, 5),
46
+ action: `Review the ${type} foundation in whale.config.json and consider widening it.`
47
+ });
48
+ }
49
+ return insights;
50
+ }
51
+ /**
52
+ * Refinements logged without an inferred scope produce no validation
53
+ * effect — they're notes, not rules. Worth surfacing because users
54
+ * often think they've created an exception when they haven't.
55
+ */
56
+ function analyzeScopelessRefinements(refinements) {
57
+ const scopeless = refinements.filter((r) => !r.scope);
58
+ if (scopeless.length === 0)
59
+ return [];
60
+ return [
61
+ {
62
+ id: "refinements.scopeless",
63
+ category: "refinements",
64
+ severity: "info",
65
+ title: `${scopeless.length} refinement(s) have no active scope`,
66
+ detail: `These are recorded as context but the validator won't act on them. ` +
67
+ `Mention an issue type (radius, spacing, hex, focus) in the note to activate scoping.`,
68
+ evidence: scopeless.map((r) => `${r.timestamp.slice(0, 10)} — ${r.note}`).slice(0, 5),
69
+ action: "Rephrase the notes or use `whale refine` with more specific wording."
70
+ }
71
+ ];
72
+ }
73
+ /**
74
+ * Components that exist in the catalog but the user hasn't populated
75
+ * with the basics. The CLAUDE.md is materially less useful for agents
76
+ * when a third of components have no description.
77
+ */
78
+ function analyzeCatalogCoverage(components) {
79
+ if (components.length === 0)
80
+ return [];
81
+ const missingDescription = components.filter((c) => !c.description || c.description.trim().length === 0);
82
+ const missingCategory = components.filter((c) => !c.category);
83
+ const missingVariants = components.filter((c) => !c.variants || c.variants.length === 0);
84
+ const insights = [];
85
+ const pct = (n) => Math.round((n / components.length) * 100);
86
+ if (missingDescription.length > 0) {
87
+ const p = pct(missingDescription.length);
88
+ insights.push({
89
+ id: "components.coverage.description",
90
+ category: "coverage",
91
+ severity: p >= 50 ? "warning" : "info",
92
+ title: `${missingDescription.length}/${components.length} component(s) (${p}%) have no description`,
93
+ detail: `Descriptions are the single field agents rely on to disambiguate similar components. ` +
94
+ `Even one sentence per component meaningfully improves AI context quality.`,
95
+ evidence: missingDescription.map((c) => c.name).slice(0, 10),
96
+ action: "Edit `intelligence/components.json` or use `whale component add <name> --description ...`."
97
+ });
98
+ }
99
+ if (missingCategory.length > 0) {
100
+ insights.push({
101
+ id: "components.coverage.category",
102
+ category: "coverage",
103
+ severity: "info",
104
+ title: `${missingCategory.length} component(s) have no category`,
105
+ detail: `Categories help agents pick the right component for a task ` +
106
+ `(form vs. navigation vs. feedback, etc.).`,
107
+ evidence: missingCategory.map((c) => c.name).slice(0, 10)
108
+ });
109
+ }
110
+ if (missingVariants.length > 0) {
111
+ insights.push({
112
+ id: "components.coverage.variants",
113
+ category: "coverage",
114
+ severity: "info",
115
+ title: `${missingVariants.length} component(s) list no variants`,
116
+ detail: `If a component has multiple visual styles (primary/secondary/ghost), ` +
117
+ `declaring them in the catalog prevents agents from inventing names.`,
118
+ evidence: missingVariants.map((c) => c.name).slice(0, 10)
119
+ });
120
+ }
121
+ return insights;
122
+ }
123
+ /**
124
+ * Components in the catalog with a `files` entry that no other file imports.
125
+ * Useful for spotting dead code, but only when we have referencedFiles.
126
+ *
127
+ * Conservative: only flags components whose ALL declared files are unreferenced.
128
+ * If one of a component's files is referenced, we assume it's in use.
129
+ */
130
+ function analyzeOrphanComponents(components, referencedFiles) {
131
+ if (!referencedFiles || referencedFiles.size === 0)
132
+ return [];
133
+ const orphans = components.filter((c) => {
134
+ if (!c.files || c.files.length === 0)
135
+ return false;
136
+ return c.files.every((f) => !referencedFiles.has(f));
137
+ });
138
+ if (orphans.length === 0)
139
+ return [];
140
+ return [
141
+ {
142
+ id: "components.orphans",
143
+ category: "components",
144
+ severity: "info",
145
+ title: `${orphans.length} catalogued component(s) appear unreferenced`,
146
+ detail: `These components are in the catalog but no other source file imports them. ` +
147
+ `They may be dead code, top-level pages, or referenced through dynamic paths the scanner can't follow.`,
148
+ evidence: orphans.map((c) => `${c.name} (${c.files?.join(", ")})`).slice(0, 10),
149
+ action: "Verify these are still in use; remove from the catalog if not."
150
+ }
151
+ ];
152
+ }
153
+ /**
154
+ * If `observations` was passed (i.e. a scan ran), flag arbitrary color
155
+ * values that bypass the design system. This is the v0.9 version of
156
+ * token drift — full bidirectional drift (declared but unused) needs
157
+ * tailwind.config resolution which is out of scope.
158
+ */
159
+ function analyzeTokenDrift(observations) {
160
+ if (!observations || observations.length === 0)
161
+ return [];
162
+ const arbitraryColors = observations.filter((o) => o.kind === "color" && o.isArbitrary);
163
+ if (arbitraryColors.length === 0)
164
+ return [];
165
+ const totalUses = arbitraryColors.reduce((sum, o) => sum + o.count, 0);
166
+ return [
167
+ {
168
+ id: "tokens.arbitrary-colors",
169
+ category: "tokens",
170
+ severity: totalUses >= 5 ? "warning" : "info",
171
+ title: `${arbitraryColors.length} distinct arbitrary color value(s) (${totalUses} use(s))`,
172
+ detail: `Arbitrary values like \`bg-[#0a84ff]\` bypass the design system. ` +
173
+ `Consider declaring these as tokens so they're consistent and reusable.`,
174
+ evidence: arbitraryColors.slice(0, 10).map((o) => `${o.raw} (${o.count}×)`),
175
+ action: "Promote these values into your Tailwind config or `whale.config.json` foundations."
176
+ }
177
+ ];
178
+ }
179
+ /**
180
+ * Two active decisions whose titles share substantive keywords might
181
+ * be in tension. Conservative heuristic — only flags strict overlap of
182
+ * meaningful tokens, never claims contradiction without user review.
183
+ */
184
+ function analyzeDecisionTension(decisions) {
185
+ const active = decisions.filter((d) => d.status === "active");
186
+ if (active.length < 2)
187
+ return [];
188
+ const stopwords = new Set([
189
+ "the", "a", "an", "and", "or", "of", "to", "for", "in", "on",
190
+ "with", "use", "using", "is", "be", "we", "our", "as", "by",
191
+ "this", "that", "from", "all", "any", "are"
192
+ ]);
193
+ const tokenize = (s) => s
194
+ .toLowerCase()
195
+ .replace(/[^a-z0-9\s]/g, " ")
196
+ .split(/\s+/)
197
+ .filter((t) => t.length >= 4 && !stopwords.has(t));
198
+ const insights = [];
199
+ for (let i = 0; i < active.length; i += 1) {
200
+ for (let j = i + 1; j < active.length; j += 1) {
201
+ const a = active[i];
202
+ const b = active[j];
203
+ if (a.category !== b.category)
204
+ continue;
205
+ const ta = new Set(tokenize(a.title));
206
+ const tb = new Set(tokenize(b.title));
207
+ const overlap = [];
208
+ for (const t of ta)
209
+ if (tb.has(t))
210
+ overlap.push(t);
211
+ if (overlap.length >= 2) {
212
+ insights.push({
213
+ id: `decisions.tension.${a.id.slice(0, 6)}.${b.id.slice(0, 6)}`,
214
+ category: "decisions",
215
+ severity: "info",
216
+ title: `Two active ${a.category} decisions share keywords: ${overlap.join(", ")}`,
217
+ detail: `"${a.title}" and "${b.title}" both touch the same topic. ` +
218
+ `If the newer supersedes the older, mark it with the \`status\` and \`supersedes\` fields.`,
219
+ evidence: [a.title, b.title]
220
+ });
221
+ }
222
+ }
223
+ }
224
+ return insights;
225
+ }
226
+ /**
227
+ * Foundations declared in the config that the project barely follows.
228
+ * Only runs if observations are available.
229
+ */
230
+ function analyzeGridDrift(config, observations) {
231
+ const grid = config.foundations?.grid;
232
+ if (!grid || !observations)
233
+ return [];
234
+ const spacings = observations.filter((o) => o.kind === "spacing" && o.pxValue !== null && o.pxValue !== 0);
235
+ const total = spacings.reduce((s, o) => s + o.count, 0);
236
+ if (total < 10)
237
+ return []; // not enough data to be meaningful
238
+ const onGrid = spacings.reduce((s, o) => (o.pxValue % grid === 0 ? s + o.count : s), 0);
239
+ const coverage = onGrid / total;
240
+ if (coverage < 0.7) {
241
+ return [
242
+ {
243
+ id: "foundations.grid-drift",
244
+ category: "foundations",
245
+ severity: "warning",
246
+ title: `Declared grid (${grid}px) covers only ${Math.round(coverage * 100)}% of observed spacing`,
247
+ detail: `${total - onGrid} of ${total} spacing usages don't fit the declared grid. ` +
248
+ `Either the grid is wrong for this project or there's significant drift to clean up.`,
249
+ action: "Run `whale adopt` to re-infer the grid, or audit the off-grid usage."
250
+ }
251
+ ];
252
+ }
253
+ return [];
254
+ }
255
+ // ---------------------------------------------------------------------------
256
+ // Main entry
257
+ // ---------------------------------------------------------------------------
258
+ export function analyze(input) {
259
+ const all = [];
260
+ all.push(...analyzeRefinementClusters(input.refinements));
261
+ all.push(...analyzeScopelessRefinements(input.refinements));
262
+ all.push(...analyzeCatalogCoverage(input.components));
263
+ all.push(...analyzeOrphanComponents(input.components, input.referencedFiles));
264
+ all.push(...analyzeTokenDrift(input.observations));
265
+ all.push(...analyzeDecisionTension(input.decisions));
266
+ all.push(...analyzeGridDrift(input.config, input.observations));
267
+ // Sort by severity (critical > warning > info), then by category for stable output.
268
+ const sevRank = { critical: 0, warning: 1, info: 2 };
269
+ all.sort((a, b) => {
270
+ const sa = sevRank[a.severity] - sevRank[b.severity];
271
+ if (sa !== 0)
272
+ return sa;
273
+ return a.category.localeCompare(b.category);
274
+ });
275
+ return all;
276
+ }