whale-igniter 1.2.3 → 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.
@@ -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 for imports and Tailwind usage", stream: process.stderr }).start();
46
+ const spinScan = ora({ text: "Scanning source files", stream: process.stderr }).start();
39
47
  try {
40
- const [refs, scanned] = await Promise.all([collectReferencedFiles(target), scanComponents(target)]);
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
- spinScan.succeed(`Scanned ${refs.size} import reference(s)` +
45
- (observations ? ` and ${observations.length} class observation(s)` : ""));
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
- spinScan.warn(`Scan failed: ${err?.message ?? err}. Continuing without orphan/drift insights.`);
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
- return printOrEmit(insights, options);
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)
@@ -0,0 +1,193 @@
1
+ import path from "node:path";
2
+ import fs from "fs-extra";
3
+ import { resolveTarget } from "../utils/paths.js";
4
+ import { loadConfig, saveConfig } from "../utils/config.js";
5
+ import { generateWiki } from "../generators/wikiGenerator.js";
6
+ import { ui } from "../ui/index.js";
7
+ import prompts from "prompts";
8
+ const VALID_CATEGORIES = ["forms", "navigation", "feedback", "surface", "layout", "data-display"];
9
+ function shippedReferencesDir() {
10
+ // Resolve relative to this file's location: src/commands/ → root → ui-references/
11
+ const thisFile = new URL(import.meta.url).pathname;
12
+ return path.resolve(path.dirname(thisFile), "../../ui-references");
13
+ }
14
+ function projectReferencesDir(target) {
15
+ return path.join(target, "references");
16
+ }
17
+ // ---------------------------------------------------------------------------
18
+ // `whale references list`
19
+ // ---------------------------------------------------------------------------
20
+ export async function referencesListCommand(targetArg) {
21
+ const target = resolveTarget(targetArg);
22
+ const config = await loadConfig(target);
23
+ const refDir = projectReferencesDir(target);
24
+ console.log();
25
+ console.log(ui.header("UI References", "list"));
26
+ console.log();
27
+ const configuredCategories = config.uiReferences ?? [];
28
+ const rows = [];
29
+ for (const cat of VALID_CATEGORIES) {
30
+ const filePath = path.join(refDir, `${cat}.md`);
31
+ const present = await fs.pathExists(filePath);
32
+ const configured = configuredCategories.includes(cat);
33
+ const status = present ? ui.ok("present") : ui.muted("not copied");
34
+ const flag = configured ? ui.glyph.check : " ";
35
+ rows.push([flag, ui.code(cat), status]);
36
+ }
37
+ const indexPath = path.join(refDir, "INDEX.md");
38
+ const indexPresent = await fs.pathExists(indexPath);
39
+ console.log(ui.section("Categories"));
40
+ for (const [flag, cat, status] of rows) {
41
+ console.log(` ${flag} ${cat.padEnd(16)} ${status}`);
42
+ }
43
+ console.log();
44
+ console.log(ui.kv("references/INDEX.md", indexPresent ? ui.ok("present") : ui.muted("not copied")));
45
+ if (configuredCategories.length === 0 && !indexPresent) {
46
+ console.log();
47
+ console.log(ui.note("No references installed yet."));
48
+ console.log(ui.indent(ui.muted("Run `whale references add <category>` or `whale references add --all`.")));
49
+ }
50
+ console.log();
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // `whale references add [category | --all]`
54
+ // ---------------------------------------------------------------------------
55
+ export async function referencesAddCommand(category, opts) {
56
+ const target = resolveTarget(opts.target);
57
+ const shipped = shippedReferencesDir();
58
+ const refDir = projectReferencesDir(target);
59
+ console.log();
60
+ console.log(ui.header("UI References", opts.all ? "add --all" : `add ${category ?? ""}`));
61
+ console.log();
62
+ if (!opts.all && !category) {
63
+ console.log(ui.fail("Specify a category or use --all."));
64
+ console.log(ui.muted(` Valid categories: ${VALID_CATEGORIES.join(", ")}`));
65
+ process.exitCode = 1;
66
+ return;
67
+ }
68
+ const categoriesToAdd = opts.all
69
+ ? [...VALID_CATEGORIES]
70
+ : [category];
71
+ // Validate
72
+ for (const cat of categoriesToAdd) {
73
+ if (!VALID_CATEGORIES.includes(cat)) {
74
+ console.log(ui.fail(`Unknown category: ${cat}`));
75
+ console.log(ui.muted(` Valid: ${VALID_CATEGORIES.join(", ")}`));
76
+ process.exitCode = 1;
77
+ return;
78
+ }
79
+ }
80
+ await fs.ensureDir(refDir);
81
+ // Always copy INDEX.md
82
+ const shippedIndex = path.join(shipped, "INDEX.md");
83
+ const projectIndex = path.join(refDir, "INDEX.md");
84
+ if (!(await fs.pathExists(projectIndex))) {
85
+ await fs.copy(shippedIndex, projectIndex);
86
+ console.log(ui.ok(`Copied references/INDEX.md`));
87
+ }
88
+ const copied = [];
89
+ const skipped = [];
90
+ for (const cat of categoriesToAdd) {
91
+ const src = path.join(shipped, `${cat}.md`);
92
+ const dest = path.join(refDir, `${cat}.md`);
93
+ if (!(await fs.pathExists(src))) {
94
+ console.log(ui.warn(`Shipped file not found for category: ${cat}`));
95
+ continue;
96
+ }
97
+ if (await fs.pathExists(dest)) {
98
+ skipped.push(cat);
99
+ continue;
100
+ }
101
+ await fs.copy(src, dest);
102
+ copied.push(cat);
103
+ console.log(ui.ok(`Copied references/${cat}.md`));
104
+ }
105
+ if (skipped.length > 0) {
106
+ console.log();
107
+ console.log(ui.note(`Skipped (already present): ${skipped.join(", ")}. Run \`whale references update\` to refresh.`));
108
+ }
109
+ // Update whale.config.json
110
+ const config = await loadConfig(target);
111
+ const existing = new Set(config.uiReferences ?? []);
112
+ for (const cat of copied)
113
+ existing.add(cat);
114
+ config.uiReferences = Array.from(existing).sort();
115
+ await saveConfig(target, config);
116
+ // Regenerate CLAUDE.md to include the references section
117
+ if (copied.length > 0) {
118
+ await generateWiki(target);
119
+ console.log();
120
+ console.log(ui.ok("CLAUDE.md updated with references section."));
121
+ }
122
+ console.log();
123
+ console.log(ui.info(`AI tools can now read references/ — consult references/INDEX.md for navigation.`));
124
+ console.log();
125
+ }
126
+ // ---------------------------------------------------------------------------
127
+ // `whale references update`
128
+ // ---------------------------------------------------------------------------
129
+ export async function referencesUpdateCommand(targetArg) {
130
+ const target = resolveTarget(targetArg);
131
+ const shipped = shippedReferencesDir();
132
+ const refDir = projectReferencesDir(target);
133
+ console.log();
134
+ console.log(ui.header("UI References", "update"));
135
+ console.log();
136
+ if (!(await fs.pathExists(refDir))) {
137
+ console.log(ui.warn("No references/ directory found in this project."));
138
+ console.log(ui.muted(" Run `whale references add <category>` first."));
139
+ return;
140
+ }
141
+ const config = await loadConfig(target);
142
+ const configuredCategories = config.uiReferences ?? [];
143
+ const allFilesToCheck = ["INDEX", ...configuredCategories.map((c) => c)];
144
+ for (const slug of allFilesToCheck) {
145
+ const filename = slug === "INDEX" ? "INDEX.md" : `${slug}.md`;
146
+ const src = path.join(shipped, filename);
147
+ const dest = path.join(refDir, filename);
148
+ if (!(await fs.pathExists(src)))
149
+ continue;
150
+ if (!(await fs.pathExists(dest))) {
151
+ await fs.copy(src, dest);
152
+ console.log(ui.ok(`Added ${filename} (was missing)`));
153
+ continue;
154
+ }
155
+ // Compare content — only prompt if modified
156
+ const [srcContent, destContent] = await Promise.all([
157
+ fs.readFile(src, "utf-8"),
158
+ fs.readFile(dest, "utf-8")
159
+ ]);
160
+ if (srcContent === destContent) {
161
+ console.log(` ${ui.glyph.check} ${filename} is up to date`);
162
+ continue;
163
+ }
164
+ // File differs — prompt
165
+ const { action } = await prompts({
166
+ type: "select",
167
+ name: "action",
168
+ message: `references/${filename} has local modifications. What would you like to do?`,
169
+ choices: [
170
+ { title: "Keep my version", value: "keep" },
171
+ { title: "Overwrite with shipped version", value: "overwrite" },
172
+ { title: "Show diff summary", value: "diff" }
173
+ ]
174
+ });
175
+ if (action === "overwrite") {
176
+ await fs.copy(src, dest);
177
+ console.log(ui.ok(`Overwritten: references/${filename}`));
178
+ }
179
+ else if (action === "diff") {
180
+ const srcLines = srcContent.split("\n").length;
181
+ const destLines = destContent.split("\n").length;
182
+ console.log(ui.note(`Shipped: ${srcLines} lines. Your version: ${destLines} lines. ` +
183
+ `Use a diff tool to compare: diff references/${filename} <(whale references --shipped ${slug})`));
184
+ console.log(ui.muted(" Keeping your version."));
185
+ }
186
+ else {
187
+ console.log(ui.muted(` Kept: references/${filename}`));
188
+ }
189
+ }
190
+ console.log();
191
+ console.log(ui.accent("References are up to date."));
192
+ console.log();
193
+ }
@@ -6,6 +6,7 @@ import { loadConfig } from "../utils/config.js";
6
6
  import { loadDecisions } from "../utils/decisions.js";
7
7
  import { loadComponents } from "../utils/components.js";
8
8
  import { loadRefinements } from "../utils/refinements.js";
9
+ import { getAiAvailability } from "../utils/aiAvailability.js";
9
10
  import { ui } from "../ui/index.js";
10
11
  /**
11
12
  * `whale sync` regenerates everything an AI agent reads, from the
@@ -40,4 +41,11 @@ export async function syncCommand(targetArg) {
40
41
  console.log();
41
42
  console.log(ui.accent("AI agents will now see the latest project state."));
42
43
  console.log(ui.muted(`Targets configured: ${(config.aiTargets ?? []).join(", ") || "(none)"}`));
44
+ if (config.enrichWithAi) {
45
+ const ai = await getAiAvailability(target);
46
+ if (ai.available) {
47
+ console.log();
48
+ console.log(ui.note("Scribe enrichment enabled (config.enrichWithAi=true). Run `whale selene suggest` to enrich wiki prose."));
49
+ }
50
+ }
43
51
  }