mandrel-platform 0.12.0 → 0.14.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.
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-coverage-threshold.mjs
4
+ *
5
+ * Optional coverage-floor gate for the shared `pr-quality.yml` reusable
6
+ * workflow (Story #109).
7
+ *
8
+ * `pr-quality.yml` already uploads the `coverage/` tree as a build artifact,
9
+ * but no job asserts a floor — a PR can drop coverage and `ci-required` stays
10
+ * green.
11
+ * The `.agents/` harness ships a CRAP/MI/coverage *ratchet*, but the shared CI
12
+ * workflow itself had no coverage floor an operator could opt into at the
13
+ * workflow layer. This script is that floor: the `unit` job runs it with the
14
+ * `coverage-threshold` workflow input, and a non-zero exit fails the job —
15
+ * which is a `needs:` of `ci-required`.
16
+ *
17
+ * Design constraints:
18
+ * • OPT-IN. A threshold of 0 (the default) is a no-op: the gate prints a
19
+ * skip note and exits 0, preserving today's behaviour for non-adopters.
20
+ * • No new tooling for consumers. The coverage source is the EXISTING
21
+ * coverage output. We read the standard Istanbul/c8/vitest
22
+ * `coverage-summary.json` (`total.<metric>.pct`) — the same file the test
23
+ * runners already emit alongside the artifact upload.
24
+ * • Dependency-free (no YAML/JSON-schema libs) so it copies cleanly into any
25
+ * consumer's `scripts/` directory, exactly like the other shared lints.
26
+ *
27
+ * Usage:
28
+ * node scripts/check-coverage-threshold.mjs --threshold 80
29
+ * node scripts/check-coverage-threshold.mjs --threshold 80 --metric statements
30
+ * node scripts/check-coverage-threshold.mjs --threshold 80 --coverage-dir packages/api/coverage
31
+ *
32
+ * Flags:
33
+ * --threshold <pct> Minimum coverage percentage (0 disables the gate).
34
+ * --metric <name> Which summary metric to assert: lines | statements |
35
+ * functions | branches. Default: lines.
36
+ * --coverage-dir <d> Override the coverage directory glob root. May be
37
+ * repeated. Default: scan the working tree for every
38
+ * coverage/coverage-summary.json under it.
39
+ * --cwd <dir> Root to resolve coverage paths against. Default: cwd.
40
+ *
41
+ * Exit codes:
42
+ * 0 — gate disabled (threshold 0), or measured coverage ≥ threshold.
43
+ * 1 — measured coverage below the threshold, OR the threshold is set but no
44
+ * coverage summary could be found / parsed (a set floor must never pass
45
+ * silently on missing data).
46
+ */
47
+
48
+ import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
49
+ import { join, resolve } from "node:path";
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Pure helpers (exported for the sibling node:test suite)
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export const VALID_METRICS = ["lines", "statements", "functions", "branches"];
56
+
57
+ /**
58
+ * Parse the CLI argv (array AFTER `node script.mjs`) into an options object.
59
+ * Throws on a malformed numeric threshold or an unknown metric so the gate
60
+ * fails loudly rather than silently mis-reading its own configuration.
61
+ */
62
+ export function parseArgs(argv) {
63
+ const opts = {
64
+ threshold: 0,
65
+ metric: "lines",
66
+ coverageDirs: [],
67
+ cwd: process.cwd(),
68
+ };
69
+ for (let i = 0; i < argv.length; i++) {
70
+ const arg = argv[i];
71
+ if ((arg === "--threshold" || arg === "-t") && argv[i + 1] !== undefined) {
72
+ opts.threshold = parseThreshold(argv[++i]);
73
+ } else if ((arg === "--metric" || arg === "-m") && argv[i + 1] !== undefined) {
74
+ const metric = String(argv[++i]).trim().toLowerCase();
75
+ if (!VALID_METRICS.includes(metric)) {
76
+ throw new Error(
77
+ `unknown --metric "${metric}" (expected one of: ${VALID_METRICS.join(", ")})`
78
+ );
79
+ }
80
+ opts.metric = metric;
81
+ } else if (arg === "--coverage-dir" && argv[i + 1] !== undefined) {
82
+ opts.coverageDirs.push(String(argv[++i]));
83
+ } else if (arg === "--cwd" && argv[i + 1] !== undefined) {
84
+ opts.cwd = String(argv[++i]);
85
+ }
86
+ }
87
+ return opts;
88
+ }
89
+
90
+ /**
91
+ * Coerce a raw threshold token into a number in [0, 100]. An empty / unset
92
+ * value is treated as 0 (gate off), mirroring the workflow input default.
93
+ * Throws on a non-numeric or out-of-range value.
94
+ */
95
+ export function parseThreshold(raw) {
96
+ if (raw === undefined || raw === null || String(raw).trim() === "") return 0;
97
+ const n = Number(String(raw).trim());
98
+ if (!Number.isFinite(n)) {
99
+ throw new Error(`invalid --threshold "${raw}" (must be a number)`);
100
+ }
101
+ if (n < 0 || n > 100) {
102
+ throw new Error(`--threshold ${n} out of range (must be between 0 and 100)`);
103
+ }
104
+ return n;
105
+ }
106
+
107
+ /**
108
+ * Extract `total.<metric>.pct` from a parsed coverage-summary.json object.
109
+ * Returns a finite number, or null when the shape doesn't carry it.
110
+ */
111
+ export function extractPct(summary, metric) {
112
+ if (!summary || typeof summary !== "object") return null;
113
+ const total = summary.total;
114
+ if (!total || typeof total !== "object") return null;
115
+ const entry = total[metric];
116
+ if (!entry || typeof entry !== "object") return null;
117
+ const pct = entry.pct;
118
+ return typeof pct === "number" && Number.isFinite(pct) ? pct : null;
119
+ }
120
+
121
+ /**
122
+ * Decide pass/fail for a single measured pct against a threshold. The gate is
123
+ * inclusive: measured === threshold PASSES (a floor of 80 admits exactly 80%).
124
+ */
125
+ export function meetsThreshold(pct, threshold) {
126
+ return typeof pct === "number" && Number.isFinite(pct) && pct >= threshold;
127
+ }
128
+
129
+ /**
130
+ * Recursively find every `coverage-summary.json` under `coverage/` directories
131
+ * below `root`. `node_modules` and dotted dirs (e.g. `.git`, `.agents`) are
132
+ * pruned so the scan stays fast and never reads a vendored framework tree.
133
+ * `roots` (from `--coverage-dir`) overrides the auto-scan when provided.
134
+ */
135
+ export function findCoverageSummaries(root, roots = []) {
136
+ if (roots.length > 0) {
137
+ const out = [];
138
+ for (const dir of roots) {
139
+ const abs = resolve(root, dir);
140
+ const file = join(abs, "coverage-summary.json");
141
+ if (existsSync(file)) out.push(file);
142
+ }
143
+ return out;
144
+ }
145
+
146
+ const found = [];
147
+ const walk = (dir) => {
148
+ let entries;
149
+ try {
150
+ entries = readdirSync(dir, { withFileTypes: true });
151
+ } catch {
152
+ return;
153
+ }
154
+ for (const entry of entries) {
155
+ const name = entry.name;
156
+ if (!entry.isDirectory()) continue;
157
+ if (name === "node_modules" || name.startsWith(".")) continue;
158
+ const full = join(dir, name);
159
+ if (name === "coverage") {
160
+ const file = join(full, "coverage-summary.json");
161
+ if (existsSync(file)) found.push(file);
162
+ // A coverage dir may still nest sub-package coverage; keep walking.
163
+ }
164
+ walk(full);
165
+ }
166
+ };
167
+ walk(resolve(root));
168
+ return found;
169
+ }
170
+
171
+ /**
172
+ * Read + parse a coverage-summary.json file. Returns the parsed object, or
173
+ * null on a read / JSON-parse failure (the caller treats this as "no data").
174
+ */
175
+ export function readSummary(file) {
176
+ try {
177
+ return JSON.parse(readFileSync(file, "utf8"));
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Core gate evaluation, decoupled from argv + process so the test suite can
185
+ * drive it directly. Returns a structured verdict:
186
+ * { ok, skipped, reason, threshold, metric, results: [{ file, pct, ok }] }
187
+ */
188
+ export function evaluateGate(opts, { findSummaries = findCoverageSummaries, read = readSummary } = {}) {
189
+ const { threshold, metric, cwd, coverageDirs } = opts;
190
+
191
+ if (threshold <= 0) {
192
+ return {
193
+ ok: true,
194
+ skipped: true,
195
+ reason: "threshold 0 — coverage gate disabled (no-op)",
196
+ threshold,
197
+ metric,
198
+ results: [],
199
+ };
200
+ }
201
+
202
+ const files = findSummaries(cwd, coverageDirs);
203
+ if (files.length === 0) {
204
+ return {
205
+ ok: false,
206
+ skipped: false,
207
+ reason:
208
+ "coverage threshold is set but no coverage-summary.json was found under " +
209
+ "any **/coverage/ directory — ensure the test step emits a json-summary " +
210
+ "reporter (a set floor must not pass on missing data)",
211
+ threshold,
212
+ metric,
213
+ results: [],
214
+ };
215
+ }
216
+
217
+ const results = [];
218
+ for (const file of files) {
219
+ const summary = read(file);
220
+ const pct = extractPct(summary, metric);
221
+ if (pct === null) {
222
+ results.push({ file, pct: null, ok: false });
223
+ } else {
224
+ results.push({ file, pct, ok: meetsThreshold(pct, threshold) });
225
+ }
226
+ }
227
+
228
+ const failures = results.filter((r) => !r.ok);
229
+ return {
230
+ ok: failures.length === 0,
231
+ skipped: false,
232
+ reason: failures.length === 0 ? "all coverage summaries meet the floor" : "below floor",
233
+ threshold,
234
+ metric,
235
+ results,
236
+ };
237
+ }
238
+
239
+ /** Render the verdict to human-readable lines for the workflow log. */
240
+ export function formatVerdict(verdict) {
241
+ const lines = [];
242
+ if (verdict.skipped) {
243
+ lines.push(`[coverage-threshold] ⏭️ ${verdict.reason}`);
244
+ return lines;
245
+ }
246
+ if (verdict.results.length === 0) {
247
+ lines.push(`[coverage-threshold] ❌ ${verdict.reason}`);
248
+ return lines;
249
+ }
250
+ for (const r of verdict.results) {
251
+ if (r.pct === null) {
252
+ lines.push(
253
+ `[coverage-threshold] ❌ ${r.file}: no "${verdict.metric}" total.pct in summary`
254
+ );
255
+ } else {
256
+ const mark = r.ok ? "✅" : "❌";
257
+ lines.push(
258
+ `[coverage-threshold] ${mark} ${r.file}: ${verdict.metric} ${r.pct}% ` +
259
+ `(floor ${verdict.threshold}%)`
260
+ );
261
+ }
262
+ }
263
+ if (verdict.ok) {
264
+ lines.push(
265
+ `[coverage-threshold] ✅ ${verdict.metric} coverage meets the ${verdict.threshold}% floor.`
266
+ );
267
+ } else {
268
+ lines.push(
269
+ `[coverage-threshold] ❌ ${verdict.metric} coverage is below the ${verdict.threshold}% floor.`
270
+ );
271
+ }
272
+ return lines;
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // CLI entrypoint (skipped under `node --test` import)
277
+ // ---------------------------------------------------------------------------
278
+
279
+ export function runCli(argv, { log = console.log, err = console.error } = {}) {
280
+ let opts;
281
+ try {
282
+ opts = parseArgs(argv);
283
+ } catch (e) {
284
+ err(`[coverage-threshold] ❌ ${e.message}`);
285
+ return 1;
286
+ }
287
+
288
+ const verdict = evaluateGate(opts);
289
+ for (const line of formatVerdict(verdict)) {
290
+ (verdict.ok ? log : err)(line);
291
+ }
292
+ return verdict.ok ? 0 : 1;
293
+ }
294
+
295
+ // Only run when executed directly, not when imported by the test suite.
296
+ const invokedDirectly =
297
+ process.argv[1] && resolve(process.argv[1]).endsWith("check-coverage-threshold.mjs");
298
+ if (invokedDirectly) {
299
+ process.exit(runCli(process.argv.slice(2)));
300
+ }
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-coverage-threshold.test.mjs — node:test suite for the optional
4
+ * coverage-floor gate that backs `pr-quality.yml`'s `coverage-threshold`
5
+ * input (Story #109).
6
+ *
7
+ * This is the "equivalent self-test" the Story's acceptance criteria call for:
8
+ * it exercises the gate with the threshold both UNSET (0 → no-op, exit 0) and
9
+ * SET (pass when measured ≥ floor, fail when below, fail when the floor is set
10
+ * but no coverage summary exists). Pure helpers + an injectable summary
11
+ * source keep the whole pipeline offline — no real coverage tree needed.
12
+ *
13
+ * Run: node scripts/check-coverage-threshold.test.mjs (or `node --test scripts/`)
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { test } from "node:test";
21
+
22
+ import {
23
+ VALID_METRICS,
24
+ parseArgs,
25
+ parseThreshold,
26
+ extractPct,
27
+ meetsThreshold,
28
+ findCoverageSummaries,
29
+ readSummary,
30
+ evaluateGate,
31
+ formatVerdict,
32
+ runCli,
33
+ } from "./check-coverage-threshold.mjs";
34
+
35
+ // Build a minimal Istanbul/c8/vitest-shaped coverage-summary object.
36
+ function summary({ lines = 0, statements = 0, functions = 0, branches = 0 } = {}) {
37
+ return {
38
+ total: {
39
+ lines: { total: 100, covered: lines, skipped: 0, pct: lines },
40
+ statements: { total: 100, covered: statements, skipped: 0, pct: statements },
41
+ functions: { total: 100, covered: functions, skipped: 0, pct: functions },
42
+ branches: { total: 100, covered: branches, skipped: 0, pct: branches },
43
+ },
44
+ };
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // parseThreshold
49
+ // ---------------------------------------------------------------------------
50
+
51
+ test("parseThreshold treats empty/unset as 0 (gate off)", () => {
52
+ assert.equal(parseThreshold(""), 0);
53
+ assert.equal(parseThreshold(" "), 0);
54
+ assert.equal(parseThreshold(undefined), 0);
55
+ assert.equal(parseThreshold(null), 0);
56
+ });
57
+
58
+ test("parseThreshold coerces numeric strings", () => {
59
+ assert.equal(parseThreshold("80"), 80);
60
+ assert.equal(parseThreshold("0"), 0);
61
+ assert.equal(parseThreshold("99.5"), 99.5);
62
+ });
63
+
64
+ test("parseThreshold rejects non-numeric and out-of-range values", () => {
65
+ assert.throws(() => parseThreshold("abc"), /must be a number/);
66
+ assert.throws(() => parseThreshold("-1"), /out of range/);
67
+ assert.throws(() => parseThreshold("101"), /out of range/);
68
+ });
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // parseArgs
72
+ // ---------------------------------------------------------------------------
73
+
74
+ test("parseArgs defaults: threshold 0, metric lines", () => {
75
+ const opts = parseArgs([]);
76
+ assert.equal(opts.threshold, 0);
77
+ assert.equal(opts.metric, "lines");
78
+ assert.deepEqual(opts.coverageDirs, []);
79
+ });
80
+
81
+ test("parseArgs reads --threshold/--metric/--coverage-dir", () => {
82
+ const opts = parseArgs([
83
+ "--threshold", "85",
84
+ "--metric", "Statements",
85
+ "--coverage-dir", "packages/api/coverage",
86
+ "--coverage-dir", "packages/web/coverage",
87
+ ]);
88
+ assert.equal(opts.threshold, 85);
89
+ assert.equal(opts.metric, "statements"); // normalized lowercase
90
+ assert.deepEqual(opts.coverageDirs, [
91
+ "packages/api/coverage",
92
+ "packages/web/coverage",
93
+ ]);
94
+ });
95
+
96
+ test("parseArgs rejects an unknown --metric", () => {
97
+ assert.throws(() => parseArgs(["--metric", "nonsense"]), /unknown --metric/);
98
+ });
99
+
100
+ test("VALID_METRICS covers the four Istanbul totals", () => {
101
+ assert.deepEqual(VALID_METRICS, ["lines", "statements", "functions", "branches"]);
102
+ });
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // extractPct / meetsThreshold
106
+ // ---------------------------------------------------------------------------
107
+
108
+ test("extractPct pulls total.<metric>.pct", () => {
109
+ const s = summary({ lines: 82, branches: 71 });
110
+ assert.equal(extractPct(s, "lines"), 82);
111
+ assert.equal(extractPct(s, "branches"), 71);
112
+ });
113
+
114
+ test("extractPct returns null for malformed shapes", () => {
115
+ assert.equal(extractPct(null, "lines"), null);
116
+ assert.equal(extractPct({}, "lines"), null);
117
+ assert.equal(extractPct({ total: {} }, "lines"), null);
118
+ assert.equal(extractPct({ total: { lines: {} } }, "lines"), null);
119
+ assert.equal(extractPct({ total: { lines: { pct: "x" } } }, "lines"), null);
120
+ });
121
+
122
+ test("meetsThreshold is inclusive at the floor", () => {
123
+ assert.equal(meetsThreshold(80, 80), true);
124
+ assert.equal(meetsThreshold(80.01, 80), true);
125
+ assert.equal(meetsThreshold(79.99, 80), false);
126
+ assert.equal(meetsThreshold(null, 80), false);
127
+ });
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // evaluateGate — threshold UNSET (the non-adopter no-op path)
131
+ // ---------------------------------------------------------------------------
132
+
133
+ test("evaluateGate: threshold 0 is a no-op pass (skipped)", () => {
134
+ const verdict = evaluateGate(
135
+ { threshold: 0, metric: "lines", cwd: ".", coverageDirs: [] },
136
+ {
137
+ // These MUST NOT be consulted when the gate is off.
138
+ findSummaries: () => {
139
+ throw new Error("findSummaries should not run when gate is disabled");
140
+ },
141
+ read: () => {
142
+ throw new Error("read should not run when gate is disabled");
143
+ },
144
+ }
145
+ );
146
+ assert.equal(verdict.ok, true);
147
+ assert.equal(verdict.skipped, true);
148
+ assert.match(verdict.reason, /disabled/);
149
+ });
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // evaluateGate — threshold SET
153
+ // ---------------------------------------------------------------------------
154
+
155
+ test("evaluateGate: SET + measured above floor → pass", () => {
156
+ const verdict = evaluateGate(
157
+ { threshold: 80, metric: "lines", cwd: ".", coverageDirs: [] },
158
+ {
159
+ findSummaries: () => ["/x/coverage/coverage-summary.json"],
160
+ read: () => summary({ lines: 91 }),
161
+ }
162
+ );
163
+ assert.equal(verdict.ok, true);
164
+ assert.equal(verdict.skipped, false);
165
+ assert.equal(verdict.results[0].pct, 91);
166
+ assert.equal(verdict.results[0].ok, true);
167
+ });
168
+
169
+ test("evaluateGate: SET + measured below floor → fail", () => {
170
+ const verdict = evaluateGate(
171
+ { threshold: 80, metric: "lines", cwd: ".", coverageDirs: [] },
172
+ {
173
+ findSummaries: () => ["/x/coverage/coverage-summary.json"],
174
+ read: () => summary({ lines: 73 }),
175
+ }
176
+ );
177
+ assert.equal(verdict.ok, false);
178
+ assert.equal(verdict.results[0].pct, 73);
179
+ assert.equal(verdict.results[0].ok, false);
180
+ });
181
+
182
+ test("evaluateGate: SET but no coverage summary found → fail (never silent-pass)", () => {
183
+ const verdict = evaluateGate(
184
+ { threshold: 80, metric: "lines", cwd: ".", coverageDirs: [] },
185
+ {
186
+ findSummaries: () => [],
187
+ read: () => null,
188
+ }
189
+ );
190
+ assert.equal(verdict.ok, false);
191
+ assert.equal(verdict.skipped, false);
192
+ assert.match(verdict.reason, /no coverage-summary\.json was found/);
193
+ });
194
+
195
+ test("evaluateGate: SET, one of many packages below floor → fail", () => {
196
+ const verdict = evaluateGate(
197
+ { threshold: 80, metric: "statements", cwd: ".", coverageDirs: [] },
198
+ {
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
+ }
202
+ );
203
+ assert.equal(verdict.ok, false);
204
+ assert.equal(verdict.results.length, 2);
205
+ assert.equal(verdict.results.find((r) => r.file.startsWith("a")).ok, true);
206
+ assert.equal(verdict.results.find((r) => r.file.startsWith("b")).ok, false);
207
+ });
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // findCoverageSummaries / readSummary — real filesystem
211
+ // ---------------------------------------------------------------------------
212
+
213
+ test("findCoverageSummaries auto-scans **/coverage/, pruning node_modules + dotted dirs", () => {
214
+ const root = mkdtempSync(join(tmpdir(), "cov-gate-"));
215
+ try {
216
+ // A real package coverage dir.
217
+ mkdirSync(join(root, "packages", "api", "coverage"), { recursive: true });
218
+ writeFileSync(
219
+ join(root, "packages", "api", "coverage", "coverage-summary.json"),
220
+ JSON.stringify(summary({ lines: 88 }))
221
+ );
222
+ // A decoy under node_modules that MUST be pruned.
223
+ mkdirSync(join(root, "node_modules", "dep", "coverage"), { recursive: true });
224
+ writeFileSync(
225
+ join(root, "node_modules", "dep", "coverage", "coverage-summary.json"),
226
+ JSON.stringify(summary({ lines: 1 }))
227
+ );
228
+ // A decoy under a dotted dir that MUST be pruned.
229
+ mkdirSync(join(root, ".agents", "coverage"), { recursive: true });
230
+ writeFileSync(
231
+ join(root, ".agents", "coverage", "coverage-summary.json"),
232
+ JSON.stringify(summary({ lines: 2 }))
233
+ );
234
+
235
+ const files = findCoverageSummaries(root);
236
+ assert.equal(files.length, 1);
237
+ assert.match(files[0], /packages[/\\]api[/\\]coverage[/\\]coverage-summary\.json$/);
238
+
239
+ const parsed = readSummary(files[0]);
240
+ assert.equal(extractPct(parsed, "lines"), 88);
241
+ } finally {
242
+ rmSync(root, { recursive: true, force: true });
243
+ }
244
+ });
245
+
246
+ test("findCoverageSummaries honours explicit --coverage-dir roots", () => {
247
+ const root = mkdtempSync(join(tmpdir(), "cov-gate-"));
248
+ try {
249
+ mkdirSync(join(root, "custom", "cov"), { recursive: true });
250
+ writeFileSync(
251
+ join(root, "custom", "cov", "coverage-summary.json"),
252
+ JSON.stringify(summary({ lines: 90 }))
253
+ );
254
+ const files = findCoverageSummaries(root, ["custom/cov"]);
255
+ assert.equal(files.length, 1);
256
+ assert.match(files[0], /custom[/\\]cov[/\\]coverage-summary\.json$/);
257
+
258
+ // A non-existent override yields nothing (the gate then fails as "no data").
259
+ assert.deepEqual(findCoverageSummaries(root, ["does/not/exist"]), []);
260
+ } finally {
261
+ rmSync(root, { recursive: true, force: true });
262
+ }
263
+ });
264
+
265
+ test("readSummary returns null on unreadable / malformed JSON", () => {
266
+ const root = mkdtempSync(join(tmpdir(), "cov-gate-"));
267
+ try {
268
+ const bad = join(root, "coverage-summary.json");
269
+ writeFileSync(bad, "{ not valid json");
270
+ assert.equal(readSummary(bad), null);
271
+ assert.equal(readSummary(join(root, "missing.json")), null);
272
+ } finally {
273
+ rmSync(root, { recursive: true, force: true });
274
+ }
275
+ });
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // runCli — end-to-end exit codes (threshold both unset and set)
279
+ // ---------------------------------------------------------------------------
280
+
281
+ test("runCli: threshold unset → exit 0 (preserves non-adopter behaviour)", () => {
282
+ const out = [];
283
+ const code = runCli([], { log: (m) => out.push(m), err: (m) => out.push(m) });
284
+ assert.equal(code, 0);
285
+ assert.ok(out.some((l) => /disabled/.test(l)));
286
+ });
287
+
288
+ test("runCli: threshold set, real passing coverage tree → exit 0", () => {
289
+ const root = mkdtempSync(join(tmpdir(), "cov-gate-"));
290
+ try {
291
+ mkdirSync(join(root, "coverage"), { recursive: true });
292
+ writeFileSync(
293
+ join(root, "coverage", "coverage-summary.json"),
294
+ JSON.stringify(summary({ lines: 95 }))
295
+ );
296
+ const out = [];
297
+ const code = runCli(["--threshold", "80", "--cwd", root], {
298
+ log: (m) => out.push(m),
299
+ err: (m) => out.push(m),
300
+ });
301
+ assert.equal(code, 0);
302
+ assert.ok(out.some((l) => /meets the 80% floor/.test(l)));
303
+ } finally {
304
+ rmSync(root, { recursive: true, force: true });
305
+ }
306
+ });
307
+
308
+ test("runCli: threshold set, real failing coverage tree → exit 1", () => {
309
+ const root = mkdtempSync(join(tmpdir(), "cov-gate-"));
310
+ try {
311
+ mkdirSync(join(root, "coverage"), { recursive: true });
312
+ writeFileSync(
313
+ join(root, "coverage", "coverage-summary.json"),
314
+ JSON.stringify(summary({ lines: 50 }))
315
+ );
316
+ const out = [];
317
+ const code = runCli(["--threshold", "80", "--cwd", root], {
318
+ log: (m) => out.push(m),
319
+ err: (m) => out.push(m),
320
+ });
321
+ assert.equal(code, 1);
322
+ assert.ok(out.some((l) => /below the 80% floor/.test(l)));
323
+ } finally {
324
+ rmSync(root, { recursive: true, force: true });
325
+ }
326
+ });
327
+
328
+ test("runCli: threshold set but no coverage data → exit 1", () => {
329
+ const root = mkdtempSync(join(tmpdir(), "cov-gate-"));
330
+ try {
331
+ const out = [];
332
+ const code = runCli(["--threshold", "80", "--cwd", root], {
333
+ log: (m) => out.push(m),
334
+ err: (m) => out.push(m),
335
+ });
336
+ assert.equal(code, 1);
337
+ assert.ok(out.some((l) => /no coverage-summary\.json was found/.test(l)));
338
+ } finally {
339
+ rmSync(root, { recursive: true, force: true });
340
+ }
341
+ });
342
+
343
+ test("formatVerdict renders a skip line for the disabled gate", () => {
344
+ const lines = formatVerdict({
345
+ ok: true, skipped: true, reason: "threshold 0 — coverage gate disabled (no-op)",
346
+ threshold: 0, metric: "lines", results: [],
347
+ });
348
+ assert.equal(lines.length, 1);
349
+ assert.match(lines[0], /⏭️/);
350
+ });