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,363 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-repo-settings.mjs
4
+ *
5
+ * GitHub-side repo-settings drift dashboard (Story #171).
6
+ *
7
+ * The 2026-07-01 settings-level audit (repo-ops consumers matrix §3a, roadmap
8
+ * §2.1) found real divergence in GitHub repo settings the platform shipped no
9
+ * contract for: default Actions workflow token permissions, merge-method
10
+ * allow-list, squash-commit source, auto-merge, and whether Actions can
11
+ * approve pull requests. `config/repo-settings.schema.json` /
12
+ * `docs/runbooks/repo-settings.json` encode the decided fleet baseline; this
13
+ * script reads each consumer's LIVE settings over the GitHub API
14
+ * (`gh api repos/{owner}/{repo}`) and reports drift against that baseline.
15
+ *
16
+ * Mirrors the shape of `check-pin-drift.mjs`: data-driven consumer registry
17
+ * (reuses `scripts/pin-drift-consumers.json` — same fleet, no second registry
18
+ * to keep in sync), an injectable `runGh` seam for offline testing, pure
19
+ * exported classifier functions, `--json` / `--strict` flags, and
20
+ * `GITHUB_STEP_SUMMARY` integration.
21
+ *
22
+ * Non-blocking by design (standing decision #10 — drift is repaired via
23
+ * auto-repair PRs / a dashboard, never a hard gate): the default exit code is
24
+ * 0 even when drift is found. `--strict` is an explicit opt-in for a one-off
25
+ * enforcement run; the scheduled dashboard invocation never passes it.
26
+ *
27
+ * Usage:
28
+ * node scripts/check-repo-settings.mjs
29
+ * node scripts/check-repo-settings.mjs --config scripts/pin-drift-consumers.json
30
+ * node scripts/check-repo-settings.mjs --baseline docs/runbooks/repo-settings.json
31
+ * node scripts/check-repo-settings.mjs --json # machine-readable envelope
32
+ * node scripts/check-repo-settings.mjs --strict # exit 1 on any drift
33
+ *
34
+ * Exit codes:
35
+ * 0 — report emitted. Without --strict this is the default even when drift
36
+ * is present (report, don't block).
37
+ * 1 — with --strict: at least one consumer drifts from the baseline.
38
+ * Without --strict: only on a fatal error (bad config, gh failure).
39
+ */
40
+
41
+ import { readFileSync, appendFileSync } from "node:fs";
42
+ import { resolve } from "node:path";
43
+ import { execFileSync } from "node:child_process";
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Arg parsing
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * @param {string[]} argv
51
+ * @returns {{ config: string, baseline: string, json: boolean, strict: boolean }}
52
+ */
53
+ export function parseArgv(argv = []) {
54
+ let config = "scripts/pin-drift-consumers.json";
55
+ let baseline = "docs/runbooks/repo-settings.json";
56
+ let json = false;
57
+ let strict = false;
58
+ for (let i = 0; i < argv.length; i += 1) {
59
+ const a = argv[i];
60
+ if (a === "--config") {
61
+ const next = argv[i + 1];
62
+ if (next && !next.startsWith("--")) {
63
+ config = next;
64
+ i += 1;
65
+ }
66
+ } else if (a === "--baseline") {
67
+ const next = argv[i + 1];
68
+ if (next && !next.startsWith("--")) {
69
+ baseline = next;
70
+ i += 1;
71
+ }
72
+ } else if (a === "--json") {
73
+ json = true;
74
+ } else if (a === "--strict") {
75
+ strict = true;
76
+ }
77
+ }
78
+ return { config, baseline, json, strict };
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Baseline dimensions
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * The baseline keys this checker understands, mapped to how to read the
87
+ * equivalent field off `gh api repos/{owner}/{repo}` (repo settings) or
88
+ * `gh api repos/{owner}/{repo}/actions/permissions/workflow` (Actions
89
+ * workflow-permissions, a separate endpoint GitHub does not fold into the
90
+ * main repo payload).
91
+ */
92
+ const REPO_FIELDS = [
93
+ "allowSquashMerge",
94
+ "allowMergeCommit",
95
+ "allowRebaseMerge",
96
+ "squashMergeCommitTitle",
97
+ "squashMergeCommitMessage",
98
+ "deleteBranchOnMerge",
99
+ "allowAutoMerge",
100
+ ];
101
+ const ACTIONS_FIELDS = ["actionsDefaultWorkflowPermissions", "actionsCanApprovePullRequestReviews"];
102
+
103
+ const REPO_FIELD_TO_API_KEY = {
104
+ allowSquashMerge: "allow_squash_merge",
105
+ allowMergeCommit: "allow_merge_commit",
106
+ allowRebaseMerge: "allow_rebase_merge",
107
+ squashMergeCommitTitle: "squash_merge_commit_title",
108
+ squashMergeCommitMessage: "squash_merge_commit_message",
109
+ deleteBranchOnMerge: "delete_branch_on_merge",
110
+ allowAutoMerge: "allow_auto_merge",
111
+ };
112
+
113
+ const ACTIONS_FIELD_TO_API_KEY = {
114
+ actionsDefaultWorkflowPermissions: "default_workflow_permissions",
115
+ actionsCanApprovePullRequestReviews: "can_approve_pull_request_reviews",
116
+ };
117
+
118
+ /**
119
+ * Map a live `gh api repos/{owner}/{repo}` payload to the baseline's field
120
+ * shape (camelCase, only the dimensions this contract governs).
121
+ *
122
+ * @param {Record<string, unknown>} repoPayload
123
+ * @returns {Record<string, unknown>}
124
+ */
125
+ export function mapRepoSettings(repoPayload) {
126
+ const out = {};
127
+ for (const field of REPO_FIELDS) {
128
+ out[field] = repoPayload[REPO_FIELD_TO_API_KEY[field]];
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /**
134
+ * Map a live `gh api repos/{owner}/{repo}/actions/permissions/workflow`
135
+ * payload to the baseline's field shape.
136
+ *
137
+ * @param {Record<string, unknown>} actionsPayload
138
+ * @returns {Record<string, unknown>}
139
+ */
140
+ export function mapActionsSettings(actionsPayload) {
141
+ const out = {};
142
+ for (const field of ACTIONS_FIELDS) {
143
+ out[field] = actionsPayload[ACTIONS_FIELD_TO_API_KEY[field]];
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /**
149
+ * Diff a consumer's live settings against the baseline for every dimension
150
+ * the baseline declares (unknown/extra baseline keys are ignored so the
151
+ * schema can grow without breaking this classifier).
152
+ *
153
+ * @param {Record<string, unknown>} live
154
+ * @param {Record<string, unknown>} baseline
155
+ * @returns {{ drifted: boolean, mismatches: Array<{ field: string, expected: unknown, actual: unknown }> }}
156
+ */
157
+ export function diffSettings(live, baseline) {
158
+ const mismatches = [];
159
+ for (const field of [...REPO_FIELDS, ...ACTIONS_FIELDS]) {
160
+ if (!(field in baseline)) continue;
161
+ const expected = baseline[field];
162
+ const actual = live[field];
163
+ if (actual !== expected) {
164
+ mismatches.push({ field, expected, actual });
165
+ }
166
+ }
167
+ return { drifted: mismatches.length > 0, mismatches };
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // GitHub access
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Run `gh api <path>` and parse the JSON response.
176
+ *
177
+ * @param {string} apiPath
178
+ * @param {(args: string[]) => string} runGh
179
+ * @returns {unknown}
180
+ */
181
+ function ghApiJson(apiPath, runGh) {
182
+ const raw = runGh(["api", apiPath, "-H", "Accept: application/vnd.github+json"]);
183
+ return JSON.parse(raw);
184
+ }
185
+
186
+ /**
187
+ * Default gh runner — shells out to the `gh` CLI. Same shape as
188
+ * check-pin-drift.mjs's defaultGhRunner so both scripts share test doubles.
189
+ *
190
+ * @param {string[]} args
191
+ * @returns {string}
192
+ */
193
+ export function defaultGhRunner(args) {
194
+ return execFileSync("gh", args, {
195
+ encoding: "utf-8",
196
+ maxBuffer: 32 * 1024 * 1024,
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Fetch one consumer's live settings across both endpoints and map them to
202
+ * the baseline's field shape.
203
+ *
204
+ * @param {string} repo "owner/repo".
205
+ * @param {(args: string[]) => string} runGh
206
+ * @returns {Record<string, unknown>}
207
+ */
208
+ export function fetchConsumerSettings(repo, runGh) {
209
+ const repoPayload = ghApiJson(`repos/${repo}`, runGh);
210
+ const actionsPayload = ghApiJson(`repos/${repo}/actions/permissions/workflow`, runGh);
211
+ return { ...mapRepoSettings(repoPayload), ...mapActionsSettings(actionsPayload) };
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Orchestration
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /**
219
+ * Build the full drift report for the configured consumers against the
220
+ * given baseline.
221
+ *
222
+ * @param {{ consumers: Array<{ name: string, repo: string, branch?: string }> }} config
223
+ * @param {Record<string, unknown>} baseline
224
+ * @param {(args: string[]) => string} runGh
225
+ * @returns {{ baseline: Record<string, unknown>, consumers: Array<object> }}
226
+ */
227
+ export function buildReport(config, baseline, runGh) {
228
+ const consumers = config.consumers.map((consumer) => {
229
+ try {
230
+ const live = fetchConsumerSettings(consumer.repo, runGh);
231
+ const { drifted, mismatches } = diffSettings(live, baseline);
232
+ return { name: consumer.name, repo: consumer.repo, status: drifted ? "drift" : "current", live, mismatches };
233
+ } catch (err) {
234
+ return {
235
+ name: consumer.name,
236
+ repo: consumer.repo,
237
+ status: "error",
238
+ error: err instanceof Error ? err.message : String(err),
239
+ };
240
+ }
241
+ });
242
+ return { baseline, consumers };
243
+ }
244
+
245
+ /**
246
+ * @param {ReturnType<typeof buildReport>} report
247
+ * @returns {boolean}
248
+ */
249
+ export function hasDrift(report) {
250
+ return report.consumers.some((c) => c.status === "drift");
251
+ }
252
+
253
+ /**
254
+ * @param {ReturnType<typeof buildReport>} report
255
+ * @returns {string}
256
+ */
257
+ export function renderReport(report) {
258
+ const lines = [];
259
+ lines.push("## Repo-Settings Baseline Dashboard");
260
+ lines.push("");
261
+ lines.push(
262
+ "Non-blocking by design (standing decision #10) — drift is reported here, never a hard gate on a consumer's `main`.",
263
+ );
264
+ lines.push("");
265
+ lines.push("| Consumer | Status | Detail |");
266
+ lines.push("| -------- | ------ | ------ |");
267
+ for (const c of report.consumers) {
268
+ if (c.status === "current") {
269
+ lines.push(`| ${c.name} | ✅ current | matches baseline |`);
270
+ } else if (c.status === "error") {
271
+ lines.push(`| ${c.name} | ⚠️ error | ${c.error} |`);
272
+ } else {
273
+ const detail = c.mismatches
274
+ .map((m) => `${m.field}: expected \`${m.expected}\`, got \`${m.actual}\``)
275
+ .join("; ");
276
+ lines.push(`| ${c.name} | ❌ drift | ${detail} |`);
277
+ }
278
+ }
279
+ return lines.join("\n");
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // CLI entry
284
+ // ---------------------------------------------------------------------------
285
+
286
+ /**
287
+ * @param {{
288
+ * argv?: string[],
289
+ * cwd?: string,
290
+ * stdout?: { write: (s: string) => void },
291
+ * stderr?: { write: (s: string) => void },
292
+ * runGh?: (args: string[]) => string,
293
+ * summaryPath?: string | undefined,
294
+ * }} [opts]
295
+ * @returns {number} exit code
296
+ */
297
+ export function runCli({
298
+ argv = process.argv.slice(2),
299
+ cwd = process.cwd(),
300
+ stdout = process.stdout,
301
+ stderr = process.stderr,
302
+ runGh = defaultGhRunner,
303
+ summaryPath = process.env.GITHUB_STEP_SUMMARY,
304
+ } = {}) {
305
+ const { config: configRel, baseline: baselineRel, json, strict } = parseArgv(argv);
306
+ const configPath = resolve(cwd, configRel);
307
+ const baselinePath = resolve(cwd, baselineRel);
308
+
309
+ let config;
310
+ let baseline;
311
+ try {
312
+ config = JSON.parse(readFileSync(configPath, "utf-8"));
313
+ } catch (err) {
314
+ stderr.write(
315
+ `[repo-settings] ❌ failed to read config ${configPath}: ${err instanceof Error ? err.message : String(err)}\n`,
316
+ );
317
+ return 1;
318
+ }
319
+ try {
320
+ baseline = JSON.parse(readFileSync(baselinePath, "utf-8"));
321
+ } catch (err) {
322
+ stderr.write(
323
+ `[repo-settings] ❌ failed to read baseline ${baselinePath}: ${err instanceof Error ? err.message : String(err)}\n`,
324
+ );
325
+ return 1;
326
+ }
327
+ if (!Array.isArray(config.consumers)) {
328
+ stderr.write(`[repo-settings] ❌ config must define { consumers: [] }\n`);
329
+ return 1;
330
+ }
331
+
332
+ const report = buildReport(config, baseline, runGh);
333
+ const drift = hasDrift(report);
334
+
335
+ if (json) {
336
+ stdout.write(`${JSON.stringify({ kind: "repo-settings-report", drift, ...report }, null, 2)}\n`);
337
+ } else {
338
+ const text = renderReport(report);
339
+ stdout.write(`${text}\n`);
340
+ if (summaryPath) {
341
+ try {
342
+ appendFileSync(summaryPath, `${text}\n`);
343
+ } catch (err) {
344
+ stderr.write(
345
+ `[repo-settings] ⚠ could not write job summary: ${err instanceof Error ? err.message : String(err)}\n`,
346
+ );
347
+ }
348
+ }
349
+ }
350
+
351
+ if (strict && drift) {
352
+ stderr.write(`[repo-settings] ❌ drift detected (--strict)\n`);
353
+ return 1;
354
+ }
355
+ return 0;
356
+ }
357
+
358
+ // Direct-invocation guard (matches the repo's other scripts/*.mjs entry style).
359
+ const invokedDirectly =
360
+ process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname);
361
+ if (invokedDirectly) {
362
+ process.exit(runCli());
363
+ }
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-repo-settings.test.mjs — node:test suite for the Story #171
4
+ * repo-settings drift dashboard.
5
+ *
6
+ * Exercises the pure classifiers (mapRepoSettings/mapActionsSettings,
7
+ * diffSettings) directly, and the full buildReport/runCli pipeline with an
8
+ * injected `runGh` seam (offline — no real GitHub calls).
9
+ *
10
+ * Run: node scripts/check-repo-settings.test.mjs (or `node --test scripts/`)
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { afterEach, beforeEach, test } from "node:test";
18
+ import {
19
+ mapRepoSettings,
20
+ mapActionsSettings,
21
+ diffSettings,
22
+ buildReport,
23
+ hasDrift,
24
+ renderReport,
25
+ runCli,
26
+ parseArgv,
27
+ } from "./check-repo-settings.mjs";
28
+
29
+ const BASELINE = {
30
+ allowSquashMerge: true,
31
+ allowMergeCommit: false,
32
+ allowRebaseMerge: false,
33
+ squashMergeCommitTitle: "PR_TITLE",
34
+ squashMergeCommitMessage: "PR_BODY",
35
+ deleteBranchOnMerge: true,
36
+ allowAutoMerge: true,
37
+ actionsDefaultWorkflowPermissions: "read",
38
+ actionsCanApprovePullRequestReviews: false,
39
+ };
40
+
41
+ test("mapRepoSettings maps GitHub's snake_case repo payload to camelCase baseline fields", () => {
42
+ const payload = {
43
+ allow_squash_merge: true,
44
+ allow_merge_commit: false,
45
+ allow_rebase_merge: false,
46
+ squash_merge_commit_title: "PR_TITLE",
47
+ squash_merge_commit_message: "PR_BODY",
48
+ delete_branch_on_merge: true,
49
+ allow_auto_merge: true,
50
+ // extra fields the repo payload carries that the schema does not govern
51
+ full_name: "owner/repo",
52
+ };
53
+ const mapped = mapRepoSettings(payload);
54
+ assert.deepEqual(mapped, {
55
+ allowSquashMerge: true,
56
+ allowMergeCommit: false,
57
+ allowRebaseMerge: false,
58
+ squashMergeCommitTitle: "PR_TITLE",
59
+ squashMergeCommitMessage: "PR_BODY",
60
+ deleteBranchOnMerge: true,
61
+ allowAutoMerge: true,
62
+ });
63
+ });
64
+
65
+ test("mapActionsSettings maps the Actions workflow-permissions payload", () => {
66
+ const payload = { default_workflow_permissions: "write", can_approve_pull_request_reviews: true };
67
+ assert.deepEqual(mapActionsSettings(payload), {
68
+ actionsDefaultWorkflowPermissions: "write",
69
+ actionsCanApprovePullRequestReviews: true,
70
+ });
71
+ });
72
+
73
+ test("diffSettings reports no mismatches when live matches baseline", () => {
74
+ const { drifted, mismatches } = diffSettings({ ...BASELINE }, BASELINE);
75
+ assert.equal(drifted, false);
76
+ assert.deepEqual(mismatches, []);
77
+ });
78
+
79
+ test("diffSettings flags every drifted field (domio-shaped: write token perms)", () => {
80
+ const live = { ...BASELINE, actionsDefaultWorkflowPermissions: "write" };
81
+ const { drifted, mismatches } = diffSettings(live, BASELINE);
82
+ assert.equal(drifted, true);
83
+ assert.deepEqual(mismatches, [
84
+ { field: "actionsDefaultWorkflowPermissions", expected: "read", actual: "write" },
85
+ ]);
86
+ });
87
+
88
+ test("diffSettings flags multiple mismatches (athportal-shaped: merge methods + squash source)", () => {
89
+ const live = {
90
+ ...BASELINE,
91
+ allowMergeCommit: true,
92
+ allowRebaseMerge: true,
93
+ squashMergeCommitMessage: "COMMIT_MESSAGES",
94
+ };
95
+ const { mismatches } = diffSettings(live, BASELINE);
96
+ const fields = mismatches.map((m) => m.field).sort();
97
+ assert.deepEqual(fields, ["allowMergeCommit", "allowRebaseMerge", "squashMergeCommitMessage"]);
98
+ });
99
+
100
+ test("diffSettings ignores baseline keys that are not in the field list (schema _note, $schema)", () => {
101
+ const baselineWithExtras = { ...BASELINE, $schema: "../../config/repo-settings.schema.json", _note: "..." };
102
+ const { drifted } = diffSettings({ ...BASELINE }, baselineWithExtras);
103
+ assert.equal(drifted, false);
104
+ });
105
+
106
+ function fakeRunGh(responses) {
107
+ return (args) => {
108
+ const path = args[1]; // ["api", "<path>", ...]
109
+ if (!(path in responses)) {
110
+ throw new Error(`fakeRunGh: no stub for ${path}`);
111
+ }
112
+ return JSON.stringify(responses[path]);
113
+ };
114
+ }
115
+
116
+ test("buildReport: consumer matching the baseline reports 'current'", () => {
117
+ const config = { consumers: [{ name: "domio", repo: "dsj1984/domio" }] };
118
+ const runGh = fakeRunGh({
119
+ "repos/dsj1984/domio": {
120
+ allow_squash_merge: true,
121
+ allow_merge_commit: false,
122
+ allow_rebase_merge: false,
123
+ squash_merge_commit_title: "PR_TITLE",
124
+ squash_merge_commit_message: "PR_BODY",
125
+ delete_branch_on_merge: true,
126
+ allow_auto_merge: true,
127
+ },
128
+ "repos/dsj1984/domio/actions/permissions/workflow": {
129
+ default_workflow_permissions: "write",
130
+ can_approve_pull_request_reviews: false,
131
+ },
132
+ });
133
+ const report = buildReport(config, BASELINE, runGh);
134
+ assert.equal(report.consumers[0].status, "drift");
135
+ assert.deepEqual(report.consumers[0].mismatches, [
136
+ { field: "actionsDefaultWorkflowPermissions", expected: "read", actual: "write" },
137
+ ]);
138
+ assert.equal(hasDrift(report), true);
139
+ });
140
+
141
+ test("buildReport: consumer fully matching baseline reports 'current' and no drift", () => {
142
+ const config = { consumers: [{ name: "swarm-os", repo: "Beestera/swarm-os" }] };
143
+ const runGh = fakeRunGh({
144
+ "repos/Beestera/swarm-os": {
145
+ allow_squash_merge: true,
146
+ allow_merge_commit: false,
147
+ allow_rebase_merge: false,
148
+ squash_merge_commit_title: "PR_TITLE",
149
+ squash_merge_commit_message: "PR_BODY",
150
+ delete_branch_on_merge: true,
151
+ allow_auto_merge: true,
152
+ },
153
+ "repos/Beestera/swarm-os/actions/permissions/workflow": {
154
+ default_workflow_permissions: "read",
155
+ can_approve_pull_request_reviews: false,
156
+ },
157
+ });
158
+ const report = buildReport(config, BASELINE, runGh);
159
+ assert.equal(report.consumers[0].status, "current");
160
+ assert.equal(hasDrift(report), false);
161
+ });
162
+
163
+ test("buildReport: a gh failure for one consumer surfaces as 'error', not a thrown exception", () => {
164
+ const config = { consumers: [{ name: "broken", repo: "owner/broken" }] };
165
+ const runGh = () => {
166
+ throw new Error("gh: repository not found");
167
+ };
168
+ const report = buildReport(config, BASELINE, runGh);
169
+ assert.equal(report.consumers[0].status, "error");
170
+ assert.match(report.consumers[0].error, /not found/);
171
+ assert.equal(hasDrift(report), false, "an error is not itself drift");
172
+ });
173
+
174
+ test("renderReport renders the non-blocking framing and per-consumer rows", () => {
175
+ const report = {
176
+ baseline: BASELINE,
177
+ consumers: [
178
+ { name: "domio", repo: "dsj1984/domio", status: "current" },
179
+ {
180
+ name: "athportal",
181
+ repo: "dsj1984/athportal",
182
+ status: "drift",
183
+ mismatches: [{ field: "allowMergeCommit", expected: false, actual: true }],
184
+ },
185
+ { name: "broken", repo: "owner/broken", status: "error", error: "boom" },
186
+ ],
187
+ };
188
+ const text = renderReport(report);
189
+ assert.match(text, /never a hard gate/);
190
+ assert.match(text, /domio.*✅ current/);
191
+ assert.match(text, /athportal.*❌ drift/);
192
+ assert.match(text, /allowMergeCommit/);
193
+ assert.match(text, /broken.*⚠️ error/);
194
+ });
195
+
196
+ test("parseArgv reads --config/--baseline/--json/--strict", () => {
197
+ const parsed = parseArgv(["--config", "foo.json", "--baseline", "bar.json", "--json", "--strict"]);
198
+ assert.deepEqual(parsed, { config: "foo.json", baseline: "bar.json", json: true, strict: true });
199
+ });
200
+
201
+ test("parseArgv defaults to the shared pin-drift consumer registry and the runbook baseline", () => {
202
+ const parsed = parseArgv([]);
203
+ assert.equal(parsed.config, "scripts/pin-drift-consumers.json");
204
+ assert.equal(parsed.baseline, "docs/runbooks/repo-settings.json");
205
+ });
206
+
207
+ let tmpDir;
208
+
209
+ beforeEach(() => {
210
+ tmpDir = mkdtempSync(join(tmpdir(), "repo-settings-test-"));
211
+ });
212
+
213
+ afterEach(() => {
214
+ rmSync(tmpDir, { recursive: true, force: true });
215
+ });
216
+
217
+ test("runCli --strict exits 1 when drift is present; exits 0 without --strict (non-blocking default)", () => {
218
+ const config = { consumers: [{ name: "domio", repo: "dsj1984/domio" }] };
219
+ const runGh = fakeRunGh({
220
+ "repos/dsj1984/domio": {
221
+ allow_squash_merge: true,
222
+ allow_merge_commit: true, // drift
223
+ allow_rebase_merge: false,
224
+ squash_merge_commit_title: "PR_TITLE",
225
+ squash_merge_commit_message: "PR_BODY",
226
+ delete_branch_on_merge: true,
227
+ allow_auto_merge: true,
228
+ },
229
+ "repos/dsj1984/domio/actions/permissions/workflow": {
230
+ default_workflow_permissions: "read",
231
+ can_approve_pull_request_reviews: false,
232
+ },
233
+ });
234
+
235
+ const stdout = { write: () => {} };
236
+ const stderr = { write: () => {} };
237
+
238
+ const configPath = join(tmpDir, "consumers.json");
239
+ const baselinePath = join(tmpDir, "baseline.json");
240
+ writeFileSync(configPath, JSON.stringify(config));
241
+ writeFileSync(baselinePath, JSON.stringify(BASELINE));
242
+
243
+ const exitNonStrict = runCli({
244
+ argv: ["--config", configPath, "--baseline", baselinePath],
245
+ cwd: tmpDir,
246
+ stdout,
247
+ stderr,
248
+ runGh,
249
+ summaryPath: undefined,
250
+ });
251
+ assert.equal(exitNonStrict, 0, "non-strict never fails on drift");
252
+
253
+ const exitStrict = runCli({
254
+ argv: ["--config", configPath, "--baseline", baselinePath, "--strict"],
255
+ cwd: tmpDir,
256
+ stdout,
257
+ stderr,
258
+ runGh,
259
+ summaryPath: undefined,
260
+ });
261
+ assert.equal(exitStrict, 1, "--strict fails on drift");
262
+ });
263
+
264
+ test("runCli --json emits a machine-readable envelope with the drift verdict", () => {
265
+ const config = { consumers: [{ name: "swarm-os", repo: "Beestera/swarm-os" }] };
266
+ const runGh = fakeRunGh({
267
+ "repos/Beestera/swarm-os": {
268
+ allow_squash_merge: true,
269
+ allow_merge_commit: false,
270
+ allow_rebase_merge: false,
271
+ squash_merge_commit_title: "PR_TITLE",
272
+ squash_merge_commit_message: "PR_BODY",
273
+ delete_branch_on_merge: true,
274
+ allow_auto_merge: true,
275
+ },
276
+ "repos/Beestera/swarm-os/actions/permissions/workflow": {
277
+ default_workflow_permissions: "read",
278
+ can_approve_pull_request_reviews: false,
279
+ },
280
+ });
281
+
282
+ let out = "";
283
+ const stdout = { write: (s) => (out += s) };
284
+ const stderr = { write: () => {} };
285
+
286
+ const configPath = join(tmpDir, "consumers.json");
287
+ const baselinePath = join(tmpDir, "baseline.json");
288
+ writeFileSync(configPath, JSON.stringify(config));
289
+ writeFileSync(baselinePath, JSON.stringify(BASELINE));
290
+
291
+ const exit = runCli({
292
+ argv: ["--config", configPath, "--baseline", baselinePath, "--json"],
293
+ cwd: tmpDir,
294
+ stdout,
295
+ stderr,
296
+ runGh,
297
+ summaryPath: undefined,
298
+ });
299
+ assert.equal(exit, 0);
300
+ const parsed = JSON.parse(out);
301
+ assert.equal(parsed.kind, "repo-settings-report");
302
+ assert.equal(parsed.drift, false);
303
+ assert.equal(parsed.consumers[0].status, "current");
304
+ });
305
+
306
+ test("runCli exits 1 on a fatal config-read error (missing file)", () => {
307
+ let errOut = "";
308
+ const exit = runCli({
309
+ argv: ["--config", join(tmpDir, "does-not-exist.json")],
310
+ cwd: tmpDir,
311
+ stdout: { write: () => {} },
312
+ stderr: { write: (s) => (errOut += s) },
313
+ runGh: () => {
314
+ throw new Error("should not be called");
315
+ },
316
+ summaryPath: undefined,
317
+ });
318
+ assert.equal(exit, 1);
319
+ assert.match(errOut, /failed to read config/);
320
+ });