mandrel-platform 0.17.0 → 0.17.2

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.17.0",
3
+ "version": "0.17.2",
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": {
@@ -14,11 +14,21 @@
14
14
  */
15
15
 
16
16
  import assert from "node:assert/strict";
17
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
17
+ import { execFileSync } from "node:child_process";
18
+ import {
19
+ mkdtempSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ rmSync,
23
+ writeFileSync,
24
+ } from "node:fs";
18
25
  import { tmpdir } from "node:os";
19
- import { join } from "node:path";
26
+ import { dirname, join } from "node:path";
27
+ import { fileURLToPath } from "node:url";
20
28
  import { test } from "node:test";
21
29
 
30
+ const __dirname = dirname(fileURLToPath(import.meta.url));
31
+
22
32
  import {
23
33
  VALID_METRICS,
24
34
  parseArgs,
@@ -33,11 +43,21 @@ import {
33
43
  } from "./check-coverage-threshold.mjs";
34
44
 
35
45
  // Build a minimal Istanbul/c8/vitest-shaped coverage-summary object.
36
- function summary({ lines = 0, statements = 0, functions = 0, branches = 0 } = {}) {
46
+ function summary({
47
+ lines = 0,
48
+ statements = 0,
49
+ functions = 0,
50
+ branches = 0,
51
+ } = {}) {
37
52
  return {
38
53
  total: {
39
54
  lines: { total: 100, covered: lines, skipped: 0, pct: lines },
40
- statements: { total: 100, covered: statements, skipped: 0, pct: statements },
55
+ statements: {
56
+ total: 100,
57
+ covered: statements,
58
+ skipped: 0,
59
+ pct: statements,
60
+ },
41
61
  functions: { total: 100, covered: functions, skipped: 0, pct: functions },
42
62
  branches: { total: 100, covered: branches, skipped: 0, pct: branches },
43
63
  },
@@ -80,10 +100,14 @@ test("parseArgs defaults: threshold 0, metric lines", () => {
80
100
 
81
101
  test("parseArgs reads --threshold/--metric/--coverage-dir", () => {
82
102
  const opts = parseArgs([
83
- "--threshold", "85",
84
- "--metric", "Statements",
85
- "--coverage-dir", "packages/api/coverage",
86
- "--coverage-dir", "packages/web/coverage",
103
+ "--threshold",
104
+ "85",
105
+ "--metric",
106
+ "Statements",
107
+ "--coverage-dir",
108
+ "packages/api/coverage",
109
+ "--coverage-dir",
110
+ "packages/web/coverage",
87
111
  ]);
88
112
  assert.equal(opts.threshold, 85);
89
113
  assert.equal(opts.metric, "statements"); // normalized lowercase
@@ -98,7 +122,12 @@ test("parseArgs rejects an unknown --metric", () => {
98
122
  });
99
123
 
100
124
  test("VALID_METRICS covers the four Istanbul totals", () => {
101
- assert.deepEqual(VALID_METRICS, ["lines", "statements", "functions", "branches"]);
125
+ assert.deepEqual(VALID_METRICS, [
126
+ "lines",
127
+ "statements",
128
+ "functions",
129
+ "branches",
130
+ ]);
102
131
  });
103
132
 
104
133
  // ---------------------------------------------------------------------------
@@ -141,7 +170,7 @@ test("evaluateGate: threshold 0 is a no-op pass (skipped)", () => {
141
170
  read: () => {
142
171
  throw new Error("read should not run when gate is disabled");
143
172
  },
144
- }
173
+ },
145
174
  );
146
175
  assert.equal(verdict.ok, true);
147
176
  assert.equal(verdict.skipped, true);
@@ -158,7 +187,7 @@ test("evaluateGate: SET + measured above floor → pass", () => {
158
187
  {
159
188
  findSummaries: () => ["/x/coverage/coverage-summary.json"],
160
189
  read: () => summary({ lines: 91 }),
161
- }
190
+ },
162
191
  );
163
192
  assert.equal(verdict.ok, true);
164
193
  assert.equal(verdict.skipped, false);
@@ -172,7 +201,7 @@ test("evaluateGate: SET + measured below floor → fail", () => {
172
201
  {
173
202
  findSummaries: () => ["/x/coverage/coverage-summary.json"],
174
203
  read: () => summary({ lines: 73 }),
175
- }
204
+ },
176
205
  );
177
206
  assert.equal(verdict.ok, false);
178
207
  assert.equal(verdict.results[0].pct, 73);
@@ -185,7 +214,7 @@ test("evaluateGate: SET but no coverage summary found → fail (never silent-pas
185
214
  {
186
215
  findSummaries: () => [],
187
216
  read: () => null,
188
- }
217
+ },
189
218
  );
190
219
  assert.equal(verdict.ok, false);
191
220
  assert.equal(verdict.skipped, false);
@@ -196,9 +225,15 @@ test("evaluateGate: SET, one of many packages below floor → fail", () => {
196
225
  const verdict = evaluateGate(
197
226
  { threshold: 80, metric: "statements", cwd: ".", coverageDirs: [] },
198
227
  {
199
- findSummaries: () => ["a/coverage/coverage-summary.json", "b/coverage/coverage-summary.json"],
200
- read: (f) => (f.startsWith("a") ? summary({ statements: 95 }) : summary({ statements: 40 })),
201
- }
228
+ findSummaries: () => [
229
+ "a/coverage/coverage-summary.json",
230
+ "b/coverage/coverage-summary.json",
231
+ ],
232
+ read: (f) =>
233
+ f.startsWith("a")
234
+ ? summary({ statements: 95 })
235
+ : summary({ statements: 40 }),
236
+ },
202
237
  );
203
238
  assert.equal(verdict.ok, false);
204
239
  assert.equal(verdict.results.length, 2);
@@ -217,24 +252,29 @@ test("findCoverageSummaries auto-scans **/coverage/, pruning node_modules + dott
217
252
  mkdirSync(join(root, "packages", "api", "coverage"), { recursive: true });
218
253
  writeFileSync(
219
254
  join(root, "packages", "api", "coverage", "coverage-summary.json"),
220
- JSON.stringify(summary({ lines: 88 }))
255
+ JSON.stringify(summary({ lines: 88 })),
221
256
  );
222
257
  // A decoy under node_modules that MUST be pruned.
223
- mkdirSync(join(root, "node_modules", "dep", "coverage"), { recursive: true });
258
+ mkdirSync(join(root, "node_modules", "dep", "coverage"), {
259
+ recursive: true,
260
+ });
224
261
  writeFileSync(
225
262
  join(root, "node_modules", "dep", "coverage", "coverage-summary.json"),
226
- JSON.stringify(summary({ lines: 1 }))
263
+ JSON.stringify(summary({ lines: 1 })),
227
264
  );
228
265
  // A decoy under a dotted dir that MUST be pruned.
229
266
  mkdirSync(join(root, ".agents", "coverage"), { recursive: true });
230
267
  writeFileSync(
231
268
  join(root, ".agents", "coverage", "coverage-summary.json"),
232
- JSON.stringify(summary({ lines: 2 }))
269
+ JSON.stringify(summary({ lines: 2 })),
233
270
  );
234
271
 
235
272
  const files = findCoverageSummaries(root);
236
273
  assert.equal(files.length, 1);
237
- assert.match(files[0], /packages[/\\]api[/\\]coverage[/\\]coverage-summary\.json$/);
274
+ assert.match(
275
+ files[0],
276
+ /packages[/\\]api[/\\]coverage[/\\]coverage-summary\.json$/,
277
+ );
238
278
 
239
279
  const parsed = readSummary(files[0]);
240
280
  assert.equal(extractPct(parsed, "lines"), 88);
@@ -251,29 +291,37 @@ test("findCoverageSummaries discovers a per-workspace fan-out layout (coverage/<
251
291
  mkdirSync(join(root, "coverage", "web"), { recursive: true });
252
292
  writeFileSync(
253
293
  join(root, "coverage", "web", "coverage-summary.json"),
254
- JSON.stringify(summary({ lines: 77 }))
294
+ JSON.stringify(summary({ lines: 77 })),
255
295
  );
256
296
  mkdirSync(join(root, "coverage", "shared"), { recursive: true });
257
297
  writeFileSync(
258
298
  join(root, "coverage", "shared", "coverage-summary.json"),
259
- JSON.stringify(summary({ lines: 93 }))
299
+ JSON.stringify(summary({ lines: 93 })),
260
300
  );
261
301
  // Decoys that must still be pruned.
262
- mkdirSync(join(root, "node_modules", "dep", "coverage"), { recursive: true });
302
+ mkdirSync(join(root, "node_modules", "dep", "coverage"), {
303
+ recursive: true,
304
+ });
263
305
  writeFileSync(
264
306
  join(root, "node_modules", "dep", "coverage", "coverage-summary.json"),
265
- JSON.stringify(summary({ lines: 1 }))
307
+ JSON.stringify(summary({ lines: 1 })),
266
308
  );
267
309
  mkdirSync(join(root, ".agents", "coverage"), { recursive: true });
268
310
  writeFileSync(
269
311
  join(root, ".agents", "coverage", "coverage-summary.json"),
270
- JSON.stringify(summary({ lines: 2 }))
312
+ JSON.stringify(summary({ lines: 2 })),
271
313
  );
272
314
 
273
315
  const files = findCoverageSummaries(root).sort();
274
316
  assert.equal(files.length, 2);
275
- assert.ok(files.some((f) => /coverage[/\\]shared[/\\]coverage-summary\.json$/.test(f)));
276
- assert.ok(files.some((f) => /coverage[/\\]web[/\\]coverage-summary\.json$/.test(f)));
317
+ assert.ok(
318
+ files.some((f) =>
319
+ /coverage[/\\]shared[/\\]coverage-summary\.json$/.test(f),
320
+ ),
321
+ );
322
+ assert.ok(
323
+ files.some((f) => /coverage[/\\]web[/\\]coverage-summary\.json$/.test(f)),
324
+ );
277
325
  } finally {
278
326
  rmSync(root, { recursive: true, force: true });
279
327
  }
@@ -285,7 +333,7 @@ test("findCoverageSummaries honours explicit --coverage-dir roots", () => {
285
333
  mkdirSync(join(root, "custom", "cov"), { recursive: true });
286
334
  writeFileSync(
287
335
  join(root, "custom", "cov", "coverage-summary.json"),
288
- JSON.stringify(summary({ lines: 90 }))
336
+ JSON.stringify(summary({ lines: 90 })),
289
337
  );
290
338
  const files = findCoverageSummaries(root, ["custom/cov"]);
291
339
  assert.equal(files.length, 1);
@@ -327,7 +375,7 @@ test("runCli: threshold set, real passing coverage tree → exit 0", () => {
327
375
  mkdirSync(join(root, "coverage"), { recursive: true });
328
376
  writeFileSync(
329
377
  join(root, "coverage", "coverage-summary.json"),
330
- JSON.stringify(summary({ lines: 95 }))
378
+ JSON.stringify(summary({ lines: 95 })),
331
379
  );
332
380
  const out = [];
333
381
  const code = runCli(["--threshold", "80", "--cwd", root], {
@@ -347,7 +395,7 @@ test("runCli: threshold set, real failing coverage tree → exit 1", () => {
347
395
  mkdirSync(join(root, "coverage"), { recursive: true });
348
396
  writeFileSync(
349
397
  join(root, "coverage", "coverage-summary.json"),
350
- JSON.stringify(summary({ lines: 50 }))
398
+ JSON.stringify(summary({ lines: 50 })),
351
399
  );
352
400
  const out = [];
353
401
  const code = runCli(["--threshold", "80", "--cwd", root], {
@@ -378,9 +426,179 @@ test("runCli: threshold set but no coverage data → exit 1", () => {
378
426
 
379
427
  test("formatVerdict renders a skip line for the disabled gate", () => {
380
428
  const lines = formatVerdict({
381
- ok: true, skipped: true, reason: "threshold 0 — coverage gate disabled (no-op)",
382
- threshold: 0, metric: "lines", results: [],
429
+ ok: true,
430
+ skipped: true,
431
+ reason: "threshold 0 — coverage gate disabled (no-op)",
432
+ threshold: 0,
433
+ metric: "lines",
434
+ results: [],
383
435
  });
384
436
  assert.equal(lines.length, 1);
385
437
  assert.match(lines[0], /⏭️/);
386
438
  });
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // pr-quality.yml Coverage threshold gate — inline step regression (#163)
442
+ //
443
+ // The workflow step embeds its OWN copy of the discovery logic (it must stay
444
+ // dependency-free for consumer checkouts that have not adopted
445
+ // check-coverage-threshold.mjs). #158 fixed only the standalone script; this
446
+ // suite's `findCoverageSummaries` tests above cannot catch a regression in the
447
+ // workflow file's embedded JS. These tests extract that embedded node script
448
+ // straight out of pr-quality.yml and run it against real fixture trees, so the
449
+ // exact #163 bug (matching a directory literally named `coverage`, missing
450
+ // `coverage/<workspace>/coverage-summary.json`) cannot survive a refactor.
451
+ // ---------------------------------------------------------------------------
452
+
453
+ // Pull the embedded `node --input-type=module - <<'NODE' … NODE` script body
454
+ // out of the "Coverage threshold gate" step, de-indenting the YAML block
455
+ // scalar so it runs as a standalone ES module.
456
+ function extractGateScript() {
457
+ const yaml = readFileSync(
458
+ join(__dirname, "..", ".github", "workflows", "pr-quality.yml"),
459
+ "utf8",
460
+ );
461
+ const lines = yaml.split(/\r?\n/);
462
+ const startIdx = lines.findIndex((l) =>
463
+ /node --input-type=module - <<'NODE'\s*$/.test(l),
464
+ );
465
+ assert.ok(
466
+ startIdx !== -1,
467
+ "expected an embedded `<<'NODE'` heredoc in pr-quality.yml",
468
+ );
469
+ // The heredoc opener carries the block-scalar indent; every body line shares
470
+ // at least that indent, and the closing `NODE` sentinel sits at the same
471
+ // indent. Strip it uniformly.
472
+ const indent = lines[startIdx].match(/^(\s*)/)[1];
473
+ const body = [];
474
+ for (let i = startIdx + 1; i < lines.length; i += 1) {
475
+ if (lines[i].trim() === "NODE") return body.join("\n");
476
+ body.push(
477
+ lines[i].startsWith(indent) ? lines[i].slice(indent.length) : lines[i],
478
+ );
479
+ }
480
+ throw new Error("unterminated `NODE` heredoc in pr-quality.yml gate step");
481
+ }
482
+
483
+ // Run the extracted gate script with COVERAGE_THRESHOLD / COVERAGE_METRIC set
484
+ // and cwd pointed at `treeRoot`. Returns { status, stdout, stderr }.
485
+ function runGateStep(treeRoot, { threshold = "0", metric = "lines" } = {}) {
486
+ const scriptPath = join(treeRoot, "__gate-step.mjs");
487
+ writeFileSync(scriptPath, extractGateScript());
488
+ try {
489
+ const stdout = execFileSync("node", [scriptPath], {
490
+ cwd: treeRoot,
491
+ env: {
492
+ ...process.env,
493
+ COVERAGE_THRESHOLD: threshold,
494
+ COVERAGE_METRIC: metric,
495
+ },
496
+ encoding: "utf8",
497
+ });
498
+ return { status: 0, stdout, stderr: "" };
499
+ } catch (err) {
500
+ return {
501
+ status: err.status ?? 1,
502
+ stdout: err.stdout?.toString() ?? "",
503
+ stderr: err.stderr?.toString() ?? "",
504
+ };
505
+ }
506
+ }
507
+
508
+ test("pr-quality gate step discovers a per-workspace coverage/<ws>/ layout (#163 regression)", () => {
509
+ const dir = mkdtempSync(join(tmpdir(), "gate-step-fanout-"));
510
+ try {
511
+ // Per-workspace fan-out: NO top-level coverage/coverage-summary.json,
512
+ // only coverage/web/ and coverage/shared/ — the exact shape that the
513
+ // pre-#158 literal-`coverage`-name match missed.
514
+ mkdirSync(join(dir, "coverage", "web"), { recursive: true });
515
+ mkdirSync(join(dir, "coverage", "shared"), { recursive: true });
516
+ writeFileSync(
517
+ join(dir, "coverage", "web", "coverage-summary.json"),
518
+ JSON.stringify(summary({ lines: 95 })),
519
+ );
520
+ writeFileSync(
521
+ join(dir, "coverage", "shared", "coverage-summary.json"),
522
+ JSON.stringify(summary({ lines: 90 })),
523
+ );
524
+
525
+ const res = runGateStep(dir, { threshold: "80", metric: "lines" });
526
+ assert.equal(
527
+ res.status,
528
+ 0,
529
+ `gate step should PASS on the fan-out layout, got exit ${res.status}\n${res.stderr}`,
530
+ );
531
+ // Both per-workspace summaries were discovered and gated — not the
532
+ // "no coverage-summary.json was found" false failure.
533
+ assert.doesNotMatch(res.stderr, /no coverage-summary\.json/);
534
+ assert.match(res.stdout, /coverage[/\\]web[/\\]coverage-summary\.json/);
535
+ assert.match(res.stdout, /coverage[/\\]shared[/\\]coverage-summary\.json/);
536
+ assert.match(res.stdout, /coverage meets the 80% floor/);
537
+ } finally {
538
+ rmSync(dir, { recursive: true, force: true });
539
+ }
540
+ });
541
+
542
+ test("pr-quality gate step: per-workspace summary below floor fails (#163 regression)", () => {
543
+ const dir = mkdtempSync(join(tmpdir(), "gate-step-fanout-fail-"));
544
+ try {
545
+ mkdirSync(join(dir, "coverage", "web"), { recursive: true });
546
+ writeFileSync(
547
+ join(dir, "coverage", "web", "coverage-summary.json"),
548
+ JSON.stringify(summary({ lines: 40 })),
549
+ );
550
+ const res = runGateStep(dir, { threshold: "80", metric: "lines" });
551
+ assert.equal(
552
+ res.status,
553
+ 1,
554
+ "below-floor per-workspace coverage must fail the gate",
555
+ );
556
+ assert.match(res.stderr, /coverage[/\\]web[/\\]coverage-summary\.json/);
557
+ assert.match(res.stderr, /below the 80% floor/);
558
+ } finally {
559
+ rmSync(dir, { recursive: true, force: true });
560
+ }
561
+ });
562
+
563
+ test("pr-quality gate step stays byte-for-byte compatible with the single coverage/ dir shape (#163)", () => {
564
+ const dir = mkdtempSync(join(tmpdir(), "gate-step-single-"));
565
+ try {
566
+ // Legacy single-workspace shape: coverage/coverage-summary.json directly.
567
+ mkdirSync(join(dir, "coverage"), { recursive: true });
568
+ writeFileSync(
569
+ join(dir, "coverage", "coverage-summary.json"),
570
+ JSON.stringify(summary({ lines: 88 })),
571
+ );
572
+ // node_modules + dotted dirs must still be pruned (never read a vendored
573
+ // coverage tree).
574
+ mkdirSync(join(dir, "node_modules", "pkg", "coverage"), {
575
+ recursive: true,
576
+ });
577
+ writeFileSync(
578
+ join(dir, "node_modules", "pkg", "coverage", "coverage-summary.json"),
579
+ JSON.stringify(summary({ lines: 1 })),
580
+ );
581
+ const res = runGateStep(dir, { threshold: "80", metric: "lines" });
582
+ assert.equal(
583
+ res.status,
584
+ 0,
585
+ `single-dir shape should PASS, got exit ${res.status}\n${res.stderr}`,
586
+ );
587
+ assert.match(res.stdout, /coverage[/\\]coverage-summary\.json/);
588
+ assert.doesNotMatch(res.stdout, /node_modules/);
589
+ assert.doesNotMatch(res.stderr, /node_modules/);
590
+ } finally {
591
+ rmSync(dir, { recursive: true, force: true });
592
+ }
593
+ });
594
+
595
+ test("pr-quality gate step still hard-fails when threshold set but no summary exists (#163)", () => {
596
+ const dir = mkdtempSync(join(tmpdir(), "gate-step-empty-"));
597
+ try {
598
+ const res = runGateStep(dir, { threshold: "80", metric: "lines" });
599
+ assert.equal(res.status, 1, "a set floor must not pass on missing data");
600
+ assert.match(res.stderr, /no coverage-summary\.json/);
601
+ } finally {
602
+ rmSync(dir, { recursive: true, force: true });
603
+ }
604
+ });