Skip to content

Commit d2780a8

Browse files
ci: document and gate product exit codes in README tables (#150)
* docs: add exit table to the headless batch template The checker in the next commit covers templates/. Product codes lived in prose only, so a missing-table file would fail the gate. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> * ci: fail when a product exit code is missing from its README AST collects return N and literal sys.exit(N) on examples, templates, and showcase. sys.exit(main()) and the FATAL except wrapper are not product codes. Blocking from day one; no skip list. Closes #138. Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> --------- Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a39affb commit d2780a8

3 files changed

Lines changed: 322 additions & 0 deletions

File tree

‎.github/workflows/validate.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ jobs:
133133
- name: Check import-scale and unevaluated-export anti-patterns
134134
run: python3 tests/check_import_export_rules.py
135135

136+
- name: Check product exit codes are in README tables
137+
run: python3 tests/check_exit_code_readme.py
138+
136139
- name: Validate template Python syntax
137140
run: |
138141
echo "Checking template Python syntax..."

‎templates/headless-batch-script-template/README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ it is forwarded to `script.py` as `sys.argv`.
3838
5. Returns explicit exit codes (0 success, 2-4 different failure modes)
3939
so a CI pipeline can detect failures.
4040

41+
## Exit codes
42+
43+
Same convention as `CONTRIBUTING.md` (file-local sequential checks; `9`
44+
is legal; argparse usage is `2`). Not a repo-wide table.
45+
46+
| Code | Meaning |
47+
| --- | --- |
48+
| 0 | Success |
49+
| 2 | argparse rejected the flags, or no mesh objects in the input `.blend` |
50+
| 3 | Modifier apply raised `RuntimeError` |
51+
| 4 | glTF export raised `RuntimeError` |
52+
4153
## Expected environment
4254

4355
- Blender on the system `PATH` (or invoked by absolute path).

‎tests/check_exit_code_readme.py‎

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
"""Every nonzero product exit code must appear in that script's README table.
2+
3+
Product codes live in `return N` and literal `sys.exit(N)`, not in
4+
`sys.exit(main())`. A regex over `sys.exit(` miscounts the tree (64 vs 58).
5+
6+
Detection (AST only):
7+
8+
- Integer literals on `return` in the file's own functions, plus literal
9+
`sys.exit(N)`.
10+
- `sys.exit(main())` and `sys.exit(name)` where `name` is assigned from a
11+
local function call (both headless templates: `exit_code = main()`) are
12+
harness pass-through, not unanalyzable.
13+
- `sys.exit(1)` inside an `except` handler is the FATAL wrapper documented
14+
in CONTRIBUTING.md — not a named check.
15+
- Scope: examples/, templates/, showcase/. tests/ and scripts/ are excluded.
16+
- Anything else at a `sys.exit(...)` site is unanalyzable and fails.
17+
18+
README check applies to entry-point files (`if __name__ == "__main__"`).
19+
Helpers such as examples/gallery_framing.py are still scanned for
20+
unanalyzable sys.exit sites but have no product table of their own.
21+
22+
No exemption list, allowlist, or skip file.
23+
"""
24+
from __future__ import annotations
25+
26+
import ast
27+
import os
28+
import re
29+
import sys
30+
31+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
32+
33+
SCOPES = ("examples", "templates", "showcase")
34+
35+
TABLE_ROW_RE = re.compile(r"^\|\s*`?(\d+)`?\s*\|")
36+
EXIT_HEADING_RE = re.compile(r"^#+\s+Exit codes\s*$", re.IGNORECASE)
37+
HEADING_RE = re.compile(r"^#+\s+")
38+
39+
40+
def relpath(path):
41+
return os.path.relpath(path, ROOT).replace("\\", "/")
42+
43+
44+
def is_dunder_main(node):
45+
if not isinstance(node, ast.Compare) or len(node.ops) != 1:
46+
return False
47+
if not isinstance(node.ops[0], ast.Eq) or len(node.comparators) != 1:
48+
return False
49+
left, right = node.left, node.comparators[0]
50+
51+
def is_name(n, ident):
52+
return isinstance(n, ast.Name) and n.id == ident
53+
54+
def is_const(n, value):
55+
return isinstance(n, ast.Constant) and n.value == value
56+
57+
return (
58+
(is_name(left, "__name__") and is_const(right, "__main__"))
59+
or (is_const(left, "__main__") and is_name(right, "__name__"))
60+
)
61+
62+
63+
def int_literal(node):
64+
"""Return an int if *node* is an integer literal, else None.
65+
66+
ast.Constant(True) is a bool (subclass of int); skip it.
67+
"""
68+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
69+
inner = int_literal(node.operand)
70+
return None if inner is None else -inner
71+
if isinstance(node, ast.Constant) and type(node.value) is int:
72+
return node.value
73+
return None
74+
75+
76+
def module_function_names(tree):
77+
names = set()
78+
for node in tree.body:
79+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
80+
names.add(node.name)
81+
return names
82+
83+
84+
def is_passthrough_call(node, func_names):
85+
"""sys.exit(main()) — Call of a function defined in this module."""
86+
return (
87+
isinstance(node, ast.Call)
88+
and isinstance(node.func, ast.Name)
89+
and node.func.id in func_names
90+
)
91+
92+
93+
class ExitVisitor(ast.NodeVisitor):
94+
def __init__(self, func_names):
95+
self.func_names = func_names
96+
self.stack = []
97+
self.scope_binds = [{}]
98+
self.literals = [] # (code, lineno)
99+
self.unanalyzable = [] # (lineno, snippet)
100+
101+
def _push_scope(self):
102+
self.scope_binds.append({})
103+
104+
def _pop_scope(self):
105+
self.scope_binds.pop()
106+
107+
def _bind(self, name, from_func):
108+
self.scope_binds[-1][name] = from_func
109+
110+
def _bound_func(self, name):
111+
for binds in reversed(self.scope_binds):
112+
if name in binds:
113+
return binds[name]
114+
return None
115+
116+
def _in_except(self):
117+
return any(isinstance(n, ast.ExceptHandler) for n in self.stack)
118+
119+
def generic_visit(self, node):
120+
self.stack.append(node)
121+
super().generic_visit(node)
122+
self.stack.pop()
123+
124+
def visit_FunctionDef(self, node):
125+
self._push_scope()
126+
self.generic_visit(node)
127+
self._pop_scope()
128+
129+
visit_AsyncFunctionDef = visit_FunctionDef
130+
131+
def visit_Assign(self, node):
132+
if is_passthrough_call(node.value, self.func_names):
133+
func_id = node.value.func.id
134+
for target in node.targets:
135+
if isinstance(target, ast.Name):
136+
self._bind(target.id, func_id)
137+
self.generic_visit(node)
138+
139+
def visit_AnnAssign(self, node):
140+
if node.value is not None and is_passthrough_call(node.value, self.func_names):
141+
if isinstance(node.target, ast.Name):
142+
self._bind(node.target.id, node.value.func.id)
143+
self.generic_visit(node)
144+
145+
def visit_Return(self, node):
146+
if node.value is not None:
147+
code = int_literal(node.value)
148+
if code is not None:
149+
self.literals.append((code, node.lineno))
150+
self.generic_visit(node)
151+
152+
def visit_Call(self, node):
153+
if not (
154+
isinstance(node.func, ast.Attribute)
155+
and node.func.attr == "exit"
156+
and isinstance(node.func.value, ast.Name)
157+
and node.func.value.id == "sys"
158+
):
159+
self.generic_visit(node)
160+
return
161+
162+
snippet = ast.unparse(node)
163+
lineno = node.lineno
164+
if len(node.args) != 1 or node.keywords:
165+
self.unanalyzable.append((lineno, snippet))
166+
self.generic_visit(node)
167+
return
168+
169+
arg = node.args[0]
170+
code = int_literal(arg)
171+
if code is not None:
172+
if code == 1 and self._in_except():
173+
self.generic_visit(node)
174+
return
175+
self.literals.append((code, lineno))
176+
self.generic_visit(node)
177+
return
178+
179+
if is_passthrough_call(arg, self.func_names):
180+
self.generic_visit(node)
181+
return
182+
183+
if isinstance(arg, ast.Name) and self._bound_func(arg.id):
184+
self.generic_visit(node)
185+
return
186+
187+
self.unanalyzable.append((lineno, snippet))
188+
self.generic_visit(node)
189+
190+
191+
def has_dunder_main(tree):
192+
for node in tree.body:
193+
if isinstance(node, ast.If) and is_dunder_main(node.test):
194+
return True
195+
return False
196+
197+
198+
def readme_table_codes(text):
199+
codes = set()
200+
in_section = False
201+
for line in text.splitlines():
202+
if EXIT_HEADING_RE.match(line):
203+
in_section = True
204+
continue
205+
if in_section and HEADING_RE.match(line):
206+
break
207+
if not in_section:
208+
continue
209+
match = TABLE_ROW_RE.match(line)
210+
if match:
211+
codes.add(int(match.group(1)))
212+
return codes
213+
214+
215+
def iter_py():
216+
for scope in SCOPES:
217+
base = os.path.join(ROOT, scope)
218+
if not os.path.isdir(base):
219+
continue
220+
for dirpath, dirnames, filenames in os.walk(base):
221+
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
222+
for name in filenames:
223+
if name.endswith(".py"):
224+
yield os.path.join(dirpath, name)
225+
226+
227+
def check_file(path):
228+
errors = []
229+
rel = relpath(path)
230+
src = open(path, encoding="utf-8").read()
231+
try:
232+
tree = ast.parse(src, filename=rel)
233+
except SyntaxError as exc:
234+
errors.append(f"{rel}: cannot parse: {exc}")
235+
return errors
236+
237+
visitor = ExitVisitor(module_function_names(tree))
238+
visitor.visit(tree)
239+
240+
for lineno, snippet in visitor.unanalyzable:
241+
errors.append(
242+
f"{rel}:{lineno}: unanalyzable exit code: {snippet}"
243+
)
244+
245+
if not has_dunder_main(tree):
246+
return errors
247+
248+
nonzero = {}
249+
for code, lineno in visitor.literals:
250+
if code == 0:
251+
continue
252+
nonzero.setdefault(code, lineno)
253+
254+
if not nonzero:
255+
return errors
256+
257+
readme = os.path.join(os.path.dirname(path), "README.md")
258+
if not os.path.isfile(readme):
259+
listed = ", ".join(f"{c} (line {nonzero[c]})" for c in sorted(nonzero))
260+
errors.append(
261+
f"{rel}: nonzero product codes {listed} but no sibling README.md"
262+
)
263+
return errors
264+
265+
table = readme_table_codes(open(readme, encoding="utf-8").read())
266+
readme_rel = relpath(readme)
267+
if not table:
268+
errors.append(
269+
f"{rel}: {readme_rel} has no Exit codes table"
270+
)
271+
return errors
272+
273+
for code, lineno in sorted(nonzero.items()):
274+
if code not in table:
275+
errors.append(
276+
f"{rel}:{lineno}: return/exit {code} is not in the {readme_rel} exit table"
277+
)
278+
return errors
279+
280+
281+
def main(argv):
282+
extra = argv[1:]
283+
paths = list(iter_py())
284+
for item in extra:
285+
paths.append(item if os.path.isabs(item) else os.path.join(ROOT, item))
286+
287+
errors = []
288+
seen = set()
289+
for path in paths:
290+
if path in seen:
291+
continue
292+
seen.add(path)
293+
if not os.path.isfile(path):
294+
errors.append(f"missing scan path {relpath(path)}")
295+
continue
296+
errors.extend(check_file(path))
297+
298+
if errors:
299+
for err in errors:
300+
print(f"ERROR: {err}", file=sys.stderr)
301+
return 1
302+
print("exit-code README checks passed.")
303+
return 0
304+
305+
306+
if __name__ == "__main__":
307+
sys.exit(main(sys.argv))

0 commit comments

Comments
 (0)