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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/agents/claude-agent.js +2 -2
- package/src/agents/codex-agent.js +1 -1
- package/src/audit/agent-readiness.js +1 -1
- package/src/audit/basal-cost.js +4 -4
- package/src/audit/webperf-input.js +1 -1
- package/src/brain/standby-store.js +1 -1
- package/src/canvas/parse-md.js +1 -1
- package/src/commands/audit.js +1 -1
- package/src/commands/plan/index.js +0 -5
- package/src/commands/plan.js +0 -1
- package/src/commands/webperf.js +1 -1
- package/src/domains/domain-synthesizer.js +1 -1
- package/src/git/hu-automation.js +2 -2
- package/src/git/hu-snapshot.js +1 -1
- package/src/hu/auto-generator.js +1 -1
- package/src/orchestrator/repair/repair-detector.js +3 -3
- package/src/plan/adr-generator.js +5 -5
- package/src/plan/plan-id.js +4 -4
- package/src/plan/plan-store.js +1 -1
- package/src/plan/plan-structural-pass.js +1 -1
- package/src/roles/perf-role.js +1 -1
- package/src/session/journal/decisions-writer.js +1 -1
- package/src/session/journal/iteration-logger.js +1 -1
- package/src/session/journal/summary-writer.js +1 -1
- package/src/skills/addyosmani-catalog.js +1 -1
- package/src/skills/semantic-detector.js +17 -2
- package/src/skills/skills-cache.js +1 -1
- package/src/sonar/api.js +22 -4
- package/src/sonar/config-resolver.js +1 -1
- package/src/sonar/issue-filter.js +110 -0
- package/src/sonar/scanner.js +1 -1
- package/src/utils/derive-project-name-from-cwd.js +3 -3
- package/src/utils/injection-guard.js +1 -1
- package/src/utils/messages.js +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v2.
|
|
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
|
@@ -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 || "").
|
|
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).
|
|
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].
|
|
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.
|
|
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
|
});
|
package/src/audit/basal-cost.js
CHANGED
|
@@ -18,7 +18,7 @@ const EXCLUDE_DIRS = new Set([
|
|
|
18
18
|
]);
|
|
19
19
|
|
|
20
20
|
function slugify(projectDir) {
|
|
21
|
-
return path.basename(projectDir).
|
|
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
|
-
.
|
|
148
|
-
.
|
|
149
|
-
.
|
|
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()).
|
|
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.
|
|
37
|
+
return sessionId.replaceAll(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
package/src/canvas/parse-md.js
CHANGED
|
@@ -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].
|
|
221
|
+
content: testMatch[2].replaceAll(/^`+|`+$/g, "").trim(),
|
|
222
222
|
});
|
|
223
223
|
}
|
|
224
224
|
}
|
package/src/commands/audit.js
CHANGED
|
@@ -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().
|
|
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;
|
package/src/commands/plan.js
CHANGED
package/src/commands/webperf.js
CHANGED
|
@@ -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()).
|
|
29
|
+
return path.basename(projectDir || process.cwd()).replaceAll(/[^a-zA-Z0-9_-]/g, "_");
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function formatHumanReport(result) {
|
package/src/git/hu-automation.js
CHANGED
|
@@ -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
|
-
.
|
|
18
|
-
.
|
|
17
|
+
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
18
|
+
.replaceAll(/^-+|-+$/g, "")
|
|
19
19
|
.slice(0, 40);
|
|
20
20
|
return `${prefix}${story.id}-${baseSlug}`;
|
|
21
21
|
}
|
package/src/git/hu-snapshot.js
CHANGED
|
@@ -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).
|
|
27
|
+
const safe = String(huId).replaceAll(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80);
|
|
28
28
|
return REF_PREFIX + safe;
|
|
29
29
|
}
|
|
30
30
|
|
package/src/hu/auto-generator.js
CHANGED
|
@@ -24,7 +24,7 @@ export function deriveProjectName(originalTask) {
|
|
|
24
24
|
]);
|
|
25
25
|
const words = originalTask
|
|
26
26
|
.toLowerCase()
|
|
27
|
-
.
|
|
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
|
-
.
|
|
54
|
-
.
|
|
55
|
-
.
|
|
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
|
-
.
|
|
42
|
-
.
|
|
43
|
-
.
|
|
44
|
-
.
|
|
45
|
-
.
|
|
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
|
|
package/src/plan/plan-id.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export function generatePlanId() {
|
|
10
10
|
const now = new Date();
|
|
11
|
-
const ts = now.toISOString().
|
|
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
|
-
.
|
|
39
|
+
.replaceAll(/[̀-ͯ]/g, "") // strip diacritics
|
|
40
40
|
.toLowerCase()
|
|
41
|
-
.
|
|
42
|
-
.
|
|
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
|
}
|
package/src/plan/plan-store.js
CHANGED
package/src/roles/perf-role.js
CHANGED
|
@@ -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()).
|
|
28
|
+
return path.basename(projectDir || process.cwd()).replaceAll(/[^a-zA-Z0-9_-]/g, "_");
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function defaultPersistDir() {
|
|
@@ -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().
|
|
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
|
|
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
|
-
|
|
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 || "").
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
.
|
|
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
|
+
}
|
package/src/sonar/scanner.js
CHANGED
|
@@ -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)).
|
|
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
|
-
.
|
|
44
|
-
.
|
|
45
|
-
.
|
|
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).
|
|
181
|
+
return text.slice(start, end).replaceAll(/\n/g, "\\n").slice(0, 120);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
function lineAt(text, index) {
|
package/src/utils/messages.js
CHANGED
|
@@ -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.
|
|
51
|
+
return template.replaceAll(/\{(\w+)\}/g, (_, k) => params[k] ?? `{${k}}`);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/**
|