karajan-code 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +14 -1
  2. package/package.json +1 -1
  3. package/src/audit/agent-readiness.js +21 -3
  4. package/src/audit/basal-cost.js +92 -9
  5. package/src/audit/deterministic-summary.js +200 -0
  6. package/src/audit/osv-findings.js +162 -0
  7. package/src/audit/semgrep-findings.js +117 -0
  8. package/src/audit/sonar-findings.js +81 -0
  9. package/src/audit/webperf-input.js +136 -0
  10. package/src/canvas/schema.js +1 -1
  11. package/src/checks/binaries.js +2 -2
  12. package/src/checks/ci.js +2 -2
  13. package/src/checks/config-files.js +6 -6
  14. package/src/checks/dir-setup.js +4 -0
  15. package/src/checks/mcp-health.js +6 -0
  16. package/src/checks/ports.js +8 -0
  17. package/src/checks/rtk.js +1 -1
  18. package/src/checks/skills.js +5 -1
  19. package/src/checks/sonar.js +1 -1
  20. package/src/checks/system.js +2 -2
  21. package/src/checks/tokens.js +1 -1
  22. package/src/checks/types.js +0 -12
  23. package/src/cli/register-meta.js +40 -2
  24. package/src/cli/register-pipeline.js +1 -0
  25. package/src/commands/audit.js +239 -28
  26. package/src/commands/board.js +7 -4
  27. package/src/commands/doctor.js +1 -1
  28. package/src/commands/webperf.js +164 -0
  29. package/src/config/defaults.js +1 -0
  30. package/src/config/overrides.js +1 -0
  31. package/src/config/schema.js +1 -0
  32. package/src/config/test-harness.js +0 -1
  33. package/src/mcp/handlers/run-handler.js +2 -2
  34. package/src/mcp/run-kj.js +1 -0
  35. package/src/mcp/sovereignty-guard.js +1 -1
  36. package/src/mcp/tools.js +1 -0
  37. package/src/orchestrator/ci-integration.js +1 -1
  38. package/src/orchestrator/config-init.js +3 -1
  39. package/src/orchestrator/direct-actions.js +1 -1
  40. package/src/orchestrator/drivers/error-recovery.js +2 -2
  41. package/src/orchestrator/drivers/iteration-loop.js +3 -13
  42. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +12 -0
  43. package/src/orchestrator/drivers/pre-loop.js +1 -4
  44. package/src/orchestrator/feedback-queue.js +0 -1
  45. package/src/orchestrator/integrations.js +0 -7
  46. package/src/orchestrator/role-output-compressor.js +0 -1
  47. package/src/orchestrator/session-journal.js +5 -41
  48. package/src/orchestrator/stages/perf-stage.js +140 -0
  49. package/src/orchestrator/stages/stage-classes.js +0 -2
  50. package/src/plan/plan-store.js +0 -14
  51. package/src/planning-game/client.js +0 -14
  52. package/src/planning-game/pipeline-adapter.js +1 -1
  53. package/src/prompts/audit.js +206 -6
  54. package/src/prompts/rtk-snippet.js +1 -1
  55. package/src/roles/audit-role.js +125 -13
  56. package/src/roles/perf-role.js +122 -0
  57. package/src/skills/addyosmani-role-map.js +6 -0
  58. package/src/sonar/api.js +1 -1
  59. package/src/utils/display/event-handlers.js +1 -1
  60. package/src/utils/display/header.js +1 -1
  61. package/src/utils/display/session-summary.js +3 -3
  62. package/src/utils/display/solomon.js +1 -1
  63. package/src/utils/role-assigner.js +1 -1
  64. package/src/webperf/scanner.js +209 -0
