karajan-code 2.15.0 → 2.16.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 (36) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/src/agents/claude-agent.js +2 -2
  4. package/src/agents/codex-agent.js +1 -1
  5. package/src/audit/agent-readiness.js +1 -1
  6. package/src/audit/basal-cost.js +4 -4
  7. package/src/audit/webperf-input.js +1 -1
  8. package/src/brain/standby-store.js +1 -1
  9. package/src/canvas/parse-md.js +1 -1
  10. package/src/commands/audit.js +1 -1
  11. package/src/commands/plan/index.js +0 -5
  12. package/src/commands/plan.js +0 -1
  13. package/src/commands/webperf.js +1 -1
  14. package/src/domains/domain-synthesizer.js +1 -1
  15. package/src/git/hu-automation.js +2 -2
  16. package/src/git/hu-snapshot.js +1 -1
  17. package/src/hu/auto-generator.js +1 -1
  18. package/src/orchestrator/repair/repair-detector.js +3 -3
  19. package/src/plan/adr-generator.js +5 -5
  20. package/src/plan/plan-id.js +4 -4
  21. package/src/plan/plan-store.js +1 -1
  22. package/src/plan/plan-structural-pass.js +1 -1
  23. package/src/roles/perf-role.js +1 -1
  24. package/src/session/journal/decisions-writer.js +1 -1
  25. package/src/session/journal/iteration-logger.js +1 -1
  26. package/src/session/journal/summary-writer.js +1 -1
  27. package/src/skills/addyosmani-catalog.js +1 -1
  28. package/src/skills/semantic-detector.js +17 -2
  29. package/src/skills/skills-cache.js +1 -1
  30. package/src/sonar/api.js +22 -4
  31. package/src/sonar/config-resolver.js +1 -1
  32. package/src/sonar/issue-filter.js +110 -0
  33. package/src/sonar/scanner.js +1 -1
  34. package/src/utils/derive-project-name-from-cwd.js +3 -3
  35. package/src/utils/injection-guard.js +1 -1
  36. package/src/utils/messages.js +1 -1
package/README.md CHANGED
@@ -23,7 +23,9 @@
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.16.0 released** — 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
+ >
28
+ > **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
29
  >
28
30
  > **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
31
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.15.0",
3
+ "version": "2.16.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",
@@ -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) {
@@ -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;
@@ -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,27 @@ 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
+ const extraRules = config?.sonar?.false_positives || [];
99
+ const { kept, suppressed } = filterFalsePositives(parsedIssues, {
100
+ extraRules,
101
+ projectRoot: config?.projectDir,
102
+ });
103
+
104
+ return {
105
+ total: kept.length,
106
+ issues: kept,
107
+ suppressed,
108
+ raw: parsedRaw,
109
+ };
92
110
  }
