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
|
@@ -12,7 +12,7 @@ import { checkVerdict, diffHash } from "../review/verdict-store.js";
|
|
|
12
12
|
import { runOneShotReview } from "../review/one-shot-review.js";
|
|
13
13
|
import { runSolomonArbitration } from "../review/solomon-arbitration.js";
|
|
14
14
|
import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
15
|
-
import { runSonarPregate, formatSonarFinding } from "../review/sonar-pregate.js";
|
|
15
|
+
import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
|
|
16
16
|
import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
|
|
17
17
|
import { checkCardFirst } from "../review/card-first.js";
|
|
18
18
|
import { checkTestsWithCode } from "../review/tests-with-code.js";
|
|
@@ -162,16 +162,23 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
162
162
|
const numstat = await rawDiff(flags.range, ["--numstat"]);
|
|
163
163
|
const added = numstat.split("\n").reduce((acc, l) => acc + (Number(l.split("\t")[0]) || 0), 0);
|
|
164
164
|
if (added > sizeWarn) {
|
|
165
|
+
// KJC-TSK-0795 AC5: say how much of the weight is the module's OWN tests —
|
|
166
|
+
// partitioning is a decision, and it must never split code from its tests.
|
|
167
|
+
const testAdded = numstat.split("\n").reduce((acc, l) => {
|
|
168
|
+
const [a, , ...f] = l.split("\t");
|
|
169
|
+
return acc + (/\/tests?\/|__tests__\/|\.test\.|\.spec\./.test(`/${f.join("\t")}`) ? Number(a) || 0 : 0);
|
|
170
|
+
}, 0);
|
|
171
|
+
const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} accompanying tests — partition by feature, never code from its tests)` : "";
|
|
165
172
|
const sizePolicy = config?.method_gates?.pr_size || "warn";
|
|
166
173
|
if (process.env.KJ_ALLOW_LARGE_PR === "1") {
|
|
167
174
|
console.log(`⚠ pr-size exempt: ${added} lines added — KJ_ALLOW_LARGE_PR=1 (explicit escape hatch)`);
|
|
168
175
|
} else if (sizePolicy === "block") {
|
|
169
|
-
const reason = `${added} lines added exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
|
|
176
|
+
const reason = `${added} lines added${split} exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
|
|
170
177
|
console.log(`✗ pr-size gate: ${reason}`);
|
|
171
178
|
process.exitCode = 1;
|
|
172
179
|
return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
|
|
173
180
|
} else {
|
|
174
|
-
console.log(`⚠ pr-size: ${added} lines added (guideline ~${sizeWarn}, source: ${sizeSource}) — an oversized warning is not an opinion: partition, or ask your user (method_gates.pr_size: block to harden)`);
|
|
181
|
+
console.log(`⚠ pr-size: ${added} lines added${split} (guideline ~${sizeWarn}, source: ${sizeSource}) — an oversized warning is not an opinion: partition, or ask your user (method_gates.pr_size: block to harden)`);
|
|
175
182
|
}
|
|
176
183
|
}
|
|
177
184
|
}
|
|
@@ -213,7 +220,12 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
213
220
|
}
|
|
214
221
|
}
|
|
215
222
|
|
|
216
|
-
|
|
223
|
+
// KJC-TSK-0795 AC1: hand the gate the numbers so a delete-only diff is
|
|
224
|
+
// exempt — deleting code adds no behavior to test.
|
|
225
|
+
const numstat = (await rawDiff(flags.range, ["--numstat"])).split("\n").map((l) => l.trim()).filter(Boolean)
|
|
226
|
+
.map((l) => { const [a, r, ...f] = l.split(/\s+/); return { file: f.join(" "), added: a === "-" ? 1 : Number(a) || 0, removed: r === "-" ? 0 : Number(r) || 0 }; });
|
|
227
|
+
const tests = checkTestsWithCode({ config, stagedFiles: changedFiles, numstat });
|
|
228
|
+
if (tests.mode === "delete-only") console.log(`⚠ tests-with-code: exempt — ${tests.reason}`);
|
|
217
229
|
if (tests.mode === "warn") console.log(`⚠ tests-with-code: ${tests.reason}`);
|
|
218
230
|
if (tests.mode === "exempt") console.log(`⚠ tests-with-code exempt: ${tests.reason}`);
|
|
219
231
|
if (!tests.ok) {
|
|
@@ -303,15 +315,26 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
303
315
|
// the cross-AI reviewer weighs them. Unavailable sonar degrades loudly.
|
|
304
316
|
let task = flags.task;
|
|
305
317
|
if (flags.sonar !== false) {
|
|
306
|
-
|
|
318
|
+
// KJC-TSK-0795 AC3: only issues on lines this diff ADDS may veto.
|
|
319
|
+
const touchedLines = addedLinesByFile(await rawDiff(flags.range, ["--unified=0"]));
|
|
320
|
+
const pre = await runSonarPregate({ config, stagedFiles: changedFiles, touchedLines, logger });
|
|
307
321
|
if (!pre.available) {
|
|
308
|
-
|
|
322
|
+
// KJC-BUG-0156 (issue #1543): a scan that FAILED must not read like one
|
|
323
|
+
// more config warning — it hid a dead quality gate for 8 straight PRs.
|
|
324
|
+
// Still fail-open (a laptop without Docker still commits), but SAID.
|
|
325
|
+
const chosen = /disabled in config/.test(pre.reason || "");
|
|
326
|
+
console.log(chosen
|
|
327
|
+
? `⚠ sonar pre-gate skipped: ${pre.reason}`
|
|
328
|
+
: `✗ sonar pre-gate UNAVAILABLE — the quality gate did NOT run on this diff: ${pre.reason}`);
|
|
309
329
|
} else {
|
|
310
330
|
const found = [...pre.blocking, ...pre.advisory];
|
|
311
331
|
if (found.length > 0) {
|
|
312
|
-
console.log(`Sonar on the changed
|
|
332
|
+
console.log(`Sonar on the changed lines — ${pre.blocking.length} blocking, ${pre.advisory.length} advisory (project total: ${pre.totalProject}):`);
|
|
313
333
|
for (const f of found) console.log(` - ${formatSonarFinding(f)}`);
|
|
314
334
|
}
|
|
335
|
+
if ((pre.preexisting || []).length > 0) {
|
|
336
|
+
console.log(`⚠ sonar trend: ${pre.preexisting.length} preexisting issue(s) on untouched lines of these files — not this PR's veto, but the debt is real`);
|
|
337
|
+
}
|
|
315
338
|
if (pre.blocking.length > 0) {
|
|
316
339
|
console.log("✗ REJECTED by sonar (deterministic) — fix the blocking findings; the cross-AI reviewer was not invoked.");
|
|
317
340
|
process.exitCode = 1;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj steward sweep` (STW-B, KJC-TSK-0790, epic KJC-PCS-0081) — a READ-ONLY
|
|
3
|
+
* pass over the Steward invariants that leaves the verdict IN THE REPO:
|
|
4
|
+
* `.karajan/steward/report.md` (a person: verdict, last evidence, and the
|
|
5
|
+
* command that renews each invariant) and `report.json` (a machine — and the
|
|
6
|
+
* BASELINE the next sweep compares against: the versioned report is the
|
|
7
|
+
* shared memory, never a record on one machine). Exit 1 only when something
|
|
8
|
+
* is BROKEN — unknown and not-observable inform with their remedy.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
VERDICTS, resolveFreshness, evaluateMainCi, evaluateSecurityAudit,
|
|
15
|
+
evaluateVulnAging, evaluateDeadCodeTrend, evaluateCoverageConfig,
|
|
16
|
+
evaluatePhantomCoverage, runInvariants,
|
|
17
|
+
} from "../steward/invariants.js";
|
|
18
|
+
import { loadPreviousAudit } from "../audit/basal-cost.js";
|
|
19
|
+
import { collectOsvFindings } from "../audit/osv-findings.js";
|
|
20
|
+
import { recordGateDecision } from "../policy/decisions.js";
|
|
21
|
+
import { syncProposedWork } from "../steward/proposed-work.js";
|
|
22
|
+
|
|
23
|
+
const stewardDir = (projectDir) => path.join(projectDir, ".karajan", "steward");
|
|
24
|
+
|
|
25
|
+
/** `gh run list` for push runs on the base branch — the sweep's default probe. */
|
|
26
|
+
const ghRuns = (projectDir, baseBranch) => () => {
|
|
27
|
+
const out = execFileSync("gh", ["run", "list", "--branch", baseBranch, "--event", "push", "--limit", "30", "--json", "name,conclusion,createdAt"], { cwd: projectDir, encoding: "utf8", timeout: 30_000 });
|
|
28
|
+
return JSON.parse(out).map((r) => ({ workflow: r.name, conclusion: r.conclusion, createdAt: r.createdAt }));
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } };
|
|
32
|
+
|
|
33
|
+
export async function stewardSweepCommand({ flags = {}, config = {}, logger = console, probes = {} } = {}) {
|
|
34
|
+
const projectDir = config.projectDir || process.cwd();
|
|
35
|
+
const baseBranch = config.base_branch || "main";
|
|
36
|
+
// STW-E (KJC-TSK-0793): --if-stale <days> — resuming work with a fresh
|
|
37
|
+
// report does not re-sweep; the report in the repo stays the ONE source.
|
|
38
|
+
if (flags.ifStale !== undefined) {
|
|
39
|
+
const prev = readJson(path.join(stewardDir(projectDir), "report.json"));
|
|
40
|
+
const ageMs = prev?.sweptAt ? (probes.nowMs ?? Date.now()) - Date.parse(prev.sweptAt) : Infinity;
|
|
41
|
+
if (ageMs < Number(flags.ifStale) * 86_400_000) {
|
|
42
|
+
logger.info?.(`steward: report is fresh (swept ${prev.sweptAt}) — not re-sweeping`);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const freshness = resolveFreshness(config);
|
|
47
|
+
const nowMs = probes.nowMs ?? Date.now();
|
|
48
|
+
const runsFn = probes.runsFn ?? ghRuns(projectDir, baseBranch);
|
|
49
|
+
|
|
50
|
+
// Live osv probe (best-effort): unavailable is null → the invariant answers
|
|
51
|
+
// unknown with its remedy, never a clean bill.
|
|
52
|
+
let vulns = probes.vulns ?? null;
|
|
53
|
+
if (vulns === null) {
|
|
54
|
+
try {
|
|
55
|
+
const osv = await (probes.osvFn ?? collectOsvFindings)(projectDir, logger);
|
|
56
|
+
if (osv?.available) vulns = (osv.vulnerabilities || []).map((v) => ({ id: v.id, severity: v.severity, publishedAt: v.publishedAt ?? null }));
|
|
57
|
+
} catch { /* stays null — unknown */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Dead-code baseline: the PREVIOUS report in the repo — shared memory.
|
|
61
|
+
const prevReport = readJson(path.join(stewardDir(projectDir), "report.json"));
|
|
62
|
+
const snapshot = await loadPreviousAudit(projectDir).catch(() => null);
|
|
63
|
+
const deadNow = typeof snapshot?.knipDeadExports?.exports === "number"
|
|
64
|
+
? { deadExports: snapshot.knipDeadExports.exports + (snapshot.knipDeadExports.files || 0) }
|
|
65
|
+
: Array.isArray(snapshot?.deadExports) ? { deadExports: snapshot.deadExports.length } : null;
|
|
66
|
+
|
|
67
|
+
const invariants = [
|
|
68
|
+
{ id: "main-ci", renew: "push to the base branch (or fix the red run)", evaluate: () => evaluateMainCi({ projectDir, baseBranch, freshness: freshness.values, runsFn, nowMs }) },
|
|
69
|
+
{ id: "security-audit", renew: "kj audit --security", evaluate: () => evaluateSecurityAudit({ projectDir, freshness: freshness.values, nowMs }) },
|
|
70
|
+
{ id: "vulnerable-deps", renew: "kj audit (osv-scanner)", evaluate: () => evaluateVulnAging({ vulns, freshness: freshness.values, nowMs }) },
|
|
71
|
+
{ id: "dead-code-trend", renew: "kj audit (knip inventory)", evaluate: () => evaluateDeadCodeTrend({ current: deadNow, previous: prevReport?.deadCodeBaseline ?? null }) },
|
|
72
|
+
{ id: "coverage-config", renew: "configure a coverage threshold", evaluate: () => evaluateCoverageConfig({ projectDir }) },
|
|
73
|
+
{ id: "phantom-coverage", renew: "KJC-TSK-0800 ships the detectors", evaluate: () => evaluatePhantomCoverage() },
|
|
74
|
+
];
|
|
75
|
+
const results = runInvariants(invariants, {}).map((r, i) => ({ ...r, renew: invariants[i].renew }));
|
|
76
|
+
const broken = results.filter((r) => r.verdict === VERDICTS.BROKEN);
|
|
77
|
+
|
|
78
|
+
const report = {
|
|
79
|
+
sweptAt: new Date(nowMs).toISOString(),
|
|
80
|
+
baseBranch,
|
|
81
|
+
freshness: { ...freshness.values, declared: freshness.declared },
|
|
82
|
+
invariants: results,
|
|
83
|
+
deadCodeBaseline: deadNow ? { ...deadNow, timestamp: new Date(nowMs).toISOString() } : (prevReport?.deadCodeBaseline ?? null),
|
|
84
|
+
};
|
|
85
|
+
const mark = { [VERDICTS.OK]: "✓", [VERDICTS.BROKEN]: "✗", [VERDICTS.UNKNOWN]: "?", [VERDICTS.NOT_OBSERVABLE]: "∅" };
|
|
86
|
+
const md = [
|
|
87
|
+
"# Steward report",
|
|
88
|
+
"", `Last swept: ${report.sweptAt}`, "",
|
|
89
|
+
`Freshness: ${freshness.declared ? "declared by the project" : "calibrated defaults (the project declared none)"} — ${Object.entries(freshness.values).map(([k, v]) => `${k}=${v}`).join(", ")}`, "",
|
|
90
|
+
...results.map((r) => [
|
|
91
|
+
`## ${mark[r.verdict] || "?"} ${r.id} — ${r.verdict}`,
|
|
92
|
+
`- evidence: ${r.evidence || "(none)"}`,
|
|
93
|
+
...(r.remedy ? [`- remedy: ${r.remedy}`] : []),
|
|
94
|
+
`- renews with: ${r.renew}`, "",
|
|
95
|
+
]).flat(),
|
|
96
|
+
].join("\n");
|
|
97
|
+
|
|
98
|
+
fs.mkdirSync(stewardDir(projectDir), { recursive: true });
|
|
99
|
+
fs.writeFileSync(path.join(stewardDir(projectDir), "report.md"), md);
|
|
100
|
+
fs.writeFileSync(path.join(stewardDir(projectDir), "report.json"), `${JSON.stringify(report, null, 2)}\n`);
|
|
101
|
+
|
|
102
|
+
// Every sweep is SEALED in the decision chain — the same criterion policy
|
|
103
|
+
// decisions already meet: recorded and verifiable, not just notified.
|
|
104
|
+
try {
|
|
105
|
+
const counts = {};
|
|
106
|
+
for (const r of results) counts[r.verdict] = (counts[r.verdict] || 0) + 1;
|
|
107
|
+
recordGateDecision(projectDir, { kind: "steward-sweep", verdicts: { ok: counts[VERDICTS.OK] || 0, broken: broken.length, unknown: counts[VERDICTS.UNKNOWN] || 0, "not-observable": counts[VERDICTS.NOT_OBSERVABLE] || 0 }, broken_ids: broken.map((b) => b.id) });
|
|
108
|
+
} catch (err) { logger.warn?.(`⚠ steward: the sweep could not be sealed in the decision chain (${err.message})`); }
|
|
109
|
+
|
|
110
|
+
// STW-D (KJC-TSK-0792): every break is PROPOSED work on the board the brain
|
|
111
|
+
// already consumes. AFTER the report and the seal: a board failure never
|
|
112
|
+
// costs either, and it is said loudly — never swallowed.
|
|
113
|
+
try {
|
|
114
|
+
const sync = await syncProposedWork({ projectDir, config, results, sweptAt: report.sweptAt });
|
|
115
|
+
if (!sync.synced) logger.warn?.(`⚠ steward: broken invariants not carded — ${sync.reason}`);
|
|
116
|
+
else if (sync.created || sync.updated || sync.resolved) logger.info?.(`steward board: ${sync.created} card(s) proposed, ${sync.updated} updated, ${sync.resolved} resolved`);
|
|
117
|
+
} catch (err) { logger.warn?.(`⚠ steward: carding the broken invariants failed (${err.message}) — the report and the seal stand`); }
|
|
118
|
+
|
|
119
|
+
// A report nobody can see is not shared state — say it, do not fix their tree.
|
|
120
|
+
try {
|
|
121
|
+
execFileSync("git", ["check-ignore", "-q", path.join(".karajan", "steward", "report.md")], { cwd: projectDir });
|
|
122
|
+
logger.warn?.("⚠ steward: the report path is gitignored — untrack .karajan/steward from .gitignore so the state is shared");
|
|
123
|
+
} catch { /* not ignored — good */ }
|
|
124
|
+
|
|
125
|
+
if (flags.json) {
|
|
126
|
+
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
127
|
+
} else {
|
|
128
|
+
for (const r of results) logger.info?.(`${mark[r.verdict]} ${r.id}: ${r.verdict}${r.verdict === VERDICTS.OK ? "" : ` — ${r.remedy || r.evidence}`}`);
|
|
129
|
+
logger.info?.(broken.length ? `steward: ${broken.length} invariant(s) BROKEN — the report names them (.karajan/steward/report.md)` : "steward: nothing broken — the full state is in .karajan/steward/report.md");
|
|
130
|
+
}
|
|
131
|
+
process.exitCode = broken.length ? 1 : 0;
|
|
132
|
+
return broken.length ? 1 : 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* STW-E — the on-resume mode: when work resumes there is someone in front to
|
|
137
|
+
* review the report, which is the requirement. Only for projects that ADOPTED
|
|
138
|
+
* the Steward (a report exists, or method_gates.steward is declared): the
|
|
139
|
+
* default imposes nothing. Best-effort: a failed sweep never stops a resume.
|
|
140
|
+
*/
|
|
141
|
+
export async function sweepOnResume({ config = {}, logger = console, sweepFn = stewardSweepCommand } = {}) {
|
|
142
|
+
const projectDir = config.projectDir || process.cwd();
|
|
143
|
+
const adopted = fs.existsSync(path.join(stewardDir(projectDir), "report.json")) || Boolean(config.method_gates?.steward);
|
|
144
|
+
if (!adopted) return;
|
|
145
|
+
try { await sweepFn({ flags: { ifStale: 1 }, config, logger }); } catch (err) { logger.warn?.(`⚠ steward: on-resume sweep failed (${err.message}) — resuming anyway`); }
|
|
146
|
+
}
|
package/src/config/defaults.js
CHANGED
|
@@ -80,7 +80,12 @@ const DEFAULTS = {
|
|
|
80
80
|
tests_with_code: "warn",
|
|
81
81
|
// MG-C: informative nudge when the staged diff exceeds this many
|
|
82
82
|
// added lines (0 disables). The project's CI owns any hard budget.
|
|
83
|
-
pr_size_warn: 150
|
|
83
|
+
pr_size_warn: 150,
|
|
84
|
+
// CLM-B (claims-with-evidence ADR): the Stop gate crosses the hard data of
|
|
85
|
+
// the turn's final message against that turn's tool outputs. "off" (adoption
|
|
86
|
+
// is explicit) | "warn" (report only) | "block" (a datum DENIED by its own
|
|
87
|
+
// source refuses to end the turn; unbacked data is still only reported).
|
|
88
|
+
claims: "off"
|
|
84
89
|
},
|
|
85
90
|
review_rules: "./.karajan/review-rules.md",
|
|
86
91
|
coder_rules: "./.karajan/coder-rules.md",
|
|
@@ -75,9 +75,24 @@ export const violations = (s, branch) => {
|
|
|
75
75
|
if (!s || !(s.edited_sources || []).length) return v;
|
|
76
76
|
if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
|
|
77
77
|
else if (branch && !CARD.test(branch)) v.push("La rama '" + branch + "' no referencia ninguna card — usa feat/<CARD-ID>-descripcion (y una card VIVA en el board)");
|
|
78
|
-
if (!(s.edited_tests || []).length) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
|
|
78
|
+
if (!(s.edited_tests || []).length && sessionAddsCode(s)) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
|
|
79
79
|
return v;
|
|
80
80
|
};
|
|
81
|
+
// KJC-TSK-0795 AC1: deleting code adds no behavior to test — a session whose
|
|
82
|
+
// source edits only REMOVED lines owes no test. Unreadable git = conservative
|
|
83
|
+
// (the violation stands): a gate must never stand down on a shrug.
|
|
84
|
+
const sessionAddsCode = (s) => {
|
|
85
|
+
const files = s.edited_sources || [];
|
|
86
|
+
// _git builds a shell string: any path outside a strict charset stays out of
|
|
87
|
+
// it — and the gate stays ARMED for it (conservative, and injection-proof).
|
|
88
|
+
if (files.some((f) => !/^[A-Za-z0-9._/-]+$/.test(f))) return true;
|
|
89
|
+
const stat = _git("diff HEAD --numstat -- " + files.join(" "), ROOT);
|
|
90
|
+
if (!stat) return true;
|
|
91
|
+
return stat.split(String.fromCharCode(10)).some((l) => {
|
|
92
|
+
const a = l.trim().replaceAll(String.fromCharCode(9), " ").split(" ").filter(Boolean)[0];
|
|
93
|
+
return a === "-" || Number(a) > 0;
|
|
94
|
+
});
|
|
95
|
+
};
|
|
81
96
|
export const recordEscape = (sid, escape, tool) => {
|
|
82
97
|
const state = load();
|
|
83
98
|
const s = session(state, sid);
|
|
@@ -136,7 +151,21 @@ process.stdin.on("end", () => {
|
|
|
136
151
|
// the first real update_card did not clear its pending entry).
|
|
137
152
|
const cid = /\\\\?"cardId\\\\?"\\s*:\\s*\\\\?"([A-Za-z0-9-]+)\\\\?"/.exec(text);
|
|
138
153
|
const st = /\\\\?"status\\\\?"\\s*:\\s*\\\\?"([^"\\\\]+)\\\\?"/.exec(text);
|
|
139
|
-
if (cid && st && CLOSING.includes(st[1].toLowerCase()))
|
|
154
|
+
if (cid && st && CLOSING.includes(st[1].toLowerCase())) {
|
|
155
|
+
clearPending(cid[1].toUpperCase());
|
|
156
|
+
// KJC-BUG-0157: a pending whose card-id NEVER existed (branch named
|
|
157
|
+
// before the tracker confirmed the create) could not be cleared by any
|
|
158
|
+
// verified route. The pending's TRUE identity is its PR: a CONFIRMED
|
|
159
|
+
// closing update whose request names that PR clears it too — still a
|
|
160
|
+
// real tracker call seen by this hook, never a promise in prose.
|
|
161
|
+
const prIn = Number(input?.updates?.pipelineStatus?.prCreated?.prNumber);
|
|
162
|
+
if (Number.isFinite(prIn) && prIn > 0) {
|
|
163
|
+
const state = load();
|
|
164
|
+
const s = session(state, sid);
|
|
165
|
+
s.pending_moves = (s.pending_moves || []).filter((p) => Number(p.pr) !== prIn);
|
|
166
|
+
save(state);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
140
169
|
process.exit(0);
|
|
141
170
|
}
|
|
142
171
|
if (tool === "Bash") {
|
|
@@ -230,7 +259,7 @@ process.stdin.on("data", (d) => { raw += d; });
|
|
|
230
259
|
process.stdin.on("end", () => {
|
|
231
260
|
try {
|
|
232
261
|
if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
|
|
233
|
-
const { session_id: sid = "default" } = JSON.parse(raw);
|
|
262
|
+
const { session_id: sid = "default", transcript_path: transcript = null } = JSON.parse(raw);
|
|
234
263
|
const state = load();
|
|
235
264
|
// Tamper check runs regardless of session state: shell indirection leaves
|
|
236
265
|
// no edit-tool record, but it cannot survive \`kj sentinel verify\`, whose
|
|
@@ -252,12 +281,31 @@ process.stdin.on("end", () => {
|
|
|
252
281
|
process.exit(2);
|
|
253
282
|
}
|
|
254
283
|
state.tamper_blocks = 0;
|
|
284
|
+
// CLM-B (KJC-TSK-0802, claims-with-evidence ADR): the hard data of the turn's
|
|
285
|
+
// final message, crossed against that turn's outputs. It runs even without a
|
|
286
|
+
// tracked session — a turn with no edits still ends with a final message. The
|
|
287
|
+
// hook carries no policy: kj reads method_gates.claims and answers 0 (off,
|
|
288
|
+
// clean, or warn) or 2 (a datum DENIED by its own source, in block mode). A
|
|
289
|
+
// missing kj or an errored spawn fails open.
|
|
290
|
+
let claimsNote = "";
|
|
291
|
+
let claimsBlock = null;
|
|
292
|
+
if (transcript) {
|
|
293
|
+
const c = spawnSync("kj", ["claims", "gate", "--transcript", transcript], { cwd: ROOT, encoding: "utf8" });
|
|
294
|
+
if (!c.error && c.status === 2) claimsBlock = "claims: un dato del mensaje final esta DESMENTIDO por las salidas de este turno — verificalo o marcalo como no comprobado. Detalle: kj claims check --transcript " + transcript;
|
|
295
|
+
else if (!c.error && c.status === 0 && (c.stderr || "").trim()) claimsNote = "kj claims: " + c.stderr.trim().split(String.fromCharCode(10))[0] + " — datos sin respaldo en este turno (informa, no bloquea).";
|
|
296
|
+
}
|
|
255
297
|
const s = state.sessions?.[sid];
|
|
256
|
-
if (!s)
|
|
298
|
+
if (!s) {
|
|
299
|
+
if (claimsBlock) { console.error("kj sentinel: el turno NO puede terminar:" + String.fromCharCode(10) + "- " + claimsBlock); process.exit(2); }
|
|
300
|
+
if (claimsNote) console.log(JSON.stringify({ systemMessage: claimsNote }));
|
|
301
|
+
process.exit(0);
|
|
302
|
+
}
|
|
257
303
|
const v = violations(s, branchOf());
|
|
304
|
+
if (claimsBlock) v.push(claimsBlock);
|
|
258
305
|
if (!v.length) {
|
|
259
306
|
s.blocks = 0;
|
|
260
307
|
const notes = [];
|
|
308
|
+
if (claimsNote) notes.push(claimsNote);
|
|
261
309
|
if ((s.escapes || []).length) notes.push("kj sentinel: esta sesion uso " + s.escapes.length + " escape(s): " + s.escapes.join(", ") + " — decision registrada; detalle en kj sentinel status.");
|
|
262
310
|
if ((s.closed_cards || []).length) {
|
|
263
311
|
notes.push("kj sentinel: card(s) cerrada(s) en este turno: " + s.closed_cards.join(", ") + " — el board las muestra: " + boardHint());
|
|
@@ -292,7 +340,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
|
|
292
340
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
293
341
|
import { spawnSync } from "node:child_process";
|
|
294
342
|
import { homedir } from "node:os";
|
|
295
|
-
import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
343
|
+
import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
296
344
|
const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
|
297
345
|
const PUBLISH = /\\bnpm\\s+publish\\b|\\bfirebase\\s+deploy\\b|\\bgh\\s+release\\s+create\\b/;
|
|
298
346
|
const PUSH = /\\bgit\\s+push\\b/;
|
|
@@ -301,7 +349,7 @@ let raw = "";
|
|
|
301
349
|
process.stdin.on("data", (d) => { raw += d; });
|
|
302
350
|
process.stdin.on("end", () => {
|
|
303
351
|
try {
|
|
304
|
-
const { session_id: sid = "default", tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
|
|
352
|
+
const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, transcript_path: transcript = null } = JSON.parse(raw);
|
|
305
353
|
// Self-protection (KJC-TSK-0715) rules run BEFORE any escape, including
|
|
306
354
|
// KJ_SENTINEL_OFF: the sentinel is not dismantled from inside a session —
|
|
307
355
|
// only the human, editing outside it.
|
|
@@ -681,6 +729,37 @@ process.stdin.on("end", () => {
|
|
|
681
729
|
}
|
|
682
730
|
}
|
|
683
731
|
if (EDIT_TOOLS.includes(tool)) {
|
|
732
|
+
// STW-C (KJC-TSK-0791): the sweep's verdict has consequence where work
|
|
733
|
+
// STARTS. Inform ALWAYS, once per session and impossible to miss; BLOCK
|
|
734
|
+
// almost never — only security and persistent red main, and only when
|
|
735
|
+
// the project adopted it (method_gates.steward: block). Unknown is never
|
|
736
|
+
// treated as broken: nothing is asserted that is not known. No report =
|
|
737
|
+
// the project does not use the Steward yet — silence, adoption is explicit.
|
|
738
|
+
try {
|
|
739
|
+
const rep = JSON.parse(readFileSync(join(ROOT, ".karajan", "steward", "report.json"), "utf8"));
|
|
740
|
+
const bad = (rep.invariants || []).filter((r) => r.verdict === "broken");
|
|
741
|
+
const stale = (rep.invariants || []).filter((r) => r.verdict === "unknown");
|
|
742
|
+
if (bad.length || stale.length) {
|
|
743
|
+
const hard = bad.filter((r) => r.id === "vulnerable-deps" || r.id === "main-ci");
|
|
744
|
+
let blockOn = false;
|
|
745
|
+
try { blockOn = /steward:\\s*["']?block/.test(readFileSync(join(ROOT, ".karajan", "kj.config.yml"), "utf8")); } catch { /* no config — no block */ }
|
|
746
|
+
if (hard.length && blockOn) {
|
|
747
|
+
if (escOn("KJ_ALLOW_STEWARD")) { recordEscape(sid, "KJ_ALLOW_STEWARD", tool); }
|
|
748
|
+
else {
|
|
749
|
+
console.error("kj sentinel: steward — el estado del proyecto bloquea empezar (barrido " + rep.sweptAt + "):" + String.fromCharCode(10) + hard.map((r) => "- " + r.id + ": " + (r.evidence || "") + " -> " + (r.remedy || r.renew || "kj steward sweep")).join(String.fromCharCode(10)) + String.fromCharCode(10) + "(KJ_ALLOW_STEWARD=1 = excepcion consciente de esta sesion, queda registrada)");
|
|
750
|
+
process.exit(2);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
const st = load();
|
|
754
|
+
const s = session(st, sid);
|
|
755
|
+
if (!s.steward_informed) {
|
|
756
|
+
s.steward_informed = true;
|
|
757
|
+
save(st);
|
|
758
|
+
const lines = [...bad.map((r) => "ROTO " + r.id + ": " + (r.remedy || r.evidence || "")), ...stale.map((r) => "desconocido " + r.id + " (no se afirma lo que no se sabe) — refresca: " + (r.renew || "kj steward sweep"))];
|
|
759
|
+
console.log(JSON.stringify({ systemMessage: "kj steward (barrido " + rep.sweptAt + "): " + lines.join(" | ") + " — detalle: .karajan/steward/report.md" }));
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
} catch { /* no report or unreadable — the Steward is not adopted here */ }
|
|
684
763
|
const file = input.file_path || input.notebook_path;
|
|
685
764
|
const rel = file ? relative(ROOT, String(file)).replaceAll("\\\\", "/") : "";
|
|
686
765
|
if (rel && CODE.test(rel) && !TESTS.test(rel)) {
|
|
@@ -714,6 +793,19 @@ process.stdin.on("end", () => {
|
|
|
714
793
|
}
|
|
715
794
|
// (the pending entry itself is recorded by the PostToolUse hook, only once
|
|
716
795
|
// gh confirms "merged pull request #N" — a failed merge never blocks)
|
|
796
|
+
// CLM-C (KJC-TSK-0803): a PR body outlives the turn — an invented figure
|
|
797
|
+
// there misleads every future reader. When the command writes one from a
|
|
798
|
+
// file, kj crosses ITS data against this turn's outputs; the hook carries
|
|
799
|
+
// no policy (kj reads method_gates.claims; off = this check does not exist).
|
|
800
|
+
const bodyFile = /\\bgh\\s+pr\\s+(?:create|edit)\\b/.test(cmd) ? /--body-file[= ]+("([^"]+)"|'([^']+)'|([^\\s"']+))/.exec(cmd) : null;
|
|
801
|
+
if (bodyFile && transcript) {
|
|
802
|
+
const body = bodyFile[2] || bodyFile[3] || bodyFile[4];
|
|
803
|
+
const g = spawnSync("kj", ["claims", "gate", "--transcript", transcript, "--file", body], { cwd: ROOT, encoding: "utf8" });
|
|
804
|
+
if (!g.error && g.status === 2) {
|
|
805
|
+
console.error("kj sentinel: claims — un dato del cuerpo de la PR esta DESMENTIDO por las salidas de este turno:\\n" + (g.stderr || "").trim() + "\\nVerificalo o marcalo como no comprobado antes de crear la PR. Detalle: kj claims check --transcript " + transcript + " --file " + body);
|
|
806
|
+
process.exit(2);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
717
809
|
if (PUBLISH.test(cmd)) {
|
|
718
810
|
if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
|
|
719
811
|
const res = spawnSync("kj", ["release", "check", "--json"], { cwd: ROOT, encoding: "utf8" });
|
|
@@ -12,8 +12,9 @@ import { join } from "node:path";
|
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { dirname } from "node:path";
|
|
14
14
|
|
|
15
|
+
import { parse as parseYaml } from "yaml";
|
|
15
16
|
import { upsertManagedBlock } from "../utils/managed-markers.js";
|
|
16
|
-
import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, policyWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
|
|
17
|
+
import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, policyWorkflowFor, stewardWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
|
|
17
18
|
|
|
18
19
|
// La versión del kj que corre harden — es la que se pinea en el fallback
|
|
19
20
|
// npx del workflow de policy (jamás @latest en CI). Lectura LAZY: en el
|
|
@@ -85,7 +86,12 @@ export function installWorkflows({
|
|
|
85
86
|
const policy = existsSync(join(projectDir, ".karajan", "policy.yml"))
|
|
86
87
|
? { file: "kj-policy.yml", blockId: "wf-policy", body: policyWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") }
|
|
87
88
|
: null;
|
|
88
|
-
|
|
89
|
+
// STW-E (KJC-TSK-0793): the scheduled sweep is OPT-IN — the project says
|
|
90
|
+
// steward.action: true in its config; nothing is imposed by default.
|
|
91
|
+
let stewardOn = false;
|
|
92
|
+
try { stewardOn = parseYaml(readFileSync(join(projectDir, ".karajan", "kj.config.yml"), "utf8"))?.steward?.action === true; } catch { /* no config — no action */ }
|
|
93
|
+
const steward = stewardOn ? { file: "kj-steward.yml", blockId: "wf-steward", body: stewardWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") } : null;
|
|
94
|
+
const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : []), ...(policy ? [policy] : []), ...(steward ? [steward] : [])];
|
|
89
95
|
const results = [];
|
|
90
96
|
for (const wf of all) {
|
|
91
97
|
const target = join(dir, wf.file);
|
|
@@ -186,7 +186,10 @@ export const SHRINK_BUDGET_WORKFLOW = [
|
|
|
186
186
|
" env:",
|
|
187
187
|
" BASE_REF: ${{ github.base_ref }}",
|
|
188
188
|
" run: |",
|
|
189
|
-
|
|
189
|
+
// KJC-TSK-0795 AC4 — measured in GREBLA: a 414-line warning that was almost
|
|
190
|
+
// all pnpm-lock.yaml. Generated files are weight nobody wrote: the generated
|
|
191
|
+
// gate applies the same exclusions kj's own repo applies.
|
|
192
|
+
" d=$(git diff --numstat \"origin/${BASE_REF}...HEAD\" -- . ':!**/*.md' ':!*.lock' ':!package-lock.json' ':!pnpm-lock.yaml' ':!npm-shrinkwrap.json' ':!dist/**' ':!build/**' ':!coverage/**' ':!**/*.snap' ':!**/__snapshots__/**' || true)",
|
|
190
193
|
' a=$(printf "%s\\n" "$d" | awk \'$1!="-"{s+=$1}END{print s+0}\')',
|
|
191
194
|
' r=$(printf "%s\\n" "$d" | awk \'$2!="-"{s+=$2}END{print s+0}\')',
|
|
192
195
|
' net=$((a - r)); echo "net=$net (limit=$LOC_LIMIT)"',
|
|
@@ -272,6 +275,40 @@ export function mutationWorkflowFor(language, pm = "npm") {
|
|
|
272
275
|
return { file: "kj-mutation.yml", blockId: "wf-mutation", body };
|
|
273
276
|
}
|
|
274
277
|
|
|
278
|
+
// STW-E (KJC-TSK-0793): the scheduled Steward sweep — OPT-IN via
|
|
279
|
+
// steward.action: true. Decay happens precisely on the days nobody looks, so
|
|
280
|
+
// the watcher cannot live inside a session: the Action runs where the repo
|
|
281
|
+
// lives, publishes the SAME report file every other mode writes (one source
|
|
282
|
+
// of truth), and a broken invariant never stops the report from publishing.
|
|
283
|
+
export function stewardWorkflowFor(kjVersion, pkgName = "karajan-code") {
|
|
284
|
+
return [
|
|
285
|
+
"name: Steward sweep",
|
|
286
|
+
"on:",
|
|
287
|
+
" schedule:",
|
|
288
|
+
' - cron: "17 6 * * 1"',
|
|
289
|
+
" workflow_dispatch: {}",
|
|
290
|
+
"permissions:",
|
|
291
|
+
" contents: write",
|
|
292
|
+
"jobs:",
|
|
293
|
+
" sweep:",
|
|
294
|
+
" runs-on: ubuntu-latest",
|
|
295
|
+
" steps:",
|
|
296
|
+
` - uses: ${PINNED_ACTIONS.checkout}`,
|
|
297
|
+
` - uses: ${PINNED_ACTIONS.setupNode}`,
|
|
298
|
+
" with:",
|
|
299
|
+
' node-version: "22"',
|
|
300
|
+
` - run: npm install -g ${pkgName}@${kjVersion}`,
|
|
301
|
+
" - name: Sweep (a broken invariant must not stop the report)",
|
|
302
|
+
" env:",
|
|
303
|
+
" GH_TOKEN: ${{ github.token }}",
|
|
304
|
+
" run: kj steward sweep || true",
|
|
305
|
+
" - name: Publish the report and the proposed cards",
|
|
306
|
+
" run: |",
|
|
307
|
+
" git add .karajan/steward .karajan/plans 2>/dev/null || true",
|
|
308
|
+
' git diff --cached --quiet || { git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" commit -m "chore(steward): sweep report [skip ci]" && git push; }',
|
|
309
|
+
].join("\n");
|
|
310
|
+
}
|
|
311
|
+
|
|
275
312
|
/** Conditional workflows: shrink-budget on strict, pack-smoke when publishable. */
|
|
276
313
|
export function extraWorkflowsFor({ profile = "standard", publishable = false, pm = "npm" } = {}) {
|
|
277
314
|
const extras = [];
|
package/src/privacy/scan.js
CHANGED
|
@@ -52,12 +52,25 @@ const SECRET_HEURISTICS = [
|
|
|
52
52
|
{ type: "conn-string", re: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:([^\s@/]+)@/gi },
|
|
53
53
|
];
|
|
54
54
|
|
|
55
|
+
// KJC-TSK-0797 (epic KJC-PCS-0082) — context BEFORE generics, measured twice:
|
|
56
|
+
// GREBLA's pinned Actions reported as phone numbers, and this repo's own
|
|
57
|
+
// workflow pinning flagged twelve times in one PR. A git object id is not a
|
|
58
|
+
// phone; an email on an RFC 2606 documentation domain is not a person. Both
|
|
59
|
+
// are blanked before redactPII and COUNTED, so a clean result can say
|
|
60
|
+
// "nothing found" or "found and discarded by context" — never the same thing.
|
|
61
|
+
// The personal denylist runs before this: the user's own datum always blocks.
|
|
62
|
+
const CONTEXT_DISCARDS = [
|
|
63
|
+
{ type: "git-sha", re: /\b(?:[0-9a-f]{64}|[0-9a-f]{40})\b/g },
|
|
64
|
+
{ type: "doc-domain-email", re: /\b[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)*(?:example\.(?:com|org|net)|test|invalid|localhost|example)\b/g },
|
|
65
|
+
];
|
|
66
|
+
|
|
55
67
|
/**
|
|
56
68
|
* Scan a text. Returns findings — denylist hits as `severity:"block"`
|
|
57
69
|
* (masked value), generic PII as `severity:"warn"` (line already redacted).
|
|
58
70
|
*/
|
|
59
71
|
export function scanText(text, { list = loadPrivacyList(), source = "<text>" } = {}) {
|
|
60
72
|
const findings = [];
|
|
73
|
+
let discarded = 0;
|
|
61
74
|
String(text).split("\n").forEach((line, i) => {
|
|
62
75
|
let probe = line;
|
|
63
76
|
for (const a of list.allow) probe = probe.split(a).join(" ");
|
|
@@ -86,6 +99,10 @@ export function scanText(text, { list = loadPrivacyList(), source = "<text>" } =
|
|
|
86
99
|
re.lastIndex = m.index + m[0].length;
|
|
87
100
|
}
|
|
88
101
|
}
|
|
102
|
+
for (const { re } of CONTEXT_DISCARDS) {
|
|
103
|
+
re.lastIndex = 0;
|
|
104
|
+
probe = probe.replace(re, (m) => { discarded += 1; return " ".repeat(m.length); });
|
|
105
|
+
}
|
|
89
106
|
const red = redactPII(probe);
|
|
90
107
|
if (red.total > 0) {
|
|
91
108
|
for (const [type, n] of Object.entries(red.counts)) {
|
|
@@ -93,12 +110,14 @@ export function scanText(text, { list = loadPrivacyList(), source = "<text>" } =
|
|
|
93
110
|
}
|
|
94
111
|
}
|
|
95
112
|
});
|
|
113
|
+
findings.discardedByContext = discarded;
|
|
96
114
|
return findings;
|
|
97
115
|
}
|
|
98
116
|
|
|
99
117
|
/** Scan files/dirs recursively (skips node_modules/.git, binaries, big files). */
|
|
100
118
|
export function scanPaths(paths, { list = loadPrivacyList() } = {}) {
|
|
101
119
|
const findings = [];
|
|
120
|
+
let discarded = 0;
|
|
102
121
|
const visit = (p) => {
|
|
103
122
|
if (!existsSync(p)) return;
|
|
104
123
|
// Skip symlinks: an ancestor link recurses forever; an outside link ships nothing.
|
|
@@ -113,8 +132,11 @@ export function scanPaths(paths, { list = loadPrivacyList() } = {}) {
|
|
|
113
132
|
if (BINARY_EXT.has(extname(p).toLowerCase()) || st.size > MAX_FILE_BYTES) return;
|
|
114
133
|
const content = readFileSync(p);
|
|
115
134
|
if (content.includes(0)) return; // binary sniff: NUL byte
|
|
116
|
-
|
|
135
|
+
const fileFindings = scanText(content.toString("utf8"), { list, source: p });
|
|
136
|
+
discarded += fileFindings.discardedByContext || 0;
|
|
137
|
+
findings.push(...fileFindings);
|
|
117
138
|
};
|
|
118
139
|
for (const p of paths) visit(p);
|
|
140
|
+
findings.discardedByContext = discarded;
|
|
119
141
|
return findings;
|
|
120
142
|
}
|
|
@@ -28,13 +28,38 @@ export function formatSonarFinding(issue) {
|
|
|
28
28
|
return `(${issue.severity}) [${issueFile(issue)}${line}] ${issue.rule} — ${issue.message}`;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* KJC-TSK-0795 AC3: the NEW line numbers each file gains in a unified diff
|
|
33
|
+
* (`git diff --unified=0`). Only what the diff ADDS can be the author's fault.
|
|
34
|
+
* @param {string} diffText @returns {Map<string, Set<number>>}
|
|
35
|
+
*/
|
|
36
|
+
export function addedLinesByFile(diffText) {
|
|
37
|
+
const map = new Map();
|
|
38
|
+
let current = null;
|
|
39
|
+
for (const raw of String(diffText || "").split("\n")) {
|
|
40
|
+
if (raw.startsWith("+++ ")) {
|
|
41
|
+
const p = raw.slice(4).trim();
|
|
42
|
+
current = p.startsWith("b/") ? p.slice(2) : p === "/dev/null" ? null : p;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const h = current ? /^@@ [^+]*\+(\d+)(?:,(\d+))? @@/.exec(raw) : null;
|
|
46
|
+
if (!h) continue;
|
|
47
|
+
const start = Number(h[1]);
|
|
48
|
+
const count = h[2] === undefined ? 1 : Number(h[2]);
|
|
49
|
+
if (count === 0) continue;
|
|
50
|
+
const set = map.get(current) ?? map.set(current, new Set()).get(current);
|
|
51
|
+
for (let i = 0; i < count; i++) set.add(start + i);
|
|
52
|
+
}
|
|
53
|
+
return map;
|
|
54
|
+
}
|
|
55
|
+
|
|
31
56
|
/**
|
|
32
57
|
* Scan the project (single-flight via the tool governor) and return the
|
|
33
58
|
* open issues that live on the staged files, split by blocking severity.
|
|
34
59
|
* Every failure path degrades to {available:false, reason} — the pre-gate
|
|
35
60
|
* never breaks the review, it only refuses to stay silent.
|
|
36
61
|
*/
|
|
37
|
-
export async function runSonarPregate({ config, stagedFiles = [], logger = null }) {
|
|
62
|
+
export async function runSonarPregate({ config, stagedFiles = [], touchedLines = null, logger = null }) {
|
|
38
63
|
if (config?.review_gate?.sonar === false) {
|
|
39
64
|
return { available: false, reason: "disabled in config (review_gate.sonar: false)" };
|
|
40
65
|
}
|
|
@@ -42,16 +67,26 @@ export async function runSonarPregate({ config, stagedFiles = [], logger = null
|
|
|
42
67
|
try {
|
|
43
68
|
lock = await acquireToolLock("sonar-scanner", { timeoutMs: 300_000 });
|
|
44
69
|
const scan = await runSonarScan(config);
|
|
70
|
+
if (scan.note) logger?.warn?.(scan.note); // KJC-BUG-0156: precedence is said, never silent
|
|
45
71
|
if (!scan.ok) {
|
|
46
72
|
return { available: false, reason: (scan.stderr || scan.stdout || "sonar scan failed").trim() };
|
|
47
73
|
}
|
|
74
|
+
// The scan above ALWAYS runs before issues are read (single-flight): the
|
|
75
|
+
// verdict is about the code as it is now, never a stale server analysis
|
|
76
|
+
// (KJC-TSK-0795 AC2 — that failure mode has no route here, by design).
|
|
48
77
|
const res = await getOpenIssues(config, scan.projectKey);
|
|
49
78
|
const staged = new Set(stagedFiles);
|
|
50
79
|
const onStaged = (res.issues || []).filter((i) => staged.has(issueFile(i)));
|
|
80
|
+
// KJC-TSK-0795 AC3: with the diff's line map, only issues on lines the PR
|
|
81
|
+
// ADDS can veto — a 3-line PR must not answer for 30 preexisting issues.
|
|
82
|
+
// No line, or an untouched line, is the file's TREND: reported, never a block.
|
|
83
|
+
const isTouched = (i) => !touchedLines || (i.line != null && touchedLines.get(issueFile(i))?.has(Number(i.line)));
|
|
84
|
+
const own = onStaged.filter(isTouched);
|
|
51
85
|
return {
|
|
52
86
|
available: true,
|
|
53
|
-
blocking:
|
|
54
|
-
advisory:
|
|
87
|
+
blocking: own.filter((i) => BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
|
|
88
|
+
advisory: own.filter((i) => !BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
|
|
89
|
+
preexisting: touchedLines ? onStaged.filter((i) => !isTouched(i)) : [],
|
|
55
90
|
totalProject: res.total ?? (res.issues || []).length,
|
|
56
91
|
};
|
|
57
92
|
} catch (err) {
|