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.
- package/.pre-commit-hooks.yaml +27 -0
- package/CHANGELOG.md +344 -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 +56 -6
- package/test/selftest.js +1227 -0
- package/test/selftest.py +190 -0
package/lib/p1.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Gate 1 — "verify before you speak".
|
|
3
|
+
// Port of gates/p1_verify.py: same flags, same defaults, same JSON, same human
|
|
4
|
+
// lines, same state file (.signoff/p1.json), same exit codes (0 pass / 1 fail /
|
|
5
|
+
// 2 usage). Pure Node, CommonJS, no Python.
|
|
6
|
+
//
|
|
7
|
+
// The reference implementation is the source of truth; every branch below says
|
|
8
|
+
// which Python line it mirrors when the behaviour is not obvious.
|
|
9
|
+
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const os = require("os");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
const { spawnSync } = require("child_process");
|
|
14
|
+
const { statePath, writeJSON } = require("./core");
|
|
15
|
+
|
|
16
|
+
const USAGE =
|
|
17
|
+
"usage: signoff p1 [-h] [--run RUN] [--budget BUDGET] [--install-hook] [--json] [--quiet]";
|
|
18
|
+
|
|
19
|
+
const HELP =
|
|
20
|
+
USAGE +
|
|
21
|
+
"\n\n" +
|
|
22
|
+
[
|
|
23
|
+
"options:",
|
|
24
|
+
" -h, --help show this help message and exit",
|
|
25
|
+
" --run RUN command to run (repeatable)",
|
|
26
|
+
" --budget BUDGET seconds for all commands together (default: 180.0)",
|
|
27
|
+
" --install-hook write .git/hooks/pre-commit",
|
|
28
|
+
" --json print the verdict as one JSON line",
|
|
29
|
+
" --quiet suppress the per-command line",
|
|
30
|
+
].join("\n") +
|
|
31
|
+
"\n";
|
|
32
|
+
|
|
33
|
+
// --- case counting ---------------------------------------------------------
|
|
34
|
+
// Order matters and is byte-identical to the Python: the first pattern that has
|
|
35
|
+
// a hit wins, and the answer is the largest number it saw.
|
|
36
|
+
const CASE_PATTERNS = [
|
|
37
|
+
/(\d+)\s+passed/g,
|
|
38
|
+
/(\d+)\s+tests?\b/g,
|
|
39
|
+
/(\d+)\s+checks?\b/g,
|
|
40
|
+
/(\d+)\s+assertions?\b/g,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
function countCases(text) {
|
|
44
|
+
for (const re of CASE_PATTERNS) {
|
|
45
|
+
let best = null;
|
|
46
|
+
for (const m of text.matchAll(re)) {
|
|
47
|
+
const n = Number(m[1]);
|
|
48
|
+
if (best === null || n > best) best = n;
|
|
49
|
+
}
|
|
50
|
+
if (best !== null) return best;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- Python string primitives ---------------------------------------------
|
|
56
|
+
// str.splitlines() breaks on more than \n: \r, \v, \f, \x1c-\x1e, \x85, U+2028,
|
|
57
|
+
// U+2029. `tail` must contain the same strings as the Python version.
|
|
58
|
+
const LINE_BREAK = /\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/;
|
|
59
|
+
|
|
60
|
+
function splitLines(text) {
|
|
61
|
+
const lines = text.split(LINE_BREAK);
|
|
62
|
+
if (lines.length && lines[lines.length - 1] === "") lines.pop();
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// str.isspace() characters, so str.strip() and JS trim() agree.
|
|
67
|
+
const WS =
|
|
68
|
+
"[\\t\\n\\v\\f\\r \\x1c-\\x1f\\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]";
|
|
69
|
+
const LEAD_TRAIL = new RegExp("^" + WS + "+|" + WS + "+$", "g");
|
|
70
|
+
|
|
71
|
+
function pyStrip(s) {
|
|
72
|
+
return s.replace(LEAD_TRAIL, "");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- helpers ---------------------------------------------------------------
|
|
76
|
+
function nowMs() {
|
|
77
|
+
const [s, ns] = process.hrtime();
|
|
78
|
+
return s * 1000 + ns / 1e6;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isDir(p) {
|
|
82
|
+
try {
|
|
83
|
+
return fs.statSync(p).isDirectory();
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function pythonExe() {
|
|
90
|
+
return process.env.SIGNOFF_PYTHON || (process.platform === "win32" ? "python" : "python3");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function shellQuote(s) {
|
|
94
|
+
return /[\s]/.test(s) ? `"${s}"` : s;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function hasFile(cwd, name) {
|
|
98
|
+
try {
|
|
99
|
+
return fs.statSync(path.join(cwd, name)).isFile();
|
|
100
|
+
} catch (e) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function names(cwd) {
|
|
106
|
+
try {
|
|
107
|
+
return fs.readdirSync(cwd);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// `make test` is only a guess when the Makefile actually declares that target.
|
|
114
|
+
function makefileHasTest(cwd) {
|
|
115
|
+
try {
|
|
116
|
+
return /^test[^\n:]*:/m.test(fs.readFileSync(path.join(cwd, "Makefile"), "utf8"));
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Mirrors default_commands() for the original two cases and extends it to the
|
|
123
|
+
// ecosystems a repo is most likely to be built with. First match wins: one suite
|
|
124
|
+
// per commit keeps the hook fast, and a repo needing two runs can pass --run.
|
|
125
|
+
function anyFileIn(dir, re) {
|
|
126
|
+
try {
|
|
127
|
+
return fs.readdirSync(dir).some((n) => re.test(n));
|
|
128
|
+
} catch (e) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// A `tests/` directory is not evidence of pytest: it is the single most common
|
|
134
|
+
// layout in every ecosystem, and a JS repo with tests/ must run `npm test`, not a
|
|
135
|
+
// python binary that may not even be installed.
|
|
136
|
+
function looksPython(cwd) {
|
|
137
|
+
return (
|
|
138
|
+
anyFileIn(path.join(cwd, "tests"), /\.py$/i) ||
|
|
139
|
+
hasFile(cwd, "pyproject.toml") ||
|
|
140
|
+
hasFile(cwd, "setup.py") ||
|
|
141
|
+
hasFile(cwd, "setup.cfg") ||
|
|
142
|
+
hasFile(cwd, "requirements.txt") ||
|
|
143
|
+
hasFile(cwd, "tox.ini") ||
|
|
144
|
+
hasFile(cwd, "pytest.ini") ||
|
|
145
|
+
hasFile(cwd, "conftest.py")
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const DETECTORS = [
|
|
150
|
+
// package.json first: its `test` script is an explicit statement of intent and
|
|
151
|
+
// beats every layout heuristic below.
|
|
152
|
+
{ id: "npm", hit: (cwd) => hasFile(cwd, "package.json"), cmd: () => "npm test --silent" },
|
|
153
|
+
{
|
|
154
|
+
id: "pytest-tests",
|
|
155
|
+
hit: (cwd) => isDir(path.join(cwd, "tests")) && looksPython(cwd),
|
|
156
|
+
cmd: () => `${shellQuote(pythonExe())} -m pytest tests -q`,
|
|
157
|
+
},
|
|
158
|
+
{ id: "go", hit: (cwd) => hasFile(cwd, "go.mod"), cmd: () => "go test ./..." },
|
|
159
|
+
{ id: "cargo", hit: (cwd) => hasFile(cwd, "Cargo.toml"), cmd: () => "cargo test --quiet" },
|
|
160
|
+
{ id: "maven", hit: (cwd) => hasFile(cwd, "pom.xml"), cmd: () => "mvn -q test" },
|
|
161
|
+
{
|
|
162
|
+
id: "gradle",
|
|
163
|
+
hit: (cwd) => hasFile(cwd, "build.gradle") || hasFile(cwd, "build.gradle.kts"),
|
|
164
|
+
cmd: (cwd) => (hasFile(cwd, "gradlew") || hasFile(cwd, "gradlew.bat") ? "gradlew test -q" : "gradle test -q"),
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "python",
|
|
168
|
+
hit: (cwd) => hasFile(cwd, "pyproject.toml") || hasFile(cwd, "setup.py") || hasFile(cwd, "tox.ini"),
|
|
169
|
+
cmd: () => `${shellQuote(pythonExe())} -m pytest -q`,
|
|
170
|
+
},
|
|
171
|
+
{ id: "ruby", hit: (cwd) => hasFile(cwd, "Gemfile") && hasFile(cwd, "Rakefile"), cmd: () => "bundle exec rake test" },
|
|
172
|
+
{ id: "elixir", hit: (cwd) => hasFile(cwd, "mix.exs"), cmd: () => "mix test" },
|
|
173
|
+
{ id: "php", hit: (cwd) => hasFile(cwd, "composer.json"), cmd: () => "composer test" },
|
|
174
|
+
{
|
|
175
|
+
id: "dotnet",
|
|
176
|
+
hit: (cwd) => names(cwd).some((n) => /\.(csproj|sln|fsproj)$/i.test(n)),
|
|
177
|
+
cmd: () => "dotnet test --nologo",
|
|
178
|
+
},
|
|
179
|
+
{ id: "make", hit: (cwd) => makefileHasTest(cwd), cmd: () => "make test" },
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
function detectCommands(cwd) {
|
|
183
|
+
for (const d of DETECTORS) {
|
|
184
|
+
let hit = false;
|
|
185
|
+
try {
|
|
186
|
+
hit = d.hit(cwd);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
hit = false;
|
|
189
|
+
}
|
|
190
|
+
if (hit) return { id: d.id, cmds: [d.cmd(cwd)] };
|
|
191
|
+
}
|
|
192
|
+
return { id: null, cmds: [] };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function defaultCommands(cwd) {
|
|
196
|
+
return detectCommands(cwd).cmds;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --- cache + docs fast path -----------------------------------------------
|
|
200
|
+
// A hook that re-runs the whole suite on every commit gets bypassed; the cache
|
|
201
|
+
// makes the second commit on an unchanged tree free, and a docs-only change needs
|
|
202
|
+
// no suite at all. Both paths are observable in .signoff/p1.json.
|
|
203
|
+
function localConfig(cwd) {
|
|
204
|
+
return require("./core").readJSON(path.join(cwd, ".signoff", "config.json"), {}) || {};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function gitTree(cwd) {
|
|
208
|
+
const r = spawnSync("git", ["write-tree"], { cwd, encoding: "utf8", windowsHide: true });
|
|
209
|
+
const tree = r.status === 0 ? String(r.stdout || "").trim() : "";
|
|
210
|
+
return /^[0-9a-f]{40,64}$/.test(tree) ? tree : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function stagedList(cwd) {
|
|
214
|
+
const r = spawnSync("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR"], { cwd, encoding: "utf8", windowsHide: true });
|
|
215
|
+
if (r.status !== 0) return [];
|
|
216
|
+
return String(r.stdout || "").split(/\r?\n/).map((ln) => ln.trim()).filter(Boolean);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function version() {
|
|
220
|
+
try {
|
|
221
|
+
return require("../package.json").version;
|
|
222
|
+
} catch (e) {
|
|
223
|
+
return "0";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// json.dumps(obj, ensure_ascii=False) — default separators, i.e. ", " and ": ".
|
|
228
|
+
// Only used for the flat one-line verdict, so primitives are all we need.
|
|
229
|
+
function pyJsonInline(obj) {
|
|
230
|
+
return (
|
|
231
|
+
"{" +
|
|
232
|
+
Object.entries(obj)
|
|
233
|
+
.map(([k, v]) => JSON.stringify(k) + ": " + JSON.stringify(v))
|
|
234
|
+
.join(", ") +
|
|
235
|
+
"}"
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- flags -----------------------------------------------------------------
|
|
240
|
+
// The five flags of the Python gate, plus prefix matching so `--bud 5` works
|
|
241
|
+
// like argparse does.
|
|
242
|
+
const OPTIONS = [
|
|
243
|
+
{ long: "run", key: "run", arg: "RUN", takesValue: true, multi: true },
|
|
244
|
+
{ long: "budget", key: "budget", arg: "BUDGET", takesValue: true },
|
|
245
|
+
{ long: "install-hook", key: "installHook", arg: null },
|
|
246
|
+
{ long: "json", key: "json", arg: null },
|
|
247
|
+
{ long: "quiet", key: "quiet", arg: null },
|
|
248
|
+
{ long: "help", key: "help", arg: null },
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
const NEGATIVE_NUMBER = /^-\d+$|^-\d*\.\d+$/;
|
|
252
|
+
const FLOAT = /^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/;
|
|
253
|
+
|
|
254
|
+
function resolveOption(tok) {
|
|
255
|
+
if (tok === "-h") return OPTIONS.find((o) => o.key === "help");
|
|
256
|
+
if (!tok.startsWith("--")) return null;
|
|
257
|
+
const name = tok.slice(2);
|
|
258
|
+
const exact = OPTIONS.find((o) => o.long === name);
|
|
259
|
+
if (exact) return exact;
|
|
260
|
+
const cands = OPTIONS.filter((o) => o.long.startsWith(name));
|
|
261
|
+
return cands.length === 1 ? cands[0] : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function isOptionToken(tok) {
|
|
265
|
+
if (tok === "-") return false; // argparse treats a lone "-" as a value
|
|
266
|
+
if (!tok.startsWith("-")) return false;
|
|
267
|
+
return !NEGATIVE_NUMBER.test(tok);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function parse(argv) {
|
|
271
|
+
const a = { run: [], budget: 180.0, installHook: false, json: false, quiet: false, help: false };
|
|
272
|
+
const extra = [];
|
|
273
|
+
for (let i = 0; i < argv.length; i++) {
|
|
274
|
+
const tok = String(argv[i]);
|
|
275
|
+
if (tok === "--") {
|
|
276
|
+
for (i++; i < argv.length; i++) extra.push(String(argv[i]));
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
if (!tok.startsWith("-") || tok === "-") {
|
|
280
|
+
extra.push(tok);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
let name = tok;
|
|
284
|
+
let inline = null;
|
|
285
|
+
const eq = tok.indexOf("=");
|
|
286
|
+
if (eq !== -1) {
|
|
287
|
+
name = tok.slice(0, eq);
|
|
288
|
+
inline = tok.slice(eq + 1);
|
|
289
|
+
}
|
|
290
|
+
const opt = resolveOption(name);
|
|
291
|
+
if (!opt) return { error: `unrecognized arguments: ${tok}` };
|
|
292
|
+
if (!opt.takesValue) {
|
|
293
|
+
if (inline !== null) return { error: `argument ${name}: ignored explicit argument '${inline}'` };
|
|
294
|
+
a[opt.key] = true;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
let val;
|
|
298
|
+
if (inline !== null) val = inline;
|
|
299
|
+
else if (i + 1 < argv.length && !isOptionToken(String(argv[i + 1]))) val = String(argv[++i]);
|
|
300
|
+
else return { error: `argument ${name}: expected one argument` };
|
|
301
|
+
if (opt.multi) a[opt.key].push(val);
|
|
302
|
+
else a[opt.key] = val;
|
|
303
|
+
}
|
|
304
|
+
if (extra.length) return { error: `unrecognized arguments: ${extra.join(" ")}` };
|
|
305
|
+
|
|
306
|
+
const raw = String(a.budget);
|
|
307
|
+
if (!FLOAT.test(raw)) return { error: `argument --budget: invalid float value: '${raw}'` };
|
|
308
|
+
a.budget = Number(raw);
|
|
309
|
+
// `nan`/`inf` parse in Python but then blow up in int(budget * 1000); calling
|
|
310
|
+
// that a usage error is the only portable answer.
|
|
311
|
+
if (!Number.isFinite(a.budget)) return { error: `argument --budget: invalid float value: '${raw}'` };
|
|
312
|
+
return a;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- --install-hook --------------------------------------------------------
|
|
316
|
+
function installHook(cwd) {
|
|
317
|
+
const hooks = path.join(cwd, ".git", "hooks");
|
|
318
|
+
if (!isDir(hooks)) {
|
|
319
|
+
console.log("[p1] not a git repository (no .git/hooks)");
|
|
320
|
+
return 2;
|
|
321
|
+
}
|
|
322
|
+
const hook = path.join(hooks, "pre-commit");
|
|
323
|
+
fs.writeFileSync(hook, "#!/bin/sh\n# installed by SignOff / dsh-signoff\nexec signoff p1\n", "utf8");
|
|
324
|
+
try {
|
|
325
|
+
fs.chmodSync(hook, 0o755);
|
|
326
|
+
} catch (e) {
|
|
327
|
+
/* chmod is a no-op on Windows, exactly like the Python try/except */
|
|
328
|
+
}
|
|
329
|
+
console.log(`[p1] hook installed: ${hook}`);
|
|
330
|
+
return 0;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// --- main ------------------------------------------------------------------
|
|
334
|
+
function run(argv) {
|
|
335
|
+
const args = Array.isArray(argv) ? argv : [];
|
|
336
|
+
const a = parse(args);
|
|
337
|
+
|
|
338
|
+
if (a.error) {
|
|
339
|
+
process.stderr.write(USAGE + "\n");
|
|
340
|
+
process.stderr.write(`signoff p1: error: ${a.error}\n`);
|
|
341
|
+
return 2;
|
|
342
|
+
}
|
|
343
|
+
if (a.help) {
|
|
344
|
+
process.stdout.write(HELP);
|
|
345
|
+
return 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const cwd = process.cwd();
|
|
349
|
+
if (a.installHook) return installHook(cwd);
|
|
350
|
+
|
|
351
|
+
const cmds = a.run.length ? a.run : defaultCommands(cwd);
|
|
352
|
+
if (!cmds.length) {
|
|
353
|
+
console.log("[p1] nothing to run: no known test runner in this directory (npm, pytest, go, cargo, maven, gradle, dotnet, ruby, elixir, php, make)");
|
|
354
|
+
return 2;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const cfg = localConfig(cwd);
|
|
358
|
+
const budgetMsCfg = Math.trunc(a.budget * 1000);
|
|
359
|
+
const tree = gitTree(cwd);
|
|
360
|
+
|
|
361
|
+
// Docs-only fast path: markdown cannot change a test outcome, and paying the
|
|
362
|
+
// suite for a typo fix is how hooks get disabled.
|
|
363
|
+
if (!a.run.length && cfg.docsFast !== false && process.env.SIGNOFF_NO_FAST !== "1" && tree) {
|
|
364
|
+
const staged = stagedList(cwd);
|
|
365
|
+
if (staged.length && staged.every((f) => /\.(md|mdx|txt|rst|adoc)$/i.test(f))) {
|
|
366
|
+
const skipped = {
|
|
367
|
+
gate: "p1",
|
|
368
|
+
ok: true,
|
|
369
|
+
elapsedMs: 0,
|
|
370
|
+
budgetMs: budgetMsCfg,
|
|
371
|
+
cases: 0,
|
|
372
|
+
results: [],
|
|
373
|
+
skipped: "docs-only",
|
|
374
|
+
};
|
|
375
|
+
writeJSON(statePath(cwd, "p1.json"), skipped);
|
|
376
|
+
if (a.json) console.log(pyJsonInline({ gate: "p1", ok: true, elapsedMs: 0, budgetMs: budgetMsCfg, cases: 0 }));
|
|
377
|
+
else console.log(`[p1] PASS 0 ms (docs only: ${staged.length} doc file(s), no code changed)`);
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Result cache: same staged tree + same commands + same version => same verdict.
|
|
383
|
+
const cacheOn = cfg.cache !== false && process.env.SIGNOFF_NO_CACHE !== "1";
|
|
384
|
+
let cacheFile = null;
|
|
385
|
+
if (cacheOn && tree) {
|
|
386
|
+
const key = require("./core").sha256([tree, version(), cmds.join("\n")].join("\n")).slice(0, 32);
|
|
387
|
+
cacheFile = path.join(cwd, ".signoff", "cache", `p1-${key}.json`);
|
|
388
|
+
const hit = require("./core").readJSON(cacheFile, null);
|
|
389
|
+
if (hit && hit.ok) {
|
|
390
|
+
const payload = {
|
|
391
|
+
gate: "p1",
|
|
392
|
+
ok: true,
|
|
393
|
+
elapsedMs: 0,
|
|
394
|
+
budgetMs: budgetMsCfg,
|
|
395
|
+
cases: hit.cases || 0,
|
|
396
|
+
results: hit.results || [],
|
|
397
|
+
cached: true,
|
|
398
|
+
cachedAt: hit.ts,
|
|
399
|
+
};
|
|
400
|
+
writeJSON(statePath(cwd, "p1.json"), payload);
|
|
401
|
+
if (a.json) console.log(pyJsonInline({ gate: "p1", ok: true, elapsedMs: 0, budgetMs: budgetMsCfg, cases: payload.cases }));
|
|
402
|
+
else console.log(`[p1] PASS 0 ms (cached: same tree as ${hit.ts}, cases=${payload.cases})`);
|
|
403
|
+
return 0;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const started = nowMs();
|
|
408
|
+
const results = [];
|
|
409
|
+
for (const cmd of cmds) {
|
|
410
|
+
const t0 = nowMs();
|
|
411
|
+
const r = spawnSync(cmd, {
|
|
412
|
+
shell: true,
|
|
413
|
+
cwd,
|
|
414
|
+
encoding: "buffer",
|
|
415
|
+
// Python buffers everything; 64 MiB is the honest middle ground.
|
|
416
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
417
|
+
});
|
|
418
|
+
const ms = Math.round(nowMs() - t0);
|
|
419
|
+
// Python: text=True, errors="replace" — undecodable bytes become U+FFFD.
|
|
420
|
+
const out =
|
|
421
|
+
(r.stdout ? r.stdout.toString("utf8") : "") + (r.stderr ? r.stderr.toString("utf8") : "");
|
|
422
|
+
let rc;
|
|
423
|
+
if (r.status === null) {
|
|
424
|
+
// Killed by a signal: Python reports -signum.
|
|
425
|
+
rc = -(os.constants.signals[r.signal] || 1);
|
|
426
|
+
} else {
|
|
427
|
+
rc = r.status;
|
|
428
|
+
}
|
|
429
|
+
results.push({ cmd, rc, ms, cases: countCases(out), tail: splitLines(pyStrip(out)).slice(-3) });
|
|
430
|
+
if (!a.quiet) console.log(`[p1] rc=${rc} ${ms} ms ${cmd}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const elapsed = Math.round(nowMs() - started);
|
|
434
|
+
const budgetMs = Math.trunc(a.budget * 1000);
|
|
435
|
+
const ok = results.every((r) => r.rc === 0) && elapsed <= a.budget * 1000;
|
|
436
|
+
const totalCases = results.reduce((n, r) => n + (r.cases || 0), 0);
|
|
437
|
+
const payload = {
|
|
438
|
+
gate: "p1",
|
|
439
|
+
ok,
|
|
440
|
+
elapsedMs: elapsed,
|
|
441
|
+
budgetMs,
|
|
442
|
+
cases: totalCases,
|
|
443
|
+
results,
|
|
444
|
+
};
|
|
445
|
+
writeJSON(statePath(cwd, "p1.json"), payload);
|
|
446
|
+
|
|
447
|
+
if (ok && cacheFile) {
|
|
448
|
+
writeJSON(cacheFile, {
|
|
449
|
+
ok: true,
|
|
450
|
+
cases: totalCases,
|
|
451
|
+
results,
|
|
452
|
+
ts: require("./core").nowISO(),
|
|
453
|
+
cmds,
|
|
454
|
+
tree,
|
|
455
|
+
version: version(),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (a.json) {
|
|
460
|
+
console.log(
|
|
461
|
+
pyJsonInline({ gate: payload.gate, ok, elapsedMs: elapsed, budgetMs, cases: totalCases })
|
|
462
|
+
);
|
|
463
|
+
} else {
|
|
464
|
+
console.log(`[p1] ${ok ? "PASS" : "FAIL"} ${elapsed} ms (budget ${budgetMs} ms), cases=${totalCases}`);
|
|
465
|
+
}
|
|
466
|
+
return ok ? 0 : 1;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
module.exports = { run, defaultCommands, detectCommands };
|