package/README.md CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v2.8.0 released** — Audit-driven hardening release. The 2026-04-30 self-audit (`kj audit`) flagged 13 issues across security, code quality, performance, architecture and testing; this release closes all of them in 16 PRs with 0 user-visible API changes. Highlights: (1) **Security**: every `child_process` call in `src/` now uses tokenised arg arrays (no shell expansion) — closed 7 sites that took template strings. (2) **FASE 1 e2e suite**: 7 scenarios + fake-coder/sonar fixtures cover the 5-bug class from the 2026-04-27 demo regression (zombie-HU, saveSession-missing, Repairer unfixable, zombi-status, audit smoke). 23 e2e tests in 6s. (3) **File splits**: `cli.js` 699→113 LOC, `commands/plan.js` 549→14 LOC (shim) + sub-commands, `iteration-loop.js` 513→311 LOC, `pre-loop.js` 626→435 LOC. Each big driver now under the 600-LOC ceiling. (4) **ESLint ratcheted**: 57 warnings closed in `src/`, then `no-unused-vars` / `no-useless-assignment` / `no-useless-escape` / `preserve-caught-error` switched warn→error so regressions fail CI. New rules ban `console.*` outside CLI paths and `globalThis.__KJ_*` outside `test-harness.js`. (5) **BREAKING**: `engines.node` 18→20.10.0 (Node 18 LTS hit EOL 2025-04-30). See [CHANGELOG.md](./CHANGELOG.md) for the full punch list and [MIGRATION-v2.md](./MIGRATION-v2.md) for v1→v2 breaking changes.
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.8.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;
@@ -92,14 +92,68 @@ function runDepcheck(projectDir) {
92
92
  });
93
93
  }
94
94
 
