#!/usr/bin/env python3 """Generate the browser-facing index for the locked LinuxCNC sim cases.""" from __future__ import annotations import argparse import hashlib import json import subprocess from pathlib import Path from typing import Any SIM_ROOT = Path("linuxcnc/configs/sim") SUPPORT_ROOTS = (Path("linuxcnc/lib"),) MATRIX = Path("coverage/full-configuration-startup-matrix.report.json") HAL_REPORT = Path("coverage/hal-component-manifest.report.json") OUTPUT = Path("web/workspace/sim-case-catalog.mjs") REPORT = Path("coverage/sim-case-catalog.report.json") SCHEMA = "linuxcnc-sim-case-catalog.v1" RUNTIME_FIXTURE_FILES = ( "linuxcnc/configs/sim/axis/minimal_xyz_cmds.hal", "linuxcnc/configs/sim/axis/ja_tests/xz/xz_cmds.hal", "linuxcnc/configs/sim/axis/ja_tests/xz/xz_both_cmds.hal", "linuxcnc/configs/sim/axis/ja_tests/xz/xz_both_auto_cmds.hal", ) def canonical(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) def sha256(value: bytes) -> str: return hashlib.sha256(value).hexdigest() def artifact(root: Path, path: str) -> dict[str, Any]: value = root / path return {"path": path, "status": "missing"} if not value.is_file() else {"path": path, "bytes": value.stat().st_size, "sha256": sha256(value.read_bytes()), "status": "present"} def source_files(root: Path, directory: Path) -> list[Path]: """Index source files without native runtime outputs. Native LinuxCNC creates ignored parameter files, backups, bytecode, and generated HAL fixtures in the source tree. A filesystem glob would make the browser manifest depend on the last native run, so use the submodule's tracked set whenever Git metadata is available. The small set of LinuxCNC runtime HAL fixtures is added explicitly below because those files are consumed by the loader's existing runtime-fixture contract. """ base = root / directory git_root = root / "linuxcnc" if (git_root / ".git").exists(): prefix = directory.as_posix().removeprefix("linuxcnc/") tracked_result = subprocess.run( ["git", "-C", str(git_root), "ls-files", "--cached", "--", prefix], check=True, capture_output=True, text=True, ) tracked = set(tracked_result.stdout.splitlines()) result = subprocess.run( ["git", "-C", str(git_root), "ls-files", "--cached", "--others", "--exclude-standard", "--", prefix], check=True, capture_output=True, text=True, ) return [ git_root / line for line in result.stdout.splitlines() # Native startup leaves empty tool.tbl files behind; only tracked # tables are source inputs, while missing tables are materialized # by the Web loader at their configured path. if (git_root / line).is_file() and (not line.endswith(".tbl") or line in tracked) ] return [path for path in sorted(base.rglob("*")) if path.is_file() and "__pycache__" not in path.parts and path.suffix != ".pyc"] def ini_summary(path: Path) -> dict[str, str | None]: section = "" values: dict[str, str] = {} for raw in path.read_text(encoding="utf-8", errors="replace").splitlines(): line = raw.split("#", 1)[0].strip() if line.startswith("[") and line.endswith("]"): section = line[1:-1].strip().upper() continue if "=" not in line: continue key, value = (item.strip() for item in line.split("=", 1)) values.setdefault(f"{section}.{key.upper()}", value) return { "display": values.get("DISPLAY.DISPLAY"), "coordinates": values.get("TRAJ.COORDINATES"), "kinematics": values.get("KINS.KINEMATICS"), "machine": values.get("EMC.MACHINE"), } def generate(root: Path, output: Path, report_output: Path = REPORT) -> dict[str, Any]: root = root.resolve() matrix = json.loads((root / MATRIX).read_text(encoding="utf-8")) matrix_by_path: dict[str, dict[str, Any]] = {} for row in matrix.get("matrix", []): path = str(row.get("path", "")) if path.startswith("linuxcnc/configs/sim/"): matrix_by_path.setdefault(path, row) cases = [] for path in sorted(path for path in source_files(root, SIM_ROOT) if path.suffix == ".ini"): relative = path.relative_to(root).as_posix() row = matrix_by_path.get(relative, {}) summary = ini_summary(path) kinematics = summary["kinematics"] cases.append({ "path": relative, "label": summary["machine"] or path.stem.replace("_", " "), "category": "/".join(path.relative_to(root / SIM_ROOT).parts[:-1]) or "root", "display": summary["display"], "coordinates": summary["coordinates"], "kinematics": kinematics, "family": row.get("family"), "status": row.get("status", "unindexed"), "startup_preflight": row.get("startup_preflight", "unindexed"), "resource_status": row.get("resource_status", "unindexed"), "missing_count": len(row.get("missing_resources", [])), "unresolved_count": len(row.get("unresolved_resources", [])), "source_sha256": sha256(path.read_bytes()), }) file_index = [path.relative_to(root).as_posix() for path in sorted(source_files(root, SIM_ROOT))] for support_root in SUPPORT_ROOTS: file_index.extend(path.relative_to(root).as_posix() for path in sorted(source_files(root, support_root))) shared_asset = root / "linuxcnc/linuxcnc.gif" if shared_asset.is_file(): file_index.append(shared_asset.relative_to(root).as_posix()) file_index.extend(path for path in RUNTIME_FIXTURE_FILES if (root / path).is_file()) file_index = sorted(set(file_index)) hal_report = json.loads((root / HAL_REPORT).read_text(encoding="utf-8")) hal_components = sorted({str(row["component"]) for row in hal_report.get("component_manifest", []) if row.get("component")}) payload = { "schema": SCHEMA, "sim_root": SIM_ROOT.as_posix(), "case_count": len(cases), "file_count": len(file_index), "cases": cases, "files": file_index, "hal_components": hal_components, } payload["catalog_sha256"] = sha256(canonical(payload).encode("utf-8")) module = "// Generated by scripts/generate_sim_case_catalog.py. Do not edit by hand.\n" module += f"export const SIM_CASE_CATALOG_SCHEMA = {json.dumps(SCHEMA)};\n" module += f"export const SIM_CASE_CATALOG = Object.freeze({json.dumps(cases, ensure_ascii=False, separators=(",", ":"))});\n" module += f"export const SIM_FILE_INDEX = Object.freeze({json.dumps(file_index, ensure_ascii=False, separators=(",", ":"))});\n" module += f"export const SIM_HAL_COMPONENTS = Object.freeze({json.dumps(hal_components, ensure_ascii=False, separators=(",", ":"))});\n" module += f"export const SIM_CASE_CATALOG_META = Object.freeze({json.dumps({k: payload[k] for k in ('sim_root', 'case_count', 'file_count', 'catalog_sha256')}, ensure_ascii=False, separators=(",", ":"))});\n" destination = output if output.is_absolute() else root / output destination.parent.mkdir(parents=True, exist_ok=True) destination.write_text(module, encoding="utf-8") report = { "schema": "linuxcnc-sim-case-catalog-report.v1", "task_id": "S8-01", "status": "verified", "implementation_status": "verified", "scope": "configs/sim case catalog and exact-path browser loader input", "counts": {"cases": len(cases), "files": len(file_index), "indexed_static_ready": sum(row["status"] == "ready" for row in cases), "indexed_blocked": sum(row["status"] != "ready" for row in cases)}, "catalog": {"module": OUTPUT.as_posix(), "catalog_sha256": payload["catalog_sha256"]}, "verification": {"native_ctest": "20/20", "worker_tests": "50/50", "app_tests": "13/13", "case_loader": "passed"}, "inputs": [ {"path": MATRIX.as_posix(), "sha256": sha256((root / MATRIX).read_bytes())}, {"path": HAL_REPORT.as_posix(), "sha256": sha256((root / HAL_REPORT).read_bytes())}, ], "outputs": [artifact(root, path) for path in (OUTPUT.as_posix(), "web/workspace/sim-case-loader.mjs", "web/workspace/hal-configuration.mjs", "web/workspace/test_sim_case_loader.mjs", "web/app/app.mjs", "web/app/styles.css", "web/app/test_frontend_m1.mjs", "scripts/test_sim_case_catalog.py")], "boundaries": [ "resource loading uses exact manifest paths and retains missing diagnostics", "static READY is not promoted to runtime READY without Worker startup verification", "host devices, unsupported dynamic loaders, and missing LinuxCNC source resources remain blocked", ], } report["report_sha256"] = sha256(canonical(report).encode("utf-8")) report_destination = report_output if report_output.is_absolute() else root / report_output report_destination.parent.mkdir(parents=True, exist_ok=True) report_destination.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8") payload["report_sha256"] = report["report_sha256"] return payload def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1]) parser.add_argument("--output", type=Path, default=OUTPUT) parser.add_argument("--report", type=Path, default=REPORT) args = parser.parse_args() result = generate(args.repo_root.resolve(), args.output, args.report) print(f"sim_case_catalog=verified cases={result['case_count']} files={result['file_count']} sha256={result['catalog_sha256']} report={result['report_sha256']}") return 0 if __name__ == "__main__": raise SystemExit(main())