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.
Files changed (64) hide show
  1. package/README.md +14 -1
  2. package/package.json +1 -1
  3. package/src/audit/agent-readiness.js +21 -3
  4. package/src/audit/basal-cost.js +92 -9
  5. package/src/audit/deterministic-summary.js +200 -0
  6. package/src/audit/osv-findings.js +162 -0
  7. package/src/audit/semgrep-findings.js +117 -0
  8. package/src/audit/sonar-findings.js +81 -0
  9. package/src/audit/webperf-input.js +136 -0
  10. package/src/canvas/schema.js +1 -1
  11. package/src/checks/binaries.js +2 -2
  12. package/src/checks/ci.js +2 -2
  13. package/src/checks/config-files.js +6 -6
  14. package/src/checks/dir-setup.js +4 -0
  15. package/src/checks/mcp-health.js +6 -0
  16. package/src/checks/ports.js +8 -0
  17. package/src/checks/rtk.js +1 -1
  18. package/src/checks/skills.js +5 -1
  19. package/src/checks/sonar.js +1 -1
  20. package/src/checks/system.js +2 -2
  21. package/src/checks/tokens.js +1 -1
  22. package/src/checks/types.js +0 -12
  23. package/src/cli/register-meta.js +40 -2
  24. package/src/cli/register-pipeline.js +1 -0
  25. package/src/commands/audit.js +239 -28
  26. package/src/commands/board.js +7 -4
  27. package/src/commands/doctor.js +1 -1
  28. package/src/commands/webperf.js +164 -0
  29. package/src/config/defaults.js +1 -0
  30. package/src/config/overrides.js +1 -0
  31. package/src/config/schema.js +1 -0
  32. package/src/config/test-harness.js +0 -1
  33. package/src/mcp/handlers/run-handler.js +2 -2
  34. package/src/mcp/run-kj.js +1 -0
  35. package/src/mcp/sovereignty-guard.js +1 -1
  36. package/src/mcp/tools.js +1 -0
  37. package/src/orchestrator/ci-integration.js +1 -1
  38. package/src/orchestrator/config-init.js +3 -1
  39. package/src/orchestrator/direct-actions.js +1 -1
  40. package/src/orchestrator/drivers/error-recovery.js +2 -2
  41. package/src/orchestrator/drivers/iteration-loop.js +3 -13
  42. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +12 -0
  43. package/src/orchestrator/drivers/pre-loop.js +1 -4
  44. package/src/orchestrator/feedback-queue.js +0 -1
  45. package/src/orchestrator/integrations.js +0 -7
  46. package/src/orchestrator/role-output-compressor.js +0 -1
  47. package/src/orchestrator/session-journal.js +5 -41
  48. package/src/orchestrator/stages/perf-stage.js +140 -0
  49. package/src/orchestrator/stages/stage-classes.js +0 -2
  50. package/src/plan/plan-store.js +0 -14
  51. package/src/planning-game/client.js +0 -14
  52. package/src/planning-game/pipeline-adapter.js +1 -1
  53. package/src/prompts/audit.js +206 -6
  54. package/src/prompts/rtk-snippet.js +1 -1
  55. package/src/roles/audit-role.js +125 -13
  56. package/src/roles/perf-role.js +122 -0
  57. package/src/skills/addyosmani-role-map.js +6 -0
  58. package/src/sonar/api.js +1 -1
  59. package/src/utils/display/event-handlers.js +1 -1
  60. package/src/utils/display/header.js +1 -1
  61. package/src/utils/display/session-summary.js +3 -3
  62. package/src/utils/display/solomon.js +1 -1
  63. package/src/utils/role-assigner.js +1 -1
  64. package/src/webperf/scanner.js +209 -0
@@ -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
- async execute(input) {
30
- const task = typeof input === "string" ? input : input?.task || this.context?.task || "";
31
- const onOutput = typeof input === "string" ? null : input?.onOutput || null;
32
- const rawDimensions = typeof input === "object" ? input?.dimensions || null : null;
33
- const context = typeof input === "object" ? input?.context || null : null;
34
- const dimensions = typeof rawDimensions === "string" ? parseDimensions(rawDimensions) : rawDimensions;
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: result.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: result.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
- basalCost: basalCost || undefined, growthDelta: growthDelta || undefined, provider
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: result.usage
144
+ usage
72
145
  };
73
146
  } catch {
74
- return { ok: true, result: { raw: result.output, provider }, summary: "Audit complete (unstructured output)", usage: result.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
- export class SonarApiError extends Error {
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
- export const QUIET_SUPPRESSED = new Set([
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
- export 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}`;
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
- export function printSessionPlanner(planner) {
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
- export function printSessionSonar(sonar) {
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
- export function isBudgetUnavailable(budget) {
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
- export const SOLOMON_RULING_HANDLERS = {
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)` : "";
@@ -128,4 +128,4 @@ export function applyRoleAssignments(config, assignments) {
128
128
  return config;
129
129
  }
130
130
 
131
- export { CAPABILITY_TIERS, ROLE_PREFERENCES };
131
+ export { CAPABILITY_TIERS };
@@ -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
+ }