karajan-code 2.16.0 → 2.17.1
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 +5 -1
- package/package.json +4 -2
- package/scripts/esbuild-sea.config.mjs +16 -1
- package/src/audit/circular-deps.js +144 -0
- package/src/audit/dead-exports.js +186 -0
- package/src/audit/deterministic-summary.js +62 -0
- package/src/audit/issue-filter.js +136 -0
- package/src/checks/binaries.js +56 -0
- package/src/checks/runner.js +4 -0
- package/src/cli/register-pipeline.js +21 -0
- package/src/commands/board.js +58 -2
- package/src/commands/doctor.js +17 -0
- package/src/commands/install-tools.js +211 -0
- package/src/prompts/audit.js +36 -1
- package/src/roles/audit-role.js +27 -3
- package/src/sonar/api.js +7 -3
- package/src/sonar/issue-filter.js +10 -108
- package/src/utils/install-hints.js +136 -0
package/src/checks/binaries.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { checkBinary, KNOWN_AGENTS } from "../utils/agent-detect.js";
|
|
16
16
|
import { runCommand } from "../utils/process.js";
|
|
17
17
|
import { withDocLink } from "../utils/doc-links.js";
|
|
18
|
+
import { getInstallHint, appliesToStack } from "../utils/install-hints.js";
|
|
18
19
|
import { STRATEGY } from "./types.js";
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -105,6 +106,56 @@ function createSerenaCheck() {
|
|
|
105
106
|
};
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Audit-side external tool (semgrep, osv-scanner, lighthouse) check.
|
|
111
|
+
*
|
|
112
|
+
* These are not blockers: `kj audit` and `kj webperf` degrade to
|
|
113
|
+
* available:false when missing. We surface them in `kj doctor` so the
|
|
114
|
+
* user can see at a glance which audit dimensions are active, and we
|
|
115
|
+
* point to `kj install-tools` for one-line remediation.
|
|
116
|
+
*
|
|
117
|
+
* `lighthouse` is stack-gated: only flagged when the project is
|
|
118
|
+
* frontend / fullstack. On backend-only projects the check is a no-op
|
|
119
|
+
* (returns ok:true with detail "n/a") to keep doctor output focused.
|
|
120
|
+
*/
|
|
121
|
+
function createAuditToolCheck(tool, { gatedByStack = false, label }) {
|
|
122
|
+
return {
|
|
123
|
+
name: `audit-tool:${tool}`,
|
|
124
|
+
label,
|
|
125
|
+
strategy: STRATEGY.MANUAL,
|
|
126
|
+
async detect({ config } = {}) {
|
|
127
|
+
if (gatedByStack) {
|
|
128
|
+
// Stack detection is best-effort — failing here just means we
|
|
129
|
+
// treat the project as backend-only (skip the check). Keeps the
|
|
130
|
+
// doctor pipeline resilient to weird repos.
|
|
131
|
+
let stack = null;
|
|
132
|
+
try {
|
|
133
|
+
const { detectProjectStack } = await import("../utils/stack-detect.js");
|
|
134
|
+
stack = await detectProjectStack(config?.projectDir || process.cwd());
|
|
135
|
+
} catch { /* ignore */ }
|
|
136
|
+
if (!appliesToStack(tool, stack)) {
|
|
137
|
+
return { ok: true, severity: "info", detail: "n/a (no frontend stack detected)" };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const result = await checkBinary(tool);
|
|
141
|
+
if (result.ok) {
|
|
142
|
+
return { ok: true, severity: "info", detail: result.version || "available" };
|
|
143
|
+
}
|
|
144
|
+
const hint = await getInstallHint(tool);
|
|
145
|
+
const fixLine = hint.command
|
|
146
|
+
? `Run \`kj install-tools --only ${tool}\` or: ${hint.command}`
|
|
147
|
+
: `Run \`kj install-tools --only ${tool}\` or see ${hint.manualUrl}`;
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
severity: "warn",
|
|
151
|
+
detail: "Not found — `kj audit` dimension will degrade",
|
|
152
|
+
fix: fixLine,
|
|
153
|
+
extra: { tool, suggested: hint.command, manager: hint.manager, manualUrl: hint.manualUrl },
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
108
159
|
/**
|
|
109
160
|
* Aggregate: all binary-related checks for the current config.
|
|
110
161
|
* @returns {import("./types.js").Check[]}
|
|
@@ -119,5 +170,10 @@ export function getBinaryChecks() {
|
|
|
119
170
|
}
|
|
120
171
|
checks.push(createDockerCheck());
|
|
121
172
|
checks.push(createSerenaCheck());
|
|
173
|
+
// KJC-TSK v2.18 — surface audit-side tools in doctor so the user knows
|
|
174
|
+
// which dimensions will work. lighthouse is gated to frontend stacks.
|
|
175
|
+
checks.push(createAuditToolCheck("semgrep", { label: "Semgrep (audit/security)" }));
|
|
176
|
+
checks.push(createAuditToolCheck("osv-scanner", { label: "OSV-Scanner (audit/security)" }));
|
|
177
|
+
checks.push(createAuditToolCheck("lighthouse", { label: "Lighthouse (webperf)", gatedByStack: true }));
|
|
122
178
|
return checks;
|
|
123
179
|
}
|
package/src/checks/runner.js
CHANGED
|
@@ -196,6 +196,10 @@ export async function runChecks(checks, ctx, options = {}) {
|
|
|
196
196
|
detail: e.detail ?? "",
|
|
197
197
|
fix: e.detectResult?.fix,
|
|
198
198
|
runMs: e.runMs,
|
|
199
|
+
// Forward optional structured metadata so the doctor printer can
|
|
200
|
+
// surface per-tool remediation tips (KJC-TSK v2.18). Undefined when
|
|
201
|
+
// the check didn't set one — kept minimal so JSON output stays tight.
|
|
202
|
+
extra: e.detectResult?.extra,
|
|
199
203
|
}));
|
|
200
204
|
|
|
201
205
|
const summary = summarize(reports);
|
|
@@ -3,6 +3,7 @@ import { configCommand } from "../commands/config.js";
|
|
|
3
3
|
import { codeCommand } from "../commands/code.js";
|
|
4
4
|
import { reviewCommand } from "../commands/review.js";
|
|
5
5
|
import { scanCommand } from "../commands/scan.js";
|
|
6
|
+
import { installToolsCommand } from "../commands/install-tools.js";
|
|
6
7
|
import { doctorCommand } from "../commands/doctor.js";
|
|
7
8
|
import { reportCommand } from "../commands/report.js";
|
|
8
9
|
import { runCommandHandler } from "../commands/run.js";
|
|
@@ -192,6 +193,26 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
192
193
|
if (Number.isInteger(exitCode)) process.exit(exitCode);
|
|
193
194
|
});
|
|
194
195
|
|
|
196
|
+
program
|
|
197
|
+
.command("install-tools")
|
|
198
|
+
.description("Install external audit tools (semgrep, osv-scanner, lighthouse, docker, sonar) using the package manager available on your system")
|
|
199
|
+
.option("--only <tools>", "Comma-separated subset (e.g. \"semgrep,osv-scanner\"). Bypasses stack-gating.")
|
|
200
|
+
.option("-y, --yes", "Auto-accept all prompts (non-interactive)")
|
|
201
|
+
.option("--dry-run", "Show what would be installed without running anything")
|
|
202
|
+
.action(async (flags) => {
|
|
203
|
+
try {
|
|
204
|
+
const { exitCode } = await installToolsCommand({
|
|
205
|
+
only: flags.only,
|
|
206
|
+
yes: !!flags.yes,
|
|
207
|
+
dryRun: !!flags.dryRun,
|
|
208
|
+
});
|
|
209
|
+
if (Number.isInteger(exitCode)) process.exit(exitCode);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
console.error(`kj install-tools: ${err?.message || err}`);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
195
216
|
program
|
|
196
217
|
.command("status")
|
|
197
218
|
.description("Show pipeline dashboard with HU progress")
|
package/src/commands/board.js
CHANGED
|
@@ -88,6 +88,40 @@ function buildBoardUrl(port, projectSlug) {
|
|
|
88
88
|
return projectSlug ? `${base}/p/${projectSlug}` : base;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* After spawning the board daemon, watch for an early exit. Resolves to
|
|
93
|
+
* an error string when the child dies within the window — a broken
|
|
94
|
+
* native module, a crash on boot, or the silent entry-point mismatch
|
|
95
|
+
* that made the daemon exit 0 without a trace — and to `null` when it
|
|
96
|
+
* survives. This is what stops `startBoard` from reporting `ok` for a
|
|
97
|
+
* daemon that died milliseconds after spawn. The window is overridable
|
|
98
|
+
* via KJ_BOARD_START_WINDOW_MS (tests set it low to stay fast).
|
|
99
|
+
* @param {import('node:child_process').ChildProcess} child
|
|
100
|
+
* @param {string} logPath
|
|
101
|
+
* @returns {Promise<string|null>}
|
|
102
|
+
*/
|
|
103
|
+
export function waitForEarlyExit(child, logPath) {
|
|
104
|
+
const envWindow = Number(process.env.KJ_BOARD_START_WINDOW_MS);
|
|
105
|
+
const windowMs = Number.isFinite(envWindow) && envWindow >= 0 ? envWindow : 1500;
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
let settled = false;
|
|
108
|
+
let timer = null;
|
|
109
|
+
const onExit = (code, signal) => {
|
|
110
|
+
const how = signal ? `signal ${signal}` : `exit code ${code}`;
|
|
111
|
+
finish(`HU Board daemon died on startup (${how}). See ${logPath} for the cause.`);
|
|
112
|
+
};
|
|
113
|
+
function finish(value) {
|
|
114
|
+
if (settled) return;
|
|
115
|
+
settled = true;
|
|
116
|
+
if (timer) clearTimeout(timer);
|
|
117
|
+
child.removeListener("exit", onExit);
|
|
118
|
+
resolve(value);
|
|
119
|
+
}
|
|
120
|
+
child.once("exit", onExit);
|
|
121
|
+
timer = setTimeout(() => finish(null), windowMs);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
91
125
|
export async function startBoard(desiredPort = 4000, opts = {}) {
|
|
92
126
|
const { projectSlug = null, bind = "127.0.0.1" } = opts;
|
|
93
127
|
const existingPid = readPid();
|
|
@@ -141,8 +175,11 @@ export async function startBoard(desiredPort = 4000, opts = {}) {
|
|
|
141
175
|
|
|
142
176
|
// Use process.execPath (absolute path to current node binary) instead of "node".
|
|
143
177
|
// Fixes ENOENT on nvm setups where `node` is not in the spawned process's PATH.
|
|
178
|
+
// KJ_BOARD_DAEMON=1 tells server.js to run main() regardless of how
|
|
179
|
+
// its entry-point path compares — the symlink/Windows/space-in-path
|
|
180
|
+
// mismatch that used to make the daemon exit 0 in total silence.
|
|
144
181
|
const child = spawn(process.execPath, [serverPath], {
|
|
145
|
-
env: { ...process.env, PORT: String(port), BIND_HOST: bind },
|
|
182
|
+
env: { ...process.env, PORT: String(port), BIND_HOST: bind, KJ_BOARD_DAEMON: "1" },
|
|
146
183
|
detached: true,
|
|
147
184
|
stdio: ["ignore", logFd, logFd],
|
|
148
185
|
cwd: BOARD_DIR
|
|
@@ -154,6 +191,14 @@ export async function startBoard(desiredPort = 4000, opts = {}) {
|
|
|
154
191
|
if (!child.pid) {
|
|
155
192
|
throw new Error("Failed to spawn HU Board server");
|
|
156
193
|
}
|
|
194
|
+
// Don't report success blindly: a crash on boot (e.g. broken
|
|
195
|
+
// better-sqlite3, or the silent entry-point mismatch) used to be
|
|
196
|
+
// reported as ok:true, leaving the user with a dead board and a log
|
|
197
|
+
// showing only the start line. Catch the early exit here.
|
|
198
|
+
const failure = await waitForEarlyExit(child, logPath);
|
|
199
|
+
if (failure) {
|
|
200
|
+
throw new Error(failure);
|
|
201
|
+
}
|
|
157
202
|
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
158
203
|
// Detach for real: `detached: true` only sets up the new session;
|
|
159
204
|
// without `unref()` the parent's event loop still holds a reference
|
|
@@ -289,7 +334,18 @@ export function renderBoardBanner({ url, status, projectName }) {
|
|
|
289
334
|
export async function boardCommand({ action = "start", port = 4000, bind = "127.0.0.1", logger }) {
|
|
290
335
|
switch (action) {
|
|
291
336
|
case "start": {
|
|
292
|
-
|
|
337
|
+
let result;
|
|
338
|
+
try {
|
|
339
|
+
result = await startBoard(port, { bind });
|
|
340
|
+
} catch (err) {
|
|
341
|
+
// The daemon died on boot (broken native module, crash) or no
|
|
342
|
+
// free port was found. Surface it cleanly — the whole point of
|
|
343
|
+
// this fix is that `kj board start` never reports a phantom
|
|
344
|
+
// success while leaving a dead board behind.
|
|
345
|
+
logger.error(`HU Board failed to start: ${err.message}`);
|
|
346
|
+
process.exitCode = 1;
|
|
347
|
+
return { ok: false, error: err.message };
|
|
348
|
+
}
|
|
293
349
|
if (result.alreadyRunning) {
|
|
294
350
|
logger.info(`HU Board already running (PID ${result.pid}) at ${result.url}`);
|
|
295
351
|
} else {
|
package/src/commands/doctor.js
CHANGED
|
@@ -171,4 +171,21 @@ function printHuman(report, { verbose }) {
|
|
|
171
171
|
if (Object.keys(report.overrides).length > 0) {
|
|
172
172
|
console.log(`Runtime overrides applied: ${JSON.stringify(report.overrides)}`);
|
|
173
173
|
}
|
|
174
|
+
|
|
175
|
+
// KJC-TSK v2.18 — if any external audit tool (semgrep, osv-scanner,
|
|
176
|
+
// lighthouse) reported missing, surface the one-line remediation so
|
|
177
|
+
// the user doesn't have to dig the fix lines out of the wall of
|
|
178
|
+
// checks. The audit-tool checks emit names like "audit-tool:semgrep"
|
|
179
|
+
// and embed { tool, suggested } in `extra` (see binaries.js).
|
|
180
|
+
const installable = report.checks
|
|
181
|
+
.filter((c) => c.name?.startsWith("audit-tool:") && c.status === STATUS.WARN && c.extra?.tool)
|
|
182
|
+
.map((c) => c.extra.tool);
|
|
183
|
+
if (installable.length > 0) {
|
|
184
|
+
console.log();
|
|
185
|
+
const list = installable.join(", ");
|
|
186
|
+
const only = installable.length === 1 ? ` --only ${installable[0]}` : "";
|
|
187
|
+
console.log(`Tip: run \`kj install-tools${only}\` to install: ${list}.`);
|
|
188
|
+
}
|
|
174
189
|
}
|
|
190
|
+
|
|
191
|
+
export const __test = { printHuman };
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// `kj install-tools` — install the external audit tools that `kj audit`
|
|
2
|
+
// and `kj webperf` rely on but does not bundle.
|
|
3
|
+
//
|
|
4
|
+
// Covers semgrep, osv-scanner, lighthouse, docker, sonar. Picks the
|
|
5
|
+
// install command per tool from `getInstallHint` (HU1) so the command
|
|
6
|
+
// matches the package manager available on the user's system.
|
|
7
|
+
//
|
|
8
|
+
// Lighthouse is stack-gated: by default we only prompt about it when
|
|
9
|
+
// the project is frontend/fullstack. Pass `--only lighthouse` to bypass
|
|
10
|
+
// the gate.
|
|
11
|
+
//
|
|
12
|
+
// Docker and sonar are special cases — see TOOL_HANDLERS below.
|
|
13
|
+
|
|
14
|
+
import readline from "node:readline";
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { promisify } from "node:util";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import fs from "node:fs/promises";
|
|
19
|
+
import { checkBinary } from "../utils/agent-detect.js";
|
|
20
|
+
import { getInstallHint, detectPackageManagers, appliesToStack } from "../utils/install-hints.js";
|
|
21
|
+
import { detectProjectStack } from "../utils/stack-detect.js";
|
|
22
|
+
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
24
|
+
|
|
25
|
+
const ALL_TOOLS = ["semgrep", "osv-scanner", "lighthouse", "docker", "sonar"];
|
|
26
|
+
|
|
27
|
+
function parseOnlyList(raw) {
|
|
28
|
+
if (!raw) return null;
|
|
29
|
+
const wanted = String(raw).split(",").map((t) => t.trim()).filter(Boolean);
|
|
30
|
+
const invalid = wanted.filter((t) => !ALL_TOOLS.includes(t));
|
|
31
|
+
if (invalid.length > 0) {
|
|
32
|
+
throw new Error(`Unknown tool(s) in --only: ${invalid.join(", ")}. Valid: ${ALL_TOOLS.join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
return wanted;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function promptYesNo(question, defaultYes = true) {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
if (!process.stdin.isTTY) { resolve(defaultYes); return; } // non-TTY → assume default
|
|
40
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
41
|
+
const hint = defaultYes ? "Y/n" : "y/N";
|
|
42
|
+
rl.question(`${question} [${hint}] `, (answer) => {
|
|
43
|
+
rl.close();
|
|
44
|
+
const a = (answer || "").trim().toLowerCase();
|
|
45
|
+
if (!a) return resolve(defaultYes);
|
|
46
|
+
resolve(a === "y" || a === "yes");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function isInstalled(tool) {
|
|
52
|
+
// sonar is a Docker container, not a binary. We check it separately
|
|
53
|
+
// by looking at running containers via `docker ps`.
|
|
54
|
+
if (tool === "sonar") return checkSonarRunning();
|
|
55
|
+
if (tool === "lighthouse") return (await checkBinary("lighthouse")).ok;
|
|
56
|
+
if (tool === "docker") return (await checkBinary("docker")).ok;
|
|
57
|
+
return (await checkBinary(tool)).ok;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function checkSonarRunning() {
|
|
61
|
+
try {
|
|
62
|
+
const { stdout } = await execFileAsync("docker", ["ps", "--format", "{{.Names}}", "--filter", "name=sonarqube"], { timeout: 5000 });
|
|
63
|
+
return stdout.trim().length > 0;
|
|
64
|
+
} catch { return false; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function runInstall(command) {
|
|
68
|
+
// Split on whitespace (commands here are simple, no nested quoting).
|
|
69
|
+
const [bin, ...args] = command.split(/\s+/);
|
|
70
|
+
try {
|
|
71
|
+
const { stdout, stderr } = await execFileAsync(bin, args, { timeout: 300_000, maxBuffer: 32 * 1024 * 1024 });
|
|
72
|
+
return { ok: true, stdout, stderr };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return { ok: false, error: err?.shortMessage || err?.message || String(err), stderr: err?.stderr };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Sonar handler: special case. Cannot install Sonar via package manager;
|
|
80
|
+
* the install is `docker compose -f <our-template> up -d` if Docker is
|
|
81
|
+
* available. Falls back to a manual URL otherwise.
|
|
82
|
+
*/
|
|
83
|
+
async function handleSonar({ available, dryRun }) {
|
|
84
|
+
if (!available.docker) {
|
|
85
|
+
return {
|
|
86
|
+
tool: "sonar",
|
|
87
|
+
action: "manual",
|
|
88
|
+
reason: "docker not available — sonar needs docker. Install docker first.",
|
|
89
|
+
manualUrl: "https://docs.docker.com/get-docker/",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const composePath = path.join(process.cwd(), "docker-compose.yml");
|
|
93
|
+
let composeExists = false;
|
|
94
|
+
try { await fs.access(composePath); composeExists = true; } catch { /* missing */ }
|
|
95
|
+
const command = composeExists
|
|
96
|
+
? "docker compose up -d"
|
|
97
|
+
: "docker run -d --name sonarqube -p 9000:9000 sonarqube:lts-community";
|
|
98
|
+
return {
|
|
99
|
+
tool: "sonar",
|
|
100
|
+
action: dryRun ? "dry-run" : "ready",
|
|
101
|
+
command,
|
|
102
|
+
manualUrl: "https://docs.sonarsource.com/sonarqube-community-build/try-out-sonarqube/",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Docker handler: never auto-install (platform-specific, invasive).
|
|
108
|
+
* Always point at the official docs.
|
|
109
|
+
*/
|
|
110
|
+
function handleDocker(available) {
|
|
111
|
+
return {
|
|
112
|
+
tool: "docker",
|
|
113
|
+
action: "manual",
|
|
114
|
+
reason: "Docker install is platform-specific — see official docs.",
|
|
115
|
+
manualUrl: "https://docs.docker.com/get-docker/",
|
|
116
|
+
suggested: available.brew ? "brew install --cask docker" : null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Plan + (optionally execute) the install of every requested tool.
|
|
122
|
+
*
|
|
123
|
+
* @param {Object} opts
|
|
124
|
+
* @param {string|null} [opts.only] - comma-separated subset
|
|
125
|
+
* @param {boolean} [opts.yes] - skip prompts
|
|
126
|
+
* @param {boolean} [opts.dryRun] - plan-only, no execution
|
|
127
|
+
* @param {Object} [opts.logger]
|
|
128
|
+
* @returns {Promise<{ results: Array<Object>, exitCode: number }>}
|
|
129
|
+
*/
|
|
130
|
+
export async function installToolsCommand(opts = {}) {
|
|
131
|
+
const only = parseOnlyList(opts.only);
|
|
132
|
+
const yes = !!opts.yes;
|
|
133
|
+
const dryRun = !!opts.dryRun;
|
|
134
|
+
const logger = opts.logger || console;
|
|
135
|
+
|
|
136
|
+
const targetTools = only || ALL_TOOLS;
|
|
137
|
+
const available = await detectPackageManagers();
|
|
138
|
+
const stack = await detectProjectStack(process.cwd()).catch(() => null);
|
|
139
|
+
|
|
140
|
+
const results = [];
|
|
141
|
+
for (const tool of targetTools) {
|
|
142
|
+
// Stack gating — lighthouse on backend-only projects is skipped
|
|
143
|
+
// UNLESS the user passed --only lighthouse explicitly.
|
|
144
|
+
if (tool === "lighthouse" && !only && !appliesToStack(tool, stack)) {
|
|
145
|
+
results.push({ tool, action: "skipped", reason: "no frontend stack detected" });
|
|
146
|
+
logger.info?.(`⊘ ${tool}: skipped (no frontend stack detected; use --only lighthouse to force)`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (await isInstalled(tool)) {
|
|
151
|
+
results.push({ tool, action: "already-installed" });
|
|
152
|
+
logger.info?.(`✓ ${tool}: already installed`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (tool === "sonar") {
|
|
157
|
+
const result = await handleSonar({ available, dryRun });
|
|
158
|
+
results.push(result);
|
|
159
|
+
logger.info?.(`▸ sonar: ${result.command || result.manualUrl}`);
|
|
160
|
+
if (!dryRun && result.action === "ready") {
|
|
161
|
+
const proceed = yes || await promptYesNo(`Start SonarQube with: ${result.command}?`);
|
|
162
|
+
if (proceed) {
|
|
163
|
+
const r = await runInstall(result.command);
|
|
164
|
+
result.action = r.ok ? "installed" : "failed";
|
|
165
|
+
if (!r.ok) result.error = r.error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (tool === "docker") {
|
|
172
|
+
const result = handleDocker(available);
|
|
173
|
+
results.push(result);
|
|
174
|
+
logger.info?.(`▸ docker: ${result.suggested || result.manualUrl}`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// semgrep / osv-scanner / lighthouse — package-manager driven.
|
|
179
|
+
const hint = await getInstallHint(tool, available);
|
|
180
|
+
if (!hint.command || !hint.manager) {
|
|
181
|
+
results.push({ tool, action: "manual", reason: "no compatible package manager found", manualUrl: hint.manualUrl, suggested: hint.command });
|
|
182
|
+
logger.warn?.(`✗ ${tool}: no compatible package manager. Manual: ${hint.manualUrl}`);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (dryRun) {
|
|
186
|
+
results.push({ tool, action: "dry-run", command: hint.command, manager: hint.manager });
|
|
187
|
+
logger.info?.(`▸ ${tool}: would run \`${hint.command}\` (manager: ${hint.manager})`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const proceed = yes || await promptYesNo(`Install ${tool} with: ${hint.command}?`);
|
|
191
|
+
if (!proceed) {
|
|
192
|
+
results.push({ tool, action: "declined", command: hint.command });
|
|
193
|
+
logger.info?.(`⊘ ${tool}: declined`);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
logger.info?.(`▸ ${tool}: running \`${hint.command}\`...`);
|
|
197
|
+
const r = await runInstall(hint.command);
|
|
198
|
+
if (r.ok) {
|
|
199
|
+
results.push({ tool, action: "installed", command: hint.command, manager: hint.manager });
|
|
200
|
+
logger.info?.(`✓ ${tool}: installed via ${hint.manager}`);
|
|
201
|
+
} else {
|
|
202
|
+
results.push({ tool, action: "failed", command: hint.command, error: r.error });
|
|
203
|
+
logger.warn?.(`✗ ${tool}: install failed (${r.error})`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const failures = results.filter((r) => r.action === "failed").length;
|
|
208
|
+
return { results, exitCode: failures > 0 ? 1 : 0 };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export const __test = { parseOnlyList, isInstalled, ALL_TOOLS };
|
package/src/prompts/audit.js
CHANGED
|
@@ -28,7 +28,7 @@ const VALID_SCORES = new Set(["A", "B", "C", "D", "F"]);
|
|
|
28
28
|
const VALID_SEVERITIES = new Set(["critical", "high", "medium", "low"]);
|
|
29
29
|
const VALID_IMPACT = new Set(["high", "medium", "low"]);
|
|
30
30
|
|
|
31
|
-
export function buildAuditPrompt({ task, instructions, dimensions = null, context = null, basalCost = null, growthDelta = null, stack = null, sonarFindings = null, webperf = null, osvFindings = null, semgrepFindings = null }) {
|
|
31
|
+
export function buildAuditPrompt({ task, instructions, dimensions = null, context = null, basalCost = null, growthDelta = null, stack = null, sonarFindings = null, webperf = null, osvFindings = null, semgrepFindings = null, circularDeps = null, deadExports = null }) {
|
|
32
32
|
const sections = [SUBAGENT_PREAMBLE];
|
|
33
33
|
|
|
34
34
|
if (instructions) {
|
|
@@ -346,6 +346,41 @@ export function buildAuditPrompt({ task, instructions, dimensions = null, contex
|
|
|
346
346
|
sections.push(lines.join("\n"));
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
// Knip dead-exports findings — KJC-TSK v2.17. Stack-aware, JS/TS only.
|
|
350
|
+
// Folds into the `codeQuality` dimension as MINOR for unused exports/types
|
|
351
|
+
// and MAJOR for unused files (likely real dead code).
|
|
352
|
+
if (deadExports?.available && deadExports.total > 0) {
|
|
353
|
+
const lines = ["## Dead Code (knip)"];
|
|
354
|
+
lines.push(`- Unused exports/types: ${(deadExports.exports || []).length}`);
|
|
355
|
+
lines.push(`- Unused files: ${(deadExports.files || []).length}`);
|
|
356
|
+
const allItems = [...(deadExports.exports || []), ...(deadExports.files || [])].slice(0, 40);
|
|
357
|
+
for (const item of allItems) {
|
|
358
|
+
const loc = `${item.path}${item.line ? `:${item.line}` : ""}`;
|
|
359
|
+
const name = item.name ? ` \`${item.name}\`` : "";
|
|
360
|
+
lines.push(` - [${item.severity}] ${loc} [${item.rule}]${name}`);
|
|
361
|
+
}
|
|
362
|
+
lines.push("");
|
|
363
|
+
lines.push("These dead-code findings are GROUND TRUTH (deterministic, no LLM guessing). Fold them into the `codeQuality` dimension. Use the knip rule id (`knip:unused-exports`, `knip:unused-types`, `knip:unused-files`) verbatim in the `rule` field. Unused files at MAJOR severity are most likely real dead code; unused exports at MINOR may be public API surface kept for downstream consumers — flag but don't insist on deletion.");
|
|
364
|
+
sections.push(lines.join("\n"));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Madge circular-import findings — KJC-TSK v2.17. Stack-aware, JS/TS only.
|
|
368
|
+
if (circularDeps?.available && circularDeps.total > 0) {
|
|
369
|
+
const lines = ["## Circular Import Cycles"];
|
|
370
|
+
lines.push(`- Total cycles: ${circularDeps.total}`);
|
|
371
|
+
const slice = (circularDeps.cycles || []).slice(0, 30);
|
|
372
|
+
for (const c of slice) {
|
|
373
|
+
const chain = (c.cycle || []).join(" → ");
|
|
374
|
+
lines.push(` - [${c.severity}] ${chain} → ${c.cycle?.[0] || ""}`);
|
|
375
|
+
}
|
|
376
|
+
if (circularDeps.cycles?.length > slice.length) {
|
|
377
|
+
lines.push(` - ... ${circularDeps.cycles.length - slice.length} more cycles`);
|
|
378
|
+
}
|
|
379
|
+
lines.push("");
|
|
380
|
+
lines.push("These cycles are GROUND TRUTH (deterministic, no LLM guessing). Fold them into the `architecture` dimension. Use `madge:circular-import` as the rule field. MAJOR severity = chain length ≥ 4 files; MINOR = shorter chains. Recommend the standard fix: extract a third module that both ends depend on, or break the cycle with a typeof/inline import.");
|
|
381
|
+
sections.push(lines.join("\n"));
|
|
382
|
+
}
|
|
383
|
+
|
|
349
384
|
if (context) {
|
|
350
385
|
sections.push(`## Context\n${context}`);
|
|
351
386
|
}
|
package/src/roles/audit-role.js
CHANGED
|
@@ -6,6 +6,8 @@ import { collectSonarFindings } from "../audit/sonar-findings.js";
|
|
|
6
6
|
import { collectWebPerfInput } from "../audit/webperf-input.js";
|
|
7
7
|
import { collectOsvFindings } from "../audit/osv-findings.js";
|
|
8
8
|
import { collectSemgrepFindings } from "../audit/semgrep-findings.js";
|
|
9
|
+
import { collectCircularDeps } from "../audit/circular-deps.js";
|
|
10
|
+
import { collectDeadExports } from "../audit/dead-exports.js";
|
|
9
11
|
|
|
10
12
|
function parseDimensions(dimensionsStr) {
|
|
11
13
|
if (!dimensionsStr || dimensionsStr === "all") return null;
|
|
@@ -47,6 +49,8 @@ export class AuditRole extends AgentRole {
|
|
|
47
49
|
const noSonar = typeof input === "object" ? Boolean(input?.noSonar) : false;
|
|
48
50
|
const noOsv = typeof input === "object" ? Boolean(input?.noOsv) : false;
|
|
49
51
|
const noSemgrep = typeof input === "object" ? Boolean(input?.noSemgrep) : false;
|
|
52
|
+
const noMadge = typeof input === "object" ? Boolean(input?.noMadge) : false;
|
|
53
|
+
const noKnip = typeof input === "object" ? Boolean(input?.noKnip) : false;
|
|
50
54
|
const projectDir = this.config?.projectDir || process.cwd();
|
|
51
55
|
let basalCost = null;
|
|
52
56
|
let growthDelta = null;
|
|
@@ -55,6 +59,8 @@ export class AuditRole extends AgentRole {
|
|
|
55
59
|
let webperf = null;
|
|
56
60
|
let osvFindings = null;
|
|
57
61
|
let semgrepFindings = null;
|
|
62
|
+
let circularDeps = null;
|
|
63
|
+
let deadExports = null;
|
|
58
64
|
try {
|
|
59
65
|
basalCost = await measureBasalCost(projectDir);
|
|
60
66
|
const previous = await loadPreviousAudit(projectDir);
|
|
@@ -86,7 +92,23 @@ export class AuditRole extends AgentRole {
|
|
|
86
92
|
semgrepFindings = await collectSemgrepFindings(projectDir, this.logger);
|
|
87
93
|
} catch { /* semgrep is best-effort */ }
|
|
88
94
|
}
|
|
89
|
-
|
|
95
|
+
// Madge circular-import deps — KJC-TSK v2.17. Stack-aware: skipped for
|
|
96
|
+
// non-JS/TS projects. Best-effort: any failure returns available:false
|
|
97
|
+
// and the audit continues without the section.
|
|
98
|
+
if (!noMadge) {
|
|
99
|
+
try {
|
|
100
|
+
circularDeps = await collectCircularDeps(projectDir, stack, this.config, this.logger);
|
|
101
|
+
} catch { /* madge is best-effort */ }
|
|
102
|
+
}
|
|
103
|
+
// Knip dead-exports — KJC-TSK v2.17. Stack-aware, JS/TS only, needs
|
|
104
|
+
// package.json. Best-effort: any failure returns available:false and
|
|
105
|
+
// the audit continues without the section.
|
|
106
|
+
if (!noKnip) {
|
|
107
|
+
try {
|
|
108
|
+
deadExports = await collectDeadExports(projectDir, stack, this.config, this.logger);
|
|
109
|
+
} catch { /* knip is best-effort */ }
|
|
110
|
+
}
|
|
111
|
+
return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports };
|
|
90
112
|
}
|
|
91
113
|
|
|
92
114
|
/**
|
|
@@ -102,11 +124,11 @@ export class AuditRole extends AgentRole {
|
|
|
102
124
|
const context = typeof input === "object" ? input?.context || null : null;
|
|
103
125
|
const dimensions = typeof rawDimensions === "string" ? parseDimensions(rawDimensions) : rawDimensions;
|
|
104
126
|
|
|
105
|
-
const { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings } = deterministicCtx;
|
|
127
|
+
const { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports } = deterministicCtx;
|
|
106
128
|
|
|
107
129
|
const provider = this.resolveProvider();
|
|
108
130
|
const agent = this.createAgentInstance(provider);
|
|
109
|
-
const prompt = buildAuditPrompt({ task, instructions: this.instructions, dimensions, context, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings });
|
|
131
|
+
const prompt = buildAuditPrompt({ task, instructions: this.instructions, dimensions, context, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports });
|
|
110
132
|
const runArgs = { prompt, role: "audit" };
|
|
111
133
|
if (onOutput) runArgs.onOutput = onOutput;
|
|
112
134
|
const startedAt = Date.now();
|
|
@@ -138,6 +160,8 @@ export class AuditRole extends AgentRole {
|
|
|
138
160
|
webperf: webperf?.available ? webperf : undefined,
|
|
139
161
|
osvFindings: osvFindings?.available ? osvFindings : undefined,
|
|
140
162
|
semgrepFindings: semgrepFindings?.available ? semgrepFindings : undefined,
|
|
163
|
+
circularDeps: circularDeps?.available ? circularDeps : undefined,
|
|
164
|
+
deadExports: deadExports?.available ? deadExports : undefined,
|
|
141
165
|
provider
|
|
142
166
|
},
|
|
143
167
|
summary: buildSummary(parsed),
|
package/src/sonar/api.js
CHANGED
|
@@ -95,9 +95,13 @@ export async function getOpenIssues(config, projectKey = null) {
|
|
|
95
95
|
// KJC-TSK-0416: pre-filtro de falsos positivos antes de devolver al
|
|
96
96
|
// caller. El coder, audit y scan consumen lo que pasa el filtro;
|
|
97
97
|
// suppressed se devuelve aparte para observabilidad.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
// v2.17.0: accepts both legacy config.sonar.false_positives (no `tool`
|
|
99
|
+
// field, implicit "sonar") and the new generalised config.audit.false_positives.
|
|
100
|
+
const sonarLegacy = (config?.sonar?.false_positives || []).map((r) => ({ tool: "sonar", ...r }));
|
|
101
|
+
const auditRules = (config?.audit?.false_positives || []).filter((r) => (r.tool || "sonar") === "sonar");
|
|
102
|
+
const taggedIssues = parsedIssues.map((i) => ({ ...i, tool: "sonar" }));
|
|
103
|
+
const { kept, suppressed } = filterFalsePositives(taggedIssues, {
|
|
104
|
+
extraRules: [...sonarLegacy, ...auditRules],
|
|
101
105
|
projectRoot: config?.projectDir,
|
|
102
106
|
});
|
|
103
107
|
|