karajan-code 4.51.1 → 4.53.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.
Files changed (69) hide show
  1. package/package.json +2 -1
  2. package/scripts/contract-claude.mjs +24 -0
  3. package/scripts/esbuild-sea.config.mjs +7 -0
  4. package/scripts/record-agent-fixtures.mjs +98 -0
  5. package/src/agents/claude-agent.js +8 -0
  6. package/src/agents/model-registry.js +5 -0
  7. package/src/agents/opencode-agent.js +16 -1
  8. package/src/audit/issue-filter.js +19 -1
  9. package/src/checks/audit-cycle.js +41 -0
  10. package/src/checks/claude-contract-check.js +27 -0
  11. package/src/checks/method.js +3 -2
  12. package/src/checks/release-check.js +23 -1
  13. package/src/checks/sonar.js +30 -12
  14. package/src/checks/supply-chain-check.js +47 -0
  15. package/src/cli/register-meta.js +14 -1
  16. package/src/cli/register-pipeline.js +9 -0
  17. package/src/cli/register-plan.js +10 -0
  18. package/src/commands/check.js +11 -1
  19. package/src/commands/config.js +2 -0
  20. package/src/commands/harden.js +3 -0
  21. package/src/commands/hu.js +24 -3
  22. package/src/commands/identity.js +4 -1
  23. package/src/commands/init.js +34 -1
  24. package/src/commands/plan/generate.js +5 -0
  25. package/src/commands/plan/license.js +35 -0
  26. package/src/commands/plan/ready.js +1 -1
  27. package/src/commands/plan/validate.js +1 -1
  28. package/src/commands/report.js +14 -0
  29. package/src/commands/review-gate.js +26 -2
  30. package/src/commands/worktree.js +35 -2
  31. package/src/config/defaults.js +10 -1
  32. package/src/config/overrides.js +8 -0
  33. package/src/config/schema.js +7 -0
  34. package/src/environment/playbook.js +2 -0
  35. package/src/harden/act-sign.js +2 -2
  36. package/src/harden/branch-protection.js +97 -0
  37. package/src/harden/phone-invite.js +15 -8
  38. package/src/harden/phone-texts.js +17 -0
  39. package/src/harden/security-level.js +18 -2
  40. package/src/harden/sentinel-hooks.js +2 -1
  41. package/src/hu/complexity-tier.js +32 -0
  42. package/src/hu/lane-prune.js +98 -0
  43. package/src/hu/model-router.js +7 -16
  44. package/src/hu/routing-metrics.js +16 -0
  45. package/src/hu/store.js +2 -0
  46. package/src/mcp/handlers/direct-handlers.js +2 -2
  47. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +2 -0
  48. package/src/orchestrator/hu-sub-pipeline.js +19 -0
  49. package/src/orchestrator/stages/hu-reviewer-stage.js +10 -2
  50. package/src/orchestrator/stages/reviewer-stage.js +8 -1
  51. package/src/orchestrator/stages/sonar-stage.js +19 -1
  52. package/src/plan/plan-schema.js +4 -1
  53. package/src/plan/rework-license.js +38 -0
  54. package/src/prompts/hu-reviewer.js +5 -2
  55. package/src/prompts/planner.js +9 -1
  56. package/src/report/model-tally.js +39 -0
  57. package/src/review/branch-overlap.js +40 -0
  58. package/src/review/correction-rounds.js +40 -0
  59. package/src/review/dep-cooldown.js +87 -0
  60. package/src/review/one-shot-review.js +5 -2
  61. package/src/review/parser.js +21 -0
  62. package/src/review/publish-pr.js +54 -0
  63. package/src/review/solomon-arbitration.js +2 -2
  64. package/src/review/verdict-store.js +19 -7
  65. package/src/roles/sonar-role.js +4 -2
  66. package/src/sonar/api.js +7 -0
  67. package/src/utils/injection-guard.js +59 -43
  68. package/src/utils/injection-patterns.json +36 -0
  69. package/src/utils/model-selector.js +47 -3
