@mmerterden/multi-agent-pipeline 20.27.2 → 20.29.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +20 -6
  3. package/README.tr.md +6 -5
  4. package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +0 -1
  5. package/docs/architecture.md +4 -4
  6. package/docs/ecosystem.md +5 -5
  7. package/docs/facts.json +14 -12
  8. package/docs/features.md +2 -0
  9. package/install/_dev-only-files.mjs +1 -0
  10. package/install/catalog-history.json +1 -1
  11. package/install/claude.mjs +112 -0
  12. package/install/index.mjs +0 -2
  13. package/install/templates/claude-hooks.json +30 -1
  14. package/manifest.json +101 -67
  15. package/package.json +4 -1
  16. package/pipeline/agents/code-reviewer.md +1 -0
  17. package/pipeline/agents/test-gen-analyst.md +98 -0
  18. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
  19. package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
  20. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  21. package/pipeline/commands/multi-agent/test-gen/SKILL.md +124 -0
  22. package/pipeline/contract/types/index.d.ts +1 -1
  23. package/pipeline/lib/hook-registration.mjs +252 -0
  24. package/pipeline/lib/prefs-value.mjs +44 -0
  25. package/pipeline/lib/run-completion.mjs +148 -0
  26. package/pipeline/multi-agent-refs/analysis/render.md +4 -4
  27. package/pipeline/multi-agent-refs/cross-cli-contract.md +11 -5
  28. package/pipeline/multi-agent-refs/features/doctor.md +24 -0
  29. package/pipeline/multi-agent-refs/features/test-classification.md +76 -0
  30. package/pipeline/multi-agent-refs/features/usage-reporting.md +17 -0
  31. package/pipeline/multi-agent-refs/help/en.md +1 -0
  32. package/pipeline/multi-agent-refs/help/tr.md +1 -0
  33. package/pipeline/multi-agent-refs/phases/operations.md +19 -2
  34. package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
  35. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
  36. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +7 -8
  37. package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
  38. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
  39. package/pipeline/multi-agent-refs/phases/phase-5-report.md +10 -8
  40. package/pipeline/multi-agent-refs/test-gen/stages.md +227 -0
  41. package/pipeline/preferences-template.json +10 -0
  42. package/pipeline/rules/figma-pipeline.md +2 -0
  43. package/pipeline/schemas/agent-state.schema.json +20 -5
  44. package/pipeline/schemas/phases.json +3 -2
  45. package/pipeline/schemas/phases.schema.json +5 -1
  46. package/pipeline/schemas/prefs.schema.json +51 -0
  47. package/pipeline/schemas/runs-index.schema.json +1 -1
  48. package/pipeline/schemas/test-gen-discovery.schema.json +73 -0
  49. package/pipeline/schemas/test-gen-gaps.schema.json +122 -0
  50. package/pipeline/schemas/test-gen-inventory.schema.json +59 -0
  51. package/pipeline/schemas/test-gen-results.schema.json +44 -0
  52. package/pipeline/scripts/_active-run.mjs +158 -0
  53. package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
  54. package/pipeline/scripts/_guard_rules.py +605 -0
  55. package/pipeline/scripts/_handle-patterns.mjs +166 -0
  56. package/pipeline/scripts/_http-post.mjs +141 -0
  57. package/pipeline/scripts/_stack-routing.mjs +4 -0
  58. package/pipeline/scripts/_test-gaming.mjs +227 -0
  59. package/pipeline/scripts/_unified-diff.mjs +104 -0
  60. package/pipeline/scripts/_worktree-snapshot.mjs +113 -0
  61. package/pipeline/scripts/agent-guard.py +118 -7
  62. package/pipeline/scripts/agent-guard.sh +50 -3
  63. package/pipeline/scripts/argv-secret-scan.mjs +114 -0
  64. package/pipeline/scripts/autopilot-runner.mjs +8 -2
  65. package/pipeline/scripts/confirm-command.mjs +70 -0
  66. package/pipeline/scripts/doctor.mjs +105 -0
  67. package/pipeline/scripts/evidence-gate.mjs +82 -7
  68. package/pipeline/scripts/gate-ledger.mjs +163 -7
  69. package/pipeline/scripts/handle-diff-audit.mjs +188 -0
  70. package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
  71. package/pipeline/scripts/pre-commit-check.sh +12 -0
  72. package/pipeline/scripts/routine-registry.mjs +5 -1
  73. package/pipeline/scripts/run-scope-guard.mjs +188 -0
  74. package/pipeline/scripts/run-scope-guard.sh +23 -0
  75. package/pipeline/scripts/run-stop-guard.mjs +246 -0
  76. package/pipeline/scripts/run-stop-guard.sh +25 -0
  77. package/pipeline/scripts/run-ui-tests.sh +27 -3
  78. package/pipeline/scripts/test-classify.mjs +296 -0
  79. package/pipeline/scripts/test-gen-gate.mjs +409 -0
  80. package/pipeline/scripts/test-gen-state.mjs +313 -0
  81. package/pipeline/scripts/test-integrity-gate.mjs +76 -11
  82. package/pipeline/scripts/uninstall.mjs +2 -0
  83. package/pipeline/scripts/usage-register-beacon.mjs +239 -0
  84. package/pipeline/scripts/usage-register.mjs +128 -33
  85. package/pipeline/scripts/usage-report.mjs +43 -33
  86. package/pipeline/scripts/validate-state.mjs +26 -0
  87. package/pipeline/scripts/write-state.mjs +114 -0
  88. package/pipeline/skills/.skill-manifest.json +23 -3
  89. package/pipeline/skills/.skills-index.json +57 -2
  90. package/pipeline/skills/shared/README.md +10 -5
  91. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  92. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +120 -0
  93. package/pipeline/skills/shared/external/fix-review-verdicts/SKILL.md +133 -0
  94. package/pipeline/skills/shared/external/handle-request/SKILL.md +97 -0
  95. package/pipeline/skills/shared/external/scenario-gap-analysis/SKILL.md +129 -0
  96. package/pipeline/skills/shared/external/scenario-gap-analysis/references/scenario-schema.md +60 -0
  97. package/pipeline/skills/shared/external/test-result-triage/SKILL.md +160 -0
  98. package/pipeline/skills/skills-index.md +7 -2
  99. package/install/_telemetry.mjs +0 -61
