#!/usr/bin/env python3 """Inventory FreeCAD workbench/module boundaries from the current source tree.""" from __future__ import annotations import argparse import ast import hashlib import json import re import subprocess from pathlib import Path def revision(source_root: Path) -> str: try: return subprocess.run( ["git", "-C", str(source_root), "rev-parse", "HEAD"], check=True, capture_output=True, text=True, ).stdout.strip() except (OSError, subprocess.CalledProcessError): return "unknown" def sha256(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def cmake_conditions(parent_text: str, name: str) -> list[str]: stack: list[str] = [] conditions: list[str] = [] for raw_line in parent_text.splitlines(): line = raw_line.strip() opening = re.match(r"if\s*\((.+)\)", line, re.IGNORECASE) if opening: stack.append(" ".join(opening.group(1).split())) continue if re.match(r"endif\b", line, re.IGNORECASE): if stack: stack.pop() continue if re.search(rf"add_subdirectory\({re.escape(name)}\)", line, re.IGNORECASE): conditions.append(" and ".join(stack) if stack else "unconditional") return conditions or ["not referenced by src/Mod/CMakeLists.txt"] def targets(module_path: Path) -> list[str]: names: set[str] = set() for cmake_path in module_path.rglob("CMakeLists.txt"): cmake = cmake_path.read_text(encoding="utf-8", errors="replace") names.update(re.findall(r"(?:add_library|add_executable)\(\s*([A-Za-z0-9_+-]+)", cmake)) return sorted(names) COMMAND_TOKEN = re.compile(r"^[A-Za-z][A-Za-z0-9_]+$") def constant_label(node: ast.AST | None) -> str | None: if isinstance(node, ast.Constant) and isinstance(node.value, str): return node.value if isinstance(node, (ast.List, ast.Tuple)): labels = [constant_label(item) for item in node.elts] return "/".join(label for label in labels if label) or None return None def python_gui_commands(path: Path) -> list[tuple[str, int, str, str | None]]: try: tree = ast.parse(path.read_text(encoding="utf-8", errors="replace")) except SyntaxError: return [] commands: list[tuple[str, int, str, str | None]] = [] bindings: dict[str, list[ast.AST]] = {} for node in ast.walk(tree): if isinstance(node, ast.Assign): for target in node.targets: if isinstance(target, ast.Name): bindings.setdefault(target.id, []).append(node.value) def strings(node: ast.AST, seen: frozenset[str] = frozenset()): if isinstance(node, ast.Constant) and isinstance(node.value, str): yield node elif isinstance(node, (ast.List, ast.Tuple, ast.Set)): for item in node.elts: yield from strings(item, seen) elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): yield from strings(node.left, seen) yield from strings(node.right, seen) elif isinstance(node, ast.Name) and node.id not in seen: for value in bindings.get(node.id, []): yield from strings(value, seen | {node.id}) for node in ast.walk(tree): if not isinstance(node, ast.Call): continue call_name = node.func.attr if isinstance(node.func, ast.Attribute) else node.func.id if isinstance(node.func, ast.Name) else "" if call_name not in {"addCommand", "appendToolbar", "appendMenu"}: continue surface = "registration" if call_name == "addCommand" else "toolbar" if call_name == "appendToolbar" else "menu" label = None if call_name == "addCommand" else constant_label(node.args[0] if node.args else None) args = node.args[:1] if call_name == "addCommand" else node.args[1:] for arg in args: for child in strings(arg): if COMMAND_TOKEN.fullmatch(child.value): commands.append((child.value, child.lineno, surface, label)) return commands def cpp_gui_commands(path: Path) -> list[tuple[str, int, str, str | None]]: text = path.read_text(encoding="utf-8", errors="replace") # Preserve line numbers and ordinary string arguments, but ignore examples # inside comments and raw literals when locating executable registrations. tokens = re.compile( r'R"(?P[^\s()\\]{0,16})\(.*?\)(?P=delimiter)"' r'|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'' r'|//[^\n]*|/\*.*?\*/', re.DOTALL) text = tokens.sub(lambda match: re.sub(r'[^\n]', ' ', match[0]) if match[0].startswith(('//', '/*', 'R"')) else match[0], text) patterns = [ re.compile(r"(?:Gui::)?Command\s*\(\s*\"([A-Za-z][A-Za-z0-9_]+)\""), re.compile(r"addCommand\s*\(\s*\"([A-Za-z][A-Za-z0-9_]+)\""), ] commands: list[tuple[str, int, str, str | None]] = [] for pattern in patterns: for match in pattern.finditer(text): commands.append((match.group(1), text.count("\n", 0, match.start()) + 1, "registration", None)) return commands def source_gui_inventory(source_root: Path, module_path: Path) -> dict[str, object]: command_evidence: dict[str, dict[str, object]] = {} task_panels: list[str] = [] resources: list[str] = [] # Python workbenches register commands outside Gui/ as well. Only command # registrations and menu references are collected, never Python API symbols. gui_files = sorted(path for path in module_path.rglob("*") if path.is_file() and ("Gui" in path.parts or path.suffix == ".py") and not any(part.lower() in {"tests", "test", "__pycache__"} for part in path.relative_to(module_path).parts[:-1])) for path in gui_files: suffix = path.suffix.lower() if "task" in path.name.lower() and suffix in {".cpp", ".h", ".py", ".ui"}: task_panels.append(str(path.relative_to(source_root))) if suffix in {".qrc", ".ui", ".svg", ".png", ".xpm"}: resources.append(str(path.relative_to(source_root))) found = python_gui_commands(path) if suffix == ".py" else cpp_gui_commands(path) if suffix in {".cpp", ".cxx", ".cc", ".h", ".hpp"} else [] for command, line, surface, label in found: evidence = command_evidence.setdefault( command, {"path": str(path.relative_to(source_root)), "line": line, "uiSurfaces": []}, ) surface_entry = {"kind": surface, "label": label, "path": str(path.relative_to(source_root)), "line": line} if surface_entry not in evidence["uiSurfaces"]: evidence["uiSurfaces"].append(surface_entry) return { "commands": command_evidence, "taskPanelFiles": sorted(set(task_panels)), "resourceFiles": sorted(set(resources)), } def web_routes(registry_path: Path) -> dict[str, dict[str, object]]: routes: dict[str, dict[str, object]] = {} line_pattern = re.compile( r'\{ module: "([^"]+)", workers: \[(.*?)\], primaryWorker: (null|"[^"]+"), commands: \[(.*?)\].*guiDependency: (true|false),' ) quoted = re.compile(r'"([^"]+)"') for line_number, line in enumerate(registry_path.read_text(encoding="utf-8").splitlines(), 1): match = line_pattern.search(line) if not match: continue module, workers_text, primary_text, commands_text, gui_text = match.groups() routes[module] = { "workers": quoted.findall(workers_text), "primaryWorker": None if primary_text == "null" else primary_text.strip('"'), "commands": quoted.findall(commands_text), "guiDependency": gui_text == "true", "source": {"path": str(registry_path), "line": line_number}, } return routes def inventory(source_root: Path) -> dict[str, object]: mod_root = source_root / "src/Mod" parent = (mod_root / "CMakeLists.txt").read_text(encoding="utf-8", errors="replace") modules: list[dict[str, object]] = [] for path in sorted(p for p in mod_root.iterdir() if p.is_dir()): if path.name.startswith("."): continue cmake_path = path / "CMakeLists.txt" init_path = path / "Init.py" if not cmake_path.exists() and not init_path.exists(): continue app_path = path / "App" gui_path = path / "Gui" gui_inventory = source_gui_inventory(source_root, path) if path.name == "Web" and (source_root / "src/Gui").is_dir(): core = source_gui_inventory(source_root, source_root / "src/Gui") gui_inventory["commands"].update(core["commands"]) for key in ("taskPanelFiles", "resourceFiles"): gui_inventory[key] = sorted(set(gui_inventory[key]) | set(core[key])) modules.append( { "id": path.name, "source_path": str(path.relative_to(source_root)), "cmake_path": str(cmake_path.relative_to(source_root)) if cmake_path.exists() else None, "cmake_sha256": sha256(cmake_path) if cmake_path.exists() else None, "init_path": str(init_path.relative_to(source_root)) if init_path.exists() else None, "has_app": app_path.is_dir(), "has_gui": gui_path.is_dir(), "targets": targets(path), "build_conditions": cmake_conditions(parent, path.name), "gui_command_count": len(gui_inventory["commands"]), "task_panel_file_count": len(gui_inventory["taskPanelFiles"]), "resource_file_count": len(gui_inventory["resourceFiles"]), "task_panel_files": gui_inventory["taskPanelFiles"], "resource_files": gui_inventory["resourceFiles"], "gui_commands": gui_inventory["commands"], } ) return { "schema": "freecad-web-workbench-inventory/v1", "source_root": str(source_root), "source_revision": revision(source_root), "module_count": len(modules), "modules": modules, } def command_matrix(report: dict[str, object], registry_path: Path) -> dict[str, object]: routes = web_routes(registry_path) records: list[dict[str, object]] = [] module_summaries: list[dict[str, object]] = [] for module in report["modules"]: # type: ignore[index] module_id = str(module["id"]) route = routes.get(module_id, {}) gui_commands = module["gui_commands"] for command, source in sorted(gui_commands.items()): records.append( { "id": f"{module_id}:desktop:{command}", "module": module_id, "command": command, "kind": "desktop-gui", "sourcePath": source["path"], "sourceLine": source["line"], "uiSurfaces": source["uiSurfaces"], "route": None, "disposition": "restricted", "capabilityReason": "desktop Gui/Qt command requires a dedicated portable Web workflow and is not callable in the GUI-free WASM kernel", } ) for command in route.get("commands", []): worker = route.get("primaryWorker") records.append( { "id": f"{module_id}:web:{command}", "module": module_id, "command": command, "kind": "web-command", "sourcePath": "web/src/module-registry.generated.ts", "sourceLine": route["source"]["line"], "uiSurfaces": [{"kind": "web-workflow", "label": command, "path": "web/src/module-registry.generated.ts", "line": route["source"]["line"]}], "route": {"worker": worker, "command": command} if worker else None, "disposition": "native_handler" if worker else "not_implemented", "capabilityReason": "registered Worker route; execution remains owned by its native fc_web_dispatch_json facade" if worker else "module has no registered Worker route", } ) module_summaries.append( { "module": module_id, "hasGui": module["has_gui"], "desktopCommandCount": len(gui_commands), "webCommandCount": len(route.get("commands", [])), "taskPanelFileCount": module["task_panel_file_count"], "resourceFileCount": module["resource_file_count"], "taskPanelEntries": module["task_panel_files"], "resourceEntries": module["resource_files"], } ) disposition_counts = {key: sum(record["disposition"] == key for record in records) for key in ("native_handler", "restricted", "not_implemented")} return { "schema": "freecad-web-workbench-command-matrix/v1", "taskId": "FW-P6-FPA-G", "sourceRevision": report["source_revision"], "source": {"workbenchInventory": "docs/workbench-inventory.json", "webRegistry": "web/src/module-registry.generated.ts", "webRegistrySha256": sha256(registry_path)}, "moduleCount": len(module_summaries), "guiModuleCount": sum(bool(module["hasGui"]) for module in module_summaries), "recordCount": len(records), "dispositionCounts": disposition_counts, "modules": module_summaries, "records": records, "boundaries": [ "desktop GUI commands remain restricted until a dedicated Web workflow is backed by a native dispatcher route", "native_handler means a Worker route exists; the route-specific gate remains authoritative for command behavior", ], } def render(report: dict[str, object]) -> str: lines = [ "# FreeCAD Web Workbench Inventory", "", f"- Source revision: `{report['source_revision']}`", f"- Module count: `{report['module_count']}`", "", "| Module | App | Gui | Targets | Build condition |", "|---|---:|---:|---|---|", ] for item in report["modules"]: # type: ignore[index] lines.append( f"| `{item['id']}` | {'yes' if item['has_app'] else 'no'} | " f"{'yes' if item['has_gui'] else 'no'} | " f"{', '.join(f'`{target}`' for target in item['targets']) or '-'} | " f"{'; '.join(item['build_conditions'])} |" ) return "\n".join(lines) + "\n" def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--source-root", type=Path, required=True) parser.add_argument("--json-output", type=Path, required=True) parser.add_argument("--markdown-output", type=Path, required=True) parser.add_argument("--web-registry", type=Path) parser.add_argument("--command-json-output", type=Path) parser.add_argument("--public-command-json-output", type=Path) args = parser.parse_args() report = inventory(args.source_root.resolve()) args.json_output.parent.mkdir(parents=True, exist_ok=True) args.json_output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") args.markdown_output.write_text(render(report), encoding="utf-8") summary = {"schema": report["schema"], "module_count": report["module_count"]} if args.web_registry and args.command_json_output: matrix = command_matrix(report, args.web_registry.resolve()) for output in filter(None, (args.command_json_output, args.public_command_json_output)): output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(matrix, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") summary.update({"command_records": matrix["recordCount"], "gui_modules": matrix["guiModuleCount"], "dispositions": matrix["dispositionCounts"]}) print(json.dumps(summary)) return 0 if __name__ == "__main__": raise SystemExit(main())