karajan-code 2.8.0 → 2.9.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 (47) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/audit/basal-cost.js +92 -9
  4. package/src/audit/deterministic-summary.js +200 -0
  5. package/src/audit/osv-findings.js +162 -0
  6. package/src/audit/semgrep-findings.js +117 -0
  7. package/src/audit/sonar-findings.js +81 -0
  8. package/src/audit/webperf-input.js +90 -0
  9. package/src/canvas/schema.js +1 -1
  10. package/src/checks/binaries.js +2 -2
  11. package/src/checks/ci.js +2 -2
  12. package/src/checks/config-files.js +6 -6
  13. package/src/checks/dir-setup.js +4 -0
  14. package/src/checks/mcp-health.js +6 -0
  15. package/src/checks/ports.js +8 -0
  16. package/src/checks/rtk.js +1 -1
  17. package/src/checks/skills.js +5 -1
  18. package/src/checks/sonar.js +1 -1
  19. package/src/checks/system.js +2 -2
  20. package/src/checks/tokens.js +1 -1
  21. package/src/checks/types.js +0 -12
  22. package/src/cli/register-meta.js +15 -1
  23. package/src/commands/audit.js +239 -28
  24. package/src/commands/doctor.js +1 -1
  25. package/src/config/test-harness.js +0 -1
  26. package/src/orchestrator/ci-integration.js +1 -1
  27. package/src/orchestrator/direct-actions.js +1 -1
  28. package/src/orchestrator/drivers/error-recovery.js +2 -2
  29. package/src/orchestrator/drivers/iteration-loop.js +3 -13
  30. package/src/orchestrator/drivers/pre-loop.js +1 -4
  31. package/src/orchestrator/feedback-queue.js +0 -1
  32. package/src/orchestrator/integrations.js +0 -7
  33. package/src/orchestrator/role-output-compressor.js +0 -1
  34. package/src/orchestrator/session-journal.js +4 -41
  35. package/src/orchestrator/stages/stage-classes.js +0 -2
  36. package/src/plan/plan-store.js +0 -14
  37. package/src/planning-game/client.js +0 -14
  38. package/src/planning-game/pipeline-adapter.js +1 -1
  39. package/src/prompts/audit.js +206 -6
  40. package/src/prompts/rtk-snippet.js +1 -1
  41. package/src/roles/audit-role.js +125 -13
  42. package/src/sonar/api.js +1 -1
  43. package/src/utils/display/event-handlers.js +1 -1
  44. package/src/utils/display/header.js +1 -1
  45. package/src/utils/display/session-summary.js +3 -3
  46. package/src/utils/display/solomon.js +1 -1
  47. package/src/utils/role-assigner.js +1 -1
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.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.
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.8.0",
3
+ "version": "2.9.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",
@@ -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
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Semgrep SAST collector for `kj audit` (KJC-TSK-0366).
3
+ *
4
+ * Wraps `semgrep --config auto --json` and feeds structured static-
5
+ * analysis findings into the audit prompt. Equivalent to the SAST half
6
+ * of `snyk code` but free for OSS, no account, runs locally. Semgrep
7
+ * has 2000+ built-in rules across JS/TS/Python/Go/etc. covering OWASP
8
+ * Top 10, taint analysis, hardcoded secrets, regex misuse and language-
9
+ * specific anti-patterns.
10
+ *
11
+ * Same best-effort pattern as sonar-findings.js and osv-findings.js:
12
+ * - Missing binary or timeout returns {available: false, reason}.
13
+ * - 120s timeout (semgrep can be slow on large repos with --config auto).
14
+ * - Severity-grouped (ERROR / WARNING / INFO) and capped at
15
+ * MAX_SEMGREP_FINDINGS_IN_PROMPT (50) total entries with overflow
16
+ * markers.
17
+ * - The LLM is told these are deterministic and asked to fold them
18
+ * into the `security` dimension using the rule ID as the rule field.
19
+ */
20
+
21
+ import { execFile } from "node:child_process";
22
+ import { promisify } from "node:util";
23
+
24
+ const execFileAsync = promisify(execFile);
25
+
26
+ const SCAN_TIMEOUT_MS = 120_000;
27
+ const SEVERITY_ORDER = ["ERROR", "WARNING", "INFO"];
28
+
29
+ /**
30
+ * Run semgrep on the project and return a structured result.
31
+ *
32
+ * @param {string} projectDir - absolute path to the project root
33
+ * @param {object} [logger]
34
+ * @returns {Promise<{available: boolean, reason?: string, total?: number, findings?: object[]}>}
35
+ */
36
+ export async function collectSemgrepFindings(projectDir, logger = null) {
37
+ let stdout;
38
+ try {
39
+ const { stdout: out } = await execFileAsync(
40
+ "semgrep",
41
+ ["--config", "auto", "--json", "--quiet", `--timeout=${Math.floor(SCAN_TIMEOUT_MS / 1000)}`, projectDir || "."],
42
+ { cwd: projectDir, timeout: SCAN_TIMEOUT_MS + 5000, maxBuffer: 32 * 1024 * 1024 }
43
+ );
44
+ stdout = out;
45
+ } catch (err) {
46
+ if (err.code === "ENOENT") {
47
+ if (logger?.warn) logger.warn("semgrep not found in PATH — install via 'pipx install semgrep' or 'brew install semgrep'");
48
+ return { available: false, reason: "semgrep not installed" };
49
+ }
50
+ if (err.killed) {
51
+ if (logger?.warn) logger.warn(`semgrep timed out after ${SCAN_TIMEOUT_MS}ms`);
52
+ return { available: false, reason: `semgrep timed out after ${SCAN_TIMEOUT_MS}ms` };
53
+ }
54
+ // semgrep exits 1 when it finds blocking rules (ERROR severity), but
55
+ // still emits a valid JSON report on stdout. Rescue that case.
56
+ if (typeof err.stdout === "string" && err.stdout.trim().length > 0) {
57
+ stdout = err.stdout;
58
+ } else {
59
+ if (logger?.warn) logger.warn(`semgrep failed: ${err.message}`);
60
+ return { available: false, reason: `semgrep failed: ${err.message}` };
61
+ }
62
+ }
63
+
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(stdout);
67
+ } catch (err) {
68
+ if (logger?.warn) logger.warn(`semgrep output not parseable: ${err.message}`);
69
+ return { available: false, reason: "semgrep output not parseable as JSON" };
70
+ }
71
+
72
+ return { available: true, ...parseSemgrepOutput(parsed, projectDir) };
73
+ }
74
+
75
+ /**
76
+ * Convert semgrep's JSON shape into the flat findings list the prompt
77
+ * builder consumes. Semgrep emits results[] with rule id, severity,
78
+ * path, start/end line, message, fix hints.
79
+ *
80
+ * @param {object} raw - parsed semgrep --json output
81
+ * @param {string} [projectDir] - used to make paths repo-relative
82
+ * @returns {{total: number, findings: object[]}}
83
+ */
84
+ export function parseSemgrepOutput(raw, projectDir = "") {
85
+ const results = Array.isArray(raw?.results) ? raw.results : [];
86
+ const findings = [];
87
+ for (const r of results) {
88
+ const file = r.path && projectDir
89
+ ? r.path.startsWith(projectDir + "/") ? r.path.slice(projectDir.length + 1) : r.path
90
+ : r.path || "";
91
+ findings.push({
92
+ rule: r.check_id || "(unknown)",
93
+ severity: String(r.extra?.severity || "INFO").toUpperCase(),
94
+ file,
95
+ line: r.start?.line ?? 0,
96
+ endLine: r.end?.line ?? 0,
97
+ message: r.extra?.message || "",
98
+ fix: r.extra?.fix || r.extra?.fix_regex || null,
99
+ cwe: r.extra?.metadata?.cwe || null,
100
+ owasp: r.extra?.metadata?.owasp || null,
101
+ });
102
+ }
103
+ return { total: findings.length, findings };
104
+ }
105
+
106
+ /**
107
+ * Group findings by severity in canonical order (ERROR → WARNING → INFO).
108
+ */
109
+ export function groupFindingsBySeverity(findings) {
110
+ const groups = Object.fromEntries(SEVERITY_ORDER.map(s => [s, []]));
111
+ for (const f of findings || []) {
112
+ const sev = (f.severity || "INFO").toUpperCase();
113
+ if (groups[sev]) groups[sev].push(f);
114
+ else groups.INFO.push(f);
115
+ }
116
+ return groups;
117
+ }