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.
Files changed (54) hide show
  1. package/README.md +6 -3
  2. package/package.json +5 -2
  3. package/packages/hu-board/public/app.js +1 -0
  4. package/packages/hu-board/public/index.html +2 -0
  5. package/packages/hu-board/public/styles.css +22 -0
  6. package/packages/hu-board/public/utils/governance-view.js +149 -0
  7. package/packages/hu-board/src/auth.js +29 -1
  8. package/packages/hu-board/src/routes/governance.js +140 -0
  9. package/packages/hu-board/src/server.js +2 -0
  10. package/scripts/install.js +4 -3
  11. package/scripts/postinstall.js +4 -3
  12. package/scripts/toml-value.js +18 -0
  13. package/src/audit/basal-cost.js +4 -0
  14. package/src/audit/dead-exports.js +52 -1
  15. package/src/audit/deterministic-summary.js +18 -3
  16. package/src/audit/member-reachability.js +158 -0
  17. package/src/audit/osv-findings.js +1 -0
  18. package/src/checks/ai-trash.js +1 -1
  19. package/src/checks/mcp-health.js +1 -1
  20. package/src/checks/native-build.js +2 -2
  21. package/src/checks/release-check.js +62 -2
  22. package/src/claims/cross-check.js +86 -0
  23. package/src/claims/extract.js +56 -0
  24. package/src/claims/turn.js +54 -0
  25. package/src/cli/advanced-commands.js +1 -1
  26. package/src/cli/register-meta.js +66 -5
  27. package/src/commands/claims.js +73 -0
  28. package/src/commands/hu.js +3 -1
  29. package/src/commands/init.js +1 -1
  30. package/src/commands/policy.js +84 -3
  31. package/src/commands/privacy.js +6 -2
  32. package/src/commands/resume.js +4 -0
  33. package/src/commands/review-gate.js +34 -9
  34. package/src/commands/steward.js +146 -0
  35. package/src/config/defaults.js +6 -1
  36. package/src/environment/playbook.js +2 -1
  37. package/src/guards/duplicate-members.js +86 -0
  38. package/src/harden/sentinel-hooks.js +188 -22
  39. package/src/harden/workflow-engine.js +8 -2
  40. package/src/harden/workflow-templates.js +38 -1
  41. package/src/policy/exceptions.js +14 -4
  42. package/src/policy/report.js +99 -0
  43. package/src/privacy/scan.js +23 -1
  44. package/src/review/card-first.js +4 -1
  45. package/src/review/one-shot-review.js +10 -4
  46. package/src/review/parser.js +9 -0
  47. package/src/review/sonar-pregate.js +38 -3
  48. package/src/review/tests-with-code.js +12 -1
  49. package/src/review/unparseable-verdict.js +50 -0
  50. package/src/roles/audit-role.js +19 -1
  51. package/src/sonar/scanner.js +34 -8
  52. package/src/steward/invariants.js +190 -0
  53. package/src/steward/phantom-coverage.js +137 -0
  54. package/src/steward/proposed-work.js +68 -0
@@ -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
- findings.push(...scanText(content.toString("utf8"), { list, source: p }));
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
  }
@@ -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
- export const CARD_REF_RE = /\b[a-z][a-z0-9]{1,9}(?:-[a-z][a-z0-9]{1,9})*-\d{1,6}\b/i;
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
- const parsed = parseMaybeJsonString(result.output);
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
- throw new Error(`reviewer ${activeReviewer} returned no parseable verdict`);
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, {
@@ -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: onStaged.filter((i) => BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
54
- advisory: onStaged.filter((i) => !BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
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
+ }
@@ -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) { try { await saveAuditSnapshot(projectDir, basalCost); } catch { /* best-effort */ } }
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,
@@ -181,11 +181,27 @@ async function resolveSonarTokenWithFallback(config, apiHost) {
181
181
  return null;
182
182
  }
183
183
 
184
- async function ensureSonarProjectProperties(cwd = process.cwd()) {
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.access(propsPath);
188
- return; // already exists
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
- await ensureSonarProjectProperties();
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 = normalizeScannerConfig({
266
- ...sonarConfig.scanner,
267
- ...coverage.scannerPatch
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
+ }