Files
EVEngine/scripts/check_bindings.py
T
西风逍遥游andCursor c8762751b0 Scan nested packages by CMake DIR in the binding check.
Host chapters were absorbing editor addFunc names after the layout nest because the checker rglob'd top-level folders. Skip nested module dirs so NAME still matches the existing docs and gap list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 18:08:17 +08:00

228 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
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__":
sys.exit(main())