Files
linuxcncWebWASM/scripts/generate_sim_case_catalog.py

200 lines
9.9 KiB
Python

#!/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",
"linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt-headless_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())