@@ -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,110 @@
1
+ // KJC-TSK-0416: pre-filtro determinístico de falsos positivos Sonar.
2
+ //
3
+ // Antes de que los issues Sonar lleguen al coder como feedback (sonar-role)
4
+ // o al auditor (audit), filtramos los que sabemos que son falsos positivos
5
+ // por reglas conocidas. Sin esto el coder quemaba tokens intentando
6
+ // "arreglar" cosas que no están rotas.
7
+ //
8
+ // Dos mecanismos complementarios:
9
+ // 1. Rules estáticas: { rule, filePattern, reason } — match por reglaId
10
+ // + patrón de path. Cargadas por defecto del catálogo de FALSE_POSITIVES
11
+ // abajo + extensibles via config.sonar.false_positives.
12
+ // 2. Inline ignore: `// karajan-sonar-ignore: <ruleId>` en la línea del
13
+ // issue (o la anterior) → suprime ese hit exacto.
14
+
15
+ import fs from "node:fs";
16
+
17
+ // Catálogo built-in de patrones conocidos. Cada entrada:
18
+ // { rule: "javascript:S2699", filePattern: RegExp, reason: "..." }
19
+ // Ampliable via config.sonar.false_positives.
20
+ export const DEFAULT_FALSE_POSITIVES = [
21
+ {
22
+ rule: "javascript:S2699",
23
+ filePattern: /tests\/architecture\//,
24
+ reason: "Tests structurales fallan vía expect(offenders, msg).toEqual([]) — Sonar no detecta el assert con mensaje custom",
25
+ },
26
+ ];
27
+
28
+ /**
29
+ * Comprueba si una línea del file contiene `// karajan-sonar-ignore: <ruleId>`.
30
+ * Acepta la línea exacta del issue o la inmediatamente anterior.
31
+ *
32
+ * @param {string} filePath
33
+ * @param {number} line - 1-indexed
34
+ * @param {string} ruleId
35
+ * @returns {boolean}
36
+ */
37
+ export function hasInlineIgnore(filePath, line, ruleId) {
38
+ if (!filePath || !line || !ruleId) return false;
39
+ if (!fs.existsSync(filePath)) return false;
40
+ try {
41
+ const lines = fs.readFileSync(filePath, "utf8").split("\n");
42
+ const idx = line - 1;
43
+ const candidates = [lines[idx], lines[idx - 1]].filter(Boolean);
44
+ const re = new RegExp(`karajan-sonar-ignore\\s*:\\s*${escapeRegex(ruleId)}\\b`);
45
+ return candidates.some((l) => re.test(l));
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ function escapeRegex(s) {
52
+ return String(s).replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
53
+ }
54
+
55
+ /**
56
+ * Filtra issues de Sonar separando los reales de los falsos positivos.
57
+ *
58
+ * @param {object[]} issues - array de Sonar issues (shape: { rule, component, line, message, severity, ... })
59
+ * @param {object} [opts]
60
+ * @param {object[]} [opts.extraRules] - rules adicionales del config
61
+ * @param {string} [opts.projectRoot] - para resolver component → ruta absoluta
62
+ * @returns {{ kept: object[], suppressed: object[] }}
63
+ */
64
+ export function filterFalsePositives(issues, opts = {}) {
65
+ if (!Array.isArray(issues)) return { kept: [], suppressed: [] };
66
+ const rules = [...DEFAULT_FALSE_POSITIVES, ...(opts.extraRules || [])].map((r) => ({
67
+ ...r,
68
+ filePattern: r.filePattern instanceof RegExp ? r.filePattern : new RegExp(String(r.filePattern || ".*")),
69
+ }));
70
+ const projectRoot = opts.projectRoot || process.cwd();
71
+ const kept = [];
72
+ const suppressed = [];
73
+
74
+ for (const issue of issues) {
75
+ const path = componentToPath(issue.component);
76
+ let matched = null;
77
+
78
+ // 1. Rules estáticas.
79
+ for (const rule of rules) {
80
+ if (issue.rule === rule.rule && rule.filePattern.test(path)) {
81
+ matched = { rule: rule.rule, path, reason: rule.reason };
82
+ break;
83
+ }
84
+ }
85
+
86
+ // 2. Inline ignore (más caro — solo si la regla estática no matchea).
87
+ if (!matched && issue.rule && issue.line && path) {
88
+ const absPath = path.startsWith("/") ? path : `${projectRoot}/${path}`;
89
+ if (hasInlineIgnore(absPath, issue.line, issue.rule)) {
90
+ matched = { rule: issue.rule, path, reason: "inline `// karajan-sonar-ignore`" };
91
+ }
92
+ }
93
+
94
+ if (matched) {
95
+ suppressed.push({ ...issue, _suppressedBy: matched });
96
+ } else {
97
+ kept.push(issue);
98
+ }
99
+ }
100
+ return { kept, suppressed };
101
+ }
102
+
103
+ /**
104
+ * Sonar reporta component como `<projectKey>:<relativePath>`. Extrae el path.
105
+ */
106
+ function componentToPath(component) {
107
+ if (typeof component !== "string") return "";
108
+ const colonIdx = component.indexOf(":");
109
+ return colonIdx >= 0 ? component.slice(colonIdx + 1) : component;
110
+ }
@@ -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
  /**