Files
EVEngine/scripts/profile_matrix.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

472 lines
17 KiB
Python

#!/usr/bin/env python3
"""Configure, build and smoke-test the supported module profiles.
The matrix has two layers:
* ``--check`` is a fast, toolchain-free contract check used by CI. It resolves
the manifest in Python and verifies that hostless profiles cannot acquire a
renderer or window.
* ``--configure --build --smoke`` invokes CMake directly (never ``make``). The
build target is ``eve_profile_smoke``: selected module OBJECT targets,
standalone public-header translation units and independent capability probes.
It intentionally does not link the full ``unit_test`` executable.
Examples::
python3 scripts/profile_matrix.py --check
python3 scripts/profile_matrix.py --profile physics-core-only \
--configure --build --smoke
python3 scripts/profile_matrix.py --all --dry-run
The ``web`` profile uses ``BUILD_PLATFORM=webgpu``. Native Dawn or an
Emscripten toolchain must be supplied by the caller; the script does not hide
that platform requirement behind a soft skip.
"""
from __future__ import annotations
import argparse
import os
import shlex
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))
import check_module_manifest as manifest # noqa: E402
import utf8_stdio # noqa: E402
PROFILES = (
"minimal",
"2d",
"3d",
"runtime-3d",
"web",
"procgen-core-only",
"physics-core-only",
"asset-core-only",
"headless",
"server",
)
HOSTLESS = {"procgen-core-only", "physics-core-only", "asset-core-only", "headless", "server"}
RUNTIME_ONLY = {"runtime-3d"}
_MODULE_DIRS: dict[str, str] | None = None
def is_editor_module(name: str) -> bool:
global _MODULE_DIRS
if _MODULE_DIRS is None:
_MODULE_DIRS = {item.name: item.dir for item in manifest.parse_manifest()}
return manifest.is_authoring_facet(name, _MODULE_DIRS.get(name))
CORE_SEEDS = {
"procgen-core-only": ("common",),
"physics-core-only": ("common", "platform_event"),
"asset-core-only": ("common", "data", "asset"),
"headless": ("common", "data", "platform_event", "timer"),
"server": (
"common",
"data",
"platform_event",
"timer",
"network",
"authority",
"decision",
"definitions",
"effects",
"game_event",
"orders",
"schema",
"social",
"statepatch",
"steering",
"tags",
"transaction",
"economy",
"attributes",
"sensing",
"spatial",
"action",
"settlement",
),
}
FORBIDDEN_HOSTLESS = {"cmdline", "window", "graphics", "physics", "procgen"}
PHYSICS_CORE_SOURCES = (
"physics/Body.cpp",
"physics/Body3D.cpp",
"physics/Fixture.cpp",
"physics/Joint3D.cpp",
"physics/PhysicsHandles.cpp",
"physics/PhysicsLink.cpp",
"physics/Shape3D.cpp",
"physics/backend/SimulationBackend.cpp",
"physics/World.cpp",
"physics/World3D.cpp",
)
@dataclass(frozen=True)
class ModuleContract:
name: str
deps: tuple[str, ...]
third_party: tuple[str, ...]
groups: tuple[str, ...]
required: bool
core: bool
_KEYWORDS = {
"NAME",
"LIB",
"LAYER",
"DIR",
"DEPS",
"OPTIONAL_DEPS",
"THIRDPARTY",
"SCRIPT",
"SLOT",
"GROUP",
"REQUIRED",
"CORE",
}
_ONE_VALUE = {"NAME", "LIB", "LAYER", "DIR"}
_MULTI_VALUE = {"DEPS", "OPTIONAL_DEPS", "THIRDPARTY", "SCRIPT", "SLOT", "GROUP"}
def _cmake_blocks(path: Path) -> list[str]:
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
blocks: list[str] = []
for start, line in enumerate(lines):
if not line.lstrip().startswith("eve_declare_module("):
continue
depth = 0
quoted = False
escaped = False
block: list[str] = []
for current in lines[start:]:
block.append(current)
for char in current:
if escaped:
escaped = False
elif char == "\\" and quoted:
escaped = True
elif char == '"':
quoted = not quoted
elif not quoted and char == "(":
depth += 1
elif not quoted and char == ")":
depth -= 1
if depth == 0:
blocks.append("".join(block))
break
else:
raise ValueError(f"unterminated eve_declare_module at line {start + 1}")
return blocks
def _parse_contract(block: str) -> ModuleContract:
tokens = shlex.split(block.replace("\n", " "))
if not tokens or not tokens[0].startswith("eve_declare_module("):
raise ValueError(f"invalid module declaration: {block!r}")
tokens[0] = tokens[0][len("eve_declare_module("):]
if not tokens[0]:
tokens.pop(0)
values: dict[str, list[str] | bool] = {}
index = 0
while index < len(tokens):
key = tokens[index].rstrip(")")
index += 1
if key in {"REQUIRED", "CORE"}:
values[key] = True
elif key in _ONE_VALUE:
if index >= len(tokens):
raise ValueError(f"{key} has no value in {block!r}")
values[key] = [tokens[index].rstrip(")")]
index += 1
elif key in _MULTI_VALUE:
result: list[str] = []
while index < len(tokens) and tokens[index].rstrip(")") not in _KEYWORDS:
result.append(tokens[index].rstrip(")"))
index += 1
values[key] = result
else:
# The first token is the command spelling; an unknown token after
# it indicates a manifest syntax that this matrix cannot resolve.
raise ValueError(f"unknown manifest token {key!r} in {block!r}")
name = values.get("NAME", [])
if not isinstance(name, list) or not name:
raise ValueError(f"manifest declaration has no NAME: {block!r}")
return ModuleContract(
name=name[0],
deps=tuple(values.get("DEPS", []) if isinstance(values.get("DEPS", []), list) else []),
third_party=tuple(
values.get("THIRDPARTY", [])
if isinstance(values.get("THIRDPARTY", []), list)
else []
),
groups=tuple(values.get("GROUP", []) if isinstance(values.get("GROUP", []), list) else []),
required=bool(values.get("REQUIRED", False)),
core=bool(values.get("CORE", False)),
)
def contracts() -> list[ModuleContract]:
return [
_parse_contract(block)
for path in manifest.manifest_files()
for block in _cmake_blocks(path)
]
def resolve(profile: str, declared: list[ModuleContract] | None = None) -> tuple[list[str], list[str]]:
modules = declared or contracts()
by_name = {module.name: module for module in modules}
if profile in CORE_SEEDS:
wanted = list(CORE_SEEDS[profile])
else:
wanted = []
for module in modules:
selection_profile = "3d" if profile == "runtime-3d" else profile
enabled = module.required or profile == "full" or selection_profile in module.groups
if profile in RUNTIME_ONLY and is_editor_module(module.name):
enabled = False
if enabled:
wanted.append(module.name)
pending = list(wanted)
while pending:
name = pending.pop(0)
if name not in by_name:
raise ValueError(f"profile {profile}: undeclared module {name}")
for dependency in by_name[name].deps:
if dependency not in by_name:
raise ValueError(f"module {name} depends on undeclared {dependency}")
if dependency not in wanted:
wanted.append(dependency)
pending.append(dependency)
ordered = [module.name for module in modules if module.name in wanted]
groups: list[str] = []
for module in modules:
if module.name in wanted:
for group in module.third_party:
if group not in groups:
groups.append(group)
return ordered, groups
def check_contracts() -> int:
declarations = manifest.parse_manifest()
errors = manifest.validate(declarations, manifest.MODULES)
try:
module_contracts = contracts()
except ValueError as error:
errors.append(str(error))
module_contracts = []
names = {module.name for module in module_contracts}
if set(PROFILES) & names:
errors.append("profile name collides with a module name")
for profile in PROFILES:
try:
enabled, groups = resolve(profile, module_contracts)
except ValueError as error:
errors.append(str(error))
continue
enabled_set = set(enabled)
if profile in RUNTIME_ONLY:
leaked = sorted(name for name in enabled_set if is_editor_module(name))
if leaked:
errors.append(f"{profile}: runtime-only profile enables {', '.join(leaked)}")
if profile in HOSTLESS:
leaked = sorted(enabled_set & FORBIDDEN_HOSTLESS)
if leaked:
errors.append(f"{profile}: hostless profile enables {', '.join(leaked)}")
if profile == "procgen-core-only" and "EVProcgenCoreCheck" not in (
ROOT / "src" / "modules" / "CMakeLists.txt"
).read_text(encoding="utf-8"):
errors.append("procgen-core-only: EVProcgenCoreCheck target is not wired")
if profile == "physics-core-only" and "EVPhysicsCoreCheck" not in (
ROOT / "src" / "modules" / "CMakeLists.txt"
).read_text(encoding="utf-8"):
errors.append("physics-core-only: EVPhysicsCoreCheck target is not wired")
if profile == "physics-core-only":
physics_cmake = (ROOT / "src" / "modules" / "CMakeLists.txt").read_text(
encoding="utf-8"
)
missing_sources = [
source for source in PHYSICS_CORE_SOURCES if source not in physics_cmake
]
if missing_sources:
errors.append(
"physics-core-only: domain sources missing from core check: "
+ ", ".join(missing_sources)
)
core_block = physics_cmake.split("set(_eve_physics_core_sources", 1)[-1].split(
"set(_eve_physics_core_boundary_files", 1
)[0]
if "graphics/" in core_block or "gpgpu/" in core_block:
errors.append("physics-core-only: core source block mentions graphics/gpgpu")
print(
f"profile {profile:18s}: {len(enabled):2d} modules; "
f"third-party groups: {', '.join(groups) or '(none)'}"
)
profile_cmake = (ROOT / "cmake" / "profile_checks.cmake").read_text(encoding="utf-8")
if "eve_public_header_checks" not in profile_cmake:
errors.append("independent public-header target is missing")
if "eve_capability_present_check" not in profile_cmake or "eve_capability_absent_check" not in profile_cmake:
errors.append("independent optional-capability targets are incomplete")
if any(
"target_link_libraries" in line and "unit_test" in line
for line in profile_cmake.splitlines()
):
errors.append("profile checks must not link unit_test")
test_cmake = (ROOT / "test" / "CMakeLists.txt").read_text(encoding="utf-8")
if "eve_thirdparty_libs(_eve_test_tp_libs" not in test_cmake:
errors.append("test runner is not using manifest-derived third-party groups")
if errors:
for error in errors:
print(f"FAIL {error}", file=sys.stderr)
return 1
print(f"profile matrix contract OK: {len(PROFILES)} profiles")
return 0
def build_directory(root: Path, profile: str) -> Path:
return root / profile
def cmake_configure_command(args: argparse.Namespace, profile: str) -> list[str]:
platform = args.platform or ("webgpu" if profile == "web" else "linux")
cmake_command = shlex.split(args.cmake_command)
if not cmake_command:
raise ValueError("--cmake-command must not be empty")
return cmake_command + [
"-S",
str(ROOT),
"-B",
str(build_directory(Path(args.build_root), profile)),
"-G",
args.generator,
"-DCMAKE_BUILD_TYPE=Debug",
f"-DBUILD_PLATFORM={platform}",
"-DBUILD_TESTING=OFF",
"-DEVENGINE_BUILD_DEMO=OFF",
"-DEVENGINE_BUILD_PROFILE_CHECKS=ON",
f"-DEVENGINE_PROFILE={profile}",
]
def run_command(command: list[str], cwd: Path, env: dict[str, str] | None = None) -> None:
print("$ " + shlex.join(command))
subprocess.run(command, cwd=cwd, env=env, check=True)
def run_smoke(args: argparse.Namespace, profile: str) -> None:
directory = build_directory(Path(args.build_root), profile)
candidates: list[Path] = []
for stem in ("eve_capability_present_check", "eve_capability_absent_check"):
candidates.extend(sorted(directory.rglob(stem)))
candidates.extend(sorted(directory.rglob(stem + ".js")))
if not candidates:
raise RuntimeError(
f"{profile}: capability smoke binaries are missing; build eve_profile_smoke first"
)
for candidate in candidates:
if candidate.suffix == ".js":
node = os.environ.get("NODE", "node")
run_command([node, str(candidate)], ROOT)
elif candidate.is_file() and os.access(candidate, os.X_OK):
run_command([str(candidate)], ROOT)
def execute_profile(args: argparse.Namespace, profile: str) -> None:
command = cmake_configure_command(args, profile)
directory = build_directory(Path(args.build_root), profile)
if args.dry_run:
if args.configure:
print("$ " + shlex.join(command))
if args.build:
print(
"$ "
+ shlex.join(
[
"cmake",
"--build",
str(directory),
"--target",
"eve_profile_smoke",
"--parallel",
str(args.jobs),
]
)
)
if args.smoke:
print(f"$ <run {directory}/profile/eve_capability_{'{present,absent}'}_check>")
return
if args.configure:
run_command(command, ROOT)
if args.build:
run_command(
[
"cmake",
"--build",
str(directory),
"--target",
"eve_profile_smoke",
"--parallel",
str(args.jobs),
],
ROOT,
)
if args.smoke:
run_smoke(args, profile)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
selection = parser.add_mutually_exclusive_group()
selection.add_argument("--profile", choices=PROFILES)
selection.add_argument("--all", action="store_true", help="run every profile in matrix order")
parser.add_argument("--check", action="store_true", help="run the static contract check")
parser.add_argument("--configure", action="store_true", help="run CMake configure")
parser.add_argument("--build", action="store_true", help="build eve_profile_smoke")
parser.add_argument("--smoke", action="store_true", help="run independent capability probes")
parser.add_argument("--dry-run", action="store_true", help="print commands without executing them")
parser.add_argument("--build-root", default=str(ROOT / "build" / "profile-matrix"))
parser.add_argument("--generator", default="Ninja")
parser.add_argument("--platform", help="override BUILD_PLATFORM (default: linux, webgpu for web)")
parser.add_argument(
"--cmake-command",
default=os.environ.get("EVENGINE_CMAKE_COMMAND", "cmake"),
help="configure launcher, e.g. 'emcmake cmake' for Emscripten",
)
parser.add_argument("--jobs", type=int, default=min(os.cpu_count() or 1, 4))
args = parser.parse_args(argv)
if not any((args.check, args.configure, args.build, args.smoke, args.dry_run)):
args.check = True
if args.dry_run and not any((args.configure, args.build, args.smoke)):
args.configure = args.build = args.smoke = True
if args.check:
result = check_contracts()
if result:
return result
selected = list(PROFILES) if args.all else ([args.profile] if args.profile else [])
if (args.configure or args.build or args.smoke or args.dry_run) and not selected:
parser.error("--profile or --all is required with configure/build/smoke/dry-run")
for profile in selected:
execute_profile(args, profile)
return 0
if __name__ == "__main__":
utf8_stdio.enable_utf8_stdio()
sys.exit(main())