karajan-code 3.7.2 → 3.9.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.
@@ -0,0 +1,117 @@
1
+ // KJC-TSK-0570 (Onboard C) — `kj start`: the single entry point to the Brain.
2
+ // Wires the read-only sweep (0568) → deterministic assessment (0569) → haiku
3
+ // decider (0569), reports the recommended next step, and — interactively only —
4
+ // confirms and dispatches the chosen intent to its saved command
5
+ // (harden/rag/run/plan). --json / --yes / no-TTY stay read-only: emit the
6
+ // assessment + intent and apply nothing. Collaborators are injected for testing.
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { execa } from "execa";
10
+ import { runReadOnlySweep } from "../start/sweep.js";
11
+ import { buildAssessment } from "../start/assessment.js";
12
+ import { StartDecidorRole } from "../start/start-decider-role.js";
13
+ import { createWizard, isTTY } from "../utils/wizard.js";
14
+
15
+ // The recommended next step shown for each intent.
16
+ const NEXT_STEP = {
17
+ ASSESS_ONLY: "Everything looks healthy — nothing to do.",
18
+ RECOMMEND_HARDEN: "Suggested: `kj harden --interactive`",
19
+ RECOMMEND_INDEX: "Suggested: `kj rag index`",
20
+ START_TASK: 'Suggested: `kj run "<task>"`',
21
+ PROPOSE_PLAN: 'Suggested: `kj plan "<goal>"`',
22
+ };
23
+
24
+ // Each writing intent maps to the saved command + args `kj start` dispatches.
25
+ const DISPATCH = {
26
+ RECOMMEND_HARDEN: () => ["harden", "--interactive"],
27
+ RECOMMEND_INDEX: () => ["rag", "index"],
28
+ START_TASK: (task) => ["run", task],
29
+ PROPOSE_PLAN: (task) => ["plan", task],
30
+ };
31
+
32
+ const ASK_FALLBACK = "What would you like to do with this project?";
33
+ const CLI_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
34
+
35
+ // Run a saved `kj` subcommand as a child, inheriting stdio so its own prompts
36
+ // (e.g. `harden --interactive`) drive the real terminal. Never throws here.
37
+ async function spawnKj(args, { cwd }) {
38
+ await execa("node", [CLI_PATH, ...args.filter(Boolean)], { cwd, stdio: "inherit", reject: false });
39
+ }
40
+
41
+ // The decider has its own quota/parse recovery; a spawn-level failure (CLI not
42
+ // installed) still throws, so we degrade here too — `kj start` never crashes.
43
+ async function decide({ task, text, config, logger, makeDecider }) {
44
+ const decider = makeDecider({ config, logger });
45
+ try {
46
+ const decision = await decider.execute({ userMessage: task, assessment: text });
47
+ return decision.result || {};
48
+ } catch (err) {
49
+ logger?.warn?.(`[start] decider unavailable (${err?.message || err}) — asking the user.`);
50
+ return { intent: "ASK_USER", rationale: "Decider unavailable.", questionToAsk: ASK_FALLBACK, degraded: true };
51
+ }
52
+ }
53
+
54
+ // Ask one yes/no, always closing the readline interface afterwards.
55
+ async function confirmApply(makeWizard) {
56
+ const wizard = makeWizard();
57
+ try {
58
+ return await wizard.confirm("Apply this?", true);
59
+ } finally {
60
+ wizard.close();
61
+ }
62
+ }
63
+
64
+ // Print the rationale + recommended step, or the open question for ASK_USER.
65
+ function render(result) {
66
+ if (result.intent === "ASK_USER") {
67
+ console.log(`❓ ${result.questionToAsk || ASK_FALLBACK}`);
68
+ return;
69
+ }
70
+ if (result.rationale) console.log(`→ ${result.rationale}`);
71
+ const step = NEXT_STEP[result.intent];
72
+ if (step) console.log(` ${step}`);
73
+ }
74
+
75
+ export async function startCommand({ task = "", config, logger, flags = {}, deps = {} }) {
76
+ const sweep = deps.runReadOnlySweep || runReadOnlySweep;
77
+ const assess = deps.buildAssessment || buildAssessment;
78
+ const makeDecider = deps.makeDecider || ((opts) => new StartDecidorRole(opts));
79
+ const tty = deps.isTTY || isTTY;
80
+ const spawn = deps.spawnKj || spawnKj;
81
+ const makeWizard = deps.makeWizard || (() => createWizard());
82
+
83
+ const bundle = await sweep(config.projectDir, { declared: flags.maturity || null });
84
+ const { text, summary } = assess(bundle);
85
+ let result = await decide({ task, text, config, logger, makeDecider });
86
+
87
+ if (flags.json) {
88
+ console.log(JSON.stringify({ maturity: summary.maturity, assessment: text, ...result }, null, 2));
89
+ return { ok: true, intent: result.intent };
90
+ }
91
+
92
+ console.log(`${text}\n`);
93
+ const interactive = tty() && !flags.yes;
94
+ let goal = task;
95
+
96
+ // ASK_USER: one interactive re-route — the open answer becomes the goal.
97
+ if (result.intent === "ASK_USER" && interactive) {
98
+ const wizard = makeWizard();
99
+ try {
100
+ const answer = await wizard.input(`❓ ${result.questionToAsk || ASK_FALLBACK}`);
101
+ if (answer) {
102
+ goal = answer;
103
+ result = await decide({ task: answer, text, config, logger, makeDecider });
104
+ }
105
+ } finally {
106
+ wizard.close();
107
+ }
108
+ }
109
+ render(result);
110
+
111
+ // Dispatch the chosen intent to its saved command, confirming first (it writes).
112
+ const toArgs = DISPATCH[result.intent];
113
+ if (interactive && toArgs && (await confirmApply(makeWizard))) {
114
+ await spawn(toArgs(goal), { cwd: config.projectDir });
115
+ }
116
+ return { ok: true, intent: result.intent };
117
+ }
@@ -27,7 +27,8 @@ const DEFAULTS = {
27
27
  triage: { provider: null, model: null },
28
28
  discover: { provider: null, model: null },
29
29
  architect: { provider: null, model: null },
30
- hu_reviewer: { provider: null, model: null }
30
+ hu_reviewer: { provider: null, model: null },
31
+ start: { provider: null, model: "haiku" }
31
32
  },
