Skip to content

Commit 7afd8a3

Browse files
TMHSDigitalclaude
andcommitted
ci: link gate checks the staged Pages tree; exit-code gate resolves named codes
Two gates passed because authors were careful, not because they enforced it. Link gate: pages.yml ran tests/check_site_links.py on docs/ and only then stripped contact/asset sheets, DESIGN_NOTES.md and *.md into _site. A link to a stripped file passed CI and 404'd in production. - scripts/site/stage_public.py is now the single staging list; pages.yml and validate-site both stage with it and check `--root _site`. - check_site_links.py takes --root (default docs/ for quick local runs). Falsified: a probe link to gallery/contact-sheets/anvil-contact-sheet.webp passes the old docs/ check (0 failures) and fails the staged check (1). Exit-code gate: visit_Return recorded only int literals, so `return EXIT_CLIPPED` (12), framing passthroughs (10) and `raise SystemExit(N)` were invisible. Now resolved: module-level int constants, shared-helper constants and check functions read from examples/gallery_*.py (check_framing 10, check_asset_quality 11) including `fcode = check_framing(...); return fcode`, and raise SystemExit. All current codes are documented (passes). Falsified: deleting the `12` row from sky-texture-sun-elevation's README fails the new checker at :719; the old checker still passed. Not done: flagging every unresolvable `return name` in main as unanalyzable. 277 such returns exist and all pass through other functions' results, whose own returns are already scanned; flagging them would be noise, not signal. Closes #325 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 579410a commit 7afd8a3

6 files changed

Lines changed: 174 additions & 22 deletions

File tree

‎.github/workflows/pages.yml‎

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,19 +92,14 @@ jobs:
9292
# drift from gallery.json. The committed docs/gallery/assets/*.webp are untouched.
9393
run: python scripts/build_gallery.py
9494

95-
- name: Check every internal link, anchor and image alt
96-
run: python tests/check_site_links.py
95+
- name: Stage the public site (leave out internal docs and unlinked sheets)
96+
run: python scripts/site/stage_public.py --src docs --out _site
9797

98-
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6
98+
- name: Check every internal link, anchor and image alt (on the staged tree)
99+
# Checked after staging so a link to a stripped file fails here, not in production.
100+
run: python tests/check_site_links.py --root _site
99101

