karajan-code 2.15.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -1
  2. package/package.json +4 -2
  3. package/scripts/esbuild-sea.config.mjs +16 -1
  4. package/src/agents/claude-agent.js +2 -2
  5. package/src/agents/codex-agent.js +1 -1
  6. package/src/audit/agent-readiness.js +1 -1
  7. package/src/audit/basal-cost.js +4 -4
  8. package/src/audit/circular-deps.js +144 -0
  9. package/src/audit/dead-exports.js +186 -0
  10. package/src/audit/deterministic-summary.js +62 -0
  11. package/src/audit/issue-filter.js +136 -0
  12. package/src/audit/webperf-input.js +1 -1
  13. package/src/brain/standby-store.js +1 -1
  14. package/src/canvas/parse-md.js +1 -1
  15. package/src/commands/audit.js +1 -1
  16. package/src/commands/plan/index.js +0 -5
  17. package/src/commands/plan.js +0 -1
  18. package/src/commands/webperf.js +1 -1
  19. package/src/domains/domain-synthesizer.js +1 -1
  20. package/src/git/hu-automation.js +2 -2
  21. package/src/git/hu-snapshot.js +1 -1
  22. package/src/hu/auto-generator.js +1 -1
  23. package/src/orchestrator/repair/repair-detector.js +3 -3
  24. package/src/plan/adr-generator.js +5 -5
  25. package/src/plan/plan-id.js +4 -4
  26. package/src/plan/plan-store.js +1 -1
  27. package/src/plan/plan-structural-pass.js +1 -1
  28. package/src/prompts/audit.js +36 -1
  29. package/src/roles/audit-role.js +27 -3
  30. package/src/roles/perf-role.js +1 -1
  31. package/src/session/journal/decisions-writer.js +1 -1
  32. package/src/session/journal/iteration-logger.js +1 -1
  33. package/src/session/journal/summary-writer.js +1 -1
  34. package/src/skills/addyosmani-catalog.js +1 -1
  35. package/src/skills/semantic-detector.js +17 -2
  36. package/src/skills/skills-cache.js +1 -1
  37. package/src/sonar/api.js +26 -4
  38. package/src/sonar/config-resolver.js +1 -1
  39. package/src/sonar/issue-filter.js +12 -0
  40. package/src/sonar/scanner.js +1 -1
  41. package/src/utils/derive-project-name-from-cwd.js +3 -3
  42. package/src/utils/injection-guard.js +1 -1
  43. package/src/utils/messages.js +1 -1
package/README.md CHANGED
@@ -23,7 +23,11 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v2.15.0 released** — 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.
26
+ > **v2.17.0 released** — 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.
27
+ >
28
+ > **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.
29
+ >
30
+ > **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.
27
31
  >
