mandrel-platform 1.13.0 → 1.13.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/README.md +27 -3
- package/config/stryker.base.json +2 -0
- package/package.json +4 -2
- package/scripts/audit-check.mjs +210 -47
- package/scripts/audit-check.test.mjs +479 -49
- package/scripts/check-action-download-retries.mjs +155 -11
- package/scripts/check-action-download-retries.test.mjs +168 -3
- package/scripts/check-advisory-scan-setup.test.mjs +186 -5
- package/scripts/check-coverage-threshold.mjs +112 -17
- package/scripts/check-coverage-threshold.test.mjs +335 -0
- package/scripts/check-husky-hook-modes.test.mjs +134 -0
- package/scripts/check-runner-runs-on.test.mjs +223 -2
- package/scripts/check-semgrep-lockfile.test.mjs +238 -7
- package/scripts/check-workflow-lint-tier.test.mjs +176 -0
- package/scripts/check-workflow-portability.mjs +6 -3
- package/scripts/check-workflow-portability.test.mjs +1 -1
- package/scripts/env-doctor.mjs +493 -62
- package/scripts/env-doctor.test.mjs +529 -3
- package/scripts/fixtures/npm-audit-v2.json +66 -0
- package/scripts/install-git-hooks.mjs +123 -0
- package/scripts/install-git-hooks.test.mjs +160 -0
- package/scripts/issue-intake.test.mjs +351 -21
- package/scripts/runner-toggle.test.mjs +407 -0
- package/scripts/select-semgrep-python.sh +98 -17
- package/scripts/select-semgrep-python.test.mjs +139 -12
- package/scripts/stryker-base-config.test.mjs +34 -0
- package/scripts/update-semgrep-rules.mjs +83 -5
- package/scripts/update-semgrep-rules.test.mjs +55 -1
- package/scripts/workflow-lint-gate.test.mjs +128 -0
|
@@ -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`.
|
|
202
|
-
*
|
|
203
|
-
* different depths (one scoped to
|
|
204
|
-
* produce prefixes of different lengths,
|
|
205
|
-
* different keys and the union
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
`= ${
|
|
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
|
+
});
|