mandrel-platform 0.20.1 → 0.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "0.20.1",
3
+ "version": "0.24.0",
4
4
  "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-ci-required-aggregator.test.mjs — regression guard for the
4
+ * self-maintaining `ci-required` aggregators (Story #234).
5
+ *
6
+ * Both `ci-required` aggregator jobs — `.github/workflows/pr-quality.yml`
7
+ * (the fleet-wide reusable PR gate) and `.github/workflows/ci.yml` (this
8
+ * repo's own gate) — previously required hand-maintained triple bookkeeping:
9
+ * the `needs:` array, a per-job `env:` block, and a bash loop over hardcoded
10
+ * job names. A job added to `needs:` but forgotten in the env/loop silently
11
+ * passed on a red run — on the platform's sole required branch-protection
12
+ * context.
13
+ *
14
+ * Story #234 replaced both with a `toJSON(needs)`-driven check. This suite
15
+ * pins that design against regression:
16
+ *
17
+ * 1. STRUCTURE — each aggregator's `steps:` derive results from
18
+ * `toJSON(needs)` and contain NO hardcoded reference to any job named in
19
+ * its own `needs:` array (the "no hardcoded tier-name list" AC).
20
+ * 2. PARITY — the two aggregators' `steps:` blocks are textually identical,
21
+ * so a fix to one cannot drift from the other.
22
+ * 3. SEMANTICS — the shared run script passes on `success`/`skipped` and
23
+ * fails on anything else, INCLUDING `cancelled` (load-bearing for #223's
24
+ * fail-fast design), while naming the failing jobs and their results.
25
+ * Executed against real bash+jq; skipped when jq is unavailable locally
26
+ * (CI's ubuntu runner always has it).
27
+ *
28
+ * Run: node --test scripts/check-ci-required-aggregator.test.mjs
29
+ */
30
+
31
+ import assert from "node:assert/strict";
32
+ import { test } from "node:test";
33
+ import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
34
+ import { execFileSync, spawnSync } from "node:child_process";
35
+ import { join, resolve, dirname } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ import { tmpdir } from "node:os";
38
+
39
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
40
+
41
+ const AGGREGATOR_FILES = [
42
+ ".github/workflows/pr-quality.yml",
43
+ ".github/workflows/ci.yml",
44
+ ];
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Minimal indentation-based extraction (dependency-free, mirrors the
48
+ // line-oriented approach of check-workflow-portability.mjs). Both workflows
49
+ // declare jobs at 2-space indent, so the `ci-required` job block runs from
50
+ // its ` ci-required:` line to the next non-blank line at indent <= 2.
51
+ // ---------------------------------------------------------------------------
52
+
53
+ function extractJobBlock(content, jobId) {
54
+ const lines = content.split("\n");
55
+ const start = lines.findIndex((l) => l === ` ${jobId}:`);
56
+ assert.notEqual(start, -1, `job \`${jobId}\` not found`);
57
+ let end = lines.length;
58
+ for (let i = start + 1; i < lines.length; i++) {
59
+ if (/^\s*$/.test(lines[i])) continue;
60
+ const indent = lines[i].match(/^(\s*)/)[1].length;
61
+ if (indent <= 2) {
62
+ end = i;
63
+ break;
64
+ }
65
+ }
66
+ return lines.slice(start, end).join("\n");
67
+ }
68
+
69
+ /** The `- <name>` entries under the job's `needs:` key. */
70
+ function extractNeeds(jobBlock) {
71
+ const lines = jobBlock.split("\n");
72
+ const start = lines.findIndex((l) => l === " needs:");
73
+ assert.notEqual(start, -1, "`needs:` block not found");
74
+ const names = [];
75
+ for (let i = start + 1; i < lines.length; i++) {
76
+ const m = lines[i].match(/^\s+-\s+([A-Za-z0-9_-]+)\s*$/);
77
+ if (!m) break;
78
+ names.push(m[1]);
79
+ }
80
+ assert.ok(names.length > 0, "`needs:` list is empty");
81
+ return names;
82
+ }
83
+
84
+ /** Everything from ` steps:` to the end of the job block. */
85
+ function extractSteps(jobBlock) {
86
+ const idx = jobBlock.indexOf(" steps:");
87
+ assert.notEqual(idx, -1, "`steps:` block not found");
88
+ return jobBlock.slice(idx);
89
+ }
90
+
91
+ /** The dedented body of the (single) `run: |` block scalar in the steps. */
92
+ function extractRunScript(steps) {
93
+ const lines = steps.split("\n");
94
+ const start = lines.findIndex((l) => /^\s+run:\s*\|\s*$/.test(l));
95
+ assert.notEqual(start, -1, "`run: |` block not found");
96
+ const runIndent = lines[start].match(/^(\s*)/)[1].length;
97
+ const body = [];
98
+ for (let i = start + 1; i < lines.length; i++) {
99
+ if (/^\s*$/.test(lines[i])) {
100
+ body.push("");
101
+ continue;
102
+ }
103
+ const indent = lines[i].match(/^(\s*)/)[1].length;
104
+ if (indent <= runIndent) break;
105
+ body.push(lines[i].slice(runIndent + 2));
106
+ }
107
+ return body.join("\n");
108
+ }
109
+
110
+ const blocks = AGGREGATOR_FILES.map((rel) => {
111
+ const content = readFileSync(join(repoRoot, rel), "utf8");
112
+ const job = extractJobBlock(content, "ci-required");
113
+ return { rel, job, needs: extractNeeds(job), steps: extractSteps(job) };
114
+ });
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // 1. STRUCTURE — toJSON(needs)-driven, no hardcoded tier-name list
118
+ // ---------------------------------------------------------------------------
119
+
120
+ for (const { rel, needs, steps } of blocks) {
121
+ test(`${rel}: aggregator derives results from toJSON(needs)`, () => {
122
+ assert.match(
123
+ steps,
124
+ /\$\{\{\s*toJSON\(needs\)\s*\}\}/,
125
+ "the aggregator steps must consume `${{ toJSON(needs) }}`"
126
+ );
127
+ });
128
+
129
+ test(`${rel}: aggregator steps contain no hardcoded tier/job name`, () => {
130
+ // Strip YAML comments — prose may legitimately mention a job; only the
131
+ // executable env/run surface must stay name-free.
132
+ const executable = steps
133
+ .split("\n")
134
+ .filter((l) => !/^\s*#/.test(l))
135
+ .join("\n");
136
+ for (const name of needs) {
137
+ const re = new RegExp(
138
+ `(?<![A-Za-z0-9_-])${name.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}(?![A-Za-z0-9_-])`
139
+ );
140
+ assert.doesNotMatch(
141
+ executable,
142
+ re,
143
+ `steps hardcode \`${name}\` — adding a job to \`needs:\` must be the only edit; ` +
144
+ `never reintroduce the per-job env/loop bookkeeping`
145
+ );
146
+ }
147
+ assert.ok(
148
+ !/needs\.[A-Za-z0-9_-]+\.result/.test(executable),
149
+ "steps must not read per-job `needs.<id>.result` expressions"
150
+ );
151
+ });
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // 2. PARITY — the two implementations are textually identical
156
+ // ---------------------------------------------------------------------------
157
+
158
+ test("pr-quality.yml and ci.yml aggregator steps are textually identical", () => {
159
+ assert.equal(
160
+ blocks[0].steps,
161
+ blocks[1].steps,
162
+ "the two `ci-required` steps blocks must not drift — apply every change to both"
163
+ );
164
+ });
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // 3. SEMANTICS — pass on success/skipped, fail (naming jobs) on anything else
168
+ // ---------------------------------------------------------------------------
169
+
170
+ function jqAvailable() {
171
+ try {
172
+ execFileSync("jq", ["--version"], { stdio: "ignore" });
173
+ return true;
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+
179
+ function runAggregator(needsResults) {
180
+ const script = extractRunScript(blocks[0].steps);
181
+ const dir = mkdtempSync(join(tmpdir(), "ci-required-"));
182
+ try {
183
+ const file = join(dir, "aggregate.sh");
184
+ writeFileSync(file, script);
185
+ const needsJson = Object.fromEntries(
186
+ Object.entries(needsResults).map(([k, result]) => [k, { result, outputs: {} }])
187
+ );
188
+ return spawnSync("bash", [file], {
189
+ encoding: "utf8",
190
+ env: { ...process.env, NEEDS_JSON: JSON.stringify(needsJson) },
191
+ });
192
+ } finally {
193
+ rmSync(dir, { recursive: true, force: true });
194
+ }
195
+ }
196
+
197
+ const semantics = { skip: jqAvailable() ? false : "jq not available on this host" };
198
+
199
+ test("run script: all success → exit 0", semantics, () => {
200
+ const r = runAggregator({ lint: "success", unit: "success" });
201
+ assert.equal(r.status, 0, r.stderr);
202
+ });
203
+
204
+ test("run script: skipped counts as a pass", semantics, () => {
205
+ const r = runAggregator({ lint: "success", e2e: "skipped" });
206
+ assert.equal(r.status, 0, r.stderr);
207
+ });
208
+
209
+ test("run script: failure fails and names the job(result)", semantics, () => {
210
+ const r = runAggregator({ lint: "success", unit: "failure" });
211
+ assert.equal(r.status, 1);
212
+ assert.match(r.stderr, /unit\(failure\)/);
213
+ });
214
+
215
+ test("run script: cancelled fails (load-bearing for fail-fast, #223)", semantics, () => {
216
+ const r = runAggregator({ lint: "success", e2e: "cancelled" });
217
+ assert.equal(r.status, 1);
218
+ assert.match(r.stderr, /e2e\(cancelled\)/);
219
+ });
220
+
221
+ test("run script: every non-passing job is named", semantics, () => {
222
+ const r = runAggregator({ lint: "failure", unit: "cancelled", e2e: "success" });
223
+ assert.equal(r.status, 1);
224
+ assert.match(r.stderr, /lint\(failure\)/);
225
+ assert.match(r.stderr, /unit\(cancelled\)/);
226
+ assert.doesNotMatch(r.stderr, /e2e/);
227
+ });
@@ -481,63 +481,46 @@ test("formatVerdict renders a skip line for the disabled gate", () => {
481
481
  });
482
482
 
483
483
  // ---------------------------------------------------------------------------
484
- // pr-quality.yml Coverage threshold gate — inline step regression (#163)
484
+ // pr-quality.yml Coverage threshold gate — workflow-step parity (#163, #230)
485
485
  //
486
- // The workflow step embeds its OWN copy of the discovery logic (it must stay
487
- // dependency-free for consumer checkouts that have not adopted
488
- // check-coverage-threshold.mjs). #158 fixed only the standalone script; this
489
- // suite's `findCoverageSummaries` tests above cannot catch a regression in the
490
- // workflow file's embedded JS. These tests extract that embedded node script
491
- // straight out of pr-quality.yml and run it against real fixture trees, so the
492
- // exact #163 bug (matching a directory literally named `coverage`, missing
493
- // `coverage/<workspace>/coverage-summary.json`) cannot survive a refactor.
486
+ // The workflow's "Coverage threshold gate" step no longer embeds a copy of
487
+ // this script (Story #230): it sparse-side-checkouts mandrel-platform at
488
+ // `github.job_workflow_sha` into `_mandrel-platform-scripts/` and runs
489
+ // `scripts/check-coverage-threshold.mjs` directly. These tests exercise that
490
+ // exact invocation shape the real script, run from the side-checkout path,
491
+ // with the workflow's `--threshold` / `--metric` args and the consumer
492
+ // checkout as cwd against real fixture trees, so the original #163 bug
493
+ // (matching a directory literally named `coverage`, missing
494
+ // `coverage/<workspace>/coverage-summary.json`) still cannot regress, and a
495
+ // drift between "what the workflow runs" and "what these tests run" is
496
+ // structurally impossible.
494
497
  // ---------------------------------------------------------------------------
495
498
 
496
- // Pull the embedded `node --input-type=module - <<'NODE' NODE` script body
497
- // out of the "Coverage threshold gate" step, de-indenting the YAML block
498
- // scalar so it runs as a standalone ES module.
499
- function extractGateScript() {
500
- const yaml = readFileSync(
501
- join(__dirname, "..", ".github", "workflows", "pr-quality.yml"),
502
- "utf8",
503
- );
504
- const lines = yaml.split(/\r?\n/);
505
- const startIdx = lines.findIndex((l) =>
506
- /node --input-type=module - <<'NODE'\s*$/.test(l),
507
- );
508
- assert.ok(
509
- startIdx !== -1,
510
- "expected an embedded `<<'NODE'` heredoc in pr-quality.yml",
511
- );
512
- // The heredoc opener carries the block-scalar indent; every body line shares
513
- // at least that indent, and the closing `NODE` sentinel sits at the same
514
- // indent. Strip it uniformly.
515
- const indent = lines[startIdx].match(/^(\s*)/)[1];
516
- const body = [];
517
- for (let i = startIdx + 1; i < lines.length; i += 1) {
518
- if (lines[i].trim() === "NODE") return body.join("\n");
519
- body.push(
520
- lines[i].startsWith(indent) ? lines[i].slice(indent.length) : lines[i],
521
- );
522
- }
523
- throw new Error("unterminated `NODE` heredoc in pr-quality.yml gate step");
524
- }
499
+ const WORKFLOW_FILE = join(__dirname, "..", ".github", "workflows", "pr-quality.yml");
500
+ const GATE_SCRIPT = join(__dirname, "check-coverage-threshold.mjs");
525
501
 
526
- // Run the extracted gate script with COVERAGE_THRESHOLD / COVERAGE_METRIC set
527
- // and cwd pointed at `treeRoot`. Returns { status, stdout, stderr }.
502
+ // Mirror the workflow step: materialize the side-checkout layout
503
+ // (`_mandrel-platform-scripts/scripts/check-coverage-threshold.mjs`) inside
504
+ // the fixture tree, then run the script exactly as the workflow does.
528
505
  function runGateStep(treeRoot, { threshold = "0", metric = "lines" } = {}) {
529
- const scriptPath = join(treeRoot, "__gate-step.mjs");
530
- writeFileSync(scriptPath, extractGateScript());
506
+ const sideCheckout = join(treeRoot, "_mandrel-platform-scripts", "scripts");
507
+ mkdirSync(sideCheckout, { recursive: true });
508
+ writeFileSync(
509
+ join(sideCheckout, "check-coverage-threshold.mjs"),
510
+ readFileSync(GATE_SCRIPT, "utf8"),
511
+ );
531
512
  try {
532
- const stdout = execFileSync("node", [scriptPath], {
533
- cwd: treeRoot,
534
- env: {
535
- ...process.env,
536
- COVERAGE_THRESHOLD: threshold,
537
- COVERAGE_METRIC: metric,
538
- },
539
- encoding: "utf8",
540
- });
513
+ const stdout = execFileSync(
514
+ "node",
515
+ [
516
+ join("_mandrel-platform-scripts", "scripts", "check-coverage-threshold.mjs"),
517
+ "--threshold",
518
+ threshold,
519
+ "--metric",
520
+ metric,
521
+ ],
522
+ { cwd: treeRoot, env: { ...process.env }, encoding: "utf8" },
523
+ );
541
524
  return { status: 0, stdout, stderr: "" };
542
525
  } catch (err) {
543
526
  return {
@@ -548,6 +531,28 @@ function runGateStep(treeRoot, { threshold = "0", metric = "lines" } = {}) {
548
531
  }
549
532
  }
550
533
 
534
+ test("pr-quality.yml runs the side-checkout script — no inline coverage heredoc remains (#230)", () => {
535
+ const yaml = readFileSync(WORKFLOW_FILE, "utf8");
536
+ // The gate step invokes the platform script from the side-checkout…
537
+ assert.match(
538
+ yaml,
539
+ /node _mandrel-platform-scripts\/scripts\/check-coverage-threshold\.mjs/,
540
+ "the Coverage threshold gate must run scripts/check-coverage-threshold.mjs from the side-checkout",
541
+ );
542
+ // …and the migration guard does the same.
543
+ assert.match(
544
+ yaml,
545
+ /node _mandrel-platform-scripts\/scripts\/check-destructive-migration\.mjs/,
546
+ "the migration guard must run scripts/check-destructive-migration.mjs from the side-checkout",
547
+ );
548
+ // The old inlined coverage-discovery copy (the #163 drift class) is gone.
549
+ assert.doesNotMatch(
550
+ yaml,
551
+ /findCoverageSummaries|intentionally duplicated|keep the two in sync/i,
552
+ "no inlined copy of the coverage gate may remain in pr-quality.yml",
553
+ );
554
+ });
555
+
551
556
  test("pr-quality gate step discovers a per-workspace coverage/<ws>/ layout (#163 regression)", () => {
552
557
  const dir = mkdtempSync(join(tmpdir(), "gate-step-fanout-"));
553
558
  try {
@@ -36,6 +36,7 @@
36
36
  * node scripts/check-destructive-migration.mjs \
37
37
  * --changed-files <file-with-one-path-per-line> \
38
38
  * [--label-present] \
39
+ * [--override-label <name>] \
39
40
  * [--migration-glob '**​/migrations/**,**​/drizzle/**'] \
40
41
  * [--repo-root <dir>]
41
42
  *
@@ -45,10 +46,18 @@
45
46
  * • --label-present Pass when the override acknowledgement label is on the
46
47
  * PR. Overrides a destructive finding (exit 0 with a
47
48
  * warning) instead of blocking.
49
+ * • --override-label The acknowledgement label NAME to cite in messages and
50
+ * the step summary (behaviour is still driven solely by
51
+ * --label-present). Default `migration:destructive-ok`.
48
52
  * • --migration-glob Comma-separated migration path globs. Default
49
53
  * `**​/migrations/**,**​/drizzle/**`.
50
54
  * • --repo-root Root to resolve changed-file paths against. Default cwd.
51
55
  *
56
+ * When `GITHUB_STEP_SUMMARY` is set (i.e. running inside a GitHub Actions
57
+ * step) and a destructive finding exists, a markdown summary block (ALLOWED
58
+ * via override / BLOCKED) is appended to that file — the same job-summary
59
+ * surface the previous in-workflow bash implementation wrote.
60
+ *
52
61
  * Exit codes:
53
62
  * 0 — no destructive migration in the changed set, OR a destructive
54
63
  * migration is present AND the override label is applied.
@@ -60,7 +69,7 @@
60
69
  * docs/reusable-workflows.md (`pr-quality.yml` → migration guard).
61
70
  */
62
71
 
63
- import { readFileSync } from "node:fs";
72
+ import { appendFileSync, readFileSync } from "node:fs";
64
73
  import { resolve } from "node:path";
65
74
 
66
75
  // The override acknowledgement label. Documented in docs/reusable-workflows.md.
@@ -205,14 +214,47 @@ export function detectDestructiveMigrations({ changedFiles, readFile, globs = DE
205
214
  return { destructive: findings.length > 0, findings };
206
215
  }
207
216
 
217
+ /**
218
+ * Render the GitHub job-summary markdown block for a destructive finding —
219
+ * the same summary surface the previous in-workflow bash implementation
220
+ * appended to `GITHUB_STEP_SUMMARY`. Only called when findings exist.
221
+ *
222
+ * @param {object} opts
223
+ * @param {Array<{file: string, signals: string[]}>} opts.findings
224
+ * @param {boolean} opts.labelPresent
225
+ * @param {string} opts.overrideLabel
226
+ * @returns {string} Markdown, trailing-newline-terminated.
227
+ */
228
+ export function formatStepSummary({ findings, labelPresent, overrideLabel }) {
229
+ const list = findings
230
+ .map((f) => ` • ${f.file} → ${f.signals.join(", ")}`)
231
+ .join("\n");
232
+ if (labelPresent) {
233
+ return (
234
+ "### Destructive-migration guard — ALLOWED via override\n\n" +
235
+ `Override label \`${overrideLabel}\` is present. Findings:\n\n` +
236
+ `${list}\n`
237
+ );
238
+ }
239
+ return (
240
+ "### ❌ Destructive-migration guard — BLOCKED\n\n" +
241
+ "A destructive migration was detected and the override label\n" +
242
+ `\`${overrideLabel}\` is NOT applied. Findings:\n\n` +
243
+ `${list}\n\n` +
244
+ `A reviewer must apply the \`${overrideLabel}\` label to\n` +
245
+ "acknowledge the destructive change, then re-run this check.\n"
246
+ );
247
+ }
248
+
208
249
  // ---------------------------------------------------------------------------
209
250
  // CLI
210
251
  // ---------------------------------------------------------------------------
211
252
 
212
- function parseArgs(argv) {
253
+ export function parseArgs(argv) {
213
254
  const opts = {
214
255
  changedFiles: null,
215
256
  labelPresent: false,
257
+ overrideLabel: DEFAULT_OVERRIDE_LABEL,
216
258
  globs: DEFAULT_MIGRATION_GLOBS,
217
259
  repoRoot: process.cwd(),
218
260
  };
@@ -222,6 +264,8 @@ function parseArgs(argv) {
222
264
  opts.changedFiles = argv[++i];
223
265
  } else if (a === "--label-present") {
224
266
  opts.labelPresent = true;
267
+ } else if (a === "--override-label" && argv[i + 1]) {
268
+ opts.overrideLabel = argv[++i];
225
269
  } else if (a === "--migration-glob" && argv[i + 1]) {
226
270
  opts.globs = argv[++i]
227
271
  .split(",")
@@ -252,7 +296,8 @@ function main() {
252
296
  if (opts.help) {
253
297
  process.stdout.write(
254
298
  "Usage: node scripts/check-destructive-migration.mjs --changed-files <path|-> " +
255
- "[--label-present] [--migration-glob <csv>] [--repo-root <dir>]\n"
299
+ "[--label-present] [--override-label <name>] [--migration-glob <csv>] " +
300
+ "[--repo-root <dir>]\n"
256
301
  );
257
302
  process.exit(0);
258
303
  }
@@ -290,18 +335,35 @@ function main() {
290
335
  .map((f) => ` • ${f.file} → ${f.signals.join(", ")}`)
291
336
  .join("\n");
292
337
 
338
+ // Inside a GitHub Actions step, mirror the finding onto the job summary —
339
+ // the same surface the previous in-workflow bash implementation wrote.
340
+ if (process.env.GITHUB_STEP_SUMMARY) {
341
+ try {
342
+ appendFileSync(
343
+ process.env.GITHUB_STEP_SUMMARY,
344
+ formatStepSummary({
345
+ findings,
346
+ labelPresent: opts.labelPresent,
347
+ overrideLabel: opts.overrideLabel,
348
+ })
349
+ );
350
+ } catch {
351
+ // Best-effort: the exit code below is the gate, the summary is cosmetic.
352
+ }
353
+ }
354
+
293
355
  if (opts.labelPresent) {
294
356
  process.stdout.write(
295
357
  `⚠️ Destructive migration detected, but the override label ` +
296
- `'${DEFAULT_OVERRIDE_LABEL}' is applied — allowing.\n${summary}\n`
358
+ `'${opts.overrideLabel}' is applied — allowing.\n${summary}\n`
297
359
  );
298
360
  process.exit(0);
299
361
  }
300
362
 
301
363
  process.stderr.write(
302
364
  `❌ Destructive migration detected and the override label ` +
303
- `'${DEFAULT_OVERRIDE_LABEL}' is NOT applied — blocking.\n${summary}\n\n` +
304
- `To proceed, a reviewer must apply the '${DEFAULT_OVERRIDE_LABEL}' label ` +
365
+ `'${opts.overrideLabel}' is NOT applied — blocking.\n${summary}\n\n` +
366
+ `To proceed, a reviewer must apply the '${opts.overrideLabel}' label ` +
305
367
  `to acknowledge the destructive change, then re-run this check.\n`
306
368
  );
307
369
  process.exit(1);
@@ -14,18 +14,27 @@
14
14
  */
15
15
 
16
16
  import assert from "node:assert/strict";
17
+ import { execFileSync } from "node:child_process";
18
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { fileURLToPath } from "node:url";
21
+ import { dirname, join } from "node:path";
17
22
  import { test } from "node:test";
18
23
 
19
24
  import {
20
25
  DEFAULT_MIGRATION_GLOBS,
21
26
  DEFAULT_OVERRIDE_LABEL,
22
27
  detectDestructiveMigrations,
28
+ formatStepSummary,
23
29
  globToRegExp,
24
30
  isMigrationFile,
31
+ parseArgs,
25
32
  scanMigrationText,
26
33
  stripComments,
27
34
  } from "./check-destructive-migration.mjs";
28
35
 
36
+ const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "check-destructive-migration.mjs");
37
+
29
38
  // Build a readFile seam from an in-memory { path: text } map.
30
39
  function fakeReader(files) {
31
40
  return (path) => {
@@ -181,3 +190,140 @@ test("override label and default globs are the documented contract values", () =
181
190
  assert.equal(DEFAULT_OVERRIDE_LABEL, "migration:destructive-ok");
182
191
  assert.deepEqual(DEFAULT_MIGRATION_GLOBS, ["**/migrations/**", "**/drizzle/**"]);
183
192
  });
193
+
194
+ // ── parseArgs: the CLI surface pr-quality.yml drives ───────────────────────
195
+
196
+ test("parseArgs: defaults", () => {
197
+ const opts = parseArgs([]);
198
+ assert.equal(opts.changedFiles, null);
199
+ assert.equal(opts.labelPresent, false);
200
+ assert.equal(opts.overrideLabel, DEFAULT_OVERRIDE_LABEL);
201
+ assert.deepEqual(opts.globs, DEFAULT_MIGRATION_GLOBS);
202
+ });
203
+
204
+ test("parseArgs: --override-label, --label-present, --migration-glob, --changed-files", () => {
205
+ const opts = parseArgs([
206
+ "--changed-files", "-",
207
+ "--label-present",
208
+ "--override-label", "db:drop-ok",
209
+ "--migration-glob", "**/db/changes/**, **/sql/**",
210
+ ]);
211
+ assert.equal(opts.changedFiles, "-");
212
+ assert.equal(opts.labelPresent, true);
213
+ assert.equal(opts.overrideLabel, "db:drop-ok");
214
+ assert.deepEqual(opts.globs, ["**/db/changes/**", "**/sql/**"]);
215
+ });
216
+
217
+ // ── formatStepSummary: the job-summary block parity contract ────────────────
218
+
219
+ test("formatStepSummary: blocked shape names the override label and findings", () => {
220
+ const md = formatStepSummary({
221
+ findings: [{ file: "db/migrations/0010_drop.sql", signals: ["DROP statement"] }],
222
+ labelPresent: false,
223
+ overrideLabel: "migration:destructive-ok",
224
+ });
225
+ assert.ok(md.includes("### ❌ Destructive-migration guard — BLOCKED"));
226
+ assert.ok(md.includes("`migration:destructive-ok`"));
227
+ assert.ok(md.includes("db/migrations/0010_drop.sql → DROP statement"));
228
+ });
229
+
230
+ test("formatStepSummary: allowed shape reports the finding without blocking language", () => {
231
+ const md = formatStepSummary({
232
+ findings: [{ file: "db/migrations/0010_drop.sql", signals: ["TRUNCATE"] }],
233
+ labelPresent: true,
234
+ overrideLabel: "db:drop-ok",
235
+ });
236
+ assert.ok(md.includes("### Destructive-migration guard — ALLOWED via override"));
237
+ assert.ok(md.includes("`db:drop-ok`"));
238
+ assert.ok(!md.includes("BLOCKED"));
239
+ });
240
+
241
+ // ── CLI end-to-end: exit codes are the gate (behavioural-parity proof) ──────
242
+
243
+ function runCli(args, { input = "", env = {} } = {}) {
244
+ try {
245
+ const stdout = execFileSync(process.execPath, [SCRIPT, ...args], {
246
+ input,
247
+ encoding: "utf8",
248
+ env: { ...process.env, GITHUB_STEP_SUMMARY: "", ...env },
249
+ });
250
+ return { code: 0, stdout, stderr: "" };
251
+ } catch (err) {
252
+ return { code: err.status, stdout: err.stdout ?? "", stderr: err.stderr ?? "" };
253
+ }
254
+ }
255
+
256
+ test("CLI: destructive migration without the label blocks (exit 1)", () => {
257
+ const root = mkdtempSync(join(tmpdir(), "destmig-"));
258
+ try {
259
+ mkdirSync(join(root, "db", "migrations"), { recursive: true });
260
+ writeFileSync(join(root, "db", "migrations", "0010_drop.sql"), "DROP TABLE users;\n");
261
+ const res = runCli(
262
+ ["--changed-files", "-", "--repo-root", root, "--override-label", "db:drop-ok"],
263
+ { input: "db/migrations/0010_drop.sql\n" }
264
+ );
265
+ assert.equal(res.code, 1);
266
+ assert.ok(res.stderr.includes("'db:drop-ok' is NOT applied — blocking"));
267
+ } finally {
268
+ rmSync(root, { recursive: true, force: true });
269
+ }
270
+ });
271
+
272
+ test("CLI: the override label downgrades the block to a warning (exit 0)", () => {
273
+ const root = mkdtempSync(join(tmpdir(), "destmig-"));
274
+ try {
275
+ mkdirSync(join(root, "db", "migrations"), { recursive: true });
276
+ writeFileSync(join(root, "db", "migrations", "0010_drop.sql"), "DROP TABLE users;\n");
277
+ const res = runCli(
278
+ ["--changed-files", "-", "--repo-root", root, "--label-present"],
279
+ { input: "db/migrations/0010_drop.sql\n" }
280
+ );
281
+ assert.equal(res.code, 0);
282
+ assert.ok(res.stdout.includes(`'${DEFAULT_OVERRIDE_LABEL}' is applied — allowing`));
283
+ } finally {
284
+ rmSync(root, { recursive: true, force: true });
285
+ }
286
+ });
287
+
288
+ test("CLI: comment-only DROP does not trip the guard (exit 0)", () => {
289
+ const root = mkdtempSync(join(tmpdir(), "destmig-"));
290
+ try {
291
+ mkdirSync(join(root, "db", "migrations"), { recursive: true });
292
+ writeFileSync(
293
+ join(root, "db", "migrations", "0011_note.sql"),
294
+ "-- DROP TABLE users; (rolled back)\nALTER TABLE users ADD COLUMN nickname TEXT;\n"
295
+ );
296
+ const res = runCli(["--changed-files", "-", "--repo-root", root], {
297
+ input: "db/migrations/0011_note.sql\n",
298
+ });
299
+ assert.equal(res.code, 0);
300
+ assert.ok(res.stdout.includes("No destructive migration detected"));
301
+ } finally {
302
+ rmSync(root, { recursive: true, force: true });
303
+ }
304
+ });
305
+
306
+ test("CLI: missing --changed-files is a usage error (exit 2)", () => {
307
+ const res = runCli([]);
308
+ assert.equal(res.code, 2);
309
+ assert.ok(res.stderr.includes("--changed-files"));
310
+ });
311
+
312
+ test("CLI: a finding writes the GITHUB_STEP_SUMMARY block when the env var is set", () => {
313
+ const root = mkdtempSync(join(tmpdir(), "destmig-"));
314
+ try {
315
+ mkdirSync(join(root, "db", "migrations"), { recursive: true });
316
+ writeFileSync(join(root, "db", "migrations", "0010_drop.sql"), "TRUNCATE audit_log;\n");
317
+ const summaryFile = join(root, "step-summary.md");
318
+ const res = runCli(
319
+ ["--changed-files", "-", "--repo-root", root],
320
+ { input: "db/migrations/0010_drop.sql\n", env: { GITHUB_STEP_SUMMARY: summaryFile } }
321
+ );
322
+ assert.equal(res.code, 1);
323
+ const summary = readFileSync(summaryFile, "utf8");
324
+ assert.ok(summary.includes("### ❌ Destructive-migration guard — BLOCKED"));
325
+ assert.ok(summary.includes("TRUNCATE"));
326
+ } finally {
327
+ rmSync(root, { recursive: true, force: true });
328
+ }
329
+ });