karajan-code 4.28.1 → 4.29.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.
@@ -32,21 +32,78 @@ async function relayConfig(fetchFn) {
32
32
  }
33
33
  const SIGN_PAGE = "https://karajancode.com/sign";
34
34
  const POLL_INTERVAL_MS = 2000;
35
- const TTL_MS = 120000;
35
+ const TTL_MS = 60000;
36
+
37
+ /** Spinner en su sitio (una línea, con \r) solo en TTY; no-op fuera de TTY o en tests. */
38
+ function makeSpinner() {
39
+ if (!process.stdout?.isTTY) return { tick() {}, stop() {} };
40
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
41
+ let i = 0;
42
+ return {
43
+ tick() { process.stdout.write(`\r${frames[i++ % frames.length]} esperando la firma del móvil…`); },
44
+ stop() { process.stdout.write("\r\x1b[K"); },
45
+ };
46
+ }
36
47
 
37
48
  const phoneKeyPath = (home) => join(home ?? homedir(), ".karajan", "supervisor-phone.json");
38
49
 
50
+ // KJC-TSK-0831 (ADR 0010): el padrón de firmantes vive VERSIONADO en el repo,
51
+ // no una única clave local — así un equipo comparte la confianza y el CI puede
52
+ // verificar (0823) contra el mismo conjunto. La legacy ~/.karajan sigue valiendo.
53
+ const signersPath = (projectDir) => join(projectDir ?? process.cwd(), ".karajan", "supervisor-signers.json");
54
+
55
+ /** Valida una clave pública ed25519 raw en base64 canónico (32 bytes). */
56
+ export function validatePhonePublicKey(publicKeyBase64) {
57
+ const raw = Buffer.from(publicKeyBase64 ?? "", "base64");
58
+ if (raw.length !== 32 || raw.toString("base64") !== publicKeyBase64) {
59
+ throw new Error("la clave debe ser base64 canónico de EXACTAMENTE 32 bytes (ed25519 raw)");
60
+ }
61
+ return publicKeyBase64;
62
+ }
63
+
39
64
  export const isPhoneEnrolled = ({ home } = {}) => existsSync(phoneKeyPath(home));
40
65
 
41
66
  export const readEnrolledKey = ({ home } = {}) =>
42
67
  JSON.parse(readFileSync(phoneKeyPath(home), "utf8")).publicKey;
43
68
 
