Files
西风逍遥游 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

165 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""Report object-file size and high-impact header usage for local builds."""
from __future__ import annotations
import argparse
import collections
import json
import re
from dataclasses import dataclass
from pathlib import Path
import utf8_stdio
SOURCE_SUFFIXES = {".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx"}
HEADER_SUFFIXES = {".h", ".hh", ".hpp", ".hxx"}
INCLUDE_RE = re.compile(r'^\s*#\s*include\s*["<]([^">]+)[">]', re.MULTILINE)
TEMPLATE_RE = re.compile(r"\btemplate\s*<")
@dataclass(frozen=True)
class HeaderMetric:
path: str
fanout: int
lines: int
bytes: int
templates: int
@property
def impact(self) -> int:
return self.fanout * self.bytes
def source_files(source_dir: Path) -> list[Path]:
roots = [source_dir / "src", source_dir / "test"]
return sorted(
path
for root in roots
if root.exists()
for path in root.rglob("*")
if path.is_file() and path.suffix.lower() in SOURCE_SUFFIXES
)
def direct_header_metrics(source_dir: Path) -> list[HeaderMetric]:
files = source_files(source_dir)
include_roots = [source_dir / "src" / "engine", source_dir / "src" / "modules"]
headers: dict[str, Path] = {}
for root in include_roots:
if not root.exists():
continue
for path in root.rglob("*"):
if path.is_file() and path.suffix.lower() in HEADER_SUFFIXES:
headers.setdefault(path.relative_to(root).as_posix(), path)
users: dict[str, set[Path]] = collections.defaultdict(set)
for path in files:
text = path.read_text(encoding="utf-8", errors="ignore")
for include in INCLUDE_RE.findall(text):
if include in headers:
users[include].add(path)
metrics = []
for include, consumers in users.items():
path = headers[include]
text = path.read_text(encoding="utf-8", errors="ignore")
metrics.append(
HeaderMetric(
path=path.relative_to(source_dir).as_posix(),
fanout=len(consumers),
lines=text.count("\n") + 1,
bytes=len(text.encode("utf-8")),
templates=len(TEMPLATE_RE.findall(text)),
)
)
return sorted(metrics, key=lambda metric: (-metric.impact, -metric.fanout, metric.path))
def object_metrics(build_dir: Path) -> list[tuple[int, Path]]:
if not build_dir.exists():
return []
return sorted(
(
(path.stat().st_size, path)
for path in build_dir.rglob("*")
if path.is_file() and path.suffix.lower() in {".o", ".obj"}
),
reverse=True,
)
def mib(value: int) -> str:
return f"{value / (1024 * 1024):.2f}"
def report(source_dir: Path, build_dir: Path, limit: int) -> dict[str, object]:
headers = direct_header_metrics(source_dir)
objects = object_metrics(build_dir)
template_headers = sorted(
(metric for metric in headers if metric.templates),
key=lambda metric: (-metric.impact, -metric.templates, metric.path),
)
print(f"Build directory: {build_dir}")
if objects:
total = sum(size for size, _ in objects)
print(f"Objects: {len(objects)} files, {mib(total)} MiB total")
print("\nLargest object files (MiB):")
for size, path in objects[:limit]:
print(f" {mib(size):>10} {path.relative_to(build_dir)}")
else:
print("Objects: none found (header analysis is still available)")
print("\nHigh-impact directly included project headers:")
print(" fanout templates lines KiB header")
for metric in headers[:limit]:
print(
f" {metric.fanout:6} {metric.templates:9} {metric.lines:8} "
f"{metric.bytes / 1024:8.1f} {metric.path}"
)
print("\nTemplate-bearing headers, ordered by direct fanout x source bytes:")
print(" fanout templates lines KiB header")
for metric in template_headers[:limit]:
print(
f" {metric.fanout:6} {metric.templates:9} {metric.lines:8} "
f"{metric.bytes / 1024:8.1f} {metric.path}"
)
return {
"build_dir": str(build_dir),
"objects": {
"count": len(objects),
"bytes": sum(size for size, _ in objects),
"largest": [
{"path": path.relative_to(build_dir).as_posix(), "bytes": size}
for size, path in objects[:limit]
],
},
"headers": [metric.__dict__ | {"impact": metric.impact} for metric in headers],
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source-dir", type=Path, default=Path.cwd())
parser.add_argument("--build-dir", type=Path, required=True)
parser.add_argument("--limit", type=int, default=30)
parser.add_argument("--json", type=Path, help="also write the complete report as JSON")
args = parser.parse_args()
source_dir = args.source_dir.resolve()
build_dir = args.build_dir.resolve()
result = report(source_dir, build_dir, max(1, args.limit))
if args.json:
args.json.parent.mkdir(parents=True, exist_ok=True)
args.json.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
print(f"\nJSON report: {args.json}")
return 0
if __name__ == "__main__":
utf8_stdio.enable_utf8_stdio()
raise SystemExit(main())