`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.
329 lines
12 KiB
Python
329 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Enforce the examples/ layout contract.
|
|
|
|
Every directory under ``examples/`` is one of two reviewed kinds:
|
|
|
|
* a **runnable example** -- it owns ``main.nut`` plus a ``config.nut`` that
|
|
assigns the engine ``config`` table, and it is executed by
|
|
``scripts/smoke_examples.sh`` in CI; or
|
|
* a **non-runnable sample** -- a C++/plugin/asset package that is explicitly
|
|
listed in :data:`NON_RUNNABLE_EXAMPLES` with the reason it cannot be started
|
|
by ``eve run``.
|
|
|
|
Both kinds must own a ``README.md``, and every example must appear in the
|
|
``examples/README.md`` overview so the directory stays discoverable.
|
|
|
|
The runtime half of the contract lives in ``scripts/smoke_examples.sh``: every
|
|
directory that this gate classifies as runnable is launched for at least
|
|
``MIN_RUN_SECONDS`` and must still be alive, with no error markers, when the
|
|
harness kills it. A new example therefore cannot reach ``dev`` without either
|
|
surviving that smoke run or being added to the reviewed exemption list here.
|
|
|
|
Usage::
|
|
|
|
python3 scripts/check_examples.py
|
|
python3 scripts/check_examples.py --json
|
|
python3 scripts/check_examples.py --list-runnable
|
|
python3 scripts/check_examples.py --print-missing-rows
|
|
python3 scripts/check_examples.py --changed-base origin/dev
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable, Mapping
|
|
import utf8_stdio
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
EXAMPLES_DIR = REPO / "examples"
|
|
INDEX_FILE = EXAMPLES_DIR / "README.md"
|
|
|
|
# Directories that intentionally cannot be started as an ``eve run`` project.
|
|
# Every entry needs a reason; a stale entry (missing directory) and an entry
|
|
# whose directory grew a main.nut both fail this gate.
|
|
NON_RUNNABLE_EXAMPLES: Mapping[str, str] = {
|
|
"agent": "C++ 强化学习示例:main.cpp + train.nut,随 CMake 构建,不是 eve 可运行项目。",
|
|
"ai-editor": "AI 生成编辑器的演示资料(editor_demo.py + editors/*.editor.json),通过 eve mcp 无头主机运行。",
|
|
"economy": "单文件 economy 模块脚本演示,用 eve run -r examples/economy/economy_demo.nut 运行,没有项目级 config/main。",
|
|
"live2d-backend-plugin": "Live2D 后端替换插件的 C++ 骨架(CMakeLists.txt),随宿主工程编译,不是 eve 可运行项目。",
|
|
"native-plugin": "SDK 原生插件示例(CMakeLists.txt + hello_plugin.cpp),由 CMake 构建后再被脚本加载。",
|
|
"shader_effect_package": "打包好的 shader effect 资产包(effect.vert/frag + parameters.json),供其它示例引用。",
|
|
"surface-fluid-dynamic": "C++ 侧流体示例(main.cpp),随构建系统编译,不是 eve 可运行项目。",
|
|
}
|
|
|
|
# ``config = { ... }`` / ``config <- { ... }`` or a direct field assignment.
|
|
CONFIG_ASSIGN_RE = re.compile(r"^\s*config\s*(?:<-|=)(?!=)", re.MULTILINE)
|
|
CONFIG_FIELD_RE = re.compile(r"^\s*config\.[A-Za-z_]", re.MULTILINE)
|
|
INDEX_LINK_RE = re.compile(r"\]\(([^)]+?)/README\.md\)")
|
|
HEADING_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Example:
|
|
"""One directory directly under ``examples/``."""
|
|
|
|
name: str
|
|
path: Path
|
|
runnable: bool
|
|
|
|
@property
|
|
def main(self) -> Path:
|
|
return self.path / "main.nut"
|
|
|
|
@property
|
|
def config(self) -> Path:
|
|
return self.path / "config.nut"
|
|
|
|
@property
|
|
def readme(self) -> Path:
|
|
return self.path / "README.md"
|
|
|
|
@property
|
|
def relative(self) -> str:
|
|
return f"examples/{self.name}"
|
|
|
|
|
|
def discover(root: Path = REPO) -> list[Example]:
|
|
"""Return every example directory, sorted by name."""
|
|
examples_dir = root / "examples"
|
|
if not examples_dir.is_dir():
|
|
return []
|
|
return [
|
|
Example(name=child.name, path=child, runnable=(child / "main.nut").is_file())
|
|
for child in sorted(examples_dir.iterdir())
|
|
if child.is_dir()
|
|
]
|
|
|
|
|
|
def linked_examples(index_text: str) -> list[str]:
|
|
"""Return the example names linked from the overview README, in file order."""
|
|
names: list[str] = []
|
|
for raw in INDEX_LINK_RE.findall(index_text):
|
|
name = raw.strip().removeprefix("./").rstrip("/")
|
|
if "/" in name or not name or name.startswith(".."):
|
|
continue
|
|
names.append(name)
|
|
return names
|
|
|
|
|
|
def assigns_config_table(config_text: str) -> bool:
|
|
"""True when ``config.nut`` actually overrides the engine ``config`` table."""
|
|
return bool(CONFIG_ASSIGN_RE.search(config_text) or CONFIG_FIELD_RE.search(config_text))
|
|
|
|
|
|
def read_text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8", errors="replace")
|
|
|
|
|
|
def index_path(root: Path = REPO) -> Path:
|
|
return root / "examples" / "README.md"
|
|
|
|
|
|
def index_exists(root: Path = REPO) -> bool:
|
|
return index_path(root).is_file()
|
|
|
|
|
|
def read_index_text(root: Path = REPO) -> str:
|
|
path = index_path(root)
|
|
return read_text(path) if path.is_file() else ""
|
|
|
|
|
|
def readme_title(path: Path) -> str:
|
|
"""First ``# heading`` of an example README, or the directory name."""
|
|
if not path.is_file():
|
|
return path.parent.name
|
|
match = HEADING_RE.search(read_text(path))
|
|
if not match:
|
|
return path.parent.name
|
|
return match.group(1).lstrip("# ").strip()
|
|
|
|
|
|
def conformance_findings(
|
|
root: Path = REPO,
|
|
exemptions: Mapping[str, str] | None = None,
|
|
) -> list[str]:
|
|
"""Return every examples/ layout violation as a human-readable line."""
|
|
exemptions = NON_RUNNABLE_EXAMPLES if exemptions is None else exemptions
|
|
examples = discover(root)
|
|
if not examples:
|
|
return [f"error: no example directories found under {root / 'examples'}"]
|
|
|
|
findings: list[str] = []
|
|
by_name = {example.name: example for example in examples}
|
|
|
|
for example in examples:
|
|
if not example.readme.is_file():
|
|
findings.append(f"{example.relative}/README.md: missing example README")
|
|
|
|
if example.runnable:
|
|
if not example.config.is_file():
|
|
findings.append(
|
|
f"{example.relative}/config.nut: runnable example has no config.nut"
|
|
)
|
|
elif not assigns_config_table(read_text(example.config)):
|
|
findings.append(
|
|
f"{example.relative}/config.nut: does not assign the engine `config` "
|
|
"table, so window size/title are silently ignored"
|
|
)
|
|
if not read_text(example.main).strip():
|
|
findings.append(f"{example.relative}/main.nut: empty")
|
|
elif example.name not in exemptions:
|
|
findings.append(
|
|
f"{example.relative}/: has no main.nut and is not listed in "
|
|
"NON_RUNNABLE_EXAMPLES in scripts/check_examples.py; either make it a "
|
|
"runnable example (main.nut + config.nut) or exempt it with a reason"
|
|
)
|
|
|
|
for name in sorted(exemptions):
|
|
reason = exemptions[name]
|
|
example = by_name.get(name)
|
|
if example is None:
|
|
findings.append(
|
|
f"scripts/check_examples.py: NON_RUNNABLE_EXAMPLES entry '{name}' has no "
|
|
"matching examples/ directory"
|
|
)
|
|
continue
|
|
if not reason.strip():
|
|
findings.append(
|
|
f"scripts/check_examples.py: NON_RUNNABLE_EXAMPLES entry '{name}' needs a reason"
|
|
)
|
|
if example.runnable:
|
|
findings.append(
|
|
f"scripts/check_examples.py: NON_RUNNABLE_EXAMPLES entry '{name}' is stale: "
|
|
"the directory now owns main.nut and is smoke-testable"
|
|
)
|
|
|
|
linked = linked_examples(read_index_text(root))
|
|
linked_set = set(linked)
|
|
if not index_exists(root):
|
|
findings.append("examples/README.md: missing example overview")
|
|
for name in linked:
|
|
if name not in by_name:
|
|
findings.append(
|
|
f"examples/README.md: links examples/{name}/README.md but that directory "
|
|
"does not exist"
|
|
)
|
|
for example in examples:
|
|
if example.name not in linked_set:
|
|
findings.append(
|
|
f"examples/README.md: {example.relative} is not listed in the overview table"
|
|
)
|
|
return findings
|
|
|
|
|
|
def missing_index_rows(root: Path = REPO) -> list[str]:
|
|
"""Markdown table rows for examples missing from the overview README."""
|
|
linked = set(linked_examples(read_index_text(root)))
|
|
return [
|
|
f"| [{example.name}]({example.name}/README.md) | {readme_title(example.readme)} |"
|
|
for example in discover(root)
|
|
if example.name not in linked
|
|
]
|
|
|
|
|
|
def changed_example_dirs(base: str, root: Path = REPO) -> list[str]:
|
|
"""Example directory names added or modified between ``base`` and HEAD."""
|
|
result = subprocess.run(
|
|
["git", "diff", "--name-only", f"{base}...HEAD", "--", "examples"],
|
|
cwd=root,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
# git writes UTF-8; the locale default (cp936 on a Chinese Windows)
|
|
# would mangle or reject a non-ASCII path instead.
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
)
|
|
if result.returncode != 0:
|
|
return []
|
|
names: set[str] = set()
|
|
for line in result.stdout.splitlines():
|
|
parts = Path(line.strip()).parts
|
|
if len(parts) >= 2 and parts[0] == "examples":
|
|
names.add(parts[1])
|
|
return sorted(names)
|
|
|
|
|
|
def main(argv: Iterable[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--root", type=Path, default=REPO, help=argparse.SUPPRESS)
|
|
parser.add_argument("--json", action="store_true", help="emit findings as JSON")
|
|
parser.add_argument(
|
|
"--list-runnable",
|
|
action="store_true",
|
|
help="print the runnable examples that scripts/smoke_examples.sh must launch",
|
|
)
|
|
parser.add_argument(
|
|
"--print-missing-rows",
|
|
action="store_true",
|
|
help="print the overview rows needed for unregistered examples",
|
|
)
|
|
parser.add_argument(
|
|
"--changed-base",
|
|
help="git revision to diff against; reports which examples this change adds",
|
|
)
|
|
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
|
|
examples = discover(args.root)
|
|
|
|
if args.list_runnable:
|
|
for example in examples:
|
|
if example.runnable:
|
|
print(example.name)
|
|
return 0
|
|
|
|
if args.print_missing_rows:
|
|
for row in missing_index_rows(args.root):
|
|
print(row)
|
|
return 0
|
|
|
|
if args.changed_base:
|
|
changed = changed_example_dirs(args.changed_base, args.root)
|
|
known = {example.name for example in examples}
|
|
runnable = {example.name for example in examples if example.runnable}
|
|
print(f"changed example directories ({args.changed_base}...HEAD): {len(changed)}")
|
|
for name in changed:
|
|
if name not in known:
|
|
continue
|
|
if name in runnable:
|
|
print(f" smoke-covered examples/{name}")
|
|
else:
|
|
print(f" exempt examples/{name} (NON_RUNNABLE_EXAMPLES)")
|
|
new = [name for name in changed if name not in known]
|
|
if new:
|
|
print(f" removed {', '.join(new)}")
|
|
|
|
findings = conformance_findings(args.root)
|
|
if args.json:
|
|
print(json.dumps({"ok": not findings, "findings": findings}, ensure_ascii=False))
|
|
return 1 if findings else 0
|
|
|
|
if findings:
|
|
for finding in findings:
|
|
print(f"error: {finding}", file=sys.stderr)
|
|
print(
|
|
f"error: {len(findings)} example contract violation(s); see the "
|
|
"\"示例规范\" section of examples/README.md",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
runnable = sum(1 for example in examples if example.runnable)
|
|
exempt = sum(1 for example in examples if not example.runnable)
|
|
print(
|
|
f"examples layout OK: {len(examples)} examples "
|
|
f"({runnable} runnable, {exempt} reviewed non-runnable)"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
utf8_stdio.enable_utf8_stdio()
|
|
sys.exit(main())
|