44
- /** Valida y persiste la clave pública del móvil (base64 del raw de 32 bytes). */
45
- export function enrollPhone(publicKeyBase64, { home } = {}) {
46
- const raw = Buffer.from(publicKeyBase64 ?? "", "base64");
47
- if (raw.length !== 32 || raw.toString("base64") !== publicKeyBase64) {
48
- throw new Error("la clave debe ser base64 canónico de EXACTAMENTE 32 bytes (ed25519 raw)");
69
+ /** Claves públicas del padrón versionado del repo (o [] si no hay). */
70
+ export function readSigners({ projectDir } = {}) {
71
+ try {
72
+ const doc = JSON.parse(readFileSync(signersPath(projectDir), "utf8"));
73
+ return Array.isArray(doc?.signers) ? doc.signers.map((s) => s.publicKey).filter(Boolean) : [];
74
+ } catch {
75
+ return [];
49
76
  }
77
+ }
78
+
79
+ /**
80
+ * Conjunto de claves AUTORIZADAS a sellar: el padrón del repo ∪ la clave legacy
81
+ * de ~/.karajan (compat). Sin fallbacks silenciosos: si no hay ninguna, el Set
82
+ * está vacío y la verificación fallará ruidosamente.
83
+ */
84
+ export function readEnrolledKeys({ projectDir, home } = {}) {
85
+ const keys = new Set(readSigners({ projectDir }));
86
+ if (isPhoneEnrolled({ home })) keys.add(readEnrolledKey({ home }));
87
+ return keys;
88
+ }
89
+
90
+ /** Añade una clave pública al padrón versionado del repo (dedup). Acto humano. */
91
+ export function addSigner(publicKeyBase64, { projectDir, label } = {}) {
92
+ validatePhonePublicKey(publicKeyBase64);
93
+ const path = signersPath(projectDir);
94
+ let doc = { signers: [] };
95
+ try { doc = JSON.parse(readFileSync(path, "utf8")); } catch { /* new file */ }
96
+ if (!Array.isArray(doc.signers)) doc.signers = [];
97
+ if (doc.signers.some((s) => s.publicKey === publicKeyBase64)) return doc;
98
+ doc.signers.push({ publicKey: publicKeyBase64, label: label ?? null, enrolledAt: new Date().toISOString() });
99
+ mkdirSync(join(path, ".."), { recursive: true });
100
+ writeFileSync(path, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
101
+ return doc;
102
+ }
103
+
104
+ /** Valida y persiste la clave pública del móvil (legacy: clave única ~/.karajan). */
105
+ export function enrollPhone(publicKeyBase64, { home } = {}) {
106
+ validatePhonePublicKey(publicKeyBase64);
50
107
  mkdirSync(join(phoneKeyPath(home), ".."), { recursive: true });
51
108
  const record = { publicKey: publicKeyBase64, enrolledAt: new Date().toISOString() };
52
109
  writeFileSync(phoneKeyPath(home), `${JSON.stringify(record, null, 2)}\n`, "utf8");
@@ -98,22 +155,34 @@ export async function requestPhoneSignature({ project, files, kjVersion, logger
98
155
  const signUrl = `${SIGN_PAGE}?c=${cid}`;
99
156
  drawQr(signUrl);
100
157
  logger.info?.(`phone-sign: escanea el QR o abre ${signUrl} y firma en el móvil (caduca en ${TTL_MS / 1000}s)`);
158
+ logger.info?.("phone-sign: esperando la firma del móvil…");
159
+ const spinner = deps.spinner ?? makeSpinner();
101
160
  const deadline = now() + TTL_MS;
102
161
  while (now() < deadline) {
103
162
  const res = await fetchFn(`${relay.url}/${cid}?key=${relay.apiKey}`);
104
- if (!res.ok) throw new Error(`phone-sign: fallo consultando la petición de firma (HTTP ${res.status})`);
163
+ if (!res.ok) { spinner.stop(); throw new Error(`phone-sign: fallo consultando la petición de firma (HTTP ${res.status})`); }
105
164
  const fields = (await res.json()).fields ?? {};
106
165
  if (fields.state?.stringValue === "signed") {
107
- const enrolled = readEnrolledKey({ home: deps.home });
108
- if (fields.publicKey?.stringValue !== enrolled) {
109
- return { ok: false, reason: "la publicKey del doc no coincide con la enrolada — el doc es transporte, la verdad es la enrolada" };
166
+ spinner.stop();
167
+ // KJC-TSK-0831 (ADR 0010): la clave que firma debe estar en el PADRÓN
168
+ // autorizado (repo ∪ legacy). El doc es transporte; la verdad es el padrón.
169
+ const authorized = readEnrolledKeys({ projectDir: deps.projectDir, home: deps.home });
170
+ const signerKey = fields.publicKey?.stringValue;
171
+ if (!signerKey || !authorized.has(signerKey)) {
172
+ return { ok: false, reason: "la publicKey que firma no está en el padrón autorizado — el doc es transporte, la verdad es el padrón" };
110
173
  }
111
174
  const payload = canonicalPayload({ cid, nonce, project, files });
112
- const good = verifyPhoneSignature({ payload, signature: fields.signature?.stringValue ?? "", publicKey: enrolled });
113
- return good ? { ok: true } : { ok: false, reason: "firma ed25519 inválida para el payload canónico" };
175
+ const signature = fields.signature?.stringValue ?? "";
176
+ const good = verifyPhoneSignature({ payload, signature, publicKey: signerKey });
177
+ if (!good) return { ok: false, reason: "firma ed25519 inválida para el payload canónico" };
178
+ // KJC-TSK-0823: devuelve el desafío + firma para que el sello los GRABE en
179
+ // la provenance y CI pueda re-verificar server-side (clave del padrón).
180
+ const filesHash = createHash("sha256").update(JSON.stringify(files)).digest("hex");
181
+ return { ok: true, signer: signerKey, signature, challenge: { cid, nonce, project, filesHash } };
114
182
  }
115
- logger.info?.("phone-sign: esperando la firma del móvil…");
183
+ spinner.tick();
116
184
  await sleep(POLL_INTERVAL_MS);
117
185
  }
186
+ spinner.stop();
118
187
  return { ok: false, reason: "caducado" };
119
188
  }
@@ -123,6 +123,7 @@ export async function commitSupervisorRegeneration({
123
123
  // es OBLIGATORIA — jamás se degrada a solo-nonce. Firma los MISMOS
124
124
  // files/hashes de la provenance (normalizados al par {file, sha256}).
125
125
  const phone = deps.phone ?? { enrolled: isPhoneEnrolled, request: requestPhoneSignature };
126
+ let signatureBlock = null;
126
127
  if (phone.enrolled({})) {
127
128
  const signed = await phone.request({
128
129
  project: basename(projectDir),
@@ -133,6 +134,11 @@ export async function commitSupervisorRegeneration({
133
134
  if (!signed.ok) {
134
135
  throw new Error(`capa 5: firma del móvil rechazada (${signed.reason}) — con móvil enrolado el sello exige su firma (PRP-0023)`);
135
136
  }
137
+ // KJC-TSK-0823: graba el desafío + firma en la provenance para que CI
138
+ // re-verifique server-side que un humano (clave del padrón) aprobó.
139
+ if (signed.challenge && signed.signature) {
140
+ signatureBlock = { ...signed.challenge, signer: signed.signer, signature: signed.signature };
141
+ }
136
142
  }
137
143
  const who = readIdentity(projectDir);
138
144
  const provenance = {
@@ -141,6 +147,7 @@ export async function commitSupervisorRegeneration({
141
147
  generation,
142
148
  who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
143
149
  files: hashed,
150
+ ...(signatureBlock ? { signature: signatureBlock } : {}),
144
151
  };
145
152
  writeFileSync(join(projectDir, PROVENANCE_FILE), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
146
153
  recordGateDecision(projectDir, {
@@ -39,6 +39,26 @@ async function runBootstrapGate(server, a) {
39
39
  await ensureBootstrap(projectDir, config);
40
40
  }
41
41
 
42
+ /**
43
+ * KJC-BUG-0175 (#1723): the tool schemas promise "either `task` or
44
+ * `taskFile`", but only kj_run read the file — every direct handler
45
+ * answered "Missing required field: task" to a taskFile-only call. Same
46
+ * resolution as run-handler: read the file (relative to projectDir) into
47
+ * `a.task`, then require it.
48
+ * @returns {Promise<Object|null>} a failPayload when the task is missing or the file unreadable
49
+ */
50
+ async function requireTask(a, server) {
51
+ try {
52
+ await ensureTaskFromFile(a, a.projectDir || (await resolveProjectDir(server, a.projectDir).catch(() => null)));
53
+ } catch (err) {
54
+ return failPayload(`taskFile read failed: ${err.message}`);
55
+ }
56
+ if (!a.task) {
57
+ return failPayload("Missing required field: task (or pass taskFile with a .md path)");
58
+ }
59
+ return null;
60
+ }
61
+
42
62
  function applySessionOverrides(a, roleKeys) {
43
63
  const sessionOvr = getSessionOverrides();
44
64
  for (const key of roleKeys) {
@@ -389,9 +409,8 @@ export async function handleArchitectDirect(a, server, extra) {
389
409
  }
390
410
 
391
411
  export async function handleCode(a, server, extra) {
392
- if (!a.task) {
393
- return failPayload("Missing required field: task");
394
- }
412
+ const missingTask = await requireTask(a, server);
413
+ if (missingTask) return missingTask;
395
414
  await runBootstrapGate(server, a);
396
415
  if (!isPreflightAcked()) {
397
416
  // Auto-acknowledge with defaults for autonomous operation
@@ -404,25 +423,22 @@ export async function handleCode(a, server, extra) {
404
423
  }
405
424
 
406
425
  export async function handleReview(a, server, extra) {
407
- if (!a.task) {
408
- return failPayload("Missing required field: task");
409
- }
426
+ const missingTask = await requireTask(a, server);
427
+ if (missingTask) return missingTask;
410
428
  await runBootstrapGate(server, a);
411
429
  return handleReviewDirect(a, server, extra);
412
430
  }
413
431
 
414
432
  export async function handlePlan(a, server, extra) {
415
- if (!a.task) {
416
- return failPayload("Missing required field: task");
417
- }
433
+ const missingTask = await requireTask(a, server);
434
+ if (missingTask) return missingTask;
418
435
  await runBootstrapGate(server, a);
419
436
  return handlePlanDirect(a, server, extra);
420
437
  }
421
438
 
422
439
  export async function handleDiscover(a, server, extra) {
423
- if (!a.task) {
424
- return failPayload("Missing required field: task");
425
- }
440
+ const missingTask = await requireTask(a, server);
441
+ if (missingTask) return missingTask;
426
442
  const validModes = new Set(["gaps", "momtest", "wendel", "classify", "jtbd"]);
427
443
  if (a.mode && !validModes.has(a.mode)) {
428
444
  return failPayload(`Invalid mode "${a.mode}". Valid values: ${[...validModes].join(", ")}`);
@@ -432,25 +448,22 @@ export async function handleDiscover(a, server, extra) {
432
448
  }
433
449
 
434
450
  export async function handleTriage(a, server, extra) {
435
- if (!a.task) {
436
- return failPayload("Missing required field: task");
437
- }
451
+ const missingTask = await requireTask(a, server);
452
+ if (missingTask) return missingTask;
438
453
  await runBootstrapGate(server, a);
439
454
  return handleTriageDirect(a, server, extra);
440
455
  }
441
456
 
442
457
  export async function handleResearcher(a, server, extra) {
443
- if (!a.task) {
444
- return failPayload("Missing required field: task");
445
- }
458
+ const missingTask = await requireTask(a, server);
459
+ if (missingTask) return missingTask;
446
460
  await runBootstrapGate(server, a);
447
461
  return handleResearcherDirect(a, server, extra);
448
462
  }
449
463
 
450
464
  export async function handleArchitect(a, server, extra) {
451
- if (!a.task) {
452
- return failPayload("Missing required field: task");
453
- }
465
+ const missingTask = await requireTask(a, server);
466
+ if (missingTask) return missingTask;
454
467
  await runBootstrapGate(server, a);
455
468
  return handleArchitectDirect(a, server, extra);
456
469
  }
@@ -1,7 +1,7 @@
1
1
  // KJC-PCS-0049 Step 7 — MCP handlers calling src/rag/* directly. Imports
2
2
  // from src/commands/* are forbidden by the layer-boundaries test
3
3
  // (MCP and CLI are peer layers); we hit the pure rag/* modules instead.
4
- import { countChunks, openVecStore } from "../../rag/vec-store.js";
4
+ import { countChunks, openVecStore, projectSlug } from "../../rag/vec-store.js";
5
5
  import { makeGovernedEmbedder } from "../../rag/governed-embedder.js";
6
6
  import { indexProject } from "../../rag/indexer.js";
7
7
  import { query } from "../../rag/retriever.js";
@@ -22,11 +22,17 @@ export async function handleRagQuery(args, server) {
22
22
  const config = await buildConfig({ ...args, projectDir }, "rag-query");
23
23
  const topK = Number(args?.topK) || 5;
24
24
  const scope = args?.scope || "all";
25
+ // KJC-BUG-0177 (#1713): the store is shared by every indexed project,
26
+ // and the MCP tool searched it unfiltered while the CLI (KJC-TSK-0438)
27
+ // scoped to the project's slug. Same rule here: default = slug of
28
+ // projectDir, `project: "all"` disables the filter, `project: <slug>`
29
+ // overrides it.
30
+ const project = args?.project === "all" ? null : (args?.project || projectSlug(projectDir) || null);
25
31
  const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
26
32
  try {
27
- if (countChunks(db) === 0) return responseText({ hits: [], empty: true, topK, scope });
28
- const hits = await query(db, makeGovernedEmbedder(config), text, { topK, scope });
29
- return responseText({ hits, empty: false, topK, scope });
33
+ if (countChunks(db) === 0) return responseText({ hits: [], empty: true, topK, scope, project });
34
+ const hits = await query(db, makeGovernedEmbedder(config), text, { topK, scope, project });
35
+ return responseText({ hits, empty: false, topK, scope, project });
30
36
  } finally { db.close(); }
31
37
  } catch (err) {
32
38
  return failPayload(`kj_rag_query failed: ${err.message}`);
@@ -40,6 +40,7 @@ import {
40
40
  } from "./handlers/management-handlers.js";
41
41
  import { handleHu, handleSkills, handleSuggest } from "./handlers/hu-handlers.js";
42
42
  import { handleRagQuery, handleRagIndex } from "./handlers/rag-handler.js";
43
+ import { withKarajanHome } from "../utils/paths.js";
43
44
 
44
45
  export async function handleToolCall(name, args, server, extra) {
45
46
  const a = asObject(args);
@@ -73,7 +74,16 @@ export async function handleToolCall(name, args, server, extra) {
73
74
  kj_rag_index: (a, server) => handleRagIndex(a, server),
74
75
  }[name];
75
76
  if (handler) {
76
- return handler(a, server, extra);
77
+ // KJC-BUG-0176 (#1722): `kjHome` only reached the env of the `kj`
78
+ // subprocesses spawned by run-kj.js; the direct handlers resolve the
79
+ // home from THIS process (bootstrap gate, config load), so the
80
+ // argument was ignored and the gate failed with "Config file not
81
+ // found". Pin the home to this call's async context instead — no
82
+ // env mutation, so overlapping calls never see each other's home.
83
+ const kjHome = typeof a.kjHome === "string" && a.kjHome.trim() !== "" ? a.kjHome : null;
84
+ return kjHome
85
+ ? withKarajanHome(kjHome, () => handler(a, server, extra))
86
+ : handler(a, server, extra);
77
87
  }
78
88
  return failPayload(`Unknown tool: ${name}`);
79
89
  }
package/src/mcp/tools.js CHANGED
@@ -413,6 +413,7 @@ export const tools = [
413
413
  text: { type: "string", description: "The natural-language query." },
414
414
  topK: { type: "number", description: "Max number of hits to return. Default: 5." },
415
415
  scope: { type: "string", description: "Filter by chunk kind: 'plans' | 'code' | 'onboarding' | 'all' (default)." },
416
+ project: { type: "string", description: "Project slug to search. Defaults to the basename of projectDir (same isolation as `kj rag query`); 'all' searches every indexed project." },
416
417
  projectDir: { type: "string", description: "Absolute path to the project directory" }
417
418
  },
418
419
  required: ["text"]
@@ -43,6 +43,8 @@ export async function pickCrossReviewer({ config, hostAgent, detectAgents = dete
43
43
  */
44
44
  export async function runOneShotReview({
45
45
  diff, task, config, logger, projectDir,
46
+ // KJC-TSK-0838: what the sonar pre-gate saw, persisted with the verdict.
47
+ sonar = null,
46
48
  hostAgent = detectHostAgent(),
47
49
  createAgentFn = createAgent,
48
50
  detectAgents = detectAvailableAgents,
@@ -123,6 +125,7 @@ export async function runOneShotReview({
123
125
  issues: parsed.blocking_issues || [],
124
126
  suggestions: parsed.non_blocking_suggestions || [],
125
127
  summary: parsed.summary || parsed.raw_summary || "",
128
+ ...(sonar ? { sonar } : {}),
126
129
  confidence: parsed.confidence ?? null,
127
130
  });
128
131
  }
@@ -9,9 +9,12 @@
9
9
  * a laptop without Docker still commits.
10
10
  */
11
11
 
12
- import { runSonarScan } from "../sonar/scanner.js";
12
+ import { existsSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { indexedFilesFrom, runSonarScan } from "../sonar/scanner.js";
13
15
  import { getOpenIssues } from "../sonar/api.js";
14
16
  import { acquireToolLock } from "../utils/tool-governor.js";
17
+ import { sourceFilesOf } from "./tests-with-code.js";
15
18
 
16
19
  const BLOCKING_SEVERITIES = new Set(["BLOCKER", "CRITICAL"]);
17
20
 
@@ -59,35 +62,81 @@ export function addedLinesByFile(diffText) {
59
62
  * Every failure path degrades to {available:false, reason} — the pre-gate
60
63
  * never breaks the review, it only refuses to stay silent.
61
64
  */
62
- export async function runSonarPregate({ config, stagedFiles = [], touchedLines = null, logger = null }) {
65
+ /**
66
+ * KJC-TSK-0838 step 3 (monorepo): each staged file belongs to the nearest
67
+ * directory up from it that owns a sonar-project.properties; the repo root
68
+ * ("") owns the rest. One scan per group, run from that directory.
69
+ * @returns {Map<string, string[]>} repo-relative dir → staged files
70
+ */
71
+ export function groupByScanRoot(files, { root = process.cwd(), exists = existsSync } = {}) {
72
+ const groups = new Map();
73
+ for (const file of files) {
74
+ let owner = "";
75
+ for (let dir = dirname(file); dir && dir !== "."; dir = dirname(dir)) {
76
+ if (exists(join(root, dir, "sonar-project.properties"))) { owner = dir; break; }
77
+ }
78
+ (groups.get(owner) ?? groups.set(owner, []).get(owner)).push(file);
79
+ }
80
+ return groups;
81
+ }
82
+
83
+ export async function runSonarPregate({ config, stagedFiles = [], touchedLines = null, logger = null, exists = existsSync }) {
63
84
  if (config?.review_gate?.sonar === false) {
64
85
  return { available: false, reason: "disabled in config (review_gate.sonar: false)" };
65
86
  }
66
87
  let lock = null;
67
88
  try {
68
89
  lock = await acquireToolLock("sonar-scanner", { timeoutMs: 300_000 });
69
- const scan = await runSonarScan(config);
70
- if (scan.note) logger?.warn?.(scan.note); // KJC-BUG-0156: precedence is said, never silent
71
- if (!scan.ok) {
72
- return { available: false, reason: (scan.stderr || scan.stdout || "sonar scan failed").trim() };
90
+ const groups = groupByScanRoot(stagedFiles, { exists });
91
+ if (groups.size === 0) groups.set("", []);
92
+ const indexed = new Set();
93
+ const issues = [];
94
+ const keys = [];
95
+ let totalProject = 0;
96
+ for (const dir of groups.keys()) {
97
+ const prefix = dir ? `${dir}/` : "";
98
+ // KJC-TSK-0838: verbose, so the scanner names every file it indexed.
99
+ const scan = await runSonarScan(config, null, { verbose: true, ...(dir ? { cwd: join(process.cwd(), dir) } : {}) });
100
+ if (scan.note) logger?.warn?.(scan.note); // KJC-BUG-0156: precedence is said, never silent
101
+ if (!scan.ok) {
102
+ return { available: false, reason: (scan.stderr || scan.stdout || "sonar scan failed").trim() };
103
+ }
104
+ // Coverage is PROVED by the scanner's own index, never inferred from
105
+ // config — a root-scoped properties file let 4 PRs under packages/radar
106
+ // pass as "0 issues on staged files". No index in the log = no proof.
107
+ const own = indexedFilesFrom(scan.stdout);
108
+ if (own.size === 0) {
109
+ return { available: false, reason: `the scanner log names no indexed file (sonar.verbose) — coverage cannot be proved for ${dir || "the repo root"}` };
110
+ }
111
+ for (const f of own) indexed.add(`${prefix}${f}`);
112
+ keys.push(scan.projectKey);
113
+ // The scan above ALWAYS runs before issues are read (single-flight): the
114
+ // verdict is about the code as it is now, never a stale server analysis
115
+ // (KJC-TSK-0795 AC2 — that failure mode has no route here, by design).
116
+ const res = await getOpenIssues(config, scan.projectKey);
117
+ totalProject += res.total ?? (res.issues || []).length;
118
+ // Issue components are package-relative: made repo-relative here.
119
+ for (const i of res.issues || []) issues.push({ ...i, component: `${scan.projectKey}:${prefix}${issueFile(i)}` });
73
120
  }
74
- // The scan above ALWAYS runs before issues are read (single-flight): the
75
- // verdict is about the code as it is now, never a stale server analysis
76
- // (KJC-TSK-0795 AC2 — that failure mode has no route here, by design).
77
- const res = await getOpenIssues(config, scan.projectKey);
121
+ const { sources } = sourceFilesOf(config, stagedFiles);
122
+ const covered = sources.filter((f) => indexed.has(f));
123
+ const uncovered = sources.filter((f) => !indexed.has(f));
78
124
  const staged = new Set(stagedFiles);
79
- const onStaged = (res.issues || []).filter((i) => staged.has(issueFile(i)));
125
+ const onStaged = issues.filter((i) => staged.has(issueFile(i)));
80
126
  // KJC-TSK-0795 AC3: with the diff's line map, only issues on lines the PR
81
127
  // ADDS can veto — a 3-line PR must not answer for 30 preexisting issues.
82
128
  // No line, or an untouched line, is the file's TREND: reported, never a block.
83
129
  const isTouched = (i) => !touchedLines || (i.line != null && touchedLines.get(issueFile(i))?.has(Number(i.line)));
84
- const own = onStaged.filter(isTouched);
130
+ const mine = onStaged.filter(isTouched);
85
131
  return {
86
132
  available: true,
87
- blocking: own.filter((i) => BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
88
- advisory: own.filter((i) => !BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
133
+ projectKey: keys.join(","),
134
+ covered,
135
+ uncovered,
136
+ blocking: mine.filter((i) => BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
137
+ advisory: mine.filter((i) => !BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
89
138
  preexisting: touchedLines ? onStaged.filter((i) => !isTouched(i)) : [],
90
- totalProject: res.total ?? (res.issues || []).length,
139
+ totalProject,
91
140
  };
92
141
  } catch (err) {
93
142
  logger?.warn?.(`[sonar-pregate] ${err.message}`);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * KJC-TSK-0838 (ADR "Sonar es un gate de commit no evadible", 2026-09-13) —
3
+ * a diff that touches source files enters only with a sonar block that RAN
4
+ * and COVERED every staged source; docs-only diffs are exempt. The only other
5
+ * way through is a live HUMAN grant on `method.sonar.code` (kj policy grant:
6
+ * identity, expiry, reason, anchored decision log). No env var is read here,
7
+ * on purpose: an escape an agent can set for itself is the hole this closes
8
+ * (KRD-TSK-0023 reached main in 4 code PRs with no real analysis).
9
+ */
10
+ import { sourceFilesOf } from "./tests-with-code.js";
11
+
12
+ export const SONAR_RULE_ID = "method.sonar.code";
13
+
14
+ const GRANT_HINT = `Only a human grant lifts this: kj policy grant --rule ${SONAR_RULE_ID} --until <iso> --reason "<why>"`;
15
+
16
+ function liveGrant(standingExceptions, now) {
17
+ return standingExceptions.find((e) => e.rule_id === SONAR_RULE_ID && new Date(e.expiresAt).getTime() > now.getTime()) || null;
18
+ }
19
+
20
+ /**
21
+ * @returns {{ok: boolean, mode: "docs-only"|"pass"|"granted"|"block", reason?: string, grant?: object, sources?: string[]}}
22
+ */
23
+ export function checkSonarRequirement({ config = {}, stagedFiles = [], sonar, standingExceptions = [], now = new Date() }) {
24
+ const { sources } = sourceFilesOf(config, stagedFiles);
25
+ if (sources.length === 0) return { ok: true, mode: "docs-only" };
26
+
27
+ const grant = liveGrant(standingExceptions, now);
28
+ if (grant) return { ok: true, mode: "granted", grant, sources };
29
+
30
+ const head = `Sonar is mandatory for code (${sources.length} staged source${sources.length === 1 ? "" : "s"})`;
31
+ if (!sonar) {
32
+ return { ok: false, mode: "block", sources, reason: `${head} — the verdict carries no sonar block; run \`kj review --staged\` again. ${GRANT_HINT}` };
33
+ }
34
+ if (!sonar.ran) {
35
+ return { ok: false, mode: "block", sources, reason: `${head} — the analysis did not run: ${sonar.reason || "unknown reason"}. ${GRANT_HINT}` };
36
+ }
37
+ // Recomputed from `covered` against the CURRENT staged sources: the diff
38
+ // hash already pins the file set, so a source missing from the proof is a
39
+ // source the scan never indexed, whatever the block claims.
40
+ const coveredSet = new Set(sonar.covered || []);
41
+ const uncovered = sources.filter((f) => !coveredSet.has(f));
42
+ if (uncovered.length > 0) {
43
+ const list = `${uncovered.slice(0, 5).join(", ")}${uncovered.length > 5 ? "…" : ""}`;
44
+ return { ok: false, mode: "block", sources, reason: `${head} — the scan never indexed ${uncovered.length} of them (${list}): fix sonar-project.properties so they are analysed. ${GRANT_HINT}` };
45
+ }
46
+ return { ok: true, mode: "pass", sources };
47
+ }
@@ -11,17 +11,28 @@
11
11
  const DEFAULT_TEST_PATTERNS = ["/tests/", "/__tests__/", ".test.", ".spec."];
12
12
  const DEFAULT_SOURCE_EXTS = [".js", ".jsx", ".ts", ".tsx", ".py", ".go", ".java", ".rb", ".php", ".cs"];
13
13
 
14
- export function checkTestsWithCode({ config = {}, stagedFiles = [], numstat = null, env = process.env }) {
15
- if (env.KJ_ALLOW_NO_TESTS === "1") {
16
- return { ok: true, mode: "exempt", reason: "KJ_ALLOW_NO_TESTS=1 (explicit escape hatch)" };
17
- }
14
+ /**
15
+ * The staged files that are SOURCE by the project's own definition: source
16
+ * extension and not a test. Shared by every gate that must tell "code" from
17
+ * "docs" (tests-with-code, the sonar coverage proof of KJC-TSK-0838).
18
+ */
19
+ export function sourceFilesOf(config = {}, files = []) {
18
20
  const patterns = config.development?.test_file_patterns || DEFAULT_TEST_PATTERNS;
19
21
  const exts = config.development?.source_file_extensions || DEFAULT_SOURCE_EXTS;
20
22
  // Staged paths are repo-relative (no leading slash) — normalize so the
21
23
  // "/tests/" style patterns also match the top-level tests directory.
22
24
  const isTest = (f) => patterns.some((p) => `/${f}`.includes(p));
23
- const sources = stagedFiles.filter((f) => !isTest(f) && exts.some((e) => f.endsWith(e)));
24
- const hasTests = stagedFiles.some(isTest);
25
+ return {
26
+ sources: files.filter((f) => !isTest(f) && exts.some((e) => f.endsWith(e))),
27
+ hasTests: files.some(isTest),
28
+ };
29
+ }
30
+
31
+ export function checkTestsWithCode({ config = {}, stagedFiles = [], numstat = null, env = process.env }) {
32
+ if (env.KJ_ALLOW_NO_TESTS === "1") {
33
+ return { ok: true, mode: "exempt", reason: "KJ_ALLOW_NO_TESTS=1 (explicit escape hatch)" };
34
+ }
35
+ const { sources, hasTests } = sourceFilesOf(config, stagedFiles);
25
36
 
26
37
  if (sources.length === 0 || hasTests) return { ok: true, mode: "pass" };
27
38
  // KJC-TSK-0795 AC1 (epic KJC-PCS-0082): deleting code adds no behavior to
@@ -16,6 +16,11 @@ import { runCommand } from "../utils/process.js";
16
16
 
17
17
  const STORE_DIR = path.join(".karajan", "reviews");
18
18
 
19
+ // KJC-BUG-0173: a verdict is keyed by one exact diff hash, so once that diff is
20
+ // committed or changed its hash is never looked up again — anything older than
21
+ // this TTL is dead weight and safe to shed. Nothing live stays staged for weeks.
22
+ export const VERDICT_TTL_DAYS = 14;
23
+
19
24
  export function diffHash(diff) {
20
25
  // trimEnd: runners differ on the final newline (execa strips it, raw
21
26
  // git keeps it) — trailing whitespace must not void a verdict.
@@ -36,9 +41,44 @@ export async function saveVerdict(projectDir, diff, verdict) {
36
41
  const file = verdictPath(projectDir, hash);
37
42
  await fs.mkdir(path.dirname(file), { recursive: true });
38
43
  await fs.writeFile(file, `${JSON.stringify(record, null, 2)}\n`);
44
+ // KJC-BUG-0173: a review is the natural moment to shed dead verdicts.
45
+ // Best-effort — hygiene must never break a save.
46
+ try { await pruneVerdicts({ projectDir }); } catch { /* hygiene, not correctness */ }
39
47
  return record;
40
48
  }
41
49
 
50
+ /**
51
+ * Opportunistic GC for the verdict store: removes verdict files whose mtime is
52
+ * older than `maxAgeDays`. A verdict only counts for a byte-identical diff, so
53
+ * an aged one can never be re-checked. `now`/`dryRun` injectable for tests.
54
+ * @returns {Promise<{scanned:number, removed:number, expired:string[], dryRun:boolean, maxAgeDays:number}>}
55
+ */
56
+ export async function pruneVerdicts({ projectDir, maxAgeDays = VERDICT_TTL_DAYS, now = Date.now, dryRun = false } = {}) {
57
+ const dir = path.join(projectDir || process.cwd(), STORE_DIR);
58
+ const cutoff = now() - maxAgeDays * 86400000;
59
+ let names;
60
+ try {
61
+ names = await fs.readdir(dir);
62
+ } catch {
63
+ return { scanned: 0, removed: 0, expired: [], dryRun, maxAgeDays };
64
+ }
65
+ const jsons = names.filter((n) => n.endsWith(".json"));
66
+ const expired = [];
67
+ for (const name of jsons) {
68
+ try {
69
+ const st = await fs.stat(path.join(dir, name));
70
+ if (st.mtimeMs < cutoff) expired.push(name);
71
+ } catch { /* vanished mid-scan */ }
72
+ }
73
+ let removed = 0;
74
+ if (!dryRun) {
75
+ for (const name of expired) {
76
+ try { await fs.unlink(path.join(dir, name)); removed += 1; } catch { /* already gone */ }
77
+ }
78
+ }
79
+ return { scanned: jsons.length, removed, expired, dryRun, maxAgeDays };
80
+ }
81
+
42
82
  export async function loadVerdict(projectDir, hash) {
43
83
  try {
44
84
  return JSON.parse(await fs.readFile(verdictPath(projectDir, hash), "utf8"));
@@ -56,7 +96,7 @@ export async function loadVerdict(projectDir, hash) {
56
96
  * (KJC-BUG-0115).
57
97
  * @returns {Promise<{stamped: boolean}>}
58
98
  */
59
- export async function stampStagedVerdict({ projectDir, reviewer, summary = "" }) {
99
+ export async function stampStagedVerdict({ projectDir, reviewer, summary = "", sonar = null }) {
60
100
  const dir = projectDir || process.cwd();
61
101
  try {
62
102
  await fs.access(path.join(dir, ".karajan", "review-gate"));
@@ -67,10 +107,30 @@ export async function stampStagedVerdict({ projectDir, reviewer, summary = "" })
67
107
  if (res.exitCode !== 0 || !res.stdout?.trim()) return { stamped: false };
68
108
  await saveVerdict(dir, res.stdout, {
69
109
  verdict: "approved", reviewer, host: "kj-pipeline", issues: [], summary,
110
+ // KJC-TSK-0838: what the pipeline's sonar stage saw, so --check can tell
111
+ // a run whose gate ran from one that skipped it.
112
+ ...(sonar ? { sonar } : {}),
70
113
  });
71
114
  return { stamped: true };
72
115
  }
73
116
 
117
+ /**
118
+ * KJC-TSK-0838: the pipeline sonar stage result as a verdict sonar block.
119
+ * SKIPPED (no remote, no key) or absent is `ran:false` WITH the reason — the
120
+ * pre-commit check then refuses code, as it would for any other diff.
121
+ * Coverage lists stay empty: the pipeline scans the whole project, and the
122
+ * per-file proof (verbose index) is not wired into SonarRole yet.
123
+ */
124
+ export function pipelineSonarBlock(stageResult) {
125
+ if (!stageResult) return { ran: false, source: "pipeline", reason: "the pipeline recorded no sonar stage" };
126
+ if (!stageResult.gateStatus || stageResult.gateStatus === "SKIPPED") {
127
+ return { ran: false, source: "pipeline", reason: stageResult.reason || stageResult.error || "the sonar stage did not run" };
128
+ }
129
+ return {
130
+ ran: true, source: "pipeline", projectKey: stageResult.projectKey || null, gateStatus: stageResult.gateStatus, covered: [], uncovered: [],
131
+ };
132
+ }
133
+
74
134
  /**
75
135
  * Is there an APPROVED verdict for exactly this diff?
76
136
  * @returns {Promise<{ok: boolean, verdict?: object, reason?: string}>}