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.
- formwork_cli/__init__.py +326 -0
- formwork_cli/kit/COSTS.md +111 -0
- formwork_cli/kit/adapters/claude-code/README.md +53 -0
- formwork_cli/kit/adapters/claude-code/settings.json +46 -0
- formwork_cli/kit/adapters/codex/README.md +43 -0
- formwork_cli/kit/adapters/cursor/README.md +45 -0
- formwork_cli/kit/adapters/gemini-cli/README.md +47 -0
- formwork_cli/kit/build +410 -0
- formwork_cli/kit/check/checks/config-shape +123 -0
- formwork_cli/kit/check/checks/decision-ids +159 -0
- formwork_cli/kit/check/checks/doc-links +133 -0
- formwork_cli/kit/check/checks/generated-current +74 -0
- formwork_cli/kit/check/checks/guard-wired +139 -0
- formwork_cli/kit/check/checks/kit-integrity +199 -0
- formwork_cli/kit/check/checks/predictions-first +127 -0
- formwork_cli/kit/check/checks/role-shape +172 -0
- formwork_cli/kit/check/checks/rule-labels +135 -0
- formwork_cli/kit/check/fixtures/config-shape/must-fail/documents-a-section-that-does-not-exist/.formwork.toml +5 -0
- formwork_cli/kit/check/fixtures/config-shape/must-fail/documents-a-section-that-does-not-exist/formwork/guide.md +13 -0
- formwork_cli/kit/check/fixtures/config-shape/must-fail/rules-as-a-switchboard/.formwork.toml +8 -0
- formwork_cli/kit/check/fixtures/config-shape/must-pass/layers-kept-apart/.formwork.toml +5 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-fail/a-placeholder-shipped/docs/decisions/0003-still-pending.md +7 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-fail/superseded-by-nothing/docs/decisions/0002-old.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-fail/two-decisions-one-number/docs/decisions/0007-first.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-fail/two-decisions-one-number/docs/decisions/0007-second.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0001-the-first.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0002-the-second.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-pass/clean-numbering/docs/decisions/0003-the-third.md +6 -0
- formwork_cli/kit/check/fixtures/decision-ids/must-pass/nothing-recorded-yet/docs/decisions/README.md +3 -0
- formwork_cli/kit/check/fixtures/doc-links/must-fail/never-written/index.md +7 -0
- formwork_cli/kit/check/fixtures/doc-links/must-fail/renamed-file/architecture-notes.md +3 -0
- formwork_cli/kit/check/fixtures/doc-links/must-fail/renamed-file/guide.md +8 -0
- formwork_cli/kit/check/fixtures/doc-links/must-pass/links-resolve/architecture-notes.md +1 -0
- formwork_cli/kit/check/fixtures/doc-links/must-pass/links-resolve/guide.md +5 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.claude/agents/sample.md +22 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.codex/agents/sample.toml +22 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.formwork.toml +1 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/.gemini/agents/sample.md +23 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/formwork/build +349 -0
- formwork_cli/kit/check/fixtures/generated-current/must-fail/a-generated-file-was-edited/formwork/roles/method/sample.md +18 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.claude/agents/sample.md +20 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.codex/agents/sample.toml +22 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.formwork.toml +1 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/.gemini/agents/sample.md +23 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/formwork/build +349 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/generated-and-current/formwork/roles/method/sample.md +18 -0
- formwork_cli/kit/check/fixtures/generated-current/must-pass/nothing-is-generated-here/README.md +3 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-fail/declared-but-no-file/.formwork.toml +1 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-fail/file-but-not-wired/.claude/settings.json +1 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-fail/file-but-not-wired/.formwork.toml +1 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-pass/declared-and-wired/.claude/settings.json +1 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-pass/declared-and-wired/.formwork.toml +1 -0
- formwork_cli/kit/check/fixtures/guard-wired/must-pass/nothing-declared/README.md +1 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-check-went-missing/formwork/check/checks/still-here +2 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-check-went-missing/state/fingerprints.txt +2 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-guard-was-altered/formwork/guard/git-boundary +3 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-fail/a-guard-was-altered/state/fingerprints.txt +1 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-pass/everything-matches/formwork/guard/git-boundary +2 -0
- formwork_cli/kit/check/fixtures/kit-integrity/must-pass/everything-matches/state/fingerprints.txt +1 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/architect.md +3 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/researcher.md +3 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-fail/argued-with-no-predictions/docs/rounds/0004-the-storage-question/round.md +4 -0
- 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
- formwork_cli/kit/check/fixtures/predictions-first/must-pass/no-rounds-at-all/docs/README.md +3 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/architect.md +3 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/predictions.md +4 -0
- formwork_cli/kit/check/fixtures/predictions-first/must-pass/predictions-on-record/docs/rounds/0005-the-shape-of-a-brief/researcher.md +3 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/claims-a-grant-binds-everywhere/formwork/roles/README.md +6 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/claims-a-grant-binds-everywhere/formwork/roles/complete.md +18 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/missing-a-section/formwork/roles/vague.md +16 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/spawn-without-being-lead/formwork/roles/eager.md +18 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/two-roles-one-job/formwork/roles/first.md +18 -0
- formwork_cli/kit/check/fixtures/role-shape/must-fail/two-roles-one-job/formwork/roles/second.md +18 -0
- formwork_cli/kit/check/fixtures/role-shape/must-pass/well-formed/formwork/roles/complete.md +18 -0
- formwork_cli/kit/check/fixtures/rule-labels/must-fail/claims-enforcement-that-does-not-exist/formwork/rules/core.md +9 -0
- formwork_cli/kit/check/fixtures/rule-labels/must-fail/no-catches/formwork/rules/core.md +9 -0
- formwork_cli/kit/check/fixtures/rule-labels/must-fail/unlabelled/formwork/rules/core.md +7 -0
- formwork_cli/kit/check/fixtures/rule-labels/must-pass/well-formed/formwork/rules/core.md +10 -0
- formwork_cli/kit/check/run +340 -0
- formwork_cli/kit/check/test_gate.py +222 -0
- formwork_cli/kit/first-run.md +204 -0
- formwork_cli/kit/fw +121 -0
- formwork_cli/kit/glossary.md +160 -0
- formwork_cli/kit/guard/git-boundary +627 -0
- formwork_cli/kit/guard/protected-files +748 -0
- formwork_cli/kit/guard/quality-gate +260 -0
- formwork_cli/kit/guard/test_boundary.py +273 -0
- formwork_cli/kit/guard/test_protection.py +254 -0
- formwork_cli/kit/guard/test_quality_gate.py +156 -0
- formwork_cli/kit/install +395 -0
- formwork_cli/kit/limits.md +141 -0
- formwork_cli/kit/loop.md +82 -0
- formwork_cli/kit/roles/HOW-TO-ADD-A-ROLE.md +105 -0
- formwork_cli/kit/roles/TEMPLATE.md +26 -0
- formwork_cli/kit/roles/method/architect.md +269 -0
- formwork_cli/kit/roles/method/challenger.md +243 -0
- formwork_cli/kit/roles/method/lead.md +280 -0
- formwork_cli/kit/roles/method/record-keeper.md +206 -0
- formwork_cli/kit/roles/method/researcher.md +246 -0
- formwork_cli/kit/roles/method/reviewer.md +207 -0
- formwork_cli/kit/roles/packs/accessibility.md +236 -0
- formwork_cli/kit/roles/packs/ai.md +248 -0
- formwork_cli/kit/roles/packs/analyst.md +233 -0
- formwork_cli/kit/roles/packs/backend.md +425 -0
- formwork_cli/kit/roles/packs/brainstormer.md +190 -0
- formwork_cli/kit/roles/packs/data.md +212 -0
- formwork_cli/kit/roles/packs/devops.md +203 -0
- formwork_cli/kit/roles/packs/frontend.md +224 -0
- formwork_cli/kit/roles/packs/integrations.md +215 -0
- formwork_cli/kit/roles/packs/legal.md +251 -0
- formwork_cli/kit/roles/packs/marketing.md +206 -0
- formwork_cli/kit/roles/packs/mobile.md +202 -0
- formwork_cli/kit/roles/packs/performance.md +192 -0
- formwork_cli/kit/roles/packs/product.md +217 -0
- formwork_cli/kit/roles/packs/security.md +267 -0
- formwork_cli/kit/roles/packs/sre.md +203 -0
- formwork_cli/kit/roles/packs/tester.md +246 -0
- formwork_cli/kit/roles/packs/user-researcher.md +218 -0
- formwork_cli/kit/roles/packs/ux.md +205 -0
- formwork_cli/kit/roles/packs/visual.md +199 -0
- formwork_cli/kit/roles/packs/writer.md +198 -0
- formwork_cli/kit/round.md +131 -0
- formwork_cli/kit/rules/core.md +195 -0
- formwork_cli/kit/rules/full.md +493 -0
- formwork_cli/kit/templates/brief.md +68 -0
- formwork_cli/kit/templates/decision.md +93 -0
- formwork_cli/kit/templates/predictions.md +54 -0
- formwork_cli/kit/templates/report.md +52 -0
- formwork_cli/kit/templates/round.md +77 -0
- formwork_cli/kit/test_install.py +165 -0
- formwork_cli/kit/troubleshooting.md +247 -0
- formwork_cli/kit-page/FORMWORK.md +182 -0
- formwork_kit-0.1.0.dist-info/METADATA +308 -0
- formwork_kit-0.1.0.dist-info/RECORD +137 -0
- formwork_kit-0.1.0.dist-info/WHEEL +4 -0
- formwork_kit-0.1.0.dist-info/entry_points.txt +2 -0
- 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())
|