mandrel-platform 1.10.0 → 1.11.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": "1.10.0",
3
+ "version": "1.11.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": {
@@ -10,9 +10,26 @@
10
10
  * green.
11
11
  * The `.agents/` harness ships a CRAP/MI/coverage *ratchet*, but the shared CI
12
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`.
13
+ * workflow layer. This script is that floor: the `coverage-floor` job runs it
14
+ * with the `coverage-threshold` workflow input, and a non-zero exit fails the
15
+ * job — which is a `needs:` of `ci-required`.
16
+ *
17
+ * MERGED MEASUREMENT (Story #468). The gate used to assert every discovered
18
+ * `coverage-summary.json` INDEPENDENTLY — a logical AND across per-workspace
19
+ * summaries. That made the floor an artifact of which job happened to run
20
+ * which tests: it false-failed every shard of a sharded tier (each shard
21
+ * measures its own subset), and it forced a consumer to keep its whole suite
22
+ * in the one tier the gate could see, because a scoped tier's summary is a
23
+ * partial measurement of the repo. The gate now UNIONS every summary it finds
24
+ * into one measurement and asserts the floor once.
25
+ *
26
+ * Two tiers can exercise the same source file, and a summary records how MANY
27
+ * lines each covered, never WHICH — so the true union is not recoverable from
28
+ * counts. Overlap therefore resolves as per-file `max(covered)`, which is a
29
+ * provable LOWER BOUND on the union: the merged number can only understate
30
+ * real coverage, so the floor may false-fail and can never false-pass. Tiers
31
+ * scoped to disjoint projects — the case this exists for — overlap on nothing
32
+ * and merge exactly.
16
33
  *
17
34
  * Design constraints:
18
35
  * • OPT-IN. A threshold of 0 (the default) is a no-op: the gate prints a
@@ -40,7 +57,7 @@
40
57
  *
41
58
  * Exit codes:
42
59
  * 0 — gate disabled (threshold 0), or measured coverage ≥ threshold.
43
- * 1 — measured coverage below the threshold, OR the threshold is set but no
60
+ * 1 — merged coverage below the threshold, OR the threshold is set but no
44
61
  * coverage summary could be found / parsed (a set floor must never pass
45
62
  * silently on missing data).
46
63
  */
@@ -164,6 +181,169 @@ export function meetsThreshold(pct, threshold) {
164
181
  return typeof pct === "number" && Number.isFinite(pct) && pct >= threshold;
165
182
  }
166
183
 
184
+ /**
185
+ * The per-file keys of a coverage-summary.json (everything but `total`).
186
+ * json-summary emits one entry per source file, keyed by the ABSOLUTE path it
187
+ * had in the workspace that produced it.
188
+ */
189
+ export function summaryFileKeys(summary) {
190
+ if (!summary || typeof summary !== "object") return [];
191
+ return Object.keys(summary).filter(
192
+ (k) => k !== "total" && summary[k] && typeof summary[k] === "object"
193
+ );
194
+ }
195
+
196
+ /**
197
+ * Longest common DIRECTORY prefix of a key list (trailing slash included, or
198
+ * "" when there is none). Filenames are excluded from the comparison so a
199
+ * single-entry summary yields its own directory rather than the file itself.
200
+ *
201
+ * This is the FALLBACK normalizer only — see `toRepoRelativeKey`. It is not
202
+ * safe on its own across summaries: two tiers whose file sets bottom out at
203
+ * different depths (one scoped to `packages/api`, one spanning the repo)
204
+ * produce prefixes of different lengths, so the same file normalizes to two
205
+ * different keys and the union double-counts it.
206
+ */
207
+ export function commonDirPrefix(keys) {
208
+ const lists = keys.map((k) => String(k).replace(/\\/g, "/").split("/").slice(0, -1));
209
+ if (lists.length === 0) return "";
210
+ let prefix = lists[0];
211
+ for (let i = 1; i < lists.length; i++) {
212
+ const other = lists[i];
213
+ let n = 0;
214
+ while (n < prefix.length && n < other.length && prefix[n] === other[n]) n++;
215
+ prefix = prefix.slice(0, n);
216
+ }
217
+ return prefix.length > 0 ? prefix.join("/") + "/" : "";
218
+ }
219
+
220
+ /**
221
+ * Normalize one absolute coverage key to a repo-relative path, ANCHORED ON
222
+ * THE CHECKOUT rather than on the key list's own shape: walk the key's
223
+ * segments left-to-right and return the first (longest) suffix that resolves
224
+ * to a real path under `cwd`.
225
+ *
226
+ * This is what lets two tiers' summaries merge when they were produced under
227
+ * different workspace roots — a self-hosted fleet where one job ran under
228
+ * `/actions-runner/_work/repo/repo` and another under `/srv/runner2/_work/repo/repo`
229
+ * still normalizes both to `src/foo.ts`. Anchoring on the checkout also avoids
230
+ * the mixed-depth failure `commonDirPrefix` has on its own.
231
+ *
232
+ * Returns null when nothing resolves (a generated or since-deleted file); the
233
+ * caller falls back to the prefix form and counts it.
234
+ */
235
+ export function toRepoRelativeKey(key, { exists = existsSync, cwd = process.cwd() } = {}) {
236
+ if (typeof key !== "string" || key.trim() === "") return null;
237
+ const norm = key.replace(/\\/g, "/");
238
+ const absolute = norm.startsWith("/") || /^[A-Za-z]:\//.test(norm);
239
+ if (!absolute) return norm.replace(/^\.\//, "");
240
+ const segs = norm.split("/").filter((seg) => seg !== "" && !/^[A-Za-z]:$/.test(seg));
241
+ for (let i = 0; i < segs.length; i++) {
242
+ const candidate = segs.slice(i).join("/");
243
+ if (exists(join(cwd, candidate))) return candidate;
244
+ }
245
+ return null;
246
+ }
247
+
248
+ /**
249
+ * Reduce one parsed summary to the per-file {covered, total} counts for
250
+ * `metric`, keyed by normalized path.
251
+ *
252
+ * A summary carrying ONLY a `total` block (no per-file entries) cannot be
253
+ * merged per file, so it is kept as an OPAQUE contribution keyed by nothing —
254
+ * its counts are added to the aggregate whole. That can double-count a file
255
+ * two such summaries share, which is why json-summary's per-file output is the
256
+ * supported shape; the opaque path exists so a reduced summary degrades to
257
+ * today's arithmetic rather than vanishing from the measurement.
258
+ */
259
+ export function normalizeSummary(summary, metric, { exists = existsSync, cwd = process.cwd() } = {}) {
260
+ const files = new Map();
261
+ let unresolved = 0;
262
+ const keys = summaryFileKeys(summary);
263
+
264
+ if (keys.length === 0) {
265
+ const total = summary && typeof summary === "object" ? summary.total : null;
266
+ const entry = total && typeof total === "object" ? total[metric] : null;
267
+ const covered = entry && Number.isFinite(entry.covered) ? entry.covered : null;
268
+ const denom = entry && Number.isFinite(entry.total) ? entry.total : null;
269
+ return {
270
+ files,
271
+ unresolved,
272
+ opaque: covered !== null && denom !== null ? { covered, total: denom } : null,
273
+ };
274
+ }
275
+
276
+ const prefix = commonDirPrefix(keys);
277
+ for (const key of keys) {
278
+ const entry = summary[key][metric];
279
+ if (!entry || !Number.isFinite(entry.covered) || !Number.isFinite(entry.total)) continue;
280
+ let normalized = toRepoRelativeKey(key, { exists, cwd });
281
+ if (normalized === null) {
282
+ unresolved += 1;
283
+ const raw = String(key).replace(/\\/g, "/");
284
+ normalized = prefix && raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
285
+ }
286
+ const prev = files.get(normalized);
287
+ files.set(
288
+ normalized,
289
+ prev
290
+ ? {
291
+ // MAX, never sum. Two tiers exercising the same file report how
292
+ // MANY lines each covered, never WHICH — so the true union is
293
+ // unknowable from counts alone. max() is a provable lower bound on
294
+ // it (the union is at least the larger contribution), which keeps
295
+ // the floor able to false-fail but never to false-pass.
296
+ covered: Math.max(prev.covered, entry.covered),
297
+ total: Math.max(prev.total, entry.total),
298
+ }
299
+ : { covered: entry.covered, total: entry.total }
300
+ );
301
+ }
302
+ return { files, unresolved, opaque: null };
303
+ }
304
+
305
+ /**
306
+ * Union a list of normalized summaries into ONE measurement:
307
+ * `sum(covered) / sum(total) * 100` over the merged per-file map.
308
+ */
309
+ export function mergeNormalized(parts) {
310
+ const files = new Map();
311
+ let covered = 0;
312
+ let total = 0;
313
+ let unresolved = 0;
314
+
315
+ for (const part of parts) {
316
+ unresolved += part.unresolved || 0;
317
+ for (const [key, value] of part.files) {
318
+ const prev = files.get(key);
319
+ files.set(
320
+ key,
321
+ prev
322
+ ? {
323
+ covered: Math.max(prev.covered, value.covered),
324
+ total: Math.max(prev.total, value.total),
325
+ }
326
+ : { covered: value.covered, total: value.total }
327
+ );
328
+ }
329
+ if (part.opaque) {
330
+ covered += part.opaque.covered;
331
+ total += part.opaque.total;
332
+ }
333
+ }
334
+ for (const value of files.values()) {
335
+ covered += value.covered;
336
+ total += value.total;
337
+ }
338
+ return {
339
+ covered,
340
+ total,
341
+ pct: total > 0 ? (covered / total) * 100 : null,
342
+ fileCount: files.size,
343
+ unresolved,
344
+ };
345
+ }
346
+
167
347
  /**
168
348
  * Recursively find every `coverage-summary.json` under `root`, regardless of
169
349
  * the name of the directory that directly contains it. `node_modules` and
@@ -228,7 +408,10 @@ export function readSummary(file) {
228
408
  * drive it directly. Returns a structured verdict:
229
409
  * { ok, skipped, reason, threshold, metric, results: [{ file, pct, ok }] }
230
410
  */
231
- export function evaluateGate(opts, { findSummaries = findCoverageSummaries, read = readSummary } = {}) {
411
+ export function evaluateGate(
412
+ opts,
413
+ { findSummaries = findCoverageSummaries, read = readSummary, exists = existsSync } = {}
414
+ ) {
232
415
  const { threshold, metric, cwd, coverageDirs } = opts;
233
416
 
234
417
  if (threshold <= 0) {
@@ -239,6 +422,7 @@ export function evaluateGate(opts, { findSummaries = findCoverageSummaries, read
239
422
  threshold,
240
423
  metric,
241
424
  results: [],
425
+ merged: null,
242
426
  };
243
427
  }
244
428
 
@@ -254,28 +438,56 @@ export function evaluateGate(opts, { findSummaries = findCoverageSummaries, read
254
438
  threshold,
255
439
  metric,
256
440
  results: [],
441
+ merged: null,
257
442
  };
258
443
  }
259
444
 
445
+ // Per-summary rows stay in the verdict as CONTRIBUTIONS — they name which
446
+ // artifact carried which numbers, so a tier that quietly stopped producing
447
+ // coverage is visible in the log. They are no longer individually asserted:
448
+ // the floor is one verdict over the union (see the header note).
260
449
  const results = [];
450
+ const parts = [];
261
451
  for (const file of files) {
262
452
  const summary = read(file);
263
453
  const pct = extractPct(summary, metric);
264
- if (pct === null) {
265
- results.push({ file, pct: null, ok: false });
266
- } else {
267
- results.push({ file, pct, ok: meetsThreshold(pct, threshold) });
268
- }
454
+ const part = normalizeSummary(summary, metric, { exists, cwd });
455
+ parts.push(part);
456
+ results.push({
457
+ file,
458
+ pct,
459
+ fileCount: part.files.size,
460
+ unresolved: part.unresolved,
461
+ contributed: part.files.size > 0 || part.opaque !== null,
462
+ });
269
463
  }
270
464
 
271
- const failures = results.filter((r) => !r.ok);
465
+ const merged = mergeNormalized(parts);
466
+
467
+ if (merged.pct === null) {
468
+ return {
469
+ ok: false,
470
+ skipped: false,
471
+ reason:
472
+ `coverage threshold is set but no "${metric}" counts could be read from ` +
473
+ `any of the ${files.length} coverage summaries found (a set floor must ` +
474
+ "not pass on unreadable data)",
475
+ threshold,
476
+ metric,
477
+ results,
478
+ merged,
479
+ };
480
+ }
481
+
482
+ const ok = meetsThreshold(merged.pct, threshold);
272
483
  return {
273
- ok: failures.length === 0,
484
+ ok,
274
485
  skipped: false,
275
- reason: failures.length === 0 ? "all coverage summaries meet the floor" : "below floor",
486
+ reason: ok ? "merged coverage meets the floor" : "below floor",
276
487
  threshold,
277
488
  metric,
278
489
  results,
490
+ merged,
279
491
  };
280
492
  }
281
493
 
@@ -290,26 +502,43 @@ export function formatVerdict(verdict) {
290
502
  lines.push(`[coverage-threshold] ❌ ${verdict.reason}`);
291
503
  return lines;
292
504
  }
505
+
506
+ // Contribution rows first: which artifact carried what.
293
507
  for (const r of verdict.results) {
294
- if (r.pct === null) {
295
- lines.push(
296
- `[coverage-threshold] ❌ ${r.file}: no "${verdict.metric}" total.pct in summary`
297
- );
298
- } else {
299
- const mark = r.ok ? "✅" : "❌";
508
+ if (!r.contributed) {
300
509
  lines.push(
301
- `[coverage-threshold] ${mark} ${r.file}: ${verdict.metric} ${r.pct}% ` +
302
- `(floor ${verdict.threshold}%)`
510
+ `[coverage-threshold] ⚠️ ${r.file}: no "${verdict.metric}" counts — contributed nothing`
303
511
  );
512
+ continue;
304
513
  }
514
+ const shown = r.pct === null ? "n/a" : `${r.pct}%`;
515
+ const unresolvedNote =
516
+ r.unresolved > 0 ? `, ${r.unresolved} path(s) unresolved against the checkout` : "";
517
+ lines.push(
518
+ `[coverage-threshold] • ${r.file}: ${r.fileCount} file(s), ` +
519
+ `${verdict.metric} ${shown} on its own${unresolvedNote}`
520
+ );
521
+ }
522
+
523
+ const m = verdict.merged;
524
+ if (!m || m.pct === null) {
525
+ lines.push(`[coverage-threshold] ❌ ${verdict.reason}`);
526
+ return lines;
305
527
  }
528
+
529
+ const rounded = Math.round(m.pct * 100) / 100;
530
+ lines.push(
531
+ `[coverage-threshold] Σ merged across ${verdict.results.length} summary(ies): ` +
532
+ `${m.covered}/${m.total} ${verdict.metric} over ${m.fileCount} unique file(s) ` +
533
+ `= ${rounded}% (floor ${verdict.threshold}%)`
534
+ );
306
535
  if (verdict.ok) {
307
536
  lines.push(
308
- `[coverage-threshold] ✅ ${verdict.metric} coverage meets the ${verdict.threshold}% floor.`
537
+ `[coverage-threshold] ✅ merged ${verdict.metric} coverage meets the ${verdict.threshold}% floor.`
309
538
  );
310
539
  } else {
311
540
  lines.push(
312
- `[coverage-threshold] ❌ ${verdict.metric} coverage is below the ${verdict.threshold}% floor.`
541
+ `[coverage-threshold] ❌ merged ${verdict.metric} coverage is below the ${verdict.threshold}% floor.`
313
542
  );
314
543
  }
315
544
  return lines;
@@ -40,6 +40,11 @@ import {
40
40
  evaluateGate,
41
41
  formatVerdict,
42
42
  runCli,
43
+ summaryFileKeys,
44
+ commonDirPrefix,
45
+ toRepoRelativeKey,
46
+ normalizeSummary,
47
+ mergeNormalized,
43
48
  } from "./check-coverage-threshold.mjs";
44
49
 
45
50
  // Build a minimal Istanbul/c8/vitest-shaped coverage-summary object.
@@ -235,7 +240,8 @@ test("evaluateGate: SET + measured above floor → pass", () => {
235
240
  assert.equal(verdict.ok, true);
236
241
  assert.equal(verdict.skipped, false);
237
242
  assert.equal(verdict.results[0].pct, 91);
238
- assert.equal(verdict.results[0].ok, true);
243
+ assert.equal(verdict.results[0].contributed, true);
244
+ assert.equal(verdict.merged.pct, 91);
239
245
  });
240
246
 
241
247
  test("evaluateGate: SET + measured below floor → fail", () => {
@@ -248,7 +254,7 @@ test("evaluateGate: SET + measured below floor → fail", () => {
248
254
  );
249
255
  assert.equal(verdict.ok, false);
250
256
  assert.equal(verdict.results[0].pct, 73);
251
- assert.equal(verdict.results[0].ok, false);
257
+ assert.equal(verdict.merged.pct, 73);
252
258
  });
253
259
 
254
260
  test("evaluateGate: SET but no coverage summary found → fail (never silent-pass)", () => {
@@ -264,7 +270,10 @@ test("evaluateGate: SET but no coverage summary found → fail (never silent-pas
264
270
  assert.match(verdict.reason, /no coverage-summary\.json was found/);
265
271
  });
266
272
 
267
- test("evaluateGate: SET, one of many packages below floor → fail", () => {
273
+ test("evaluateGate: SET, many packages — the floor is one merged number, not a per-summary AND", () => {
274
+ // 95/100 + 40/100 = 135/200 = 67.5%, below the 80 floor. Both summaries are
275
+ // still REPORTED as contributions (the log names which artifact carried
276
+ // what) but neither is asserted on its own.
268
277
  const verdict = evaluateGate(
269
278
  { threshold: 80, metric: "statements", cwd: ".", coverageDirs: [] },
270
279
  {
@@ -280,8 +289,13 @@ test("evaluateGate: SET, one of many packages below floor → fail", () => {
280
289
  );
281
290
  assert.equal(verdict.ok, false);
282
291
  assert.equal(verdict.results.length, 2);
283
- assert.equal(verdict.results.find((r) => r.file.startsWith("a")).ok, true);
284
- assert.equal(verdict.results.find((r) => r.file.startsWith("b")).ok, false);
292
+ assert.equal(verdict.merged.covered, 135);
293
+ assert.equal(verdict.merged.total, 200);
294
+ assert.equal(verdict.merged.pct, 67.5);
295
+ assert.ok(
296
+ verdict.results.every((r) => r.contributed),
297
+ "both summaries must be reported as contributions",
298
+ );
285
299
  });
286
300
 
287
301
  // ---------------------------------------------------------------------------
@@ -480,6 +494,279 @@ test("formatVerdict renders a skip line for the disabled gate", () => {
480
494
  assert.match(lines[0], /⏭️/);
481
495
  });
482
496
 
497
+ // ---------------------------------------------------------------------------
498
+ // Merged measurement (Story #468)
499
+ //
500
+ // The floor used to be a logical AND across per-workspace summaries, which
501
+ // made it an artifact of which JOB ran which tests: a scoped tier's summary is
502
+ // a partial measurement of the repo, and every shard of a sharded tier
503
+ // false-failed on its own subset. These tests pin the union semantics and the
504
+ // lower-bound property that makes the union safe to assert.
505
+ // ---------------------------------------------------------------------------
506
+
507
+ // A coverage-summary.json with real per-file entries: { "<abs path>": pct-ish
508
+ // counts }, plus the `total` block json-summary always writes.
509
+ function fileSummary(entries, metric = "lines") {
510
+ const out = {};
511
+ let covered = 0;
512
+ let total = 0;
513
+ for (const [path, counts] of Object.entries(entries)) {
514
+ covered += counts.covered;
515
+ total += counts.total;
516
+ out[path] = {
517
+ [metric]: {
518
+ total: counts.total,
519
+ covered: counts.covered,
520
+ skipped: 0,
521
+ pct: (counts.covered / counts.total) * 100,
522
+ },
523
+ };
524
+ }
525
+ out.total = {
526
+ [metric]: {
527
+ total,
528
+ covered,
529
+ skipped: 0,
530
+ pct: total > 0 ? (covered / total) * 100 : 0,
531
+ },
532
+ };
533
+ return out;
534
+ }
535
+
536
+ /** An `exists` stub that answers true only for a known set of repo-relative paths. */
537
+ function existsIn(relPaths, cwd = "/repo") {
538
+ const known = new Set(relPaths.map((r) => join(cwd, r)));
539
+ return (candidate) => known.has(candidate);
540
+ }
541
+
542
+ test("summaryFileKeys returns per-file entries and never `total`", () => {
543
+ const sum = fileSummary({ "/ws/src/a.ts": { covered: 5, total: 10 } });
544
+ assert.deepEqual(summaryFileKeys(sum), ["/ws/src/a.ts"]);
545
+ assert.deepEqual(summaryFileKeys(null), []);
546
+ });
547
+
548
+ test("commonDirPrefix excludes the filename, so a single entry yields its directory", () => {
549
+ assert.equal(commonDirPrefix(["/ws/repo/src/a.ts"]), "/ws/repo/src/");
550
+ assert.equal(commonDirPrefix(["/ws/repo/src/a.ts", "/ws/repo/api/b.ts"]), "/ws/repo/");
551
+ assert.equal(commonDirPrefix([]), "");
552
+ });
553
+
554
+ test("toRepoRelativeKey anchors on the checkout — different workspace roots normalize alike", () => {
555
+ const exists = existsIn(["src/foo.ts"]);
556
+ // Two tiers, two runner workspace roots, one source file.
557
+ assert.equal(
558
+ toRepoRelativeKey("/actions-runner/_work/repo/repo/src/foo.ts", { exists, cwd: "/repo" }),
559
+ "src/foo.ts",
560
+ );
561
+ assert.equal(
562
+ toRepoRelativeKey("/srv/runner2/_work/repo/repo/src/foo.ts", { exists, cwd: "/repo" }),
563
+ "src/foo.ts",
564
+ );
565
+ });
566
+
567
+ test("toRepoRelativeKey returns null when nothing resolves against the checkout", () => {
568
+ const exists = existsIn(["src/foo.ts"]);
569
+ assert.equal(toRepoRelativeKey("/ws/generated/nope.ts", { exists, cwd: "/repo" }), null);
570
+ assert.equal(toRepoRelativeKey("", { exists, cwd: "/repo" }), null);
571
+ });
572
+
573
+ test("mergeNormalized: disjoint files sum — aggregate is sum(covered)/sum(total)", () => {
574
+ const exists = existsIn(["src/a.ts", "api/b.ts"]);
575
+ const opts = { exists, cwd: "/repo" };
576
+ const unit = normalizeSummary(
577
+ fileSummary({ "/ws/repo/src/a.ts": { covered: 90, total: 100 } }),
578
+ "lines",
579
+ opts,
580
+ );
581
+ const contract = normalizeSummary(
582
+ fileSummary({ "/ws/repo/api/b.ts": { covered: 30, total: 100 } }),
583
+ "lines",
584
+ opts,
585
+ );
586
+ const merged = mergeNormalized([unit, contract]);
587
+ assert.equal(merged.covered, 120);
588
+ assert.equal(merged.total, 200);
589
+ assert.equal(merged.pct, 60);
590
+ assert.equal(merged.fileCount, 2);
591
+ });
592
+
593
+ test("mergeNormalized: an overlapping file takes MAX(covered), never the sum (the lower-bound property)", () => {
594
+ const exists = existsIn(["src/shared.ts"]);
595
+ const opts = { exists, cwd: "/repo" };
596
+ const unit = normalizeSummary(
597
+ fileSummary({ "/ws/repo/src/shared.ts": { covered: 40, total: 100 } }),
598
+ "lines",
599
+ opts,
600
+ );
601
+ const contract = normalizeSummary(
602
+ fileSummary({ "/ws/repo/src/shared.ts": { covered: 70, total: 100 } }),
603
+ "lines",
604
+ opts,
605
+ );
606
+ const merged = mergeNormalized([unit, contract]);
607
+ // Summing would give 110/200 — and 110 covered lines in a 100-line file is
608
+ // not a measurement, it is an artifact. max() is a lower bound on the union.
609
+ assert.equal(merged.covered, 70);
610
+ assert.equal(merged.total, 100);
611
+ assert.equal(merged.pct, 70);
612
+ assert.equal(merged.fileCount, 1, "the shared file must merge into ONE entry");
613
+ });
614
+
615
+ test("normalizeSummary: mixed-depth tiers still merge the same file into one entry", () => {
616
+ // The unit tier spans the repo; the contract tier is scoped to packages/api.
617
+ // Their own longest-common-dir prefixes differ in DEPTH, so prefix-relative
618
+ // keys alone would disagree ("packages/api/src/db.ts" vs "src/db.ts") and
619
+ // double-count the shared file. Anchoring on the checkout resolves both.
620
+ const exists = existsIn(["packages/web/src/ui.ts", "packages/api/src/db.ts"]);
621
+ const opts = { exists, cwd: "/repo" };
622
+ const unit = normalizeSummary(
623
+ fileSummary({
624
+ "/ws/repo/packages/web/src/ui.ts": { covered: 80, total: 100 },
625
+ "/ws/repo/packages/api/src/db.ts": { covered: 10, total: 100 },
626
+ }),
627
+ "lines",
628
+ opts,
629
+ );
630
+ const contract = normalizeSummary(
631
+ fileSummary({ "/ws/repo/packages/api/src/db.ts": { covered: 95, total: 100 } }),
632
+ "lines",
633
+ opts,
634
+ );
635
+ assert.equal(contract.files.size, 1);
636
+ assert.ok(contract.files.has("packages/api/src/db.ts"));
637
+ const merged = mergeNormalized([unit, contract]);
638
+ assert.equal(merged.fileCount, 2, "the shared file must not double-count");
639
+ assert.equal(merged.covered, 175, "80 + max(10, 95)");
640
+ assert.equal(merged.total, 200);
641
+ });
642
+
643
+ test("normalizeSummary counts keys it could not resolve against the checkout", () => {
644
+ const exists = existsIn(["src/a.ts"]);
645
+ const part = normalizeSummary(
646
+ fileSummary({
647
+ "/ws/repo/src/a.ts": { covered: 5, total: 10 },
648
+ "/ws/repo/dist/generated.js": { covered: 1, total: 10 },
649
+ }),
650
+ "lines",
651
+ { exists, cwd: "/repo" },
652
+ );
653
+ assert.equal(part.unresolved, 1);
654
+ assert.equal(part.files.size, 2, "an unresolved key still contributes, via the prefix fallback");
655
+ });
656
+
657
+ test("evaluateGate: a weighted merge PASSES where the old per-summary AND failed", () => {
658
+ // 900/1000 (90%) + 70/100 (70%) = 970/1100 = 88.18%, above an 80 floor.
659
+ // Under the old AND the 70% summary alone red the gate — which is exactly
660
+ // the false-fail that forced a consumer to keep its whole suite in one tier.
661
+ const exists = existsIn(["src/big.ts", "api/small.ts"]);
662
+ const verdict = evaluateGate(
663
+ { threshold: 80, metric: "lines", cwd: "/repo", coverageDirs: [] },
664
+ {
665
+ exists,
666
+ findSummaries: () => ["unit/coverage/coverage-summary.json", "contract/coverage/coverage-summary.json"],
667
+ read: (f) =>
668
+ f.startsWith("unit")
669
+ ? fileSummary({ "/ws/repo/src/big.ts": { covered: 900, total: 1000 } })
670
+ : fileSummary({ "/ws/repo/api/small.ts": { covered: 70, total: 100 } }),
671
+ },
672
+ );
673
+ assert.equal(verdict.ok, true);
674
+ assert.equal(verdict.merged.covered, 970);
675
+ assert.equal(verdict.merged.total, 1100);
676
+ assert.equal(Math.round(verdict.merged.pct * 100) / 100, 88.18);
677
+ });
678
+
679
+ test("evaluateGate: equal-weight 85% + 70% against an 80 floor fails on the weighted number (77.5%)", () => {
680
+ const exists = existsIn(["src/a.ts", "api/b.ts"]);
681
+ const verdict = evaluateGate(
682
+ { threshold: 80, metric: "lines", cwd: "/repo", coverageDirs: [] },
683
+ {
684
+ exists,
685
+ findSummaries: () => ["a/coverage/coverage-summary.json", "b/coverage/coverage-summary.json"],
686
+ read: (f) =>
687
+ f.startsWith("a")
688
+ ? fileSummary({ "/ws/repo/src/a.ts": { covered: 85, total: 100 } })
689
+ : fileSummary({ "/ws/repo/api/b.ts": { covered: 70, total: 100 } }),
690
+ },
691
+ );
692
+ assert.equal(verdict.ok, false);
693
+ assert.equal(verdict.merged.pct, 77.5);
694
+ });
695
+
696
+ test("evaluateGate: a sharded tier's partial summaries merge instead of each false-failing", () => {
697
+ // Two shards of ONE tier, each measuring only the files its shard ran.
698
+ // Asserted individually both sit at 50%; merged they are the repo's 90%.
699
+ const exists = existsIn(["src/a.ts", "src/b.ts"]);
700
+ const verdict = evaluateGate(
701
+ { threshold: 80, metric: "lines", cwd: "/repo", coverageDirs: [] },
702
+ {
703
+ exists,
704
+ findSummaries: () => ["unit-results-1/coverage/coverage-summary.json", "unit-results-2/coverage/coverage-summary.json"],
705
+ read: (f) =>
706
+ f.includes("unit-results-1")
707
+ ? fileSummary({
708
+ "/ws/repo/src/a.ts": { covered: 90, total: 100 },
709
+ "/ws/repo/src/b.ts": { covered: 10, total: 100 },
710
+ })
711
+ : fileSummary({
712
+ "/ws/repo/src/a.ts": { covered: 10, total: 100 },
713
+ "/ws/repo/src/b.ts": { covered: 90, total: 100 },
714
+ }),
715
+ },
716
+ );
717
+ assert.equal(verdict.ok, true);
718
+ assert.equal(verdict.merged.covered, 180, "max per file across shards: 90 + 90");
719
+ assert.equal(verdict.merged.pct, 90);
720
+ });
721
+
722
+ test("evaluateGate: summaries carrying only a `total` block still contribute (no silent vanish)", () => {
723
+ const verdict = evaluateGate(
724
+ { threshold: 80, metric: "lines", cwd: "/repo", coverageDirs: [] },
725
+ {
726
+ exists: () => false,
727
+ findSummaries: () => ["a/coverage/coverage-summary.json"],
728
+ read: () => summary({ lines: 91 }),
729
+ },
730
+ );
731
+ assert.equal(verdict.ok, true);
732
+ assert.equal(verdict.merged.covered, 91);
733
+ assert.equal(verdict.merged.total, 100);
734
+ assert.equal(verdict.results[0].contributed, true);
735
+ });
736
+
737
+ test("evaluateGate: a set floor with summaries that carry no readable counts still FAILS", () => {
738
+ const verdict = evaluateGate(
739
+ { threshold: 80, metric: "lines", cwd: "/repo", coverageDirs: [] },
740
+ {
741
+ exists: () => false,
742
+ findSummaries: () => ["a/coverage/coverage-summary.json"],
743
+ read: () => ({ total: { branches: { total: 1, covered: 1, pct: 100 } } }),
744
+ },
745
+ );
746
+ assert.equal(verdict.ok, false);
747
+ assert.match(verdict.reason, /could be read from/);
748
+ });
749
+
750
+ test("formatVerdict names each contributing artifact and the merged total", () => {
751
+ const exists = existsIn(["src/a.ts", "api/b.ts"]);
752
+ const verdict = evaluateGate(
753
+ { threshold: 80, metric: "lines", cwd: "/repo", coverageDirs: [] },
754
+ {
755
+ exists,
756
+ findSummaries: () => ["unit-results-1/coverage/coverage-summary.json", "contract-results-1/coverage/coverage-summary.json"],
757
+ read: (f) =>
758
+ f.startsWith("unit")
759
+ ? fileSummary({ "/ws/repo/src/a.ts": { covered: 90, total: 100 } })
760
+ : fileSummary({ "/ws/repo/api/b.ts": { covered: 80, total: 100 } }),
761
+ },
762
+ );
763
+ const out = formatVerdict(verdict).join("\n");
764
+ assert.match(out, /unit-results-1\/coverage\/coverage-summary\.json/);
765
+ assert.match(out, /contract-results-1\/coverage\/coverage-summary\.json/);
766
+ assert.match(out, /merged across 2 summary\(ies\)/);
767
+ assert.match(out, /170\/200/);
768
+ });
769
+
483
770
  // ---------------------------------------------------------------------------
484
771
  // pr-quality.yml Coverage threshold gate — workflow-step parity (#163, #230)
485
772
  //