karajan-code 4.15.0 → 4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.15.0",
3
+ "version": "4.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",
@@ -8,7 +8,14 @@ import { homedir } from "node:os";
8
8
  import { dirname, join } from "node:path";
9
9
 
10
10
  export const DEFAULT_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
11
- export const HOOK_COMMAND = "kj-trash hook";
11
+ // KJC-BUG-0095: the bare command fails SILENTLY when the bin drops off PATH
12
+ // (nvm switch, global reinstall) — the anti-delete net goes down without
13
+ // notice. The installed line fails CLOSED: a missing bin blocks the tool
14
+ // call (exit 2) naming the net and the remedy, never a quiet pass-through.
15
+ export const LEGACY_HOOK_COMMAND = "kj-trash hook";
16
+ export const HOOK_COMMAND =
17
+ "if command -v kj-trash >/dev/null 2>&1; then exec kj-trash hook; fi; " +
18
+ "echo 'kj-trash: bin not on PATH — the anti-delete net is DOWN. Fix: npm i -g karajan-code (or remove this hook from ~/.claude/settings.json).' >&2; exit 2";
12
19
  const MATCHER = "Bash";
13
20
 
14
21
  async function readSettings(path) {
@@ -34,6 +41,20 @@ export function patchSettings(settings, command = HOOK_COMMAND, matcher = MATCHE
34
41
  entry = { matcher, hooks: [{ type: "command", command }] };
35
42
  preToolUse.push(entry);
36
43
  mutated = true;
44
+ } else if (hasHookCommand(entry, LEGACY_HOOK_COMMAND) && command !== LEGACY_HOOK_COMMAND) {
45
+ // KJC-BUG-0095 migration: upgrade the silent legacy line in place —
46
+ // deduped (reviewer catch): an entry already carrying the new line
47
+ // must end up with a single copy, not legacy-turned-duplicate.
48
+ let seen = false;
49
+ entry.hooks = entry.hooks
50
+ .map((h) => (h.type === "command" && h.command === LEGACY_HOOK_COMMAND ? { ...h, command } : h))
51
+ .filter((h) => {
52
+ if (!(h.type === "command" && h.command === command)) return true;
53
+ if (seen) return false;
54
+ seen = true;
55
+ return true;
56
+ });
57
+ mutated = true;
37
58
  } else if (!hasHookCommand(entry, command)) {
38
59
  entry.hooks = [...(entry.hooks ?? []), { type: "command", command }];
39
60
  mutated = true;
@@ -208,7 +208,17 @@ try {
208
208
  // better-sqlite3's native build by default, so DB-backed commands need
209
209
  // `pnpm approve-builds`; that's surfaced as a doctor warning, KJC-BUG-0092 —
210
210
  // not a gate failure, since it's inherent to pnpm.) Auto-skips without pnpm.
211
- const hasPnpm = spawnSync("pnpm", ["--version"], { encoding: "utf8", env: childEnv }).status === 0;
211
+ // KJC-BUG-0135: `npm run` leaks npm_config_* into the child env, and pnpm
212
+ // maps those variables onto its own config — which can re-anchor it to THIS
213
+ // repo (7-aug incident: the repo's node_modules converted to pnpm layout,
214
+ // native bindings wiped, and simple-git-hooks' hook overwrote
215
+ // .karajan/hooks). The smoke runs with a scrubbed env and a pinned --dir,
216
+ // and the tripwire below turns any host contamination into a loud gate
217
+ // failure instead of a silent green.
218
+ const pnpmEnv = Object.fromEntries(Object.entries(childEnv).filter(([k]) => !/^npm_config_/i.test(k)));
219
+ const HOST_MARKERS = ["pnpm-lock.yaml", "pnpm-workspace.yaml", path.join("node_modules", ".pnpm")];
220
+ const hostMarkersBefore = new Set(HOST_MARKERS.filter((m) => fs.existsSync(path.join(repoRoot, m))));
221
+ const hasPnpm = spawnSync("pnpm", ["--version"], { encoding: "utf8", env: pnpmEnv }).status === 0;
212
222
  if (!hasPnpm) {
213
223
  console.log("verify-pack: pnpm not installed — skipping pnpm smoke (npm path covered above) ⚠");
214
224
  } else {
@@ -224,11 +234,11 @@ try {
224
234
  // versions (supply-chain protection), so right after publishing
225
235
  // karajan-core it silently resolves an OLD one and this smoke fails on
226
236
  // missing subpaths. The gate verifies packaging, not release-age policy.
227
- spawnSync("pnpm", ["add", tgzPath, "--store-dir", path.join(pnpmTmp, ".store"), "--config.minimum-release-age=0"], {
228
- encoding: "utf8",
229
- env: childEnv,
230
- cwd: pnpmTmp,
231
- });
237
+ spawnSync(
238
+ "pnpm",
239
+ ["add", tgzPath, "--dir", pnpmTmp, "--ignore-workspace", "--store-dir", path.join(pnpmTmp, ".store"), "--config.minimum-release-age=0"],
240
+ { encoding: "utf8", env: pnpmEnv, cwd: pnpmTmp },
241
+ );
232
242
  const pnpmBin = path.join(pnpmTmp, "node_modules", ".bin", "kj");
233
243
  if (!fs.existsSync(pnpmBin)) fail(`kj binary missing after pnpm install: ${pnpmBin}`);
234
244
  let pnpmVersion;
@@ -242,6 +252,18 @@ try {
242
252
  }
243
253
  console.log(`verify-pack: pnpm install + kj --version → ${pnpmVersion} ✓ (DB build needs \`pnpm approve-builds\`)`);
244
254
  }
255
+ // Tripwire (KJC-BUG-0135): the smoke must NEVER touch the host repo. If a
256
+ // pnpm artifact appeared at the repo root during this run, the gate fails
257
+ // naming it — never a green over a contaminated tree.
258
+ const contaminated = HOST_MARKERS.filter(
259
+ (m) => !hostMarkersBefore.has(m) && fs.existsSync(path.join(repoRoot, m)),
260
+ );
261
+ if (contaminated.length > 0) {
262
+ fail(
263
+ `pnpm smoke contaminated the host repo: ${contaminated.join(", ")} appeared at the repo root`,
264
+ "remove the listed files and run `npm ci` to restore the npm layout; then verify .karajan/hooks with `git status`",
265
+ );
266
+ }
245
267
 
246
268
  console.log(`\n✓ verify-pack: karajan-code@${expectedVersion} installs clean and runs.`);
247
269
  } finally {
@@ -60,7 +60,7 @@ export class BaseAgent {
60
60
  // a PATH is a partial overlay and merges over the inherited env first,
61
61
  // so an overlay caller can never cost the child PATH/HOME.
62
62
  const complete = options.env && ("PATH" in options.env || "Path" in options.env);
63
- const base = complete ? options.env : { ...process.env, ...(options.env || {}) };
63
+ const base = complete ? options.env : { ...process.env, ...options.env };
64
64
  options = {
65
65
  ...options,
66
66
  env: buildAgentEnv(base, {
@@ -28,9 +28,7 @@ async function genericChecks(projectDir) {
28
28
  checks.push({ name: "manifest", ok: Boolean(version), detail: version ? `version ${version}` : "package.json has no version" });
29
29
 
30
30
  const clPath = join(projectDir, "CHANGELOG.md");
31
- if (!existsSync(clPath)) {
32
- checks.push({ name: "changelog-current", ok: false, detail: "no CHANGELOG.md — the release story must exist before the release" });
33
- } else {
31
+ if (existsSync(clPath)) {
34
32
  const cl = readFileSync(clPath, "utf8");
35
33
  const topVersioned = (cl.match(/^## \[(\d+\.\d+\.\d+)\]/m) || [])[1] || null;
36
34
  // Content still sitting under [Unreleased] means unpromoted entries —
@@ -39,25 +37,20 @@ async function genericChecks(projectDir) {
39
37
  const unreleasedBody = afterUnreleased.split(/^## \[/m)[0] ?? "";
40
38
  const pending = /\S/.test(unreleasedBody);
41
39
  const ok = topVersioned === version && !pending;
42
- checks.push({
43
- name: "changelog-current",
44
- ok,
45
- detail: ok
46
- ? `top section is [${version}], Unreleased empty`
47
- : pending
48
- ? `[Unreleased] still carries content — promote it into [${version}] before releasing`
49
- : `top CHANGELOG section is [${topVersioned ?? "none"}] but the manifest says ${version} — promote Unreleased before releasing`,
50
- });
40
+ let detail = `top section is [${version}], Unreleased empty`;
41
+ if (pending) detail = `[Unreleased] still carries content — promote it into [${version}] before releasing`;
42
+ else if (!ok) detail = `top CHANGELOG section is [${topVersioned ?? "none"}] but the manifest says ${version} — promote Unreleased before releasing`;
43
+ checks.push({ name: "changelog-current", ok, detail });
44
+ } else {
45
+ checks.push({ name: "changelog-current", ok: false, detail: "no CHANGELOG.md — the release story must exist before the release" });
51
46
  }
52
47
 
53
48
  const tagsOut = await runCommand("git", ["-C", projectDir, "tag", "--list", "v[0-9]*"]);
54
49
  const tags = (tagsOut.stdout || "").split("\n").map((t) => t.trim().replace(/^v/, "")).filter((t) => /^\d+\.\d+\.\d+$/.test(t));
55
50
  const ahead = version ? tags.filter((t) => semverCmp(t, version) > 0) : [];
56
- checks.push({
57
- name: "tags",
58
- ok: ahead.length === 0,
59
- detail: ahead.length ? `tag(s) ahead of the manifest exist: v${ahead.join(", v")}` : (tags.includes(version) ? `v${version} already tagged` : `tag v${version} pending (created after publish)`),
60
- });
51
+ let tagDetail = tags.includes(version) ? `v${version} already tagged` : `tag v${version} pending (created after publish)`;
52
+ if (ahead.length > 0) tagDetail = `tag(s) ahead of the manifest exist: v${ahead.join(", v")}`;
53
+ checks.push({ name: "tags", ok: ahead.length === 0, detail: tagDetail });
61
54
  return { checks, version, pkg };
62
55
  }
63
56
 
@@ -62,7 +62,7 @@ export async function envInstallCommand({ config = null, logger = null, flags =
62
62
  // The effective backend feeds the render on EVERY non-error path —
63
63
  // chosen, declared-in-file or default — so the playbook never falls
64
64
  // back to hu-board while the selection said otherwise.
65
- config = { ...config, state_backend: sel.backend, board: sel.boardName ? { ...(config?.board || {}), name: sel.boardName } : config?.board };
65
+ config = { ...config, state_backend: sel.backend, board: sel.boardName ? { ...config?.board, name: sel.boardName } : config?.board };
66
66
  } catch (err) {
67
67
  console.log(`⚠ board selection failed (${err.message}) — continuing with the configured default`);
68
68
  }
@@ -184,7 +184,10 @@ export async function hardenCommand({
184
184
  const verb = dryRun ? "would install" : "installed";
185
185
  logger.info?.(`kj harden (${profile}) — ${verb} ${result.hooks.length} hook(s) → ${result.hooksPath}`);
186
186
  for (const h of result.hooks) logger.info?.(` • ${h.hook}: ${h.action}`);
187
- for (const c of out.configs) logger.info?.(` • ${c.file}: ${c.action}${c.note ? ` (${c.note})` : ""}`);
187
+ for (const c of out.configs) {
188
+ const note = c.note ? ` (${c.note})` : "";
189
+ logger.info?.(` • ${c.file}: ${c.action}${note}`);
190
+ }
188
191
  for (const w of out.workflows) logger.info?.(` • ${w.file}: ${w.action}`);
189
192
  for (const g of out.guidelines) logger.info?.(` • ${g.file}: ${g.action}`);
190
193
  if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check`.");
@@ -98,7 +98,8 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
98
98
  // KJC-TSK-0697 — `--library` targets the distilled-canon collection:
99
99
  // its own project namespace plus the kind filter.
100
100
  const library = Boolean(flags.library);
101
- const project = library ? LIBRARY_PROJECT : (flags.project === "all" ? null : (flags.project || detected || null));
101
+ let project = flags.project === "all" ? null : (flags.project || detected || null);
102
+ if (library) project = LIBRARY_PROJECT;
102
103
  // KJC-BUG-0061 follow-up: align the CLI `--json` shape with the MCP
103
104
  // handler. The MCP tool responds `{ hits: [], empty: true, topK, scope }`
104
105
  // so agents (and the `/kj-rag-query` skill from Camino B) have a
@@ -115,9 +116,8 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
115
116
  // Safe by grammar: parseWhere (core where-parser.js) accepts ONLY
116
117
  // AND-joined key=value pairs — an OR or parens never parses, so the
117
118
  // composed clause cannot leak past the kind filter; it fails loudly.
118
- const where = library
119
- ? (flags.where ? `kind=library AND ${flags.where}` : "kind=library")
120
- : (flags.where || null);
119
+ let where = flags.where || null;
120
+ if (library) where = flags.where ? `kind=library AND ${flags.where}` : "kind=library";
121
121
  const rerankOpts = flags.rerank ? { model: flags.rerankModel } : null;
122
122
  const hits = await query(db, makeGovernedEmbedder(config), text, { topK, scope, project, mode, alpha, where, rerankOpts });
123
123
  if (flags.json) {
@@ -26,6 +26,9 @@ async function enforceCardFirst({ config, projectDir }) {
26
26
  const branch = branchRes.stdout?.trim() || "HEAD";
27
27
  const card = await checkCardFirst({ config, projectDir, branch });
28
28
  if (card.mode === "warn") console.log(`⚠ card-first: ${card.reason}`);
29
+ // KJC-TSK-0732: the verification LEVEL is part of the verdict — a pass on
30
+ // branch-ref alone must never read as a tracker-verified pass.
31
+ if (card.note) console.log(`⚠ card-first: ${card.note}`);
29
32
  if (card.mode === "exempt" && card.reason.includes("KJ_ALLOW_NO_CARD")) console.log(`⚠ card-first exempt: ${card.reason}`);
30
33
  if (!card.ok) {
31
34
  console.log(`✗ card-first gate: ${card.reason}`);
@@ -66,7 +66,9 @@ const DEFAULTS = {
66
66
  // through the host agent's MCP/tools, never mirrored by kj. There is NO
67
67
  // "none": Karajan does not run without a board.
68
68
  state_backend: "hu-board",
69
- board: { name: null },
69
+ // verify_cmd (KJC-TSK-0732): user adapter that checks a card ref against
70
+ // the real tracker — prints {exists, live, status} JSON; {ref} substituted.
71
+ board: { name: null, verify_cmd: null },
70
72
  // Method gates (KJC-PCS-0068): rules climb from playbook text to
71
73
  // deterministic enforcement. card_first: "auto" = block with hu-board
72
74
  // (fully verifiable), warn with planning-game/external (presence only);
@@ -53,6 +53,7 @@ export async function pickStateBackend({ projectDir = process.cwd(), home = os.h
53
53
  const backend = external ? "external" : value;
54
54
  const boardName = external ? value.slice("external:".length) : null;
55
55
  await persistStateBackend(projectDir, backend, boardName);
56
- logger.info?.(`✓ board: ${backend}${boardName ? ` (${boardName})` : ""} — persisted in .karajan/kj.config.yml`);
56
+ const suffix = boardName ? ` (${boardName})` : "";
57
+ logger.info?.(`✓ board: ${backend}${suffix} — persisted in .karajan/kj.config.yml`);
57
58
  return { backend, boardName, source: "chosen" };
58
59
  }
@@ -109,7 +109,7 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
109
109
  "# POSIX-safe extraction (no grep -o): the ref is the leading",
110
110
  "# card-shaped token of the branch's last segment (feat/REF-desc).",
111
111
  "seg=$(printf '%s' \"$branch\" | tr '[:upper:]' '[:lower:]'); seg=\"${seg##*/}\"",
112
- "ref=$(printf '%s' \"$seg\" | sed -nE 's/^([a-z][a-z0-9]{1,9}(-[a-z][a-z0-9]{1,9})*-[0-9]{1,6})(-.*)?$/\\1/p')",
112
+ String.raw`ref=$(printf '%s' "$seg" | sed -nE 's/^([a-z][a-z0-9]{1,9}(-[a-z][a-z0-9]{1,9})*-[0-9]{1,6})(-.*)?$/\1/p')`,
113
113
  'header=$(head -n1 "$msg_file")',
114
114
  'if [ -n "$ref" ] && [ -n "$header" ]; then',
115
115
  ' case "$header" in',
@@ -122,7 +122,7 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
122
122
  " stamp=$(printf '%s' \"$ref\" | tr '[:lower:]' '[:upper:]')",
123
123
  ' new_header="$header ($stamp)"',
124
124
  ' if [ "${#new_header}" -le 100 ]; then',
125
- " { printf '%s\\n' \"$new_header\"; tail -n +2 \"$msg_file\"; } > \"$msg_file.kjtmp\" && mv \"$msg_file.kjtmp\" \"$msg_file\"",
125
+ String.raw` { printf '%s\n' "$new_header"; tail -n +2 "$msg_file"; } > "$msg_file.kjtmp" && mv "$msg_file.kjtmp" "$msg_file"`,
126
126
  " fi",
127
127
  " fi",
128
128
  " ;;",
@@ -26,6 +26,17 @@ const ALLOWED_COMMANDS = [
26
26
  "bundle install", "composer install", "dotnet restore"
27
27
  ];
28
28
 
29
+ // KJC-BUG-0099: a dependency install must not be arbitrary code execution —
30
+ // lifecycle hooks (postinstall & friends) run whatever the dependency ships.
31
+ // The allow-list above names the INTENT; execution appends the ecosystem's
32
+ // script-skipping flag. Ecosystems without package hooks run untouched.
33
+ const HARDEN_FLAGS = {
34
+ npm: ["--ignore-scripts"],
35
+ pnpm: ["--ignore-scripts"],
36
+ yarn: ["--ignore-scripts"],
37
+ composer: ["--no-scripts", "--no-plugins"],
38
+ };
39
+
29
40
  /**
30
41
  * True when `target` resolves to a path inside `base` (or is `base` itself).
31
42
  * Uses path.relative instead of a string prefix check: a naive
@@ -64,7 +75,7 @@ async function runCommand({ cmd, cwd }) {
64
75
  // Even though `cmd` is allow-listed, defense-in-depth: switched to
65
76
  // execFileSync with a tokenised arg array so no shell expansion at all.
66
77
  const [program, ...args] = cmd.trim().split(/\s+/);
67
- const output = execFileSync(program, args, {
78
+ const output = execFileSync(program, [...args, ...(HARDEN_FLAGS[program] ?? [])], {
68
79
  cwd: cwd || process.cwd(),
69
80
  encoding: "utf8",
70
81
  stdio: ["pipe", "pipe", "pipe"],
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { listPlans, loadPlan } from "../plan/plan-store.js";
10
10
  import { escapeRegExp } from "../utils/escape-regexp.js";
11
+ import { verifyCardAlive } from "./card-verify.js";
11
12
 
12
13
  const LIVE_STATUSES = new Set(["pending", "running", "failed"]);
13
14
  // Card-shaped reference: LIN-123, bb-002 and multi-segment ids like
@@ -44,7 +45,7 @@ async function findLiveCardInBranch(projectDir, branch) {
44
45
  return closedHit;
45
46
  }
46
47
 
47
- export async function checkCardFirst({ config = {}, projectDir = process.cwd(), branch, env = process.env }) {
48
+ export async function checkCardFirst({ config = {}, projectDir = process.cwd(), branch, env = process.env, deps = {} }) {
48
49
  if (env.KJ_ALLOW_NO_CARD === "1") {
49
50
  return { ok: true, mode: "exempt", reason: "KJ_ALLOW_NO_CARD=1 (explicit escape hatch)" };
50
51
  }
@@ -73,11 +74,24 @@ export async function checkCardFirst({ config = {}, projectDir = process.cwd(),
73
74
  : { ok: true, mode: "warn", reason };
74
75
  }
75
76
 
76
- // planning-game / external: presence check only — liveness lives in the
77
- // project's own board, out of local reach.
78
- if (CARD_REF_RE.test(branch)) return { ok: true, mode: "pass", ref: branch.match(CARD_REF_RE)[0] };
79
- const effective = policy === "auto" ? "warn" : policy;
77
+ // planning-game / external: presence check, upgraded to LIVENESS against
78
+ // the real tracker when board.verify_cmd declares an adapter
79
+ // (KJC-TSK-0732, issue #1371). Unverifiable degrades honestly — the
80
+ // result names its level so nobody mistakes branch-ref for tracker.
80
81
  const boardName = config.board?.name || backend;
82
+ const effective = policy === "auto" ? "warn" : policy;
83
+ if (CARD_REF_RE.test(branch)) {
84
+ const ref = branch.match(CARD_REF_RE)[0];
85
+ const { verifyFn = verifyCardAlive } = deps;
86
+ const v = await verifyFn({ ref, verifyCmd: config.board?.verify_cmd, projectDir });
87
+ if (v.verified === false) {
88
+ const reason = `branch references ${ref} but ${boardName} reports it "${v.status}" — card-first needs a live card in the tracker (create/reopen it there)`;
89
+ return effective === "block"
90
+ ? { ok: false, mode: "block", reason, level: v.level }
91
+ : { ok: true, mode: "warn", reason, level: v.level };
92
+ }
93
+ return { ok: true, mode: "pass", ref, level: v.level, ...(v.note ? { note: v.note } : {}) };
94
+ }
81
95
  const reason = `no card reference in branch "${branch}" — card-first on ${boardName}: create the card there and name the branch after it (e.g. feat/ABC-123-summary)`;
82
96
  return effective === "block"
83
97
  ? { ok: false, mode: "block", reason }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * card-verify — KJC-TSK-0732 (issue #1371). With a declared tracker,
3
+ * card-first upgrades "the branch names a card" to "the tracker says that
4
+ * card is ALIVE". Transport-agnostic: `board.verify_cmd` is the USER'S
5
+ * adapter (their CLI/MCP wrapper — credentials never enter kj) printing
6
+ * {exists, live, status?} JSON on stdout. Unverifiable NEVER blocks: it
7
+ * degrades to branch-ref level SAYING SO — a gate must not claim a check
8
+ * it didn't make, in either direction. Definitive verdicts are cached
9
+ * briefly; degradations never are, so recovery is immediate.
10
+ */
11
+ import { exec } from "node:child_process";
12
+ import { createHash } from "node:crypto";
13
+ import { promisify } from "node:util";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { extractFirstJson } from "../utils/json-extract.js";
17
+
18
+ const execAsync = promisify(exec);
19
+ export const VERIFY_TIMEOUT_MS = 4000;
20
+ const CACHE_TTL_MS = 10 * 60 * 1000;
21
+ // The ref lands inside a shell command (the user's adapter template), so it
22
+ // must be inert BY CONSTRUCTION — card-ref alphabet only, anchored (codex
23
+ // catch: same injection class as the tournament coder names).
24
+ const SAFE_REF_RE = /^[a-z0-9][a-z0-9-]{0,63}$/i;
25
+
26
+ const defaultExec = async (cmd, { cwd }) =>
27
+ execAsync(cmd, { cwd, timeout: VERIFY_TIMEOUT_MS, maxBuffer: 1024 * 1024 });
28
+
29
+ function cachePath(projectDir) {
30
+ return path.join(projectDir, ".kj", "cache", "card-verify.json");
31
+ }
32
+
33
+ /** @returns {Promise<{level: "tracker"|"branch-ref", verified: boolean|null, status?: string|null, note?: string}>}
34
+ * verified: true = alive · false = definitively not · null = degraded. */
35
+ export async function verifyCardAlive({ ref, verifyCmd, projectDir = process.cwd(), deps = {} }) {
36
+ const { execFn = defaultExec, now = Date.now } = deps;
37
+ if (!verifyCmd) {
38
+ return {
39
+ level: "branch-ref", verified: null,
40
+ note: "card-first checked the branch reference only — set board.verify_cmd to verify the card against your tracker",
41
+ };
42
+ }
43
+
44
+ if (!SAFE_REF_RE.test(String(ref))) {
45
+ return {
46
+ level: "branch-ref", verified: null,
47
+ note: `tracker verification skipped — ref "${String(ref).slice(0, 30)}" is outside the card-ref alphabet`,
48
+ };
49
+ }
50
+ let cache = {};
51
+ try {
52
+ cache = JSON.parse(fs.readFileSync(cachePath(projectDir), "utf8"));
53
+ } catch { /* no cache yet */ }
54
+ // Keyed by adapter AND ref (reviewer catch): a verdict from one
55
+ // verify_cmd must never answer for a different one.
56
+ const key = `${createHash("sha256").update(verifyCmd).digest("hex").slice(0, 12)}:${ref}`;
57
+ const hit = cache[key];
58
+ if (hit && now() - hit.at < CACHE_TTL_MS) return hit.result;
59
+
60
+ const cmd = verifyCmd.includes("{ref}") ? verifyCmd.replaceAll("{ref}", ref) : `${verifyCmd} ${ref}`;
61
+ try {
62
+ const { stdout } = await execFn(cmd, { cwd: projectDir });
63
+ const parsed = extractFirstJson(String(stdout));
64
+ if (!parsed || typeof parsed.exists !== "boolean") {
65
+ throw new Error("adapter must print JSON {exists, live, status} on stdout");
66
+ }
67
+ const alive = parsed.exists && parsed.live !== false;
68
+ const result = alive
69
+ ? { level: "tracker", verified: true, status: parsed.status ?? null }
70
+ : { level: "tracker", verified: false, status: parsed.status ?? (parsed.exists ? "not-live" : "not-found") };
71
+ try {
72
+ fs.mkdirSync(path.dirname(cachePath(projectDir)), { recursive: true });
73
+ fs.writeFileSync(cachePath(projectDir), JSON.stringify({ ...cache, [key]: { at: now(), result } }));
74
+ } catch { /* cache is best-effort — the verdict stands without it */ }
75
+ return result;
76
+ } catch (err) {
77
+ const why = String(err.message || err).split("\n")[0].slice(0, 120);
78
+ return {
79
+ level: "branch-ref", verified: null,
80
+ note: `tracker verification degraded to branch-ref (${why}) — fix board.verify_cmd or its credentials`,
81
+ };
82
+ }
83
+ }
@@ -47,13 +47,17 @@ export function buildScannerOpts(projectKey, scanner = {}) {
47
47
  opts.push(`-Dsonar.javascript.lcov.reportPaths=${scanner.javascript_lcov_report_paths}`);
48
48
  }
49
49
  const rules = scanner.disabled_rules || [];
50
- rules.forEach((rule, i) => {
51
- opts.push(
52
- `-Dsonar.issue.ignore.multicriteria=e${i + 1}`,
53
- `-Dsonar.issue.ignore.multicriteria.e${i + 1}.ruleKey=${rule}`,
54
- `-Dsonar.issue.ignore.multicriteria.e${i + 1}.resourceKey=**/*`
55
- );
56
- });
50
+ if (rules.length > 0) {
51
+ // KJC-BUG-0139: the list property is declared ONCE — one `multicriteria=`
52
+ // per rule was last-wins, silently un-ignoring every rule but the final one.
53
+ opts.push(`-Dsonar.issue.ignore.multicriteria=${rules.map((_, i) => `e${i + 1}`).join(",")}`);
54
+ rules.forEach((rule, i) => {
55
+ opts.push(
56
+ `-Dsonar.issue.ignore.multicriteria.e${i + 1}.ruleKey=${rule}`,
57
+ `-Dsonar.issue.ignore.multicriteria.e${i + 1}.resourceKey=**/*`
58
+ );
59
+ });
60
+ }
57
61
  return opts.join(" ");
58
62
  }
59
63
 
@@ -123,9 +123,8 @@ export async function judgeTournament({ tournamentDir, config = {}, logger = con
123
123
  let solomon = null;
124
124
  if (escalated) {
125
125
  if (!solomonFn) {
126
- throw new Error(
127
- `tournament: ${verdict.tie ? "empate declarado" : `discrepancia juez(${judgeTop}) vs scoreboard(${boardTop})`} y sin árbitro configurado`
128
- );
126
+ const why = verdict.tie ? "empate declarado" : `discrepancia juez(${judgeTop}) vs scoreboard(${boardTop})`;
127
+ throw new Error(`tournament: ${why} y sin árbitro configurado`);
129
128
  }
130
129
  solomon = await solomonFn({
131
130
  judgeRanking: verdict.ranking,
@@ -17,7 +17,7 @@ import { execFile } from "node:child_process";
17
17
  import { promisify } from "node:util";
18
18
 
19
19
  const execFileAsync = promisify(execFile);
20
- const TESTS_RE = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[a-z]+$/;
20
+ const TESTS_RE = /(?:(?:^|\/)(?:tests?|__tests__|spec)\/)|(?:\.(?:test|spec)\.[a-z]+$)/;
21
21
  const MUTATE_TIMEOUT_MS = 10 * 60 * 1000;
22
22
 
23
23
  // EXACT unified-diff header forms — an added line whose content starts with
@@ -17,7 +17,8 @@ const HIGHLIGHT_MAX = 160;
17
17
  * ("Minor. **The method, enforced.** …"), bold stripped, truncated. Pure.
18
18
  */
19
19
  export function parseChangelogHighlight(markdown, version) {
20
- const section = String(markdown).split(new RegExp(`^## \\[${version.replaceAll(".", "\\.")}\\][^\\n]*$`, "m"))[1];
20
+ const escapedVersion = version.replaceAll(".", String.raw`\.`);
21
+ const section = String(markdown).split(new RegExp(String.raw`^## \[${escapedVersion}\][^\n]*$`, "m"))[1];
21
22
  if (!section) return null;
22
23
  const firstLine = section.split(/^##|^###/m)[0].split("\n").map((l) => l.trim()).find(Boolean);
23
24
  if (!firstLine) return null;