signoff-cli 0.0.0-stage → 4.0.1

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 (57) hide show
  1. package/.pre-commit-hooks.yaml +27 -0
  2. package/CHANGELOG.md +344 -0
  3. package/LICENSE +21 -0
  4. package/README.md +540 -3
  5. package/action.yml +24 -0
  6. package/bin/signoff.js +218 -0
  7. package/docs/diagrams/README.md +41 -0
  8. package/docs/diagrams/signoff-1-capability-en.png +0 -0
  9. package/docs/diagrams/signoff-1-capability.png +0 -0
  10. package/docs/diagrams/signoff-2-market-en.png +0 -0
  11. package/docs/diagrams/signoff-2-market.png +0 -0
  12. package/docs/diagrams/signoff-3-user-en.png +0 -0
  13. package/docs/diagrams/signoff-3-user.png +0 -0
  14. package/docs/diagrams/signoff-4-flow-en.png +0 -0
  15. package/docs/diagrams/signoff-4-flow.png +0 -0
  16. package/docs/diagrams/signoff-5-competitors-en.png +0 -0
  17. package/docs/diagrams/signoff-5-competitors.png +0 -0
  18. package/docs/diagrams/src/signoff-1-capability-en.mmd +81 -0
  19. package/docs/diagrams/src/signoff-1-capability.mmd +84 -0
  20. package/docs/diagrams/src/signoff-2-market-en.mmd +72 -0
  21. package/docs/diagrams/src/signoff-2-market.mmd +72 -0
  22. package/docs/diagrams/src/signoff-3-user-en.mmd +94 -0
  23. package/docs/diagrams/src/signoff-3-user.mmd +92 -0
  24. package/docs/gates.en.md +105 -0
  25. package/docs/gates.md +88 -0
  26. package/docs/integrations.md +58 -0
  27. package/docs/promotion.md +71 -0
  28. package/docs/signoff.schema.json +108 -0
  29. package/docs/trust.md +72 -0
  30. package/gates/guard.py +474 -0
  31. package/gates/p1_verify.py +111 -0
  32. package/gates/p2_coverage.py +127 -0
  33. package/gates/p3_queue.py +186 -0
  34. package/gates/p4_ledger.py +249 -0
  35. package/gates/p5_slop.py +109 -0
  36. package/lib/audit.js +213 -0
  37. package/lib/bootstrap.js +361 -0
  38. package/lib/checkpoint.js +184 -0
  39. package/lib/ci.js +58 -0
  40. package/lib/core.js +120 -0
  41. package/lib/cov.js +253 -0
  42. package/lib/detect.js +169 -0
  43. package/lib/doctor.js +304 -0
  44. package/lib/editor.js +97 -0
  45. package/lib/explain.js +73 -0
  46. package/lib/guard.js +1229 -0
  47. package/lib/p1.js +469 -0
  48. package/lib/p2.js +323 -0
  49. package/lib/p3.js +633 -0
  50. package/lib/p4.js +733 -0
  51. package/lib/p5.js +438 -0
  52. package/lib/preset.js +66 -0
  53. package/lib/secrets.js +336 -0
  54. package/lib/sign.js +196 -0
  55. package/package.json +56 -6
  56. package/test/selftest.js +1227 -0
  57. package/test/selftest.py +190 -0
