mandrel-platform 1.12.0 → 1.13.1

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.
@@ -31,6 +31,25 @@
31
31
  * scoped to disjoint projects — the case this exists for — overlap on nothing
32
32
  * and merge exactly.
33
33
  *
34
+ * That never-false-pass claim rests on TWO invariants the gate now enforces
35
+ * outright (Story #489), because each was violable on its own:
36
+ *
37
+ * 1. A file is counted ONCE. `max(covered)` only applies to entries that
38
+ * merged under the SAME key, so any normalization that gives one file two
39
+ * keys turns the max into a SUM — which inflates the aggregate and lets
40
+ * the floor false-pass, the exact failure the paragraph above rules out.
41
+ * Keys that resolve against the checkout were always safe; keys that do
42
+ * not (a generated or since-deleted file) fall back to prefix-stripping,
43
+ * and that prefix is now computed ONCE across every summary rather than
44
+ * per summary — see `sharedDirPrefix`. Anything still unresolved after
45
+ * that is NAMED in the verdict, so a residual double-count is visible
46
+ * rather than silent.
47
+ * 2. The percentage is EXACT at every integer. `(covered * 100) / total`,
48
+ * never `(covered / total) * 100` — see `mergeNormalized`. The floor
49
+ * compare is inclusive, so a boundary run must land exactly on the floor
50
+ * rather than a rounding step below it, and the printed number must be
51
+ * the number the decision was made on (`formatPctForVerdict`).
52
+ *
34
53
  * Design constraints:
35
54
  * • OPT-IN. A threshold of 0 (the default) is a no-op: the gate prints a
36
55
  * skip note and exits 0, preserving today's behaviour for non-adopters.
@@ -181,6 +200,25 @@ export function meetsThreshold(pct, threshold) {
181
200
  return typeof pct === "number" && Number.isFinite(pct) && pct >= threshold;
182
201
  }
183
202
 
203
+ /**
204
+ * Render a measured pct for the log at the LOWEST precision that still agrees
205
+ * with the decision `meetsThreshold` made on it.
206
+ *
207
+ * A verdict that prints "57% (floor 57%)" and then fails is not a report, it
208
+ * is a contradiction — and rounding for readability is what manufactures one:
209
+ * 56.999% displayed at 2dp is "57". So the displayed value is widened until
210
+ * its side of the floor matches the real one, and only then printed.
211
+ */
212
+ export function formatPctForVerdict(pct, threshold) {
213
+ const met = meetsThreshold(pct, threshold);
214
+ for (const digits of [2, 4, 6]) {
215
+ const factor = 10 ** digits;
216
+ const rounded = Math.round(pct * factor) / factor;
217
+ if ((rounded >= threshold) === met) return String(rounded);
218
+ }
219
+ return String(pct);
220
+ }
221
+
184
222
  /**
185
223
  * The per-file keys of a coverage-summary.json (everything but `total`).
186
224
  * json-summary emits one entry per source file, keyed by the ABSOLUTE path it
@@ -198,11 +236,12 @@ export function summaryFileKeys(summary) {
198
236
  * "" when there is none). Filenames are excluded from the comparison so a
199
237
  * single-entry summary yields its own directory rather than the file itself.
200
238
  *
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.
239
+ * This is the FALLBACK normalizer only — see `toRepoRelativeKey`. Feed it the
240
+ * keys of EVERY summary at once (`sharedDirPrefix`), never one summary's keys:
241
+ * two tiers whose file sets bottom out at different depths (one scoped to
242
+ * `packages/api`, one spanning the repo) produce prefixes of different lengths,
243
+ * so the same file normalizes to two different keys and the union
244
+ * double-counts it.
206
245
  */
207
246
  export function commonDirPrefix(keys) {
208
247
  const lists = keys.map((k) => String(k).replace(/\\/g, "/").split("/").slice(0, -1));
@@ -217,6 +256,22 @@ export function commonDirPrefix(keys) {
217
256
  return prefix.length > 0 ? prefix.join("/") + "/" : "";
218
257
  }
219
258
 
259
+ /**
260
+ * The ONE prefix every summary's unresolved keys are stripped against: the
261
+ * longest common directory prefix over the union of every summary's file keys.
262
+ *
263
+ * Computing this per summary is the double-count bug: the prefix is a function
264
+ * of the key set it is given, so a repo-spanning tier and a `packages/api`
265
+ * tier strip different amounts from the SAME absolute path and the file lands
266
+ * under two keys, which `mergeNormalized` sums instead of maxing. One prefix
267
+ * across all summaries strips the same amount everywhere, so the file merges.
268
+ */
269
+ export function sharedDirPrefix(summaries) {
270
+ const keys = [];
271
+ for (const summary of summaries) keys.push(...summaryFileKeys(summary));
272
+ return commonDirPrefix(keys);
273
+ }
274
+
220
275
  /**
221
276
  * Normalize one absolute coverage key to a repo-relative path, ANCHORED ON
222
277
  * THE CHECKOUT rather than on the key list's own shape: walk the key's
@@ -249,6 +304,12 @@ export function toRepoRelativeKey(key, { exists = existsSync, cwd = process.cwd(
249
304
  * Reduce one parsed summary to the per-file {covered, total} counts for
250
305
  * `metric`, keyed by normalized path.
251
306
  *
307
+ * `prefix` is the SHARED prefix from `sharedDirPrefix`, computed once across
308
+ * every summary in the run. It defaults to "" — meaning an unresolved key is
309
+ * kept whole — rather than to this summary's own prefix, so a caller that
310
+ * forgets to thread it through under-merges (two long keys) instead of
311
+ * silently reintroducing the per-summary double-count.
312
+ *
252
313
  * A summary carrying ONLY a `total` block (no per-file entries) cannot be
253
314
  * merged per file, so it is kept as an OPAQUE contribution keyed by nothing —
254
315
  * its counts are added to the aggregate whole. That can double-count a file
@@ -256,8 +317,13 @@ export function toRepoRelativeKey(key, { exists = existsSync, cwd = process.cwd(
256
317
  * supported shape; the opaque path exists so a reduced summary degrades to
257
318
  * today's arithmetic rather than vanishing from the measurement.
258
319
  */
259
- export function normalizeSummary(summary, metric, { exists = existsSync, cwd = process.cwd() } = {}) {
320
+ export function normalizeSummary(
321
+ summary,
322
+ metric,
323
+ { exists = existsSync, cwd = process.cwd(), prefix = "" } = {}
324
+ ) {
260
325
  const files = new Map();
326
+ const unresolvedKeys = [];
261
327
  let unresolved = 0;
262
328
  const keys = summaryFileKeys(summary);
263
329
 
@@ -269,11 +335,11 @@ export function normalizeSummary(summary, metric, { exists = existsSync, cwd = p
269
335
  return {
270
336
  files,
271
337
  unresolved,
338
+ unresolvedKeys,
272
339
  opaque: covered !== null && denom !== null ? { covered, total: denom } : null,
273
340
  };
274
341
  }
275
342
 
276
- const prefix = commonDirPrefix(keys);
277
343
  for (const key of keys) {
278
344
  const entry = summary[key][metric];
279
345
  if (!entry || !Number.isFinite(entry.covered) || !Number.isFinite(entry.total)) continue;
@@ -282,6 +348,7 @@ export function normalizeSummary(summary, metric, { exists = existsSync, cwd = p
282
348
  unresolved += 1;
283
349
  const raw = String(key).replace(/\\/g, "/");
284
350
  normalized = prefix && raw.startsWith(prefix) ? raw.slice(prefix.length) : raw;
351
+ unresolvedKeys.push(normalized);
285
352
  }
286
353
  const prev = files.get(normalized);
287
354
  files.set(
@@ -299,7 +366,7 @@ export function normalizeSummary(summary, metric, { exists = existsSync, cwd = p
299
366
  : { covered: entry.covered, total: entry.total }
300
367
  );
301
368
  }
302
- return { files, unresolved, opaque: null };
369
+ return { files, unresolved, unresolvedKeys, opaque: null };
303
370
  }
304
371
 
305
372
  /**
@@ -308,12 +375,12 @@ export function normalizeSummary(summary, metric, { exists = existsSync, cwd = p
308
375
  */
309
376
  export function mergeNormalized(parts) {
310
377
  const files = new Map();
378
+ const unresolvedKeys = new Set();
311
379
  let covered = 0;
312
380
  let total = 0;
313
- let unresolved = 0;
314
381
 
315
382
  for (const part of parts) {
316
- unresolved += part.unresolved || 0;
383
+ for (const key of part.unresolvedKeys || []) unresolvedKeys.add(key);
317
384
  for (const [key, value] of part.files) {
318
385
  const prev = files.get(key);
319
386
  files.set(
@@ -335,12 +402,23 @@ export function mergeNormalized(parts) {
335
402
  covered += value.covered;
336
403
  total += value.total;
337
404
  }
405
+ const unresolvedList = [...unresolvedKeys].sort();
338
406
  return {
339
407
  covered,
340
408
  total,
341
- pct: total > 0 ? (covered / total) * 100 : null,
409
+ // `(covered * 100) / total`, NOT `(covered / total) * 100`. The latter
410
+ // forms a ratio in [0,1] first, and most such ratios are unrepresentable
411
+ // in binary: 57/100 is stored as 0.5699999999999999, so scaling by 100
412
+ // yields 56.99999999999999 and the inclusive `>= 57` compare FAILS on a
413
+ // run that is exactly at its floor. Multiplying first keeps the numerator
414
+ // an exact integer, so every integer percentage is exact.
415
+ pct: total > 0 ? (covered * 100) / total : null,
342
416
  fileCount: files.size,
343
- unresolved,
417
+ // Unique unresolved FILES, not unresolved contributions: the same
418
+ // generated file seen by two tiers is one unresolved path, and reporting
419
+ // it twice would misdescribe how much of the aggregate is uncertain.
420
+ unresolved: unresolvedList.length,
421
+ unresolvedKeys: unresolvedList,
344
422
  };
345
423
  }
346
424
 
@@ -446,12 +524,18 @@ export function evaluateGate(
446
524
  // artifact carried which numbers, so a tier that quietly stopped producing
447
525
  // coverage is visible in the log. They are no longer individually asserted:
448
526
  // the floor is one verdict over the union (see the header note).
527
+ // Parse every summary BEFORE normalizing any of them: the prefix unresolved
528
+ // keys are stripped against is computed once over the union of all their
529
+ // keys (`sharedDirPrefix`). Computed per summary it is a function of that
530
+ // summary's own depth, so one file gets two keys and the union sums it.
531
+ const parsed = files.map((file) => ({ file, summary: read(file) }));
532
+ const prefix = sharedDirPrefix(parsed.map((entry) => entry.summary));
533
+
449
534
  const results = [];
450
535
  const parts = [];
451
- for (const file of files) {
452
- const summary = read(file);
536
+ for (const { file, summary } of parsed) {
453
537
  const pct = extractPct(summary, metric);
454
- const part = normalizeSummary(summary, metric, { exists, cwd });
538
+ const part = normalizeSummary(summary, metric, { exists, cwd, prefix });
455
539
  parts.push(part);
456
540
  results.push({
457
541
  file,
@@ -526,12 +610,23 @@ export function formatVerdict(verdict) {
526
610
  return lines;
527
611
  }
528
612
 
529
- const rounded = Math.round(m.pct * 100) / 100;
613
+ const shownPct = formatPctForVerdict(m.pct, verdict.threshold);
530
614
  lines.push(
531
615
  `[coverage-threshold] Σ merged across ${verdict.results.length} summary(ies): ` +
532
616
  `${m.covered}/${m.total} ${verdict.metric} over ${m.fileCount} unique file(s) ` +
533
- `= ${rounded}% (floor ${verdict.threshold}%)`
617
+ `= ${shownPct}% (floor ${verdict.threshold}%)`
534
618
  );
619
+ // Name every path that never resolved against the checkout. Such a key is
620
+ // merged on its prefix-stripped form, which is a weaker identity than a
621
+ // checkout-anchored one — so if a residual double-count IS inflating the
622
+ // aggregate, the file responsible is in the log rather than inferred.
623
+ if (m.unresolvedKeys && m.unresolvedKeys.length > 0) {
624
+ lines.push(
625
+ `[coverage-threshold] ⚠️ ${m.unresolvedKeys.length} path(s) never resolved ` +
626
+ `against the checkout and were merged on their normalized key: ` +
627
+ m.unresolvedKeys.join(", ")
628
+ );
629
+ }
535
630
  if (verdict.ok) {
536
631
  lines.push(
537
632
  `[coverage-threshold] ✅ merged ${verdict.metric} coverage meets the ${verdict.threshold}% floor.`
@@ -45,6 +45,8 @@ import {
45
45
  toRepoRelativeKey,
46
46
  normalizeSummary,
47
47
  mergeNormalized,
48
+ sharedDirPrefix,
49
+ formatPctForVerdict,
48
50
  } from "./check-coverage-threshold.mjs";
49
51
 
50
52
  // Build a minimal Istanbul/c8/vitest-shaped coverage-summary object.
@@ -937,3 +939,336 @@ test("pr-quality gate step still hard-fails when threshold set but no summary ex
937
939
  rmSync(dir, { recursive: true, force: true });
938
940
  }
939
941
  });
942
+
943
+ // ---------------------------------------------------------------------------
944
+ // Boundary arithmetic and single-counting (Story #489)
945
+ //
946
+ // Two ways a floor can assert something other than the floor it prints:
947
+ //
948
+ // • It false-FAILS at its own boundary. `(covered / total) * 100` forms a
949
+ // ratio in [0,1] first, and 57/100 has no exact binary representation —
950
+ // it stores as 0.5699999999999999, so scaling by 100 gives
951
+ // 56.99999999999999. The inclusive `>= 57` compare then fails on a run
952
+ // that IS at 57%, while the verdict line rounds for display and prints
953
+ // "57% (floor 57%)". A gate whose printed number contradicts its own exit
954
+ // code is unactionable.
955
+ // • It false-PASSES on a double count. The merge maxes only on EQUAL keys,
956
+ // so a file that normalizes to two keys is SUMMED, inflating the
957
+ // aggregate. That is the one direction the header's lower-bound argument
958
+ // rules out, so it has to be structurally impossible rather than
959
+ // incidentally absent.
960
+ // ---------------------------------------------------------------------------
961
+
962
+ test("mergeNormalized: an exactly-at-floor percentage is exact, not a hair below", () => {
963
+ // The literal regression: these three ratios all lose their last bit under
964
+ // `(covered / total) * 100` and land just under an integer.
965
+ for (const [covered, total, expected] of [
966
+ [57, 100, 57],
967
+ [29, 100, 29],
968
+ [58, 100, 58],
969
+ ]) {
970
+ const merged = mergeNormalized([
971
+ { files: new Map([["src/a.ts", { covered, total }]]), unresolved: 0, unresolvedKeys: [], opaque: null },
972
+ ]);
973
+ assert.equal(
974
+ merged.pct,
975
+ expected,
976
+ `${covered}/${total} must be exactly ${expected}, not ${expected} minus an epsilon`,
977
+ );
978
+ }
979
+ });
980
+
981
+ test("meetsThreshold: exactly at the floor passes; one point under does not", () => {
982
+ for (const [covered, floor] of [
983
+ [57, 57],
984
+ [29, 29],
985
+ [58, 58],
986
+ ]) {
987
+ const merged = mergeNormalized([
988
+ { files: new Map([["src/a.ts", { covered, total: 100 }]]), unresolved: 0, unresolvedKeys: [], opaque: null },
989
+ ]);
990
+ assert.equal(
991
+ meetsThreshold(merged.pct, floor),
992
+ true,
993
+ `${covered}% must meet a ${floor}% floor — the compare is inclusive`,
994
+ );
995
+ }
996
+ const below = mergeNormalized([
997
+ { files: new Map([["src/a.ts", { covered: 56, total: 100 }]]), unresolved: 0, unresolvedKeys: [], opaque: null },
998
+ ]);
999
+ assert.equal(meetsThreshold(below.pct, 57), false, "56% must not meet a 57% floor");
1000
+ });
1001
+
1002
+ test("evaluateGate: a run measured exactly at the floor PASSES end to end", () => {
1003
+ const exists = existsIn(["src/a.ts"]);
1004
+ const run = (covered, threshold) =>
1005
+ evaluateGate(
1006
+ { threshold, metric: "lines", cwd: "/repo", coverageDirs: [] },
1007
+ {
1008
+ exists,
1009
+ findSummaries: () => ["coverage/coverage-summary.json"],
1010
+ read: () => fileSummary({ "/ws/repo/src/a.ts": { covered, total: 100 } }),
1011
+ },
1012
+ );
1013
+
1014
+ for (const [covered, floor] of [
1015
+ [57, 57],
1016
+ [29, 29],
1017
+ [58, 58],
1018
+ ]) {
1019
+ const verdict = run(covered, floor);
1020
+ assert.equal(verdict.ok, true, `${covered}/100 must clear a floor of ${floor}`);
1021
+ // The printed line and the exit code must tell the same story.
1022
+ assert.match(
1023
+ formatVerdict(verdict).join("\n"),
1024
+ /meets the/,
1025
+ "a passing verdict must read as passing",
1026
+ );
1027
+ }
1028
+ assert.equal(run(56, 57).ok, false, "56/100 must still fail a floor of 57");
1029
+ });
1030
+
1031
+ test("formatPctForVerdict never prints a number on the wrong side of the floor", () => {
1032
+ // Exactly at the floor: printed plainly.
1033
+ assert.equal(formatPctForVerdict(57, 57), "57");
1034
+ // Below the floor but 2dp-rounds ONTO it — the display must widen rather
1035
+ // than manufacture a "57% (floor 57%)" line above a failing exit code.
1036
+ const shown = formatPctForVerdict(56.999, 57);
1037
+ assert.ok(Number(shown) < 57, `printed ${shown}% must read as below a 57% floor`);
1038
+ // Above the floor but 2dp-rounds BELOW it — the same rule in reverse.
1039
+ const shownUp = formatPctForVerdict(57.0001, 57);
1040
+ assert.ok(Number(shownUp) >= 57, `printed ${shownUp}% must read as at or above a 57% floor`);
1041
+ });
1042
+
1043
+ test("sharedDirPrefix is computed over every summary's keys at once", () => {
1044
+ const unit = fileSummary({
1045
+ "/ws/repo/packages/web/src/ui.ts": { covered: 1, total: 1 },
1046
+ "/ws/repo/packages/api/dist/gen.js": { covered: 1, total: 1 },
1047
+ });
1048
+ const contract = fileSummary({
1049
+ "/ws/repo/packages/api/src/db.ts": { covered: 1, total: 1 },
1050
+ "/ws/repo/packages/api/dist/gen.js": { covered: 1, total: 1 },
1051
+ });
1052
+ // Each summary ALONE bottoms out at a different depth…
1053
+ assert.equal(commonDirPrefix(summaryFileKeys(unit)), "/ws/repo/packages/");
1054
+ assert.equal(commonDirPrefix(summaryFileKeys(contract)), "/ws/repo/packages/api/");
1055
+ // …so only the union gives both tiers the same amount to strip.
1056
+ assert.equal(sharedDirPrefix([unit, contract]), "/ws/repo/packages/");
1057
+ });
1058
+
1059
+ test("evaluateGate: an unresolved file at two depths counts ONCE, at the max", () => {
1060
+ // `dist/gen.js` is generated: it exists in neither tier's checkout, so
1061
+ // neither key resolves and both fall back to prefix-stripping. The unit
1062
+ // tier spans the repo and the contract tier is scoped to packages/api, so
1063
+ // their own prefixes differ in depth — the file used to land under
1064
+ // "api/dist/gen.js" and "dist/gen.js" and be SUMMED.
1065
+ const exists = existsIn(["packages/web/src/ui.ts", "packages/api/src/db.ts"]);
1066
+ const unit = fileSummary({
1067
+ "/ws/repo/packages/web/src/ui.ts": { covered: 80, total: 100 },
1068
+ "/ws/repo/packages/api/dist/gen.js": { covered: 30, total: 100 },
1069
+ });
1070
+ const contract = fileSummary({
1071
+ "/ws/repo/packages/api/src/db.ts": { covered: 60, total: 100 },
1072
+ "/ws/repo/packages/api/dist/gen.js": { covered: 50, total: 100 },
1073
+ });
1074
+
1075
+ const verdict = evaluateGate(
1076
+ { threshold: 60, metric: "lines", cwd: "/repo", coverageDirs: [] },
1077
+ {
1078
+ exists,
1079
+ findSummaries: () => ["unit-results-1/coverage-summary.json", "contract-results-1/coverage-summary.json"],
1080
+ read: (f) => (f.startsWith("unit") ? unit : contract),
1081
+ },
1082
+ );
1083
+
1084
+ const m = verdict.merged;
1085
+ assert.equal(m.fileCount, 3, "the generated file must merge into ONE entry, not two");
1086
+ assert.equal(m.covered, 190, "80 + 60 + max(30, 50) — never 80 + 60 + 30 + 50");
1087
+ assert.equal(m.total, 300, "a 100-line file must contribute 100 denominators, not 200");
1088
+ assert.deepEqual(m.unresolvedKeys, ["api/dist/gen.js"], "the unresolved path is named once");
1089
+
1090
+ // …and the verdict NAMES it, so a residual double count is visible.
1091
+ const out = formatVerdict(verdict).join("\n");
1092
+ assert.match(out, /api\/dist\/gen\.js/, "the verdict must name the unresolved file");
1093
+ assert.match(out, /never resolved/, "and say what is uncertain about it");
1094
+ });
1095
+
1096
+ test("a per-summary prefix double-counts, and can carry the aggregate OVER a floor", () => {
1097
+ // Vacuity guard for the test above, and the reason it matters. With each
1098
+ // summary given its OWN prefix — the pre-#489 behaviour — the generated file
1099
+ // lands under two keys and is SUMMED. When that file is better covered than
1100
+ // the rest of the tree, the artifact lifts the aggregate: here 61.67% (one
1101
+ // count) becomes 68.75% (two), which false-PASSES a 65% floor. That is the
1102
+ // one direction the header's lower-bound argument says cannot happen.
1103
+ const exists = existsIn(["packages/web/src/ui.ts", "packages/api/src/db.ts"]);
1104
+ const summaries = [
1105
+ fileSummary({
1106
+ "/ws/repo/packages/web/src/ui.ts": { covered: 80, total: 100 },
1107
+ "/ws/repo/packages/api/dist/gen.js": { covered: 90, total: 100 },
1108
+ }),
1109
+ fileSummary({
1110
+ "/ws/repo/packages/api/src/db.ts": { covered: 10, total: 100 },
1111
+ "/ws/repo/packages/api/dist/gen.js": { covered: 95, total: 100 },
1112
+ }),
1113
+ ];
1114
+ const perSummary = summaries.map((sum) =>
1115
+ normalizeSummary(sum, "lines", {
1116
+ exists,
1117
+ cwd: "/repo",
1118
+ prefix: commonDirPrefix(summaryFileKeys(sum)),
1119
+ }),
1120
+ );
1121
+ const inflated = mergeNormalized(perSummary);
1122
+ assert.equal(inflated.fileCount, 4, "per-summary prefixes give the generated file two keys");
1123
+ assert.equal(inflated.covered, 275, "…which the union then SUMS (80 + 10 + 90 + 95)");
1124
+ assert.equal(meetsThreshold(inflated.pct, 65), true, "the inflated number clears a 65% floor");
1125
+
1126
+ // The shared prefix counts it once, and the same tree correctly fails.
1127
+ const verdict = evaluateGate(
1128
+ { threshold: 65, metric: "lines", cwd: "/repo", coverageDirs: [] },
1129
+ {
1130
+ exists,
1131
+ findSummaries: () => ["unit/coverage-summary.json", "contract/coverage-summary.json"],
1132
+ read: (f) => (f.startsWith("unit") ? summaries[0] : summaries[1]),
1133
+ },
1134
+ );
1135
+ assert.equal(verdict.merged.fileCount, 3);
1136
+ assert.equal(verdict.merged.covered, 185, "80 + 10 + max(90, 95)");
1137
+ assert.equal(verdict.ok, false, "counted once, the tree is below the 65% floor and must fail");
1138
+ });
1139
+
1140
+ // ---------------------------------------------------------------------------
1141
+ // The coverage-floor job's own wiring (Story #489)
1142
+ //
1143
+ // Job-SCOPED, not file-wide: `pr-quality.yml` legitimately carries job-level
1144
+ // `permissions:` on other jobs and legitimately downloads other artifacts, so
1145
+ // a repo-wide grep answers a question nobody asked. Each assertion below
1146
+ // resolves the `coverage-floor` block and inspects only what is inside it.
1147
+ // (Same technique as scripts/check-workflow-lint-tier.test.mjs.)
1148
+ // ---------------------------------------------------------------------------
1149
+
1150
+ const PR_QUALITY = readFileSync(WORKFLOW_FILE, "utf8");
1151
+
1152
+ /**
1153
+ * The lines belonging to one top-level job — from ` <name>:` until the next
1154
+ * key at the same indentation. A plain line comparison rather than a built
1155
+ * regex: Semgrep's detect-non-literal-regexp rejects a RegExp built from a
1156
+ * non-literal, and a job header is an exact line anyway.
1157
+ */
1158
+ function jobBlock(text, job) {
1159
+ const lines = text.split(/\r?\n/);
1160
+ const start = lines.findIndex((l) => l.trimEnd() === ` ${job}:`);
1161
+ assert.notEqual(start, -1, `job '${job}' not found`);
1162
+ const block = [];
1163
+ for (let i = start + 1; i < lines.length; i += 1) {
1164
+ if (/^ {2}\S/.test(lines[i])) break;
1165
+ block.push(lines[i]);
1166
+ }
1167
+ return block;
1168
+ }
1169
+
1170
+ /** Keys declared directly on a job (indent 4), ignoring nested mappings. */
1171
+ function jobKeys(block) {
1172
+ return block.filter((l) => /^ {4}[A-Za-z_-]+:/.test(l)).map((l) => l.trim().split(":")[0]);
1173
+ }
1174
+
1175
+ /**
1176
+ * Expand one minimatch brace group into the concrete globs it resolves to, so
1177
+ * `{unit,contract}-results-*` can be compared against an exact expected SET
1178
+ * rather than matched as a string. String slicing, not a built regex.
1179
+ */
1180
+ function expandPatternGroups(pattern) {
1181
+ const open = pattern.indexOf("{");
1182
+ if (open === -1) return [pattern];
1183
+ const close = pattern.indexOf("}", open);
1184
+ assert.notEqual(close, -1, `unbalanced brace group in pattern '${pattern}'`);
1185
+ const head = pattern.slice(0, open);
1186
+ const tail = pattern.slice(close + 1);
1187
+ const out = [];
1188
+ for (const alt of pattern.slice(open + 1, close).split(",")) {
1189
+ out.push(...expandPatternGroups(`${head}${alt.trim()}${tail}`));
1190
+ }
1191
+ return out;
1192
+ }
1193
+
1194
+ /** Every `pattern:` value declared inside a job block, brace groups expanded. */
1195
+ function resolvedDownloadPatterns(job) {
1196
+ const raw = jobBlock(PR_QUALITY, job)
1197
+ .map((l) => l.match(/^\s*pattern:\s*'(.+)'\s*$/))
1198
+ .filter((m) => m !== null)
1199
+ .map((m) => m[1]);
1200
+ assert.ok(raw.length > 0, `job '${job}' declares no download pattern`);
1201
+ return new Set(raw.flatMap(expandPatternGroups));
1202
+ }
1203
+
1204
+ test("the coverage-floor job downloads exactly the unit and contract tiers", () => {
1205
+ assert.deepEqual(
1206
+ [...resolvedDownloadPatterns("coverage-floor")].sort(),
1207
+ ["contract-results-*", "unit-results-*"],
1208
+ "the floor is asserted on whatever this step downloads, so the download set must " +
1209
+ "be exactly the tiers the job needs: — a wider glob like '*-results-*' silently " +
1210
+ "admits any future <x>-results-* artifact into the aggregate, and dropping a " +
1211
+ "tier asserts the floor on a partial measurement",
1212
+ );
1213
+ });
1214
+
1215
+ test("the download set matches what the needed tiers actually upload", () => {
1216
+ const patterns = resolvedDownloadPatterns("coverage-floor");
1217
+ for (const tier of ["unit", "contract"]) {
1218
+ assert.ok(
1219
+ PR_QUALITY.includes(`name: ${tier}-results-\${{ matrix.shard }}`),
1220
+ `the ${tier} tier must still upload ${tier}-results-<shard>`,
1221
+ );
1222
+ assert.ok(
1223
+ patterns.has(`${tier}-results-*`),
1224
+ `dropping ${tier} from the download set drops it from the aggregate`,
1225
+ );
1226
+ }
1227
+ // Nothing else: the e2e tier's artifact must not be in the resolved set.
1228
+ assert.ok(
1229
+ !patterns.has("playwright-report-*") && !patterns.has("*-results-*"),
1230
+ "no wider glob may stand in for the two named tiers",
1231
+ );
1232
+ });
1233
+
1234
+ test("the coverage-floor job needs exactly the tiers it downloads", () => {
1235
+ const block = jobBlock(PR_QUALITY, "coverage-floor").join("\n");
1236
+ const needs = block.slice(block.indexOf("needs:"), block.indexOf("runs-on:"));
1237
+ assert.match(needs, /^\s*- unit$/m);
1238
+ assert.match(needs, /^\s*- contract$/m);
1239
+ });
1240
+
1241
+ test("the coverage-floor job declares NO job-level permissions", () => {
1242
+ const keys = jobKeys(jobBlock(PR_QUALITY, "coverage-floor"));
1243
+ assert.ok(
1244
+ !keys.includes("permissions"),
1245
+ "GitHub validates a called reusable workflow's declared job permissions against " +
1246
+ "the caller's grant at COMPILE time, regardless of the job's `if:` gate — so a " +
1247
+ "scope added here fails the ENTIRE call with startup_failure (zero jobs) for " +
1248
+ "every consumer that has not granted it, including consumers with the floor off " +
1249
+ "(Story #292). The workflow-level grant already covers same-run artifact download.",
1250
+ );
1251
+ });
1252
+
1253
+ test("the permissions scoping is real: other pr-quality jobs DO declare permissions", () => {
1254
+ // Without this, the assertion above would quietly become vacuous the moment
1255
+ // `jobBlock` stopped scoping.
1256
+ const withPermissions = ["migration-guard", "security", "osv-scan"].filter((job) =>
1257
+ jobKeys(jobBlock(PR_QUALITY, job)).includes("permissions"),
1258
+ );
1259
+ assert.ok(
1260
+ withPermissions.length > 0,
1261
+ "expected at least one job to declare permissions, else the guard above proves nothing",
1262
+ );
1263
+ });
1264
+
1265
+ test("coverage-floor is a needs: of ci-required", () => {
1266
+ const block = jobBlock(PR_QUALITY, "ci-required").join("\n");
1267
+ const needs = block.slice(block.indexOf("needs:"), block.indexOf("steps:"));
1268
+ assert.match(
1269
+ needs,
1270
+ /^\s*- coverage-floor$/m,
1271
+ "the floor is only load-bearing because the aggregate depends on it — dropped " +
1272
+ "from ci-required's needs:, a red floor leaves the required context green",
1273
+ );
1274
+ });