whale-igniter 1.2.2 → 1.3.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/README.md +89 -281
- package/dist/analyzer/insights.js +144 -0
- package/dist/commands/adopt.js +20 -0
- package/dist/commands/changes.js +127 -0
- package/dist/commands/ignite.js +142 -76
- package/dist/commands/insights.js +146 -9
- package/dist/commands/references.js +193 -0
- package/dist/commands/sync.js +8 -0
- package/dist/generators/wikiGenerator.js +8 -304
- package/dist/index.js +33 -2
- package/dist/mcp/server.js +29 -0
- package/dist/scanner/extractors/css.js +95 -0
- package/dist/scanner/extractors/inline.js +131 -0
- package/dist/scanner/extractors/styleBlocks.js +37 -0
- package/dist/scanner/normalizer.js +59 -0
- package/dist/scanner/tailwindScanner.js +39 -0
- package/dist/templates/claude.js +93 -0
- package/dist/templates/components.js +45 -0
- package/dist/templates/conventions.js +45 -0
- package/dist/templates/decisions.js +34 -0
- package/dist/templates/foundations.js +34 -0
- package/dist/templates/project.js +82 -0
- package/dist/utils/aiAvailability.js +25 -0
- package/dist/utils/wizardMapping.js +54 -0
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { resolveTarget } from "../utils/paths.js";
|
|
3
|
+
import { loadDecisions } from "../utils/decisions.js";
|
|
4
|
+
import { loadComponents } from "../utils/components.js";
|
|
5
|
+
import { loadRefinements } from "../utils/refinements.js";
|
|
6
|
+
import { ui } from "../ui/index.js";
|
|
7
|
+
const INTELLIGENCE_FILES = [
|
|
8
|
+
"intelligence/decisions.json",
|
|
9
|
+
"intelligence/components.json",
|
|
10
|
+
"intelligence/refinements.json",
|
|
11
|
+
"whale.config.json"
|
|
12
|
+
];
|
|
13
|
+
function gitDiffFiles(target, since) {
|
|
14
|
+
try {
|
|
15
|
+
const result = execSync(`git diff --name-only ${since} HEAD -- ${INTELLIGENCE_FILES.join(" ")}`, {
|
|
16
|
+
cwd: target,
|
|
17
|
+
encoding: "utf8"
|
|
18
|
+
});
|
|
19
|
+
return result.trim().split("\n").filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function gitFileAtRef(target, ref, file) {
|
|
26
|
+
try {
|
|
27
|
+
const raw = execSync(`git show ${ref}:${file}`, { cwd: target, encoding: "utf8" });
|
|
28
|
+
return JSON.parse(raw);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function changesCommand(opts) {
|
|
35
|
+
const target = resolveTarget(opts.target);
|
|
36
|
+
const since = opts.since ?? "HEAD~1";
|
|
37
|
+
const changedFiles = gitDiffFiles(target, since);
|
|
38
|
+
if (changedFiles.length === 0) {
|
|
39
|
+
console.log(ui.note(`No intelligence store changes since ${since}`));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
console.log(ui.section(`Changes since ${since}`));
|
|
43
|
+
const summary = {
|
|
44
|
+
decisions: { added: [], removed: [], modified: [] },
|
|
45
|
+
components: { added: [], removed: [] },
|
|
46
|
+
refinements: { added: [], removed: [] },
|
|
47
|
+
configChanged: false
|
|
48
|
+
};
|
|
49
|
+
if (changedFiles.includes("whale.config.json")) {
|
|
50
|
+
summary.configChanged = true;
|
|
51
|
+
}
|
|
52
|
+
if (changedFiles.includes("intelligence/decisions.json")) {
|
|
53
|
+
const before = gitFileAtRef(target, since, "intelligence/decisions.json");
|
|
54
|
+
const after = await loadDecisions(target);
|
|
55
|
+
const beforeIds = new Map(before.map((d) => [d.id, d.title]));
|
|
56
|
+
const afterIds = new Map(after.map((d) => [d.id, d.title]));
|
|
57
|
+
for (const [id, title] of afterIds) {
|
|
58
|
+
if (!beforeIds.has(id))
|
|
59
|
+
summary.decisions.added.push(title);
|
|
60
|
+
}
|
|
61
|
+
for (const [id, title] of beforeIds) {
|
|
62
|
+
if (!afterIds.has(id))
|
|
63
|
+
summary.decisions.removed.push(title);
|
|
64
|
+
else if (afterIds.get(id) !== title)
|
|
65
|
+
summary.decisions.modified.push(title);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (changedFiles.includes("intelligence/components.json")) {
|
|
69
|
+
const before = gitFileAtRef(target, since, "intelligence/components.json");
|
|
70
|
+
const after = await loadComponents(target);
|
|
71
|
+
const beforeNames = new Set(before.map((c) => c.name));
|
|
72
|
+
const afterNames = new Set(after.map((c) => c.name));
|
|
73
|
+
for (const name of afterNames) {
|
|
74
|
+
if (!beforeNames.has(name))
|
|
75
|
+
summary.components.added.push(name);
|
|
76
|
+
}
|
|
77
|
+
for (const name of beforeNames) {
|
|
78
|
+
if (!afterNames.has(name))
|
|
79
|
+
summary.components.removed.push(name);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (changedFiles.includes("intelligence/refinements.json")) {
|
|
83
|
+
const before = gitFileAtRef(target, since, "intelligence/refinements.json");
|
|
84
|
+
const after = await loadRefinements(target);
|
|
85
|
+
const beforeIds = new Set(before.map((r) => r.id));
|
|
86
|
+
const afterIds = new Set(after.map((r) => r.id));
|
|
87
|
+
const beforeNotes = new Map(before.map((r) => [r.id, r.note]));
|
|
88
|
+
for (const r of after) {
|
|
89
|
+
if (!beforeIds.has(r.id))
|
|
90
|
+
summary.refinements.added.push(r.note);
|
|
91
|
+
}
|
|
92
|
+
for (const [id, note] of beforeNotes) {
|
|
93
|
+
if (!afterIds.has(id))
|
|
94
|
+
summary.refinements.removed.push(note);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// ---- Render ----------------------------------------------------------------
|
|
98
|
+
if (summary.configChanged) {
|
|
99
|
+
console.log(ui.note("whale.config.json changed"));
|
|
100
|
+
}
|
|
101
|
+
const { decisions: d } = summary;
|
|
102
|
+
if (d.added.length || d.removed.length || d.modified.length) {
|
|
103
|
+
console.log(ui.section("Decisions"));
|
|
104
|
+
for (const t of d.added)
|
|
105
|
+
console.log(ui.ok(`+ ${t}`));
|
|
106
|
+
for (const t of d.removed)
|
|
107
|
+
console.log(ui.fail(`- ${t}`));
|
|
108
|
+
for (const t of d.modified)
|
|
109
|
+
console.log(ui.warn(`~ ${t}`));
|
|
110
|
+
}
|
|
111
|
+
const { components: c } = summary;
|
|
112
|
+
if (c.added.length || c.removed.length) {
|
|
113
|
+
console.log(ui.section("Components"));
|
|
114
|
+
for (const n of c.added)
|
|
115
|
+
console.log(ui.ok(`+ ${n}`));
|
|
116
|
+
for (const n of c.removed)
|
|
117
|
+
console.log(ui.fail(`- ${n}`));
|
|
118
|
+
}
|
|
119
|
+
const { refinements: r } = summary;
|
|
120
|
+
if (r.added.length || r.removed.length) {
|
|
121
|
+
console.log(ui.section("Refinements"));
|
|
122
|
+
for (const n of r.added)
|
|
123
|
+
console.log(ui.ok(`+ ${n}`));
|
|
124
|
+
for (const n of r.removed)
|
|
125
|
+
console.log(ui.fail(`- ${n}`));
|
|
126
|
+
}
|
|
127
|
+
}
|
package/dist/commands/ignite.js
CHANGED
|
@@ -5,6 +5,9 @@ import { loadConfig, saveConfig, DEFAULT_CONFIG } from "../utils/config.js";
|
|
|
5
5
|
import { getPack, listPacks } from "../utils/registry.js";
|
|
6
6
|
import { validateCss } from "../validators/cssValidator.js";
|
|
7
7
|
import { generateWiki } from "../generators/wikiGenerator.js";
|
|
8
|
+
import { appendDecision } from "../utils/decisions.js";
|
|
9
|
+
import { getAiAvailability } from "../utils/aiAvailability.js";
|
|
10
|
+
import { mapWizardAnswers, suggestUiCategories } from "../utils/wizardMapping.js";
|
|
8
11
|
import { ui } from "../ui/index.js";
|
|
9
12
|
import { PACKAGE_VERSION } from "../version.js";
|
|
10
13
|
const OPINIONATED_PACKS = ["lighthouse", "forge", "scribe"];
|
|
@@ -20,8 +23,11 @@ export async function igniteCommand(projectName = "whale-project", options = {})
|
|
|
20
23
|
console.log();
|
|
21
24
|
// ---- Resolve config based on mode -----------------------------------------
|
|
22
25
|
let config;
|
|
26
|
+
let projectIntentNote;
|
|
23
27
|
if (mode === "interactive") {
|
|
24
|
-
|
|
28
|
+
const result = await runWizard(projectName);
|
|
29
|
+
config = result.config;
|
|
30
|
+
projectIntentNote = result.projectIntentNote;
|
|
25
31
|
}
|
|
26
32
|
else if (mode === "minimal") {
|
|
27
33
|
config = {
|
|
@@ -43,6 +49,16 @@ export async function igniteCommand(projectName = "whale-project", options = {})
|
|
|
43
49
|
const target = await initCommand(projectName, { config, silent: true });
|
|
44
50
|
const targetRel = path.relative(process.cwd(), target) || ".";
|
|
45
51
|
console.log(ui.ok(`Workspace created at ${ui.path(targetRel)}`));
|
|
52
|
+
// ---- Step 1b: record free-text project intent as first decision ------------
|
|
53
|
+
if (projectIntentNote) {
|
|
54
|
+
await appendDecision(target, {
|
|
55
|
+
title: "Project intent",
|
|
56
|
+
category: "product",
|
|
57
|
+
context: "Captured at project creation via `whale ignite --interactive`.",
|
|
58
|
+
decision: projectIntentNote
|
|
59
|
+
});
|
|
60
|
+
console.log(ui.ok("Project intent recorded as first decision."));
|
|
61
|
+
}
|
|
46
62
|
// ---- Step 2: confirm packs against registry -------------------------------
|
|
47
63
|
console.log();
|
|
48
64
|
console.log(ui.section("Packs"));
|
|
@@ -58,6 +74,9 @@ export async function igniteCommand(projectName = "whale-project", options = {})
|
|
|
58
74
|
validPacks.push(name);
|
|
59
75
|
packLines.push(`${ui.glyph.check} ${ui.code(name)} ${ui.muted(`(${pack.kind})`)} — ${pack.description}`);
|
|
60
76
|
}
|
|
77
|
+
if (packLines.length === 0) {
|
|
78
|
+
packLines.push(ui.muted("(no packs selected)"));
|
|
79
|
+
}
|
|
61
80
|
console.log(ui.indent(packLines.join("\n")));
|
|
62
81
|
finalConfig.packs = validPacks;
|
|
63
82
|
finalConfig.ignited = {
|
|
@@ -127,86 +146,133 @@ export async function igniteCommand(projectName = "whale-project", options = {})
|
|
|
127
146
|
console.log();
|
|
128
147
|
}
|
|
129
148
|
// ---------------------------------------------------------------------------
|
|
130
|
-
// Interactive wizard —
|
|
149
|
+
// Interactive wizard — human-language questions, technical config is derived.
|
|
131
150
|
// ---------------------------------------------------------------------------
|
|
132
151
|
async function runWizard(defaultName) {
|
|
133
152
|
console.log(ui.muted("Answer a few questions to shape your workspace. Ctrl+C to cancel."));
|
|
134
153
|
console.log();
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
{
|
|
164
|
-
{
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
154
|
+
const onCancel = () => {
|
|
155
|
+
console.log();
|
|
156
|
+
console.log(ui.warn("Ignite cancelled."));
|
|
157
|
+
process.exit(130);
|
|
158
|
+
};
|
|
159
|
+
// Q0: project name
|
|
160
|
+
const { projectName } = await prompts({ type: "text", name: "projectName", message: "Project name:", initial: defaultName }, { onCancel });
|
|
161
|
+
// Q1: what are you building?
|
|
162
|
+
const { projectType } = await prompts({
|
|
163
|
+
type: "select",
|
|
164
|
+
name: "projectType",
|
|
165
|
+
message: "What are you building?",
|
|
166
|
+
choices: [
|
|
167
|
+
{ title: "Web app", value: "web-app" },
|
|
168
|
+
{ title: "Marketing site", value: "marketing-site" },
|
|
169
|
+
{ title: "Portfolio", value: "portfolio" },
|
|
170
|
+
{ title: "Internal tool", value: "internal-tool" },
|
|
171
|
+
{ title: "Prototype", value: "prototype" },
|
|
172
|
+
{ title: "Something else", value: "other" }
|
|
173
|
+
]
|
|
174
|
+
}, { onCancel });
|
|
175
|
+
let projectTypeOther;
|
|
176
|
+
if (projectType === "other") {
|
|
177
|
+
const { freeText } = await prompts({ type: "text", name: "freeText", message: "Describe it briefly:" }, { onCancel });
|
|
178
|
+
projectTypeOther = freeText;
|
|
179
|
+
}
|
|
180
|
+
// Q2: stack — choices reordered by what's most likely for this project type
|
|
181
|
+
const appFirstChoices = [
|
|
182
|
+
{ title: "TypeScript + React", value: "react-ts" },
|
|
183
|
+
{ title: "TypeScript + Vue", value: "vue-ts" },
|
|
184
|
+
{ title: "TypeScript + Svelte", value: "svelte-ts" },
|
|
185
|
+
{ title: "Plain HTML + CSS + JS", value: "html-css-js" },
|
|
186
|
+
{ title: "Other", value: "other" }
|
|
187
|
+
];
|
|
188
|
+
const staticFirstChoices = [
|
|
189
|
+
{ title: "Plain HTML + CSS + JS", value: "html-css-js" },
|
|
190
|
+
{ title: "TypeScript + React", value: "react-ts" },
|
|
191
|
+
{ title: "TypeScript + Vue", value: "vue-ts" },
|
|
192
|
+
{ title: "TypeScript + Svelte", value: "svelte-ts" },
|
|
193
|
+
{ title: "Other", value: "other" }
|
|
194
|
+
];
|
|
195
|
+
const stackChoices = projectType === "web-app" || projectType === "internal-tool"
|
|
196
|
+
? appFirstChoices
|
|
197
|
+
: staticFirstChoices;
|
|
198
|
+
const { stack } = await prompts({
|
|
199
|
+
type: "select",
|
|
200
|
+
name: "stack",
|
|
201
|
+
message: "What language or framework?",
|
|
202
|
+
choices: stackChoices
|
|
203
|
+
}, { onCancel });
|
|
204
|
+
let stackOther;
|
|
205
|
+
if (stack === "other") {
|
|
206
|
+
const { freeText } = await prompts({ type: "text", name: "freeText", message: "Briefly describe it (we'll use plain CSS defaults):" }, { onCancel });
|
|
207
|
+
stackOther = freeText;
|
|
208
|
+
}
|
|
209
|
+
// Q3: team size
|
|
210
|
+
const { teamSize } = await prompts({
|
|
211
|
+
type: "select",
|
|
212
|
+
name: "teamSize",
|
|
213
|
+
message: "Who will work on this?",
|
|
214
|
+
choices: [
|
|
215
|
+
{ title: "Just me", value: "solo" },
|
|
216
|
+
{ title: "A small team (2–5 people)", value: "small" },
|
|
217
|
+
{ title: "A larger team (6+)", value: "large" }
|
|
218
|
+
]
|
|
219
|
+
}, { onCancel });
|
|
220
|
+
// Q4: UI references — only if AI is available
|
|
221
|
+
let uiCategories;
|
|
222
|
+
const ai = await getAiAvailability();
|
|
223
|
+
if (ai.available) {
|
|
224
|
+
const suggested = suggestUiCategories(projectType);
|
|
225
|
+
const { addRefs } = await prompts({
|
|
226
|
+
type: "confirm",
|
|
227
|
+
name: "addRefs",
|
|
228
|
+
message: "Add UI component references to this project?",
|
|
229
|
+
initial: true
|
|
230
|
+
}, { onCancel });
|
|
231
|
+
if (addRefs) {
|
|
232
|
+
const allCategories = ["forms", "navigation", "feedback", "surface", "layout", "data-display"];
|
|
233
|
+
const { selected } = await prompts({
|
|
234
|
+
type: "multiselect",
|
|
235
|
+
name: "selected",
|
|
236
|
+
message: "Which categories? (space to toggle, enter to confirm)",
|
|
237
|
+
instructions: false,
|
|
238
|
+
hint: "suggested for this project type are pre-selected",
|
|
239
|
+
choices: allCategories.map((cat) => ({
|
|
240
|
+
title: cat,
|
|
241
|
+
value: cat,
|
|
242
|
+
selected: suggested.includes(cat)
|
|
243
|
+
}))
|
|
244
|
+
}, { onCancel });
|
|
245
|
+
uiCategories = selected ?? suggested;
|
|
195
246
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
...DEFAULT_CONFIG,
|
|
199
|
-
projectName: answers.projectName,
|
|
200
|
-
projectType: answers.projectType,
|
|
201
|
-
stack: answers.stack,
|
|
202
|
-
packs: answers.packs && answers.packs.length > 0 ? answers.packs : OPINIONATED_PACKS,
|
|
203
|
-
aiTargets: answers.aiTargets && answers.aiTargets.length > 0 ? answers.aiTargets : ["claude"],
|
|
204
|
-
foundations: {
|
|
205
|
-
grid: answers.grid ?? 8,
|
|
206
|
-
radius: {
|
|
207
|
-
control: answers.radiusControl ?? 2,
|
|
208
|
-
container: answers.radiusContainer ?? 4
|
|
209
|
-
}
|
|
247
|
+
else {
|
|
248
|
+
uiCategories = [];
|
|
210
249
|
}
|
|
250
|
+
}
|
|
251
|
+
// Q5: agent packs
|
|
252
|
+
const allPacks = listPacks();
|
|
253
|
+
const { agentPacks } = await prompts({
|
|
254
|
+
type: "multiselect",
|
|
255
|
+
name: "agentPacks",
|
|
256
|
+
message: "Which agent packs to install?",
|
|
257
|
+
instructions: false,
|
|
258
|
+
hint: "space to toggle, enter to confirm",
|
|
259
|
+
choices: allPacks.map((p) => ({
|
|
260
|
+
title: `${p.name} — ${p.description}`,
|
|
261
|
+
value: p.name,
|
|
262
|
+
selected: p.name === "selene"
|
|
263
|
+
}))
|
|
264
|
+
}, { onCancel });
|
|
265
|
+
// Map human answers to technical config
|
|
266
|
+
const wizardAnswers = {
|
|
267
|
+
projectType,
|
|
268
|
+
projectTypeOther,
|
|
269
|
+
stack,
|
|
270
|
+
stackOther,
|
|
271
|
+
teamSize,
|
|
272
|
+
uiCategories,
|
|
273
|
+
agentPacks
|
|
211
274
|
};
|
|
275
|
+
const result = mapWizardAnswers(wizardAnswers);
|
|
276
|
+
result.config.projectName = projectName;
|
|
277
|
+
return result;
|
|
212
278
|
}
|
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
3
|
import ora from "ora";
|
|
4
|
+
import prompts from "prompts";
|
|
4
5
|
import { resolveTarget } from "../utils/paths.js";
|
|
5
6
|
import { loadConfig } from "../utils/config.js";
|
|
6
|
-
import { loadRefinements } from "../utils/refinements.js";
|
|
7
|
+
import { loadRefinements, appendRefinement } from "../utils/refinements.js";
|
|
7
8
|
import { loadDecisions } from "../utils/decisions.js";
|
|
8
9
|
import { loadComponents } from "../utils/components.js";
|
|
9
|
-
import { analyze } from "../analyzer/insights.js";
|
|
10
|
+
import { analyze, analyzeSpacingDrift, analyzeColorDrift, analyzeRadiiDrift } from "../analyzer/insights.js";
|
|
10
11
|
import { collectReferencedFiles, normalizePath } from "../analyzer/imports.js";
|
|
11
12
|
import { scanComponents } from "../scanner/componentScanner.js";
|
|
12
13
|
import { aggregateTailwind } from "../scanner/tailwindScanner.js";
|
|
14
|
+
import { extractFromCssFiles } from "../scanner/extractors/css.js";
|
|
15
|
+
import { extractFromInlineStyles } from "../scanner/extractors/inline.js";
|
|
16
|
+
import { extractFromStyleBlocks } from "../scanner/extractors/styleBlocks.js";
|
|
13
17
|
import { ui } from "../ui/index.js";
|
|
18
|
+
import { writeJsonFile } from "../utils/writeJson.js";
|
|
19
|
+
import { getAiAvailability } from "../utils/aiAvailability.js";
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
14
21
|
const SEVERITY_RANK = { info: 0, warning: 1, critical: 2 };
|
|
15
22
|
export async function insightsCommand(targetArg, options = {}) {
|
|
16
23
|
const target = resolveTarget(targetArg);
|
|
@@ -34,18 +41,28 @@ export async function insightsCommand(targetArg, options = {}) {
|
|
|
34
41
|
spin.succeed(`Loaded ${refinements.length} refinement(s), ${decisions.length} decision(s), ${components.length} component(s)`);
|
|
35
42
|
let referencedFiles;
|
|
36
43
|
let observations;
|
|
44
|
+
let styleObservations;
|
|
37
45
|
if (!options.skipScan) {
|
|
38
|
-
const spinScan = ora({ text: "Scanning source
|
|
46
|
+
const spinScan = ora({ text: "Scanning source files", stream: process.stderr }).start();
|
|
39
47
|
try {
|
|
40
|
-
const [refs, scanned] = await Promise.all([
|
|
48
|
+
const [refs, scanned, cssObs, inlineObs, blockObs] = await Promise.all([
|
|
49
|
+
collectReferencedFiles(target),
|
|
50
|
+
scanComponents(target),
|
|
51
|
+
extractFromCssFiles(target),
|
|
52
|
+
extractFromInlineStyles(target),
|
|
53
|
+
extractFromStyleBlocks(target)
|
|
54
|
+
]);
|
|
41
55
|
referencedFiles = refs;
|
|
42
56
|
const allClassStrings = scanned.flatMap((c) => c.classNames);
|
|
43
57
|
observations = allClassStrings.length > 0 ? aggregateTailwind(allClassStrings) : undefined;
|
|
44
|
-
|
|
45
|
-
|
|
58
|
+
styleObservations = [...cssObs, ...inlineObs, ...blockObs];
|
|
59
|
+
spinScan.succeed(`Scanned ${refs.size} import(s)` +
|
|
60
|
+
(observations ? `, ${observations.length} Tailwind class(es)` : "") +
|
|
61
|
+
`, ${styleObservations.length} style observation(s)`);
|
|
46
62
|
}
|
|
47
63
|
catch (err) {
|
|
48
|
-
|
|
64
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
65
|
+
spinScan.warn(`Scan failed: ${msg}. Continuing without drift insights.`);
|
|
49
66
|
}
|
|
50
67
|
}
|
|
51
68
|
const normalisedComponents = referencedFiles
|
|
@@ -57,10 +74,130 @@ export async function insightsCommand(targetArg, options = {}) {
|
|
|
57
74
|
decisions,
|
|
58
75
|
components: normalisedComponents,
|
|
59
76
|
observations,
|
|
60
|
-
referencedFiles
|
|
77
|
+
referencedFiles,
|
|
78
|
+
styleObservations
|
|
61
79
|
});
|
|
62
|
-
|
|
80
|
+
printOrEmit(insights, options);
|
|
81
|
+
if (!options.json) {
|
|
82
|
+
const ai = await getAiAvailability(target);
|
|
83
|
+
if (ai.available && insights.length > 0) {
|
|
84
|
+
console.log(ui.note("AI-generated UX hints available. Run `whale selene suggest --focus all` to get them."));
|
|
85
|
+
console.log();
|
|
86
|
+
}
|
|
87
|
+
else if (!ai.available) {
|
|
88
|
+
console.log(ui.muted("AI features available with Claude Code, Codex, or an API key."));
|
|
89
|
+
console.log();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export async function insightsDriftReviewCommand(category, opts) {
|
|
94
|
+
const validCategories = ["spacing", "color", "radii"];
|
|
95
|
+
if (!validCategories.includes(category)) {
|
|
96
|
+
console.log(ui.fail(`Unknown drift category: ${category}. Valid: ${validCategories.join(", ")}`));
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const cat = category;
|
|
101
|
+
const target = resolveTarget(opts.target);
|
|
102
|
+
console.log();
|
|
103
|
+
console.log(ui.header("Whale Igniter", `insights drift ${cat}`));
|
|
104
|
+
console.log();
|
|
105
|
+
const spin = ora({ text: "Scanning source files", stream: process.stderr }).start();
|
|
106
|
+
const [config, refinements, cssObs, inlineObs, blockObs] = await Promise.all([
|
|
107
|
+
loadConfig(target),
|
|
108
|
+
loadRefinements(target),
|
|
109
|
+
extractFromCssFiles(target),
|
|
110
|
+
extractFromInlineStyles(target),
|
|
111
|
+
extractFromStyleBlocks(target)
|
|
112
|
+
]);
|
|
113
|
+
const styleObservations = [...cssObs, ...inlineObs, ...blockObs];
|
|
114
|
+
spin.succeed(`${styleObservations.length} style observation(s) found`);
|
|
115
|
+
const driftInsights = cat === "spacing"
|
|
116
|
+
? analyzeSpacingDrift(styleObservations, config, refinements)
|
|
117
|
+
: cat === "color"
|
|
118
|
+
? analyzeColorDrift(styleObservations, config, refinements)
|
|
119
|
+
: analyzeRadiiDrift(styleObservations, config, refinements);
|
|
120
|
+
if (driftInsights.length === 0) {
|
|
121
|
+
console.log(ui.ok(`No ${cat} drift found.`));
|
|
122
|
+
console.log();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
console.log(ui.section(`${cat} drift — ${driftInsights.length} insight(s)`));
|
|
126
|
+
console.log();
|
|
127
|
+
if (!opts.review) {
|
|
128
|
+
for (const ins of driftInsights) {
|
|
129
|
+
console.log(ui.indent(ui.emphasis(ins.title)));
|
|
130
|
+
console.log(ui.indent(ui.muted(ins.detail), 1));
|
|
131
|
+
if (ins.evidence) {
|
|
132
|
+
for (const e of ins.evidence.slice(0, 5)) {
|
|
133
|
+
console.log(ui.indent(ui.dot(e), 2));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
console.log();
|
|
137
|
+
}
|
|
138
|
+
console.log(ui.muted("Use --review to enter the interactive review loop."));
|
|
139
|
+
console.log();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Interactive review loop
|
|
143
|
+
const refactorQueuePath = path.join(target, "intelligence", "refactor-queue.json");
|
|
144
|
+
const refactorQueue = (await fs.pathExists(refactorQueuePath))
|
|
145
|
+
? await fs.readJson(refactorQueuePath)
|
|
146
|
+
: [];
|
|
147
|
+
let reviewed = 0;
|
|
148
|
+
for (const ins of driftInsights) {
|
|
149
|
+
console.log(ui.section(ins.title));
|
|
150
|
+
console.log(ui.indent(ui.muted(ins.detail)));
|
|
151
|
+
if (ins.evidence) {
|
|
152
|
+
for (const e of ins.evidence)
|
|
153
|
+
console.log(ui.indent(ui.dot(e), 1));
|
|
154
|
+
}
|
|
155
|
+
console.log();
|
|
156
|
+
const { action } = await prompts({
|
|
157
|
+
type: "select",
|
|
158
|
+
name: "action",
|
|
159
|
+
message: "What would you like to do?",
|
|
160
|
+
choices: [
|
|
161
|
+
{ title: "Accept as refinement (suppress this drift)", value: "refine" },
|
|
162
|
+
{ title: "Flag for refactor (add to queue)", value: "queue" },
|
|
163
|
+
{ title: "Skip", value: "skip" },
|
|
164
|
+
{ title: "Quit review", value: "quit" }
|
|
165
|
+
]
|
|
166
|
+
});
|
|
167
|
+
if (!action || action === "quit")
|
|
168
|
+
break;
|
|
169
|
+
if (action === "refine") {
|
|
170
|
+
await appendRefinement(target, {
|
|
171
|
+
id: randomUUID(),
|
|
172
|
+
timestamp: new Date().toISOString(),
|
|
173
|
+
note: ins.title,
|
|
174
|
+
scope: { issueType: cat }
|
|
175
|
+
});
|
|
176
|
+
console.log(ui.ok("Recorded as refinement."));
|
|
177
|
+
}
|
|
178
|
+
else if (action === "queue") {
|
|
179
|
+
refactorQueue.push({
|
|
180
|
+
id: randomUUID(),
|
|
181
|
+
insightId: ins.id,
|
|
182
|
+
title: ins.title,
|
|
183
|
+
evidence: ins.evidence ?? [],
|
|
184
|
+
addedAt: new Date().toISOString()
|
|
185
|
+
});
|
|
186
|
+
console.log(ui.ok("Added to refactor queue."));
|
|
187
|
+
}
|
|
188
|
+
reviewed++;
|
|
189
|
+
console.log();
|
|
190
|
+
}
|
|
191
|
+
if (refactorQueue.length > 0) {
|
|
192
|
+
await fs.ensureDir(path.join(target, "intelligence"));
|
|
193
|
+
await writeJsonFile(refactorQueuePath, refactorQueue);
|
|
194
|
+
}
|
|
195
|
+
console.log(ui.accent(`Reviewed ${reviewed} insight(s).`));
|
|
196
|
+
console.log();
|
|
63
197
|
}
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// Shared rendering
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
64
201
|
function printOrEmit(insights, options) {
|
|
65
202
|
let filtered = insights;
|
|
66
203
|
if (options.category)
|