`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.
165 lines
5.4 KiB
Python
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())
|