karajan-code 4.55.0 → 4.57.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/scripts/osv-gate.mjs +45 -0
- package/src/audit/osv-findings.js +8 -0
- package/src/checks/release-check.js +7 -1
- package/src/cli/register-meta.js +2 -0
- package/src/cli/register-pipeline.js +8 -0
- package/src/commands/harden.js +5 -15
- package/src/commands/rag.js +1 -1
- package/src/commands/review-gate.js +14 -8
- package/src/commands/worktree.js +2 -1
- package/src/git/hu-automation.js +2 -2
- package/src/harden/check.js +14 -13
- package/src/harden/config-engine.js +34 -24
- package/src/harden/config-templates.js +17 -1
- 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/sandbox.js +120 -0
- package/src/harden/sentinel/sentinel-shell.mjs +6 -5
- package/src/harden/sentinel-hooks.js +22 -87
- package/src/rag/indexer.js +2 -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 +7 -3
- package/src/review/tests-with-code.js +5 -2
- package/src/review/third-party-review.js +67 -0
- package/src/steward/gate.js +31 -0
- package/src/utils/git.js +3 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sandbox — kj harden turns the host's Bash sandbox on (KJC-TSK-1087, ADR 0015).
|
|
3
|
+
*
|
|
4
|
+
* Claude Code sandboxes Bash with bubblewrap on Linux and seatbelt on macOS. With
|
|
5
|
+
* allowUnsandboxedCommands off, a sandbox that cannot start leaves the session with
|
|
6
|
+
* no Bash at all (pilot KJC-TSK-1083: Ubuntu 24.04 restricts user namespaces until
|
|
7
|
+
* bwrap has an AppArmor profile). So the probe runs first, on this machine.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { delimiter, dirname, join } from "node:path";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
const USERNS_SYSCTL = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns";
|
|
14
|
+
|
|
15
|
+
const whichOnPath = (bin) => (process.env.PATH ?? "").split(delimiter).map((d) => join(d, bin)).find((p) => existsSync(p)) ?? null;
|
|
16
|
+
const runSync = (bin, args) => spawnSync(bin, args, { encoding: "utf8", timeout: 10_000 });
|
|
17
|
+
|
|
18
|
+
const missing = (tool, pkg) => ({ ok: false, reason: `${tool} is not installed`, remedy: `install ${pkg} (apt install ${pkg}, dnf install ${pkg})` });
|
|
19
|
+
|
|
20
|
+
function userNamespacesRestricted(readFile) {
|
|
21
|
+
try { return readFile(USERNS_SYSCTL).trim() === "1"; } catch { return false; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function probeLinux({ which, run, readFile }) {
|
|
25
|
+
const bwrap = which("bwrap");
|
|
26
|
+
if (!bwrap) return missing("bubblewrap (bwrap)", "bubblewrap");
|
|
27
|
+
if (!which("socat")) return missing("socat", "socat");
|
|
28
|
+
// The same namespaces Claude Code's sandbox asks for: user and network.
|
|
29
|
+
const res = run(bwrap, ["--ro-bind", "/", "/", "--dev", "/dev", "--unshare-user", "--unshare-net", "true"]);
|
|
30
|
+
if (res.status === 0) return { ok: true };
|
|
31
|
+
if (userNamespacesRestricted(readFile)) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
reason: "AppArmor restricts unprivileged user namespaces and bwrap has no profile",
|
|
35
|
+
remedy: "add /etc/apparmor.d/bwrap allowing userns for /usr/bin/bwrap, then load it with apparmor_parser -r (your user does this)",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const detail = String(res.stderr ?? "").trim() || "exit " + res.status;
|
|
39
|
+
return { ok: false, reason: `bwrap cannot create its namespaces: ${detail}`, remedy: "fix bwrap on this machine, then run kj harden again" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Can the host sandbox start on this machine? Never throws.
|
|
44
|
+
* @returns {{ok: true} | {ok: false, reason: string, remedy?: string}}
|
|
45
|
+
*/
|
|
46
|
+
export function probeSandbox({ platform = process.platform, which = whichOnPath, run = runSync, readFile = (p) => readFileSync(p, "utf8"), exists = existsSync } = {}) {
|
|
47
|
+
if (platform === "linux") return probeLinux({ which, run, readFile });
|
|
48
|
+
if (platform === "darwin") {
|
|
49
|
+
if (!exists("/usr/bin/sandbox-exec")) return { ok: false, reason: "sandbox-exec is missing from /usr/bin" };
|
|
50
|
+
const res = run("/usr/bin/sandbox-exec", ["-p", "(version 1)(allow default)", "/usr/bin/true"]);
|
|
51
|
+
const detail = String(res.stderr ?? "").trim() || "exit " + res.status;
|
|
52
|
+
return res.status === 0 ? { ok: true } : { ok: false, reason: `sandbox-exec cannot start a sandbox: ${detail}` };
|
|
53
|
+
}
|
|
54
|
+
return { ok: false, reason: `the host sandbox does not run on ${platform}` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// KJC-TSK-1092: what the method needs from the sandbox (lessons of the pilot, KJC-TSK-1083).
|
|
58
|
+
// gh keeps its tokens in the keyring over D-Bus; kj review reaches a local SonarQube,
|
|
59
|
+
// which the sandbox's private localhost cannot (the reviewer agents run inside it).
|
|
60
|
+
// Outside the project only kj's state and package caches are writable, and the
|
|
61
|
+
// supervisor and the phone key stay out of reach.
|
|
62
|
+
const METHOD_SANDBOX = {
|
|
63
|
+
excludedCommands: ["gh *", "kj review *"],
|
|
64
|
+
denyWrite: [".karajan/harness", ".karajan/supervisor-provenance.json", "~/.karajan/supervisor-phone.json"],
|
|
65
|
+
allowWrite: ["~/.karajan", "~/.npm/_cacache", "~/.npm/_logs", "~/.npm/_npx", "~/.cache", "~/.config/configstore"],
|
|
66
|
+
allowedDomains: [
|
|
67
|
+
"github.com", "api.github.com", "*.githubusercontent.com", "uploads.github.com", "registry.npmjs.org",
|
|
68
|
+
"api.openai.com", "chatgpt.com", "*.openai.com", "api.anthropic.com", "*.anthropic.com", "claude.ai",
|
|
69
|
+
"api.githubcopilot.com", "*.githubcopilot.com", "*.googleapis.com",
|
|
70
|
+
],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const union = (have, add) => [...new Set([...(Array.isArray(have) ? have : []), ...add])];
|
|
74
|
+
|
|
75
|
+
function withMethodSandbox(settings) {
|
|
76
|
+
const sb = settings.sandbox ?? {};
|
|
77
|
+
const fsCfg = sb.filesystem ?? {};
|
|
78
|
+
const net = sb.network ?? {};
|
|
79
|
+
return {
|
|
80
|
+
...settings,
|
|
81
|
+
sandbox: {
|
|
82
|
+
...sb,
|
|
83
|
+
enabled: true,
|
|
84
|
+
allowUnsandboxedCommands: false,
|
|
85
|
+
excludedCommands: union(sb.excludedCommands, METHOD_SANDBOX.excludedCommands),
|
|
86
|
+
filesystem: { ...fsCfg, denyWrite: union(fsCfg.denyWrite, METHOD_SANDBOX.denyWrite), allowWrite: union(fsCfg.allowWrite, METHOD_SANDBOX.allowWrite) },
|
|
87
|
+
network: { ...net, allowedDomains: union(net.allowedDomains, METHOD_SANDBOX.allowedDomains) },
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Turn the host sandbox on in .claude/settings.local.json when it can start here.
|
|
94
|
+
* @returns {{state: "on" | "off" | "left-off" | "untouched", reason?: string}}
|
|
95
|
+
*/
|
|
96
|
+
export function installSandbox({ projectDir, logger = console, probe = probeSandbox }) {
|
|
97
|
+
const ready = probe();
|
|
98
|
+
if (!ready.ok) {
|
|
99
|
+
const remedy = ready.remedy ? `; ${ready.remedy}` : "";
|
|
100
|
+
logger.info?.(`kj harden: host sandbox left off, ${ready.reason}${remedy}`);
|
|
101
|
+
return { state: "off", reason: ready.reason };
|
|
102
|
+
}
|
|
103
|
+
const path = join(projectDir, ".claude", "settings.local.json");
|
|
104
|
+
let settings = {};
|
|
105
|
+
if (existsSync(path)) {
|
|
106
|
+
try {
|
|
107
|
+
settings = JSON.parse(readFileSync(path, "utf8"));
|
|
108
|
+
} catch {
|
|
109
|
+
logger.warn?.(`kj harden: ${path} is not valid JSON, the host sandbox was not written`);
|
|
110
|
+
return { state: "untouched", reason: "invalid JSON" };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (settings.sandbox?.enabled === false) {
|
|
114
|
+
logger.info?.(`kj harden: the host sandbox stays off, ${path} says enabled: false`);
|
|
115
|
+
return { state: "left-off", reason: "enabled: false" };
|
|
116
|
+
}
|
|
117
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
118
|
+
writeFileSync(path, `${JSON.stringify(withMethodSandbox(settings), null, 2)}\n`);
|
|
119
|
+
return { state: "on" };
|
|
120
|
+
}
|
|
@@ -87,20 +87,21 @@ export const isGhIssueCreate = (cmd) => shellSegments(cmd).some((words) => {
|
|
|
87
87
|
});
|
|
88
88
|
|
|
89
89
|
// KJC-BUG-0204: the real command puts flags between the verb's words
|
|
90
|
-
// (
|
|
91
|
-
|
|
90
|
+
// (npm --prefix x publish), so they are read in order, not adjacent.
|
|
91
|
+
// KJC-BUG-0373: only a release of the package; a deploy (database rules, a site)
|
|
92
|
+
// is not one, and gating it kept an open database open.
|
|
93
|
+
const PUBLISH_VERBS = [["npm", "publish"], ["gh", "release", "create"]];
|
|
92
94
|
// Known options that take a separate value: that value is not a subcommand word
|
|
93
95
|
// (npm --tag publish test does not publish). An unknown option is not skipped, so
|
|
94
96
|
// a real publish behind it is still read (a false positive costs less than a miss).
|
|
95
97
|
const VALUE_OPTIONS = {
|
|
96
98
|
npm: ["--prefix", "--tag", "--registry", "--userconfig", "--workspace", "-w", "--otp", "--access", "--cache", "--loglevel", "-C"],
|
|
97
|
-
firebase: ["--account", "--project", "-P", "--config", "-c", "--only", "--except", "--token", "--message", "-m"],
|
|
98
99
|
gh: ["--repo", "-R", "--hostname"],
|
|
99
100
|
};
|
|
100
101
|
|
|
101
102
|
/**
|
|
102
|
-
* KJC-BUG-0333 (#2093): a simple command whose head is npm
|
|
103
|
-
*
|
|
103
|
+
* KJC-BUG-0333 (#2093): a simple command whose head is npm or gh and whose own
|
|
104
|
+
* words then say publish/release create. Words inside a quoted
|
|
104
105
|
* argument of another command (a kj rag query text, a commit message) are one
|
|
105
106
|
* word of THAT command, so they never read as a publish.
|
|
106
107
|
* @param {string} cmd
|
|
@@ -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);
|
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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1078: Sonar on a third-party PR, when SonarQube is reachable. The
|
|
3
|
+
* own-work pre-gate scans the PR's files and only issues on the lines the PR
|
|
4
|
+
* adds can fail the check; without Sonar the check says "not available" and
|
|
5
|
+
* passes (decision of the user: Sonar if available, never demanded).
|
|
6
|
+
*/
|
|
7
|
+
import { addedLinesByFile, issueFile, runSonarPregate } from "./sonar-pregate.js";
|
|
8
|
+
|
|
9
|
+
export async function sonarCheck({ diff, config, projectDir = process.cwd(), pregate = runSonarPregate }) {
|
|
10
|
+
const touchedLines = addedLinesByFile(diff);
|
|
11
|
+
let res;
|
|
12
|
+
try {
|
|
13
|
+
res = await pregate({ config, stagedFiles: [...touchedLines.keys()], touchedLines, projectDir });
|
|
14
|
+
} catch (err) {
|
|
15
|
+
res = { available: false, reason: err.message };
|
|
16
|
+
}
|
|
17
|
+
if (!res.available) return { id: "sonar", ok: true, detail: `not available: ${res.reason}` };
|
|
18
|
+
const blocking = res.blocking ?? [];
|
|
19
|
+
return blocking.length
|
|
20
|
+
? { id: "sonar", ok: false, detail: blocking.map((i) => `${issueFile(i)}:${i.line} ${i.rule} ${i.severity}`).join("; ") }
|
|
21
|
+
: { id: "sonar", ok: true, detail: `no blocking Sonar issue on the added lines (${(res.advisory ?? []).length} advisory)` };
|
|
22
|
+
}
|
|
@@ -82,7 +82,8 @@ export function groupByScanRoot(files, { root = process.cwd(), exists = existsSy
|
|
|
82
82
|
return groups;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
// KJC-TSK-1078: projectDir is where the files live (a third-party PR's clone may not be the cwd).
|
|
86
|
+
export async function runSonarPregate({ config, stagedFiles = [], touchedLines = null, logger = null, exists = existsSync, projectDir = process.cwd() }) {
|
|
86
87
|
if (config?.review_gate?.sonar === false) {
|
|
87
88
|
return { available: false, reason: "disabled in config (review_gate.sonar: false)" };
|
|
88
89
|
}
|
|
@@ -94,7 +95,9 @@ export async function runSonarPregate({ config, stagedFiles = [], touchedLines =
|
|
|
94
95
|
let lock = null;
|
|
95
96
|
try {
|
|
96
97
|
lock = await acquireToolLock("sonar-scanner", { timeoutMs: 300_000, slots });
|
|
97
|
-
const groups = groupByScanRoot(stagedFiles, { exists });
|
|
98
|
+
const groups = groupByScanRoot(stagedFiles, { root: projectDir, exists });
|
|
99
|
+
// The root keeps the scanner's own default (cwd) unless the project lives elsewhere.
|
|
100
|
+
const rootCwd = projectDir === process.cwd() ? null : projectDir;
|
|
98
101
|
if (groups.size === 0) groups.set("", []);
|
|
99
102
|
const indexed = new Set();
|
|
100
103
|
const issues = [];
|
|
@@ -103,7 +106,8 @@ export async function runSonarPregate({ config, stagedFiles = [], touchedLines =
|
|
|
103
106
|
for (const dir of groups.keys()) {
|
|
104
107
|
const prefix = dir ? `${dir}/` : "";
|
|
105
108
|
// KJC-TSK-0838: verbose, so the scanner names every file it indexed.
|
|
106
|
-
const
|
|
109
|
+
const scanCwd = dir ? join(projectDir, dir) : rootCwd;
|
|
110
|
+
const scan = await runSonarScan(config, null, { verbose: true, ...(scanCwd ? { cwd: scanCwd } : {}) });
|
|
107
111
|
if (scan.note) logger?.warn?.(scan.note); // KJC-BUG-0156: precedence is said, never silent
|
|
108
112
|
if (!scan.ok) {
|
|
109
113
|
return { available: false, reason: (scan.stderr || scan.stdout || "sonar scan failed").trim() };
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* No escape (ADR 0015): deletions and comment-only cleanups already pass.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
import { isGenerated } from "./loc-budget.js";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_TEST_PATTERNS =["/tests/", "/__tests__/", ".test.", ".spec."];
|
|
12
14
|
const DEFAULT_SOURCE_EXTS = [".js", ".jsx", ".ts", ".tsx", ".py", ".go", ".java", ".rb", ".php", ".cs"];
|
|
13
15
|
|
|
14
16
|
/**
|
|
@@ -22,8 +24,9 @@ export function sourceFilesOf(config = {}, files = []) {
|
|
|
22
24
|
// Staged paths are repo-relative (no leading slash) — normalize so the
|
|
23
25
|
// "/tests/" style patterns also match the top-level tests directory.
|
|
24
26
|
const isTest = (f) => patterns.some((p) => `/${f}`.includes(p));
|
|
27
|
+
// KJC-BUG-0377: build output (a rebuilt docs site's _astro/*.js) is nobody's code.
|
|
25
28
|
return {
|
|
26
|
-
sources: files.filter((f) => !isTest(f) && exts.some((e) => f.endsWith(e))),
|
|
29
|
+
sources: files.filter((f) => !isTest(f) && !isGenerated(f) && exts.some((e) => f.endsWith(e))),
|
|
27
30
|
hasTests: files.some(isTest),
|
|
28
31
|
};
|
|
29
32
|
}
|