karajan-code 4.28.2 → 4.29.1
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 +2 -2
- package/scripts/postinstall.js +35 -15
- package/src/checks/method.js +34 -1
- package/src/checks/sonar.js +16 -7
- package/src/commands/review-gate.js +55 -2
- package/src/git/automation.js +3 -1
- 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 +21 -1
- package/src/roles/commiter-role.js +4 -1
- package/src/sonar/scanner.js +40 -15
- package/src/utils/paths.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "karajan-code",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.29.1",
|
|
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",
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"express-rate-limit": "^8.5.0",
|
|
113
113
|
"helmet": "^8.1.0",
|
|
114
114
|
"js-yaml": "^4.2.0",
|
|
115
|
-
"karajan-core": "^1.
|
|
115
|
+
"karajan-core": "^1.5.0",
|
|
116
116
|
"karajan-rag": "^1.2.0",
|
|
117
117
|
"knip": "^6.15.0",
|
|
118
118
|
"madge": "^8.0.0",
|
package/scripts/postinstall.js
CHANGED
|
@@ -27,12 +27,28 @@ async function writeJson(file, obj) {
|
|
|
27
27
|
await fs.writeFile(file, `${JSON.stringify(obj, null, 2)}\n`, "utf8");
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
const CLAUDE_JSON_PATH = path.join(os.homedir(), ".claude.json");
|
|
31
|
+
|
|
32
|
+
// KJC-BUG-0179 (#1730): a home INSIDE the npm package is the old default
|
|
33
|
+
// this script wrote itself — wiped on every reinstall, never where `kj init`
|
|
34
|
+
// stores kj.config.yml. Such a value is stale, not a user choice.
|
|
35
|
+
const isPackageDirHome = (home) => /node_modules[\\/]karajan-code[\\/]\.karajan[\\/]?$/.test(home);
|
|
36
|
+
|
|
37
|
+
/** The home a previous registration carried, if the user (not this script) set it. */
|
|
38
|
+
function homeFromExistingEntry(entry) {
|
|
39
|
+
const previous = entry?.env?.KARAJAN_HOME || entry?.env?.KJ_HOME;
|
|
40
|
+
return previous && !isPackageDirHome(previous) ? previous : null;
|
|
33
41
|
}
|
|
34
42
|
|
|
35
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Precedence: KARAJAN_HOME → KJ_HOME (deprecated) → the existing entry's home
|
|
45
|
+
* → the instances registry → `~/.karajan` (where the CLI defaults, KJC-BUG-0179).
|
|
46
|
+
*/
|
|
47
|
+
async function resolveKjHome(existingEntry) {
|
|
48
|
+
if (process.env.KARAJAN_HOME) return process.env.KARAJAN_HOME;
|
|
49
|
+
if (process.env.KJ_HOME) return process.env.KJ_HOME;
|
|
50
|
+
const previous = homeFromExistingEntry(existingEntry);
|
|
51
|
+
if (previous) return previous;
|
|
36
52
|
try {
|
|
37
53
|
const registry = await readJson(REGISTRY_PATH);
|
|
38
54
|
const names = Object.keys(registry.instances || {});
|
|
@@ -43,28 +59,31 @@ async function resolveKjHomeFromRegistry() {
|
|
|
43
59
|
} catch {
|
|
44
60
|
// No registry yet — use default
|
|
45
61
|
}
|
|
46
|
-
return
|
|
62
|
+
return path.join(os.homedir(), ".karajan");
|
|
47
63
|
}
|
|
48
64
|
|
|
49
|
-
async function
|
|
50
|
-
const claudeJsonPath = path.join(os.homedir(), ".claude.json");
|
|
51
|
-
let config = {};
|
|
65
|
+
async function readClaudeConfig() {
|
|
52
66
|
try {
|
|
53
|
-
|
|
67
|
+
return await readJson(CLAUDE_JSON_PATH);
|
|
54
68
|
} catch {
|
|
55
|
-
|
|
69
|
+
return {};
|
|
56
70
|
}
|
|
71
|
+
}
|
|
57
72
|
|
|
73
|
+
async function setupClaudeMcp(config, kjHome) {
|
|
58
74
|
config.mcpServers = config.mcpServers || {};
|
|
75
|
+
// Keep every env key the user added to the entry; only the home is ours,
|
|
76
|
+
// and it is written under the current name (KJ_HOME is deprecated).
|
|
77
|
+
const { KJ_HOME: _stale, ...userEnv } = config.mcpServers["karajan-mcp"]?.env || {};
|
|
59
78
|
config.mcpServers["karajan-mcp"] = {
|
|
60
79
|
type: "stdio",
|
|
61
80
|
command: "node",
|
|
62
81
|
args: [path.join(ROOT_DIR, "src", "mcp", "server.js")],
|
|
63
82
|
cwd: ROOT_DIR,
|
|
64
|
-
env: {
|
|
83
|
+
env: { ...userEnv, KARAJAN_HOME: kjHome }
|
|
65
84
|
};
|
|
66
85
|
|
|
67
|
-
await writeJson(
|
|
86
|
+
await writeJson(CLAUDE_JSON_PATH, config);
|
|
68
87
|
}
|
|
69
88
|
|
|
70
89
|
function upsertCodexMcpBlock(toml, block) {
|
|
@@ -96,7 +115,7 @@ async function setupCodexMcp(kjHome) {
|
|
|
96
115
|
`args = [${tomlPath(path.join(ROOT_DIR, "src", "mcp", "server.js"))}]`,
|
|
97
116
|
`cwd = ${tomlPath(ROOT_DIR)}`,
|
|
98
117
|
'[mcp_servers."karajan-mcp".env]',
|
|
99
|
-
`
|
|
118
|
+
`KARAJAN_HOME = ${tomlPath(kjHome)}`
|
|
100
119
|
].join("\n");
|
|
101
120
|
|
|
102
121
|
const updated = upsertCodexMcpBlock(toml, block);
|
|
@@ -105,9 +124,10 @@ async function setupCodexMcp(kjHome) {
|
|
|
105
124
|
}
|
|
106
125
|
|
|
107
126
|
async function main() {
|
|
108
|
-
const
|
|
127
|
+
const claudeConfig = await readClaudeConfig();
|
|
128
|
+
const kjHome = await resolveKjHome(claudeConfig.mcpServers?.["karajan-mcp"]);
|
|
109
129
|
|
|
110
|
-
await setupClaudeMcp(kjHome);
|
|
130
|
+
await setupClaudeMcp(claudeConfig, kjHome);
|
|
111
131
|
await setupCodexMcp(kjHome);
|
|
112
132
|
|
|
113
133
|
console.log("karajan-mcp registered in Claude Code and Codex.");
|
package/src/checks/method.js
CHANGED
|
@@ -39,16 +39,44 @@ export async function collectMethodStats({ projectDir, run = runCommand, sample
|
|
|
39
39
|
|
|
40
40
|
const verdicts = recentVerdicts(projectDir, sample);
|
|
41
41
|
const stamped = verdicts.filter((v) => v.workspace);
|
|
42
|
+
const sonar = { proved: 0, docsOnly: 0, granted: 0, unproved: 0 };
|
|
43
|
+
for (const v of verdicts) {
|
|
44
|
+
const kind = sonarProof(v);
|
|
45
|
+
if (kind) sonar[kind] += 1;
|
|
46
|
+
}
|
|
42
47
|
|
|
43
48
|
return {
|
|
44
49
|
commits: { total: subjects.length, withCard: subjects.filter((s) => CARD_REF_RE.test(s)).length },
|
|
45
50
|
verdicts: { total: verdicts.length, stamped: stamped.length, root: stamped.filter((v) => v.workspace === "root").length },
|
|
46
51
|
testless: { sampled: blocks.length, offenders },
|
|
52
|
+
sonar,
|
|
47
53
|
};
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
/**
|
|
57
|
+
* KJC-TSK-0838 (ADR 2026-09-13): what a verdict's sonar block proves —
|
|
58
|
+
* "proved" (ran and covered every source), "docsOnly", "granted" (a human
|
|
59
|
+
* lifted the rule) or "unproved" (an approved verdict for code with no
|
|
60
|
+
* analysis, a skipped pipeline stage, or a source the scan never saw).
|
|
61
|
+
* A verdict without a block predates the ADR and counts as nothing.
|
|
62
|
+
*/
|
|
63
|
+
export function sonarProof(v) {
|
|
64
|
+
const s = v?.sonar;
|
|
65
|
+
if (!s || v.verdict !== "approved") return null;
|
|
66
|
+
// A pipeline stamp has no mode: the stage ran, or it did not.
|
|
67
|
+
if (s.source === "pipeline") return s.ran ? "proved" : "unproved";
|
|
68
|
+
// A block without a mode was written before the requirement existed
|
|
69
|
+
// (the block landed one PR before fail-closed): not retroactive either.
|
|
70
|
+
if (!s.mode) return null;
|
|
71
|
+
if (s.mode === "docs-only") return "docsOnly";
|
|
72
|
+
if (s.mode === "granted") return "granted";
|
|
73
|
+
if (s.ran && (s.uncovered || []).length === 0) return "proved";
|
|
74
|
+
return "unproved";
|
|
75
|
+
}
|
|
76
|
+
|
|
50
77
|
export function formatMethodStats(s) {
|
|
51
|
-
|
|
78
|
+
const sonar = s.sonar ? ` · sonar proof: ${s.sonar.proved} proved, ${s.sonar.docsOnly} docs-only, ${s.sonar.granted} granted, ${s.sonar.unproved} unproved` : "";
|
|
79
|
+
return `commits with card ref ${s.commits.withCard}/${s.commits.total} · verdict workspaces root ${s.verdicts.root}/${s.verdicts.stamped || 0} stamped (${s.verdicts.total} total) · source commits without tests ${s.testless.offenders}/${s.testless.sampled}${sonar}`;
|
|
52
80
|
}
|
|
53
81
|
|
|
54
82
|
function createMethodCheck() {
|
|
@@ -59,6 +87,11 @@ function createMethodCheck() {
|
|
|
59
87
|
async detect({ config = {}, projectDir = process.cwd(), run = runCommand } = {}) {
|
|
60
88
|
const stats = await collectMethodStats({ projectDir, run, sample: config.method_gates?.report_sample || 20 });
|
|
61
89
|
const detail = formatMethodStats(stats);
|
|
90
|
+
// KJC-TSK-0838: an approved verdict for code without sonar proof is the
|
|
91
|
+
// method in red, not a trend — the gate should have refused it.
|
|
92
|
+
if (stats.sonar.unproved > 0) {
|
|
93
|
+
return { ok: false, severity: "fail", detail: `${stats.sonar.unproved} approved verdict(s) without sonar proof — ${detail}` };
|
|
94
|
+
}
|
|
62
95
|
const drought = stats.commits.total >= 5 && stats.commits.withCard / stats.commits.total < 0.5;
|
|
63
96
|
if (drought) {
|
|
64
97
|
return { ok: false, severity: "warn", detail: `most recent commits carry no card reference — ${detail}` };
|
package/src/checks/sonar.js
CHANGED
|
@@ -11,11 +11,11 @@ import { isSonarReachable } from "../sonar/manager.js";
|
|
|
11
11
|
import { STRATEGY } from "./types.js";
|
|
12
12
|
import { withDocLink } from "../utils/doc-links.js";
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return
|
|
14
|
+
/** Which config switch turns Sonar off, or null when none does. */
|
|
15
|
+
function disabledSwitch(config) {
|
|
16
|
+
if (config.sonarqube?.enabled === false) return "sonarqube.enabled: false";
|
|
17
|
+
if (config.review_gate?.sonar === false) return "review_gate.sonar: false";
|
|
18
|
+
return null;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function sonarHost(config) {
|
|
@@ -33,8 +33,17 @@ function createSonarStatusCheck() {
|
|
|
33
33
|
strategy: STRATEGY.NONE,
|
|
34
34
|
async detect({ config }) {
|
|
35
35
|
const host = sonarHost(config);
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
// KJC-TSK-0838 (ADR 2026-09-13): Sonar switched off is a DEFECT, not a
|
|
37
|
+
// preference — the commit gate rejects code diffs without a sonar
|
|
38
|
+
// proof, so this config only hurts. Only docs-only diffs are exempt.
|
|
39
|
+
const off = disabledSwitch(config);
|
|
40
|
+
if (off) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
severity: "fail",
|
|
44
|
+
detail: `Disabled in config (${off}) — Sonar is mandatory for code; the commit gate rejects code diffs without a sonar proof`,
|
|
45
|
+
fix: withDocLink(`Set sonarqube.enabled: true and drop review_gate.sonar: false; a laptop without Docker runs 'kj sonar start'. Only a human grant (kj policy grant --rule method.sonar.code) lifts the gate.`, "sonar_docker"),
|
|
46
|
+
};
|
|
38
47
|
}
|
|
39
48
|
let reachable;
|
|
40
49
|
try {
|
|
@@ -13,6 +13,7 @@ import { runOneShotReview } from "../review/one-shot-review.js";
|
|
|
13
13
|
import { runSolomonArbitration } from "../review/solomon-arbitration.js";
|
|
14
14
|
import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
15
15
|
import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
|
|
16
|
+
import { checkSonarRequirement, SONAR_RULE_ID } from "../review/sonar-requirement.js";
|
|
16
17
|
import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
|
|
17
18
|
import { checkCardFirst } from "../review/card-first.js";
|
|
18
19
|
import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
|
|
@@ -53,6 +54,14 @@ async function rawDiff(range, extraArgs = []) {
|
|
|
53
54
|
return res.stdout;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
// KJC-TSK-0838: a grant is the ONLY way past the sonar requirement, so it is
|
|
58
|
+
// said with who gave it, its scope and until when.
|
|
59
|
+
const formatSonarGrant = (g) => {
|
|
60
|
+
const scope = g.origin === "global" ? " GLOBAL" : "";
|
|
61
|
+
const who = g.who?.git ?? "?";
|
|
62
|
+
return `⚠ sonar requirement lifted by a HUMAN grant [${SONAR_RULE_ID}]${scope} until ${g.expiresAt} — ${who}: ${g.justification || "sin justificación"}`;
|
|
63
|
+
};
|
|
64
|
+
|
|
56
65
|
// KJC-TSK-0813 (AC3): la exención dice su PROCEDENCIA — un standing global
|
|
57
66
|
// (concedido para toda la máquina) no pasa por uno del proyecto. El texto
|
|
58
67
|
// de las de proyecto queda EXACTAMENTE como estaba.
|
|
@@ -327,7 +336,25 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
327
336
|
return { ok: true, merge: true, reason: "pure merge commit (MERGE_HEAD, empty staged diff)" };
|
|
328
337
|
}
|
|
329
338
|
}
|
|
330
|
-
|
|
339
|
+
let res = await checkVerdict(projectDir, diff);
|
|
340
|
+
// KJC-TSK-0838: a reviewed diff with code enters only if the verdict's
|
|
341
|
+
// sonar block proves the analysis ran and covered every staged source.
|
|
342
|
+
// The headless pipeline stamps its verdict after its own sonar stage and
|
|
343
|
+
// carries no block yet (step 3 of the card) — said, never assumed silent.
|
|
344
|
+
if (res.ok && res.verdict.host === "kj-pipeline") {
|
|
345
|
+
// The pipeline scans the whole project (no per-file proof yet), so its
|
|
346
|
+
// stamp must at least show the stage RAN; no block or a skipped stage
|
|
347
|
+
// is refused like any other unanalysed code.
|
|
348
|
+
const stage = res.verdict.sonar;
|
|
349
|
+
if (!stage?.ran) {
|
|
350
|
+
const why = stage ? `the pipeline's sonar stage did not run: ${stage.reason || "unknown reason"}` : "the pipeline verdict carries no sonar block";
|
|
351
|
+
res = { ok: false, verdict: res.verdict, reason: `Sonar is mandatory for code — ${why}` };
|
|
352
|
+
}
|
|
353
|
+
} else if (res.ok) {
|
|
354
|
+
const req = checkSonarRequirement({ config, stagedFiles: changedFiles, sonar: res.verdict.sonar, standingExceptions: std.standing });
|
|
355
|
+
if (!req.ok) res = { ok: false, verdict: res.verdict, reason: req.reason };
|
|
356
|
+
else if (req.mode === "granted") console.log(formatSonarGrant(req.grant));
|
|
357
|
+
}
|
|
331
358
|
console.log(res.ok
|
|
332
359
|
? `✓ verdict ok — approved by ${res.verdict.reviewer} (diff ${res.verdict.diffHash.slice(0, 12)})`
|
|
333
360
|
: `✗ ${res.reason}`);
|
|
@@ -344,6 +371,10 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
344
371
|
// without spending reviewer tokens; the rest travel with the task so
|
|
345
372
|
// the cross-AI reviewer weighs them. Unavailable sonar degrades loudly.
|
|
346
373
|
let task = flags.task;
|
|
374
|
+
// KJC-TSK-0838: what sonar saw travels INSIDE the verdict, bound to the
|
|
375
|
+
// diff hash — a reviewed diff and a reviewed AND analysed diff must be
|
|
376
|
+
// distinguishable by --check and by the method report.
|
|
377
|
+
let sonarRecord = { ran: false, reason: "--no-sonar: the pre-gate was skipped by flag" };
|
|
347
378
|
if (flags.sonar !== false) {
|
|
348
379
|
// KJC-TSK-0795 AC3: only issues on lines this diff ADDS may veto.
|
|
349
380
|
const touchedLines = addedLinesByFile(await rawDiff(flags.range, ["--unified=0"]));
|
|
@@ -356,7 +387,15 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
356
387
|
console.log(chosen
|
|
357
388
|
? `⚠ sonar pre-gate skipped: ${pre.reason}`
|
|
358
389
|
: `✗ sonar pre-gate UNAVAILABLE — the quality gate did NOT run on this diff: ${pre.reason}`);
|
|
390
|
+
sonarRecord = { ran: false, reason: pre.reason };
|
|
359
391
|
} else {
|
|
392
|
+
sonarRecord = {
|
|
393
|
+
ran: true, projectKey: pre.projectKey, covered: pre.covered ?? [], uncovered: pre.uncovered ?? [],
|
|
394
|
+
blocking: pre.blocking.length, advisory: pre.advisory.length,
|
|
395
|
+
};
|
|
396
|
+
if (sonarRecord.uncovered.length > 0) {
|
|
397
|
+
console.log(`⚠ sonar coverage: ${sonarRecord.uncovered.length} staged source(s) were NOT in the analysis (${sonarRecord.uncovered.slice(0, 5).join(", ")}${sonarRecord.uncovered.length > 5 ? "…" : ""}) — the scan did not see them`);
|
|
398
|
+
}
|
|
360
399
|
const found = [...pre.blocking, ...pre.advisory];
|
|
361
400
|
if (found.length > 0) {
|
|
362
401
|
console.log(`Sonar on the changed lines — ${pre.blocking.length} blocking, ${pre.advisory.length} advisory (project total: ${pre.totalProject}):`);
|
|
@@ -381,6 +420,20 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
381
420
|
}
|
|
382
421
|
}
|
|
383
422
|
|
|
423
|
+
// KJC-TSK-0838: fail-CLOSED for code. Sonar disabled, down, skipped by
|
|
424
|
+
// flag, or blind to a staged source — the diff does not reach the reviewer.
|
|
425
|
+
// Docs-only diffs pass; a live human grant on the rule is the only escape.
|
|
426
|
+
const sonarReq = checkSonarRequirement({ config, stagedFiles: changedFiles, sonar: sonarRecord, standingExceptions: std.standing });
|
|
427
|
+
if (!sonarReq.ok) {
|
|
428
|
+
console.log(`✗ ${sonarReq.reason}`);
|
|
429
|
+
process.exitCode = 1;
|
|
430
|
+
return { verdict: "rejected", reviewer: "sonar", issues: [{ severity: "high", file: undefined, description: sonarReq.reason }] };
|
|
431
|
+
}
|
|
432
|
+
if (sonarReq.mode === "granted") console.log(formatSonarGrant(sonarReq.grant));
|
|
433
|
+
// The verdict says on what grounds the requirement was met (proof, docs-only,
|
|
434
|
+
// human grant) — the method report reads it back.
|
|
435
|
+
sonarRecord.mode = sonarReq.mode;
|
|
436
|
+
|
|
384
437
|
// MUT-A (KJC-TSK-0716): mutation pre-gate — opt-in (method_gates.mutation),
|
|
385
438
|
// SOLO en --staged (jamás en pre-commit: cuesta minutos; y jamás en --range:
|
|
386
439
|
// el scope es el ÍNDICE y anotaría trabajo ajeno — catch de codex). block
|
|
@@ -403,7 +456,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
403
456
|
}
|
|
404
457
|
}
|
|
405
458
|
|
|
406
|
-
const record = await runOneShotReview({ diff, task, config, logger, projectDir });
|
|
459
|
+
const record = await runOneShotReview({ diff, task, config, logger, projectDir, sonar: sonarRecord });
|
|
407
460
|
printVerdict(record);
|
|
408
461
|
process.exitCode = record.verdict === "approved" ? 0 : 1;
|
|
409
462
|
return record;
|
package/src/git/automation.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { addCheckpoint } from "../session/store.js";
|
|
7
|
-
import { stampStagedVerdict } from "../review/verdict-store.js";
|
|
7
|
+
import { pipelineSonarBlock, stampStagedVerdict } from "../review/verdict-store.js";
|
|
8
8
|
import {
|
|
9
9
|
ensureGitRepo,
|
|
10
10
|
currentBranch,
|
|
@@ -277,6 +277,8 @@ export async function finalizeGitAutomation({ config, gitCtx, task, logger, sess
|
|
|
277
277
|
projectDir: config?.projectDir || process.cwd(),
|
|
278
278
|
reviewer: config?.reviewer || "pipeline-reviewer",
|
|
279
279
|
summary: `kj run session ${session?.id || ""}: reviewer approved`.trim(),
|
|
280
|
+
// KJC-TSK-0838: the sonar stage result travels with the stamp.
|
|
281
|
+
sonar: pipelineSonarBlock(stageResults?.sonar),
|
|
280
282
|
}),
|
|
281
283
|
});
|
|
282
284
|
committed = commitResult.committed;
|
|
@@ -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
|
|
@@ -96,7 +96,7 @@ export async function loadVerdict(projectDir, hash) {
|
|
|
96
96
|
* (KJC-BUG-0115).
|
|
97
97
|
* @returns {Promise<{stamped: boolean}>}
|
|
98
98
|
*/
|
|
99
|
-
export async function stampStagedVerdict({ projectDir, reviewer, summary = "" }) {
|
|
99
|
+
export async function stampStagedVerdict({ projectDir, reviewer, summary = "", sonar = null }) {
|
|
100
100
|
const dir = projectDir || process.cwd();
|
|
101
101
|
try {
|
|
102
102
|
await fs.access(path.join(dir, ".karajan", "review-gate"));
|
|
@@ -107,10 +107,30 @@ export async function stampStagedVerdict({ projectDir, reviewer, summary = "" })
|
|
|
107
107
|
if (res.exitCode !== 0 || !res.stdout?.trim()) return { stamped: false };
|
|
108
108
|
await saveVerdict(dir, res.stdout, {
|
|
109
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 } : {}),
|
|
110
113
|
});
|
|
111
114
|
return { stamped: true };
|
|
112
115
|
}
|
|
113
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
|
+
|
|
114
134
|
/**
|
|
115
135
|
* Is there an APPROVED verdict for exactly this diff?
|
|
116
136
|
* @returns {Promise<{ok: boolean, verdict?: object, reason?: string}>}
|
|
@@ -35,7 +35,9 @@ export class CommiterRole extends BaseRole {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
async execute(input) {
|
|
38
|
-
|
|
38
|
+
// KJC-TSK-0838: the caller hands the sonar stage result (pipelineSonarBlock);
|
|
39
|
+
// without it the stamp carries no proof and the commit gate refuses code.
|
|
40
|
+
const { task, commitMessage, push = false, createPr = false, sonar = null } = input || {};
|
|
39
41
|
|
|
40
42
|
const isRepo = await ensureGitRepo();
|
|
41
43
|
if (!isRepo) {
|
|
@@ -65,6 +67,7 @@ export class CommiterRole extends BaseRole {
|
|
|
65
67
|
projectDir: this.config?.projectDir || process.cwd(),
|
|
66
68
|
reviewer: this.config?.reviewer || "pipeline-reviewer",
|
|
67
69
|
summary: "kj pipeline (commiter role): review passed",
|
|
70
|
+
sonar,
|
|
68
71
|
}),
|
|
69
72
|
});
|
|
70
73
|
const commitHash = await revParse("HEAD");
|
package/src/sonar/scanner.js
CHANGED
|
@@ -37,8 +37,23 @@ export async function pickSonarScanner(scannerConfig = {}, deps = {}) {
|
|
|
37
37
|
return { type: "docker" };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* KJC-TSK-0838: the files the scanner ITSELF reports having indexed
|
|
42
|
+
* (`'<path>' indexed with language '<lang>'`, emitted with sonar.verbose).
|
|
43
|
+
* Paths are relative to the scanned base dir. This is the proof of coverage:
|
|
44
|
+
* what the engine saw, not what the config claims it should see.
|
|
45
|
+
* @param {string} scanOutput @returns {Set<string>}
|
|
46
|
+
*/
|
|
47
|
+
export function indexedFilesFrom(scanOutput) {
|
|
48
|
+
const files = new Set();
|
|
49
|
+
for (const m of String(scanOutput || "").matchAll(/'([^']+)' indexed with language '/g)) files.add(m[1]);
|
|
50
|
+
return files;
|
|
51
|
+
}
|
|
52
|
+
|
|
40
53
|
export function buildScannerOpts(projectKey, scanner = {}) {
|
|
41
54
|
const opts = [`-Dsonar.projectKey=${projectKey}`];
|
|
55
|
+
// KJC-TSK-0838: verbose is what makes the scanner name every indexed file.
|
|
56
|
+
if (scanner.verbose) opts.push("-Dsonar.verbose=true");
|
|
42
57
|
if (scanner.sources) opts.push(`-Dsonar.sources=${scanner.sources}`);
|
|
43
58
|
if (scanner.exclusions) opts.push(`-Dsonar.exclusions=${scanner.exclusions}`);
|
|
44
59
|
if (scanner.test_inclusions) opts.push(`-Dsonar.test.inclusions=${scanner.test_inclusions}`);
|
|
@@ -225,18 +240,27 @@ export async function ensureSonarProjectProperties(cwd = process.cwd()) {
|
|
|
225
240
|
}
|
|
226
241
|
}
|
|
227
242
|
|
|
228
|
-
export async function runSonarScan(config, projectKey = null) {
|
|
243
|
+
export async function runSonarScan(config, projectKey = null, { verbose = false, cwd = null } = {}) {
|
|
244
|
+
const scanCwd = cwd || process.cwd();
|
|
245
|
+
// KJC-TSK-0838 step 3 (monorepo): a nested package scans from its own
|
|
246
|
+
// directory under the key its properties declare — so the proof of
|
|
247
|
+
// coverage is about that package, not the root's `sonar.sources`.
|
|
248
|
+
const repoProps = await ensureSonarProjectProperties(scanCwd);
|
|
229
249
|
let effectiveProjectKey;
|
|
230
|
-
|
|
231
|
-
effectiveProjectKey =
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
250
|
+
if (cwd && !projectKey && repoProps.declaredKey) {
|
|
251
|
+
effectiveProjectKey = repoProps.declaredKey;
|
|
252
|
+
} else {
|
|
253
|
+
try {
|
|
254
|
+
effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey });
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return {
|
|
257
|
+
ok: false,
|
|
258
|
+
projectKey: null,
|
|
259
|
+
stdout: "",
|
|
260
|
+
stderr: error?.message || String(error),
|
|
261
|
+
exitCode: 1
|
|
262
|
+
};
|
|
263
|
+
}
|
|
240
264
|
}
|
|
241
265
|
const sonarConfig = config?.sonarqube || {};
|
|
242
266
|
const rawHost = sonarConfig.host || "http://localhost:9000";
|
|
@@ -260,7 +284,6 @@ export async function runSonarScan(config, projectKey = null) {
|
|
|
260
284
|
}
|
|
261
285
|
// KJC-BUG-0156: kj's -Dsonar.projectKey stays (scan and query must use the
|
|
262
286
|
// SAME key), but the repo's properties own the LAYOUT from here on.
|
|
263
|
-
const repoProps = await ensureSonarProjectProperties();
|
|
264
287
|
const token = await resolveSonarTokenWithFallback(config, apiHost);
|
|
265
288
|
if (!token) {
|
|
266
289
|
return {
|
|
@@ -284,7 +307,8 @@ export async function runSonarScan(config, projectKey = null) {
|
|
|
284
307
|
const scannerConfig = respectRepoProperties(
|
|
285
308
|
normalizeScannerConfig({
|
|
286
309
|
...sonarConfig.scanner,
|
|
287
|
-
...coverage.scannerPatch
|
|
310
|
+
...coverage.scannerPatch,
|
|
311
|
+
...(verbose ? { verbose: true } : {})
|
|
288
312
|
}),
|
|
289
313
|
repoProps
|
|
290
314
|
);
|
|
@@ -310,7 +334,7 @@ export async function runSonarScan(config, projectKey = null) {
|
|
|
310
334
|
"run",
|
|
311
335
|
"--rm",
|
|
312
336
|
"-v",
|
|
313
|
-
`${
|
|
337
|
+
`${scanCwd}:/usr/src`,
|
|
314
338
|
...(isLocalHost ? ["--add-host", "host.docker.internal:host-gateway"] : []),
|
|
315
339
|
...(isLocalHost || isExternalSonar ? [] : ["--network", sonarNetwork]),
|
|
316
340
|
"-e", "SONAR_HOST_URL",
|
|
@@ -320,10 +344,11 @@ export async function runSonarScan(config, projectKey = null) {
|
|
|
320
344
|
];
|
|
321
345
|
}
|
|
322
346
|
|
|
323
|
-
const result = await runCommand(cmd, args, { timeout: scannerTimeout, env });
|
|
347
|
+
const result = await runCommand(cmd, args, { timeout: scannerTimeout, env, cwd: scanCwd });
|
|
324
348
|
return {
|
|
325
349
|
ok: result.exitCode === 0,
|
|
326
350
|
projectKey: effectiveProjectKey,
|
|
351
|
+
cwd: scanCwd,
|
|
327
352
|
scanner: pick.type,
|
|
328
353
|
note,
|
|
329
354
|
stdout: result.stdout,
|