@@ -0,0 +1,40 @@
1
+ /**
2
+ * KJC-TSK-1052 (TEAM-F): a team on one repo collides in the merge when two
3
+ * branches change the same files. kj warns at review time: the remote branches
4
+ * still in progress (commits outside origin/<base>, active in the last days)
5
+ * whose changes share files with what the commit stages. A warning, never a block.
6
+ */
7
+ import { runCommand } from "../utils/process.js";
8
+
9
+ const MAX_AGE_DAYS = 14;
10
+ const MAX_BRANCHES = 20;
11
+
12
+ /**
13
+ * @returns {Promise<{branch: string, author: string, files: string[]}[]>}
14
+ */
15
+ export async function branchOverlaps({ projectDir, stagedFiles, base = "main", now = Date.now(), run = runCommand }) {
16
+ const git = async (args) => {
17
+ const res = await run("git", ["-C", projectDir, ...args]);
18
+ return res.exitCode === 0 ? res.stdout.trim() : null;
19
+ };
20
+ const baseRef = `origin/${base}`;
21
+ if (stagedFiles.length === 0 || (await git(["rev-parse", "--verify", "--quiet", baseRef])) === null) return [];
22
+ const current = await git(["rev-parse", "--abbrev-ref", "HEAD"]);
23
+ const refs = (await git(["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)|%(committerdate:unix)|%(authorname)", "refs/remotes/origin"])) ?? "";
24
+ const staged = new Set(stagedFiles);
25
+ const candidates = refs.split("\n").filter(Boolean)
26
+ .map((line) => { const [branch, when, author] = line.split("|"); return { branch, when: Number(when) * 1000, author }; })
27
+ .filter((r) => r.branch !== baseRef && r.branch !== "origin/HEAD" && r.branch !== `origin/${current}`)
28
+ .filter((r) => now - r.when < MAX_AGE_DAYS * 86_400_000)
29
+ .slice(0, MAX_BRANCHES);
30
+ const overlaps = [];
31
+ for (const { branch, author } of candidates) {
32
+ if (Number(await git(["rev-list", "--count", `${baseRef}..${branch}`])) === 0) continue;
33
+ const files = ((await git(["diff", "--name-only", `${baseRef}...${branch}`])) ?? "").split("\n").filter((f) => staged.has(f));
34
+ if (files.length > 0) overlaps.push({ branch, author, files });
35
+ }
36
+ return overlaps;
37
+ }
38
+
39
+ /** The kj review warning, one line per branch. */
40
+ export const formatOverlaps = (overlaps) => overlaps.map((o) => `⚠ ${o.branch} (${o.author}) also changes ${o.files.join(", ")}: coordinate before the merge collides`);
@@ -0,0 +1,40 @@
1
+ /**
2
+ * KJC-TSK-1017: a rejection followed by an approval is the method working: the
3
+ * second AI exists to catch what the first one misses, by how models work, not
4
+ * because the coder did it badly. kj names it a review correction with its
5
+ * round count, never a coder error.
6
+ */
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { runCommand } from "../utils/process.js";
10
+
11
+ export const correctionNote = (rounds) => (rounds > 0
12
+ ? `review correction: ${rounds} round(s) before this approval, the cross-AI method working`
13
+ : "");
14
+
15
+ /** Rejections on the branch since its last approval, the current record aside. */
16
+ export function rejectionsBeforeApproval(records, { branch, hash }) {
17
+ const mine = records
18
+ .filter((r) => r.branch === branch && r.diffHash !== hash)
19
+ .toSorted((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
20
+ let rounds = 0;
21
+ for (const r of mine) rounds = r.verdict === "approved" ? 0 : rounds + 1;
22
+ return rounds;
23
+ }
24
+
25
+ export async function branchOf(projectDir, run = runCommand) {
26
+ const res = await run("git", ["-C", projectDir, "rev-parse", "--abbrev-ref", "HEAD"]);
27
+ return res.exitCode === 0 ? res.stdout.trim() : null;
28
+ }
29
+
30
+ export async function reviewCorrectionRounds(projectDir, record) {
31
+ if (!record?.branch) return 0;
32
+ const dir = path.join(projectDir, ".karajan", "reviews");
33
+ const names = await fs.readdir(dir).catch(() => []);
34
+ const records = [];
35
+ for (const name of names.filter((n) => n.endsWith(".json"))) {
36
+ // A report, not a gate: an unreadable verdict file is left out of the count.
37
+ try { records.push(JSON.parse(await fs.readFile(path.join(dir, name), "utf8"))); } catch { /* see above */ }
38
+ }
39
+ return rejectionsBeforeApproval(records, { branch: record.branch, hash: record.diffHash });
40
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * KJC-TSK-0722 (step 1): supply-chain campaigns (Shai-Hulud, CanisterWorm) are
3
+ * caught in their first hours, so a dependency published minutes ago is the
4
+ * riskiest one to take. kj reads what a package-lock.json diff installs and
5
+ * asks npm how old each version is. A signal, never a crash: without the
6
+ * registry, the package is listed as unverified with the reason.
7
+ */
8
+ import { runCommand } from "../utils/process.js";
9
+
10
+ export const DEFAULT_COOLDOWN_HOURS = 72;
11
+ // npm view per package: a lockfile refresh of hundreds is said, not queried one by one.
12
+ const MAX_QUERIES = 40;
13
+
14
+ const KEY = /^[ +]\s*"(?:[^"]*\/)?node_modules\/((?:@[^/"]+\/)?[^/"]+)":\s*\{/;
15
+ const VERSION = /^\+\s*"version":\s*"([^"]+)"/;
16
+
17
+ /** The packages a diff of package-lock.json adds or moves to another version. */
18
+ export function lockfileChanges(diff) {
19
+ const changes = [];
20
+ for (const chunk of String(diff ?? "").split(/^diff --git /m)) {
21
+ if (!/^a\/(?:\S*\/)?package-lock\.json /.test(chunk)) continue;
22
+ let current = null;
23
+ for (const line of chunk.split("\n")) {
24
+ const key = KEY.exec(line);
25
+ if (key) { current = key[1]; continue; }
26
+ const version = VERSION.exec(line);
27
+ if (version && current) { changes.push({ name: current, version: version[1] }); current = null; }
28
+ }
29
+ }
30
+ return changes;
31
+ }
32
+
33
+ async function publishedAt({ name, version }, run) {
34
+ const res = await run("npm", ["view", `${name}@${version}`, "time", "--json"]);
35
+ if (res.exitCode !== 0) throw new Error((res.stderr || res.stdout || "npm view failed").trim());
36
+ const when = Date.parse(JSON.parse(res.stdout || "{}")[version]);
37
+ if (Number.isNaN(when)) throw new Error(`npm gives no publish time for ${version}`);
38
+ return when;
39
+ }
40
+
41
+ export const COOLDOWN_RULE_ID = "method.dep_cooldown";
42
+
43
+ /**
44
+ * KJC-TSK-1056: with method_gates.dep_cooldown: block, a young dependency stops
45
+ * the review; only a live human grant on method.dep_cooldown lifts it. What
46
+ * could not be verified (no registry) never blocks: a false positive would
47
+ * stop legitimate work for a network blip.
48
+ */
49
+ export function cooldownGate({ result, mode, standingExceptions = [], now = new Date() }) {
50
+ if (mode !== "block" || result.young.length === 0) return { block: false };
51
+ const grant = standingExceptions.find((e) => e.rule_id === COOLDOWN_RULE_ID && new Date(e.expiresAt).getTime() > now.getTime());
52
+ if (grant) return { block: false, grant };
53
+ const list = result.young.map((p) => `${p.name}@${p.version} (${p.hours} h)`).join(", ");
54
+ return { block: true, reason: `${result.young.length} dependency(ies) under the cooldown: ${list} (method_gates.dep_cooldown: block). Wait it out, pin an older version, or a human lifts it: kj policy grant --rule ${COOLDOWN_RULE_ID} --until <iso> --reason "<why>"` };
55
+ }
56
+
57
+ /** What kj review prints, and the note the reviewer gets; empty when there is nothing to say. */
58
+ export function formatCooldown({ young, unverified, skipped = 0 }, thresholdHours = DEFAULT_COOLDOWN_HOURS) {
59
+ const lines = [
60
+ ...young.map((p) => `⚠ supply-chain cooldown: ${p.name}@${p.version} was published ${p.hours} h ago (under ${thresholdHours} h): campaigns are caught in their first hours`),
61
+ ...unverified.map((p) => `⚠ supply-chain cooldown: ${p.name}@${p.version} unverified (${p.reason})`),
62
+ ...(skipped > 0 ? [`⚠ supply-chain cooldown: ${skipped} packages changed (a regenerated lockfile?), too many to check one by one: none was checked`] : []),
63
+ ];
64
+ const note = young.length === 0 ? "" : `Dependencies published under ${thresholdHours} h ago (weigh them as a supply-chain risk): ${young.map((p) => `${p.name}@${p.version} (${p.hours} h)`).join(", ")}`;
65
+ return { lines, note };
66
+ }
67
+
68
+ /**
69
+ * Over MAX_QUERIES changed packages (a regenerated lockfile) none is queried:
70
+ * one line says so, instead of a line per package and a call per package.
71
+ * @returns {Promise<{young: {name, version, hours}[], unverified: {name, version, reason}[], skipped?: number}>}
72
+ */
73
+ export async function cooldownAdvisories(diff, { thresholdHours = DEFAULT_COOLDOWN_HOURS, run = runCommand, now = Date.now() } = {}) {
74
+ const changes = lockfileChanges(diff);
75
+ if (changes.length > MAX_QUERIES) return { young: [], unverified: [], skipped: changes.length };
76
+ const young = [];
77
+ const unverified = [];
78
+ for (const pkg of changes) {
79
+ try {
80
+ const hours = Math.floor((now - await publishedAt(pkg, run)) / 3_600_000);
81
+ if (hours < thresholdHours) young.push({ ...pkg, hours });
82
+ } catch (err) {
83
+ unverified.push({ ...pkg, reason: err.message });
84
+ }
85
+ }
86
+ return { young, unverified };
87
+ }
@@ -12,12 +12,13 @@ import { createAgent } from "../agents/index.js";
12
12
  import { resolveRole } from "../config/role-resolver.js";
