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
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
|
+
}
|