#!/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 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" 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 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((root / SIM_ROOT).rglob("*.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((root / SIM_ROOT).rglob("*")) if path.is_file()] for support_root in SUPPORT_ROOTS: file_index.extend(path.relative_to(root).as_posix() for path in sorted((root / support_root).rglob("*")) if path.is_file()) shared_asset = root / "linuxcnc/linuxcnc.gif" if shared_asset.is_file(): file_index.append(shared_asset.relative_to(root).as_posix()) 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())