karajan-code 4.21.0 → 4.23.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 +6 -3
- package/package.json +5 -2
- package/packages/hu-board/public/app.js +1 -0
- package/packages/hu-board/public/index.html +2 -0
- package/packages/hu-board/public/styles.css +22 -0
- package/packages/hu-board/public/utils/governance-view.js +149 -0
- package/packages/hu-board/src/auth.js +29 -1
- package/packages/hu-board/src/routes/governance.js +140 -0
- package/packages/hu-board/src/server.js +2 -0
- package/scripts/install.js +4 -3
- package/scripts/postinstall.js +4 -3
- package/scripts/toml-value.js +18 -0
- package/src/audit/basal-cost.js +4 -0
- package/src/audit/dead-exports.js +52 -1
- package/src/audit/deterministic-summary.js +18 -3
- package/src/audit/member-reachability.js +158 -0
- package/src/audit/osv-findings.js +1 -0
- package/src/checks/ai-trash.js +1 -1
- package/src/checks/mcp-health.js +1 -1
- package/src/checks/native-build.js +2 -2
- package/src/checks/release-check.js +62 -2
- package/src/claims/cross-check.js +86 -0
- package/src/claims/extract.js +56 -0
- package/src/claims/turn.js +54 -0
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +66 -5
- package/src/commands/claims.js +73 -0
- package/src/commands/hu.js +3 -1
- package/src/commands/init.js +1 -1
- package/src/commands/policy.js +84 -3
- package/src/commands/privacy.js +6 -2
- package/src/commands/resume.js +4 -0
- package/src/commands/review-gate.js +34 -9
- package/src/commands/steward.js +146 -0
- package/src/config/defaults.js +6 -1
- package/src/environment/playbook.js +2 -1
- package/src/guards/duplicate-members.js +86 -0
- package/src/harden/sentinel-hooks.js +188 -22
- package/src/harden/workflow-engine.js +8 -2
- package/src/harden/workflow-templates.js +38 -1
- package/src/policy/exceptions.js +14 -4
- package/src/policy/report.js +99 -0
- package/src/privacy/scan.js +23 -1
- package/src/review/card-first.js +4 -1
- package/src/review/one-shot-review.js +10 -4
- package/src/review/parser.js +9 -0
- package/src/review/sonar-pregate.js +38 -3
- package/src/review/tests-with-code.js +12 -1
- package/src/review/unparseable-verdict.js +50 -0
- package/src/roles/audit-role.js +19 -1
- package/src/sonar/scanner.js +34 -8
- package/src/steward/invariants.js +190 -0
- package/src/steward/phantom-coverage.js +137 -0
- package/src/steward/proposed-work.js +68 -0
package/src/privacy/scan.js
CHANGED
|
@@ -52,12 +52,25 @@ const SECRET_HEURISTICS = [
|
|
|
52
52
|
{ type: "conn-string", re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:([^\s@/]+)@/gi },
|
|
53
53
|
];
|
|
54
54
|
|
|
55
|
+
// KJC-TSK-0797 (epic KJC-PCS-0082) — context BEFORE generics, measured twice:
|
|
56
|
+
// GREBLA's pinned Actions reported as phone numbers, and this repo's own
|
|
57
|
+
// workflow pinning flagged twelve times in one PR. A git object id is not a
|
|
58
|
+
// phone; an email on an RFC 2606 documentation domain is not a person. Both
|
|
59
|
+
// are blanked before redactPII and COUNTED, so a clean result can say
|
|
60
|
+
// "nothing found" or "found and discarded by context" — never the same thing.
|
|
61
|
+
// The personal denylist runs before this: the user's own datum always blocks.
|
|
62
|
+
const CONTEXT_DISCARDS = [
|
|
63
|
+
{ type: "git-sha", re: /\b(?:[0-9a-f]{64}|[0-9a-f]{40})\b/g },
|
|
64
|
+
{ type: "doc-domain-email", re: /\b[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)*(?:example\.(?:com|org|net)|test|invalid|localhost|example)\b/g },
|
|
65
|
+
];
|
|
66
|
+
|
|
55
67
|
/**
|
|
56
68
|
* Scan a text. Returns findings — denylist hits as `severity:"block"`
|
|
57
69
|
* (masked value), generic PII as `severity:"warn"` (line already redacted).
|
|
58
70
|
*/
|
|
59
71
|
export function scanText(text, { list = loadPrivacyList(), source = "<text>" } = {}) {
|
|
60
72
|
const findings = [];
|
|
73
|
+
let discarded = 0;
|
|
61
74
|
String(text).split("\n").forEach((line, i) => {
|
|
62
75
|
let probe = line;
|
|
63
76
|
for (const a of list.allow) probe = probe.split(a).join(" ");
|
|
@@ -86,6 +99,10 @@ export function scanText(text, { list = loadPrivacyList(), source = "<text>" } =
|
|
|
86
99
|
re.lastIndex = m.index + m[0].length;
|
|
87
100
|
}
|
|
88
101
|
}
|
|
102
|
+
for (const { re } of CONTEXT_DISCARDS) {
|
|
103
|
+
re.lastIndex = 0;
|
|
104
|
+
probe = probe.replace(re, (m) => { discarded += 1; return " ".repeat(m.length); });
|
|
105
|
+
}
|
|
89
106
|
const red = redactPII(probe);
|
|
90
107
|
if (red.total > 0) {
|
|
91
108
|
for (const [type, n] of Object.entries(red.counts)) {
|
|
@@ -93,12 +110,14 @@ export function scanText(text, { list = loadPrivacyList(), source = "<text>" } =
|
|
|
93
110
|
}
|
|
94
111
|
}
|
|
95
112
|
});
|
|
113
|
+
findings.discardedByContext = discarded;
|
|
96
114
|
return findings;
|
|
97
115
|
}
|
|
98
116
|
|
|
99
117
|
/** Scan files/dirs recursively (skips node_modules/.git, binaries, big files). */
|
|
100
118
|
export function scanPaths(paths, { list = loadPrivacyList() } = {}) {
|
|
101
119
|
const findings = [];
|
|
120
|
+
let discarded = 0;
|
|
102
121
|
const visit = (p) => {
|
|
103
122
|
if (!existsSync(p)) return;
|
|
104
123
|
// Skip symlinks: an ancestor link recurses forever; an outside link ships nothing.
|
|
@@ -113,8 +132,11 @@ export function scanPaths(paths, { list = loadPrivacyList() } = {}) {
|
|
|
113
132
|
if (BINARY_EXT.has(extname(p).toLowerCase()) || st.size > MAX_FILE_BYTES) return;
|
|
114
133
|
const content = readFileSync(p);
|
|
115
134
|
if (content.includes(0)) return; // binary sniff: NUL byte
|
|
116
|
-
|
|
135
|
+
const fileFindings = scanText(content.toString("utf8"), { list, source: p });
|
|
136
|
+
discarded += fileFindings.discardedByContext || 0;
|
|
137
|
+
findings.push(...fileFindings);
|
|
117
138
|
};
|
|
118
139
|
for (const p of paths) visit(p);
|
|
140
|
+
findings.discardedByContext = discarded;
|
|
119
141
|
return findings;
|
|
120
142
|
}
|
package/src/review/card-first.js
CHANGED
|
@@ -14,7 +14,10 @@ const LIVE_STATUSES = new Set(["pending", "running", "failed"]);
|
|
|
14
14
|
// Card-shaped reference: LIN-123, bb-002 and multi-segment ids like
|
|
15
15
|
// KJC-TSK-0684 (optional middle segments) — tight enough to skip slugs.
|
|
16
16
|
// Shared with the method report (MG-D): one pattern, one truth.
|
|
17
|
-
|
|
17
|
+
// The lookahead keeps a version tail from reading as a card (KJC-BUG-0154):
|
|
18
|
+
// `chore/release-4.22.0` matched "release-4" at the dot's word boundary, and
|
|
19
|
+
// the board-sync gate then demanded moving a card that exists nowhere.
|
|
20
|
+
export const CARD_REF_RE = /\b[a-z][a-z0-9]{1,9}(?:-[a-z][a-z0-9]{1,9})*-\d{1,6}\b(?!\.\d)/i;
|
|
18
21
|
const DEFAULT_EXEMPT_PREFIXES = ["chore/release-"];
|
|
19
22
|
|
|
20
23
|
// Token-boundary match: BB-002 must not satisfy a branch that actually
|
|
@@ -12,9 +12,10 @@ import { createAgent } from "../agents/index.js";
|
|
|
12
12
|
import { resolveRole } from "../config/role-resolver.js";
|
|
13
13
|
import { buildReviewerPrompt } from "../prompts/reviewer.js";
|
|
14
14
|
import { resolveReviewProfile } from "./profiles.js";
|
|
15
|
-
import { parseMaybeJsonString } from "./parser.js";
|
|
15
|
+
import { parseMaybeJsonString, normalizeReviewPayload } from "./parser.js";
|
|
16
16
|
import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
|
|
17
|
-
import { saveVerdict } from "./verdict-store.js";
|
|
17
|
+
import { saveVerdict, diffHash } from "./verdict-store.js";
|
|
18
|
+
import { reportUnparseableVerdict } from "./unparseable-verdict.js";
|
|
18
19
|
import { detectWorkspace } from "./workspace.js";
|
|
19
20
|
import { isQuotaExhausted, candidateStatus, pickQuotaFallback, formatCandidateMenu } from "./reviewer-fallback.js";
|
|
20
21
|
|
|
@@ -103,9 +104,14 @@ export async function runOneShotReview({
|
|
|
103
104
|
throw new Error(`reviewer ${activeReviewer} failed: ${result?.error || "no output"}`);
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
|
|
107
|
+
// KJC-BUG-0146, second half: parseMaybeJsonString only PARSES — it returns whatever JSON came
|
|
108
|
+
// back, wrapper and all. Yesterday's fix taught normalizeReviewPayload to unwrap {ok, result},
|
|
109
|
+
// but this path never called it, so the bug survived with a green test on the wrong function.
|
|
110
|
+
// A test can only prove the code it actually exercises.
|
|
111
|
+
const parsed = normalizeReviewPayload(parseMaybeJsonString(result.output));
|
|
107
112
|
if (!parsed || typeof parsed.approved !== "boolean") {
|
|
108
|
-
|
|
113
|
+
// KJC-BUG-0146: keep the answer. Throwing it away is what left eight occurrences undiagnosed.
|
|
114
|
+
throw new Error(await reportUnparseableVerdict({ projectDir, reviewer: activeReviewer, output: result.output, hash: diffHash(diff) }));
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
return saveVerdict(projectDir, diff, {
|
package/src/review/parser.js
CHANGED
|
@@ -33,10 +33,19 @@ export function normalizeReviewPayload(payload) {
|
|
|
33
33
|
if (isReviewPayload(payload)) return payload;
|
|
34
34
|
if (Array.isArray(payload)) return findReviewInArray(payload);
|
|
35
35
|
|
|
36
|
+
// KJC-BUG-0146 — the verdict often arrives WRAPPED by the CLI that produced it:
|
|
37
|
+
// {"ok":true,"result":{approved,...}}. Only the string form was unwrapped, so a
|
|
38
|
+
// perfectly good approval from codex was thrown away as "no parseable verdict"
|
|
39
|
+
// eight times in three days. The evidence came from the raw answer this bug's
|
|
40
|
+
// first fix started saving.
|
|
36
41
|
if (typeof payload.result === "string") {
|
|
37
42
|
const parsedResult = parseMaybeJsonString(payload.result);
|
|
38
43
|
if (parsedResult?.approved !== undefined) return parsedResult;
|
|
39
44
|
}
|
|
45
|
+
if (payload.result && typeof payload.result === "object") {
|
|
46
|
+
const inner = normalizeReviewPayload(payload.result);
|
|
47
|
+
if (inner) return inner;
|
|
48
|
+
}
|
|
40
49
|
|
|
41
50
|
return null;
|
|
42
51
|
}
|
|
@@ -28,13 +28,38 @@ export function formatSonarFinding(issue) {
|
|
|
28
28
|
return `(${issue.severity}) [${issueFile(issue)}${line}] ${issue.rule} — ${issue.message}`;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* KJC-TSK-0795 AC3: the NEW line numbers each file gains in a unified diff
|
|
33
|
+
* (`git diff --unified=0`). Only what the diff ADDS can be the author's fault.
|
|
34
|
+
* @param {string} diffText @returns {Map<string, Set<number>>}
|
|
35
|
+
*/
|
|
36
|
+
export function addedLinesByFile(diffText) {
|
|
37
|
+
const map = new Map();
|
|
38
|
+
let current = null;
|
|
39
|
+
for (const raw of String(diffText || "").split("\n")) {
|
|
40
|
+
if (raw.startsWith("+++ ")) {
|
|
41
|
+
const p = raw.slice(4).trim();
|
|
42
|
+
current = p.startsWith("b/") ? p.slice(2) : p === "/dev/null" ? null : p;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const h = current ? /^@@ [^+]*\+(\d+)(?:,(\d+))? @@/.exec(raw) : null;
|
|
46
|
+
if (!h) continue;
|
|
47
|
+
const start = Number(h[1]);
|
|
48
|
+
const count = h[2] === undefined ? 1 : Number(h[2]);
|
|
49
|
+
if (count === 0) continue;
|
|
50
|
+
const set = map.get(current) ?? map.set(current, new Set()).get(current);
|
|
51
|
+
for (let i = 0; i < count; i++) set.add(start + i);
|
|
52
|
+
}
|
|
53
|
+
return map;
|
|
54
|
+
}
|
|
55
|
+
|
|
31
56
|
/**
|
|
32
57
|
* Scan the project (single-flight via the tool governor) and return the
|
|
33
58
|
* open issues that live on the staged files, split by blocking severity.
|
|
34
59
|
* Every failure path degrades to {available:false, reason} — the pre-gate
|
|
35
60
|
* never breaks the review, it only refuses to stay silent.
|
|
36
61
|
*/
|
|
37
|
-
export async function runSonarPregate({ config, stagedFiles = [], logger = null }) {
|
|
62
|
+
export async function runSonarPregate({ config, stagedFiles = [], touchedLines = null, logger = null }) {
|
|
38
63
|
if (config?.review_gate?.sonar === false) {
|
|
39
64
|
return { available: false, reason: "disabled in config (review_gate.sonar: false)" };
|
|
40
65
|
}
|
|
@@ -42,16 +67,26 @@ export async function runSonarPregate({ config, stagedFiles = [], logger = null
|
|
|
42
67
|
try {
|
|
43
68
|
lock = await acquireToolLock("sonar-scanner", { timeoutMs: 300_000 });
|
|
44
69
|
const scan = await runSonarScan(config);
|
|
70
|
+
if (scan.note) logger?.warn?.(scan.note); // KJC-BUG-0156: precedence is said, never silent
|
|
45
71
|
if (!scan.ok) {
|
|
46
72
|
return { available: false, reason: (scan.stderr || scan.stdout || "sonar scan failed").trim() };
|
|
47
73
|
}
|
|
74
|
+
// The scan above ALWAYS runs before issues are read (single-flight): the
|
|
75
|
+
// verdict is about the code as it is now, never a stale server analysis
|
|
76
|
+
// (KJC-TSK-0795 AC2 — that failure mode has no route here, by design).
|
|
48
77
|
const res = await getOpenIssues(config, scan.projectKey);
|
|
49
78
|
const staged = new Set(stagedFiles);
|
|
50
79
|
const onStaged = (res.issues || []).filter((i) => staged.has(issueFile(i)));
|
|
80
|
+
// KJC-TSK-0795 AC3: with the diff's line map, only issues on lines the PR
|
|
81
|
+
// ADDS can veto — a 3-line PR must not answer for 30 preexisting issues.
|
|
82
|
+
// No line, or an untouched line, is the file's TREND: reported, never a block.
|
|
83
|
+
const isTouched = (i) => !touchedLines || (i.line != null && touchedLines.get(issueFile(i))?.has(Number(i.line)));
|
|
84
|
+
const own = onStaged.filter(isTouched);
|
|
51
85
|
return {
|
|
52
86
|
available: true,
|
|
53
|
-
blocking:
|
|
54
|
-
advisory:
|
|
87
|
+
blocking: own.filter((i) => BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
|
|
88
|
+
advisory: own.filter((i) => !BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
|
|
89
|
+
preexisting: touchedLines ? onStaged.filter((i) => !isTouched(i)) : [],
|
|
55
90
|
totalProject: res.total ?? (res.issues || []).length,
|
|
56
91
|
};
|
|
57
92
|
} catch (err) {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
const DEFAULT_TEST_PATTERNS = ["/tests/", "/__tests__/", ".test.", ".spec."];
|
|
12
12
|
const DEFAULT_SOURCE_EXTS = [".js", ".jsx", ".ts", ".tsx", ".py", ".go", ".java", ".rb", ".php", ".cs"];
|
|
13
13
|
|
|
14
|
-
export function checkTestsWithCode({ config = {}, stagedFiles = [], env = process.env }) {
|
|
14
|
+
export function checkTestsWithCode({ config = {}, stagedFiles = [], numstat = null, env = process.env }) {
|
|
15
15
|
if (env.KJ_ALLOW_NO_TESTS === "1") {
|
|
16
16
|
return { ok: true, mode: "exempt", reason: "KJ_ALLOW_NO_TESTS=1 (explicit escape hatch)" };
|
|
17
17
|
}
|
|
@@ -24,6 +24,17 @@ export function checkTestsWithCode({ config = {}, stagedFiles = [], env = proces
|
|
|
24
24
|
const hasTests = stagedFiles.some(isTest);
|
|
25
25
|
|
|
26
26
|
if (sources.length === 0 || hasTests) return { ok: true, mode: "pass" };
|
|
27
|
+
// KJC-TSK-0795 AC1 (epic KJC-PCS-0082): deleting code adds no behavior to
|
|
28
|
+
// test. When the caller hands the numbers and EVERY touched source only
|
|
29
|
+
// removed lines, the gate stands down — demanding a test here is the false
|
|
30
|
+
// positive that teaches people to skip the gate (measured in GREBLA's two
|
|
31
|
+
// cleanup PRs). Callers that only know names keep the old behavior.
|
|
32
|
+
if (Array.isArray(numstat)) {
|
|
33
|
+
const bySource = numstat.filter((n) => sources.includes(n.file));
|
|
34
|
+
if (bySource.length === sources.length && bySource.every((n) => (n.added || 0) === 0)) {
|
|
35
|
+
return { ok: true, mode: "delete-only", sources, reason: "every touched source only removes lines — deleting is not new behavior" };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
27
38
|
|
|
28
39
|
const policy = config.method_gates?.tests_with_code || "warn";
|
|
29
40
|
const reason = `source changes without any test change (${sources.slice(0, 5).join(", ")}${sources.length > 5 ? "…" : ""}) — the failing test comes first; add one or KJ_ALLOW_NO_TESTS=1 for a deliberate exception`;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0146 — when a reviewer answers something the parser cannot read.
|
|
3
|
+
*
|
|
4
|
+
* Until now that answer was thrown away: the error said "no parseable verdict"
|
|
5
|
+
* and not one byte of what the reviewer actually replied survived. Eight
|
|
6
|
+
* occurrences produced zero evidence, which is why the bug stayed undiagnosed
|
|
7
|
+
* for two days. A failure nobody can inspect is a failure nobody can fix.
|
|
8
|
+
*
|
|
9
|
+
* So the raw answer is written next to the verdicts and an excerpt travels in
|
|
10
|
+
* the error. Nothing else changes: an unreadable answer is still a refusal,
|
|
11
|
+
* never a pass — it COULD be a rejection in the wrong shape, and switching to
|
|
12
|
+
* another reviewer would turn it into an approval.
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs/promises";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
const DIR = path.join(".karajan", "reviews");
|
|
18
|
+
const EXCERPT = 400;
|
|
19
|
+
|
|
20
|
+
/** One line about what came back, so the shape is visible without opening anything. */
|
|
21
|
+
export function describeOutput(output) {
|
|
22
|
+
if (output === undefined || output === null) return "no output at all";
|
|
23
|
+
if (typeof output !== "string") return `${typeof output}, not a string`;
|
|
24
|
+
if (output.trim() === "") return "empty output";
|
|
25
|
+
return `${output.length} chars, starts with ${JSON.stringify(output.slice(0, 60))}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Writes the raw answer for inspection and returns the error message to raise.
|
|
30
|
+
* A failure to write is never allowed to hide the original problem.
|
|
31
|
+
* @param {{projectDir?: string, reviewer: string, output: unknown, hash: string, writeFile?: Function}} args
|
|
32
|
+
*/
|
|
33
|
+
export async function reportUnparseableVerdict({ projectDir = process.cwd(), reviewer, output, hash, writeFile = fs.writeFile }) {
|
|
34
|
+
const file = path.join(projectDir, DIR, `${hash}.unparseable.txt`);
|
|
35
|
+
const body = typeof output === "string" ? output : JSON.stringify(output ?? null, null, 2);
|
|
36
|
+
let saved = null;
|
|
37
|
+
try {
|
|
38
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
39
|
+
await writeFile(file, `reviewer: ${reviewer}\n\n${body}\n`, "utf8");
|
|
40
|
+
saved = file;
|
|
41
|
+
} catch {
|
|
42
|
+
// Could not save it — the excerpt below still travels, and the message says so.
|
|
43
|
+
}
|
|
44
|
+
const excerpt = typeof output === "string" && output.trim() ? `\n--- what ${reviewer} answered (first ${EXCERPT} chars) ---\n${output.slice(0, EXCERPT)}\n---` : "";
|
|
45
|
+
return [
|
|
46
|
+
`reviewer ${reviewer} returned no parseable verdict (${describeOutput(output)})`,
|
|
47
|
+
saved ? `full answer saved to ${path.relative(projectDir, saved)}` : "the full answer could NOT be saved to disk",
|
|
48
|
+
"an unreadable answer is a refusal, not a pass: it may be a rejection in the wrong shape (KJC-BUG-0146)",
|
|
49
|
+
].join("\n") + excerpt;
|
|
50
|
+
}
|
package/src/roles/audit-role.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
1
3
|
import { AgentRole } from "./agent-role.js";
|
|
4
|
+
import { securityAuditMarkerPath } from "../steward/invariants.js";
|
|
2
5
|
import { buildAuditPrompt, parseAuditOutput, AUDIT_DIMENSIONS } from "../prompts/audit.js";
|
|
3
6
|
import { measureBasalCost, loadPreviousAudit, saveAuditSnapshot, computeGrowthDelta } from "../audit/basal-cost.js";
|
|
4
7
|
import { detectProjectStack } from "../utils/stack-detect.js";
|
|
@@ -123,6 +126,12 @@ export class AuditRole extends AgentRole {
|
|
|
123
126
|
if (!noKnip) {
|
|
124
127
|
try {
|
|
125
128
|
deadExports = await collectDeadExports(projectDir, stack, this.config, this.logger);
|
|
129
|
+
// AC8 (KJC-TSK-0794): the report leads with the derivative — hand the
|
|
130
|
+
// block its previous measurement, if one was ever recorded.
|
|
131
|
+
if (deadExports?.available) {
|
|
132
|
+
const prev = await loadPreviousAudit(projectDir);
|
|
133
|
+
deadExports.previous = prev?.knipDeadExports ? { ...prev.knipDeadExports, timestamp: prev.timestamp || null } : null;
|
|
134
|
+
}
|
|
126
135
|
} catch { /* knip is best-effort */ }
|
|
127
136
|
}
|
|
128
137
|
if (!noInjectionScan) {
|
|
@@ -145,6 +154,12 @@ export class AuditRole extends AgentRole {
|
|
|
145
154
|
aiSlop = await collectAiSlop(projectDir);
|
|
146
155
|
} catch { /* ai-slop scan is best-effort */ }
|
|
147
156
|
}
|
|
157
|
+
// STW-A (KJC-TSK-0789 AC5): record that the security surface was looked
|
|
158
|
+
// at, so the Steward can age it — GREBLA went 79 days with "never".
|
|
159
|
+
try {
|
|
160
|
+
mkdirSync(dirname(securityAuditMarkerPath(projectDir)), { recursive: true });
|
|
161
|
+
writeFileSync(securityAuditMarkerPath(projectDir), JSON.stringify({ at: new Date().toISOString(), mode: securityOnly ? "security" : "full" }));
|
|
162
|
+
} catch { /* recording is best-effort — the audit itself already ran */ }
|
|
148
163
|
return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports, injectionFindings, infraFindings, aiSlop };
|
|
149
164
|
}
|
|
150
165
|
|
|
@@ -183,7 +198,10 @@ export class AuditRole extends AgentRole {
|
|
|
183
198
|
if (!parsed) {
|
|
184
199
|
return { ok: true, result: { raw: result.output, provider }, summary: "Audit complete (unstructured output)", usage };
|
|
185
200
|
}
|
|
186
|
-
if (basalCost) {
|
|
201
|
+
if (basalCost) {
|
|
202
|
+
const knipDeadExports = deadExports?.available ? { exports: (deadExports.exports || []).length, files: (deadExports.files || []).length } : null;
|
|
203
|
+
try { await saveAuditSnapshot(projectDir, { ...basalCost, knipDeadExports }); } catch { /* best-effort */ }
|
|
204
|
+
}
|
|
187
205
|
|
|
188
206
|
return {
|
|
189
207
|
ok: true,
|
package/src/sonar/scanner.js
CHANGED
|
@@ -181,11 +181,27 @@ async function resolveSonarTokenWithFallback(config, apiHost) {
|
|
|
181
181
|
return null;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
|
|
184
|
+
/**
|
|
185
|
+
* KJC-BUG-0156 (issue #1543): the repo's sonar-project.properties is the
|
|
186
|
+
* CANONICAL layout — kj's -D opts override it on the scanner CLI, so when the
|
|
187
|
+
* file exists kj's layout opts must stand down. The ignore rules stay: those
|
|
188
|
+
* are kj's own policy, not project layout.
|
|
189
|
+
*/
|
|
190
|
+
export function respectRepoProperties(scanner = {}, props = {}) {
|
|
191
|
+
if (!props.existed) return scanner;
|
|
192
|
+
const kept = { ...scanner };
|
|
193
|
+
for (const k of ["sources", "exclusions", "test_inclusions", "coverage_exclusions", "javascript_lcov_report_paths"]) delete kept[k];
|
|
194
|
+
return kept;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @returns {Promise<{existed: boolean, declaredKey: string|null}>} */
|
|
198
|
+
export async function ensureSonarProjectProperties(cwd = process.cwd()) {
|
|
185
199
|
const propsPath = path.join(cwd, "sonar-project.properties");
|
|
186
200
|
try {
|
|
187
|
-
await fsPromises.
|
|
188
|
-
|
|
201
|
+
const raw = await fsPromises.readFile(propsPath, "utf8");
|
|
202
|
+
// the repo's declared key is what the server will know — kj must query THAT
|
|
203
|
+
const declaredKey = raw.split("\n").map((l) => /^\s*sonar\.projectKey\s*=\s*(.+)$/.exec(l)).find(Boolean)?.[1]?.trim() ?? null;
|
|
204
|
+
return { existed: true, declaredKey };
|
|
189
205
|
} catch {
|
|
190
206
|
// Auto-generate based on project structure
|
|
191
207
|
let pkg = {};
|
|
@@ -205,6 +221,7 @@ async function ensureSonarProjectProperties(cwd = process.cwd()) {
|
|
|
205
221
|
`sonar.exclusions=**/node_modules/**,**/dist/**,**/build/**,**/coverage/**`,
|
|
206
222
|
].join("\n");
|
|
207
223
|
await fsPromises.writeFile(propsPath, props + "\n", "utf8");
|
|
224
|
+
return { existed: false, declaredKey: null };
|
|
208
225
|
}
|
|
209
226
|
}
|
|
210
227
|
|
|
@@ -241,7 +258,9 @@ export async function runSonarScan(config, projectKey = null) {
|
|
|
241
258
|
exitCode: start.exitCode
|
|
242
259
|
};
|
|
243
260
|
}
|
|
244
|
-
|
|
261
|
+
// KJC-BUG-0156: kj's -Dsonar.projectKey stays (scan and query must use the
|
|
262
|
+
// SAME key), but the repo's properties own the LAYOUT from here on.
|
|
263
|
+
const repoProps = await ensureSonarProjectProperties();
|
|
245
264
|
const token = await resolveSonarTokenWithFallback(config, apiHost);
|
|
246
265
|
if (!token) {
|
|
247
266
|
return {
|
|
@@ -262,10 +281,16 @@ export async function runSonarScan(config, projectKey = null) {
|
|
|
262
281
|
exitCode: coverage.exitCode || 1
|
|
263
282
|
};
|
|
264
283
|
}
|
|
265
|
-
const scannerConfig =
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
284
|
+
const scannerConfig = respectRepoProperties(
|
|
285
|
+
normalizeScannerConfig({
|
|
286
|
+
...sonarConfig.scanner,
|
|
287
|
+
...coverage.scannerPatch
|
|
288
|
+
}),
|
|
289
|
+
repoProps
|
|
290
|
+
);
|
|
291
|
+
const note = repoProps.existed && sonarConfig.scanner?.sources
|
|
292
|
+
? "sonar: the repo's sonar-project.properties wins over sonarqube.scanner.sources — kj passed only its ignore rules"
|
|
293
|
+
: null;
|
|
269
294
|
|
|
270
295
|
const pick = await pickSonarScanner(sonarConfig.scanner);
|
|
271
296
|
const env = {
|
|
@@ -300,6 +325,7 @@ export async function runSonarScan(config, projectKey = null) {
|
|
|
300
325
|
ok: result.exitCode === 0,
|
|
301
326
|
projectKey: effectiveProjectKey,
|
|
302
327
|
scanner: pick.type,
|
|
328
|
+
note,
|
|
303
329
|
stdout: result.stdout,
|
|
304
330
|
stderr: result.stderr,
|
|
305
331
|
exitCode: result.exitCode
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Steward invariants — the verdict kernel (STW-A, KJC-TSK-0789, epic
|
|
3
|
+
* KJC-PCS-0081). Trust expires like permission does: a green that nobody has
|
|
4
|
+
* re-earned is not a green. Every invariant answers ONE of four things:
|
|
5
|
+
*
|
|
6
|
+
* ok — evidence exists, fresh, and holds
|
|
7
|
+
* broken — evidence exists and says it does not hold
|
|
8
|
+
* unknown — the evidence EXPIRED or cannot be read → remedy: refresh
|
|
9
|
+
* not-observable — there was never anywhere to look → remedy: instrument
|
|
10
|
+
*
|
|
11
|
+
* The last two are the point (GREBLA: workflows fire ONLY on pull_request, so
|
|
12
|
+
* "how many days has main been red" had no possible answer — and 21 days of
|
|
13
|
+
* red E2E hid a 17-day production bug). Confusing either with ok is the false
|
|
14
|
+
* green Karajan exists to prevent.
|
|
15
|
+
*/
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { parse as parseYaml } from "yaml";
|
|
19
|
+
export const VERDICTS = { OK: "ok", BROKEN: "broken", UNKNOWN: "unknown", NOT_OBSERVABLE: "not-observable" };
|
|
20
|
+
// Defaults CALIBRATED with GREBLA's measured decay (79 days without a security
|
|
21
|
+
// audit; 21 days of red suite; 17 of them hiding a production bug). A project
|
|
22
|
+
// can declare its own under steward.freshness — and AC8: when the defaults
|
|
23
|
+
// apply, the report says so and says which values they are.
|
|
24
|
+
export const DEFAULT_FRESHNESS = { main_ci_red_days: 3, security_audit_days: 14, critical_vuln_days: 7, high_vuln_days: 30 };
|
|
25
|
+
/** @returns {{values: object, declared: boolean}} */
|
|
26
|
+
export function resolveFreshness(config = {}) {
|
|
27
|
+
const declared = config?.steward?.freshness && typeof config.steward.freshness === "object" ? config.steward.freshness : null;
|
|
28
|
+
return { values: { ...DEFAULT_FRESHNESS, ...(declared || {}) }, declared: Boolean(declared) };
|
|
29
|
+
}
|
|
30
|
+
/** Does any workflow run ON PUSH to the base branch? Reading the repo decides
|
|
31
|
+
* observability — a remote API cannot tell "green" from "nobody looked". */
|
|
32
|
+
function pushWorkflows(projectDir, baseBranch) {
|
|
33
|
+
const dir = path.join(projectDir, ".github", "workflows");
|
|
34
|
+
let names;
|
|
35
|
+
try { names = fs.readdirSync(dir).filter((f) => /\.ya?ml$/.test(f)); } catch { return []; }
|
|
36
|
+
const hits = [];
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
let wf;
|
|
39
|
+
try { wf = parseYaml(fs.readFileSync(path.join(dir, name), "utf8")); } catch { continue; }
|
|
40
|
+
// YAML 1.1 quirk: `on:` may parse as boolean true key.
|
|
41
|
+
const on = wf?.on ?? wf?.[true];
|
|
42
|
+
const push = Array.isArray(on) ? (on.includes("push") ? {} : null) : typeof on === "string" ? (on === "push" ? {} : null) : (on?.push ?? null);
|
|
43
|
+
if (push === null || push === undefined) continue;
|
|
44
|
+
const branches = push?.branches;
|
|
45
|
+
if (!branches || (Array.isArray(branches) && branches.includes(baseBranch))) hits.push(wf?.name || name);
|
|
46
|
+
}
|
|
47
|
+
return hits;
|
|
48
|
+
}
|
|
49
|
+
const days = (ms) => Math.floor(ms / 86_400_000);
|
|
50
|
+
const plural = (n) => `${n} day${n === 1 ? "" : "s"}`;
|
|
51
|
+
/**
|
|
52
|
+
* Invariant #1 — the base branch has CI of its own and it is green.
|
|
53
|
+
* `runsFn(workflows)` is injected (the sweep wires `gh run list`); it returns
|
|
54
|
+
* [{workflow, conclusion, createdAt}] newest-first for push runs on the base.
|
|
55
|
+
*/
|
|
56
|
+
export function evaluateMainCi({ projectDir, baseBranch = "main", freshness = DEFAULT_FRESHNESS, runsFn = null, nowMs = Date.now() }) {
|
|
57
|
+
const instrumented = pushWorkflows(projectDir, baseBranch);
|
|
58
|
+
if (instrumented.length === 0) {
|
|
59
|
+
return { verdict: VERDICTS.NOT_OBSERVABLE, evidence: `no workflow runs on push to ${baseBranch} — "how long has it been red" has no possible answer`, remedy: `instrument: add a push trigger for ${baseBranch} to at least one workflow` };
|
|
60
|
+
}
|
|
61
|
+
let runs;
|
|
62
|
+
try { runs = runsFn ? runsFn(instrumented) : null; } catch { runs = null; }
|
|
63
|
+
if (!Array.isArray(runs) || runs.length === 0) {
|
|
64
|
+
return { verdict: VERDICTS.UNKNOWN, evidence: `instrumented (${instrumented.join(", ")}) but the runs could not be read`, remedy: "refresh: run the sweep where gh can list the runs" };
|
|
65
|
+
}
|
|
66
|
+
const sorted = [...runs].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
|
67
|
+
const latest = sorted[0];
|
|
68
|
+
if (latest.conclusion === "success") {
|
|
69
|
+
return { verdict: VERDICTS.OK, evidence: `green on ${baseBranch} since ${latest.createdAt}`, remedy: null };
|
|
70
|
+
}
|
|
71
|
+
// The streak starts at the FIRST red after the last green — not at the green.
|
|
72
|
+
const greenIdx = sorted.findIndex((r) => r.conclusion === "success");
|
|
73
|
+
const firstRed = greenIdx === -1 ? sorted.at(-1) : sorted[greenIdx - 1];
|
|
74
|
+
const redDays = days(nowMs - Date.parse(firstRed.createdAt));
|
|
75
|
+
if (redDays > freshness.main_ci_red_days) {
|
|
76
|
+
return { verdict: VERDICTS.BROKEN, evidence: `${baseBranch} red for ${plural(redDays)} (tolerance ${freshness.main_ci_red_days}) — a suite this red stops meaning anything`, remedy: "fix or revert to green; a red suite is the project's #1 invariant" };
|
|
77
|
+
}
|
|
78
|
+
return { verdict: VERDICTS.OK, evidence: `red for ${plural(redDays)}, inside the ${freshness.main_ci_red_days}-day tolerance — a fresh failure is work, not decay`, remedy: null };
|
|
79
|
+
}
|
|
80
|
+
/** Where `kj audit` records its last run for the Steward to age. */
|
|
81
|
+
export const securityAuditMarkerPath = (projectDir) => path.join(projectDir, ".karajan", "steward", "security-audit.json");
|
|
82
|
+
/**
|
|
83
|
+
* AC5 — how long since `kj audit --security` last ran. "Never" is GREBLA's
|
|
84
|
+
* real case: 79 days with an open redirect and untouched dependencies.
|
|
85
|
+
* No record is BROKEN, not unknown: running the audit fixes both "never"
|
|
86
|
+
* and "before recording existed", so the remedy is the same either way.
|
|
87
|
+
*/
|
|
88
|
+
export function evaluateSecurityAudit({ projectDir, freshness = DEFAULT_FRESHNESS, nowMs = Date.now() }) {
|
|
89
|
+
let marker;
|
|
90
|
+
try { marker = JSON.parse(fs.readFileSync(securityAuditMarkerPath(projectDir), "utf8")); } catch { marker = null; }
|
|
91
|
+
if (!marker?.at) {
|
|
92
|
+
return { verdict: VERDICTS.BROKEN, evidence: "no security audit on record — never run, or run before recording existed", remedy: "run kj audit --security (zero tokens) and remediate what it finds" };
|
|
93
|
+
}
|
|
94
|
+
const age = days(nowMs - Date.parse(marker.at));
|
|
95
|
+
if (age > freshness.security_audit_days) {
|
|
96
|
+
return { verdict: VERDICTS.BROKEN, evidence: `last security audit ${plural(age)} ago (freshness ${freshness.security_audit_days})`, remedy: "run kj audit --security" };
|
|
97
|
+
}
|
|
98
|
+
return { verdict: VERDICTS.OK, evidence: `security audit ${plural(age)} ago (${marker.at})`, remedy: null };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* AC6 — vulnerable dependencies age by the ADVISORY's published date, never
|
|
102
|
+
* the discovery's: the clock started when the world knew. A critical with no
|
|
103
|
+
* date counts as overdue — unknown age is not youth.
|
|
104
|
+
*/
|
|
105
|
+
export function evaluateVulnAging({ vulns, freshness = DEFAULT_FRESHNESS, nowMs = Date.now() }) {
|
|
106
|
+
if (!Array.isArray(vulns)) {
|
|
107
|
+
return { verdict: VERDICTS.UNKNOWN, evidence: "no vulnerability scan handed to the invariant", remedy: "refresh: run kj audit so osv-scanner reports the dependencies" };
|
|
108
|
+
}
|
|
109
|
+
const windowFor = (sev) => (sev === "CRITICAL" ? freshness.critical_vuln_days : sev === "HIGH" ? freshness.high_vuln_days : null);
|
|
110
|
+
const overdue = vulns.filter((v) => {
|
|
111
|
+
const win = windowFor(String(v.severity || "").toUpperCase());
|
|
112
|
+
if (win === null) return false;
|
|
113
|
+
if (!v.publishedAt) return true;
|
|
114
|
+
return days(nowMs - Date.parse(v.publishedAt)) > win;
|
|
115
|
+
});
|
|
116
|
+
if (overdue.length > 0) {
|
|
117
|
+
const items = overdue.map((v) => `${v.id} (${v.severity}, advisory ${v.publishedAt || "date unknown — unknown age is not youth"})`).join("; ");
|
|
118
|
+
return { verdict: VERDICTS.BROKEN, evidence: `${overdue.length} vulnerable dependencies past their advisory-age window: ${items}`, remedy: "update or replace the affected packages" };
|
|
119
|
+
}
|
|
120
|
+
return { verdict: VERDICTS.OK, evidence: `${vulns.length} known vulnerabilities, all inside their advisory-age windows`, remedy: null };
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* AC4 — dead code informs by DERIVATIVE and carries NO security weight: in
|
|
124
|
+
* GREBLA the sensitive functions stayed in the bundle, but the backend rules
|
|
125
|
+
* kept protecting them — removing UI neither widened nor closed any surface.
|
|
126
|
+
* Growth is the decay signal; the absolute is somebody else's report.
|
|
127
|
+
*/
|
|
128
|
+
export function evaluateDeadCodeTrend({ current, previous }) {
|
|
129
|
+
if (!current || typeof current.deadExports !== "number") {
|
|
130
|
+
return { verdict: VERDICTS.UNKNOWN, evidence: "no dead-code measurement on record", remedy: "refresh: run kj audit so the inventory is measured" };
|
|
131
|
+
}
|
|
132
|
+
if (!previous || typeof previous.deadExports !== "number") {
|
|
133
|
+
return { verdict: VERDICTS.OK, evidence: `${current.deadExports} dead exports — first measurement, the trend starts here`, remedy: null };
|
|
134
|
+
}
|
|
135
|
+
const delta = current.deadExports - previous.deadExports;
|
|
136
|
+
if (delta > 0) {
|
|
137
|
+
return { verdict: VERDICTS.BROKEN, evidence: `dead code grew +${delta} since ${previous.timestamp || "the last audit"} (now ${current.deadExports}; no security weight — dead code is debt, not attack surface)`, remedy: "delete what the inventory names, or declare the false positives" };
|
|
138
|
+
}
|
|
139
|
+
return { verdict: VERDICTS.OK, evidence: `dead code ${delta === 0 ? "flat" : delta} since the last audit (now ${current.deadExports})`, remedy: null };
|
|
140
|
+
}
|
|
141
|
+
const COVERAGE_CONFIGS = ["vitest.config.js", "vitest.config.ts", "vitest.config.mjs", "jest.config.js", "jest.config.ts", "jest.config.json", "package.json"];
|
|
142
|
+
/**
|
|
143
|
+
* AC7 — coverage is an invariant of CONFIGURATION, not of value: no 80% is
|
|
144
|
+
* demanded here. Either something measures the level (CI's job), or nothing
|
|
145
|
+
* does — and "nothing measures it" is the definition of not observable.
|
|
146
|
+
*/
|
|
147
|
+
export function evaluateCoverageConfig({ projectDir }) {
|
|
148
|
+
for (const name of COVERAGE_CONFIGS) {
|
|
149
|
+
let text;
|
|
150
|
+
try { text = fs.readFileSync(path.join(projectDir, name), "utf8"); } catch { continue; }
|
|
151
|
+
if (/coverage[\s\S]{0,400}?(thresholds?|lines|branches|functions|statements)\s*[:=]/.test(text)) {
|
|
152
|
+
return { verdict: VERDICTS.OK, evidence: `coverage thresholds configured in ${name} — the level itself is CI's job`, remedy: null };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { verdict: VERDICTS.NOT_OBSERVABLE, evidence: "no coverage threshold configured anywhere — nothing measures the level", remedy: "instrument: configure a coverage threshold (any value the team stands behind — no 80% is demanded)" };
|
|
156
|
+
}
|
|
157
|
+
/** AC3 — phantom coverage: the two detectors exist (KJC-TSK-0800) but the
|
|
158
|
+
* sweep does not yet discover test↔source pairs, so their output is INJECTED.
|
|
159
|
+
* No output → not observable: never ok by absence of the detector's run. */
|
|
160
|
+
export function evaluatePhantomCoverage({ phantoms } = {}) {
|
|
161
|
+
if (!Array.isArray(phantoms)) {
|
|
162
|
+
return { verdict: VERDICTS.NOT_OBSERVABLE, evidence: "the phantom detectors were not run", remedy: "instrument: run the detectors (steward/phantom-coverage) over the suite's test↔source pairs and feed their output" };
|
|
163
|
+
}
|
|
164
|
+
if (phantoms.length > 0) {
|
|
165
|
+
return { verdict: VERDICTS.BROKEN, evidence: `${phantoms.length} phantom test(s): ${phantoms.map((p) => p.literal || p.member).join(", ")} — they add to the count and cover nothing`, remedy: "rewrite each test against the live UI, or delete it with the dead code it exercised" };
|
|
166
|
+
}
|
|
167
|
+
return { verdict: VERDICTS.OK, evidence: "no phantom tests in the detectors' output", remedy: null };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Run a list of invariants. A child whose `dependsOn` parent came out
|
|
171
|
+
* not-observable INHERITS it — an invariant built on an unobserved one must
|
|
172
|
+
* never report ok. A probe that throws is unknown: never a green light.
|
|
173
|
+
*/
|
|
174
|
+
export function runInvariants(invariants, ctx = {}) {
|
|
175
|
+
const results = [];
|
|
176
|
+
const byId = new Map();
|
|
177
|
+
for (const inv of invariants) {
|
|
178
|
+
const parent = inv.dependsOn ? byId.get(inv.dependsOn) : null;
|
|
179
|
+
let res;
|
|
180
|
+
if (parent && parent.verdict === VERDICTS.NOT_OBSERVABLE) {
|
|
181
|
+
res = { verdict: VERDICTS.NOT_OBSERVABLE, evidence: `depends on ${inv.dependsOn}, which is not observable`, remedy: `instrument ${inv.dependsOn} first (${parent.remedy || "no remedy stated"})` };
|
|
182
|
+
} else {
|
|
183
|
+
try { res = inv.evaluate(ctx); } catch (err) { res = { verdict: VERDICTS.UNKNOWN, evidence: `the probe itself failed (${err.message})`, remedy: "fix the probe — a broken probe is never a green light" }; }
|
|
184
|
+
}
|
|
185
|
+
const row = { id: inv.id, ...res };
|
|
186
|
+
byId.set(inv.id, row);
|
|
187
|
+
results.push(row);
|
|
188
|
+
}
|
|
189
|
+
return results;
|
|
190
|
+
}
|