@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.
@@ -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
@@ -41,3 +41,77 @@ export function releaseGate(
41
41
  input?: Evidence,
42
42
  options?: { minScore?: number },
43
43
  ): Gate;
44
+
45
+ export interface ReleaseTask {
46
+ id: string;
47
+ priority: "blocker" | "review" | "release";
48
+ title: string;
49
+ verify: string;
50
+ }
51
+ export interface ReleasePlan extends Gate {
52
+ summary: string;
53
+ tasks: ReleaseTask[];
54
+ }
55
+ export interface ReleaseScenario {
56
+ name: string;
57
+ description: string;
58
+ evidence: Evidence;
59
+ }
60
+ export function releaseScenarios(): ReleaseScenario[];
61
+ export function scenarioEvidence(name: string): Evidence;
62
+ export function releasePlan(
63
+ input?: Evidence,
64
+ options?: { minScore?: number },
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
@@ -41,3 +41,77 @@ export function releaseGate(
41
41
  input?: Evidence,
42
42
  options?: { minScore?: number },
43
43
  ): Gate;
44
+
45
+ export interface ReleaseTask {
46
+ id: string;
47
+ priority: "blocker" | "review" | "release";
48
+ title: string;
49
+ verify: string;
50
+ }
51
+ export interface ReleasePlan extends Gate {
52
+ summary: string;
53
+ tasks: ReleaseTask[];
54
+ }
55
+ export interface ReleaseScenario {
56
+ name: string;
57
+ description: string;
58
+ evidence: Evidence;
59
+ }
60
+ export function releaseScenarios(): ReleaseScenario[];
61
+ export function scenarioEvidence(name: string): Evidence;
62
+ export function releasePlan(
63
+ input?: Evidence,
64
+ options?: { minScore?: number },
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.3.0",
4
- "description": "Explainable deployment readiness scoring for tests, coverage, and builds. Turns questionable confidence into a number.",
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,7 +1,15 @@
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
- import { shipIt, preflight, releaseGate } from "./index.js";
4
+ import {
5
+ shipIt,
6
+ preflight,
7
+ releaseGate,
8
+ releasePlan,
9
+ releaseScenarios,
10
+ scenarioEvidence,
11
+ evaluateReports,
12
+ } from "./index.js";
5
13
 
6
14
  import { readStdin } from "./input.js";
7
15
 
@@ -12,7 +20,13 @@ try {
12
20
  version: { type: "boolean", short: "v" },
13
21
  json: { type: "boolean" },
14
22
  stdin: { type: "boolean" },
23
+ reports: { type: "boolean" },
24
+ "report-file": { type: "string" },
25
+ policy: { type: "string" },
15
26
  gate: { type: "boolean" },
27
+ plan: { type: "boolean" },
28
+ scenario: { type: "string" },
29
+ "list-scenarios": { type: "boolean" },
16
30
  "min-score": { type: "string" },
17
31
  checklist: { type: "boolean" },
18
32
  tests: { type: "string" },
@@ -28,7 +42,7 @@ try {
28
42
  });
29
43
  if (values.help) {
30
44
  console.log(
31
- "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 --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.",
32
46
  );
33
47
  } else if (values.version) {
34
48
  console.log(
@@ -36,15 +50,68 @@ try {
36
50
  readFileSync(new URL("../package.json", import.meta.url), "utf8"),
37
51
  ).version,
38
52
  );
53
+ } else if (values["list-scenarios"]) {
54
+ console.log(
55
+ releaseScenarios()
56
+ .map((item) => item.name + " — " + item.description)
57
+ .join("\n"),
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.");
39
95
  } else {
40
- const input = values.stdin ? JSON.parse(await readStdin()) : {};
96
+ const input = values.stdin
97
+ ? JSON.parse(await readStdin())
98
+ : values.scenario !== undefined
99
+ ? scenarioEvidence(values.scenario)
100
+ : {};
101
+ if (values.stdin && values.scenario !== undefined)
102
+ throw new TypeError("Choose --stdin or --scenario, not both.");
103
+ if ([values.gate, values.plan, values.checklist].filter(Boolean).length > 1)
104
+ throw new TypeError("Choose one of --gate, --plan or --checklist.");
41
105
  if (!input || typeof input !== "object" || Array.isArray(input))
42
106
  throw new TypeError("stdin must contain an evidence object.");
43
107
  if (
44
108
  values["min-score"] !== undefined &&
45
- (!values.gate || !/^\d+(?:\.\d+)?$/.test(values["min-score"]))
109
+ (!(values.gate || values.plan) ||
110
+ !/^\d+(?:\.\d+)?$/.test(values["min-score"]))
46
111
  )
47
- throw new TypeError("--min-score requires --gate and a decimal number.");
112
+ throw new TypeError(
113
+ "--min-score requires --gate or --plan and a decimal number.",
114
+ );
48
115
  for (const [flag, key] of Object.entries({
49
116
  tests: "tests",
50
117
  failing: "failingTests",
@@ -66,21 +133,34 @@ try {
66
133
  for (const key of ["day", "branch"])
67
134
  if (values[key] !== undefined) input[key] = values[key];
68
135
  if (values.dirty) input.uncommittedChanges = true;
69
- const result = values.gate
70
- ? releaseGate(input, {
71
- minScore:
72
- values["min-score"] === undefined
73
- ? undefined
74
- : Number(values["min-score"]),
75
- })
76
- : values.checklist
77
- ? preflight(input)
78
- : shipIt(input);
79
- if (values.gate && !result.passed) process.exitCode = 1;
136
+ const result =
137
+ values.gate || values.plan
138
+ ? (values.plan ? releasePlan : releaseGate)(input, {
139
+ minScore:
140
+ values["min-score"] === undefined
141
+ ? undefined
142
+ : Number(values["min-score"]),
143
+ })
144
+ : values.checklist
145
+ ? preflight(input)
146
+ : shipIt(input);
147
+ if ((values.gate || values.plan) && !result.passed) process.exitCode = 1;
148
+ if (values.plan && !values.json)
149
+ console.log(
150
+ result.summary +
151
+ "\n" +
152
+ result.tasks
153
+ .map(
154
+ (task) =>
155
+ `[${task.priority}] ${task.title}\n Verify: ${task.verify}`,
156
+ )
157
+ .join("\n") +
158
+ "\n",
159
+ );
80
160
  console.log(
81
161
  values.json
82
162
  ? JSON.stringify(result, null, 2)
83
- : `${result.score}/100 — ${result.verdict}${values.gate ? (result.passed ? " — gate passed" : " — gate blocked") : ""}\n${result.reasons.map((reason) => `- ${reason}`).join("\n")}${result.actions ? "\n\nBefore you ship:\n" + result.actions.map((action) => `[ ] ${action}`).join("\n") : ""}`,
163
+ : `${result.score}/100 — ${result.verdict}${values.gate || values.plan ? (result.passed ? " — gate passed" : " — gate blocked") : ""}\n${result.reasons.map((reason) => `- ${reason}`).join("\n")}${result.actions ? "\n\nBefore you ship:\n" + result.actions.map((action) => `[ ] ${action}`).join("\n") : ""}`,
84
164
  );
85
165
  }
86
166
  } catch (error) {
package/src/index.js CHANGED
@@ -187,3 +187,118 @@ export function preflight(input = {}) {
187
187
  );
188
188
  return { ...result, actions };
189
189
  }
190
+
191
+ export { releaseScenarios, scenarioEvidence } from "./scenarios.js";
192
+ /** Explain the gate as ordered work with observable completion criteria. */
193
+ export function releasePlan(input = {}, options = {}) {
194
+ const gate = releaseGate(input, options);
195
+ const tasks = [];
196
+ const add = (id, priority, title, verify) =>
197
+ tasks.push({ id, priority, title, verify });
198
+ if (input.build !== true)
199
+ add(
200
+ "build",
201
+ "blocker",
202
+ "Get a passing build",
203
+ "Run the release build for the exact commit and record its successful result.",
204
+ );
205
+ if (!input.tests)
206
+ add(
207
+ "tests",
208
+ "blocker",
209
+ "Run a nonempty test suite",
210
+ "Record the test count and outcome for the release commit.",
211
+ );
212
+ if (input.failingTests)
213
+ add(
214
+ "failing-tests",
215
+ "blocker",
216
+ "Repair failing tests",
217
+ "Reproduce each failure, fix its cause and rerun the suite.",
218
+ );
219
+ if (input.coverage === undefined)
220
+ add(
221
+ "coverage-evidence",
222
+ "blocker",
223
+ "Measure coverage",
224
+ "Generate coverage for the release commit and record the percentage.",
225
+ );
226
+ else if (input.coverage < 80)
227
+ add(
228
+ "coverage-review",
229
+ "review",
230
+ "Review untested paths",
231
+ "Inspect uncovered critical paths and add meaningful tests where needed.",
232
+ );
233
+ if (input.criticalIssues)
234
+ add(
235
+ "critical-issues",
236
+ "blocker",
237
+ "Resolve critical issues",
238
+ "Record the resolution and verify the affected behavior.",
239
+ );
240
+ if (input.lintFailures)
241
+ add(
242
+ "lint",
243
+ "blocker",
244
+ "Fix lint failures",
245
+ "Run the configured lint checks successfully.",
246
+ );
247
+ if (input.uncommittedChanges)
248
+ add(
249
+ "worktree",
250
+ "blocker",
251
+ "Reconcile local changes",
252
+ "Commit or stash intentionally and verify the exact release source.",
253
+ );
254
+ if (input.branch && !["main", "master"].includes(input.branch.trim()))
255
+ add(
256
+ "branch",
257
+ "review",
258
+ "Confirm release branch",
259
+ "Verify that this branch is the intended release source and that its CI passed.",
260
+ );
261
+ if (input.day?.trim().toLowerCase() === "friday")
262
+ add(
263
+ "friday",
264
+ "review",
265
+ "Confirm weekend support",
266
+ "Identify monitoring ownership and a tested rollback path.",
267
+ );
268
+ if (gate.score < gate.minScore)
269
+ add(
270
+ "score",
271
+ "blocker",
272
+ "Improve evidence to the configured threshold",
273
+ "Address score deductions, rerun the checks and reevaluate the gate.",
274
+ );
275
+ add(
276
+ "rollback",
277
+ "release",
278
+ "Prepare the return route",
279
+ "Verify the rollback artifact and procedure before deployment.",
280
+ );
281
+ add(
282
+ "monitoring",
283
+ "release",
284
+ "Watch the actual release",
285
+ "Check health and error metrics after deployment and name an owner.",
286
+ );
287
+ const summaries = {
288
+ "Absolutely Not": "The evidence says stop. The confidence hat can wait.",
289
+ Questionable:
290
+ "The release has questions. Answer them before adding confetti.",
291
+ "Probably Fine": "Close is a useful direction, not a deployment receipt.",
292
+ "Ship It": "Good evidence. Keep the parachute and watch the landing.",
293
+ "Suspiciously Ready":
294
+ "Boring, verified readiness. Give the checks their share of the applause.",
295
+ };
296
+ const rank = { blocker: 0, review: 1, release: 2 };
297
+ tasks.sort((a, b) => rank[a.priority] - rank[b.priority]);
298
+ return { ...gate, summary: summaries[gate.verdict], tasks };
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 > 262144) throw new RangeError("stdin exceeds 256 KiB.");
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();