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/README.md
CHANGED
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v2.
|
|
26
|
+
> **v2.17.1 released** — Patch. Fixes **KJC-BUG-0055**: a project deleted from the HU Board (🗑️) no longer resurrects on the next `kj plan` or board restart. Four independent leaks closed: (1) **`sync.js` temporal gate** — the unconditional `removeTombstone` from KJC-BUG-0050 becomes a `plan.updatedAt > tombstone.deleted_at` comparison, so a tombstoned project revives only when the plan is genuinely newer than the delete; (2) **`ephemeral-cleaner.js`** now writes a tombstone and `rm -rf`'s `hu-stories/`, `sessions/` and `~/.kj/plans/` dirs when wiping ephemeral projects at boot — previously the orphan directories revived the project on the next scan; (3) **`fullScan` boot GC** sweeps orphan tombstoned dirs (the manual DB-wipe case); (4) **`DELETE /api/projects/:id`** honours `KJ_PLANS_DIR` instead of the hardcoded path. New `getTombstone()` helper in `db.js`. **Also fixes a silent `kj board start` failure (#753)**: the daemon's entry-point guard compared `import.meta.url` to a hand-built `file://` path, which wrongly returned false on Windows, symlinked/global installs and paths with spaces — the board exited 0 with an empty log. `server.js` now trusts a `KJ_BOARD_DAEMON` launcher flag, adds `uncaughtException`/`unhandledRejection` handlers and an actionable `better-sqlite3` load error; `board.js` detects an early daemon exit instead of reporting a phantom PID. 4 909/4 909 tests passing across 408 files. Safe upgrade from 2.17.0.
|
|
27
|
+
>
|
|
28
|
+
> **v2.17.0** — Minor. `kj audit` gains two new deterministic structural collectors and the v2.16 Sonar false-positive filter is generalised to apply across every collector. **Knip dead-exports collector** (codeQuality dim): reports unused exports/types (MINOR) and unused files (MAJOR), stack-aware (JS/TS only, needs package.json), subprocess via `--reporter json`, 120s timeout. **Madge circular-import collector** (architecture dim): detects import cycles, severity by chain length (≥4 files = MAJOR), honours tsconfig.json path aliases, 60s timeout. **Generalised FP filter**: every collector (sonar, knip, madge, osv, semgrep) now uses the same `config.audit.false_positives` shape with `tool` field plus inline marker `// karajan-audit-ignore: <tool>:<ruleId>`; legacy `config.sonar.false_positives` and `// karajan-sonar-ignore:` keep working. **Built-in catalogue** ships 4 entries by default (knip:unused-files in tests/fixtures + examples, knip:unused-exports on barrel files, madge cycles in node_modules). **BREAKING(engines)**: Node `>=20.10` → `>=20.19` (knip 6.x requirement; same flavour as the v2.8 18→20.10 bump). 4 872/4 872 tests passing across 402 files. Safe upgrade from 2.16.0 if you're on Node ≥ 20.19.
|
|
29
|
+
>
|
|
30
|
+
> **v2.16.0** — Minor. Quality-focused release: **deterministic Sonar false-positive filter** (KJC-TSK-0416), wire universal de Brain Recovery completado en `semantic-detector` (TSK-0413 step D), codemod `replace(/regex/g, …)` → `replaceAll(/regex/g, …)` en 41 sitios, y limpieza de hallazgos del propio `kj audit` v2.15.0. The Sonar filter combines two mechanisms: (1) **static rules** `{ rule, filePattern, reason }` from a built-in catalog (covering common false positives like `javascript:S2699` on `tests/architecture/`) plus extensible via `config.sonar.false_positives`; (2) **inline ignores** with `// karajan-sonar-ignore: <ruleId>` on the issue line. Result: the coder stops burning tokens "fixing" non-broken assertions. Brain Recovery now wraps **every** AI call in the pipeline — `semantic-detector` was the last legacy caller. 4 846/4 846 tests passing across 401 files. Safe upgrade from 2.15.0.
|
|
27
31
|
>
|
|
28
32
|
> **v2.15.0** — Minor. Three epics, 30+ commits, ~4 000 LOC. (1) **Brain Recovery** (KJC-PCS-0044): universal error classifier with 7 rich classes (rate_limit_short, quota_daily, quota_monthly, api_down, auth_failed, network_timeout, silenced, unknown_fatal), `withBrainRecovery` wrapper wired into ALL agent invocations (no silent failures), persistent hibernation in `~/.kj/standby/<id>.json` with event-driven scheduler (no polling), `kj standby list` + `kj standby resume`, board reconcile at startup, **fallback chain** when quota exhausted with `retryAfter > 12h` (Claude → Codex → OpenCode → Aider, configurable per role via `kj init`). (2) **Model Routing + Undo** (KJC-PCS-0043): each HU gets `coder_model` + `reviewer_model` with cross-provider review by default (claude↔codex), per-HU override from board modal, OpenCode + Aider as first-class providers, ⏪ Undo button restores files via git snapshots. (3) **Self-Healing Plan** (KJC-PCS-0042): structural integrity pass breaks cycles + cleans orphan refs, smart convergence guard for self-fix loop, `kj plan fix [planId] [--prompt]` for iterating without regenerating, skip Sonar for SPIKE/DOC/RESEARCH HUs, eliminate Failed kanban column. 4 835/4 835 tests passing across 400 files. Safe upgrade from 2.14.3.
|
|
29
33
|
>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "karajan-code",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.1",
|
|
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",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"cli"
|
|
25
25
|
],
|
|
26
26
|
"engines": {
|
|
27
|
-
"node": ">=20.
|
|
27
|
+
"node": ">=20.19.0"
|
|
28
28
|
},
|
|
29
29
|
"imports": {
|
|
30
30
|
"#utils/*": "./src/utils/*",
|
|
@@ -70,6 +70,8 @@
|
|
|
70
70
|
"commander": "^14.0.0",
|
|
71
71
|
"execa": "^9.6.0",
|
|
72
72
|
"js-yaml": "^4.1.0",
|
|
73
|
+
"knip": "^6.14.1",
|
|
74
|
+
"madge": "^8.0.0",
|
|
73
75
|
"valibot": "^1.3.1"
|
|
74
76
|
},
|
|
75
77
|
"devDependencies": {
|
|
@@ -153,7 +153,22 @@ export const seaBuildOptions = {
|
|
|
153
153
|
// this `external` entry is belt-and-braces — if any other dependency
|
|
154
154
|
// pulls better-sqlite3 in we want a clear "not bundled" error, not a
|
|
155
155
|
// silent half-bundle.
|
|
156
|
-
|
|
156
|
+
//
|
|
157
|
+
// madge (KJC-TSK v2.17): pulled in by src/audit/circular-deps.js via a
|
|
158
|
+
// dynamic import. Madge transitively depends on @vue/compiler-sfc which
|
|
159
|
+
// optionally requires ~20 template engines (velocityjs, dustjs-linkedin,
|
|
160
|
+
// twig, …) that are not installed — esbuild fails the bundle if it
|
|
161
|
+
// tries to walk those. Marking madge external keeps `kj audit` working
|
|
162
|
+
// in npm-installed Karajan (madge is in node_modules) and degrades
|
|
163
|
+
// gracefully in the SEA binary (the dynamic import fails → audit
|
|
164
|
+
// continues without circular-dep findings).
|
|
165
|
+
// knip (KJC-TSK v2.17): invoked as a subprocess by src/audit/dead-exports.js
|
|
166
|
+
// via `process.execPath knip-bin --reporter json`. Its native parser
|
|
167
|
+
// (oxc-parser, oxc-resolver) and CLI machinery must not be bundled. In SEA
|
|
168
|
+
// builds the createRequire(import.meta.url).resolve("knip/bin/knip.js")
|
|
169
|
+
// throws → collector returns available:false. npm installs get knip
|
|
170
|
+
// resolved normally from node_modules.
|
|
171
|
+
external: ["better-sqlite3", "madge", "knip", "oxc-parser", "oxc-resolver"],
|
|
157
172
|
plugins: [seaTransformPlugin, huBoardStubPlugin],
|
|
158
173
|
logLevel: "info",
|
|
159
174
|
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Circular import-deps collector for `kj audit` (KJC-TSK — v2.17).
|
|
2
|
+
//
|
|
3
|
+
// Deterministic, stack-aware. Uses madge programmatically; activates only
|
|
4
|
+
// when the project's stack has JS/TS sources. Anything else returns
|
|
5
|
+
// { available: false, reason }.
|
|
6
|
+
//
|
|
7
|
+
// Same best-effort shape as sonar-findings / osv-findings / semgrep-findings:
|
|
8
|
+
// - missing dep or timeout returns { available: false, reason }
|
|
9
|
+
// - findings are passed through audit/issue-filter.js so the user can
|
|
10
|
+
// suppress known cycles via config.audit.false_positives or inline
|
|
11
|
+
// `// karajan-audit-ignore: madge:circular-import` markers.
|
|
12
|
+
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import fs from "node:fs/promises";
|
|
15
|
+
import { filterFalsePositives } from "./issue-filter.js";
|
|
16
|
+
|
|
17
|
+
// madge is loaded dynamically: in the SEA standalone binary it is excluded
|
|
18
|
+
// from the bundle (see scripts/esbuild-sea.config.mjs) and a require()
|
|
19
|
+
// failure here cleanly degrades to available:false, while npm installs work
|
|
20
|
+
// normally. Keeping the import inside collectCircularDeps() also avoids
|
|
21
|
+
// paying the load cost for non-JS projects.
|
|
22
|
+
|
|
23
|
+
const SCAN_TIMEOUT_MS = 60_000;
|
|
24
|
+
const MAX_CYCLES_REPORTED = 50;
|
|
25
|
+
|
|
26
|
+
// Extensions madge will follow. Python/Go/etc. are not supported and we
|
|
27
|
+
// short-circuit on stack detection — listing only JS/TS variants here is a
|
|
28
|
+
// safety net so we never accidentally scan unrelated files.
|
|
29
|
+
const JS_TS_EXTENSIONS = ["js", "mjs", "cjs", "jsx", "ts", "tsx"];
|
|
30
|
+
|
|
31
|
+
function hasJsTsStack(stack) {
|
|
32
|
+
if (!stack) return false;
|
|
33
|
+
const lang = (stack.language || "").toLowerCase();
|
|
34
|
+
if (lang === "javascript" || lang === "typescript") return true;
|
|
35
|
+
const langs = Array.isArray(stack.languages) ? stack.languages : [];
|
|
36
|
+
return langs.some((l) => /^(javascript|typescript|jsx?|tsx?)$/i.test(String(l)));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function findTsConfig(projectDir) {
|
|
40
|
+
for (const name of ["tsconfig.json", "jsconfig.json"]) {
|
|
41
|
+
const p = path.join(projectDir, name);
|
|
42
|
+
try { await fs.access(p); return p; } catch { /* not present */ }
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pickEntrypoint(projectDir) {
|
|
48
|
+
// madge needs an entrypoint. We prefer common conventions; if none
|
|
49
|
+
// exists we fall back to scanning `src/` which covers ~95% of JS/TS
|
|
50
|
+
// projects we've seen.
|
|
51
|
+
return path.join(projectDir, "src");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Collect circular-import findings for a project.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} projectDir
|
|
58
|
+
* @param {object} stack - shape returned by detectProjectStack()
|
|
59
|
+
* @param {object} [config] - kj config (for config.audit.false_positives)
|
|
60
|
+
* @param {object} [logger]
|
|
61
|
+
* @returns {Promise<{available: boolean, reason?: string, total?: number, cycles?: object[], suppressed?: object[]}>}
|
|
62
|
+
*/
|
|
63
|
+
export async function collectCircularDeps(projectDir, stack, config = {}, logger = null) {
|
|
64
|
+
if (!hasJsTsStack(stack)) {
|
|
65
|
+
return { available: false, reason: "no JS/TS sources detected — circular-dep scan skipped" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const entry = pickEntrypoint(projectDir);
|
|
69
|
+
try { await fs.access(entry); } catch {
|
|
70
|
+
return { available: false, reason: `entrypoint ${path.relative(projectDir, entry)} not found` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let madge;
|
|
74
|
+
try {
|
|
75
|
+
({ default: madge } = await import("madge"));
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return { available: false, reason: `madge not loadable (${err?.message || err}). In the SEA binary install karajan-code via npm to enable.` };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const tsConfig = await findTsConfig(projectDir);
|
|
81
|
+
const opts = {
|
|
82
|
+
fileExtensions: JS_TS_EXTENSIONS,
|
|
83
|
+
excludeRegExp: [/node_modules/, /\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /dist\//, /build\//, /coverage\//],
|
|
84
|
+
detectiveOptions: { es6: { mixedImports: true } },
|
|
85
|
+
};
|
|
86
|
+
if (tsConfig) opts.tsConfig = tsConfig;
|
|
87
|
+
|
|
88
|
+
let cycles;
|
|
89
|
+
try {
|
|
90
|
+
const scan = await Promise.race([
|
|
91
|
+
madge(entry, opts),
|
|
92
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error("madge timeout")), SCAN_TIMEOUT_MS)),
|
|
93
|
+
]);
|
|
94
|
+
cycles = scan.circular() || [];
|
|
95
|
+
} catch (err) {
|
|
96
|
+
logger?.warn?.(`audit/circular-deps: madge failed (${err?.message || err})`);
|
|
97
|
+
return { available: false, reason: `madge run failed: ${err?.message || err}` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// madge returns paths relative to the entry dir; normalize to paths
|
|
101
|
+
// relative to projectDir so they match how the rest of the audit (Sonar,
|
|
102
|
+
// Semgrep) reports locations and so config.audit.false_positives can
|
|
103
|
+
// target them with familiar globs like "src/".
|
|
104
|
+
const relPrefix = path.relative(projectDir, entry);
|
|
105
|
+
const normalizedCycles = cycles.map((cycle) =>
|
|
106
|
+
cycle.map((p) => (relPrefix ? path.join(relPrefix, p) : p)),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const issues = normalizedCycles.slice(0, MAX_CYCLES_REPORTED).map((cycle, idx) => ({
|
|
110
|
+
tool: "madge",
|
|
111
|
+
rule: "circular-import",
|
|
112
|
+
component: cycle[0],
|
|
113
|
+
path: cycle[0],
|
|
114
|
+
line: 1,
|
|
115
|
+
severity: cycle.length >= 4 ? "MAJOR" : "MINOR",
|
|
116
|
+
message: `Circular import chain (${cycle.length} files): ${cycle.join(" → ")} → ${cycle[0]}`,
|
|
117
|
+
cycle,
|
|
118
|
+
index: idx,
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
const extraRules = (config?.audit?.false_positives || []).filter((r) => r.tool === "madge");
|
|
122
|
+
const { kept, suppressed } = filterFalsePositives(issues, { extraRules, projectRoot: projectDir });
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
available: true,
|
|
126
|
+
total: kept.length,
|
|
127
|
+
suppressedCount: suppressed.length,
|
|
128
|
+
cycles: kept,
|
|
129
|
+
suppressed,
|
|
130
|
+
truncated: normalizedCycles.length > MAX_CYCLES_REPORTED ? normalizedCycles.length - MAX_CYCLES_REPORTED : 0,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Group cycles by severity for the deterministic summary renderer.
|
|
136
|
+
*/
|
|
137
|
+
export function groupCyclesBySeverity(cycles) {
|
|
138
|
+
const groups = { MAJOR: [], MINOR: [] };
|
|
139
|
+
for (const c of cycles || []) {
|
|
140
|
+
const sev = c.severity || "MINOR";
|
|
141
|
+
if (groups[sev]) groups[sev].push(c);
|
|
142
|
+
}
|
|
143
|
+
return groups;
|
|
144
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Dead exports collector for `kj audit` (KJC-TSK v2.17, code-quality dim).
|
|
2
|
+
//
|
|
3
|
+
// Wraps knip via subprocess. Reports unused exports + unused files —
|
|
4
|
+
// findings consumed deterministically by the LLM phase so the coder
|
|
5
|
+
// stops shipping new code without noticing dead code piling up.
|
|
6
|
+
//
|
|
7
|
+
// Same best-effort pattern as madge / semgrep / osv / sonar:
|
|
8
|
+
// - missing project signal (no package.json, no JS/TS) → available:false
|
|
9
|
+
// - missing knip binary → available:false (degrades cleanly in SEA)
|
|
10
|
+
// - timeout / parse error → available:false with reason
|
|
11
|
+
//
|
|
12
|
+
// Findings pass through audit/issue-filter.js so users can suppress
|
|
13
|
+
// false positives via config.audit.false_positives (tool="knip") or
|
|
14
|
+
// inline `// karajan-audit-ignore: knip:<ruleId>`.
|
|
15
|
+
|
|
16
|
+
import { execFile } from "node:child_process";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import fs from "node:fs/promises";
|
|
21
|
+
import { filterFalsePositives } from "./issue-filter.js";
|
|
22
|
+
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
24
|
+
|
|
25
|
+
const SCAN_TIMEOUT_MS = 120_000;
|
|
26
|
+
const MAX_DEAD_EXPORTS_REPORTED = 100;
|
|
27
|
+
const MAX_UNUSED_FILES_REPORTED = 50;
|
|
28
|
+
|
|
29
|
+
function hasJsTsStack(stack) {
|
|
30
|
+
if (!stack) return false;
|
|
31
|
+
const lang = (stack.language || "").toLowerCase();
|
|
32
|
+
if (lang === "javascript" || lang === "typescript") return true;
|
|
33
|
+
const langs = Array.isArray(stack.languages) ? stack.languages : [];
|
|
34
|
+
return langs.some((l) => /^(javascript|typescript|jsx?|tsx?)$/i.test(String(l)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function hasPackageJson(projectDir) {
|
|
38
|
+
try { await fs.access(path.join(projectDir, "package.json")); return true; } catch { return false; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the knip binary path relative to this package's node_modules.
|
|
43
|
+
* In SEA builds knip is `external` and require.resolve throws — we let
|
|
44
|
+
* the caller catch it and return available:false.
|
|
45
|
+
*
|
|
46
|
+
* knip restricts its subpath exports to "." and "./session", so we can't
|
|
47
|
+
* directly resolve "knip/bin/knip.js" or even "knip/package.json".
|
|
48
|
+
* Instead we resolve "knip" (the main entry, dist/index.js), walk up to
|
|
49
|
+
* its package root, and locate bin/knip.js from there.
|
|
50
|
+
*/
|
|
51
|
+
function resolveKnipBin() {
|
|
52
|
+
const require = createRequire(import.meta.url);
|
|
53
|
+
const entry = require.resolve("knip"); // e.g. .../node_modules/knip/dist/index.js
|
|
54
|
+
// entry is .../node_modules/knip/dist/index.js — the package root is two levels up
|
|
55
|
+
const knipRoot = path.dirname(path.dirname(entry));
|
|
56
|
+
return path.join(knipRoot, "bin", "knip.js");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Collect dead-exports findings for a project.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} projectDir
|
|
63
|
+
* @param {object} stack
|
|
64
|
+
* @param {object} [config]
|
|
65
|
+
* @param {object} [logger]
|
|
66
|
+
* @returns {Promise<{available: boolean, reason?: string, total?: number, exports?: object[], files?: object[], suppressed?: object[]}>}
|
|
67
|
+
*/
|
|
68
|
+
export async function collectDeadExports(projectDir, stack, config = {}, logger = null) {
|
|
69
|
+
if (!hasJsTsStack(stack)) {
|
|
70
|
+
return { available: false, reason: "no JS/TS sources detected — dead-export scan skipped" };
|
|
71
|
+
}
|
|
72
|
+
if (!(await hasPackageJson(projectDir))) {
|
|
73
|
+
return { available: false, reason: "no package.json found — knip needs one to detect entry points" };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let knipBin;
|
|
77
|
+
try { knipBin = resolveKnipBin(); } catch (err) {
|
|
78
|
+
return { available: false, reason: `knip binary not resolvable (${err?.message || err}). In the SEA binary install karajan-code via npm to enable.` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let raw;
|
|
82
|
+
try {
|
|
83
|
+
const { stdout } = await execFileAsync(
|
|
84
|
+
process.execPath,
|
|
85
|
+
[knipBin, "--reporter", "json", "--no-progress", "--no-exit-code"],
|
|
86
|
+
{ cwd: projectDir, timeout: SCAN_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 },
|
|
87
|
+
);
|
|
88
|
+
raw = stdout;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// knip exits non-zero when issues are found; with --no-exit-code that
|
|
91
|
+
// should not happen, but old configs might still trigger it. If the
|
|
92
|
+
// process produced JSON on stdout we still try to parse it.
|
|
93
|
+
raw = err?.stdout || "";
|
|
94
|
+
if (!raw) {
|
|
95
|
+
logger?.warn?.(`audit/dead-exports: knip failed (${err?.message || err})`);
|
|
96
|
+
return { available: false, reason: `knip run failed: ${err?.shortMessage || err?.message || err}` };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let report;
|
|
101
|
+
try {
|
|
102
|
+
report = JSON.parse(raw);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return { available: false, reason: `knip output not parseable as JSON (${err?.message || err})` };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// knip --reporter json shape: { files: [paths], issues: [{ file, exports?, types?, ... }], ... }
|
|
108
|
+
const unusedFiles = Array.isArray(report.files) ? report.files : [];
|
|
109
|
+
const fileIssues = Array.isArray(report.issues) ? report.issues : [];
|
|
110
|
+
|
|
111
|
+
const deadExportIssues = [];
|
|
112
|
+
for (const fi of fileIssues) {
|
|
113
|
+
const file = fi.file || fi.filePath || "";
|
|
114
|
+
if (!file) continue;
|
|
115
|
+
for (const ex of fi.exports || []) {
|
|
116
|
+
deadExportIssues.push({
|
|
117
|
+
tool: "knip",
|
|
118
|
+
rule: "unused-exports",
|
|
119
|
+
component: file,
|
|
120
|
+
path: file,
|
|
121
|
+
line: ex.line || 1,
|
|
122
|
+
col: ex.col,
|
|
123
|
+
name: ex.name,
|
|
124
|
+
severity: "MINOR",
|
|
125
|
+
message: `Unused export \`${ex.name}\` in ${file}`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
for (const t of fi.types || []) {
|
|
129
|
+
deadExportIssues.push({
|
|
130
|
+
tool: "knip",
|
|
131
|
+
rule: "unused-types",
|
|
132
|
+
component: file,
|
|
133
|
+
path: file,
|
|
134
|
+
line: t.line || 1,
|
|
135
|
+
name: t.name,
|
|
136
|
+
severity: "MINOR",
|
|
137
|
+
message: `Unused type \`${t.name}\` in ${file}`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const unusedFileIssues = unusedFiles.map((f) => ({
|
|
143
|
+
tool: "knip",
|
|
144
|
+
rule: "unused-files",
|
|
145
|
+
component: f,
|
|
146
|
+
path: f,
|
|
147
|
+
line: 1,
|
|
148
|
+
severity: "MAJOR",
|
|
149
|
+
message: `Unused file: ${f}`,
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
const allIssues = [
|
|
153
|
+
...deadExportIssues.slice(0, MAX_DEAD_EXPORTS_REPORTED),
|
|
154
|
+
...unusedFileIssues.slice(0, MAX_UNUSED_FILES_REPORTED),
|
|
155
|
+
];
|
|
156
|
+
const extraRules = (config?.audit?.false_positives || []).filter((r) => r.tool === "knip");
|
|
157
|
+
const { kept, suppressed } = filterFalsePositives(allIssues, { extraRules, projectRoot: projectDir });
|
|
158
|
+
|
|
159
|
+
const keptExports = kept.filter((i) => i.rule === "unused-exports" || i.rule === "unused-types");
|
|
160
|
+
const keptFiles = kept.filter((i) => i.rule === "unused-files");
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
available: true,
|
|
164
|
+
total: kept.length,
|
|
165
|
+
suppressedCount: suppressed.length,
|
|
166
|
+
exports: keptExports,
|
|
167
|
+
files: keptFiles,
|
|
168
|
+
suppressed,
|
|
169
|
+
truncated: {
|
|
170
|
+
exports: deadExportIssues.length > MAX_DEAD_EXPORTS_REPORTED ? deadExportIssues.length - MAX_DEAD_EXPORTS_REPORTED : 0,
|
|
171
|
+
files: unusedFiles.length > MAX_UNUSED_FILES_REPORTED ? unusedFiles.length - MAX_UNUSED_FILES_REPORTED : 0,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Group dead-export issues by severity for the deterministic summary.
|
|
178
|
+
*/
|
|
179
|
+
export function groupDeadExportsBySeverity(items) {
|
|
180
|
+
const groups = { MAJOR: [], MINOR: [] };
|
|
181
|
+
for (const i of items || []) {
|
|
182
|
+
const sev = i.severity || "MINOR";
|
|
183
|
+
if (groups[sev]) groups[sev].push(i);
|
|
184
|
+
}
|
|
185
|
+
return groups;
|
|
186
|
+
}
|
|
@@ -12,11 +12,15 @@
|
|
|
12
12
|
import { groupIssuesBySeverity } from "./sonar-findings.js";
|
|
13
13
|
import { groupVulnerabilitiesBySeverity } from "./osv-findings.js";
|
|
14
14
|
import { groupFindingsBySeverity as groupSemgrepBySeverity } from "./semgrep-findings.js";
|
|
15
|
+
import { groupCyclesBySeverity } from "./circular-deps.js";
|
|
16
|
+
import { groupDeadExportsBySeverity } from "./dead-exports.js";
|
|
15
17
|
|
|
16
18
|
const MAX_SAMPLE_DEAD_EXPORTS = 10;
|
|
17
19
|
const MAX_SAMPLE_SONAR_PER_SEVERITY = 5;
|
|
18
20
|
const MAX_SAMPLE_OSV_PER_SEVERITY = 5;
|
|
19
21
|
const MAX_SAMPLE_SEMGREP_PER_SEVERITY = 5;
|
|
22
|
+
const MAX_SAMPLE_CYCLES_PER_SEVERITY = 5;
|
|
23
|
+
const MAX_SAMPLE_KNIP_PER_SEVERITY = 5;
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
26
|
* @param {{basalCost?: object, growthDelta?: object, stack?: object, sonarFindings?: object, webperf?: object, osvFindings?: object}} ctx
|
|
@@ -32,11 +36,39 @@ export function formatDeterministicSummary(ctx) {
|
|
|
32
36
|
if (ctx.sonarFindings) lines.push(...formatSonarBlock(ctx.sonarFindings));
|
|
33
37
|
if (ctx.osvFindings) lines.push(...formatOsvBlock(ctx.osvFindings));
|
|
34
38
|
if (ctx.semgrepFindings) lines.push(...formatSemgrepBlock(ctx.semgrepFindings));
|
|
39
|
+
if (ctx.circularDeps) lines.push(...formatCircularDepsBlock(ctx.circularDeps));
|
|
40
|
+
if (ctx.deadExports) lines.push(...formatDeadExportsBlock(ctx.deadExports));
|
|
35
41
|
if (ctx.webperf) lines.push(...formatWebperfBlock(ctx.webperf));
|
|
36
42
|
|
|
37
43
|
return lines.join("\n");
|
|
38
44
|
}
|
|
39
45
|
|
|
46
|
+
function formatCircularDepsBlock(circularDeps) {
|
|
47
|
+
if (!circularDeps.available) {
|
|
48
|
+
return ["### Circular Dependencies (architecture)", `- Status: not available — ${circularDeps.reason || "madge not available"}`, ""];
|
|
49
|
+
}
|
|
50
|
+
const lines = ["### Circular Dependencies (architecture)"];
|
|
51
|
+
lines.push(`- Total cycles: ${circularDeps.total ?? 0}${circularDeps.suppressedCount ? ` (+${circularDeps.suppressedCount} suppressed)` : ""}`);
|
|
52
|
+
if ((circularDeps.total ?? 0) > 0) {
|
|
53
|
+
const groups = groupCyclesBySeverity(circularDeps.cycles || []);
|
|
54
|
+
for (const [severity, cycles] of Object.entries(groups)) {
|
|
55
|
+
if (cycles.length === 0) continue;
|
|
56
|
+
lines.push(` - ${severity} (${cycles.length}):`);
|
|
57
|
+
for (const c of cycles.slice(0, MAX_SAMPLE_CYCLES_PER_SEVERITY)) {
|
|
58
|
+
const head = (c.cycle || [c.path]).slice(0, 3).join(" → ");
|
|
59
|
+
const more = (c.cycle?.length || 0) > 3 ? ` → … (${c.cycle.length} files)` : "";
|
|
60
|
+
lines.push(` - ${head}${more}`);
|
|
61
|
+
}
|
|
62
|
+
if (cycles.length > MAX_SAMPLE_CYCLES_PER_SEVERITY) {
|
|
63
|
+
lines.push(` - ... and ${cycles.length - MAX_SAMPLE_CYCLES_PER_SEVERITY} more in ${severity}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (circularDeps.truncated > 0) lines.push(` - ... ${circularDeps.truncated} more cycles not shown`);
|
|
67
|
+
}
|
|
68
|
+
lines.push("");
|
|
69
|
+
return lines;
|
|
70
|
+
}
|
|
71
|
+
|
|
40
72
|
function formatSemgrepBlock(semgrepFindings) {
|
|
41
73
|
if (!semgrepFindings.available) {
|
|
42
74
|
return ["### Semgrep SAST", `- Status: not available — ${semgrepFindings.reason || "semgrep not installed"}`, ""];
|
|
@@ -86,6 +118,34 @@ function formatOsvBlock(osvFindings) {
|
|
|
86
118
|
return lines;
|
|
87
119
|
}
|
|
88
120
|
|
|
121
|
+
function formatDeadExportsBlock(deadExports) {
|
|
122
|
+
if (!deadExports.available) {
|
|
123
|
+
return ["### Dead Code (knip)", `- Status: not available — ${deadExports.reason || "knip not available"}`, ""];
|
|
124
|
+
}
|
|
125
|
+
const lines = ["### Dead Code (knip)"];
|
|
126
|
+
const exportsTotal = (deadExports.exports || []).length;
|
|
127
|
+
const filesTotal = (deadExports.files || []).length;
|
|
128
|
+
lines.push(`- Unused exports/types: ${exportsTotal}`);
|
|
129
|
+
lines.push(`- Unused files: ${filesTotal}`);
|
|
130
|
+
if (deadExports.suppressedCount) lines.push(`- Suppressed (FP filter): ${deadExports.suppressedCount}`);
|
|
131
|
+
const allItems = [...(deadExports.exports || []), ...(deadExports.files || [])];
|
|
132
|
+
if (allItems.length > 0) {
|
|
133
|
+
const groups = groupDeadExportsBySeverity(allItems);
|
|
134
|
+
for (const [severity, items] of Object.entries(groups)) {
|
|
135
|
+
if (items.length === 0) continue;
|
|
136
|
+
lines.push(` - ${severity} (${items.length}):`);
|
|
137
|
+
for (const item of items.slice(0, MAX_SAMPLE_KNIP_PER_SEVERITY)) {
|
|
138
|
+
lines.push(` - ${item.path}${item.line ? `:${item.line}` : ""} [${item.rule}]${item.name ? ` \`${item.name}\`` : ""}`);
|
|
139
|
+
}
|
|
140
|
+
if (items.length > MAX_SAMPLE_KNIP_PER_SEVERITY) {
|
|
141
|
+
lines.push(` - ... and ${items.length - MAX_SAMPLE_KNIP_PER_SEVERITY} more in ${severity}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
lines.push("");
|
|
146
|
+
return lines;
|
|
147
|
+
}
|
|
148
|
+
|
|
89
149
|
function formatStackBlock(stack) {
|
|
90
150
|
if (!stack || (!stack.frameworks?.length && !stack.language && !stack.isFrontend && !stack.isBackend)) return [];
|
|
91
151
|
const lines = ["### Project Stack"];
|
|
@@ -195,6 +255,8 @@ export function deterministicContextHasFindings(ctx) {
|
|
|
195
255
|
if (ctx.sonarFindings?.qualityGate?.status === "ERROR") return true;
|
|
196
256
|
if (ctx.osvFindings?.available && (ctx.osvFindings.total ?? 0) > 0) return true;
|
|
197
257
|
if (ctx.semgrepFindings?.available && (ctx.semgrepFindings.total ?? 0) > 0) return true;
|
|
258
|
+
if (ctx.circularDeps?.available && (ctx.circularDeps.total ?? 0) > 0) return true;
|
|
259
|
+
if (ctx.deadExports?.available && (ctx.deadExports.total ?? 0) > 0) return true;
|
|
198
260
|
if (ctx.growthDelta && (Math.abs(ctx.growthDelta.lines || 0) > 100 || Math.abs(ctx.growthDelta.deps || 0) > 0)) return true;
|
|
199
261
|
return false;
|
|
200
262
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Generalised deterministic false-positive filter for kj audit collectors.
|
|
2
|
+
//
|
|
3
|
+
// Originally introduced as `src/sonar/issue-filter.js` in v2.16.0 (KJC-TSK-0416)
|
|
4
|
+
// for Sonar findings. Generalised here so every collector (sonar, knip, madge,
|
|
5
|
+
// osv, semgrep, …) can reuse the same two mechanisms:
|
|
6
|
+
//
|
|
7
|
+
// 1. Static rules: { tool, rule, filePattern, reason }. Match by tool name
|
|
8
|
+
// + rule id + path pattern. Catalogue below + extensible per project via
|
|
9
|
+
// config.audit.false_positives. Legacy `config.sonar.false_positives` is
|
|
10
|
+
// honoured for backwards compatibility.
|
|
11
|
+
// 2. Inline ignore: `// karajan-audit-ignore: <tool>:<ruleId>` on the issue
|
|
12
|
+
// line or the previous one. Legacy `// karajan-sonar-ignore: <ruleId>`
|
|
13
|
+
// keeps working when the issue's tool is "sonar".
|
|
14
|
+
//
|
|
15
|
+
// Issues filtered out are kept in the `suppressed` list with `_suppressedBy`
|
|
16
|
+
// metadata for auditability — no silent dropping.
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_FALSE_POSITIVES = [
|
|
21
|
+
{
|
|
22
|
+
tool: "sonar",
|
|
23
|
+
rule: "javascript:S2699",
|
|
24
|
+
filePattern: /tests\/architecture\//,
|
|
25
|
+
reason: "Structural tests assert via expect(offenders, msg).toEqual([]) — Sonar misses the custom-message form",
|
|
26
|
+
},
|
|
27
|
+
// Knip — v2.17 audit/dead-exports collector.
|
|
28
|
+
{
|
|
29
|
+
tool: "knip",
|
|
30
|
+
rule: "unused-files",
|
|
31
|
+
filePattern: /(?:^|\/)tests\/fixtures\//,
|
|
32
|
+
reason: "Test fixtures are loaded by file path at runtime, not statically imported — knip can't see them",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
tool: "knip",
|
|
36
|
+
rule: "unused-files",
|
|
37
|
+
filePattern: /(?:^|\/)examples\//,
|
|
38
|
+
reason: "Example projects are user-facing entry points, not consumed by the parent project's source",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
tool: "knip",
|
|
42
|
+
rule: "unused-exports",
|
|
43
|
+
filePattern: /(?:^|\/)(?:index|main)\.(?:js|ts|mjs|cjs|jsx|tsx)$/,
|
|
44
|
+
reason: "Barrel re-exports in index.{js,ts} are legitimate public API surface even when no in-tree caller uses them",
|
|
45
|
+
},
|
|
46
|
+
// Madge — v2.17 audit/circular-deps collector.
|
|
47
|
+
{
|
|
48
|
+
tool: "madge",
|
|
49
|
+
rule: "circular-import",
|
|
50
|
+
filePattern: /node_modules\//,
|
|
51
|
+
reason: "Cycles inside node_modules are upstream's problem, not ours — defensive default since madge excludes node_modules by config",
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export function hasInlineIgnore(filePath, line, ruleId, tool = "sonar") {
|
|
56
|
+
if (!filePath || !line || !ruleId) return false;
|
|
57
|
+
if (!fs.existsSync(filePath)) return false;
|
|
58
|
+
try {
|
|
59
|
+
const lines = fs.readFileSync(filePath, "utf8").split("\n");
|
|
60
|
+
const idx = line - 1;
|
|
61
|
+
const candidates = [lines[idx], lines[idx - 1]].filter(Boolean);
|
|
62
|
+
const escapedRule = escapeRegex(ruleId);
|
|
63
|
+
const escapedTool = escapeRegex(tool);
|
|
64
|
+
// New generalised marker + legacy sonar-only marker.
|
|
65
|
+
const reAudit = new RegExp(`karajan-audit-ignore\\s*:\\s*${escapedTool}:${escapedRule}\\b`);
|
|
66
|
+
const reLegacy = tool === "sonar"
|
|
67
|
+
? new RegExp(`karajan-sonar-ignore\\s*:\\s*${escapedRule}\\b`)
|
|
68
|
+
: null;
|
|
69
|
+
return candidates.some((l) => reAudit.test(l) || (reLegacy && reLegacy.test(l)));
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function escapeRegex(s) {
|
|
76
|
+
return String(s).replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Filter a list of normalised audit findings into kept + suppressed.
|
|
81
|
+
*
|
|
82
|
+
* @param {object[]} issues Each issue must carry { tool, rule, component|path, line? }.
|
|
83
|
+
* `tool` defaults to "sonar" for backwards compat.
|
|
84
|
+
* @param {object} [opts]
|
|
85
|
+
* @param {object[]} [opts.extraRules] Rules from config.audit.false_positives
|
|
86
|
+
* (or legacy config.sonar.false_positives).
|
|
87
|
+
* @param {string} [opts.projectRoot] Used to resolve component → absolute path.
|
|
88
|
+
* @returns {{ kept: object[], suppressed: object[] }}
|
|
89
|
+
*/
|
|
90
|
+
export function filterFalsePositives(issues, opts = {}) {
|
|
91
|
+
if (!Array.isArray(issues)) return { kept: [], suppressed: [] };
|
|
92
|
+
const rules = [...DEFAULT_FALSE_POSITIVES, ...(opts.extraRules || [])].map((r) => ({
|
|
93
|
+
tool: r.tool || "sonar",
|
|
94
|
+
rule: r.rule,
|
|
95
|
+
filePattern: r.filePattern instanceof RegExp ? r.filePattern : new RegExp(String(r.filePattern || ".*")),
|
|
96
|
+
reason: r.reason,
|
|
97
|
+
}));
|
|
98
|
+
const projectRoot = opts.projectRoot || process.cwd();
|
|
99
|
+
const kept = [];
|
|
100
|
+
const suppressed = [];
|
|
101
|
+
|
|
102
|
+
for (const issue of issues) {
|
|
103
|
+
const tool = issue.tool || "sonar";
|
|
104
|
+
const path = componentToPath(issue.component) || issue.path || "";
|
|
105
|
+
let matched = null;
|
|
106
|
+
|
|
107
|
+
// 1. Static rules.
|
|
108
|
+
for (const rule of rules) {
|
|
109
|
+
if (rule.tool === tool && issue.rule === rule.rule && rule.filePattern.test(path)) {
|
|
110
|
+
matched = { tool, rule: rule.rule, path, reason: rule.reason };
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 2. Inline ignore (only checked if no static rule matched).
|
|
116
|
+
if (!matched && issue.rule && issue.line && path) {
|
|
117
|
+
const absPath = path.startsWith("/") ? path : `${projectRoot}/${path}`;
|
|
118
|
+
if (hasInlineIgnore(absPath, issue.line, issue.rule, tool)) {
|
|
119
|
+
matched = { tool, rule: issue.rule, path, reason: "inline `// karajan-audit-ignore`" };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (matched) {
|
|
124
|
+
suppressed.push({ ...issue, _suppressedBy: matched });
|
|
125
|
+
} else {
|
|
126
|
+
kept.push(issue);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { kept, suppressed };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function componentToPath(component) {
|
|
133
|
+
if (typeof component !== "string") return "";
|
|
134
|
+
const colonIdx = component.indexOf(":");
|
|
135
|
+
return colonIdx >= 0 ? component.slice(colonIdx + 1) : component;
|
|
136
|
+
}
|