Files
EVEngine/scripts/check_bindings.py
西风逍遥游 31880f0021 feat(examples): gate examples in CI and make repository scripts UTF-8 on every host
`examples/<name>/` was a tested surface in name only. The source-quality job had
no examples check at all, and scripts/smoke_examples.sh accepted any process that
had not printed one of its error markers: a project that died during startup,
exited after a single frame, or crashed without a marker was reported as PASS.
Nothing stopped a malformed or unregistered example from reaching dev, which is
how 14 examples arrived without READMEs and 20 were never registered in the
overview table.

Source side
- scripts/check_examples.py classifies every directory under examples/ as either
  runnable (main.nut plus a config.nut that assigns the engine `config` table) or
  as a reviewed non-runnable sample listed in NON_RUNNABLE_EXAMPLES with a
  reason. Both kinds must own a README.md and appear in examples/README.md.
  Stale exemptions and overview links to missing directories fail as well.
- `make check/examples` and a new source-quality CI step run the gate.
- scripts/tests/test_check_examples.py covers it and pins each failure mode
  against fixtures.

Runtime side
- scripts/smoke_examples.sh now enforces liveness, not just marker absence: every
  runnable example must still be running after MIN_RUN_SECONDS (default 2s; CI
  passes 2s), and a process that dies inside that window fails whatever its exit
  code. A clean self-exit after the window passes with an explicit note instead
  of being silently accepted as a pass.
- The Linux CI step sets MIN_RUN_SECONDS=2 and keeps RUN_SECONDS=6, so the job's
  wall clock is unchanged (13.7 min of its 20 min budget on dev).

