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 +14 -1
- package/package.json +1 -1
- package/src/audit/agent-readiness.js +21 -3
- package/src/audit/webperf-input.js +60 -14
- package/src/cli/register-meta.js +25 -1
- package/src/cli/register-pipeline.js +1 -0
- package/src/commands/board.js +7 -4
- 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/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/config-init.js +3 -1
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +12 -0
- package/src/orchestrator/session-journal.js +1 -0
- package/src/orchestrator/stages/perf-stage.js +140 -0
- package/src/roles/perf-role.js +122 -0
- package/src/skills/addyosmani-role-map.js +6 -0
- package/src/webperf/scanner.js +209 -0
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v2.
|
|
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
|
@@ -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
|
-
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
20
|
-
* `kj
|
|
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
|
|
80
|
+
const projectDir = config?.projectDir;
|
|
81
|
+
const persisted = loadPersistedScan(webperfConfig, projectDir);
|
|
36
82
|
|
|
37
|
-
if (
|
|
38
|
-
const verdict = evaluateCwv(
|
|
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:
|
|
43
|
-
capturedAt:
|
|
88
|
+
url: persisted.url || webperfConfig.url || null,
|
|
89
|
+
capturedAt: persisted.capturedAt || null,
|
|
44
90
|
cwv: verdict,
|
|
45
|
-
thresholds:
|
|
91
|
+
thresholds: persisted.thresholds || webperfConfig.thresholds || CWV_THRESHOLDS,
|
|
46
92
|
};
|
|
47
93
|
}
|
|
48
94
|
|
package/src/cli/register-meta.js
CHANGED
|
@@ -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
|
-
|
|
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")
|
package/src/commands/board.js
CHANGED
|
@@ -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
|
+
}
|
package/src/config/defaults.js
CHANGED
package/src/config/overrides.js
CHANGED
|
@@ -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"]
|
package/src/config/schema.js
CHANGED
|
@@ -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
|
+
}
|