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.
- package/README.md +14 -1
- package/package.json +1 -1
- package/src/audit/agent-readiness.js +21 -3
- package/src/audit/basal-cost.js +92 -9
- package/src/audit/deterministic-summary.js +200 -0
- package/src/audit/osv-findings.js +162 -0
- package/src/audit/semgrep-findings.js +117 -0
- package/src/audit/sonar-findings.js +81 -0
- package/src/audit/webperf-input.js +136 -0
- package/src/canvas/schema.js +1 -1
- package/src/checks/binaries.js +2 -2
- package/src/checks/ci.js +2 -2
- package/src/checks/config-files.js +6 -6
- package/src/checks/dir-setup.js +4 -0
- package/src/checks/mcp-health.js +6 -0
- package/src/checks/ports.js +8 -0
- package/src/checks/rtk.js +1 -1
- package/src/checks/skills.js +5 -1
- package/src/checks/sonar.js +1 -1
- package/src/checks/system.js +2 -2
- package/src/checks/tokens.js +1 -1
- package/src/checks/types.js +0 -12
- package/src/cli/register-meta.js +40 -2
- package/src/cli/register-pipeline.js +1 -0
- package/src/commands/audit.js +239 -28
- package/src/commands/board.js +7 -4
- package/src/commands/doctor.js +1 -1
- 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/config/test-harness.js +0 -1
- 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/ci-integration.js +1 -1
- package/src/orchestrator/config-init.js +3 -1
- package/src/orchestrator/direct-actions.js +1 -1
- package/src/orchestrator/drivers/error-recovery.js +2 -2
- package/src/orchestrator/drivers/iteration-loop.js +3 -13
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +12 -0
- package/src/orchestrator/drivers/pre-loop.js +1 -4
- package/src/orchestrator/feedback-queue.js +0 -1
- package/src/orchestrator/integrations.js +0 -7
- package/src/orchestrator/role-output-compressor.js +0 -1
- package/src/orchestrator/session-journal.js +5 -41
- package/src/orchestrator/stages/perf-stage.js +140 -0
- package/src/orchestrator/stages/stage-classes.js +0 -2
- package/src/plan/plan-store.js +0 -14
- package/src/planning-game/client.js +0 -14
- package/src/planning-game/pipeline-adapter.js +1 -1
- package/src/prompts/audit.js +206 -6
- package/src/prompts/rtk-snippet.js +1 -1
- package/src/roles/audit-role.js +125 -13
- package/src/roles/perf-role.js +122 -0
- package/src/skills/addyosmani-role-map.js +6 -0
- package/src/sonar/api.js +1 -1
- package/src/utils/display/event-handlers.js +1 -1
- package/src/utils/display/header.js +1 -1
- package/src/utils/display/session-summary.js +3 -3
- package/src/utils/display/solomon.js +1 -1
- package/src/utils/role-assigner.js +1 -1
- 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;
|
package/src/audit/basal-cost.js
CHANGED
|
@@ -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
|
|
168
|
+
for (const match of exportContent.matchAll(EXPORT_RE)) {
|
|
114
169
|
exports.push(match[1]);
|
|
115
170
|
}
|
|
116
|
-
for (const match of
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|