@deployanyway/ship-it-meter 0.3.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 +14 -0
- package/MIGRATION.md +12 -0
- package/README.md +74 -1
- package/dist/index.cjs +448 -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 +74 -0
- package/index.d.ts +74 -0
- package/package.json +9 -4
- package/src/cli.js +98 -18
- package/src/index.js +115 -0
- package/src/input.js +9 -2
- package/src/reports.js +262 -0
- package/src/scenarios.js +133 -0
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
|
+
}
|
package/src/scenarios.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Demonstration inputs, never a claim about a real release.
|
|
2
|
+
const scenarios = {
|
|
3
|
+
ready: {
|
|
4
|
+
description:
|
|
5
|
+
"Passing evidence, clean worktree and main branch. Boring is a compliment.",
|
|
6
|
+
evidence: {
|
|
7
|
+
tests: 125,
|
|
8
|
+
coverage: 96,
|
|
9
|
+
build: true,
|
|
10
|
+
branch: "main",
|
|
11
|
+
day: "tuesday",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
friday: {
|
|
15
|
+
description:
|
|
16
|
+
"Good evidence on Friday. Confirm rollback and on-call before the victory lap.",
|
|
17
|
+
evidence: {
|
|
18
|
+
tests: 125,
|
|
19
|
+
coverage: 92,
|
|
20
|
+
build: true,
|
|
21
|
+
branch: "main",
|
|
22
|
+
day: "friday",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
"no-evidence": {
|
|
26
|
+
description:
|
|
27
|
+
"Confidence is present. Test, coverage and build evidence have not arrived.",
|
|
28
|
+
evidence: {},
|
|
29
|
+
},
|
|
30
|
+
"failed-build": {
|
|
31
|
+
description:
|
|
32
|
+
"The build is red. The deploy button is not a color correction tool.",
|
|
33
|
+
evidence: {
|
|
34
|
+
tests: 125,
|
|
35
|
+
coverage: 92,
|
|
36
|
+
build: false,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
"failing-tests": {
|
|
40
|
+
description:
|
|
41
|
+
"Some tests fail. Green deployment lights will not repair assertions.",
|
|
42
|
+
evidence: {
|
|
43
|
+
tests: 125,
|
|
44
|
+
failingTests: 3,
|
|
45
|
+
coverage: 92,
|
|
46
|
+
build: true,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
"low-coverage": {
|
|
50
|
+
description:
|
|
51
|
+
"A passing build with many untested paths. The bugs appreciate the privacy.",
|
|
52
|
+
evidence: {
|
|
53
|
+
tests: 125,
|
|
54
|
+
coverage: 45,
|
|
55
|
+
build: true,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
"critical-issue": {
|
|
59
|
+
description:
|
|
60
|
+
"A critical issue blocks the gate even when everything else looks cheerful.",
|
|
61
|
+
evidence: {
|
|
62
|
+
tests: 125,
|
|
63
|
+
coverage: 96,
|
|
64
|
+
build: true,
|
|
65
|
+
criticalIssues: 1,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
"lint-failures": {
|
|
69
|
+
description:
|
|
70
|
+
"Lint failures are still failures, however decorative the semicolons feel.",
|
|
71
|
+
evidence: {
|
|
72
|
+
tests: 125,
|
|
73
|
+
coverage: 96,
|
|
74
|
+
build: true,
|
|
75
|
+
lintFailures: 2,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
"dirty-worktree": {
|
|
79
|
+
description:
|
|
80
|
+
"The laptop contains uncommitted changes. It is not the release archive.",
|
|
81
|
+
evidence: {
|
|
82
|
+
tests: 125,
|
|
83
|
+
coverage: 96,
|
|
84
|
+
build: true,
|
|
85
|
+
uncommittedChanges: true,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
"feature-branch": {
|
|
89
|
+
description:
|
|
90
|
+
"Healthy evidence on a feature branch. Verify the intended release source.",
|
|
91
|
+
evidence: {
|
|
92
|
+
tests: 125,
|
|
93
|
+
coverage: 96,
|
|
94
|
+
build: true,
|
|
95
|
+
branch: "feature/zoomies",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
"zero-tests": {
|
|
99
|
+
description:
|
|
100
|
+
"No tests ran. Zero failures is an especially small achievement here.",
|
|
101
|
+
evidence: {
|
|
102
|
+
tests: 0,
|
|
103
|
+
coverage: 0,
|
|
104
|
+
build: true,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
"weekend-incident": {
|
|
108
|
+
description:
|
|
109
|
+
"A failed build, failing tests and a critical issue. Bring evidence, not a bigger confidence hat.",
|
|
110
|
+
evidence: {
|
|
111
|
+
tests: 125,
|
|
112
|
+
failingTests: 12,
|
|
113
|
+
coverage: 60,
|
|
114
|
+
build: false,
|
|
115
|
+
criticalIssues: 1,
|
|
116
|
+
day: "saturday",
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
export function releaseScenarios() {
|
|
121
|
+
return Object.entries(scenarios).map(([name, value]) => ({
|
|
122
|
+
name,
|
|
123
|
+
description: value.description,
|
|
124
|
+
evidence: { ...value.evidence },
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
export function scenarioEvidence(name) {
|
|
128
|
+
if (typeof name !== "string" || !Object.hasOwn(scenarios, name))
|
|
129
|
+
throw new RangeError(
|
|
130
|
+
"Unknown scenario. Choose: " + Object.keys(scenarios).join(", "),
|
|
131
|
+
);
|
|
132
|
+
return { ...scenarios[name].evidence };
|
|
133
|
+
}
|