@event4u/agent-config 14.0.0 → 14.1.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +53 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_conformance.js +112 -8
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/hooks/dispatch.js +112 -8
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +114 -9
- package/package.json +1 -1
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/scripts/_lib/gate_ledger.ts +5 -1
- package/src/scripts/build_archive_index.ts +62 -3
- package/src/scripts/check_gate_coverage.ts +93 -0
- package/src/scripts/check_pr_ci_current.ts +86 -3
- package/src/scripts/git_authorization_hook.ts +186 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +62 -6
package/dist/mcp/server.mjs
CHANGED
|
@@ -34092,6 +34092,21 @@ var init_language_mirror_hook = __esm({
|
|
|
34092
34092
|
import fs55 from "node:fs";
|
|
34093
34093
|
import path56 from "node:path";
|
|
34094
34094
|
import { fileURLToPath as fileURLToPath31, pathToFileURL as pathToFileURL20 } from "node:url";
|
|
34095
|
+
function sessionSlug(session_id) {
|
|
34096
|
+
return session_id.replace(/[^A-Za-z0-9._-]/gu, "_").slice(0, 80);
|
|
34097
|
+
}
|
|
34098
|
+
function ledgerFileFor(session_id) {
|
|
34099
|
+
if (!session_id) {
|
|
34100
|
+
return STATE_FILE2;
|
|
34101
|
+
}
|
|
34102
|
+
return path56.join(STATE_DIR, `${sessionSlug(session_id)}.json`);
|
|
34103
|
+
}
|
|
34104
|
+
function pendingFileFor(session_id) {
|
|
34105
|
+
if (!session_id) {
|
|
34106
|
+
return PENDING_FILE;
|
|
34107
|
+
}
|
|
34108
|
+
return path56.join(PENDING_DIR, `${sessionSlug(session_id)}.json`);
|
|
34109
|
+
}
|
|
34095
34110
|
function _isObject4(v) {
|
|
34096
34111
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
34097
34112
|
}
|
|
@@ -34110,6 +34125,18 @@ function isInterrogative(prose) {
|
|
|
34110
34125
|
t
|
|
34111
34126
|
);
|
|
34112
34127
|
}
|
|
34128
|
+
function isAffirmative(prose) {
|
|
34129
|
+
const t = prose.trim().replace(/[.!]+$/u, "");
|
|
34130
|
+
if (!t || t.length > 24) {
|
|
34131
|
+
return false;
|
|
34132
|
+
}
|
|
34133
|
+
if (isInterrogative(t)) {
|
|
34134
|
+
return false;
|
|
34135
|
+
}
|
|
34136
|
+
return /^(?:option\s*)?\d{1,2}[.)]?$/iu.test(t) || /^(ja|jo|jep|yes|yep|yeah|ok|okay|okey|klar|passt|gut|los|go|go ahead|mach|mach das|mach es|leg los|do it|proceed|bitte)$/iu.test(
|
|
34137
|
+
t
|
|
34138
|
+
);
|
|
34139
|
+
}
|
|
34113
34140
|
function splitFences(prompt) {
|
|
34114
34141
|
const fences = [];
|
|
34115
34142
|
const prose = prompt.replace(/```[^\n]*\n([\s\S]*?)```/g, (_m, body) => {
|
|
@@ -34148,6 +34175,40 @@ function classifyAuthorization(prompt) {
|
|
|
34148
34175
|
}
|
|
34149
34176
|
return { authorized: [...authorized], evidence };
|
|
34150
34177
|
}
|
|
34178
|
+
function takePending(consumer_root, session_id, now) {
|
|
34179
|
+
const file = path56.join(consumer_root, pendingFileFor(session_id));
|
|
34180
|
+
let raw;
|
|
34181
|
+
try {
|
|
34182
|
+
raw = fs55.readFileSync(file, "utf8");
|
|
34183
|
+
} catch {
|
|
34184
|
+
return null;
|
|
34185
|
+
}
|
|
34186
|
+
try {
|
|
34187
|
+
fs55.rmSync(file, { force: true });
|
|
34188
|
+
} catch {
|
|
34189
|
+
}
|
|
34190
|
+
try {
|
|
34191
|
+
const decoded = JSON.parse(raw);
|
|
34192
|
+
if (!_isObject4(decoded)) {
|
|
34193
|
+
return null;
|
|
34194
|
+
}
|
|
34195
|
+
const op = decoded["op"];
|
|
34196
|
+
if (typeof op !== "string" || !ALL_OPS.includes(op)) {
|
|
34197
|
+
return null;
|
|
34198
|
+
}
|
|
34199
|
+
const recorded = typeof decoded["session_id"] === "string" ? decoded["session_id"] : "";
|
|
34200
|
+
if (session_id && recorded && recorded !== session_id) {
|
|
34201
|
+
return null;
|
|
34202
|
+
}
|
|
34203
|
+
const at = Date.parse(String(decoded["refused_at"] ?? ""));
|
|
34204
|
+
if (!Number.isFinite(at) || now - at > PENDING_MAX_AGE_MS) {
|
|
34205
|
+
return null;
|
|
34206
|
+
}
|
|
34207
|
+
return { op, session_id: recorded, refused_at: String(decoded["refused_at"]) };
|
|
34208
|
+
} catch {
|
|
34209
|
+
return null;
|
|
34210
|
+
}
|
|
34211
|
+
}
|
|
34151
34212
|
function run2(stdin_text, options) {
|
|
34152
34213
|
let envelope = {};
|
|
34153
34214
|
if (stdin_text.trim()) {
|
|
@@ -34173,6 +34234,12 @@ function run2(stdin_text, options) {
|
|
|
34173
34234
|
return EXIT_ALLOW2;
|
|
34174
34235
|
}
|
|
34175
34236
|
const { authorized, evidence } = classifyAuthorization(prompt);
|
|
34237
|
+
const session = typeof envelope["session_id"] === "string" ? envelope["session_id"] : "";
|
|
34238
|
+
const pending = takePending(options.consumer_root, session, Date.now());
|
|
34239
|
+
if (pending && !authorized.includes(pending.op) && isAffirmative(prompt)) {
|
|
34240
|
+
authorized.push(pending.op);
|
|
34241
|
+
evidence[pending.op] = `confirmation of the refused \`${pending.op}\` (refused ${pending.refused_at}): "${prompt.trim().slice(0, 40)}"`;
|
|
34242
|
+
}
|
|
34176
34243
|
const ledger = {
|
|
34177
34244
|
session_id: typeof envelope["session_id"] === "string" ? envelope["session_id"] : "",
|
|
34178
34245
|
detected_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -34181,7 +34248,7 @@ function run2(stdin_text, options) {
|
|
|
34181
34248
|
prompt_chars: prompt.length
|
|
34182
34249
|
};
|
|
34183
34250
|
try {
|
|
34184
|
-
atomic_write_json(path56.join(options.consumer_root,
|
|
34251
|
+
atomic_write_json(path56.join(options.consumer_root, ledgerFileFor(session)), ledger);
|
|
34185
34252
|
} catch {
|
|
34186
34253
|
}
|
|
34187
34254
|
return EXIT_ALLOW2;
|
|
@@ -34219,7 +34286,7 @@ function _isCliEntry18() {
|
|
|
34219
34286
|
return false;
|
|
34220
34287
|
}
|
|
34221
34288
|
}
|
|
34222
|
-
var EXIT_ALLOW2, STATE_FILE2, PHRASES, OUTPUT_LINE, PASTED_COMMANDS;
|
|
34289
|
+
var EXIT_ALLOW2, STATE_FILE2, STATE_DIR, PENDING_DIR, PENDING_FILE, PENDING_MAX_AGE_MS, ALL_OPS, PHRASES, OUTPUT_LINE, PASTED_COMMANDS;
|
|
34223
34290
|
var init_git_authorization_hook = __esm({
|
|
34224
34291
|
"src/scripts/git_authorization_hook.ts"() {
|
|
34225
34292
|
"use strict";
|
|
@@ -34227,6 +34294,20 @@ var init_git_authorization_hook = __esm({
|
|
|
34227
34294
|
init_hook_stdin();
|
|
34228
34295
|
EXIT_ALLOW2 = 0;
|
|
34229
34296
|
STATE_FILE2 = path56.join("agents", "state", "git-authorization.json");
|
|
34297
|
+
STATE_DIR = path56.join("agents", "state", "git-authorization");
|
|
34298
|
+
PENDING_DIR = path56.join("agents", "state", "git-authorization-pending");
|
|
34299
|
+
PENDING_FILE = path56.join("agents", "state", "git-authorization-pending.json");
|
|
34300
|
+
PENDING_MAX_AGE_MS = 10 * 60 * 1e3;
|
|
34301
|
+
ALL_OPS = [
|
|
34302
|
+
"commit",
|
|
34303
|
+
"push",
|
|
34304
|
+
"branch",
|
|
34305
|
+
"pr-create",
|
|
34306
|
+
"pr-merge",
|
|
34307
|
+
"tag",
|
|
34308
|
+
"release",
|
|
34309
|
+
"publish"
|
|
34310
|
+
];
|
|
34230
34311
|
PHRASES = [
|
|
34231
34312
|
{ op: "commit", re: /\b(commit(e|et|te|ten)?|committe|einchecken)\b/i },
|
|
34232
34313
|
{ op: "push", re: /\b(push(e|en|t)?|hochladen|hochschieben|raufschieben)\b/i },
|
|
@@ -34456,17 +34537,30 @@ function commandOp(command) {
|
|
|
34456
34537
|
return found;
|
|
34457
34538
|
}
|
|
34458
34539
|
function _loadLedger(consumer_root, session_id, now) {
|
|
34540
|
+
const candidates = [ledgerFileFor(session_id)];
|
|
34541
|
+
if (!candidates.includes(STATE_FILE2)) {
|
|
34542
|
+
candidates.push(STATE_FILE2);
|
|
34543
|
+
}
|
|
34544
|
+
for (const rel of candidates) {
|
|
34545
|
+
const found = _readLedgerFile(path57.join(consumer_root, rel), session_id, now);
|
|
34546
|
+
if (found !== null) {
|
|
34547
|
+
return found;
|
|
34548
|
+
}
|
|
34549
|
+
}
|
|
34550
|
+
return { authorized: /* @__PURE__ */ new Set(), present: false };
|
|
34551
|
+
}
|
|
34552
|
+
function _readLedgerFile(file, session_id, now) {
|
|
34459
34553
|
try {
|
|
34460
|
-
const raw = fs56.readFileSync(
|
|
34554
|
+
const raw = fs56.readFileSync(file, "utf8");
|
|
34461
34555
|
const decoded = JSON.parse(raw);
|
|
34462
34556
|
if (_isObject5(decoded) && Array.isArray(decoded["authorized"])) {
|
|
34463
34557
|
const ledgerSession = typeof decoded["session_id"] === "string" ? decoded["session_id"] : "";
|
|
34464
34558
|
if (session_id && ledgerSession && ledgerSession !== session_id) {
|
|
34465
|
-
return
|
|
34559
|
+
return null;
|
|
34466
34560
|
}
|
|
34467
34561
|
const at = Date.parse(String(decoded["detected_at"] ?? ""));
|
|
34468
34562
|
if (Number.isFinite(at) && now - at > LEDGER_MAX_AGE_MS) {
|
|
34469
|
-
return
|
|
34563
|
+
return null;
|
|
34470
34564
|
}
|
|
34471
34565
|
return {
|
|
34472
34566
|
authorized: new Set(decoded["authorized"].filter(Boolean)),
|
|
@@ -34475,7 +34569,7 @@ function _loadLedger(consumer_root, session_id, now) {
|
|
|
34475
34569
|
}
|
|
34476
34570
|
} catch {
|
|
34477
34571
|
}
|
|
34478
|
-
return
|
|
34572
|
+
return null;
|
|
34479
34573
|
}
|
|
34480
34574
|
function decide(command, ledger) {
|
|
34481
34575
|
if (command === null) {
|
|
@@ -34489,7 +34583,7 @@ function decide(command, ledger) {
|
|
|
34489
34583
|
if (BLOCK_OPS.has(op)) {
|
|
34490
34584
|
const reason = `Blocked: \`${op}\` with no authorization in this turn's prompt. This operation is irreversible and non-destructive-by-default already declares it never-autonomous. Command: ${shown}. Ask the user, in one numbered-options block, and run it only after they answer this turn.`;
|
|
34491
34585
|
return { exit: EXIT_BLOCK, stdout: "", stderr: `${reason}
|
|
34492
|
-
|
|
34586
|
+
`, blockedOp: op };
|
|
34493
34587
|
}
|
|
34494
34588
|
if (WARN_OPS.has(op)) {
|
|
34495
34589
|
const reason = `No \`${op}\` authorization found in this turn's prompt (commit-policy: a one-shot authorization is spent on the operation it named). If the user did authorize it this turn, proceed; otherwise stop and ask. Command: ${shown}`;
|
|
@@ -34519,6 +34613,16 @@ function run3(stdin_text, options) {
|
|
|
34519
34613
|
extractCommand(envelope),
|
|
34520
34614
|
_loadLedger(options.consumer_root, session_id, Date.now())
|
|
34521
34615
|
);
|
|
34616
|
+
if (decision.blockedOp) {
|
|
34617
|
+
try {
|
|
34618
|
+
atomic_write_json(path57.join(options.consumer_root, pendingFileFor(session_id)), {
|
|
34619
|
+
op: decision.blockedOp,
|
|
34620
|
+
session_id,
|
|
34621
|
+
refused_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
34622
|
+
});
|
|
34623
|
+
} catch {
|
|
34624
|
+
}
|
|
34625
|
+
}
|
|
34522
34626
|
if (decision.stdout) {
|
|
34523
34627
|
process.stdout.write(decision.stdout);
|
|
34524
34628
|
}
|
|
@@ -34565,6 +34669,7 @@ var init_block_unauthorized_git = __esm({
|
|
|
34565
34669
|
"src/scripts/hooks/block_unauthorized_git.ts"() {
|
|
34566
34670
|
"use strict";
|
|
34567
34671
|
init_hook_stdin();
|
|
34672
|
+
init_state_io();
|
|
34568
34673
|
init_git_authorization_hook();
|
|
34569
34674
|
EXIT_ALLOW3 = 0;
|
|
34570
34675
|
EXIT_BLOCK = 1;
|
|
@@ -35610,12 +35715,12 @@ function resolveDepth(parentRef, open) {
|
|
|
35610
35715
|
if (!parent) return { depth: 2, depth_basis: "asserted-parent" };
|
|
35611
35716
|
return { depth: parent.depth + 1, depth_basis: "observed" };
|
|
35612
35717
|
}
|
|
35613
|
-
function
|
|
35718
|
+
function ledgerFileFor2(root, ts) {
|
|
35614
35719
|
return path60.join(root, LEDGER_DIR, `${ts.slice(0, 7)}.jsonl`);
|
|
35615
35720
|
}
|
|
35616
35721
|
function appendLedgerLine(root, ts, line) {
|
|
35617
35722
|
if (is_replay_mode()) return;
|
|
35618
|
-
const file =
|
|
35723
|
+
const file = ledgerFileFor2(root, ts);
|
|
35619
35724
|
fs59.mkdirSync(path60.dirname(file), { recursive: true });
|
|
35620
35725
|
fs59.appendFileSync(file, `${JSON.stringify(line)}
|
|
35621
35726
|
`, "utf8");
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@event4u/agent-config",
|
|
3
3
|
"mcpName": "io.github.event4u-app/agent-config",
|
|
4
|
-
"version": "14.
|
|
4
|
+
"version": "14.1.0",
|
|
5
5
|
"description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"private": false,
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
"note": "Markdown links between source artefacts that cross a pack boundary the source pack's `requires` graph does not reach — revealed when the scan root moved off the deleted packages/ tree to the live corpus (0 -> 753 artefacts scanned). 82 distinct pack pairs across 131 source files; largest single class is engineering-base -> meta (73), skills in a consumer-installable pack citing this package's own maintenance skills. Each is a link that dangles for a consumer who installs the source pack without the target's. Judged real drift rather than an over-strict gate because it is long-tailed — top bucket 22%, no single systematic cause — unlike the lint_artefact_frontmatter case, where one required shape had 0-of-288 adoption. Fix per finding: add the edge to packs.yml requires, retarget the link, or move the artefact."
|
|
35
35
|
},
|
|
36
36
|
"check_gate_completeness": {
|
|
37
|
-
"count":
|
|
38
|
-
"landed": "2026-08-
|
|
39
|
-
"note": "Ledger adoption ratchet.
|
|
37
|
+
"count": 215,
|
|
38
|
+
"landed": "2026-08-17",
|
|
39
|
+
"note": "Ledger adoption ratchet. Gates registered under the CI task roots that carry neither per-target completeness accounting (`_lib/gate_ledger.ts`) nor a `// ledger-exempt:` marker. This is deliberately NOT a sweep: road-to-skill-ecosystem-gate-integrity Phase 1 adopts exactly three gates (lint_framework_leakage, check_references, lint_roadmap_complexity) plus the checker itself to prove the interface, and the remainder migrates as gates are touched. Shrink-only from here — the number may not grow, so a NEW gate must adopt or exempt. Lowered 216 -> 215 on 2026-08-17 by discharging the obligation three NEW arrivals had incurred, which is the standing rule being enforced rather than a drain: the registered population grew 244 -> 258 and three of the fourteen arrivals adopted nothing — `build_archive_index`, `check_gate_coverage`, `check_pr_ci_current`. The reading was 218 against this baseline of 216, and the missing arithmetic is that one PRE-existing gate (`lint_rule_skill_pack_reach`) had adopted in the meantime: 216 + 3 - 1 = 218. All three adopted a real ledger. `check_pr_ci_current` first took a reasoned exemption claiming no target population; the completion review refuted it from the file itself — the check rows ARE a population, and rows whose `state` is not a string were being dropped before any verdict ran, so a dropped row could be the failing one. The exemption was withdrawn and the ledger adopted, counting kept rows as read, dropped rows as skips, and rows a pre-row verdict never reached as out of scope. The denominator in this note was 14 stale (it said 244) and is now expressed without one, because a count pinned to a population that grows weekly rots faster than the count itself. Note what this lowering does NOT buy: headroom is zero either way, so the next un-adopted arrival reds this gate immediately — which is the intended behaviour and the reason the number is not padded. The refreshed `landed` is load-bearing rather than bookkeeping: `checkRatchet` returns `improved` BEFORE it evaluates staleness, so an entry left at 216 while the tree measures 215 would never expire at any age (pinned by tests/lib/gate_baseline.test.ts, which asserts `improved` at landed + STALE_AFTER_DAYS + 400 days) — the greenest available state and the worst, since it switches off the anti-fossilization clause exactly when someone has done real work.",
|
|
40
40
|
"falsifier": "./scripts-run src/scripts/check_gate_completeness"
|
|
41
41
|
},
|
|
42
42
|
"check_depth_budget": {
|
|
@@ -57,7 +57,9 @@ export type GateSkipReason =
|
|
|
57
57
|
| 'generated_artifact'
|
|
58
58
|
| 'dead_scan_root'
|
|
59
59
|
| 'declared_exemption'
|
|
60
|
-
| 'not_applicable_kind'
|
|
60
|
+
| 'not_applicable_kind'
|
|
61
|
+
| 'precondition_unmet'
|
|
62
|
+
| 'check_did_not_run';
|
|
61
63
|
|
|
62
64
|
/**
|
|
63
65
|
* One sentence per skip code, printed verbatim in the ledger's own output.
|
|
@@ -79,6 +81,8 @@ export const SKIP_REASON_MESSAGE: Record<GateSkipReason, string> = {
|
|
|
79
81
|
dead_scan_root: 'the root this target was enumerated from no longer exists',
|
|
80
82
|
declared_exemption: 'the target carries an explicit, reviewable exemption from this check',
|
|
81
83
|
not_applicable_kind: 'the target is a kind of artifact this check does not apply to',
|
|
84
|
+
precondition_unmet: 'a precondition of this run settled the verdict before the target could be inspected',
|
|
85
|
+
check_did_not_run: 'the check itself could not be executed, so the target was never read',
|
|
82
86
|
};
|
|
83
87
|
|
|
84
88
|
/** Terminal outcomes a planned target can reach. Exactly one, exactly once. */
|
|
@@ -45,6 +45,7 @@ import * as path from 'node:path';
|
|
|
45
45
|
import process from 'node:process';
|
|
46
46
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
47
47
|
|
|
48
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
48
49
|
import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
|
|
49
50
|
import { parse_frontmatter } from './validate_frontmatter.js';
|
|
50
51
|
|
|
@@ -217,12 +218,24 @@ export function archiveFiles(dir: string): string[] {
|
|
|
217
218
|
.sort();
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
|
|
221
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Build entries from an enumeration the CALLER already performed.
|
|
223
|
+
*
|
|
224
|
+
* Split out so the ledger's plan and the results can come from ONE walk. With
|
|
225
|
+
* two walks, a file created between them reports as a skip that never happened
|
|
226
|
+
* and a file deleted between them yields an entry that was never planned — the
|
|
227
|
+
* plan would describe a different directory state than the results.
|
|
228
|
+
*/
|
|
229
|
+
export function buildIndexFrom(dir: string, names: readonly string[]): ArchiveEntry[] {
|
|
230
|
+
return names.map((name) =>
|
|
222
231
|
buildEntry(name.replace(/\.md$/, ''), fs.readFileSync(path.join(dir, name), 'utf8')),
|
|
223
232
|
);
|
|
224
233
|
}
|
|
225
234
|
|
|
235
|
+
export function buildIndex(dir: string): ArchiveEntry[] {
|
|
236
|
+
return buildIndexFrom(dir, archiveFiles(dir));
|
|
237
|
+
}
|
|
238
|
+
|
|
226
239
|
/** Table-cell text: pipes escaped, newlines flattened, never empty. */
|
|
227
240
|
function _cell(value: string | null): string {
|
|
228
241
|
if (value === null) {
|
|
@@ -309,7 +322,44 @@ export function main(argv: readonly string[]): number {
|
|
|
309
322
|
const quiet = argv.includes('--quiet');
|
|
310
323
|
const dir = path.join(ROOT, ARCHIVE_REL);
|
|
311
324
|
|
|
312
|
-
|
|
325
|
+
// ONE walk feeds both the plan and the results.
|
|
326
|
+
//
|
|
327
|
+
// Planning the result set and completing it in the next statement would make
|
|
328
|
+
// `planned === completed, skipped=0` true by construction — an accounting
|
|
329
|
+
// line that can never report anything. Planning a SECOND walk is only
|
|
330
|
+
// marginally better: it describes a directory state the results did not come
|
|
331
|
+
// from. Enumerating once and passing that list into the builder makes the
|
|
332
|
+
// gap real — the natural future fix for an unreadable archive file is a
|
|
333
|
+
// `try/catch { continue }` in the builder, and that file then shows up here
|
|
334
|
+
// as a skip instead of vanishing.
|
|
335
|
+
//
|
|
336
|
+
// `not-extractable` is a real reading rather than a skip and therefore
|
|
337
|
+
// completes — but a frontmatter PARSE failure is swallowed by `_frontmatter`
|
|
338
|
+
// and zeroes title/verdict/status, which is a genuine per-target degrade the
|
|
339
|
+
// disposition tally cannot show. Both are visible here as the difference
|
|
340
|
+
// between the planned and completed sets.
|
|
341
|
+
const walked = archiveFiles(dir);
|
|
342
|
+
const entries = buildIndexFrom(dir, walked);
|
|
343
|
+
const planned = walked.map((f) => path.basename(f, '.md'));
|
|
344
|
+
const produced = new Set(entries.map((e) => e.slug));
|
|
345
|
+
const ledger = new GateLedger('build_archive_index');
|
|
346
|
+
ledger.plan(planned);
|
|
347
|
+
for (const slug of planned) {
|
|
348
|
+
// `check_did_not_run`, not `no_applicable_files`: the file IS one the
|
|
349
|
+
// check applies to — the entry build did not produce a reading for it.
|
|
350
|
+
if (produced.has(slug)) ledger.complete(slug);
|
|
351
|
+
else ledger.skip(slug, 'check_did_not_run');
|
|
352
|
+
}
|
|
353
|
+
// A produced entry outside the plan means the two sets came apart despite
|
|
354
|
+
// the single walk; the ledger would under-state the denominator silently.
|
|
355
|
+
const unplanned = [...produced].filter((s) => !planned.includes(s));
|
|
356
|
+
if (unplanned.length > 0) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
`build_archive_index: ${String(unplanned.length)} entry/entries were produced but never ` +
|
|
359
|
+
`planned (${unplanned.slice(0, 5).join(', ')}) — the ledger denominator is wrong.`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
313
363
|
try {
|
|
314
364
|
reportScanned({
|
|
315
365
|
gate: 'build_archive_index',
|
|
@@ -325,6 +375,15 @@ export function main(argv: readonly string[]): number {
|
|
|
325
375
|
throw err;
|
|
326
376
|
}
|
|
327
377
|
|
|
378
|
+
// Reported before the `--check` and write paths branch, so both publish the
|
|
379
|
+
// same accounting. NOT on all three: the earliest exit — `reportScanned`
|
|
380
|
+
// throwing `DeadScopeError` on an empty walk — returns above this line and
|
|
381
|
+
// reports nothing, which is the one exit where the accounting would say the
|
|
382
|
+
// most. Left as-is rather than papered over: the ledger has no targets to
|
|
383
|
+
// report there either, and moving it above the scope assertion would publish
|
|
384
|
+
// a plan for a root the assertion is about to reject.
|
|
385
|
+
ledger.report();
|
|
386
|
+
|
|
328
387
|
const mdPath = path.join(dir, INDEX_MD);
|
|
329
388
|
const jsonPath = path.join(dir, INDEX_JSON);
|
|
330
389
|
const md = renderMarkdown(entries);
|
|
@@ -50,6 +50,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
50
50
|
|
|
51
51
|
import * as yaml from 'js-yaml';
|
|
52
52
|
import { checkRatchet } from './_lib/gate_baseline.js';
|
|
53
|
+
import { GateLedger, type GateSkipReason } from './_lib/gate_ledger.js';
|
|
53
54
|
import { runCountedProbe } from './_lib/counted_probe.js';
|
|
54
55
|
import { listGateScripts } from './_lib/gate_population.js';
|
|
55
56
|
import { describeOutcome, namesEstateInvalidatingError } from './_lib/gate_result.js';
|
|
@@ -187,6 +188,59 @@ export function parse_scanned(output: string): number | null {
|
|
|
187
188
|
return m === null ? null : Number(m[1]);
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Map a gate verdict onto its ledger outcome.
|
|
193
|
+
*
|
|
194
|
+
* Extracted and exported so the mapping can be PINNED — it was inline, and the
|
|
195
|
+
* completion review's three sharpest findings were all mis-mappings that a
|
|
196
|
+
* discrimination test would have caught in one line each.
|
|
197
|
+
*
|
|
198
|
+
* The split is INSPECTED vs NOT INSPECTED, never pass vs fail. `fail` counts
|
|
199
|
+
* into the ledger's `scanned=` (`report()` sets `checked = completed + failed`),
|
|
200
|
+
* so a target that could not be READ must never land there — that inflates the
|
|
201
|
+
* exact number the library exists to make trustworthy. This file's own comment
|
|
202
|
+
* already says a gate that could not MEASURE "is not a gate that found
|
|
203
|
+
* violations, and it is not a gate that passed either"; this agrees with it.
|
|
204
|
+
*/
|
|
205
|
+
export function ledgerOutcomeFor(
|
|
206
|
+
verdict: GateResult['verdict'],
|
|
207
|
+
): 'complete' | 'fail' | GateSkipReason {
|
|
208
|
+
switch (verdict) {
|
|
209
|
+
case 'pending':
|
|
210
|
+
return 'disabled_by_configuration';
|
|
211
|
+
case 'unavailable':
|
|
212
|
+
// NOT `missing_credentials`. The only manifest row carrying
|
|
213
|
+
// `unavailable_exit` is unavailable for an unbuilt `site/dist`, and the
|
|
214
|
+
// skip sentence IS the audit surface — naming a credential would send the
|
|
215
|
+
// reader hunting an unset token instead of running the build.
|
|
216
|
+
return 'no_applicable_files';
|
|
217
|
+
case 'crashed':
|
|
218
|
+
// NOT `dead_scan_root` — a gate that threw has no dead root, and the skip
|
|
219
|
+
// sentence would send the reader to check a scan path that is fine. The
|
|
220
|
+
// vocabulary was extended rather than approximated, same reasoning as the
|
|
221
|
+
// `unavailable` case above.
|
|
222
|
+
return 'check_did_not_run';
|
|
223
|
+
case 'estate_invalid':
|
|
224
|
+
// Nothing was read here either. `crashed` still reds the build through
|
|
225
|
+
// this gate's own filter and `estate_invalid` deliberately does not, but
|
|
226
|
+
// neither belongs in the inspected count.
|
|
227
|
+
return 'dead_scan_root';
|
|
228
|
+
case 'silent':
|
|
229
|
+
case 'below_floor':
|
|
230
|
+
return 'fail';
|
|
231
|
+
case 'ok':
|
|
232
|
+
return 'complete';
|
|
233
|
+
default: {
|
|
234
|
+
// Exhaustiveness guard. A `Verdict` member added later must be classified
|
|
235
|
+
// deliberately: without this, a new member would silently count as
|
|
236
|
+
// INSPECTED and both the switch and its test would stay green —
|
|
237
|
+
// re-creating the over-report this function was extracted to pin.
|
|
238
|
+
const unhandled: never = verdict;
|
|
239
|
+
throw new Error(`ledgerOutcomeFor: unhandled verdict ${String(unhandled)}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
190
244
|
/** Classify one gate's run against its declared floor. Pure — testable without
|
|
191
245
|
* spawning anything. */
|
|
192
246
|
export function classify(
|
|
@@ -323,6 +377,31 @@ export function main(argv: readonly string[]): number {
|
|
|
323
377
|
return 0;
|
|
324
378
|
}
|
|
325
379
|
|
|
380
|
+
// Per-target accounting over the manifest rows. The gate that audits gate
|
|
381
|
+
// coverage was itself unaudited.
|
|
382
|
+
//
|
|
383
|
+
// Stated precisely, because the obvious justification overstates it: this gate
|
|
384
|
+
// ALREADY prints counted `pending N · unavailable N` warnings, so those two
|
|
385
|
+
// classes were not invisible. What the ledger adds is that they are expressed
|
|
386
|
+
// in the estate-wide vocabulary every other adopter uses, so a reader
|
|
387
|
+
// comparing gates does not have to learn this one's private status names — and
|
|
388
|
+
// that `crashed` and `estate_invalid`, which had no counted line at all, stop
|
|
389
|
+
// being counted as inspected.
|
|
390
|
+
//
|
|
391
|
+
// Note the two numbers this produces in one run: the ledger's `scanned=` (what
|
|
392
|
+
// was inspected) and the contract line `scanned:` (manifest rows read). They
|
|
393
|
+
// differ by the skips and that is correct, but only the latter is what
|
|
394
|
+
// `SCANNED_RE` parses.
|
|
395
|
+
const ledger = new GateLedger('check_gate_coverage');
|
|
396
|
+
// Deduplicated on purpose. `load_manifest` does not check id uniqueness, and a
|
|
397
|
+
// copy-pasted row with a forgotten rename would otherwise make `plan()` throw
|
|
398
|
+
// an uncaught LedgerUsageError — surfacing a manifest mistake as a stack trace
|
|
399
|
+
// and Node's exit 1, which this file's own contract reads as "a gate is blind,
|
|
400
|
+
// collapsed, or silent" rather than as the exit 2 a malformed manifest earns.
|
|
401
|
+
// The duplicate still runs twice and still prints twice; only the accounting
|
|
402
|
+
// refuses to double-count it.
|
|
403
|
+
ledger.plan([...new Set(specs.map((s) => s.id))]);
|
|
404
|
+
|
|
326
405
|
const results = specs.map((s) => {
|
|
327
406
|
if (spec_is_pending(s)) {
|
|
328
407
|
const { scanned, crashed } = probe_pending(s);
|
|
@@ -332,6 +411,20 @@ export function main(argv: readonly string[]): number {
|
|
|
332
411
|
return classify(s, scanned, crashed, exit_code, output);
|
|
333
412
|
});
|
|
334
413
|
|
|
414
|
+
// `recorded` pairs with the deduplicated plan above: a duplicated manifest id
|
|
415
|
+
// must be accounted once, and recording it twice would raise the same
|
|
416
|
+
// LedgerUsageError the dedupe exists to avoid.
|
|
417
|
+
const recorded = new Set<string>();
|
|
418
|
+
for (const r of results) {
|
|
419
|
+
if (recorded.has(r.id)) continue;
|
|
420
|
+
recorded.add(r.id);
|
|
421
|
+
const outcome = ledgerOutcomeFor(r.verdict);
|
|
422
|
+
if (outcome === 'complete') ledger.complete(r.id);
|
|
423
|
+
else if (outcome === 'fail') ledger.fail(r.id, r.message);
|
|
424
|
+
else ledger.skip(r.id, outcome);
|
|
425
|
+
}
|
|
426
|
+
ledger.report();
|
|
427
|
+
|
|
335
428
|
if (wantJson) {
|
|
336
429
|
process.stdout.write(`${JSON.stringify({ generated_by: 'check_gate_coverage', results }, null, 2)}\n`);
|
|
337
430
|
} else {
|
|
@@ -34,12 +34,29 @@
|
|
|
34
34
|
* Exit codes (contract §6): 0 = current, or unobservable-and-said-so ·
|
|
35
35
|
* 1 = the remote answered and the verdict is not about the branch head ·
|
|
36
36
|
* 2 = internal error. `scanned:` is emitted on EVERY exit path.
|
|
37
|
+
*
|
|
38
|
+
* ## The per-target ledger, and the exemption that was withdrawn
|
|
39
|
+
*
|
|
40
|
+
* This gate first shipped with a `// ledger-exempt:` marker arguing it had "no
|
|
41
|
+
* target population to account for — a short-circuiting chain of 0-3 remote
|
|
42
|
+
* facts". Its completion review refuted that from the file itself: the check
|
|
43
|
+
* rows ARE a population, and the parse below **drops** every row whose `state`
|
|
44
|
+
* is not a string. A dropped row could be the failing one, so the gate could
|
|
45
|
+
* print *"green on its own head"* over a check it never read — the precise
|
|
46
|
+
* "absence of scanning looks like absence of findings" shape the ledger exists
|
|
47
|
+
* to count.
|
|
48
|
+
*
|
|
49
|
+
* The exemption's secondary claim was accurate — every `main()` exit already
|
|
50
|
+
* self-reports through `scanReport` with an explicit `allowEmpty` reason — and
|
|
51
|
+
* that is exactly why the first argument was easy to believe. It was still
|
|
52
|
+
* resting on the false half.
|
|
37
53
|
*/
|
|
38
54
|
|
|
39
55
|
import * as path from 'node:path';
|
|
40
56
|
import { spawnSync } from 'node:child_process';
|
|
41
57
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
42
58
|
|
|
59
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
43
60
|
import { reportScanned } from './_lib/scan_scope.js';
|
|
44
61
|
|
|
45
62
|
const NETWORK_TIMEOUT_MS = 8_000;
|
|
@@ -117,6 +134,12 @@ export interface Facts {
|
|
|
117
134
|
/** The head SHA the reported checks ran against. */
|
|
118
135
|
checksHead: string | null;
|
|
119
136
|
rows: readonly CheckRow[];
|
|
137
|
+
/**
|
|
138
|
+
* Names of check rows the parse DROPPED because their `state` was not a
|
|
139
|
+
* string. Carried out rather than swallowed: a dropped row could be the
|
|
140
|
+
* failing one, so it is a skip the ledger must count, not a non-event.
|
|
141
|
+
*/
|
|
142
|
+
droppedRows?: readonly string[];
|
|
120
143
|
/** Set when something could not be observed; forces the degrade path. */
|
|
121
144
|
unobservable: string | null;
|
|
122
145
|
/**
|
|
@@ -235,6 +258,51 @@ export function decide(f: Facts, opts: DecideOptions = {}): Verdict {
|
|
|
235
258
|
};
|
|
236
259
|
}
|
|
237
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Whether {@link decide} reached the check rows at all.
|
|
263
|
+
*
|
|
264
|
+
* Every branch above the row logic — unobservable, no open PR, behind/diverged,
|
|
265
|
+
* ahead — settles the verdict WITHOUT reading a single row state. Recording
|
|
266
|
+
* those rows as `complete` would publish `scanned=N` for a run that inspected
|
|
267
|
+
* none of them: the absence-of-scanning-reads-as-absence-of-findings inflation
|
|
268
|
+
* the ledger exists to count, in the gate that adopted it. Under `--pre-push`,
|
|
269
|
+
* `ahead` is the NORMAL state, so this is the common path, not the exotic one.
|
|
270
|
+
*/
|
|
271
|
+
export function rowsWereEvaluated(f: Facts): boolean {
|
|
272
|
+
if (f.unobservable !== null) return false;
|
|
273
|
+
if (f.pr === null) return false;
|
|
274
|
+
return f.relation !== 'behind' && f.relation !== 'diverged' && f.relation !== 'ahead';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Publish the row-level accounting for one run.
|
|
279
|
+
*
|
|
280
|
+
* Three populations, three outcomes: rows the parse dropped are skips (a dropped
|
|
281
|
+
* row could be the failing one), rows a row-reading verdict covered are
|
|
282
|
+
* completions, and rows the verdict never reached are out of scope for THIS run.
|
|
283
|
+
*
|
|
284
|
+
* The line is emitted even under `--quiet` — {@link GateLedger.report} writes
|
|
285
|
+
* unconditionally by design, so a `--quiet` run of this gate is one line louder
|
|
286
|
+
* than before. Stated here because it is a change to this gate's own contract.
|
|
287
|
+
*/
|
|
288
|
+
function reportRowLedger(f: Facts): void {
|
|
289
|
+
const ledger = new GateLedger('check_pr_ci_current');
|
|
290
|
+
const dropped = new Set(f.droppedRows ?? []);
|
|
291
|
+
// Duplicate check names are a normal remote condition — a cancelled re-run
|
|
292
|
+
// beside a live one, two workflows sharing a job name, or a dropped row
|
|
293
|
+
// whose name equals a kept row's. `plan` throws on a repeat, and this gate
|
|
294
|
+
// promises to degrade rather than block, so the plan is deduplicated.
|
|
295
|
+
const planned = [...new Set([...f.rows.map((r) => r.name), ...dropped])];
|
|
296
|
+
ledger.plan(planned);
|
|
297
|
+
const evaluated = rowsWereEvaluated(f);
|
|
298
|
+
for (const name of planned) {
|
|
299
|
+
if (dropped.has(name)) ledger.skip(name, 'not_applicable_kind');
|
|
300
|
+
else if (evaluated) ledger.complete(name);
|
|
301
|
+
else ledger.outOfScope(name, 'precondition_unmet');
|
|
302
|
+
}
|
|
303
|
+
ledger.report();
|
|
304
|
+
}
|
|
305
|
+
|
|
238
306
|
/**
|
|
239
307
|
* Ancestry between the local tip and the PR head.
|
|
240
308
|
*
|
|
@@ -261,7 +329,7 @@ export function relate(
|
|
|
261
329
|
/** Gather the facts `decide` consumes. Every failure becomes `unobservable`. */
|
|
262
330
|
export function gather(repo: string): Facts {
|
|
263
331
|
const empty: Facts = {
|
|
264
|
-
pr: null, prHead: null, localHead: null, checksHead: null, rows: [], unobservable: null, relation: 'unknown',
|
|
332
|
+
pr: null, prHead: null, localHead: null, checksHead: null, rows: [], droppedRows: [], unobservable: null, relation: 'unknown',
|
|
265
333
|
};
|
|
266
334
|
|
|
267
335
|
const branch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repo);
|
|
@@ -296,9 +364,19 @@ export function gather(repo: string): Facts {
|
|
|
296
364
|
// with parseable stdout is DATA, not an error. Treating it as unobservable
|
|
297
365
|
// would make the gate blind in exactly the red case it exists for.
|
|
298
366
|
let rows: CheckRow[] = [];
|
|
367
|
+
// A row whose `state` is not a string is DROPPED, and that drop is the one
|
|
368
|
+
// thing in this gate worth counting: a dropped row could be the failing one,
|
|
369
|
+
// and without accounting the gate prints "green on its own head" over a
|
|
370
|
+
// check it never read. `dropped` is surfaced by the caller through the
|
|
371
|
+
// ledger rather than swallowed here.
|
|
372
|
+
let dropped: string[] = [];
|
|
299
373
|
if (checks.stdout.trim() !== '') {
|
|
300
374
|
try {
|
|
301
|
-
|
|
375
|
+
const parsed = JSON.parse(checks.stdout) as CheckRow[];
|
|
376
|
+
rows = parsed.filter((r) => typeof r.state === 'string');
|
|
377
|
+
dropped = parsed
|
|
378
|
+
.filter((r) => typeof r.state !== 'string')
|
|
379
|
+
.map((r, i) => (typeof r.name === 'string' ? r.name : `row-${String(i)}`));
|
|
302
380
|
} catch {
|
|
303
381
|
rows = [];
|
|
304
382
|
}
|
|
@@ -329,6 +407,7 @@ export function gather(repo: string): Facts {
|
|
|
329
407
|
localHead: localSha,
|
|
330
408
|
checksHead: runsHead.ok && runsHead.stdout.trim() !== '' ? runsHead.stdout.trim() : null,
|
|
331
409
|
rows,
|
|
410
|
+
droppedRows: dropped,
|
|
332
411
|
unobservable: null,
|
|
333
412
|
relation: relate(repo, localSha, prSha),
|
|
334
413
|
};
|
|
@@ -374,7 +453,11 @@ export function main(argv?: readonly string[]): number {
|
|
|
374
453
|
|
|
375
454
|
let verdict: Verdict;
|
|
376
455
|
try {
|
|
377
|
-
|
|
456
|
+
const facts = gather(repo);
|
|
457
|
+
// The verdict comes FIRST: whether the rows were inspected at all is a
|
|
458
|
+
// property of the verdict's path, not of the parse (see reportRowLedger).
|
|
459
|
+
verdict = decide(facts, { prePush });
|
|
460
|
+
reportRowLedger(facts);
|
|
378
461
|
} catch (exc) {
|
|
379
462
|
scanReport(0, 'internal error — nothing was compared');
|
|
380
463
|
process.stderr.write(
|