karajan-code 4.54.0 → 4.56.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/package.json +1 -1
- package/src/agents/claude-agent.js +3 -0
- package/src/cli/register-pipeline.js +8 -0
- package/src/commands/harden.js +1 -15
- package/src/commands/plan/generate.js +12 -11
- package/src/commands/rag.js +1 -1
- package/src/commands/review-gate.js +14 -8
- package/src/config/defaults.js +2 -0
- package/src/config/schema.js +6 -0
- package/src/harden/check.js +14 -13
- package/src/harden/config-engine.js +25 -24
- package/src/harden/harden-scope.js +18 -0
- package/src/harden/hook-templates.js +24 -3
- package/src/harden/human-act.js +32 -9
- package/src/harden/sentinel/sentinel-shell.mjs +5 -3
- package/src/harden/sentinel-hooks.js +22 -87
- package/src/hu/coder-routing.js +66 -0
- package/src/hu/model-router.js +18 -20
- package/src/hu/routing-metrics.js +2 -1
- package/src/orchestrator/drivers/iteration-loop.js +2 -0
- package/src/orchestrator/drivers/run-hu-batch.js +4 -1
- package/src/orchestrator/hu-sub-pipeline.js +3 -1
- package/src/orchestrator/stages/coder-stage.js +4 -1
- package/src/prompts/reviewer.js +2 -0
- package/src/rag/indexer.js +2 -2
- package/src/report/model-tally.js +6 -2
- package/src/review/one-shot-review.js +9 -3
- package/src/review/pr-requirements.js +87 -0
- package/src/review/pr-security.js +70 -0
- package/src/review/pr-sonar.js +22 -0
- package/src/review/sonar-pregate.js +15 -4
- package/src/review/third-party-review.js +67 -0
- package/src/roles/agent-role.js +1 -1
- package/src/roles/coder-role.js +8 -2
- package/src/steward/gate.js +31 -0
- package/src/utils/tool-governor.js +31 -16
|
@@ -475,7 +475,7 @@ import { appendFileSync, existsSync, readFileSync, realpathSync, statSync } from
|
|
|
475
475
|
import { spawnSync } from "node:child_process";
|
|
476
476
|
import { homedir } from "node:os";
|
|
477
477
|
import { fileURLToPath } from "node:url";
|
|
478
|
-
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session,
|
|
478
|
+
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
479
479
|
// KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
|
|
480
480
|
import { isGhIssueCreate, isPublishCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
|
|
481
481
|
import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
|
|
@@ -485,7 +485,6 @@ const inOrder = (want, got) => { let i = 0; for (const w of got) if (w === want[
|
|
|
485
485
|
// KJC-BUG-0333: a publish is read per simple command by the shell reader, so
|
|
486
486
|
// words inside a quoted argument of another command are not a publish.
|
|
487
487
|
const isPublish = isPublishCommand;
|
|
488
|
-
const PUSH = /\\bgit\\s+push\\b/;
|
|
489
488
|
const PROTECTED = /\\.claude\\/settings\\.json\\b|\\.karajan\\/(hooks|harness)\\//;
|
|
490
489
|
let raw = "";
|
|
491
490
|
process.stdin.on("data", (d) => { raw += d; });
|
|
@@ -606,32 +605,17 @@ process.stdin.on("end", () => {
|
|
|
606
605
|
}
|
|
607
606
|
}
|
|
608
607
|
}
|
|
609
|
-
// KJC-TSK-0822 v1.1:
|
|
610
|
-
//
|
|
611
|
-
//
|
|
608
|
+
// KJC-TSK-0822 v1.1: la clave del movil (capa 5) no se toca desde la sesion.
|
|
609
|
+
// KJC-TSK-1084 (ADR 0015): los comandos de acto humano (kj harden --commit,
|
|
610
|
+
// kj identity enroll-phone) ya no se leen aqui: la propia orden rechaza un
|
|
611
|
+
// proceso que desciende de un agente (src/harden/human-act.js). Queda el
|
|
612
|
+
// FICHERO de la clave, que solo esto protege hasta que el sandbox lo vete.
|
|
612
613
|
{
|
|
613
|
-
// KJC-BUG-0354: un titulo o un mensaje de commit que solo lo MENCIONA no es
|
|
614
|
-
const
|
|
615
|
-
const namesEnroll = (enrollCmd.includes("identity") && enrollCmd.includes("enroll-phone")) || enrollCmd.includes("supervisor-phone.json");
|
|
614
|
+
// KJC-BUG-0354: un titulo o un mensaje de commit que solo lo MENCIONA no es escribirlo.
|
|
615
|
+
const namesPhoneFile = stripTextOnlyCommandValues(String(input.command || "")).includes("supervisor-phone.json");
|
|
616
616
|
const writesEnroll = EDIT_TOOLS.includes(tool) && String(input.file_path || "").includes("supervisor-phone.json");
|
|
617
|
-
if ((tool === "Bash" &&
|
|
618
|
-
console.error("karajan sentinel:
|
|
619
|
-
process.exit(2);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
// ADR 0009 (KJC-BUG-0161): kj harden --commit es un ACTO HUMANO — la
|
|
623
|
-
// sesion ni lo intenta. Sin escape (superficie de supervisor). includes()
|
|
624
|
-
// a proposito: cero ambiguedad de escapes en plantilla, y fail-closed
|
|
625
|
-
// (mencionarlo en un argumento tambien bloquea: preferible a un hueco).
|
|
626
|
-
// KJC-BUG-0354: salvo en el texto entrecomillado de kj hu add, kj adr add y
|
|
627
|
-
// git commit, que solo guardan texto (decision del usuario, 16-oct).
|
|
628
|
-
// ANTES de KJ_SENTINEL_OFF a proposito, como el guard PROTECTED de
|
|
629
|
-
// arriba: un env var lo pone tambien un agente — el supervisor no se
|
|
630
|
-
// apaga con interruptores al alcance del supervisado.
|
|
631
|
-
{
|
|
632
|
-
const kjCmd = stripTextOnlyCommandValues(String(input.command || ""));
|
|
633
|
-
if (tool === "Bash" && kjCmd.includes("harden") && kjCmd.includes("--commit")) {
|
|
634
|
-
console.error("karajan sentinel: kj harden --commit es un acto humano (ADR 0009) — pideselo a tu usuario; ninguna sesion de agente lo ejecuta." + doc("supervisor"));
|
|
617
|
+
if ((tool === "Bash" && namesPhoneFile) || writesEnroll) {
|
|
618
|
+
console.error("karajan sentinel: la clave del movil (capa 5) es del humano — supervisor-phone.json no se toca desde la sesion; enrolar es kj identity enroll-phone desde su terminal." + doc("supervisor"));
|
|
635
619
|
process.exit(2);
|
|
636
620
|
}
|
|
637
621
|
}
|
|
@@ -676,7 +660,6 @@ process.stdin.on("end", () => {
|
|
|
676
660
|
return v.length > 0 ? v : null;
|
|
677
661
|
};
|
|
678
662
|
const declaredGh = idLine("gh_user");
|
|
679
|
-
const declaredEmail = idLine("git_email");
|
|
680
663
|
const ghHostsPath = join(process.env.GH_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "gh"), "hosts.yml");
|
|
681
664
|
const activeGh = () => {
|
|
682
665
|
if (!existsSync(ghHostsPath)) return null;
|
|
@@ -699,11 +682,11 @@ process.stdin.on("end", () => {
|
|
|
699
682
|
// $( and backticks are separators too (review catch): their payload is its own segment.
|
|
700
683
|
const SEPS = [";", "&&", "||", "|", "$(", String.fromCharCode(96), "(", ")"];
|
|
701
684
|
const bare = (w) => w.replaceAll(String.fromCharCode(39), "").replaceAll(String.fromCharCode(34), "");
|
|
702
|
-
const scan = (text, depth) => { if (depth > 3) return; for (const seg of SEPS.reduce((acc, s) => acc.flatMap((l) => l.split(s)), text.split(ESC_NL))) checkSeg(headOf(words(seg)), depth
|
|
703
|
-
const checkSeg = (ws, depth
|
|
685
|
+
const scan = (text, depth) => { if (depth > 3) return; for (const seg of SEPS.reduce((acc, s) => acc.flatMap((l) => l.split(s)), text.split(ESC_NL))) checkSeg(headOf(words(seg)), depth); };
|
|
686
|
+
const checkSeg = (ws, depth) => {
|
|
704
687
|
if (ws.length === 0) return;
|
|
705
|
-
if (WRAPPERS.includes(ws[0])) { // wrapper args have arity (sudo -u bob): scan from the first gh/
|
|
706
|
-
const j = ws.findIndex((w, i) => i > 0 && ["gh",
|
|
688
|
+
if (WRAPPERS.includes(ws[0])) { // wrapper args have arity (sudo -u bob): scan from the first gh/wrapper token (review catch)
|
|
689
|
+
const j = ws.findIndex((w, i) => i > 0 && ["gh", ...WRAPPERS].includes(bare(w)));
|
|
707
690
|
if (j > 0) scan(ws.slice(j).map(bare).join(" "), depth + 1);
|
|
708
691
|
return;
|
|
709
692
|
}
|
|
@@ -715,46 +698,9 @@ process.stdin.on("end", () => {
|
|
|
715
698
|
if (!declaredGh) { problems.push("gh without a declared identity for this clone — run: kj identity set"); return; }
|
|
716
699
|
const eff = switchedTo || activeGh();
|
|
717
700
|
if (eff !== declaredGh) problems.push("gh as '" + (eff || "no session") + "' but this clone is declared as '" + declaredGh + "' — prefix the command: gh auth switch --user " + declaredGh + " && ...");
|
|
718
|
-
return;
|
|
719
|
-
}
|
|
720
|
-
if (ws[0] === "git") {
|
|
721
|
-
// Global options (-C, -c, --git-dir, --work-tree...) are NOT re-implemented: they
|
|
722
|
-
// are forwarded verbatim to git var, so git resolves identity exactly as the real
|
|
723
|
-
// command would (review catches: -C cumulative, --git-dir, --opt=value forms).
|
|
724
|
-
const TWO = ["-c", "-C", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--exec-path", "--config-env"];
|
|
725
|
-
let k = 1;
|
|
726
|
-
while (k < ws.length && ws[k].startsWith("-")) k += TWO.includes(ws[k]) && ws[k + 1] ? 2 : 1;
|
|
727
|
-
const globals = ws.slice(1, k).map(bare);
|
|
728
|
-
const sub = ws[k];
|
|
729
|
-
if (sub === "push") { // push authenticates with the gh session, not user.email (review catch)
|
|
730
|
-
if (!declaredGh) { problems.push("git push without a declared identity for this clone — run: kj identity set"); return; }
|
|
731
|
-
const eff = switchedTo || activeGh();
|
|
732
|
-
if (eff !== declaredGh) problems.push("git push with gh session '" + (eff || "no session") + "' but this clone is declared as '" + declaredGh + "' — prefix: gh auth switch --user " + declaredGh + " && ...");
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
// IDN-B2: anything that authors or rewrites commits carries an identity (review catch).
|
|
736
|
-
if (!["commit", "tag", "merge", "rebase", "cherry-pick", "am", "revert"].includes(sub)) return; // am: the COMMITTER is still this user
|
|
737
|
-
if (!declaredEmail) { problems.push("git " + sub + " without a declared identity for this clone — run: kj identity set"); return; }
|
|
738
|
-
// Let GIT resolve author and committer under the command's own environment
|
|
739
|
-
// (review catch: user.email, -c, GIT_*_EMAIL, EMAIL, GIT_CONFIG_*, includeIf are
|
|
740
|
-
// its business): git var is the single source of truth.
|
|
741
|
-
const assigns = raw.slice(0, raw.length - ws.length).filter((w) => w.indexOf("=") > 0);
|
|
742
|
-
const env = Object.assign({}, process.env);
|
|
743
|
-
for (const w of assigns) env[w.slice(0, w.indexOf("="))] = bare(w.slice(w.indexOf("=") + 1));
|
|
744
|
-
const ident = (v) => {
|
|
745
|
-
const r = spawnSync("git", [...globals, "var", v], { cwd: ROOT, encoding: "utf8", env });
|
|
746
|
-
const s = r.status === 0 ? String(r.stdout) : "";
|
|
747
|
-
const a = s.indexOf("<");
|
|
748
|
-
const b = s.indexOf(">");
|
|
749
|
-
return a >= 0 && b > a ? s.slice(a + 1, b) : null;
|
|
750
|
-
};
|
|
751
|
-
const ai = ws.findIndex((w) => w.startsWith("--author"));
|
|
752
|
-
const authorRaw = ai < 0 ? null : ws[ai].includes("=") ? ws[ai].slice(ws[ai].indexOf("=") + 1) : ws.slice(ai + 1).join(" ");
|
|
753
|
-
const authorMail = authorRaw === null ? null : bare(authorRaw.includes("<") ? authorRaw.slice(authorRaw.indexOf("<") + 1, authorRaw.indexOf(">")) : authorRaw.trim());
|
|
754
|
-
const mails = [authorMail ?? ident("GIT_AUTHOR_IDENT"), ident("GIT_COMMITTER_IDENT")]; // --author overrides the author (review catch); the committer is always git's
|
|
755
|
-
const offender = mails.find((m) => m !== null && m !== declaredEmail) ?? (mails[0] === null ? "none" : null);
|
|
756
|
-
if (offender !== null) problems.push("git " + sub + " as '" + offender + "' but this clone is declared as '" + declaredEmail + "' — use: git -c user.email=" + declaredEmail + " " + sub + " ... (or git config user.email " + declaredEmail + ")");
|
|
757
701
|
}
|
|
702
|
+
// KJC-TSK-1085 (ADR 0015): git commit/tag/rebase/push are no longer read here:
|
|
703
|
+
// pre-commit checks the author, pre-push every pushed commit and the gh session.
|
|
758
704
|
};
|
|
759
705
|
scan(CMD_TEXT, 0);
|
|
760
706
|
if (problems.length > 0) {
|
|
@@ -1177,17 +1123,14 @@ process.stdin.on("end", () => {
|
|
|
1177
1123
|
}
|
|
1178
1124
|
}
|
|
1179
1125
|
const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
|
|
1180
|
-
|
|
1126
|
+
// KJC-TSK-1089: git push is not read any more; pending moves still stop the turn (stop gate).
|
|
1127
|
+
const ADVANCE = /\\bgit\\s+commit\\b|\\bgh\\s+pr\\s+create\\b/;
|
|
1181
1128
|
const mergeM = cmd.match(MERGE);
|
|
1182
1129
|
const pend = pendingMoves(load().sessions?.[sid]);
|
|
1183
|
-
// KJC-BUG-0364: one board-gate decision per command; the push gate below
|
|
1184
|
-
// reuses it, so a second slow gh cannot contradict the first answer.
|
|
1185
|
-
let boardDecision = null;
|
|
1186
1130
|
if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
|
|
1187
1131
|
// KJC-BUG-0198: una card partida en varias PRs no puede quedar bloqueada
|
|
1188
1132
|
// por su primera mitad (boardGate, en la lib).
|
|
1189
|
-
|
|
1190
|
-
const { blocking, carried, reason } = boardDecision;
|
|
1133
|
+
const { blocking, carried, reason } = boardGate(sid, pend);
|
|
1191
1134
|
for (const c of carried) console.error("karajan sentinel: board-sync arrastra " + c.card + " — " + c.why + ", se movera al cerrarla");
|
|
1192
1135
|
// ADR 0015 (KJC-TSK-0925): no escape; an epic and a card split across PRs
|
|
1193
1136
|
// are already decided above (KJC-BUG-0230, 0198).
|
|
@@ -1234,18 +1177,10 @@ process.stdin.on("end", () => {
|
|
|
1234
1177
|
console.error("karajan sentinel: release check en ROJO — no se publica ni despliega hasta resolverlo:" + (items || "\\n- corre kj release check para el detalle") + "\\n(sin escape: se repara lo que el check nombra)" + doc("release"));
|
|
1235
1178
|
process.exit(2);
|
|
1236
1179
|
}
|
|
1237
|
-
} else if (PUSH.test(cmd)) {
|
|
1238
|
-
// KJC-BUG-0147: a pending move blocks the push like it blocks the commit.
|
|
1239
|
-
// ADR 0015 (KJC-TSK-0925): with no escape.
|
|
1240
|
-
// KJC-BUG-0262: a carried card does not block the push the commit let through.
|
|
1241
|
-
const sess = load().sessions?.[sid];
|
|
1242
|
-
const decision = boardDecision || boardGate(sid, pendingMoves(sess));
|
|
1243
|
-
const v = violations(sess, branchOf(), decision.blocking);
|
|
1244
|
-
if (v.length) {
|
|
1245
|
-
console.error("karajan sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + (decision.reason ? "\\n(" + decision.reason + ")" : "") + "\\nResuelve antes de empujar. Estado: kj sentinel status" + doc("push-gate"));
|
|
1246
|
-
process.exit(2);
|
|
1247
|
-
}
|
|
1248
1180
|
}
|
|
1181
|
+
// KJC-TSK-1089 (ADR 0015): git push is no longer read here. The base branch is
|
|
1182
|
+
// protected (required checks), the card and the tests are judged at commit
|
|
1183
|
+
// (kj review), and pending card moves keep the turn from ending (stop gate).
|
|
1249
1184
|
}
|
|
1250
1185
|
} catch { /* fail open */ }
|
|
1251
1186
|
process.exit(0);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0701 (MR-D): the model and effort the coder of an HU runs on. Its
|
|
3
|
+
* tier (MR-A) picks them on the first iteration; after a failed one the coder
|
|
4
|
+
* climbs to the most capable model. A model the user chose always wins, the
|
|
5
|
+
* reviewer and Solomon are never routed, and model_routing.enabled: false turns
|
|
6
|
+
* it off; model_routing.by_provider picks other models per level.
|
|
7
|
+
*/
|
|
8
|
+
import { getDefaultModelTiers, resolveModelForRole } from "../utils/model-selector.js";
|
|
9
|
+
|
|
10
|
+
const TIER_ROUTE = { trivial: { level: "trivial", effort: "low" }, standard: { level: "medium", effort: "medium" } };
|
|
11
|
+
const ESCALATED = { level: "complex", effort: "max" };
|
|
12
|
+
// Only claude's CLI takes --effort.
|
|
13
|
+
const EFFORT_PROVIDERS = new Set(["claude"]);
|
|
14
|
+
|
|
15
|
+
const userChoice = (config) => {
|
|
16
|
+
if (config.model_routing?.enabled === false) return "model_routing.enabled is false";
|
|
17
|
+
if (config.model_routing?.fixed?.coder) return `model_routing.fixed.coder is ${config.model_routing.fixed.coder}`;
|
|
18
|
+
if (config.roles?.coder?.model) return `the coder model was chosen (${config.roles.coder.model})`;
|
|
19
|
+
if (config.coder_options?.model) return `the coder model was chosen (${config.coder_options.model})`;
|
|
20
|
+
if (config.model_profile) return `the model profile ${config.model_profile} was chosen`;
|
|
21
|
+
return null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const routed = ({ model, effort, escalated, why }) => ({ skip: false, model, effort, escalated, reason: `${why} -> ${model}${effort ? ` (effort ${effort})` : ""}` });
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* KJC-TSK-1070: huModel is the HU's coder_model (kj plan fills it from the
|
|
28
|
+
* tier, the user may change it on the board); it drives the first iteration.
|
|
29
|
+
* @param {{ tier?: string, huModel?: string|null, attempt: number, config: object }} args
|
|
30
|
+
* @returns {{ skip: true, reason: string } | { skip: false, model: string, effort: string|null, escalated: boolean, reason: string }}
|
|
31
|
+
*/
|
|
32
|
+
export function routeCoder({ tier, huModel = null, attempt, config }) {
|
|
33
|
+
const chosen = userChoice(config);
|
|
34
|
+
if (chosen) return { skip: true, reason: chosen };
|
|
35
|
+
const provider = config.roles?.coder?.provider || config.coder;
|
|
36
|
+
const effortOf = (route) => (EFFORT_PROVIDERS.has(provider) ? route?.effort ?? null : null);
|
|
37
|
+
const escalated = attempt > 1;
|
|
38
|
+
if (!escalated && huModel) return routed({ model: huModel, effort: effortOf(TIER_ROUTE[tier]), escalated, why: "the HU's coder_model" });
|
|
39
|
+
const route = escalated ? ESCALATED : TIER_ROUTE[tier];
|
|
40
|
+
if (!route) return { skip: true, reason: tier === "deep" ? "deep HU: the usual model and effort" : `no tier to route by (${tier ?? "none"})` };
|
|
41
|
+
const tierMap = getDefaultModelTiers();
|
|
42
|
+
for (const [name, levels] of Object.entries(config.model_routing?.by_provider ?? {})) tierMap[name] = { ...tierMap[name], ...levels };
|
|
43
|
+
const model = resolveModelForRole({ role: "coder", provider, level: route.level, tierMap });
|
|
44
|
+
if (!model) return { skip: true, reason: `${provider} has no known model for the ${route.level} level` };
|
|
45
|
+
const why = escalated ? `iteration ${attempt} after a failed one: the most capable model` : `${tier} HU`;
|
|
46
|
+
return routed({ model, effort: effortOf(route), escalated, why });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* KJC-TSK-1070: the route of an HU's coder for this attempt, on its lane
|
|
51
|
+
* config (coder_route, read by the coder stage); a skipped route clears it.
|
|
52
|
+
*/
|
|
53
|
+
export function applyCoderRoute({ laneConfig, story, attempt, config, logger }) {
|
|
54
|
+
const route = routeCoder({ tier: story.complexity?.tier, huModel: story.coder_model || null, attempt, config });
|
|
55
|
+
laneConfig.coder_route = route.skip ? null : { model: route.model, effort: route.effort };
|
|
56
|
+
// KJC-TSK-1071 (MR-D3): the HU keeps its last decision, and whether it ever climbed.
|
|
57
|
+
story.coder_routing = {
|
|
58
|
+
tier: story.complexity?.tier ?? null,
|
|
59
|
+
model: route.skip ? null : route.model,
|
|
60
|
+
effort: route.skip ? null : route.effort,
|
|
61
|
+
escalated: Boolean(story.coder_routing?.escalated || route.escalated),
|
|
62
|
+
reason: route.reason,
|
|
63
|
+
};
|
|
64
|
+
logger.info(`HU ${story.id}: coder ${route.skip ? "not routed" : "routed"}: ${route.reason}`);
|
|
65
|
+
return route;
|
|
66
|
+
}
|
package/src/hu/model-router.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { getDefaultModelTiers } from "../utils/model-selector.js";
|
|
2
|
+
import { huComplexity } from "./complexity-tier.js";
|
|
3
|
+
import { routeCoder } from "./coder-routing.js";
|
|
2
4
|
|
|
3
5
|
// KJC-TSK-0405: dado un HU con complexity score, recomienda coder_model y
|
|
4
6
|
// reviewer_model. El reviewer por defecto usa el provider alternativo al
|
|
@@ -35,26 +37,6 @@ const CROSS_PROVIDER_REVIEWER = {
|
|
|
35
37
|
aider: "claude",
|
|
36
38
|
};
|
|
37
39
|
|
|
38
|
-
// KJC-TSK-0405 step 2: heurística para inferir complexity desde
|
|
39
|
-
// task_type cuando el planner no emite un score explícito. Permite
|
|
40
|
-
// que el model-router asigne modelos razonables sin un triage explícito.
|
|
41
|
-
const TASK_TYPE_COMPLEXITY = {
|
|
42
|
-
sw: "medium",
|
|
43
|
-
refactor: "medium",
|
|
44
|
-
"add-tests": "simple",
|
|
45
|
-
infra: "trivial",
|
|
46
|
-
doc: "trivial",
|
|
47
|
-
spike: "complex",
|
|
48
|
-
research: "complex",
|
|
49
|
-
audit: "complex",
|
|
50
|
-
analysis: "medium",
|
|
51
|
-
"no-code": "trivial",
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
export function complexityFromTaskType(taskType) {
|
|
55
|
-
return TASK_TYPE_COMPLEXITY[taskType] || "medium";
|
|
56
|
-
}
|
|
57
|
-
|
|
58
40
|
export function complexityToLevel(complexity) {
|
|
59
41
|
if (typeof complexity === "string" && LEVELS.includes(complexity)) return complexity;
|
|
60
42
|
if (typeof complexity === "number" && SCORE_TO_LEVEL[complexity]) return SCORE_TO_LEVEL[complexity];
|
|
@@ -97,3 +79,19 @@ export function recommendModelsForHu({ complexity, coderProvider, config = {} })
|
|
|
97
79
|
reviewer_provider,
|
|
98
80
|
};
|
|
99
81
|
}
|
|
82
|
+
|
|
83
|
+
const TIER_LEVEL = { trivial: "trivial", standard: "medium", deep: "complex" };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* KJC-TSK-1072 (MR-D4): the models kj plan writes on an HU. coder_model comes
|
|
87
|
+
* from the HU's tier (MR-A) through routeCoder, the criterion the run uses;
|
|
88
|
+
* empty for a deep HU or a model the config already chose. The reviewer keeps
|
|
89
|
+
* its cross-provider recommendation. The tier travels as `complexity`.
|
|
90
|
+
*/
|
|
91
|
+
export function planHuModels({ title = "", goal = "", acceptanceCriteria = [], tier = null, tierReason = null, config = {} }) {
|
|
92
|
+
const complexity = huComplexity({ title, goal, acceptanceCriteria, tier, tierReason });
|
|
93
|
+
const coderProvider = config.roles?.coder?.provider || config.coder || "claude";
|
|
94
|
+
const route = routeCoder({ tier: complexity.tier, attempt: 1, config });
|
|
95
|
+
const reviewer = recommendModelsForHu({ complexity: TIER_LEVEL[complexity.tier], coderProvider, config });
|
|
96
|
+
return { ...reviewer, coder_model: route.skip ? null : route.model, complexity };
|
|
97
|
+
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* fixed set of fields, so the measured ones are kept here, in the plan, where
|
|
5
5
|
* kj report reads them. Unmeasured fields stay out rather than written as null.
|
|
6
6
|
*/
|
|
7
|
-
|
|
7
|
+
// KJC-TSK-1071 (MR-D3): coder_routing is the coder's routing decision for the HU.
|
|
8
|
+
export const ROUTING_FIELDS = ["cost_usd", "tokens_in", "tokens_out", "cached_tokens", "coder", "reviewer_rejections", "coder_routing"];
|
|
8
9
|
|
|
9
10
|
export function keepRoutingMetrics(plan, huId, outcome) {
|
|
10
11
|
const hu = plan?.hus?.find((h) => h.id === huId);
|
|
@@ -210,6 +210,8 @@ export async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitt
|
|
|
210
210
|
|
|
211
211
|
ctx.eventBase.iteration = i;
|
|
212
212
|
ctx.iteration = i;
|
|
213
|
+
// KJC-TSK-1070 (MR-D2): an HU lane picks its coder's route per iteration.
|
|
214
|
+
ctx.beforeIteration?.(i);
|
|
213
215
|
|
|
214
216
|
let iterResult;
|
|
215
217
|
try {
|
|
@@ -36,6 +36,7 @@ import { createHuSnapshot } from "../../git/hu-snapshot.js";
|
|
|
36
36
|
import { generateDiff } from "../../review/diff-generator.js";
|
|
37
37
|
import { runAcceptanceCoverageStage } from "../stages/acceptance-coverage-stage.js";
|
|
38
38
|
import { criteriaOfCard } from "../../roles/acceptance-coverage-role.js";
|
|
39
|
+
import { applyCoderRoute } from "../../hu/coder-routing.js";
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
42
|
* @param {object} args
|
|
@@ -180,6 +181,7 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
|
|
|
180
181
|
|
|
181
182
|
for (let attempt = 1; attempt <= laneConfig.max_iterations; attempt++) {
|
|
182
183
|
logger.info(`HU ${story.id}: coder iteration ${attempt}/${laneConfig.max_iterations}`);
|
|
184
|
+
applyCoderRoute({ laneConfig, story, attempt, config: ctx.config, logger });
|
|
183
185
|
emitProgress(emitter, makeEvent("iteration:start", { ...ctx.eventBase, stage: "iteration" }, {
|
|
184
186
|
message: `Iteration ${attempt}/${laneConfig.max_iterations}`,
|
|
185
187
|
detail: { iteration: attempt, maxIterations: laneConfig.max_iterations }
|
|
@@ -386,7 +388,8 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
|
|
|
386
388
|
// Fallback: no acceptance_tests → standard pipeline (reviewer/tester).
|
|
387
389
|
// The loop reads everything through ctx.* — handing it a lane view
|
|
388
390
|
// keeps the shared ctx untouched (no more mutate-and-restore dance).
|
|
389
|
-
const laneCtx = { ...ctx, config: laneConfig, pipelineFlags: laneFlags, session: laneSession, brainCtx: laneBrain, plannedTask: lanePlannedTask
|
|
391
|
+
const laneCtx = { ...ctx, config: laneConfig, pipelineFlags: laneFlags, session: laneSession, brainCtx: laneBrain, plannedTask: lanePlannedTask,
|
|
392
|
+
beforeIteration: (attempt) => applyCoderRoute({ laneConfig, story, attempt, config: ctx.config, logger }) };
|
|
390
393
|
const result = await runIterationLoop(laneCtx, { task: huTask, askQuestion, emitter, logger });
|
|
391
394
|
if (result?.approved) {
|
|
392
395
|
// KJC-BUG-0248: what the HU's push or PR said joins what the loop recorded.
|
|
@@ -156,7 +156,7 @@ function sumTokenField(entries, field) {
|
|
|
156
156
|
* entries recorded during this HU. Cost D (KJC-TSK-0515).
|
|
157
157
|
* @returns {object}
|
|
158
158
|
*/
|
|
159
|
-
function buildHuOutcome({ story
|
|
159
|
+
function buildHuOutcome({ story, iterResult, status, startedAt, huBudgetEntries }) {
|
|
160
160
|
const r = iterResult || {};
|
|
161
161
|
const git = r.git || {};
|
|
162
162
|
// Iterations are tracked in the standard pipeline as
|
|
@@ -198,6 +198,8 @@ function buildHuOutcome({ story: _story, iterResult, status, startedAt, huBudget
|
|
|
198
198
|
// KJC-TSK-0702 (MR-E): the baseline model routing is measured against.
|
|
199
199
|
tokens_out: sumTokenField(huBudgetEntries, 'tokens_out'),
|
|
200
200
|
...routingBaseline(huBudgetEntries, status),
|
|
201
|
+
// KJC-TSK-1071 (MR-D3): the routing decision applyCoderRoute left on the HU.
|
|
202
|
+
coder_routing: story?.coder_routing ?? null,
|
|
201
203
|
finishedAt: new Date().toISOString(),
|
|
202
204
|
};
|
|
203
205
|
}
|
|
@@ -79,6 +79,8 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
|
|
|
79
79
|
// PAR-H (KJC-TSK-0631): lane env (KJ_LANE_SLOT / KJ_PORT_OFFSET)
|
|
80
80
|
// reaches the coder subprocess so services it starts don't collide.
|
|
81
81
|
env: config?.lane_env || null,
|
|
82
|
+
// KJC-TSK-1070 (MR-D2): the model and effort of this HU's coder.
|
|
83
|
+
route: config?.coder_route || null,
|
|
82
84
|
onOutput: coderStall.onOutput,
|
|
83
85
|
// Lets Brain Recovery persist a standby snapshot if the coder's
|
|
84
86
|
// provider hits a quota cap mid-run (KJC hibernation wiring).
|
|
@@ -87,7 +89,8 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
|
|
|
87
89
|
} finally {
|
|
88
90
|
coderStall.stop();
|
|
89
91
|
}
|
|
90
|
-
|
|
92
|
+
// KJC-TSK-1071 (MR-D3): the model the HU's route ran the coder on, so kj report measures the real one.
|
|
93
|
+
trackBudget({ role: "coder", provider: coderRole.provider, model: config?.coder_route?.model ?? coderRole.model, result: coderExecResult.result, duration_ms: Date.now() - coderStart });
|
|
91
94
|
applyQuotaSimulation(coderExecResult, { iteration, agent: coderRole.provider });
|
|
92
95
|
// KJC-BUG-0285 (#1982): a commit the coder made on its own carried the error the
|
|
93
96
|
// reviewer then rejected, and stayed on the branch. Undone softly: the changes
|
package/src/prompts/reviewer.js
CHANGED
|
@@ -54,6 +54,8 @@ export async function buildReviewerPromptLayout({ task, diff, reviewRules, mode,
|
|
|
54
54
|
section(`You are a code reviewer in ${mode} mode.`, STABLE),
|
|
55
55
|
section("CRITICAL SCOPE RULE: Only review changes that are part of the diff below. Do NOT flag issues in unchanged code, missing features planned for future tasks, or improvements outside the scope of this task. If the diff is correct for what the task asks, approve it — even if the broader codebase has other issues.", STABLE),
|
|
56
56
|
section("Only block approval for issues IN THE DIFF that are bugs, security vulnerabilities, or clear violations of the review rules.", STABLE),
|
|
57
|
+
// KJC-BUG-0369 (#2196): a reviewer blocked GitHub's on.schedule timezone, which exists.
|
|
58
|
+
section("A claim that a tool, platform or API does not support something the diff uses is not a blocking issue on its own: your training data may predate the feature. Block only when the diff or the task context shows it fails; otherwise put it in non_blocking_suggestions.", STABLE),
|
|
57
59
|
section("Return only one valid JSON object and nothing else.", STABLE),
|
|
58
60
|
section("JSON schema:", STABLE),
|
|
59
61
|
section('{"approved":boolean,"blocking_issues":[{"id":string,"severity":"critical|high|medium|low","category":"security|correctness|performance|style|other","file":string,"line":number,"description":string,"suggested_fix":string}],"non_blocking_suggestions":[string],"summary":string,"confidence":number}', STABLE),
|
package/src/rag/indexer.js
CHANGED
|
@@ -173,7 +173,7 @@ export async function indexProject(projectDir, { db, embedder, karajanHome, logg
|
|
|
173
173
|
if (source.startsWith(prefix) && outOf(source) !== null) deleteChunksBySource(db, source);
|
|
174
174
|
}
|
|
175
175
|
for (const s of sources) {
|
|
176
|
-
const r = await indexFile(s, { db, embedder, logger, project: slug, kind: sourceKind(
|
|
176
|
+
const r = await indexFile(s, { db, embedder, logger, project: slug, kind: sourceKind() });
|
|
177
177
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
178
178
|
}
|
|
179
179
|
}
|
|
@@ -211,7 +211,7 @@ export async function indexProjectDelta(projectDir, { db, embedder, since, logge
|
|
|
211
211
|
logger.info?.(`[rag-indexer] delta delete ${relative(process.cwd(), abs)}`);
|
|
212
212
|
} else if (indexableReason(relative(projectDir, abs), abs, { exclude, projectDir }) === null) {
|
|
213
213
|
// KJC-TSK-0891: the same criterion as the full walk, not a list of extensions.
|
|
214
|
-
const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind(
|
|
214
|
+
const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind() });
|
|
215
215
|
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
216
216
|
}
|
|
217
217
|
}
|
|
@@ -23,11 +23,14 @@ export function modelTally(hus) {
|
|
|
23
23
|
failure_rate: round(failed / rows.length, 4),
|
|
24
24
|
};
|
|
25
25
|
});
|
|
26
|
-
|
|
26
|
+
// KJC-TSK-1071 (MR-D3): how many HUs routing moved off the usual model, and how many climbed.
|
|
27
|
+
const routed = (hus ?? []).filter((h) => h?.outcome?.coder_routing?.model);
|
|
28
|
+
const routing = { routed: routed.length, escalated: routed.filter((h) => h.outcome.coder_routing.escalated).length };
|
|
29
|
+
return { models, unmeasured: (hus ?? []).length - measured.length, routing };
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/** The kj report block, or null when no HU was run at all. */
|
|
30
|
-
export function formatModelTally({ models, unmeasured }) {
|
|
33
|
+
export function formatModelTally({ models, unmeasured, routing = null }) {
|
|
31
34
|
if (models.length === 0 && unmeasured === 0) return null;
|
|
32
35
|
const lines = ["Coder models (per HU, from the plans):"];
|
|
33
36
|
for (const m of models) {
|
|
@@ -35,5 +38,6 @@ export function formatModelTally({ models, unmeasured }) {
|
|
|
35
38
|
lines.push(` ${m.model.padEnd(24)} ${m.hus} HU · $${m.cost_usd.toFixed(4)} · ${m.tokens_in} in / ${m.tokens_out} out · ${iterations} iter · ${Math.round(m.failure_rate * 100)}% failed first time`);
|
|
36
39
|
}
|
|
37
40
|
if (unmeasured > 0) lines.push(` ${unmeasured} HU(s) before the measurement (baseline, unmeasured)`);
|
|
41
|
+
if (routing?.routed) lines.push(` Routing: ${routing.routed} HU routed, ${routing.escalated} climbed to the top model (model_routing.enabled: false turns it off)`);
|
|
38
42
|
return lines.join("\n");
|
|
39
43
|
}
|
|
@@ -55,12 +55,17 @@ export async function runOneShotReview({
|
|
|
55
55
|
createAgentFn = createAgent,
|
|
56
56
|
detectAgents = detectAvailableAgents,
|
|
57
57
|
candidateStatusFn = candidateStatus,
|
|
58
|
+
// KJC-TSK-1075: a third-party PR is not the host's own work, so the reviewer
|
|
59
|
+
// it names runs even if it is the host; and nothing is stored, so the
|
|
60
|
+
// review can never unlock a commit of one's own.
|
|
61
|
+
reviewerOverride = null,
|
|
62
|
+
persist = true,
|
|
58
63
|
}) {
|
|
59
64
|
if (!diff || !diff.trim()) {
|
|
60
65
|
throw new Error("nothing to review — the diff is empty (stage your changes or pass --range)");
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
const reviewer = await pickCrossReviewer({ config, hostAgent, detectAgents });
|
|
68
|
+
const reviewer = reviewerOverride || await pickCrossReviewer({ config, hostAgent, detectAgents });
|
|
64
69
|
if (!reviewer) {
|
|
65
70
|
throw new Error(
|
|
66
71
|
`cross-AI review requires an agent other than the host (${hostAgent || "unknown"}) — install codex, claude or another supported CLI`
|
|
@@ -123,7 +128,7 @@ export async function runOneShotReview({
|
|
|
123
128
|
throw new Error(await reportUnparseableVerdict({ projectDir, reviewer: activeReviewer, output: result.output, hash: diffHash(diff) }));
|
|
124
129
|
}
|
|
125
130
|
|
|
126
|
-
|
|
131
|
+
const record = {
|
|
127
132
|
verdict: parsed.approved ? "approved" : "rejected",
|
|
128
133
|
reviewer: activeReviewer,
|
|
129
134
|
host: hostAgent || null,
|
|
@@ -140,5 +145,6 @@ export async function runOneShotReview({
|
|
|
140
145
|
// the verdict, bound to this diff, like the other two proofs.
|
|
141
146
|
...(ui ? { ui } : {}),
|
|
142
147
|
confidence: parsed.confidence ?? null,
|
|
143
|
-
}
|
|
148
|
+
};
|
|
149
|
+
return persist ? saveVerdict(projectDir, diff, record) : record;
|
|
144
150
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1076: the requirements of a third-party PR, checked with no tokens.
|
|
3
|
+
* Each check is {id, ok, detail}; none stops the review (the agent decides).
|
|
4
|
+
* The rules are the own-work ones: the same budget, Conventional Commits and
|
|
5
|
+
* attribution patterns, and tests-with-code.
|
|
6
|
+
*/
|
|
7
|
+
import { AI_ATTRIBUTION, CONVENTIONAL_TYPES } from "../harden/hook-templates.js";
|
|
8
|
+
import { loadPolicy } from "../policy/engine.js";
|
|
9
|
+
import { runCommand } from "../utils/process.js";
|
|
10
|
+
import { budgetedAdded } from "./loc-budget.js";
|
|
11
|
+
import { checkTestsWithCode } from "./tests-with-code.js";
|
|
12
|
+
|
|
13
|
+
const HEADER_MAX = 100;
|
|
14
|
+
const CONVENTIONAL = new RegExp(String.raw`^(${CONVENTIONAL_TYPES})(\([^)]+\))?!?: \S`);
|
|
15
|
+
const ATTRIBUTION = new RegExp(AI_ATTRIBUTION, "i");
|
|
16
|
+
|
|
17
|
+
const rowsOf = (numstat) => numstat.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => {
|
|
18
|
+
const [a, r, ...f] = l.split(/\s+/);
|
|
19
|
+
return { file: f.join(" "), added: a === "-" ? 1 : Number(a) || 0, removed: r === "-" ? 0 : Number(r) || 0 };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The PR size budget, one source for every gate (PL-C, KJC-TSK-0735): the
|
|
24
|
+
* policy's net_lines_added invariant, else method_gates.pr_size_max, 200.
|
|
25
|
+
*/
|
|
26
|
+
export function prSizeLimit({ projectDir, config }) {
|
|
27
|
+
const pol = loadPolicy({ projectDir });
|
|
28
|
+
const inv = pol.errors.length === 0
|
|
29
|
+
? (pol.policy.invariants || []).find((i) => i.kind === "diff-threshold" && i.metric === "net_lines_added")
|
|
30
|
+
: null;
|
|
31
|
+
return {
|
|
32
|
+
ideal: config?.method_gates?.pr_size_warn ?? 150,
|
|
33
|
+
limit: inv?.max ?? config?.method_gates?.pr_size_max ?? 200,
|
|
34
|
+
source: inv ? `policy [${inv.id}]` : "method_gates",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Lines added that count against the budget (generated output and human docs exempt). */
|
|
39
|
+
export function sizeCheck({ numstat, limit }) {
|
|
40
|
+
const { added, exempt } = budgetedAdded(numstat);
|
|
41
|
+
const exemptNote = exempt > 0 ? ` (${exempt} exempt)` : "";
|
|
42
|
+
return added > limit
|
|
43
|
+
? { id: "size", ok: false, detail: `${added} lines added${exemptNote}, over the ${limit}-line limit: partition the PR` }
|
|
44
|
+
: { id: "size", ok: true, detail: `${added} lines added${exemptNote}, within the ${limit}-line limit` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Conventional Commits headers (at most 100 characters) and no AI attribution in base..head. */
|
|
48
|
+
export function commitChecks(commits) {
|
|
49
|
+
const badHeader = commits.filter((c) => !CONVENTIONAL.test(c.subject) || c.subject.length > HEADER_MAX);
|
|
50
|
+
const attributed = commits.filter((c) => ATTRIBUTION.test(`${c.subject}\n${c.body}`));
|
|
51
|
+
const list = (cs) => cs.map((c) => `${c.sha.slice(0, 8)} "${c.subject}"`).join(", ");
|
|
52
|
+
return [
|
|
53
|
+
badHeader.length
|
|
54
|
+
? { id: "conventional-commits", ok: false, detail: `not Conventional Commits or over ${HEADER_MAX} characters: ${list(badHeader)}` }
|
|
55
|
+
: { id: "conventional-commits", ok: true, detail: `${commits.length} commit(s) with Conventional Commits headers` },
|
|
56
|
+
attributed.length
|
|
57
|
+
? { id: "ai-attribution", ok: false, detail: `AI attribution in: ${list(attributed)}` }
|
|
58
|
+
: { id: "ai-attribution", ok: true, detail: "no AI attribution in the commits" },
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Source changed with no test changed (a delete-only change needs none). */
|
|
63
|
+
export function testsCheck({ config, numstat }) {
|
|
64
|
+
const rows = rowsOf(numstat);
|
|
65
|
+
const res = checkTestsWithCode({ config, stagedFiles: rows.map((r) => r.file), numstat: rows });
|
|
66
|
+
const ok = res.ok && res.mode !== "warn";
|
|
67
|
+
return { id: "tests", ok, detail: ok ? `tests: ${res.mode}` : res.reason || "source changed with no test changed" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const git = async (projectDir, args) => {
|
|
71
|
+
const res = await runCommand("git", args, { cwd: projectDir });
|
|
72
|
+
if (res.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${(res.stderr || "").trim()}`);
|
|
73
|
+
return res.stdout;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Size, commits and tests of base...head, read from git. */
|
|
77
|
+
export async function prRequirementChecks({ range, projectDir, config, run = git }) {
|
|
78
|
+
const numstat = await run(projectDir, ["diff", "--numstat", range]);
|
|
79
|
+
// The commits of a PR are base..head, whichever range notation was given.
|
|
80
|
+
const [base, head] = range.split(/\.{2,3}/);
|
|
81
|
+
const log = await run(projectDir, ["log", "--format=%H%x1f%s%x1f%b%x1e", `${base}..${head || "HEAD"}`]);
|
|
82
|
+
const commits = log.split("\x1e").map((r) => r.trim()).filter(Boolean).map((r) => {
|
|
83
|
+
const [sha, subject = "", body = ""] = r.split("\x1f");
|
|
84
|
+
return { sha, subject, body };
|
|
85
|
+
});
|
|
86
|
+
return [sizeCheck({ numstat, limit: prSizeLimit({ projectDir, config }).limit }), ...commitChecks(commits), testsCheck({ config, numstat })];
|
|
87
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1077: the security and privacy requirements of a third-party PR,
|
|
3
|
+
* with no tokens. Each check is {id, ok, detail} and never stops the review.
|
|
4
|
+
* They reuse the scanners kj applies to its own work: the output guard's
|
|
5
|
+
* credential patterns, the privacy scan, the prompt-injection catalog, the
|
|
6
|
+
* supply-chain cooldown and semgrep, limited to the PR's files.
|
|
7
|
+
*/
|
|
8
|
+
import { CREDENTIAL_PATTERNS, scanDiff as scanGuards } from "../guards/output-guard.js";
|
|
9
|
+
import { scanDiff as scanInjection } from "../utils/injection-guard.js";
|
|
10
|
+
import { loadPrivacyList, scanText } from "../privacy/scan.js";
|
|
11
|
+
import { isGeneratedPath, splitAddedByFile } from "../privacy/diff-scope.js";
|
|
12
|
+
import { collectSemgrepFindings } from "../audit/semgrep-findings.js";
|
|
13
|
+
import { cooldownAdvisories } from "./dep-cooldown.js";
|
|
14
|
+
|
|
15
|
+
const CREDENTIAL_IDS = new Set(CREDENTIAL_PATTERNS.map((p) => p.id));
|
|
16
|
+
const BLOCKING_SEMGREP = new Set(["ERROR", "WARNING"]);
|
|
17
|
+
const check = (id, problems, okDetail) => (problems.length
|
|
18
|
+
? { id, ok: false, detail: problems.join("; ") }
|
|
19
|
+
: { id, ok: true, detail: okDetail });
|
|
20
|
+
|
|
21
|
+
/** Credentials in added lines (AWS, private keys, tokens, URLs with passwords...). */
|
|
22
|
+
export function secretsCheck(diff, config) {
|
|
23
|
+
const found = scanGuards(diff, config).violations.filter((v) => CREDENTIAL_IDS.has(v.id));
|
|
24
|
+
return check("secrets", found.map((v) => `${v.file}:${v.line} ${v.id}`), "no secrets in the added lines");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Personal data in added lines, masked; generated output only for denylist hits. */
|
|
28
|
+
export function privacyCheck(diff, { list = loadPrivacyList() } = {}) {
|
|
29
|
+
const found = splitAddedByFile(diff).flatMap(({ file, added }) => {
|
|
30
|
+
const hits = scanText(added, { list, source: file });
|
|
31
|
+
return isGeneratedPath(file) ? hits.filter((f) => f.severity === "block") : hits;
|
|
32
|
+
});
|
|
33
|
+
return check("privacy", found.map((f) => `${f.source}:${f.line} ${f.type} ${f.masked}`), "no personal data in the added lines");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Prompt-injection patterns in the added lines (text an AI will read). */
|
|
37
|
+
export function injectionCheck(diff) {
|
|
38
|
+
const res = scanInjection(diff);
|
|
39
|
+
return check("prompt-injection", res.clean ? [] : [res.summary], "no prompt-injection patterns");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Dependencies the lockfile adds that were published under the threshold. */
|
|
43
|
+
export async function cooldownCheck(diff, { advisories = cooldownAdvisories, thresholdHours } = {}) {
|
|
44
|
+
const { young = [], unverified = [], skipped = 0 } = await advisories(diff, thresholdHours ? { thresholdHours } : {});
|
|
45
|
+
const problems = young.map((p) => `${p.name}@${p.version} published ${p.hours} h ago`);
|
|
46
|
+
const notes = [...unverified.map((p) => `${p.name}@${p.version} unverified`), ...(skipped ? [`${skipped} packages changed, none checked`] : [])];
|
|
47
|
+
const res = check("dependency-cooldown", problems, "no dependency published in the last hours");
|
|
48
|
+
return notes.length ? { ...res, detail: `${res.detail} (${notes.join("; ")})` } : res;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** semgrep (the static part of kj audit --security), counted on the PR's files only. */
|
|
52
|
+
export async function semgrepCheck({ projectDir, files, collect = collectSemgrepFindings }) {
|
|
53
|
+
const res = await collect(projectDir);
|
|
54
|
+
if (!res.available) return { id: "semgrep", ok: true, detail: `not available: ${res.reason}` };
|
|
55
|
+
const inPr = new Set(files);
|
|
56
|
+
const found = (res.findings ?? []).filter((f) => inPr.has(f.file) && BLOCKING_SEMGREP.has(f.severity));
|
|
57
|
+
return check("semgrep", found.map((f) => `${f.file}:${f.line} ${f.rule}`), "no semgrep findings in the PR's files");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every security and privacy check of the PR's diff. */
|
|
61
|
+
export async function prSecurityChecks({ diff, projectDir, config }) {
|
|
62
|
+
const files = splitAddedByFile(diff).map((f) => f.file);
|
|
63
|
+
return [
|
|
64
|
+
secretsCheck(diff, config),
|
|
65
|
+
privacyCheck(diff),
|
|
66
|
+
injectionCheck(diff),
|
|
67
|
+
await cooldownCheck(diff, { thresholdHours: config?.supply_chain?.cooldown_hours }),
|
|
68
|
+
await semgrepCheck({ projectDir, files }),
|
|
69
|
+
];
|
|
70
|
+
}
|