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
package/README.md
CHANGED
|
@@ -34,6 +34,7 @@ Your AI agent (Claude Code, Codex, Copilot, Antigravity, Cursor…) writes the c
|
|
|
34
34
|
- **Nothing personal ships** — every outbound boundary audits before it leaves the machine: the pre-commit rejects a staged diff carrying your denylisted personal data, hardcoded platform tokens (`ghp_`, `sk-`, `AKIA`…) block outright, `verify-pack`-style tarball scans guard the publish, and `kj privacy scan <dir>` audits any build output. Your denylist lives in `~/.karajan/privacy.yml` — the install asks and writes it for you.
|
|
35
35
|
- **Installing IS activating** — `kj env install` performs the enforcement itself (git hooks, verdict gate, tool gate) instead of trusting the agent to run setup steps, and ends by printing the method into the very conversation that installed it. A commit outside the method is rejected, not narrated.
|
|
36
36
|
- **The turn cannot end red — the Sentinel** — a deterministic supervisor (zero LLM) wired into the harness's synchronous hooks records the method state of the session as tools run, and a Stop hook blocks the agent from ending its turn while method violations are open. The program rules, the agent thinks. See [guarantee levels](#guarantee-levels-governed-vs-supervised).
|
|
37
|
+
- **Trust expires — the Steward** — `kj steward sweep` checks the project's declared guarantees against their freshness and answers one of FOUR verdicts per invariant: ok, broken, *unknown* (evidence expired → refresh it) or *not observable* (nowhere to look → instrument it) — because confusing the last two with green is how a project decays for weeks behind a passing facade. The verdict is versioned in the repo, sealed in the decision chain, and every break lands on the board as PROPOSED work that nothing executes unreviewed. Runs on demand, on `kj resume` when stale, or as an opt-in scheduled Action.
|
|
37
38
|
- **The review panel never runs dry** — nine built-in agents (Claude Code, Codex, GitHub Copilot, Antigravity `agy` — the gemini successor —, Kimi Code, Qwen, OpenCode, Aider, Gemini legacy), and when the configured reviewer exhausts its quota, `kj review` switches to an authenticated candidate with a LOUD notice — or hands you the menu of candidates with their tier (free / subscription / local) and the exact login command. Never a silent failure, never the brain reviewing itself.
|
|
38
39
|
|
|
39
40
|
This repo runs under its own environment: every commit to karajan-code carries a cross-AI verdict.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "karajan-code",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.23.0",
|
|
4
4
|
"description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0",
|
|
@@ -123,17 +123,18 @@
|
|
|
123
123
|
},
|
|
124
124
|
"devDependencies": {
|
|
125
125
|
"@eslint/js": "^10.0.1",
|
|
126
|
-
"smol-toml": "^1.7.0",
|
|
127
126
|
"@vitest/coverage-v8": "^4.1.8",
|
|
128
127
|
"esbuild": "^0.28.0",
|
|
129
128
|
"eslint": "^10.4.1",
|
|
130
129
|
"eslint-plugin-import-x": "^4.16.2",
|
|
130
|
+
"eslint-plugin-node-security": "5.2.2",
|
|
131
131
|
"eslint-plugin-security": "^4.0.0",
|
|
132
132
|
"globals": "^17.6.0",
|
|
133
133
|
"lint-staged": "^17.0.7",
|
|
134
134
|
"postject": "^1.0.0-alpha.6",
|
|
135
135
|
"prettier": "^3.4.2",
|
|
136
136
|
"simple-git-hooks": "^2.13.1",
|
|
137
|
+
"smol-toml": "^1.7.0",
|
|
137
138
|
"vitest": "^4.1.8"
|
|
138
139
|
}
|
|
139
140
|
}
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
* legacy callers keep working.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { timingSafeEqual } from "node:crypto";
|
|
24
|
+
|
|
23
25
|
/** Loopback addresses we trust without auth, regardless of token state. */
|
|
24
26
|
const LOOPBACK_ADDRESSES = new Set([
|
|
25
27
|
"127.0.0.1",
|
|
@@ -41,7 +43,7 @@ export function authMiddleware() {
|
|
|
41
43
|
if (isLoopback(req)) return next();
|
|
42
44
|
|
|
43
45
|
const token = extractToken(req);
|
|
44
|
-
if (token
|
|
46
|
+
if (tokensMatch(token, expected)) return next();
|
|
45
47
|
|
|
46
48
|
return res.status(401).json({
|
|
47
49
|
error: "Unauthorized",
|
|
@@ -53,6 +55,32 @@ export function authMiddleware() {
|
|
|
53
55
|
};
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Constant-time comparison of a presented token against the expected one.
|
|
60
|
+
*
|
|
61
|
+
* `===` on strings stops at the first differing byte, so the time it takes to
|
|
62
|
+
* reject a token leaks how much of a correct prefix was supplied. Over the
|
|
63
|
+
* loopback interface that is noise — but this middleware exists precisely for
|
|
64
|
+
* the case the header above describes, where the board is bound beyond
|
|
65
|
+
* loopback and the peer is on the LAN. There the difference is measurable, and
|
|
66
|
+
* the token can be recovered a byte at a time.
|
|
67
|
+
*
|
|
68
|
+
* `timingSafeEqual` throws when the buffers differ in length, so the length is
|
|
69
|
+
* checked first. That is not a leak worth closing: the length of the expected
|
|
70
|
+
* token is fixed by whatever wrote `~/.karajan/hu-board/token`, and a token of
|
|
71
|
+
* the wrong length is wrong regardless of its contents.
|
|
72
|
+
*
|
|
73
|
+
* @param {string|undefined} presented
|
|
74
|
+
* @param {string} expected
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
function tokensMatch(presented, expected) {
|
|
78
|
+
if (typeof presented !== "string") return false;
|
|
79
|
+
const left = Buffer.from(presented, "utf8");
|
|
80
|
+
const right = Buffer.from(expected, "utf8");
|
|
81
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
/**
|
|
57
85
|
* @param {import('express').Request} req
|
|
58
86
|
* @returns {boolean}
|
package/src/audit/basal-cost.js
CHANGED
|
@@ -272,10 +272,14 @@ export async function saveAuditSnapshot(projectDir, metrics) {
|
|
|
272
272
|
|
|
273
273
|
export function computeGrowthDelta(current, previous) {
|
|
274
274
|
if (!previous) return null;
|
|
275
|
+
// AC8 (KJC-TSK-0794): "not measured before" must never read as "unchanged" —
|
|
276
|
+
// the derivative only exists when BOTH snapshots actually measured it.
|
|
277
|
+
const bothMeasured = Array.isArray(current.deadExports) && Array.isArray(previous.deadExports);
|
|
275
278
|
return {
|
|
276
279
|
lines: current.totalLines - previous.totalLines,
|
|
277
280
|
files: current.totalFiles - previous.totalFiles,
|
|
278
281
|
deps: current.dependencies.total - (previous.dependencies?.total ?? 0),
|
|
282
|
+
deadExports: bothMeasured ? current.deadExports.length - previous.deadExports.length : null,
|
|
279
283
|
since: previous.timestamp || null
|
|
280
284
|
};
|
|
281
285
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { execFile } from "node:child_process";
|
|
17
17
|
import { promisify } from "node:util";
|
|
18
18
|
import { createRequire } from "node:module";
|
|
19
|
+
import os from "node:os";
|
|
19
20
|
import path from "node:path";
|
|
20
21
|
import fs from "node:fs/promises";
|
|
21
22
|
import { filterFalsePositives } from "./issue-filter.js";
|
|
@@ -26,6 +27,42 @@ const SCAN_TIMEOUT_MS = 120_000;
|
|
|
26
27
|
const MAX_DEAD_EXPORTS_REPORTED = 100;
|
|
27
28
|
const MAX_UNUSED_FILES_REPORTED = 50;
|
|
28
29
|
|
|
30
|
+
// KJC-TSK-0794 AC7: a Firebase callable or a declared global-setup reported as
|
|
31
|
+
// dead is the false positive that gets the inventory switched off. When the
|
|
32
|
+
// project declares nothing itself, kj declares FOR it what it can read.
|
|
33
|
+
const KNIP_OWN_CONFIGS = ["knip.json", "knip.jsonc", ".knip.json", "knip.js", "knip.ts", "knip.config.js", "knip.config.ts"];
|
|
34
|
+
// knip's default root entry patterns, restated because an explicit `entry`
|
|
35
|
+
// REPLACES them (package.json main/bin/exports always count on their own).
|
|
36
|
+
const KNIP_DEFAULT_ENTRY = ["{index,cli,main}.{js,mjs,cjs,jsx,ts,mts,cts,tsx}", "src/{index,cli,main}.{js,mjs,cjs,jsx,ts,mts,cts,tsx}"];
|
|
37
|
+
|
|
38
|
+
async function hasOwnKnipConfig(projectDir) {
|
|
39
|
+
for (const f of KNIP_OWN_CONFIGS) {
|
|
40
|
+
try { await fs.access(path.join(projectDir, f)); return true; } catch { /* keep looking */ }
|
|
41
|
+
}
|
|
42
|
+
try { return Boolean(JSON.parse(await fs.readFile(path.join(projectDir, "package.json"), "utf8")).knip); } catch { return false; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Entrypoints kj can vouch for: config.audit.entrypoints (the user's word) and
|
|
46
|
+
* firebase.json's functions.source (the platform's word). Null when nothing. */
|
|
47
|
+
async function resolveProjectEntrypoints(projectDir, config) {
|
|
48
|
+
const declared = (config?.audit?.entrypoints || []).filter((e) => typeof e === "string");
|
|
49
|
+
const firebase = [];
|
|
50
|
+
try {
|
|
51
|
+
const fb = JSON.parse(await fs.readFile(path.join(projectDir, "firebase.json"), "utf8"));
|
|
52
|
+
const fns = Array.isArray(fb.functions) ? fb.functions : fb.functions ? [fb.functions] : [];
|
|
53
|
+
for (const f of fns) if (typeof f?.source === "string") firebase.push(f.source);
|
|
54
|
+
} catch { /* no firebase.json — nothing to declare */ }
|
|
55
|
+
return declared.length || firebase.length ? { declared, firebase } : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildEphemeralKnipConfig({ declared, firebase }) {
|
|
59
|
+
const root = { entry: [...KNIP_DEFAULT_ENTRY, ...declared] };
|
|
60
|
+
if (!firebase.length) return root;
|
|
61
|
+
const workspaces = { ".": root };
|
|
62
|
+
for (const src of firebase) workspaces[src] = { entry: ["index.{js,ts}", "src/index.{js,ts}"] };
|
|
63
|
+
return { workspaces };
|
|
64
|
+
}
|
|
65
|
+
|
|
29
66
|
function hasJsTsStack(stack) {
|
|
30
67
|
if (!stack) return false;
|
|
31
68
|
const lang = (stack.language || "").toLowerCase();
|
|
@@ -78,11 +115,24 @@ export async function collectDeadExports(projectDir, stack, config = {}, logger
|
|
|
78
115
|
return { available: false, reason: `knip binary not resolvable (${err?.message || err}). In the SEA binary install karajan-code via npm to enable.` };
|
|
79
116
|
}
|
|
80
117
|
|
|
118
|
+
const args = [knipBin, "--reporter", "json", "--no-progress", "--no-exit-code"];
|
|
119
|
+
let declaredEntrypoints = null;
|
|
120
|
+
try {
|
|
121
|
+
const eps = await resolveProjectEntrypoints(projectDir, config);
|
|
122
|
+
// a project with its OWN knip config already decided — it is respected
|
|
123
|
+
if (eps && !(await hasOwnKnipConfig(projectDir))) {
|
|
124
|
+
const cfgPath = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "kj-knip-")), "knip.json");
|
|
125
|
+
await fs.writeFile(cfgPath, JSON.stringify(buildEphemeralKnipConfig(eps)));
|
|
126
|
+
args.push("--config", cfgPath);
|
|
127
|
+
declaredEntrypoints = eps;
|
|
128
|
+
}
|
|
129
|
+
} catch { /* declaring is best-effort; the default scan still runs */ }
|
|
130
|
+
|
|
81
131
|
let raw;
|
|
82
132
|
try {
|
|
83
133
|
const { stdout } = await execFileAsync(
|
|
84
134
|
process.execPath,
|
|
85
|
-
|
|
135
|
+
args,
|
|
86
136
|
{ cwd: projectDir, timeout: SCAN_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 },
|
|
87
137
|
);
|
|
88
138
|
raw = stdout;
|
|
@@ -163,6 +213,7 @@ export async function collectDeadExports(projectDir, stack, config = {}, logger
|
|
|
163
213
|
available: true,
|
|
164
214
|
total: kept.length,
|
|
165
215
|
suppressedCount: suppressed.length,
|
|
216
|
+
declaredEntrypoints,
|
|
166
217
|
exports: keptExports,
|
|
167
218
|
files: keptFiles,
|
|
168
219
|
suppressed,
|
|
@@ -185,9 +185,20 @@ function formatDeadExportsBlock(deadExports) {
|
|
|
185
185
|
const lines = ["### Dead Code (knip)"];
|
|
186
186
|
const exportsTotal = (deadExports.exports || []).length;
|
|
187
187
|
const filesTotal = (deadExports.files || []).length;
|
|
188
|
+
const reported = exportsTotal + filesTotal;
|
|
189
|
+
const suppressed = deadExports.suppressedCount || 0;
|
|
190
|
+
// AC8 (KJC-TSK-0794): derivative first; then how many ENTERED and how many
|
|
191
|
+
// were FILTERED — a shrinking number must never hide a shrinking scan.
|
|
192
|
+
const prev = deadExports.previous;
|
|
193
|
+
if (prev && typeof prev.exports === "number") {
|
|
194
|
+
const d = reported - (prev.exports + (prev.files || 0));
|
|
195
|
+
lines.push(`- Δ dead code: ${d >= 0 ? "+" : ""}${d} since ${prev.timestamp || "last audit"} (now ${reported})`);
|
|
196
|
+
}
|
|
197
|
+
lines.push(`- ${reported + suppressed} entered the scan, ${suppressed} filtered as declared false positives, ${reported} reported`);
|
|
198
|
+
const eps = deadExports.declaredEntrypoints;
|
|
199
|
+
if (eps) lines.push(`- Declared entrypoints honored: ${[...eps.declared, ...eps.firebase.map((s) => `${s} (firebase functions)`)].join(", ")}`);
|
|
188
200
|
lines.push(`- Unused exports/types: ${exportsTotal}`);
|
|
189
201
|
lines.push(`- Unused files: ${filesTotal}`);
|
|
190
|
-
if (deadExports.suppressedCount) lines.push(`- Suppressed (FP filter): ${deadExports.suppressedCount}`);
|
|
191
202
|
const allItems = [...(deadExports.exports || []), ...(deadExports.files || [])];
|
|
192
203
|
if (allItems.length > 0) {
|
|
193
204
|
const groups = groupDeadExportsBySeverity(allItems);
|
|
@@ -234,8 +245,12 @@ function formatBasalCostBlock(basalCost, growthDelta) {
|
|
|
234
245
|
}
|
|
235
246
|
|
|
236
247
|
const dead = Array.isArray(basalCost.deadExports) ? basalCost.deadExports : [];
|
|
248
|
+
// AC8 (KJC-TSK-0794): the DERIVATIVE leads — "went from N to M" is what a
|
|
249
|
+
// reader acts on; the absolute is the secondary datum.
|
|
250
|
+
const dDelta = growthDelta?.deadExports;
|
|
251
|
+
const lead = typeof dDelta === "number" ? `${dDelta >= 0 ? "+" : ""}${dDelta} since last audit — ` : "";
|
|
237
252
|
if (dead.length > 0) {
|
|
238
|
-
lines.push(`- Dead exports: ${dead.length}`);
|
|
253
|
+
lines.push(`- Dead exports: ${lead}${dead.length} total`);
|
|
239
254
|
for (const de of dead.slice(0, MAX_SAMPLE_DEAD_EXPORTS)) {
|
|
240
255
|
lines.push(` - \`${de.name}\` in ${de.file}`);
|
|
241
256
|
}
|
|
@@ -243,7 +258,7 @@ function formatBasalCostBlock(basalCost, growthDelta) {
|
|
|
243
258
|
lines.push(` - ... and ${dead.length - MAX_SAMPLE_DEAD_EXPORTS} more`);
|
|
244
259
|
}
|
|
245
260
|
} else {
|
|
246
|
-
lines.push(
|
|
261
|
+
lines.push(`- Dead exports: ${lead}0`);
|
|
247
262
|
}
|
|
248
263
|
|
|
249
264
|
if (growthDelta) {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Member reachability (KJC-TSK-0794, epic KJC-PCS-0082) — which class members
|
|
3
|
+
* no entrypoint can reach, said ONLY inside the perimeter validated with known
|
|
4
|
+
* truth (GREBLA: 31/139 unreachable at dd5a91a checked by hand, 0/108 on their
|
|
5
|
+
* cleaned main): one file, a recognized framework contract, no dynamic dispatch.
|
|
6
|
+
* Everything outside comes out NOT OBSERVABLE with its reason, never as clean —
|
|
7
|
+
* an inflated inventory gets switched off, and then nobody reads the real one.
|
|
8
|
+
*/
|
|
9
|
+
import { parse } from "@babel/parser";
|
|
10
|
+
const plugins = (file) => [
|
|
11
|
+
...(/\.(ts|tsx|mts|cts)$/.test(file) ? ["typescript"] : []),
|
|
12
|
+
...(/\.(tsx|jsx)$/.test(file) || !/\.(ts|mts|cts)$/.test(file) ? ["jsx"] : []),
|
|
13
|
+
"decorators",
|
|
14
|
+
];
|
|
15
|
+
// Entrypoints are what the FRAMEWORK calls — declared per framework, VERSIONED
|
|
16
|
+
// (bump on any list change), never a hand-kept list in a run. v1 covers Lit;
|
|
17
|
+
// other frameworks stay NOT OBSERVABLE until their contract is declared here.
|
|
18
|
+
export const ENTRYPOINT_CATALOG = {
|
|
19
|
+
version: 2, // v2: each list says WHICH slot the framework touches — static and instance are different worlds
|
|
20
|
+
lit: {
|
|
21
|
+
instance: ["constructor", "render", "connectedCallback", "disconnectedCallback", "attributeChangedCallback", "adoptedCallback", "firstUpdated", "updated", "willUpdate", "shouldUpdate", "performUpdate", "getUpdateComplete", "createRenderRoot"],
|
|
22
|
+
static: ["properties", "styles", "observedAttributes"],
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
const BASES = new Map([["LitElement", "lit"]]);
|
|
26
|
+
function walk(node, visit) {
|
|
27
|
+
if (!node || typeof node.type !== "string") return;
|
|
28
|
+
visit(node);
|
|
29
|
+
for (const key of Object.keys(node)) {
|
|
30
|
+
const child = node[key];
|
|
31
|
+
if (Array.isArray(child)) child.forEach((c) => walk(c, visit));
|
|
32
|
+
else if (child && typeof child.type === "string") walk(child, visit);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const keyName = (m) => {
|
|
36
|
+
if (m.key?.type === "PrivateName") return `#${m.key.id.name}`;
|
|
37
|
+
if (!m.computed && m.key?.type === "Identifier") return m.key.name;
|
|
38
|
+
return m.key?.type === "StringLiteral" || m.key?.type === "NumericLiteral" ? String(m.key.value) : null;
|
|
39
|
+
};
|
|
40
|
+
const ACCESSOR_KINDS = new Map([["get", "getter"], ["set", "setter"]]);
|
|
41
|
+
const kindOf = (m) => ACCESSOR_KINDS.get(m.kind) ?? (/Method/.test(m.type) ? "method" : "field");
|
|
42
|
+
/** `this.x` / `this['x']` / `this.#x` inside a node, prefixed with the slot the
|
|
43
|
+
* context can actually reach ("static " inside static members, "" otherwise).
|
|
44
|
+
* Over-collecting through functions that rebind `this` only makes members MORE
|
|
45
|
+
* alive — never dead. */
|
|
46
|
+
const thisRefs = (node, prefix = "") => {
|
|
47
|
+
const refs = new Set();
|
|
48
|
+
walk(node, (n) => {
|
|
49
|
+
if (n.type !== "MemberExpression" || n.object?.type !== "ThisExpression") return;
|
|
50
|
+
if (n.property.type === "PrivateName") refs.add(`${prefix}#${n.property.id.name}`);
|
|
51
|
+
else if (!n.computed && n.property.type === "Identifier") refs.add(prefix + n.property.name);
|
|
52
|
+
else if (n.property.type === "StringLiteral" || n.property.type === "NumericLiteral") refs.add(prefix + String(n.property.value));
|
|
53
|
+
});
|
|
54
|
+
return refs;
|
|
55
|
+
};
|
|
56
|
+
const notObs = (name, line, reason) => ({ name, line, observable: false, reason, unreachable: [] });
|
|
57
|
+
|
|
58
|
+
function analyzeClass(node, { staticUses, thisPropCount }) {
|
|
59
|
+
const name = node.id?.name ?? "(anonymous)";
|
|
60
|
+
const line = node.loc.start.line;
|
|
61
|
+
const sup = node.superClass;
|
|
62
|
+
if (!sup) return notObs(name, line, "no framework contract — members may be called from outside the file");
|
|
63
|
+
if (sup.type !== "Identifier") return notObs(name, line, "mixin or computed base class — inherited entrypoints cannot be resolved");
|
|
64
|
+
const framework = BASES.get(sup.name);
|
|
65
|
+
if (!framework) return notObs(name, line, `unknown base class ${sup.name} — its contract is not in the entrypoint catalog (v${ENTRYPOINT_CATALOG.version})`);
|
|
66
|
+
|
|
67
|
+
const cat = ENTRYPOINT_CATALOG[framework];
|
|
68
|
+
const entries = new Set([...cat.instance, ...cat.static.map((s) => `static ${s}`)]);
|
|
69
|
+
const members = new Map(); // slot → { refs, spots, entry }
|
|
70
|
+
// reached at class-definition time (static blocks, static field initializers)
|
|
71
|
+
// or by ClassName.X anywhere in the file — a use is a use, wherever it sits
|
|
72
|
+
const roots = new Set([...(staticUses.get(name) ?? [])].map((s) => `static ${s}`));
|
|
73
|
+
const ctorFields = new Map(); // this._x = v in the constructor: no AST member exists
|
|
74
|
+
for (const m of node.body.body) {
|
|
75
|
+
if (m.type === "StaticBlock") { thisRefs(m, "static ").forEach((r) => roots.add(r)); continue; }
|
|
76
|
+
if (m.type === "TSDeclareMethod" || m.type === "TSIndexSignature") continue;
|
|
77
|
+
if (m.static && /Property/.test(m.type) && m.value) thisRefs(m.value, "static ").forEach((r) => roots.add(r));
|
|
78
|
+
const n = keyName(m);
|
|
79
|
+
if (n === null) return notObs(name, line, `a member name is computed at line ${m.loc.start.line} — the inventory cannot name what it cannot see`);
|
|
80
|
+
const slot = m.static ? `static ${n}` : n;
|
|
81
|
+
const refs = thisRefs(m, m.static ? "static " : "");
|
|
82
|
+
refs.delete(slot); // recursion does not keep itself alive
|
|
83
|
+
const rec = members.get(slot) ?? { refs: new Set(), spots: [], entry: false };
|
|
84
|
+
refs.forEach((r) => rec.refs.add(r));
|
|
85
|
+
rec.spots.push({ line: m.loc.start.line, endLine: m.loc.end.line, kind: kindOf(m) });
|
|
86
|
+
// a decorated member is registered by the framework: it may call or expose it
|
|
87
|
+
if (entries.has(slot) || (m.decorators?.length ?? 0) > 0) rec.entry = true;
|
|
88
|
+
members.set(slot, rec);
|
|
89
|
+
if (n === "constructor" && !m.static) collectCtorAssignments(m, ctorFields);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const alive = new Set();
|
|
93
|
+
const queue = [...members.keys()].filter((k) => members.get(k).entry || roots.has(k));
|
|
94
|
+
while (queue.length) {
|
|
95
|
+
const k = queue.pop();
|
|
96
|
+
if (alive.has(k) || !members.has(k)) continue;
|
|
97
|
+
alive.add(k);
|
|
98
|
+
members.get(k).refs.forEach((r) => { if (members.has(r) && !alive.has(r)) queue.push(r); });
|
|
99
|
+
}
|
|
100
|
+
const unreachable = [...members.entries()]
|
|
101
|
+
.filter(([k]) => !alive.has(k))
|
|
102
|
+
.flatMap(([k, rec]) => rec.spots.map((s) => ({ name: k, ...s })))
|
|
103
|
+
.sort((a, b) => a.line - b.line);
|
|
104
|
+
// HEURISTIC, reported apart (AC5): a constructor field whose name appears in
|
|
105
|
+
// `this.X` form exactly ONCE in the whole file exists only to be initialized.
|
|
106
|
+
const constructorFields = [...ctorFields.entries()]
|
|
107
|
+
.filter(([n]) => !members.has(n) && thisPropCount.get(n) === 1)
|
|
108
|
+
.map(([n, l]) => ({ name: n, line: l, heuristic: "single this-appearance in file" }));
|
|
109
|
+
// memberNames: the class's member slots — phantom-coverage (KJC-TSK-0800)
|
|
110
|
+
// needs to tell "a call to a member of THIS class" from any other call.
|
|
111
|
+
return { name, line, observable: true, reason: null, framework, catalogVersion: ENTRYPOINT_CATALOG.version, total: members.size, memberNames: [...members.keys()], unreachable, constructorFields };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** `this.x = …` statements inside the constructor body (first line wins). */
|
|
115
|
+
function collectCtorAssignments(ctor, out) {
|
|
116
|
+
walk(ctor.body, (n) => {
|
|
117
|
+
if (n.type !== "AssignmentExpression" || n.left?.type !== "MemberExpression") return;
|
|
118
|
+
const l = n.left;
|
|
119
|
+
if (l.object?.type !== "ThisExpression" || l.computed || l.property.type !== "Identifier") return;
|
|
120
|
+
if (!out.has(l.property.name)) out.set(l.property.name, n.loc.start.line);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {string} source
|
|
126
|
+
* @param {{file?: string}} [where]
|
|
127
|
+
* @returns {{file: string, observable: boolean, reason: string|null, classes: Array<object>}}
|
|
128
|
+
*/
|
|
129
|
+
export function analyzeMemberReachability(source, { file = "<source>" } = {}) {
|
|
130
|
+
let tree;
|
|
131
|
+
try {
|
|
132
|
+
tree = parse(source, { sourceType: "unambiguous", allowReturnOutsideFunction: true, plugins: plugins(file) });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return { file, observable: false, reason: `could not be parsed (${err.message}) — not read as clean`, classes: [] };
|
|
135
|
+
}
|
|
136
|
+
// String dispatch is the most dangerous false positive: ONE computed access
|
|
137
|
+
// on `this` that is not a literal makes the whole file not observable — a
|
|
138
|
+
// list with garbage is worth less than an honest "I do not know".
|
|
139
|
+
let dynamic = null;
|
|
140
|
+
const staticUses = new Map(); // ClassName → Set of properties used as ClassName.X
|
|
141
|
+
const thisPropCount = new Map(); // property → how many `this.X` appearances in the file
|
|
142
|
+
walk(tree, (n) => {
|
|
143
|
+
if (n.type !== "MemberExpression") return;
|
|
144
|
+
if (n.object?.type === "Identifier" && !n.computed && n.property.type === "Identifier") {
|
|
145
|
+
(staticUses.get(n.object.name) ?? staticUses.set(n.object.name, new Set()).get(n.object.name)).add(n.property.name);
|
|
146
|
+
}
|
|
147
|
+
if (n.object?.type !== "ThisExpression") return;
|
|
148
|
+
if (n.computed && n.property.type !== "StringLiteral" && n.property.type !== "NumericLiteral") { dynamic ??= n.loc.start.line; return; }
|
|
149
|
+
if (!n.computed && n.property.type === "Identifier") thisPropCount.set(n.property.name, (thisPropCount.get(n.property.name) ?? 0) + 1);
|
|
150
|
+
});
|
|
151
|
+
if (dynamic !== null) return { file, observable: false, reason: `computed access on this at line ${dynamic} — string dispatch cannot be followed`, classes: [] };
|
|
152
|
+
|
|
153
|
+
const classes = [];
|
|
154
|
+
walk(tree, (node) => {
|
|
155
|
+
if (node.type === "ClassDeclaration" || node.type === "ClassExpression") classes.push(analyzeClass(node, { staticUses, thisPropCount }));
|
|
156
|
+
});
|
|
157
|
+
return { file, observable: true, reason: null, classes };
|
|
158
|
+
}
|
|
@@ -104,6 +104,7 @@ export function parseOsvOutput(raw) {
|
|
|
104
104
|
aliases: vuln.aliases || [],
|
|
105
105
|
severity: extractSeverity(vuln, groups),
|
|
106
106
|
summary: vuln.summary || vuln.details?.split("\n")[0] || "",
|
|
107
|
+
publishedAt: vuln.published || null, // the ADVISORY's date — the Steward ages by it (KJC-TSK-0789 AC6)
|
|
107
108
|
package: name,
|
|
108
109
|
version,
|
|
109
110
|
ecosystem,
|
|
@@ -41,8 +41,13 @@ const SAYS_EMPTY = /(^|\W)(\[\]|\bnone\b|\bno results?\b|\bempty\b|\b0 (results?
|
|
|
41
41
|
* @returns {{claims: Array<object>, denied: Array<object>, unbacked: Array<object>}}
|
|
42
42
|
*/
|
|
43
43
|
export function crossCheck({ text, outputs = [], userSaid = "" }) {
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
// Backing can come from an output OR from the user (repeating their datum is not
|
|
45
|
+
// inventing). The DENIED analysis reads only the OUTPUTS: the user asking "how
|
|
46
|
+
// many cards are left?" mentions the noun without saying anything about emptiness,
|
|
47
|
+
// and must not veto a denial — found by the stop-gate wiring test.
|
|
48
|
+
const sources = outputs.map(norm);
|
|
49
|
+
const haystack = [...sources, norm(userSaid)];
|
|
50
|
+
const claims = extractClaims(text).map((claim) => ({ ...claim, status: verdictFor(claim, haystack, sources) }));
|
|
46
51
|
return {
|
|
47
52
|
claims,
|
|
48
53
|
denied: claims.filter((c) => c.status === DENIED),
|
|
@@ -50,15 +55,15 @@ export function crossCheck({ text, outputs = [], userSaid = "" }) {
|
|
|
50
55
|
};
|
|
51
56
|
}
|
|
52
57
|
|
|
53
|
-
function verdictFor(claim, haystack) {
|
|
58
|
+
function verdictFor(claim, haystack, sources) {
|
|
54
59
|
if (haystack.some((h) => appearsIn(h, claim))) return BACKED;
|
|
55
60
|
|
|
56
|
-
// A count stated as non-zero while every
|
|
61
|
+
// A count stated as non-zero while every OUTPUT that mentions the same noun says empty:
|
|
57
62
|
// that is the "four cards are waiting" case, and it is the one that blocks.
|
|
58
63
|
if (claim.kind === "count" && Number(claim.value) > 0) {
|
|
59
64
|
const noun = nounAfterCount(claim.sentence, claim.value);
|
|
60
65
|
if (noun) {
|
|
61
|
-
const mentions =
|
|
66
|
+
const mentions = sources.filter((h) => h.includes(norm(noun)));
|
|
62
67
|
if (mentions.length && mentions.every((h) => SAYS_EMPTY.test(h))) return DENIED;
|
|
63
68
|
}
|
|
64
69
|
// Small numbers are prose as often as data ("las dos capas", "3 reglas"): not worth accusing.
|
|
@@ -30,7 +30,7 @@ export const ADVANCED_GROUPS = [
|
|
|
30
30
|
{ title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
|
|
31
31
|
{ title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
|
|
32
32
|
{ title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
|
|
33
|
-
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims"] },
|
|
33
|
+
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims", "steward"] },
|
|
34
34
|
{ title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity"] },
|
|
35
35
|
{ title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
|
|
36
36
|
{ title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
|
package/src/cli/register-meta.js
CHANGED
|
@@ -6,7 +6,8 @@ import { onboardCommand } from "../commands/onboard.js";
|
|
|
6
6
|
import { startCommand } from "../commands/start.js";
|
|
7
7
|
import { identityCommand } from "../commands/identity.js";
|
|
8
8
|
import { policyCommand } from "../commands/policy.js";
|
|
9
|
-
import { claimsCommand } from "../commands/claims.js";
|
|
9
|
+
import { claimsCommand, claimsGateCommand } from "../commands/claims.js";
|
|
10
|
+
import { stewardSweepCommand } from "../commands/steward.js";
|
|
10
11
|
import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand } from "../commands/rag.js";
|
|
11
12
|
import { qmdQueryCommand } from "../commands/qmd.js";
|
|
12
13
|
import { ragMcpCommand } from "../commands/rag-mcp.js";
|
|
@@ -314,12 +315,32 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
314
315
|
|
|
315
316
|
// KJC-TSK-0733 PL-A — policy as code: motor determinista en modo warn.
|
|
316
317
|
// CLM-B (KJC-TSK-0802): the data the AI states, checked against what actually ran.
|
|
317
|
-
program.command("
|
|
318
|
-
|
|
318
|
+
const stewardCmd = program.command("steward").description("El Steward: gobierna el ESTADO del proyecto — invariantes con caducidad y cuatro veredictos (épica claims/steward)");
|
|
319
|
+
stewardCmd.command("sweep")
|
|
320
|
+
.description("Barrido read-only de los invariantes: deja el informe versionado en .karajan/steward/ (md + json) y sale con 1 solo si algo está ROTO — unknown y not-observable informan con su remedio")
|
|
321
|
+
.option("--if-stale <days>", "Solo barre si el informe tiene más de N días — retomar trabajo con informe fresco no re-barre")
|
|
322
|
+
.option("--json", "Machine-readable")
|
|
323
|
+
.action(async (flags) => {
|
|
324
|
+
await withConfig(pkgVersion, "steward-sweep", flags, async ({ config }) => {
|
|
325
|
+
process.exitCode = await stewardSweepCommand({ flags, config });
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
const claimsCmd = program.command("claims").description("Afirmaciones con fuente: comprueba los datos que la IA afirma en un turno contra las salidas de ese turno (ADR claims-with-evidence)");
|
|
329
|
+
claimsCmd.command("check")
|
|
319
330
|
.description("Cruza el mensaje final del turno con sus salidas — exit 2 solo si un dato está DESMENTIDO por su propia fuente; falla abierto si no puede leer el transcript")
|
|
320
331
|
.requiredOption("--transcript <path>", "Ruta del transcript de la sesión (la que pasa el hook)")
|
|
332
|
+
.option("--file <path>", "Cruza el contenido de este fichero (cuerpo de PR, card) en vez del mensaje final del turno")
|
|
321
333
|
.option("--json", "Machine-readable")
|
|
322
334
|
.action(async (flags) => { process.exitCode = await claimsCommand({ flags }); });
|
|
335
|
+
claimsCmd.command("gate")
|
|
336
|
+
.description("El mismo cruce, gobernado por method_gates.claims del proyecto (off|warn|block) — lo invocan los hooks; off = silencio, block = exit 2 solo con un dato desmentido")
|
|
337
|
+
.requiredOption("--transcript <path>", "Ruta del transcript de la sesión")
|
|
338
|
+
.option("--file <path>", "Cruza el contenido de este fichero (cuerpo de PR, card) en vez del mensaje final del turno")
|
|
339
|
+
.action(async (flags) => {
|
|
340
|
+
await withConfig(pkgVersion, "claims-gate", flags, async ({ config }) => {
|
|
341
|
+
process.exitCode = await claimsGateCommand({ flags, config });
|
|
342
|
+
});
|
|
343
|
+
});
|
|
323
344
|
|
|
324
345
|
const policyCmd = program.command("policy").description("Policy as code (.karajan/policy.yml, vocabulario cerrado): eval/check deterministas, grant con caducidad, anchor del decision log — deny en commit y CI");
|
|
325
346
|
policyCmd.command("eval")
|
package/src/commands/claims.js
CHANGED
|
@@ -9,10 +9,41 @@
|
|
|
9
9
|
* It fails OPEN. A verifier that cannot read the transcript says so and gets out
|
|
10
10
|
* of the way: a broken check must never hold a session hostage.
|
|
11
11
|
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
12
13
|
import { readTurn } from "../claims/turn.js";
|
|
13
14
|
import { crossCheck, formatClaimReport } from "../claims/cross-check.js";
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
/**
|
|
17
|
+
* `kj claims gate` — the same check, run by the Stop hook with the PROJECT's
|
|
18
|
+
* say-so. The hook stays policy-free: kj reads `method_gates.claims` and
|
|
19
|
+
* decides. "off" (default: adoption is explicit) exits 0 in silence; "warn"
|
|
20
|
+
* reports and never blocks; "block" refuses only a datum DENIED by its own
|
|
21
|
+
* source — unbacked data is reported either way, per the accepted ADR:
|
|
22
|
+
* inform always, block almost never.
|
|
23
|
+
*/
|
|
24
|
+
export async function claimsGateCommand({ flags = {}, config = {}, logger = console, readTurnFn = readTurn, readFileFn = readFileSync } = {}) {
|
|
25
|
+
const mode = config?.method_gates?.claims ?? "off";
|
|
26
|
+
if (mode !== "warn" && mode !== "block") return 0;
|
|
27
|
+
let turn;
|
|
28
|
+
try {
|
|
29
|
+
turn = readTurnFn(flags.transcript);
|
|
30
|
+
// CLM-C: with --file the ARTIFACT is what gets checked — a PR body, a card, a
|
|
31
|
+
// note — against the same turn's outputs. The final message is what outlives
|
|
32
|
+
// the turn least; the artifact is what outlives it most.
|
|
33
|
+
if (flags.file) turn = { ...turn, text: String(readFileFn(flags.file, "utf8")) };
|
|
34
|
+
} catch {
|
|
35
|
+
return 0; // not observable: a gate that cannot read its inputs gets out of the way
|
|
36
|
+
}
|
|
37
|
+
const result = crossCheck(turn);
|
|
38
|
+
if (result.denied.length && mode === "block") {
|
|
39
|
+
logger.error(formatClaimReport(result));
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
if (result.denied.length || result.unbacked.length) logger.error(formatClaimReport(result));
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function claimsCommand({ flags = {}, logger = console, readTurnFn = readTurn, readFileFn = readFileSync } = {}) {
|
|
16
47
|
const path = flags.transcript;
|
|
17
48
|
if (!path) {
|
|
18
49
|
logger.error("kj claims check: --transcript <path> is required");
|
|
@@ -21,6 +52,7 @@ export async function claimsCommand({ flags = {}, logger = console, readTurnFn =
|
|
|
21
52
|
let turn;
|
|
22
53
|
try {
|
|
23
54
|
turn = readTurnFn(path);
|
|
55
|
+
if (flags.file) turn = { ...turn, text: String(readFileFn(flags.file, "utf8")) };
|
|
24
56
|
} catch (err) {
|
|
25
57
|
// Not observable: the transcript could not be read. Never reported as clean.
|
|
26
58
|
const note = `kj claims: transcript not readable (${err.message}) — nothing checked`;
|
package/src/commands/hu.js
CHANGED
|
@@ -43,7 +43,9 @@ export function findTitleMatches(title, allHus) {
|
|
|
43
43
|
return { identical, similar };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
// Exported for the Steward's proposed-work sync (KJC-TSK-0792): broken
|
|
47
|
+
// invariants land in the same backlog the brain already consumes.
|
|
48
|
+
export async function backlogPlan(projectDir) {
|
|
47
49
|
const plans = await listPlans(projectDir);
|
|
48
50
|
const existing = plans.find((p) => p.alias === BACKLOG_NAME || p.name === BACKLOG_NAME);
|
|
49
51
|
if (existing) return loadPlan(projectDir, existing.planId);
|
package/src/commands/privacy.js
CHANGED
|
@@ -28,9 +28,12 @@ export async function privacyScanCommand({ paths = [], flags = {}, logger = cons
|
|
|
28
28
|
const warns = findings.filter((f) => f.severity === "warn");
|
|
29
29
|
const ok = blocks.length === 0;
|
|
30
30
|
process.exitCode = ok ? 0 : 1;
|
|
31
|
+
// KJC-TSK-0797 AC4: "nothing found" and "found but explained by context"
|
|
32
|
+
// are different truths — the report says which one it is.
|
|
33
|
+
const discarded = findings.discardedByContext || 0;
|
|
31
34
|
// --json keeps stdout machine-clean: exactly one JSON document, no prose.
|
|
32
35
|
if (flags.json) {
|
|
33
|
-
process.stdout.write(`${JSON.stringify({ ok, blocks: blocks.length, warns: warns.length, findings })}\n`);
|
|
36
|
+
process.stdout.write(`${JSON.stringify({ ok, blocks: blocks.length, warns: warns.length, discardedByContext: discarded, findings })}\n`);
|
|
34
37
|
return { ok, findings };
|
|
35
38
|
}
|
|
36
39
|
for (const f of blocks) logger.error?.(`✗ BLOCK [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
|
|
@@ -38,8 +41,9 @@ export async function privacyScanCommand({ paths = [], flags = {}, logger = cons
|
|
|
38
41
|
if (!list.present) {
|
|
39
42
|
logger.info?.(`hint: no personal denylist found — create ${privacyConfigPath()} (personal: [...], allow: [...]) so YOUR data blocks, not just warns`);
|
|
40
43
|
}
|
|
44
|
+
const context = discarded > 0 ? `; ${discarded} candidate(s) discarded by context — git SHAs / documentation domains` : "";
|
|
41
45
|
logger.info?.(ok
|
|
42
|
-
? `privacy scan: clean of denylist hits (${warns.length} generic warning(s))`
|
|
46
|
+
? `privacy scan: clean of denylist hits (${warns.length} generic warning(s)${context})`
|
|
43
47
|
: `privacy scan: ${blocks.length} personal-data hit(s) — this must not ship`);
|
|
44
48
|
return { ok, findings };
|
|
45
49
|
}
|
package/src/commands/resume.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { resumeFlow } from "../orchestrator.js";
|
|
3
|
+
import { sweepOnResume } from "./steward.js";
|
|
3
4
|
import { createActivityLog } from "../activity-log.js";
|
|
4
5
|
import { printEvent } from "../utils/display/event-handlers.js";
|
|
5
6
|
import { withCliRunLog } from "../utils/cli-run-log.js";
|
|
@@ -9,6 +10,9 @@ import { createCliAskQuestion } from "../utils/cli-ask-question.js";
|
|
|
9
10
|
export async function resumeCommand({ sessionId, answer, config, logger, flags }) {
|
|
10
11
|
const jsonMode = flags?.json;
|
|
11
12
|
const quietMode = config.output?.quiet !== false;
|
|
13
|
+
// STW-E (KJC-TSK-0793): resuming work is when someone is in front to review
|
|
14
|
+
// the state — sweep if the report went stale; adoption stays explicit.
|
|
15
|
+
await sweepOnResume({ config, logger });
|
|
12
16
|
|
|
13
17
|
// Same wrapper as every other CLI command — without it `.kj/run.log`
|
|
14
18
|
// is never opened during the resume and `kj-tail` stays silent
|