karajan-code 2.9.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 CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v2.9.0 released** — Audit overhaul release. `kj audit` becomes a stack-aware, two-phase analysis tool. Highlights: (1) **Two-phase mode**: deterministic findings (basalCost + Sonar + OSV + Semgrep + WebPerf) print first; you decide whether to spend tokens on the LLM analysis with `Continue with LLM analysis? [y/N]`. New `--deterministic-only` flag for zero-token runs. (2) **Three new deterministic security collectors**: SonarQube findings as ground truth in the prompt, OSV-Scanner for dependency CVEs (broader DB than `npm audit`), Semgrep for SAST (XSS, SQLi, taint flow, hardcoded secrets — equivalent to `snyk code` but free and local). (3) **Stack-aware prompt**: detects frontend/backend/fullstack and filters dimension heuristics — no more N+1 query nags on Astro projects, no more bundle-size nags on Express APIs. (4) **Accessibility dimension** auto-activates for frontend projects (WCAG 2.x: alt text, labels, ARIA, focus management, contrast hints). (5) **WebPerf section** with frontend perf hints (lazy loading, image format, render-blocking, font-display, critical CSS) plus optional Core Web Vitals verdict integration. (6) **`--report-file` flag** persists audits to `.md` or `.json` with reproducibility header (timestamp, branch, commit, flags). `$KJ_AUDIT_REPORT_DIR` env var for CI. (7) **Token/cost transparency**: every audit ends with provider + model + tokens + estimated USD cost. (8) **CLI/MCP parity bug fixed** — CLI now drives `AuditRole` like MCP does; both paths produce identical prompt content. Plus repo health: 228 dead exports → 3 in 5 atomic PRs, and the `kj audit` detector itself now matches knip ground truth (166 → 4 false positives, 99.7% reduction). 13 audit PRs + 5 dead-export PRs + 106 new tests; full suite 4305/4305 passing. See [CHANGELOG.md](./CHANGELOG.md) for the full punch list.
26
+ > **v2.10.0 released** — Agent-readiness release. Karajan is now the first orchestrator with a full agent-readability surface: an `llms.txt` index at the root, a `SKILL.md` per CLI command under `docs/agents/`, and a static auditor that scores any third-party repo against the same shape. Highlights: (1) **`kj audit --agent-readiness`** scores any repo 0–100 across seven checks (llms.txt, robots AI-bot allowlist, page token budgets ≤ 32 KB, heading hierarchy, agents/README, SKILL.md coverage). LLM-free, deterministic, JSON-able. Karajan-on-Karajan: **100/100**. (2) **Six new SKILL.md files** (`kj doctor / init / board / review / resume / clean`) under `docs/agents/`, all with the same `What it does · Inputs · Outputs · Side effects · Failure modes · Example` contract; CI guards every link in `llms.txt` resolves. (3) **Webperf quality gate inside the iteration loop** (`pipeline.perf.enabled`): PASS continues, FAIL pushes blocking-metric feedback to the coder, scanner-missing skips best-effort. (4) **HU Board hardening**: binds 127.0.0.1 by default, opt-in `--bind 0.0.0.0` enforces an auto-generated token, helmet headers, rate limiting at 300 req/min — "safe by default on a coffee-shop WiFi". (5) **a11y skills auto-route**: tasks mentioning `accessibility / WCAG / ARIA / screen reader / keyboard nav` automatically pull the `frontend-ui-engineering` skill. (6) **Asciinema demo scripts** under `docs/demos/` so the recordings re-record per release instead of rotting. 5 PRs merged (#605–#609 + #610), 4358/4358 tests passing. See [CHANGELOG.md](./CHANGELOG.md) for the full punch list.
27
27
 
28
28
  You describe what you want to build. Karajan orchestrates multiple AI agents to plan it, implement it, test it, review it with SonarQube, and iterate. No babysitting required.
29
29
 
@@ -113,6 +113,19 @@ cd wrappers/python && pip install .
113
113
 
114
114
  That's it. `kj init` auto-detects your installed agents and installs RTK for token optimization.
115
115
 
116
+ ### Optional scanners for `kj audit` + `kj webperf`
117
+
118
+ Karajan auto-skips any scanner that isn't installed. Add the ones that match your projects:
119
+
120
+ | Tool | Install | What you get |
121
+ |------|---------|--------------|
122
+ | **SonarQube** | `docker compose -f ~/sonarqube/docker-compose.yml up -d` | Code quality + security rules with line-precision in `kj audit` |
123
+ | **OSV-Scanner** | `go install github.com/google/osv-scanner@latest` | Dependency CVE coverage broader than `npm audit` |
124
+ | **Semgrep** | `pipx install semgrep` | SAST: XSS, SQLi, taint flow, secrets — equivalent to `snyk code`, free for OSS |
125
+ | **Lighthouse** | `npm install -g lighthouse` | Core Web Vitals + opportunities for `kj webperf` (auto-feeds `kj audit`) |
126
+
127
+ Skip any per-run with `--no-sonar`, `--no-osv`, `--no-semgrep`. See [docs/GETTING-STARTED.md](docs/GETTING-STARTED.md#optional-scanners--kj-audit--kj-webperf) for full table.
128
+
116
129
  ## Three ways to use Karajan
117
130
 
118
131
  Karajan installs **three commands**: `kj`, `kj-tail`, and `karajan-mcp`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -104,10 +104,15 @@ const CHECKS = [
104
104
  if (!fs.existsSync(docsDir)) return { ok: true, hint: null, details: { docsDirPresent: false } };
105
105
  const violations = [];
106
106
  for (const f of listMarkdownFiles(docsDir)) {
107
- const text = safeRead(f);
108
- const h1Count = (text.match(/^#\s+/gm) || []).length;
107
+ const text = stripFencedCodeBlocks(safeRead(f));
108
+ // Count BOTH markdown `# ` and HTML `<h1>` headings — bilingual
109
+ // README-style files often use a centered `<h1 align="center">`
110
+ // banner, which is still semantically the document's H1.
111
+ const mdH1Count = (text.match(/^#\s+/gm) || []).length;
112
+ const htmlH1Count = (text.match(/<h1[\s>]/gi) || []).length;
113
+ const h1Count = mdH1Count + htmlH1Count;
109
114
  if (h1Count !== 1) {
110
- violations.push({ file: path.relative(rootDir, f), h1Count });
115
+ violations.push({ file: path.relative(rootDir, f), h1Count, mdH1Count, htmlH1Count });
111
116
  continue;
112
117
  }
113
118
  // Check for skipped levels (#### without preceding ###, etc.)
@@ -166,6 +171,19 @@ function safeRead(p) {
166
171
  try { return fs.readFileSync(p, "utf8"); } catch { return ""; }
167
172
  }
168
173
 
174
+ /**
175
+ * Replace every fenced code block (```…``` or ~~~…~~~) with blank lines
176
+ * of the same line count so heading regexes don't accidentally match
177
+ * shell comments inside bash code blocks (`# install foo`). Preserving
178
+ * line counts keeps later byte-offset checks stable.
179
+ */
180
+ function stripFencedCodeBlocks(text) {
181
+ return text.replace(/^([ \t]*)(```|~~~)[^\n]*\n([\s\S]*?)\n\1\2[^\n]*$/gm, (match) => {
182
+ const lineCount = match.split("\n").length;
183
+ return "\n".repeat(lineCount - 1);
184
+ });
185
+ }
186
+
169
187
  function listMarkdownFiles(dir, out = []) {
170
188
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
171
189
  if (e.name.startsWith(".") || e.name === "node_modules") continue;
@@ -7,22 +7,67 @@
7
7
  * source (bundle bloat, render-blocking, image opt, lazy loading).
8
8
  * This is the common path because `kj audit` runs against a code
9
9
  * tree, not a live preview server.
10
- * - "cwv-result": a Core Web Vitals measurement was supplied (either
11
- * via config.webperf.lastResult, or in the future via a `kj webperf`
12
- * command that drives Chrome DevTools MCP). evaluateCwv from
13
- * src/webperf/cwv-gate.js produces a structured verdict the prompt
14
- * renders verbatim.
10
+ * - "cwv-result": a Core Web Vitals measurement was supplied. Three
11
+ * possible sources, in priority order:
12
+ * 1. `config.webperf.lastResult` — inline {metrics, thresholds, ...}
13
+ * (rare; legacy / programmatic injection)
14
+ * 2. `config.webperf.lastResultPath` — file path written by
15
+ * `kj webperf` (KJC-TSK-0149). This is the standard wiring.
16
+ * 3. `~/.karajan/webperf/<projectSlug>/last.json` — fallback
17
+ * discovery when the user ran `kj webperf` but didn't patch
18
+ * their kj.config.yml.
19
+ * evaluateCwv from src/webperf/cwv-gate.js produces a structured
20
+ * verdict the prompt renders verbatim.
15
21
  *
16
22
  * The "live measurement" path is intentionally NOT wired here: the
17
23
  * audit's LLM sub-agent is forbidden from MCP tool use (SUBAGENT_PREAMBLE
18
24
  * in src/prompts/audit.js), and spawning Chrome / launching a preview
19
- * server during a read-only audit is out of scope. When a future
20
- * `kj webperf` command exists, it can write the result into
21
- * `config.webperf.lastResult` and this collector will surface it.
25
+ * server during a read-only audit is wrong layer. `kj webperf` does
26
+ * the measurement; `kj audit` consumes the persisted result.
22
27
  */
23
28
 
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+ import os from "node:os";
24
32
  import { evaluateCwv, CWV_THRESHOLDS } from "../webperf/cwv-gate.js";
25
33
 
34
+ // Resolve per call — `os.homedir()` reads $HOME each time, so test
35
+ // suites that override $HOME for hermetic tmp dirs work without
36
+ // special re-imports.
37
+ function webperfHome() {
38
+ return path.join(os.homedir(), ".karajan", "webperf");
39
+ }
40
+
41
+ function projectSlug(projectDir) {
42
+ return path.basename(projectDir || process.cwd()).replace(/[^a-zA-Z0-9_-]/g, "_");
43
+ }
44
+
45
+ /**
46
+ * Try the three discovery paths in priority order. Returns the parsed
47
+ * scan object (shape produced by src/webperf/scanner.js) or null.
48
+ */
49
+ function loadPersistedScan(webperfConfig, projectDir) {
50
+ // 1. Inline result on the config (back-compat)
51
+ if (webperfConfig.lastResult && typeof webperfConfig.lastResult === "object" && webperfConfig.lastResult.metrics) {
52
+ return webperfConfig.lastResult;
53
+ }
54
+ // 2. Explicit file path on the config
55
+ const candidates = [];
56
+ if (webperfConfig.lastResultPath) candidates.push(webperfConfig.lastResultPath);
57
+ // 3. Fallback: ~/.karajan/webperf/<slug>/last.json
58
+ if (projectDir) {
59
+ candidates.push(path.join(webperfHome(), projectSlug(projectDir), "last.json"));
60
+ }
61
+ for (const candidate of candidates) {
62
+ try {
63
+ const raw = fs.readFileSync(candidate, "utf8");
64
+ const parsed = JSON.parse(raw);
65
+ if (parsed && typeof parsed === "object" && parsed.metrics) return parsed;
66
+ } catch { /* missing file or unparseable — try next */ }
67
+ }
68
+ return null;
69
+ }
70
+
26
71
  /**
27
72
  * Decide what (if anything) to feed the audit prompt about web perf.
28
73
  *
@@ -32,17 +77,18 @@ import { evaluateCwv, CWV_THRESHOLDS } from "../webperf/cwv-gate.js";
32
77
  */
33
78
  export function collectWebPerfInput(stack, config = {}) {
34
79
  const webperfConfig = config?.webperf || {};
35
- const lastResult = webperfConfig.lastResult || null;
80
+ const projectDir = config?.projectDir;
81
+ const persisted = loadPersistedScan(webperfConfig, projectDir);
36
82
 
37
- if (lastResult && typeof lastResult === "object" && lastResult.metrics) {
38
- const verdict = evaluateCwv(lastResult.metrics, lastResult.thresholds || webperfConfig.thresholds);
83
+ if (persisted) {
84
+ const verdict = persisted.cwv || evaluateCwv(persisted.metrics, persisted.thresholds || webperfConfig.thresholds);
39
85
  return {
40
86
  available: true,
41
87
  mode: "cwv-result",
42
- url: lastResult.url || webperfConfig.url || null,
43
- capturedAt: lastResult.capturedAt || null,
88
+ url: persisted.url || webperfConfig.url || null,
89
+ capturedAt: persisted.capturedAt || null,
44
90
  cwv: verdict,
45
- thresholds: lastResult.thresholds || webperfConfig.thresholds || CWV_THRESHOLDS,
91
+ thresholds: persisted.thresholds || webperfConfig.thresholds || CWV_THRESHOLDS,
46
92
  };
47
93
  }
48
94
 
@@ -5,6 +5,7 @@ import { architectCommand } from "../commands/architect.js";
5
5
  import { auditCommand } from "../commands/audit.js";
6
6
  import { resumeCommand } from "../commands/resume.js";
7
7
  import { boardCommand } from "../commands/board.js";
8
+ import { webperfCommand } from "../commands/webperf.js";
8
9
  import { undoCommand } from "../commands/undo.js";
9
10
  import { syncCommand } from "../commands/sync.js";
10
11
  import { cleanCommand } from "../commands/clean.js";
@@ -125,6 +126,27 @@ export function registerMeta(program, { pkgVersion }) {
125
126
  });
126
127
  });
127
128
 
129
+ program
130
+ .command("webperf")
131
+ .description("Run a Lighthouse web-perf scan against a URL — Core Web Vitals + opportunities")
132
+ .argument("<url>", "Target URL (e.g. http://localhost:3000)")
133
+ .option("--mobile", "Use the Lighthouse mobile preset (default: desktop)")
134
+ .option("--json", "Output raw JSON")
135
+ .option("--no-persist", "Don't write the result into ~/.karajan/webperf/<slug>/last.json (kj audit reads it from there)")
136
+ .action(async (url, flags) => {
137
+ await withConfig(pkgVersion, "webperf", flags, async ({ config, logger }) => {
138
+ const result = await webperfCommand({
139
+ url,
140
+ config, logger,
141
+ mobile: Boolean(flags.mobile),
142
+ json: Boolean(flags.json),
143
+ persist: flags.persist !== false,
144
+ });
145
+ // Exit with non-zero when the verdict is FAIL so CI can gate.
146
+ if (result && result.ok === false) process.exitCode = 1;
147
+ });
148
+ });
149
+
128
150
  program
129
151
  .command("resume")
130
152
  .description("Resume a paused session")
@@ -167,10 +189,12 @@ export function registerMeta(program, { pkgVersion }) {
167
189
  .command("board [action]")
168
190
  .description("Manage HU Board (start|stop|status|open)")
169
191
  .option("--port <number>", "Port (default: 4000)", "4000")
192
+ .option("--bind <host>", "Bind host (default: 127.0.0.1; use 0.0.0.0 to expose on LAN — token auth auto-enforced)")
170
193
  .action(async (action = "start", opts) => {
171
194
  await withConfig(pkgVersion, "board", opts, async ({ config, logger }) => {
172
195
  const port = Number(opts.port) || config.hu_board?.port || 4000;
173
- await boardCommand({ action, port, logger });
196
+ const bind = opts.bind || config.hu_board?.bind || "127.0.0.1";
197
+ await boardCommand({ action, port, bind, logger });
174
198
  });
175
199
  });
176
200
 
@@ -58,6 +58,7 @@ export function registerPipeline(program, { pkgVersion }) {
58
58
  .option("--enable-tester")
59
59
  .option("--enable-security")
60
60
  .option("--enable-impeccable")
61
+ .option("--enable-perf")
61
62
  .option("--enable-triage")
62
63
  .option("--enable-discover")
63
64
  .option("--enable-architect")
@@ -89,7 +89,7 @@ function buildBoardUrl(port, projectSlug) {
89
89
  }
90
90
 
91
91
  export async function startBoard(desiredPort = 4000, opts = {}) {
92
- const { projectSlug = null } = opts;
92
+ const { projectSlug = null, bind = "127.0.0.1" } = opts;
93
93
  const existingPid = readPid();
94
94
  if (existingPid && isProcessAlive(existingPid)) {
95
95
  // Trust the saved PID — port info comes from the PID file if present
@@ -129,7 +129,7 @@ export async function startBoard(desiredPort = 4000, opts = {}) {
129
129
  // Use process.execPath (absolute path to current node binary) instead of "node".
130
130
  // Fixes ENOENT on nvm setups where `node` is not in the spawned process's PATH.
131
131
  const child = spawn(process.execPath, [serverPath], {
132
- env: { ...process.env, PORT: String(port) },
132
+ env: { ...process.env, PORT: String(port), BIND_HOST: bind },
133
133
  detached: true,
134
134
  stdio: "ignore",
135
135
  cwd: BOARD_DIR
@@ -265,14 +265,17 @@ export function renderBoardBanner({ url, status, projectName }) {
265
265
  return ["", rule, ...content, rule, ""].join("\n");
266
266
  }
267
267
 
268
- export async function boardCommand({ action = "start", port = 4000, logger }) {
268
+ export async function boardCommand({ action = "start", port = 4000, bind = "127.0.0.1", logger }) {
269
269
  switch (action) {
270
270
  case "start": {
271
- const result = await startBoard(port);
271
+ const result = await startBoard(port, { bind });
272
272
  if (result.alreadyRunning) {
273
273
  logger.info(`HU Board already running (PID ${result.pid}) at ${result.url}`);
274
274
  } else {
275
275
  logger.info(`HU Board started (PID ${result.pid}) at ${result.url}`);
276
+ if (bind !== "127.0.0.1" && bind !== "localhost") {
277
+ logger.warn(`HU Board bound to ${bind} — token auth enforced for non-loopback peers. Token: ~/.karajan/hu-board/token`);
278
+ }
276
279
  }
277
280
  return result;
278
281
  }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * `kj webperf <url>` — KJC-TSK-0149.
3
+ *
4
+ * Headless Lighthouse wrapper. Produces structured CWV + opportunities
5
+ * JSON. Writes the result into `~/.karajan/webperf/<projectSlug>/last.json`
6
+ * so future `kj audit` runs can surface the verdict via the existing
7
+ * WebPerf section (KJC-TSK-0360 cwv-result mode).
8
+ */
9
+
10
+ import fs from "node:fs/promises";
11
+ import path from "node:path";
12
+ import os from "node:os";
13
+ import yaml from "js-yaml";
14
+ import { runWebperfScan } from "../webperf/scanner.js";
15
+ import { withCliRunLog } from "../utils/cli-run-log.js";
16
+
17
+ // Resolve home each call so tests that override $HOME for hermetic
18
+ // tmp dirs work without re-importing the module.
19
+ function defaultReportDir() {
20
+ return path.join(os.homedir(), ".karajan", "webperf");
21
+ }
22
+
23
+ /**
24
+ * Slug a project directory the same way audit/basal-cost.js and
25
+ * plan/plan-store.js do, so the per-project webperf history aligns
26
+ * with audit history under ~/.karajan/.
27
+ */
28
+ function projectSlug(projectDir) {
29
+ return path.basename(projectDir || process.cwd()).replace(/[^a-zA-Z0-9_-]/g, "_");
30
+ }
31
+
32
+ function formatHumanReport(result) {
33
+ if (!result.ok) {
34
+ return `WebPerf scan FAILED — ${result.reason}`;
35
+ }
36
+ const lines = ["## WebPerf Scan", ""];
37
+ lines.push(`- URL: ${result.url}`);
38
+ lines.push(`- Preset: ${result.preset}`);
39
+ lines.push(`- Captured: ${result.capturedAt}`);
40
+ if (result.lighthouseVersion) lines.push(`- Lighthouse: v${result.lighthouseVersion}`);
41
+ if (typeof result.performanceScore === "number") {
42
+ lines.push(`- Performance score: ${result.performanceScore}/100`);
43
+ }
44
+ lines.push("");
45
+ lines.push(`### Verdict: ${result.cwv.pass ? "PASS" : "FAIL"}`);
46
+ lines.push("");
47
+ lines.push("### Core Web Vitals");
48
+ for (const [metric, score] of Object.entries(result.cwv.scores || {})) {
49
+ const value = formatMetricValue(metric, score.value);
50
+ lines.push(`- ${metric.toUpperCase()}: ${value} (${score.rating})`);
51
+ }
52
+ if (result.cwv.blocking?.length) {
53
+ lines.push("");
54
+ lines.push("### Blocking metrics (above poor threshold)");
55
+ for (const b of result.cwv.blocking) {
56
+ lines.push(`- ${b.metric.toUpperCase()}: ${formatMetricValue(b.metric, b.value)} > ${formatMetricValue(b.metric, b.threshold)}`);
57
+ }
58
+ }
59
+ if (result.issues?.length) {
60
+ lines.push("");
61
+ lines.push(`### Top opportunities (${result.issues.length})`);
62
+ for (const issue of result.issues.slice(0, 10)) {
63
+ const savings = issue.savingsMs ? ` (saves ~${Math.round(issue.savingsMs)}ms)` : "";
64
+ lines.push(`- [${issue.severity.toUpperCase()}] ${issue.id}${savings}: ${issue.title}`);
65
+ }
66
+ if (result.issues.length > 10) {
67
+ lines.push(`- ... and ${result.issues.length - 10} more`);
68
+ }
69
+ }
70
+ return lines.join("\n");
71
+ }
72
+
73
+ function formatMetricValue(metric, value) {
74
+ if (typeof value !== "number") return "n/a";
75
+ if (metric === "cls") return value.toFixed(3);
76
+ return `${Math.round(value)}ms`;
77
+ }
78
+
79
+ /**
80
+ * Persist the scan result into ~/.karajan/webperf/<slug>/last.json so
81
+ * `kj audit` can read it via collectWebPerfInput (KJC-TSK-0360).
82
+ */
83
+ async function persistResult(result, projectDir) {
84
+ if (!result.ok) return null;
85
+ const dir = path.join(defaultReportDir(), projectSlug(projectDir));
86
+ await fs.mkdir(dir, { recursive: true });
87
+ const filePath = path.join(dir, "last.json");
88
+ await fs.writeFile(filePath, JSON.stringify(result, null, 2), "utf8");
89
+ return filePath;
90
+ }
91
+
92
+ /**
93
+ * Optionally update kj.config.yml so `kj audit` finds the result by
94
+ * default (mirrors how sonar config is wired). Best-effort: never
95
+ * fails the command; absence of config file or write error is logged.
96
+ */
97
+ async function patchConfigLastResult(configPath, lastResultPath, logger) {
98
+ if (!configPath || !lastResultPath) return false;
99
+ let raw;
100
+ try { raw = await fs.readFile(configPath, "utf8"); } catch { return false; }
101
+ let parsed;
102
+ try { parsed = yaml.load(raw) || {}; } catch (err) {
103
+ if (logger?.warn) logger.warn(`webperf: cannot parse ${configPath}: ${err.message}`);
104
+ return false;
105
+ }
106
+ parsed.webperf = parsed.webperf || {};
107
+ parsed.webperf.lastResultPath = lastResultPath;
108
+ try {
109
+ await fs.writeFile(configPath, yaml.dump(parsed), "utf8");
110
+ return true;
111
+ } catch (err) {
112
+ if (logger?.warn) logger.warn(`webperf: cannot write ${configPath}: ${err.message}`);
113
+ return false;
114
+ }
115
+ }
116
+
117
+ export async function webperfCommand({ url, config, logger, json = false, mobile = false, persist = true, thresholds = null }) {
118
+ if (!url) {
119
+ throw new Error("kj webperf: <url> is required (e.g. http://localhost:3000)");
120
+ }
121
+ return withCliRunLog("webperf", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
122
+ logger.info(`Running webperf scan against ${url} (preset: ${mobile ? "mobile" : "desktop"})`);
123
+ runLog.logText(`[webperf] scan url=${url} preset=${mobile ? "mobile" : "desktop"}`);
124
+
125
+ const scanThresholds = thresholds || config?.webperf?.thresholds || null;
126
+ const result = await runWebperfScan(url, {
127
+ preset: mobile ? "mobile" : "desktop",
128
+ thresholds: scanThresholds,
129
+ logger,
130
+ });
131
+
132
+ if (json) {
133
+ console.log(JSON.stringify(result, null, 2));
134
+ } else {
135
+ console.log(formatHumanReport(result));
136
+ }
137
+
138
+ if (!result.ok) {
139
+ runLog.logText(`[webperf] failed reason=${result.reason}`);
140
+ return { ok: false, reason: result.reason };
141
+ }
142
+
143
+ runLog.logText(`[webperf] verdict=${result.cwv.pass ? "PASS" : "FAIL"} score=${result.performanceScore}`);
144
+
145
+ let savedPath = null;
146
+ if (persist) {
147
+ try {
148
+ savedPath = await persistResult(result, config?.projectDir);
149
+ if (savedPath) {
150
+ logger.info(`WebPerf result saved: ${savedPath}`);
151
+ // Wire into kj.config.yml so `kj audit` picks it up automatically.
152
+ const configPath = config?.configFilePath
153
+ || path.join(config?.projectDir || process.cwd(), ".karajan", "kj.config.yml");
154
+ await patchConfigLastResult(configPath, savedPath, logger);
155
+ }
156
+ } catch (err) {
157
+ logger.warn(`webperf: persist failed (non-blocking): ${err.message}`);
158
+ }
159
+ }
160
+
161
+ // Exit code: 0 for PASS, 1 for FAIL. Lets CI gate on it.
162
+ return { ok: result.cwv.pass, result, savedPath };
163
+ });
164
+ }
@@ -37,6 +37,7 @@ const DEFAULTS = {
37
37
  tester: { enabled: true },
38
38
  security: { enabled: true },
39
39
  impeccable: { enabled: false },
40
+ perf: { enabled: false },
40
41
  triage: { enabled: true },
41
42
  discover: { enabled: false },
42
43
  architect: { enabled: false },
@@ -37,6 +37,7 @@ const PIPELINE_ENABLE_FLAGS = [
37
37
  ["enablePlanner", "planner"], ["enableRefactorer", "refactorer"],
38
38
  ["enableSolomon", "solomon"], ["enableResearcher", "researcher"],
39
39
  ["enableTester", "tester"], ["enableSecurity", "security"], ["enableImpeccable", "impeccable"],
40
+ ["enablePerf", "perf"],
40
41
  ["enableTriage", "triage"], ["enableDiscover", "discover"],
41
42
  ["enableArchitect", "architect"],
42
43
  ["enableHuReviewer", "hu_reviewer"]
@@ -89,6 +89,7 @@ const PipelineMap = v.optional(v.looseObject({
89
89
  tester: v.optional(PipelineEntry),
90
90
  security: v.optional(PipelineEntry),
91
91
  impeccable: v.optional(PipelineEntry),
92
+ perf: v.optional(PipelineEntry),
92
93
  triage: v.optional(PipelineEntry),
93
94
  discover: v.optional(PipelineEntry),
94
95
  architect: v.optional(PipelineEntry),
@@ -263,7 +263,7 @@ export async function handleResume(a, server, extra) {
263
263
  }
264
264
  a.answer = validation.sanitized;
265
265
  }
266
- applySessionOverrides(a, ["coder", "reviewer", "tester", "security", "solomon", "enableTester", "enableSecurity", "enableImpeccable"]);
266
+ applySessionOverrides(a, ["coder", "reviewer", "tester", "security", "solomon", "enableTester", "enableSecurity", "enableImpeccable", "enablePerf"]);
267
267
  return handleResumeDirect(a, server, extra);
268
268
  }
269
269
 
@@ -303,6 +303,6 @@ export async function handleRun(a, server, extra) {
303
303
  const logger = createLogger("info", "mcp");
304
304
  logger.info("Preflight auto-acknowledged with default agent config");
305
305
  }
306
- applySessionOverrides(a, ["coder", "reviewer", "tester", "security", "solomon", "enableTester", "enableSecurity", "enableImpeccable"]);
306
+ applySessionOverrides(a, ["coder", "reviewer", "tester", "security", "solomon", "enableTester", "enableSecurity", "enableImpeccable", "enablePerf"]);
307
307
  return handleRunDirect(a, server, extra);
308
308
  }
package/src/mcp/run-kj.js CHANGED
@@ -50,6 +50,7 @@ export async function runKjCommand({ command, commandArgs = [], options = {}, en
50
50
  normalizeBoolFlag(options.enableTester, "--enable-tester", args);
51
51
  normalizeBoolFlag(options.enableSecurity, "--enable-security", args);
52
52
  normalizeBoolFlag(options.enableImpeccable, "--enable-impeccable", args);
53
+ normalizeBoolFlag(options.enablePerf, "--enable-perf", args);
53
54
  normalizeBoolFlag(options.design, "--design", args);
54
55
  normalizeBoolFlag(options.enableTriage, "--enable-triage", args);
55
56
  normalizeBoolFlag(options.enableDiscover, "--enable-discover", args);
@@ -14,7 +14,7 @@ const ALLOWED_PARAMS = new Set([
14
14
  "planner", "coder", "reviewer", "refactorer",
15
15
  "plannerModel", "coderModel", "reviewerModel", "refactorerModel",
16
16
  "enablePlanner", "enableReviewer", "enableRefactorer", "enableResearcher",
17
- "enableTester", "enableSecurity", "enableImpeccable",
17
+ "enableTester", "enableSecurity", "enableImpeccable", "enablePerf",
18
18
  "enableDiscover", "enableArchitect",
19
19
  "architectModel", "huFile",
20
20
  "enableSerena", "enableCi",
package/src/mcp/tools.js CHANGED
@@ -76,6 +76,7 @@ export const tools = [
76
76
  enableTester: { type: "boolean" },
77
77
  enableSecurity: { type: "boolean" },
78
78
  enableImpeccable: { type: "boolean" },
79
+ enablePerf: { type: "boolean" },
79
80
  enableTriage: { type: "boolean" },
80
81
  enableDiscover: { type: "boolean" },
81
82
  enableArchitect: { type: "boolean" },
@@ -217,6 +217,7 @@ export function resolvePipelineFlags(config) {
217
217
  testerEnabled: Boolean(config.pipeline?.tester?.enabled),
218
218
  securityEnabled: Boolean(config.pipeline?.security?.enabled),
219
219
  impeccableEnabled: Boolean(config.pipeline?.impeccable?.enabled),
220
+ perfEnabled: Boolean(config.pipeline?.perf?.enabled),
220
221
  reviewerEnabled: config.pipeline?.reviewer?.enabled !== false,
221
222
  discoverEnabled: Boolean(config.pipeline?.discover?.enabled),
222
223
  architectEnabled: Boolean(config.pipeline?.architect?.enabled),
@@ -225,7 +226,7 @@ export function resolvePipelineFlags(config) {
225
226
  }
226
227
 
227
228
  export async function handleDryRun({ task, config, flags, emitter, pipelineFlags }) {
228
- const { plannerEnabled, refactorerEnabled, researcherEnabled, testerEnabled, securityEnabled, impeccableEnabled, reviewerEnabled, discoverEnabled, architectEnabled, huReviewerEnabled } = pipelineFlags;
229
+ const { plannerEnabled, refactorerEnabled, researcherEnabled, testerEnabled, securityEnabled, impeccableEnabled, perfEnabled, reviewerEnabled, discoverEnabled, architectEnabled, huReviewerEnabled } = pipelineFlags;
229
230
  const plannerRole = resolveRole(config, "planner");
230
231
  const coderRole = resolveRole(config, "coder");
231
232
  const reviewerRole = resolveRole(config, "reviewer");
@@ -259,6 +260,7 @@ export async function handleDryRun({ task, config, flags, emitter, pipelineFlags
259
260
  tester_enabled: testerEnabled,
260
261
  security_enabled: securityEnabled,
261
262
  impeccable_enabled: impeccableEnabled,
263
+ perf_enabled: perfEnabled,
262
264
  solomon_enabled: Boolean(config.pipeline?.solomon?.enabled),
263
265
  hu_reviewer_enabled: huReviewerEnabled
264
266
  },
@@ -18,6 +18,7 @@ import {
18
18
  runTddCheckStage, runSonarStage, runSonarCloudStage,
19
19
  } from "../../iteration-stages.js";
20
20
  import { runImpeccableStage } from "../../post-loop-stages.js";
21
+ import { runPerfStage } from "../../stages/perf-stage.js";
21
22
  import { tryCiComment } from "../../ci-integration.js";
22
23
 
23
24
  export async function runQualityGateStages({ config, logger, emitter, eventBase, session, trackBudget, i, askQuestion, repeatDetector, budgetSummary, sonarState, task, stageResults, coderRole, pipelineFlags, brainCtx }) {
@@ -72,5 +73,16 @@ export async function runQualityGateStages({ config, logger, emitter, eventBase,
72
73
  }
73
74
  }
74
75
 
76
+ if (pipelineFlags?.perfEnabled) {
77
+ const perfResult = await runPerfStage({
78
+ config, logger, emitter, eventBase, session, trackBudget,
79
+ iteration: i, task, brainCtx
80
+ });
81
+ if (perfResult.stageResult) {
82
+ stageResults.perf = perfResult.stageResult;
83
+ }
84
+ if (perfResult.action === "continue") return { action: "continue" };
85
+ }
86
+
75
87
  return { action: "ok" };
76
88
  }
@@ -239,6 +239,7 @@ export function buildPlanSummary({ pipelineFlags, config, stageResults, task })
239
239
  if (pipelineFlags.testerEnabled) activeStages.push("Tester");
240
240
  if (pipelineFlags.securityEnabled) activeStages.push("Security");
241
241
  if (pipelineFlags.impeccableEnabled) activeStages.push("Impeccable");
242
+ if (pipelineFlags.perfEnabled) activeStages.push("Perf");
242
243
  lines.push(`│ Stages: ${activeStages.join(" → ")}`);
243
244
 
244
245
  // Config
@@ -0,0 +1,140 @@
1
+ /**
2
+ * PerfStage — webperf quality gate inside the iteration loop.
3
+ *
4
+ * KJC-TSK-0151. Wraps PerfRole and slots into the existing
5
+ * runQualityGateStages flow alongside Sonar and Impeccable. Designed
6
+ * to be the simplest possible quality gate:
7
+ *
8
+ * - PASS verdict (cwv.pass === true) → return ok, iteration continues.
9
+ * - FAIL verdict → set reviewer feedback with the blocking metrics
10
+ * so the coder receives concrete guidance next iteration. Returns
11
+ * `continue` to retry the iteration loop. No repeat-detector +
12
+ * retry-limit machinery: rerunning Lighthouse on the same URL
13
+ * gives the same result unless the coder changes something, and
14
+ * the iteration limit (max_iterations) is the natural ceiling.
15
+ * - Scanner unavailable (lighthouse missing, URL unreachable, timeout)
16
+ * → log a warn and return ok-skipped. Best-effort, never blocks
17
+ * the pipeline by itself.
18
+ *
19
+ * The role surface (PerfRole) is responsible for persisting the result
20
+ * to ~/.karajan/webperf/<slug>/last.json so subsequent `kj audit` runs
21
+ * pick it up via the auto-discovery added in #602.
22
+ */
23
+
24
+ import { PerfRole } from "../../roles/perf-role.js";
25
+ import { setReviewerFeedback } from "../../session/mutators.js";
26
+ import { saveSession } from "../../session/store.js";
27
+ import { emitProgress, makeEvent } from "../../utils/events.js";
28
+
29
+ /**
30
+ * Build the human-readable feedback line that goes back to the coder
31
+ * on FAIL. Concrete enough that the LLM can act on it without re-reading
32
+ * the whole report.
33
+ */
34
+ function buildFeedback(roleResult) {
35
+ const { result } = roleResult;
36
+ const score = typeof result.performanceScore === "number" ? result.performanceScore : null;
37
+ const blocking = (result.cwv?.blocking || []).map(b => `${b.metric.toUpperCase()}=${Math.round(b.value)} (poor>${b.threshold})`);
38
+ const top = (result.issues || []).slice(0, 3).map(i => `${i.id}${i.savingsMs ? ` (~${Math.round(i.savingsMs)}ms)` : ""}`);
39
+
40
+ const lines = [`Webperf gate FAILED${score !== null ? ` (score ${score}/100)` : ""}.`];
41
+ if (blocking.length > 0) lines.push(`Blocking metrics: ${blocking.join(", ")}.`);
42
+ if (top.length > 0) lines.push(`Top opportunities: ${top.join(", ")}.`);
43
+ lines.push("Address the blocking metric(s) before the next iteration; rerunning without code changes will produce the same verdict.");
44
+ return lines.join(" ");
45
+ }
46
+
47
+ /**
48
+ * Run the perf gate against config.webperf.url.
49
+ *
50
+ * @returns {Promise<{action: "ok"|"continue", stageResult?: object, summary?: string}>}
51
+ */
52
+ export async function runPerfStage({ config, logger, emitter, eventBase, session, trackBudget, iteration, task: _task, brainCtx }) {
53
+ if (!session) {
54
+ throw new Error("runPerfStage: `session` is required.");
55
+ }
56
+ logger.setContext({ iteration, stage: "perf" });
57
+ emitProgress(
58
+ emitter,
59
+ makeEvent("perf:start", { ...eventBase, stage: "perf" }, {
60
+ message: "WebPerf gate scanning",
61
+ detail: { url: config?.webperf?.url || null, preset: config?.webperf?.preset || "desktop" },
62
+ })
63
+ );
64
+
65
+ const role = new PerfRole({ config, logger, emitter });
66
+ const roleResult = await role.execute();
67
+
68
+ // Track budget — the role doesn't spawn an LLM but it does consume wall
69
+ // time; surface that in the budget summary alongside the agent calls.
70
+ if (trackBudget) {
71
+ try { trackBudget("perf", { tokens_in: 0, tokens_out: 0, cost_usd: 0 }); } catch { /* best-effort */ }
72
+ }
73
+
74
+ // Scanner unavailable → skip with warn. PerfRole returns ok=false for
75
+ // both "verdict FAIL" and "scanner failed", so we differentiate on the
76
+ // result.error field (set only by the second case).
77
+ if (!roleResult.ok && roleResult.result?.error) {
78
+ logger.warn(`Perf gate skipped: ${roleResult.result.error}`);
79
+ emitProgress(emitter, makeEvent("perf:end", { ...eventBase, stage: "perf" }, {
80
+ status: "warn",
81
+ message: `Perf gate skipped — ${roleResult.result.error}`,
82
+ }));
83
+ return { action: "ok", stageResult: { skipped: true, reason: roleResult.result.error } };
84
+ }
85
+
86
+ // PASS verdict
87
+ if (roleResult.ok === true) {
88
+ emitProgress(emitter, makeEvent("perf:end", { ...eventBase, stage: "perf" }, {
89
+ status: "ok",
90
+ message: roleResult.summary,
91
+ }));
92
+ return {
93
+ action: "ok",
94
+ stageResult: {
95
+ verdict: "PASS",
96
+ url: roleResult.result?.url,
97
+ performanceScore: roleResult.result?.performanceScore,
98
+ cwv: roleResult.result?.cwv,
99
+ savedPath: roleResult.result?.savedPath,
100
+ summary: roleResult.summary,
101
+ },
102
+ };
103
+ }
104
+
105
+ // FAIL verdict → push concrete feedback to the coder for next iteration.
106
+ const feedback = buildFeedback(roleResult);
107
+ setReviewerFeedback(session, feedback);
108
+ await saveSession(session);
109
+
110
+ // Brain: when enabled, surface perf feedback on the queue too.
111
+ if (brainCtx?.enabled) {
112
+ try {
113
+ const { processRoleOutput } = await import("../brain-coordinator.js");
114
+ processRoleOutput(brainCtx, {
115
+ roleName: "perf",
116
+ output: { verdict: "fail", summary: feedback },
117
+ iteration,
118
+ });
119
+ } catch (err) {
120
+ logger.warn(`Perf gate: brain queue push failed (non-blocking): ${err.message}`);
121
+ }
122
+ }
123
+
124
+ emitProgress(emitter, makeEvent("perf:end", { ...eventBase, stage: "perf" }, {
125
+ status: "fail",
126
+ message: roleResult.summary,
127
+ }));
128
+
129
+ return {
130
+ action: "continue",
131
+ stageResult: {
132
+ verdict: "FAIL",
133
+ url: roleResult.result?.url,
134
+ performanceScore: roleResult.result?.performanceScore,
135
+ cwv: roleResult.result?.cwv,
136
+ issues: roleResult.result?.issues,
137
+ summary: feedback,
138
+ },
139
+ };
140
+ }
@@ -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" },
@@ -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
+ }