signoff-cli 0.0.0-stage → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.pre-commit-hooks.yaml +27 -0
  2. package/CHANGELOG.md +336 -0
  3. package/LICENSE +21 -0
  4. package/README.md +540 -3
  5. package/action.yml +24 -0
  6. package/bin/signoff.js +218 -0
  7. package/docs/diagrams/README.md +41 -0
  8. package/docs/diagrams/signoff-1-capability-en.png +0 -0
  9. package/docs/diagrams/signoff-1-capability.png +0 -0
  10. package/docs/diagrams/signoff-2-market-en.png +0 -0
  11. package/docs/diagrams/signoff-2-market.png +0 -0
  12. package/docs/diagrams/signoff-3-user-en.png +0 -0
  13. package/docs/diagrams/signoff-3-user.png +0 -0
  14. package/docs/diagrams/signoff-4-flow-en.png +0 -0
  15. package/docs/diagrams/signoff-4-flow.png +0 -0
  16. package/docs/diagrams/signoff-5-competitors-en.png +0 -0
  17. package/docs/diagrams/signoff-5-competitors.png +0 -0
  18. package/docs/diagrams/src/signoff-1-capability-en.mmd +81 -0
  19. package/docs/diagrams/src/signoff-1-capability.mmd +84 -0
  20. package/docs/diagrams/src/signoff-2-market-en.mmd +72 -0
  21. package/docs/diagrams/src/signoff-2-market.mmd +72 -0
  22. package/docs/diagrams/src/signoff-3-user-en.mmd +94 -0
  23. package/docs/diagrams/src/signoff-3-user.mmd +92 -0
  24. package/docs/gates.en.md +105 -0
  25. package/docs/gates.md +88 -0
  26. package/docs/integrations.md +58 -0
  27. package/docs/promotion.md +71 -0
  28. package/docs/signoff.schema.json +108 -0
  29. package/docs/trust.md +72 -0
  30. package/gates/guard.py +474 -0
  31. package/gates/p1_verify.py +111 -0
  32. package/gates/p2_coverage.py +127 -0
  33. package/gates/p3_queue.py +186 -0
  34. package/gates/p4_ledger.py +249 -0
  35. package/gates/p5_slop.py +109 -0
  36. package/lib/audit.js +213 -0
  37. package/lib/bootstrap.js +361 -0
  38. package/lib/checkpoint.js +184 -0
  39. package/lib/ci.js +58 -0
  40. package/lib/core.js +120 -0
  41. package/lib/cov.js +253 -0
  42. package/lib/detect.js +169 -0
  43. package/lib/doctor.js +304 -0
  44. package/lib/editor.js +97 -0
  45. package/lib/explain.js +73 -0
  46. package/lib/guard.js +1229 -0
  47. package/lib/p1.js +469 -0
  48. package/lib/p2.js +323 -0
  49. package/lib/p3.js +633 -0
  50. package/lib/p4.js +733 -0
  51. package/lib/p5.js +438 -0
  52. package/lib/preset.js +66 -0
  53. package/lib/secrets.js +336 -0
  54. package/lib/sign.js +196 -0
  55. package/package.json +55 -6
  56. package/test/selftest.js +1226 -0
  57. package/test/selftest.py +190 -0
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env python3
2
+ """Self-test for dsh-signoff: every gate is run against a throwaway repo and the
3
+ exit code is the assertion. Run with `python test/selftest.py`.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import tempfile
13
+ from pathlib import Path
14
+
15
+ ROOT = Path(__file__).resolve().parent.parent
16
+ GATES = ROOT / "gates"
17
+ PASS, FAIL = [], []
18
+
19
+
20
+ def check(name: str, got: int, want: int, extra: str = "") -> None:
21
+ (PASS if got == want else FAIL).append(f"{name}: rc={got} want={want} {extra}")
22
+
23
+
24
+ def gate(name: str, *args: str, cwd: Path, home: Path | None = None) -> subprocess.CompletedProcess:
25
+ env = dict(os.environ)
26
+ env["SIGNOFF_HOME"] = str(home or (cwd / ".signoff-home"))
27
+ return subprocess.run(
28
+ [sys.executable, str(GATES / name), *args],
29
+ cwd=str(cwd),
30
+ capture_output=True,
31
+ text=True,
32
+ errors="replace",
33
+ env=env,
34
+ )
35
+
36
+
37
+ def build_repo(base: Path) -> Path:
38
+ repo = base / "repo"
39
+ (repo / "tests").mkdir(parents=True)
40
+ (repo / "verify" / "scenarios").mkdir(parents=True)
41
+ (repo / "docs").mkdir()
42
+ (repo / "tasks" / "t1").mkdir(parents=True)
43
+ (repo / "tasks" / "t2").mkdir(parents=True)
44
+ (repo / "tests" / "test_ok.py").write_text("def test_ok():\n assert True\n", encoding="utf-8")
45
+ (repo / "verify" / "scenarios" / "a.json").write_text(
46
+ json.dumps({"checks": [{"name": "x"}, {"name": "y"}, {"name": "z"}]}), encoding="utf-8"
47
+ )
48
+ (repo / "package.json").write_text('{"name": "fixture", "version": "0.0.0"}\n', encoding="utf-8")
49
+ (repo / "tasks" / "t1" / "card.json").write_text(
50
+ json.dumps({"id": "t1", "goal": "g", "scope": ["src/a/**"], "acceptance": ["pass"], "rollback": "HEAD"}),
51
+ encoding="utf-8",
52
+ )
53
+ (repo / "tasks" / "t2" / "card.json").write_text(
54
+ json.dumps({"id": "t2", "goal": "g", "scope": ["src/a/b.py"], "acceptance": ["pass"], "rollback": "HEAD"}),
55
+ encoding="utf-8",
56
+ )
57
+ (repo / "docs" / "clean.md").write_text("# clean\n\n40 cases in 3.6 s, 12/12 PASS.\n", encoding="utf-8")
58
+ (repo / "docs" / "dirty.md").write_text(
59
+ "# dirty\n\n这个方案赋能全链路,闭环彻底解决,已验证。\n", encoding="utf-8"
60
+ )
61
+ subprocess.run(["git", "init", "-q"], cwd=str(repo), check=True)
62
+ subprocess.run(["git", "config", "user.email", "t@t"], cwd=str(repo), check=True)
63
+ subprocess.run(["git", "config", "user.name", "t"], cwd=str(repo), check=True)
64
+ subprocess.run(["git", "add", "-A"], cwd=str(repo), check=True)
65
+ subprocess.run(["git", "commit", "-qm", "init"], cwd=str(repo), check=True)
66
+ return repo
67
+
68
+
69
+ def main() -> int:
70
+ base = Path(tempfile.mkdtemp(prefix="signoff-selftest-"))
71
+ try:
72
+ repo = build_repo(base)
73
+ home = base / "home"
74
+
75
+ # P1 - passing command, parsed case count; then a failing command.
76
+ r = gate("p1_verify.py", "--run", f'"{sys.executable}" -c "print(\'3 passed\')"', cwd=repo, home=home)
77
+ check("p1 pass", r.returncode, 0, r.stdout.strip().splitlines()[-1] if r.stdout.strip() else "")
78
+ p1 = json.loads((repo / ".signoff" / "p1.json").read_text(encoding="utf-8"))
79
+ check("p1 cases parsed", p1["cases"], 3, str(p1["cases"]))
80
+ r = gate("p1_verify.py", "--run", f'"{sys.executable}" -c "raise SystemExit(1)"', cwd=repo, home=home)
81
+ check("p1 fail on rc!=0", r.returncode, 1)
82
+
83
+ # P2 - one scenario app with three assertions; strict still passes.
84
+ r = gate("p2_coverage.py", "--strict", "--json", cwd=repo, home=home)
85
+ check("p2 pass", r.returncode, 0, r.stdout.strip())
86
+ r = gate("p2_coverage.py", "--min-assertions", "9", cwd=repo, home=home)
87
+ check("p2 fail on threshold", r.returncode, 1)
88
+
89
+ # P3 - overlapping scopes land in different waves; unowned change is red.
90
+ r = gate("p3_queue.py", "validate", cwd=repo, home=home)
91
+ check("p3 validate", r.returncode, 0, r.stdout.strip())
92
+ r = gate("p3_queue.py", "plan", cwd=repo, home=home)
93
+ check("p3 plan splits", r.returncode, 0)
94
+ waves = json.loads((repo / ".signoff" / "p3.json").read_text(encoding="utf-8"))["waves"]
95
+ check("p3 wave count", len(waves), 2, str(waves))
96
+ r = gate("p3_queue.py", "collect", "--changed", "src/a/b.py", "stray.txt", cwd=repo, home=home)
97
+ check("p3 unowned red", r.returncode, 1)
98
+
99
+ # P4 - ticket -> allow -> verify; then tamper and deny.
100
+ r = gate("p4_ledger.py", "ask", "--action", "publish", "--target", "demo@1.0.0", cwd=repo, home=home)
101
+ check("p4 ask", r.returncode, 0, r.stdout.strip().splitlines()[0])
102
+ token = r.stdout.split("ticket ")[1].split()[0]
103
+ r = gate("p4_ledger.py", "confirm", "--ticket", token, "--decision", "allow", "--detail", "d", "--rollback", "HEAD", cwd=repo, home=home)
104
+ check("p4 confirm allow", r.returncode, 0, r.stdout.strip())
105
+ r = gate("p4_ledger.py", "verify", cwd=repo, home=home)
106
+ check("p4 verify ok", r.returncode, 0, r.stdout.strip())
107
+ r = gate("p4_ledger.py", "confirm", "--ticket", token, "--decision", "allow", cwd=repo, home=home)
108
+ check("p4 ticket single-use", r.returncode, 2)
109
+ log = home / "audit.log"
110
+ log.write_text(log.read_text(encoding="utf-8").replace('"d"', '"tampered"'), encoding="utf-8")
111
+ r = gate("p4_ledger.py", "verify", cwd=repo, home=home)
112
+ check("p4 tamper detected", r.returncode, 1, r.stdout.strip())
113
+ # free-text rollback must be refused
114
+ r = gate("p4_ledger.py", "ask", "--action", "publish", "--target", "x", cwd=repo, home=home)
115
+ token = r.stdout.split("ticket ")[1].split()[0]
116
+ gate("p4_ledger.py", "confirm", "--ticket", token, "--decision", "allow", "--rollback", "npm deprecate x", cwd=repo, home=home)
117
+ r = gate("p4_ledger.py", "verify", cwd=repo, home=home)
118
+ check("p4 fake rollback refused", r.returncode, 1, r.stdout.strip())
119
+
120
+ # P5 - dirty doc red, clean doc green.
121
+ r = gate("p5_slop.py", "docs/dirty.md", cwd=repo, home=home)
122
+ check("p5 dirty red", r.returncode, 1, r.stdout.strip().splitlines()[-1])
123
+ r = gate("p5_slop.py", "docs/clean.md", cwd=repo, home=home)
124
+ check("p5 clean green", r.returncode, 0, r.stdout.strip().splitlines()[-1])
125
+
126
+ # Guard - install once, then the gates run themselves.
127
+ ghome = base / "guard-home" # its own ledger: p4 stays out of this section
128
+
129
+ def guard(*a, extra=None):
130
+ env = dict(os.environ)
131
+ env["SIGNOFF_HOME"] = str(ghome)
132
+ env.pop("SIGNOFF_SKIP", None)
133
+ if extra:
134
+ env.update(extra)
135
+ return subprocess.run(
136
+ [sys.executable, str(GATES / "guard.py"), *a],
137
+ cwd=str(repo),
138
+ capture_output=True,
139
+ text=True,
140
+ errors="replace",
141
+ env=env,
142
+ )
143
+
144
+ r = guard("init", "--no-ci")
145
+ check("guard init", r.returncode, 0, r.stdout.strip().splitlines()[0] if r.stdout.strip() else "")
146
+ hooks = repo / ".git" / "hooks"
147
+ check("guard pre-commit hook", 1 if (hooks / "pre-commit").exists() else 0, 1)
148
+ check("guard pre-push hook", 1 if (hooks / "pre-push").exists() else 0, 1)
149
+ check("guard config", 1 if (repo / ".signoff" / "config.json").exists() else 0, 1)
150
+ check("guard gitignore", 1 if ".signoff/" in (repo / ".gitignore").read_text(encoding="utf-8") else 0, 1)
151
+ r = guard("status", "--json")
152
+ check("guard status", 1 if json.loads(r.stdout)["installed"]["pre-commit"] else 0, 1)
153
+
154
+ (repo / "docs" / "clean.md").write_text(
155
+ "# clean\n\n40 cases in 3.6 s, 12/12 PASS.\nsecond line\n", encoding="utf-8"
156
+ )
157
+ subprocess.run(["git", "add", "-A"], cwd=str(repo), check=True)
158
+ r = guard("hook", "pre-commit")
159
+ check("guard hook pass", r.returncode, 0, r.stdout.strip().splitlines()[-1] if r.stdout.strip() else "")
160
+ r = guard("hook", "pre-commit")
161
+ check("guard cache hit", 1 if "cached" in r.stdout else 0, 1)
162
+
163
+ (repo / "docs" / "dirty.md").write_text(
164
+ "# dirty\n\n这个方案赋能全链路,闭环彻底解决,已验证。\nagain\n", encoding="utf-8"
165
+ )
166
+ subprocess.run(["git", "add", "-A"], cwd=str(repo), check=True)
167
+ r = guard("hook", "pre-commit")
168
+ check("guard hook fail closed", r.returncode, 1, "")
169
+
170
+ r = guard("hook", "pre-commit", extra={"SIGNOFF_SKIP": "1"})
171
+ check("guard skip allowed", r.returncode, 0, r.stdout.strip())
172
+ check("guard skip recorded", 1 if (repo / ".signoff" / "skips.log").exists() else 0, 1)
173
+
174
+ r = guard("uninstall")
175
+ check("guard uninstall", 1 if not (hooks / "pre-commit").exists() else 0, 1)
176
+ r = guard("init", "--dry-run", "--no-ci")
177
+ check("guard dry-run writes nothing", 1 if not (hooks / "pre-commit").exists() else 0, 1)
178
+ finally:
179
+ shutil.rmtree(base, ignore_errors=True)
180
+
181
+ for line in PASS:
182
+ print(f"PASS {line}")
183
+ for line in FAIL:
184
+ print(f"FAIL {line}")
185
+ print(f"\n{len(PASS)} passed, {len(FAIL)} failed")
186
+ return 0 if not FAIL else 1
187
+
188
+
189
+ if __name__ == "__main__":
190
+ raise SystemExit(main())