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,133 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check: every link between documents points at something that exists.
|
|
3
|
+
|
|
4
|
+
doc-links <directory>
|
|
5
|
+
|
|
6
|
+
CATCHES A document that refers to a file which was renamed, moved or never
|
|
7
|
+
written. The reference still reads correctly, so nothing announces it,
|
|
8
|
+
and the reader follows it into nothing.
|
|
9
|
+
|
|
10
|
+
Both forms count: a markdown link, and a path in backticks. The
|
|
11
|
+
second is how a broken reference sat on the front page unnoticed.
|
|
12
|
+
|
|
13
|
+
Exit status:
|
|
14
|
+
0 every link resolves
|
|
15
|
+
1 at least one does not, and it is named with its line
|
|
16
|
+
2 the check could not run
|
|
17
|
+
|
|
18
|
+
Only links between files are checked. Web addresses are somebody else's
|
|
19
|
+
problem, and an anchor without a path points inside the same document.
|
|
20
|
+
|
|
21
|
+
Python 3, standard library only, no dependencies.
|
|
22
|
+
"""
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
LINK = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
|
|
28
|
+
# A path inside backticks is a reference too. The front page pointed at a file
|
|
29
|
+
# that did not exist for a day, and the markdown-only version never saw it.
|
|
30
|
+
TICKED = re.compile(r"`([A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]+)+/?)`")
|
|
31
|
+
# Only paths into the kit's own tree, and they resolve from the project root
|
|
32
|
+
# rather than from the file they appear in. Anything else in backticks is
|
|
33
|
+
# somebody else's repository, a command, or an example, and guessing which
|
|
34
|
+
# would produce noise rather than findings.
|
|
35
|
+
OURS = ("formwork/",)
|
|
36
|
+
# Third-party and generated trees. Without these, a single install of
|
|
37
|
+
# somebody's dependencies produced dozens of findings about other people's
|
|
38
|
+
# READMEs, and the file count in the summary was meaningless.
|
|
39
|
+
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "env",
|
|
40
|
+
".tox", ".nox", "build", "dist", "target", "vendor",
|
|
41
|
+
"site-packages", ".next", ".nuxt", "coverage", ".mypy_cache",
|
|
42
|
+
".pytest_cache", ".ruff_cache", ".gradle", "Pods"}
|
|
43
|
+
|
|
44
|
+
# Paths the runner has told this check to stay out of. Part of the check
|
|
45
|
+
# contract: the runner decides, the check obeys.
|
|
46
|
+
EXCLUDED = [os.path.abspath(p) for p in
|
|
47
|
+
os.environ.get("FORMWORK_EXCLUDE", "").split(os.pathsep) if p]
|
|
48
|
+
# .txt was included and produced findings in data files that merely happened
|
|
49
|
+
# to contain bracket-parenthesis text. Markdown and reStructuredText only.
|
|
50
|
+
TEXT_EXT = {".md", ".rst"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def links_in(text):
|
|
54
|
+
# A fenced code block holds examples. Showing a link in one is legitimate
|
|
55
|
+
# work, and failing the gate for it is a false positive in a kit that is
|
|
56
|
+
# mostly documentation.
|
|
57
|
+
fenced = False
|
|
58
|
+
for line_no, line in enumerate(text.split("\n"), 1):
|
|
59
|
+
if line.lstrip().startswith("```"):
|
|
60
|
+
fenced = not fenced
|
|
61
|
+
continue
|
|
62
|
+
if fenced:
|
|
63
|
+
continue
|
|
64
|
+
for m in TICKED.finditer(line):
|
|
65
|
+
target = m.group(1).strip().rstrip("/")
|
|
66
|
+
if target.startswith(OURS):
|
|
67
|
+
yield line_no, target, True # resolves from the root
|
|
68
|
+
for m in LINK.finditer(line):
|
|
69
|
+
target = m.group(1).strip()
|
|
70
|
+
if not target:
|
|
71
|
+
continue
|
|
72
|
+
# Somebody else's server, or a spot inside this same document.
|
|
73
|
+
if target.startswith(("http://", "https://", "mailto:", "#")):
|
|
74
|
+
continue
|
|
75
|
+
# A trailing anchor names a heading; the file is what we resolve.
|
|
76
|
+
path = target.split("#", 1)[0]
|
|
77
|
+
if path:
|
|
78
|
+
yield line_no, path, False # resolves from this file
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main(argv):
|
|
82
|
+
if len(argv) < 2:
|
|
83
|
+
print("usage: doc-links <directory>", file=sys.stderr)
|
|
84
|
+
return 2
|
|
85
|
+
root = argv[1]
|
|
86
|
+
if not os.path.isdir(root):
|
|
87
|
+
print("ERROR: not a directory: %s" % root, file=sys.stderr)
|
|
88
|
+
return 2
|
|
89
|
+
|
|
90
|
+
broken = []
|
|
91
|
+
unreadable = []
|
|
92
|
+
scanned = 0
|
|
93
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
94
|
+
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
|
|
95
|
+
here = os.path.abspath(dirpath)
|
|
96
|
+
if any(here == e or here.startswith(e + os.sep) for e in EXCLUDED):
|
|
97
|
+
dirnames[:] = []
|
|
98
|
+
continue
|
|
99
|
+
for fn in filenames:
|
|
100
|
+
if os.path.splitext(fn)[1].lower() not in TEXT_EXT:
|
|
101
|
+
continue
|
|
102
|
+
full = os.path.join(dirpath, fn)
|
|
103
|
+
try:
|
|
104
|
+
text = open(full, encoding="utf-8", errors="strict").read()
|
|
105
|
+
except (UnicodeDecodeError, OSError) as e:
|
|
106
|
+
# Silently skipping meant one stray byte hid a document from
|
|
107
|
+
# this check entirely, and the summary still said every link
|
|
108
|
+
# resolved. Say so instead.
|
|
109
|
+
unreadable.append("%s — cannot be read (%s)"
|
|
110
|
+
% (os.path.relpath(full, root), e))
|
|
111
|
+
continue
|
|
112
|
+
scanned += 1
|
|
113
|
+
for line_no, path, from_root in links_in(text):
|
|
114
|
+
base = root if from_root else dirpath
|
|
115
|
+
resolved = os.path.normpath(os.path.join(base, path))
|
|
116
|
+
if not os.path.exists(resolved):
|
|
117
|
+
broken.append((os.path.relpath(full, root), line_no, path))
|
|
118
|
+
|
|
119
|
+
if broken or unreadable:
|
|
120
|
+
print("%d link(s) point at nothing, in %d file(s) scanned"
|
|
121
|
+
% (len(broken), scanned))
|
|
122
|
+
for rel, line_no, path in broken:
|
|
123
|
+
print(" %s:%d -> %s" % (rel, line_no, path))
|
|
124
|
+
for u in unreadable:
|
|
125
|
+
print(" %s" % u)
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
print("%d file(s) scanned, every link resolves" % scanned)
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
sys.exit(main(sys.argv))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check: every generated file matches what its source produces.
|
|
3
|
+
|
|
4
|
+
generated-current <directory>
|
|
5
|
+
|
|
6
|
+
CATCHES A hand-edit to a generated file. It works, for exactly as long as
|
|
7
|
+
nobody regenerates — then it vanishes, and whoever made it does not
|
|
8
|
+
find out.
|
|
9
|
+
|
|
10
|
+
And a generated file left behind after its source changed, which is
|
|
11
|
+
the drift that copying was supposed to remove and quietly reintroduces
|
|
12
|
+
it.
|
|
13
|
+
|
|
14
|
+
The real work is done by `formwork roles --check`, which regenerates in memory
|
|
15
|
+
and compares bytes. This check exists so that comparison happens on every turn
|
|
16
|
+
rather than when somebody remembers.
|
|
17
|
+
|
|
18
|
+
Exit status:
|
|
19
|
+
0 everything current, or nothing is generated here
|
|
20
|
+
1 something is stale or hand-edited, and it is named
|
|
21
|
+
2 the check could not run
|
|
22
|
+
"""
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
TIMEOUT = 120
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main(argv):
|
|
31
|
+
args = [a for a in argv[1:] if not a.startswith("--")]
|
|
32
|
+
if not args:
|
|
33
|
+
print("usage: generated-current <directory>", file=sys.stderr)
|
|
34
|
+
return 2
|
|
35
|
+
root = args[0]
|
|
36
|
+
if not os.path.isdir(root):
|
|
37
|
+
print("ERROR: not a directory: %s" % root, file=sys.stderr)
|
|
38
|
+
return 2
|
|
39
|
+
|
|
40
|
+
# Absolute, because the generator is run with the project as its working
|
|
41
|
+
# directory and a relative path would then resolve against the wrong root.
|
|
42
|
+
build = os.path.abspath(os.path.join(root, "formwork", "build"))
|
|
43
|
+
if not os.path.isfile(build):
|
|
44
|
+
print("no generator here, nothing generated")
|
|
45
|
+
return 0
|
|
46
|
+
if not os.access(build, os.X_OK):
|
|
47
|
+
print("ERROR: %s is not executable, so nothing can be compared"
|
|
48
|
+
% os.path.relpath(build, root), file=sys.stderr)
|
|
49
|
+
return 2
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
# No --all: the generator checks the configured runtime plus any
|
|
53
|
+
# generated tree already present. Demanding --all made a fork that
|
|
54
|
+
# uses one runtime generate files for three, or live with a red gate.
|
|
55
|
+
p = subprocess.run([build, "--check"], capture_output=True,
|
|
56
|
+
text=True, cwd=root, timeout=TIMEOUT)
|
|
57
|
+
except subprocess.TimeoutExpired:
|
|
58
|
+
print("ERROR: the generator took longer than %ds" % TIMEOUT,
|
|
59
|
+
file=sys.stderr)
|
|
60
|
+
return 2
|
|
61
|
+
except OSError as e:
|
|
62
|
+
print("ERROR: could not run the generator: %s" % e, file=sys.stderr)
|
|
63
|
+
return 2
|
|
64
|
+
|
|
65
|
+
out = (p.stdout + p.stderr).strip()
|
|
66
|
+
if p.returncode == 2:
|
|
67
|
+
print("ERROR: the generator could not run:\n%s" % out, file=sys.stderr)
|
|
68
|
+
return 2
|
|
69
|
+
print(out)
|
|
70
|
+
return 1 if p.returncode else 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
sys.exit(main(sys.argv))
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check: if a runtime is declared, its guard is actually wired up.
|
|
3
|
+
|
|
4
|
+
guard-wired <directory>
|
|
5
|
+
|
|
6
|
+
CATCHES A project that believes it has a boundary and does not. The rule is
|
|
7
|
+
written down, the adapter exists in the repository, and nothing
|
|
8
|
+
connects the two. Everything looks right and nothing refuses.
|
|
9
|
+
|
|
10
|
+
A project that declares no runtime is not failed. It has made no claim.
|
|
11
|
+
|
|
12
|
+
Exit status:
|
|
13
|
+
0 no runtime declared, or the declared runtime is wired
|
|
14
|
+
1 a runtime is declared and its wiring is missing
|
|
15
|
+
2 the check could not run
|
|
16
|
+
"""
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
# Where each runtime keeps its hook configuration, relative to the project.
|
|
23
|
+
WIRING = {
|
|
24
|
+
"claude-code": ".claude/settings.json",
|
|
25
|
+
"codex": ".codex/hooks.json",
|
|
26
|
+
"cursor": ".cursor/hooks.json",
|
|
27
|
+
"gemini-cli": ".gemini/settings.json",
|
|
28
|
+
}
|
|
29
|
+
# All three guards, not one. A settings file with the other two deleted used
|
|
30
|
+
# to report "declared and wired".
|
|
31
|
+
MARKERS = ("formwork/guard/git-boundary",
|
|
32
|
+
"formwork/guard/protected-files",
|
|
33
|
+
"formwork/guard/quality-gate")
|
|
34
|
+
MARKER = MARKERS[0]
|
|
35
|
+
|
|
36
|
+
# Events that actually fire before a tool runs, or at the end of a turn.
|
|
37
|
+
# Anything else is a place to park a hook so it never happens.
|
|
38
|
+
LIVE_EVENTS = {"PreToolUse", "Stop", "SubagentStop", "preToolUse",
|
|
39
|
+
"beforeShellExecution", "BeforeTool"}
|
|
40
|
+
|
|
41
|
+
# A matcher that can match a real tool name. "zzz-never-matches" cannot.
|
|
42
|
+
MATCHES_A_TOOL = re.compile(
|
|
43
|
+
r"(?i)\b(Bash|Write|Edit|NotebookEdit|Task|WebFetch|Read|Glob|Grep|\*|\.\*)\b")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def declared_runtime(root):
|
|
47
|
+
config = os.path.join(root, ".formwork.toml")
|
|
48
|
+
if not os.path.exists(config):
|
|
49
|
+
return None
|
|
50
|
+
try:
|
|
51
|
+
text = open(config, encoding="utf-8", errors="ignore").read()
|
|
52
|
+
except OSError:
|
|
53
|
+
return None
|
|
54
|
+
# Accept the key wherever it sits. The configuration grew a [bindings]
|
|
55
|
+
# section and three documents disagreed about where runtime lived; a
|
|
56
|
+
# reader that only understood one of them broke the other.
|
|
57
|
+
m = re.search(r'^\s*runtime\s*=\s*["\']([^"\']+)["\']', text, re.M)
|
|
58
|
+
return m.group(1) if m else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv):
|
|
62
|
+
if len(argv) < 2:
|
|
63
|
+
print("usage: guard-wired <directory>", file=sys.stderr)
|
|
64
|
+
return 2
|
|
65
|
+
root = argv[1]
|
|
66
|
+
if not os.path.isdir(root):
|
|
67
|
+
print("ERROR: not a directory: %s" % root, file=sys.stderr)
|
|
68
|
+
return 2
|
|
69
|
+
|
|
70
|
+
runtime = declared_runtime(root)
|
|
71
|
+
if runtime is None:
|
|
72
|
+
print("no runtime declared, nothing claimed")
|
|
73
|
+
return 0
|
|
74
|
+
if runtime not in WIRING:
|
|
75
|
+
print("runtime '%s' is not one this kit knows how to wire" % runtime)
|
|
76
|
+
print(" known: %s" % ", ".join(sorted(WIRING)))
|
|
77
|
+
return 1
|
|
78
|
+
|
|
79
|
+
path = os.path.join(root, WIRING[runtime])
|
|
80
|
+
if not os.path.exists(path):
|
|
81
|
+
print("runtime '%s' is declared, but %s does not exist"
|
|
82
|
+
% (runtime, WIRING[runtime]))
|
|
83
|
+
return 1
|
|
84
|
+
try:
|
|
85
|
+
text = open(path, encoding="utf-8", errors="ignore").read()
|
|
86
|
+
except OSError as e:
|
|
87
|
+
print("ERROR: cannot read %s: %s" % (path, e), file=sys.stderr)
|
|
88
|
+
return 2
|
|
89
|
+
# A bare substring test passed when a guard's path appeared anywhere at
|
|
90
|
+
# all — including inside a key called "_disabled_note", and including
|
|
91
|
+
# inside an echo. The path must START a command value.
|
|
92
|
+
# The value may contain escaped quotes: "\"$CLAUDE_PROJECT_DIR\"/formwork/..."
|
|
93
|
+
# A path inside a "command" string was not enough. Every guard was moved
|
|
94
|
+
# to an event that never fires, with a matcher that matches nothing, and
|
|
95
|
+
# this check still said wired. It now reads the shape, not just the text.
|
|
96
|
+
def wired_for(marker):
|
|
97
|
+
try:
|
|
98
|
+
data = json.loads(text)
|
|
99
|
+
except ValueError:
|
|
100
|
+
# Not JSON. Fall back to the text test, which is all the TOML
|
|
101
|
+
# runtimes can be given today.
|
|
102
|
+
return marker in text
|
|
103
|
+
|
|
104
|
+
hooks = data.get("hooks")
|
|
105
|
+
if not isinstance(hooks, dict):
|
|
106
|
+
return False
|
|
107
|
+
for event, entries in hooks.items():
|
|
108
|
+
if event not in LIVE_EVENTS:
|
|
109
|
+
continue
|
|
110
|
+
if not isinstance(entries, list):
|
|
111
|
+
continue
|
|
112
|
+
for entry in entries:
|
|
113
|
+
if not isinstance(entry, dict):
|
|
114
|
+
continue
|
|
115
|
+
matcher = entry.get("matcher")
|
|
116
|
+
if matcher is not None and not MATCHES_A_TOOL.search(str(matcher)):
|
|
117
|
+
continue
|
|
118
|
+
for h in entry.get("hooks") or []:
|
|
119
|
+
if not isinstance(h, dict):
|
|
120
|
+
continue
|
|
121
|
+
cmd = str(h.get("command", ""))
|
|
122
|
+
if marker in cmd and not cmd.strip().startswith("echo"):
|
|
123
|
+
return True
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
missing = [m for m in MARKERS if not wired_for(m)]
|
|
127
|
+
if missing:
|
|
128
|
+
print("runtime '%s' is declared and %s exists, but it does not "
|
|
129
|
+
"invoke:" % (runtime, WIRING[runtime]))
|
|
130
|
+
for m in missing:
|
|
131
|
+
print(" %s" % m)
|
|
132
|
+
return 1
|
|
133
|
+
|
|
134
|
+
print("runtime '%s' declared and wired in %s" % (runtime, WIRING[runtime]))
|
|
135
|
+
return 0
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
sys.exit(main(sys.argv))
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check: nothing that does the enforcing has been changed without saying so.
|
|
3
|
+
|
|
4
|
+
kit-integrity <directory>
|
|
5
|
+
kit-integrity --record <directory> write down what is there now
|
|
6
|
+
|
|
7
|
+
CATCHES A guard, a check, the gate, or a piece of wiring, quietly altered.
|
|
8
|
+
Four characters turn the strongest rule in this kit off, and before
|
|
9
|
+
this check nothing anywhere noticed.
|
|
10
|
+
|
|
11
|
+
It catches the routes the guard cannot: an interpreter assembling a
|
|
12
|
+
path, a human editing in their own editor, a file replaced while the
|
|
13
|
+
hook was disabled.
|
|
14
|
+
|
|
15
|
+
The fingerprints live OUTSIDE the repository, beside the word lists. A record
|
|
16
|
+
kept next to the thing it describes protects nothing.
|
|
17
|
+
|
|
18
|
+
Exit status:
|
|
19
|
+
0 every protected file matches its fingerprint
|
|
20
|
+
1 something changed, was added, or went missing
|
|
21
|
+
2 the check could not run — including no fingerprints recorded yet
|
|
22
|
+
"""
|
|
23
|
+
import hashlib
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
PROTECTED = (
|
|
30
|
+
"formwork/guard",
|
|
31
|
+
"formwork/check/run",
|
|
32
|
+
"formwork/check/checks",
|
|
33
|
+
"formwork/adapters",
|
|
34
|
+
".formwork.toml",
|
|
35
|
+
".claude/settings.json",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
STATE = os.environ.get("FORMWORK_STATE_DIR") or os.environ.get(
|
|
39
|
+
"FORMWORK_DENYLIST_DIR", os.path.expanduser("~/.formwork"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def record_path(root):
|
|
43
|
+
"""Where this project's fingerprints live.
|
|
44
|
+
|
|
45
|
+
One file per project, named after the project's own path.
|
|
46
|
+
|
|
47
|
+
It used to be a single file for the whole machine. Two projects on one
|
|
48
|
+
machine then shared one record, so the second one to be set up inherited
|
|
49
|
+
the first one's fingerprints and reported that its untouched guards had
|
|
50
|
+
been tampered with. Recording to fix it moved the accusation to the other
|
|
51
|
+
project. An audit hit this on its first run.
|
|
52
|
+
"""
|
|
53
|
+
# realpath, not abspath. On macOS /tmp is a symlink to /private/tmp, so
|
|
54
|
+
# the same project reached two ways hashed to two different records and
|
|
55
|
+
# the second one reported "nothing recorded yet".
|
|
56
|
+
# An explicit file wins. Fixtures carry their own state directory and
|
|
57
|
+
# cannot know the name below, which depends on where they were copied to.
|
|
58
|
+
explicit = os.environ.get("FORMWORK_FINGERPRINTS")
|
|
59
|
+
if explicit:
|
|
60
|
+
return explicit
|
|
61
|
+
full = os.path.realpath(root)
|
|
62
|
+
digest = hashlib.sha256(full.encode("utf-8")).hexdigest()[:16]
|
|
63
|
+
name = re.sub(r"[^A-Za-z0-9_.-]", "-", os.path.basename(full.rstrip(os.sep)))
|
|
64
|
+
mine = os.path.join(STATE, "fingerprints", "%s-%s.txt" % (name, digest))
|
|
65
|
+
|
|
66
|
+
# One migration, once. An older version kept a single record for the whole
|
|
67
|
+
# machine. Reading it as a fallback brought the shared-record bug straight
|
|
68
|
+
# back, so instead it is moved here the first time this project is
|
|
69
|
+
# checked, and the flat file is left behind renamed so nothing is lost.
|
|
70
|
+
legacy = os.path.join(STATE, "fingerprints.txt")
|
|
71
|
+
if os.path.isfile(legacy) and not os.path.exists(mine):
|
|
72
|
+
try:
|
|
73
|
+
os.makedirs(os.path.dirname(mine), exist_ok=True)
|
|
74
|
+
shutil.copyfile(legacy, mine)
|
|
75
|
+
os.rename(legacy, legacy + ".migrated")
|
|
76
|
+
print("moved the old machine-wide record to %s" % mine,
|
|
77
|
+
file=sys.stderr)
|
|
78
|
+
print(" It used to be shared by every project on this machine.",
|
|
79
|
+
file=sys.stderr)
|
|
80
|
+
except OSError:
|
|
81
|
+
pass
|
|
82
|
+
return mine
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def digest(path):
|
|
86
|
+
h = hashlib.sha256()
|
|
87
|
+
with open(path, "rb") as f:
|
|
88
|
+
for block in iter(lambda: f.read(65536), b""):
|
|
89
|
+
h.update(block)
|
|
90
|
+
return h.hexdigest()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def protected_files(root):
|
|
94
|
+
found = {}
|
|
95
|
+
for rel in PROTECTED:
|
|
96
|
+
full = os.path.join(root, rel)
|
|
97
|
+
if os.path.isfile(full):
|
|
98
|
+
found[rel] = digest(full)
|
|
99
|
+
elif os.path.isdir(full):
|
|
100
|
+
for dp, dn, fns in os.walk(full):
|
|
101
|
+
dn[:] = [d for d in dn if d not in ("__pycache__", "state")]
|
|
102
|
+
for fn in sorted(fns):
|
|
103
|
+
if fn.endswith(".pyc"):
|
|
104
|
+
continue
|
|
105
|
+
p = os.path.join(dp, fn)
|
|
106
|
+
try:
|
|
107
|
+
found[os.path.relpath(p, root)] = digest(p)
|
|
108
|
+
except OSError:
|
|
109
|
+
# A dangling symlink used to become a traceback and
|
|
110
|
+
# exit 1, which the contract reserves for findings.
|
|
111
|
+
found[os.path.relpath(p, root)] = "UNREADABLE"
|
|
112
|
+
return found
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_record(root):
|
|
116
|
+
if not os.path.exists(record_path(root)):
|
|
117
|
+
return None
|
|
118
|
+
out = {}
|
|
119
|
+
for line in open(record_path(root), encoding="utf-8"):
|
|
120
|
+
line = line.split("#", 1)[0].strip()
|
|
121
|
+
if not line or " " not in line:
|
|
122
|
+
continue
|
|
123
|
+
h, rel = line.split(None, 1)
|
|
124
|
+
out[rel.strip()] = h
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main(argv):
|
|
129
|
+
record_mode = "--record" in argv
|
|
130
|
+
args = [a for a in argv[1:] if not a.startswith("--")]
|
|
131
|
+
if not args:
|
|
132
|
+
print("usage: kit-integrity [--record] <directory>", file=sys.stderr)
|
|
133
|
+
return 2
|
|
134
|
+
root = args[0]
|
|
135
|
+
if not os.path.isdir(root):
|
|
136
|
+
print("ERROR: not a directory: %s" % root, file=sys.stderr)
|
|
137
|
+
return 2
|
|
138
|
+
|
|
139
|
+
now = protected_files(root)
|
|
140
|
+
|
|
141
|
+
if record_mode:
|
|
142
|
+
try:
|
|
143
|
+
os.makedirs(os.path.dirname(record_path(root)), exist_ok=True)
|
|
144
|
+
except OSError as e:
|
|
145
|
+
print("ERROR: cannot write the record: %s" % e, file=sys.stderr)
|
|
146
|
+
print(" Check FORMWORK_STATE_DIR points at a directory you "
|
|
147
|
+
"can write to.", file=sys.stderr)
|
|
148
|
+
return 2
|
|
149
|
+
with open(record_path(root), "w", encoding="utf-8") as f:
|
|
150
|
+
f.write("# Fingerprints of everything that enforces a rule.\n")
|
|
151
|
+
f.write("# Kept outside the repository on purpose: a record beside\n")
|
|
152
|
+
f.write("# the thing it describes protects nothing.\n")
|
|
153
|
+
f.write("# Re-record deliberately, after reviewing what changed.\n")
|
|
154
|
+
for rel in sorted(now):
|
|
155
|
+
f.write("%s %s\n" % (now[rel], rel))
|
|
156
|
+
print("recorded %d file(s) to %s" % (len(now), record_path(root)))
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
if not now:
|
|
160
|
+
# Every other check in this kit treats "the watcher is not watching"
|
|
161
|
+
# as status 2. This one used to call it a pass.
|
|
162
|
+
print("ERROR: none of the protected files are here", file=sys.stderr)
|
|
163
|
+
print(" Nothing is being watched, and that is not a pass.",
|
|
164
|
+
file=sys.stderr)
|
|
165
|
+
return 2
|
|
166
|
+
|
|
167
|
+
was = load_record(root)
|
|
168
|
+
if was is None:
|
|
169
|
+
print("ERROR: no fingerprints recorded at %s" % record_path(root), file=sys.stderr)
|
|
170
|
+
print(" Run: formwork/check/checks/kit-integrity --record .",
|
|
171
|
+
file=sys.stderr)
|
|
172
|
+
print(" Until then nothing is watching the guards, and that is "
|
|
173
|
+
"not a pass.", file=sys.stderr)
|
|
174
|
+
return 2
|
|
175
|
+
|
|
176
|
+
changed = sorted(r for r in now if r in was and now[r] != was[r])
|
|
177
|
+
added = sorted(r for r in now if r not in was)
|
|
178
|
+
missing = sorted(r for r in was if r not in now)
|
|
179
|
+
|
|
180
|
+
if changed or added or missing:
|
|
181
|
+
print("%d protected file(s) differ from the record"
|
|
182
|
+
% (len(changed) + len(added) + len(missing)))
|
|
183
|
+
for r in changed:
|
|
184
|
+
print(" changed %s" % r)
|
|
185
|
+
for r in added:
|
|
186
|
+
print(" added %s" % r)
|
|
187
|
+
for r in missing:
|
|
188
|
+
print(" missing %s" % r)
|
|
189
|
+
print()
|
|
190
|
+
print("These files enforce every other rule. If you meant it, review "
|
|
191
|
+
"the change and re-record.")
|
|
192
|
+
return 1
|
|
193
|
+
|
|
194
|
+
print("%d protected file(s), all matching the record" % len(now))
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
sys.exit(main(sys.argv))
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check: the challenger's predictions were written before it read anything.
|
|
3
|
+
|
|
4
|
+
predictions-first <directory>
|
|
5
|
+
|
|
6
|
+
A round lives in docs/rounds/<name>/ and holds one file per participant. The
|
|
7
|
+
challenger's file is predictions.md.
|
|
8
|
+
|
|
9
|
+
CATCHES A round whose predictions were written afterwards, or not at all.
|
|
10
|
+
|
|
11
|
+
An objection composed after reading somebody's work is shaped by that
|
|
12
|
+
work — you find whichever weakness the text put in front of you. A
|
|
13
|
+
prediction is not. One that proves accurate shows the weakness was
|
|
14
|
+
built into the approach rather than a slip on the day, which is a far
|
|
15
|
+
stronger result. Written afterwards, it is a description, and it
|
|
16
|
+
proves nothing while looking exactly the same.
|
|
17
|
+
|
|
18
|
+
WHAT THE EVIDENCE IS WORTH, HONESTLY
|
|
19
|
+
------------------------------------
|
|
20
|
+
Two things are checked, and they are not equally strong.
|
|
21
|
+
|
|
22
|
+
* **The file exists.** Solid. A round with reports and no predictions did
|
|
23
|
+
not do this at all.
|
|
24
|
+
* **It is older than the reports.** Weak. Modification times are not carried
|
|
25
|
+
by version control and are reset by copying, so this can be wrong in both
|
|
26
|
+
directions.
|
|
27
|
+
|
|
28
|
+
The second is reported as a warning rather than treated as proof. Nothing here
|
|
29
|
+
can prove when somebody thought something; what it can do is make the absence
|
|
30
|
+
impossible to miss.
|
|
31
|
+
|
|
32
|
+
Exit status:
|
|
33
|
+
0 every round has predictions, written first as far as can be told
|
|
34
|
+
1 a round has reports and no predictions
|
|
35
|
+
2 the check could not run
|
|
36
|
+
"""
|
|
37
|
+
import os
|
|
38
|
+
import sys
|
|
39
|
+
|
|
40
|
+
ROUNDS = os.path.join("docs", "rounds")
|
|
41
|
+
PREDICTIONS = "predictions.md"
|
|
42
|
+
# brief.md is the human's input, written before anybody works. Counting it as
|
|
43
|
+
# a participant report meant every correctly-run round warned about itself.
|
|
44
|
+
SKIP = {"README.md", "round.md", "index.md", "brief.md"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def rounds(root):
|
|
48
|
+
base = os.path.join(root, ROUNDS)
|
|
49
|
+
if not os.path.isdir(base):
|
|
50
|
+
return None
|
|
51
|
+
out = []
|
|
52
|
+
for name in sorted(os.listdir(base)):
|
|
53
|
+
d = os.path.join(base, name)
|
|
54
|
+
if os.path.isdir(d):
|
|
55
|
+
out.append((name, d))
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main(argv):
|
|
60
|
+
args = [a for a in argv[1:] if not a.startswith("--")]
|
|
61
|
+
if not args:
|
|
62
|
+
print("usage: predictions-first <directory>", file=sys.stderr)
|
|
63
|
+
return 2
|
|
64
|
+
root = args[0]
|
|
65
|
+
if not os.path.isdir(root):
|
|
66
|
+
print("ERROR: not a directory: %s" % root, file=sys.stderr)
|
|
67
|
+
return 2
|
|
68
|
+
|
|
69
|
+
found = rounds(root)
|
|
70
|
+
if found is None:
|
|
71
|
+
print("no %s directory, no rounds to check" % ROUNDS)
|
|
72
|
+
return 0
|
|
73
|
+
if not found:
|
|
74
|
+
print("no rounds recorded yet")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
problems = []
|
|
78
|
+
warnings = []
|
|
79
|
+
checked = 0
|
|
80
|
+
|
|
81
|
+
for name, d in found:
|
|
82
|
+
files = [f for f in sorted(os.listdir(d))
|
|
83
|
+
if f.endswith(".md") and f not in SKIP]
|
|
84
|
+
reports = [f for f in files if f != PREDICTIONS]
|
|
85
|
+
if not reports:
|
|
86
|
+
continue # nothing was argued yet
|
|
87
|
+
checked += 1
|
|
88
|
+
|
|
89
|
+
predictions = os.path.join(d, PREDICTIONS)
|
|
90
|
+
if not os.path.exists(predictions):
|
|
91
|
+
problems.append("%s — has %d report(s) and no %s"
|
|
92
|
+
% (name, len(reports), PREDICTIONS))
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
when = os.path.getmtime(predictions)
|
|
97
|
+
earliest = min(os.path.getmtime(os.path.join(d, r)) for r in reports)
|
|
98
|
+
except OSError:
|
|
99
|
+
continue
|
|
100
|
+
if when > earliest:
|
|
101
|
+
warnings.append("%s — %s is newer than the earliest report"
|
|
102
|
+
% (name, PREDICTIONS))
|
|
103
|
+
|
|
104
|
+
if problems:
|
|
105
|
+
print("%d round(s) argued without predictions, of %d checked"
|
|
106
|
+
% (len(problems), checked))
|
|
107
|
+
for p in problems:
|
|
108
|
+
print(" %s" % p)
|
|
109
|
+
if warnings:
|
|
110
|
+
for w in warnings:
|
|
111
|
+
print(" warning: %s" % w)
|
|
112
|
+
return 1
|
|
113
|
+
|
|
114
|
+
if warnings:
|
|
115
|
+
print("%d round(s) checked. Nothing missing, but:" % checked)
|
|
116
|
+
for w in warnings:
|
|
117
|
+
print(" warning: %s" % w)
|
|
118
|
+
print(" Modification times are weak evidence — version control does "
|
|
119
|
+
"not carry them and copying resets them.")
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
print("%d round(s) checked, each with predictions on record" % checked)
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
sys.exit(main(sys.argv))
|