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.
- package/.pre-commit-hooks.yaml +27 -0
- package/CHANGELOG.md +336 -0
- package/LICENSE +21 -0
- package/README.md +540 -3
- package/action.yml +24 -0
- package/bin/signoff.js +218 -0
- package/docs/diagrams/README.md +41 -0
- package/docs/diagrams/signoff-1-capability-en.png +0 -0
- package/docs/diagrams/signoff-1-capability.png +0 -0
- package/docs/diagrams/signoff-2-market-en.png +0 -0
- package/docs/diagrams/signoff-2-market.png +0 -0
- package/docs/diagrams/signoff-3-user-en.png +0 -0
- package/docs/diagrams/signoff-3-user.png +0 -0
- package/docs/diagrams/signoff-4-flow-en.png +0 -0
- package/docs/diagrams/signoff-4-flow.png +0 -0
- package/docs/diagrams/signoff-5-competitors-en.png +0 -0
- package/docs/diagrams/signoff-5-competitors.png +0 -0
- package/docs/diagrams/src/signoff-1-capability-en.mmd +81 -0
- package/docs/diagrams/src/signoff-1-capability.mmd +84 -0
- package/docs/diagrams/src/signoff-2-market-en.mmd +72 -0
- package/docs/diagrams/src/signoff-2-market.mmd +72 -0
- package/docs/diagrams/src/signoff-3-user-en.mmd +94 -0
- package/docs/diagrams/src/signoff-3-user.mmd +92 -0
- package/docs/gates.en.md +105 -0
- package/docs/gates.md +88 -0
- package/docs/integrations.md +58 -0
- package/docs/promotion.md +71 -0
- package/docs/signoff.schema.json +108 -0
- package/docs/trust.md +72 -0
- package/gates/guard.py +474 -0
- package/gates/p1_verify.py +111 -0
- package/gates/p2_coverage.py +127 -0
- package/gates/p3_queue.py +186 -0
- package/gates/p4_ledger.py +249 -0
- package/gates/p5_slop.py +109 -0
- package/lib/audit.js +213 -0
- package/lib/bootstrap.js +361 -0
- package/lib/checkpoint.js +184 -0
- package/lib/ci.js +58 -0
- package/lib/core.js +120 -0
- package/lib/cov.js +253 -0
- package/lib/detect.js +169 -0
- package/lib/doctor.js +304 -0
- package/lib/editor.js +97 -0
- package/lib/explain.js +73 -0
- package/lib/guard.js +1229 -0
- package/lib/p1.js +469 -0
- package/lib/p2.js +323 -0
- package/lib/p3.js +633 -0
- package/lib/p4.js +733 -0
- package/lib/p5.js +438 -0
- package/lib/preset.js +66 -0
- package/lib/secrets.js +336 -0
- package/lib/sign.js +196 -0
- package/package.json +55 -6
- package/test/selftest.js +1226 -0
- 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
|
+
}
|