95
+ // Regexes hoisted to module scope so they're compiled once instead of per file.
96
+ // Naming: *Re catches static/syntactic patterns; we still rely on the import set
97
+ // being a superset (false negative > false positive in audit context).
98
+ const EXPORT_RE = /export\s+(?:default\s+)?(?:function|class|const|let|var|async\s+function)\s+(\w+)/g;
99
+ const NAMED_EXPORT_RE = /export\s*\{([^}]+)\}/g;
100
+ const STATIC_NAMED_IMPORT_RE = /import\s+\{([^}]+)\}\s+from/g;
101
+ const DEFAULT_IMPORT_RE = /import\s+(\w+)\s+from/g;
102
+ // Namespace import (`import * as ns from <path>`) — knip-equivalent: mark
103
+ // the target file as fully-consumed because we cannot statically know
104
+ // which `ns.X` accesses happen.
105
+ const NAMESPACE_IMPORT_RE = /import\s+\*\s+as\s+\w+\s+from\s+["']([^"']+)["']/g;
106
+ // Any dynamic `await import(<path>)` — destructured, late-bound, or
107
+ // member-accessed. Conservatively mark the target as fully-consumed:
108
+ // trying to enumerate downstream destructuring/member-access patterns is
109
+ // fragile (the same test may use parenthesised assignment with no const,
110
+ // or bind first and `.foo`-access later). A false-negative here (one
111
+ // extra "live" export we can't prove dead) is always cheaper than a
112
+ // false-positive in audit context.
113
+ const DYNAMIC_IMPORT_RE = /\bawait\s+import\s*\(\s*["']([^"']+)["']\s*\)/g;
114
+ // Re-export bridges (`export { x } from <path>` and `export * from <path>`)
115
+ // — both consume from the target file (the re-exporter is itself another
116
+ // file consumer downstream).
117
+ const RE_EXPORT_NAMED_RE = /export\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']/g;
118
+ const RE_EXPORT_STAR_RE = /export\s*\*\s*from\s*["']([^"']+)["']/g;
119
+ // Export preceded by a JSDoc block with `@internal` is opt-out: documented as
120
+ // dynamic-import-only or test-fixture surface that the regex passes can't see.
121
+ const INTERNAL_EXPORT_RE = /\/\*\*[\s\S]*?@internal[\s\S]*?\*\/\s*export\s+(?:default\s+)?(?:function|class|const|let|var|async\s+function)\s+(\w+)/g;
122
+
123
+ function resolveImportSpecifier(fromFile, specifier) {
124
+ // Only resolve project-relative paths; bare specifiers (npm packages) and
125
+ // node: builtins are not in our exportMap and don't matter here.
126
+ if (!specifier.startsWith(".") && !specifier.startsWith("/")) return null;
127
+ const base = path.resolve(path.dirname(fromFile), specifier);
128
+ // Caller will probe both with and without explicit extensions.
129
+ return base.endsWith(".js") || base.endsWith(".mjs") || base.endsWith(".cjs")
130
+ || base.endsWith(".ts") || base.endsWith(".tsx")
131
+ ? base
132
+ : `${base}.js`;
133
+ }
134
+
135
+ // Strip every string literal (template, double, single) from the content
136
+ // before scanning for export declarations. Test files routinely embed
137
+ // sample source code inside fixtures (template literals for plugin
138
+ // modules, double-quoted diff lines for prompt tests, etc.). At top level
139
+ // `export ...` is a statement, never an expression, so it can never
140
+ // legitimately appear inside a quoted string in real code — meaning the
141
+ // strip is safe for export detection. We do NOT use this stripped view
142
+ // for import detection: the import regexes anchor on `import {...} from`
143
+ // regardless of the path string, but the namespace/dynamic-import passes
144
+ // need the actual path inside the quotes to resolve the target module.
145
+ function stripStringLiterals(content) {
146
+ return content
147
+ .replace(/`[^`]*`/g, "``")
148
+ .replace(/"(?:\\.|[^"\\])*"/g, '""')
149
+ .replace(/'(?:\\.|[^'\\])*'/g, "''");
150
+ }
151
+
95
152
  async function findDeadExports(sourceFiles) {
96
153
  const exportMap = new Map(); // file -> [exportName]
154
+ const internalExportsByFile = new Map(); // file -> Set<exportName>
97
155
  const importedNames = new Set();
98
-
99
- const exportRe = /export\s+(?:default\s+)?(?:function|class|const|let|var|async\s+function)\s+(\w+)/g;
100
- const namedExportRe = /export\s*\{([^}]+)\}/g;
101
- const importRe = /import\s+\{([^}]+)\}\s+from/g;
102
- const defaultImportRe = /import\s+(\w+)\s+from/g;
156
+ const fullyConsumedFiles = new Set(); // resolved file paths reached via `import *` or bound dynamic import
103
157
 
104
158
  for (const file of sourceFiles) {
105
159
  let content;
@@ -108,12 +162,13 @@ async function findDeadExports(sourceFiles) {
108
162
  } catch {
109
163
  continue;
110
164
  }
165
+ const exportContent = stripStringLiterals(content);
111
166
 
112
167
  const exports = [];
113
- for (const match of content.matchAll(exportRe)) {
168
+ for (const match of exportContent.matchAll(EXPORT_RE)) {
114
169
  exports.push(match[1]);
115
170
  }
116
- for (const match of content.matchAll(namedExportRe)) {
171
+ for (const match of exportContent.matchAll(NAMED_EXPORT_RE)) {
117
172
  const names = match[1].split(",").map(n => n.trim().split(/\s+as\s+/).pop().trim());
118
173
  exports.push(...names);
119
174
  }
@@ -121,18 +176,46 @@ async function findDeadExports(sourceFiles) {
121
176
  exportMap.set(file, exports);
122
177
  }
123
178
 
124
- for (const match of content.matchAll(importRe)) {
179
+ // Collect `@internal`-tagged exports up front so the dead-pass can skip them.
180
+ const internalSet = new Set();
181
+ for (const match of exportContent.matchAll(INTERNAL_EXPORT_RE)) {
182
+ internalSet.add(match[1]);
183
+ }
184
+ if (internalSet.size > 0) {
185
+ internalExportsByFile.set(file, internalSet);
186
+ }
187
+
188
+ for (const match of content.matchAll(STATIC_NAMED_IMPORT_RE)) {
125
189
  const names = match[1].split(",").map(n => n.trim().split(/\s+as\s+/)[0].trim());
126
190
  for (const name of names) importedNames.add(name);
127
191
  }
128
- for (const match of content.matchAll(defaultImportRe)) {
192
+ for (const match of content.matchAll(DEFAULT_IMPORT_RE)) {
129
193
  importedNames.add(match[1]);
130
194
  }
195
+ for (const match of content.matchAll(NAMESPACE_IMPORT_RE)) {
196
+ const resolved = resolveImportSpecifier(file, match[1]);
197
+ if (resolved) fullyConsumedFiles.add(resolved);
198
+ }
199
+ for (const match of content.matchAll(DYNAMIC_IMPORT_RE)) {
200
+ const resolved = resolveImportSpecifier(file, match[1]);
201
+ if (resolved) fullyConsumedFiles.add(resolved);
202
+ }
203
+ for (const match of content.matchAll(RE_EXPORT_NAMED_RE)) {
204
+ const names = match[1].split(",").map(n => n.trim().split(/\s+as\s+/)[0].trim());
205
+ for (const name of names) importedNames.add(name);
206
+ }
207
+ for (const match of content.matchAll(RE_EXPORT_STAR_RE)) {
208
+ const resolved = resolveImportSpecifier(file, match[1]);
209
+ if (resolved) fullyConsumedFiles.add(resolved);
210
+ }
131
211
  }
132
212
 
133
213
  const dead = [];
134
214
  for (const [file, exports] of exportMap) {
215
+ if (fullyConsumedFiles.has(file)) continue; // module imported via * or bound await import — can't tell which exports are used
216
+ const internalSet = internalExportsByFile.get(file);
135
217
  for (const name of exports) {
218
+ if (internalSet?.has(name)) continue; // explicitly opted out via @internal
136
219
  if (!importedNames.has(name)) {
137
220
  dead.push({ file, name });
138
221
  }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Deterministic-only summary formatter for `kj audit` two-phase mode
3
+ * (KJC-TSK-0364). Renders the data AuditRole.collectDeterministic()
4
+ * gathers — basalCost, growth delta, stack, Sonar findings, WebPerf —
5
+ * as a self-contained markdown block the user can read BEFORE deciding
6
+ * whether to spend tokens on the LLM analysis.
7
+ *
8
+ * Kept in src/audit/ (not src/commands/) so the same formatter can be
9
+ * reused by future MCP tools and other entrypoints.
10
+ */
11
+
12
+ import { groupIssuesBySeverity } from "./sonar-findings.js";
13
+ import { groupVulnerabilitiesBySeverity } from "./osv-findings.js";
14
+ import { groupFindingsBySeverity as groupSemgrepBySeverity } from "./semgrep-findings.js";
15
+
16
+ const MAX_SAMPLE_DEAD_EXPORTS = 10;
17
+ const MAX_SAMPLE_SONAR_PER_SEVERITY = 5;
18
+ const MAX_SAMPLE_OSV_PER_SEVERITY = 5;
19
+ const MAX_SAMPLE_SEMGREP_PER_SEVERITY = 5;
20
+
21
+ /**
22
+ * @param {{basalCost?: object, growthDelta?: object, stack?: object, sonarFindings?: object, webperf?: object, osvFindings?: object}} ctx
23
+ * @returns {string} markdown block
24
+ */
25
+ export function formatDeterministicSummary(ctx) {
26
+ const lines = ["## Deterministic Findings", ""];
27
+ lines.push("These are the rule-based findings (no LLM). They cost zero tokens and are always factually accurate. The interactive prompt below asks if you want to extend this with an LLM analysis (which will cost tokens).");
28
+ lines.push("");
29
+
30
+ if (ctx.stack) lines.push(...formatStackBlock(ctx.stack));
31
+ if (ctx.basalCost) lines.push(...formatBasalCostBlock(ctx.basalCost, ctx.growthDelta));
32
+ if (ctx.sonarFindings) lines.push(...formatSonarBlock(ctx.sonarFindings));
33
+ if (ctx.osvFindings) lines.push(...formatOsvBlock(ctx.osvFindings));
34
+ if (ctx.semgrepFindings) lines.push(...formatSemgrepBlock(ctx.semgrepFindings));
35
+ if (ctx.webperf) lines.push(...formatWebperfBlock(ctx.webperf));
36
+
37
+ return lines.join("\n");
38
+ }
39
+
40
+ function formatSemgrepBlock(semgrepFindings) {
41
+ if (!semgrepFindings.available) {
42
+ return ["### Semgrep SAST", `- Status: not available — ${semgrepFindings.reason || "semgrep not installed"}`, ""];
43
+ }
44
+ const lines = ["### Semgrep SAST"];
45
+ lines.push(`- Total findings: ${semgrepFindings.total ?? 0}`);
46
+ if ((semgrepFindings.total ?? 0) > 0) {
47
+ const groups = groupSemgrepBySeverity(semgrepFindings.findings || []);
48
+ for (const [severity, findings] of Object.entries(groups)) {
49
+ if (findings.length === 0) continue;
50
+ lines.push(` - ${severity} (${findings.length}):`);
51
+ for (const f of findings.slice(0, MAX_SAMPLE_SEMGREP_PER_SEVERITY)) {
52
+ const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "";
53
+ lines.push(` - ${loc} [${f.rule}]`);
54
+ }
55
+ if (findings.length > MAX_SAMPLE_SEMGREP_PER_SEVERITY) {
56
+ lines.push(` - ... and ${findings.length - MAX_SAMPLE_SEMGREP_PER_SEVERITY} more in ${severity}`);
57
+ }
58
+ }
59
+ }
60
+ lines.push("");
61
+ return lines;
62
+ }
63
+
64
+ function formatOsvBlock(osvFindings) {
65
+ if (!osvFindings.available) {
66
+ return ["### OSV Vulnerabilities", `- Status: not available — ${osvFindings.reason || "osv-scanner not installed"}`, ""];
67
+ }
68
+ const lines = ["### OSV Vulnerabilities (Dependencies)"];
69
+ lines.push(`- Total open: ${osvFindings.total ?? 0}`);
70
+ if ((osvFindings.total ?? 0) > 0) {
71
+ const groups = groupVulnerabilitiesBySeverity(osvFindings.vulnerabilities || []);
72
+ for (const [severity, vulns] of Object.entries(groups)) {
73
+ if (vulns.length === 0) continue;
74
+ lines.push(` - ${severity} (${vulns.length}):`);
75
+ for (const v of vulns.slice(0, MAX_SAMPLE_OSV_PER_SEVERITY)) {
76
+ const where = v.package ? `${v.package}@${v.version || "?"}` : "(unknown)";
77
+ const fixed = v.fixed ? ` → fixed in ${v.fixed}` : "";
78
+ lines.push(` - ${v.id} ${where}${fixed}`);
79
+ }
80
+ if (vulns.length > MAX_SAMPLE_OSV_PER_SEVERITY) {
81
+ lines.push(` - ... and ${vulns.length - MAX_SAMPLE_OSV_PER_SEVERITY} more in ${severity}`);
82
+ }
83
+ }
84
+ }
85
+ lines.push("");
86
+ return lines;
87
+ }
88
+
89
+ function formatStackBlock(stack) {
90
+ if (!stack || (!stack.frameworks?.length && !stack.language && !stack.isFrontend && !stack.isBackend)) return [];
91
+ const lines = ["### Project Stack"];
92
+ if (stack.language) lines.push(`- Language: ${stack.language}`);
93
+ if (stack.frameworks?.length) lines.push(`- Frameworks: ${stack.frameworks.join(", ")}`);
94
+ const tier = stack.isFullstack ? "fullstack (frontend + backend)"
95
+ : stack.isFrontend ? "frontend-only"
96
+ : stack.isBackend ? "backend-only"
97
+ : "unknown";
98
+ lines.push(`- Tier: ${tier}`);
99
+ lines.push("");
100
+ return lines;
101
+ }
102
+
103
+ function formatBasalCostBlock(basalCost, growthDelta) {
104
+ const lines = ["### Basal Cost"];
105
+ lines.push(`- Source: ${basalCost.totalLines} lines across ${basalCost.totalFiles} files`);
106
+ const deps = basalCost.dependencies || {};
107
+ if (deps.total) lines.push(`- Dependencies: ${deps.total} (${deps.dependencies} prod + ${deps.devDependencies} dev)`);
108
+
109
+ const unused = basalCost.unusedDependencies?.unused;
110
+ if (Array.isArray(unused) && unused.length > 0) {
111
+ lines.push(`- Unused dependencies (${unused.length}): ${unused.join(", ")}`);
112
+ }
113
+
114
+ const dead = Array.isArray(basalCost.deadExports) ? basalCost.deadExports : [];
115
+ if (dead.length > 0) {
116
+ lines.push(`- Dead exports: ${dead.length}`);
117
+ for (const de of dead.slice(0, MAX_SAMPLE_DEAD_EXPORTS)) {
118
+ lines.push(` - \`${de.name}\` in ${de.file}`);
119
+ }
120
+ if (dead.length > MAX_SAMPLE_DEAD_EXPORTS) {
121
+ lines.push(` - ... and ${dead.length - MAX_SAMPLE_DEAD_EXPORTS} more`);
122
+ }
123
+ } else {
124
+ lines.push("- Dead exports: 0");
125
+ }
126
+
127
+ if (growthDelta) {
128
+ lines.push("");
129
+ lines.push("**Growth since last audit**");
130
+ lines.push(`- Lines: ${growthDelta.lines >= 0 ? "+" : ""}${growthDelta.lines}`);
131
+ lines.push(`- Files: ${growthDelta.files >= 0 ? "+" : ""}${growthDelta.files}`);
132
+ lines.push(`- Dependencies: ${growthDelta.deps >= 0 ? "+" : ""}${growthDelta.deps}`);
133
+ if (growthDelta.since) lines.push(`- Since: ${growthDelta.since}`);
134
+ }
135
+ lines.push("");
136
+ return lines;
137
+ }
138
+
139
+ function formatSonarBlock(sonarFindings) {
140
+ if (!sonarFindings.available) {
141
+ return ["### SonarQube", `- Status: not available — ${sonarFindings.reason || "host unreachable"}`, ""];
142
+ }
143
+ const lines = ["### SonarQube"];
144
+ if (sonarFindings.qualityGate?.status) lines.push(`- Quality gate: ${sonarFindings.qualityGate.status}`);
145
+ lines.push(`- Open issues: ${sonarFindings.total ?? 0}`);
146
+ if ((sonarFindings.total ?? 0) > 0) {
147
+ const groups = groupIssuesBySeverity(sonarFindings.issues || []);
148
+ for (const [severity, issues] of Object.entries(groups)) {
149
+ if (issues.length === 0) continue;
150
+ lines.push(` - ${severity} (${issues.length}):`);
151
+ for (const issue of issues.slice(0, MAX_SAMPLE_SONAR_PER_SEVERITY)) {
152
+ const loc = issue.component ? `${issue.component}${issue.line ? `:${issue.line}` : ""}` : "";
153
+ const rule = issue.rule ? ` [${issue.rule}]` : "";
154
+ lines.push(` - ${loc}${rule} ${issue.message || ""}`.trim());
155
+ }
156
+ if (issues.length > MAX_SAMPLE_SONAR_PER_SEVERITY) {
157
+ lines.push(` - ... and ${issues.length - MAX_SAMPLE_SONAR_PER_SEVERITY} more in ${severity}`);
158
+ }
159
+ }
160
+ }
161
+ lines.push("");
162
+ return lines;
163
+ }
164
+
165
+ function formatWebperfBlock(webperf) {
166
+ if (!webperf.available) {
167
+ return ["### WebPerf", `- Status: not collected — ${webperf.reason || "no frontend signal"}`, ""];
168
+ }
169
+ const lines = ["### WebPerf"];
170
+ if (webperf.mode === "cwv-result" && webperf.cwv) {
171
+ lines.push(`- Mode: live CWV measurement`);
172
+ if (webperf.url) lines.push(`- URL: ${webperf.url}`);
173
+ lines.push(`- Verdict: ${webperf.cwv.pass ? "PASS" : "FAIL"}`);
174
+ for (const [metric, score] of Object.entries(webperf.cwv.scores || {})) {
175
+ lines.push(` - ${metric.toUpperCase()}: ${score.value} (${score.rating})`);
176
+ }
177
+ } else {
178
+ lines.push(`- Mode: static hints only (no live CWV measurement available)`);
179
+ lines.push(`- LLM phase will scan source for bundle/lazy/img-opt patterns when activated`);
180
+ }
181
+ lines.push("");
182
+ return lines;
183
+ }
184
+
185
+ /**
186
+ * Detect whether the deterministic context found any signal worth showing.
187
+ * If everything came back empty/unavailable, the CLI can skip the
188
+ * "Continue with LLM?" prompt — there's nothing to compare against.
189
+ */
190
+ export function deterministicContextHasFindings(ctx) {
191
+ if (!ctx) return false;
192
+ if (ctx.basalCost?.deadExports?.length > 0) return true;
193
+ if (ctx.basalCost?.unusedDependencies?.unused?.length > 0) return true;
194
+ if (ctx.sonarFindings?.available && (ctx.sonarFindings.total ?? 0) > 0) return true;
195
+ if (ctx.sonarFindings?.qualityGate?.status === "ERROR") return true;
196
+ if (ctx.osvFindings?.available && (ctx.osvFindings.total ?? 0) > 0) return true;
197
+ if (ctx.semgrepFindings?.available && (ctx.semgrepFindings.total ?? 0) > 0) return true;
198
+ if (ctx.growthDelta && (Math.abs(ctx.growthDelta.lines || 0) > 100 || Math.abs(ctx.growthDelta.deps || 0) > 0)) return true;
199
+ return false;
200
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * OSV-Scanner findings collector for `kj audit` (KJC-TSK-0365).
3
+ *
4
+ * Wraps Google's `osv-scanner` CLI and feeds its results into the audit
5
+ * prompt as a deterministic vulnerability section. OSV.dev's database
6
+ * pulls from GitHub Advisory Database, GLSA, PyPI advisories, Go vuln
7
+ * DB and others — broader coverage than `npm audit` alone, with no
8
+ * account, token, or upload (the scanner runs locally and only the
9
+ * advisory data is fetched on demand).
10
+ *
11
+ * This module follows the same pattern as src/audit/sonar-findings.js:
12
+ * - Best-effort: missing binary or scan errors return
13
+ * {available: false, reason} and the audit continues without the
14
+ * section.
15
+ * - Severity-grouped: helps the LLM (and the deterministic summary)
16
+ * prioritise the worst issues without scrolling.
17
+ * - Capped: only the first MAX_OSV_VULNS_IN_PROMPT entries land in
18
+ * the prompt, with overflow markers per severity, so an
19
+ * out-of-control result list cannot blow the token budget.
20
+ */
21
+
22
+ import { execFile } from "node:child_process";
23
+ import { promisify } from "node:util";
24
+
25
+ const execFileAsync = promisify(execFile);
26
+
27
+ const SCAN_TIMEOUT_MS = 60_000;
28
+ const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "MODERATE"];
29
+
30
+ /**
31
+ * Run osv-scanner on the project and return a structured result.
32
+ *
33
+ * @param {string} projectDir - absolute path to the project root
34
+ * @param {object} [logger]
35
+ * @returns {Promise<{available: boolean, reason?: string, total?: number, vulnerabilities?: object[]}>}
36
+ */
37
+ export async function collectOsvFindings(projectDir, logger = null) {
38
+ let stdout;
39
+ try {
40
+ const { stdout: out } = await execFileAsync(
41
+ "osv-scanner",
42
+ ["--format=json", "--recursive", projectDir || "."],
43
+ { cwd: projectDir, timeout: SCAN_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }
44
+ );
45
+ stdout = out;
46
+ } catch (err) {
47
+ // osv-scanner exits with non-zero when vulnerabilities are found, so
48
+ // err.code 1 is NORMAL — we still want the stdout. Real failures are
49
+ // ENOENT (binary missing) or stdout empty.
50
+ if (err.code === "ENOENT") {
51
+ if (logger?.warn) logger.warn("osv-scanner not found in PATH — install via 'go install github.com/google/osv-scanner@latest'");
52
+ return { available: false, reason: "osv-scanner not installed" };
53
+ }
54
+ if (err.killed) {
55
+ if (logger?.warn) logger.warn(`osv-scanner timed out after ${SCAN_TIMEOUT_MS}ms`);
56
+ return { available: false, reason: `osv-scanner timed out after ${SCAN_TIMEOUT_MS}ms` };
57
+ }
58
+ if (typeof err.stdout === "string" && err.stdout.trim().length > 0) {
59
+ stdout = err.stdout;
60
+ } else {
61
+ if (logger?.warn) logger.warn(`osv-scanner failed: ${err.message}`);
62
+ return { available: false, reason: `osv-scanner failed: ${err.message}` };
63
+ }
64
+ }
65
+
66
+ let parsed;
67
+ try {
68
+ parsed = JSON.parse(stdout);
69
+ } catch (err) {
70
+ if (logger?.warn) logger.warn(`osv-scanner output not parseable: ${err.message}`);
71
+ return { available: false, reason: "osv-scanner output not parseable as JSON" };
72
+ }
73
+
74
+ return { available: true, ...parseOsvOutput(parsed) };
75
+ }
76
+
77
+ /**
78
+ * Flatten the osv-scanner JSON shape into a flat list of vulnerabilities.
79
+ * The scanner emits results[].packages[].vulnerabilities[].
80
+ *
81
+ * @param {object} raw - parsed osv-scanner JSON
82
+ * @returns {{total: number, vulnerabilities: object[]}}
83
+ */
84
+ export function parseOsvOutput(raw) {
85
+ const flat = [];
86
+ const results = Array.isArray(raw?.results) ? raw.results : [];
87
+ for (const result of results) {
88
+ const source = result.source?.path || result.source || "";
89
+ const packages = Array.isArray(result.packages) ? result.packages : [];
90
+ for (const pkg of packages) {
91
+ const name = pkg.package?.name || "";
92
+ const version = pkg.package?.version || "";
93
+ const ecosystem = pkg.package?.ecosystem || "";
94
+ const vulns = Array.isArray(pkg.vulnerabilities) ? pkg.vulnerabilities : [];
95
+ const groups = Array.isArray(pkg.groups) ? pkg.groups : [];
96
+ for (const vuln of vulns) {
97
+ flat.push({
98
+ id: vuln.id || vuln.aliases?.[0] || "UNKNOWN",
99
+ aliases: vuln.aliases || [],
100
+ severity: extractSeverity(vuln, groups),
101
+ summary: vuln.summary || vuln.details?.split("\n")[0] || "",
102
+ package: name,
103
+ version,
104
+ ecosystem,
105
+ source,
106
+ fixed: extractFixedVersion(vuln),
107
+ });
108
+ }
109
+ }
110
+ }
111
+ return { total: flat.length, vulnerabilities: flat };
112
+ }
113
+
114
+ /**
115
+ * Resolve a vulnerability's effective severity. osv-scanner reports
116
+ * CVSS (per-vuln) and a separate `groups[].severity` (per scanner run).
117
+ * Group severity wins when present because it reflects scanner policy.
118
+ */
119
+ function extractSeverity(vuln, groups) {
120
+ // Group-level severity (scanner-resolved, includes ecosystem context)
121
+ for (const group of groups) {
122
+ if (group.ids?.includes(vuln.id) && group.severity) {
123
+ return String(group.severity).toUpperCase();
124
+ }
125
+ }
126
+ // Per-vuln CVSS — pick the highest score
127
+ const severities = Array.isArray(vuln.severity) ? vuln.severity : [];
128
+ for (const sev of severities) {
129
+ if (sev?.severity) return String(sev.severity).toUpperCase();
130
+ }
131
+ // database_specific severity (GHSA-style)
132
+ if (vuln.database_specific?.severity) {
133
+ return String(vuln.database_specific.severity).toUpperCase();
134
+ }
135
+ return "UNKNOWN";
136
+ }
137
+
138
+ function extractFixedVersion(vuln) {
139
+ const ranges = vuln.affected?.[0]?.ranges || [];
140
+ for (const range of ranges) {
141
+ const events = range.events || [];
142
+ for (const event of events) {
143
+ if (event.fixed) return event.fixed;
144
+ }
145
+ }
146
+ return null;
147
+ }
148
+
149
+ /**
150
+ * Group vulnerabilities by severity, preserving canonical ordering
151
+ * (CRITICAL → HIGH → MEDIUM/MODERATE → LOW → UNKNOWN).
152
+ */
153
+ export function groupVulnerabilitiesBySeverity(vulnerabilities) {
154
+ const groups = Object.fromEntries(SEVERITY_ORDER.map(s => [s, []]));
155
+ groups.UNKNOWN = [];
156
+ for (const vuln of vulnerabilities || []) {
157
+ const sev = (vuln.severity || "UNKNOWN").toUpperCase();
158
+ if (groups[sev]) groups[sev].push(vuln);
159
+ else groups.UNKNOWN.push(vuln);
160
+ }
161
+ return groups;
162
+ }