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.
- package/README.md +23 -9
- package/package.json +2 -2
- package/scripts/verify-pack.mjs +19 -1
- package/src/checks/config-files.js +86 -0
- package/src/checks/method.js +34 -1
- package/src/checks/sonar.js +16 -7
- package/src/cli/register-pipeline.js +4 -1
- package/src/commands/identity.js +5 -2
- package/src/commands/review-gate.js +71 -3
- package/src/git/automation.js +3 -1
- package/src/harden/harden-engine.js +5 -2
- package/src/harden/hook-templates.js +25 -10
- package/src/harden/phone-sign.js +82 -13
- package/src/harden/supervisor-commit.js +7 -0
- package/src/mcp/handlers/direct-handlers.js +34 -21
- package/src/mcp/handlers/rag-handler.js +10 -4
- package/src/mcp/server-handlers.js +11 -1
- package/src/mcp/tools.js +1 -0
- package/src/review/one-shot-review.js +3 -0
- package/src/review/sonar-pregate.js +64 -15
- package/src/review/sonar-requirement.js +47 -0
- package/src/review/tests-with-code.js +17 -6
- package/src/review/verdict-store.js +61 -1
- package/src/roles/commiter-role.js +4 -1
- package/src/sonar/scanner.js +40 -15
- package/src/utils/paths.js +1 -0
package/src/harden/phone-sign.js
CHANGED
|
@@ -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 =
|
|
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
|
-
/**
|
|
45
|
-
export function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 =
|
|
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
|
|
130
|
+
const mine = onStaged.filter(isTouched);
|
|
85
131
|
return {
|
|
86
132
|
available: true,
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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}>}
|