karajan-code 3.7.1 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/package.json +1 -1
- package/src/agents/availability.js +21 -0
- package/src/audit/audit-fallback.js +66 -0
- package/src/cli/advanced-commands.js +61 -0
- package/src/cli/register-meta.js +49 -0
- package/src/cli.js +17 -4
- package/src/commands/advanced.js +31 -0
- package/src/commands/audit.js +19 -3
- package/src/commands/board.js +7 -4
- package/src/commands/harden.js +2 -1
- package/src/commands/mutate.js +94 -0
- package/src/commands/plan/generate.js +2 -2
- package/src/commands/start.js +117 -0
- package/src/config/defaults.js +2 -1
- package/src/config/schema.js +1 -0
- package/src/guards/secret-redactor.js +71 -0
- package/src/harden/workflow-engine.js +4 -2
- package/src/harden/workflow-templates.js +52 -0
- package/src/mutate/diff-scope.js +101 -0
- package/src/mutate/reviewer-signal.js +76 -0
- package/src/mutate/runner.js +72 -0
- package/src/mutate/tool-registry.js +90 -0
- package/src/orchestrator/drivers/post-loop.js +2 -2
- package/src/orchestrator/stages/reviewer-stage.js +9 -3
- package/src/prompts/start-decision.js +52 -0
- package/src/roles/reviewer-role.js +7 -0
- package/src/start/assessment.js +74 -0
- package/src/start/maturity.js +73 -0
- package/src/start/start-decider-role.js +79 -0
- package/src/start/sweep.js +112 -0
package/src/config/defaults.js
CHANGED
|
@@ -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 },
|
package/src/config/schema.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
|
@@ -347,8 +347,8 @@ export async function tryAutoStartBoard(config, logger, emitter, eventBase) {
|
|
|
347
347
|
try {
|
|
348
348
|
const { startBoard, renderBoardBanner } = await import("../../commands/board.js");
|
|
349
349
|
const boardPort = config.hu_board.port || 4000;
|
|
350
|
-
// Scope the board URL to the current run's project (
|
|
351
|
-
// the user lands on a filtered view, not the global dashboard.
|
|
350
|
+
// Scope the board URL to the current run's project (`#board/<slug>`)
|
|
351
|
+
// so the user lands on a filtered view, not the global dashboard.
|
|
352
352
|
const slug = config.projectDir ? slugFor(config.projectDir) : null;
|
|
353
353
|
const boardResult = await startBoard(boardPort, { projectSlug: slug });
|
|
354
354
|
const status = boardResult.alreadyRunning ? "already running" : "started";
|
|
@@ -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
|
-
|
|
201
|
+
diff = await getPrDiff(session.ci_pr_number);
|
|
200
202
|
logger.info(`Reviewer reading PR diff #${session.ci_pr_number}`);
|
|
201
|
-
|
|
203
|
+
} else {
|
|
204
|
+
diff = await generateDiff({ baseRef: session.session_start_sha, stageNewFiles: true });
|
|
202
205
|
}
|
|
203
|
-
|
|
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 }) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// KJC-TSK-0569 (Onboard B) — prompt + intent schema for the StartDecidor.
|
|
2
|
+
//
|
|
3
|
+
// The decider only DECIDES (one intent from a closed set); the safe HOW is
|
|
4
|
+
// deterministic (0570 maps the intent to a saved command). Keep this prompt
|
|
5
|
+
// tight: it routes, it never executes.
|
|
6
|
+
const PREAMBLE = [
|
|
7
|
+
"IMPORTANT: You are running as a Karajan sub-agent (the `kj start` decider).",
|
|
8
|
+
"Do NOT mention Karajan, do NOT use MCP tools, do NOT execute anything.",
|
|
9
|
+
"Your only job: read the project assessment and pick ONE next-step intent.",
|
|
10
|
+
].join(" ");
|
|
11
|
+
|
|
12
|
+
/** Closed set of intents. Anything else degrades to ASK_USER. */
|
|
13
|
+
export const INTENTS = new Set([
|
|
14
|
+
"ASSESS_ONLY",
|
|
15
|
+
"RECOMMEND_HARDEN",
|
|
16
|
+
"RECOMMEND_INDEX",
|
|
17
|
+
"START_TASK",
|
|
18
|
+
"PROPOSE_PLAN",
|
|
19
|
+
"ASK_USER",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const INTENT_GUIDE = [
|
|
23
|
+
"- ASSESS_ONLY: the project is healthy and the user gave no goal — just report.",
|
|
24
|
+
"- RECOMMEND_HARDEN: quality gaps exist (no tests/CI, drifting checks, missing config).",
|
|
25
|
+
"- RECOMMEND_INDEX: the codebase has no knowledge index yet.",
|
|
26
|
+
"- START_TASK: the user stated a concrete thing to build or fix.",
|
|
27
|
+
"- PROPOSE_PLAN: legacy/large project, or a goal that needs a modernization/feature plan.",
|
|
28
|
+
"- ASK_USER: intent is unclear — ask ONE open question to disambiguate.",
|
|
29
|
+
].join("\n");
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {{userMessage?: string, assessment?: string, instructions?: string}} input
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function buildStartDecisionPrompt({ userMessage = "", assessment = "", instructions } = {}) {
|
|
36
|
+
const sections = [PREAMBLE];
|
|
37
|
+
if (instructions) sections.push(instructions);
|
|
38
|
+
sections.push(
|
|
39
|
+
"You route the next step for a project the user just opened with `kj start`.",
|
|
40
|
+
"## Project assessment (read-only signals)",
|
|
41
|
+
assessment || "(no assessment available)",
|
|
42
|
+
"## User goal",
|
|
43
|
+
userMessage.trim() || "(none given)",
|
|
44
|
+
"## Intents",
|
|
45
|
+
INTENT_GUIDE,
|
|
46
|
+
"Pick the single best intent. Prefer a concrete next step over ASK_USER when the assessment is clear.",
|
|
47
|
+
"If you pick ASK_USER, put the open question in questionToAsk.",
|
|
48
|
+
"Return a single valid JSON object and nothing else.",
|
|
49
|
+
'JSON schema: {"intent":"ASSESS_ONLY|RECOMMEND_HARDEN|RECOMMEND_INDEX|START_TASK|PROPOSE_PLAN|ASK_USER","rationale":string,"questionToAsk":string}',
|
|
50
|
+
);
|
|
51
|
+
return sections.join("\n\n");
|
|
52
|
+
}
|
|
@@ -2,6 +2,7 @@ import { AgentRole } from "./agent-role.js";
|
|
|
2
2
|
import { buildRtkInstructions } from "../prompts/rtk-snippet.js";
|
|
3
3
|
import { extractFirstJson } from "../utils/json-extract.js";
|
|
4
4
|
import { section, buildPromptLayout, joinLayout, STABLE, VOLATILE } from "../prompts/prompt-layout.js";
|
|
5
|
+
import { isMutationReviewEnabled, buildReviewerMutationSignal } from "../mutate/reviewer-signal.js";
|
|
5
6
|
|
|
6
7
|
const MAX_DIFF_LENGTH = 12000;
|
|
7
8
|
|
|
@@ -38,6 +39,11 @@ export class ReviewerRole extends AgentRole {
|
|
|
38
39
|
// on every review — last. The buckets ride along so ClaudeAgent can
|
|
39
40
|
// ship the stable block via --append-system-prompt (Φ1-D).
|
|
40
41
|
async buildPrompt({ task, diff, reviewRules }) {
|
|
42
|
+
// Opt-in (KJ_REVIEW_MUTATION): null when off/clean → prompt stays identical.
|
|
43
|
+
const mutationSignal = await buildReviewerMutationSignal({
|
|
44
|
+
enabled: isMutationReviewEnabled(),
|
|
45
|
+
projectDir: this.config?.projectDir,
|
|
46
|
+
});
|
|
41
47
|
const layout = buildPromptLayout([
|
|
42
48
|
section(SUBAGENT_PREAMBLE, STABLE),
|
|
43
49
|
section(this.instructions, STABLE),
|
|
@@ -51,6 +57,7 @@ export class ReviewerRole extends AgentRole {
|
|
|
51
57
|
section(reviewRules ? `Review rules:\n${reviewRules}` : null, STABLE),
|
|
52
58
|
section(`Task context:\n${task}`, VOLATILE),
|
|
53
59
|
section(`Git diff:\n${truncateDiff(diff)}`, VOLATILE),
|
|
60
|
+
section(mutationSignal, VOLATILE),
|
|
54
61
|
]);
|
|
55
62
|
return { prompt: joinLayout(layout), stablePrompt: layout.stable, volatilePrompt: layout.volatile };
|
|
56
63
|
}
|