Files
EVEngine/scripts/check_bindings.py
T

180 lines
6.5 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 the module sources for
`addFunc("name", ...)` / `addVar("name", ...)` and greps the matching
docs/usr/modules/*.md chapter for the name.
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 module dirs.
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
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"
# Module dir -> 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 iter_modules() -> list[tuple[str, Path]]:
out = []
for d in sorted(MODULES.iterdir()):
if not d.is_dir():
continue
cpp = [p for p in d.rglob("*.cpp") if "third-party" not in str(p)]
if cpp:
out.append((d.name, d))
return out
def bound_names(module_dir: Path) -> set[str]:
names: set[str] = set()
for cpp in module_dir.rglob("*.cpp"):
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 module dirs 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
for module, d in iter_modules():
if selected and module not in selected:
continue
names = bound_names(d)
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 in iter_modules():
doc = doc_for(module)
if doc is None:
continue
names = bound_names(d)
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(iter_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())