package/lib/p3.js ADDED
@@ -0,0 +1,633 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // SignOff gate 3 - parallel work, no collisions.
4
+ //
5
+ // A task card says what a worker may touch, what proves it done and where the
6
+ // rollback point is. `plan` turns a pile of cards into waves: cards whose scopes
7
+ // overlap never share a wave. `collect` checks the diff back against the scopes and
8
+ // names both kinds of failure - a file nobody owns, and a card that shipped nothing.
9
+ //
10
+ // This is a line-by-line port of gates/p3_queue.py. Everything observable is meant
11
+ // to match the Python gate: subcommands, flags, JSON payloads (and their key order),
12
+ // human lines, .signoff/p3.json and exit codes. Two deliberate notes:
13
+ // * the working root is process.cwd() (the Python gate uses Path.cwd()), not the
14
+ // package root that lib/core.js exposes as ROOT;
15
+ // * Python's fnmatch.fnmatch is reimplemented below (it is a shell pattern match
16
+ // where `*` also crosses "/", unlike most glob libraries), including the
17
+ // os.path.normcase case-fold that filenames get on Windows.
18
+ // Pure Node >= 18, no Python.
19
+
20
+ const fs = require("fs");
21
+ const path = require("path");
22
+ const { spawnSync } = require("child_process");
23
+ const core = require("./core");
24
+
25
+ const PROG = "signoff p3";
26
+ const CHOICES = ["validate", "plan", "collect"];
27
+ const REQUIRED = ["id", "goal", "scope", "acceptance", "rollback"];
28
+
29
+ const USAGE =
30
+ "usage: signoff p3 [-h] [--cards CARDS] [--changed [CHANGED ...]] [--json]\n" +
31
+ " {validate,plan,collect}\n";
32
+
33
+ const HELP =
34
+ USAGE +
35
+ "\npositional arguments:\n" +
36
+ " {validate,plan,collect}\n" +
37
+ "\noptions:\n" +
38
+ " -h, --help show this help message and exit\n" +
39
+ " --cards CARDS\n" +
40
+ " --changed [CHANGED ...]\n" +
41
+ " --json\n";
42
+
43
+ const WIN = process.platform === "win32";
44
+
45
+ /* ------------------------------------------------------------------ *
46
+ * Python value semantics (truthiness, str(), repr())
47
+ * ------------------------------------------------------------------ */
48
+
49
+ // Python `bool(v)`. Differs from JS truthiness for [] / {} (falsy) and NaN (truthy).
50
+ function pyTruthy(v) {
51
+ if (v === undefined || v === null) return false;
52
+ if (typeof v === "boolean") return v;
53
+ if (typeof v === "number") return v !== 0;
54
+ if (typeof v === "string") return v.length > 0;
55
+ if (Array.isArray(v)) return v.length > 0;
56
+ if (typeof v === "object") return Object.keys(v).length > 0;
57
+ return true;
58
+ }
59
+
60
+ // Python str(v) for the scalar values a card can hold.
61
+ function pyStr(v) {
62
+ return typeof v === "string" ? v : pyRepr(v);
63
+ }
64
+
65
+ // Python repr(v), enough for scope entries and duplicate-id messages.
66
+ function pyRepr(v) {
67
+ if (v === undefined || v === null) return "None";
68
+ if (typeof v === "boolean") return v ? "True" : "False";
69
+ if (typeof v === "number") return Number.isInteger(v) && !Object.is(v, -0) ? String(v) : String(v);
70
+ if (typeof v === "string") return pyStrRepr(v);
71
+ if (Array.isArray(v)) return "[" + v.map(pyRepr).join(", ") + "]";
72
+ if (typeof v === "object") {
73
+ return "{" + Object.keys(v).map((k) => pyStrRepr(k) + ": " + pyRepr(v[k])).join(", ") + "}";
74
+ }
75
+ return String(v);
76
+ }
77
+
78
+ function pyStrRepr(s) {
79
+ const quote = s.indexOf("'") !== -1 && s.indexOf('"') === -1 ? '"' : "'";
80
+ let out = quote;
81
+ for (const ch of s) {
82
+ const cp = ch.codePointAt(0);
83
+ if (ch === quote) out += "\\" + ch;
84
+ else if (ch === "\\") out += "\\\\";
85
+ else if (ch === "\n") out += "\\n";
86
+ else if (ch === "\r") out += "\\r";
87
+ else if (ch === "\t") out += "\\t";
88
+ else if (cp < 0x20 || cp === 0x7f) out += "\\x" + cp.toString(16).padStart(2, "0");
89
+ else out += ch;
90
+ }
91
+ return out + quote;
92
+ }
93
+
94
+ // json.dumps(obj, ensure_ascii=False) - the one-line form, which keeps Python's
95
+ // default separators (", " and ": "), unlike JSON.stringify.
96
+ function jsonLine(obj) {
97
+ process.stdout.write(jsonCompact(obj) + "\n");
98
+ }
99
+
100
+ function jsonCompact(v) {
101
+ if (v === undefined || v === null) return "null";
102
+ if (v === true) return "true";
103
+ if (v === false) return "false";
104
+ if (typeof v === "number") return JSON.stringify(v);
105
+ if (typeof v === "string") return JSON.stringify(v);
106
+ if (Array.isArray(v)) return "[" + v.map(jsonCompact).join(", ") + "]";
107
+ const keys = Object.keys(v);
108
+ return "{" + keys.map((k) => JSON.stringify(k) + ": " + jsonCompact(v[k])).join(", ") + "}";
109
+ }
110
+
111
+ // Python's str ordering (code points) - differs from JS's UTF-16 code-unit order.
112
+ function cmpPyStr(a, b) {
113
+ const A = [...a];
114
+ const B = [...b];
115
+ const n = Math.min(A.length, B.length);
116
+ for (let i = 0; i < n; i++) {
117
+ const x = A[i].codePointAt(0);
118
+ const y = B[i].codePointAt(0);
119
+ if (x !== y) return x < y ? -1 : 1;
120
+ }
121
+ return A.length === B.length ? 0 : A.length < B.length ? -1 : 1;
122
+ }
123
+
124
+ /* ------------------------------------------------------------------ *
125
+ * fnmatch.fnmatch (Python) - shell pattern, `*` crosses "/"
126
+ * ------------------------------------------------------------------ */
127
+
128
+ const patternCache = new Map();
129
+
130
+ function fnmatch(name, pat) {
131
+ // os.path.normcase: on Windows a shell match ignores case (and folds "/" to "\",
132
+ // which our callers have already normalised away).
133
+ if (WIN) {
134
+ name = name.toLowerCase();
135
+ pat = pat.toLowerCase();
136
+ }
137
+ let re = patternCache.get(pat);
138
+ if (!re) {
139
+ if (patternCache.size > 4096) patternCache.clear();
140
+ re = new RegExp("^(?:" + translate(pat) + ")$", "s");
141
+ patternCache.set(pat, re);
142
+ }
143
+ return re.test(name);
144
+ }
145
+
146
+ const RE_SPECIAL = /[\\^$.*+?()[\]{}|]/;
147
+
148
+ function escapeRe(ch) {
149
+ return RE_SPECIAL.test(ch) ? "\\" + ch : ch;
150
+ }
151
+
152
+ // Mirrors CPython 3.15 Lib/fnmatch.py translate(), minus the newline behaviour
153
+ // (the `s` flag) and with set-operator escaping dropped for & ~ | - Python's re
154
+ // treats them as literals anyway, and `\&` is only tolerated outside u-mode.
155
+ function translate(pat) {
156
+ const out = [];
157
+ const n = pat.length;
158
+ let i = 0;
159
+ while (i < n) {
160
+ const c = pat[i];
161
+ i += 1;
162
+ if (c === "*") {
163
+ out.push(".*");
164
+ while (i < n && pat[i] === "*") i += 1;
165
+ } else if (c === "?") {
166
+ out.push(".");
167
+ } else if (c === "[") {
168
+ const done = translateClass(pat, i);
169
+ out.push(done.src);
170
+ i = done.next;
171
+ } else {
172
+ out.push(escapeRe(c));
173
+ }
174
+ }
175
+ return out.join("");
176
+ }
177
+
178
+ function translateClass(pat, i) {
179
+ const n = pat.length;
180
+ let j = i;
181
+ if (j < n && pat[j] === "!") j += 1;
182
+ if (j < n && pat[j] === "]") j += 1;
183
+ while (j < n && pat[j] !== "]") j += 1;
184
+ if (j >= n) return { src: "\\[", next: i }; // unterminated: "[" is literal
185
+ let stuff = pat.slice(i, j);
186
+ const next = j + 1;
187
+ if (stuff.indexOf("-") === -1) {
188
+ stuff = stuff.replace(/\\/g, "\\\\");
189
+ } else {
190
+ const chunks = [];
191
+ let k = i + (pat[i] === "!" ? 2 : 1);
192
+ let at = i;
193
+ for (;;) {
194
+ const dash = pat.indexOf("-", k);
195
+ if (dash < 0 || dash >= j) break;
196
+ chunks.push(pat.slice(at, dash));
197
+ at = dash + 1;
198
+ k = dash + 3;
199
+ }
200
+ const tail = pat.slice(at, j);
201
+ if (tail) chunks.push(tail);
202
+ else if (chunks.length) chunks[chunks.length - 1] += "-";
203
+ // Drop empty ranges (invalid in a RE), last to first, exactly as CPython does.
204
+ for (let q = chunks.length - 1; q > 0; q--) {
205
+ const prev = chunks[q - 1];
206
+ const cur = chunks[q];
207
+ if (prev.length && cur.length && prev[prev.length - 1] > cur[0]) {
208
+ chunks[q - 1] = prev.slice(0, -1) + cur.slice(1);
209
+ chunks.splice(q, 1);
210
+ }
211
+ }
212
+ stuff = chunks.map((s) => s.replace(/\\/g, "\\\\").replace(/-/g, "\\-")).join("-");
213
+ }
214
+ if (stuff === "") return { src: "(?!)", next }; // empty class: never matches
215
+ if (stuff === "!") return { src: ".", next }; // negated empty class: any char
216
+ if (stuff[0] === "!") stuff = "^" + stuff.slice(1);
217
+ else if (stuff[0] === "^" || stuff[0] === "[") stuff = "\\" + stuff;
218
+ return { src: "[" + stuff + "]", next };
219
+ }
220
+
221
+ /* ------------------------------------------------------------------ *
222
+ * Cards
223
+ * ------------------------------------------------------------------ */
224
+
225
+ function isDir(p) {
226
+ try {
227
+ return fs.statSync(p).isDirectory();
228
+ } catch (e) {
229
+ return false;
230
+ }
231
+ }
232
+
233
+ // pathlib.Path comparison is by parts (drive, components), normcased per platform -
234
+ // not by the joined string, which would order "a-b/x" before "a/x".
235
+ function pathParts(p) {
236
+ return String(p).split(/[\\/]+/).filter((s) => s.length > 0);
237
+ }
238
+
239
+ function cmpPyPath(a, b) {
240
+ const pa = pathParts(a);
241
+ const pb = pathParts(b);
242
+ const n = Math.min(pa.length, pb.length);
243
+ for (let i = 0; i < n; i++) {
244
+ const x = WIN ? pa[i].toLowerCase() : pa[i];
245
+ const y = WIN ? pb[i].toLowerCase() : pb[i];
246
+ const c = cmpPyStr(x, y);
247
+ if (c !== 0) return c;
248
+ }
249
+ return pa.length === pb.length ? 0 : pa.length < pb.length ? -1 : 1;
250
+ }
251
+
252
+ // cards_dir.rglob("card.json"): zero or more directories, dotfiles included,
253
+ // symlinked directories not followed.
254
+ function findCardFiles(dir) {
255
+ const found = [];
256
+ let entries;
257
+ try {
258
+ entries = fs.readdirSync(dir, { withFileTypes: true });
259
+ } catch (e) {
260
+ return found;
261
+ }
262
+ for (const ent of entries) {
263
+ const full = path.join(dir, ent.name);
264
+ if (ent.isDirectory()) found.push(...findCardFiles(full));
265
+ else if (ent.isFile() && ent.name === "card.json") found.push(full);
266
+ }
267
+ return found;
268
+ }
269
+
270
+ function displayPath(file, root) {
271
+ const rel = path.relative(root, file);
272
+ if (rel === "") return ".";
273
+ if (!path.isAbsolute(rel) && rel !== ".." && !rel.startsWith(".." + path.sep)) return rel;
274
+ return file;
275
+ }
276
+
277
+ // The two malformed-card cases that actually show up in the wild (a Windows editor
278
+ // BOM, a truncated write) get Python's exact json wording; anything else keeps the
279
+ // JavaScript parse error, which is the one place the ports word things differently.
280
+ function readCard(f) {
281
+ const text = fs.readFileSync(f, "utf8");
282
+ if (text.charCodeAt(0) === 0xfeff) {
283
+ throw new Error("Unexpected UTF-8 BOM (decode using utf-8-sig): line 1 column 1 (char 0)");
284
+ }
285
+ if (!text.trim()) throw new Error("Expecting value: line 1 column 1 (char 0)");
286
+ return JSON.parse(text);
287
+ }
288
+
289
+ function loadCards(cardsDir, root) {
290
+ const cards = [];
291
+ const files = isDir(cardsDir) ? findCardFiles(cardsDir) : [];
292
+ files.sort(cmpPyPath);
293
+ for (const f of files) {
294
+ let data;
295
+ try {
296
+ data = readCard(f);
297
+ } catch (e) {
298
+ cards.push({ id: f, broken: "unreadable: " + ((e && e.message) || String(e)) });
299
+ continue;
300
+ }
301
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
302
+ // Python would die on data["_file"] for a non-dict card.
303
+ throw new TypeError(`card is not an object: ${f}`);
304
+ }
305
+ data._file = displayPath(f, root);
306
+ cards.push(data);
307
+ }
308
+ return cards;
309
+ }
310
+
311
+ function validate(cards) {
312
+ const problems = [];
313
+ const seen = new Map();
314
+ for (const c of cards) {
315
+ const cid = Object.prototype.hasOwnProperty.call(c, "id") ? c.id : "?";
316
+ if (pyTruthy(c.broken)) {
317
+ problems.push(`${pyStr(cid)}: ${pyStr(c.broken)}`);
318
+ continue;
319
+ }
320
+ for (const key of REQUIRED) {
321
+ if (!pyTruthy(c[key])) problems.push(`${pyStr(cid)}: missing '${key}'`);
322
+ }
323
+ const scope = c.scope;
324
+ if (Array.isArray(scope)) {
325
+ for (const pat of scope) {
326
+ if (typeof pat !== "string" || !pat.trim()) {
327
+ problems.push(`${pyStr(cid)}: bad scope entry ${pyRepr(pat)}`);
328
+ }
329
+ }
330
+ } else if (Object.prototype.hasOwnProperty.call(c, "scope")) {
331
+ problems.push(`${pyStr(cid)}: scope must be a list`);
332
+ }
333
+ if (seen.has(cid)) {
334
+ problems.push(`duplicate id ${pyStr(cid)} (${pyStr(seen.get(cid))} vs ${pyStr(c._file)})`);
335
+ }
336
+ seen.set(cid, c._file === undefined ? "" : c._file);
337
+ }
338
+ return problems;
339
+ }
340
+
341
+ /* ------------------------------------------------------------------ *
342
+ * Scope matching
343
+ * ------------------------------------------------------------------ */
344
+
345
+ function asString(v, what) {
346
+ if (typeof v !== "string") {
347
+ throw new TypeError(`${what} must be a string, not ${pyRepr(v)}`);
348
+ }
349
+ return v;
350
+ }
351
+
352
+ // `for pat in card.get("scope") or []` - a bare string iterates its characters,
353
+ // and a mapping iterates its keys, exactly like Python.
354
+ function scopeItems(card) {
355
+ const scope = card.scope;
356
+ if (!pyTruthy(scope)) return [];
357
+ if (typeof scope === "string") return [...scope];
358
+ if (Array.isArray(scope)) return scope;
359
+ if (typeof scope === "object") return Object.keys(scope);
360
+ throw new TypeError(`${pyStr(scope)} is not iterable`);
361
+ }
362
+
363
+ function prefix(pat) {
364
+ const glob = asString(pat, "scope entry");
365
+ const chars = [...glob];
366
+ for (let i = 0; i < chars.length; i++) {
367
+ if (chars[i] === "*" || chars[i] === "?" || chars[i] === "[") {
368
+ return chars.slice(0, i).join("").replace(/[/\\]+$/, "");
369
+ }
370
+ }
371
+ return glob.replace(/[/\\]+$/, "");
372
+ }
373
+
374
+ function conflicts(a, b) {
375
+ for (const pa of scopeItems(a)) {
376
+ for (const pb of scopeItems(b)) {
377
+ if (pa === pb) return true;
378
+ const fa = prefix(pa).replace(/\\/g, "/");
379
+ const fb = prefix(pb).replace(/\\/g, "/");
380
+ if (!fa || !fb) return true;
381
+ if (fa.startsWith(fb) || fb.startsWith(fa)) return true;
382
+ }
383
+ }
384
+ return false;
385
+ }
386
+
387
+ // Greedy first-fit: a card joins the first wave it collides with nobody in.
388
+ function plan(cards) {
389
+ const waves = [];
390
+ for (const card of cards) {
391
+ if (pyTruthy(card.broken)) continue;
392
+ let placed = false;
393
+ for (const wave of waves) {
394
+ if (!wave.some((other) => conflicts(card, other))) {
395
+ wave.push(card);
396
+ placed = true;
397
+ break;
398
+ }
399
+ }
400
+ if (!placed) waves.push([card]);
401
+ }
402
+ return waves;
403
+ }
404
+
405
+ function cardOwns(card, file) {
406
+ const target = file.replace(/\\/g, "/");
407
+ for (const raw of scopeItems(card)) {
408
+ const pat = asString(raw, "scope entry").replace(/\\/g, "/");
409
+ if (fnmatch(target, pat) || target === prefix(pat)) return true;
410
+ const pre = prefix(pat);
411
+ if (pre && target.startsWith(pre.replace(/\/+$/, "") + "/")) return true;
412
+ }
413
+ return false;
414
+ }
415
+
416
+ /* ------------------------------------------------------------------ *
417
+ * git
418
+ * ------------------------------------------------------------------ */
419
+
420
+ function splitLines(text) {
421
+ return String(text)
422
+ .split(/\r\n|\r|\n/)
423
+ .map((ln) => ln.trim())
424
+ .filter((ln) => ln.length > 0);
425
+ }
426
+
427
+ function gitChanged(cwd) {
428
+ const files = [];
429
+ const commands = [
430
+ ["diff", "--name-only", "HEAD"],
431
+ ["ls-files", "--others", "--exclude-standard"],
432
+ ];
433
+ for (const args of commands) {
434
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
435
+ if (r.error) continue;
436
+ if (r.status === 0) files.push(...splitLines(r.stdout || ""));
437
+ }
438
+ return [...new Set(files)].sort(cmpPyStr);
439
+ }
440
+
441
+ /* ------------------------------------------------------------------ *
442
+ * argparse-compatible argument parsing
443
+ * ------------------------------------------------------------------ */
444
+
445
+ const OPTIONS = [
446
+ { name: "help", flags: ["-h", "--help"] },
447
+ { name: "cards", flags: ["--cards"], value: true },
448
+ { name: "changed", flags: ["--changed"], nargs: "*" },
449
+ { name: "json", flags: ["--json"], storeTrue: true },
450
+ ];
451
+
452
+ function knownFlags() {
453
+ const out = [];
454
+ for (const opt of OPTIONS) out.push(...opt.flags);
455
+ return out;
456
+ }
457
+
458
+ function lookupFlag(token) {
459
+ const flags = knownFlags();
460
+ if (flags.includes(token)) return OPTIONS.find((o) => o.flags.includes(token));
461
+ // argparse allows unambiguous prefixes of long options.
462
+ if (token.startsWith("--")) {
463
+ const hits = OPTIONS.filter((o) => o.flags.some((f) => f.startsWith("--") && f.startsWith(token)));
464
+ if (hits.length === 1) return hits[0];
465
+ if (hits.length > 1) {
466
+ const names = hits.map((o) => o.flags.find((f) => f.startsWith("--") && f.startsWith(token)));
467
+ return { ambiguous: names.join(", ") };
468
+ }
469
+ }
470
+ return null;
471
+ }
472
+
473
+ function parseArgs(argv) {
474
+ const state = { cmd: null, extra: [], cards: "tasks", changed: null, json: false, help: false };
475
+ const unknown = [];
476
+ for (let i = 0; i < argv.length; i++) {
477
+ const token = String(argv[i]);
478
+ if (token === "--") {
479
+ // everything after "--" is positional
480
+ for (i += 1; i < argv.length; i++) state.extra.push(String(argv[i]));
481
+ break;
482
+ }
483
+ if (token.length > 1 && token[0] === "-") {
484
+ const eq = token.indexOf("=");
485
+ const name = eq === -1 ? token : token.slice(0, eq);
486
+ const inline = eq === -1 ? undefined : token.slice(eq + 1);
487
+ const opt = lookupFlag(name);
488
+ if (!opt) {
489
+ unknown.push(token);
490
+ continue;
491
+ }
492
+ if (opt.ambiguous) return { error: `ambiguous option: ${name} could match ${opt.ambiguous}` };
493
+ if (opt.name === "help") {
494
+ if (inline !== undefined) return { error: "ignored explicit argument '" + inline + "'" };
495
+ state.help = true;
496
+ continue;
497
+ }
498
+ if (opt.storeTrue) {
499
+ if (inline !== undefined) {
500
+ return { error: "ignored explicit argument '" + inline + "'" };
501
+ }
502
+ state[opt.name] = true;
503
+ continue;
504
+ }
505
+ if (opt.name === "cards") {
506
+ if (inline !== undefined) {
507
+ state.cards = inline;
508
+ continue;
509
+ }
510
+ if (i + 1 >= argv.length || String(argv[i + 1]).startsWith("-")) {
511
+ return { error: "argument --cards: expected one argument" };
512
+ }
513
+ state.cards = String(argv[++i]);
514
+ continue;
515
+ }
516
+ if (opt.name === "changed") {
517
+ if (inline !== undefined) {
518
+ state.changed = [inline];
519
+ continue;
520
+ }
521
+ const values = [];
522
+ while (i + 1 < argv.length && !String(argv[i + 1]).startsWith("-")) values.push(String(argv[++i]));
523
+ state.changed = values;
524
+ continue;
525
+ }
526
+ }
527
+ if (state.cmd === null) state.cmd = token;
528
+ else state.extra.push(token);
529
+ }
530
+ if (state.help) return state;
531
+ if (state.cmd === null) return { error: "the following arguments are required: cmd" };
532
+ if (!CHOICES.includes(state.cmd)) {
533
+ const choices = CHOICES.map((c) => `'${c}'`).join(", ");
534
+ return { error: `argument cmd: invalid choice: '${state.cmd}' (choose from ${choices})` };
535
+ }
536
+ if (state.extra.length || unknown.length) {
537
+ return { error: "unrecognized arguments: " + [...state.extra, ...unknown].join(" ") };
538
+ }
539
+ return state;
540
+ }
541
+
542
+ /* ------------------------------------------------------------------ *
543
+ * Entry point
544
+ * ------------------------------------------------------------------ */
545
+
546
+ function run(argv) {
547
+ const a = parseArgs(argv || []);
548
+ if (a.help) {
549
+ process.stdout.write(HELP);
550
+ return 0;
551
+ }
552
+ if (a.error) {
553
+ process.stderr.write(USAGE + PROG + ": error: " + a.error + "\n");
554
+ return 2;
555
+ }
556
+
557
+ const root = process.cwd();
558
+ const stateFile = core.statePath(root, "p3.json");
559
+ const cards = loadCards(path.resolve(root, a.cards), root);
560
+ const problems = validate(cards);
561
+
562
+ if (a.cmd === "validate") {
563
+ const ok = problems.length === 0;
564
+ const payload = { gate: "p3", cmd: "validate", ok, cards: cards.length, problems };
565
+ core.writeJSON(stateFile, payload);
566
+ if (a.json) {
567
+ jsonLine({ gate: payload.gate, cmd: payload.cmd, ok: payload.ok, cards: payload.cards });
568
+ } else {
569
+ for (const p of problems) process.stdout.write(`[p3] ${p}\n`);
570
+ process.stdout.write(`[p3] ${ok ? "PASS" : "FAIL"} cards=${cards.length} problems=${problems.length}\n`);
571
+ }
572
+ return ok ? 0 : 1;
573
+ }
574
+
575
+ if (a.cmd === "plan") {
576
+ const waves = plan(cards);
577
+ const ok = problems.length === 0 && waves.length > 0;
578
+ const payload = {
579
+ gate: "p3",
580
+ cmd: "plan",
581
+ ok,
582
+ waves: waves.map((w) => w.map((c) => (Object.prototype.hasOwnProperty.call(c, "id") ? c.id : null))),
583
+ problems,
584
+ };
585
+ core.writeJSON(stateFile, payload);
586
+ if (a.json) {
587
+ jsonLine(payload);
588
+ } else {
589
+ waves.forEach((wave, i) => {
590
+ const ids = wave.map((c) => (Object.prototype.hasOwnProperty.call(c, "id") ? pyStr(c.id) : "?"));
591
+ process.stdout.write(`[p3] wave ${i + 1}: ${ids.join(", ")}\n`);
592
+ });
593
+ process.stdout.write(`[p3] ${ok ? "PASS" : "FAIL"} waves=${waves.length} cards=${cards.length}\n`);
594
+ }
595
+ return ok ? 0 : 1;
596
+ }
597
+
598
+ const changed = a.changed !== null ? a.changed : gitChanged(root);
599
+ const owners = new Map();
600
+ for (const f of changed) {
601
+ owners.set(
602
+ f,
603
+ cards.filter((c) => !pyTruthy(c.broken) && cardOwns(c, f)).map((c) => c.id)
604
+ );
605
+ }
606
+ const unowned = changed.filter((f) => !owners.get(f).length);
607
+ const missing = cards
608
+ .filter((c) => !pyTruthy(c.broken) && !changed.some((f) => cardOwns(c, f)))
609
+ .map((c) => (Object.prototype.hasOwnProperty.call(c, "id") ? c.id : null));
610
+ const ok = problems.length === 0 && unowned.length === 0;
611
+ const payload = {
612
+ gate: "p3",
613
+ cmd: "collect",
614
+ ok,
615
+ changed: changed.length,
616
+ unowned,
617
+ cardsWithoutChange: missing,
618
+ problems,
619
+ };
620
+ core.writeJSON(stateFile, payload);
621
+ if (a.json) {
622
+ jsonLine({ gate: payload.gate, cmd: payload.cmd, ok: payload.ok, changed: payload.changed });
623
+ } else {
624
+ for (const f of unowned) process.stdout.write(`[p3] unowned change: ${f}\n`);
625
+ for (const cid of missing) process.stdout.write(`[p3] card shipped nothing: ${pyStr(cid)}\n`);
626
+ process.stdout.write(
627
+ `[p3] ${ok ? "PASS" : "FAIL"} changed=${changed.length} unowned=${unowned.length} empty=${missing.length}\n`
628
+ );
629
+ }
630
+ return ok ? 0 : 1;
631
+ }
632
+
633
+ module.exports = { run };