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/guard.js
ADDED
|
@@ -0,0 +1,1229 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// SignOff auto-guard: install once, then the gates run themselves.
|
|
3
|
+
//
|
|
4
|
+
// `guard init` wires the five gates into git hooks and CI so that a normal
|
|
5
|
+
// `git commit` / `git push` / pull request cannot get past a red gate without
|
|
6
|
+
// leaving a trace. `guard hook <stage>` is what the hooks call.
|
|
7
|
+
//
|
|
8
|
+
// Port of gates/guard.py with one intended change: a gate is spawned as
|
|
9
|
+
// `process.execPath bin/signoff.js <gate> ...` (the pure-Node dispatcher)
|
|
10
|
+
// instead of `python <gate>.py`. Nothing here requires Python: the pytest
|
|
11
|
+
// fallback is only considered when a python interpreter happens to exist.
|
|
12
|
+
//
|
|
13
|
+
// Design rules (kept from the Python reference):
|
|
14
|
+
// * stdlib only, no network;
|
|
15
|
+
// * quiet on success (one line), full gate output only when something fails;
|
|
16
|
+
// * a pass is cached per staged tree, so re-running the same commit is ~0 ms;
|
|
17
|
+
// * fail closed: a missing CLI or a red gate blocks, and the only way past is
|
|
18
|
+
// an explicit, recorded skip (SIGNOFF_SKIP=1) or `git commit --no-verify`.
|
|
19
|
+
|
|
20
|
+
const fs = require("fs");
|
|
21
|
+
const os = require("os");
|
|
22
|
+
const path = require("path");
|
|
23
|
+
const { spawnSync } = require("child_process");
|
|
24
|
+
const core = require("./core");
|
|
25
|
+
const detector = require("./detect");
|
|
26
|
+
|
|
27
|
+
const ROOT = path.join(__dirname, "..");
|
|
28
|
+
const DISPATCHER = path.join(ROOT, "bin", "signoff.js");
|
|
29
|
+
const HOOK_MARK = "# generated by signoff-cli";
|
|
30
|
+
const LEGACY_HOOK_MARK = "# generated by dsh-signoff";
|
|
31
|
+
function markedByUs(text) {
|
|
32
|
+
const t = String(text || "");
|
|
33
|
+
return t.includes(HOOK_MARK) || t.includes(LEGACY_HOOK_MARK);
|
|
34
|
+
}
|
|
35
|
+
const STATE = ".signoff";
|
|
36
|
+
const DEFAULT_CONFIG = {
|
|
37
|
+
checkpoint: false,
|
|
38
|
+
version: 2,
|
|
39
|
+
docs: ["README.md", "docs"],
|
|
40
|
+
test_cmd: null,
|
|
41
|
+
budget: 180,
|
|
42
|
+
tasks: "tasks",
|
|
43
|
+
pre_push: true,
|
|
44
|
+
ci: true,
|
|
45
|
+
// Explicit defaults: a reader should not have to infer these from a `!== false`.
|
|
46
|
+
secrets: true,
|
|
47
|
+
cache: true,
|
|
48
|
+
docsFast: true,
|
|
49
|
+
coverage: null,
|
|
50
|
+
sign: false,
|
|
51
|
+
};
|
|
52
|
+
// pre-commit: run the gates and record what they saw
|
|
53
|
+
// commit-msg: turn that record into a trailer on the message (never fails a commit)
|
|
54
|
+
// pre-push: the full set, since a push is the last cheap moment to stop
|
|
55
|
+
const STAGES = ["pre-commit", "commit-msg", "pre-push"];
|
|
56
|
+
const PREPARE_SCRIPT = "signoff init --quiet";
|
|
57
|
+
|
|
58
|
+
class GuardExit extends Error {
|
|
59
|
+
constructor(message, code) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.code = code === undefined ? 1 : code;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A bad flag value, raised before anything is written (argparse does the same).
|
|
66
|
+
class ArgparseError extends Error {}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------- helpers
|
|
69
|
+
|
|
70
|
+
function now() {
|
|
71
|
+
return core.nowISO();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sh(args, cwd, timeout) {
|
|
75
|
+
const r = spawnSync(args[0], args.slice(1), {
|
|
76
|
+
cwd: cwd === undefined || cwd === null ? process.cwd() : String(cwd),
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
timeout: timeout === undefined || timeout === null ? undefined : timeout,
|
|
79
|
+
windowsHide: true,
|
|
80
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
code: r.status === null ? 127 : r.status,
|
|
84
|
+
out: r.stdout || "",
|
|
85
|
+
err: r.stderr || "",
|
|
86
|
+
error: r.error,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readText(p) {
|
|
91
|
+
return fs.readFileSync(p, "utf8");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isFile(p) {
|
|
95
|
+
try {
|
|
96
|
+
return fs.statSync(p).isFile();
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isDir(p) {
|
|
103
|
+
try {
|
|
104
|
+
return fs.statSync(p).isDirectory();
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function exists(p) {
|
|
111
|
+
try {
|
|
112
|
+
fs.statSync(p);
|
|
113
|
+
return true;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function repoRoot(cwd) {
|
|
120
|
+
const r = sh(["git", "rev-parse", "--show-toplevel"], cwd);
|
|
121
|
+
if (r.code !== 0) throw new GuardExit("signoff: not a git repository (run inside a repo)", 1);
|
|
122
|
+
// git prints forward slashes; the Python reference prints the platform form.
|
|
123
|
+
return path.resolve(r.out.trim());
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stateDir(root) {
|
|
127
|
+
const d = path.join(root, STATE);
|
|
128
|
+
fs.mkdirSync(d, { recursive: true });
|
|
129
|
+
return d;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function loadConfig(root) {
|
|
133
|
+
const p = path.join(root, STATE, "config.json");
|
|
134
|
+
const cfg = Object.assign({}, DEFAULT_CONFIG);
|
|
135
|
+
if (exists(p)) {
|
|
136
|
+
try {
|
|
137
|
+
Object.assign(cfg, JSON.parse(fs.readFileSync(p, "utf8")));
|
|
138
|
+
} catch (e) {
|
|
139
|
+
/* keep the defaults */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return cfg;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function saveConfig(root, cfg) {
|
|
146
|
+
fs.mkdirSync(path.join(root, STATE), { recursive: true });
|
|
147
|
+
fs.writeFileSync(path.join(root, STATE, "config.json"), JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function packageVersion() {
|
|
151
|
+
try {
|
|
152
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
153
|
+
return String(pkg.version === undefined ? "0" : pkg.version);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
return "0";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------- install
|
|
160
|
+
|
|
161
|
+
function hookBody(stage, chain) {
|
|
162
|
+
// commit-msg receives the message file as $1; it must never block a commit, so
|
|
163
|
+
// a missing CLI warns and gets out of the way instead of exiting 1.
|
|
164
|
+
const extra = stage === "commit-msg" ? ' "$1"' : "";
|
|
165
|
+
// A chained install keeps the hook that was already there as `<stage>.local` and runs
|
|
166
|
+
// it first: its verdict is the repo owner's, so a failure stops the commit before any
|
|
167
|
+
// signoff gate spends time on it.
|
|
168
|
+
// A chained hook can be a shell script, a python file, a node script or a PowerShell
|
|
169
|
+
// script. The suffix picks the launcher: on Windows a git hook has no exec bit, so
|
|
170
|
+
// `sh file.py` would fail even though `python file.py` works. `.local` stays first so
|
|
171
|
+
// repos installed before 3.9.0 keep working.
|
|
172
|
+
const chained = chain
|
|
173
|
+
? `CHAIN_DIR="$(git rev-parse --git-dir)/hooks"
|
|
174
|
+
for LOCAL in "$CHAIN_DIR/${stage}.local" "$CHAIN_DIR/${stage}.local.sh" "$CHAIN_DIR/${stage}.local.py" "$CHAIN_DIR/${stage}.local.js" "$CHAIN_DIR/${stage}.local.ps1" "$CHAIN_DIR/${stage}.local.cmd"; do
|
|
175
|
+
[ -f "$LOCAL" ] || continue
|
|
176
|
+
if [ -x "$LOCAL" ]; then
|
|
177
|
+
"$LOCAL" "$@" || exit $?
|
|
178
|
+
else
|
|
179
|
+
case "$LOCAL" in
|
|
180
|
+
*.py) PY=$(command -v python3 || command -v python || command -v py) || { echo "[signoff] chained hook needs python: $LOCAL" >&2; exit 1; }; "$PY" "$LOCAL" "$@" || exit $? ;;
|
|
181
|
+
*.js) node "$LOCAL" "$@" || exit $? ;;
|
|
182
|
+
*.ps1) powershell -NoProfile -ExecutionPolicy Bypass -File "$LOCAL" "$@" || exit $? ;;
|
|
183
|
+
*.cmd) cmd //c "$LOCAL" "$@" || exit $? ;;
|
|
184
|
+
*) sh "$LOCAL" "$@" || exit $? ;;
|
|
185
|
+
esac
|
|
186
|
+
fi
|
|
187
|
+
break
|
|
188
|
+
done
|
|
189
|
+
`
|
|
190
|
+
: "";
|
|
191
|
+
const tail =
|
|
192
|
+
stage === "commit-msg"
|
|
193
|
+
? `echo "[signoff] CLI not found: the commit continues, but it gets no Signoff trailer" >&2
|
|
194
|
+
exit 0
|
|
195
|
+
`
|
|
196
|
+
: `echo "[signoff] CLI not found: npm i -g signoff-cli (or add it as a devDependency)" >&2
|
|
197
|
+
echo "[signoff] this repo is guarded; install the CLI, or bypass this once with --no-verify" >&2
|
|
198
|
+
exit 1
|
|
199
|
+
`;
|
|
200
|
+
return `#!/bin/sh
|
|
201
|
+
${HOOK_MARK} ${packageVersion()} -- remove with: signoff uninstall
|
|
202
|
+
ROOT=$(git rev-parse --show-toplevel) || exit 1
|
|
203
|
+
${chained}if command -v signoff >/dev/null 2>&1; then
|
|
204
|
+
exec signoff hook ${stage}${extra}
|
|
205
|
+
fi
|
|
206
|
+
if [ -f "$ROOT/node_modules/signoff-cli/bin/signoff.js" ]; then
|
|
207
|
+
exec node "$ROOT/node_modules/signoff-cli/bin/signoff.js" hook ${stage}${extra}
|
|
208
|
+
fi
|
|
209
|
+
if [ -f "$ROOT/node_modules/dsh-signoff/bin/signoff.js" ]; then
|
|
210
|
+
exec node "$ROOT/node_modules/dsh-signoff/bin/signoff.js" hook ${stage}${extra}
|
|
211
|
+
fi
|
|
212
|
+
${tail}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const CI_BODY = `# generated by signoff-cli -- remove with: signoff uninstall
|
|
216
|
+
name: signoff
|
|
217
|
+
on: [push, pull_request]
|
|
218
|
+
jobs:
|
|
219
|
+
gates:
|
|
220
|
+
runs-on: ubuntu-latest
|
|
221
|
+
steps:
|
|
222
|
+
- uses: actions/checkout@v4
|
|
223
|
+
with: { fetch-depth: 0 }
|
|
224
|
+
- uses: actions/setup-node@v4
|
|
225
|
+
with: { node-version: "20" }
|
|
226
|
+
- uses: actions/setup-python@v5
|
|
227
|
+
with: { python-version: "3.11" }
|
|
228
|
+
- run: npx --yes --package=signoff-cli@latest signoff guard --ci
|
|
229
|
+
- name: audit trailers
|
|
230
|
+
env:
|
|
231
|
+
GITHUB_EVENT_BEFORE: \${{ github.event.before }}
|
|
232
|
+
run: |
|
|
233
|
+
# A shallow clone has no history, and \`git rev-parse HEAD~1\` echoes the missing
|
|
234
|
+
# name instead of failing, so ask for a verified commit hash or nothing at all.
|
|
235
|
+
BASE="\${GITHUB_EVENT_BEFORE}"
|
|
236
|
+
if [ -z "$BASE" ] || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
|
|
237
|
+
git fetch --unshallow --quiet 2>/dev/null || git fetch --deepen=50 --quiet 2>/dev/null || true
|
|
238
|
+
BASE=$(git rev-parse --verify --quiet "HEAD~1^{commit}" || true)
|
|
239
|
+
fi
|
|
240
|
+
if [ -n "$BASE" ]; then
|
|
241
|
+
npx --yes --package=signoff-cli@latest signoff audit --range "$BASE..HEAD"
|
|
242
|
+
else
|
|
243
|
+
echo "no base commit in this clone; skipping the trailer audit"
|
|
244
|
+
fi
|
|
245
|
+
`;
|
|
246
|
+
|
|
247
|
+
function writeExec(p, text) {
|
|
248
|
+
fs.writeFileSync(p, text, "utf8");
|
|
249
|
+
try {
|
|
250
|
+
fs.chmodSync(p, 0o755);
|
|
251
|
+
} catch (e) {
|
|
252
|
+
/* windows has no execute bit */
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------- marker
|
|
257
|
+
//
|
|
258
|
+
// `.signoff/passed` is the hand-off from pre-commit to commit-msg: the tree the
|
|
259
|
+
// gate run saw, and whether it passed or was explicitly skipped. It is deleted
|
|
260
|
+
// by commit-msg as soon as it is used, so a marker never outlives its commit.
|
|
261
|
+
|
|
262
|
+
// Consume the hand-off marker. It is single-use: a stale marker (tree no longer
|
|
263
|
+
// matches, or a commit that never happened) must never survive to a later commit,
|
|
264
|
+
// or the same trailer could be appended twice.
|
|
265
|
+
function dropMarker(root) {
|
|
266
|
+
try {
|
|
267
|
+
fs.rmSync(markerPath(root), { force: true });
|
|
268
|
+
return true;
|
|
269
|
+
} catch (e) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function markerPath(root) {
|
|
275
|
+
return path.join(root, STATE, "passed");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function currentTree(root) {
|
|
279
|
+
const r = sh(["git", "write-tree"], root);
|
|
280
|
+
const tree = r.code === 0 ? r.out.trim() : "";
|
|
281
|
+
return /^[0-9a-f]{40,64}$/.test(tree) ? tree : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function writeMarker(root, mode) {
|
|
285
|
+
const tree = currentTree(root);
|
|
286
|
+
if (!tree) return null;
|
|
287
|
+
fs.mkdirSync(path.join(root, STATE), { recursive: true });
|
|
288
|
+
dropMarker(root);
|
|
289
|
+
fs.writeFileSync(markerPath(root), JSON.stringify({ tree, mode, ts: now() }) + "\n", "utf8");
|
|
290
|
+
if (mode === "pass") maybeCheckpoint(root);
|
|
291
|
+
return tree;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// A repo that has ever written a checkpoint keeps writing them: the ledger is then
|
|
295
|
+
// tamper-evident from the outside, not only from inside the file.
|
|
296
|
+
function maybeCheckpoint(root) {
|
|
297
|
+
try {
|
|
298
|
+
const cfg = loadConfig(root);
|
|
299
|
+
const cp = require("./checkpoint");
|
|
300
|
+
if (cfg.checkpoint !== true && !exists(cp.checkpointPath())) return { ok: true, skipped: "no checkpoint in this repo" };
|
|
301
|
+
const now = cp.summarize();
|
|
302
|
+
const prev = cp.readCheckpoint();
|
|
303
|
+
// Same ledger, same statement: re-signing would only churn the file.
|
|
304
|
+
if (prev && Number(prev.seq) === now.seq && prev.head === now.head) {
|
|
305
|
+
return { ok: true, seq: now.seq, unchanged: true };
|
|
306
|
+
}
|
|
307
|
+
const made = cp.create({ sign: true });
|
|
308
|
+
return { ok: true, seq: made.checkpoint.seq, anchor: made.checkpoint.anchor };
|
|
309
|
+
} catch (e) {
|
|
310
|
+
// A hook must never fail a commit over bookkeeping; callers that care can see why.
|
|
311
|
+
return { ok: false, error: String((e && e.message) || e) };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// commit-msg <msgfile>: append the trailer only when the message belongs to the
|
|
316
|
+
// tree the gates saw. Anything else (no marker, stale marker, unreadable file)
|
|
317
|
+
// leaves the message alone and exits 0 — this hook never fails a commit.
|
|
318
|
+
function cmdCommitMsg(msgfile) {
|
|
319
|
+
try {
|
|
320
|
+
if (typeof msgfile !== "string" || !msgfile) return 0;
|
|
321
|
+
const root = repoRoot();
|
|
322
|
+
const p = path.isAbsolute(msgfile) ? msgfile : path.join(root, msgfile);
|
|
323
|
+
if (!exists(p) || !exists(markerPath(root))) return 0;
|
|
324
|
+
let marker;
|
|
325
|
+
try {
|
|
326
|
+
marker = JSON.parse(readText(markerPath(root)));
|
|
327
|
+
} catch (e) {
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
|
330
|
+
const tree = currentTree(root);
|
|
331
|
+
if (!tree || !marker) return 0;
|
|
332
|
+
if (marker.tree !== tree) {
|
|
333
|
+
dropMarker(root); // stale: the staged tree changed after the gate run
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
const skip = marker.mode === "skip";
|
|
337
|
+
const line = `Signoff-${skip ? "Skip" : "Pass"}: ${tree.slice(0, 12)}`;
|
|
338
|
+
// Sign the full tree (not the 12-char prefix) so an auditor can verify against
|
|
339
|
+
// the commit's own tree without trusting anything local. Signing never blocks a
|
|
340
|
+
// commit: a missing key just leaves the trailer unsigned.
|
|
341
|
+
let extra = "";
|
|
342
|
+
if (!skip && process.env.SIGNOFF_NO_SIGN !== "1") {
|
|
343
|
+
try {
|
|
344
|
+
const signed = require("./sign").sign(tree);
|
|
345
|
+
if (signed) extra = `\nSignoff-Key: ${signed.key}\nSignoff-Sig: ${signed.sig}`;
|
|
346
|
+
} catch (e) {
|
|
347
|
+
extra = "";
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
let text = readText(p);
|
|
351
|
+
if (text.length && !text.endsWith("\n")) text += "\n";
|
|
352
|
+
fs.writeFileSync(p, text + line + extra + "\n", "utf8");
|
|
353
|
+
dropMarker(root);
|
|
354
|
+
console.log(`signoff: ${line}`);
|
|
355
|
+
return 0;
|
|
356
|
+
} catch (e) {
|
|
357
|
+
// fail open on purpose: a broken trailer is better than a lost commit
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// `init --prepare`: make the guard self-installing for anyone who clones the repo.
|
|
363
|
+
function addPrepareScript(root, say, dryRun) {
|
|
364
|
+
const pkgPath = path.join(root, "package.json");
|
|
365
|
+
if (!isFile(pkgPath)) {
|
|
366
|
+
say(" prepare skipped (no package.json)");
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
let pkg;
|
|
370
|
+
try {
|
|
371
|
+
pkg = JSON.parse(readText(pkgPath));
|
|
372
|
+
} catch (e) {
|
|
373
|
+
say(" prepare skipped (package.json is not valid JSON)");
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (!pkg || typeof pkg !== "object") {
|
|
377
|
+
say(" prepare skipped (package.json is not an object)");
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
// A prepare script that cannot find the CLI breaks every npm install of the repo.
|
|
381
|
+
// Only write it when the package is a devDependency (then node_modules/.bin/signoff
|
|
382
|
+
// exists before lifecycle scripts run) or a local install is already present.
|
|
383
|
+
const deps = Object.assign({}, pkg.devDependencies || {}, pkg.dependencies || {});
|
|
384
|
+
const selfDep =
|
|
385
|
+
Object.prototype.hasOwnProperty.call(deps, "signoff-cli") ||
|
|
386
|
+
Object.prototype.hasOwnProperty.call(deps, "dsh-signoff");
|
|
387
|
+
const binName = process.platform === "win32" ? "signoff.cmd" : "signoff";
|
|
388
|
+
const localBin = isFile(path.join(root, "node_modules", ".bin", binName));
|
|
389
|
+
if (!selfDep && !localBin) {
|
|
390
|
+
say(" prepare skipped: signoff-cli is not a devDependency, so the CLI is absent during install");
|
|
391
|
+
say(" run: npm i -D signoff-cli then signoff init --prepare");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
|
|
395
|
+
const old = typeof scripts.prepare === "string" ? scripts.prepare.trim() : "";
|
|
396
|
+
if (old.includes(PREPARE_SCRIPT)) {
|
|
397
|
+
say(` prepare already set (${old})`);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const next = old ? `${old} && ${PREPARE_SCRIPT}` : PREPARE_SCRIPT;
|
|
401
|
+
if (dryRun) {
|
|
402
|
+
say(`would set scripts.prepare = "${next}" in package.json`);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
pkg.scripts = Object.assign({}, scripts, { prepare: next });
|
|
406
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
407
|
+
say(` prepare package.json scripts.prepare = "${next}"`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// The extension of a chained hook decides how it is launched, so keep the one that
|
|
411
|
+
// says what it is. A shell script (or anything unrecognised) keeps the plain `.local`.
|
|
412
|
+
function chainSuffix(file) {
|
|
413
|
+
const text = readText(file);
|
|
414
|
+
if (/^#!.*\bpython/i.test(text)) return ".py";
|
|
415
|
+
if (/^#!.*\bnode\b/i.test(text)) return ".js";
|
|
416
|
+
if (/^#!.*\bpwsh\b|^#!.*\bpowershell\b/i.test(text)) return ".ps1";
|
|
417
|
+
return "";
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const CHAIN_SUFFIXES = ["", ".sh", ".py", ".js", ".ps1", ".cmd"];
|
|
421
|
+
|
|
422
|
+
function findChainFile(hooks, name) {
|
|
423
|
+
for (const suffix of CHAIN_SUFFIXES) {
|
|
424
|
+
const p = path.join(hooks, `${name}.local${suffix}`);
|
|
425
|
+
if (exists(p)) return p;
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function cmdInit(args) {
|
|
431
|
+
const root = repoRoot();
|
|
432
|
+
const cfg = loadConfig(root);
|
|
433
|
+
let lockedNote = "";
|
|
434
|
+
if (args.testCmd) cfg.test_cmd = args.testCmd;
|
|
435
|
+
// Pinning what detection found keeps the runner from drifting on another machine
|
|
436
|
+
// (a laptop with pytest installed used to silently switch p1 to pytest).
|
|
437
|
+
if (args.lockTest) {
|
|
438
|
+
// `say` is defined further down (it honours --quiet), so report through it lazily.
|
|
439
|
+
const d = detector.detect(root, cfg);
|
|
440
|
+
if (d.cmd) {
|
|
441
|
+
cfg.test_cmd = d.cmd;
|
|
442
|
+
lockedNote = ` locked ${d.cmd} (${d.source})`;
|
|
443
|
+
} else {
|
|
444
|
+
lockedNote = " locked nothing to lock: no test runner was detected";
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (args.budget) cfg.budget = args.budget;
|
|
448
|
+
if (args.docs) cfg.docs = args.docs;
|
|
449
|
+
cfg.pre_push = !args.noPush;
|
|
450
|
+
cfg.ci = !args.noCi;
|
|
451
|
+
cfg.version = 2;
|
|
452
|
+
|
|
453
|
+
const say = args.quiet ? () => {} : (line) => console.log(line);
|
|
454
|
+
|
|
455
|
+
const hooks = path.join(root, ".git", "hooks");
|
|
456
|
+
const targets = [
|
|
457
|
+
["pre-commit", "pre-commit"],
|
|
458
|
+
["commit-msg", "commit-msg"],
|
|
459
|
+
];
|
|
460
|
+
if (cfg.pre_push) targets.push(["pre-push", "pre-push"]);
|
|
461
|
+
|
|
462
|
+
if (args.dryRun) {
|
|
463
|
+
for (const [name] of targets) {
|
|
464
|
+
const target = path.join(hooks, name);
|
|
465
|
+
const local = path.join(hooks, `${name}.local${chainSuffix(target)}`);
|
|
466
|
+
if (exists(target) && !markedByUs(readText(target))) {
|
|
467
|
+
say(
|
|
468
|
+
args.chain
|
|
469
|
+
? `would chain ${path.join(hooks, name)} -> ${path.basename(local)}`
|
|
470
|
+
: `would back up ${path.join(hooks, name)}`
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
say(`would write ${target}`);
|
|
474
|
+
}
|
|
475
|
+
if (cfg.ci) say(`would write ${path.join(root, ".github", "workflows", "signoff.yml")}`);
|
|
476
|
+
say(`would write ${path.join(root, STATE, "config.json")}`);
|
|
477
|
+
if (args.prepare) addPrepareScript(root, say, true);
|
|
478
|
+
return 0;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
fs.mkdirSync(hooks, { recursive: true });
|
|
482
|
+
const notes = [];
|
|
483
|
+
for (const [name, stage] of targets) {
|
|
484
|
+
const target = path.join(hooks, name);
|
|
485
|
+
const existingChain = findChainFile(hooks, name);
|
|
486
|
+
let chain = !!existingChain;
|
|
487
|
+
if (exists(target) && !markedByUs(readText(target))) {
|
|
488
|
+
if (args.chain) {
|
|
489
|
+
if (!existingChain) {
|
|
490
|
+
const local = path.join(hooks, `${name}.local${chainSuffix(target)}`);
|
|
491
|
+
fs.renameSync(target, local);
|
|
492
|
+
notes.push(` chained .git/hooks/${path.basename(local)} runs first`);
|
|
493
|
+
} else {
|
|
494
|
+
notes.push(` chained .git/hooks/${path.basename(existingChain)} runs first`);
|
|
495
|
+
}
|
|
496
|
+
chain = true;
|
|
497
|
+
} else {
|
|
498
|
+
const backup = path.join(hooks, `${name}.signoff-backup`);
|
|
499
|
+
if (!exists(backup)) {
|
|
500
|
+
fs.writeFileSync(backup, readText(target), "utf8");
|
|
501
|
+
notes.push(` saved old hook -> .git/hooks/${path.basename(backup)}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
writeExec(target, hookBody(stage, chain));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (cfg.ci) {
|
|
509
|
+
const ci = path.join(root, ".github", "workflows");
|
|
510
|
+
fs.mkdirSync(ci, { recursive: true });
|
|
511
|
+
writeExec(path.join(ci, "signoff.yml"), CI_BODY);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const gi = path.join(root, ".gitignore");
|
|
515
|
+
const line = `${STATE}/`;
|
|
516
|
+
if (exists(gi)) {
|
|
517
|
+
const txt = readText(gi);
|
|
518
|
+
if (!txt.split(/\r?\n/).includes(line)) {
|
|
519
|
+
fs.writeFileSync(gi, txt.replace(/\n+$/, "") + "\n" + line + "\n", "utf8");
|
|
520
|
+
}
|
|
521
|
+
} else {
|
|
522
|
+
fs.writeFileSync(gi, line + "\n", "utf8");
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (args.preset) {
|
|
526
|
+
const pre = require("./preset").apply(cfg, args.preset, cfg);
|
|
527
|
+
if (!pre.ok) {
|
|
528
|
+
console.error(`signoff init: ${pre.error}`);
|
|
529
|
+
return 2;
|
|
530
|
+
}
|
|
531
|
+
for (const n of pre.notes) say(n);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (args.editor) {
|
|
535
|
+
if (String(args.editor) !== "vscode") {
|
|
536
|
+
console.error(`signoff init: unknown editor '${args.editor}' (supported: vscode)`);
|
|
537
|
+
return 2;
|
|
538
|
+
}
|
|
539
|
+
const ed = require("./editor").vscode(root, { command: "signoff" });
|
|
540
|
+
say(ed.ok ? ` editor .vscode/tasks.json (${ed.added} task(s) added)` : ` editor ${ed.reason}`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
saveConfig(root, cfg);
|
|
544
|
+
|
|
545
|
+
// The install point is what `audit --since-install` measures from, and what lets the
|
|
546
|
+
// plain audit explain a `missing` commit that simply predates the guard. Re-running
|
|
547
|
+
// init keeps the first one: history before the guard is history, not a failure.
|
|
548
|
+
const installPath = path.join(root, STATE, "installed.json");
|
|
549
|
+
if (args.resetInstall) {
|
|
550
|
+
const head = core.git(["rev-parse", "HEAD"], root);
|
|
551
|
+
const record = { sha: head.code === 0 ? head.out.trim() : "", ts: now(), version: packageVersion() };
|
|
552
|
+
fs.writeFileSync(installPath, JSON.stringify(record) + "\n", "utf8");
|
|
553
|
+
notes.push(` install reset to ${record.sha ? record.sha.slice(0, 12) : "(no commits yet)"}`);
|
|
554
|
+
} else if (!exists(installPath)) {
|
|
555
|
+
const head = core.git(["rev-parse", "HEAD"], root);
|
|
556
|
+
const record = { sha: head.code === 0 ? head.out.trim() : "", ts: now(), version: packageVersion() };
|
|
557
|
+
fs.writeFileSync(installPath, JSON.stringify(record) + "\n", "utf8");
|
|
558
|
+
notes.push(` install ${record.sha ? record.sha.slice(0, 12) : "(no commits yet)"}`);
|
|
559
|
+
} else {
|
|
560
|
+
// Re-init rewrites the hooks, so the recorded version has to follow them; the install
|
|
561
|
+
// point itself (sha + ts) stays put - that is the boundary of "before the guard".
|
|
562
|
+
let prev = null;
|
|
563
|
+
try {
|
|
564
|
+
prev = JSON.parse(fs.readFileSync(installPath, "utf8"));
|
|
565
|
+
} catch (e) {
|
|
566
|
+
prev = null;
|
|
567
|
+
}
|
|
568
|
+
if (prev) {
|
|
569
|
+
const next = Object.assign({}, prev);
|
|
570
|
+
let reason = null;
|
|
571
|
+
// A bootstrap can run during the very first commit, where HEAD does not exist yet, so
|
|
572
|
+
// the install point arrives without a sha. Re-init after that commit is the moment to
|
|
573
|
+
// fill it in - otherwise `audit --since-install` stays unusable for the repo's life.
|
|
574
|
+
if (!prev.sha) {
|
|
575
|
+
const head = core.git(["rev-parse", "HEAD"], root);
|
|
576
|
+
const headSha = head.code === 0 ? head.out.trim() : "";
|
|
577
|
+
if (headSha) {
|
|
578
|
+
next.sha = headSha;
|
|
579
|
+
next.ts = now();
|
|
580
|
+
reason = `${headSha.slice(0, 12)} (filled in: the first bootstrap ran before this commit)`;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
// Re-init rewrites the hooks, so the recorded version has to follow them; the install
|
|
584
|
+
// point itself (sha + ts) stays put - that is the boundary of "before the guard".
|
|
585
|
+
if (prev.version !== packageVersion()) {
|
|
586
|
+
next.version = packageVersion();
|
|
587
|
+
if (!reason) reason = `${String(prev.version || "?")} -> ${packageVersion()} (same sha)`;
|
|
588
|
+
}
|
|
589
|
+
if (reason) {
|
|
590
|
+
fs.writeFileSync(installPath, JSON.stringify(next) + "\n", "utf8");
|
|
591
|
+
notes.push(` install ${reason}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (args.sign) {
|
|
597
|
+
// A repo-local key is optional; without one the trailer is simply unsigned.
|
|
598
|
+
const keyPath = require("./sign").generateKey(path.join(root, STATE));
|
|
599
|
+
if (keyPath) {
|
|
600
|
+
cfg.sign_key = path.relative(root, keyPath).split(path.sep).join("/");
|
|
601
|
+
saveConfig(root, cfg);
|
|
602
|
+
notes.push(" sign commits carry Signoff-Sig (local key: .signoff/signing_key)");
|
|
603
|
+
} else {
|
|
604
|
+
notes.push(" sign skipped (ssh-keygen not available)");
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (args.share) {
|
|
609
|
+
// Only the portable fields: a pinned test command would break on a clone without that
|
|
610
|
+
// runner, which is the opposite of "clone and it works".
|
|
611
|
+
const decl = { $schema: "https://raw.githubusercontent.com/ayuanxx-max/dsh-signoff/master/docs/signoff.schema.json" };
|
|
612
|
+
decl.gates = ["p1", "p2", "p3", "p4", "p5"];
|
|
613
|
+
if (args.lockTest && cfg.test_cmd) decl.test_cmd = cfg.test_cmd;
|
|
614
|
+
fs.writeFileSync(path.join(root, "signoff.json"), JSON.stringify(decl, null, 2) + "\n", "utf8");
|
|
615
|
+
notes.push(" share signoff.json (commit it: clones build the five gates themselves)");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (args.prepare) addPrepareScript(root, say, false);
|
|
619
|
+
|
|
620
|
+
say(`signoff ${packageVersion()} installed in ${path.basename(root)}`);
|
|
621
|
+
for (const n of notes) say(n);
|
|
622
|
+
for (const [name] of targets) say(` hook .git/hooks/${name}`);
|
|
623
|
+
if (cfg.ci) say(" ci .github/workflows/signoff.yml");
|
|
624
|
+
say(` config ${STATE}/config.json`);
|
|
625
|
+
if (lockedNote) say(lockedNote);
|
|
626
|
+
say(` install ${STATE}/installed.json`);
|
|
627
|
+
say(` test ${cfg.test_cmd || detector.describe(root, cfg)}`);
|
|
628
|
+
say(" next git commit as usual; a red gate stops the commit");
|
|
629
|
+
return 0;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ---------------------------------------------------------------- run
|
|
633
|
+
|
|
634
|
+
function pythonExe() {
|
|
635
|
+
const override = process.env.SIGNOFF_PYTHON;
|
|
636
|
+
if (override) return override;
|
|
637
|
+
return process.platform === "win32" ? "python" : "python3";
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Python is optional: it is probed only to keep the pytest shortcut working in
|
|
641
|
+
// repos that really use it. A machine without python just detects nothing.
|
|
642
|
+
function pythonInterpreter() {
|
|
643
|
+
const candidates = [pythonExe()];
|
|
644
|
+
if (!process.env.SIGNOFF_PYTHON && process.platform !== "win32") candidates.push("python");
|
|
645
|
+
for (const exe of candidates) {
|
|
646
|
+
const r = sh([exe, "-c", "import sys;print(sys.executable)"]);
|
|
647
|
+
if (r.code === 0 && r.out.trim()) return r.out.trim();
|
|
648
|
+
}
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function findTestFiles(root) {
|
|
653
|
+
const found = [];
|
|
654
|
+
const skip = new Set([".git", "node_modules", ".signoff", "__pycache__", ".venv", "venv"]);
|
|
655
|
+
const walk = (dir, depth) => {
|
|
656
|
+
if (depth > 8 || found.length) return;
|
|
657
|
+
let entries;
|
|
658
|
+
try {
|
|
659
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
660
|
+
} catch (e) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
for (const e of entries) {
|
|
664
|
+
if (found.length) return;
|
|
665
|
+
const p = path.join(dir, e.name);
|
|
666
|
+
if (e.isDirectory()) {
|
|
667
|
+
if (skip.has(e.name)) continue;
|
|
668
|
+
walk(p, depth + 1);
|
|
669
|
+
} else if (/^test_.*\.py$/.test(e.name)) {
|
|
670
|
+
found.push(p);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
walk(root, 0);
|
|
676
|
+
return found;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function detectTest(root, cfg) {
|
|
680
|
+
const d = detector.detect(root, cfg);
|
|
681
|
+
return d.cmd || null;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function stagedFiles(root) {
|
|
685
|
+
const r = sh(["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"], root);
|
|
686
|
+
return r.out
|
|
687
|
+
.split(/\r?\n/)
|
|
688
|
+
.map((ln) => ln.trim())
|
|
689
|
+
.filter((ln) => ln.length > 0);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function treeKey(root) {
|
|
693
|
+
const r = sh(["git", "write-tree"], root);
|
|
694
|
+
const base = r.code === 0 ? r.out.trim() : "";
|
|
695
|
+
const r2 = sh(["git", "diff", "--cached", "--name-only"], root);
|
|
696
|
+
return base + "|" + r2.out.split(/\s+/).filter(Boolean).sort().join(",");
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function stagedDocs(files, cfg) {
|
|
700
|
+
const docs = cfg.docs || [];
|
|
701
|
+
const out = [];
|
|
702
|
+
for (const f of files) {
|
|
703
|
+
if (!/\.(md|mdx|txt)$/.test(f)) continue;
|
|
704
|
+
for (const raw of docs) {
|
|
705
|
+
const d = String(raw).replace(/\/+$/, "");
|
|
706
|
+
if (f === d || f.startsWith(d + "/")) {
|
|
707
|
+
out.push(f);
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return out;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function ledgerPath() {
|
|
716
|
+
const home = process.env.SIGNOFF_HOME;
|
|
717
|
+
return home ? path.join(home, "audit.log") : path.join(os.homedir(), ".signoff", "audit.log");
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function docsFiles(root, cfg) {
|
|
721
|
+
const out = [];
|
|
722
|
+
for (const raw of cfg.docs || []) {
|
|
723
|
+
const base = String(raw).replace(/\/+$/, "");
|
|
724
|
+
const target = path.join(root, base);
|
|
725
|
+
if (isFile(target)) {
|
|
726
|
+
out.push(base);
|
|
727
|
+
} else if (isDir(target)) {
|
|
728
|
+
const md = [];
|
|
729
|
+
const walk = (dir) => {
|
|
730
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
731
|
+
const p = path.join(dir, e.name);
|
|
732
|
+
if (e.isDirectory()) walk(p);
|
|
733
|
+
else if (e.name.endsWith(".md")) md.push(p);
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
walk(target);
|
|
737
|
+
md.sort();
|
|
738
|
+
for (const p of md) out.push(path.relative(root, p).split(path.sep).join("/"));
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return out;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function plan(root, cfg, files, full) {
|
|
745
|
+
const steps = [];
|
|
746
|
+
// Secrets first: a leaked key is not a style issue and must not wait for the suite.
|
|
747
|
+
if (cfg.secrets !== false) steps.push(["secrets", ["--json"]]);
|
|
748
|
+
const docs = stagedDocs(files, cfg);
|
|
749
|
+
if (docs.length) steps.push(["p5", docs]);
|
|
750
|
+
const cmd = detectTest(root, cfg);
|
|
751
|
+
if (cmd) steps.push(["p1", ["--run", cmd, "--budget", String(cfg.budget === undefined ? 180 : cfg.budget)]]);
|
|
752
|
+
// P2 only joins when the repo actually has scenario fixtures; a repo without
|
|
753
|
+
// them is not "low coverage", it is simply not using this gate.
|
|
754
|
+
if (isDir(path.join(root, "verify", "scenarios")) || cfg.apps) steps.push(["p2", ["--strict"]]);
|
|
755
|
+
if (exists(ledgerPath())) steps.push(["p4", ["verify"]]);
|
|
756
|
+
// Line coverage of the change is only meaningful when the project already produces
|
|
757
|
+
// a coverage file; a missing file is reported by the gate itself, not by the guard.
|
|
758
|
+
if (cfg.coverage) {
|
|
759
|
+
const min = typeof cfg.coverage === "object" && cfg.coverage.min !== undefined ? cfg.coverage.min : 80;
|
|
760
|
+
steps.push(["cov", ["--diff", "--min", String(min), "--allow-missing"]]);
|
|
761
|
+
}
|
|
762
|
+
return steps;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// The one intended change from the Python guard: the gate is this CLI itself,
|
|
766
|
+
// run on the current node, so a global `npm i -g signoff-cli` needs no python.
|
|
767
|
+
function runGate(name, gargs, root, timeoutSeconds) {
|
|
768
|
+
const t0 = Date.now();
|
|
769
|
+
const r = sh([process.execPath, DISPATCHER, name, ...gargs], root, timeoutSeconds * 1000);
|
|
770
|
+
const secs = (Date.now() - t0) / 1000;
|
|
771
|
+
if (r.error && (r.error.code === "ETIMEDOUT" || r.error.signal)) {
|
|
772
|
+
return { rc: 1, secs, out: `${name}: timed out after ${Math.round(timeoutSeconds)}s` };
|
|
773
|
+
}
|
|
774
|
+
if (r.error) {
|
|
775
|
+
return { rc: 127, secs, out: `${name}: cannot start ${process.execPath} (${r.error.message})` };
|
|
776
|
+
}
|
|
777
|
+
return { rc: r.code, secs, out: r.out + r.err };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// A PR reviewer should not have to open the log to learn which gate failed.
|
|
781
|
+
function writeSummary(steps, seconds, ok, failed) {
|
|
782
|
+
const ci = require("./ci");
|
|
783
|
+
if (!ci.isCI()) return;
|
|
784
|
+
const bad = new Set((failed || []).map(([n]) => n));
|
|
785
|
+
const rows = steps.map(([name]) => [name, bad.has(name) ? "FAIL" : "ok"]);
|
|
786
|
+
ci.summary(
|
|
787
|
+
`### SignOff ${ok ? "PASS" : "FAIL"} (${Number(seconds).toFixed(2)}s)\n\n` +
|
|
788
|
+
ci.table(rows, ["gate", "result"]) +
|
|
789
|
+
(bad.size ? `\n\nFull output: \`.signoff/hook.log\`` : "")
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function cmdGuard(args) {
|
|
794
|
+
const root = repoRoot();
|
|
795
|
+
const cfg = loadConfig(root);
|
|
796
|
+
const files = stagedFiles(root);
|
|
797
|
+
const full = !!(args.full || args.ci);
|
|
798
|
+
// Only pre-commit writes the hand-off marker: it is what commit-msg turns into a
|
|
799
|
+
// trailer. A pre-push run must not leave one behind for a commit that never came.
|
|
800
|
+
const mark = args.mark !== false;
|
|
801
|
+
|
|
802
|
+
if (!files.length && !full) {
|
|
803
|
+
console.log("signoff guard: nothing staged");
|
|
804
|
+
return 0;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const key = treeKey(root);
|
|
808
|
+
const cache = path.join(root, STATE, "cache.json");
|
|
809
|
+
if (!full && exists(cache)) {
|
|
810
|
+
try {
|
|
811
|
+
const c = JSON.parse(fs.readFileSync(cache, "utf8"));
|
|
812
|
+
if (c.key === key && c.ok) {
|
|
813
|
+
if (mark) writeMarker(root, "pass");
|
|
814
|
+
console.log(`signoff guard: cached pass (${Number(c.seconds || 0).toFixed(2)}s)`);
|
|
815
|
+
return 0;
|
|
816
|
+
}
|
|
817
|
+
} catch (e) {
|
|
818
|
+
/* a corrupt cache is a cache miss */
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (process.env.SIGNOFF_SKIP === "1") {
|
|
823
|
+
const sd = stateDir(root);
|
|
824
|
+
if (mark) writeMarker(root, "skip");
|
|
825
|
+
fs.appendFileSync(path.join(sd, "skips.log"), `${now()} skipped ${files.length} file(s) via SIGNOFF_SKIP=1\n`, "utf8");
|
|
826
|
+
console.log("signoff guard: SKIPPED (SIGNOFF_SKIP=1, recorded in .signoff/skips.log)");
|
|
827
|
+
return 0;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
let steps = plan(root, cfg, files, full);
|
|
831
|
+
if (args.ci) {
|
|
832
|
+
// CI has no index: check every configured doc, not just the staged ones.
|
|
833
|
+
const allDocs = docsFiles(root, cfg);
|
|
834
|
+
if (allDocs.length) steps = [["p5", allDocs]].concat(steps.filter((s) => s[0] !== "p5"));
|
|
835
|
+
}
|
|
836
|
+
if (!steps.length) {
|
|
837
|
+
if (mark) writeMarker(root, "pass");
|
|
838
|
+
console.log("signoff guard: no gate applies to this change");
|
|
839
|
+
return 0;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const budget = Number(cfg.budget === undefined ? 180 : cfg.budget);
|
|
843
|
+
console.log(`signoff guard: ${files.length} file(s) staged, ${steps.length} gate(s)`);
|
|
844
|
+
const t0 = Date.now();
|
|
845
|
+
const failed = [];
|
|
846
|
+
for (const [name, gargs] of steps) {
|
|
847
|
+
const res = runGate(name, gargs, root, Math.max(budget, 60));
|
|
848
|
+
console.log(` ${name.padEnd(4)} ${res.rc === 0 ? "ok " : "FAIL"} ${res.secs.toFixed(2).padStart(6, " ")}s`);
|
|
849
|
+
if (res.rc !== 0) failed.push([name, res.out]);
|
|
850
|
+
}
|
|
851
|
+
const total = (Date.now() - t0) / 1000;
|
|
852
|
+
|
|
853
|
+
if (failed.length) {
|
|
854
|
+
const sd = stateDir(root);
|
|
855
|
+
fs.writeFileSync(path.join(sd, "hook.log"), failed.map(([n, o]) => `== ${n} ==\n${o}`).join("\n\n"), "utf8");
|
|
856
|
+
console.log(`signoff guard: FAIL (${total.toFixed(2)}s)`);
|
|
857
|
+
for (const [n, out] of failed) {
|
|
858
|
+
const tail = out
|
|
859
|
+
.trim()
|
|
860
|
+
.split(/\r?\n/)
|
|
861
|
+
.filter((ln) => ln.trim())
|
|
862
|
+
.slice(-8);
|
|
863
|
+
console.log(`-- ${n} --`);
|
|
864
|
+
for (const ln of tail) console.log(` ${ln}`);
|
|
865
|
+
}
|
|
866
|
+
console.log(` full output: ${STATE}/hook.log`);
|
|
867
|
+
writeSummary(steps, total, false, failed);
|
|
868
|
+
console.log(" bypass once: SIGNOFF_SKIP=1 git commit ... (recorded) or git commit --no-verify");
|
|
869
|
+
return 1;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
fs.writeFileSync(
|
|
873
|
+
path.join(root, STATE, "cache.json"),
|
|
874
|
+
JSON.stringify({ key, ok: true, ts: now(), seconds: Number(total.toFixed(3)) }) + "\n",
|
|
875
|
+
"utf8"
|
|
876
|
+
);
|
|
877
|
+
if (mark) writeMarker(root, "pass");
|
|
878
|
+
console.log(`signoff guard: PASS (${total.toFixed(2)}s)`);
|
|
879
|
+
writeSummary(steps, total, true, []);
|
|
880
|
+
return 0;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ---------------------------------------------------------------- status
|
|
884
|
+
|
|
885
|
+
function installed(root) {
|
|
886
|
+
const hooks = path.join(root, ".git", "hooks");
|
|
887
|
+
const out = {};
|
|
888
|
+
for (const name of STAGES) {
|
|
889
|
+
const p = path.join(hooks, name);
|
|
890
|
+
out[name] = !!(exists(p) && markedByUs(readText(p)));
|
|
891
|
+
}
|
|
892
|
+
const ci = path.join(root, ".github", "workflows", "signoff.yml");
|
|
893
|
+
out.ci = !!(exists(ci) && markedByUs(readText(ci)));
|
|
894
|
+
out.config = exists(path.join(root, STATE, "config.json"));
|
|
895
|
+
// A commit can carry a trailer only when both halves of the hand-off exist:
|
|
896
|
+
// pre-commit records the tree, commit-msg writes the line from that record.
|
|
897
|
+
out.trailer = !!(out["pre-commit"] && out["commit-msg"]);
|
|
898
|
+
const c = path.join(root, STATE, "cache.json");
|
|
899
|
+
if (exists(c)) {
|
|
900
|
+
try {
|
|
901
|
+
const d = JSON.parse(fs.readFileSync(c, "utf8"));
|
|
902
|
+
out.last_run = { ok: d.ok, ts: d.ts, seconds: d.seconds };
|
|
903
|
+
} catch (e) {
|
|
904
|
+
/* unreadable cache: no last run */
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return out;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function cmdStatus(args) {
|
|
911
|
+
const root = repoRoot();
|
|
912
|
+
const cfg = loadConfig(root);
|
|
913
|
+
const st = installed(root);
|
|
914
|
+
if (args.json) {
|
|
915
|
+
console.log(JSON.stringify({ root, version: packageVersion(), trailer: st.trailer, installed: st, config: cfg }));
|
|
916
|
+
return 0;
|
|
917
|
+
}
|
|
918
|
+
console.log(`repo ${root}`);
|
|
919
|
+
console.log(`version ${packageVersion()}`);
|
|
920
|
+
for (const k of ["pre-commit", "commit-msg", "pre-push", "ci"]) {
|
|
921
|
+
console.log(`${k.padEnd(12)}${st[k] ? "installed" : "missing"}`);
|
|
922
|
+
}
|
|
923
|
+
console.log(`${"trailer".padEnd(12)}${st.trailer ? "enforced" : "off"}`);
|
|
924
|
+
console.log(`${"config".padEnd(12)}${st.config ? "present" : "missing"}`);
|
|
925
|
+
console.log(`${"test".padEnd(12)}${detector.describe(root, cfg)}`);
|
|
926
|
+
const lr = st.last_run;
|
|
927
|
+
console.log(`${"last run".padEnd(12)}${lr ? lr.ts + (lr.ok ? " PASS" : " FAIL") : "never"}`);
|
|
928
|
+
return 0;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function cmdUninstall(args) {
|
|
932
|
+
const root = repoRoot();
|
|
933
|
+
const removedState = [];
|
|
934
|
+
const runtime = [`${STATE}/passed`, `${STATE}/cache.json`];
|
|
935
|
+
const removed = [];
|
|
936
|
+
for (const name of STAGES) {
|
|
937
|
+
const p = path.join(root, ".git", "hooks", name);
|
|
938
|
+
if (!exists(p) || !markedByUs(readText(p))) continue;
|
|
939
|
+
const local = findChainFile(path.join(root, ".git", "hooks"), name);
|
|
940
|
+
if (local) {
|
|
941
|
+
fs.rmSync(p, { force: true });
|
|
942
|
+
fs.renameSync(local, p);
|
|
943
|
+
removed.push(`.git/hooks/${name} (restored from ${path.basename(local)})`);
|
|
944
|
+
} else {
|
|
945
|
+
fs.unlinkSync(p);
|
|
946
|
+
removed.push(`.git/hooks/${name}`);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
const ci = path.join(root, ".github", "workflows", "signoff.yml");
|
|
950
|
+
if (exists(ci) && markedByUs(readText(ci))) {
|
|
951
|
+
fs.unlinkSync(ci);
|
|
952
|
+
removed.push(".github/workflows/signoff.yml");
|
|
953
|
+
}
|
|
954
|
+
// The marker and the last-run cache describe a hook that no longer exists; leaving
|
|
955
|
+
// them behind makes the next `status` look installed. config/installed/ledger stay
|
|
956
|
+
// unless asked for: they are the repo's record, not runtime state.
|
|
957
|
+
for (const rel of runtime) {
|
|
958
|
+
const p = path.join(root, rel);
|
|
959
|
+
if (exists(p)) {
|
|
960
|
+
fs.rmSync(p, { force: true });
|
|
961
|
+
removedState.push(rel);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if (args.all || args.keepConfig) {
|
|
965
|
+
const ip = path.join(root, STATE, "installed.json");
|
|
966
|
+
if (exists(ip)) {
|
|
967
|
+
fs.rmSync(ip, { force: true });
|
|
968
|
+
removedState.push(`${STATE}/installed.json`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
if (args.all) {
|
|
972
|
+
const p = path.join(root, STATE, "config.json");
|
|
973
|
+
if (exists(p)) {
|
|
974
|
+
fs.unlinkSync(p);
|
|
975
|
+
removed.push(`${STATE}/config.json`);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
console.log(`removed: ${removed.length ? removed.join(", ") : "nothing"}`);
|
|
979
|
+
if (removedState.length) console.log(`state: ${removedState.join(", ")}`);
|
|
980
|
+
if (!args.all) {
|
|
981
|
+
const kept = [`${STATE}/config.json`, `${STATE}/ledger.jsonl`, `${STATE}/skips.log`].filter((rel) =>
|
|
982
|
+
exists(path.join(root, rel))
|
|
983
|
+
);
|
|
984
|
+
if (kept.length) console.log(`kept: ${kept.join(", ")} (use --all to drop the config)`);
|
|
985
|
+
}
|
|
986
|
+
return 0;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// ---------------------------------------------------------------- lock
|
|
990
|
+
// Two commits in one repo at the same moment (IDE plus terminal) would race on
|
|
991
|
+
// .signoff/passed: the first commit-msg consumes the marker the second run just wrote.
|
|
992
|
+
// One hook run at a time; a lock left by a dead pid or older than 10 minutes is broken.
|
|
993
|
+
function lockPath(root) {
|
|
994
|
+
return path.join(stateDir(root), "lock");
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function pidAlive(pid) {
|
|
998
|
+
if (!pid) return false;
|
|
999
|
+
try {
|
|
1000
|
+
process.kill(pid, 0);
|
|
1001
|
+
return true;
|
|
1002
|
+
} catch (e) {
|
|
1003
|
+
return e.code === "EPERM";
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function sleepMs(ms) {
|
|
1008
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// The owner record is a file inside the lock directory; 3.9.0 wrote a plain file at the
|
|
1012
|
+
// same path, so both shapes are read (and both can be broken).
|
|
1013
|
+
function readLockOwner(p) {
|
|
1014
|
+
try {
|
|
1015
|
+
const file = fs.statSync(p).isDirectory() ? path.join(p, "owner.json") : p;
|
|
1016
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1017
|
+
} catch (e) {
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function withLock(root, stage, fn) {
|
|
1023
|
+
const p = lockPath(root);
|
|
1024
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
1025
|
+
const raw = process.env.SIGNOFF_LOCK_WAIT_MS;
|
|
1026
|
+
const wait = raw === undefined ? 8000 : Number(raw) || 0;
|
|
1027
|
+
const deadline = Date.now() + wait;
|
|
1028
|
+
for (;;) {
|
|
1029
|
+
try {
|
|
1030
|
+
// mkdir is atomic and, unlike O_EXCL create, it is also honoured on SMB/NFS shares,
|
|
1031
|
+
// where two `open(..., "wx")` calls can both succeed.
|
|
1032
|
+
fs.mkdirSync(p);
|
|
1033
|
+
fs.writeFileSync(
|
|
1034
|
+
path.join(p, "owner.json"),
|
|
1035
|
+
JSON.stringify({ pid: process.pid, stage: stage, ts: new Date().toISOString() }),
|
|
1036
|
+
"utf8"
|
|
1037
|
+
);
|
|
1038
|
+
break;
|
|
1039
|
+
} catch (e) {
|
|
1040
|
+
const held = e.code === "EEXIST" ? readLockOwner(p) : null;
|
|
1041
|
+
if (e.code !== "EEXIST") {
|
|
1042
|
+
// EACCES or a plain file where the directory should be: treat as held only if
|
|
1043
|
+
// something is visibly there, otherwise let the run continue.
|
|
1044
|
+
if (!fs.existsSync(p)) return fn();
|
|
1045
|
+
}
|
|
1046
|
+
const old = !held || !pidAlive(held.pid) || Date.now() - Date.parse(held.ts || 0) > 10 * 60 * 1000;
|
|
1047
|
+
if (old) {
|
|
1048
|
+
fs.rmSync(p, { force: true, recursive: true });
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
if (Date.now() >= deadline) {
|
|
1052
|
+
console.error(`signoff: another signoff run holds the lock (pid ${held.pid}, ${held.stage}, ${held.ts})`);
|
|
1053
|
+
console.error("signoff: retry when it finishes; if it is gone, delete .signoff/lock");
|
|
1054
|
+
return 1;
|
|
1055
|
+
}
|
|
1056
|
+
sleepMs(200);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
try {
|
|
1060
|
+
return fn();
|
|
1061
|
+
} finally {
|
|
1062
|
+
fs.rmSync(p, { force: true, recursive: true });
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function optedIn(root) {
|
|
1067
|
+
return exists(path.join(root, STATE, "config.json"));
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// The machine-wide dispatcher (core.hooksPath) reaches repos that never ran `signoff init`.
|
|
1071
|
+
// Five gates there would fail for reasons the repo never agreed to (no cards, no scenario
|
|
1072
|
+
// files, prose rules), so an unopted repo gets the one gate that is always meaningful -
|
|
1073
|
+
// do the tests pass - and leaves no state behind.
|
|
1074
|
+
function lightGuard(root, cfg, files) {
|
|
1075
|
+
if (!files.length) return 0;
|
|
1076
|
+
// A leaked key is the one accident that cannot be undone by a revert, so it is
|
|
1077
|
+
// checked even where the repo never opted in. --no-state keeps that promise.
|
|
1078
|
+
if (cfg.secrets !== false) {
|
|
1079
|
+
const sec = runGate("secrets", ["--json", "--no-state"], root, 60);
|
|
1080
|
+
if (sec.rc !== 0 && sec.rc !== 2) {
|
|
1081
|
+
console.log("signoff guard: FAIL (staged secret, machine-wide guard)");
|
|
1082
|
+
for (const ln of String(sec.out || "").trim().split(/\r?\n/).filter(Boolean).slice(-8)) {
|
|
1083
|
+
console.log(` ${ln}`);
|
|
1084
|
+
}
|
|
1085
|
+
console.log(" remove the value, or allow this rule in .signoff/config.json");
|
|
1086
|
+
return 1;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
const res = runGate("p1", [], root, 300);
|
|
1090
|
+
if (res.rc === 0) return 0;
|
|
1091
|
+
if (res.rc === 2) {
|
|
1092
|
+
console.log("signoff: no test runner detected; run `signoff init` for the full five gates");
|
|
1093
|
+
return 0;
|
|
1094
|
+
}
|
|
1095
|
+
console.log("signoff guard: FAIL (P1 tests, machine-wide guard)");
|
|
1096
|
+
for (const ln of String(res.out || "").trim().split(/\r?\n/).filter(Boolean).slice(-8)) {
|
|
1097
|
+
console.log(` ${ln}`);
|
|
1098
|
+
}
|
|
1099
|
+
console.log(" opt in with `signoff init` (or `npm i -D signoff-cli`) to get all five gates");
|
|
1100
|
+
return 1;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function cmdHook(args) {
|
|
1104
|
+
const stage = args._[0];
|
|
1105
|
+
if (stage === undefined)
|
|
1106
|
+
return argparseError(HOOK_USAGE, "signoff guard hook", "the following arguments are required: stage");
|
|
1107
|
+
if (STAGES.indexOf(stage) === -1)
|
|
1108
|
+
return argparseError(
|
|
1109
|
+
HOOK_USAGE,
|
|
1110
|
+
"signoff guard hook",
|
|
1111
|
+
`argument stage: invalid choice: '${stage}' (choose from 'pre-commit', 'commit-msg', 'pre-push')`
|
|
1112
|
+
);
|
|
1113
|
+
const root = repoRoot();
|
|
1114
|
+
if (!optedIn(root)) {
|
|
1115
|
+
// Nothing to hand off and nothing to audit: an unopted repo has no marker protocol.
|
|
1116
|
+
if (stage === "commit-msg") return 0;
|
|
1117
|
+
// Test suites and scripted runs own their fixture repos; they ask for the machine
|
|
1118
|
+
// layer to stay out of the way explicitly rather than by unsetting core.hooksPath.
|
|
1119
|
+
if (process.env.SIGNOFF_NO_MACHINE === "1") return 0;
|
|
1120
|
+
// A committed signoff.json is the repository opting in for everyone who clones it:
|
|
1121
|
+
// finish the local install, then run the same five gates as a local `signoff init`.
|
|
1122
|
+
const boot = require("./bootstrap");
|
|
1123
|
+
if (!boot.declaration(root)) return lightGuard(root, loadConfig(root), stagedFiles(root));
|
|
1124
|
+
boot.ensureRepo({ cwd: root, quiet: true });
|
|
1125
|
+
if (!optedIn(root)) return lightGuard(root, loadConfig(root), stagedFiles(root));
|
|
1126
|
+
}
|
|
1127
|
+
if (stage === "commit-msg") return cmdCommitMsg(args._[1]);
|
|
1128
|
+
// commit-msg is deliberately not locked: it must never block a commit that git already
|
|
1129
|
+
// accepted, and it only reads the marker pre-commit wrote.
|
|
1130
|
+
return withLock(root, stage, () =>
|
|
1131
|
+
cmdGuard({ full: stage === "pre-push", ci: false, mark: stage === "pre-commit" })
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ---------------------------------------------------------------- main
|
|
1136
|
+
|
|
1137
|
+
// argparse-shaped errors, so a failed command reads the same as the Python
|
|
1138
|
+
// reference (message on stderr, exit 2, nothing on stdout).
|
|
1139
|
+
const USAGE = "usage: signoff guard [-h] {init,guard,hook,status,uninstall} ...";
|
|
1140
|
+
const HOOK_USAGE = "usage: signoff guard hook [-h] {pre-commit,commit-msg,pre-push}";
|
|
1141
|
+
const INIT_USAGE =
|
|
1142
|
+
"usage: signoff guard init [-h] [--test-cmd TEST_CMD] [--budget BUDGET]\n" +
|
|
1143
|
+
" [--docs [DOCS ...]] [--no-ci] [--no-push]\n" +
|
|
1144
|
+
" [--prepare] [--lock-test] [--reset-install] [--share]\n" +
|
|
1145
|
+
" [--quiet] [--dry-run]";
|
|
1146
|
+
|
|
1147
|
+
function argparseError(usage, prog, body) {
|
|
1148
|
+
process.stderr.write(`${usage}\n`);
|
|
1149
|
+
process.stderr.write(`${prog}: error: ${body}\n`);
|
|
1150
|
+
return 2;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function usageError(body) {
|
|
1154
|
+
return argparseError(USAGE, "signoff guard", body);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function isInitPositionalError(args) {
|
|
1158
|
+
return args.docs === undefined && args._.length > 0;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function main(argv) {
|
|
1162
|
+
const args = core.parseArgs(argv || [], {});
|
|
1163
|
+
const cmd = args._.shift();
|
|
1164
|
+
try {
|
|
1165
|
+
if (cmd === "init") {
|
|
1166
|
+
// argparse raises "unrecognized arguments" from the top-level parser,
|
|
1167
|
+
// so the usage line is the parent's, not the subparser's.
|
|
1168
|
+
if (isInitPositionalError(args)) return usageError(`unrecognized arguments: ${args._.join(" ")}`);
|
|
1169
|
+
return cmdInit(normalizeInit(args));
|
|
1170
|
+
}
|
|
1171
|
+
if (cmd === "guard") return cmdGuard(args);
|
|
1172
|
+
if (cmd === "hook") return cmdHook(args);
|
|
1173
|
+
if (cmd === "status") return cmdStatus(args);
|
|
1174
|
+
if (cmd === "uninstall") {
|
|
1175
|
+
if (args._.length) return usageError(`unrecognized arguments: ${args._.join(" ")}`);
|
|
1176
|
+
return cmdUninstall(args);
|
|
1177
|
+
}
|
|
1178
|
+
if (!cmd) return usageError("the following arguments are required: cmd");
|
|
1179
|
+
return usageError(
|
|
1180
|
+
`argument cmd: invalid choice: '${cmd}' (choose from 'init', 'guard', 'hook', 'status', 'uninstall')`
|
|
1181
|
+
);
|
|
1182
|
+
} catch (e) {
|
|
1183
|
+
if (e instanceof GuardExit) {
|
|
1184
|
+
// repoRoot() fails closed: same one-line message and exit 1 as the Python guard.
|
|
1185
|
+
process.stderr.write(`${e.message}\n`);
|
|
1186
|
+
return e.code;
|
|
1187
|
+
}
|
|
1188
|
+
if (e instanceof ArgparseError) return argparseError(INIT_USAGE, "signoff guard init", e.message);
|
|
1189
|
+
throw e;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// `--docs` takes zero or more values in the Python reference (argparse nargs="*").
|
|
1194
|
+
// init has no positionals of its own, so any left-over words belong to --docs.
|
|
1195
|
+
function normalizeInit(args) {
|
|
1196
|
+
const rawBudget = args.budget;
|
|
1197
|
+
let budget = null;
|
|
1198
|
+
if (rawBudget === true) throw new ArgparseError("argument --budget: expected one argument");
|
|
1199
|
+
if (rawBudget !== undefined) {
|
|
1200
|
+
budget = Number(rawBudget);
|
|
1201
|
+
if (!Number.isFinite(budget)) throw new ArgparseError(`argument --budget: invalid float value: '${rawBudget}'`);
|
|
1202
|
+
}
|
|
1203
|
+
if (args.testCmd === true) throw new ArgparseError("argument --test-cmd: expected one argument");
|
|
1204
|
+
const out = {
|
|
1205
|
+
testCmd: typeof args.testCmd === "string" ? args.testCmd : null,
|
|
1206
|
+
lockTest: !!args.lockTest,
|
|
1207
|
+
resetInstall: !!args.resetInstall,
|
|
1208
|
+
budget,
|
|
1209
|
+
docs: null,
|
|
1210
|
+
noCi: !!args.noCi,
|
|
1211
|
+
noPush: !!args.noPush,
|
|
1212
|
+
chain: !!args.chain,
|
|
1213
|
+
prepare: !!args.prepare,
|
|
1214
|
+
quiet: !!args.quiet,
|
|
1215
|
+
dryRun: !!args.dryRun,
|
|
1216
|
+
share: !!args.share,
|
|
1217
|
+
sign: !!args.sign,
|
|
1218
|
+
preset: args.preset ? String(args.preset) : null,
|
|
1219
|
+
editor: args.editor ? String(args.editor) : null,
|
|
1220
|
+
};
|
|
1221
|
+
if (args.docs !== undefined) {
|
|
1222
|
+
const list = Array.isArray(args.docs) ? args.docs.slice() : args.docs === true ? [] : [String(args.docs)];
|
|
1223
|
+
out.docs = list.concat(args._.map(String));
|
|
1224
|
+
if (out.docs.length === 0) out.docs = null; // `--docs` alone: argparse -> []
|
|
1225
|
+
}
|
|
1226
|
+
return out;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
module.exports = { main, hookBody, CI_BODY, DEFAULT_CONFIG, maybeCheckpoint };
|