karajan-code 4.22.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.
@@ -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`;
@@ -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
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Phantom coverage (KJC-TSK-0800, epic KJC-PCS-0081) — a phantom test is
3
+ * worse than no test: it adds to the count, feels like a net, and covers
4
+ * nothing. TWO detectors, because one is not enough: the call graph catches
5
+ * a unit test that only exercises unreachable members, and LITERAL crossing
6
+ * catches the case the graph cannot see — GREBLA's hierarchy.spec called no
7
+ * dead method, it looked for an aria-label that only existed inside one.
8
+ * Both inherit the reachability perimeter: a NOT OBSERVABLE analysis makes
9
+ * the detector not observable — no test is accused on an unreliable basis.
10
+ */
11
+ import { parse } from "@babel/parser";
12
+
13
+ const parseLoose = (source, file) =>
14
+ parse(source, {
15
+ sourceType: "unambiguous", allowReturnOutsideFunction: true,
16
+ plugins: [...(/\.(ts|tsx|mts|cts)$/.test(file) ? ["typescript"] : []), ...(/\.(tsx|jsx)$/.test(file) || !/\.(ts|mts|cts)$/.test(file) ? ["jsx"] : []), "decorators"],
17
+ });
18
+
19
+ function walk(node, visit) {
20
+ if (!node || typeof node.type !== "string") return;
21
+ visit(node);
22
+ for (const key of Object.keys(node)) {
23
+ const child = node[key];
24
+ if (Array.isArray(child)) child.forEach((c) => walk(c, visit));
25
+ else if (child && typeof child.type === "string") walk(child, visit);
26
+ }
27
+ }
28
+
29
+ const notObservable = (sourceAnalysis) => {
30
+ if (!sourceAnalysis?.observable) return sourceAnalysis?.reason || "the reachability analysis is not observable";
31
+ const classes = (sourceAnalysis.classes || []).filter((c) => c.observable);
32
+ if (classes.length === 0) return "no class in the source is observable — the analysis cannot vouch for anything";
33
+ return null;
34
+ };
35
+ const observableClasses = (a) => (a.classes || []).filter((c) => c.observable);
36
+ const bareName = (slot) => slot.replace(/^static /, "");
37
+
38
+ /**
39
+ * Unit detector: of the test's member CALLS that belong to an analyzed class,
40
+ * do all of them land on unreachable members? One live call absolves.
41
+ * @returns {{observable: boolean, reason?: string, phantoms: Array<{member: string, className: string}>}}
42
+ */
43
+ export function detectPhantomUnit({ sourceAnalysis, testSource, file = "<test>" }) {
44
+ const why = notObservable(sourceAnalysis);
45
+ if (why) return { observable: false, reason: `not observable: ${why}`, phantoms: [] };
46
+ let tree;
47
+ try { tree = parseLoose(testSource, file); } catch (err) {
48
+ return { observable: false, reason: `the test could not be parsed (${err.message})`, phantoms: [] };
49
+ }
50
+ // Calls grouped by RECEIVER (weak binding, reviewer's catch): a receiver is
51
+ // tied to a class only when EVERY member it calls belongs to that class —
52
+ // mixing two objects under one name must not get a test accused.
53
+ const byReceiver = new Map();
54
+ walk(tree, (n) => {
55
+ if (n.type !== "CallExpression" || n.callee?.type !== "MemberExpression" || n.callee.computed || n.callee.property?.type !== "Identifier") return;
56
+ const recv = n.callee.object?.type === "Identifier" ? n.callee.object.name : n.callee.object?.type === "ThisExpression" ? "this" : "(expr)";
57
+ (byReceiver.get(recv) ?? byReceiver.set(recv, new Set()).get(recv)).add(n.callee.property.name);
58
+ });
59
+ const phantoms = [];
60
+ for (const c of observableClasses(sourceAnalysis)) {
61
+ const members = new Set(c.memberNames.map(bareName));
62
+ const dead = new Set(c.unreachable.map((u) => bareName(u.name)));
63
+ for (const [, names] of byReceiver) {
64
+ const touched = [...names].filter((n) => members.has(n));
65
+ // bound to this class, and everything it touches is dead → phantom;
66
+ // one live call — or a call outside the class — absolves the receiver.
67
+ if (touched.length > 0 && touched.length === names.size && touched.every((n) => dead.has(n))) {
68
+ for (const n of touched) phantoms.push({ member: n, className: c.name, file });
69
+ }
70
+ }
71
+ }
72
+ return { observable: true, phantoms };
73
+ }
74
+
75
+ const literalsOf = (tree, minLength) => {
76
+ const out = [];
77
+ walk(tree, (n) => {
78
+ if (n.type === "StringLiteral" && n.value.trim().length >= minLength) out.push({ value: n.value, line: n.loc.start.line });
79
+ if (n.type === "TemplateElement" && n.value.cooked && n.value.cooked.trim().length >= minLength) out.push({ value: n.value.cooked, line: n.loc.start.line });
80
+ });
81
+ return out;
82
+ };
83
+
84
+ /**
85
+ * E2E detector: literals the test looks for that ONLY exist inside
86
+ * unreachable spans of the source. A literal also present in reachable code
87
+ * is never reported — that test does cover something.
88
+ * @returns {{observable: boolean, reason?: string, phantoms: Array<{literal: string, line: number}>}}
89
+ */
90
+ export function detectPhantomE2E({ sourceText, sourceAnalysis, testSource, file = "<spec>", minLength = 4 }) {
91
+ const why = notObservable(sourceAnalysis);
92
+ if (why) return { observable: false, reason: `not observable: ${why}`, phantoms: [] };
93
+ let sourceTree, testTree;
94
+ try {
95
+ sourceTree = parseLoose(sourceText, sourceAnalysis.file || "<source>");
96
+ testTree = parseLoose(testSource, file);
97
+ } catch (err) {
98
+ return { observable: false, reason: `could not be parsed (${err.message})`, phantoms: [] };
99
+ }
100
+ const deadSpans = observableClasses(sourceAnalysis).flatMap((c) => c.unreachable.map((u) => [u.line, u.endLine]));
101
+ const inDead = (line) => deadSpans.some(([a, b]) => line >= a && line <= b);
102
+ const onlyInDead = new Map(); // literal → line where it lives
103
+ for (const lit of literalsOf(sourceTree, minLength)) {
104
+ if (inDead(lit.line)) { if (!onlyInDead.has(lit.value)) onlyInDead.set(lit.value, lit.line); }
105
+ else onlyInDead.set(lit.value, -1); // seen reachable — poisoned, never reportable
106
+ }
107
+ const wanted = new Set(literalsOf(testTree, minLength).map((l) => l.value));
108
+ const phantoms = [...wanted]
109
+ .filter((v) => onlyInDead.get(v) !== undefined && onlyInDead.get(v) !== -1)
110
+ .map((v) => ({ literal: v, line: onlyInDead.get(v), file }));
111
+ return { observable: true, phantoms };
112
+ }
113
+
114
+ /**
115
+ * The temporal signature — zero static analysis, and it would have been
116
+ * enough for GREBLA: a bug gets fixed or reverted; a phantom fails the SAME
117
+ * way indefinitely because it proves something that no longer exists.
118
+ * kj holds no per-test history, so the source is INJECTED and said:
119
+ * failures = [{test, reason, at}] from whoever owns the CI history.
120
+ */
121
+ export function assessTemporalSignature({ failures, thresholdDays = 7 } = {}) {
122
+ if (!Array.isArray(failures)) {
123
+ return { observable: false, reason: "no per-test failure history handed in — kj holds none; inject it from the CI that owns it", suspects: [] };
124
+ }
125
+ const byTest = new Map();
126
+ for (const f of failures) (byTest.get(f.test) ?? byTest.set(f.test, []).get(f.test)).push(f);
127
+ const suspects = [];
128
+ for (const [test, rows] of byTest) {
129
+ const reasons = new Set(rows.map((r) => String(r.reason || "").trim().toLowerCase()));
130
+ if (reasons.size !== 1) continue; // changing reasons look like a bug being worked
131
+ const times = rows.map((r) => Date.parse(r.at)).filter(Number.isFinite);
132
+ if (times.length < 2) continue;
133
+ const days = Math.floor((Math.max(...times) - Math.min(...times)) / 86_400_000);
134
+ if (days >= thresholdDays) suspects.push({ test, reason: rows[0].reason, days, firstAt: new Date(Math.min(...times)).toISOString(), lastAt: new Date(Math.max(...times)).toISOString() });
135
+ }
136
+ return { observable: true, suspects };
137
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Proposed work (STW-D, KJC-TSK-0792, epic KJC-PCS-0081) — every broken
3
+ * invariant becomes a CARD in the channel the Brain already consumes, as a
4
+ * PROPOSAL: evidence, since-when, and a remedy plan that nothing executes
5
+ * without review (the user's decision, 22-aug: an unreviewed generated plan
6
+ * can do more harm than the decay itself). The same break UPDATES its card —
7
+ * a daily sweep must never flood the board with twins — and a green
8
+ * invariant resolves it with the green evidence.
9
+ *
10
+ * kj only writes the board it owns (hu-board). planning-game and external
11
+ * boards are never mirrored (KJC-TSK-0684: a half-empty parallel board is
12
+ * worse than none): there the sweep reports synced:false LOUDLY, and the
13
+ * Sentinel's inevitable notice (STW-C) pushes the host agent — who does
14
+ * have the board's MCP — to card them.
15
+ */
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { addHu, updateHu, updateHuStatus } from "../plan/plan-hu-ops.js";
19
+ import { savePlan } from "../plan/plan-store.js";
20
+ import { backlogPlan } from "../commands/hu.js";
21
+
22
+ const mapPath = (projectDir) => path.join(projectDir, ".karajan", "steward", "cards.json");
23
+ const readMap = (projectDir) => { try { return JSON.parse(fs.readFileSync(mapPath(projectDir), "utf8")); } catch { return {}; } };
24
+
25
+ const cardText = (r, brokenSince) => [
26
+ `Steward invariant BROKEN since ${brokenSince}.`,
27
+ `Evidence: ${r.evidence || "(none recorded)"}`,
28
+ `Proposed remedy plan: ${r.remedy || r.renew || "kj steward sweep"}.`,
29
+ "This plan is a PROPOSAL — review before executing: nothing in it runs unreviewed.",
30
+ ].join("\n");
31
+
32
+ /**
33
+ * @returns {Promise<{synced: boolean, reason?: string, created?: number, updated?: number, resolved?: number}>}
34
+ */
35
+ export async function syncProposedWork({ projectDir, config = {}, results = [], sweptAt }) {
36
+ const backend = config.state_backend || "hu-board";
37
+ if (backend !== "hu-board") {
38
+ return { synced: false, reason: `the board lives in ${backend} — kj never mirrors a board it does not own; card the broken invariants through your agent's board tools` };
39
+ }
40
+ const map = readMap(projectDir);
41
+ const broken = results.filter((r) => r.verdict === "broken");
42
+ const okAgain = results.filter((r) => r.verdict === "ok" && map[r.id]);
43
+ if (broken.length === 0 && okAgain.length === 0) return { synced: true, created: 0, updated: 0, resolved: 0 };
44
+
45
+ const plan = await backlogPlan(projectDir);
46
+ let created = 0, updated = 0, resolved = 0;
47
+ for (const r of broken) {
48
+ const known = map[r.id];
49
+ if (known) {
50
+ updateHu(plan, known.huId, { scope: cardText(r, known.brokenSince) });
51
+ updated += 1;
52
+ } else {
53
+ const hu = addHu(plan, { title: `steward: ${r.id} broken — proposed remedy (review before executing)`, scope: cardText(r, sweptAt), created_by: "steward" });
54
+ map[r.id] = { huId: hu.id, brokenSince: sweptAt };
55
+ created += 1;
56
+ }
57
+ }
58
+ for (const r of okAgain) {
59
+ updateHu(plan, map[r.id].huId, { scope: `Resolved: the invariant is green again (${r.evidence || "no evidence text"}) — validation stays with the user.` });
60
+ updateHuStatus(plan, map[r.id].huId, "done");
61
+ delete map[r.id];
62
+ resolved += 1;
63
+ }
64
+ await savePlan(projectDir, plan);
65
+ fs.mkdirSync(path.dirname(mapPath(projectDir)), { recursive: true });
66
+ fs.writeFileSync(mapPath(projectDir), `${JSON.stringify(map, null, 2)}\n`);
67
+ return { synced: true, created, updated, resolved };
68
+ }