whale-igniter 1.5.2 → 1.5.4
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 +16 -0
- package/dist/commands/adopt.js +4 -0
- package/dist/commands/changes.js +3 -3
- package/dist/commands/friendly.js +11 -5
- package/dist/commands/ignite.js +6 -1
- package/dist/commands/init.js +4 -0
- package/dist/commands/insights.js +7 -0
- package/dist/commands/selene.js +12 -7
- package/dist/commands/sync.js +5 -1
- package/dist/commands/validate.js +33 -2
- package/dist/commands/wiki.js +5 -1
- package/dist/generators/markdownGenerator.js +16 -8
- package/dist/generators/reportGenerator.js +2 -28
- package/dist/generators/wikiGenerator.js +11 -3
- package/dist/index.js +19 -7
- package/dist/selene/redaction.js +45 -0
- package/dist/templates/claude.js +10 -2
- package/dist/templates/project.js +6 -4
- package/dist/utils/decisions.js +3 -4
- package/dist/utils/frontmatter.js +62 -0
- package/dist/utils/refinements.js +3 -3
- package/dist/utils/targetGuard.js +77 -0
- package/dist/utils/validationSummary.js +103 -0
- package/dist/validators/cssValidator.js +8 -5
- package/dist/version.js +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Whale Igniter are documented here.
|
|
4
4
|
|
|
5
|
+
## 1.5.4 - 2026-07-08
|
|
6
|
+
|
|
7
|
+
### Security
|
|
8
|
+
|
|
9
|
+
- Added automatic secret redaction before Selene prompts are copied, written, cached, or sent to Anthropic/OpenAI.
|
|
10
|
+
- Added `npm run security:scan` to detect likely committed secrets in source and compiled output.
|
|
11
|
+
- Added the security scan to `prepublishOnly`, after build and before tests, so npm releases fail before publishing if high-confidence secrets are found.
|
|
12
|
+
- Replaced the `gray-matter`/`js-yaml` dependency path with a local frontmatter parser, restoring a zero-vulnerability production audit.
|
|
13
|
+
|
|
14
|
+
## 1.5.3 - 2026-07-06
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
5
21
|
## 1.5.2 - 2026-06-10
|
|
6
22
|
|
|
7
23
|
### Changed
|
package/dist/commands/adopt.js
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();
|
package/dist/commands/changes.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import matter from "gray-matter";
|
|
3
2
|
import { resolveTarget } from "../utils/paths.js";
|
|
4
3
|
import { loadComponents } from "../utils/components.js";
|
|
4
|
+
import { parseFrontmatter } from "../utils/frontmatter.js";
|
|
5
5
|
import { ui } from "../ui/index.js";
|
|
6
6
|
const DECISIONS_DIR = "intelligence/decisions";
|
|
7
7
|
const REFINEMENTS_DIR = "intelligence/refinements";
|
|
@@ -42,10 +42,10 @@ function mdLabelAtRef(target, ref, file, label) {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
const decisionLabel = (raw) => {
|
|
45
|
-
const title =
|
|
45
|
+
const title = parseFrontmatter(raw).data.title;
|
|
46
46
|
return title ? String(title) : "";
|
|
47
47
|
};
|
|
48
|
-
const refinementLabel = (raw) =>
|
|
48
|
+
const refinementLabel = (raw) => parseFrontmatter(raw).content.trim().split("\n")[0] ?? "";
|
|
49
49
|
function gitDirChanges(target, since, dir, label) {
|
|
50
50
|
const result = { added: [], removed: [], modified: [] };
|
|
51
51
|
let raw = "";
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/ignite.js
CHANGED
|
@@ -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
|
|
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 ------------
|
package/dist/commands/init.js
CHANGED
|
@@ -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();
|
package/dist/commands/selene.js
CHANGED
|
@@ -14,6 +14,7 @@ import { copyToClipboard } from "../selene/clipboard.js";
|
|
|
14
14
|
import { resolveProvider, describeProviderState } from "../selene/providers.js";
|
|
15
15
|
import { callProvider, estimateTokens, estimateCostUsd } from "../selene/apiClient.js";
|
|
16
16
|
import { readCache, writeCache, clearCache } from "../selene/cache.js";
|
|
17
|
+
import { redactSecrets } from "../selene/redaction.js";
|
|
17
18
|
import { ui } from "../ui/index.js";
|
|
18
19
|
const PENDING_DIR = ".whale/selene";
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
@@ -83,6 +84,10 @@ async function emitPrompt(target, prompt, kind, options) {
|
|
|
83
84
|
* prompt mode, etc. Centralising keeps the policy consistent.
|
|
84
85
|
*/
|
|
85
86
|
async function runSelene(target, kind, prompt, options) {
|
|
87
|
+
const safePrompt = redactSecrets(prompt);
|
|
88
|
+
if (safePrompt !== prompt) {
|
|
89
|
+
console.log(ui.warning("Potential secrets were redacted before Selene output/API use."));
|
|
90
|
+
}
|
|
86
91
|
const config = await loadConfig(target);
|
|
87
92
|
const sel = config.selene ?? {};
|
|
88
93
|
// Step 1: figure out the mode.
|
|
@@ -103,7 +108,7 @@ async function runSelene(target, kind, prompt, options) {
|
|
|
103
108
|
process.exitCode = 1;
|
|
104
109
|
return { rawResponse: null };
|
|
105
110
|
}
|
|
106
|
-
await emitPrompt(target,
|
|
111
|
+
await emitPrompt(target, safePrompt, kind, options);
|
|
107
112
|
return { rawResponse: null };
|
|
108
113
|
}
|
|
109
114
|
// ---- API mode --------------------------------------------------------------
|
|
@@ -111,7 +116,7 @@ async function runSelene(target, kind, prompt, options) {
|
|
|
111
116
|
// Cache lookup, unless the user opted out.
|
|
112
117
|
const cacheEnabled = !options.noCache && !sel.noCache;
|
|
113
118
|
if (cacheEnabled) {
|
|
114
|
-
const cached = await readCache(target, resolved.model,
|
|
119
|
+
const cached = await readCache(target, resolved.model, safePrompt);
|
|
115
120
|
if (cached) {
|
|
116
121
|
console.log(ui.muted(" cache hit — no API call needed"));
|
|
117
122
|
// Still stash the response so `apply` from disk works if the user wants.
|
|
@@ -122,7 +127,7 @@ async function runSelene(target, kind, prompt, options) {
|
|
|
122
127
|
// Cost confirmation.
|
|
123
128
|
const confirm = options.confirmCost || sel.confirmCost;
|
|
124
129
|
if (confirm) {
|
|
125
|
-
const inputTokens = estimateTokens(
|
|
130
|
+
const inputTokens = estimateTokens(safePrompt);
|
|
126
131
|
const expectedOutput = sel.maxTokens ?? 1500;
|
|
127
132
|
const cost = estimateCostUsd(resolved.model, inputTokens, expectedOutput);
|
|
128
133
|
const costStr = cost === null ? "unknown" : `≈ $${cost.toFixed(4)}`;
|
|
@@ -135,13 +140,13 @@ async function runSelene(target, kind, prompt, options) {
|
|
|
135
140
|
});
|
|
136
141
|
if (!ok) {
|
|
137
142
|
console.log(ui.warning("Aborted — falling back to prompt mode."));
|
|
138
|
-
await emitPrompt(target,
|
|
143
|
+
await emitPrompt(target, safePrompt, kind, options);
|
|
139
144
|
return { rawResponse: null };
|
|
140
145
|
}
|
|
141
146
|
}
|
|
142
147
|
const spin = ora({ text: "Calling provider", stream: process.stderr }).start();
|
|
143
148
|
const result = await callProvider(resolved, {
|
|
144
|
-
prompt,
|
|
149
|
+
prompt: safePrompt,
|
|
145
150
|
maxTokens: sel.maxTokens ?? 1500,
|
|
146
151
|
temperature: sel.temperature ?? 0.2
|
|
147
152
|
});
|
|
@@ -152,7 +157,7 @@ async function runSelene(target, kind, prompt, options) {
|
|
|
152
157
|
}
|
|
153
158
|
// Graceful fallback: still emit the prompt so the user isn't stuck.
|
|
154
159
|
console.log(ui.warning("\nFalling back to prompt mode so you can run this manually:"));
|
|
155
|
-
await emitPrompt(target,
|
|
160
|
+
await emitPrompt(target, safePrompt, kind, options);
|
|
156
161
|
return { rawResponse: null };
|
|
157
162
|
}
|
|
158
163
|
const usage = result.inputTokens !== null && result.outputTokens !== null
|
|
@@ -160,7 +165,7 @@ async function runSelene(target, kind, prompt, options) {
|
|
|
160
165
|
: "";
|
|
161
166
|
spin.succeed(`Response received${usage}`);
|
|
162
167
|
if (cacheEnabled) {
|
|
163
|
-
await writeCache(target, resolved.model,
|
|
168
|
+
await writeCache(target, resolved.model, safePrompt, result.text);
|
|
164
169
|
}
|
|
165
170
|
await stashResponse(target, kind, result.text);
|
|
166
171
|
return { rawResponse: result.text };
|
package/dist/commands/sync.js
CHANGED
|
@@ -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
|
-
|
|
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 },
|
package/dist/commands/wiki.js
CHANGED
|
@@ -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
|
-
|
|
31
|
+
"## Open Issues",
|
|
31
32
|
""
|
|
32
33
|
];
|
|
33
|
-
|
|
34
|
-
lines.push(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
.
|
|
47
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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]")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const REDACTED = "[REDACTED_SECRET]";
|
|
2
|
+
const SECRET_PATTERNS = [
|
|
3
|
+
{
|
|
4
|
+
name: "private-key",
|
|
5
|
+
pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g
|
|
6
|
+
},
|
|
7
|
+
{ name: "openai-key", pattern: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
|
|
8
|
+
{ name: "github-token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g },
|
|
9
|
+
{ name: "slack-token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
10
|
+
{ name: "aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
11
|
+
{ name: "google-api-key", pattern: /\bAIza[0-9A-Za-z_-]{30,}\b/g },
|
|
12
|
+
{ name: "anthropic-key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g }
|
|
13
|
+
];
|
|
14
|
+
const ASSIGNMENT_PATTERN = /\b(api[_-]?key|token|secret|password|passwd|client[_-]?secret|private[_-]?key)\b(\s*[:=]\s*)(["']?)([^"'\s,;)}\]]{16,})(\3)/gi;
|
|
15
|
+
function redactAssignment(match, key, separator, quote, value) {
|
|
16
|
+
if (isLikelyPlaceholder(value))
|
|
17
|
+
return match;
|
|
18
|
+
return `${key}${separator}${quote}${REDACTED}${quote}`;
|
|
19
|
+
}
|
|
20
|
+
function isLikelyPlaceholder(value) {
|
|
21
|
+
const normalized = value.toLowerCase();
|
|
22
|
+
return [
|
|
23
|
+
"example",
|
|
24
|
+
"placeholder",
|
|
25
|
+
"changeme",
|
|
26
|
+
"dummy",
|
|
27
|
+
"fake",
|
|
28
|
+
"mock",
|
|
29
|
+
"sample",
|
|
30
|
+
"test",
|
|
31
|
+
"xxxx",
|
|
32
|
+
"..."
|
|
33
|
+
].some((needle) => normalized.includes(needle));
|
|
34
|
+
}
|
|
35
|
+
export function redactSecrets(input) {
|
|
36
|
+
let output = input;
|
|
37
|
+
for (const { pattern } of SECRET_PATTERNS) {
|
|
38
|
+
output = output.replace(pattern, REDACTED);
|
|
39
|
+
}
|
|
40
|
+
output = output.replace(ASSIGNMENT_PATTERN, redactAssignment);
|
|
41
|
+
return output;
|
|
42
|
+
}
|
|
43
|
+
export function containsRedactedSecret(input) {
|
|
44
|
+
return redactSecrets(input) !== input;
|
|
45
|
+
}
|
package/dist/templates/claude.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
2
|
-
return
|
|
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
|
}
|
package/dist/utils/decisions.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
|
-
import
|
|
3
|
+
import { parseFrontmatter, stringifyFrontmatter } from "./frontmatter.js";
|
|
4
4
|
const DIR = "intelligence/decisions";
|
|
5
5
|
const LEGACY_STORE = "intelligence/decisions.json";
|
|
6
6
|
function slugify(title) {
|
|
@@ -17,7 +17,7 @@ function section(body, heading) {
|
|
|
17
17
|
return text && text.length > 0 ? text : undefined;
|
|
18
18
|
}
|
|
19
19
|
function parseFile(raw) {
|
|
20
|
-
const { data, content } =
|
|
20
|
+
const { data, content } = parseFrontmatter(raw);
|
|
21
21
|
const decision = section(content, "Decision");
|
|
22
22
|
if (!data.id || !data.title || !decision)
|
|
23
23
|
return null;
|
|
@@ -34,7 +34,7 @@ function parseFile(raw) {
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
function serialize(d) {
|
|
37
|
-
|
|
37
|
+
return stringifyFrontmatter([
|
|
38
38
|
"## Context",
|
|
39
39
|
d.context ?? "",
|
|
40
40
|
"",
|
|
@@ -51,7 +51,6 @@ function serialize(d) {
|
|
|
51
51
|
createdAt: d.timestamp,
|
|
52
52
|
supersedes: d.supersedes ?? null
|
|
53
53
|
});
|
|
54
|
-
return fm;
|
|
55
54
|
}
|
|
56
55
|
async function loadLegacy(target) {
|
|
57
56
|
const file = path.join(target, LEGACY_STORE);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
function parseValue(raw) {
|
|
2
|
+
const value = raw.trim();
|
|
3
|
+
if (value === "null")
|
|
4
|
+
return null;
|
|
5
|
+
if (value === "true")
|
|
6
|
+
return true;
|
|
7
|
+
if (value === "false")
|
|
8
|
+
return false;
|
|
9
|
+
if (/^-?\d+(?:\.\d+)?$/.test(value))
|
|
10
|
+
return Number(value);
|
|
11
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
12
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(value);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return value.slice(1, -1);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
function stringifyValue(value) {
|
|
23
|
+
if (value === null || value === undefined)
|
|
24
|
+
return "null";
|
|
25
|
+
if (typeof value === "boolean" || typeof value === "number")
|
|
26
|
+
return String(value);
|
|
27
|
+
const text = String(value);
|
|
28
|
+
if (text === "" || /[:#\n\r]|^\s|\s$/.test(text))
|
|
29
|
+
return JSON.stringify(text);
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
export function parseFrontmatter(raw) {
|
|
33
|
+
if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) {
|
|
34
|
+
return { data: {}, content: raw };
|
|
35
|
+
}
|
|
36
|
+
const lineEnding = raw.startsWith("---\r\n") ? "\r\n" : "\n";
|
|
37
|
+
const startLength = 3 + lineEnding.length;
|
|
38
|
+
const endMarker = `${lineEnding}---${lineEnding}`;
|
|
39
|
+
const end = raw.indexOf(endMarker, startLength);
|
|
40
|
+
if (end === -1)
|
|
41
|
+
return { data: {}, content: raw };
|
|
42
|
+
const block = raw.slice(startLength, end);
|
|
43
|
+
const content = raw.slice(end + endMarker.length);
|
|
44
|
+
const data = {};
|
|
45
|
+
for (const line of block.split(/\r?\n/)) {
|
|
46
|
+
if (!line.trim())
|
|
47
|
+
continue;
|
|
48
|
+
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
49
|
+
if (!match)
|
|
50
|
+
continue;
|
|
51
|
+
data[match[1]] = parseValue(match[2]);
|
|
52
|
+
}
|
|
53
|
+
return { data, content };
|
|
54
|
+
}
|
|
55
|
+
export function stringifyFrontmatter(content, data) {
|
|
56
|
+
const lines = ["---"];
|
|
57
|
+
for (const [key, value] of Object.entries(data)) {
|
|
58
|
+
lines.push(`${key}: ${stringifyValue(value)}`);
|
|
59
|
+
}
|
|
60
|
+
lines.push("---", content);
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
|
-
import
|
|
3
|
+
import { parseFrontmatter, stringifyFrontmatter } from "./frontmatter.js";
|
|
4
4
|
const DIR = "intelligence/refinements";
|
|
5
5
|
const LEGACY_STORE = "intelligence/refinements.json";
|
|
6
6
|
function slugify(text) {
|
|
@@ -11,7 +11,7 @@ function slugify(text) {
|
|
|
11
11
|
.slice(0, 60);
|
|
12
12
|
}
|
|
13
13
|
function parseFile(raw) {
|
|
14
|
-
const { data, content } =
|
|
14
|
+
const { data, content } = parseFrontmatter(raw);
|
|
15
15
|
const note = content.trim();
|
|
16
16
|
if (!data.id || !note)
|
|
17
17
|
return null;
|
|
@@ -40,7 +40,7 @@ function serialize(r) {
|
|
|
40
40
|
fm.selector = r.scope.selector;
|
|
41
41
|
if (r.scope?.file)
|
|
42
42
|
fm.file = r.scope.file;
|
|
43
|
-
return
|
|
43
|
+
return stringifyFrontmatter(r.note, fm);
|
|
44
44
|
}
|
|
45
45
|
async function loadLegacy(target) {
|
|
46
46
|
const file = path.join(target, LEGACY_STORE);
|
|
@@ -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
|
|
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.
|
|
1
|
+
export const PACKAGE_VERSION = "1.5.4";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whale-igniter",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.4",
|
|
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",
|
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
"build": "tsc",
|
|
24
24
|
"postbuild": "node -e \"require('node:fs').chmodSync('dist/index.js', 0o755)\"",
|
|
25
25
|
"start": "node dist/index.js",
|
|
26
|
+
"security:scan": "tsx scripts/security-scan.ts",
|
|
26
27
|
"test": "tsx tests/run.ts && tsx tests/mcp-smoke.ts && tsx tests/extractors.ts && tsx tests/driftAnalyzers.ts",
|
|
27
|
-
"prepublishOnly": "npm run build && npm test"
|
|
28
|
+
"prepublishOnly": "npm run build && npm run security:scan && npm test"
|
|
28
29
|
},
|
|
29
30
|
"engines": {
|
|
30
31
|
"node": ">=20.0.0"
|
|
@@ -66,7 +67,6 @@
|
|
|
66
67
|
"commander": "^12.1.0",
|
|
67
68
|
"fs-extra": "^11.2.0",
|
|
68
69
|
"glob": "^13.0.6",
|
|
69
|
-
"gray-matter": "^4.0.3",
|
|
70
70
|
"ora": "^8.1.0",
|
|
71
71
|
"postcss": "^8.4.47",
|
|
72
72
|
"prompts": "^2.4.2",
|