13
13
  import { buildReviewerPrompt } from "../prompts/reviewer.js";
14
14
  import { resolveReviewProfile } from "./profiles.js";
15
- import { parseMaybeJsonString, normalizeReviewPayload } from "./parser.js";
15
+ import { parseReviewVerdict } from "./parser.js";
16
16
  import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
17
17
  import { saveVerdict, diffHash } from "./verdict-store.js";
18
18
  import { summarizeGeneratedFiles } from "./prompt-diff.js";
19
19
  import { reportUnparseableVerdict } from "./unparseable-verdict.js";
20
20
  import { detectWorkspace } from "./workspace.js";
21
+ import { branchOf } from "./correction-rounds.js";
21
22
  import { isQuotaExhausted, candidateStatus, pickQuotaFallback, formatCandidateMenu } from "./reviewer-fallback.js";
22
23
 
23
24
  // Cross-AI preference when the configured reviewer IS the host.
@@ -116,7 +117,7 @@ export async function runOneShotReview({
116
117
  // back, wrapper and all. Yesterday's fix taught normalizeReviewPayload to unwrap {ok, result},
117
118
  // but this path never called it, so the bug survived with a green test on the wrong function.
118
119
  // A test can only prove the code it actually exercises.
119
- const parsed = normalizeReviewPayload(parseMaybeJsonString(result.output));
120
+ const parsed = parseReviewVerdict(result.output);
120
121
  if (!parsed || typeof parsed.approved !== "boolean") {
121
122
  // KJC-BUG-0146: keep the answer. Throwing it away is what left eight occurrences undiagnosed.
122
123
  throw new Error(await reportUnparseableVerdict({ projectDir, reviewer: activeReviewer, output: result.output, hash: diffHash(diff) }));
@@ -128,6 +129,8 @@ export async function runOneShotReview({
128
129
  host: hostAgent || null,
129
130
  // KJC-TSK-0680: where the review ran — makes any isolation claim auditable.
130
131
  workspace: await detectWorkspace(projectDir),
132
+ // KJC-TSK-1017: the branch lets an approval count the corrections before it.
133
+ branch: await branchOf(projectDir),
131
134
  issues: parsed.blocking_issues || [],
132
135
  suggestions: parsed.non_blocking_suggestions || [],
133
136
  summary: parsed.summary || parsed.raw_summary || "",
@@ -10,6 +10,27 @@ export function parseMaybeJsonString(value) {
10
10
  return extractFirstJson(value);
11
11
  }
12
12
 
13
+ /**
14
+ * KJC-BUG-0357 (#2151): claude's reviewTask answers with its whole stream-json
15
+ * event stream, which opens with hook and init events, so its first JSON is no
16
+ * answer. The answer is the text of the final `result` event; any other output
17
+ * is returned as it came.
18
+ */
19
+ export function agentAnswerText(raw) {
20
+ if (typeof raw !== "string") return raw;
21
+ const lines = raw.trim().split("\n");
22
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
23
+ const event = extractFirstJson(lines[i]);
24
+ if (event?.type === "result" && typeof event.result === "string") return event.result;
25
+ }
26
+ return raw;
27
+ }
28
+
29
+ /** The review verdict in an agent's answer, or null: the one path kj review reads it by. */
30
+ export function parseReviewVerdict(output) {
31
+ return normalizeReviewPayload(parseMaybeJsonString(agentAnswerText(output)));
32
+ }
33
+
13
34
  function isReviewPayload(obj) {
14
35
  return obj?.approved !== undefined && obj?.blocking_issues !== undefined;
15
36
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * KJC-TSK-0913: the cross-AI verdict kj holds for each commit of a PR, published
3
+ * on the PR as ONE GitHub review (event COMMENT), so the review that already
4
+ * happened counts in reviews.totalCount instead of looking like a blind merge.
5
+ * A loose conversation comment does not count, so it is never used.
6
+ */
7
+ import { runCommand } from "../utils/process.js";
8
+ import { diffHash, loadVerdict } from "./verdict-store.js";
9
+
10
+ const MARKER = (head) => `<!-- kj-review head=${head} -->`;
11
+
12
+ // gh runs in the project's own directory, so it reads and writes the same repo the verdicts belong to.
13
+ async function json(run, cmd, args, cwd) {
14
+ const res = await run(cmd, args, { cwd });
15
+ if (res.exitCode !== 0) throw new Error(`${cmd} ${args.slice(0, 3).join(" ")} failed: ${(res.stderr || res.stdout || "").trim()}`);
16
+ return JSON.parse(res.stdout || "null");
17
+ }
18
+
19
+ /** The verdict of a commit: the one recorded for the diff it brought, staged byte for byte. */
20
+ async function verdictOf(projectDir, sha, run) {
21
+ const res = await run("git", ["-C", projectDir, "diff", `${sha}^`, sha]);
22
+ return res.exitCode === 0 && res.stdout ? loadVerdict(projectDir, diffHash(res.stdout)) : null;
23
+ }
24
+
25
+ /**
26
+ * @returns {Promise<{published: boolean, commits?: number, reason?: string}>}
27
+ */
28
+ export async function publishPrReview({ projectDir, pr, run = runCommand }) {
29
+ const info = await json(run, "gh", ["pr", "view", String(pr), "--json", "headRefOid,commits"], projectDir);
30
+ const rows = [];
31
+ const uncovered = [];
32
+ for (const c of info.commits ?? []) {
33
+ const v = await verdictOf(projectDir, c.oid, run);
34
+ if (v?.verdict === "approved") rows.push({ ...c, v });
35
+ else uncovered.push(`${c.oid.slice(0, 7)} (${c.messageHeadline})`);
36
+ }
37
+ if (uncovered.length > 0) throw new Error(`PR #${pr}: ${uncovered.length} commit(s) without an approved verdict of their exact diff, nothing published: ${uncovered.join(", ")}`);
38
+ const reviews = await json(run, "gh", ["api", `repos/{owner}/{repo}/pulls/${pr}/reviews`], projectDir);
39
+ if ((reviews ?? []).some((r) => String(r.body ?? "").includes(MARKER(info.headRefOid)))) {
40
+ return { published: false, reason: `a kj review for head ${info.headRefOid.slice(0, 7)} is already published` };
41
+ }
42
+ const body = [
43
+ `Cross-AI review by Karajan, one verdict per commit, each bound to the exact diff it brought.`,
44
+ "",
45
+ ...rows.map((r) => {
46
+ const summary = r.v.summary ? ". " + r.v.summary : "";
47
+ return `- \`${r.oid.slice(0, 7)}\` ${r.messageHeadline}: approved by ${r.v.reviewer} (diff ${r.v.diffHash.slice(0, 12)})${summary}`;
48
+ }),
49
+ "",
50
+ MARKER(info.headRefOid),
51
+ ].join("\n");
52
+ await json(run, "gh", ["api", "-X", "POST", `repos/{owner}/{repo}/pulls/${pr}/reviews`, "-f", "event=COMMENT", "-f", `commit_id=${info.headRefOid}`, "-f", `body=${body}`], projectDir);
53
+ return { published: true, commits: rows.length };
54
+ }
@@ -13,7 +13,7 @@
13
13
  * reject keeps it closed. Both record the full conflict for audit.
14
14
  */
15
15
  import { createAgent } from "../agents/index.js";
16
- import { parseMaybeJsonString } from "./parser.js";
16
+ import { parseMaybeJsonString, agentAnswerText } from "./parser.js";
17
17
  import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
18
18
  import { diffHash, loadVerdict, saveVerdict } from "./verdict-store.js";
19
19
  import { summarizeGeneratedFiles } from "./prompt-diff.js";
@@ -95,7 +95,7 @@ export async function runSolomonArbitration({
95
95
  : `No other agent CLI on this machine can arbitrate (needs one distinct from brain "${hostAgent}" and reviewer "${verdict.reviewer}") — install a third agent CLI or fix ${solomon}.`;
96
96
  throw new Error(`solomon ${solomon} failed: ${String(result?.error || "no output").slice(0, 400)}\n${hint}`);
97
97
  }
98
- const parsed = parseMaybeJsonString(result.output);
98
+ const parsed = parseMaybeJsonString(agentAnswerText(result.output));
99
99
  if (!parsed || !["approve", "reject"].includes(parsed.ruling)) {
100
100
  throw new Error(`solomon ${solomon} returned no parseable ruling`);
101
101
  }
@@ -14,6 +14,7 @@ import fs from "node:fs/promises";
14
14
  import path from "node:path";
15
15
  import { runCommand } from "../utils/process.js";
16
16
  import { kjStamp, stampDiffers, stampLabel } from "./kj-provenance.js";
17
+ import { sourceFilesOf } from "./tests-with-code.js";
17
18
 
18
19
  const STORE_DIR = path.join(".karajan", "reviews");
19
20
 
@@ -107,13 +108,15 @@ export async function stampStagedVerdict({ projectDir, reviewer, summary = "", s
107
108
  }
108
109
  // KJC-BUG-0294: the pipeline commits by path (`--only`); the verdict must bind
109
110
  // to that diff, not to other files the person had staged.
110
- const res = await runCommand("git", ["diff", "--cached", ...(paths?.length ? ["--", ...paths] : [])], { cwd: dir });
111
+ const pathArgs = paths?.length ? ["--", ...paths] : [];
112
+ const res = await runCommand("git", ["diff", "--cached", ...pathArgs], { cwd: dir });
111
113
  if (res.exitCode !== 0 || !res.stdout?.trim()) return { stamped: false };
114
+ // A deleted file cannot be indexed, so it never counts as a source to prove.
115
+ const names = sonar?.indexed ? (await runCommand("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR", ...pathArgs], { cwd: dir })).stdout.split("\n").filter(Boolean) : [];
112
116
  await saveVerdict(dir, res.stdout, {
113
117
  verdict: "approved", reviewer, host: "kj-pipeline", issues: [], summary,
114
- // KJC-TSK-0838: what the pipeline's sonar stage saw, so --check can tell
115
- // a run whose gate ran from one that skipped it.
116
- ...(sonar ? { sonar } : {}),
118
+ // KJC-TSK-0838/0839: what the pipeline's sonar stage saw, proved per staged source.
119
+ ...(sonar ? { sonar: coverageFor(sonar, names) } : {}),
117
120
  });
118
121
  return { stamped: true };
119
122
  }
@@ -122,8 +125,8 @@ export async function stampStagedVerdict({ projectDir, reviewer, summary = "", s
122
125
  * KJC-TSK-0838: the pipeline sonar stage result as a verdict sonar block.
123
126
  * SKIPPED (no remote, no key) or absent is `ran:false` WITH the reason — the
124
127
  * pre-commit check then refuses code, as it would for any other diff.
125
- * Coverage lists stay empty: the pipeline scans the whole project, and the
126
- * per-file proof (verbose index) is not wired into SonarRole yet.
128
+ * KJC-TSK-0839: a stage that ran carries the files its verbose scan indexed;
129
+ * stampStagedVerdict turns them into covered/uncovered for the staged sources.
127
130
  */
128
131
  export function pipelineSonarBlock(stageResult) {
129
132
  if (!stageResult) return { ran: false, source: "pipeline", reason: "the pipeline recorded no sonar stage" };
@@ -131,10 +134,19 @@ export function pipelineSonarBlock(stageResult) {
131
134
  return { ran: false, source: "pipeline", reason: stageResult.reason || stageResult.error || "the sonar stage did not run" };
132
135
  }
133
136
  return {
134
- ran: true, source: "pipeline", projectKey: stageResult.projectKey || null, gateStatus: stageResult.gateStatus, covered: [], uncovered: [],
137
+ ran: true, source: "pipeline", projectKey: stageResult.projectKey || null, gateStatus: stageResult.gateStatus, indexed: stageResult.indexedFiles ?? [],
135
138
  };
136
139
  }
137
140
 
141
+ /** KJC-TSK-0839: the per-source proof of a pipeline block, against the files this commit stages. */
142
+ function coverageFor(sonar, stagedNames) {
143
+ if (!sonar?.ran || !Array.isArray(sonar.indexed)) return sonar;
144
+ const { indexed, ...block } = sonar;
145
+ const seen = new Set(indexed);
146
+ const { sources } = sourceFilesOf({}, stagedNames);
147
+ return { ...block, covered: sources.filter((f) => seen.has(f)), uncovered: sources.filter((f) => !seen.has(f)) };
148
+ }
149
+
138
150
  /**
139
151
  * Is there an APPROVED verdict for exactly this diff?
140
152
  * @returns {Promise<{ok: boolean, verdict?: object, reason?: string}>}
@@ -1,5 +1,5 @@
1
1
  import { BaseRole } from "./base-role.js";
2
- import { runSonarScan } from "../sonar/scanner.js";
2
+ import { indexedFilesFrom, runSonarScan } from "../sonar/scanner.js";
3
3
  import { getQualityGateStatus, getOpenIssues } from "../sonar/api.js";
4
4
  import { shouldBlockByProfile, summarizeIssues } from "../sonar/enforcer.js";
5
5
 
@@ -20,7 +20,8 @@ export class SonarRole extends BaseRole {
20
20
  }
21
21
 
22
22
  async execute(_input) {
23
- const scan = await runSonarScan(this.config);
23
+ // KJC-TSK-0839: verbose, so the scanner names every file it indexed.
24
+ const scan = await runSonarScan(this.config, null, { verbose: true });
24
25
 
25
26
  if (!scan.ok) {
26
27
  return {
@@ -53,6 +54,7 @@ export class SonarRole extends BaseRole {
53
54
  ok: !blocking,
54
55
  result: {
55
56
  projectKey: scan.projectKey,
57
+ indexedFiles: [...indexedFilesFrom(scan.stdout)],
56
58
  gateStatus: gate.status,
57
59
  conditions: gate.conditions || [],
58
60
  issues,
package/src/sonar/api.js CHANGED
@@ -124,6 +124,13 @@ export async function getQualityGateStatus(config, projectKey = null) {
124
124
  }
125
125
  }
126
126
 
127
+ /** KJC-TSK-1051 (TEAM-E): the names of the quality profiles the project runs on. */
128
+ export async function getProjectQualityProfiles(config, projectKey = null) {
129
+ const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey, cwd: queryCwd(config) });
130
+ const body = await sonarFetch(config, `/api/qualityprofiles/search?project=${encodeURIComponent(effectiveProjectKey)}`);
131
+ return (JSON.parse(body).profiles ?? []).map((p) => p.name);
132
+ }
133
+
127
134
  export async function getOpenIssues(config, projectKey = null) {
128
135
  const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey, cwd: queryCwd(config) });
129
136
  // KJC-BUG-0339 (#2099): current SonarQube ignores projectKeys and answered with
@@ -8,52 +8,52 @@
8
8
  * @module utils/injection-guard
9
9
  */
10
10
 
11
- /** Phrases that attempt to override system/reviewer instructions. */
12
- const DIRECTIVE_PATTERNS = [
13
- /ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|rules?|prompts?|guidelines?)/i,
14
- /disregard\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|rules?|prompts?)/i,
15
- /forget\s+(all\s+)?(previous|prior|your)\s+(instructions?|rules?|context)/i,
16
- /override\s+(the\s+)?(system|reviewer|review)\s+(prompt|instructions?|rules?)/i,
17
- /do\s+not\s+follow\s+(the\s+)?(above|previous|system)\s+(instructions?|rules?)/i,
18
- /new\s+instructions?\s*:/i,
19
- /system\s*:\s*you\s+are/i,
20
- /\bact\s+as\s+(a\s+)?(different|new|my|an?\s+)/i,
21
- /you\s+are\s+now\s+(a\s+)?/i,
22
- /from\s+now\s+on\s*(,|\s)?\s*you\s+(will|should|must|are)/i,
23
- /pretend\s+(you\s+are|to\s+be)/i,
24
- /respond\s+(only\s+)?with\s+["']?approved["']?/i,
25
- /always\s+(return|respond|output)\s+.*approved/i,
26
- /set\s+approved\s*[=:]\s*true/i,
27
- /\bapproved["']?\s*:\s*true\b.*ignore/i,
28
- /output\s+the\s+following\s+json/i,
29
- /return\s+this\s+exact\s+(json|response|output)/i,
30
- ];
31
-
32
- /** Unicode categories that can hide or disguise content. */
33
- const UNICODE_PATTERNS = [
34
- /[\u200B-\u200F]/, // zero-width spaces, LTR/RTL marks
35
- /[\u202A-\u202E]/, // bidi embedding/override
36
- /[\u2066-\u2069]/, // bidi isolate
37
- /[\uFEFF]/, // BOM mid-text
38
- /[\u00AD]{3,}/, // repeated soft hyphens
39
- /[\u2060-\u2064]/, // invisible operators
40
- /[\u{E0000}-\u{E007F}]/u, // tag characters
41
- ];
42
-
43
- /** Maximum comment block size (chars) before flagging. */
44
- const MAX_COMMENT_BLOCK = 2000;
11
+ import { readFileSync } from "node:fs";
45
12
 
46
13
  /**
47
- * Comment block patterns for common languages.
48
- * Each regex captures the full block content.
14
+ * KJC-TSK-0703: the patterns live in injection-patterns.json, the family's
15
+ * versioned catalog (karajan-radar reads it from Python). Only constructs both
16
+ * JavaScript RegExp and Python re understand are accepted.
49
17
  */
50
- const COMMENT_BLOCK_RE = [
51
- /\/\*[\s\S]{0,20000}?\*\//g, // C-style /* ... */
52
- /<!--[\s\S]{0,20000}?-->/g, // HTML <!-- ... -->
53
- /"""\s*[\s\S]{0,20000}?\s*"""/g, // Python docstrings
54
- /'''\s*[\s\S]{0,20000}?\s*'''/g, // Python docstrings (single)
55
- /=begin[\s\S]{0,20000}?=end/g, // Ruby block comments
18
+ const NON_PORTABLE = [
19
+ [/\\u\{/, "\\u{...} escapes (put the literal character in the JSON instead)"],
20
+ [/\\[pP]\{/, "\\p{...} property escapes"],
21
+ [/\(\?<(?![=!])/, "a named group (Python spells it (?P<name>)"],
22
+ [/\\k</, "a named backreference"],
56
23
  ];
24
+ const PORTABLE_FLAGS = new Set(["i", "m", "s", "u"]);
25
+
26
+ /** Throws, naming the pattern, when an entry would not work outside JavaScript. */
27
+ export function assertPortable({ id, expression, flags = "" }) {
28
+ for (const flag of flags) {
29
+ if (!PORTABLE_FLAGS.has(flag)) throw new Error(`${id}: flag "${flag}" is not portable (only i, m, s, u)`);
30
+ }
31
+ for (const [construct, what] of NON_PORTABLE) {
32
+ if (construct.test(expression)) throw new Error(`${id}: ${what} is not portable: ${expression}`);
33
+ }
34
+ return new RegExp(expression, flags);
35
+ }
36
+
37
+ /** The catalog compiled per type; a missing or broken catalog fails loudly, never scans empty. */
38
+ // The SEA bundle has no sibling files: scripts/esbuild-sea.config.mjs inlines this exact read.
39
+ const readCatalog = () => readFileSync(new URL("./injection-patterns.json", import.meta.url), "utf8");
40
+
41
+ export function loadInjectionCatalog() {
42
+ const catalog = JSON.parse(readCatalog());
43
+ const compiled = { directive: [], unicode: [], comment_block: [], maxCommentBlock: catalog.max_comment_block, version: catalog.version };
44
+ for (const entry of catalog.patterns) {
45
+ const re = assertPortable(entry);
46
+ // Comment blocks are walked match by match: the global flag is the engine's, not the catalog's.
47
+ compiled[entry.type].push(entry.type === "comment_block" ? new RegExp(re.source, `${re.flags}g`) : re);
48
+ }
49
+ return compiled;
50
+ }
51
+
52
+ const CATALOG = loadInjectionCatalog();
53
+ const DIRECTIVE_PATTERNS = CATALOG.directive;
54
+ const UNICODE_PATTERNS = CATALOG.unicode;
55
+ const MAX_COMMENT_BLOCK = CATALOG.maxCommentBlock;
56
+ const COMMENT_BLOCK_RE = CATALOG.comment_block;
57
57
 
58
58
  /**
59
59
  * @typedef {Object} InjectionFinding
@@ -118,7 +118,7 @@ export function scanForInjection(text, opts = {}) {
118
118
  for (const re of COMMENT_BLOCK_RE) {
119
119
  let m;
120
120
  while ((m = re.exec(text)) !== null) {
121
- if (m[0].length > maxBlock) {
121
+ if (proseLength(m[0]) > maxBlock) {
122
122
  findings.push({
123
123
  type: "comment_block",
124
124
  pattern: `block ${m[0].length} chars (max ${maxBlock})`,
@@ -175,6 +175,22 @@ export function scanDiff(diff, opts = {}) {
175
175
 
176
176
  // --- Helpers ---
177
177
 
178
+ /**
179
+ * KJC-TSK-0743: the size of a comment block that counts against the limit. In a
180
+ * JSDoc block (`/**`), tag lines (`* @typedef`, `* @property`...) are structure,
181
+ * not prose: they are left out unless a single one runs past 300 chars. Any
182
+ * instruction inside a tag line is still caught by the directive patterns.
183
+ */
184
+ const JSDOC_TAG_LINE = /^\s*\*\s*@[A-Za-z]+/;
185
+ const MAX_TAG_LINE = 300;
186
+ function proseLength(block) {
187
+ if (!block.startsWith("/**")) return block.length;
188
+ return block
189
+ .split("\n")
190
+ .filter((line) => !(JSDOC_TAG_LINE.test(line) && line.length <= MAX_TAG_LINE))
191
+ .join("\n").length;
192
+ }
193
+
178
194
  function excerpt(text, index) {
179
195
  const start = Math.max(0, index - 20);
180
196
  const end = Math.min(text.length, index + 100);
@@ -0,0 +1,36 @@
1
+ {
2
+ "$comment": "KJC-TSK-0703: the canonical prompt-injection catalog of the karajan family. Data, not code: each language keeps only its small scanning engine. Expressions must stay portable (JavaScript RegExp and Python re): invisible characters go as literal characters (JSON escapes), never as regex escapes like \\u{...}; flags are only i, m, s, u. Bump version on any change.",
3
+ "version": "1.0.0",
4
+ "max_comment_block": 2000,
5
+ "patterns": [
6
+ { "id": "directive-ignore-previous", "type": "directive", "expression": "ignore\\s+(all\\s+)?(previous|prior|above|earlier)\\s+(instructions?|rules?|prompts?|guidelines?)", "flags": "i", "severity": "high", "why": "Tells the model to drop the instructions it was given, the classic override." },
7
+ { "id": "directive-disregard-previous", "type": "directive", "expression": "disregard\\s+(all\\s+)?(previous|prior|above|earlier)\\s+(instructions?|rules?|prompts?)", "flags": "i", "severity": "high", "why": "Same override as ignore-previous, worded to slip past a single keyword." },
8
+ { "id": "directive-forget-instructions", "type": "directive", "expression": "forget\\s+(all\\s+)?(previous|prior|your)\\s+(instructions?|rules?|context)", "flags": "i", "severity": "high", "why": "Asks the model to wipe its context or rules before obeying new ones." },
9
+ { "id": "directive-override-system", "type": "directive", "expression": "override\\s+(the\\s+)?(system|reviewer|review)\\s+(prompt|instructions?|rules?)", "flags": "i", "severity": "high", "why": "Names the system or reviewer prompt as something to replace." },
10
+ { "id": "directive-do-not-follow", "type": "directive", "expression": "do\\s+not\\s+follow\\s+(the\\s+)?(above|previous|system)\\s+(instructions?|rules?)", "flags": "i", "severity": "high", "why": "Negates the instructions above it, steering the model away from its task." },
11
+ { "id": "directive-new-instructions", "type": "directive", "expression": "new\\s+instructions?\\s*:", "flags": "i", "severity": "medium", "why": "Announces a fresh instruction block inside content that should carry none." },
12
+ { "id": "directive-system-you-are", "type": "directive", "expression": "system\\s*:\\s*you\\s+are", "flags": "i", "severity": "high", "why": "Forges a system message that redefines who the model is." },
13
+ { "id": "directive-act-as", "type": "directive", "expression": "\\bact\\s+as\\s+(a\\s+)?(different|new|my|an?\\s+)", "flags": "i", "severity": "medium", "why": "Role reassignment: asks the model to become another persona." },
14
+ { "id": "directive-you-are-now", "type": "directive", "expression": "you\\s+are\\s+now\\s+(a\\s+)?", "flags": "i", "severity": "medium", "why": "Role reassignment phrased as a fact the model should accept." },
15
+ { "id": "directive-from-now-on", "type": "directive", "expression": "from\\s+now\\s+on\\s*(,|\\s)?\\s*you\\s+(will|should|must|are)", "flags": "i", "severity": "medium", "why": "Installs a standing behaviour change for the rest of the conversation." },
16
+ { "id": "directive-pretend", "type": "directive", "expression": "pretend\\s+(you\\s+are|to\\s+be)", "flags": "i", "severity": "medium", "why": "Role-play framing used to step around the model's instructions." },
17
+ { "id": "directive-respond-approved", "type": "directive", "expression": "respond\\s+(only\\s+)?with\\s+[\"']?approved[\"']?", "flags": "i", "severity": "high", "why": "Dictates the reviewer's verdict instead of letting it judge the change." },
18
+ { "id": "directive-always-approved", "type": "directive", "expression": "always\\s+(return|respond|output)\\s+.*approved", "flags": "i", "severity": "high", "why": "Fixes the output to approved whatever the content says." },
19
+ { "id": "directive-set-approved", "type": "directive", "expression": "set\\s+approved\\s*[=:]\\s*true", "flags": "i", "severity": "high", "why": "Tries to write the verdict field directly." },
20
+ { "id": "directive-approved-true-ignore", "type": "directive", "expression": "\\bapproved[\"']?\\s*:\\s*true\\b.*ignore", "flags": "i", "severity": "high", "why": "A forged approved verdict next to an instruction to ignore something." },
21
+ { "id": "directive-output-json", "type": "directive", "expression": "output\\s+the\\s+following\\s+json", "flags": "i", "severity": "medium", "why": "Hands the model a ready answer to emit instead of its own." },
22
+ { "id": "directive-return-exact", "type": "directive", "expression": "return\\s+this\\s+exact\\s+(json|response|output)", "flags": "i", "severity": "medium", "why": "Same as output-json: a scripted response smuggled into the content." },
23
+ { "id": "unicode-zero-width", "type": "unicode", "expression": "[\u200B-\u200F]", "flags": "", "severity": "high", "why": "Zero-width spaces and LTR/RTL marks hide text a human reviewer cannot see." },
24
+ { "id": "unicode-bidi-embedding", "type": "unicode", "expression": "[\u202A-\u202E]", "flags": "", "severity": "high", "why": "Bidi embedding and override characters reorder what is displayed (Trojan Source)." },
25
+ { "id": "unicode-bidi-isolate", "type": "unicode", "expression": "[\u2066-\u2069]", "flags": "", "severity": "high", "why": "Bidi isolates, the other half of the Trojan Source trick." },
26
+ { "id": "unicode-bom-midtext", "type": "unicode", "expression": "[\uFEFF]", "flags": "", "severity": "medium", "why": "A byte order mark in the middle of text is invisible and has no honest use there." },
27
+ { "id": "unicode-soft-hyphens", "type": "unicode", "expression": "[\u00AD]{3,}", "flags": "", "severity": "low", "why": "Runs of soft hyphens split words so keyword filters miss them." },
28
+ { "id": "unicode-invisible-operators", "type": "unicode", "expression": "[\u2060-\u2064]", "flags": "", "severity": "medium", "why": "Word joiner and invisible operators carry no visible glyph." },
29
+ { "id": "unicode-tag-characters", "type": "unicode", "expression": "[\uDB40\uDC00-\uDB40\uDC7F]", "flags": "u", "severity": "high", "why": "Unicode tag characters (U+E0000 to U+E007F) can encode a whole hidden instruction." },
30
+ { "id": "comment-block-c", "type": "comment_block", "expression": "/\\*[\\s\\S]{0,20000}?\\*/", "flags": "", "severity": "low", "why": "An oversized C-style comment can hide a payload addressed to the model." },
31
+ { "id": "comment-block-html", "type": "comment_block", "expression": "<!--[\\s\\S]{0,20000}?-->", "flags": "", "severity": "low", "why": "An oversized HTML comment is invisible when rendered but read by the model." },
32
+ { "id": "comment-block-docstring-double", "type": "comment_block", "expression": "\"\"\"\\s*[\\s\\S]{0,20000}?\\s*\"\"\"", "flags": "", "severity": "low", "why": "An oversized Python docstring can carry instructions as prose." },
33
+ { "id": "comment-block-docstring-single", "type": "comment_block", "expression": "'''\\s*[\\s\\S]{0,20000}?\\s*'''", "flags": "", "severity": "low", "why": "Same as the double-quoted docstring, with single quotes." },
34
+ { "id": "comment-block-ruby", "type": "comment_block", "expression": "=begin[\\s\\S]{0,20000}?=end", "flags": "", "severity": "low", "why": "An oversized Ruby block comment, the same hiding place." }
35
+ ]
36
+ }