formwork-kit 0.1.0__py3-none-any.whl

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 (137) hide show
  1. formwork_cli/__init__.py +326 -0
  2. formwork_cli/kit/COSTS.md +111 -0
  3. formwork_cli/kit/adapters/claude-code/README.md +53 -0
  4. formwork_cli/kit/adapters/claude-code/settings.json +46 -0
  5. formwork_cli/kit/adapters/codex/README.md +43 -0
  6. formwork_cli/kit/adapters/cursor/README.md +45 -0
  7. formwork_cli/kit/adapters/gemini-cli/README.md +47 -0
  8. formwork_cli/kit/build +410 -0
  9. formwork_cli/kit/check/checks/config-shape +123 -0
  10. formwork_cli/kit/check/checks/decision-ids +159 -0
  11. formwork_cli/kit/check/checks/doc-links +133 -0
  12. formwork_cli/kit/check/checks/generated-current +74 -0
  13. formwork_cli/kit/check/checks/guard-wired +139 -0
  14. formwork_cli/kit/check/checks/kit-integrity +199 -0
  15. formwork_cli/kit/check/checks/predictions-first +127 -0
  16. formwork_cli/kit/check/checks/role-shape +172 -0
  17. formwork_cli/kit/check/checks/rule-labels +135 -0
  18. formwork_cli/kit/check/fixtures/config-shape/must-fail/documents-a-section-that-does-not-exist/.formwork.toml +5 -0
  19. formwork_cli/kit/check/fixtures/config-shape/must-fail/documents-a-section-that-does-not-exist/formwork/guide.md +13 -0
  20. formwork_cli/kit/check/fixtures/config-shape/must-fail/rules-as-a-switchboard/.formwork.toml +8 -0
  21. formwork_cli/kit/check/fixtures/config-shape/must-pass/layers-kept-apart/.formwork.toml +5 -0
  22. formwork_cli/kit/check/fixtures/decision-ids/must-fail/a-placeholder-shipped/docs/decisions/0003-still-pending.md +7 -0
  23. formwork_cli/kit/check/fixtures/decision-ids/must-fail/superseded-by-nothing/docs/decisions/0002-old.md +6 -0
  24. formwork_cli/kit/check/fixtures/decision-ids/must-fail/two-decisions-one-number/docs/decisions/0007-first.md +6 -0
  25. formwork_cli/kit/check/fixtures/decision-ids/must-fail/two-decisions-one-number/docs/decisions/0007-second.md +6 -0
  26. formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0001-the-first.md +6 -0
  27. formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0002-the-second.md +6 -0
  28. formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0003-the-third.md +6 -0
  29. formwork_cli/kit/check/fixtures/decision-ids/must-pass/nothing-recorded-yet/docs/decisions/README.md +3 -0
  30. formwork_cli/kit/check/fixtures/doc-links/must-fail/never-written/index.md +7 -0
  31. formwork_cli/kit/check/fixtures/doc-links/must-fail/renamed-file/architecture-notes.md +3 -0
  32. formwork_cli/kit/check/fixtures/doc-links/must-fail/renamed-file/guide.md +8 -0
  33. formwork_cli/kit/check/fixtures/doc-links/must-pass/links-resolve/architecture-notes.md +1 -0
  34. formwork_cli/kit/check/fixtures/doc-links/must-pass/links-resolve/guide.md +5 -0
  35. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.claude/agents/sample.md +22 -0
  36. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.codex/agents/sample.toml +22 -0
  37. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.formwork.toml +1 -0
  38. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.gemini/agents/sample.md +23 -0
  39. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/formwork/build +349 -0
  40. formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/formwork/roles/method/sample.md +18 -0
  41. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.claude/agents/sample.md +20 -0
  42. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.codex/agents/sample.toml +22 -0
  43. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.formwork.toml +1 -0
  44. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.gemini/agents/sample.md +23 -0
  45. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/formwork/build +349 -0
  46. formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/formwork/roles/method/sample.md +18 -0
  47. formwork_cli/kit/check/fixtures/generated-current/must-pass/nothing-is-generated-here/README.md +3 -0
  48. formwork_cli/kit/check/fixtures/guard-wired/must-fail/declared-but-no-file/.formwork.toml +1 -0
  49. formwork_cli/kit/check/fixtures/guard-wired/must-fail/file-but-not-wired/.claude/settings.json +1 -0
  50. formwork_cli/kit/check/fixtures/guard-wired/must-fail/file-but-not-wired/.formwork.toml +1 -0
  51. formwork_cli/kit/check/fixtures/guard-wired/must-pass/declared-and-wired/.claude/settings.json +1 -0
  52. formwork_cli/kit/check/fixtures/guard-wired/must-pass/declared-and-wired/.formwork.toml +1 -0
  53. formwork_cli/kit/check/fixtures/guard-wired/must-pass/nothing-declared/README.md +1 -0
  54. formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-check-went-missing/formwork/check/checks/still-here +2 -0
  55. formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-check-went-missing/state/fingerprints.txt +2 -0
  56. formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-guard-was-altered/formwork/guard/git-boundary +3 -0
  57. formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-guard-was-altered/state/fingerprints.txt +1 -0
  58. formwork_cli/kit/check/fixtures/kit-integrity/must-pass/everything-matches/formwork/guard/git-boundary +2 -0
  59. formwork_cli/kit/check/fixtures/kit-integrity/must-pass/everything-matches/state/fingerprints.txt +1 -0
  60. formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/architect.md +3 -0
  61. formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/researcher.md +3 -0
  62. formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/round.md +4 -0
  63. formwork_cli/kit/check/fixtures/predictions-first/must-pass/a-round-that-has-not-argued-yet/docs/rounds/0006-not-started/round.md +3 -0
  64. formwork_cli/kit/check/fixtures/predictions-first/must-pass/no-rounds-at-all/docs/README.md +3 -0
  65. formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/architect.md +3 -0
  66. formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/predictions.md +4 -0
  67. formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/researcher.md +3 -0
  68. formwork_cli/kit/check/fixtures/role-shape/must-fail/claims-a-grant-binds-everywhere/formwork/roles/README.md +6 -0
  69. formwork_cli/kit/check/fixtures/role-shape/must-fail/claims-a-grant-binds-everywhere/formwork/roles/complete.md +18 -0
  70. formwork_cli/kit/check/fixtures/role-shape/must-fail/missing-a-section/formwork/roles/vague.md +16 -0
  71. formwork_cli/kit/check/fixtures/role-shape/must-fail/spawn-without-being-lead/formwork/roles/eager.md +18 -0
  72. formwork_cli/kit/check/fixtures/role-shape/must-fail/two-roles-one-job/formwork/roles/first.md +18 -0
  73. formwork_cli/kit/check/fixtures/role-shape/must-fail/two-roles-one-job/formwork/roles/second.md +18 -0
  74. formwork_cli/kit/check/fixtures/role-shape/must-pass/well-formed/formwork/roles/complete.md +18 -0
  75. formwork_cli/kit/check/fixtures/rule-labels/must-fail/claims-enforcement-that-does-not-exist/formwork/rules/core.md +9 -0
  76. formwork_cli/kit/check/fixtures/rule-labels/must-fail/no-catches/formwork/rules/core.md +9 -0
  77. formwork_cli/kit/check/fixtures/rule-labels/must-fail/unlabelled/formwork/rules/core.md +7 -0
  78. formwork_cli/kit/check/fixtures/rule-labels/must-pass/well-formed/formwork/rules/core.md +10 -0
  79. formwork_cli/kit/check/run +340 -0
  80. formwork_cli/kit/check/test_gate.py +222 -0
  81. formwork_cli/kit/first-run.md +204 -0
  82. formwork_cli/kit/fw +121 -0
  83. formwork_cli/kit/glossary.md +160 -0
  84. formwork_cli/kit/guard/git-boundary +627 -0
  85. formwork_cli/kit/guard/protected-files +748 -0
  86. formwork_cli/kit/guard/quality-gate +260 -0
  87. formwork_cli/kit/guard/test_boundary.py +273 -0
  88. formwork_cli/kit/guard/test_protection.py +254 -0
  89. formwork_cli/kit/guard/test_quality_gate.py +156 -0
  90. formwork_cli/kit/install +395 -0
  91. formwork_cli/kit/limits.md +141 -0
  92. formwork_cli/kit/loop.md +82 -0
  93. formwork_cli/kit/roles/HOW-TO-ADD-A-ROLE.md +105 -0
  94. formwork_cli/kit/roles/TEMPLATE.md +26 -0
  95. formwork_cli/kit/roles/method/architect.md +269 -0
  96. formwork_cli/kit/roles/method/challenger.md +243 -0
  97. formwork_cli/kit/roles/method/lead.md +280 -0
  98. formwork_cli/kit/roles/method/record-keeper.md +206 -0
  99. formwork_cli/kit/roles/method/researcher.md +246 -0
  100. formwork_cli/kit/roles/method/reviewer.md +207 -0
  101. formwork_cli/kit/roles/packs/accessibility.md +236 -0
  102. formwork_cli/kit/roles/packs/ai.md +248 -0
  103. formwork_cli/kit/roles/packs/analyst.md +233 -0
  104. formwork_cli/kit/roles/packs/backend.md +425 -0
  105. formwork_cli/kit/roles/packs/brainstormer.md +190 -0
  106. formwork_cli/kit/roles/packs/data.md +212 -0
  107. formwork_cli/kit/roles/packs/devops.md +203 -0
  108. formwork_cli/kit/roles/packs/frontend.md +224 -0
  109. formwork_cli/kit/roles/packs/integrations.md +215 -0
  110. formwork_cli/kit/roles/packs/legal.md +251 -0
  111. formwork_cli/kit/roles/packs/marketing.md +206 -0
  112. formwork_cli/kit/roles/packs/mobile.md +202 -0
  113. formwork_cli/kit/roles/packs/performance.md +192 -0
  114. formwork_cli/kit/roles/packs/product.md +217 -0
  115. formwork_cli/kit/roles/packs/security.md +267 -0
  116. formwork_cli/kit/roles/packs/sre.md +203 -0
  117. formwork_cli/kit/roles/packs/tester.md +246 -0
  118. formwork_cli/kit/roles/packs/user-researcher.md +218 -0
  119. formwork_cli/kit/roles/packs/ux.md +205 -0
  120. formwork_cli/kit/roles/packs/visual.md +199 -0
  121. formwork_cli/kit/roles/packs/writer.md +198 -0
  122. formwork_cli/kit/round.md +131 -0
  123. formwork_cli/kit/rules/core.md +195 -0
  124. formwork_cli/kit/rules/full.md +493 -0
  125. formwork_cli/kit/templates/brief.md +68 -0
  126. formwork_cli/kit/templates/decision.md +93 -0
  127. formwork_cli/kit/templates/predictions.md +54 -0
  128. formwork_cli/kit/templates/report.md +52 -0
  129. formwork_cli/kit/templates/round.md +77 -0
  130. formwork_cli/kit/test_install.py +165 -0
  131. formwork_cli/kit/troubleshooting.md +247 -0
  132. formwork_cli/kit-page/FORMWORK.md +182 -0
  133. formwork_kit-0.1.0.dist-info/METADATA +308 -0
  134. formwork_kit-0.1.0.dist-info/RECORD +137 -0
  135. formwork_kit-0.1.0.dist-info/WHEEL +4 -0
  136. formwork_kit-0.1.0.dist-info/entry_points.txt +2 -0
  137. formwork_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env python3