100-
- name: Stage the public site (leave out internal docs and unlinked sheets)
101-
# Everything stays in the repo; only the Pages artifact shrinks. No
102-
# published page links to any of these (tests/check_site_links.py).
103-
run: |
104-
mkdir -p _site
105-
cp -r docs/. _site/
106-
rm -rf _site/gallery/contact-sheets _site/gallery/asset-sheets _site/gallery/DESIGN_NOTES.md
107-
rm -f _site/*.md
102+
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6
108103

109104
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
110105
with:

‎.github/workflows/validate.yml‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,11 @@ jobs:
136136
- name: Build landing page into docs/
137137
run: python scripts/site/build_site.py --repo-root . --out docs
138138

139-
- name: Check every internal link, anchor and image alt
140-
run: python tests/check_site_links.py
139+
- name: Stage the public site exactly as pages.yml does
140+
run: python scripts/site/stage_public.py --src docs --out _site
141+
142+
- name: Check every internal link, anchor and image alt (on the staged tree)
143+
run: python tests/check_site_links.py --root _site
141144

142145
validate-manifest:
143146
name: Validate plugin manifest

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,6 @@ build/
4848

4949
# Playwright MCP session captures (screenshots, console logs)
5050
.playwright-mcp/
51+
52+
# Staged Pages tree (scripts/site/stage_public.py)
53+
_site/

‎scripts/site/stage_public.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
#!/usr/bin/env python3
2+
"""Stage the public Pages tree: copy docs/ and drop what is not published.
3+
4+
python scripts/site/stage_public.py --src docs --out _site
5+
6+
Everything stays in the repo; only the Pages artifact shrinks. The internal
7+
docs (docs/*.md, gallery/DESIGN_NOTES.md) and the unlinked contact/asset sheets
8+
are left out. pages.yml deploys this tree and validate-site checks it, and
9+
tests/check_site_links.py runs against it, so a published link to a stripped
10+
file fails CI instead of 404ing in production. Keep EXCLUDE the single list.
11+
"""
12+
from __future__ import annotations
13+
14+
import argparse
15+
import shutil
16+
import sys
17+
from pathlib import Path
18+
19+
# Paths relative to --src; top-level *.md files are dropped too (see stage()).
20+
EXCLUDE_DIRS = ("gallery/contact-sheets", "gallery/asset-sheets")
21+
EXCLUDE_FILES = ("gallery/DESIGN_NOTES.md",)
22+
23+
24+
def stage(src: Path, out: Path) -> None:
25+
if out.exists():
26+
shutil.rmtree(out)
27+
shutil.copytree(src, out)
28+
for d in EXCLUDE_DIRS:
29+
shutil.rmtree(out / d, ignore_errors=True)
30+
for f in EXCLUDE_FILES:
31+
(out / f).unlink(missing_ok=True)
32+
for md in out.glob("*.md"):
33+
md.unlink()
34+
35+
36+
def main(argv: list[str] | None = None) -> int:
37+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
38+
ap.add_argument("--src", type=Path, default=Path("docs"))
39+
ap.add_argument("--out", type=Path, default=Path("_site"))
40+
a = ap.parse_args(argv)
41+
if not (a.src / "index.html").is_file():
42+
print(f"ERROR: {a.src / 'index.html'} missing; build the site first", file=sys.stderr)
43+
return 2
44+
stage(a.src, a.out)
45+
print(f"staged {a.src} -> {a.out}")
46+
return 0
47+
48+
49+
if __name__ == "__main__":
50+
sys.exit(main())

‎tests/check_exit_code_readme.py‎

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@
66
Detection (AST only):
77
88
- Integer literals on `return` in the file's own functions, plus literal
9-
`sys.exit(N)`.
9+
`sys.exit(N)` and `raise SystemExit(N)`.
10+
- Named codes resolve too: a module-level `NAME = <int>` constant
11+
(`return EXIT_CLIPPED`), a shared-helper constant
12+
(`gallery_framing.EXIT_FRAMING`), a shared-helper check call
13+
(`return gallery_framing.check_framing(...)` is 10), and a local name
14+
assigned from one (`fcode = gallery_framing.check_framing(...)`;
15+
`return fcode`). Helper codes are read from examples/gallery_*.py, not
16+
hardcoded. A `return name` that resolves to none of these is a pass-through
17+
of another function's result; that function's own returns are scanned.
1018
- `sys.exit(main())` and `sys.exit(name)` where `name` is assigned from a
1119
local function call (both headless templates: `exit_code = main()`) are
1220
harness pass-through, not unanalyzable.
@@ -81,6 +89,51 @@ def module_function_names(tree):
8189
return names
8290

8391

92+
def module_int_constants(tree):
93+
"""Module-level `NAME = <int literal>` bindings."""
94+
consts = {}
95+
for node in tree.body:
96+
if isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
97+
code = int_literal(node.value)
98+
if code is not None:
99+
consts[node.targets[0].id] = code
100+
return consts
101+
102+
103+
def load_helper_codes():
104+
"""Exit codes of the shared render-gate helpers (examples/gallery_*.py).
105+
106+
Returns ({(module, CONST): code}, {(module, func): code}) where func is a
107+
helper function whose non-zero return is a module constant.
108+
"""
109+
consts, funcs = {}, {}
110+
base = os.path.join(ROOT, "examples")
111+
for name in sorted(os.listdir(base)):
112+
if not (name.startswith("gallery_") and name.endswith(".py")):
113+
continue
114+
mod = name[:-3]
115+
tree = ast.parse(open(os.path.join(base, name), encoding="utf-8").read())
116+
mconsts = module_int_constants(tree)
117+
for k, v in mconsts.items():
118+
consts[(mod, k)] = v
119+
for node in tree.body:
120+
if isinstance(node, ast.FunctionDef):
121+
for r in ast.walk(node):
122+
if isinstance(r, ast.Return) and isinstance(r.value, ast.Name) and mconsts.get(r.value.id):
123+
funcs[(mod, node.name)] = mconsts[r.value.id]
124+
return consts, funcs
125+
126+
127+
HELPER_CONSTS, HELPER_FUNCS = load_helper_codes()
128+
129+
130+
def _dotted(node):
131+
"""('mod', 'attr') for `mod.attr`, else None."""
132+
if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name):
133+
return (node.value.id, node.attr)
134+
return None
135+
136+
84137
def is_passthrough_call(node, func_names):
85138
"""sys.exit(main()) — Call of a function defined in this module."""
86139
return (
@@ -91,18 +144,39 @@ def is_passthrough_call(node, func_names):
91144

92145

93146
class ExitVisitor(ast.NodeVisitor):
94-
def __init__(self, func_names):
147+
def __init__(self, func_names, consts=None):
95148
self.func_names = func_names
149+
self.consts = consts or {}
96150
self.stack = []
97151
self.scope_binds = [{}]
152+
self.code_binds = [{}] # name -> code, per function scope
98153
self.literals = [] # (code, lineno)
99154
self.unanalyzable = [] # (lineno, snippet)
100155

101156
def _push_scope(self):
102157
self.scope_binds.append({})
158+
self.code_binds.append({})
103159

104160
def _pop_scope(self):
105161
self.scope_binds.pop()
162+
self.code_binds.pop()
163+
164+
def resolve_code(self, node):
165+
"""Exit code a returned/exited expression names, or None if not static."""
166+
code = int_literal(node)
167+
if code is not None:
168+
return code
169+
if isinstance(node, ast.Name):
170+
for binds in reversed(self.code_binds):
171+
if node.id in binds:
172+
return binds[node.id]
173+
return self.consts.get(node.id)
174+
dotted = _dotted(node)
175+
if dotted in HELPER_CONSTS:
176+
return HELPER_CONSTS[dotted]
177+
if isinstance(node, ast.Call):
178+
return HELPER_FUNCS.get(_dotted(node.func))
179+
return None
106180

107181
def _bind(self, name, from_func):
108182
self.scope_binds[-1][name] = from_func
@@ -129,6 +203,11 @@ def visit_FunctionDef(self, node):
129203
visit_AsyncFunctionDef = visit_FunctionDef
130204

131205
def visit_Assign(self, node):
206+
if isinstance(node.value, ast.Call) and _dotted(node.value.func) in HELPER_FUNCS:
207+
code = HELPER_FUNCS[_dotted(node.value.func)]
208+
for target in node.targets:
209+
if isinstance(target, ast.Name):
210+
self.code_binds[-1][target.id] = code
132211
if is_passthrough_call(node.value, self.func_names):
133212
func_id = node.value.func.id
134213
for target in node.targets:
@@ -144,11 +223,22 @@ def visit_AnnAssign(self, node):
144223

145224
def visit_Return(self, node):
146225
if node.value is not None:
147-
code = int_literal(node.value)
226+
code = self.resolve_code(node.value)
148227
if code is not None:
149228
self.literals.append((code, node.lineno))
150229
self.generic_visit(node)
151230

231+
def visit_Raise(self, node):
232+
exc = node.exc
233+
if isinstance(exc, ast.Call) and isinstance(exc.func, ast.Name) and exc.func.id == "SystemExit":
234+
if len(exc.args) == 1 and not exc.keywords:
235+
code = self.resolve_code(exc.args[0])
236+
if code is not None:
237+
self.literals.append((code, node.lineno))
238+
elif not is_passthrough_call(exc.args[0], self.func_names):
239+
self.unanalyzable.append((node.lineno, ast.unparse(node)))
240+
self.generic_visit(node)
241+
152242
def visit_Call(self, node):
153243
if not (
154244
isinstance(node.func, ast.Attribute)
@@ -167,7 +257,7 @@ def visit_Call(self, node):
167257
return
168258

169259
arg = node.args[0]
170-
code = int_literal(arg)
260+
code = self.resolve_code(arg)
171261
if code is not None:
172262
if code == 1 and self._in_except():
173263
self.generic_visit(node)
@@ -234,7 +324,7 @@ def check_file(path):
234324
errors.append(f"{rel}: cannot parse: {exc}")
235325
return errors
236326

237-
visitor = ExitVisitor(module_function_names(tree))
327+
visitor = ExitVisitor(module_function_names(tree), module_int_constants(tree))
238328
visitor.visit(tree)
239329

240330
for lineno, snippet in visitor.unanalyzable:

‎tests/check_site_links.py‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@
55
committed, so CI builds it first):
66
77
python scripts/site/build_site.py --repo-root . --out docs
8-
python tests/check_site_links.py
8+
python scripts/site/stage_public.py --src docs --out _site
9+
python tests/check_site_links.py --root _site
910
10-
Walks docs/index.html, docs/404.html and docs/gallery/**/index.html. Each
11+
Check the staged tree, not docs/: staging strips internal files, and a link to
12+
one of them must fail here rather than 404 in production. --root defaults to
13+
docs/ for a quick local pass.
14+
15+
Walks ROOT/index.html, ROOT/404.html and ROOT/gallery/**/index.html. Each
1116
relative or site-absolute ``href``/``src`` must name a file under docs/ (a
1217
directory means its index.html), and each ``#fragment`` must match an ``id``
1318
on the target page; every ``srcset`` candidate counts as a reference too, and
@@ -18,6 +23,7 @@
1823

1924
from __future__ import annotations
2025

26+
import argparse
2127
import json
2228
import sys
2329
from html.parser import HTMLParser
@@ -94,9 +100,14 @@ def resolve(page: Path, url: str, base: str) -> Path | None:
94100
return target.resolve()
95101

96102

97-
def main() -> int:
103+
def main(argv: list[str] | None = None) -> int:
104+
global DOCS
105+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
106+
ap.add_argument("--root", type=Path, default=DOCS,
107+
help="site tree to check (default: docs/; CI checks the staged _site/)")
108+
DOCS = ap.parse_args(argv).root.resolve()
98109
if not (DOCS / "index.html").is_file():
99-
print("ERROR: docs/index.html missing; run scripts/site/build_site.py first",
110+
print(f"ERROR: {DOCS / 'index.html'} missing; run scripts/site/build_site.py first",
100111
file=sys.stderr)
101112
return 2
102113
pages = [DOCS / "index.html", DOCS / "404.html",

0 commit comments

Comments
 (0)