28
32
  > **v2.14.2** — Patch. More dogfooding fallout from GRETA Plan 2 v2.14.1: the **▶ Run button** on the HU Board appeared on every `pending` card regardless of `blocked_by` (you could launch a HU whose deps don't exist yet — the user caught it instantly), and titles on the board lost their **`[EPICA]` prefix** at some point in v2.14.x evolution, making it impossible to tell at a glance which area of the plan a card belonged to. Fixes: `canRunHu` now requires `blockedBy.length === 0` before showing ▶; the planner prompt now demands `description: "[EPICA] one-sentence description"` with INFRA/SHARED fallbacks. Plus a new doc `docs/task-templates/spec-conventions.md` collecting the 6 SPEC conventions the planner v2.14.x understands (épicas, scope exclusions, transversal deps, reuse, async observers, explicit deps), so users don't have to rediscover them by dogfooding. 2 PRs (#687, #688). 4584/4584 tests passing. Safe upgrade from 2.14.1.
29
33
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.15.0",
3
+ "version": "2.17.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -24,7 +24,7 @@
24
24
  "cli"
25
25
  ],
26
26
  "engines": {
27
- "node": ">=20.10.0"
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
- external: ["better-sqlite3"],
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
  };
@@ -156,7 +156,7 @@ function summarizeToolCall(name, input = {}) {
156
156
  case "Glob": return `Glob ${input.pattern || ""}`.trim();
157
157
  case "Grep": return `Grep "${(input.pattern || "").slice(0, 60)}"${input.path ? ` in ${rel(input.path)}` : ""}`;
158
158
  case "Bash": {
159
- const cmd = String(input.command || "").replace(/\s+/g, " ").slice(0, 100);
159
+ const cmd = String(input.command || "").replaceAll(/\s+/g, " ").slice(0, 100);
160
160
  return `Bash $ ${cmd}`;
161
161
  }
162
162
  case "TodoWrite": return `Todo ${(input.todos || []).length} items`;
@@ -259,7 +259,7 @@ export function createStreamJsonFilter(onOutput) {
259
259
  } else if (block.type === "tool_use") {
260
260
  onOutput({ stream, line: summarizeToolCall(block.name, block.input), kind: "tool" });
261
261
  } else if (block.type === "thinking" && block.thinking) {
262
- onOutput({ stream, line: `thinking: ${block.thinking.slice(0, 80).replace(/\s+/g, " ")}…`, kind: "thinking" });
262
+ onOutput({ stream, line: `thinking: ${block.thinking.slice(0, 80).replaceAll(/\s+/g, " ")}…`, kind: "thinking" });
263
263
  }
264
264
  }
265
265
  return;
@@ -11,7 +11,7 @@ import { resolveBin } from "./resolve-bin.js";
11
11
  function extractCodexTokens(stdout) {
12
12
  const match = (stdout || "").match(/tokens?\s+used\s*\n\s*([\d,]+)/i);
13
13
  if (!match) return null;
14
- const total = Number(match[1].replace(/,/g, ""));
14
+ const total = Number(match[1].replaceAll(/,/g, ""));
15
15
  if (!Number.isFinite(total) || total <= 0) return null;
16
16
  return { tokens_in: 0, tokens_out: total };
17
17
  }
@@ -178,7 +178,7 @@ function safeRead(p) {
178
178
  * line counts keeps later byte-offset checks stable.
179
179
  */
180
180
  function stripFencedCodeBlocks(text) {
181
- return text.replace(/^([ \t]*)(```|~~~)[^\n]*\n([\s\S]*?)\n\1\2[^\n]*$/gm, (match) => {
181
+ return text.replaceAll(/^([ \t]*)(```|~~~)[^\n]*\n([\s\S]*?)\n\1\2[^\n]*$/gm, (match) => {
182
182
  const lineCount = match.split("\n").length;
183
183
  return "\n".repeat(lineCount - 1);
184
184
  });
@@ -18,7 +18,7 @@ const EXCLUDE_DIRS = new Set([
18
18
  ]);
19
19
 
20
20
  function slugify(projectDir) {
21
- return path.basename(projectDir).replace(/[^a-zA-Z0-9_-]/g, "_");
21
+ return path.basename(projectDir).replaceAll(/[^a-zA-Z0-9_-]/g, "_");
22
22
  }
23
23
 
24
24
  async function walkSourceFiles(dir) {
@@ -144,9 +144,9 @@ function resolveImportSpecifier(fromFile, specifier) {
144
144
  // need the actual path inside the quotes to resolve the target module.
145
145
  function stripStringLiterals(content) {
146
146
  return content
147
- .replace(/`[^`]*`/g, "``")
148
- .replace(/"(?:\\.|[^"\\])*"/g, '""')
149
- .replace(/'(?:\\.|[^'\\])*'/g, "''");
147
+ .replaceAll(/`[^`]*`/g, "``")
148
+ .replaceAll(/"(?:\\.|[^"\\])*"/g, '""')
149
+ .replaceAll(/'(?:\\.|[^'\\])*'/g, "''");
150
150
  }
151
151
 
152
152
  async function findDeadExports(sourceFiles) {
@@ -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
+ }
@@ -39,7 +39,7 @@ function webperfHome() {
39
39
  }
40
40
 
41
41
  function projectSlug(projectDir) {
42
- return path.basename(projectDir || process.cwd()).replace(/[^a-zA-Z0-9_-]/g, "_");
42
+ return path.basename(projectDir || process.cwd()).replaceAll(/[^a-zA-Z0-9_-]/g, "_");
43
43
  }
44
44
 
45
45
  /**
@@ -34,7 +34,7 @@ export function standbyDoneDir() {
34
34
 
35
35
  function sanitizeId(sessionId) {
36
36
  if (!sessionId || typeof sessionId !== "string") throw new Error("sessionId requerido");
37
- return sessionId.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120);
37
+ return sessionId.replaceAll(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120);
38
38
  }
39
39
 
40
40
  /**
@@ -218,7 +218,7 @@ function parseOperations(text) {
218
218
  if (testMatch && inAcceptance) {
219
219
  current.acceptance.push({
220
220
  type: testMatch[1].toLowerCase(),
221
- content: testMatch[2].replace(/^`+|`+$/g, "").trim(),
221
+ content: testMatch[2].replaceAll(/^`+|`+$/g, "").trim(),
222
222
  });
223
223
  }
224
224
  }
@@ -157,7 +157,7 @@ async function resolveReportFilePath(flagValue, isJson) {
157
157
 
158
158
  let resolved;
159
159
  if (isDir) {
160
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
160
+ const stamp = new Date().toISOString().replaceAll(/[:.]/g, "-");
161
161
  resolved = path.resolve(target, `audit-${stamp}.${ext}`);
162
162
  } else {
163
163
  resolved = path.resolve(target);
@@ -1,6 +1,4 @@
1
1
  // Barrel: re-export all plan sub-command handlers.
2
- import { planGenerateCommand } from "./generate.js";
3
-
4
2
  export { planGenerateCommand } from "./generate.js";
5
3
  export { planListCommand } from "./list.js";
6
4
  export { planShowCommand } from "./show.js";
@@ -11,6 +9,3 @@ export { planAddHuCommand } from "./add-hu.js";
11
9
  export { planRemoveHuCommand } from "./remove-hu.js";
12
10
  export { planMigrateResultCommand } from "./migrate-result.js";
13
11
  export { planFixCommand } from "./fix.js";
14
-
15
- // Keep backward compat — old callers import planCommand
16
- export const planCommand = planGenerateCommand;
@@ -12,5 +12,4 @@ export {
12
12
  planRemoveHuCommand,
13
13
  planMigrateResultCommand,
14
14
  planFixCommand,
15
- planCommand,
16
15
  } from "./plan/index.js";
@@ -26,7 +26,7 @@ function defaultReportDir() {
26
26
  * with audit history under ~/.karajan/.
27
27
  */
28
28
  function projectSlug(projectDir) {
29
- return path.basename(projectDir || process.cwd()).replace(/[^a-zA-Z0-9_-]/g, "_");
29
+ return path.basename(projectDir || process.cwd()).replaceAll(/[^a-zA-Z0-9_-]/g, "_");
30
30
  }
31
31
 
32
32
  function formatHumanReport(result) {
@@ -80,7 +80,7 @@ function buildKeywords(task, hints) {
80
80
 
81
81
  const taskWords = task
82
82
  .toLowerCase()
83
- .replace(/[^a-z0-9\s-]/g, " ")
83
+ .replaceAll(/[^a-z0-9\s-]/g, " ")
84
84
  .split(/\s+/)
85
85
  .filter(w => w.length > 3 && !stopWords.has(w));
86
86
 
@@ -14,8 +14,8 @@ import { runCommand } from "../utils/process.js";
14
14
  export function buildHuBranchName(prefix, story) {
15
15
  const baseSlug = String(story.title || story.id || "hu")
16
16
  .toLowerCase()
17
- .replace(/[^a-z0-9]+/g, "-")
18
- .replace(/^-+|-+$/g, "")
17
+ .replaceAll(/[^a-z0-9]+/g, "-")
18
+ .replaceAll(/^-+|-+$/g, "")
19
19
  .slice(0, 40);
20
20
  return `${prefix}${story.id}-${baseSlug}`;
21
21
  }
@@ -24,7 +24,7 @@ const REF_PREFIX = "refs/kj-snapshots/";
24
24
  */
25
25
  export function snapshotRefForHu(huId) {
26
26
  if (!huId) throw new Error("huId requerido");
27
- const safe = String(huId).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80);
27
+ const safe = String(huId).replaceAll(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80);
28
28
  return REF_PREFIX + safe;
29
29
  }
30
30
 
@@ -24,7 +24,7 @@ export function deriveProjectName(originalTask) {
24
24
  ]);
25
25
  const words = originalTask
26
26
  .toLowerCase()
27
- .replace(/[^\p{L}\p{N}\s-]/gu, " ")
27
+ .replaceAll(/[^\p{L}\p{N}\s-]/gu, " ")
28
28
  .split(/\s+/)
29
29
  .filter(w => w && !STOPWORDS.has(w));
30
30
  const meaningful = words.slice(0, 6);
@@ -50,9 +50,9 @@ const INFEASIBLE_SIGNATURES = [
50
50
  export function failureSignature(cmd, errorOutput) {
51
51
  const cmdNorm = String(cmd || "").trim().slice(0, 200);
52
52
  const errNorm = String(errorOutput || "")
53
- .replace(/\s+/g, " ") // collapse whitespace
54
- .replace(/\d+/g, "N") // numbers tend to drift; line numbers, counts
55
- .replace(/\/[^\s]+/g, "/PATH") // absolute paths drift across runs
53
+ .replaceAll(/\s+/g, " ") // collapse whitespace
54
+ .replaceAll(/\d+/g, "N") // numbers tend to drift; line numbers, counts
55
+ .replaceAll(/\/[^\s]+/g, "/PATH") // absolute paths drift across runs
56
56
  .trim()
57
57
  .slice(0, 300);
58
58
  return `${cmdNorm}|||${errNorm}`;
@@ -38,11 +38,11 @@ function slugifyTitle(title) {
38
38
  // Decompose accents (NFD) then strip combining marks so "versión"
39
39
  // → "version", keeping the ADR id ASCII-only and filesystem-safe.
40
40
  .normalize("NFD")
41
- .replace(/[̀-ͯ]/g, "")
42
- .replace(/[^a-z0-9\s-]/g, "")
43
- .replace(/\s+/g, "-")
44
- .replace(/-+/g, "-")
45
- .replace(/^-|-$/g, "")
41
+ .replaceAll(/[̀-ͯ]/g, "")
42
+ .replaceAll(/[^a-z0-9\s-]/g, "")
43
+ .replaceAll(/\s+/g, "-")
44
+ .replaceAll(/-+/g, "-")
45
+ .replaceAll(/^-|-$/g, "")
46
46
  .slice(0, 80) || "untitled";
47
47
  }
48
48
 
@@ -8,7 +8,7 @@
8
8
  */
9
9
  export function generatePlanId() {
10
10
  const now = new Date();
11
- const ts = now.toISOString().replace(/[-:T]/g, "").replace(/\.\d+Z$/, "");
11
+ const ts = now.toISOString().replaceAll(/[-:T]/g, "").replace(/\.\d+Z$/, "");
12
12
  const rand = Math.random().toString(36).slice(2, 6);
13
13
  return `plan-${ts}-${rand}`;
14
14
  }
@@ -36,10 +36,10 @@ export function normaliseAlias(name) {
36
36
  if (!name || typeof name !== "string") return null;
37
37
  const slug = name
38
38
  .normalize("NFKD")
39
- .replace(/[̀-ͯ]/g, "") // strip diacritics
39
+ .replaceAll(/[̀-ͯ]/g, "") // strip diacritics
40
40
  .toLowerCase()
41
- .replace(/[^a-z0-9]+/g, "-") // non-alphanum → guion
42
- .replace(/^-+|-+$/g, "") // trim guiones extremos
41
+ .replaceAll(/[^a-z0-9]+/g, "-") // non-alphanum → guion
42
+ .replaceAll(/^-+|-+$/g, "") // trim guiones extremos
43
43
  .slice(0, 60);
44
44
  return slug || null;
45
45
  }
@@ -50,7 +50,7 @@ export function projectSlug(projectDir) {
50
50
  return projectDir
51
51
  .replace(/^\//, "")
52
52
  .replace(/[/\\]/g, "_")
53
- .replace(/[^a-zA-Z0-9_.-]/g, "")
53
+ .replaceAll(/[^a-zA-Z0-9_.-]/g, "")
54
54
  .slice(0, 120);
55
55
  }
56
56
 
@@ -31,7 +31,7 @@ export function removeOrphanRefs(plan) {
31
31
  return fixes;
32
32
  }
33
33
 
34
- export function breakCycles(plan) {
34
+ function breakCycles(plan) {
35
35
  const fixes = [];
36
36
  let cycles = findCycles(plan.hus);
37
37
  let budget = plan.hus.length;
@@ -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
  }
@@ -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
- return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings };
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),
@@ -25,7 +25,7 @@ import { BaseRole } from "./base-role.js";
25
25
  import { runWebperfScan } from "../webperf/scanner.js";
26
26
 
27
27
  function projectSlug(projectDir) {
28
- return path.basename(projectDir || process.cwd()).replace(/[^a-zA-Z0-9_-]/g, "_");
28
+ return path.basename(projectDir || process.cwd()).replaceAll(/[^a-zA-Z0-9_-]/g, "_");
29
29
  }
30
30
 
31
31
  function defaultPersistDir() {
@@ -41,7 +41,7 @@ import { getSessionRoot } from "../../utils/paths.js";
41
41
  */
42
42
 
43
43
  function esc(text) {
44
- return String(text ?? "").replace(/\|/g, "\\|");
44
+ return String(text ?? "").replaceAll(/\|/g, "\\|");
45
45
  }
46
46
 
47
47
  function formatConditions(conditions) {
@@ -28,7 +28,7 @@
28
28
  */
29
29
 
30
30
  function esc(text) {
31
- return String(text ?? "").replace(/\|/g, "\\|");
31
+ return String(text ?? "").replaceAll(/\|/g, "\\|");
32
32
  }
33
33
 
34
34
  function formatDuration(ms) {
@@ -33,7 +33,7 @@ import path from "node:path";
33
33
  */
34
34
 
35
35
  function esc(text) {
36
- return String(text ?? "").replace(/\|/g, "\\|");
36
+ return String(text ?? "").replaceAll(/\|/g, "\\|");
37
37
  }
38
38
 
39
39
  function formatDuration(ms) {
@@ -279,7 +279,7 @@ export async function listAvailableSlugs() {
279
279
  */
280
280
  export async function loadSkillBySlug(slug) {
281
281
  if (!slug || typeof slug !== "string") return null;
282
- const safeSlug = slug.trim().replace(/[^A-Za-z0-9._-]/g, "");
282
+ const safeSlug = slug.trim().replaceAll(/[^A-Za-z0-9._-]/g, "");
283
283
  if (!safeSlug) return null;
284
284
 
285
285
  const skillPath = path.join(getSkillsRoot(), safeSlug, SKILL_FILE);
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { createAgent } from "../agents/index.js";
18
18
  import { resolveRole } from "../config.js";
19
+ import { withBrainRecovery } from "../brain/with-brain-recovery.js";
19
20
 
20
21
  /**
21
22
  * Curated catalog of skills the classifier is allowed to suggest. Keeping
@@ -106,15 +107,29 @@ export async function refineSkillsSemantically({ task, alreadyDetected = [], con
106
107
  }
107
108
 
108
109
  const prompt = PROMPT(task, SEMANTIC_CATALOG, alreadyDetected);
110
+ // KJC-TSK-0413 step D: wrap with Brain Recovery. semantic-detector usa
111
+ // signature legacy `runTask(prompt, opts)` — adapter normaliza al estándar
112
+ // `runTask({ prompt, timeoutMs })` que withBrainRecovery espera.
113
+ const adapter = {
114
+ provider: agent.provider,
115
+ runTask: (args) => agent.runTask(args.prompt, { timeoutMs: args.timeoutMs }),
116
+ };
109
117
  let res;
110
118
  try {
111
- res = await agent.runTask(prompt, { timeoutMs: 30_000 });
119
+ res = await withBrainRecovery({
120
+ agent: adapter,
121
+ taskArgs: { prompt, timeoutMs: 30_000 },
122
+ role: "semantic-detector",
123
+ logger,
124
+ });
112
125
  } catch (err) {
113
126
  logger?.warn?.(`semantic-detector: classifier call failed (${err.message}) — skipping`);
114
127
  return [];
115
128
  }
116
129
  if (!res?.ok) {
117
- logger?.debug?.("semantic-detector: classifier returned not-ok — skipping");
130
+ // En test env el sleep es no-op así que abort viene rápido. Skip-on-fail
131
+ // sigue siendo el comportamiento — semantic detection es best-effort.
132
+ logger?.debug?.(`semantic-detector: classifier returned not-ok (${res?.recovery?.class || "unknown"}) — skipping`);
118
133
  return [];
119
134
  }
120
135
 
@@ -24,7 +24,7 @@ export function getCacheRoot() {
24
24
 
25
25
  function sanitize(name) {
26
26
  // Allow safe skill name characters, replace everything else with "_".
27
- return String(name || "").replace(/[^A-Za-z0-9._-]/g, "_");
27
+ return String(name || "").replaceAll(/[^A-Za-z0-9._-]/g, "_");
28
28
  }
29
29
 
30
30
  function metaPath(name) {
package/src/sonar/api.js CHANGED
@@ -2,6 +2,7 @@ import { runCommand } from "../utils/process.js";
2
2
  import { withRetry } from "../utils/retry.js";
3
3
  import { resolveSonarProjectKey } from "./project-key.js";
4
4
  import { resolveSonarToken } from "./config-resolver.js";
5
+ import { filterFalsePositives } from "./issue-filter.js";
5
6
 
6
7
  class SonarApiError extends Error {
7
8
  constructor(message, { url, httpStatus, hint } = {}) {
@@ -83,10 +84,31 @@ export async function getOpenIssues(config, projectKey = null) {
83
84
  const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey });
84
85
  const body = await sonarFetch(config, `/api/issues/search?projectKeys=${effectiveProjectKey}&statuses=OPEN`);
85
86
 
87
+ let parsedIssues = [];
88
+ let parsedRaw = body;
86
89
  try {
87
90
  const parsed = JSON.parse(body);
88
- return { total: parsed.total || 0, issues: parsed.issues || [], raw: parsed };
89
- } catch { /* SonarQube response is not valid JSON */
90
- return { total: 0, issues: [], raw: body };
91
- }
91
+ parsedIssues = parsed.issues || [];
92
+ parsedRaw = parsed;
93
+ } catch { /* SonarQube response is not valid JSON */ }
94
+
95
+ // KJC-TSK-0416: pre-filtro de falsos positivos antes de devolver al
96
+ // caller. El coder, audit y scan consumen lo que pasa el filtro;
97
+ // suppressed se devuelve aparte para observabilidad.
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],
105
+ projectRoot: config?.projectDir,
106
+ });
107
+
108
+ return {
109
+ total: kept.length,
110
+ issues: kept,
111
+ suppressed,
112
+ raw: parsedRaw,
113
+ };
92
114
  }
@@ -16,7 +16,7 @@ const DEFAULT_HOST = "http://localhost:9000";
16
16
  */
17
17
  export function resolveSonarHost(rawHost) {
18
18
  const host = String(rawHost || DEFAULT_HOST)
19
- .replace(/host\.docker\.internal/g, "localhost")
19
+ .replaceAll(/host\.docker\.internal/g, "localhost")
20
20
  .replace(/\/+$/, "");
21
21
  return host;
22
22
  }
@@ -0,0 +1,12 @@
1
+ // @deprecated — moved to src/audit/issue-filter.js (v2.17.0). This shim
2
+ // re-exports the generalised filter so existing imports keep working. All
3
+ // Sonar-specific behaviour (default `tool="sonar"`, legacy
4
+ // `// karajan-sonar-ignore: <rule>` markers) is preserved by the new module.
5
+ //
6
+ // New code should import from "../audit/issue-filter.js" directly.
7
+
8
+ export {
9
+ filterFalsePositives,
10
+ hasInlineIgnore,
11
+ DEFAULT_FALSE_POSITIVES,
12
+ } from "../audit/issue-filter.js";
@@ -164,7 +164,7 @@ async function ensureSonarProjectProperties(cwd = process.cwd()) {
164
164
  } catch {
165
165
  // no package.json or invalid JSON — use defaults
166
166
  }
167
- const projectKey = (pkg.name || path.basename(cwd)).replace(/[^a-zA-Z0-9_.-]/g, "_");
167
+ const projectKey = (pkg.name || path.basename(cwd)).replaceAll(/[^a-zA-Z0-9_.-]/g, "_");
168
168
  const props = [
169
169
  `sonar.projectKey=${projectKey}`,
170
170
  `sonar.projectName=${pkg.name || path.basename(cwd)}`,
@@ -40,9 +40,9 @@ function prettifyName(raw) {
40
40
  if (!trimmed) return null;
41
41
  // Insert space at camelCase boundaries, then split on -/_/whitespace.
42
42
  const spaced = trimmed
43
- .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
44
- .replace(/[-_]+/g, " ")
45
- .replace(/\s+/g, " ")
43
+ .replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2")
44
+ .replaceAll(/[-_]+/g, " ")
45
+ .replaceAll(/\s+/g, " ")
46
46
  .trim();
47
47
  if (!spaced) return null;
48
48
  return spaced
@@ -178,7 +178,7 @@ export function scanDiff(diff, opts = {}) {
178
178
  function excerpt(text, index) {
179
179
  const start = Math.max(0, index - 20);
180
180
  const end = Math.min(text.length, index + 100);
181
- return text.slice(start, end).replace(/\n/g, "\\n").slice(0, 120);
181
+ return text.slice(start, end).replaceAll(/\n/g, "\\n").slice(0, 120);
182
182
  }
183
183
 
184
184
  function lineAt(text, index) {
@@ -48,7 +48,7 @@ const MESSAGES = {
48
48
  */
49
49
  export function msg(key, lang = "en", params = {}) {
50
50
  const template = MESSAGES[lang]?.[key] || MESSAGES.en[key] || key;
51
- return template.replace(/\{(\w+)\}/g, (_, k) => params[k] ?? `{${k}}`);
51
+ return template.replaceAll(/\{(\w+)\}/g, (_, k) => params[k] ?? `{${k}}`);
52
52
  }
53
53
 
54
54
  /**