mandrel-platform 0.17.2 → 0.18.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.
@@ -0,0 +1,435 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-ruleset.mjs
4
+ *
5
+ * Live branch-ruleset drift dashboard (Story #178).
6
+ *
7
+ * The platform ships `config/main-protection.schema.json` +
8
+ * `docs/runbooks/main-protection.json` (the decided main-branch protection
9
+ * contract: required status checks, no bypass actors, linear history,
10
+ * force-push/deletion blocked) plus a setup runbook — but nothing detects a
11
+ * LIVE ruleset drifting from that contract after initial setup. A silent
12
+ * ruleset edit (an actor added to `bypass_actors`, `strict` status checks
13
+ * turned off, force-pushes re-enabled) would go unnoticed today. The
14
+ * 2026-07-01 audit found exactly this risk class at the repo-settings layer
15
+ * (Story #171, `check-repo-settings.mjs`); this script is the same detector
16
+ * for the branch-ruleset layer.
17
+ *
18
+ * Mirrors the shape of `check-repo-settings.mjs` / `check-pin-drift.mjs`:
19
+ * data-driven consumer registry (reuses `scripts/pin-drift-consumers.json` —
20
+ * same fleet, no second registry to keep in sync), an injectable `runGh`
21
+ * seam for offline testing, pure exported classifier functions, `--json` /
22
+ * `--strict` flags, and `GITHUB_STEP_SUMMARY` integration.
23
+ *
24
+ * Reads each consumer's LIVE rulesets via the GitHub Rulesets API
25
+ * (`gh api repos/{owner}/{repo}/rulesets` for the list, then
26
+ * `gh api repos/{owner}/{repo}/rulesets/{id}` per ruleset for full rule
27
+ * detail — the list endpoint omits `rules`/`bypass_actors`) and diffs the
28
+ * ruleset targeting `refs/heads/<branch>` against
29
+ * `docs/runbooks/main-protection.json`:
30
+ *
31
+ * - `pull_request` rule present → PR required to merge.
32
+ * - `bypass_actors` empty → no bypass actor exempts the rule.
33
+ * - `required_status_checks` rule → contexts match `requiredStatusChecks`
34
+ * and `strict_required_status_checks_policy`
35
+ * is true (branch must be up to date).
36
+ * - `required_linear_history` rule → present iff `requireLinearHistory`.
37
+ * - `non_fast_forward` rule (force-push) → present iff `!allowForcePushes`.
38
+ * - `deletion` rule → present iff `!allowDeletions`.
39
+ *
40
+ * Non-blocking by design (standing decision #10 — same posture as
41
+ * `check-repo-settings.mjs` and `check-pin-drift.mjs`): the default exit
42
+ * code is 0 even when drift is found. `--strict` is an explicit opt-in for a
43
+ * one-off enforcement run; the scheduled dashboard invocation never passes
44
+ * it.
45
+ *
46
+ * Out of scope (see the Story): auto-fixing rulesets. This script reports
47
+ * drift and points at the setup runbook — it never mutates a live ruleset.
48
+ *
49
+ * Usage:
50
+ * node scripts/check-ruleset.mjs
51
+ * node scripts/check-ruleset.mjs --config scripts/pin-drift-consumers.json
52
+ * node scripts/check-ruleset.mjs --contract docs/runbooks/main-protection.json
53
+ * node scripts/check-ruleset.mjs --json # machine-readable envelope
54
+ * node scripts/check-ruleset.mjs --strict # exit 1 on any drift
55
+ *
56
+ * Exit codes:
57
+ * 0 — report emitted. Without --strict this is the default even when drift
58
+ * is present (report, don't block).
59
+ * 1 — with --strict: at least one consumer drifts from the contract.
60
+ * Without --strict: only on a fatal error (bad config, gh failure).
61
+ */
62
+
63
+ import { readFileSync, appendFileSync } from "node:fs";
64
+ import { resolve } from "node:path";
65
+ import { execFileSync } from "node:child_process";
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Arg parsing
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * @param {string[]} argv
73
+ * @returns {{ config: string, contract: string, json: boolean, strict: boolean }}
74
+ */
75
+ export function parseArgv(argv = []) {
76
+ let config = "scripts/pin-drift-consumers.json";
77
+ let contract = "docs/runbooks/main-protection.json";
78
+ let json = false;
79
+ let strict = false;
80
+ for (let i = 0; i < argv.length; i += 1) {
81
+ const a = argv[i];
82
+ if (a === "--config") {
83
+ const next = argv[i + 1];
84
+ if (next && !next.startsWith("--")) {
85
+ config = next;
86
+ i += 1;
87
+ }
88
+ } else if (a === "--contract") {
89
+ const next = argv[i + 1];
90
+ if (next && !next.startsWith("--")) {
91
+ contract = next;
92
+ i += 1;
93
+ }
94
+ } else if (a === "--json") {
95
+ json = true;
96
+ } else if (a === "--strict") {
97
+ strict = true;
98
+ }
99
+ }
100
+ return { config, contract, json, strict };
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Ruleset shape mapping
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Pick the ruleset (from a consumer's full `GET /rulesets/{id}` list) that
109
+ * targets the contract's protected branch — `refs/heads/<branch>` in the
110
+ * ruleset's `conditions.ref_name.include` list — and is `active`. Disabled
111
+ * ("evaluate") rulesets are ignored: they exist but do not enforce, so a
112
+ * contract check against them would be misleading.
113
+ *
114
+ * @param {Array<Record<string, unknown>>} rulesets Full ruleset objects (post-detail-fetch).
115
+ * @param {string} branch
116
+ * @returns {Record<string, unknown> | null}
117
+ */
118
+ export function findBranchRuleset(rulesets, branch) {
119
+ const targetRef = `refs/heads/${branch}`;
120
+ const match = rulesets.find((rs) => {
121
+ if (rs.enforcement !== "active") return false;
122
+ const include = rs.conditions?.ref_name?.include ?? [];
123
+ return include.includes(targetRef) || include.includes("~DEFAULT_BRANCH");
124
+ });
125
+ return match ?? null;
126
+ }
127
+
128
+ /**
129
+ * Map a full ruleset object's `rules[]` array into the contract's field
130
+ * shape, so it can be diffed the same way `check-repo-settings.mjs` diffs
131
+ * camelCase settings fields against the baseline.
132
+ *
133
+ * @param {Record<string, unknown>} ruleset
134
+ * @returns {{
135
+ * pullRequestRequired: boolean,
136
+ * bypassActorsEmpty: boolean,
137
+ * requiredStatusChecks: string[],
138
+ * strictRequiredStatusChecksPolicy: boolean,
139
+ * requireLinearHistory: boolean,
140
+ * allowForcePushes: boolean,
141
+ * allowDeletions: boolean,
142
+ * }}
143
+ */
144
+ export function mapRulesetToContract(ruleset) {
145
+ const rules = Array.isArray(ruleset.rules) ? ruleset.rules : [];
146
+ const byType = Object.fromEntries(rules.map((r) => [r.type, r]));
147
+
148
+ const statusCheckRule = byType.required_status_checks;
149
+ const statusChecks = (statusCheckRule?.parameters?.required_status_checks ?? []).map((c) => c.context);
150
+
151
+ const bypassActors = Array.isArray(ruleset.bypass_actors) ? ruleset.bypass_actors : [];
152
+
153
+ return {
154
+ pullRequestRequired: Boolean(byType.pull_request),
155
+ bypassActorsEmpty: bypassActors.length === 0,
156
+ requiredStatusChecks: statusChecks,
157
+ strictRequiredStatusChecksPolicy: Boolean(statusCheckRule?.parameters?.strict_required_status_checks_policy),
158
+ // GitHub models "force pushes blocked" as the presence of the
159
+ // `non_fast_forward` rule, and "deletions blocked" as the presence of
160
+ // the `deletion` rule — both are "rule present == restriction active",
161
+ // the inverse of the contract's `allow*` booleans.
162
+ allowForcePushes: !byType.non_fast_forward,
163
+ allowDeletions: !byType.deletion,
164
+ requireLinearHistory: Boolean(byType.required_linear_history),
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Diff a mapped live ruleset against the main-protection contract. Unknown
170
+ * contract keys (`$schema`, `branch`, `aggregatorJob`, `upstreamJobs`,
171
+ * `enforceAdmins`, `_note`) are ignored — this checker only asserts the
172
+ * dimensions a ruleset can actually encode.
173
+ *
174
+ * `requiredStatusChecks` is compared as a set (order-independent) since
175
+ * GitHub does not guarantee array ordering on the API response.
176
+ *
177
+ * @param {ReturnType<typeof mapRulesetToContract>} live
178
+ * @param {Record<string, unknown>} contract
179
+ * @returns {{ drifted: boolean, mismatches: Array<{ field: string, expected: unknown, actual: unknown }> }}
180
+ */
181
+ export function diffRuleset(live, contract) {
182
+ const mismatches = [];
183
+
184
+ if (contract.requiredStatusChecks !== undefined) {
185
+ const expected = [...contract.requiredStatusChecks].sort();
186
+ const actual = [...(live.requiredStatusChecks ?? [])].sort();
187
+ if (JSON.stringify(expected) !== JSON.stringify(actual)) {
188
+ mismatches.push({
189
+ field: "requiredStatusChecks",
190
+ expected: contract.requiredStatusChecks,
191
+ actual: live.requiredStatusChecks,
192
+ });
193
+ }
194
+ }
195
+
196
+ if (live.pullRequestRequired !== true) {
197
+ mismatches.push({ field: "pullRequestRequired", expected: true, actual: live.pullRequestRequired });
198
+ }
199
+
200
+ if (live.bypassActorsEmpty !== true) {
201
+ mismatches.push({ field: "bypassActorsEmpty", expected: true, actual: live.bypassActorsEmpty });
202
+ }
203
+
204
+ if (live.strictRequiredStatusChecksPolicy !== true) {
205
+ mismatches.push({
206
+ field: "strictRequiredStatusChecksPolicy",
207
+ expected: true,
208
+ actual: live.strictRequiredStatusChecksPolicy,
209
+ });
210
+ }
211
+
212
+ const boolFields = [
213
+ ["requireLinearHistory", contract.requireLinearHistory],
214
+ ["allowForcePushes", contract.allowForcePushes],
215
+ ["allowDeletions", contract.allowDeletions],
216
+ ];
217
+ for (const [field, expected] of boolFields) {
218
+ if (expected === undefined) continue;
219
+ if (live[field] !== expected) {
220
+ mismatches.push({ field, expected, actual: live[field] });
221
+ }
222
+ }
223
+
224
+ return { drifted: mismatches.length > 0, mismatches };
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // GitHub access
229
+ // ---------------------------------------------------------------------------
230
+
231
+ function ghApiJson(apiPath, runGh) {
232
+ const raw = runGh(["api", apiPath, "-H", "Accept: application/vnd.github+json"]);
233
+ return JSON.parse(raw);
234
+ }
235
+
236
+ /**
237
+ * Default gh runner — shells out to the `gh` CLI. Same shape as
238
+ * check-repo-settings.mjs's defaultGhRunner so all three checkers share test
239
+ * doubles.
240
+ *
241
+ * @param {string[]} args
242
+ * @returns {string}
243
+ */
244
+ export function defaultGhRunner(args) {
245
+ return execFileSync("gh", args, {
246
+ encoding: "utf-8",
247
+ maxBuffer: 32 * 1024 * 1024,
248
+ });
249
+ }
250
+
251
+ /**
252
+ * Fetch a consumer's rulesets: list, then hydrate each with the detail
253
+ * endpoint (the list response omits `rules`/`bypass_actors`), then pick the
254
+ * one targeting `branch`.
255
+ *
256
+ * @param {string} repo "owner/repo".
257
+ * @param {string} branch
258
+ * @param {(args: string[]) => string} runGh
259
+ * @returns {Record<string, unknown> | null}
260
+ */
261
+ export function fetchBranchRuleset(repo, branch, runGh) {
262
+ const list = ghApiJson(`repos/${repo}/rulesets`, runGh);
263
+ const detailed = (Array.isArray(list) ? list : []).map((rs) => ghApiJson(`repos/${repo}/rulesets/${rs.id}`, runGh));
264
+ return findBranchRuleset(detailed, branch);
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Orchestration
269
+ // ---------------------------------------------------------------------------
270
+
271
+ /**
272
+ * Build the full drift report for the configured consumers against the
273
+ * given contract.
274
+ *
275
+ * @param {{ consumers: Array<{ name: string, repo: string, branch?: string }> }} config
276
+ * @param {Record<string, unknown>} contract
277
+ * @param {(args: string[]) => string} runGh
278
+ * @returns {{ contract: Record<string, unknown>, consumers: Array<object> }}
279
+ */
280
+ export function buildReport(config, contract, runGh) {
281
+ const branch = contract.branch ?? "main";
282
+ const consumers = config.consumers.map((consumer) => {
283
+ const consumerBranch = consumer.branch ?? branch;
284
+ try {
285
+ const ruleset = fetchBranchRuleset(consumer.repo, consumerBranch, runGh);
286
+ if (!ruleset) {
287
+ return {
288
+ name: consumer.name,
289
+ repo: consumer.repo,
290
+ status: "missing",
291
+ error: `no active ruleset targets refs/heads/${consumerBranch}`,
292
+ };
293
+ }
294
+ const live = mapRulesetToContract(ruleset);
295
+ const { drifted, mismatches } = diffRuleset(live, contract);
296
+ return {
297
+ name: consumer.name,
298
+ repo: consumer.repo,
299
+ status: drifted ? "drift" : "current",
300
+ rulesetId: ruleset.id,
301
+ rulesetName: ruleset.name,
302
+ live,
303
+ mismatches,
304
+ };
305
+ } catch (err) {
306
+ return {
307
+ name: consumer.name,
308
+ repo: consumer.repo,
309
+ status: "error",
310
+ error: err instanceof Error ? err.message : String(err),
311
+ };
312
+ }
313
+ });
314
+ return { contract, consumers };
315
+ }
316
+
317
+ /**
318
+ * @param {ReturnType<typeof buildReport>} report
319
+ * @returns {boolean}
320
+ */
321
+ export function hasDrift(report) {
322
+ return report.consumers.some((c) => c.status === "drift" || c.status === "missing");
323
+ }
324
+
325
+ /**
326
+ * @param {ReturnType<typeof buildReport>} report
327
+ * @returns {string}
328
+ */
329
+ export function renderReport(report) {
330
+ const lines = [];
331
+ lines.push("## Branch-Ruleset Drift Dashboard");
332
+ lines.push("");
333
+ lines.push(
334
+ "Non-blocking by design (standing decision #10) — drift is reported here, never a hard gate on a consumer's `main`.",
335
+ );
336
+ lines.push("");
337
+ lines.push("| Consumer | Status | Detail |");
338
+ lines.push("| -------- | ------ | ------ |");
339
+ for (const c of report.consumers) {
340
+ if (c.status === "current") {
341
+ lines.push(`| ${c.name} | ✅ current | matches the main-protection contract |`);
342
+ } else if (c.status === "missing") {
343
+ lines.push(`| ${c.name} | ⚠️ missing | ${c.error} |`);
344
+ } else if (c.status === "error") {
345
+ lines.push(`| ${c.name} | ⚠️ error | ${c.error} |`);
346
+ } else {
347
+ const detail = c.mismatches
348
+ .map((m) => `${m.field}: expected \`${JSON.stringify(m.expected)}\`, got \`${JSON.stringify(m.actual)}\``)
349
+ .join("; ");
350
+ lines.push(`| ${c.name} | ❌ drift | ${detail} |`);
351
+ }
352
+ }
353
+ return lines.join("\n");
354
+ }
355
+
356
+ // ---------------------------------------------------------------------------
357
+ // CLI entry
358
+ // ---------------------------------------------------------------------------
359
+
360
+ /**
361
+ * @param {{
362
+ * argv?: string[],
363
+ * cwd?: string,
364
+ * stdout?: { write: (s: string) => void },
365
+ * stderr?: { write: (s: string) => void },
366
+ * runGh?: (args: string[]) => string,
367
+ * summaryPath?: string | undefined,
368
+ * }} [opts]
369
+ * @returns {number} exit code
370
+ */
371
+ export function runCli({
372
+ argv = process.argv.slice(2),
373
+ cwd = process.cwd(),
374
+ stdout = process.stdout,
375
+ stderr = process.stderr,
376
+ runGh = defaultGhRunner,
377
+ summaryPath = process.env.GITHUB_STEP_SUMMARY,
378
+ } = {}) {
379
+ const { config: configRel, contract: contractRel, json, strict } = parseArgv(argv);
380
+ const configPath = resolve(cwd, configRel);
381
+ const contractPath = resolve(cwd, contractRel);
382
+
383
+ let config;
384
+ let contract;
385
+ try {
386
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
387
+ } catch (err) {
388
+ stderr.write(
389
+ `[ruleset] ❌ failed to read config ${configPath}: ${err instanceof Error ? err.message : String(err)}\n`,
390
+ );
391
+ return 1;
392
+ }
393
+ try {
394
+ contract = JSON.parse(readFileSync(contractPath, "utf-8"));
395
+ } catch (err) {
396
+ stderr.write(
397
+ `[ruleset] ❌ failed to read contract ${contractPath}: ${err instanceof Error ? err.message : String(err)}\n`,
398
+ );
399
+ return 1;
400
+ }
401
+ if (!Array.isArray(config.consumers)) {
402
+ stderr.write(`[ruleset] ❌ config must define { consumers: [] }\n`);
403
+ return 1;
404
+ }
405
+
406
+ const report = buildReport(config, contract, runGh);
407
+ const drift = hasDrift(report);
408
+
409
+ if (json) {
410
+ stdout.write(`${JSON.stringify({ kind: "ruleset-report", drift, ...report }, null, 2)}\n`);
411
+ } else {
412
+ const text = renderReport(report);
413
+ stdout.write(`${text}\n`);
414
+ if (summaryPath) {
415
+ try {
416
+ appendFileSync(summaryPath, `${text}\n`);
417
+ } catch (err) {
418
+ stderr.write(`[ruleset] ⚠ could not write job summary: ${err instanceof Error ? err.message : String(err)}\n`);
419
+ }
420
+ }
421
+ }
422
+
423
+ if (strict && drift) {
424
+ stderr.write(`[ruleset] ❌ drift detected (--strict)\n`);
425
+ return 1;
426
+ }
427
+ return 0;
428
+ }
429
+
430
+ // Direct-invocation guard (matches the repo's other scripts/*.mjs entry style).
431
+ const invokedDirectly =
432
+ process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
433
+ if (invokedDirectly) {
434
+ process.exit(runCli());
435
+ }