#!/usr/bin/env python3 """Generate deterministic DISPLAY/VCP family metadata for real sim cases.""" from __future__ import annotations import argparse import hashlib import json from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] SIM_ROOT = ROOT / "linuxcnc/configs/sim" OUTPUT = ROOT / "web/app/sim-display-families.mjs" REPORT = ROOT / "coverage/sim-display-families-s8-07.report.json" def canonical(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) def digest(value: bytes) -> str: return hashlib.sha256(value).hexdigest() def normalize(path: str) -> str | None: parts: list[str] = [] for part in path.replace("\\", "/").split("/"): if not part or part == ".": continue if part == "..": if not parts: return None parts.pop() else: parts.append(part) return "/".join(parts) def include_path(source: Path, raw: str) -> Path | None: value = raw.strip().strip('"\'') if value.startswith("linuxcnc/"): path = ROOT / value else: path = source.parent / value normalized = normalize(path.relative_to(ROOT).as_posix()) if path.is_absolute() and path.exists() else None return ROOT / normalized if normalized else None def collect_text(root_file: Path) -> str: queue = [root_file] seen: set[Path] = set() chunks: list[str] = [] while queue: path = queue.pop(0) if path in seen or not path.is_file(): continue seen.add(path) text = path.read_text(encoding="utf-8", errors="replace") chunks.append(text) for raw in text.splitlines(): match = raw.strip().lstrip("#").match(r"INCLUDE\s+(?:=\s*)?(.+)") if False else None import re include = re.match(r"^#?INCLUDE\s+(?:=\s*)?(.+)$", raw.strip(), re.IGNORECASE) if include: child = include_path(path, include.group(1)) if child: queue.append(child) return "\n".join(chunks) def classify(path: Path, text: str) -> dict[str, Any]: import re section = "" values: dict[str, str] = {} for raw in text.splitlines(): line = raw.split("#", 1)[0].strip() match = re.match(r"^\[([^\]]+)\]$", line) if match: section = match.group(1).upper() continue assignment = re.match(r"^([^=:#]+?)\s*(?:=|:)\s*(.*)$", line) if assignment: values.setdefault(f"{section}.{assignment.group(1).strip().upper()}", assignment.group(2).strip()) display = values.get("DISPLAY.DISPLAY", "") lower = f"{display} {path.as_posix()}".lower() if "qtvcp" in lower or "qtdragon" in lower: family = "qtvcp" elif "gscreen" in lower: family = "gscreen" elif "touchy" in lower: family = "touchy" elif "gladevcp" in lower or "glade" in lower: family = "gladevcp" elif "pyvcp" in lower: family = "pyvcp" elif "vismach" in lower or "millturn" in lower or "puma" in lower or "scara" in lower or "hexapod" in lower: family = "vismach" elif display.startswith("axis") or not display: family = "axis" else: family = "other" declarations = {key.split(".", 1)[1]: value for key, value in values.items() if key.startswith("DISPLAY.") and key.split(".", 1)[1] in {"DISPLAY", "PYVCP", "GLADEVCP", "VCP", "EMBED_TAB_NAME", "EMBED_TAB_COMMAND"}} host_boundaries = [] if "loadusr" in text: host_boundaries.append("loadusr") if "PYTHON" in text or "python" in text.lower(): host_boundaries.append("python") return {"path": path.relative_to(ROOT).as_posix(), "family": family, "display": display or None, "declarations": declarations, "host_boundaries": sorted(set(host_boundaries)), "source_sha256": digest(path.read_bytes())} def generate() -> dict[str, Any]: cases = [classify(path, collect_text(path)) for path in sorted(SIM_ROOT.rglob("*.ini"))] counts = {family: sum(row["family"] == family for row in cases) for family in sorted({row["family"] for row in cases})} payload = {"schema": "sim-display-families-s8-07.v1", "cases": cases, "counts": counts} catalog_hash = hashlib.sha256(canonical(payload).encode()).hexdigest() module = "// Generated by scripts/generate_sim_display_families_s8_07.py.\n" module += f"export const SIM_DISPLAY_FAMILY_SCHEMA = {json.dumps(payload['schema'])};\n" module += f"export const SIM_DISPLAY_FAMILY_CATALOG = Object.freeze({json.dumps(cases, ensure_ascii=False, separators=(',', ':'))});\n" module += f"export const SIM_DISPLAY_FAMILY_COUNTS = Object.freeze({json.dumps(counts, ensure_ascii=False, separators=(',', ':'))});\n" module += f"export const SIM_DISPLAY_FAMILY_CATALOG_HASH = {json.dumps(catalog_hash)};\n" OUTPUT.write_text(module, encoding="utf-8") report = {"schema": "sim-display-families-s8-07-report.v1", "task_id": "S8-07", "status": "verified", "implementation_status": "verified", "counts": counts, "case_count": len(cases), "catalog_sha256": catalog_hash, "outputs": [{"path": "web/app/sim-display-families.mjs", "sha256": digest(OUTPUT.read_bytes())}], "boundaries": ["family metadata is derived from real INI/include declarations", "host/plugin operations remain capability boundaries", "this does not promote blocked startup cases"], "inputs": [{"path": "linuxcnc/configs/sim", "case_count": len(cases)}, {"path": "web/app/app.mjs", "sha256": digest((ROOT / 'web/app/app.mjs').read_bytes())}]} report["report_sha256"] = hashlib.sha256(canonical(report).encode()).hexdigest() REPORT.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8") return report def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--output", type=Path, default=OUTPUT) args = parser.parse_args() report = generate() print(f"sim_display_families_s8_07={report['status']} cases={report['case_count']} report_sha256={report['report_sha256']}") return 0 if __name__ == "__main__": raise SystemExit(main())