@deployanyway/ship-it-meter 0.4.0 → 1.0.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/CHANGELOG.md +6 -0
- package/MIGRATION.md +6 -0
- package/README.md +50 -0
- package/dist/index.cjs +214 -0
- package/examples/basic.js +12 -0
- package/examples/collect-receipts.mjs +85 -0
- package/examples/report-gate.mjs +13 -0
- package/index.d.cts +52 -0
- package/index.d.ts +52 -0
- package/package.json +9 -4
- package/src/cli.js +42 -2
- package/src/index.js +5 -0
- package/src/input.js +9 -2
- package/src/reports.js +262 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
- Show Your Receipts: release policies from actual reports.
|
|
6
|
+
- Typed API, CLI integration, runnable codebase example and meaningful workflow tests.
|
|
7
|
+
- Stable contracts and migration guidance; original humor stays around accurate facts.
|
|
8
|
+
|
|
3
9
|
## 0.4.0
|
|
4
10
|
|
|
5
11
|
Twelve named demonstration scenarios show passing evidence, Friday releases, missing evidence, failed builds, failing tests, low coverage, critical issues, lint failures, dirty worktrees, feature branches, zero tests and weekend incidents. These are examples, never evidence about your real project.
|
package/MIGRATION.md
CHANGED
|
@@ -9,3 +9,9 @@ Install 0.3.0 with npm. Seeds and exact humorous wording are version-specific. D
|
|
|
9
9
|
12 discoverable evidence scenarios and releasePlan with ordered blocker/review/release tasks and observable completion criteria; API and CLI scenarios/plans; passing/blocked gate exit codes preserved.
|
|
10
10
|
|
|
11
11
|
Existing defaults and entry points remain available. The new release plan and demonstration scenarios are opt-in; the original scoring and gate rules are retained.
|
|
12
|
+
|
|
13
|
+
## 0.4.0 to stable 1.0.0
|
|
14
|
+
|
|
15
|
+
Report APIs and CLI receipt mode are additive. Existing supplied-evidence scoring remains a heuristic and named scenarios remain examples. Use report policies for actual CI gating; do not confuse demonstration scenarios with measured artifacts.
|
|
16
|
+
|
|
17
|
+
See README for exact contracts, bounds and failure behavior.
|
package/README.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# ship-it-meter
|
|
2
2
|
|
|
3
|
+
## Show Your Receipts: release policies from actual reports (1.0.0)
|
|
4
|
+
|
|
5
|
+
evaluateReports(bundle, policy) turns actual test, coverage and build receipts into a release gate and prioritized repair tasks. parseTestReport supports completed Node TAP v13 and Jest JSON. parseCoverageReport supports c8/Istanbul coverage-summary.json. Skipped/todo tests are excluded from executed evidence; failed/cancelled tests, unsuccessful Jest suites and collected-only/interrupted runs do not count as successful execution. Coverage counts must reconcile; empty lines/statements are not measured evidence.
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { evaluateReports } from "@deployanyway/ship-it-meter";
|
|
9
|
+
const result = evaluateReports(
|
|
10
|
+
{
|
|
11
|
+
commit: "abc123",
|
|
12
|
+
tests: {
|
|
13
|
+
commit: "abc123",
|
|
14
|
+
capturedAt: "2026-10-09T02:00:00Z",
|
|
15
|
+
format: "node-tap",
|
|
16
|
+
data: tapOutput,
|
|
17
|
+
},
|
|
18
|
+
coverage: {
|
|
19
|
+
commit: "abc123",
|
|
20
|
+
capturedAt: "2026-10-09T02:00:00Z",
|
|
21
|
+
format: "istanbul-summary",
|
|
22
|
+
data: coverageSummary,
|
|
23
|
+
},
|
|
24
|
+
build: {
|
|
25
|
+
commit: "abc123",
|
|
26
|
+
capturedAt: "2026-10-09T02:00:00Z",
|
|
27
|
+
exitCode: buildExitCode,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{ minTests: 1, minCoverage: 80, minScore: 80, maxAgeMs: 3600000 },
|
|
31
|
+
);
|
|
32
|
+
if (!result.passed) console.error(result.blockers);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
All three receipts are required, must identify the same nonempty bundle commit, and use valid UTC ISO capturedAt timestamps. Future/stale receipts block. Default policy: at least one executed test, all four coverage percentages >=80, passing build and score >=80, max age one hour. now can be supplied for deterministic evaluation. Unknown policy keys throw, so misspelled thresholds do not silently apply defaults. The policy is configurable; it does not alter your artifacts.
|
|
36
|
+
|
|
37
|
+
Result adds evidence, receipts, commit, policy and evaluatedAt to a release plan. Each receipt exposes acceptance and issues; rejected receipts add blocker tasks with verification criteria. The final summary/passed value follows receipt blockers, even when the heuristic score looks good. Parse helpers throw on malformed reports; evaluateReports converts report problems into blockers. Invalid policy/bundle configuration throws.
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
ship-it-meter --report-file receipts.json --policy '{"minCoverage":90,"maxAgeMs":1800000}' --json
|
|
41
|
+
cat receipts.json | ship-it-meter --reports --json
|
|
42
|
+
node node_modules/@deployanyway/ship-it-meter/examples/report-gate.mjs receipts.json
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Report CLI input is bounded to 10 MiB and exits 1 when blocked, 2 for invalid arguments/configuration. Receipt mode rejects manual evidence overrides and conflicting gate/scenario flags. The browser demo uses pure parsers; file reading and CI collection happen in Node. Metadata is a claim from your trusted pipeline, not a cryptographic attestation; do not relabel old reports or trust arbitrary submitted bundles. The gate neither runs your CI nor deploys anything. See examples/collect-receipts.mjs for collecting new Node test and c8 coverage reports together, followed by your actual npm build, inside an npm script. It requires c8 as a project dev dependency, Node-discoverable tests (or explicit test file arguments), npm run build, an unchanged Git commit and clean tracked source. Generated/untracked files must be excluded from your test configuration; receipt metadata remains a trusted pipeline claim.
|
|
46
|
+
|
|
47
|
+
## Stable v1 contract
|
|
48
|
+
|
|
49
|
+
Node 22.13+ or Node 24. MIT licensed. CLI flags, structured fields, ESM/CommonJS exports and declarations are covered by tests and installed-package checks. Existing 0.4 APIs remain available except the explicitly documented doggo-log redaction/text-context changes. Future incompatible public API changes require a major release; callers should consume structured fields rather than parse jokes. Exact humorous wording and seeded catalog choices are version-specific. No telemetry, external API keys or network service is needed for core use.
|
|
50
|
+
|
|
51
|
+
Run npm test, npm run lint, npm run format:check, npm run coverage, npm run test:types and npm run verify:package from a source checkout. Runnable examples are shipped under examples/. The root demo is https://deployanyway.github.io/.
|
|
52
|
+
|
|
3
53
|
## Evidence into an actual release plan (0.4.0)
|
|
4
54
|
|
|
5
55
|
Twelve named demonstration scenarios show passing evidence, Friday releases, missing evidence, failed builds, failing tests, low coverage, critical issues, lint failures, dirty worktrees, feature branches, zero tests and weekend incidents. These are examples, never evidence about your real project.
|
package/dist/index.cjs
CHANGED
|
@@ -20,6 +20,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.js
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
evaluateReports: () => evaluateReports,
|
|
24
|
+
parseCoverageReport: () => parseCoverageReport,
|
|
25
|
+
parseTestReport: () => parseTestReport,
|
|
23
26
|
preflight: () => preflight,
|
|
24
27
|
releaseGate: () => releaseGate,
|
|
25
28
|
releasePlan: () => releasePlan,
|
|
@@ -151,6 +154,214 @@ function scenarioEvidence(name) {
|
|
|
151
154
|
return { ...scenarios[name].evidence };
|
|
152
155
|
}
|
|
153
156
|
|
|
157
|
+
// src/reports.js
|
|
158
|
+
var object = (value, label) => {
|
|
159
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
160
|
+
throw new TypeError(`${label} must be an object.`);
|
|
161
|
+
return value;
|
|
162
|
+
};
|
|
163
|
+
var count = (value, label) => {
|
|
164
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
165
|
+
throw new TypeError(`${label} must be a nonnegative safe integer.`);
|
|
166
|
+
return value;
|
|
167
|
+
};
|
|
168
|
+
var json = (value) => typeof value === "string" ? JSON.parse(value) : value;
|
|
169
|
+
var date = (value, label) => {
|
|
170
|
+
if (typeof value !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(value) || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString().slice(0, 19) !== value.slice(0, 19))
|
|
171
|
+
throw new TypeError(`${label} must be a UTC ISO timestamp.`);
|
|
172
|
+
return Date.parse(value);
|
|
173
|
+
};
|
|
174
|
+
function parseTestReport(data, format = "node-tap") {
|
|
175
|
+
if (format === "node-tap") {
|
|
176
|
+
if (typeof data !== "string" || !data.startsWith("TAP version 13") || /^Bail out!/m.test(data))
|
|
177
|
+
throw new TypeError("Expected a completed Node TAP v13 report.");
|
|
178
|
+
const values = {};
|
|
179
|
+
for (const key of [
|
|
180
|
+
"tests",
|
|
181
|
+
"pass",
|
|
182
|
+
"fail",
|
|
183
|
+
"cancelled",
|
|
184
|
+
"skipped",
|
|
185
|
+
"todo"
|
|
186
|
+
]) {
|
|
187
|
+
const matches = [
|
|
188
|
+
...data.matchAll(new RegExp(`^# ${key} (\\d+)\\r?$`, "gm"))
|
|
189
|
+
];
|
|
190
|
+
if (matches.length !== 1)
|
|
191
|
+
throw new TypeError(`TAP requires one ${key} summary.`);
|
|
192
|
+
values[key] = count(Number(matches[0][1]), key);
|
|
193
|
+
}
|
|
194
|
+
if (values.pass + values.fail + values.cancelled + values.skipped + values.todo !== values.tests)
|
|
195
|
+
throw new TypeError("TAP counts do not reconcile.");
|
|
196
|
+
return {
|
|
197
|
+
tests: values.tests - values.skipped - values.todo,
|
|
198
|
+
passedTests: values.pass,
|
|
199
|
+
failingTests: values.fail + values.cancelled,
|
|
200
|
+
skippedTests: values.skipped,
|
|
201
|
+
todoTests: values.todo,
|
|
202
|
+
successful: values.fail + values.cancelled === 0
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (format === "jest") {
|
|
206
|
+
const report = object(json(data), "Jest report");
|
|
207
|
+
const total = count(report.numTotalTests, "numTotalTests"), passed = count(report.numPassedTests, "numPassedTests"), failed = count(report.numFailedTests, "numFailedTests"), pending = count(report.numPendingTests, "numPendingTests"), todo = count(report.numTodoTests ?? 0, "numTodoTests");
|
|
208
|
+
if (total !== passed + failed + pending + todo || typeof report.success !== "boolean")
|
|
209
|
+
throw new TypeError("Jest counts/success do not reconcile.");
|
|
210
|
+
if (report.wasInterrupted || report.testResults?.some(
|
|
211
|
+
(result) => result.assertionResults?.some(
|
|
212
|
+
(assertion) => assertion.wouldRun === true
|
|
213
|
+
)
|
|
214
|
+
))
|
|
215
|
+
throw new TypeError(
|
|
216
|
+
"Interrupted or collected-only Jest output is not executed evidence."
|
|
217
|
+
);
|
|
218
|
+
const failedSuites = count(
|
|
219
|
+
report.numFailedTestSuites ?? 0,
|
|
220
|
+
"numFailedTestSuites"
|
|
221
|
+
);
|
|
222
|
+
return {
|
|
223
|
+
tests: total - pending - todo,
|
|
224
|
+
passedTests: passed,
|
|
225
|
+
failingTests: failed,
|
|
226
|
+
skippedTests: pending,
|
|
227
|
+
todoTests: todo,
|
|
228
|
+
successful: report.success && failed === 0 && failedSuites === 0
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
throw new RangeError("Supported test formats: node-tap, jest.");
|
|
232
|
+
}
|
|
233
|
+
function parseCoverageReport(data) {
|
|
234
|
+
const total = object(
|
|
235
|
+
object(json(data), "Coverage report").total,
|
|
236
|
+
"Coverage total"
|
|
237
|
+
), result = {};
|
|
238
|
+
for (const key of ["lines", "statements", "functions", "branches"]) {
|
|
239
|
+
const metric = object(total[key], `Coverage ${key}`), all = count(metric.total, `${key}.total`), covered = count(metric.covered, `${key}.covered`);
|
|
240
|
+
if (all === 0 && ["lines", "statements"].includes(key) || covered > all || typeof metric.pct !== "number" || !Number.isFinite(metric.pct) || metric.pct < 0 || metric.pct > 100)
|
|
241
|
+
throw new TypeError(
|
|
242
|
+
`Coverage ${key} must contain measured code and a percentage.`
|
|
243
|
+
);
|
|
244
|
+
const computed = all === 0 ? 100 : covered / all * 100;
|
|
245
|
+
if (Math.abs(computed - metric.pct) > 0.02)
|
|
246
|
+
throw new TypeError(`Coverage ${key} percentage disagrees with counts.`);
|
|
247
|
+
result[key] = metric.pct;
|
|
248
|
+
}
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
function evaluateReports(bundle, options = {}) {
|
|
252
|
+
object(bundle, "Report bundle");
|
|
253
|
+
object(options, "Policy");
|
|
254
|
+
for (const key of Object.keys(options))
|
|
255
|
+
if (!["minTests", "minCoverage", "minScore", "maxAgeMs", "now"].includes(key))
|
|
256
|
+
throw new TypeError(`Unknown policy option: ${key}.`);
|
|
257
|
+
if (typeof bundle.commit !== "string" || !bundle.commit.trim())
|
|
258
|
+
throw new TypeError("Report bundle requires a nonempty commit.");
|
|
259
|
+
const policy = {
|
|
260
|
+
minTests: 1,
|
|
261
|
+
minCoverage: 80,
|
|
262
|
+
minScore: 80,
|
|
263
|
+
maxAgeMs: 36e5,
|
|
264
|
+
...options
|
|
265
|
+
};
|
|
266
|
+
for (const [key, value] of Object.entries({
|
|
267
|
+
minTests: 1,
|
|
268
|
+
minCoverage: 80,
|
|
269
|
+
minScore: 80,
|
|
270
|
+
maxAgeMs: 36e5
|
|
271
|
+
}))
|
|
272
|
+
if (policy[key] === void 0) policy[key] = value;
|
|
273
|
+
count(policy.minTests, "minTests");
|
|
274
|
+
if (policy.minTests < 1) throw new RangeError("minTests must be positive.");
|
|
275
|
+
for (const key of ["minCoverage", "minScore"])
|
|
276
|
+
if (typeof policy[key] !== "number" || !Number.isFinite(policy[key]) || policy[key] < 0 || policy[key] > 100)
|
|
277
|
+
throw new RangeError(`${key} must be from 0 to 100.`);
|
|
278
|
+
if (!Number.isSafeInteger(policy.maxAgeMs) || policy.maxAgeMs < 1)
|
|
279
|
+
throw new RangeError("maxAgeMs must be a positive safe integer.");
|
|
280
|
+
const now = options.now ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
281
|
+
const nowMs = date(now, "now");
|
|
282
|
+
const evidence = {}, receipts = [], blockers = [];
|
|
283
|
+
for (const kind of ["tests", "coverage", "build"]) {
|
|
284
|
+
const issues = [], report = bundle[kind];
|
|
285
|
+
if (!report) {
|
|
286
|
+
issues.push(`${kind}: report is missing.`);
|
|
287
|
+
} else
|
|
288
|
+
try {
|
|
289
|
+
object(report, kind);
|
|
290
|
+
const captured = date(report.capturedAt, `${kind}.capturedAt`);
|
|
291
|
+
if (report.commit !== bundle.commit)
|
|
292
|
+
issues.push(`${kind}: commit does not match ${bundle.commit}.`);
|
|
293
|
+
if (captured > nowMs)
|
|
294
|
+
issues.push(`${kind}: timestamp is in the future.`);
|
|
295
|
+
if (nowMs - captured > policy.maxAgeMs)
|
|
296
|
+
issues.push(`${kind}: report is stale.`);
|
|
297
|
+
if (kind === "tests") {
|
|
298
|
+
if (!["node-tap", "jest"].includes(report.format))
|
|
299
|
+
throw new RangeError("Test format must be node-tap or jest.");
|
|
300
|
+
const parsed = parseTestReport(report.data, report.format);
|
|
301
|
+
evidence.tests = parsed.tests;
|
|
302
|
+
evidence.failingTests = parsed.failingTests;
|
|
303
|
+
if (!parsed.successful)
|
|
304
|
+
issues.push("tests: test execution was unsuccessful.");
|
|
305
|
+
if (parsed.tests < policy.minTests)
|
|
306
|
+
issues.push(
|
|
307
|
+
`tests: ${parsed.tests} executed tests is below ${policy.minTests}.`
|
|
308
|
+
);
|
|
309
|
+
} else if (kind === "coverage") {
|
|
310
|
+
if (report.format !== "istanbul-summary")
|
|
311
|
+
throw new RangeError("Coverage format must be istanbul-summary.");
|
|
312
|
+
const coverage = parseCoverageReport(report.data);
|
|
313
|
+
evidence.coverage = coverage.lines;
|
|
314
|
+
for (const [metric, value] of Object.entries(coverage))
|
|
315
|
+
if (value < policy.minCoverage)
|
|
316
|
+
issues.push(
|
|
317
|
+
`coverage: ${metric} ${value}% is below ${policy.minCoverage}%.`
|
|
318
|
+
);
|
|
319
|
+
} else {
|
|
320
|
+
count(report.exitCode, "build.exitCode");
|
|
321
|
+
evidence.build = report.exitCode === 0;
|
|
322
|
+
if (report.exitCode !== 0)
|
|
323
|
+
issues.push(`build: command exited ${report.exitCode}.`);
|
|
324
|
+
}
|
|
325
|
+
} catch (error) {
|
|
326
|
+
issues.push(`${kind}: ${error.message}`);
|
|
327
|
+
}
|
|
328
|
+
receipts.push({
|
|
329
|
+
kind,
|
|
330
|
+
format: kind === "build" ? "exit-code" : report?.format ?? "missing",
|
|
331
|
+
commit: typeof report?.commit === "string" ? report.commit : null,
|
|
332
|
+
capturedAt: typeof report?.capturedAt === "string" ? report.capturedAt : null,
|
|
333
|
+
accepted: issues.length === 0,
|
|
334
|
+
issues
|
|
335
|
+
});
|
|
336
|
+
blockers.push(...issues);
|
|
337
|
+
}
|
|
338
|
+
const plan = releasePlan(evidence, { minScore: policy.minScore });
|
|
339
|
+
const combined = [.../* @__PURE__ */ new Set([...blockers, ...plan.blockers])];
|
|
340
|
+
const receiptTasks = receipts.filter((item) => !item.accepted).map((item) => ({
|
|
341
|
+
id: "receipt-" + item.kind,
|
|
342
|
+
priority: "blocker",
|
|
343
|
+
title: "Repair " + item.kind + " evidence",
|
|
344
|
+
verify: `Generate a completed ${item.kind} report for ${bundle.commit} with a current UTC timestamp; resolve: ${item.issues.join(" ")}`
|
|
345
|
+
}));
|
|
346
|
+
return {
|
|
347
|
+
...plan,
|
|
348
|
+
summary: combined.length ? "The receipts say stop. Confidence must wait." : plan.summary,
|
|
349
|
+
tasks: [...receiptTasks, ...plan.tasks],
|
|
350
|
+
passed: combined.length === 0,
|
|
351
|
+
blockers: combined,
|
|
352
|
+
evidence,
|
|
353
|
+
receipts,
|
|
354
|
+
commit: bundle.commit,
|
|
355
|
+
policy: {
|
|
356
|
+
minTests: policy.minTests,
|
|
357
|
+
minCoverage: policy.minCoverage,
|
|
358
|
+
minScore: policy.minScore,
|
|
359
|
+
maxAgeMs: policy.maxAgeMs
|
|
360
|
+
},
|
|
361
|
+
evaluatedAt: now
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
154
365
|
// src/index.js
|
|
155
366
|
var days = [
|
|
156
367
|
"monday",
|
|
@@ -408,6 +619,9 @@ function releasePlan(input = {}, options = {}) {
|
|
|
408
619
|
}
|
|
409
620
|
// Annotate the CommonJS export names for ESM import in node:
|
|
410
621
|
0 && (module.exports = {
|
|
622
|
+
evaluateReports,
|
|
623
|
+
parseCoverageReport,
|
|
624
|
+
parseTestReport,
|
|
411
625
|
preflight,
|
|
412
626
|
releaseGate,
|
|
413
627
|
releasePlan,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { evaluateReports } from "@deployanyway/ship-it-meter";
|
|
6
|
+
// Configure in your project: "release:receipts": "node node_modules/@deployanyway/ship-it-meter/examples/collect-receipts.mjs"
|
|
7
|
+
// Requires your project's c8 dev dependency, Node tests using Node test discovery, and npm run build.
|
|
8
|
+
const run = (args) =>
|
|
9
|
+
spawnSync(process.execPath, args, {
|
|
10
|
+
encoding: "utf8",
|
|
11
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
12
|
+
});
|
|
13
|
+
const git = (args) => spawnSync("git", args, { encoding: "utf8" });
|
|
14
|
+
let temp;
|
|
15
|
+
try {
|
|
16
|
+
if (!process.env.npm_execpath)
|
|
17
|
+
throw new Error(
|
|
18
|
+
"Run through an npm script so the build uses the current npm CLI.",
|
|
19
|
+
);
|
|
20
|
+
const head = git(["rev-parse", "HEAD"]);
|
|
21
|
+
if (head.status !== 0)
|
|
22
|
+
throw new Error("Cannot identify the exact Git commit.");
|
|
23
|
+
const commit = head.stdout.trim();
|
|
24
|
+
const clean = () => {
|
|
25
|
+
const status = git(["status", "--porcelain", "--untracked-files=no"]);
|
|
26
|
+
if (status.status !== 0 || status.stdout.trim())
|
|
27
|
+
throw new Error(
|
|
28
|
+
"Tracked source changes must be committed before collecting receipts.",
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
clean();
|
|
32
|
+
temp = mkdtempSync(join(tmpdir(), "deployanyway-receipts-"));
|
|
33
|
+
const tests = run([
|
|
34
|
+
resolve("node_modules/c8/bin/c8.js"),
|
|
35
|
+
"--reporter=json-summary",
|
|
36
|
+
"--reports-dir",
|
|
37
|
+
temp,
|
|
38
|
+
"--temp-directory",
|
|
39
|
+
join(temp, "v8"),
|
|
40
|
+
process.execPath,
|
|
41
|
+
"--test",
|
|
42
|
+
"--test-reporter=tap",
|
|
43
|
+
...process.argv.slice(2),
|
|
44
|
+
]);
|
|
45
|
+
if (tests.error) throw tests.error;
|
|
46
|
+
const testTime = new Date().toISOString(),
|
|
47
|
+
coverage = JSON.parse(
|
|
48
|
+
readFileSync(join(temp, "coverage-summary.json"), "utf8"),
|
|
49
|
+
);
|
|
50
|
+
const build = run([process.env.npm_execpath, "run", "build"]);
|
|
51
|
+
if (build.error) throw build.error;
|
|
52
|
+
const after = git(["rev-parse", "HEAD"]);
|
|
53
|
+
if (after.status !== 0 || after.stdout.trim() !== commit)
|
|
54
|
+
throw new Error("Commit changed during collection.");
|
|
55
|
+
clean();
|
|
56
|
+
const bundle = {
|
|
57
|
+
commit,
|
|
58
|
+
tests: {
|
|
59
|
+
commit,
|
|
60
|
+
capturedAt: testTime,
|
|
61
|
+
format: "node-tap",
|
|
62
|
+
data: tests.stdout,
|
|
63
|
+
},
|
|
64
|
+
coverage: {
|
|
65
|
+
commit,
|
|
66
|
+
capturedAt: testTime,
|
|
67
|
+
format: "istanbul-summary",
|
|
68
|
+
data: coverage,
|
|
69
|
+
},
|
|
70
|
+
build: {
|
|
71
|
+
commit,
|
|
72
|
+
capturedAt: new Date().toISOString(),
|
|
73
|
+
exitCode: build.status,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
writeFileSync("receipts.json", JSON.stringify(bundle, null, 2));
|
|
77
|
+
const result = evaluateReports(bundle);
|
|
78
|
+
console.log(JSON.stringify(result, null, 2));
|
|
79
|
+
process.exitCode = result.passed ? 0 : 1;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error(error.message);
|
|
82
|
+
process.exitCode = 2;
|
|
83
|
+
} finally {
|
|
84
|
+
if (temp) rmSync(temp, { recursive: true, force: true });
|
|
85
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { evaluateReports } from "@deployanyway/ship-it-meter";
|
|
3
|
+
try {
|
|
4
|
+
if (!process.argv[2]) throw new TypeError("Provide receipts.json.");
|
|
5
|
+
const result = evaluateReports(
|
|
6
|
+
JSON.parse(readFileSync(process.argv[2], "utf8")),
|
|
7
|
+
);
|
|
8
|
+
console.log(JSON.stringify(result, null, 2));
|
|
9
|
+
process.exitCode = result.passed ? 0 : 1;
|
|
10
|
+
} catch (error) {
|
|
11
|
+
console.error(error.message);
|
|
12
|
+
process.exitCode = 2;
|
|
13
|
+
}
|
package/index.d.cts
CHANGED
|
@@ -63,3 +63,55 @@ export function releasePlan(
|
|
|
63
63
|
input?: Evidence,
|
|
64
64
|
options?: { minScore?: number },
|
|
65
65
|
): ReleasePlan;
|
|
66
|
+
export type TestReportFormat = "node-tap" | "jest";
|
|
67
|
+
export interface TestReportSummary {
|
|
68
|
+
tests: number;
|
|
69
|
+
passedTests: number;
|
|
70
|
+
failingTests: number;
|
|
71
|
+
skippedTests: number;
|
|
72
|
+
todoTests: number;
|
|
73
|
+
successful: boolean;
|
|
74
|
+
}
|
|
75
|
+
export function parseTestReport(
|
|
76
|
+
data: unknown,
|
|
77
|
+
format?: TestReportFormat,
|
|
78
|
+
): TestReportSummary;
|
|
79
|
+
export function parseCoverageReport(
|
|
80
|
+
data: unknown,
|
|
81
|
+
): Record<"lines" | "statements" | "functions" | "branches", number>;
|
|
82
|
+
export interface ReportMetadata {
|
|
83
|
+
commit: string;
|
|
84
|
+
capturedAt: string;
|
|
85
|
+
}
|
|
86
|
+
export interface ReportBundle {
|
|
87
|
+
commit: string;
|
|
88
|
+
tests?: ReportMetadata & { format: TestReportFormat; data: unknown };
|
|
89
|
+
coverage?: ReportMetadata & { format: "istanbul-summary"; data: unknown };
|
|
90
|
+
build?: ReportMetadata & { exitCode: number };
|
|
91
|
+
}
|
|
92
|
+
export interface ReportPolicy {
|
|
93
|
+
minTests?: number;
|
|
94
|
+
minCoverage?: number;
|
|
95
|
+
minScore?: number;
|
|
96
|
+
maxAgeMs?: number;
|
|
97
|
+
now?: string;
|
|
98
|
+
}
|
|
99
|
+
export interface ReportReceipt {
|
|
100
|
+
kind: "tests" | "coverage" | "build";
|
|
101
|
+
format: string;
|
|
102
|
+
commit: string | null;
|
|
103
|
+
capturedAt: string | null;
|
|
104
|
+
accepted: boolean;
|
|
105
|
+
issues: string[];
|
|
106
|
+
}
|
|
107
|
+
export interface ReportEvaluation extends ReleasePlan {
|
|
108
|
+
evidence: Evidence;
|
|
109
|
+
receipts: ReportReceipt[];
|
|
110
|
+
commit: string;
|
|
111
|
+
policy: Required<Omit<ReportPolicy, "now">>;
|
|
112
|
+
evaluatedAt: string;
|
|
113
|
+
}
|
|
114
|
+
export function evaluateReports(
|
|
115
|
+
bundle: ReportBundle,
|
|
116
|
+
options?: ReportPolicy,
|
|
117
|
+
): ReportEvaluation;
|
package/index.d.ts
CHANGED
|
@@ -63,3 +63,55 @@ export function releasePlan(
|
|
|
63
63
|
input?: Evidence,
|
|
64
64
|
options?: { minScore?: number },
|
|
65
65
|
): ReleasePlan;
|
|
66
|
+
export type TestReportFormat = "node-tap" | "jest";
|
|
67
|
+
export interface TestReportSummary {
|
|
68
|
+
tests: number;
|
|
69
|
+
passedTests: number;
|
|
70
|
+
failingTests: number;
|
|
71
|
+
skippedTests: number;
|
|
72
|
+
todoTests: number;
|
|
73
|
+
successful: boolean;
|
|
74
|
+
}
|
|
75
|
+
export function parseTestReport(
|
|
76
|
+
data: unknown,
|
|
77
|
+
format?: TestReportFormat,
|
|
78
|
+
): TestReportSummary;
|
|
79
|
+
export function parseCoverageReport(
|
|
80
|
+
data: unknown,
|
|
81
|
+
): Record<"lines" | "statements" | "functions" | "branches", number>;
|
|
82
|
+
export interface ReportMetadata {
|
|
83
|
+
commit: string;
|
|
84
|
+
capturedAt: string;
|
|
85
|
+
}
|
|
86
|
+
export interface ReportBundle {
|
|
87
|
+
commit: string;
|
|
88
|
+
tests?: ReportMetadata & { format: TestReportFormat; data: unknown };
|
|
89
|
+
coverage?: ReportMetadata & { format: "istanbul-summary"; data: unknown };
|
|
90
|
+
build?: ReportMetadata & { exitCode: number };
|
|
91
|
+
}
|
|
92
|
+
export interface ReportPolicy {
|
|
93
|
+
minTests?: number;
|
|
94
|
+
minCoverage?: number;
|
|
95
|
+
minScore?: number;
|
|
96
|
+
maxAgeMs?: number;
|
|
97
|
+
now?: string;
|
|
98
|
+
}
|
|
99
|
+
export interface ReportReceipt {
|
|
100
|
+
kind: "tests" | "coverage" | "build";
|
|
101
|
+
format: string;
|
|
102
|
+
commit: string | null;
|
|
103
|
+
capturedAt: string | null;
|
|
104
|
+
accepted: boolean;
|
|
105
|
+
issues: string[];
|
|
106
|
+
}
|
|
107
|
+
export interface ReportEvaluation extends ReleasePlan {
|
|
108
|
+
evidence: Evidence;
|
|
109
|
+
receipts: ReportReceipt[];
|
|
110
|
+
commit: string;
|
|
111
|
+
policy: Required<Omit<ReportPolicy, "now">>;
|
|
112
|
+
evaluatedAt: string;
|
|
113
|
+
}
|
|
114
|
+
export function evaluateReports(
|
|
115
|
+
bundle: ReportBundle,
|
|
116
|
+
options?: ReportPolicy,
|
|
117
|
+
): ReportEvaluation;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deployanyway/ship-it-meter",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Report-backed release policies, prioritized plans and explainable readiness scores. Confidence is not a build artifact; bring your receipts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"dist",
|
|
28
28
|
"index.d.ts",
|
|
29
29
|
"index.d.cts",
|
|
30
|
-
"MIGRATION.md"
|
|
30
|
+
"MIGRATION.md",
|
|
31
|
+
"examples"
|
|
31
32
|
],
|
|
32
33
|
"engines": {
|
|
33
34
|
"node": ">=22.13"
|
|
@@ -75,7 +76,11 @@
|
|
|
75
76
|
"scoring",
|
|
76
77
|
"typescript",
|
|
77
78
|
"humor",
|
|
78
|
-
"deployanyway"
|
|
79
|
+
"deployanyway",
|
|
80
|
+
"ci",
|
|
81
|
+
"release-policy",
|
|
82
|
+
"test-reports",
|
|
83
|
+
"istanbul"
|
|
79
84
|
],
|
|
80
85
|
"main": "./dist/index.cjs",
|
|
81
86
|
"types": "./index.d.ts",
|
package/src/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { URL } from "node:url";
|
|
4
4
|
import {
|
|
5
5
|
shipIt,
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
releasePlan,
|
|
9
9
|
releaseScenarios,
|
|
10
10
|
scenarioEvidence,
|
|
11
|
+
evaluateReports,
|
|
11
12
|
} from "./index.js";
|
|
12
13
|
|
|
13
14
|
import { readStdin } from "./input.js";
|
|
@@ -19,6 +20,9 @@ try {
|
|
|
19
20
|
version: { type: "boolean", short: "v" },
|
|
20
21
|
json: { type: "boolean" },
|
|
21
22
|
stdin: { type: "boolean" },
|
|
23
|
+
reports: { type: "boolean" },
|
|
24
|
+
"report-file": { type: "string" },
|
|
25
|
+
policy: { type: "string" },
|
|
22
26
|
gate: { type: "boolean" },
|
|
23
27
|
plan: { type: "boolean" },
|
|
24
28
|
scenario: { type: "string" },
|
|
@@ -38,7 +42,7 @@ try {
|
|
|
38
42
|
});
|
|
39
43
|
if (values.help) {
|
|
40
44
|
console.log(
|
|
41
|
-
"Usage: ship-it-meter [options]\n\nOptions:\n --tests number Total tests\n --failing number Failing tests\n --coverage number Coverage percent\n --build pass|fail Build result\n --critical number Open critical issues\n --lint number Lint failures\n --dirty Uncommitted changes\n --branch name Branch name\n --day weekday Full weekday name\n --scenario name Explore a named evidence scenario\n --list-scenarios List sample scenarios\n --plan Gate plus prioritized tasks and verification criteria\n --checklist Actionable, mildly concerned preflight\n --json Structured output\n -h, --help Help\n -v, --version Version\n\n--stdin reads a JSON evidence object (256 KiB); explicit flags override it. --gate requires passing evidence; --min-score 80 selects its threshold. A blocked gate exits 1. Missing test, coverage, and build evidence reduces readiness.\nExit codes: 0 scored successfully; 2 invalid arguments.",
|
|
45
|
+
"Usage: ship-it-meter [options]\n\nOptions:\n --reports Read report bundle JSON from stdin (10 MiB)\n --report-file path Read report bundle JSON file\n --policy JSON Receipt thresholds/freshness; receipt mode only\n --tests number Total tests\n --failing number Failing tests\n --coverage number Coverage percent\n --build pass|fail Build result\n --critical number Open critical issues\n --lint number Lint failures\n --dirty Uncommitted changes\n --branch name Branch name\n --day weekday Full weekday name\n --scenario name Explore a named evidence scenario\n --list-scenarios List sample scenarios\n --plan Gate plus prioritized tasks and verification criteria\n --checklist Actionable, mildly concerned preflight\n --json Structured output\n -h, --help Help\n -v, --version Version\n\n--stdin reads a JSON evidence object (256 KiB); explicit flags override it. --gate requires passing evidence; --min-score 80 selects its threshold. A blocked gate exits 1. Missing test, coverage, and build evidence reduces readiness.\nExit codes: 0 scored successfully; 2 invalid arguments.",
|
|
42
46
|
);
|
|
43
47
|
} else if (values.version) {
|
|
44
48
|
console.log(
|
|
@@ -52,6 +56,42 @@ try {
|
|
|
52
56
|
.map((item) => item.name + " — " + item.description)
|
|
53
57
|
.join("\n"),
|
|
54
58
|
);
|
|
59
|
+
} else if (values.reports || values["report-file"] !== undefined) {
|
|
60
|
+
if (values.reports && values["report-file"] !== undefined)
|
|
61
|
+
throw new TypeError("Choose --reports stdin or --report-file.");
|
|
62
|
+
const allowed = new Set(["reports", "report-file", "policy", "json"]);
|
|
63
|
+
for (const [key, value] of Object.entries(values))
|
|
64
|
+
if (value !== undefined && !allowed.has(key))
|
|
65
|
+
throw new TypeError(`--${key} cannot combine with report evaluation.`);
|
|
66
|
+
if (
|
|
67
|
+
values["report-file"] !== undefined &&
|
|
68
|
+
statSync(values["report-file"]).size > 10485760
|
|
69
|
+
)
|
|
70
|
+
throw new RangeError("Report bundle exceeds 10 MiB.");
|
|
71
|
+
const text =
|
|
72
|
+
values["report-file"] !== undefined
|
|
73
|
+
? readFileSync(values["report-file"], "utf8")
|
|
74
|
+
: await readStdin(process.stdin, 10485760);
|
|
75
|
+
if (Buffer.byteLength(text) > 10485760)
|
|
76
|
+
throw new RangeError("Report bundle exceeds 10 MiB.");
|
|
77
|
+
const policy = values.policy === undefined ? {} : JSON.parse(values.policy);
|
|
78
|
+
const result = evaluateReports(JSON.parse(text), policy);
|
|
79
|
+
if (!result.passed) process.exitCode = 1;
|
|
80
|
+
console.log(
|
|
81
|
+
values.json
|
|
82
|
+
? JSON.stringify(result, null, 2)
|
|
83
|
+
: `Receipt gate: ${result.passed ? "PASS" : "BLOCKED"} for ${result.commit}\n` +
|
|
84
|
+
result.receipts
|
|
85
|
+
.map(
|
|
86
|
+
(item) =>
|
|
87
|
+
`${item.kind}: ${item.accepted ? "accepted" : "rejected"}`,
|
|
88
|
+
)
|
|
89
|
+
.join("\n") +
|
|
90
|
+
"\n" +
|
|
91
|
+
result.blockers.map((item) => "- " + item).join("\n"),
|
|
92
|
+
);
|
|
93
|
+
} else if (values.policy !== undefined) {
|
|
94
|
+
throw new TypeError("--policy requires --reports or --report-file.");
|
|
55
95
|
} else {
|
|
56
96
|
const input = values.stdin
|
|
57
97
|
? JSON.parse(await readStdin())
|
package/src/index.js
CHANGED
|
@@ -297,3 +297,8 @@ export function releasePlan(input = {}, options = {}) {
|
|
|
297
297
|
tasks.sort((a, b) => rank[a.priority] - rank[b.priority]);
|
|
298
298
|
return { ...gate, summary: summaries[gate.verdict], tasks };
|
|
299
299
|
}
|
|
300
|
+
export {
|
|
301
|
+
parseTestReport,
|
|
302
|
+
parseCoverageReport,
|
|
303
|
+
evaluateReports,
|
|
304
|
+
} from "./reports.js";
|
package/src/input.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/** Read bounded UTF-8 stdin without swallowing upstream failures. */
|
|
2
|
-
export async function readStdin(stream = process.stdin) {
|
|
2
|
+
export async function readStdin(stream = process.stdin, maxBytes = 262144) {
|
|
3
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
|
|
4
|
+
throw new RangeError("Input limit must be a positive safe integer.");
|
|
3
5
|
if (stream.isTTY) throw new TypeError("Pipe or redirect input.");
|
|
4
6
|
let size = 0;
|
|
5
7
|
const chunks = [];
|
|
6
8
|
for await (const chunk of stream) {
|
|
7
9
|
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
8
10
|
size += buffer.length;
|
|
9
|
-
if (size >
|
|
11
|
+
if (size > maxBytes)
|
|
12
|
+
throw new RangeError(
|
|
13
|
+
maxBytes === 262144
|
|
14
|
+
? "stdin exceeds 256 KiB."
|
|
15
|
+
: `stdin exceeds ${maxBytes} bytes.`,
|
|
16
|
+
);
|
|
10
17
|
chunks.push(buffer);
|
|
11
18
|
}
|
|
12
19
|
const text = Buffer.concat(chunks).toString("utf8").trim();
|
package/src/reports.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { releasePlan } from "./index.js";
|
|
2
|
+
const object = (value, label) => {
|
|
3
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
4
|
+
throw new TypeError(`${label} must be an object.`);
|
|
5
|
+
return value;
|
|
6
|
+
};
|
|
7
|
+
const count = (value, label) => {
|
|
8
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
9
|
+
throw new TypeError(`${label} must be a nonnegative safe integer.`);
|
|
10
|
+
return value;
|
|
11
|
+
};
|
|
12
|
+
const json = (value) => (typeof value === "string" ? JSON.parse(value) : value);
|
|
13
|
+
const date = (value, label) => {
|
|
14
|
+
if (
|
|
15
|
+
typeof value !== "string" ||
|
|
16
|
+
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(value) ||
|
|
17
|
+
!Number.isFinite(Date.parse(value)) ||
|
|
18
|
+
new Date(value).toISOString().slice(0, 19) !== value.slice(0, 19)
|
|
19
|
+
)
|
|
20
|
+
throw new TypeError(`${label} must be a UTC ISO timestamp.`);
|
|
21
|
+
return Date.parse(value);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Parse completed Node TAP or Jest JSON test results; skipped/todo tests are not passing evidence. */
|
|
25
|
+
export function parseTestReport(data, format = "node-tap") {
|
|
26
|
+
if (format === "node-tap") {
|
|
27
|
+
if (
|
|
28
|
+
typeof data !== "string" ||
|
|
29
|
+
!data.startsWith("TAP version 13") ||
|
|
30
|
+
/^Bail out!/m.test(data)
|
|
31
|
+
)
|
|
32
|
+
throw new TypeError("Expected a completed Node TAP v13 report.");
|
|
33
|
+
const values = {};
|
|
34
|
+
for (const key of [
|
|
35
|
+
"tests",
|
|
36
|
+
"pass",
|
|
37
|
+
"fail",
|
|
38
|
+
"cancelled",
|
|
39
|
+
"skipped",
|
|
40
|
+
"todo",
|
|
41
|
+
]) {
|
|
42
|
+
const matches = [
|
|
43
|
+
...data.matchAll(new RegExp(`^# ${key} (\\d+)\\r?$`, "gm")),
|
|
44
|
+
];
|
|
45
|
+
if (matches.length !== 1)
|
|
46
|
+
throw new TypeError(`TAP requires one ${key} summary.`);
|
|
47
|
+
values[key] = count(Number(matches[0][1]), key);
|
|
48
|
+
}
|
|
49
|
+
if (
|
|
50
|
+
values.pass +
|
|
51
|
+
values.fail +
|
|
52
|
+
values.cancelled +
|
|
53
|
+
values.skipped +
|
|
54
|
+
values.todo !==
|
|
55
|
+
values.tests
|
|
56
|
+
)
|
|
57
|
+
throw new TypeError("TAP counts do not reconcile.");
|
|
58
|
+
return {
|
|
59
|
+
tests: values.tests - values.skipped - values.todo,
|
|
60
|
+
passedTests: values.pass,
|
|
61
|
+
failingTests: values.fail + values.cancelled,
|
|
62
|
+
skippedTests: values.skipped,
|
|
63
|
+
todoTests: values.todo,
|
|
64
|
+
successful: values.fail + values.cancelled === 0,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (format === "jest") {
|
|
68
|
+
const report = object(json(data), "Jest report");
|
|
69
|
+
const total = count(report.numTotalTests, "numTotalTests"),
|
|
70
|
+
passed = count(report.numPassedTests, "numPassedTests"),
|
|
71
|
+
failed = count(report.numFailedTests, "numFailedTests"),
|
|
72
|
+
pending = count(report.numPendingTests, "numPendingTests"),
|
|
73
|
+
todo = count(report.numTodoTests ?? 0, "numTodoTests");
|
|
74
|
+
if (
|
|
75
|
+
total !== passed + failed + pending + todo ||
|
|
76
|
+
typeof report.success !== "boolean"
|
|
77
|
+
)
|
|
78
|
+
throw new TypeError("Jest counts/success do not reconcile.");
|
|
79
|
+
if (
|
|
80
|
+
report.wasInterrupted ||
|
|
81
|
+
report.testResults?.some((result) =>
|
|
82
|
+
result.assertionResults?.some(
|
|
83
|
+
(assertion) => assertion.wouldRun === true,
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
throw new TypeError(
|
|
88
|
+
"Interrupted or collected-only Jest output is not executed evidence.",
|
|
89
|
+
);
|
|
90
|
+
const failedSuites = count(
|
|
91
|
+
report.numFailedTestSuites ?? 0,
|
|
92
|
+
"numFailedTestSuites",
|
|
93
|
+
);
|
|
94
|
+
return {
|
|
95
|
+
tests: total - pending - todo,
|
|
96
|
+
passedTests: passed,
|
|
97
|
+
failingTests: failed,
|
|
98
|
+
skippedTests: pending,
|
|
99
|
+
todoTests: todo,
|
|
100
|
+
successful: report.success && failed === 0 && failedSuites === 0,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
throw new RangeError("Supported test formats: node-tap, jest.");
|
|
104
|
+
}
|
|
105
|
+
/** Read Istanbul/c8 coverage-summary.json. Empty instrumented code is not evidence. */
|
|
106
|
+
export function parseCoverageReport(data) {
|
|
107
|
+
const total = object(
|
|
108
|
+
object(json(data), "Coverage report").total,
|
|
109
|
+
"Coverage total",
|
|
110
|
+
),
|
|
111
|
+
result = {};
|
|
112
|
+
for (const key of ["lines", "statements", "functions", "branches"]) {
|
|
113
|
+
const metric = object(total[key], `Coverage ${key}`),
|
|
114
|
+
all = count(metric.total, `${key}.total`),
|
|
115
|
+
covered = count(metric.covered, `${key}.covered`);
|
|
116
|
+
if (
|
|
117
|
+
(all === 0 && ["lines", "statements"].includes(key)) ||
|
|
118
|
+
covered > all ||
|
|
119
|
+
typeof metric.pct !== "number" ||
|
|
120
|
+
!Number.isFinite(metric.pct) ||
|
|
121
|
+
metric.pct < 0 ||
|
|
122
|
+
metric.pct > 100
|
|
123
|
+
)
|
|
124
|
+
throw new TypeError(
|
|
125
|
+
`Coverage ${key} must contain measured code and a percentage.`,
|
|
126
|
+
);
|
|
127
|
+
const computed = all === 0 ? 100 : (covered / all) * 100;
|
|
128
|
+
if (Math.abs(computed - metric.pct) > 0.02)
|
|
129
|
+
throw new TypeError(`Coverage ${key} percentage disagrees with counts.`);
|
|
130
|
+
result[key] = metric.pct;
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
/** Gate real report envelopes. Caller-supplied commit/timestamps are provenance claims, not signatures. */
|
|
135
|
+
export function evaluateReports(bundle, options = {}) {
|
|
136
|
+
object(bundle, "Report bundle");
|
|
137
|
+
object(options, "Policy");
|
|
138
|
+
for (const key of Object.keys(options))
|
|
139
|
+
if (
|
|
140
|
+
!["minTests", "minCoverage", "minScore", "maxAgeMs", "now"].includes(key)
|
|
141
|
+
)
|
|
142
|
+
throw new TypeError(`Unknown policy option: ${key}.`);
|
|
143
|
+
if (typeof bundle.commit !== "string" || !bundle.commit.trim())
|
|
144
|
+
throw new TypeError("Report bundle requires a nonempty commit.");
|
|
145
|
+
const policy = {
|
|
146
|
+
minTests: 1,
|
|
147
|
+
minCoverage: 80,
|
|
148
|
+
minScore: 80,
|
|
149
|
+
maxAgeMs: 3600000,
|
|
150
|
+
...options,
|
|
151
|
+
};
|
|
152
|
+
for (const [key, value] of Object.entries({
|
|
153
|
+
minTests: 1,
|
|
154
|
+
minCoverage: 80,
|
|
155
|
+
minScore: 80,
|
|
156
|
+
maxAgeMs: 3600000,
|
|
157
|
+
}))
|
|
158
|
+
if (policy[key] === undefined) policy[key] = value;
|
|
159
|
+
count(policy.minTests, "minTests");
|
|
160
|
+
if (policy.minTests < 1) throw new RangeError("minTests must be positive.");
|
|
161
|
+
for (const key of ["minCoverage", "minScore"])
|
|
162
|
+
if (
|
|
163
|
+
typeof policy[key] !== "number" ||
|
|
164
|
+
!Number.isFinite(policy[key]) ||
|
|
165
|
+
policy[key] < 0 ||
|
|
166
|
+
policy[key] > 100
|
|
167
|
+
)
|
|
168
|
+
throw new RangeError(`${key} must be from 0 to 100.`);
|
|
169
|
+
if (!Number.isSafeInteger(policy.maxAgeMs) || policy.maxAgeMs < 1)
|
|
170
|
+
throw new RangeError("maxAgeMs must be a positive safe integer.");
|
|
171
|
+
const now = options.now ?? new Date().toISOString();
|
|
172
|
+
const nowMs = date(now, "now");
|
|
173
|
+
const evidence = {},
|
|
174
|
+
receipts = [],
|
|
175
|
+
blockers = [];
|
|
176
|
+
for (const kind of ["tests", "coverage", "build"]) {
|
|
177
|
+
const issues = [],
|
|
178
|
+
report = bundle[kind];
|
|
179
|
+
if (!report) {
|
|
180
|
+
issues.push(`${kind}: report is missing.`);
|
|
181
|
+
} else
|
|
182
|
+
try {
|
|
183
|
+
object(report, kind);
|
|
184
|
+
const captured = date(report.capturedAt, `${kind}.capturedAt`);
|
|
185
|
+
if (report.commit !== bundle.commit)
|
|
186
|
+
issues.push(`${kind}: commit does not match ${bundle.commit}.`);
|
|
187
|
+
if (captured > nowMs)
|
|
188
|
+
issues.push(`${kind}: timestamp is in the future.`);
|
|
189
|
+
if (nowMs - captured > policy.maxAgeMs)
|
|
190
|
+
issues.push(`${kind}: report is stale.`);
|
|
191
|
+
if (kind === "tests") {
|
|
192
|
+
if (!["node-tap", "jest"].includes(report.format))
|
|
193
|
+
throw new RangeError("Test format must be node-tap or jest.");
|
|
194
|
+
const parsed = parseTestReport(report.data, report.format);
|
|
195
|
+
evidence.tests = parsed.tests;
|
|
196
|
+
evidence.failingTests = parsed.failingTests;
|
|
197
|
+
if (!parsed.successful)
|
|
198
|
+
issues.push("tests: test execution was unsuccessful.");
|
|
199
|
+
if (parsed.tests < policy.minTests)
|
|
200
|
+
issues.push(
|
|
201
|
+
`tests: ${parsed.tests} executed tests is below ${policy.minTests}.`,
|
|
202
|
+
);
|
|
203
|
+
} else if (kind === "coverage") {
|
|
204
|
+
if (report.format !== "istanbul-summary")
|
|
205
|
+
throw new RangeError("Coverage format must be istanbul-summary.");
|
|
206
|
+
const coverage = parseCoverageReport(report.data);
|
|
207
|
+
evidence.coverage = coverage.lines;
|
|
208
|
+
for (const [metric, value] of Object.entries(coverage))
|
|
209
|
+
if (value < policy.minCoverage)
|
|
210
|
+
issues.push(
|
|
211
|
+
`coverage: ${metric} ${value}% is below ${policy.minCoverage}%.`,
|
|
212
|
+
);
|
|
213
|
+
} else {
|
|
214
|
+
count(report.exitCode, "build.exitCode");
|
|
215
|
+
evidence.build = report.exitCode === 0;
|
|
216
|
+
if (report.exitCode !== 0)
|
|
217
|
+
issues.push(`build: command exited ${report.exitCode}.`);
|
|
218
|
+
}
|
|
219
|
+
} catch (error) {
|
|
220
|
+
issues.push(`${kind}: ${error.message}`);
|
|
221
|
+
}
|
|
222
|
+
receipts.push({
|
|
223
|
+
kind,
|
|
224
|
+
format: kind === "build" ? "exit-code" : (report?.format ?? "missing"),
|
|
225
|
+
commit: typeof report?.commit === "string" ? report.commit : null,
|
|
226
|
+
capturedAt:
|
|
227
|
+
typeof report?.capturedAt === "string" ? report.capturedAt : null,
|
|
228
|
+
accepted: issues.length === 0,
|
|
229
|
+
issues,
|
|
230
|
+
});
|
|
231
|
+
blockers.push(...issues);
|
|
232
|
+
}
|
|
233
|
+
const plan = releasePlan(evidence, { minScore: policy.minScore });
|
|
234
|
+
const combined = [...new Set([...blockers, ...plan.blockers])];
|
|
235
|
+
const receiptTasks = receipts
|
|
236
|
+
.filter((item) => !item.accepted)
|
|
237
|
+
.map((item) => ({
|
|
238
|
+
id: "receipt-" + item.kind,
|
|
239
|
+
priority: "blocker",
|
|
240
|
+
title: "Repair " + item.kind + " evidence",
|
|
241
|
+
verify: `Generate a completed ${item.kind} report for ${bundle.commit} with a current UTC timestamp; resolve: ${item.issues.join(" ")}`,
|
|
242
|
+
}));
|
|
243
|
+
return {
|
|
244
|
+
...plan,
|
|
245
|
+
summary: combined.length
|
|
246
|
+
? "The receipts say stop. Confidence must wait."
|
|
247
|
+
: plan.summary,
|
|
248
|
+
tasks: [...receiptTasks, ...plan.tasks],
|
|
249
|
+
passed: combined.length === 0,
|
|
250
|
+
blockers: combined,
|
|
251
|
+
evidence,
|
|
252
|
+
receipts,
|
|
253
|
+
commit: bundle.commit,
|
|
254
|
+
policy: {
|
|
255
|
+
minTests: policy.minTests,
|
|
256
|
+
minCoverage: policy.minCoverage,
|
|
257
|
+
minScore: policy.minScore,
|
|
258
|
+
maxAgeMs: policy.maxAgeMs,
|
|
259
|
+
},
|
|
260
|
+
evaluatedAt: now,
|
|
261
|
+
};
|
|
262
|
+
}
|