2
+ """The gate. One command, and the only one a brief may name.
3
+
4
+ formwork/check/run run every check over the project
5
+ formwork/check/run --demo-fail watch each check refuse a broken input
6
+ formwork/check/run --list what exists
7
+
8
+ Exit status is the contract. Every adapter and every brief depends on it:
9
+
10
+ 0 clean
11
+ 1 something is wrong, and it is named
12
+ 2 the gate could not run, so this is NOT a pass
13
+
14
+ Status 2 exists because the most dangerous outcome is a gate reporting success
15
+ when it never ran.
16
+
17
+ WHAT A CHECK MUST SHIP, AND WHY IT IS TWO THINGS
18
+ ------------------------------------------------
19
+ Every check ships inputs of both kinds:
20
+
21
+ fixtures/<check>/must-fail/<case>/ the check must reject these
22
+ fixtures/<check>/must-pass/<case>/ the check must accept these
23
+
24
+ A case may also carry a `state/` directory. If it does, the runner points
25
+ FORMWORK_STATE_DIR at it, so a check that reads something kept outside the
26
+ repository can still be exercised.
27
+
28
+ An earlier version demanded only the first, and an audit broke it in one
29
+ minute: a check that ignores its input and fails on any directory named
30
+ "broken" passed the gate while examining nothing at all. Rejecting something
31
+ proves a check can say no. It does not prove the check looked.
32
+
33
+ **Requiring both means a check has to discriminate.** A check missing either
34
+ kind does not run — status 2, not a pass.
35
+
36
+ And every fixture is copied to a directory with a random name before the check
37
+ sees it, so a check cannot cheat by recognising "must-fail" or the case name.
38
+ It has to look at the contents.
39
+
40
+ TIMEOUTS
41
+ --------
42
+ A check that never returns used to hang the gate forever, which in a hook
43
+ freezes the agent until somebody kills it. Every check now has a deadline, and
44
+ exceeding it is "could not run", never "fine".
45
+
46
+ Python 3, standard library only, no dependencies.
47
+ """
48
+ import os
49
+ import shutil
50
+ import subprocess
51
+ import sys
52
+ import tempfile
53
+
54
+ HERE = os.path.dirname(os.path.abspath(__file__))
55
+ CHECKS_DIR = os.path.join(HERE, "checks")
56
+ FIXTURES_DIR = os.path.join(HERE, "fixtures")
57
+ PROJECT_ROOT = os.path.dirname(os.path.dirname(HERE))
58
+
59
+ CLEAN, FINDINGS, CANNOT_RUN = 0, 1, 2
60
+
61
+ # Seconds a single check may take. Generous for a repository check, and far
62
+ # short of a hook timeout.
63
+ def _seconds(name, default):
64
+ """A malformed timeout must not become a traceback on the blocking path."""
65
+ raw = os.environ.get(name, str(default))
66
+ try:
67
+ v = int(raw)
68
+ except (TypeError, ValueError):
69
+ return None
70
+ if v <= 0:
71
+ return None
72
+ # A number too large to be a timeout raised OverflowError deep inside
73
+ # subprocess, which surfaced as exit 1. A day is already absurd.
74
+ return min(v, 86400)
75
+
76
+
77
+ DEADLINE = _seconds("FORMWORK_CHECK_TIMEOUT", 60)
78
+
79
+ MUST_FAIL, MUST_PASS = "must-fail", "must-pass"
80
+
81
+
82
+ def cases(check_name, kind):
83
+ d = os.path.join(FIXTURES_DIR, check_name, kind)
84
+ if not os.path.isdir(d):
85
+ return []
86
+ return [os.path.join(d, c) for c in sorted(os.listdir(d))
87
+ if os.path.isdir(os.path.join(d, c))]
88
+
89
+
90
+ def discover():
91
+ """Every file in checks/, with the inputs it ships and whether it can run.
92
+
93
+ Non-executable files are listed rather than skipped. A check silently
94
+ absent is the one failure a gate must never report as success, and an
95
+ audit removed one with a single allowed `chmod` and watched the gate
96
+ report green over the remaining eight.
97
+ """
98
+ if not os.path.isdir(CHECKS_DIR):
99
+ return None
100
+ found = []
101
+ for name in sorted(os.listdir(CHECKS_DIR)):
102
+ path = os.path.join(CHECKS_DIR, name)
103
+ if name.startswith(".") or not os.path.isfile(path):
104
+ continue
105
+ found.append((name, path, cases(name, MUST_FAIL), cases(name, MUST_PASS),
106
+ os.access(path, os.X_OK)))
107
+ return found
108
+
109
+
110
+ def anonymised(fixture):
111
+ """A copy of a fixture under a name that carries no information.
112
+
113
+ Without this, a check can pass the gate by recognising the word
114
+ "must-fail" or the case name, while examining nothing. An audit did
115
+ exactly that in about a minute.
116
+
117
+ **It hides the case name and nothing else.** Names inside the fixture are
118
+ left alone, because several checks legitimately read them: a decision
119
+ record is identified by its numbered filename, a role by its `.md`
120
+ ending. So a check that looked for a particular file INSIDE a fixture
121
+ could still pass without examining anything. A later audit wrote one and
122
+ it worked.
123
+
124
+ Scrambling the contents was tried and broke the checks that read names
125
+ for real reasons. The limit is recorded in formwork/limits.md instead of
126
+ being papered over.
127
+ """
128
+ tmp = tempfile.mkdtemp(prefix="fw-")
129
+ dest = os.path.join(tmp, "subject")
130
+ shutil.copytree(fixture, dest)
131
+ return tmp, dest
132
+
133
+
134
+ def invoke(path, target, exclude=()):
135
+ """Run a check over a directory, with a deadline.
136
+
137
+ FORMWORK_EXCLUDE carries paths the check must not descend into. The
138
+ fixtures are wrong on purpose, so a check run over the project would find
139
+ them and be right to complain. The runner owns that knowledge; a check
140
+ must not have to know where the kit stores things.
141
+
142
+ Returns (exit_code, output). A check that overruns returns CANNOT_RUN,
143
+ because a gate that hangs is worse than one that fails.
144
+ """
145
+ env = dict(os.environ)
146
+ env["FORMWORK_EXCLUDE"] = os.pathsep.join(exclude)
147
+ # A check may depend on state kept outside the repository — a word list, a
148
+ # set of fingerprints. A fixture supplies its own by carrying a `state/`
149
+ # directory, and without this such a check could never ship a fixture,
150
+ # which the gate requires. So it could never ship at all.
151
+ state = os.path.join(target, "state")
152
+ if os.path.isdir(state):
153
+ env["FORMWORK_STATE_DIR"] = state
154
+ try:
155
+ p = subprocess.run([path, target], capture_output=True, text=True,
156
+ env=env, timeout=DEADLINE)
157
+ except subprocess.TimeoutExpired:
158
+ return CANNOT_RUN, ("took longer than %ds and was stopped. A check that "
159
+ "does not return is not a pass." % DEADLINE)
160
+ except OSError as e:
161
+ return CANNOT_RUN, "could not be started: %s" % e
162
+ return p.returncode, (p.stdout + p.stderr).strip()
163
+
164
+
165
+ KNOWN_FLAGS = {"--list", "--demo-fail"}
166
+
167
+
168
+ def main(argv):
169
+ args = set(argv[1:])
170
+ unknown = sorted(a for a in args if a.startswith("-")
171
+ and a not in KNOWN_FLAGS)
172
+ if unknown:
173
+ print("ERROR: unknown option(s): %s" % ", ".join(unknown),
174
+ file=sys.stderr)
175
+ print(" Known: %s. A mistyped --demo-fail used to run the whole "
176
+ "gate for real." % ", ".join(sorted(KNOWN_FLAGS)), file=sys.stderr)
177
+ return CANNOT_RUN
178
+ checks = discover()
179
+
180
+ if checks is None:
181
+ print("ERROR: no checks directory at %s" % CHECKS_DIR, file=sys.stderr)
182
+ print(" The gate did not run. This is not a pass.", file=sys.stderr)
183
+ return CANNOT_RUN
184
+ if DEADLINE is None:
185
+ print("ERROR: FORMWORK_CHECK_TIMEOUT is not a positive whole number of "
186
+ "seconds: %r" % os.environ.get("FORMWORK_CHECK_TIMEOUT"),
187
+ file=sys.stderr)
188
+ print(" The gate did not run. This is not a pass.", file=sys.stderr)
189
+ return CANNOT_RUN
190
+
191
+ if not checks:
192
+ print("ERROR: no checks found in %s" % CHECKS_DIR, file=sys.stderr)
193
+ print(" A gate with nothing in it reports success about nothing.",
194
+ file=sys.stderr)
195
+ return CANNOT_RUN
196
+
197
+ # A check that cannot be executed is not a check that passed. The gate
198
+ # refuses rather than quietly running the remainder.
199
+ unrunnable = [n for n, _p, _f, _q, ok in checks if not ok]
200
+ if unrunnable:
201
+ print("ERROR: %d check(s) present but not executable:" % len(unrunnable),
202
+ file=sys.stderr)
203
+ for n in unrunnable:
204
+ print(" %s" % n, file=sys.stderr)
205
+ print(" The gate did not run. A check that cannot run has not "
206
+ "passed. Restore the execute bit: chmod +x formwork/check/checks/*",
207
+ file=sys.stderr)
208
+ return CANNOT_RUN
209
+
210
+ # A check that ships only one kind of input cannot be trusted. One kind
211
+ # proves it can say no; the other proves it was listening.
212
+ incomplete = []
213
+ for name, _, fails, passes, _x in checks:
214
+ if not fails:
215
+ incomplete.append("%s has no must-fail input" % name)
216
+ if not passes:
217
+ incomplete.append("%s has no must-pass input" % name)
218
+ if incomplete:
219
+ print("ERROR: %d check(s) are not properly equipped:" % len(incomplete),
220
+ file=sys.stderr)
221
+ for i in incomplete:
222
+ print(" %s" % i, file=sys.stderr)
223
+ print(" A check needs an input it rejects AND one it accepts, or "
224
+ "it has not been shown to discriminate.", file=sys.stderr)
225
+ return CANNOT_RUN
226
+
227
+ if "--list" in args:
228
+ for name, _, fails, passes, _x in checks:
229
+ print("%-20s %d must-fail, %d must-pass"
230
+ % (name, len(fails), len(passes)))
231
+ for f in fails:
232
+ print("%-20s reject %s" % ("", os.path.basename(f)))
233
+ for p in passes:
234
+ print("%-20s accept %s" % ("", os.path.basename(p)))
235
+ return CLEAN
236
+
237
+ problems = []
238
+
239
+ if "--demo-fail" in args:
240
+ print("Each check, against the input it is meant to reject.")
241
+ print()
242
+ for name, path, fails, _p, _x in checks:
243
+ for fixture in fails:
244
+ tmp, subject = anonymised(fixture)
245
+ code, out = invoke(path, subject)
246
+ shutil.rmtree(tmp, ignore_errors=True)
247
+ case = os.path.basename(fixture)
248
+ if code == 0:
249
+ print(" %-14s %-24s ACCEPTED — and it must not"
250
+ % (name, case))
251
+ problems.append("%s/%s was accepted" % (name, case))
252
+ elif code == CANNOT_RUN:
253
+ print(" %-14s %-24s could not run: %s"
254
+ % (name, case, out.split("\n")[0]))
255
+ problems.append("%s/%s could not run" % (name, case))
256
+ else:
257
+ first = out.split("\n")[0] if out else "(no output)"
258
+ print(" %-14s %-24s rejected, as it should: %s"
259
+ % (name, case, first))
260
+ print()
261
+ if problems:
262
+ print("GATE: %d check(s) did not behave." % len(problems))
263
+ return FINDINGS
264
+ print("GATE: every check rejected its broken input. That is the point.")
265
+ return CLEAN
266
+
267
+ # Pass 1 — the project itself.
268
+ for name, path, _f, _p, _x in checks:
269
+ code, out = invoke(path, PROJECT_ROOT, exclude=[FIXTURES_DIR])
270
+ if code == CANNOT_RUN:
271
+ print("ERROR: check %s could not run:\n%s" % (name, out),
272
+ file=sys.stderr)
273
+ return CANNOT_RUN
274
+ if code != 0:
275
+ problems.append(name)
276
+ print("FAIL %s" % name)
277
+ for line in out.split("\n"):
278
+ if line.strip():
279
+ print(" %s" % line)
280
+ else:
281
+ print("ok %s" % name)
282
+
283
+ # Pass 2 — prove each check rejects what it should.
284
+ accepted = []
285
+ for name, path, fails, _p, _x in checks:
286
+ for fixture in fails:
287
+ tmp, subject = anonymised(fixture)
288
+ code, out = invoke(path, subject)
289
+ shutil.rmtree(tmp, ignore_errors=True)
290
+ if code == CANNOT_RUN:
291
+ print("ERROR: %s could not run on %s: %s"
292
+ % (name, os.path.basename(fixture), out), file=sys.stderr)
293
+ return CANNOT_RUN
294
+ if code == 0:
295
+ accepted.append("%s/%s" % (name, os.path.basename(fixture)))
296
+
297
+ # Pass 3 — prove each check accepts what it should. Without this a check
298
+ # that simply always fails would look rigorous.
299
+ rejected = []
300
+ for name, path, _f, passes, _x in checks:
301
+ for fixture in passes:
302
+ tmp, subject = anonymised(fixture)
303
+ code, out = invoke(path, subject)
304
+ shutil.rmtree(tmp, ignore_errors=True)
305
+ if code == CANNOT_RUN:
306
+ print("ERROR: %s could not run on %s: %s"
307
+ % (name, os.path.basename(fixture), out), file=sys.stderr)
308
+ return CANNOT_RUN
309
+ if code != 0:
310
+ rejected.append("%s/%s" % (name, os.path.basename(fixture)))
311
+
312
+ if accepted:
313
+ print()
314
+ print("FAIL these inputs are meant to be rejected, and were not:")
315
+ for a in accepted:
316
+ print(" %s" % a)
317
+ print(" A check that cannot fail is not evidence.")
318
+
319
+ if rejected:
320
+ print()
321
+ print("FAIL these inputs are meant to be accepted, and were not:")
322
+ for r in rejected:
323
+ print(" %s" % r)
324
+ print(" A check that refuses everything has not been shown to "
325
+ "look at anything.")
326
+
327
+ print()
328
+ if problems or accepted or rejected:
329
+ print("GATE: red. %d check(s) failed on the project, %d input(s) "
330
+ "wrongly accepted, %d wrongly rejected."
331
+ % (len(problems), len(accepted), len(rejected)))
332
+ return FINDINGS
333
+
334
+ print("GATE: green. %d check(s), each shown to reject the wrong and accept "
335
+ "the right." % len(checks))
336
+ return CLEAN
337
+
338
+
339
+ if __name__ == "__main__":
340
+ sys.exit(main(sys.argv))
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env python3
2
+ """Prove the gate can fail.
3
+
4
+ The gate's whole job is refusing. A gate nobody has watched refuse is not
5
+ evidence of anything, so this exercises every way it is supposed to say no.
6
+
7
+ green a sound project -> 0
8
+ red a check that finds something -> 1
9
+ red a broken input that does not break -> 1
10
+ cannot no checks at all -> 2
11
+ cannot a check with no broken input -> 2
12
+
13
+ Python 3, standard library only, no dependencies.
14
+ """
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+
21
+ HERE = os.path.dirname(os.path.abspath(__file__))
22
+ RUN = os.path.join(HERE, "run")
23
+ results = []
24
+
25
+
26
+ def check(name, got, want, output=""):
27
+ ok = got == want
28
+ results.append(ok)
29
+ print(" [%s] %-46s expected %d, got %d"
30
+ % ("pass" if ok else "FAIL", name, want, got))
31
+ if not ok and output:
32
+ for line in output.strip().split("\n")[:6]:
33
+ print(" %s" % line)
34
+
35
+
36
+ def run_gate(gate_dir, *args):
37
+ p = subprocess.run([sys.executable, os.path.join(gate_dir, "run")] + list(args),
38
+ capture_output=True, text=True)
39
+ return p.returncode, p.stdout + p.stderr
40
+
41
+
42
+ def build_gate(project_files, checks, fixtures, passes=None):
43
+ """A throwaway project with its own copy of the gate inside it.
44
+
45
+ The runner takes the project root to be two levels above itself, so the
46
+ layout here mirrors a real fork: <project>/formwork/check/.
47
+ """
48
+ root = tempfile.mkdtemp(prefix="formwork-gate-")
49
+ gate = os.path.join(root, "formwork", "check")
50
+ os.makedirs(os.path.join(gate, "checks"))
51
+ os.makedirs(os.path.join(gate, "fixtures"))
52
+ shutil.copy(RUN, os.path.join(gate, "run"))
53
+ os.chmod(os.path.join(gate, "run"), 0o755)
54
+
55
+ for rel, text in project_files.items():
56
+ full = os.path.join(root, rel)
57
+ os.makedirs(os.path.dirname(full), exist_ok=True)
58
+ open(full, "w", encoding="utf-8").write(text)
59
+
60
+ for name, body in checks.items():
61
+ p = os.path.join(gate, "checks", name)
62
+ open(p, "w", encoding="utf-8").write(body)
63
+ os.chmod(p, 0o755)
64
+
65
+ for kind, table in (("must-fail", fixtures), ("must-pass", passes or {})):
66
+ for cname, cases in table.items():
67
+ for case, files in cases.items():
68
+ d = os.path.join(gate, "fixtures", cname, kind, case)
69
+ os.makedirs(d)
70
+ for fn, text in files.items():
71
+ open(os.path.join(d, fn), "w", encoding="utf-8").write(text)
72
+
73
+ return root, gate
74
+
75
+
76
+ # A check that fails when it finds a file called "wrong.md".
77
+ FINDS_WRONG = '''#!/usr/bin/env python3
78
+ import os, sys
79
+ root = sys.argv[1]
80
+ ex = [os.path.abspath(p) for p in os.environ.get("FORMWORK_EXCLUDE","").split(os.pathsep) if p]
81
+ hits = []
82
+ for dp, dns, fns in os.walk(root):
83
+ here = os.path.abspath(dp)
84
+ if any(here == e or here.startswith(e + os.sep) for e in ex):
85
+ dns[:] = []
86
+ continue
87
+ hits += [os.path.join(dp, f) for f in fns if f == "wrong.md"]
88
+ print("%d found" % len(hits))
89
+ sys.exit(1 if hits else 0)
90
+ '''
91
+
92
+ # A check that never fails, whatever it is given. The gate must notice.
93
+ NEVER_FAILS = '''#!/usr/bin/env python3
94
+ import sys
95
+ print("all fine, always")
96
+ sys.exit(0)
97
+ '''
98
+
99
+
100
+ def main():
101
+ print("THE GATE")
102
+
103
+ # green - a sound project, one check, one input that does break it
104
+ root, gate = build_gate({"README.md": "fine\\n"},
105
+ {"finds-wrong": FINDS_WRONG},
106
+ {"finds-wrong": {"has-one": {"wrong.md": "x\\n"}}},
107
+ {"finds-wrong": {"has-none": {"fine.md": "x\\n"}}})
108
+ code, out = run_gate(gate)
109
+ check("green: sound project, check proved able to fail", code, 0, out)
110
+ shutil.rmtree(root)
111
+
112
+ # red - the check finds something in the project
113
+ root, gate = build_gate({"README.md": "fine\\n", "docs/wrong.md": "x\\n"},
114
+ {"finds-wrong": FINDS_WRONG},
115
+ {"finds-wrong": {"has-one": {"wrong.md": "x\\n"}}},
116
+ {"finds-wrong": {"has-none": {"fine.md": "x\\n"}}})
117
+ code, out = run_gate(gate)
118
+ check("red: a check finds something", code, 1, out)
119
+ shutil.rmtree(root)
120
+
121
+ # red - the broken input does not break the check
122
+ root, gate = build_gate({"README.md": "fine\\n"},
123
+ {"never-fails": NEVER_FAILS},
124
+ {"never-fails": {"supposedly-broken": {"a.md": "x\\n"}}},
125
+ {"never-fails": {"fine": {"b.md": "x\\n"}}})
126
+ code, out = run_gate(gate)
127
+ check("red: a check that cannot fail", code, 1, out)
128
+ shutil.rmtree(root)
129
+
130
+ # cannot run - a check with no broken input at all
131
+ root, gate = build_gate({"README.md": "fine\\n"},
132
+ {"finds-wrong": FINDS_WRONG},
133
+ {})
134
+ code, out = run_gate(gate)
135
+ check("cannot run: a check ships no broken input", code, 2, out)
136
+ shutil.rmtree(root)
137
+
138
+ # cannot run - nothing to run
139
+ root, gate = build_gate({"README.md": "fine\\n"}, {}, {})
140
+ code, out = run_gate(gate)
141
+ check("cannot run: no checks at all", code, 2, out)
142
+ shutil.rmtree(root)
143
+
144
+ # the teaching mode must itself refuse when a broken input does not break
145
+ root, gate = build_gate({"README.md": "fine\\n"},
146
+ {"never-fails": NEVER_FAILS},
147
+ {"never-fails": {"supposedly-broken": {"a.md": "x\\n"}}},
148
+ {"never-fails": {"fine": {"b.md": "x\\n"}}})
149
+ code, out = run_gate(gate, "--demo-fail")
150
+ check("red: --demo-fail catches a check that cannot fail", code, 1, out)
151
+ shutil.rmtree(root)
152
+
153
+ # A check that recognises the fixture's name instead of reading it. This
154
+ # passed the gate before fixtures were anonymised.
155
+ NAME_CHEAT = """#!/usr/bin/env python3
156
+ import sys, os
157
+ sys.exit(1 if "broken" in sys.argv[1] else 0)
158
+ """
159
+ root, gate = build_gate({"README.md": "fine\n"},
160
+ {"cheat": NAME_CHEAT},
161
+ {"cheat": {"broken": {"a.md": "x\n"}}},
162
+ {"cheat": {"ok": {"a.md": "x\n"}}})
163
+ code, out = run_gate(gate)
164
+ check("red: a check that reads the name, not the input", code, 1, out)
165
+ shutil.rmtree(root)
166
+
167
+ # A check that never returns used to hang the gate for ever.
168
+ HANG = """#!/usr/bin/env python3
169
+ import time
170
+ time.sleep(600)
171
+ """
172
+ root, gate = build_gate({"README.md": "fine\n"},
173
+ {"slow": HANG},
174
+ {"slow": {"broken": {"a.md": "x\n"}}},
175
+ {"slow": {"ok": {"a.md": "x\n"}}})
176
+ env_before = os.environ.get("FORMWORK_CHECK_TIMEOUT")
177
+ os.environ["FORMWORK_CHECK_TIMEOUT"] = "2"
178
+ code, out = run_gate(gate)
179
+ if env_before is None:
180
+ del os.environ["FORMWORK_CHECK_TIMEOUT"]
181
+ else:
182
+ os.environ["FORMWORK_CHECK_TIMEOUT"] = env_before
183
+ check("cannot run: a check that never returns", code, 2, out)
184
+ shutil.rmtree(root)
185
+
186
+ # An audit removed a check with one allowed `chmod` and watched the gate
187
+ # report green over the remaining ones. A missing check must never look
188
+ # like a passing check.
189
+ root, gate = build_gate({"README.md": "fine\n"},
190
+ {"finds-wrong": FINDS_WRONG,
191
+ "also-finds-wrong": FINDS_WRONG},
192
+ {"finds-wrong": {"has-one": {"wrong.md": "x\n"}},
193
+ "also-finds-wrong": {"has-one": {"wrong.md": "x\n"}}},
194
+ {"finds-wrong": {"has-none": {"fine.md": "x\n"}},
195
+ "also-finds-wrong": {"has-none": {"fine.md": "x\n"}}})
196
+ os.chmod(os.path.join(gate, "checks", "also-finds-wrong"), 0o644)
197
+ code, out = run_gate(gate)
198
+ check("cannot run: a check present but not executable", code, 2, out)
199
+ shutil.rmtree(root)
200
+
201
+ # A malformed timeout must be status 2, not a traceback with status 1.
202
+ root, gate = build_gate({"README.md": "fine\n"},
203
+ {"finds-wrong": FINDS_WRONG},
204
+ {"finds-wrong": {"has-one": {"wrong.md": "x\n"}}},
205
+ {"finds-wrong": {"has-none": {"fine.md": "x\n"}}})
206
+ before = os.environ.get("FORMWORK_CHECK_TIMEOUT")
207
+ os.environ["FORMWORK_CHECK_TIMEOUT"] = "not-a-number"
208
+ code, out = run_gate(gate)
209
+ if before is None:
210
+ del os.environ["FORMWORK_CHECK_TIMEOUT"]
211
+ else:
212
+ os.environ["FORMWORK_CHECK_TIMEOUT"] = before
213
+ check("cannot run: a timeout that is not a number", code, 2, out)
214
+ shutil.rmtree(root)
215
+
216
+ print()
217
+ print("%d of %d behaved as specified." % (sum(results), len(results)))
218
+ return 0 if all(results) else 1
219
+
220
+
221
+ if __name__ == "__main__":
222
+ sys.exit(main())