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>
228 lines
8.2 KiB
Python
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())
|