#!/usr/bin/env python3 """Index LinuxCNC GUI sources, simulation descriptors and visual assets. This is an audit input for FE-M4. It does not claim that a desktop GUI file is already implemented in Web; each row retains its source hash and adaptation boundary for later React/Worker tests. """ from __future__ import annotations import hashlib import json from collections import Counter from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] REPORT = ROOT / "coverage/gui-source-inventory-fe-m4.report.json" CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1" SOURCE_ROOTS = ( Path("linuxcnc/src/emc/usr_intf/axis"), Path("linuxcnc/src/emc/usr_intf/gmoccapy"), Path("linuxcnc/src/emc/usr_intf/gscreen"), Path("linuxcnc/src/emc/usr_intf/touchy"), Path("linuxcnc/src/emc/usr_intf/qtvcp"), Path("linuxcnc/src/emc/usr_intf/qtplasmac"), Path("linuxcnc/src/emc/usr_intf/pyui"), Path("linuxcnc/src/emc/usr_intf/gremlin"), Path("linuxcnc/lib/python/qtvcp"), Path("linuxcnc/lib/python/gmoccapy"), Path("linuxcnc/lib/python/gscreen"), Path("linuxcnc/lib/python/touchy"), Path("linuxcnc/lib/python/gladevcp"), Path("linuxcnc/lib/python/plasmac"), Path("linuxcnc/lib/python/vismach.py"), ) SIM_ROOT = Path("linuxcnc/configs/sim") GUI_FAMILIES = ("axis", "gmoccapy", "gscreen", "touchy", "qtvcp", "qtdragon", "qtplasmac", "pyvcp", "gladevcp", "ngcgui", "vismach", "plasma", "laser") ASSET_SUFFIXES = {".png", ".gif", ".svg", ".jpg", ".jpeg", ".ui", ".glade", ".gladep", ".xml", ".pref", ".halscope", ".halshow"} def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for block in iter(lambda: handle.read(1024 * 1024), b""): digest.update(block) return digest.hexdigest() def canonical(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) def family(path: str) -> str: lower = path.lower() for name in GUI_FAMILIES: if f"/{name}/" in lower or lower.endswith(f"/{name}") or name in Path(lower).name: return name return "shared" def adaptation(path: str) -> str: lower = path.lower() if "/src/emc/usr_intf/" in lower and any(token in lower for token in ("pncconf", "stepconf")): return "REAL-EXCLUDED/WEB-EQUIV" if lower.endswith((".glade", ".gladep", ".ui", ".xml")): return "WEB-EQUIV/DESCRIPTOR" if "/python/" in lower or lower.endswith(".py"): return "PY-WASM/WEB-EQUIV" if any(lower.endswith(suffix) for suffix in ASSET_SUFFIXES): return "WEB-EQUIV/SOURCE-ASSET" return "WEB-EQUIV/SOURCE-MAP" def rows() -> list[dict[str, Any]]: selected: dict[str, Path] = {} for root in SOURCE_ROOTS: path = ROOT / root if path.is_file(): selected[root.as_posix()] = path elif path.is_dir(): for item in path.rglob("*"): if item.is_file() and "__pycache__" not in item.parts and item.suffix != ".pyc": selected[item.relative_to(ROOT).as_posix()] = item for item in (ROOT / SIM_ROOT).rglob("*"): if not item.is_file() or "__pycache__" in item.parts or item.suffix == ".pyc": continue relative = item.relative_to(ROOT).as_posix() lower = relative.lower() if any(f"/{name}/" in lower for name in GUI_FAMILIES) or item.suffix.lower() in ASSET_SUFFIXES: selected[relative] = item result = [] for relative, path in sorted(selected.items()): result.append({"source_id": f"gui:{relative}", "path": relative, "family": family(relative), "kind": "asset" if path.suffix.lower() in ASSET_SUFFIXES else "source", "adaptation": adaptation(relative), "bytes": path.stat().st_size, "sha256": sha256(path), "status": "planned"}) return result def generate() -> dict[str, Any]: records = rows() manifest_path = ROOT / "coverage/manifest.jsonl" manifest_sources: set[str] = set() if manifest_path.is_file(): for line in manifest_path.read_text(encoding="utf-8").splitlines(): try: value = json.loads(line) except json.JSONDecodeError: continue if isinstance(value, dict): source = value.get("source") if isinstance(source, str): manifest_sources.add(source.removeprefix("config:").removeprefix("source:").removeprefix("doc:")) for record in records: record["manifest_indexed"] = record["path"] in manifest_sources family_counts = Counter(record["family"] for record in records) kind_counts = Counter(record["kind"] for record in records) report: dict[str, Any] = { "schema": "gui-source-inventory-fe-m4-report.v1", "task_id": "FE-M4-GUI-01", "context_id": CONTEXT_ID, "status": "verified" if records and all(record["sha256"] for record in records) else "blocked", "implementation_status": "audited", "scope": {"source_roots": [path.as_posix() for path in SOURCE_ROOTS], "simulation_root": SIM_ROOT.as_posix(), "families": list(GUI_FAMILIES)}, "counts": {"records": len(records), "families": dict(sorted(family_counts.items())), "kinds": dict(sorted(kind_counts.items())), "manifest_indexed": sum(record["manifest_indexed"] for record in records), "manifest_unindexed": sum(not record["manifest_indexed"] for record in records)}, "records": records, "checks": {"source_hashes": {"status": "verified" if all(record["sha256"] for record in records) else "blocked"}, "exact_paths": {"status": "verified" if all((ROOT / record["path"]).is_file() for record in records) else "blocked"}, "manifest_reverse_index": {"status": "verified" if all(record["manifest_indexed"] for record in records) else "blocked", "unindexed": [record["path"] for record in records if not record["manifest_indexed"]][:100]}}, "boundaries": ["desktop GTK/Qt/Tk windows are not copied into Web", "Python/VCP files require PY-WASM and capability checks", "REAL-EXCLUDED hardware configuration editors remain explicit", "full GUI acceptance requires Worker workflow, source oracle and browser evidence per family"], } report["report_sha256"] = hashlib.sha256(canonical(report).encode("utf-8")).hexdigest() REPORT.parent.mkdir(parents=True, exist_ok=True) REPORT.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8") return report if __name__ == "__main__": report = generate() print(f"gui_source_inventory={report['status']} records={report['counts']['records']} families={len(report['counts']['families'])} unindexed={report['counts']['manifest_unindexed']} report_sha256={report['report_sha256']}")