153 lines
7.7 KiB
Python
153 lines
7.7 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
|
|
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())
|