@@ -0,0 +1,409 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file test-gen-gate.mjs - each /multi-agent:test-gen stage is held to its output.
4
+ *
5
+ * A stage that says it is done has produced something on disk, or it has not.
6
+ * This reads what is there:
7
+ *
8
+ * check an artefact matches its schema (test-gen-inventory,
9
+ * -discovery, -gaps, -results), read from schemas/ by a small
10
+ * validator covering the keywords those schemas use, so the
11
+ * runtime needs no dependency.
12
+ * baseline at the start of a stage that may edit the repository: the
13
+ * working tree, untracked files included, as a git tree
14
+ * (_worktree-snapshot.mjs). The stage is then judged by what
15
+ * changed after it, never by the person's uncommitted work.
16
+ * handles at the end of the handle stage: the delta adds at least one
17
+ * handle line in product code, or handles.md says
18
+ * `HANDLES: none-needed` and cites at least one `file:line`,
19
+ * inside the repository, that carries a handle by its own
20
+ * platform's pattern. Saying no handle was needed is a claim, and
21
+ * the claim is that the handles already exist.
22
+ * written at the end of the write stage: the delta touches test files
23
+ * only; every listed file is inside the repository, changed by
24
+ * the stage and not empty; the delta carries no test gaming
25
+ * (_test-gaming.mjs); the run command is one discovery found
26
+ * (--discovery); and the run executed more than zero tests
27
+ * (test-summary.mjs, from --junit, --xcresult-json or a --log).
28
+ * results the classification (test-classify.mjs) as results.json: three
29
+ * states, pass | fail | not_measured, every non-pass with a reason.
30
+ *
31
+ * Usage:
32
+ * test-gen-gate.mjs check --stage inventory|discovery|gaps|results --file <path>
33
+ * test-gen-gate.mjs baseline --repo <path> --out <path>
34
+ * test-gen-gate.mjs handles --repo <path> --baseline <path> --handles <handles.md>
35
+ * test-gen-gate.mjs written --repo <path> --baseline <path> --written <written.json>
36
+ * [--discovery <discovery.json>]
37
+ * (--junit <file|dir> | --xcresult-json <file> | --log <file> [--stack <id>])
38
+ * test-gen-gate.mjs results --classification <path> --out <results.json> [--written <written.json>]
39
+ *
40
+ * Output: one JSON verdict on stdout.
41
+ * Exit: 0 pass, 1 fail, 2 usage, 3 input unreadable, 4 internal error (a crash,
42
+ * never a verdict)
43
+ *
44
+ * @module pipeline/scripts/test-gen-gate
45
+ */
46
+
47
+ import {
48
+ existsSync,
49
+ mkdirSync,
50
+ readFileSync,
51
+ realpathSync,
52
+ statSync,
53
+ writeFileSync,
54
+ } from "node:fs";
55
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
56
+ import { fileURLToPath, pathToFileURL } from "node:url";
57
+
58
+ import { countAddedHandles, isHandleLine, isTestPath } from "./_handle-patterns.mjs";
59
+ import { scanTestGaming } from "./_test-gaming.mjs";
60
+ import { parseUnifiedDiff } from "./_unified-diff.mjs";
61
+ import { GitError, diffSince, git, snapshotTree } from "./_worktree-snapshot.mjs";
62
+
63
+ const HERE = dirname(fileURLToPath(import.meta.url));
64
+ const SCHEMAS = join(HERE, "..", "schemas");
65
+ const STAGES = ["inventory", "discovery", "gaps", "results"];
66
+
67
+ class Unreadable extends Error {}
68
+
69
+ function usage(msg) {
70
+ process.stderr.write(`test-gen-gate: ${msg}\n`);
71
+ process.exit(2);
72
+ }
73
+
74
+ function verdict(ok, body) {
75
+ writeFileSync(1, `${JSON.stringify({ ok, ...body }, null, 2)}\n`);
76
+ process.exit(ok ? 0 : 1);
77
+ }
78
+
79
+ function parseArgs(argv) {
80
+ const out = {};
81
+ for (let i = 0; i < argv.length; i++) {
82
+ const a = argv[i];
83
+ if (!a.startsWith("--")) usage(`unexpected argument ${a}`);
84
+ const v = argv[i + 1];
85
+ if (v === undefined || v.startsWith("-"))
86
+ usage(`${a} needs a value that does not start with "-"`);
87
+ out[a.slice(2)] = v;
88
+ i++;
89
+ }
90
+ return out;
91
+ }
92
+
93
+ function need(args, key) {
94
+ if (!args[key]) usage(`--${key} is required`);
95
+ return args[key];
96
+ }
97
+
98
+ function readText(path, what) {
99
+ try {
100
+ return readFileSync(path, "utf8");
101
+ } catch (err) {
102
+ throw new Unreadable(`cannot read ${what} ${path}: ${err.message}`);
103
+ }
104
+ }
105
+
106
+ function readJson(path, what) {
107
+ const text = readText(path, what);
108
+ try {
109
+ return JSON.parse(text);
110
+ } catch (err) {
111
+ throw new Unreadable(`${what} ${path} is not JSON: ${err.message}`);
112
+ }
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Schema check: the subset of JSON Schema the test-gen schemas use.
117
+
118
+ function typeOk(type, v) {
119
+ if (type === "object") return v !== null && typeof v === "object" && !Array.isArray(v);
120
+ if (type === "array") return Array.isArray(v);
121
+ if (type === "integer") return Number.isInteger(v);
122
+ if (type === "string") return typeof v === "string";
123
+ if (type === "boolean") return typeof v === "boolean";
124
+ return true;
125
+ }
126
+
127
+ /**
128
+ * @param {object} s schema node
129
+ * @param {*} v value
130
+ * @param {string} at JSON pointer for messages
131
+ * @param {object} root the whole schema, for $ref
132
+ * @returns {string[]} errors
133
+ */
134
+ export function validate(s, v, at = "", root = s) {
135
+ if (s.$ref) return validate(root.$defs[s.$ref.replace("#/$defs/", "")], v, at, root);
136
+ const errors = [];
137
+ const here = at || "/";
138
+ if ("const" in s && v !== s.const) return [`${here} must be ${JSON.stringify(s.const)}`];
139
+ if (s.enum && !s.enum.includes(v)) return [`${here} must be one of ${s.enum.join(", ")}`];
140
+ if (s.type && !typeOk(s.type, v)) return [`${here} must be ${s.type}`];
141
+ if (typeof v === "string") {
142
+ if (s.minLength && v.length < s.minLength) errors.push(`${here} is empty`);
143
+ if (s.pattern && !new RegExp(s.pattern).test(v))
144
+ errors.push(`${here} does not match ${s.pattern}`);
145
+ }
146
+ if (typeof v === "number" && s.minimum !== undefined && v < s.minimum) {
147
+ errors.push(`${here} is below ${s.minimum}`);
148
+ }
149
+ if (Array.isArray(v)) {
150
+ if (s.minItems && v.length < s.minItems) errors.push(`${here} needs ${s.minItems}+ items`);
151
+ if (s.items) v.forEach((x, i) => errors.push(...validate(s.items, x, `${at}/${i}`, root)));
152
+ }
153
+ if (typeOk("object", v) && (s.properties || s.required)) {
154
+ for (const k of s.required || []) if (!(k in v)) errors.push(`${at}/${k} is required`);
155
+ for (const [k, x] of Object.entries(v)) {
156
+ if (s.properties?.[k]) errors.push(...validate(s.properties[k], x, `${at}/${k}`, root));
157
+ else if (s.additionalProperties === false) errors.push(`${at}/${k} is not allowed`);
158
+ }
159
+ if (s.if && validate(s.if, v, at, root).length === 0 && s.then) {
160
+ errors.push(...validate(s.then, v, at, root));
161
+ }
162
+ }
163
+ return errors;
164
+ }
165
+
166
+ function check(args) {
167
+ const stage = need(args, "stage");
168
+ if (!STAGES.includes(stage)) usage(`--stage must be one of ${STAGES.join(", ")}`);
169
+ const schema = readJson(join(SCHEMAS, `test-gen-${stage}.schema.json`), "schema");
170
+ const doc = readJson(need(args, "file"), `${stage} artefact`);
171
+ const errors = validate(schema, doc);
172
+ verdict(errors.length === 0, { stage, file: args.file, errors });
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Handle stage
177
+
178
+ /** The repository-relative path of `file`, or null when it resolves outside the repository. */
179
+ function insideRepo(repo, file) {
180
+ const root = realpathSync(repo);
181
+ const abs = resolve(root, file);
182
+ const rel = relative(root, existsSync(abs) ? realpathSync(abs) : abs);
183
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
184
+ return rel.split(sep).join("/");
185
+ }
186
+
187
+ function stageDelta(repo, baselinePath) {
188
+ const base = readJson(baselinePath, "baseline");
189
+ if (typeof base.tree !== "string") {
190
+ throw new Unreadable(
191
+ `baseline ${baselinePath} has no tree: take it with test-gen-gate.mjs baseline`,
192
+ );
193
+ }
194
+ try {
195
+ return diffSince(repo, base.tree);
196
+ } catch (err) {
197
+ if (err instanceof GitError) throw new Unreadable(err.message);
198
+ throw err;
199
+ }
200
+ }
201
+
202
+ function headOrNull(repo) {
203
+ try {
204
+ return git(repo, ["rev-parse", "--verify", "--quiet", "HEAD"]).trim();
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+
210
+ function baseline(args) {
211
+ const repo = need(args, "repo");
212
+ const out = need(args, "out");
213
+ let tree;
214
+ let head;
215
+ try {
216
+ tree = snapshotTree(repo);
217
+ head = headOrNull(repo);
218
+ } catch (err) {
219
+ if (err instanceof GitError) throw new Unreadable(err.message);
220
+ throw err;
221
+ }
222
+ const doc = { tree, head, startedAt: new Date().toISOString() };
223
+ mkdirSync(dirname(out), { recursive: true });
224
+ writeFileSync(out, `${JSON.stringify(doc, null, 2)}\n`);
225
+ verdict(true, doc);
226
+ }
227
+
228
+ /** `path:line` references in handles.md that resolve to a handle line. */
229
+ function resolvedHandleEvidence(repo, text) {
230
+ const refs = [...text.matchAll(/([\w./@+-]+\.[A-Za-z0-9]+):([1-9]\d*)\b/g)];
231
+ const resolved = [];
232
+ const rejected = [];
233
+ for (const [, file, lineStr] of refs) {
234
+ const line = Number(lineStr);
235
+ const rel = insideRepo(repo, file);
236
+ if (rel === null) {
237
+ rejected.push(`${file}:${line} is outside the repository`);
238
+ continue;
239
+ }
240
+ const abs = join(repo, rel);
241
+ let lines;
242
+ try {
243
+ lines = readFileSync(abs, "utf8").split("\n");
244
+ } catch {
245
+ rejected.push(`${file}:${line} does not exist`);
246
+ continue;
247
+ }
248
+ if (line > lines.length || !isHandleLine(file, lines[line - 1])) {
249
+ rejected.push(`${file}:${line} carries no handle`);
250
+ continue;
251
+ }
252
+ resolved.push(`${file}:${line}`);
253
+ }
254
+ return { resolved, rejected };
255
+ }
256
+
257
+ function handles(args) {
258
+ const repo = need(args, "repo");
259
+ const delta = stageDelta(repo, need(args, "baseline"));
260
+ const text = readText(need(args, "handles"), "handles.md");
261
+ const now = countAddedHandles(delta);
262
+ if (now.total > 0) {
263
+ verdict(true, { added: now.total, byFile: now.byFile });
264
+ }
265
+ if (/^\s*HANDLES:\s*none-needed\b/im.test(text)) {
266
+ const { resolved, rejected } = resolvedHandleEvidence(repo, text);
267
+ verdict(resolved.length > 0, {
268
+ added: 0,
269
+ noneNeeded: true,
270
+ evidence: resolved,
271
+ rejected,
272
+ reason: resolved.length
273
+ ? "no handle needed: the cited handles exist"
274
+ : "HANDLES: none-needed needs file:line evidence of handles that exist",
275
+ });
276
+ }
277
+ verdict(false, {
278
+ added: 0,
279
+ reason:
280
+ "the stage added no handle line after its stage-start snapshot, and handles.md does not say HANDLES: none-needed",
281
+ });
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Write stage
286
+
287
+ async function written(args) {
288
+ const repo = need(args, "repo");
289
+ const delta = stageDelta(repo, need(args, "baseline"));
290
+ const doc = readJson(need(args, "written"), "written.json");
291
+ const files = Array.isArray(doc.files) ? doc.files : [];
292
+ const problems = [];
293
+ const changed = new Map(parseUnifiedDiff(delta));
294
+ for (const [file, f] of changed) {
295
+ if (!isTestPath(file)) {
296
+ problems.push(`${file} is not a test file; after the handle stage only tests may change`);
297
+ } else if (f.isDeleted) problems.push(`${file} was deleted by the write stage`);
298
+ }
299
+ if (files.length === 0) problems.push("written.json lists no test file");
300
+ for (const f of files) {
301
+ const rel = typeof f === "string" ? insideRepo(repo, f) : null;
302
+ if (rel === null) {
303
+ problems.push(`${f} is outside the repository`);
304
+ continue;
305
+ }
306
+ const abs = join(repo, rel);
307
+ if (!existsSync(abs)) problems.push(`${f} does not exist`);
308
+ else if (statSync(abs).size === 0) problems.push(`${f} is empty`);
309
+ else if (!changed.has(rel)) problems.push(`${f} was not written or changed by this stage`);
310
+ }
311
+ const gaming = scanTestGaming(delta);
312
+ for (const g of gaming) problems.push(`${g.kind} at ${g.file}:${g.line}: ${g.evidence}`);
313
+ if (args.discovery) {
314
+ const disc = readJson(args.discovery, "discovery.json");
315
+ const known = (Array.isArray(disc.testRoots) ? disc.testRoots : []).map((r) => r.command);
316
+ const command = String(doc.command || "").trim();
317
+ if (!command) problems.push("written.json has no run command");
318
+ else if (!known.some((k) => typeof k === "string" && k && command.startsWith(k.trim()))) {
319
+ problems.push(`run command "${command}" is none of the runners discovery found`);
320
+ }
321
+ }
322
+ const ts = await import("./test-summary.mjs");
323
+ let summary;
324
+ if (args.junit) summary = ts.summarizeJunit(args.junit);
325
+ else if (args["xcresult-json"]) {
326
+ summary = ts.fromXcresultJson(
327
+ readJson(args["xcresult-json"], "xcresult summary"),
328
+ args["xcresult-json"],
329
+ );
330
+ } else if (args.log) {
331
+ let parsers;
332
+ if (args.stack) {
333
+ const { getAdapter } = await import("./_stack-adapter.mjs");
334
+ parsers = getAdapter(args.stack, { strict: true }).testSummary?.parsers;
335
+ }
336
+ summary = ts.summarize(readText(args.log, "test log"), { parsers, source: args.log });
337
+ } else usage("written needs --junit, --xcresult-json or --log");
338
+ if (!summary || summary.executed === null) {
339
+ problems.push(`test counts unread: ${summary?.reason || "no result"}`);
340
+ } else if (summary.executed === 0) problems.push("the run executed 0 tests");
341
+ verdict(problems.length === 0, {
342
+ files: files.length,
343
+ executed: summary?.executed ?? null,
344
+ failed: summary?.failed ?? null,
345
+ problems,
346
+ });
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Results
351
+
352
+ // A test this run wrote that is red on the base too is not inherited the way a
353
+ // preexisting failure is at Gate 3: the test is new, so its red says the
354
+ // behaviour it asserts is missing. That is a finding for triage, hence fail.
355
+ const STATE_OF = {
356
+ passed: "pass",
357
+ regression: "fail",
358
+ preexisting: "fail",
359
+ unstable: "fail",
360
+ not_measured: "not_measured",
361
+ };
362
+
363
+ export function toResults(classification, writtenDoc) {
364
+ const fileOf = new Map(
365
+ (Array.isArray(writtenDoc?.tests) ? writtenDoc.tests : []).map((t) => [t.id, t.file]),
366
+ );
367
+ const tests = (classification.tests || []).map((t) => {
368
+ const status = STATE_OF[t.status];
369
+ if (!status) throw new Unreadable(`unknown classification ${t.status} for ${t.id}`);
370
+ const row = { id: t.id, status, classification: t.status };
371
+ if (fileOf.get(t.id)) row.file = fileOf.get(t.id);
372
+ if (status !== "pass") row.reason = t.reason || `classified ${t.status}`;
373
+ return row;
374
+ });
375
+ const summary = { pass: 0, fail: 0, not_measured: 0 };
376
+ for (const t of tests) summary[t.status]++;
377
+ return {
378
+ schemaVersion: "1.0.0",
379
+ source: classification.source === "fallback" ? "fallback" : "toolkit",
380
+ green: classification.green === true && summary.fail === 0 && summary.not_measured === 0,
381
+ tests,
382
+ summary,
383
+ };
384
+ }
385
+
386
+ function results(args) {
387
+ const classification = readJson(need(args, "classification"), "classification");
388
+ const writtenDoc = args.written ? readJson(args.written, "written.json") : null;
389
+ const out = need(args, "out");
390
+ const doc = toResults(classification, writtenDoc);
391
+ mkdirSync(dirname(out), { recursive: true });
392
+ writeFileSync(out, `${JSON.stringify(doc, null, 2)}\n`);
393
+ verdict(true, { out, summary: doc.summary, green: doc.green });
394
+ }
395
+
396
+ async function main() {
397
+ const [cmd, ...rest] = process.argv.slice(2);
398
+ const commands = { check, baseline, handles, written, results };
399
+ if (!commands[cmd]) usage(`first argument is one of ${Object.keys(commands).join(", ")}`);
400
+ try {
401
+ await commands[cmd](parseArgs(rest));
402
+ } catch (err) {
403
+ const code = err instanceof Unreadable ? 3 : 4;
404
+ writeFileSync(1, `${JSON.stringify({ ok: false, reason: err.message })}\n`);
405
+ process.exit(code);
406
+ }
407
+ }
408
+
409
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();