signoff-cli 0.0.0-stage → 4.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.
Files changed (57) hide show
  1. package/.pre-commit-hooks.yaml +27 -0
  2. package/CHANGELOG.md +336 -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 +55 -6
  56. package/test/selftest.js +1226 -0
  57. package/test/selftest.py +190 -0
package/lib/p2.js ADDED
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // SignOff gate 2 - coverage has a level, not a vibe.
4
+ //
5
+ // Node port of gates/p2_coverage.py, which stays the reference implementation.
6
+ // Parity with it is the point: same flags and defaults, same exit codes, same
7
+ // stdout (including Python's text-mode newline translation), same `.signoff/p2.json`
8
+ // and same `--md` table. App discovery, scenario counting, grading and thresholds
9
+ // are ported line by line, so a difference here is a bug.
10
+
11
+ const fs = require("fs");
12
+ const path = require("path");
13
+ const core = require("./core");
14
+
15
+ // Python writes text (stdout, write_text) through a TextIOWrapper with newline=None,
16
+ // which turns every "\n" into os.linesep. On Windows the reference gate therefore
17
+ // emits CRLF; the port has to do the same or the bytes differ.
18
+ const EOL = process.platform === "win32" ? "\r\n" : "\n";
19
+ const SKIP_DIRS = new Set(["node_modules", "docs", "gates", "bin", "test"]);
20
+ // The ladder the four level names encode; kept because the reference gate defines it.
21
+ const LEVELS = { missing: 0, smoke: 1, harness: 2, scenario: 3 };
22
+ const NEGATIVE_NUMBER = /^-\d+$|^-\d*\.\d+$/; // argparse's _negative_number_matcher
23
+ const OPTIONS = ["help", "min-scenario", "min-assertions", "strict", "md", "json"];
24
+ const USAGE = `usage: signoff p2 [-h] [--min-scenario MIN_SCENARIO]
25
+ [--min-assertions MIN_ASSERTIONS] [--strict] [--md FILE]
26
+ [--json]`;
27
+ const HELP = `${USAGE}
28
+
29
+ options:
30
+ -h, --help show this help message and exit
31
+ --min-scenario MIN_SCENARIO
32
+ require at least N scenario-level apps
33
+ --min-assertions MIN_ASSERTIONS
34
+ --strict fail on missing/harness/broken
35
+ --md FILE write a markdown table
36
+ --json
37
+ `;
38
+
39
+ // --- Python shims ---------------------------------------------------------
40
+
41
+ function eol(text) {
42
+ return text.split("\n").join(EOL);
43
+ }
44
+
45
+ function isDir(p) {
46
+ try {
47
+ return fs.statSync(p).isDirectory();
48
+ } catch (e) {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ function exists(p) {
54
+ try {
55
+ fs.statSync(p);
56
+ return true;
57
+ } catch (e) {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ // len()/slicing/formatting in Python count code points, not UTF-16 units.
63
+ function cps(s) {
64
+ return Array.from(String(s));
65
+ }
66
+
67
+ function cut(s, n) {
68
+ return cps(s).slice(0, n).join("");
69
+ }
70
+
71
+ function pad(s, width) {
72
+ const len = cps(s).length;
73
+ return len >= width ? s : s + " ".repeat(width - len);
74
+ }
75
+
76
+ // `name[:-5]` - drop the ".json" suffix, in code points like Python slices.
77
+ function stripJson(name) {
78
+ const parts = cps(name);
79
+ return parts.slice(0, Math.max(0, parts.length - 5)).join("");
80
+ }
81
+
82
+ // pathlib sorts Paths by their normcased parts: case-insensitive on Windows.
83
+ function nameCmp(a, b) {
84
+ const ka = process.platform === "win32" ? a.toLowerCase() : a;
85
+ const kb = process.platform === "win32" ? b.toLowerCase() : b;
86
+ if (ka !== kb) return ka < kb ? -1 : 1;
87
+ return a < b ? -1 : a > b ? 1 : 0;
88
+ }
89
+
90
+ // Python text mode reads files strictly as UTF-8 and keeps a leading BOM, which
91
+ // json.loads then rejects. TextDecoder must do exactly the same.
92
+ function readUtf8Strict(p) {
93
+ const buf = fs.readFileSync(p); // directories and missing files throw, like read_text
94
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buf);
95
+ }
96
+
97
+ // json.dumps with the default separators (", " / ": ").
98
+ function dumpComma(obj) {
99
+ if (obj === null || typeof obj !== "object") return JSON.stringify(obj);
100
+ if (Array.isArray(obj)) return "[" + obj.map(dumpComma).join(", ") + "]";
101
+ return (
102
+ "{" +
103
+ Object.keys(obj)
104
+ .map((k) => JSON.stringify(k) + ": " + dumpComma(obj[k]))
105
+ .join(", ") +
106
+ "}"
107
+ );
108
+ }
109
+
110
+ function attrError(data) {
111
+ const t =
112
+ data === null ? "NoneType"
113
+ : Array.isArray(data) ? "list"
114
+ : typeof data === "string" ? "str"
115
+ : typeof data === "boolean" ? "bool"
116
+ : "int";
117
+ return new Error(`'${t}' object has no attribute 'get'`);
118
+ }
119
+
120
+ // --- the gate -------------------------------------------------------------
121
+
122
+ function appDirs(root) {
123
+ const found = [];
124
+ if (exists(path.join(root, "package.json")) || isDir(path.join(root, "verify"))) {
125
+ found.push(root);
126
+ }
127
+ for (const base of [root, path.join(root, "packages"), path.join(root, "apps")]) {
128
+ if (!isDir(base)) continue;
129
+ const names = fs.readdirSync(base).sort(nameCmp);
130
+ for (const name of names) {
131
+ const d = path.join(base, name);
132
+ if (!isDir(d) || name.startsWith(".") || SKIP_DIRS.has(name)) continue;
133
+ if (exists(path.join(d, "package.json")) || isDir(path.join(d, "verify"))) {
134
+ found.push(d);
135
+ }
136
+ }
137
+ }
138
+ return found;
139
+ }
140
+
141
+ function scenarioChecks(p) {
142
+ let data;
143
+ try {
144
+ data = JSON.parse(readUtf8Strict(p));
145
+ } catch (e) {
146
+ // OSError / ValueError (bad UTF-8, bad JSON, missing file, directory) -> -1
147
+ return -1;
148
+ }
149
+ if (data === null || typeof data !== "object" || Array.isArray(data)) throw attrError(data);
150
+ const checks = data.checks;
151
+ return Array.isArray(checks) ? checks.length : -1;
152
+ }
153
+
154
+ function inspect(app) {
155
+ const sdir = path.join(app, "verify", "scenarios");
156
+ const scenarios = [];
157
+ if (isDir(sdir)) {
158
+ const files = fs
159
+ .readdirSync(sdir)
160
+ .filter((name) => (process.platform === "win32" ? name.toLowerCase() : name).endsWith(".json"))
161
+ .sort(nameCmp);
162
+ for (const name of files) {
163
+ scenarios.push({ file: name, checks: scenarioChecks(path.join(sdir, name)) });
164
+ }
165
+ }
166
+ const broken = scenarios.filter((s) => s.checks < 0);
167
+ let level;
168
+ if (scenarios.length && !broken.length) level = "scenario";
169
+ else if (isDir(path.join(app, "verify"))) level = "harness";
170
+ else if (exists(path.join(app, "package.json"))) level = "smoke";
171
+ else level = "missing";
172
+ const assertions = scenarios.reduce((n, s) => n + (s.checks > 0 ? s.checks : 0), 0);
173
+ return {
174
+ app: path.basename(app) || "",
175
+ level,
176
+ scenarios,
177
+ assertions,
178
+ broken: broken.map((s) => s.file),
179
+ };
180
+ }
181
+
182
+ // "menu(11), perf(6)" - the per-app scenario list both the table and the md reuse.
183
+ function scenarioLabel(r) {
184
+ return r.scenarios.map((s) => `${stripJson(s.file)}(${s.checks})`).join(", ") || "-";
185
+ }
186
+
187
+ function usageError(msg) {
188
+ process.stderr.write(eol(`${USAGE}\nsignoff p2: error: ${msg}\n`));
189
+ }
190
+
191
+ // argparse-compatible enough for the five documented flags: "--key value",
192
+ // "--key=value", unambiguous prefixes, `type=int` values and store_true flags.
193
+ function parse(argv) {
194
+ const spec = { "min-scenario": "int", "min-assertions": "int", strict: "bool", md: "str", json: "bool", help: "bool" };
195
+ const a = { "min-scenario": 0, "min-assertions": 0, strict: false, md: null, json: false, help: false };
196
+ const extra = [];
197
+ for (let i = 0; i < argv.length; i++) {
198
+ const tok = argv[i];
199
+ if (tok === "-h") {
200
+ a.help = true;
201
+ continue;
202
+ }
203
+ if (tok === "--") {
204
+ // argparse: a lone "--" is not accepted here; everything after it is positional
205
+ for (let j = i; j < argv.length; j++) extra.push(argv[j]);
206
+ break;
207
+ }
208
+ if (!tok.startsWith("--")) {
209
+ if (tok.startsWith("-") && tok !== "-") throw new Error(`unrecognized arguments: ${tok}`);
210
+ extra.push(tok);
211
+ continue;
212
+ }
213
+ const eq = tok.indexOf("=");
214
+ const typed = eq === -1 ? tok.slice(2) : tok.slice(2, eq);
215
+ let name = OPTIONS.includes(typed) ? typed : null;
216
+ if (name === null) {
217
+ const hits = OPTIONS.filter((o) => o.startsWith(typed));
218
+ if (hits.length > 1) throw new Error(`ambiguous option: ${tok} could match ${hits.map((h) => "--" + h).join(", ")}`);
219
+ if (!hits.length) throw new Error(`unrecognized arguments: ${tok}`);
220
+ name = hits[0];
221
+ }
222
+ const kind = spec[name];
223
+ let val = eq === -1 ? null : tok.slice(eq + 1);
224
+ if (kind === "bool") {
225
+ if (val !== null) throw new Error(`argument --${name}: ignored explicit argument '${val}'`);
226
+ a[name] = true;
227
+ continue;
228
+ }
229
+ if (val === null) {
230
+ const next = argv[i + 1];
231
+ if (next === undefined || (next.startsWith("-") && !NEGATIVE_NUMBER.test(next))) {
232
+ throw new Error(`argument --${name}: expected one argument`);
233
+ }
234
+ val = next;
235
+ i++;
236
+ }
237
+ if (kind === "int") {
238
+ if (!/^[+-]?\d(?:_?\d)*$/.test(val.trim())) throw new Error(`argument --${name}: invalid int value: '${val}'`);
239
+ a[name] = parseInt(val.trim().replace(/_/g, ""), 10);
240
+ } else {
241
+ a[name] = val;
242
+ }
243
+ }
244
+ if (extra.length) throw new Error(`unrecognized arguments: ${extra.join(" ")}`);
245
+ return a;
246
+ }
247
+
248
+ function run(argv) {
249
+ let a;
250
+ try {
251
+ a = parse(argv || []);
252
+ } catch (e) {
253
+ usageError(e.message);
254
+ return 2;
255
+ }
256
+ if (a.help) {
257
+ process.stdout.write(eol(HELP));
258
+ return 0;
259
+ }
260
+
261
+ const root = process.cwd();
262
+ const apps = appDirs(root);
263
+ const rows = apps.map(inspect);
264
+ const scenarioApps = rows.filter((r) => r.level === "scenario");
265
+ const assertions = rows.reduce((n, r) => n + r.assertions, 0);
266
+ const broken = rows.filter((r) => r.broken.length);
267
+ const ok =
268
+ scenarioApps.length >= a["min-scenario"] &&
269
+ assertions >= a["min-assertions"] &&
270
+ !broken.length &&
271
+ (!a.strict || rows.every((r) => r.level === "scenario"));
272
+
273
+ const header = `${pad("app", 34)} ${pad("level", 9)} ${pad("scenarios", 22)} assertions`;
274
+ const lines = [header, "-".repeat(cps(header).length)];
275
+ for (const r of rows) {
276
+ const names = scenarioLabel(r);
277
+ lines.push(`${pad(cut(r.app, 33), 34)} ${pad(r.level, 9)} ${pad(cut(names, 21), 22)} ${r.assertions}`);
278
+ }
279
+
280
+ if (a.md) {
281
+ const md = ["| app | level | scenarios | assertions |", "| --- | --- | --- | --- |"];
282
+ for (const r of rows) {
283
+ md.push(`| ${r.app} | ${r.level} | ${scenarioLabel(r)} | ${r.assertions} |`);
284
+ }
285
+ // Python's write_text fails when the parent directory is missing; core.writeText
286
+ // would create it, so write directly to keep the exit code identical.
287
+ fs.writeFileSync(path.resolve(root, a.md), eol(md.join("\n") + "\n"), "utf8");
288
+ lines.push(`[p2] table written: ${a.md}`);
289
+ }
290
+
291
+ const payload = {
292
+ gate: "p2",
293
+ ok,
294
+ apps: rows.length,
295
+ scenarioApps: scenarioApps.length,
296
+ assertions,
297
+ broken: broken.map((b) => ({ app: b.app, files: b.broken })),
298
+ rows,
299
+ };
300
+ const state = core.statePath(root, "p2.json");
301
+ core.writeText(state, eol(JSON.stringify(payload, null, 2) + "\n"));
302
+
303
+ if (a.json) {
304
+ const subset = {
305
+ gate: payload.gate,
306
+ ok: payload.ok,
307
+ apps: payload.apps,
308
+ scenarioApps: payload.scenarioApps,
309
+ assertions: payload.assertions,
310
+ };
311
+ process.stdout.write(eol(dumpComma(subset) + "\n"));
312
+ } else {
313
+ const summary = `[p2] ${ok ? "PASS" : "FAIL"} apps=${rows.length} scenario=${scenarioApps.length} assertions=${assertions} broken=${broken.length}`;
314
+ process.stdout.write(eol(lines.join("\n") + "\n" + summary + "\n"));
315
+ }
316
+ return ok ? 0 : 1;
317
+ }
318
+
319
+ module.exports = { run };
320
+
321
+ if (require.main === module) {
322
+ process.exit(run(process.argv.slice(2)));
323
+ }