whale-igniter 1.5.2 → 1.5.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to Whale Igniter are documented here.
4
4
 
5
+ ## 1.5.3 - 2026-07-06
6
+
7
+ ### Added
8
+
9
+ - Added shared target-directory guardrails for commands that write files or scan projects, blocking home/root-level targets and requiring `--force` when no `.git` directory or `package.json` is present.
10
+ - Added grouped validation summaries for CLI and generated docs, plus `--full` for exhaustive validation output and generated context health warnings when validation scope looks misconfigured.
11
+
5
12
  ## 1.5.2 - 2026-06-10
6
13
 
7
14
  ### Changed
@@ -3,6 +3,7 @@ import fs from "fs-extra";
3
3
  import ora from "ora";
4
4
  import prompts from "prompts";
5
5
  import { resolveTarget } from "../utils/paths.js";
6
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
6
7
  import { scanComponents } from "../scanner/componentScanner.js";
7
8
  import { aggregateTailwind, detectTailwindConfig } from "../scanner/tailwindScanner.js";
8
9
  import { inferFoundations } from "../scanner/foundationInferrer.js";
@@ -13,6 +14,9 @@ import { ui } from "../ui/index.js";
13
14
  const SCANNER_VERSION = "1.1.0";
14
15
  export async function adoptCommand(targetArg, options = {}) {
15
16
  const target = resolveTarget(targetArg);
17
+ if (!(await guardTargetDirectory(target, { force: options.force, command: "adopt" }))) {
18
+ return;
19
+ }
16
20
  console.log();
17
21
  console.log(ui.header("Whale Igniter", `adopt • ${path.relative(process.cwd(), target) || "."}`));
18
22
  console.log();
@@ -5,20 +5,26 @@ import { syncCommand } from "./sync.js";
5
5
  import { validateCommand } from "./validate.js";
6
6
  import { wikiCommand } from "./wiki.js";
7
7
  import { ui } from "../ui/index.js";
8
- export async function rememberCommand(target) {
8
+ import { resolveTarget } from "../utils/paths.js";
9
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
10
+ export async function rememberCommand(target, options = {}) {
9
11
  console.log();
10
12
  console.log(ui.header("Whale Igniter", "remember"));
11
13
  console.log(ui.muted("Updating project memory for AI assistants."));
12
- await syncCommand(target);
14
+ await syncCommand(target, options);
13
15
  }
14
- export async function checkCommand(target) {
16
+ export async function checkCommand(target, options = {}) {
15
17
  console.log();
16
18
  console.log(ui.header("Whale Igniter", "check"));
17
19
  console.log(ui.muted("Checking project health, then looking for useful recommendations."));
18
20
  console.log();
19
- await validateCommand(target);
21
+ const resolvedTarget = resolveTarget(target);
22
+ if (!(await guardTargetDirectory(resolvedTarget, { force: options.force, command: "check" }))) {
23
+ return;
24
+ }
25
+ await validateCommand(target, { ...options, skipTargetGuard: true });
20
26
  const validationExit = process.exitCode;
21
- await insightsCommand(target, {});
27
+ await insightsCommand(target, { ...options, skipTargetGuard: true });
22
28
  if (validationExit !== undefined) {
23
29
  process.exitCode = validationExit;
24
30
  }
@@ -7,6 +7,7 @@ import { validateCss } from "../validators/cssValidator.js";
7
7
  import { generateWiki } from "../generators/wikiGenerator.js";
8
8
  import { appendDecision } from "../utils/decisions.js";
9
9
  import { getAiAvailability } from "../utils/aiAvailability.js";
10
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
10
11
  import { mapWizardAnswers, suggestUiCategories } from "../utils/wizardMapping.js";
11
12
  import { ui } from "../ui/index.js";
12
13
  import { PACKAGE_VERSION } from "../version.js";
@@ -49,7 +50,11 @@ export async function igniteCommand(projectName = "whale-project", options = {})
49
50
  }
50
51
  // ---- Step 1: scaffold workspace -------------------------------------------
51
52
  const resolvedProjectName = config.projectName ?? projectName;
52
- const target = await initCommand(resolvedProjectName, { config, silent: true });
53
+ const plannedTarget = path.resolve(process.cwd(), resolvedProjectName);
54
+ if (!(await guardTargetDirectory(plannedTarget, { force: options.force, command: "ignite" }))) {
55
+ return;
56
+ }
57
+ const target = await initCommand(resolvedProjectName, { config, silent: true, skipTargetGuard: true });
53
58
  const targetRel = path.relative(process.cwd(), target) || ".";
54
59
  console.log(ui.ok(`Workspace created at ${ui.path(targetRel)}`));
55
60
  // ---- Step 1b: record free-text project intent as first decision ------------
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import fs from "fs-extra";
3
3
  import ora from "ora";
4
4
  import { DEFAULT_CONFIG, saveConfig } from "../utils/config.js";
5
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
5
6
  import { ui } from "../ui/index.js";
6
7
  /**
7
8
  * Folders Whale always expects to exist. Created empty so subsequent
@@ -41,6 +42,9 @@ async function ensureIntelligenceFiles(target) {
41
42
  }
42
43
  export async function initCommand(projectName = "whale-project", options = {}) {
43
44
  const target = path.resolve(process.cwd(), projectName);
45
+ if (!options.skipTargetGuard && !(await guardTargetDirectory(target, { force: options.force, command: "init" }))) {
46
+ return target;
47
+ }
44
48
  const spinner = options.silent
45
49
  ? null
46
50
  : ora(`Scaffolding ${projectName}`).start();
@@ -3,6 +3,7 @@ import fs from "fs-extra";
3
3
  import ora from "ora";
4
4
  import prompts from "prompts";
5
5
  import { resolveTarget } from "../utils/paths.js";
6
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
6
7
  import { loadConfig } from "../utils/config.js";
7
8
  import { loadRefinements, appendRefinement } from "../utils/refinements.js";
8
9
  import { loadDecisions } from "../utils/decisions.js";
@@ -21,6 +22,9 @@ import { randomUUID } from "node:crypto";
21
22
  const SEVERITY_RANK = { info: 0, warning: 1, critical: 2 };
22
23
  export async function insightsCommand(targetArg, options = {}) {
23
24
  const target = resolveTarget(targetArg);
25
+ if (!options.skipTargetGuard && !(await guardTargetDirectory(target, { force: options.force, command: "insights" }))) {
26
+ return;
27
+ }
24
28
  if (!(await fs.pathExists(path.join(target, "whale.config.json")))) {
25
29
  console.log();
26
30
  console.log(ui.warn("No whale.config.json found at this target. Run `whale ignite` or `whale adopt` first."));
@@ -99,6 +103,9 @@ export async function insightsDriftReviewCommand(category, opts) {
99
103
  }
100
104
  const cat = category;
101
105
  const target = resolveTarget(opts.target);
106
+ if (!(await guardTargetDirectory(target, { force: opts.force, command: "insights drift" }))) {
107
+ return;
108
+ }
102
109
  console.log();
103
110
  console.log(ui.header("Whale Igniter", `insights drift ${cat}`));
104
111
  console.log();
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import ora from "ora";
3
3
  import { resolveTarget } from "../utils/paths.js";
4
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
4
5
  import { generateWiki } from "../generators/wikiGenerator.js";
5
6
  import { loadConfig } from "../utils/config.js";
6
7
  import { loadDecisions } from "../utils/decisions.js";
@@ -17,8 +18,11 @@ import { ui } from "../ui/index.js";
17
18
  * - before handing the project to an AI agent
18
19
  * - in CI to ensure CLAUDE.md stays in sync
19
20
  */
20
- export async function syncCommand(targetArg) {
21
+ export async function syncCommand(targetArg, options = {}) {
21
22
  const target = resolveTarget(targetArg);
23
+ if (!(await guardTargetDirectory(target, { force: options.force, command: "sync" }))) {
24
+ return;
25
+ }
22
26
  const spinner = ora("Reading intelligence stores").start();
23
27
  const config = await loadConfig(target);
24
28
  const decisions = await loadDecisions(target);
@@ -1,9 +1,14 @@
1
1
  import ora from "ora";
2
2
  import { resolveTarget } from "../utils/paths.js";
3
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
3
4
  import { validateCss, hasErrors } from "../validators/cssValidator.js";
5
+ import { formatIssueLocation, summarizeValidationIssues } from "../utils/validationSummary.js";
4
6
  import { ui } from "../ui/index.js";
5
- export async function validateCommand(targetArg) {
7
+ export async function validateCommand(targetArg, options = {}) {
6
8
  const target = resolveTarget(targetArg);
9
+ if (!options.skipTargetGuard && !(await guardTargetDirectory(target, { force: options.force, command: "validate" }))) {
10
+ return;
11
+ }
7
12
  const spinner = ora("Scanning workspace").start();
8
13
  let issues;
9
14
  try {
@@ -23,7 +28,33 @@ export async function validateCommand(targetArg) {
23
28
  process.exitCode = 0;
24
29
  return;
25
30
  }
26
- // Header with the counts as badges.
31
+ if (!options.full) {
32
+ const summary = summarizeValidationIssues(issues);
33
+ console.log();
34
+ console.log(ui.section("Top issue groups"));
35
+ console.log();
36
+ for (const group of summary.shownGroups) {
37
+ const head = group.severity === "error"
38
+ ? `${ui.glyph.cross} ${ui.danger(group.type)}`
39
+ : `${ui.glyph.warn} ${ui.warning(group.type)}`;
40
+ console.log(`${head} ${ui.muted(`- ${group.count} issue(s)`)}`);
41
+ console.log(` example: ${ui.path(formatIssueLocation(group.example))}`);
42
+ console.log(` ${group.message}`);
43
+ console.log(` ${ui.next(group.example.suggestion)}`);
44
+ if (group.example.selector) {
45
+ console.log(` ${ui.muted("selector: " + group.example.selector)}`);
46
+ }
47
+ console.log();
48
+ }
49
+ if (summary.hiddenIssueCount > 0) {
50
+ console.log(ui.muted(`+ ${summary.hiddenIssueCount} more across ${summary.hiddenFileCount} files - run \`whale check --full\` for everything`));
51
+ console.log();
52
+ }
53
+ process.exitCode = hasErrors(issues) ? 1 : 0;
54
+ return;
55
+ }
56
+ // Header with the counts as badges. Kept for --full, which preserves the
57
+ // older exhaustive output for users who explicitly ask for everything.
27
58
  console.log();
28
59
  console.log(ui.summary([
29
60
  { label: "issues", value: issues.length },
@@ -1,10 +1,14 @@
1
1
  import path from "node:path";
2
2
  import ora from "ora";
3
3
  import { resolveTarget } from "../utils/paths.js";
4
+ import { guardTargetDirectory } from "../utils/targetGuard.js";
4
5
  import { generateWiki } from "../generators/wikiGenerator.js";
5
6
  import { ui } from "../ui/index.js";
6
- export async function wikiCommand(targetArg) {
7
+ export async function wikiCommand(targetArg, options = {}) {
7
8
  const target = resolveTarget(targetArg);
9
+ if (!(await guardTargetDirectory(target, { force: options.force, command: "wiki" }))) {
10
+ return;
11
+ }
8
12
  const spinner = ora("Generating AI context").start();
9
13
  const { rootFiles, wikiFiles } = await generateWiki(target);
10
14
  spinner.succeed("AI context generated");
@@ -3,6 +3,7 @@ import fs from "fs-extra";
3
3
  import { loadConfig } from "../utils/config.js";
4
4
  import { loadRefinements } from "../utils/refinements.js";
5
5
  import { validateCss } from "../validators/cssValidator.js";
6
+ import { renderValidationSummaryMarkdown, summarizeValidationIssues } from "../utils/validationSummary.js";
6
7
  async function buildContext(target) {
7
8
  const config = await loadConfig(target);
8
9
  const refinements = await loadRefinements(target);
@@ -27,11 +28,20 @@ function renderForge(ctx) {
27
28
  "- All interactive elements MUST have a `:focus-visible` style.",
28
29
  "- Color values MUST come from semantic tokens (`var(--color-*)`).",
29
30
  "",
30
- `## Open Issues (${issues.length})`,
31
+ "## Open Issues",
31
32
  ""
32
33
  ];
33
- for (const issue of issues) {
34
- lines.push(`- \`${issue.file}:${issue.line}\` ${issue.type}: ${issue.message}`);
34
+ if (issues.length === 0) {
35
+ lines.push("No validation issues found.");
36
+ }
37
+ else {
38
+ const summary = summarizeValidationIssues(issues);
39
+ for (const group of summary.shownGroups) {
40
+ lines.push(`- **${group.type}** (${group.severity.toUpperCase()}) - ${group.count} issue(s); example \`${group.example.file}:${group.example.line}\` - ${group.message}`);
41
+ }
42
+ if (summary.hiddenIssueCount > 0) {
43
+ lines.push(`- + ${summary.hiddenIssueCount} more across ${summary.hiddenFileCount} files - run \`whale check --full\` for everything.`);
44
+ }
35
45
  }
36
46
  return lines.join("\n") + "\n";
37
47
  }
@@ -59,13 +69,11 @@ function renderScribe(ctx) {
59
69
  lines.push("");
60
70
  lines.push(`## Current Validation Status`);
61
71
  lines.push("");
62
- lines.push(`${issues.length} issue(s) open. Run \`whale validate\` for details.`);
72
+ lines.push(renderValidationSummaryMarkdown(issues));
63
73
  return lines.join("\n") + "\n";
64
74
  }
65
75
  function renderAtlas(ctx) {
66
76
  const { config, refinements, issues } = ctx;
67
- const errors = issues.filter((i) => i.severity === "error").length;
68
- const warnings = issues.filter((i) => i.severity === "warning").length;
69
77
  const lines = [
70
78
  "# Atlas — Product Strategy",
71
79
  "",
@@ -77,10 +85,10 @@ function renderAtlas(ctx) {
77
85
  `- Accent: ${config.branding?.accent ?? "unspecified"}`,
78
86
  "",
79
87
  "## Operational Health",
80
- `- Errors: ${errors}`,
81
- `- Warnings: ${warnings}`,
82
88
  `- Decisions logged: ${refinements.length}`,
83
89
  "",
90
+ renderValidationSummaryMarkdown(issues),
91
+ "",
84
92
  "## Decision Log",
85
93
  ""
86
94
  ];
@@ -1,11 +1,10 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
3
  import { validateCss } from "../validators/cssValidator.js";
4
+ import { renderValidationSummaryMarkdown } from "../utils/validationSummary.js";
4
5
  export async function generateValidationReport(target) {
5
6
  const issues = await validateCss(target);
6
7
  const reportPath = path.join(target, "docs", "validation-report.md");
7
- const errors = issues.filter((i) => i.severity === "error");
8
- const warnings = issues.filter((i) => i.severity === "warning");
9
8
  const lines = [
10
9
  "# Whale Validation Report",
11
10
  "",
@@ -13,37 +12,12 @@ export async function generateValidationReport(target) {
13
12
  "",
14
13
  "## Summary",
15
14
  "",
16
- `- Total issues: **${issues.length}**`,
17
- `- Errors: **${errors.length}**`,
18
- `- Warnings: **${warnings.length}**`,
15
+ renderValidationSummaryMarkdown(issues),
19
16
  ""
20
17
  ];
21
18
  if (issues.length === 0) {
22
19
  lines.push("✓ Clean run.");
23
20
  }
24
- else {
25
- // Group by file so the report is actionable.
26
- const byFile = new Map();
27
- for (const issue of issues) {
28
- const arr = byFile.get(issue.file) ?? [];
29
- arr.push(issue);
30
- byFile.set(issue.file, arr);
31
- }
32
- for (const [file, fileIssues] of byFile) {
33
- lines.push(`## ${file}`);
34
- lines.push("");
35
- for (const issue of fileIssues) {
36
- const tag = issue.severity === "error" ? "ERROR" : "WARNING";
37
- lines.push(`- **${tag}** (${issue.type}) at line ${issue.line}`);
38
- lines.push(` - ${issue.message}`);
39
- lines.push(` - Fix: ${issue.suggestion}`);
40
- if (issue.selector) {
41
- lines.push(` - Selector: \`${issue.selector}\``);
42
- }
43
- }
44
- lines.push("");
45
- }
46
- }
47
21
  await fs.ensureDir(path.dirname(reportPath));
48
22
  await fs.writeFile(reportPath, lines.join("\n"));
49
23
  return reportPath;
@@ -4,7 +4,8 @@ import { loadConfig } from "../utils/config.js";
4
4
  import { loadRefinements } from "../utils/refinements.js";
5
5
  import { loadDecisions } from "../utils/decisions.js";
6
6
  import { loadComponents } from "../utils/components.js";
7
- import { validateCss } from "../validators/cssValidator.js";
7
+ import { findCssValidationFiles, validateCss } from "../validators/cssValidator.js";
8
+ import { evaluateValidationHealth, renderValidationHealthWarningMarkdown, renderValidationSummaryMarkdown } from "../utils/validationSummary.js";
8
9
  import { renderFoundations } from "../templates/foundations.js";
9
10
  import { renderConventions } from "../templates/conventions.js";
10
11
  import { renderDecisions } from "../templates/decisions.js";
@@ -16,14 +17,19 @@ export async function generateWiki(target) {
16
17
  const refinements = await loadRefinements(target);
17
18
  const decisions = await loadDecisions(target);
18
19
  const components = await loadComponents(target);
19
- const issues = await validateCss(target);
20
+ const [issues, scannedFiles] = await Promise.all([
21
+ validateCss(target),
22
+ findCssValidationFiles(target)
23
+ ]);
20
24
  const errors = issues.filter((i) => i.severity === "error").length;
21
25
  const warnings = issues.filter((i) => i.severity === "warning").length;
26
+ const validationSummary = renderValidationSummaryMarkdown(issues);
27
+ const healthWarning = renderValidationHealthWarningMarkdown(evaluateValidationHealth(issues, scannedFiles.length, config));
22
28
  const wikiDir = path.join(target, "llm-wiki");
23
29
  await fs.ensureDir(wikiDir);
24
30
  const themedFiles = {
25
31
  "README.md": renderWikiReadme(config),
26
- "PROJECT.md": renderProject(config, errors, warnings, refinements.length, decisions.length),
32
+ "PROJECT.md": renderProject(config, validationSummary, healthWarning, refinements.length, decisions.length),
27
33
  "FOUNDATIONS.md": renderFoundations(config, decisions),
28
34
  "CONVENTIONS.md": renderConventions(config, refinements),
29
35
  "DECISIONS.md": renderDecisions(decisions),
@@ -39,6 +45,8 @@ export async function generateWiki(target) {
39
45
  const rootIndex = renderRootIndex(config, {
40
46
  errors,
41
47
  warnings,
48
+ validationSummary,
49
+ healthWarning,
42
50
  refinementCount: refinements.length,
43
51
  decisionCount: decisions.length,
44
52
  componentCount: components.length
package/dist/index.js CHANGED
@@ -39,25 +39,31 @@ program
39
39
  .description("Bootstrap a Whale workspace with AI-readable context (opinionated by default).")
40
40
  .option("-i, --interactive", "Run an interactive wizard to configure stack, packs, AI targets")
41
41
  .option("-m, --minimal", "Minimal scaffold: folders + config + one validator pack")
42
+ .option("--force", "Proceed even when the target does not look like a project directory")
42
43
  .action((projectName, opts) => igniteCommand(projectName, opts));
43
44
  program
44
45
  .command("init [projectName]")
45
46
  .description("Create an empty Whale workspace (no packs, no AI context).")
46
- .action(async (projectName) => {
47
- await initCommand(projectName);
47
+ .option("--force", "Proceed even when the target does not look like a project directory")
48
+ .action(async (projectName, opts) => {
49
+ await initCommand(projectName, opts);
48
50
  });
49
51
  program
50
52
  .command("sync [target]")
51
53
  .description("Regenerate CLAUDE.md and the LLM Wiki from project intelligence.")
52
- .action(syncCommand);
54
+ .option("--force", "Proceed even when the target does not look like a project directory")
55
+ .action((target, opts) => syncCommand(target, opts));
53
56
  program
54
57
  .command("remember [target]")
55
58
  .description("Update project memory for AI assistants (friendly alias for sync).")
56
- .action((target) => rememberCommand(target));
59
+ .option("--force", "Proceed even when the target does not look like a project directory")
60
+ .action((target, opts) => rememberCommand(target, opts));
57
61
  program
58
62
  .command("check [target]")
59
63
  .description("Check project health and recommendations (validate + insights).")
60
- .action((target) => checkCommand(target));
64
+ .option("--force", "Proceed even when the target does not look like a project directory")
65
+ .option("--full", "Show every validation issue instead of grouped summaries")
66
+ .action((target, opts) => checkCommand(target, opts));
61
67
  program
62
68
  .command("improve")
63
69
  .description("Ask Selene for project-wide improvement suggestions.")
@@ -73,7 +79,9 @@ program
73
79
  program
74
80
  .command("validate [target]")
75
81
  .description("Run operational validations (exits non-zero on errors).")
76
- .action(validateCommand);
82
+ .option("--force", "Proceed even when the target does not look like a project directory")
83
+ .option("--full", "Show every validation issue instead of grouped summaries")
84
+ .action((target, opts) => validateCommand(target, opts));
77
85
  program
78
86
  .command("docs [target]")
79
87
  .description("Generate validation report and run installed generator packs.")
@@ -81,7 +89,8 @@ program
81
89
  program
82
90
  .command("wiki [target]")
83
91
  .description("Regenerate AI context (alias of `sync`).")
84
- .action(wikiCommand);
92
+ .option("--force", "Proceed even when the target does not look like a project directory")
93
+ .action((target, opts) => wikiCommand(target, opts));
85
94
  program
86
95
  .command("refine <note>")
87
96
  .description("Record an operational refinement. Mention an issue type to suppress matching issues.")
@@ -157,12 +166,14 @@ const insightsCmd = program
157
166
  .option("--category <name>", "Filter by category: refinements | decisions | components | foundations | tokens | coverage")
158
167
  .option("--min-severity <level>", "Show only insights at this severity or higher (info | warning | critical)")
159
168
  .option("--skip-scan", "Skip the source scan (faster, but disables orphan/drift insights)")
169
+ .option("--force", "Proceed even when the target does not look like a project directory")
160
170
  .action((target, opts) => insightsCommand(target, opts));
161
171
  insightsCmd
162
172
  .command("drift <category>")
163
173
  .description("Show or review drift in spacing | color | radii.")
164
174
  .option("--review", "Enter interactive review loop: accept as refinement, flag for refactor, or skip")
165
175
  .option("--target <path>", "Workspace path (default: cwd)")
176
+ .option("--force", "Proceed even when the target does not look like a project directory")
166
177
  .action((category, opts) => insightsDriftReviewCommand(category, opts));
167
178
  const create = program
168
179
  .command("create")
@@ -240,6 +251,7 @@ const adopt = program
240
251
  .description("Scan an existing project and propose components, foundations and decisions.")
241
252
  .option("--pattern <glob>", "Glob pattern for source files (default: src/**/*.{tsx,jsx})")
242
253
  .option("--dry-run", "Run the scanner but don't write proposals to disk")
254
+ .option("--force", "Proceed even when the target does not look like a project directory")
243
255
  .action((target, opts) => adoptCommand(target, opts));
244
256
  adopt
245
257
  .command("review [target]")
@@ -33,7 +33,7 @@ export function renderRootIndex(config, stats) {
33
33
  const stack = config.stack ?? "css";
34
34
  const packs = (config.packs ?? []).join(", ") || "(none)";
35
35
  const refsSection = renderReferencesSection(config.uiReferences ?? []);
36
- return `# AI Agent Context — ${name}
36
+ return `${stats.healthWarning ?? ""}# AI Agent Context — ${name}
37
37
 
38
38
  > This file is auto-generated by [Whale Igniter](https://github.com/whale-igniter).
39
39
  > Do not edit by hand. Run \`whale sync\` after changes to regenerate.
@@ -65,7 +65,15 @@ Whale stores structured context in two locations:
65
65
  - \`llm-wiki/COMPONENTS.md\` — component catalog
66
66
  - \`llm-wiki/WORKFLOWS.md\` — how the team uses Whale day to day
67
67
 
68
- **Current status:** ${stats.errors} error(s) · ${stats.warnings} warning(s) · ${stats.decisionCount} decision(s) · ${stats.refinementCount} refinement(s) · ${stats.componentCount} component(s)
68
+ ## Current status
69
+
70
+ - Decisions: ${stats.decisionCount}
71
+ - Active refinements: ${stats.refinementCount}
72
+ - Components: ${stats.componentCount}
73
+
74
+ ### Validation
75
+
76
+ ${stats.validationSummary}
69
77
 
70
78
  ${refsSection}## How to work here
71
79
 
@@ -1,5 +1,5 @@
1
- export function renderProject(config, errors, warnings, refinementCount, decisionCount) {
2
- return `# Project Context
1
+ export function renderProject(config, validationSummary, healthWarning, refinementCount, decisionCount) {
2
+ return `${healthWarning ?? ""}# Project Context
3
3
 
4
4
  - **Project name:** ${config.projectName ?? "(unset)"}
5
5
  - **Project type:** ${config.projectType ?? "unspecified"}
@@ -10,11 +10,13 @@ export function renderProject(config, errors, warnings, refinementCount, decisio
10
10
 
11
11
  ## Current status
12
12
 
13
- - Validation errors: ${errors}
14
- - Validation warnings: ${warnings}
15
13
  - Decisions logged: ${decisionCount}
16
14
  - Active refinements: ${refinementCount}
17
15
 
16
+ ## Validation
17
+
18
+ ${validationSummary}
19
+
18
20
  _Generated by Whale Igniter._
19
21
  `;
20
22
  }
@@ -0,0 +1,77 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import fs from "fs-extra";
4
+ import { ui } from "../ui/index.js";
5
+ function isSamePath(a, b) {
6
+ return path.resolve(a) === path.resolve(b);
7
+ }
8
+ function isAncestorOf(ancestor, child) {
9
+ const relative = path.relative(ancestor, child);
10
+ return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative);
11
+ }
12
+ function projectMarkerMessage(target, command) {
13
+ const commandHint = command ? `whale ${command}` : "this command";
14
+ return [
15
+ `This does not look like a project directory: ${target}`,
16
+ "I could not find a .git directory or package.json there.",
17
+ `Run ${ui.code(commandHint)} from inside your project, pass an explicit project path, or add ${ui.code("--force")} if this really is the target you want.`
18
+ ].join("\n");
19
+ }
20
+ function protectedDirectoryMessage(target, command) {
21
+ const commandHint = command ? `whale ${command} path/to/project` : "pass an explicit project path";
22
+ return [
23
+ `I will not run in ${target}.`,
24
+ "That directory is your home folder, the filesystem root, or a parent of your home folder.",
25
+ `Please cd into a project directory first, or run ${ui.code(commandHint)}.`
26
+ ].join("\n");
27
+ }
28
+ export async function checkTargetDirectory(target, options = {}) {
29
+ const resolvedTarget = path.resolve(target);
30
+ const home = path.resolve(os.homedir());
31
+ const root = path.parse(resolvedTarget).root;
32
+ if (isSamePath(resolvedTarget, home) ||
33
+ isSamePath(resolvedTarget, root) ||
34
+ isAncestorOf(resolvedTarget, home)) {
35
+ return {
36
+ ok: false,
37
+ target: resolvedTarget,
38
+ reason: "protected-directory",
39
+ message: protectedDirectoryMessage(resolvedTarget, options.command)
40
+ };
41
+ }
42
+ const hasGit = await fs.pathExists(path.join(resolvedTarget, ".git"));
43
+ const hasPackageJson = await fs.pathExists(path.join(resolvedTarget, "package.json"));
44
+ if (!hasGit && !hasPackageJson) {
45
+ const message = projectMarkerMessage(resolvedTarget, options.command);
46
+ if (!options.force) {
47
+ return {
48
+ ok: false,
49
+ target: resolvedTarget,
50
+ reason: "missing-project-marker",
51
+ message
52
+ };
53
+ }
54
+ return {
55
+ ok: true,
56
+ target: resolvedTarget,
57
+ warning: message
58
+ };
59
+ }
60
+ return { ok: true, target: resolvedTarget };
61
+ }
62
+ export async function guardTargetDirectory(target, options = {}) {
63
+ const result = await checkTargetDirectory(target, options);
64
+ if (!result.ok) {
65
+ console.log();
66
+ console.log(ui.fail(result.message));
67
+ console.log();
68
+ process.exitCode = 1;
69
+ return false;
70
+ }
71
+ if (result.warning) {
72
+ console.log();
73
+ console.log(ui.warn(result.warning));
74
+ console.log();
75
+ }
76
+ return true;
77
+ }
@@ -0,0 +1,103 @@
1
+ export const VALIDATION_SUMMARY_LIMIT = 10;
2
+ export const DEFAULT_HEALTH_ERROR_THRESHOLD = 500;
3
+ export const DEFAULT_HEALTH_FILE_THRESHOLD = 1000;
4
+ function groupKey(issue) {
5
+ return `${issue.severity}:${issue.type}`;
6
+ }
7
+ export function summarizeValidationIssues(issues, limit = VALIDATION_SUMMARY_LIMIT) {
8
+ const groupsByKey = new Map();
9
+ for (const issue of issues) {
10
+ const key = groupKey(issue);
11
+ const existing = groupsByKey.get(key);
12
+ if (existing) {
13
+ existing.count += 1;
14
+ existing.files.add(issue.file);
15
+ continue;
16
+ }
17
+ groupsByKey.set(key, {
18
+ key,
19
+ type: issue.type,
20
+ severity: issue.severity,
21
+ message: issue.message,
22
+ count: 1,
23
+ files: new Set([issue.file]),
24
+ example: issue
25
+ });
26
+ }
27
+ const groups = [...groupsByKey.values()].sort((a, b) => {
28
+ if (b.count !== a.count)
29
+ return b.count - a.count;
30
+ if (a.severity !== b.severity)
31
+ return a.severity === "error" ? -1 : 1;
32
+ return a.type.localeCompare(b.type);
33
+ });
34
+ const shownGroups = groups.slice(0, limit);
35
+ const hiddenGroups = groups.slice(limit);
36
+ const hiddenFiles = new Set();
37
+ let hiddenIssueCount = 0;
38
+ for (const group of hiddenGroups) {
39
+ hiddenIssueCount += group.count;
40
+ for (const file of group.files)
41
+ hiddenFiles.add(file);
42
+ }
43
+ return {
44
+ total: issues.length,
45
+ errors: issues.filter((issue) => issue.severity === "error").length,
46
+ warnings: issues.filter((issue) => issue.severity === "warning").length,
47
+ groups,
48
+ shownGroups,
49
+ hiddenIssueCount,
50
+ hiddenFileCount: hiddenFiles.size
51
+ };
52
+ }
53
+ export function evaluateValidationHealth(issues, scannedFileCount, config) {
54
+ const errorThreshold = config.validation?.healthErrorThreshold ?? DEFAULT_HEALTH_ERROR_THRESHOLD;
55
+ const fileThreshold = config.validation?.healthFileThreshold ?? DEFAULT_HEALTH_FILE_THRESHOLD;
56
+ const errorCount = issues.filter((issue) => issue.severity === "error").length;
57
+ const reasons = [];
58
+ if (errorCount > errorThreshold) {
59
+ reasons.push(`Validation errors exceed the configured threshold (${errorCount} > ${errorThreshold}).`);
60
+ }
61
+ if (scannedFileCount > fileThreshold) {
62
+ reasons.push(`The CSS scan touched ${scannedFileCount} files, which is above the configured threshold (${fileThreshold}).`);
63
+ }
64
+ return {
65
+ ok: reasons.length === 0,
66
+ errorThreshold,
67
+ fileThreshold,
68
+ reasons
69
+ };
70
+ }
71
+ export function formatIssueLocation(issue) {
72
+ return `${issue.file}:${issue.line}`;
73
+ }
74
+ export function renderValidationSummaryMarkdown(issues, options = {}) {
75
+ const summary = summarizeValidationIssues(issues, options.limit);
76
+ const fullCommand = options.fullCommand ?? "whale check --full";
77
+ if (summary.total === 0)
78
+ return "No validation issues found.";
79
+ const lines = ["Validation issues grouped by rule/cause.", ""];
80
+ for (const group of summary.shownGroups) {
81
+ const tag = group.severity.toUpperCase();
82
+ lines.push(`- **${group.type}** (${tag}) - ${group.count} issue(s); example \`${formatIssueLocation(group.example)}\` - ${group.message}`);
83
+ }
84
+ if (summary.hiddenIssueCount > 0) {
85
+ lines.push(`- + ${summary.hiddenIssueCount} more across ${summary.hiddenFileCount} files - run \`${fullCommand}\` for everything.`);
86
+ }
87
+ return lines.join("\n");
88
+ }
89
+ export function renderValidationHealthWarningMarkdown(verdict) {
90
+ if (verdict.ok)
91
+ return "";
92
+ return [
93
+ "# Whale Context Health Warning",
94
+ "",
95
+ "> This generated context is likely misconfigured and should not be trusted yet.",
96
+ "> Whale probably scanned the wrong directory, too broad a scope, or a project with missing ignores.",
97
+ "",
98
+ ...verdict.reasons.map((reason) => `- ${reason}`),
99
+ "",
100
+ "Run `whale check --full` from the intended project directory, or pass an explicit project path, then rerun `whale sync`.",
101
+ ""
102
+ ].join("\n");
103
+ }
@@ -4,6 +4,13 @@ import path from "node:path";
4
4
  import postcss from "postcss";
5
5
  import { loadConfig } from "../utils/config.js";
6
6
  import { loadRefinements, isSuppressed } from "../utils/refinements.js";
7
+ export async function findCssValidationFiles(target) {
8
+ return glob("**/*.css", {
9
+ cwd: target,
10
+ absolute: true,
11
+ ignore: ["node_modules/**", "dist/**", ".git/**"]
12
+ });
13
+ }
7
14
  // Properties that take spacing values (length-based, grid-relevant).
8
15
  const SPACING_PROPS = new Set([
9
16
  "margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
@@ -86,11 +93,7 @@ export async function validateCss(target, options = {}) {
86
93
  config.foundations?.radius?.control ?? 2,
87
94
  config.foundations?.radius?.container ?? 4
88
95
  ];
89
- const files = await glob("**/*.css", {
90
- cwd: target,
91
- absolute: true,
92
- ignore: ["node_modules/**", "dist/**", ".git/**"]
93
- });
96
+ const files = await findCssValidationFiles(target);
94
97
  const issues = [];
95
98
  for (const file of files) {
96
99
  const content = await fs.readFile(file, "utf8");
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const PACKAGE_VERSION = "1.5.2";
1
+ export const PACKAGE_VERSION = "1.5.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whale-igniter",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
4
4
  "description": "CLI-first operational intelligence. Bootstraps and adopts AI-readable project context so agents like Claude Code, Codex and Cursor understand your design system from the first commit. Includes an MCP server, a file watcher, deterministic insights, and an opt-in AI bridge (Selene).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",