Windows / GBK
- scripts/utf8_stdio.py re-encodes stdout/stderr as UTF-8 and every scripts/*.py
  program calls it as the first statement of its __main__ block. On a Chinese
  Windows a redirected stream falls back to the ANSI code page (cp936), and
  printing a character that code page cannot encode (arrows, superscripts, bullet
  separators, emoji - all common in this repository) raised UnicodeEncodeError
  and turned a checker into a traceback.
- The mirror image is input: read_text() without encoding= decoded UTF-8
  repository text as cp936 and raised UnicodeDecodeError.
- scripts/tests/test_utf8_stdio.py enforces both conventions for every script and
  test module; text file I/O and child-process decoding now pass encoding="utf-8"
  explicitly.
- Unit-test invocations run with `python3 -X utf8` so the runner's own output
  never follows the host code page.

Violations the new gates found on top of dev, fixed here: 14 examples shipped
without a README (fluid3d, mesh-impact-lab, mesh-modifier-lab, spline-tube-lab on
the earlier dev tip, plus pcg-depth-of-field, pcg-detail-overwrite,
pcg-layer-culling, pcg-location-system, pcg-runtime-orchestration,
pcg-scene-player-culling, pcg-snow-wind, pcg-thunder-strike, pcg-ui-scaler and
pcg-underwater added by #397), 20 were missing from the overview table (those 14
plus archspace-editor, geometry-stroke-lab, interior-mapping, layered-animation,
triplanar-decal, terrain-stamping), and scripts/export_meshret_onnx.py had no
UTF-8 stdio guard.

Verified locally: `python -m ruff check scripts` (All checks passed);
scripts/check_test_manifest.py; scripts/check_quality_metadata.py;
scripts/check_architecture_contracts.py --base HEAD; scripts/check_nodiscard.py;
`make -n check/examples`; scripts/check_examples.py (125 examples: 118 runnable,
7 reviewed non-runnable); and `python -X utf8 -m unittest discover -s scripts/tests
-p "test_*.py"` (155 tests, OK). The runtime half of the contract needs a Linux
headless build, so it is verified by the CI examples smoke step rather than
locally.
2026-09-15 20:20:23 +08:00

230 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""Binding self-check: every script-facing `addFunc` must appear in the user docs.
This automates the manual step recorded in docs/usr/REVIEW.md ("各目录 C++ 文件
中的全部 addFunc 均出现在对应章节 API 快查"). It scans each CMake-declared
module DIR for `addFunc("name", ...)` / `addVar("name", ...)` (skipping nested
satellite dirs) and greps the matching docs/usr/modules/*.md chapter for the
name. Nested packages keep their CMake NAME (e.g. animation_editor), so host
chapters are not charged with editor bindings.
Exit code is non-zero when a bound name is missing from its doc chapter
(CI gate). Doc-only names (removed bindings that the doc still lists) are
reported as warnings so the doc can be cleaned up without blocking.
Existing chapters document a subset of their bindings (a large pre-existing
debt). That debt is recorded in scripts/check_bindings_gaps.txt as
"module:name" lines (regenerate with --write-gaps). Under --strict, a bound
name missing from its doc chapter fails **unless** it is listed there, so the
debt stays visible as warnings while new drift blocks CI. --modules limits the
check to the given CMake module names.
Usage:
python3 scripts/check_bindings.py # report all gaps (exit 0)
python3 scripts/check_bindings.py --strict # fail on any gap
python3 scripts/check_bindings.py --strict --modules avatar,database
python3 scripts/check_bindings.py --strict --write-gaps
# rewrite the known-gaps file
python3 scripts/check_bindings.py --quiet # only print problems
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
import check_module_manifest
import utf8_stdio
ROOT = Path(__file__).resolve().parent.parent
MODULES = ROOT / "src" / "modules"
DOCS = ROOT / "docs" / "usr" / "modules"
GAPS_FILE = Path(__file__).resolve().parent / "check_bindings_gaps.txt"
# CMake module NAME -> user-doc chapter (doc filename without .md).
DOC_ALIASES = {
"card": "cardgame",
"data": "data", # class DataModule
"tensor": "tensor", # class TF
"grid": "grid",
}
ADDFUNC_RE = re.compile(r'\badd(?:Func|Var)\(\s*"([^"]+)"')
def load_gaps() -> set[str]:
if not GAPS_FILE.is_file():
return set()
gaps: set[str] = set()
for line in GAPS_FILE.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
gaps.add(line)
return gaps
def write_gaps(entries: list[str]) -> None:
GAPS_FILE.write_text(
"# Known 'bound but not documented' gaps (module:binding), one per line.\n"
"# Regenerate with: python3 scripts/check_bindings.py --strict --write-gaps\n"
"# Remove a line when the doc chapter catches up; new gaps not listed here\n"
"# fail the CI binding check.\n" + "\n".join(sorted(set(entries))) + "\n",
encoding="utf-8",
)
def module_roots() -> list[tuple[str, Path]]:
"""Return (CMake NAME, DIR) for non-core packages, including nested facets."""
out: list[tuple[str, Path]] = []
for declaration in check_module_manifest.parse_manifest():
if declaration.core or not declaration.name:
continue
path = MODULES.joinpath(*declaration.dir.split("/"))
if path.is_dir():
out.append((declaration.name, path))
out.sort(key=lambda item: (item[1].as_posix(), item[0]))
return out
def nested_child_heads(module_dir: Path, roots: list[Path]) -> set[str]:
"""Immediate child names that belong to a nested declared module."""
heads: set[str] = set()
root = module_dir.resolve()
for other in roots:
if other == module_dir or not other.is_dir():
continue
try:
rel = other.resolve().relative_to(root)
except ValueError:
continue
if rel.parts:
heads.add(rel.parts[0])
return heads
def iter_module_cpp(module_dir: Path, skip_heads: set[str]) -> list[Path]:
files: list[Path] = []
for cpp in module_dir.rglob("*.cpp"):
if "third-party" in cpp.as_posix():
continue
rel = cpp.relative_to(module_dir)
if rel.parts and rel.parts[0] in skip_heads:
continue
files.append(cpp)
return files
def iter_modules() -> list[tuple[str, Path, set[str]]]:
roots = module_roots()
dirs = [path for _, path in roots]
out: list[tuple[str, Path, set[str]]] = []
for name, path in roots:
skip = nested_child_heads(path, dirs)
if iter_module_cpp(path, skip):
out.append((name, path, skip))
return out
def bound_names(module_dir: Path, skip_heads: set[str]) -> set[str]:
names: set[str] = set()
for cpp in iter_module_cpp(module_dir, skip_heads):
text = cpp.read_text(encoding="utf-8", errors="replace")
names.update(ADDFUNC_RE.findall(text))
return names
def doc_for(module: str) -> Path | None:
doc = DOC_ALIASES.get(module, module)
p = DOCS / f"{doc}.md"
return p if p.is_file() else None
def doc_text(doc: Path) -> str:
"""Read a module overview and all topic files in its sibling directory."""
paths = [doc]
topic_dir = doc.with_suffix("")
if topic_dir.is_dir():
paths.extend(sorted(topic_dir.rglob("*.md")))
return "\n".join(path.read_text(encoding="utf-8", errors="replace") for path in paths)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--quiet", action="store_true", help="only print failures")
parser.add_argument("--strict", action="store_true",
help="exit non-zero when a bound name is missing from its doc")
parser.add_argument("--modules", default="",
help="comma-separated CMake module names to check (default: all)")
parser.add_argument("--write-gaps", action="store_true",
help="rewrite check_bindings_gaps.txt from the current gaps")
args = parser.parse_args()
selected = {m.strip() for m in args.modules.split(",") if m.strip()}
gaps = load_gaps()
failures: list[str] = []
warnings: list[str] = []
gap_entries: list[str] = []
checked = 0
modules = iter_modules()
for module, d, skip in modules:
if selected and module not in selected:
continue
names = bound_names(d, skip)
if not names:
continue
doc = doc_for(module)
if doc is None:
warnings.append(f"{module}: no user doc chapter ({DOCS / module}.md)")
continue
text = doc_text(doc)
missing = sorted(n for n in names if n not in text)
checked += len(names)
if missing:
for name in missing:
gap_entries.append(f"{module}:{name}")
if f"{module}:{name}" in gaps:
warnings.append(f"{module} ({doc.name}): known gap (document it): {name}")
elif args.strict:
failures.append(f"{module} ({doc.name}): NEW gap, document it: {name}")
else:
warnings.append(f"{module} ({doc.name}): bound but not documented: {name}")
else:
if not args.quiet:
print(f"ok {module}: {len(names)} bindings documented")
# Doc-only names: bound surface shrank but the doc still lists old methods.
for module, d, skip in modules:
doc = doc_for(module)
if doc is None:
continue
names = bound_names(d, skip)
text = doc_text(doc)
doc_names = set(ADDFUNC_RE.findall(text)) | {
m for m in re.findall(r"`([a-zA-Z][a-zA-Z0-9_]*)\(\)", text)
}
for name in sorted(doc_names - names):
warnings.append(f"{module} ({doc.name}): documented but not bound: {name}")
for w in warnings:
print(f"WARN {w}")
for f in failures:
print(f"FAIL {f}")
print(f"checked {checked} bindings across {len(modules)} module dirs")
if args.write_gaps:
write_gaps(gap_entries)
print(f"wrote {len(set(gap_entries))} known gaps to {GAPS_FILE}")
return 1 if failures else 0
if __name__ == "__main__":
utf8_stdio.enable_utf8_stdio()
sys.exit(main())