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/test/selftest.js
ADDED
|
@@ -0,0 +1,1226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// Self-test for signoff-cli: every gate runs against a throwaway git repo and the
|
|
4
|
+
// exit code is the assertion. Node only — this is the suite `npm test` runs.
|
|
5
|
+
// node test/selftest.mjs
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const os = require("os");
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const { spawnSync } = require("child_process");
|
|
11
|
+
|
|
12
|
+
const ROOT = path.join(__dirname, "..");
|
|
13
|
+
const CLI = path.join(ROOT, "bin", "signoff.js");
|
|
14
|
+
const PASS = [];
|
|
15
|
+
const FAIL = [];
|
|
16
|
+
const AUDIT = [];
|
|
17
|
+
let HOME = "";
|
|
18
|
+
|
|
19
|
+
function check(name, got, want, extra) {
|
|
20
|
+
const ok = got === want;
|
|
21
|
+
(ok ? PASS : FAIL).push(`${name}: got=${got} want=${want}${extra ? " " + extra : ""}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function run(args, cwd, env) {
|
|
25
|
+
return spawnSync(process.execPath, [CLI, ...args], {
|
|
26
|
+
cwd,
|
|
27
|
+
encoding: "utf8",
|
|
28
|
+
// Fixtures are not the user's repos: no auto-install unless a test asks for it.
|
|
29
|
+
env: { ...process.env, SIGNOFF_HOME: HOME, SIGNOFF_NO_BOOTSTRAP: "1", ...(env || {}) },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Fixture repos are not the user's machine: keep the machine-wide dispatcher out of the
|
|
34
|
+
// suite so a commit inside a fixture only runs what the test asked for.
|
|
35
|
+
function gitEnv(extra) {
|
|
36
|
+
return { ...process.env, SIGNOFF_HOME: HOME, SIGNOFF_NO_MACHINE: "1", ...(extra || {}) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function gitOut(args, cwd, env) {
|
|
40
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8", env: gitEnv(env) });
|
|
41
|
+
if (r.status !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr}`);
|
|
42
|
+
return r.stdout;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function git(args, cwd, env) {
|
|
46
|
+
gitOut(args, cwd, env);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Git runs hooks through sh, and sh finds an extension-less `signoff` on PATH.
|
|
50
|
+
// That is how a real `git commit` reaches this checkout without a global install.
|
|
51
|
+
function makeShim(base) {
|
|
52
|
+
const dir = path.join(base, "shim");
|
|
53
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
54
|
+
const p = path.join(dir, "signoff");
|
|
55
|
+
const fwd = (s) => s.split("\\").join("/");
|
|
56
|
+
fs.writeFileSync(p, `#!/bin/sh\nexec "${fwd(process.execPath)}" "${fwd(CLI)}" "$@"\n`, "utf8");
|
|
57
|
+
try {
|
|
58
|
+
fs.chmodSync(p, 0o755);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
/* windows: sh does not need the bit */
|
|
61
|
+
}
|
|
62
|
+
return dir;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildRepo(base) {
|
|
66
|
+
const repo = path.join(base, "repo");
|
|
67
|
+
for (const d of ["tests", "verify/scenarios", "docs", "tasks/t1", "tasks/t2"]) {
|
|
68
|
+
fs.mkdirSync(path.join(repo, d), { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
const w = (p, s) => fs.writeFileSync(path.join(repo, p), s, "utf8");
|
|
71
|
+
w(
|
|
72
|
+
"package.json",
|
|
73
|
+
JSON.stringify(
|
|
74
|
+
{ name: "fixture", version: "0.0.0", devDependencies: { "signoff-cli": "^4.0.0" } },
|
|
75
|
+
null,
|
|
76
|
+
2
|
|
77
|
+
) + "\n"
|
|
78
|
+
);
|
|
79
|
+
w("tasks/t1/card.json", JSON.stringify({ id: "t1", goal: "g", scope: ["src/a/**"], acceptance: ["pass"], rollback: "HEAD" }));
|
|
80
|
+
w("tasks/t2/card.json", JSON.stringify({ id: "t2", goal: "g", scope: ["src/a/b.py"], acceptance: ["pass"], rollback: "HEAD" }));
|
|
81
|
+
w("docs/clean.md", "# clean\n\n40 cases in 3.6 s, 12/12 PASS.\n");
|
|
82
|
+
w("docs/dirty.md", "# dirty\n\n这个方案赋能全链路,闭环彻底解决,已验证。\n");
|
|
83
|
+
fs.writeFileSync(
|
|
84
|
+
path.join(repo, "verify/scenarios/a.json"),
|
|
85
|
+
JSON.stringify({ checks: [{ name: "x" }, { name: "y" }, { name: "z" }] })
|
|
86
|
+
);
|
|
87
|
+
git(["init", "-q"], repo);
|
|
88
|
+
git(["config", "user.email", "t@t"], repo);
|
|
89
|
+
git(["config", "user.name", "t"], repo);
|
|
90
|
+
git(["add", "-A"], repo);
|
|
91
|
+
git(["commit", "-qm", "init"], repo);
|
|
92
|
+
return repo;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function main() {
|
|
96
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), "signoff-selftest-"));
|
|
97
|
+
HOME = path.join(base, "home");
|
|
98
|
+
try {
|
|
99
|
+
const repo = buildRepo(base);
|
|
100
|
+
const shim = makeShim(base);
|
|
101
|
+
|
|
102
|
+
// P1
|
|
103
|
+
let r = run(["p1", "--run", `"${process.execPath}" -e "console.log('3 passed')"`], repo);
|
|
104
|
+
check("p1 pass", r.status, 0);
|
|
105
|
+
const p1 = JSON.parse(fs.readFileSync(path.join(repo, ".signoff", "p1.json"), "utf8"));
|
|
106
|
+
check("p1 cases parsed", p1.cases, 3);
|
|
107
|
+
r = run(["p1", "--run", `"${process.execPath}" -e "process.exit(1)"`], repo);
|
|
108
|
+
check("p1 fail on rc!=0", r.status, 1);
|
|
109
|
+
|
|
110
|
+
// P2
|
|
111
|
+
r = run(["p2", "--strict", "--json"], repo);
|
|
112
|
+
check("p2 pass", r.status, 0);
|
|
113
|
+
r = run(["p2", "--min-assertions", "9"], repo);
|
|
114
|
+
check("p2 fail on threshold", r.status, 1);
|
|
115
|
+
|
|
116
|
+
// P3
|
|
117
|
+
r = run(["p3", "validate"], repo);
|
|
118
|
+
check("p3 validate", r.status, 0);
|
|
119
|
+
r = run(["p3", "plan"], repo);
|
|
120
|
+
check("p3 plan splits", r.status, 0);
|
|
121
|
+
const p3 = JSON.parse(fs.readFileSync(path.join(repo, ".signoff", "p3.json"), "utf8"));
|
|
122
|
+
check("p3 wave count", p3.waves.length, 2);
|
|
123
|
+
|
|
124
|
+
// P4
|
|
125
|
+
r = run(["p4", "ask", "--action", "publish", "--target", "demo@1.0.0"], repo);
|
|
126
|
+
check("p4 ask", r.status, 0);
|
|
127
|
+
const ticket = (r.stdout.match(/ticket (\S+)/) || [])[1];
|
|
128
|
+
r = run(["p4", "confirm", "--ticket", ticket, "--decision", "allow", "--detail", "d", "--rollback", "HEAD"], repo);
|
|
129
|
+
check("p4 confirm allow", r.status, 0);
|
|
130
|
+
r = run(["p4", "verify"], repo);
|
|
131
|
+
check("p4 verify ok", r.status, 0);
|
|
132
|
+
r = run(["p4", "confirm", "--ticket", ticket, "--decision", "allow"], repo);
|
|
133
|
+
check("p4 ticket single-use", r.status, 2);
|
|
134
|
+
r = run(["p4", "ask", "--action", "publish", "--target", "deny-me"], repo);
|
|
135
|
+
const denyTicket = (r.stdout.match(/ticket (\S+)/) || [])[1];
|
|
136
|
+
r = run(["p4", "confirm", "--ticket", denyTicket, "--decision", "deny"], repo);
|
|
137
|
+
check("p4 deny red", r.status, 1);
|
|
138
|
+
const log = path.join(HOME, "audit.log");
|
|
139
|
+
fs.writeFileSync(log, fs.readFileSync(log, "utf8").replace('"d"', '"tampered"'), "utf8");
|
|
140
|
+
r = run(["p4", "verify"], repo);
|
|
141
|
+
check("p4 tamper detected", r.status, 1);
|
|
142
|
+
r = run(["p4", "ask", "--action", "publish", "--target", "x"], repo);
|
|
143
|
+
const t2 = (r.stdout.match(/ticket (\S+)/) || [])[1];
|
|
144
|
+
run(["p4", "confirm", "--ticket", t2, "--decision", "allow", "--rollback", "npm deprecate x"], repo);
|
|
145
|
+
r = run(["p4", "verify"], repo);
|
|
146
|
+
check("p4 fake rollback refused", r.status, 1);
|
|
147
|
+
|
|
148
|
+
// P4 repo-scoped ledger (3.3.0): without SIGNOFF_HOME the ledger lives in the repo,
|
|
149
|
+
// records carry their repository, and a pre-3.3.0 chain still verifies.
|
|
150
|
+
const lib = require(path.join(ROOT, "lib", "p4.js"));
|
|
151
|
+
const rhome = path.join(base, "legacy-home");
|
|
152
|
+
const rhomeDir = path.join(rhome, ".signoff");
|
|
153
|
+
fs.mkdirSync(rhomeDir, { recursive: true });
|
|
154
|
+
const v1body = {
|
|
155
|
+
ts: "2026-01-01T00:00:00+0000",
|
|
156
|
+
action: "publish",
|
|
157
|
+
target: "old@1.0.0",
|
|
158
|
+
detail: "legacy",
|
|
159
|
+
rollback: "HEAD",
|
|
160
|
+
decision: "allow",
|
|
161
|
+
};
|
|
162
|
+
const v1rec = Object.assign({}, v1body, { prev: "genesis", hash: lib.digest("genesis", v1body) });
|
|
163
|
+
fs.writeFileSync(path.join(rhomeDir, "audit.log"), lib.dumpsPy(v1rec, { sortKeys: true }) + os.EOL, "utf8");
|
|
164
|
+
const noHome = { SIGNOFF_HOME: "", HOME: rhome, USERPROFILE: rhome };
|
|
165
|
+
r = run(["p4", "verify"], repo, noHome);
|
|
166
|
+
check("p4 repo ledger seeds legacy chain", r.status, 0, (r.stdout || "").trim());
|
|
167
|
+
const repoLog = path.join(repo, ".signoff", "audit.log");
|
|
168
|
+
check("p4 ledger lands in repo", fs.existsSync(repoLog) ? 1 : 0, 1);
|
|
169
|
+
r = run(["p4", "ask", "--action", "publish", "--target", "scoped"], repo, noHome);
|
|
170
|
+
const t3 = (r.stdout.match(/ticket (\S+)/) || [])[1];
|
|
171
|
+
run(["p4", "confirm", "--ticket", t3, "--decision", "allow", "--detail", "s", "--rollback", "HEAD"], repo, noHome);
|
|
172
|
+
check("p4 record carries repo", /"repo": "[^"]+"/.test(fs.readFileSync(repoLog, "utf8")) ? 1 : 0, 1);
|
|
173
|
+
const lines = fs.readFileSync(repoLog, "utf8").trim().split(/\r?\n/);
|
|
174
|
+
const newest = JSON.parse(lines[lines.length - 1]);
|
|
175
|
+
const lastHash = newest.hash;
|
|
176
|
+
const foreignBody = {
|
|
177
|
+
ts: "2026-01-02T00:00:00+0000",
|
|
178
|
+
action: "publish",
|
|
179
|
+
target: "elsewhere",
|
|
180
|
+
detail: "other repo",
|
|
181
|
+
rollback: "v-missing-tag",
|
|
182
|
+
decision: "allow",
|
|
183
|
+
repo: "elsewhere",
|
|
184
|
+
};
|
|
185
|
+
const foreign = Object.assign({}, foreignBody, { prev: lastHash, hash: lib.digest(lastHash, foreignBody) });
|
|
186
|
+
fs.appendFileSync(repoLog, lib.dumpsPy(foreign, { sortKeys: true }) + os.EOL, "utf8");
|
|
187
|
+
r = run(["p4", "verify"], repo, noHome);
|
|
188
|
+
check("p4 skips foreign repo entries", r.status, 0, (r.stdout || "").trim());
|
|
189
|
+
const ownBody = {
|
|
190
|
+
ts: "2026-01-03T00:00:00+0000",
|
|
191
|
+
action: "publish",
|
|
192
|
+
target: "mine",
|
|
193
|
+
detail: "own repo",
|
|
194
|
+
rollback: "v-missing-tag",
|
|
195
|
+
decision: "allow",
|
|
196
|
+
repo: newest.repo,
|
|
197
|
+
};
|
|
198
|
+
const own = Object.assign({}, ownBody, { prev: foreign.hash, hash: lib.digest(foreign.hash, ownBody) });
|
|
199
|
+
fs.appendFileSync(repoLog, lib.dumpsPy(own, { sortKeys: true }) + os.EOL, "utf8");
|
|
200
|
+
r = run(["p4", "verify"], repo, noHome);
|
|
201
|
+
check("p4 still checks own entries", r.status, 1, (r.stdout || "").trim());
|
|
202
|
+
|
|
203
|
+
// P5
|
|
204
|
+
r = run(["p5", "docs/dirty.md"], repo);
|
|
205
|
+
check("p5 dirty red", r.status, 1);
|
|
206
|
+
r = run(["p5", "docs/clean.md"], repo);
|
|
207
|
+
check("p5 clean green", r.status, 0);
|
|
208
|
+
|
|
209
|
+
// Guard: install once, then the gates run themselves.
|
|
210
|
+
const ghome = path.join(base, "guard-home");
|
|
211
|
+
const g = (args, env) => run(args, repo, { SIGNOFF_HOME: ghome, ...(env || {}) });
|
|
212
|
+
// a real `git commit`, with the CLI reachable from the hooks
|
|
213
|
+
const commit = (args, env) =>
|
|
214
|
+
spawnSync("git", args, {
|
|
215
|
+
cwd: repo,
|
|
216
|
+
encoding: "utf8",
|
|
217
|
+
env: {
|
|
218
|
+
...process.env,
|
|
219
|
+
SIGNOFF_HOME: ghome,
|
|
220
|
+
PATH: `${shim}${path.delimiter}${process.env.PATH}`,
|
|
221
|
+
...(env || {}),
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
r = g(["audit", "--since-install"]);
|
|
225
|
+
check("audit --since-install needs install point", r.status, 2);
|
|
226
|
+
r = g(["init", "--no-ci"]);
|
|
227
|
+
check("guard init", r.status, 0);
|
|
228
|
+
const ipath = path.join(repo, ".signoff", "installed.json");
|
|
229
|
+
check("init records install point", fs.existsSync(ipath) ? 1 : 0, 1);
|
|
230
|
+
const ip1 = JSON.parse(fs.readFileSync(ipath, "utf8"));
|
|
231
|
+
check("install point carries sha", /^[0-9a-f]{7,40}$/.test(ip1.sha || "") ? 1 : 0, 1, JSON.stringify(ip1));
|
|
232
|
+
check("audit --since-install ok", g(["audit", "--since-install"]).status, 0, g(["audit", "--since-install"]).stdout.trim());
|
|
233
|
+
r = g(["init", "--no-ci"]);
|
|
234
|
+
const ip2 = JSON.parse(fs.readFileSync(ipath, "utf8"));
|
|
235
|
+
check("re-init keeps install point", `${ip2.sha}|${ip2.ts}` === `${ip1.sha}|${ip1.ts}` ? 1 : 0, 1);
|
|
236
|
+
check("re-init records the hook version", ip2.version, JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version);
|
|
237
|
+
|
|
238
|
+
// init --chain: the hook that was already there keeps running, and uninstall gives it back.
|
|
239
|
+
const crepo = path.join(base, "chain-repo");
|
|
240
|
+
const chome = path.join(base, "chain-home");
|
|
241
|
+
fs.mkdirSync(crepo, { recursive: true });
|
|
242
|
+
gitOut(["init", "-q", "."], crepo);
|
|
243
|
+
const chooks = path.join(crepo, ".git", "hooks");
|
|
244
|
+
fs.mkdirSync(chooks, { recursive: true });
|
|
245
|
+
const oldHook = path.join(chooks, "pre-commit");
|
|
246
|
+
fs.writeFileSync(oldHook, "#!/bin/sh\necho ran > .local-ran\nexit 0\n", "utf8");
|
|
247
|
+
fs.chmodSync(oldHook, 0o755);
|
|
248
|
+
fs.writeFileSync(path.join(crepo, "a.txt"), "a", "utf8");
|
|
249
|
+
gitOut(["add", "."], crepo);
|
|
250
|
+
gitOut(["-c", "user.name=a", "-c", "user.email=a@b", "commit", "-qm", "base"], crepo);
|
|
251
|
+
check("old hook ran before signoff", fs.existsSync(path.join(crepo, ".local-ran")) ? 1 : 0, 1);
|
|
252
|
+
const c = (args) => run(args, crepo, { SIGNOFF_HOME: chome });
|
|
253
|
+
const ccommit = (msg) =>
|
|
254
|
+
spawnSync("git", ["-c", "user.name=a", "-c", "user.email=a@b", "commit", "-qm", msg], {
|
|
255
|
+
cwd: crepo,
|
|
256
|
+
encoding: "utf8",
|
|
257
|
+
env: { ...process.env, SIGNOFF_HOME: chome, PATH: `${shim}${path.delimiter}${process.env.PATH}` },
|
|
258
|
+
});
|
|
259
|
+
r = c(["init", "--no-ci", "--chain"]);
|
|
260
|
+
check("init --chain", r.status, 0, (r.stdout || "").trim());
|
|
261
|
+
check("chain keeps old hook aside", fs.existsSync(path.join(chooks, "pre-commit.local")) ? 1 : 0, 1);
|
|
262
|
+
fs.rmSync(path.join(crepo, ".local-ran"), { force: true });
|
|
263
|
+
fs.writeFileSync(path.join(crepo, "b.txt"), "b", "utf8");
|
|
264
|
+
gitOut(["add", "."], crepo);
|
|
265
|
+
const chained = ccommit("chained");
|
|
266
|
+
check("chained commit passes", chained.status, 0, (chained.stderr || "").trim());
|
|
267
|
+
check("chained commit ran the old hook", fs.existsSync(path.join(crepo, ".local-ran")) ? 1 : 0, 1);
|
|
268
|
+
check("chained commit got a trailer", /^Signoff-Pass: [0-9a-f]{12}$/m.test(gitOut(["log", "-1", "--format=%B"], crepo)) ? 1 : 0, 1);
|
|
269
|
+
fs.writeFileSync(path.join(chooks, "pre-commit.local"), "#!/bin/sh\nexit 1\n", "utf8");
|
|
270
|
+
fs.chmodSync(path.join(chooks, "pre-commit.local"), 0o755);
|
|
271
|
+
fs.writeFileSync(path.join(crepo, "c.txt"), "c", "utf8");
|
|
272
|
+
gitOut(["add", "."], crepo);
|
|
273
|
+
check("failing old hook blocks the commit", ccommit("blocked").status === 0 ? 0 : 1, 1);
|
|
274
|
+
r = c(["uninstall"]);
|
|
275
|
+
check("uninstall restores the old hook", /restored from pre-commit\.local/.test(r.stdout) ? 1 : 0, 1);
|
|
276
|
+
check("restored hook is not ours", fs.readFileSync(oldHook, "utf8").includes("# generated by dsh-signoff") ? 0 : 1, 1);
|
|
277
|
+
check("restored hook removed .local", fs.existsSync(path.join(chooks, "pre-commit.local")) ? 0 : 1, 1);
|
|
278
|
+
|
|
279
|
+
// a chained hook keeps its language: a node hook becomes .local.js and runs with node
|
|
280
|
+
const jrepo = path.join(base, "chain-js-repo");
|
|
281
|
+
const jhome = path.join(base, "chain-js-home");
|
|
282
|
+
fs.mkdirSync(jrepo, { recursive: true });
|
|
283
|
+
gitOut(["init", "-q", "."], jrepo);
|
|
284
|
+
fs.mkdirSync(path.join(jrepo, ".git", "hooks"), { recursive: true });
|
|
285
|
+
fs.writeFileSync(
|
|
286
|
+
path.join(jrepo, ".git", "hooks", "pre-commit"),
|
|
287
|
+
"#!/usr/bin/env node\nrequire('fs').writeFileSync('.js-ran','1')\n",
|
|
288
|
+
{ encoding: "utf8", mode: 0o644 }
|
|
289
|
+
);
|
|
290
|
+
fs.writeFileSync(path.join(jrepo, "a.txt"), "a", "utf8");
|
|
291
|
+
gitOut(["add", "."], jrepo);
|
|
292
|
+
const j = (args) => run(args, jrepo, { SIGNOFF_HOME: jhome });
|
|
293
|
+
r = j(["init", "--no-ci", "--no-push", "--chain", "--quiet"]);
|
|
294
|
+
const jlocal = path.join(jrepo, ".git", "hooks", "pre-commit.local.js");
|
|
295
|
+
check("node hook chained as .local.js", fs.existsSync(jlocal) ? 1 : 0, 1, (r.stdout || "").trim());
|
|
296
|
+
const jhook = fs.readFileSync(path.join(jrepo, ".git", "hooks", "pre-commit"), "utf8");
|
|
297
|
+
check("hook dispatches .py/.js/.ps1", /\.py\) PY=/.test(jhook) && /\.js\) node/.test(jhook) ? 1 : 0, 1);
|
|
298
|
+
// Run the generated hook directly: whether `signoff` is on PATH is the harness's
|
|
299
|
+
// business elsewhere, and the point here is only that the chained hook ran first.
|
|
300
|
+
const jc = spawnSync("git", ["hook", "run", "pre-commit"], {
|
|
301
|
+
cwd: jrepo,
|
|
302
|
+
encoding: "utf8",
|
|
303
|
+
});
|
|
304
|
+
check("chained node hook left its mark", fs.existsSync(path.join(jrepo, ".js-ran")) ? 1 : 0, 1, (jc.stderr || "").trim());
|
|
305
|
+
|
|
306
|
+
// runner detection: named runners win, and an empty tests/ folder is not a suite
|
|
307
|
+
const detector = require(path.join(ROOT, "lib", "detect.js"));
|
|
308
|
+
const dprobe = (files) => {
|
|
309
|
+
const dir = fs.mkdtempSync(path.join(base, "detect-"));
|
|
310
|
+
for (const [name, body] of Object.entries(files)) {
|
|
311
|
+
const p = path.join(dir, name);
|
|
312
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
313
|
+
fs.writeFileSync(p, body, "utf8");
|
|
314
|
+
}
|
|
315
|
+
return detector.detect(dir, {});
|
|
316
|
+
};
|
|
317
|
+
check("detect npm script", dprobe({ "package.json": JSON.stringify({ scripts: { test: "node t.js" } }) }).cmd, "npm test --silent");
|
|
318
|
+
check("detect vitest", dprobe({ "package.json": JSON.stringify({ devDependencies: { vitest: "1" } }) }).source, "vitest");
|
|
319
|
+
check("detect cargo", dprobe({ "Cargo.toml": "[package]\n" }).cmd, "cargo test --quiet");
|
|
320
|
+
check("detect go", dprobe({ "go.mod": "module x\n" }).cmd, "go test ./...");
|
|
321
|
+
check("detect make", dprobe({ "Makefile": "test:\n\t@true\n" }).cmd, "make test");
|
|
322
|
+
check("empty tests dir is not a suite", dprobe({ "tests/.keep": "" }).cmd, null);
|
|
323
|
+
check("config outranks files", detector.detect(base, { test_cmd: "echo hi" }).cmd, "echo hi");
|
|
324
|
+
const ddir = fs.mkdtempSync(path.join(base, "describe-"));
|
|
325
|
+
fs.writeFileSync(path.join(ddir, "package.json"), JSON.stringify({ scripts: { test: "node t.js" } }), "utf8");
|
|
326
|
+
check("describe names the runner", /npm test --silent \(package\.json scripts\.test\)/.test(detector.describe(ddir, {})) ? 1 : 0, 1);
|
|
327
|
+
|
|
328
|
+
// the generated CI workflow has to survive the default shallow clone
|
|
329
|
+
const cirepo = path.join(base, "ci-repo");
|
|
330
|
+
fs.mkdirSync(cirepo, { recursive: true });
|
|
331
|
+
gitOut(["init", "-q", "."], cirepo);
|
|
332
|
+
fs.writeFileSync(path.join(cirepo, "a.txt"), "a", "utf8");
|
|
333
|
+
gitOut(["add", "."], cirepo);
|
|
334
|
+
gitOut(["-c", "user.name=a", "-c", "user.email=a@b", "commit", "-qm", "a"], cirepo);
|
|
335
|
+
run(["init", "--no-push"], cirepo, { SIGNOFF_HOME: path.join(base, "ci-home") });
|
|
336
|
+
const wf = fs.readFileSync(path.join(cirepo, ".github", "workflows", "signoff.yml"), "utf8");
|
|
337
|
+
check("CI workflow fetches full history", /fetch-depth: 0/.test(wf) ? 1 : 0, 1);
|
|
338
|
+
check(
|
|
339
|
+
"CI audit asks for a verified rev",
|
|
340
|
+
/--verify --quiet/.test(wf) && !/rev-parse HEAD~1"/.test(wf) ? 1 : 0,
|
|
341
|
+
1
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// uninstall: --keep-config drops runtime state, --all drops the config too
|
|
345
|
+
const urepo = path.join(base, "uninstall-repo");
|
|
346
|
+
const uhome = path.join(base, "uninstall-home");
|
|
347
|
+
fs.mkdirSync(urepo, { recursive: true });
|
|
348
|
+
gitOut(["init", "-q", "."], urepo);
|
|
349
|
+
fs.writeFileSync(path.join(urepo, "a.txt"), "a", "utf8");
|
|
350
|
+
gitOut(["add", "."], urepo);
|
|
351
|
+
gitOut(["-c", "user.name=a", "-c", "user.email=a@b", "commit", "-qm", "a"], urepo);
|
|
352
|
+
const u = (args, extra) => run(args, urepo, Object.assign({ SIGNOFF_HOME: uhome }, extra || {}));
|
|
353
|
+
u(["init", "--no-ci", "--quiet"]);
|
|
354
|
+
fs.writeFileSync(path.join(urepo, ".signoff", "passed"), '{"tree":"x"}', "utf8");
|
|
355
|
+
r = u(["uninstall", "--keep-config"]);
|
|
356
|
+
check("uninstall --keep-config keeps the config", fs.existsSync(path.join(urepo, ".signoff", "config.json")) ? 1 : 0, 1);
|
|
357
|
+
check("uninstall --keep-config drops the marker", fs.existsSync(path.join(urepo, ".signoff", "passed")) ? 0 : 1, 1);
|
|
358
|
+
check("uninstall --keep-config drops the install point", fs.existsSync(path.join(urepo, ".signoff", "installed.json")) ? 0 : 1, 1);
|
|
359
|
+
u(["init", "--no-ci", "--quiet"]);
|
|
360
|
+
r = u(["uninstall", "--all"]);
|
|
361
|
+
check("uninstall --all drops the config", fs.existsSync(path.join(urepo, ".signoff", "config.json")) ? 0 : 1, 1);
|
|
362
|
+
|
|
363
|
+
// one hook run at a time: a live lock refuses, a stale one is broken
|
|
364
|
+
u(["init", "--no-ci", "--quiet"]);
|
|
365
|
+
const lk = path.join(urepo, ".signoff", "lock");
|
|
366
|
+
fs.writeFileSync(lk, JSON.stringify({ pid: process.pid, stage: "pre-commit", ts: new Date().toISOString() }), "utf8");
|
|
367
|
+
fs.writeFileSync(path.join(urepo, "z.txt"), "z", "utf8");
|
|
368
|
+
gitOut(["add", "."], urepo);
|
|
369
|
+
r = u(["hook", "pre-commit"], { SIGNOFF_LOCK_WAIT_MS: "0" });
|
|
370
|
+
check("live lock refuses the run", r.status, 1, (r.stderr || "").trim());
|
|
371
|
+
check("live lock names the holder", /holds the lock/.test(r.stderr) ? 1 : 0, 1);
|
|
372
|
+
fs.writeFileSync(lk, JSON.stringify({ pid: 999999, stage: "pre-commit", ts: new Date(Date.now() - 3600e3).toISOString() }), "utf8");
|
|
373
|
+
r = u(["hook", "pre-commit"], { SIGNOFF_LOCK_WAIT_MS: "0" });
|
|
374
|
+
check("stale lock is broken", r.status === 0 ? 1 : 0, 1, (r.stdout || "").trim());
|
|
375
|
+
check("lock released after the run", fs.existsSync(lk) ? 0 : 1, 1);
|
|
376
|
+
|
|
377
|
+
// doctor --fix reinstalls hooks it can see are gone, then re-checks
|
|
378
|
+
gitOut(["reset", "-q"], urepo);
|
|
379
|
+
const fixHook = path.join(urepo, ".git", "hooks", "pre-commit");
|
|
380
|
+
fs.rmSync(fixHook, { force: true });
|
|
381
|
+
r = u(["doctor", "--fix", "--quiet"]);
|
|
382
|
+
check("doctor --fix reinstalls the hook", fs.existsSync(fixHook) ? 1 : 0, 1, (r.stdout || "").trim());
|
|
383
|
+
check("doctor --fix exits ok after repair", r.status, 0, (r.stdout || "").trim());
|
|
384
|
+
r = u(["doctor", "--fix"]);
|
|
385
|
+
check("doctor --fix is idempotent", /nothing to repair/.test(r.stdout) ? 1 : 0, 1, (r.stdout || "").trim());
|
|
386
|
+
|
|
387
|
+
// audit refuses an install point that is not an ancestor of HEAD, and --reset-install fixes it
|
|
388
|
+
const uipath = path.join(urepo, ".signoff", "installed.json");
|
|
389
|
+
fs.writeFileSync(uipath, JSON.stringify({ sha: "0".repeat(40), ts: new Date().toISOString(), version: "3.9.0" }), "utf8");
|
|
390
|
+
r = u(["audit", "--since-install"]);
|
|
391
|
+
check("audit refuses a foreign install point", r.status, 2, (r.stderr || "").trim());
|
|
392
|
+
check("audit says why", /not an ancestor of HEAD/.test(r.stderr) ? 1 : 0, 1);
|
|
393
|
+
r = u(["init", "--reset-install", "--no-ci", "--no-push", "--quiet"]);
|
|
394
|
+
const fixedPoint = JSON.parse(fs.readFileSync(uipath, "utf8"));
|
|
395
|
+
check("init --reset-install records HEAD", fixedPoint.sha, gitOut(["rev-parse", "HEAD"], urepo).trim());
|
|
396
|
+
r = u(["audit", "--since-install"]);
|
|
397
|
+
check("audit works after the reset", r.status, 0, (r.stderr || "").trim());
|
|
398
|
+
|
|
399
|
+
// init --lock-test pins what detection found, so another machine cannot switch runners
|
|
400
|
+
const prepo = path.join(base, "pin-repo");
|
|
401
|
+
const phome = path.join(base, "pin-home");
|
|
402
|
+
fs.mkdirSync(prepo, { recursive: true });
|
|
403
|
+
gitOut(["init", "-q", "."], prepo);
|
|
404
|
+
fs.writeFileSync(
|
|
405
|
+
path.join(prepo, "package.json"),
|
|
406
|
+
JSON.stringify({ name: "pin", devDependencies: { vitest: "^1.0.0" } }, null, 2),
|
|
407
|
+
"utf8"
|
|
408
|
+
);
|
|
409
|
+
fs.writeFileSync(path.join(prepo, "a.txt"), "a", "utf8");
|
|
410
|
+
gitOut(["add", "."], prepo);
|
|
411
|
+
const pin = (args) => run(args, prepo, { SIGNOFF_HOME: phome });
|
|
412
|
+
r = pin(["init", "--lock-test", "--no-ci", "--no-push"]);
|
|
413
|
+
const pinned = JSON.parse(fs.readFileSync(path.join(prepo, ".signoff", "config.json"), "utf8"));
|
|
414
|
+
check("init --lock-test pins the runner", pinned.test_cmd, "npx --no-install vitest run");
|
|
415
|
+
check("init --lock-test says what it locked", /locked\s+npx --no-install vitest run/.test(r.stdout) ? 1 : 0, 1, (r.stdout || "").trim());
|
|
416
|
+
|
|
417
|
+
// the lock directory shape blocks too, and a dead owner breaks it
|
|
418
|
+
const dlk = path.join(urepo, ".signoff", "lock");
|
|
419
|
+
fs.mkdirSync(dlk, { recursive: true });
|
|
420
|
+
fs.writeFileSync(path.join(dlk, "owner.json"), JSON.stringify({ pid: process.pid, stage: "pre-commit", ts: new Date().toISOString() }), "utf8");
|
|
421
|
+
r = u(["hook", "pre-commit"], { SIGNOFF_LOCK_WAIT_MS: "0" });
|
|
422
|
+
check("directory lock blocks", r.status, 1, (r.stderr || "").trim());
|
|
423
|
+
fs.writeFileSync(path.join(dlk, "owner.json"), JSON.stringify({ pid: 999999, stage: "pre-commit", ts: new Date().toISOString() }), "utf8");
|
|
424
|
+
u(["hook", "pre-commit"], { SIGNOFF_LOCK_WAIT_MS: "0" });
|
|
425
|
+
check("dead owner breaks the directory lock", fs.existsSync(dlk) ? 0 : 1, 1);
|
|
426
|
+
|
|
427
|
+
// p4 export --merge: another repo's ledger joins the table, tampering is reported
|
|
428
|
+
const lrepo = path.join(base, "ledger-repo");
|
|
429
|
+
const lhome = path.join(base, "ledger-home");
|
|
430
|
+
fs.mkdirSync(lrepo, { recursive: true });
|
|
431
|
+
gitOut(["init", "-q", "."], lrepo);
|
|
432
|
+
fs.writeFileSync(path.join(lrepo, "a.txt"), "a", "utf8");
|
|
433
|
+
gitOut(["add", "."], lrepo);
|
|
434
|
+
gitOut(["-c", "user.name=a", "-c", "user.email=a@b", "commit", "-qm", "a"], lrepo);
|
|
435
|
+
const l = (args) => run(args, lrepo, { SIGNOFF_HOME: lhome });
|
|
436
|
+
const askOut = l(["p4", "ask", "--action", "publish", "--target", "demo@1.0.0"]);
|
|
437
|
+
const lticket = (askOut.stdout.match(/ticket (\S+)/) || [])[1];
|
|
438
|
+
l(["p4", "confirm", "--ticket", lticket, "--decision", "allow", "--detail", "d", "--rollback", "HEAD"]);
|
|
439
|
+
const ledgerFile = path.join(lhome, "audit.log");
|
|
440
|
+
check("ledger file written", fs.existsSync(ledgerFile) ? 1 : 0, 1);
|
|
441
|
+
const other = path.join(base, "other-ledger.jsonl");
|
|
442
|
+
fs.copyFileSync(ledgerFile, other);
|
|
443
|
+
const expOut = path.join(base, "merged.md");
|
|
444
|
+
r = l(["p4", "export", "--out", expOut, "--merge", other]);
|
|
445
|
+
check("export --merge", r.status, 0, (r.stdout || "").trim());
|
|
446
|
+
const md = fs.readFileSync(expOut, "utf8");
|
|
447
|
+
check("merged table has a repo column", /\| # \| ts \| repo \|/.test(md) ? 1 : 0, 1);
|
|
448
|
+
check("merged table has both rows", (md.match(/^\| \d+ \|/gm) || []).length, 2);
|
|
449
|
+
const tampered = path.join(base, "tampered-ledger.jsonl");
|
|
450
|
+
fs.writeFileSync(tampered, fs.readFileSync(other, "utf8").replace('"decision": "allow"', '"decision": "deny"'), "utf8");
|
|
451
|
+
r = l(["p4", "export", "--out", path.join(base, "t.md"), "--merge", tampered]);
|
|
452
|
+
check("merge reports a broken chain", r.status === 0 ? 0 : 1, 1, (r.stderr || "").trim());
|
|
453
|
+
const jsonOut = path.join(base, "ledger.json");
|
|
454
|
+
r = l(["p4", "export", "--out", jsonOut, "--format", "json", "--merge", other]);
|
|
455
|
+
const parsed = fs.existsSync(jsonOut) ? JSON.parse(fs.readFileSync(jsonOut, "utf8")) : [];
|
|
456
|
+
check("export --format json", Array.isArray(parsed) && parsed.length === 2 && parsed[0].hash ? 1 : 0, 1);
|
|
457
|
+
const csvOut = path.join(base, "ledger.csv");
|
|
458
|
+
l(["p4", "export", "--out", csvOut, "--format", "csv"]);
|
|
459
|
+
const csv = fs.readFileSync(csvOut, "utf8").trim().split(/\r?\n/);
|
|
460
|
+
check("export --format csv has a header and one row", csv.length === 2 && csv[0].startsWith("ts,repo,action") ? 1 : 0, 1);
|
|
461
|
+
check("export rejects an unknown format", l(["p4", "export", "--format", "xml"]).status, 2);
|
|
462
|
+
const hrepo = path.join(base, "hint-repo");
|
|
463
|
+
fs.mkdirSync(hrepo, { recursive: true });
|
|
464
|
+
gitOut(["init", "-q", "."], hrepo);
|
|
465
|
+
for (const n of ["a", "b", "c"]) {
|
|
466
|
+
fs.writeFileSync(path.join(hrepo, `${n}.txt`), n, "utf8");
|
|
467
|
+
gitOut(["add", "."], hrepo);
|
|
468
|
+
gitOut(["-c", "user.name=a", "-c", "user.email=a@b", "commit", "-qm", n], hrepo);
|
|
469
|
+
}
|
|
470
|
+
run(["init", "--no-ci"], hrepo);
|
|
471
|
+
const hsha = JSON.parse(fs.readFileSync(path.join(hrepo, ".signoff", "installed.json"), "utf8")).sha;
|
|
472
|
+
const hinted = run(["audit", "--range", `${hsha}~2..HEAD`], hrepo);
|
|
473
|
+
check("audit hints pre-install commits", /predate the install point/.test(hinted.stdout) ? 1 : 0, 1, hinted.stdout.trim().replace(/\n/g, " | "));
|
|
474
|
+
check("audit --since-install in fresh repo", run(["audit", "--since-install"], hrepo).status, 0);
|
|
475
|
+
const hooks = path.join(repo, ".git", "hooks");
|
|
476
|
+
check("guard pre-commit hook", fs.existsSync(path.join(hooks, "pre-commit")) ? 1 : 0, 1);
|
|
477
|
+
check("guard commit-msg hook", fs.existsSync(path.join(hooks, "commit-msg")) ? 1 : 0, 1);
|
|
478
|
+
check("guard pre-push hook", fs.existsSync(path.join(hooks, "pre-push")) ? 1 : 0, 1);
|
|
479
|
+
check("guard config", fs.existsSync(path.join(repo, ".signoff", "config.json")) ? 1 : 0, 1);
|
|
480
|
+
check(
|
|
481
|
+
"guard gitignore",
|
|
482
|
+
fs.readFileSync(path.join(repo, ".gitignore"), "utf8").split("\n").includes(".signoff/") ? 1 : 0,
|
|
483
|
+
1
|
|
484
|
+
);
|
|
485
|
+
r = g(["status", "--json"]);
|
|
486
|
+
check("guard status", JSON.parse(r.stdout).installed["pre-commit"] ? 1 : 0, 1);
|
|
487
|
+
|
|
488
|
+
fs.writeFileSync(path.join(repo, "docs", "clean.md"), "# clean\n\n40 cases in 3.6 s, 12/12 PASS.\nsecond\n", "utf8");
|
|
489
|
+
git(["add", "-A"], repo);
|
|
490
|
+
r = g(["hook", "pre-push"]);
|
|
491
|
+
check("guard pre-push pass", r.status, 0);
|
|
492
|
+
check("pre-push leaves no marker", fs.existsSync(path.join(repo, ".signoff", "passed")) ? 0 : 1, 1);
|
|
493
|
+
r = g(["hook", "pre-commit"]);
|
|
494
|
+
check("guard hook pass", r.status, 0);
|
|
495
|
+
const markerAfterPass = JSON.parse(fs.readFileSync(path.join(repo, ".signoff", "passed"), "utf8"));
|
|
496
|
+
check("marker mode pass", markerAfterPass.mode, "pass");
|
|
497
|
+
check("marker tree is the staged tree", markerAfterPass.tree, gitOut(["write-tree"], repo).trim());
|
|
498
|
+
check("marker ts is iso", /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(markerAfterPass.ts) ? 1 : 0, 1, markerAfterPass.ts);
|
|
499
|
+
r = g(["hook", "pre-commit"]);
|
|
500
|
+
check("guard cache hit", /cached/.test(r.stdout) ? 1 : 0, 1);
|
|
501
|
+
check(
|
|
502
|
+
"marker written on cache hit",
|
|
503
|
+
fs.existsSync(path.join(repo, ".signoff", "passed")) ? 1 : 0,
|
|
504
|
+
1
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
fs.writeFileSync(path.join(repo, "docs", "dirty.md"), "# dirty\n\n该方案赋能全链路,闭环彻底解决,已验证。\nagain\n", "utf8");
|
|
508
|
+
git(["add", "-A"], repo);
|
|
509
|
+
r = g(["hook", "pre-commit"]);
|
|
510
|
+
check("guard hook fail closed", r.status, 1);
|
|
511
|
+
|
|
512
|
+
r = g(["hook", "pre-commit"], { SIGNOFF_SKIP: "1" });
|
|
513
|
+
check("guard skip allowed", r.status, 0);
|
|
514
|
+
check("guard skip recorded", fs.existsSync(path.join(repo, ".signoff", "skips.log")) ? 1 : 0, 1);
|
|
515
|
+
check(
|
|
516
|
+
"marker mode skip",
|
|
517
|
+
JSON.parse(fs.readFileSync(path.join(repo, ".signoff", "passed"), "utf8")).mode,
|
|
518
|
+
"skip"
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
// ------------------------------------------------------------- trailers
|
|
522
|
+
// A real commit: pre-commit records the tree, commit-msg turns it into a line.
|
|
523
|
+
git(["reset", "--hard", "-q"], repo);
|
|
524
|
+
fs.mkdirSync(path.join(repo, "src"), { recursive: true });
|
|
525
|
+
const treeOf = (rev) => gitOut(["rev-parse", `${rev}^{tree}`], repo).trim();
|
|
526
|
+
const bodyOf = (rev) => gitOut(["log", "-1", "--format=%B", rev], repo);
|
|
527
|
+
const lastLine = (rev) => bodyOf(rev).trim().split(/\r?\n/).pop() || "";
|
|
528
|
+
|
|
529
|
+
fs.writeFileSync(path.join(repo, "src", "note.txt"), "hello\n", "utf8");
|
|
530
|
+
git(["add", "-A"], repo);
|
|
531
|
+
let hc = commit(["commit", "-m", "trailer pass"]);
|
|
532
|
+
check("trailer commit exit", hc.status, 0, (hc.stderr || "").trim().split("\n").pop());
|
|
533
|
+
let m = lastLine("HEAD").match(/^Signoff-Pass: ([0-9a-f]{12})$/);
|
|
534
|
+
check("trailer pass appended", m ? 1 : 0, 1, lastLine("HEAD"));
|
|
535
|
+
check("trailer pass matches tree", m && m[1] === treeOf("HEAD").slice(0, 12) ? 1 : 0, 1);
|
|
536
|
+
let ar = run(["audit", "--range", "HEAD~1..HEAD"], repo);
|
|
537
|
+
check("audit pass exit", ar.status, 0);
|
|
538
|
+
check("audit pass state", /^[0-9a-f]{7} pass$/m.test(ar.stdout) ? 1 : 0, 1, ar.stdout.trim().replace(/\n/g, " | "));
|
|
539
|
+
check("audit pass summary", /audit: 1 commits, pass=1 skip=0 missing=0 skipped_merges=0/.test(ar.stdout) ? 1 : 0, 1);
|
|
540
|
+
AUDIT.push(ar.stdout.trim());
|
|
541
|
+
|
|
542
|
+
// A stale marker for another tree must never touch a message.
|
|
543
|
+
const mf = path.join(repo, ".git", "MSGTEST");
|
|
544
|
+
const markerFile = path.join(repo, ".signoff", "passed");
|
|
545
|
+
fs.writeFileSync(mf, "mismatch\n", "utf8");
|
|
546
|
+
fs.writeFileSync(
|
|
547
|
+
markerFile,
|
|
548
|
+
JSON.stringify({ tree: "0".repeat(40), mode: "pass", ts: "2026-01-01T00:00:00Z" }) + "\n",
|
|
549
|
+
"utf8"
|
|
550
|
+
);
|
|
551
|
+
r = run(["hook", "commit-msg", mf], repo);
|
|
552
|
+
check("commit-msg mismatch exit", r.status, 0);
|
|
553
|
+
check("commit-msg mismatch untouched", fs.readFileSync(mf, "utf8"), "mismatch\n");
|
|
554
|
+
check("commit-msg mismatch consumes marker", fs.existsSync(markerFile) ? 0 : 1, 1);
|
|
555
|
+
r = run(["hook", "commit-msg", mf], repo);
|
|
556
|
+
check("commit-msg without marker exit", r.status, 0);
|
|
557
|
+
check("commit-msg without marker untouched", fs.readFileSync(mf, "utf8"), "mismatch\n");
|
|
558
|
+
|
|
559
|
+
// --no-verify skips both hooks: no trailer, and audit has to say so.
|
|
560
|
+
fs.writeFileSync(path.join(repo, "src", "note.txt"), "hello\nunverified\n", "utf8");
|
|
561
|
+
git(["add", "-A"], repo);
|
|
562
|
+
hc = commit(["commit", "--no-verify", "-m", "no verify"]);
|
|
563
|
+
check("no-verify commit exit", hc.status, 0);
|
|
564
|
+
check("no-verify leaves no trailer", /^Signoff-(Pass|Skip):/m.test(bodyOf("HEAD")) ? 0 : 1, 1);
|
|
565
|
+
ar = run(["audit", "--range", "HEAD~1..HEAD"], repo);
|
|
566
|
+
check("audit missing exit", ar.status, 1);
|
|
567
|
+
check("audit missing state", /^[0-9a-f]{7} missing$/m.test(ar.stdout) ? 1 : 0, 1);
|
|
568
|
+
AUDIT.push(ar.stdout.trim());
|
|
569
|
+
|
|
570
|
+
// An explicit skip is recorded, and audit is honest about it.
|
|
571
|
+
fs.writeFileSync(path.join(repo, "src", "note.txt"), "hello\nskipped\n", "utf8");
|
|
572
|
+
git(["add", "-A"], repo);
|
|
573
|
+
hc = commit(["commit", "-m", "skipped"], { SIGNOFF_SKIP: "1" });
|
|
574
|
+
check("skip commit exit", hc.status, 0, (hc.stderr || "").trim().split("\n").pop());
|
|
575
|
+
m = lastLine("HEAD").match(/^Signoff-Skip: ([0-9a-f]{12})$/);
|
|
576
|
+
check("trailer skip appended", m ? 1 : 0, 1, lastLine("HEAD"));
|
|
577
|
+
check("trailer skip matches tree", m && m[1] === treeOf("HEAD").slice(0, 12) ? 1 : 0, 1);
|
|
578
|
+
ar = run(["audit", "--range", "HEAD~1..HEAD"], repo);
|
|
579
|
+
check("audit skip exit", ar.status, 0);
|
|
580
|
+
check("audit skip state", /^[0-9a-f]{7} skip$/m.test(ar.stdout) ? 1 : 0, 1);
|
|
581
|
+
AUDIT.push(ar.stdout.trim());
|
|
582
|
+
check("audit strict fails on skip", run(["audit", "--range", "HEAD~1..HEAD", "--strict"], repo).status, 1);
|
|
583
|
+
const aj = JSON.parse(run(["audit", "--range", "HEAD~1..HEAD", "--json"], repo).stdout);
|
|
584
|
+
check("audit json ok", aj.ok, true);
|
|
585
|
+
check("audit json counts", JSON.stringify(aj.counts), JSON.stringify({ pass: 0, skip: 1, missing: 0 }));
|
|
586
|
+
check("audit json commit state", aj.commits.length === 1 ? aj.commits[0].state : "none", "skip");
|
|
587
|
+
check("audit default range", run(["audit"], repo).status, 0);
|
|
588
|
+
fs.mkdirSync(path.join(base, "notrepo"), { recursive: true });
|
|
589
|
+
check("audit outside repo", run(["audit"], path.join(base, "notrepo")).status, 2);
|
|
590
|
+
check("audit unparsable range", run(["audit", "--range", "nope..nope2"], repo).status, 2);
|
|
591
|
+
|
|
592
|
+
// Merges have no single staged tree: counted, never graded.
|
|
593
|
+
const branch = gitOut(["symbolic-ref", "--short", "HEAD"], repo).trim();
|
|
594
|
+
git(["checkout", "-q", "-b", "side"], repo);
|
|
595
|
+
fs.writeFileSync(path.join(repo, "src", "side.txt"), "side\n", "utf8");
|
|
596
|
+
git(["add", "-A"], repo);
|
|
597
|
+
check("side commit exit", commit(["commit", "-m", "side pass"]).status, 0);
|
|
598
|
+
git(["checkout", "-q", branch], repo);
|
|
599
|
+
fs.writeFileSync(path.join(repo, "src", "main.txt"), "main\n", "utf8");
|
|
600
|
+
git(["add", "-A"], repo);
|
|
601
|
+
check("main commit exit", commit(["commit", "-m", "main pass"]).status, 0);
|
|
602
|
+
check("merge commit exit", commit(["merge", "--no-ff", "-m", "merge side", "side"]).status, 0);
|
|
603
|
+
ar = run(["audit", "--range", "HEAD~2..HEAD"], repo);
|
|
604
|
+
check("audit merge exit", ar.status, 0, ar.stdout.trim().replace(/\n/g, " | "));
|
|
605
|
+
check("audit merge skipped", /skipped_merges=1/.test(ar.stdout) ? 1 : 0, 1, ar.stdout.trim().replace(/\n/g, " | "));
|
|
606
|
+
|
|
607
|
+
// prepare is refused when the CLI would be absent during install
|
|
608
|
+
const bare = path.join(base, "bare");
|
|
609
|
+
fs.mkdirSync(bare, { recursive: true });
|
|
610
|
+
fs.writeFileSync(path.join(bare, "package.json"), JSON.stringify({ name: "bare", version: "0.0.0" }) + "\n");
|
|
611
|
+
git(["init", "-q"], bare);
|
|
612
|
+
r = run(["init", "--no-ci", "--prepare"], bare, { SIGNOFF_HOME: path.join(base, "bare-home") });
|
|
613
|
+
check("prepare skipped without devDependency", r.status, 0);
|
|
614
|
+
check(
|
|
615
|
+
"prepare not written without devDependency",
|
|
616
|
+
JSON.parse(fs.readFileSync(path.join(bare, "package.json"), "utf8")).scripts ? 0 : 1,
|
|
617
|
+
1
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
// ----------------------------------------------- init --prepare / --quiet
|
|
621
|
+
const pkgPath = path.join(repo, "package.json");
|
|
622
|
+
const pkg0 = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
623
|
+
pkg0.scripts = { build: "node -e 0" };
|
|
624
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg0, null, 2) + "\n", "utf8");
|
|
625
|
+
r = g(["init", "--dry-run", "--no-ci", "--prepare"]);
|
|
626
|
+
check("prepare dry-run exit", r.status, 0);
|
|
627
|
+
check(
|
|
628
|
+
"prepare dry-run writes nothing",
|
|
629
|
+
JSON.parse(fs.readFileSync(pkgPath, "utf8")).scripts.prepare === undefined ? 1 : 0,
|
|
630
|
+
1
|
|
631
|
+
);
|
|
632
|
+
r = g(["init", "--no-ci", "--quiet", "--prepare"]);
|
|
633
|
+
check("init --quiet silent", (r.stdout || "").trim() === "" ? 1 : 0, 1, (r.stdout || "").trim());
|
|
634
|
+
check("init --prepare exit", r.status, 0);
|
|
635
|
+
check("prepare added when absent", JSON.parse(fs.readFileSync(pkgPath, "utf8")).scripts.prepare, "signoff init --quiet");
|
|
636
|
+
const pkg1 = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
637
|
+
pkg1.scripts.prepare = "node -e 0";
|
|
638
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg1, null, 2) + "\n", "utf8");
|
|
639
|
+
r = g(["init", "--no-ci", "--quiet", "--prepare"]);
|
|
640
|
+
check("prepare merged with old value", JSON.parse(fs.readFileSync(pkgPath, "utf8")).scripts.prepare, "node -e 0 && signoff init --quiet");
|
|
641
|
+
r = g(["init", "--no-ci", "--quiet", "--prepare"]);
|
|
642
|
+
check("prepare idempotent", JSON.parse(fs.readFileSync(pkgPath, "utf8")).scripts.prepare, "node -e 0 && signoff init --quiet");
|
|
643
|
+
check("prepare added once", (fs.readFileSync(pkgPath, "utf8").match(/signoff init --quiet/g) || []).length, 1);
|
|
644
|
+
|
|
645
|
+
// ------------------------------------------------------------ status
|
|
646
|
+
r = g(["status", "--json"]);
|
|
647
|
+
const stj = JSON.parse(r.stdout);
|
|
648
|
+
check("status trailer true", stj.trailer, true);
|
|
649
|
+
check("status commit-msg hook", stj.installed["commit-msg"] ? 1 : 0, 1);
|
|
650
|
+
check("status pre-commit hook", stj.installed["pre-commit"] ? 1 : 0, 1);
|
|
651
|
+
r = g(["status"]);
|
|
652
|
+
check("status human trailer", /^trailer\s+enforced$/m.test(r.stdout) ? 1 : 0, 1, r.stdout.trim().replace(/\n/g, " | "));
|
|
653
|
+
check("status human commit-msg", /^commit-msg\s+installed$/m.test(r.stdout) ? 1 : 0, 1);
|
|
654
|
+
|
|
655
|
+
// Trailer edge cases: no marker and a stale marker must both leave the message alone.
|
|
656
|
+
const msgFile = path.join(base, "MSG.txt");
|
|
657
|
+
fs.writeFileSync(msgFile, "subject\n", "utf8");
|
|
658
|
+
r = g(["hook", "commit-msg", msgFile]);
|
|
659
|
+
check("commit-msg without marker exits 0", r.status, 0);
|
|
660
|
+
check("commit-msg without marker adds nothing", fs.readFileSync(msgFile, "utf8").trim(), "subject");
|
|
661
|
+
fs.mkdirSync(path.join(repo, ".signoff"), { recursive: true });
|
|
662
|
+
fs.writeFileSync(
|
|
663
|
+
path.join(repo, ".signoff", "passed"),
|
|
664
|
+
JSON.stringify({ tree: "0".repeat(40), mode: "pass", ts: new Date().toISOString() }),
|
|
665
|
+
"utf8"
|
|
666
|
+
);
|
|
667
|
+
r = g(["hook", "commit-msg", msgFile]);
|
|
668
|
+
check("commit-msg stale marker exits 0", r.status, 0);
|
|
669
|
+
check("commit-msg stale marker adds nothing", fs.readFileSync(msgFile, "utf8").trim(), "subject");
|
|
670
|
+
check("commit-msg stale marker consumed", fs.existsSync(path.join(repo, ".signoff", "passed")) ? 0 : 1, 1);
|
|
671
|
+
|
|
672
|
+
r = g(["uninstall"]);
|
|
673
|
+
check("guard uninstall", fs.existsSync(path.join(hooks, "pre-commit")) ? 0 : 1, 1);
|
|
674
|
+
check("uninstall removes commit-msg", fs.existsSync(path.join(hooks, "commit-msg")) ? 0 : 1, 1);
|
|
675
|
+
r = g(["init", "--dry-run", "--no-ci"]);
|
|
676
|
+
check("guard dry-run writes nothing", fs.existsSync(path.join(hooks, "pre-commit")) ? 0 : 1, 1);
|
|
677
|
+
|
|
678
|
+
// doctor: a read-only self-check that has to agree with the gates it summarizes.
|
|
679
|
+
const drepo = path.join(base, "doctor-repo");
|
|
680
|
+
fs.mkdirSync(drepo, { recursive: true });
|
|
681
|
+
gitOut(["init", "-q", "."], drepo);
|
|
682
|
+
fs.writeFileSync(path.join(drepo, "a.txt"), "a", "utf8");
|
|
683
|
+
gitOut(["add", "."], drepo);
|
|
684
|
+
gitOut(["-c", "user.name=a", "-c", "user.email=a@b", "commit", "-qm", "a"], drepo);
|
|
685
|
+
const dhome = path.join(base, "doctor-home");
|
|
686
|
+
const d = (args) => run(args, drepo, { SIGNOFF_HOME: dhome });
|
|
687
|
+
check("--version prints a version", /^\d+\.\d+\.\d+$/.test(run(["--version"]).stdout.trim()) ? 1 : 0, 1);
|
|
688
|
+
check("help carries the current name", /落印/.test(run(["--help"]).stdout) ? 1 : 0, 1);
|
|
689
|
+
check("doctor unguarded repo", d(["doctor"]).status, 1);
|
|
690
|
+
d(["init", "--no-ci"]);
|
|
691
|
+
check("doctor guarded repo", d(["doctor"]).status, 0, d(["doctor", "--quiet"]).stdout.trim());
|
|
692
|
+
const dj = JSON.parse(d(["doctor", "--json"]).stdout);
|
|
693
|
+
check("doctor json ok", dj.ok, true);
|
|
694
|
+
check("doctor json has checks", dj.checks.length > 10 ? 1 : 0, 1);
|
|
695
|
+
check("doctor quiet hides ok lines", /^ok /m.test(d(["doctor", "--quiet"]).stdout) ? 1 : 0, 0);
|
|
696
|
+
check("doctor outside repo", run(["doctor"], path.join(base, "notrepo")).status, 2);
|
|
697
|
+
fs.writeFileSync(
|
|
698
|
+
path.join(drepo, ".signoff", "passed"),
|
|
699
|
+
JSON.stringify({ tree: "a".repeat(40), mode: "pass", ts: new Date().toISOString() }),
|
|
700
|
+
"utf8"
|
|
701
|
+
);
|
|
702
|
+
check("doctor leftover marker warns only", d(["doctor"]).status, 0);
|
|
703
|
+
check("doctor sees the leftover marker", /leftover/.test(d(["doctor", "--quiet"]).stdout) ? 1 : 0, 1);
|
|
704
|
+
} finally {
|
|
705
|
+
fs.rmSync(base, { recursive: true, force: true });
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// ---- zero-touch bootstrap: ensure, the machine layer, and the light guard
|
|
709
|
+
{
|
|
710
|
+
const base = fs.mkdtempSync(path.join(os.tmpdir(), "signoff-ensure-"));
|
|
711
|
+
try {
|
|
712
|
+
const home = path.join(base, "home");
|
|
713
|
+
const gitcfg = path.join(base, "gitconfig");
|
|
714
|
+
const gitcfgSystem = path.join(base, "gitconfig-system");
|
|
715
|
+
fs.writeFileSync(gitcfg, "", "utf8");
|
|
716
|
+
fs.writeFileSync(gitcfgSystem, "", "utf8");
|
|
717
|
+
// GIT_CONFIG_GLOBAL keeps the machine layer out of the real ~/.gitconfig.
|
|
718
|
+
const env = { SIGNOFF_HOME: home, GIT_CONFIG_GLOBAL: gitcfg, GIT_CONFIG_SYSTEM: gitcfgSystem };
|
|
719
|
+
const repo = path.join(base, "repo");
|
|
720
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
721
|
+
git(["init", "-q"], repo, env);
|
|
722
|
+
git(["config", "user.email", "a@b.c"], repo, env);
|
|
723
|
+
git(["config", "user.name", "t"], repo, env);
|
|
724
|
+
fs.writeFileSync(path.join(repo, "README.md"), "# fixture\n", "utf8");
|
|
725
|
+
|
|
726
|
+
const first = run(["ensure"], repo, { ...env, SIGNOFF_NO_BOOTSTRAP: "0" });
|
|
727
|
+
check("ensure exit code", first.status, 0);
|
|
728
|
+
check("ensure writes the pre-commit hook", fs.existsSync(path.join(repo, ".git", "hooks", "pre-commit")), true);
|
|
729
|
+
check("ensure writes config.json", fs.existsSync(path.join(repo, ".signoff", "config.json")), true);
|
|
730
|
+
check("ensure writes installed.json", fs.existsSync(path.join(repo, ".signoff", "installed.json")), true);
|
|
731
|
+
|
|
732
|
+
const second = JSON.parse(run(["ensure", "--json"], repo, { ...env, SIGNOFF_NO_BOOTSTRAP: "0" }).stdout);
|
|
733
|
+
check("ensure is idempotent", second.repo.installed, false);
|
|
734
|
+
check("ensure reports the repo guarded", second.repo.ok, true);
|
|
735
|
+
|
|
736
|
+
const g = JSON.parse(run(["ensure", "--global", "--json"], repo, env).stdout);
|
|
737
|
+
check("ensure --global ok", g.machine.ok, true);
|
|
738
|
+
check("machine hook written", fs.existsSync(path.join(home, "hooks", "pre-commit")), true);
|
|
739
|
+
const hooksPath = () =>
|
|
740
|
+
spawnSync("git", ["config", "--global", "--get", "core.hooksPath"], {
|
|
741
|
+
cwd: repo,
|
|
742
|
+
encoding: "utf8",
|
|
743
|
+
env: { ...process.env, ...env },
|
|
744
|
+
});
|
|
745
|
+
check("core.hooksPath set", hooksPath().stdout.trim().length > 0 ? 1 : 0, 1);
|
|
746
|
+
run(["uninstall", "--global"], repo, env);
|
|
747
|
+
check("uninstall --global unsets it", hooksPath().status, 1);
|
|
748
|
+
|
|
749
|
+
const fresh = (name) => {
|
|
750
|
+
const d = path.join(base, name);
|
|
751
|
+
fs.mkdirSync(d, { recursive: true });
|
|
752
|
+
git(["init", "-q"], d, env);
|
|
753
|
+
git(["config", "user.email", "a@b.c"], d, env);
|
|
754
|
+
git(["config", "user.name", "t"], d, env);
|
|
755
|
+
return d;
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
// The machine layer reaches unrepos that never opted in: only P1 runs, nothing is installed.
|
|
759
|
+
const raw = fresh("raw");
|
|
760
|
+
fs.writeFileSync(
|
|
761
|
+
path.join(raw, "package.json"),
|
|
762
|
+
JSON.stringify({ scripts: { test: 'node -e "process.exit(1)"' } }),
|
|
763
|
+
"utf8"
|
|
764
|
+
);
|
|
765
|
+
git(["add", "-A"], raw, env);
|
|
766
|
+
const light = run(["hook", "pre-commit"], raw, { SIGNOFF_HOME: home });
|
|
767
|
+
check("light guard blocks a failing test", light.status, 1);
|
|
768
|
+
check("light guard installs nothing", fs.existsSync(path.join(raw, ".signoff", "config.json")), false);
|
|
769
|
+
|
|
770
|
+
const good = fresh("good");
|
|
771
|
+
fs.writeFileSync(
|
|
772
|
+
path.join(good, "package.json"),
|
|
773
|
+
JSON.stringify({ scripts: { test: 'node -e "process.exit(0)"' } }),
|
|
774
|
+
"utf8"
|
|
775
|
+
);
|
|
776
|
+
git(["add", "-A"], good, env);
|
|
777
|
+
check("light guard passes a green test", run(["hook", "pre-commit"], good, { SIGNOFF_HOME: home }).status, 0);
|
|
778
|
+
|
|
779
|
+
// A committed signoff.json opts the whole repo in: the clone builds the install itself.
|
|
780
|
+
const shared = fresh("shared");
|
|
781
|
+
fs.writeFileSync(
|
|
782
|
+
path.join(shared, "package.json"),
|
|
783
|
+
JSON.stringify({ scripts: { test: 'node -e "process.exit(0)"' } }),
|
|
784
|
+
"utf8"
|
|
785
|
+
);
|
|
786
|
+
fs.writeFileSync(path.join(shared, "signoff.json"), JSON.stringify({ gates: ["p1", "p2", "p3", "p4", "p5"] }), "utf8");
|
|
787
|
+
git(["add", "-A"], shared, env);
|
|
788
|
+
check("declared repo passes through the machine layer", run(["hook", "pre-commit"], shared, { SIGNOFF_HOME: home }).status, 0);
|
|
789
|
+
check("declared repo installs config", fs.existsSync(path.join(shared, ".signoff", "config.json")), true);
|
|
790
|
+
check("declared repo installs hooks", fs.existsSync(path.join(shared, ".git", "hooks", "pre-commit")), true);
|
|
791
|
+
|
|
792
|
+
const sharedBad = fresh("shared-bad");
|
|
793
|
+
fs.writeFileSync(
|
|
794
|
+
path.join(sharedBad, "package.json"),
|
|
795
|
+
JSON.stringify({ scripts: { test: 'node -e "process.exit(1)"' } }),
|
|
796
|
+
"utf8"
|
|
797
|
+
);
|
|
798
|
+
fs.writeFileSync(path.join(sharedBad, "signoff.json"), "{}", "utf8");
|
|
799
|
+
git(["add", "-A"], sharedBad, env);
|
|
800
|
+
check("declared repo blocks a red gate", run(["hook", "pre-commit"], sharedBad, { SIGNOFF_HOME: home }).status, 1);
|
|
801
|
+
|
|
802
|
+
// init --share writes the declaration that makes the above work for teammates
|
|
803
|
+
const sharer = fresh("sharer");
|
|
804
|
+
const sr = run(["init", "--share", "--no-ci", "--quiet"], sharer, env);
|
|
805
|
+
check("init --share exit code", sr.status, 0, (sr.stderr || "").trim());
|
|
806
|
+
const decl = JSON.parse(fs.readFileSync(path.join(sharer, "signoff.json"), "utf8"));
|
|
807
|
+
check("init --share writes gates", Array.isArray(decl.gates) && decl.gates.length === 5 ? 1 : 0, 1);
|
|
808
|
+
check("init --share does not pin the runner", decl.test_cmd === undefined ? 1 : 0, 1);
|
|
809
|
+
|
|
810
|
+
// A bootstrap during the first commit lands without a sha; a later init repairs it.
|
|
811
|
+
const repair = fresh("repair");
|
|
812
|
+
check("init before the first commit", run(["init", "--no-ci", "--quiet"], repair, env).status, 0);
|
|
813
|
+
const ip = JSON.parse(fs.readFileSync(path.join(repair, ".signoff", "installed.json"), "utf8"));
|
|
814
|
+
check("install point starts without a sha", ip.sha === "" ? 1 : 0, 1);
|
|
815
|
+
fs.writeFileSync(path.join(repair, "a.txt"), "a", "utf8");
|
|
816
|
+
git(["add", "-A"], repair, env);
|
|
817
|
+
// --no-verify: this commit only needs to exist so the install point has a sha; the
|
|
818
|
+
// repo hook would look for a global `signoff` binary the CI runner does not have.
|
|
819
|
+
git(["-c", "user.name=a", "-c", "user.email=a@b", "commit", "-qm", "first", "--no-verify"], repair, env);
|
|
820
|
+
run(["init", "--no-ci", "--quiet"], repair, env);
|
|
821
|
+
const ip2 = JSON.parse(fs.readFileSync(path.join(repair, ".signoff", "installed.json"), "utf8"));
|
|
822
|
+
check("re-init fills the install point", /^[0-9a-f]{40}$/.test(ip2.sha) ? 1 : 0, 1);
|
|
823
|
+
check("audit --since-install works after the repair", run(["audit", "--since-install"], repair, env).status, 0);
|
|
824
|
+
|
|
825
|
+
// ---------------------------------------------------------- P0 features
|
|
826
|
+
// 1. secrets: a staged key fails the gate and is never printed in full.
|
|
827
|
+
const sec = fresh("secrets");
|
|
828
|
+
// The literal is assembled at run time so this file is not itself a hit for the
|
|
829
|
+
// scanner under test (a scanner that trips on its own fixtures gets allowlisted
|
|
830
|
+
// into uselessness).
|
|
831
|
+
const fakeAws = "AKIA" + "1234567890ABCDEF";
|
|
832
|
+
fs.writeFileSync(path.join(sec, "app.js"), `const k = "${fakeAws}";\n`, "utf8");
|
|
833
|
+
git(["add", "-A"], sec, env);
|
|
834
|
+
const sr1 = run(["secrets", "--staged", "--json"], sec, env);
|
|
835
|
+
check("secrets blocks a staged key", sr1.status, 1);
|
|
836
|
+
check("secrets reports one hit", JSON.parse(sr1.stdout).hits, 1);
|
|
837
|
+
const srState = JSON.parse(fs.readFileSync(path.join(sec, ".signoff", "secrets.json"), "utf8"));
|
|
838
|
+
check("secrets names the rule", srState.findings[0].rule, "aws-access-key");
|
|
839
|
+
check("secrets masks the value", !srState.findings[0].match.includes("1234567890ABCDEF"), true);
|
|
840
|
+
fs.writeFileSync(
|
|
841
|
+
path.join(sec, ".signoff", "config.json"),
|
|
842
|
+
JSON.stringify({ secrets: { allow: ["aws-access-key"] } }),
|
|
843
|
+
"utf8"
|
|
844
|
+
);
|
|
845
|
+
check("secrets honours the allowlist", run(["secrets", "--staged"], sec, env).status, 0);
|
|
846
|
+
const secClean = fresh("secrets-clean");
|
|
847
|
+
fs.writeFileSync(path.join(secClean, "a.md"), "# clean\n", "utf8");
|
|
848
|
+
git(["add", "-A"], secClean, env);
|
|
849
|
+
check("secrets passes a clean file", run(["secrets", "--staged"], secClean, env).status, 0);
|
|
850
|
+
|
|
851
|
+
// 2. secrets run inside the guard, before the suite.
|
|
852
|
+
const secGuard = fresh("secrets-guard");
|
|
853
|
+
const fakePat = "ghp_" + "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
854
|
+
fs.writeFileSync(path.join(secGuard, "app.js"), `const t = "${fakePat}";\n`, "utf8");
|
|
855
|
+
git(["add", "-A"], secGuard, env);
|
|
856
|
+
const sg = run(["hook", "pre-commit"], secGuard, { SIGNOFF_HOME: home });
|
|
857
|
+
check("guard fails on a staged key", sg.status, 1);
|
|
858
|
+
check("guard names the secrets gate", /secrets/.test(sg.stdout) ? 1 : 0, 1, (sg.stdout || "").trim().split("\n").slice(-12).join(" | "));
|
|
859
|
+
|
|
860
|
+
// 3. runner detection beyond npm/pytest.
|
|
861
|
+
const p1lib = require(path.join(ROOT, "lib", "p1.js"));
|
|
862
|
+
const det = (name, file) => {
|
|
863
|
+
const d = fresh("det-" + name + "-" + Date.now().toString(36));
|
|
864
|
+
fs.writeFileSync(path.join(d, file), "x", "utf8");
|
|
865
|
+
return p1lib.defaultCommands(d)[0];
|
|
866
|
+
};
|
|
867
|
+
check("p1 detects go", det("go", "go.mod"), "go test ./...");
|
|
868
|
+
check("p1 detects cargo", det("cargo", "Cargo.toml"), "cargo test --quiet");
|
|
869
|
+
check("p1 detects maven", det("maven", "pom.xml"), "mvn -q test");
|
|
870
|
+
check("p1 detects dotnet", det("dotnet", "app.csproj"), "dotnet test --nologo");
|
|
871
|
+
check("p1 detects elixir", det("elixir", "mix.exs"), "mix test");
|
|
872
|
+
|
|
873
|
+
// 4. cache: the same staged tree runs the suite once.
|
|
874
|
+
const cacheRepo = fresh("cache");
|
|
875
|
+
const counter = path.join(cacheRepo, "count.txt");
|
|
876
|
+
fs.writeFileSync(
|
|
877
|
+
path.join(cacheRepo, "package.json"),
|
|
878
|
+
JSON.stringify({ scripts: { test: `node -e "require('fs').appendFileSync('count.txt','x')"` } }),
|
|
879
|
+
"utf8"
|
|
880
|
+
);
|
|
881
|
+
git(["add", "-A"], cacheRepo, env);
|
|
882
|
+
const c1 = run(["p1"], cacheRepo, env);
|
|
883
|
+
check("p1 runs the suite", c1.status, 0);
|
|
884
|
+
check("p1 counter after the first run", fs.existsSync(counter) ? fs.readFileSync(counter, "utf8").length : 0, 1);
|
|
885
|
+
const c2 = run(["p1"], cacheRepo, env);
|
|
886
|
+
check("p1 cache hit on the same tree", /cached/.test(c2.stdout) ? 1 : 0, 1);
|
|
887
|
+
check("p1 cache did not rerun the suite", fs.readFileSync(counter, "utf8").length, 1);
|
|
888
|
+
const c3 = run(["p1"], cacheRepo, { SIGNOFF_HOME: HOME, SIGNOFF_NO_BOOTSTRAP: "1", SIGNOFF_NO_CACHE: "1" });
|
|
889
|
+
check("p1 --no-cache reruns", /cached/.test(c3.stdout) ? 0 : 1, 1);
|
|
890
|
+
check("p1 --no-cache counter grows", fs.readFileSync(counter, "utf8").length, 2);
|
|
891
|
+
|
|
892
|
+
// 5. docs-only fast path: a markdown change never pays for the suite.
|
|
893
|
+
const docsRepo = fresh("docsfast");
|
|
894
|
+
fs.mkdirSync(path.join(docsRepo, "docs"), { recursive: true });
|
|
895
|
+
fs.writeFileSync(
|
|
896
|
+
path.join(docsRepo, "package.json"),
|
|
897
|
+
JSON.stringify({ scripts: { test: 'node -e "process.exit(1)"' } }),
|
|
898
|
+
"utf8"
|
|
899
|
+
);
|
|
900
|
+
git(["add", "package.json"], docsRepo, env);
|
|
901
|
+
git(["-c", "user.name=a", "-c", "user.email=a@b", "commit", "-qm", "pkg", "--no-verify"], docsRepo, env);
|
|
902
|
+
fs.writeFileSync(path.join(docsRepo, "docs", "a.md"), "# note\n", "utf8");
|
|
903
|
+
git(["add", "docs/a.md"], docsRepo, env);
|
|
904
|
+
const d1 = run(["p1"], docsRepo, env);
|
|
905
|
+
check("p1 passes a docs-only change", d1.status, 0);
|
|
906
|
+
check("p1 says it was docs only", /docs only/.test(d1.stdout) ? 1 : 0, 1);
|
|
907
|
+
fs.writeFileSync(path.join(docsRepo, "code.js"), "x", "utf8");
|
|
908
|
+
git(["add", "-A"], docsRepo, env);
|
|
909
|
+
check("p1 runs the suite once code changes", run(["p1"], docsRepo, env).status, 1);
|
|
910
|
+
|
|
911
|
+
// 6. signed trailers: the signature travels with the commit and audit checks it.
|
|
912
|
+
const keyDir = path.join(base, "signkey");
|
|
913
|
+
fs.mkdirSync(keyDir, { recursive: true });
|
|
914
|
+
const keyPath = path.join(keyDir, "id");
|
|
915
|
+
const keygen = spawnSync("ssh-keygen", ["-t", "ed25519", "-N", "", "-C", "t@signoff", "-f", keyPath], {
|
|
916
|
+
encoding: "utf8",
|
|
917
|
+
});
|
|
918
|
+
check("fixture ssh key generated", keygen.status, 0, (keygen.stderr || "").trim());
|
|
919
|
+
const signEnv = { SIGNOFF_HOME: home, SIGNOFF_SIGN_KEY: keyPath, SIGNOFF_NO_BOOTSTRAP: "1" };
|
|
920
|
+
const signed = fresh("signed");
|
|
921
|
+
// A base commit first: audit needs a real HEAD~1 to have a range at all.
|
|
922
|
+
fs.writeFileSync(path.join(signed, "base.txt"), "b", "utf8");
|
|
923
|
+
git(["add", "-A"], signed, env);
|
|
924
|
+
git(["-c", "user.name=a", "-c", "user.email=a@b", "commit", "-qm", "base", "--no-verify"], signed, env);
|
|
925
|
+
fs.writeFileSync(
|
|
926
|
+
path.join(signed, "package.json"),
|
|
927
|
+
JSON.stringify({ scripts: { test: 'node -e "process.exit(0)"' } }),
|
|
928
|
+
"utf8"
|
|
929
|
+
);
|
|
930
|
+
fs.writeFileSync(path.join(signed, "a.txt"), "a", "utf8");
|
|
931
|
+
check("init --sign exits 0", run(["init", "--sign", "--no-ci", "--quiet"], signed, signEnv).status, 0);
|
|
932
|
+
check("init --sign records a key", fs.existsSync(path.join(signed, ".signoff", "signing_key")) ? 1 : 0, 1);
|
|
933
|
+
const signedCfg = JSON.parse(fs.readFileSync(path.join(signed, ".signoff", "config.json"), "utf8"));
|
|
934
|
+
check("init --sign pins sign_key", typeof signedCfg.sign_key === "string" ? 1 : 0, 1);
|
|
935
|
+
git(["add", "-A"], signed, env);
|
|
936
|
+
check("signed pre-commit", run(["hook", "pre-commit"], signed, signEnv).status, 0);
|
|
937
|
+
const msgRel = ".git/COMMIT_EDITMSG_TEST";
|
|
938
|
+
fs.writeFileSync(path.join(signed, msgRel), "test the signed trailer\n", "utf8");
|
|
939
|
+
check("commit-msg exits 0", run(["hook", "commit-msg", msgRel], signed, signEnv).status, 0);
|
|
940
|
+
const msgText = fs.readFileSync(path.join(signed, msgRel), "utf8");
|
|
941
|
+
check("trailer has Signoff-Pass", /^Signoff-Pass: [0-9a-f]{12}$/m.test(msgText) ? 1 : 0, 1);
|
|
942
|
+
check("trailer has Signoff-Key", /^Signoff-Key: ssh-ed25519 /m.test(msgText) ? 1 : 0, 1);
|
|
943
|
+
check("trailer has Signoff-Sig", /^Signoff-Sig: [A-Za-z0-9+/=]{40,}$/m.test(msgText) ? 1 : 0, 1);
|
|
944
|
+
git(
|
|
945
|
+
["-c", "user.name=a", "-c", "user.email=a@b", "commit", "-q", "-F", msgRel, "--no-verify"],
|
|
946
|
+
signed,
|
|
947
|
+
env
|
|
948
|
+
);
|
|
949
|
+
const av1 = run(["audit", "--range", "HEAD~1..HEAD", "--verify", "--json"], signed, signEnv);
|
|
950
|
+
check("audit --verify passes", av1.status, 0, (av1.stderr || "").trim());
|
|
951
|
+
check("audit sees a self-signed commit", JSON.parse(av1.stdout).signatures["self-signed"], 1);
|
|
952
|
+
fs.writeFileSync(
|
|
953
|
+
path.join(signed, ".signoff", "allowed_signers"),
|
|
954
|
+
fs.readFileSync(keyPath + ".pub", "utf8"),
|
|
955
|
+
"utf8"
|
|
956
|
+
);
|
|
957
|
+
const av2 = run(["audit", "--range", "HEAD~1..HEAD", "--verify", "--json"], signed, signEnv);
|
|
958
|
+
check("audit verifies against allowed_signers", JSON.parse(av2.stdout).signatures.verified, 1);
|
|
959
|
+
// A tampered tree cannot verify: the signature covers the full tree id.
|
|
960
|
+
const tamper = run(["audit", "--range", "HEAD~1..HEAD", "--verify", "--require-signed", "--json"], signed, signEnv);
|
|
961
|
+
check("audit --require-signed passes when signed", tamper.status, 0);
|
|
962
|
+
|
|
963
|
+
// 7. line coverage of the change.
|
|
964
|
+
const covRepo = fresh("cov");
|
|
965
|
+
const lines = [];
|
|
966
|
+
for (let i = 1; i <= 10; i++) lines.push(`const v${i} = ${i};`);
|
|
967
|
+
fs.writeFileSync(path.join(covRepo, "a.js"), lines.join("\n") + "\n", "utf8");
|
|
968
|
+
fs.mkdirSync(path.join(covRepo, "coverage"), { recursive: true });
|
|
969
|
+
const da = [];
|
|
970
|
+
for (let i = 1; i <= 10; i++) da.push(`DA:${i},${i <= 8 ? 1 : 0}`);
|
|
971
|
+
fs.writeFileSync(
|
|
972
|
+
path.join(covRepo, "coverage", "lcov.info"),
|
|
973
|
+
["SF:a.js", ...da, "end_of_record", ""].join("\n"),
|
|
974
|
+
"utf8"
|
|
975
|
+
);
|
|
976
|
+
git(["add", "-A"], covRepo, env);
|
|
977
|
+
git(["-c", "user.name=a", "-c", "user.email=a@b", "commit", "-qm", "base", "--no-verify"], covRepo, env);
|
|
978
|
+
check("cov fails under the threshold", run(["cov", "--min", "90"], covRepo, env).status, 1);
|
|
979
|
+
check("cov passes over the threshold", run(["cov", "--min", "70"], covRepo, env).status, 0);
|
|
980
|
+
const covJson = JSON.parse(run(["cov", "--min", "70", "--json"], covRepo, env).stdout);
|
|
981
|
+
check("cov reports 80 percent", covJson.pct, 80);
|
|
982
|
+
check("cov reads lcov", covJson.source, "coverage/lcov.info");
|
|
983
|
+
// Diff mode: a change on uncovered lines is the case that matters.
|
|
984
|
+
fs.writeFileSync(path.join(covRepo, "a.js"), lines.join("\n") + "\nconst extra = 11;\n", "utf8");
|
|
985
|
+
git(["add", "a.js"], covRepo, env);
|
|
986
|
+
const covDiff = run(["cov", "--diff", "--base", "HEAD", "--min", "80", "--json"], covRepo, env);
|
|
987
|
+
check("cov diff mode passes with no instrumented change", covDiff.status, 0);
|
|
988
|
+
check("cov diff mode is reported", JSON.parse(covDiff.stdout).mode, "diff");
|
|
989
|
+
check("cov without data exits 2", run(["cov", "--file", "missing.json"], covRepo, env).status, 2);
|
|
990
|
+
check("cov allows missing data", run(["cov", "--file", "missing.json", "--allow-missing"], covRepo, env).status, 0);
|
|
991
|
+
|
|
992
|
+
// 8. CI annotations: a finding becomes a line annotation, not a log paragraph.
|
|
993
|
+
const ciRepo = fresh("ci");
|
|
994
|
+
const ciFake = "AKIA" + "1234567890ABCDEF";
|
|
995
|
+
fs.writeFileSync(path.join(ciRepo, "app.js"), `const k = "${ciFake}";\n`, "utf8");
|
|
996
|
+
git(["add", "-A"], ciRepo, env);
|
|
997
|
+
const summaryFile = path.join(base, "step-summary.md");
|
|
998
|
+
const ciRun = run(["secrets", "--staged"], ciRepo, {
|
|
999
|
+
SIGNOFF_HOME: HOME,
|
|
1000
|
+
SIGNOFF_NO_BOOTSTRAP: "1",
|
|
1001
|
+
GITHUB_ACTIONS: "true",
|
|
1002
|
+
GITHUB_STEP_SUMMARY: summaryFile,
|
|
1003
|
+
});
|
|
1004
|
+
check("CI run fails on the secret", ciRun.status, 1);
|
|
1005
|
+
check(
|
|
1006
|
+
"CI prints an annotation",
|
|
1007
|
+
/^::error file=app\.js,line=1,title=signoff\/aws-access-key::/m.test(ciRun.stdout) ? 1 : 0,
|
|
1008
|
+
1,
|
|
1009
|
+
(ciRun.stdout || "").trim().split("\n").slice(0, 2).join(" | ")
|
|
1010
|
+
);
|
|
1011
|
+
check("CI writes a step summary", fs.existsSync(summaryFile) ? 1 : 0, 1);
|
|
1012
|
+
check(
|
|
1013
|
+
"step summary names the rule",
|
|
1014
|
+
fs.existsSync(summaryFile) && /aws-access-key/.test(fs.readFileSync(summaryFile, "utf8")) ? 1 : 0,
|
|
1015
|
+
1
|
|
1016
|
+
);
|
|
1017
|
+
|
|
1018
|
+
// 9. P5 rule packs: zh stays the default, en is opt-in, both is explicit.
|
|
1019
|
+
const enMd = path.join(base, "slop-en.md");
|
|
1020
|
+
fs.writeFileSync(enMd, "A seamless and robust platform.\n", "utf8");
|
|
1021
|
+
check("p5 zh ignores English slop", run(["p5", enMd], base, env).status, 0);
|
|
1022
|
+
check("p5 --pack en flags English slop", run(["p5", "--pack", "en", enMd], base, env).status, 1);
|
|
1023
|
+
check("p5 rejects an unknown pack", run(["p5", "--pack", "xx", enMd], base, env).status, 2);
|
|
1024
|
+
check("p5 --pack both flags English slop", run(["p5", "--pack", "both", enMd], base, env).status, 1);
|
|
1025
|
+
const rulesFile = path.join(base, "rules.json");
|
|
1026
|
+
fs.writeFileSync(rulesFile, JSON.stringify({ ai_words: ["powerful"] }), "utf8");
|
|
1027
|
+
const customMd = path.join(base, "custom.md");
|
|
1028
|
+
fs.writeFileSync(customMd, "This is a powerful tool.\n", "utf8");
|
|
1029
|
+
check("p5 rules alone do not fire on en default", run(["p5", "--rules", rulesFile, customMd], base, env).status, 1);
|
|
1030
|
+
const noHitMd = path.join(base, "plain.md");
|
|
1031
|
+
fs.writeFileSync(noHitMd, "This is a fast tool.\n", "utf8");
|
|
1032
|
+
check("p5 custom word does not over-match", run(["p5", "--rules", rulesFile, noHitMd], base, env).status, 0);
|
|
1033
|
+
const packRepo = fresh("p5pack");
|
|
1034
|
+
fs.mkdirSync(path.join(packRepo, ".signoff"), { recursive: true });
|
|
1035
|
+
fs.writeFileSync(path.join(packRepo, ".signoff", "config.json"), JSON.stringify({ p5: { pack: "en" } }), "utf8");
|
|
1036
|
+
fs.writeFileSync(path.join(packRepo, "note.md"), "A seamless platform.\n", "utf8");
|
|
1037
|
+
check("p5 reads the pack from config", run(["p5", "note.md"], packRepo, env).status, 1);
|
|
1038
|
+
|
|
1039
|
+
// 10. presets: one flag for budget, docs, coverage and the rule pack.
|
|
1040
|
+
const preNode = fresh("preset-node");
|
|
1041
|
+
check("init --preset node exits 0", run(["init", "--preset", "node", "--no-ci", "--quiet"], preNode, env).status, 0);
|
|
1042
|
+
const preCfg = JSON.parse(fs.readFileSync(path.join(preNode, ".signoff", "config.json"), "utf8"));
|
|
1043
|
+
check("preset sets coverage", preCfg.coverage && preCfg.coverage.min, 80);
|
|
1044
|
+
check("preset sets the p5 pack", preCfg.p5.pack, "both");
|
|
1045
|
+
check("preset keeps test_cmd auto-detected", preCfg.test_cmd, null);
|
|
1046
|
+
check("preset sets the budget", preCfg.budget, 180);
|
|
1047
|
+
const preGo = fresh("preset-go");
|
|
1048
|
+
check("init --preset go exits 0", run(["init", "--preset", "go", "--no-ci", "--quiet"], preGo, env).status, 0);
|
|
1049
|
+
const goCfg = JSON.parse(fs.readFileSync(path.join(preGo, ".signoff", "config.json"), "utf8"));
|
|
1050
|
+
check("go preset has no coverage gate", goCfg.coverage, null);
|
|
1051
|
+
check("go preset uses the en pack", goCfg.p5.pack, "en");
|
|
1052
|
+
check("init rejects an unknown preset", run(["init", "--preset", "rust", "--no-ci"], fresh("preset-bad"), env).status, 2);
|
|
1053
|
+
|
|
1054
|
+
// 11. editor integration: tasks.json + problem matcher, merged not replaced.
|
|
1055
|
+
const edRepo = fresh("editor");
|
|
1056
|
+
check("init --editor vscode exits 0", run(["init", "--editor", "vscode", "--no-ci", "--quiet"], edRepo, env).status, 0);
|
|
1057
|
+
const tasksPath = path.join(edRepo, ".vscode", "tasks.json");
|
|
1058
|
+
const tasksJson = JSON.parse(fs.readFileSync(tasksPath, "utf8"));
|
|
1059
|
+
const guardTask = tasksJson.tasks.find((t) => t.label === "signoff: guard");
|
|
1060
|
+
check("tasks.json has the guard task", guardTask ? 1 : 0, 1);
|
|
1061
|
+
check("guard task has a problem matcher", guardTask && guardTask.problemMatcher[0], "signoff-secret");
|
|
1062
|
+
check(
|
|
1063
|
+
"problem matcher definitions written",
|
|
1064
|
+
fs.existsSync(path.join(edRepo, ".vscode", "signoff-problem-matchers.json")) ? 1 : 0,
|
|
1065
|
+
1
|
|
1066
|
+
);
|
|
1067
|
+
const withMine = JSON.parse(fs.readFileSync(tasksPath, "utf8"));
|
|
1068
|
+
withMine.tasks.push({ label: "my task", type: "shell", command: "echo hi" });
|
|
1069
|
+
fs.writeFileSync(tasksPath, JSON.stringify(withMine, null, 2), "utf8");
|
|
1070
|
+
run(["init", "--editor", "vscode", "--no-ci", "--quiet"], edRepo, env);
|
|
1071
|
+
const merged = JSON.parse(fs.readFileSync(tasksPath, "utf8"));
|
|
1072
|
+
check("editor merge keeps my task", merged.tasks.filter((t) => t.label === "my task").length, 1);
|
|
1073
|
+
check("editor merge does not duplicate", merged.tasks.filter((t) => t.label === "signoff: guard").length, 1);
|
|
1074
|
+
check("init rejects an unknown editor", run(["init", "--editor", "emacs", "--no-ci"], fresh("ed-bad"), env).status, 2);
|
|
1075
|
+
// 12. runner detection: a JS repo with a tests/ directory is still npm.
|
|
1076
|
+
const jsTests = fresh("detect-js");
|
|
1077
|
+
fs.writeFileSync(path.join(jsTests, "package.json"), JSON.stringify({ scripts: { test: "node test.js" } }), "utf8");
|
|
1078
|
+
fs.mkdirSync(path.join(jsTests, "tests"), { recursive: true });
|
|
1079
|
+
fs.writeFileSync(path.join(jsTests, "tests", "a.test.js"), "// js\n", "utf8");
|
|
1080
|
+
check("detect: package.json with tests/ is npm", p1lib.detectCommands(jsTests).id, "npm");
|
|
1081
|
+
const pyTests = fresh("detect-py");
|
|
1082
|
+
fs.mkdirSync(path.join(pyTests, "tests"), { recursive: true });
|
|
1083
|
+
fs.writeFileSync(path.join(pyTests, "tests", "test_a.py"), "def test_a():\n assert True\n", "utf8");
|
|
1084
|
+
check("detect: tests/*.py is pytest", p1lib.detectCommands(pyTests).id, "pytest-tests");
|
|
1085
|
+
const bareTests = fresh("detect-bare");
|
|
1086
|
+
fs.mkdirSync(path.join(bareTests, "tests"), { recursive: true });
|
|
1087
|
+
fs.writeFileSync(path.join(bareTests, "tests", "data.json"), "{}", "utf8");
|
|
1088
|
+
check("detect: a tests/ dir alone is not pytest", p1lib.detectCommands(bareTests).id, null);
|
|
1089
|
+
|
|
1090
|
+
// 13. Signed checkpoints over the P4 ledger.
|
|
1091
|
+
let r;
|
|
1092
|
+
// Seed two records in this run's own ledger before signing a checkpoint.
|
|
1093
|
+
const seedTicket = (run(["p4", "ask", "--action", "config", "--target", "cp"], repo).stdout.match(/ticket (\w+)/) || [])[1];
|
|
1094
|
+
check("p4 seed ticket", typeof seedTicket, "string");
|
|
1095
|
+
run(["p4", "confirm", "--ticket", seedTicket, "--decision", "allow", "--detail", "cp"], repo);
|
|
1096
|
+
const seed2 = (run(["p4", "ask", "--action", "config", "--target", "cp2"], repo).stdout.match(/ticket (\w+)/) || [])[1];
|
|
1097
|
+
run(["p4", "confirm", "--ticket", seed2, "--decision", "allow", "--detail", "cp2"], repo);
|
|
1098
|
+
r = run(["p4", "checkpoint", "--no-sign"], repo);
|
|
1099
|
+
check("p4 checkpoint writes", r.status, 0);
|
|
1100
|
+
r = run(["p4", "verify", "--checkpoint"], repo);
|
|
1101
|
+
check("p4 unsigned checkpoint is not a pass", r.status, 1);
|
|
1102
|
+
r = run(["p4", "verify", "--checkpoint", "--allow-unsigned"], repo);
|
|
1103
|
+
check("p4 --allow-unsigned", r.status, 0);
|
|
1104
|
+
r = run(["p4", "checkpoint"], repo);
|
|
1105
|
+
check("p4 checkpoint signs", r.status, 0);
|
|
1106
|
+
let cpv = JSON.parse(run(["p4", "verify", "--checkpoint", "--json"], repo).stdout);
|
|
1107
|
+
check("p4 checkpoint verifies", cpv.ok, true, cpv.state);
|
|
1108
|
+
check("p4 checkpoint covers records", cpv.seq > 0, true, String(cpv.seq));
|
|
1109
|
+
check("p4 checkpoint is self-signed", cpv.state, "self-signed");
|
|
1110
|
+
const cpAnchor = run(["p4", "anchor"], repo).stdout.trim();
|
|
1111
|
+
check("p4 anchor format", /^signoff1:\d+:[0-9a-f]{12}:[0-9a-f]{16}$/.test(cpAnchor), true, cpAnchor);
|
|
1112
|
+
check("p4 anchor matches checkpoint", cpAnchor, JSON.parse(run(["p4", "checkpoint", "--json"], repo).stdout.trim()).anchor);
|
|
1113
|
+
r = run(["p4", "verify", "--checkpoint", "--anchor", "signoff1:9:000000000000:0000000000000000"], repo);
|
|
1114
|
+
check("p4 wrong anchor is red", r.status, 1);
|
|
1115
|
+
|
|
1116
|
+
// Trusted key in allowed_signers flips self-signed -> verified.
|
|
1117
|
+
const cp = JSON.parse(fs.readFileSync(JSON.parse(run(["p4", "path", "--json"], repo).stdout).checkpoint, "utf8"));
|
|
1118
|
+
fs.mkdirSync(path.join(repo, ".signoff"), { recursive: true });
|
|
1119
|
+
fs.writeFileSync(path.join(repo, ".signoff", "allowed_signers"), cp.key + "\n", "utf8");
|
|
1120
|
+
cpv = JSON.parse(run(["p4", "verify", "--checkpoint", "--json"], repo).stdout);
|
|
1121
|
+
check("p4 checkpoint becomes verified", cpv.state, "verified");
|
|
1122
|
+
|
|
1123
|
+
// Truncating the ledger is the attack a plain hash chain cannot see.
|
|
1124
|
+
const where = JSON.parse(run(["p4", "path", "--json"], repo).stdout);
|
|
1125
|
+
check("p4 path names the ledger", fs.existsSync(where.ledger), true, where.ledger);
|
|
1126
|
+
check("p4 path names the checkpoint", where.checkpoint.endsWith("checkpoint.json"), true);
|
|
1127
|
+
const ledgerFile = where.ledger;
|
|
1128
|
+
const ledgerText = fs.readFileSync(ledgerFile, "utf8");
|
|
1129
|
+
const keep = ledgerText.trim().split(/\r?\n/).slice(0, -1).join("\n") + "\n";
|
|
1130
|
+
fs.writeFileSync(ledgerFile, keep, "utf8");
|
|
1131
|
+
r = run(["p4", "verify", "--checkpoint"], repo);
|
|
1132
|
+
check("p4 truncation is red", r.status, 1, r.stdout.includes("records were removed") ? "named" : r.stdout.slice(0, 60));
|
|
1133
|
+
fs.writeFileSync(ledgerFile, ledgerText, "utf8");
|
|
1134
|
+
|
|
1135
|
+
// 14. A repo that checkpoints keeps checkpointing on every pass.
|
|
1136
|
+
const before = JSON.parse(run(["p4", "verify", "--checkpoint", "--json"], repo).stdout).seq;
|
|
1137
|
+
const prevHome = process.env.SIGNOFF_HOME;
|
|
1138
|
+
process.env.SIGNOFF_HOME = HOME;
|
|
1139
|
+
const guarded = require("../lib/guard").maybeCheckpoint(repo);
|
|
1140
|
+
if (prevHome === undefined) delete process.env.SIGNOFF_HOME;
|
|
1141
|
+
else process.env.SIGNOFF_HOME = prevHome;
|
|
1142
|
+
check("guard keeps a valid checkpoint", guarded && guarded.ok, true, guarded && (guarded.error || ""));
|
|
1143
|
+
check("guard does not churn an unchanged ledger", guarded && guarded.seq, before);
|
|
1144
|
+
check("guard checkpoint keeps verifying", run(["p4", "verify", "--checkpoint"], repo).status, 0);
|
|
1145
|
+
|
|
1146
|
+
// 15. Distribution manifests exist and name the right entry points.
|
|
1147
|
+
const hooks = fs.readFileSync(path.join(__dirname, "..", ".pre-commit-hooks.yaml"), "utf8");
|
|
1148
|
+
check("pre-commit hook id", /id: signoff-guard/.test(hooks), true);
|
|
1149
|
+
check("pre-commit hook entry", /entry: signoff guard/.test(hooks), true);
|
|
1150
|
+
const action = fs.readFileSync(path.join(__dirname, "..", "action.yml"), "utf8");
|
|
1151
|
+
check("action installs the cli", /npm i -g signoff-cli/.test(action), true);
|
|
1152
|
+
|
|
1153
|
+
// 17. package rename (4.0.0): new mark written, legacy mark still ours
|
|
1154
|
+
const bootlib = require(path.join(__dirname, "..", "lib", "bootstrap.js"));
|
|
1155
|
+
check("hook mark names the new package", bootlib.HOOK_MARK.includes("signoff-cli"), true);
|
|
1156
|
+
check("legacy mark still recognised", bootlib.markedByUs("# generated by dsh-signoff 3.9.4"), true);
|
|
1157
|
+
check("new mark recognised", bootlib.markedByUs(bootlib.HOOK_MARK + " 4.0.0"), true);
|
|
1158
|
+
check("foreign hook is not ours", bootlib.markedByUs("#!/bin/sh\nnpx lint-staged"), false);
|
|
1159
|
+
|
|
1160
|
+
check("action runs signoff", /run: signoff \$\{\{ inputs.args \}\}/.test(action), true);
|
|
1161
|
+
check("integrations doc lists actions", /uses: ayuanxx-max\/dsh-signoff/.test(fs.readFileSync(path.join(__dirname, "..", "docs", "integrations.md"), "utf8")), true);
|
|
1162
|
+
|
|
1163
|
+
// 16. Deterministic fuzz: parsers must never throw on hostile input, and the
|
|
1164
|
+
// hashing primitives must be stable and order-sensitive.
|
|
1165
|
+
const covlib = require("../lib/cov");
|
|
1166
|
+
const p5lib2 = require("../lib/p5");
|
|
1167
|
+
const cplib = require("../lib/checkpoint");
|
|
1168
|
+
const secretslib = require("../lib/secrets");
|
|
1169
|
+
let seed = 0x51f15e;
|
|
1170
|
+
const rnd = () => {
|
|
1171
|
+
seed = (seed * 1664525 + 1013904223) >>> 0;
|
|
1172
|
+
return seed / 0x100000000;
|
|
1173
|
+
};
|
|
1174
|
+
const pick = (alphabet, n) => Array.from({ length: n }, () => alphabet[Math.floor(rnd() * alphabet.length)]).join("");
|
|
1175
|
+
let crashes = 0;
|
|
1176
|
+
for (let i = 0; i < 120; i++) {
|
|
1177
|
+
const junk = pick("\n\r\t %$#{}[]\"'\\;:=<>0aF中é😀", Math.floor(rnd() * 200));
|
|
1178
|
+
try {
|
|
1179
|
+
const r = covlib.fromLcov(junk);
|
|
1180
|
+
// fromLcov returns a per-file map; only a summary carrying pct is range-checked.
|
|
1181
|
+
if (!r || typeof r !== "object") crashes += 1000;
|
|
1182
|
+
else if (typeof r.pct === "number" && (r.pct < 0 || r.pct > 100)) crashes += 1000;
|
|
1183
|
+
} catch (e) {
|
|
1184
|
+
crashes += 1;
|
|
1185
|
+
}
|
|
1186
|
+
try {
|
|
1187
|
+
p5lib2.scan(junk);
|
|
1188
|
+
secretslib.scan(junk);
|
|
1189
|
+
} catch (e) {
|
|
1190
|
+
crashes += 1;
|
|
1191
|
+
}
|
|
1192
|
+
try {
|
|
1193
|
+
const a = [junk, pick("abc", 3), "x"];
|
|
1194
|
+
const r1 = cplib.merkleRoot(a);
|
|
1195
|
+
const r2 = cplib.merkleRoot(a);
|
|
1196
|
+
if (r1 !== r2 || !/^[0-9a-f]{64}$/.test(r1)) crashes += 1000;
|
|
1197
|
+
if (cplib.merkleRoot([a[1], a[0], a[2]]) === r1 && a[0] !== a[1]) crashes += 100;
|
|
1198
|
+
} catch (e) {
|
|
1199
|
+
crashes += 1;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
check("parsers survive 120 hostile inputs", crashes, 0);
|
|
1203
|
+
check("merkle root is order sensitive", cplib.merkleRoot(["a", "b"]) === cplib.merkleRoot(["b", "a"]), false);
|
|
1204
|
+
const p4lib = require("../lib/p4");
|
|
1205
|
+
check("digest is stable", p4lib.digest("genesis", { a: 1, b: "x" }), p4lib.digest("genesis", { b: "x", a: 1 }));
|
|
1206
|
+
check("digest binds the predecessor", p4lib.digest("genesis", { a: 1 }) === p4lib.digest("other", { a: 1 }), false);
|
|
1207
|
+
r = run(["p4", "verify", "--checkpoint"], repo);
|
|
1208
|
+
check("p4 ledger restored", r.status, 0);
|
|
1209
|
+
|
|
1210
|
+
// A BOM from a Windows editor must not look like corruption.
|
|
1211
|
+
fs.writeFileSync(ledgerFile, "\uFEFF" + ledgerText, "utf8");
|
|
1212
|
+
check("p4 tolerates a BOM", run(["p4", "verify"], repo).status, 0);
|
|
1213
|
+
fs.writeFileSync(ledgerFile, ledgerText, "utf8");
|
|
1214
|
+
} finally {
|
|
1215
|
+
fs.rmSync(base, { recursive: true, force: true });
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
for (const line of AUDIT) console.log(`AUDIT ${line.replace(/\n/g, "\nAUDIT ")}`);
|
|
1220
|
+
for (const line of PASS) console.log(`PASS ${line}`);
|
|
1221
|
+
for (const line of FAIL) console.log(`FAIL ${line}`);
|
|
1222
|
+
console.log(`\n${PASS.length} passed, ${FAIL.length} failed`);
|
|
1223
|
+
return FAIL.length ? 1 : 0;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
process.exit(main());
|