32
33
  pipeline: {
33
34
  planner: { enabled: false },
@@ -90,6 +90,7 @@ const RolesMap = v.optional(v.looseObject({
90
90
  discover: v.optional(RoleEntry),
91
91
  architect: v.optional(RoleEntry),
92
92
  hu_reviewer: v.optional(RoleEntry),
93
+ start: v.optional(RoleEntry),
93
94
  }));
94
95
 
95
96
  const PipelineEntry = v.looseObject({
@@ -0,0 +1,71 @@
1
+ // KJC-TSK-0583 — Inbound secret redaction for the review boundary.
2
+ //
3
+ // The outbound boundary (output-guard.js) already blocks committing secrets.
4
+ // This closes the *inbound* gap: a hardcoded credential in the working tree
5
+ // would otherwise reach the (possibly cloud) reviewer model verbatim inside the
6
+ // diff. `redactSecrets` masks each credential before the model sees it, reusing
7
+ // the SAME catalog as output-guard — no new patterns, no AI cost, deterministic.
8
+ import { CREDENTIAL_PATTERNS } from "./output-guard.js";
9
+
10
+ // Families whose match begins with a fixed, identifiable literal prefix. The
11
+ // placeholder keeps that prefix so the reviewer can still tell WHAT kind of
12
+ // secret was redacted (and say "move it to .env") without seeing the value.
13
+ const STABLE_PREFIX_BY_ID = {
14
+ "aws-key": "AKIA",
15
+ "npm-token": "npm_",
16
+ "openai-key": "sk-",
17
+ "anthropic-key": "sk-ant-",
18
+ "stripe-key": "sk_live_",
19
+ "stripe-test": "sk_test_",
20
+ "google-api-key": "AIza",
21
+ };
22
+
23
+ // Families with a dynamic prefix: keep the real match up to and including the
24
+ // first separator (ghp_/gho_/…, xoxb-/xoxp-/…), then mask the rest.
25
+ const DYNAMIC_SEPARATOR_BY_ID = {
26
+ "github-token": "_",
27
+ "slack-token": "-",
28
+ };
29
+
30
+ const GENERIC = "***REDACTED-SECRET***";
31
+ const SUFFIX = "***REDACTED***";
32
+
33
+ function placeholderFor(id, match) {
34
+ const stable = STABLE_PREFIX_BY_ID[id];
35
+ if (stable) return `${stable}${SUFFIX}`;
36
+ const sep = DYNAMIC_SEPARATOR_BY_ID[id];
37
+ if (sep) {
38
+ const idx = match.indexOf(sep);
39
+ return idx >= 0 ? `${match.slice(0, idx + 1)}${SUFFIX}` : GENERIC;
40
+ }
41
+ return GENERIC;
42
+ }
43
+
44
+ // Apply the most specific prefixes first so `sk-ant-` wins over `sk-`; families
45
+ // without a stable prefix keep their catalog order (stable sort) and run last.
46
+ function orderedPatterns() {
47
+ return [...CREDENTIAL_PATTERNS].sort(
48
+ (a, b) => (STABLE_PREFIX_BY_ID[b.id]?.length ?? 0) - (STABLE_PREFIX_BY_ID[a.id]?.length ?? 0)
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Mask every credential in `text`, preserving line structure. Pure and
54
+ * deterministic: clean text is returned untouched (no-op, no cost).
55
+ * @param {string} text
56
+ * @returns {string}
57
+ */
58
+ export function redactSecrets(text) {
59
+ if (typeof text !== "string" || text.length === 0) return text;
60
+ let out = text;
61
+ for (const { id, pattern } of orderedPatterns()) {
62
+ const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
63
+ out = out.replace(new RegExp(pattern.source, flags), (match) =>
64
+ // Idempotent: a broad pattern (generic-secret, hardcoded-key-assignment)
65
+ // can span an already-masked value; leave the more specific placeholder
66
+ // in place instead of clobbering it with the generic one.
67
+ match.includes("REDACTED") ? match : placeholderFor(id, match)
68
+ );
69
+ }
70
+ return out;
71
+ }
@@ -10,7 +10,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
 
12
12
  import { upsertManagedBlock } from "../utils/managed-markers.js";
13
- import { extraWorkflowsFor, qualityWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
13
+ import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
14
14
 
15
15
  const BLOCK_VERSION = 1;
16
16
  const WORKFLOWS_DIR = join(".github", "workflows");
@@ -31,12 +31,14 @@ export function installWorkflows({
31
31
  projectDir = process.cwd(),
32
32
  language = null,
33
33
  profile = "standard",
34
+ mutation = false,
34
35
  dryRun = false,
35
36
  } = {}) {
36
37
  const dir = join(projectDir, WORKFLOWS_DIR);
37
38
  const quality = qualityWorkflowFor(language);
38
39
  const extras = extraWorkflowsFor({ profile, publishable: isPublishableNpm(projectDir) });
39
- const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras];
40
+ const mut = mutation ? mutationWorkflowFor(language) : null;
41
+ const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : [])];
40
42
  const results = [];
41
43
  for (const wf of all) {
42
44
  const target = join(dir, wf.file);
@@ -147,6 +147,58 @@ export const PACK_SMOKE_WORKFLOW = [
147
147
  ' echo "Tarball installs clean: $tgz"',
148
148
  ].join("\n");
149
149
 
150
+ // Opt-in nightly/manual mutation audit (KJC-TSK-0589). NEVER a PR gate: it runs
151
+ // on a weekly schedule + manual dispatch, and the mutate step is non-blocking
152
+ // (continue-on-error) so a red run never nags. Only stacks whose `kj mutate`
153
+ // yields a JSON report (stryker/mutmut/infection) get a workflow — no silent
154
+ // scaffold for tools that can't report yet.
155
+ const MUTATION_TOOLCHAIN = {
156
+ javascript: [" - run: npm ci"],
157
+ python: [
158
+ " - uses: actions/setup-python@v5",
159
+ " with:",
160
+ " python-version: '3.12'",
161
+ " - run: pip install mutmut pytest",
162
+ ],
163
+ php: [
164
+ " - uses: shivammathur/setup-php@v2",
165
+ " with:",
166
+ " php-version: '8.3'",
167
+ " - run: composer install --no-interaction --no-progress",
168
+ ],
169
+ };
170
+ MUTATION_TOOLCHAIN.typescript = MUTATION_TOOLCHAIN.javascript;
171
+
172
+ /** Opt-in mutation workflow entry, or null for a stack without a JSON report. */
173
+ export function mutationWorkflowFor(language) {
174
+ const toolchain = MUTATION_TOOLCHAIN[language];
175
+ if (!toolchain) return null;
176
+ const body = [
177
+ "name: Mutation (nightly)",
178
+ "on:",
179
+ " schedule:",
180
+ " - cron: '0 4 * * 1'",
181
+ " workflow_dispatch:",
182
+ "permissions:",
183
+ " contents: read",
184
+ "jobs:",
185
+ " mutation:",
186
+ " runs-on: ubuntu-latest",
187
+ " steps:",
188
+ " - uses: actions/checkout@v4",
189
+ " with:",
190
+ " fetch-depth: 0",
191
+ " - uses: actions/setup-node@v4",
192
+ " with:",
193
+ " node-version: 22",
194
+ ...toolchain,
195
+ " - name: Mutation testing (advisory, never blocks a PR)",
196
+ " continue-on-error: true",
197
+ " run: npx --yes --package karajan-code kj mutate --since HEAD~1",
198
+ ].join("\n");
199
+ return { file: "kj-mutation.yml", blockId: "wf-mutation", body };
200
+ }
201
+
150
202
  /** Conditional workflows: shrink-budget on strict, pack-smoke when publishable. */
151
203
  export function extraWorkflowsFor({ profile = "standard", publishable = false } = {}) {
152
204
  const extras = [];
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Diff-scoping for mutation testing. Translates a git range (`--since <ref>`)
3
+ * into changed source files + line ranges, then into the scope flags of the
4
+ * tool resolved by the registry (`src/mutate/tool-registry.js`). Mutating only
5
+ * what the coder just touched is what makes mutation testing cheap and useful.
6
+ * The git call is injectable so the pure logic is testable without a repo.
7
+ */
8
+
9
+ import { runCommand } from "../utils/process.js";
10
+ import { getMutationTool } from "./tool-registry.js";
11
+
12
+ const SOURCE_EXTENSIONS = {
13
+ javascript: [".js", ".mjs", ".cjs", ".jsx"],
14
+ typescript: [".ts", ".tsx"],
15
+ python: [".py"],
16
+ php: [".php"],
17
+ go: [".go"],
18
+ java: [".java"],
19
+ };
20
+
21
+ const HUNK = /^@@ -\S+ \+(\d+)(?:,(\d+))?/;
22
+
23
+ /**
24
+ * Parse `git diff --unified=0` into new-side line ranges per file. Deleted
25
+ * files (`+++ /dev/null`) are omitted — there is nothing to mutate.
26
+ * @returns {Record<string, Array<[number, number]>>}
27
+ */
28
+ export function parseUnifiedDiff(diffText) {
29
+ const ranges = {};
30
+ let current = null;
31
+ for (const line of String(diffText || "").split("\n")) {
32
+ if (line.startsWith("+++ ")) {
33
+ const target = line.slice(4).trim();
34
+ current = target === "/dev/null" ? null : target.replace(/^b\//, "");
35
+ if (current) ranges[current] ??= [];
36
+ continue;
37
+ }
38
+ if (!current) continue;
39
+ const match = HUNK.exec(line);
40
+ if (!match) continue;
41
+ const start = Number(match[1]);
42
+ const count = match[2] === undefined ? 1 : Number(match[2]);
43
+ if (count > 0) ranges[current].push([start, start + count - 1]);
44
+ }
45
+ return ranges;
46
+ }
47
+
48
+ /** @returns {string[]} source extensions for a language ([] if unsupported) */
49
+ export function sourceExtensions(language) {
50
+ return SOURCE_EXTENSIONS[language] ?? [];
51
+ }
52
+
53
+ /** Keep only files whose extension belongs to the language's source set. */
54
+ export function filterSourceFiles(files, language) {
55
+ const exts = sourceExtensions(language);
56
+ if (exts.length === 0) return [];
57
+ return files.filter((file) => exts.some((ext) => file.endsWith(ext)));
58
+ }
59
+
60
+ /**
61
+ * Build the tool's scope arguments for the changed source files. `ranges`
62
+ * (per-file changed line ranges) is only consumed by Stryker (`path:start-end`).
63
+ * @param {{language: string, files: string[], ranges?: Record<string, Array<[number, number]>>}} params
64
+ */
65
+ export function buildScope({ language, files, ranges }) {
66
+ const tool = getMutationTool(language);
67
+ if (!tool.supported) {
68
+ return { supported: false, reason: tool.reason, empty: true, args: [] };
69
+ }
70
+ const source = filterSourceFiles(files ?? [], language);
71
+ if (source.length === 0) {
72
+ return { supported: true, tool: tool.id, empty: true, targets: [], args: [] };
73
+ }
74
+ const targets =
75
+ tool.id === "stryker" && ranges
76
+ ? source.flatMap((file) =>
77
+ (ranges[file] ?? [[undefined, undefined]]).map(([start, end]) =>
78
+ start === undefined ? file : `${file}:${start}-${end}`,
79
+ ),
80
+ )
81
+ : source;
82
+ const value = targets.join(tool.scope.separator);
83
+ const args = tool.scope.flag ? [tool.scope.flag, value] : [value];
84
+ return { supported: true, tool: tool.id, empty: false, targets, args };
85
+ }
86
+
87
+ /**
88
+ * Fetch the diff for `since..toRef` and compute the mutation scope. `run` is
89
+ * the injectable git runner (defaults to `runCommand`).
90
+ * @param {{since: string, language: string, toRef?: string, run?: Function}} params
91
+ */
92
+ export async function getDiffScope({ since, language, toRef = "HEAD", run = runCommand }) {
93
+ const result = await run("git", ["diff", "--unified=0", `${since}..${toRef}`]).catch(
94
+ () => null,
95
+ );
96
+ if (!result || result.exitCode !== 0) {
97
+ return { supported: true, empty: true, targets: [], args: [] };
98
+ }
99
+ const ranges = parseUnifiedDiff(result.stdout);
100
+ return buildScope({ language, files: Object.keys(ranges), ranges });
101
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Reviewer opt-in mutation signal (KJC-TSK-0588). OFF by default: only when
3
+ * `KJ_REVIEW_MUTATION` is set does the reviewer run `kj mutate` scoped to the
4
+ * diff and fold surviving mutants into its verdict as a weak-test signal.
5
+ * A null return means "add nothing" → the default-off prompt stays identical.
6
+ */
7
+
8
+ import { mutateCommand } from "../commands/mutate.js";
9
+
10
+ const DEFAULT_SINCE = "HEAD~1";
11
+ const ENABLED = /^(1|true|yes|on)$/i;
12
+
13
+ /** True when the reviewer mutation opt-in is switched on via env. */
14
+ export function isMutationReviewEnabled(env = process.env) {
15
+ return ENABLED.test(String(env?.KJ_REVIEW_MUTATION ?? "").trim());
16
+ }
17
+
18
+ // Drives `kj mutate --json` over the diff, capturing its output instead of
19
+ // printing it, and returns {code, result} where result is the normalized JSON.
20
+ async function defaultRun({ since, projectDir }) {
21
+ const lines = [];
22
+ const code = await mutateCommand({
23
+ since,
24
+ projectDir,
25
+ json: true,
26
+ maxSurvivors: Number.POSITIVE_INFINITY,
27
+ logger: { info: (m) => lines.push(String(m)) },
28
+ });
29
+ const jsonLine = [...lines].reverse().find((l) => l.trim().startsWith("{"));
30
+ return { code, result: jsonLine ? JSON.parse(jsonLine) : null };
31
+ }
32
+
33
+ function unavailableNote() {
34
+ return [
35
+ "## Mutation testing (advisory)",
36
+ "Mutation testing was requested but could not run on this diff (unsupported",
37
+ "stack or tool unavailable). Treat it as unavailable — do not infer test",
38
+ "quality from its absence.",
39
+ ].join("\n");
40
+ }
41
+
42
+ function survivorSection(survivors) {
43
+ const lines = survivors.map((s) => `- ${s.file}:${s.line ?? "?"} (${s.mutator ?? s.status})`);
44
+ return [
45
+ "## Mutation testing (advisory signal)",
46
+ "The following mutants survived over the changed lines — the tests did not",
47
+ "catch these deliberate defects, so the covering tests are weak. Weigh this",
48
+ "as a signal about test quality, not as a hard blocker on its own:",
49
+ ...lines,
50
+ ].join("\n");
51
+ }
52
+
53
+ /**
54
+ * Build the reviewer's mutation-signal prompt section, or null to add nothing.
55
+ * `run` ({since, projectDir}) → {code, result} is injectable for tests.
56
+ * @returns {Promise<string|null>}
57
+ */
58
+ export async function buildReviewerMutationSignal({
59
+ enabled,
60
+ since = DEFAULT_SINCE,
61
+ projectDir = process.cwd(),
62
+ run = defaultRun,
63
+ } = {}) {
64
+ if (!enabled) return null;
65
+ let outcome;
66
+ try {
67
+ outcome = await run({ since, projectDir });
68
+ } catch {
69
+ return unavailableNote();
70
+ }
71
+ const { code, result } = outcome ?? {};
72
+ if (code === 1 || code === 2) return unavailableNote();
73
+ const survivors = result?.survived ?? [];
74
+ if (survivors.length === 0) return null;
75
+ return survivorSection(survivors);
76
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Mutation runner + report normalizer. Executes a mutation tool and parses its
3
+ * report (the shared mutation-testing-report-schema, `files[].mutants[]`) into a
4
+ * tool-agnostic result the CLI, reviewer and CI can all consume the same way.
5
+ * `exec` and `readReport` are injectable so the logic is testable without a
6
+ * real mutation tool installed.
7
+ */
8
+
9
+ import { runCommand } from "../utils/process.js";
10
+
11
+ // A mutant the test suite detected (killed or forced to time out).
12
+ const KILLED = new Set(["Killed", "Timeout"]);
13
+ // A real survivor: the mutation went undetected (or no test covered it).
14
+ const SURVIVED = new Set(["Survived", "NoCoverage"]);
15
+ // Not a real result: equivalent, config-ignored, or uncompilable mutants.
16
+ const EXCLUDED = new Set(["Ignored", "CompileError", "RuntimeError"]);
17
+
18
+ /**
19
+ * Normalize a standard-schema report into `{score, killed, survived, total, excluded}`.
20
+ * `score` is the covered mutation score `killed / (killed + survived) * 100`, or
21
+ * null when there is nothing to mutate. Excluded mutants never count as survivors.
22
+ * @param {{files?: Record<string, {mutants?: Array<object>}>}} report
23
+ */
24
+ export function parseStandardReport(report) {
25
+ const files = report?.files ?? {};
26
+ let killed = 0;
27
+ let excluded = 0;
28
+ const survived = [];
29
+ for (const [file, entry] of Object.entries(files)) {
30
+ for (const mutant of entry?.mutants ?? []) {
31
+ const { status } = mutant;
32
+ if (EXCLUDED.has(status)) {
33
+ excluded += 1;
34
+ } else if (KILLED.has(status)) {
35
+ killed += 1;
36
+ } else if (SURVIVED.has(status)) {
37
+ survived.push({
38
+ file,
39
+ line: mutant.location?.start?.line ?? null,
40
+ mutator: mutant.mutatorName ?? null,
41
+ status,
42
+ });
43
+ }
44
+ }
45
+ }
46
+ const total = killed + survived.length;
47
+ const score = total === 0 ? null : Number(((killed / total) * 100).toFixed(2));
48
+ return { score, killed, survived, total, excluded };
49
+ }
50
+
51
+ /**
52
+ * Run a mutation tool and normalize the report it produced.
53
+ * @param {object} params
54
+ * @param {string} params.binary
55
+ * @param {string[]} [params.args]
56
+ * @param {string} [params.cwd]
57
+ * @param {Function} [params.exec] - injectable command runner (default runCommand)
58
+ * @param {() => Promise<object>} [params.readReport] - reads/parses the tool's report file
59
+ */
60
+ export async function runMutation({ binary, args = [], cwd, exec = runCommand, readReport }) {
61
+ const run = await exec(binary, args, { cwd }).catch((err) => ({
62
+ exitCode: 1,
63
+ stdout: "",
64
+ stderr: String(err?.message ?? err),
65
+ }));
66
+ let result = null;
67
+ if (readReport) {
68
+ const report = await readReport().catch(() => null);
69
+ if (report) result = parseStandardReport(report);
70
+ }
71
+ return { exitCode: run.exitCode, result, stdout: run.stdout, stderr: run.stderr };
72
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Mutation-tool registry.
3
+ *
4
+ * Maps a detected stack language (see `src/harden/stack-roots.js`) to the
5
+ * mutation-testing runner Karajan drives for it. Unknown or native-mobile
6
+ * languages resolve to an explicit `{ supported: false, reason }` object —
7
+ * never a silent fallback to another tool.
8
+ *
9
+ * `scope` describes how the runner limits mutation to a subset of files, so
10
+ * diff-scoped runs (`kj mutate --since <ref>`) can pass only the changed paths:
11
+ * - flag: the CLI flag that accepts the file list
12
+ * - separator: how multiple paths are joined for that flag
13
+ */
14
+
15
+ const TOOLS_BY_LANGUAGE = {
16
+ javascript: {
17
+ id: "stryker",
18
+ binary: "stryker",
19
+ installHint: "npm install --save-dev @stryker-mutator/core",
20
+ scope: { flag: "--mutate", separator: "," },
21
+ reportFormat: "json",
22
+ },
23
+ typescript: {
24
+ id: "stryker",
25
+ binary: "stryker",
26
+ installHint: "npm install --save-dev @stryker-mutator/core",
27
+ scope: { flag: "--mutate", separator: "," },
28
+ reportFormat: "json",
29
+ },
30
+ python: {
31
+ id: "mutmut",
32
+ binary: "mutmut",
33
+ installHint: "pip install mutmut",
34
+ scope: { flag: "--paths-to-mutate", separator: "," },
35
+ reportFormat: "json",
36
+ },
37
+ php: {
38
+ id: "infection",
39
+ binary: "infection",
40
+ installHint: "composer require --dev infection/infection",
41
+ scope: { flag: "--filter", separator: "," },
42
+ reportFormat: "json",
43
+ },
44
+ go: {
45
+ id: "go-mutesting",
46
+ binary: "go-mutesting",
47
+ installHint: "go install github.com/avito-tech/go-mutesting/cmd/go-mutesting@latest",
48
+ scope: { flag: "", separator: " " },
49
+ reportFormat: "json",
50
+ },
51
+ java: {
52
+ id: "pitest",
53
+ binary: "pitest",
54
+ installHint: "add org.pitest:pitest-maven (Maven) or gradle-pitest-plugin",
55
+ scope: { flag: "targetClasses", separator: "," },
56
+ reportFormat: "json",
57
+ },
58
+ };
59
+
60
+ const UNSUPPORTED_REASONS = {
61
+ swift: "Swift (iOS) has no mutation runner wired into Karajan",
62
+ kotlin: "Kotlin (Android) has no mutation runner wired into Karajan",
63
+ };
64
+
65
+ function normalize(language) {
66
+ if (typeof language !== "string") return "";
67
+ return language.trim().toLowerCase();
68
+ }
69
+
70
+ /**
71
+ * @param {string} language - language id from `detectStackRoots`
72
+ * @returns {{supported: true, id, binary, language, installHint, scope, reportFormat}
73
+ * | {supported: false, language, reason: string}}
74
+ */
75
+ export function getMutationTool(language) {
76
+ const key = normalize(language);
77
+ const tool = TOOLS_BY_LANGUAGE[key];
78
+ if (tool) {
79
+ return { supported: true, language: key, ...tool };
80
+ }
81
+ const reason =
82
+ UNSUPPORTED_REASONS[key] ??
83
+ `no mutation-testing tool is registered for language "${key || "(none)"}"`;
84
+ return { supported: false, language: key, reason };
85
+ }
86
+
87
+ /** @returns {string[]} languages with a registered mutation tool */
88
+ export function listSupportedLanguages() {
89
+ return Object.keys(TOOLS_BY_LANGUAGE);
90
+ }
@@ -6,6 +6,7 @@
6
6
  import { addCheckpoint, markSessionStatus, saveSession } from "../../session/store.js";
7
7
  import { setReviewerFeedback, setDeferredIssues } from "../../session/mutators.js";
8
8
  import { generateDiff } from "../../review/diff-generator.js";
9
+ import { redactSecrets } from "../../guards/secret-redactor.js";
9
10
  import { validateReviewResult } from "../../review/schema.js";
10
11
  import { filterReviewScope } from "../../review/scope-filter.js";
11
12
  import { emitProgress, makeEvent, emitAgentOutput } from "../../utils/events.js";
@@ -194,13 +195,18 @@ async function handleReviewerRejection({ review, repeatDetector, config, logger,
194
195
  }
195
196
 
196
197
  export async function fetchReviewDiff(session, logger) {
198
+ let diff;
197
199
  if (session.ci_pr_number) {
198
200
  const { getPrDiff } = await import("../../ci/pr-diff.js");
199
- const diff = await getPrDiff(session.ci_pr_number);
201
+ diff = await getPrDiff(session.ci_pr_number);
200
202
  logger.info(`Reviewer reading PR diff #${session.ci_pr_number}`);
201
- return diff;
203
+ } else {
204
+ diff = await generateDiff({ baseRef: session.session_start_sha, stageNewFiles: true });
202
205
  }
203
- return generateDiff({ baseRef: session.session_start_sha, stageNewFiles: true });
206
+ // Inbound boundary: mask hardcoded secrets before the diff reaches the
207
+ // (possibly cloud) reviewer model. Runs BEFORE the injection guard below so
208
+ // that guard still sees the real prompt-injection phrasing (KJC-TSK-0583).
209
+ return redactSecrets(diff);
204
210
  }
205
211
 
206
212
  export async function runReviewerStage({ reviewerRole, config, logger, emitter, eventBase, session, trackBudget, iteration, reviewRules, task, repeatDetector, budgetSummary, askQuestion, brainCtx }) {