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.
- package/README.md +1 -0
- package/package.json +3 -2
- package/packages/hu-board/src/auth.js +29 -1
- 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/claims/cross-check.js +10 -5
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +24 -3
- package/src/commands/claims.js +33 -1
- package/src/commands/hu.js +3 -1
- package/src/commands/privacy.js +6 -2
- package/src/commands/resume.js +4 -0
- package/src/commands/review-gate.js +30 -7
- package/src/commands/steward.js +146 -0
- package/src/config/defaults.js +6 -1
- package/src/harden/sentinel-hooks.js +98 -6
- package/src/harden/workflow-engine.js +8 -2
- package/src/harden/workflow-templates.js +38 -1
- package/src/privacy/scan.js +23 -1
- package/src/review/sonar-pregate.js +38 -3
- package/src/review/tests-with-code.js +12 -1
- 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
|
@@ -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`;
|
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
|
+
}
|
|
@@ -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
|
+
}
|