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
formwork_cli/kit/build
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate each runtime's role files from one source.
|
|
3
|
+
|
|
4
|
+
formwork roles write files for the runtime in .formwork.toml
|
|
5
|
+
formwork roles --all write files for every runtime
|
|
6
|
+
formwork roles --runtime codex just that one
|
|
7
|
+
formwork roles --check regenerate in memory and compare. Change nothing
|
|
8
|
+
|
|
9
|
+
Exit status:
|
|
10
|
+
0 done, or --check found everything current
|
|
11
|
+
1 --check found a generated file that is stale or hand-edited
|
|
12
|
+
2 could not run
|
|
13
|
+
|
|
14
|
+
WHY GENERATE AT ALL
|
|
15
|
+
-------------------
|
|
16
|
+
Four runtimes want role definitions in four shapes. Keeping four hand-written
|
|
17
|
+
copies of twenty-seven roles is the duplication that drifts — one gets updated
|
|
18
|
+
and the others quietly do not, and nothing announces it.
|
|
19
|
+
|
|
20
|
+
One source, generated outward, with a check that regeneration produces
|
|
21
|
+
identical bytes. A hand-edit to a generated file then fails the gate instead of
|
|
22
|
+
surviving.
|
|
23
|
+
|
|
24
|
+
WHAT EACH RUNTIME GETS, AND WHY CURSOR GETS NOTHING
|
|
25
|
+
---------------------------------------------------
|
|
26
|
+
claude-code .claude/agents/<name>.md markdown, tools by name
|
|
27
|
+
gemini-cli .gemini/agents/<name>.md markdown, tools by name
|
|
28
|
+
codex .codex/agents/<name>.toml TOML, no tool list at all
|
|
29
|
+
cursor nothing is written
|
|
30
|
+
|
|
31
|
+
Cursor's own documentation names `.claude/agents/` as a location it reads. So
|
|
32
|
+
the Claude Code output serves it directly, and generating a second identical
|
|
33
|
+
tree would be duplication for its own sake.
|
|
34
|
+
|
|
35
|
+
THE TOOL GRANT DOES NOT SURVIVE EVERY TRANSLATION
|
|
36
|
+
-------------------------------------------------
|
|
37
|
+
Claude Code and Gemini CLI take a named list and enforce it. Cursor has one
|
|
38
|
+
boolean. Codex has a sandbox mode, which is not a tool list.
|
|
39
|
+
|
|
40
|
+
This generator writes the grant where it can be expressed and **says in the
|
|
41
|
+
generated file where it cannot**, rather than emitting something that looks
|
|
42
|
+
like a restriction and is not. That was decided deliberately; the two rejected
|
|
43
|
+
alternatives are recorded in docs/role-formats.md.
|
|
44
|
+
|
|
45
|
+
Python 3, standard library only, no dependencies.
|
|
46
|
+
"""
|
|
47
|
+
import os
|
|
48
|
+
import re
|
|
49
|
+
import sys
|
|
50
|
+
|
|
51
|
+
CLEAN, STALE, CANNOT_RUN = 0, 1, 2
|
|
52
|
+
|
|
53
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
54
|
+
ROOT = os.path.dirname(HERE)
|
|
55
|
+
ROLES = os.path.join(HERE, "roles")
|
|
56
|
+
|
|
57
|
+
BANNER = ("GENERATED FROM %s — DO NOT EDIT.\n"
|
|
58
|
+
"Change the source and run formwork roles. A hand-edit here fails "
|
|
59
|
+
"the gate.")
|
|
60
|
+
|
|
61
|
+
# The kit's five abstract grants, mapped to what each runtime actually calls
|
|
62
|
+
# them. None means the name is not established, and the generator will not
|
|
63
|
+
# invent one.
|
|
64
|
+
TOOL_NAMES = {
|
|
65
|
+
"claude-code": {
|
|
66
|
+
"read": ["Read", "Glob", "Grep"],
|
|
67
|
+
"write": ["Write", "Edit"],
|
|
68
|
+
"run": ["Bash"],
|
|
69
|
+
"web": ["WebFetch", "WebSearch"],
|
|
70
|
+
"spawn": ["Task"],
|
|
71
|
+
},
|
|
72
|
+
"gemini-cli": {
|
|
73
|
+
"read": ["read_file", "grep_search"],
|
|
74
|
+
"run": ["run_shell_command"],
|
|
75
|
+
# Established from the publisher's own example. The name of the
|
|
76
|
+
# file-writing tool is NOT among the documented examples, and guessing
|
|
77
|
+
# it would produce an allowlist that silently omits a tool the role
|
|
78
|
+
# needs. See docs/role-formats.md.
|
|
79
|
+
"write": None,
|
|
80
|
+
"web": None,
|
|
81
|
+
"spawn": None,
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Runtimes that can hold a role to its grant at all.
|
|
86
|
+
ENFORCES = {"claude-code", "gemini-cli"}
|
|
87
|
+
|
|
88
|
+
TARGETS = {
|
|
89
|
+
"claude-code": (os.path.join(".claude", "agents"), ".md"),
|
|
90
|
+
"gemini-cli": (os.path.join(".gemini", "agents"), ".md"),
|
|
91
|
+
"codex": (os.path.join(".codex", "agents"), ".toml"),
|
|
92
|
+
}
|
|
93
|
+
ALL_RUNTIMES = tuple(sorted(TARGETS)) + ("cursor",)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def read_roles():
|
|
97
|
+
out = []
|
|
98
|
+
for sub in ("method", "packs", "project"):
|
|
99
|
+
d = os.path.join(ROLES, sub)
|
|
100
|
+
if not os.path.isdir(d):
|
|
101
|
+
continue
|
|
102
|
+
for fn in sorted(os.listdir(d)):
|
|
103
|
+
if not fn.endswith(".md") or fn.upper().startswith(("TEMPLATE",
|
|
104
|
+
"HOW-TO",
|
|
105
|
+
"README")):
|
|
106
|
+
continue
|
|
107
|
+
out.append(os.path.join(d, fn))
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse(path):
|
|
112
|
+
text = open(path, encoding="utf-8").read()
|
|
113
|
+
m = re.match(r"^---\n(.*?)\n---\n(.*)$", text, re.S)
|
|
114
|
+
if not m:
|
|
115
|
+
return None
|
|
116
|
+
meta = {}
|
|
117
|
+
for line in m.group(1).split("\n"):
|
|
118
|
+
if ":" in line:
|
|
119
|
+
k, v = line.split(":", 1)
|
|
120
|
+
meta[k.strip()] = v.strip()
|
|
121
|
+
body = m.group(2).lstrip("\n")
|
|
122
|
+
# Accept both `tools: ["read", "write"]` and `tools: [read, write]`.
|
|
123
|
+
# Reading only the quoted form meant an unquoted list parsed as NO grants,
|
|
124
|
+
# which emitted frontmatter with no tools key at all — and in Claude Code
|
|
125
|
+
# that grants every tool. The safest-looking role got the widest grant.
|
|
126
|
+
raw = meta.get("tools", "")
|
|
127
|
+
tools = re.findall(r'"([a-z]+)"', raw) or re.findall(r"\b([a-z]+)\b", raw)
|
|
128
|
+
owns = re.search(r"^\*\*Owns\.\*\*\s*(.+?)(?:\n\n|\Z)", body, re.S | re.M)
|
|
129
|
+
description = " ".join(owns.group(1).split()) if owns else meta.get("name", "")
|
|
130
|
+
if len(description) > 300:
|
|
131
|
+
description = description[:297].rsplit(" ", 1)[0] + "…"
|
|
132
|
+
return {"name": meta.get("name", ""), "pack": meta.get("pack", ""),
|
|
133
|
+
"owns": meta.get("owns", ""), "tools": tools,
|
|
134
|
+
"description": description, "body": body,
|
|
135
|
+
"source": os.path.relpath(path, ROOT)}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def resolve_tools(runtime, grants):
|
|
139
|
+
"""(names, unmapped). names is None when the list cannot be completed."""
|
|
140
|
+
table = TOOL_NAMES.get(runtime)
|
|
141
|
+
if table is None:
|
|
142
|
+
return None, list(grants)
|
|
143
|
+
names, unmapped = [], []
|
|
144
|
+
for g in grants:
|
|
145
|
+
got = table.get(g)
|
|
146
|
+
if got is None:
|
|
147
|
+
unmapped.append(g)
|
|
148
|
+
else:
|
|
149
|
+
names.extend(got)
|
|
150
|
+
if unmapped:
|
|
151
|
+
return None, unmapped
|
|
152
|
+
return names, []
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def render_markdown(role, runtime):
|
|
156
|
+
names, unmapped = resolve_tools(runtime, role["tools"])
|
|
157
|
+
if not role["tools"]:
|
|
158
|
+
# No grant could be read at all. Emitting frontmatter without a tools
|
|
159
|
+
# key means "every tool" on some runtimes, so say so instead.
|
|
160
|
+
unmapped = ["(the grant could not be read from the source)"]
|
|
161
|
+
lines = ["---", "name: %s" % role["name"],
|
|
162
|
+
"description: %s" % role["description"]]
|
|
163
|
+
if runtime == "gemini-cli":
|
|
164
|
+
lines.append("kind: local")
|
|
165
|
+
if names:
|
|
166
|
+
if runtime == "claude-code":
|
|
167
|
+
lines.append("tools: %s" % ", ".join(names))
|
|
168
|
+
else:
|
|
169
|
+
lines.append("tools:")
|
|
170
|
+
lines += [" - %s" % n for n in names]
|
|
171
|
+
lines.append("---")
|
|
172
|
+
lines.append("")
|
|
173
|
+
for line in (BANNER % role["source"]).split("\n"):
|
|
174
|
+
lines.append("<!-- %s -->" % line)
|
|
175
|
+
lines.append("")
|
|
176
|
+
if unmapped:
|
|
177
|
+
lines.append("> **This role's tool grant is not expressed here.** It "
|
|
178
|
+
"asks for %s, and %s does not have a documented name for "
|
|
179
|
+
"%s. An incomplete allowlist would quietly remove a tool "
|
|
180
|
+
"the role needs, so none is written. The grant is advice "
|
|
181
|
+
"on this runtime."
|
|
182
|
+
% (", ".join("`%s`" % t for t in role["tools"]),
|
|
183
|
+
runtime, " and ".join("`%s`" % u for u in unmapped)))
|
|
184
|
+
lines.append("")
|
|
185
|
+
lines.append(role["body"].rstrip())
|
|
186
|
+
lines.append("")
|
|
187
|
+
return "\n".join(lines)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def toml_escape(s):
|
|
191
|
+
return s.replace("\\", "\\\\").replace('"', '\\"')
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def render_toml(role):
|
|
195
|
+
banner = (BANNER % role["source"]).split("\n")
|
|
196
|
+
out = ["# %s" % b for b in banner]
|
|
197
|
+
out.append("")
|
|
198
|
+
out.append('name = "%s"' % toml_escape(role["name"]))
|
|
199
|
+
out.append('description = "%s"' % toml_escape(role["description"]))
|
|
200
|
+
out.append("")
|
|
201
|
+
out.append("# This runtime has a sandbox mode rather than a tool list, so")
|
|
202
|
+
out.append("# the grant %s is not expressed here. It is advice on Codex."
|
|
203
|
+
% ", ".join(role["tools"]))
|
|
204
|
+
out.append("")
|
|
205
|
+
out.append('developer_instructions = """')
|
|
206
|
+
out.append(role["body"].rstrip().replace('"""', '\\"\\"\\"'))
|
|
207
|
+
out.append('"""')
|
|
208
|
+
out.append("")
|
|
209
|
+
return "\n".join(out)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def existing_targets():
|
|
213
|
+
"""Runtimes whose generated tree is already present in this project.
|
|
214
|
+
|
|
215
|
+
A fork that uses one runtime should not be made to generate files for
|
|
216
|
+
three. But a tree that exists must stay current, or a stale agent
|
|
217
|
+
definition survives unnoticed — which is the whole reason this generator
|
|
218
|
+
exists.
|
|
219
|
+
"""
|
|
220
|
+
out = []
|
|
221
|
+
for runtime, (folder, _ext) in sorted(TARGETS.items()):
|
|
222
|
+
if os.path.isdir(os.path.join(ROOT, folder)):
|
|
223
|
+
out.append(runtime)
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def orphans(files, runtimes):
|
|
228
|
+
"""Generated files with no source, in the trees we generate into.
|
|
229
|
+
|
|
230
|
+
An agent definition added by hand, or one left behind after its source was
|
|
231
|
+
deleted, used to pass unnoticed: --check only compared files the source
|
|
232
|
+
produces. A hand-written agent with every tool granted is exactly what
|
|
233
|
+
this kit exists to prevent.
|
|
234
|
+
"""
|
|
235
|
+
expected = set(files)
|
|
236
|
+
found = []
|
|
237
|
+
for runtime in runtimes:
|
|
238
|
+
if runtime == "cursor":
|
|
239
|
+
continue
|
|
240
|
+
folder, ext = TARGETS[runtime]
|
|
241
|
+
d = os.path.join(ROOT, folder)
|
|
242
|
+
if not os.path.isdir(d):
|
|
243
|
+
continue
|
|
244
|
+
for fn in sorted(os.listdir(d)):
|
|
245
|
+
if not fn.endswith(ext):
|
|
246
|
+
continue
|
|
247
|
+
rel = os.path.join(folder, fn)
|
|
248
|
+
if rel not in expected:
|
|
249
|
+
found.append(rel)
|
|
250
|
+
return found
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
SAFE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def unsafe_names(roles):
|
|
257
|
+
"""Role names that are not safe to put in a path, with the reason.
|
|
258
|
+
|
|
259
|
+
`name:` was joined into a file path with no checking at all. A role
|
|
260
|
+
claiming `name: /tmp/anything` or `name: ../../../elsewhere` made the
|
|
261
|
+
generator write outside the project, and the gate stayed green. Somebody
|
|
262
|
+
cloning a repository that ships a kit would have run one documented
|
|
263
|
+
command and had files written wherever they can write.
|
|
264
|
+
"""
|
|
265
|
+
bad = []
|
|
266
|
+
seen = {}
|
|
267
|
+
for r in roles:
|
|
268
|
+
n = r["name"]
|
|
269
|
+
if not SAFE_NAME.match(n):
|
|
270
|
+
bad.append("%s declares name: %r. A name must be lower case "
|
|
271
|
+
"letters, digits and hyphens, and nothing else."
|
|
272
|
+
% (r["source"], n))
|
|
273
|
+
elif n in seen:
|
|
274
|
+
# Two roles with one name silently collapsed into one file, and
|
|
275
|
+
# both the shipped role and one of the pair disappeared.
|
|
276
|
+
bad.append("%s and %s both declare name: %s. Names must be unique."
|
|
277
|
+
% (seen[n], r["source"], n))
|
|
278
|
+
else:
|
|
279
|
+
seen[n] = r["source"]
|
|
280
|
+
return bad
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def outputs(runtimes):
|
|
284
|
+
"""{path: contents} for every runtime asked for."""
|
|
285
|
+
files = {}
|
|
286
|
+
roles = [parse(p) for p in read_roles()]
|
|
287
|
+
roles = [r for r in roles if r and r["name"]]
|
|
288
|
+
if not roles:
|
|
289
|
+
return None
|
|
290
|
+
bad = unsafe_names(roles)
|
|
291
|
+
if bad:
|
|
292
|
+
raise ValueError("\n".join(bad))
|
|
293
|
+
for runtime in runtimes:
|
|
294
|
+
if runtime == "cursor":
|
|
295
|
+
continue # reads .claude/agents/ directly
|
|
296
|
+
folder, ext = TARGETS[runtime]
|
|
297
|
+
for role in roles:
|
|
298
|
+
rel = os.path.join(folder, role["name"] + ext)
|
|
299
|
+
if ext == ".toml":
|
|
300
|
+
files[rel] = render_toml(role)
|
|
301
|
+
else:
|
|
302
|
+
files[rel] = render_markdown(role, runtime)
|
|
303
|
+
return files
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def configured_runtime():
|
|
307
|
+
config = os.path.join(ROOT, ".formwork.toml")
|
|
308
|
+
if os.path.exists(config):
|
|
309
|
+
text = open(config, encoding="utf-8", errors="ignore").read()
|
|
310
|
+
m = re.search(r'^\s*runtime\s*=\s*["\']([^"\']+)["\']', text, re.M)
|
|
311
|
+
if m and m.group(1) in ALL_RUNTIMES:
|
|
312
|
+
return m.group(1)
|
|
313
|
+
return "claude-code"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def main(argv):
|
|
317
|
+
check_only = "--check" in argv
|
|
318
|
+
if "--all" in argv:
|
|
319
|
+
runtimes = [r for r in ALL_RUNTIMES if r != "cursor"]
|
|
320
|
+
elif check_only and "--runtime" not in argv:
|
|
321
|
+
# Check the configured runtime, plus any tree already present. A fork
|
|
322
|
+
# using one runtime is not made to generate files for three, and a
|
|
323
|
+
# tree that does exist is still held to being current.
|
|
324
|
+
runtimes = sorted(set([configured_runtime()] + existing_targets())
|
|
325
|
+
- {"cursor"})
|
|
326
|
+
if not runtimes:
|
|
327
|
+
runtimes = ["claude-code"]
|
|
328
|
+
elif "--runtime" in argv:
|
|
329
|
+
i = argv.index("--runtime")
|
|
330
|
+
if i + 1 >= len(argv) or argv[i + 1] not in ALL_RUNTIMES:
|
|
331
|
+
print("ERROR: --runtime needs one of: %s" % ", ".join(ALL_RUNTIMES),
|
|
332
|
+
file=sys.stderr)
|
|
333
|
+
return CANNOT_RUN
|
|
334
|
+
runtimes = [argv[i + 1]]
|
|
335
|
+
else:
|
|
336
|
+
if not os.path.exists(os.path.join(ROOT, ".formwork.toml")) \
|
|
337
|
+
and not check_only:
|
|
338
|
+
print("ERROR: this project has no .formwork.toml, so there is no "
|
|
339
|
+
"runtime to generate for.", file=sys.stderr)
|
|
340
|
+
print(" Run the installer first, or say which: "
|
|
341
|
+
"--runtime claude-code", file=sys.stderr)
|
|
342
|
+
return CANNOT_RUN
|
|
343
|
+
runtimes = [configured_runtime()]
|
|
344
|
+
|
|
345
|
+
if runtimes == ["cursor"]:
|
|
346
|
+
print("cursor reads .claude/agents/, so nothing is generated for it")
|
|
347
|
+
runtimes = ["claude-code"]
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
files = outputs(runtimes)
|
|
351
|
+
except ValueError as e:
|
|
352
|
+
print("ERROR: %d role name(s) cannot be used:" % len(str(e).split("\n")),
|
|
353
|
+
file=sys.stderr)
|
|
354
|
+
for line in str(e).split("\n"):
|
|
355
|
+
print(" %s" % line, file=sys.stderr)
|
|
356
|
+
return CANNOT_RUN
|
|
357
|
+
if files is None:
|
|
358
|
+
print("ERROR: no roles found under %s" % ROLES, file=sys.stderr)
|
|
359
|
+
return CANNOT_RUN
|
|
360
|
+
|
|
361
|
+
if check_only:
|
|
362
|
+
stale = ["%s — generated, but nothing in formwork/roles/ produces it"
|
|
363
|
+
% o for o in orphans(files, runtimes)]
|
|
364
|
+
for rel, content in sorted(files.items()):
|
|
365
|
+
full = os.path.join(ROOT, rel)
|
|
366
|
+
if not os.path.exists(full):
|
|
367
|
+
stale.append("%s — missing" % rel)
|
|
368
|
+
elif open(full, encoding="utf-8").read() != content:
|
|
369
|
+
stale.append("%s — differs from what the source produces" % rel)
|
|
370
|
+
if stale:
|
|
371
|
+
print("%d generated file(s) are not current:" % len(stale))
|
|
372
|
+
for s in stale:
|
|
373
|
+
print(" %s" % s)
|
|
374
|
+
print(" Run formwork roles. If you edited one by hand, that edit "
|
|
375
|
+
"is about to be lost — move it to the source first.")
|
|
376
|
+
return STALE
|
|
377
|
+
print("%d generated file(s), all current" % len(files))
|
|
378
|
+
return CLEAN
|
|
379
|
+
|
|
380
|
+
written = 0
|
|
381
|
+
kept = []
|
|
382
|
+
for rel, content in sorted(files.items()):
|
|
383
|
+
full = os.path.join(ROOT, rel)
|
|
384
|
+
os.makedirs(os.path.dirname(full), exist_ok=True)
|
|
385
|
+
existing = None
|
|
386
|
+
if os.path.exists(full):
|
|
387
|
+
existing = open(full, encoding="utf-8").read()
|
|
388
|
+
# Somebody else's agent file, with the same name as one of ours.
|
|
389
|
+
# Overwriting it destroyed hand-written work with no backup and no
|
|
390
|
+
# message, in a program whose own promise is that it never deletes.
|
|
391
|
+
if existing is not None and BANNER.split(" ")[0] not in existing:
|
|
392
|
+
kept.append(rel)
|
|
393
|
+
continue
|
|
394
|
+
if existing != content:
|
|
395
|
+
open(full, "w", encoding="utf-8").write(content)
|
|
396
|
+
written += 1
|
|
397
|
+
if kept:
|
|
398
|
+
print("NOT WRITTEN, because these already exist and this kit did not "
|
|
399
|
+
"make them:")
|
|
400
|
+
for rel in kept:
|
|
401
|
+
print(" %s" % rel)
|
|
402
|
+
print(" Your file is untouched. Rename yours, or rename the role in "
|
|
403
|
+
"formwork/roles/, then run this again.")
|
|
404
|
+
print("%d role file(s) considered, %d written, for: %s"
|
|
405
|
+
% (len(files), written, ", ".join(runtimes)))
|
|
406
|
+
return CLEAN
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
if __name__ == "__main__":
|
|
410
|
+
sys.exit(main(sys.argv))
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check: the configuration file is the shape the documents describe.
|
|
3
|
+
|
|
4
|
+
config-shape <directory>
|
|
5
|
+
|
|
6
|
+
CATCHES Documentation drifting from the file it documents. Three documents
|
|
7
|
+
once described this configuration three different ways, and following
|
|
8
|
+
one of them would have moved a key where the guard could not find it.
|
|
9
|
+
Everything read correctly and the tool quietly stopped working.
|
|
10
|
+
|
|
11
|
+
Two things are checked:
|
|
12
|
+
|
|
13
|
+
1. the layers are separate — [bindings] and [strength] exist, and [rules]
|
|
14
|
+
does not, because rules are not switched off in configuration
|
|
15
|
+
2. every section the kit's own instructions show in an example is a section
|
|
16
|
+
the real file actually has
|
|
17
|
+
|
|
18
|
+
Only the kit's instructions are held to this — FORMWORK.md and everything
|
|
19
|
+
under formwork/. A design document is allowed to describe something not built
|
|
20
|
+
yet; an instruction telling somebody to write a section that nothing reads is
|
|
21
|
+
a different thing, and that is what this catches.
|
|
22
|
+
|
|
23
|
+
Exit status:
|
|
24
|
+
0 the file matches what is documented, or there is no file to check
|
|
25
|
+
1 they disagree, and the disagreement is named
|
|
26
|
+
2 the check could not run
|
|
27
|
+
"""
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
|
|
32
|
+
CONFIG = ".formwork.toml"
|
|
33
|
+
REQUIRED = ("bindings", "strength")
|
|
34
|
+
FORBIDDEN = ("rules",)
|
|
35
|
+
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv"}
|
|
36
|
+
SECTION = re.compile(r"^\s*\[([a-z_]+)\]", re.M)
|
|
37
|
+
# Paths the runner has told this check to stay out of. The kit's own fixtures
|
|
38
|
+
# are wrong on purpose.
|
|
39
|
+
EXCLUDED = [os.path.abspath(p) for p in
|
|
40
|
+
os.environ.get("FORMWORK_EXCLUDE", "").split(os.pathsep) if p]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def excluded(path):
|
|
44
|
+
a = os.path.abspath(path)
|
|
45
|
+
return any(a == e or a.startswith(e + os.sep) for e in EXCLUDED)
|
|
46
|
+
# A toml block in a document that is clearly about this configuration.
|
|
47
|
+
TOML_BLOCK = re.compile(r"```toml\n(.*?)```", re.S)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def sections(text):
|
|
51
|
+
return set(SECTION.findall(text))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(argv):
|
|
55
|
+
if len(argv) < 2:
|
|
56
|
+
print("usage: config-shape <directory>", file=sys.stderr)
|
|
57
|
+
return 2
|
|
58
|
+
root = argv[1]
|
|
59
|
+
if not os.path.isdir(root):
|
|
60
|
+
print("ERROR: not a directory: %s" % root, file=sys.stderr)
|
|
61
|
+
return 2
|
|
62
|
+
|
|
63
|
+
path = os.path.join(root, CONFIG)
|
|
64
|
+
if not os.path.exists(path):
|
|
65
|
+
print("no %s, nothing claimed" % CONFIG)
|
|
66
|
+
return 0
|
|
67
|
+
try:
|
|
68
|
+
real = open(path, encoding="utf-8", errors="strict").read()
|
|
69
|
+
except (UnicodeDecodeError, OSError) as e:
|
|
70
|
+
print("ERROR: cannot read %s: %s" % (CONFIG, e), file=sys.stderr)
|
|
71
|
+
return 2
|
|
72
|
+
|
|
73
|
+
have = sections(real)
|
|
74
|
+
problems = []
|
|
75
|
+
for s in REQUIRED:
|
|
76
|
+
if s not in have:
|
|
77
|
+
problems.append("%s has no [%s] section" % (CONFIG, s))
|
|
78
|
+
for s in FORBIDDEN:
|
|
79
|
+
if s in have:
|
|
80
|
+
problems.append("%s has a [%s] section. Rules are not switched off "
|
|
81
|
+
"in configuration" % (CONFIG, s))
|
|
82
|
+
|
|
83
|
+
# Only the kit's own instructions. Planning documents may describe intent.
|
|
84
|
+
instruction_roots = [os.path.join(root, "formwork")]
|
|
85
|
+
front = os.path.join(root, "FORMWORK.md")
|
|
86
|
+
targets = [front] if os.path.exists(front) else []
|
|
87
|
+
for base in instruction_roots:
|
|
88
|
+
for dp, dn, fns in os.walk(base):
|
|
89
|
+
dn[:] = [d for d in dn if d not in SKIP_DIRS]
|
|
90
|
+
if excluded(dp):
|
|
91
|
+
dn[:] = []
|
|
92
|
+
continue
|
|
93
|
+
targets += [os.path.join(dp, f) for f in fns if f.endswith(".md")]
|
|
94
|
+
|
|
95
|
+
for full in targets:
|
|
96
|
+
if True:
|
|
97
|
+
try:
|
|
98
|
+
text = open(full, encoding="utf-8", errors="ignore").read()
|
|
99
|
+
except OSError:
|
|
100
|
+
continue
|
|
101
|
+
for block in TOML_BLOCK.findall(text):
|
|
102
|
+
shown = sections(block)
|
|
103
|
+
if not shown & set(REQUIRED) and not shown & {"roles"}:
|
|
104
|
+
continue # some other toml, not this file
|
|
105
|
+
for s in sorted(shown - have):
|
|
106
|
+
problems.append("%s shows a [%s] section that %s does not "
|
|
107
|
+
"have" % (os.path.relpath(full, root), s,
|
|
108
|
+
CONFIG))
|
|
109
|
+
|
|
110
|
+
if problems:
|
|
111
|
+
print("%d disagreement(s) between the configuration and the documents"
|
|
112
|
+
% len(problems))
|
|
113
|
+
for p in sorted(set(problems)):
|
|
114
|
+
print(" %s" % p)
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
print("%s has %s, and every documented example matches it"
|
|
118
|
+
% (CONFIG, ", ".join("[%s]" % s for s in REQUIRED)))
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
sys.exit(main(sys.argv))
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check: decision numbers come from the record, and none was guessed.
|
|
3
|
+
|
|
4
|
+
decision-ids <directory>
|
|
5
|
+
decision-ids --next <directory> print the number a new decision takes
|
|
6
|
+
|
|
7
|
+
CATCHES Two decisions sharing a number, because two threads were each reading
|
|
8
|
+
a different stale state and both were confident. And a placeholder
|
|
9
|
+
that shipped — a decision carrying a number nobody ever assigned, which
|
|
10
|
+
everything downstream then cites.
|
|
11
|
+
|
|
12
|
+
Also a superseding decision pointing at one that does not exist, which
|
|
13
|
+
is how a cross-reference breaks silently.
|
|
14
|
+
|
|
15
|
+
What it does not do is watch how a number was chosen. Nothing can. It checks
|
|
16
|
+
that the result is consistent, which catches the failure without pretending to
|
|
17
|
+
police the act.
|
|
18
|
+
|
|
19
|
+
Exit status:
|
|
20
|
+
0 the numbering holds
|
|
21
|
+
1 it does not, and the problem is named
|
|
22
|
+
2 the check could not run
|
|
23
|
+
"""
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
DECISIONS = os.path.join("docs", "decisions")
|
|
29
|
+
NAMED = re.compile(r"^(\d{1,6})[-_]")
|
|
30
|
+
# Two kinds of placeholder, and they need different rules.
|
|
31
|
+
#
|
|
32
|
+
# These are never English. Anywhere they appear, they are a placeholder.
|
|
33
|
+
OBVIOUS_PLACEHOLDER = re.compile(
|
|
34
|
+
r"\b(DEC-PENDING|TBD|XXXX+|NNNN+|\?\?\?\?)\b")
|
|
35
|
+
|
|
36
|
+
# These are ordinary words. They only mean "unfilled" when they stand where an
|
|
37
|
+
# answer should be — alone, or right after a label. "The migration is pending
|
|
38
|
+
# review by the team" and "a TODO list was kept" are things a decision record
|
|
39
|
+
# is entitled to say, and both used to fail the gate with a message accusing
|
|
40
|
+
# the author of shipping a placeholder.
|
|
41
|
+
WORDY_PLACEHOLDER = re.compile(
|
|
42
|
+
r"(?:^|[:(\[]\s*|^\s*[-*]\s*)(TODO|PENDING)\s*(?:$|[)\].,;])", re.M)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def placeholder_in(text):
|
|
46
|
+
return OBVIOUS_PLACEHOLDER.search(text) or WORDY_PLACEHOLDER.search(text)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
PLACEHOLDER = OBVIOUS_PLACEHOLDER
|
|
50
|
+
STATUS = re.compile(r"^\s*status\s*:\s*(.+?)\s*$", re.M | re.I)
|
|
51
|
+
SUPERSEDED_BY = re.compile(r"superseded\s+by\s+#?(\d{1,6})", re.I)
|
|
52
|
+
|
|
53
|
+
ALLOWED_STATUS = {"proposed", "accepted", "rejected", "superseded"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def decision_files(root):
|
|
57
|
+
d = os.path.join(root, DECISIONS)
|
|
58
|
+
if not os.path.isdir(d):
|
|
59
|
+
return None
|
|
60
|
+
out = []
|
|
61
|
+
for fn in sorted(os.listdir(d)):
|
|
62
|
+
if not fn.endswith(".md") or fn.upper().startswith("README"):
|
|
63
|
+
continue
|
|
64
|
+
out.append((fn, os.path.join(d, fn)))
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv):
|
|
69
|
+
want_next = "--next" in argv
|
|
70
|
+
args = [a for a in argv[1:] if not a.startswith("--")]
|
|
71
|
+
if not args:
|
|
72
|
+
print("usage: decision-ids [--next] <directory>", file=sys.stderr)
|
|
73
|
+
return 2
|
|
74
|
+
root = args[0]
|
|
75
|
+
if not os.path.isdir(root):
|
|
76
|
+
print("ERROR: not a directory: %s" % root, file=sys.stderr)
|
|
77
|
+
return 2
|
|
78
|
+
|
|
79
|
+
files = decision_files(root)
|
|
80
|
+
if files is None:
|
|
81
|
+
if want_next:
|
|
82
|
+
print("0001")
|
|
83
|
+
return 0
|
|
84
|
+
print("no %s directory, nothing claimed" % DECISIONS)
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
numbers = {}
|
|
88
|
+
problems = []
|
|
89
|
+
|
|
90
|
+
for fn, path in files:
|
|
91
|
+
m = NAMED.match(fn)
|
|
92
|
+
if not m:
|
|
93
|
+
problems.append("%s — the filename does not start with a number" % fn)
|
|
94
|
+
continue
|
|
95
|
+
n = int(m.group(1))
|
|
96
|
+
numbers.setdefault(n, []).append(fn)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
text = open(path, encoding="utf-8", errors="strict").read()
|
|
100
|
+
except (UnicodeDecodeError, OSError) as e:
|
|
101
|
+
print("ERROR: cannot read %s: %s" % (path, e), file=sys.stderr)
|
|
102
|
+
return 2
|
|
103
|
+
|
|
104
|
+
ph = placeholder_in(text)
|
|
105
|
+
if ph:
|
|
106
|
+
problems.append("%s — still carries the placeholder '%s'"
|
|
107
|
+
% (fn, ph.group(1)))
|
|
108
|
+
|
|
109
|
+
st = STATUS.search(text)
|
|
110
|
+
if not st:
|
|
111
|
+
problems.append("%s — no status" % fn)
|
|
112
|
+
else:
|
|
113
|
+
# An empty value used to raise IndexError here, which surfaced
|
|
114
|
+
# as a traceback and exit 1 — where the contract says a check
|
|
115
|
+
# that cannot run says 2, and one that finds something names it.
|
|
116
|
+
words = st.group(1).strip().strip('"\'').split()
|
|
117
|
+
if not words:
|
|
118
|
+
problems.append("%s — status is empty" % fn)
|
|
119
|
+
continue
|
|
120
|
+
first = words[0].lower()
|
|
121
|
+
if first not in ALLOWED_STATUS:
|
|
122
|
+
problems.append("%s — status '%s' is not one of: %s"
|
|
123
|
+
% (fn, st.group(1).strip(),
|
|
124
|
+
", ".join(sorted(ALLOWED_STATUS))))
|
|
125
|
+
|
|
126
|
+
highest = max(numbers) if numbers else 0
|
|
127
|
+
if want_next:
|
|
128
|
+
print("%04d" % (highest + 1))
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
for n, names in sorted(numbers.items()):
|
|
132
|
+
if len(names) > 1:
|
|
133
|
+
problems.append("number %04d is used by %d files: %s"
|
|
134
|
+
% (n, len(names), ", ".join(names)))
|
|
135
|
+
|
|
136
|
+
# A supersession must point at something that exists.
|
|
137
|
+
for fn, path in files:
|
|
138
|
+
try:
|
|
139
|
+
text = open(path, encoding="utf-8", errors="ignore").read()
|
|
140
|
+
except OSError:
|
|
141
|
+
continue
|
|
142
|
+
for target in SUPERSEDED_BY.findall(text):
|
|
143
|
+
if int(target) not in numbers:
|
|
144
|
+
problems.append("%s — says it is superseded by %s, which does "
|
|
145
|
+
"not exist" % (fn, target))
|
|
146
|
+
|
|
147
|
+
if problems:
|
|
148
|
+
print("%d problem(s) across %d decision(s)" % (len(problems), len(files)))
|
|
149
|
+
for p in problems:
|
|
150
|
+
print(" %s" % p)
|
|
151
|
+
return 1
|
|
152
|
+
|
|
153
|
+
print("%d decision(s), numbered without collision, nothing left pending"
|
|
154
|
+
% len(files))
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
sys.exit(main(sys.argv))
|