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
|
@@ -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 `
|
|
14
|
-
* `coverage-threshold` workflow input, and a non-zero exit fails the
|
|
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 —
|
|
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(
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
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
|
|
484
|
+
ok,
|
|
274
485
|
skipped: false,
|
|
275
|
-
reason:
|
|
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.
|
|
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] ${
|
|
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].
|
|
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.
|
|
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,
|
|
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.
|
|
284
|
-
assert.equal(verdict.
|
|
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
|
//
|