karajan-code 2.8.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/package.json +1 -1
- package/src/audit/agent-readiness.js +21 -3
- package/src/audit/basal-cost.js +92 -9
- package/src/audit/deterministic-summary.js +200 -0
- package/src/audit/osv-findings.js +162 -0
- package/src/audit/semgrep-findings.js +117 -0
- package/src/audit/sonar-findings.js +81 -0
- package/src/audit/webperf-input.js +136 -0
- package/src/canvas/schema.js +1 -1
- package/src/checks/binaries.js +2 -2
- package/src/checks/ci.js +2 -2
- package/src/checks/config-files.js +6 -6
- package/src/checks/dir-setup.js +4 -0
- package/src/checks/mcp-health.js +6 -0
- package/src/checks/ports.js +8 -0
- package/src/checks/rtk.js +1 -1
- package/src/checks/skills.js +5 -1
- package/src/checks/sonar.js +1 -1
- package/src/checks/system.js +2 -2
- package/src/checks/tokens.js +1 -1
- package/src/checks/types.js +0 -12
- package/src/cli/register-meta.js +40 -2
- package/src/cli/register-pipeline.js +1 -0
- package/src/commands/audit.js +239 -28
- package/src/commands/board.js +7 -4
- package/src/commands/doctor.js +1 -1
- package/src/commands/webperf.js +164 -0
- package/src/config/defaults.js +1 -0
- package/src/config/overrides.js +1 -0
- package/src/config/schema.js +1 -0
- package/src/config/test-harness.js +0 -1
- package/src/mcp/handlers/run-handler.js +2 -2
- package/src/mcp/run-kj.js +1 -0
- package/src/mcp/sovereignty-guard.js +1 -1
- package/src/mcp/tools.js +1 -0
- package/src/orchestrator/ci-integration.js +1 -1
- package/src/orchestrator/config-init.js +3 -1
- package/src/orchestrator/direct-actions.js +1 -1
- package/src/orchestrator/drivers/error-recovery.js +2 -2
- package/src/orchestrator/drivers/iteration-loop.js +3 -13
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +12 -0
- package/src/orchestrator/drivers/pre-loop.js +1 -4
- package/src/orchestrator/feedback-queue.js +0 -1
- package/src/orchestrator/integrations.js +0 -7
- package/src/orchestrator/role-output-compressor.js +0 -1
- package/src/orchestrator/session-journal.js +5 -41
- package/src/orchestrator/stages/perf-stage.js +140 -0
- package/src/orchestrator/stages/stage-classes.js +0 -2
- package/src/plan/plan-store.js +0 -14
- package/src/planning-game/client.js +0 -14
- package/src/planning-game/pipeline-adapter.js +1 -1
- package/src/prompts/audit.js +206 -6
- package/src/prompts/rtk-snippet.js +1 -1
- package/src/roles/audit-role.js +125 -13
- package/src/roles/perf-role.js +122 -0
- package/src/skills/addyosmani-role-map.js +6 -0
- package/src/sonar/api.js +1 -1
- package/src/utils/display/event-handlers.js +1 -1
- package/src/utils/display/header.js +1 -1
- package/src/utils/display/session-summary.js +3 -3
- package/src/utils/display/solomon.js +1 -1
- package/src/utils/role-assigner.js +1 -1
- package/src/webperf/scanner.js +209 -0
package/src/roles/audit-role.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { AgentRole } from "./agent-role.js";
|
|
2
2
|
import { buildAuditPrompt, parseAuditOutput, AUDIT_DIMENSIONS } from "../prompts/audit.js";
|
|
3
3
|
import { measureBasalCost, loadPreviousAudit, saveAuditSnapshot, computeGrowthDelta } from "../audit/basal-cost.js";
|
|
4
|
+
import { detectProjectStack } from "../utils/stack-detect.js";
|
|
5
|
+
import { collectSonarFindings } from "../audit/sonar-findings.js";
|
|
6
|
+
import { collectWebPerfInput } from "../audit/webperf-input.js";
|
|
7
|
+
import { collectOsvFindings } from "../audit/osv-findings.js";
|
|
8
|
+
import { collectSemgrepFindings } from "../audit/semgrep-findings.js";
|
|
4
9
|
|
|
5
10
|
function parseDimensions(dimensionsStr) {
|
|
6
11
|
if (!dimensionsStr || dimensionsStr === "all") return null;
|
|
@@ -26,37 +31,98 @@ export class AuditRole extends AgentRole {
|
|
|
26
31
|
super({ ...opts, name: "audit" });
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Run the deterministic side of an audit — basalCost, stack detection,
|
|
36
|
+
* Sonar issues, WebPerf input — without invoking the LLM. Cheap (no
|
|
37
|
+
* tokens, no API call). KJC-TSK-0364 splits the original execute() into
|
|
38
|
+
* this collector + executeWithDeterministic() so the CLI can show
|
|
39
|
+
* deterministic findings first and ask the user before paying for the
|
|
40
|
+
* LLM phase.
|
|
41
|
+
*
|
|
42
|
+
* @param {object|string} input - same shape execute() accepts
|
|
43
|
+
* @returns {Promise<object>} deterministic context (the same data
|
|
44
|
+
* execute() would gather internally)
|
|
45
|
+
*/
|
|
46
|
+
async collectDeterministic(input) {
|
|
47
|
+
const noSonar = typeof input === "object" ? Boolean(input?.noSonar) : false;
|
|
48
|
+
const noOsv = typeof input === "object" ? Boolean(input?.noOsv) : false;
|
|
49
|
+
const noSemgrep = typeof input === "object" ? Boolean(input?.noSemgrep) : false;
|
|
36
50
|
const projectDir = this.config?.projectDir || process.cwd();
|
|
37
51
|
let basalCost = null;
|
|
38
52
|
let growthDelta = null;
|
|
53
|
+
let stack = null;
|
|
54
|
+
let sonarFindings = null;
|
|
55
|
+
let webperf = null;
|
|
56
|
+
let osvFindings = null;
|
|
57
|
+
let semgrepFindings = null;
|
|
39
58
|
try {
|
|
40
59
|
basalCost = await measureBasalCost(projectDir);
|
|
41
60
|
const previous = await loadPreviousAudit(projectDir);
|
|
42
61
|
growthDelta = computeGrowthDelta(basalCost, previous);
|
|
43
62
|
} catch { /* basal cost is best-effort */ }
|
|
63
|
+
try {
|
|
64
|
+
stack = await detectProjectStack(projectDir);
|
|
65
|
+
} catch { /* stack detect is best-effort */ }
|
|
66
|
+
if (!noSonar) {
|
|
67
|
+
try {
|
|
68
|
+
sonarFindings = await collectSonarFindings(this.config, this.logger);
|
|
69
|
+
} catch { /* sonar fetch is best-effort */ }
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
webperf = collectWebPerfInput(stack, this.config);
|
|
73
|
+
} catch { /* webperf input is best-effort */ }
|
|
74
|
+
// OSV vulnerabilities — KJC-TSK-0365. Best-effort: missing
|
|
75
|
+
// osv-scanner binary returns available:false and the audit
|
|
76
|
+
// continues without the section.
|
|
77
|
+
if (!noOsv) {
|
|
78
|
+
try {
|
|
79
|
+
osvFindings = await collectOsvFindings(projectDir, this.logger);
|
|
80
|
+
} catch { /* osv-scanner is best-effort */ }
|
|
81
|
+
}
|
|
82
|
+
// Semgrep SAST — KJC-TSK-0366. Best-effort: missing semgrep binary
|
|
83
|
+
// returns available:false and the audit continues without the section.
|
|
84
|
+
if (!noSemgrep) {
|
|
85
|
+
try {
|
|
86
|
+
semgrepFindings = await collectSemgrepFindings(projectDir, this.logger);
|
|
87
|
+
} catch { /* semgrep is best-effort */ }
|
|
88
|
+
}
|
|
89
|
+
return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Run the LLM phase of an audit using a pre-collected deterministic
|
|
94
|
+
* context. KJC-TSK-0364: separated from execute() so the CLI can run
|
|
95
|
+
* collectDeterministic() first, show findings, prompt, and only then
|
|
96
|
+
* call this if the user wants to spend tokens.
|
|
97
|
+
*/
|
|
98
|
+
async executeWithDeterministic(input, deterministicCtx) {
|
|
99
|
+
const task = typeof input === "string" ? input : input?.task || this.context?.task || "";
|
|
100
|
+
const onOutput = typeof input === "string" ? null : input?.onOutput || null;
|
|
101
|
+
const rawDimensions = typeof input === "object" ? input?.dimensions || null : null;
|
|
102
|
+
const context = typeof input === "object" ? input?.context || null : null;
|
|
103
|
+
const dimensions = typeof rawDimensions === "string" ? parseDimensions(rawDimensions) : rawDimensions;
|
|
104
|
+
|
|
105
|
+
const { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings } = deterministicCtx;
|
|
44
106
|
|
|
45
107
|
const provider = this.resolveProvider();
|
|
46
108
|
const agent = this.createAgentInstance(provider);
|
|
47
|
-
const prompt = buildAuditPrompt({ task, instructions: this.instructions, dimensions, context, basalCost, growthDelta });
|
|
109
|
+
const prompt = buildAuditPrompt({ task, instructions: this.instructions, dimensions, context, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings });
|
|
48
110
|
const runArgs = { prompt, role: "audit" };
|
|
49
111
|
if (onOutput) runArgs.onOutput = onOutput;
|
|
112
|
+
const startedAt = Date.now();
|
|
50
113
|
const result = await agent.runTask(runArgs);
|
|
114
|
+
const durationMs = Date.now() - startedAt;
|
|
115
|
+
|
|
116
|
+
const usage = extractUsage(result, { provider, durationMs });
|
|
51
117
|
|
|
52
118
|
if (!result.ok) {
|
|
53
|
-
return { ok: false, result: { error: result.error || result.output || "Audit failed", provider }, summary: `Audit failed: ${result.error || "unknown error"}`, usage
|
|
119
|
+
return { ok: false, result: { error: result.error || result.output || "Audit failed", provider }, summary: `Audit failed: ${result.error || "unknown error"}`, usage };
|
|
54
120
|
}
|
|
55
121
|
|
|
56
122
|
try {
|
|
57
123
|
const parsed = parseAuditOutput(result.output);
|
|
58
124
|
if (!parsed) {
|
|
59
|
-
return { ok: true, result: { raw: result.output, provider }, summary: "Audit complete (unstructured output)", usage
|
|
125
|
+
return { ok: true, result: { raw: result.output, provider }, summary: "Audit complete (unstructured output)", usage };
|
|
60
126
|
}
|
|
61
127
|
if (basalCost) { try { await saveAuditSnapshot(projectDir, basalCost); } catch { /* best-effort */ } }
|
|
62
128
|
|
|
@@ -65,13 +131,59 @@ export class AuditRole extends AgentRole {
|
|
|
65
131
|
result: {
|
|
66
132
|
summary: parsed.summary, dimensions: parsed.dimensions,
|
|
67
133
|
topRecommendations: parsed.topRecommendations,
|
|
68
|
-
|
|
134
|
+
textSummary: parsed.textSummary || undefined,
|
|
135
|
+
basalCost: basalCost || undefined, growthDelta: growthDelta || undefined,
|
|
136
|
+
stack: stack || undefined,
|
|
137
|
+
sonarFindings: sonarFindings?.available ? sonarFindings : undefined,
|
|
138
|
+
webperf: webperf?.available ? webperf : undefined,
|
|
139
|
+
osvFindings: osvFindings?.available ? osvFindings : undefined,
|
|
140
|
+
semgrepFindings: semgrepFindings?.available ? semgrepFindings : undefined,
|
|
141
|
+
provider
|
|
69
142
|
},
|
|
70
143
|
summary: buildSummary(parsed),
|
|
71
|
-
usage
|
|
144
|
+
usage
|
|
72
145
|
};
|
|
73
146
|
} catch {
|
|
74
|
-
return { ok: true, result: { raw: result.output, provider }, summary: "Audit complete (unstructured output)", usage
|
|
147
|
+
return { ok: true, result: { raw: result.output, provider }, summary: "Audit complete (unstructured output)", usage };
|
|
75
148
|
}
|
|
76
149
|
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Convenience: run both phases in one call. Identical to the previous
|
|
153
|
+
* (single-phase) execute() so MCP and pipeline callers don't change.
|
|
154
|
+
*/
|
|
155
|
+
async execute(input) {
|
|
156
|
+
const deterministicCtx = await this.collectDeterministic(input);
|
|
157
|
+
return this.executeWithDeterministic(input, deterministicCtx);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Consolidate the usage telemetry the agent spreads across its top-level
|
|
163
|
+
* result fields. Returns a single object so consumers (CLI formatter,
|
|
164
|
+
* report-file writer, MCP) don't need to know about the spread shape.
|
|
165
|
+
*
|
|
166
|
+
* @param {object} result - raw agent.runTask() return
|
|
167
|
+
* @param {{provider: string, durationMs: number}} ctx
|
|
168
|
+
* @returns {{available: boolean, provider: string, model?: string, tokens_in?: number, tokens_out?: number, total_tokens?: number, cost_usd?: number, durationMs: number, reason?: string}}
|
|
169
|
+
*/
|
|
170
|
+
function extractUsage(result, { provider, durationMs }) {
|
|
171
|
+
if (!result || typeof result !== "object") {
|
|
172
|
+
return { available: false, provider, durationMs, reason: "no agent result" };
|
|
173
|
+
}
|
|
174
|
+
const tokens_in = Number.isFinite(result.tokens_in) ? result.tokens_in : null;
|
|
175
|
+
const tokens_out = Number.isFinite(result.tokens_out) ? result.tokens_out : null;
|
|
176
|
+
if (tokens_in === null && tokens_out === null) {
|
|
177
|
+
return { available: false, provider, model: result.model, durationMs, reason: `provider "${provider}" did not report token usage` };
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
available: true,
|
|
181
|
+
provider,
|
|
182
|
+
model: result.model,
|
|
183
|
+
tokens_in: tokens_in ?? 0,
|
|
184
|
+
tokens_out: tokens_out ?? 0,
|
|
185
|
+
total_tokens: (tokens_in ?? 0) + (tokens_out ?? 0),
|
|
186
|
+
cost_usd: typeof result.cost_usd === "number" ? result.cost_usd : null,
|
|
187
|
+
durationMs,
|
|
188
|
+
};
|
|
77
189
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PerfRole — webperf quality gate (KJC-TSK-0150).
|
|
3
|
+
*
|
|
4
|
+
* Same shape as SonarRole / AuditRole: extends BaseRole, exposes an
|
|
5
|
+
* `execute()` that returns `{ok, result, summary}`. Internally drives
|
|
6
|
+
* `runWebperfScan` from src/webperf/scanner.js (the Lighthouse wrapper
|
|
7
|
+
* added in KJC-TSK-0149) and persists the result so subsequent
|
|
8
|
+
* `kj audit` runs surface it via `cwv-result` mode (KJC-TSK-0360).
|
|
9
|
+
*
|
|
10
|
+
* Configuration (kj.config.yml):
|
|
11
|
+
* webperf:
|
|
12
|
+
* url: http://localhost:3000 # required for the role to run
|
|
13
|
+
* preset: desktop # or "mobile"
|
|
14
|
+
* thresholds: { lcp: { good: 2500, poor: 4000 }, ... }
|
|
15
|
+
* persist: true # writes to ~/.karajan/webperf/<slug>/last.json
|
|
16
|
+
*
|
|
17
|
+
* Pipeline activation (KJC-TSK-0151) wires this role behind a triage-
|
|
18
|
+
* driven flag so it only runs for frontend / fullstack tasks.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs/promises";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import { BaseRole } from "./base-role.js";
|
|
25
|
+
import { runWebperfScan } from "../webperf/scanner.js";
|
|
26
|
+
|
|
27
|
+
function projectSlug(projectDir) {
|
|
28
|
+
return path.basename(projectDir || process.cwd()).replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function defaultPersistDir() {
|
|
32
|
+
return path.join(os.homedir(), ".karajan", "webperf");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function persistResult(result, projectDir) {
|
|
36
|
+
if (!result?.ok) return null;
|
|
37
|
+
const dir = path.join(defaultPersistDir(), projectSlug(projectDir));
|
|
38
|
+
await fs.mkdir(dir, { recursive: true });
|
|
39
|
+
const filePath = path.join(dir, "last.json");
|
|
40
|
+
await fs.writeFile(filePath, JSON.stringify(result, null, 2), "utf8");
|
|
41
|
+
return filePath;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class PerfRole extends BaseRole {
|
|
45
|
+
constructor({ config, logger, emitter = null, scanFn = null }) {
|
|
46
|
+
super({ name: "perf", config, logger, emitter });
|
|
47
|
+
// scanFn is injectable for tests so we don't need to mock execFile here.
|
|
48
|
+
this._scan = scanFn || runWebperfScan;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run a webperf scan against the configured URL.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} [_input] - unused; PerfRole reads its target from config
|
|
55
|
+
* @returns {Promise<{ok: boolean, result: object, summary: string}>}
|
|
56
|
+
*/
|
|
57
|
+
async execute(_input) {
|
|
58
|
+
const webperfConfig = this.config?.webperf || {};
|
|
59
|
+
const url = webperfConfig.url;
|
|
60
|
+
const preset = webperfConfig.preset === "mobile" ? "mobile" : "desktop";
|
|
61
|
+
const thresholds = webperfConfig.thresholds || null;
|
|
62
|
+
const persist = webperfConfig.persist !== false;
|
|
63
|
+
|
|
64
|
+
if (!url) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
result: { error: "config.webperf.url is required for PerfRole" },
|
|
68
|
+
summary: "PerfRole skipped: no config.webperf.url configured",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const scan = await this._scan(url, { preset, thresholds, logger: this.logger });
|
|
73
|
+
|
|
74
|
+
if (!scan.ok) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
result: {
|
|
78
|
+
url,
|
|
79
|
+
error: scan.reason || "webperf scan failed",
|
|
80
|
+
},
|
|
81
|
+
summary: `Webperf scan failed: ${scan.reason || "unknown"}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let savedPath = null;
|
|
86
|
+
if (persist) {
|
|
87
|
+
try {
|
|
88
|
+
savedPath = await persistResult(scan, this.config?.projectDir);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (this.logger?.warn) {
|
|
91
|
+
this.logger.warn(`PerfRole: failed to persist result (non-blocking): ${err.message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const verdict = scan.cwv?.pass ? "PASS" : "FAIL";
|
|
97
|
+
const score = typeof scan.performanceScore === "number" ? scan.performanceScore : null;
|
|
98
|
+
const blockingMetrics = (scan.cwv?.blocking || []).map(b => `${b.metric.toUpperCase()}=${Math.round(b.value)}`).join(", ");
|
|
99
|
+
const summaryParts = [
|
|
100
|
+
`Webperf ${verdict}`,
|
|
101
|
+
score !== null ? `score ${score}/100` : null,
|
|
102
|
+
blockingMetrics ? `blocking: ${blockingMetrics}` : null,
|
|
103
|
+
`top opportunities: ${scan.issues?.length ?? 0}`,
|
|
104
|
+
].filter(Boolean);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
ok: scan.cwv?.pass === true,
|
|
108
|
+
result: {
|
|
109
|
+
url: scan.url,
|
|
110
|
+
preset: scan.preset,
|
|
111
|
+
capturedAt: scan.capturedAt,
|
|
112
|
+
performanceScore: scan.performanceScore,
|
|
113
|
+
metrics: scan.metrics,
|
|
114
|
+
cwv: scan.cwv,
|
|
115
|
+
issues: scan.issues,
|
|
116
|
+
thresholds: scan.thresholds,
|
|
117
|
+
savedPath,
|
|
118
|
+
},
|
|
119
|
+
summary: summaryParts.join(" — "),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -89,6 +89,12 @@ export const TASK_PATTERN_TO_SLUG = [
|
|
|
89
89
|
{ pattern: /\bsecurity\b|\bhardening\b|\bauth(entication|orization)?\b|\bxss\b|\bcsrf\b|\binjection\b/i, slug: "security-and-hardening" },
|
|
90
90
|
{ pattern: /\btdd\b|\btest[- ]driven\b/i, slug: "test-driven-development" },
|
|
91
91
|
{ pattern: /\bfrontend\b|\bui\b|\bux\b|\bcss\b|\bresponsive\b/i, slug: "frontend-ui-engineering" },
|
|
92
|
+
// Accessibility (a11y / WCAG / ARIA / screen reader / keyboard nav).
|
|
93
|
+
// No dedicated addyosmani skill exists yet for a11y; until one ships,
|
|
94
|
+
// route to frontend-ui-engineering — the closest authoritative source
|
|
95
|
+
// for WCAG-aware UI work. When upstream ships an a11y skill, change
|
|
96
|
+
// only the `slug` field here.
|
|
97
|
+
{ pattern: /\baccessibility\b|\ba11y\b|\bwcag\b|\baria\b|\bscreen[\s-]?reader\b|\bkeyboard[\s-]?nav(igation)?\b/i, slug: "frontend-ui-engineering" },
|
|
92
98
|
{ pattern: /\bapi\b|\bendpoint\b|\binterface\s+design\b|\bopenapi\b|\brest\b|\bgraphql\b/i, slug: "api-and-interface-design" },
|
|
93
99
|
{ pattern: /\bci\b|\bcd\b|\bci\/cd\b|\bpipeline\b|\bgithub\s+actions\b|\bworkflow\b/i, slug: "ci-cd-and-automation" },
|
|
94
100
|
{ pattern: /\bgit\b|\bcommit\b|\bbranch\b|\bmerge\b|\bversioning\b|\bsemver\b/i, slug: "git-workflow-and-versioning" },
|
package/src/sonar/api.js
CHANGED
|
@@ -3,7 +3,7 @@ import { withRetry } from "../utils/retry.js";
|
|
|
3
3
|
import { resolveSonarProjectKey } from "./project-key.js";
|
|
4
4
|
import { resolveSonarToken } from "./config-resolver.js";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
class SonarApiError extends Error {
|
|
7
7
|
constructor(message, { url, httpStatus, hint } = {}) {
|
|
8
8
|
super(message);
|
|
9
9
|
this.name = "SonarApiError";
|
|
@@ -342,7 +342,7 @@ export const EVENT_HANDLERS = {
|
|
|
342
342
|
};
|
|
343
343
|
|
|
344
344
|
/** Event types suppressed in quiet mode (internal/noise events). */
|
|
345
|
-
|
|
345
|
+
const QUIET_SUPPRESSED = new Set([
|
|
346
346
|
"agent:output",
|
|
347
347
|
"agent:stall",
|
|
348
348
|
"pipeline:simplify",
|
|
@@ -8,7 +8,7 @@ import { ANSI } from "./formatters.js";
|
|
|
8
8
|
const DISPLAY_PKG_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../package.json");
|
|
9
9
|
const DISPLAY_VERSION = JSON.parse(readFileSync(DISPLAY_PKG_PATH, "utf8")).version;
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
const BAR = `${ANSI.dim}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${ANSI.reset}`;
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Print task, configuration and pipeline header
|
|
@@ -15,7 +15,7 @@ export function printSessionStages(stages) {
|
|
|
15
15
|
printSessionSonar(stages.sonar);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
function printSessionPlanner(planner) {
|
|
19
19
|
if (!planner?.title && !planner?.approach && !planner?.completedSteps?.length) return;
|
|
20
20
|
const planParts = [];
|
|
21
21
|
if (planner.title) planParts.push(planner.title);
|
|
@@ -26,7 +26,7 @@ export function printSessionPlanner(planner) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
function printSessionSonar(sonar) {
|
|
30
30
|
if (!sonar) return;
|
|
31
31
|
const gateLabel = sonar.gateStatus === "OK" ? ANSI.green : ANSI.red;
|
|
32
32
|
console.log(` ${ANSI.dim}\ud83d\udd0d Sonar: ${gateLabel}${sonar.gateStatus}${ANSI.reset}${ANSI.dim} (${sonar.openIssues ?? 0} issues)${ANSI.reset}`);
|
|
@@ -55,7 +55,7 @@ export function printSessionGit(git) {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
function isBudgetUnavailable(budget) {
|
|
59
59
|
return budget.usage_available === false ||
|
|
60
60
|
(budget.total_tokens === 0 && budget.total_cost_usd === 0 && Object.keys(budget.breakdown_by_role || {}).length > 0);
|
|
61
61
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ANSI } from "./formatters.js";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const SOLOMON_RULING_HANDLERS = {
|
|
4
4
|
approve(detail, elapsed) {
|
|
5
5
|
const dismissedCount = detail?.dismissed?.length || 0;
|
|
6
6
|
const dismissedSuffix = dismissedCount > 0 ? ` (${dismissedCount} dismissed)` : "";
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web performance scanner — KJC-TSK-0149.
|
|
3
|
+
*
|
|
4
|
+
* Wraps Google's Lighthouse CLI (`lighthouse`) to produce a structured
|
|
5
|
+
* Core Web Vitals + opportunity report against a local URL. Same
|
|
6
|
+
* pattern as sonar-findings.js, osv-findings.js, semgrep-findings.js:
|
|
7
|
+
* best-effort, missing binary returns {ok:false, reason}, all heavy
|
|
8
|
+
* lifting happens in an external tool the user installs themselves.
|
|
9
|
+
*
|
|
10
|
+
* Output shape is designed to feed two downstream consumers:
|
|
11
|
+
* 1. The CLI `kj webperf <url>` (KJC-TSK-0149) — print JSON or human
|
|
12
|
+
* report, optionally persist to config.webperf.lastResult.
|
|
13
|
+
* 2. `kj audit` WebPerf section (KJC-TSK-0360) — the `cwv-result` mode
|
|
14
|
+
* reads exactly this shape from `config.webperf.lastResult`.
|
|
15
|
+
*
|
|
16
|
+
* Lighthouse JSON schema reference:
|
|
17
|
+
* https://github.com/GoogleChrome/lighthouse/blob/main/types/lhr/lhr.d.ts
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
import { evaluateCwv, CWV_THRESHOLDS } from "./cwv-gate.js";
|
|
23
|
+
|
|
24
|
+
const execFileAsync = promisify(execFile);
|
|
25
|
+
|
|
26
|
+
const SCAN_TIMEOUT_MS = 120_000;
|
|
27
|
+
const MAX_OPPORTUNITIES = 20;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Opportunity audits we extract from Lighthouse output. Each maps to a
|
|
31
|
+
* known render-perf issue with actionable fixes; we keep the list short
|
|
32
|
+
* to bound the payload size and keep the LLM prompt tight when this
|
|
33
|
+
* result feeds `kj audit`.
|
|
34
|
+
*/
|
|
35
|
+
const OPPORTUNITY_AUDITS = [
|
|
36
|
+
"render-blocking-resources",
|
|
37
|
+
"unused-css-rules",
|
|
38
|
+
"unused-javascript",
|
|
39
|
+
"unminified-css",
|
|
40
|
+
"unminified-javascript",
|
|
41
|
+
"modern-image-formats",
|
|
42
|
+
"uses-optimized-images",
|
|
43
|
+
"uses-text-compression",
|
|
44
|
+
"uses-responsive-images",
|
|
45
|
+
"uses-rel-preconnect",
|
|
46
|
+
"uses-rel-preload",
|
|
47
|
+
"font-display",
|
|
48
|
+
"preload-lcp-image",
|
|
49
|
+
"lcp-lazy-loaded",
|
|
50
|
+
"non-composited-animations",
|
|
51
|
+
"third-party-summary",
|
|
52
|
+
"bootup-time",
|
|
53
|
+
"mainthread-work-breakdown",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run Lighthouse against a URL and return a structured result.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} url - target URL (must be reachable from the runner)
|
|
60
|
+
* @param {object} [opts]
|
|
61
|
+
* @param {object} [opts.thresholds] - CWV thresholds override (passed to evaluateCwv)
|
|
62
|
+
* @param {object} [opts.logger]
|
|
63
|
+
* @param {string} [opts.preset] - "desktop" or "mobile" (default: desktop)
|
|
64
|
+
* @param {number} [opts.timeoutMs] - override the 120s default
|
|
65
|
+
* @returns {Promise<object>} scanner result (see module JSDoc)
|
|
66
|
+
*/
|
|
67
|
+
export async function runWebperfScan(url, opts = {}) {
|
|
68
|
+
if (!url || typeof url !== "string") {
|
|
69
|
+
return { ok: false, reason: "no URL provided" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const logger = opts.logger || null;
|
|
73
|
+
const preset = opts.preset === "mobile" ? "mobile" : "desktop";
|
|
74
|
+
const timeoutMs = opts.timeoutMs || SCAN_TIMEOUT_MS;
|
|
75
|
+
|
|
76
|
+
const lighthouseArgs = [
|
|
77
|
+
url,
|
|
78
|
+
"--output=json",
|
|
79
|
+
"--quiet",
|
|
80
|
+
"--chrome-flags=--headless --no-sandbox --disable-gpu",
|
|
81
|
+
`--preset=${preset}`,
|
|
82
|
+
"--only-categories=performance",
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
let stdout;
|
|
86
|
+
try {
|
|
87
|
+
const result = await execFileAsync("lighthouse", lighthouseArgs, {
|
|
88
|
+
timeout: timeoutMs,
|
|
89
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
90
|
+
});
|
|
91
|
+
stdout = result.stdout;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (err.code === "ENOENT") {
|
|
94
|
+
if (logger?.warn) logger.warn("lighthouse not found in PATH — install via 'npm install -g lighthouse'");
|
|
95
|
+
return { ok: false, reason: "lighthouse not installed", url };
|
|
96
|
+
}
|
|
97
|
+
if (err.killed) {
|
|
98
|
+
return { ok: false, reason: `lighthouse timed out after ${timeoutMs}ms`, url };
|
|
99
|
+
}
|
|
100
|
+
if (typeof err.stdout === "string" && err.stdout.trim().startsWith("{")) {
|
|
101
|
+
stdout = err.stdout;
|
|
102
|
+
} else {
|
|
103
|
+
const stderr = (err.stderr || "").trim().split("\n").pop() || err.message;
|
|
104
|
+
// Lighthouse prints clear NO_DCHECK / NO_NAVSTART errors when the URL
|
|
105
|
+
// is unreachable. Surface that verbatim.
|
|
106
|
+
const reason = /failed to fetch|connection refused|net::|navigation_start_time/i.test(stderr)
|
|
107
|
+
? `URL ${url} not reachable: ${stderr}`
|
|
108
|
+
: `lighthouse failed: ${stderr}`;
|
|
109
|
+
if (logger?.warn) logger.warn(reason);
|
|
110
|
+
return { ok: false, reason, url };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let report;
|
|
115
|
+
try {
|
|
116
|
+
report = JSON.parse(stdout);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return { ok: false, reason: `lighthouse output not parseable: ${err.message}`, url };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return parseLighthouseReport(report, { url, preset, thresholds: opts.thresholds });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Convert a raw Lighthouse JSON report into the scanner's flat output.
|
|
126
|
+
* Public so tests can drive parsing without spawning lighthouse.
|
|
127
|
+
*
|
|
128
|
+
* @param {object} report - parsed Lighthouse output (lhr)
|
|
129
|
+
* @param {{url: string, preset?: string, thresholds?: object}} ctx
|
|
130
|
+
*/
|
|
131
|
+
export function parseLighthouseReport(report, ctx = {}) {
|
|
132
|
+
const audits = report?.audits || {};
|
|
133
|
+
const metrics = extractMetrics(audits);
|
|
134
|
+
const issues = extractIssues(audits);
|
|
135
|
+
const cwv = evaluateCwv(metrics, ctx.thresholds || CWV_THRESHOLDS);
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
url: ctx.url || report?.finalDisplayedUrl || report?.requestedUrl || null,
|
|
140
|
+
preset: ctx.preset || "desktop",
|
|
141
|
+
capturedAt: report?.fetchTime || new Date().toISOString(),
|
|
142
|
+
lighthouseVersion: report?.lighthouseVersion || null,
|
|
143
|
+
performanceScore: typeof report?.categories?.performance?.score === "number"
|
|
144
|
+
? Math.round(report.categories.performance.score * 100)
|
|
145
|
+
: null,
|
|
146
|
+
metrics,
|
|
147
|
+
cwv,
|
|
148
|
+
issues,
|
|
149
|
+
thresholds: ctx.thresholds || CWV_THRESHOLDS,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Pull Core Web Vitals + supporting metrics out of the Lighthouse audit
|
|
155
|
+
* collection. `numericValue` is the raw measurement Google reports (ms
|
|
156
|
+
* for time-based metrics, score 0-1 for CLS).
|
|
157
|
+
*/
|
|
158
|
+
function extractMetrics(audits) {
|
|
159
|
+
const get = (id) => {
|
|
160
|
+
const v = audits[id]?.numericValue;
|
|
161
|
+
return typeof v === "number" ? v : null;
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
// CWV — these three drive the cwv-gate verdict
|
|
165
|
+
lcp: get("largest-contentful-paint"),
|
|
166
|
+
cls: get("cumulative-layout-shift"),
|
|
167
|
+
inp: get("interaction-to-next-paint") ?? get("max-potential-fid"),
|
|
168
|
+
// Supporting (informational)
|
|
169
|
+
fcp: get("first-contentful-paint"),
|
|
170
|
+
tbt: get("total-blocking-time"),
|
|
171
|
+
si: get("speed-index"),
|
|
172
|
+
tti: get("interactive"),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Distil the Lighthouse opportunity audits into a flat `issues` array.
|
|
178
|
+
* Each issue carries the audit id, score, savings (when applicable) and
|
|
179
|
+
* a one-line description so consumers can prioritise without parsing
|
|
180
|
+
* the full lhr.
|
|
181
|
+
*/
|
|
182
|
+
function extractIssues(audits) {
|
|
183
|
+
const issues = [];
|
|
184
|
+
for (const id of OPPORTUNITY_AUDITS) {
|
|
185
|
+
const audit = audits[id];
|
|
186
|
+
if (!audit) continue;
|
|
187
|
+
// score === 1 means "passed" — skip clean audits
|
|
188
|
+
if (typeof audit.score === "number" && audit.score >= 1) continue;
|
|
189
|
+
if (audit.scoreDisplayMode === "notApplicable") continue;
|
|
190
|
+
issues.push({
|
|
191
|
+
id,
|
|
192
|
+
title: audit.title || id,
|
|
193
|
+
score: typeof audit.score === "number" ? audit.score : null,
|
|
194
|
+
savingsMs: audit.details?.overallSavingsMs ?? null,
|
|
195
|
+
savingsBytes: audit.details?.overallSavingsBytes ?? null,
|
|
196
|
+
severity: scoreToSeverity(audit.score),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return issues
|
|
200
|
+
.sort((a, b) => (b.savingsMs || 0) - (a.savingsMs || 0))
|
|
201
|
+
.slice(0, MAX_OPPORTUNITIES);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function scoreToSeverity(score) {
|
|
205
|
+
if (typeof score !== "number") return "info";
|
|
206
|
+
if (score < 0.5) return "high";
|
|
207
|
+
if (score < 0.9) return "medium";
|
|
208
|
+
return "low";
|
|
209
|
+
}
|