1330 lines
89 KiB
Python
1330 lines
89 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate the implementation-first Web capability registry.
|
|
|
|
The registry counts user-callable Worker commands, not Python source symbols.
|
|
It is intentionally static analysis: runtime validation belongs to the
|
|
deferred validation phase or to a focused issue raised during actual use.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import argparse
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from collections import Counter
|
|
from datetime import datetime, timezone
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from generate_web_workbench_inventory import cpp_gui_commands
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
REGISTRY_SOURCE = ROOT / "web/src/module-registry.generated.ts"
|
|
WORKBENCH_SOURCE = ROOT / "web/src/workbench-shell.tsx"
|
|
ARTIFACT_MANIFEST = ROOT / "web/public/modules/manifest.json"
|
|
WORKBENCH_MATRIX = ROOT / "docs/p6/workbench-command-matrix.json"
|
|
OUTPUT = ROOT / "docs/current/feature-capability-registry.json"
|
|
ROUTE_OUTPUT = ROOT / "web/public/api/desktop-command-routes.json"
|
|
PROBE_ROOT = ROOT / "freecad-source/src/WebAssembly/FreeCADCoreProbe"
|
|
CACHE_PATH = ROOT / "docs/current/feature-capability-cache.json"
|
|
|
|
|
|
def file_stamps(paths: set[Path]) -> dict:
|
|
result = {}
|
|
for path in sorted(paths):
|
|
try:
|
|
info = path.stat()
|
|
result[str(path)] = [info.st_size, info.st_mtime_ns, info.st_ctime_ns]
|
|
except FileNotFoundError:
|
|
result[str(path)] = None
|
|
return result
|
|
|
|
|
|
def generation_inputs(command_matrix: dict) -> set[Path]:
|
|
paths = {Path(__file__).resolve(), ROOT / "tools/generate_web_workbench_inventory.py", REGISTRY_SOURCE, WORKBENCH_SOURCE, ARTIFACT_MANIFEST, WORKBENCH_MATRIX,
|
|
ROOT / "web/src/native-source-policy.ts", ROOT / "web/tools/audit-native-route-policy.mjs"}
|
|
paths.update(ROOT / "web/src" / name for name in ("dock-overlay-commands.json", "dock-overlay-adapter.ts", "dock-overlay-host.tsx", "dock-overlay.css", "desktop-command-dispatcher.ts"))
|
|
paths.update(ROOT / "web/src" / name for name in ("scene-display-commands.json", "scene-command-adapter.ts", "scene-command-host.ts", "scene-command-host.css", "scene-overlap-picker.ts", "scene-camera-commands.ts", "import-render-bridge.ts", "openscad-desktop-view.tsx"))
|
|
paths.update(ROOT / "web/src" / name for name in ("test-desktop-routes.json", "test-mdi-host.tsx", "test-desktop-output.tsx"))
|
|
paths.update(ROOT / "web/src" / name for name in ("document-view-routes.json", "web-view-task.tsx",
|
|
"web-owner-standard-adapter.ts", "web-standard-command-contract.ts", "web-standard-task.tsx",
|
|
"web-website-adapter.ts", "web-website-task.tsx", "web-group-contract.ts", "web-group-adapter.ts", "web-group-task.tsx",
|
|
"web-link-contract.ts", "web-source-test-adapter.ts", "bim-relationship-task.tsx",
|
|
"web-structure-adapter.ts", "web-structure-contract.ts", "web-structure-task.tsx", "web-link-mutation-task.tsx",
|
|
"bim-windows-task.tsx", "bim-windows-contract.ts", "web-open-adapter.ts", "web-open-contract.ts", "web-open-task.tsx"))
|
|
for pattern in ("*DesktopCommands.cpp", "*Probe.cpp", "*InventoryApi.cpp", "*.h", "*.hpp"):
|
|
paths.update(PROBE_ROOT.glob(pattern))
|
|
for pattern in ("*-facade.ts", "*-desktop-adapter.ts", "*.worker.ts"):
|
|
paths.update((ROOT / "web/src").glob(pattern))
|
|
paths.update(ROOT / "freecad-source" / row["sourcePath"] for row in command_matrix.get("records", []) if row.get("sourcePath"))
|
|
return paths
|
|
|
|
ROW_RE = re.compile(
|
|
r'\{ module: "([^"]+)", workers: \[(.*?)\], primaryWorker: (.*?), '
|
|
r'commands: \[(.*?)\], objectTypeCount: (\d+), pythonApiCount: (\d+), '
|
|
r'guiDependency: (true|false), formatExtensions: \[(.*?)\] \},'
|
|
)
|
|
PAYLOAD_RE = re.compile(r'^\s*"([^"]+:[^"]+)"\s*:', re.MULTILINE)
|
|
NATIVE_DESKTOP_COMMAND_RE = re.compile(r'FC_WEB_DESKTOP_COMMAND\("([^"]+)"\s*,')
|
|
IMPLEMENTED_DESKTOP_COMMAND_RE = re.compile(r'FC_WEB_IMPLEMENTED_DESKTOP_COMMAND\("([^"]+)"\)')
|
|
NATIVE_DESKTOP_LIST_RE = re.compile(r'(?:desktopCommands|family\s*==\s*QStringLiteral\("desktop-command"\).*?QStringList\s+\w+)\s*=\s*\{(.*?)\};', re.DOTALL)
|
|
QSTRING_RE = re.compile(r'QStringLiteral\("([^"]+)"\)')
|
|
# A prefix branch alone is not proof that every command has its own source
|
|
# semantics. Only explicitly audited family handlers may claim an entire
|
|
# workbench command family; unmarked branches stay implementation_required.
|
|
GENERIC_DESKTOP_FAMILY_RE = re.compile(r'FC_WEB_AUDITED_DESKTOP_FAMILY\("([^"]+)"\)')
|
|
|
|
|
|
def quoted_values(value: str) -> list[str]:
|
|
return re.findall(r'"([^"]+)"', value)
|
|
|
|
|
|
def canonical_probe_module(stem: str, known_modules: set[str]) -> str:
|
|
normalized_stem = re.sub(r"[^a-z0-9]", "", stem.lower())
|
|
candidates = [
|
|
module for module in known_modules
|
|
if normalized_stem.startswith(re.sub(r"[^a-z0-9]", "", module.lower()))
|
|
]
|
|
if candidates:
|
|
return max(candidates, key=lambda module: len(re.sub(r"[^a-z0-9]", "", module.lower())))
|
|
module = stem
|
|
for suffix in ("RuntimeProbe", "KernelProbe", "Probe"):
|
|
if module.endswith(suffix):
|
|
return module.removesuffix(suffix)
|
|
return module
|
|
|
|
|
|
def bim_external_url_commands(workbench_text: str) -> set[str]:
|
|
"""Discover the audited URL-only entries in BIM's executable script table.
|
|
|
|
Document/geometry entries still require their own implementation evidence.
|
|
The table is consumed by the real dispatcher; adding a name alone is not enough.
|
|
"""
|
|
registry = PROBE_ROOT / "BIMDesktopCommandRegistry.h"
|
|
scripts = PROBE_ROOT / "BIMDesktopCommands.h"
|
|
probe = PROBE_ROOT / "BimRuntimeProbe.cpp"
|
|
adapter = ROOT / "web/src/bim-desktop-adapter.ts"
|
|
if not all(path.is_file() for path in (registry, scripts, probe, adapter)):
|
|
return set()
|
|
registry_text = registry.read_text(encoding="utf-8")
|
|
probe_text = probe.read_text(encoding="utf-8")
|
|
if not all(token in registry_text for token in (
|
|
'#include "BIMDesktopCommands.h"', 'std::strcmp(name, command.name)', 'return &command;',
|
|
'return command ? command->route : BimDesktopCommandRoute::Unhandled;')):
|
|
return set()
|
|
if not all(token in probe_text for token in (
|
|
'#include "BIMDesktopCommandRegistry.h"', 'bimDesktopCommandImplementation(nameValue.c_str())',
|
|
'bimDesktopCommandRoute(nameValue.c_str()) == BimDesktopCommandRoute::ExternalUrl',
|
|
'std::string(implementation->script)', 'runGeometryValueScript(commandScript.c_str(), commandArgs)',
|
|
'if (executed)', 'familyResultJson', '\\"status\\":\\"exposed\\"')):
|
|
return set()
|
|
if 'bimExternalUrl(name, response)' not in workbench_text or '<a href={url}' not in workbench_text:
|
|
return set()
|
|
adapter_text = adapter.read_text(encoding="utf-8")
|
|
if 'export function bimExternalUrl' not in adapter_text or 'native.status !== "exposed"' not in adapter_text:
|
|
return set()
|
|
script_text = scripts.read_text(encoding="utf-8")
|
|
discovered = set()
|
|
for command, module in (("BIM_Help", "BimHelp"), ("BIM_Examples", "BimExamples")):
|
|
row = re.search(r'\{"' + command + r'",\s*BimDesktopCommandRoute::ExternalUrl,\s*(\w+)\s*\}', registry_text)
|
|
if not row:
|
|
continue
|
|
body = re.search(r'static\s+const\s+char\s*\*\s*' + re.escape(row[1]) + r'\s*=\s*R"PY\((.*?)\)PY";', script_text, re.DOTALL)
|
|
if not body:
|
|
continue
|
|
expected = f"from bimcommands.{module} import {command}\n_result = {{'command': '{command}', 'url': {command}().getUrl()}}"
|
|
try:
|
|
if ast.dump(ast.parse(body[1])) != ast.dump(ast.parse(expected)):
|
|
continue
|
|
source = ROOT / f"freecad-source/src/Mod/BIM/bimcommands/{module}.py"
|
|
tree = ast.parse(source.read_text(encoding="utf-8"))
|
|
cls = next(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == command)
|
|
method = next(node for node in cls.body if isinstance(node, ast.FunctionDef) and node.name == "getUrl")
|
|
if len(method.body) != 1 or not isinstance(method.body[0], ast.Return):
|
|
continue
|
|
destination = ast.literal_eval(method.body[0].value)
|
|
if not isinstance(destination, str) or not destination.startswith("https://"):
|
|
continue
|
|
except (OSError, SyntaxError, StopIteration, ValueError, TypeError):
|
|
continue
|
|
discovered.add(command)
|
|
return discovered
|
|
|
|
|
|
def implemented_desktop_commands(text: str) -> set[str]:
|
|
"""Require an explicit marker and a command-specific exposed branch."""
|
|
implemented: set[str] = set()
|
|
|
|
def exposed_body(body: str) -> bool:
|
|
"""Recognize source-authored exposed status in compact JSON emitters."""
|
|
return bool(
|
|
re.search(
|
|
r'(?:result|record|commandResult|out|response)\.insert\(\s*(?:QStringLiteral\(\s*)?"status"\s*\)?\s*,\s*'
|
|
r'(?:QStringLiteral\(\s*)?"exposed"\s*\)?\s*\)', body,
|
|
)
|
|
or re.search(r'\bstatus\s*=\s*(?:QStringLiteral\(\s*)?["\']exposed["\']\s*\)?', body)
|
|
or re.search(r'["\']status["\']\s*:\s*["\']exposed["\']', body)
|
|
# recordsJson emitters escape the JSON quotes in the C++ string.
|
|
or re.search(r'\\?"status\\?"\s*:\s*\\?"exposed\\?"', body)
|
|
)
|
|
|
|
def branch_body(condition: str) -> str | None:
|
|
match = re.search(rf'(?:else\s+)?if\s*\([^{{}};]*{condition}[^{{}};]*\)\s*\{{', text)
|
|
if not match:
|
|
return None
|
|
opening = text.find("{", match.start())
|
|
depth = 0
|
|
for index in range(opening, len(text)):
|
|
if text[index] == "{":
|
|
depth += 1
|
|
elif text[index] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return text[opening:index + 1]
|
|
return None
|
|
|
|
for command in IMPLEMENTED_DESKTOP_COMMAND_RE.findall(text):
|
|
condition = (
|
|
rf'(?:FC_WEB_DESKTOP_COMMAND\("{re.escape(command)}"\)|'
|
|
rf'(?:name|nameValue|command|commandName|desktopName)\s*==\s*'
|
|
rf'(?:QStringLiteral\("{re.escape(command)}"\)|"{re.escape(command)}")|'
|
|
rf'(?:apiId|apiIdValue)\s*==\s*'
|
|
rf'(?:QStringLiteral\("[A-Za-z0-9_]+:desktop:{re.escape(command)}"\)|'
|
|
rf'"[A-Za-z0-9_]+:desktop:{re.escape(command)}"))'
|
|
)
|
|
branches = re.finditer(rf'(?:else\s+)?if\s*\([^{{}};]*{condition}[^{{}};]*\)\s*\{{', text)
|
|
for branch in branches:
|
|
opening = text.find("{", branch.start())
|
|
depth = 0
|
|
closing = None
|
|
for index in range(opening, len(text)):
|
|
if text[index] == "{":
|
|
depth += 1
|
|
elif text[index] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
closing = index
|
|
break
|
|
if closing is None:
|
|
continue
|
|
body = text[opening:closing + 1]
|
|
explicit_status = exposed_body(body)
|
|
# Some native probes emit the structured JSON through an
|
|
# ostringstream rather than QJsonObject::insert. Accept that
|
|
# source-authored form only when the same command-specific branch
|
|
# explicitly assigns status=exposed; do not accept a shared tail
|
|
# or a generic marker alone.
|
|
explicit_status = explicit_status or re.search(
|
|
r'\bstatus\s*=\s*(?:QStringLiteral\(\s*)?["\']exposed["\']\s*\)?',
|
|
body,
|
|
) or re.search(r'["\']status["\']\s*:\s*["\']exposed["\']', body)
|
|
# Native adapters may centralize document creation, recompute and
|
|
# status reporting in a command-specific helper. A direct return
|
|
# from the marked branch is executable behavior; requiring a
|
|
# duplicated status insertion here incorrectly stalls the batch.
|
|
explicit_status = explicit_status or re.search(
|
|
r'\breturn\s+(?:executeObjectCommand|executeDocumentCommand|executeAnalysisCommand|finishDocumentCommand)\s*\(', body
|
|
)
|
|
if explicit_status:
|
|
implemented.add(command)
|
|
break
|
|
if command in implemented:
|
|
continue
|
|
# Compact native dispatchers often use a single-statement branch:
|
|
# `if (command == ...) return executeObjectCommand(...)`. It still
|
|
# executes the marked command-specific FreeCAD path and the helper
|
|
# owns the exposed status, so do not classify it as a stalled marker.
|
|
single_statement = re.search(
|
|
rf'if\s*\([^{{}};]*(?:name|nameValue|command|commandName|desktopName)\s*==\s*'
|
|
rf'(?:QStringLiteral\("{re.escape(command)}"\)|"{re.escape(command)}")[^{{}};]*\)\s*'
|
|
rf'return\s+(?:executeObjectCommand|executeDocumentCommand|executeAnalysisCommand|finishDocumentCommand)\s*\(',
|
|
text,
|
|
)
|
|
if single_statement:
|
|
implemented.add(command)
|
|
continue
|
|
# A native dispatcher may normalize the command into a boolean first
|
|
# (`const joints = apiId == "Module:desktop:Command"`) and dispatch
|
|
# through a shared branch. The marker plus the exact canonical
|
|
# comparison and an exposed status in that dispatcher is sufficient;
|
|
# it is still command-specific and cannot match a generic marker.
|
|
canonical = re.search(
|
|
rf'(?:apiId|apiIdValue)\s*==\s*(?:QStringLiteral\("[A-Za-z0-9_]+:desktop:{re.escape(command)}"\)|"[A-Za-z0-9_]+:desktop:{re.escape(command)}")',
|
|
text,
|
|
)
|
|
if canonical and exposed_body(text):
|
|
implemented.add(command)
|
|
continue
|
|
# BIM's native dispatcher normalizes a group of exact command names
|
|
# into a boolean alias (`const bool nudge = nameValue == ...`) and
|
|
# emits one verified exposed result from that bounded branch. Match
|
|
# the exact command comparison and the alias branch; a marker alone
|
|
# still cannot make an unrelated command routable.
|
|
name_condition = rf'(?:name|nameValue|command|commandName|desktopName)\s*==\s*(?:QStringLiteral\("{re.escape(command)}"\)|"{re.escape(command)}")'
|
|
aliases = re.findall(rf'\b(?:const\s+)?bool\s+(\w+)\s*=\s*[^;{{}}]*{name_condition}[^;{{}}]*;', text)
|
|
if any((body := branch_body(rf'\b{re.escape(alias)}\b')) and exposed_body(body) for alias in aliases):
|
|
implemented.add(command)
|
|
continue
|
|
# Draft snap modes share one bounded native branch keyed by
|
|
# `snapIndex >= 0`; each marked mode is still an exact command in the
|
|
# source's snap-name table and receives the branch's exposed state.
|
|
if command.startswith("Draft_Snap_"):
|
|
snap_branch = branch_body(r'\bsnapIndex\s*>=\s*0')
|
|
if snap_branch and exposed_body(snap_branch):
|
|
implemented.add(command)
|
|
return implemented
|
|
|
|
|
|
def category(command: str) -> str:
|
|
lowered = command.lower()
|
|
if "save" in lowered or "reopen" in lowered:
|
|
return "persistence"
|
|
if "import" in lowered or "export" in lowered:
|
|
return "format"
|
|
if "python" in lowered:
|
|
return "python"
|
|
if "inspect" in lowered or "inventory" in lowered:
|
|
return "inspect"
|
|
if "create" in lowered or "recompute" in lowered:
|
|
return "modeling"
|
|
return "service"
|
|
|
|
|
|
def describe_native_gaps(records: list[dict], rejected_markers: dict[str, set[str]]) -> dict[str, int]:
|
|
"""Provide bounded source evidence without promoting textual matches to routes."""
|
|
pending = [row for row in records if any(gap.get("stage") == "native" for gap in row.get("implementationGaps", []))]
|
|
commands = {str(row["command"]) for row in pending}
|
|
locations: dict[str, list[dict]] = {command: [] for command in commands}
|
|
# Exact quoted names include embedded Python dispatches and C++ tables.
|
|
# A match is only a navigation anchor, never proof that the command works.
|
|
literal = re.compile(r'''["']([A-Za-z][A-Za-z0-9_]*)["']''')
|
|
for path in sorted(PROBE_ROOT.iterdir()):
|
|
if not path.is_file() or path.suffix not in {".cpp", ".h", ".hpp"}:
|
|
continue
|
|
for number, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
|
|
for command in set(literal.findall(line)) & commands:
|
|
candidates = locations[command]
|
|
if len(candidates) < 6:
|
|
candidates.append({"path": str(path.relative_to(ROOT)), "line": number})
|
|
counts = Counter()
|
|
for row in pending:
|
|
candidates = locations[str(row["command"])]
|
|
rejected = row["command"] in rejected_markers.get(row["module"], set())
|
|
kind = "registration-marker-rejected" if rejected else "native-source-candidate-unregistered" if candidates else "native-entry-not-located-in-probe-directory"
|
|
counts[kind] += 1
|
|
for gap in row["implementationGaps"]:
|
|
if gap.get("stage") != "native":
|
|
continue
|
|
gap.update(classification=kind, businessRequirement="retained", sourceCandidates=candidates,
|
|
evidenceScope="Static registration discovery and exact source-name anchors only; not proof of implementation completeness or business necessity.",
|
|
repair=("Inspect the existing native branch/helper at sourceCandidates and connect its executable registration; implement missing behavior before registering."
|
|
if candidates else "Locate the native entry using the supplied desktop source and build ownership; absence here does not establish absent local source or unnecessary business."))
|
|
return dict(counts)
|
|
|
|
|
|
@lru_cache(maxsize=64)
|
|
def python_command_groups(path: Path) -> set[str]:
|
|
try:
|
|
tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
|
|
except (OSError, SyntaxError):
|
|
return set()
|
|
group_classes = {
|
|
node.name
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, ast.ClassDef)
|
|
and any(isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)) and member.name == "GetCommands" for member in node.body)
|
|
}
|
|
commands: set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if not isinstance(node, ast.Call) or len(node.args) < 2:
|
|
continue
|
|
function_name = node.func.attr if isinstance(node.func, ast.Attribute) else node.func.id if isinstance(node.func, ast.Name) else ""
|
|
if function_name != "addCommand" or not isinstance(node.args[0], ast.Constant) or not isinstance(node.args[0].value, str):
|
|
continue
|
|
factory = node.args[1]
|
|
if not isinstance(factory, ast.Call):
|
|
continue
|
|
class_name = factory.func.id if isinstance(factory.func, ast.Name) else factory.func.attr if isinstance(factory.func, ast.Attribute) else ""
|
|
if class_name in group_classes:
|
|
commands.add(node.args[0].value)
|
|
return commands
|
|
|
|
|
|
def is_ui_container(row: dict[str, object]) -> bool:
|
|
command = str(row.get("command") or "")
|
|
if command == "Separator":
|
|
return True
|
|
source_path = str(row.get("sourcePath") or "")
|
|
if source_path.endswith(".py"):
|
|
return command in python_command_groups(ROOT / "freecad-source" / source_path)
|
|
if not source_path.endswith("/Workbench.cpp"):
|
|
return False
|
|
path = ROOT / "freecad-source" / source_path
|
|
try:
|
|
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
except OSError:
|
|
return False
|
|
source_line = max(1, int(row.get("sourceLine") or 1))
|
|
search_start = max(0, source_line - 4)
|
|
search_end = min(len(lines), source_line + 3)
|
|
variable = ""
|
|
for line in lines[search_start:search_end]:
|
|
match = re.search(r'([A-Za-z_]\w*)->setCommand\("' + re.escape(command) + r'"\)', line)
|
|
if match:
|
|
variable = match.group(1)
|
|
break
|
|
if not variable:
|
|
return False
|
|
tail = "\n".join(lines[source_line:min(len(lines), source_line + 24)])
|
|
return bool(
|
|
re.search(rf"\*\s*{re.escape(variable)}\s*<<", tail)
|
|
or re.search(rf"\badd[A-Za-z0-9_]+\s*\(\s*\*\s*{re.escape(variable)}\s*\)", tail)
|
|
)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--force", action="store_true", help="regenerate even when all inputs and outputs match the cache")
|
|
options = parser.parse_args(argv)
|
|
command_matrix = json.loads(WORKBENCH_MATRIX.read_text(encoding="utf-8"))
|
|
inputs = file_stamps(generation_inputs(command_matrix))
|
|
try:
|
|
cached = json.loads(CACHE_PATH.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
cached = {}
|
|
if (not options.force and cached.get("schema") == "freecad-web-feature-capability-cache/v1"
|
|
and cached.get("inputs") == inputs and OUTPUT.is_file() and ROUTE_OUTPUT.is_file()
|
|
and cached.get("outputs") == file_stamps({OUTPUT, ROUTE_OUTPUT})):
|
|
print(json.dumps({"status": "reused", **cached.get("counts", {})}))
|
|
return 0
|
|
registry_text = REGISTRY_SOURCE.read_text(encoding="utf-8")
|
|
workbench_text = WORKBENCH_SOURCE.read_text(encoding="utf-8")
|
|
artifact_payload = json.loads(ARTIFACT_MANIFEST.read_text(encoding="utf-8"))
|
|
known_modules = {
|
|
str(row.get("module"))
|
|
for row in command_matrix.get("records", [])
|
|
if isinstance(row, dict) and row.get("module")
|
|
}
|
|
desktop_keys = {
|
|
(str(row.get("module")), str(row.get("command")))
|
|
for row in command_matrix.get("records", [])
|
|
if isinstance(row, dict) and row.get("kind") == "desktop-gui"
|
|
}
|
|
artifact_modules = artifact_payload.get("modules", {})
|
|
payloads = set(PAYLOAD_RE.findall(workbench_text))
|
|
generic_command_entry = 'id="payload-editor"' in workbench_text and "JSON.parse(payloadText)" in workbench_text
|
|
native_desktop_commands: dict[str, set[str]] = {}
|
|
native_desktop_sources: dict[str, str] = {}
|
|
native_desktop_families: set[str] = set()
|
|
rejected_desktop_markers: dict[str, set[str]] = {}
|
|
for path in sorted(PROBE_ROOT.glob("*DesktopCommands.cpp")):
|
|
module = path.stem.removesuffix("DesktopCommands")
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
marked_commands = set(IMPLEMENTED_DESKTOP_COMMAND_RE.findall(text))
|
|
implemented_commands = implemented_desktop_commands(text)
|
|
native_desktop_commands[module] = set(NATIVE_DESKTOP_COMMAND_RE.findall(text)) | implemented_commands
|
|
rejected = marked_commands - implemented_commands
|
|
if rejected:
|
|
rejected_desktop_markers.setdefault(module, set()).update(rejected)
|
|
native_desktop_sources[module] = str(path.relative_to(ROOT))
|
|
for path in sorted(PROBE_ROOT.glob("*Probe.cpp")):
|
|
module = canonical_probe_module(path.stem, known_modules)
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
for registered_module in GENERIC_DESKTOP_FAMILY_RE.findall(text):
|
|
native_desktop_families.add(registered_module)
|
|
native_desktop_sources.setdefault(registered_module, str(path.relative_to(ROOT)))
|
|
marked_commands = set(IMPLEMENTED_DESKTOP_COMMAND_RE.findall(text))
|
|
implemented_commands = implemented_desktop_commands(text)
|
|
rejected = marked_commands - implemented_commands
|
|
if rejected:
|
|
rejected_desktop_markers.setdefault(module, set()).update(rejected)
|
|
if implemented_commands:
|
|
native_desktop_commands.setdefault(module, set()).update(implemented_commands)
|
|
native_desktop_sources.setdefault(module, str(path.relative_to(ROOT)))
|
|
commands = {
|
|
command
|
|
for block in NATIVE_DESKTOP_LIST_RE.findall(text)
|
|
for command in QSTRING_RE.findall(block)
|
|
}
|
|
if commands:
|
|
native_desktop_commands.setdefault(module, set()).update(commands)
|
|
native_desktop_sources.setdefault(module, str(path.relative_to(ROOT)))
|
|
# Some targets keep the active dispatcher in a bounded *InventoryApi.cpp
|
|
# translation unit rather than in the conventional *Probe.cpp file. It
|
|
# is still native ownership: include only this probe-directory pattern so
|
|
# registry discovery stays scoped and does not become a repository scan.
|
|
for path in sorted(PROBE_ROOT.glob("*InventoryApi.cpp")):
|
|
module = canonical_probe_module(path.stem, known_modules)
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
marked_commands = set(IMPLEMENTED_DESKTOP_COMMAND_RE.findall(text))
|
|
implemented_commands = implemented_desktop_commands(text)
|
|
rejected = marked_commands - implemented_commands
|
|
if rejected:
|
|
rejected_desktop_markers.setdefault(module, set()).update(rejected)
|
|
if implemented_commands:
|
|
native_desktop_commands.setdefault(module, set()).update(implemented_commands)
|
|
native_desktop_sources.setdefault(module, str(path.relative_to(ROOT)))
|
|
bim_urls = bim_external_url_commands(workbench_text)
|
|
if bim_urls:
|
|
native_desktop_commands.setdefault("BIM", set()).update(bim_urls)
|
|
native_desktop_sources.setdefault("BIM", str((PROBE_ROOT / "BimRuntimeProbe.cpp").relative_to(ROOT)))
|
|
desktop_adapter_modules: set[str] = set()
|
|
direct_desktop_workers: dict[str, str] = {}
|
|
for module in set(native_desktop_commands) | native_desktop_families:
|
|
stem = re.sub(r"[^a-z0-9]", "", module.lower())
|
|
facade_text = "\n".join(
|
|
path.read_text(encoding="utf-8", errors="replace")
|
|
for path in sorted((ROOT / "web/src").glob(f"{stem}*-facade.ts"))
|
|
)
|
|
if "fc_web_dispatch_json" in facade_text and "pythonFamily" in facade_text:
|
|
desktop_adapter_modules.add(module)
|
|
for worker in sorted((ROOT / "web/src").glob(f"{stem}*.worker.ts")):
|
|
worker_text = worker.read_text(encoding="utf-8", errors="replace")
|
|
if "DesktopRequest" in worker_text and re.search(r"is[A-Za-z0-9]+DesktopRequest", worker_text):
|
|
direct_desktop_workers[module] = worker.name
|
|
break
|
|
records: list[dict[str, object]] = []
|
|
modules: list[dict[str, object]] = []
|
|
module_rows: dict[str, dict[str, object]] = {}
|
|
web_routes: dict[tuple[str, str], dict[str, object]] = {}
|
|
for match in ROW_RE.finditer(registry_text):
|
|
module, workers_text, primary_text, commands_text, objects, python_apis, gui, formats_text = match.groups()
|
|
workers = quoted_values(workers_text)
|
|
commands = quoted_values(commands_text)
|
|
primary_values = quoted_values(primary_text)
|
|
primary_worker = primary_values[0] if primary_values else None
|
|
artifact_ready = module in artifact_modules
|
|
interactive = 0
|
|
for command in commands:
|
|
capability_id = f"{module}:{command}"
|
|
payload_ready = capability_id in payloads
|
|
generic_api_entry = "pythonFamilyBatch" in command or "pythonMethod" in command
|
|
ui_status = "interactive" if payload_ready else "generic-api-entry" if generic_api_entry else "generic-command-entry" if generic_command_entry else "needs-parameter-ui"
|
|
interactive += int(ui_status != "needs-parameter-ui")
|
|
web_record = {
|
|
"id": capability_id,
|
|
"module": module,
|
|
"command": command,
|
|
"category": category(command),
|
|
"worker": primary_worker,
|
|
"workers": workers,
|
|
"artifactReady": artifact_ready,
|
|
"uiStatus": ui_status,
|
|
"implementationStatus": "routed" if workers and artifact_ready else "missing-runtime-route",
|
|
"validationStatus": "deferred",
|
|
"implementationGaps": ([{"stage": "worker", "reason": "no Worker is registered for this module"}] if not workers else [])
|
|
+ ([{"stage": "artifact", "reason": "module artifact is absent from the manifest"}] if not artifact_ready else []),
|
|
}
|
|
web_routes[(module, command)] = web_record
|
|
transport_only = command.endswith(".desktopCommand")
|
|
if (module, command) not in desktop_keys and not transport_only:
|
|
records.append(web_record)
|
|
module_row = {
|
|
"module": module,
|
|
"commands": len(commands),
|
|
"interactiveCommands": interactive,
|
|
"needsParameterUi": len(commands) - interactive,
|
|
"desktopCommands": 0,
|
|
"implementationRequired": 0,
|
|
"workers": workers,
|
|
"artifactReady": artifact_ready,
|
|
"objectTypeCount": int(objects),
|
|
"sourcePythonSymbolCount": int(python_apis),
|
|
"guiDependency": gui == "true",
|
|
"formatExtensions": quoted_values(formats_text),
|
|
}
|
|
modules.append(module_row)
|
|
module_rows[module] = module_row
|
|
desktop_rows = [
|
|
row for row in command_matrix.get("records", [])
|
|
if isinstance(row, dict) and row.get("kind") == "desktop-gui"
|
|
]
|
|
@lru_cache(maxsize=None)
|
|
def registered_lines(relative):
|
|
path = ROOT / "freecad-source" / relative
|
|
return {(name, line) for name, line, _, _ in cpp_gui_commands(path)} if path.is_file() else set()
|
|
|
|
# Keep every existing capability, but prefer a real constructor over a
|
|
# documentation example when multiple source anchors were inventoried.
|
|
for index, row in enumerate(desktop_rows):
|
|
source = str(row.get("sourcePath", ""))
|
|
if not source.endswith((".cpp", ".cxx", ".cc", ".h", ".hpp")):
|
|
continue
|
|
identity = (row.get("command"), row.get("sourceLine"))
|
|
if identity in registered_lines(source):
|
|
continue
|
|
for surface in row.get("uiSurfaces", []):
|
|
path = str(surface.get("path", ""))
|
|
if (surface.get("kind") == "registration" and path.endswith((".cpp", ".cxx", ".cc", ".h", ".hpp"))
|
|
and (row.get("command"), surface.get("line")) in registered_lines(path)):
|
|
desktop_rows[index] = {**row, "sourcePath": path, "sourceLine": surface["line"]}
|
|
break
|
|
|
|
def family_route(module: str) -> dict[str, object] | None:
|
|
return next(
|
|
(
|
|
route for (route_module, _), route in web_routes.items()
|
|
if route_module == module and (
|
|
str(route.get("command", "")).endswith(".pythonFamilyBatch")
|
|
or str(route.get("command", "")).endswith(".pythonFamily")
|
|
)
|
|
),
|
|
None,
|
|
)
|
|
|
|
def local_route(module: str, command: str) -> dict[str, object] | None:
|
|
if module == "Web" and command in {"Std_Test5", "Std_Test6", "Std_TestCmdFuncs"}:
|
|
route = web_routes.get(("Part", "part.viewCommand"))
|
|
adapter = ROOT / "web/src/web-source-test-adapter.ts"
|
|
native = PROBE_ROOT / "WebSourceTestCommands.h"
|
|
if route and adapter.is_file() and native.is_file() and command in native.read_text(encoding="utf-8") and "executeSourceTest" in adapter.read_text(encoding="utf-8"):
|
|
return {**route, "worker": "part.worker.ts", "executionModule": "Part", "transport": "direct-command", "canonicalId": f"Web:desktop:{command}", "nativeRegistration": str(native.relative_to(ROOT))}
|
|
if module == "Web" and command in {"Std_DownloadOnlineHelp", "Std_RandomColor", "Std_Test5", "Std_Test6", "Std_TestCmdFuncs", "Std_ViewUndock", "Std_ViewVR"}:
|
|
adapter = ROOT / "web/src/web-desktop-adapter.ts"
|
|
native = PROBE_ROOT / ("WebSourceTestCommands.h" if command.startswith("Std_Test") else "WebViewCommands.h")
|
|
route = web_routes.get(("Part", "part.viewCommand"))
|
|
if command == "Std_DownloadOnlineHelp": route = web_routes.get(("Web", "web.standardCommand")) or route
|
|
if route and adapter.is_file() and native.is_file():
|
|
at = adapter.read_text(encoding="utf-8"); nt = native.read_text(encoding="utf-8")
|
|
if ((command == "Std_DownloadOnlineHelp" and "downloadOnlineHelp(args)" in at)
|
|
or (command == "Std_RandomColor" and "Std_RandomColor" in at and "part.pythonFamilyBatch" in at)
|
|
or (command.startswith("Std_Test") and command in nt and "executeSourceTest" in at)
|
|
or (command in {"Std_ViewUndock", "Std_ViewVR"} and command in nt and "executeWebDesktopCommand" in at)):
|
|
return {**route, "worker": route.get("worker") or "part.worker.ts", "executionModule": route.get("module") or "Part", "transport": "direct-command", "canonicalId": f"Web:desktop:{command}", "nativeRegistration": str(native.relative_to(ROOT))}
|
|
if module == "Web" and command in {"Std_MacroStepInto", "Std_MacroStepOver", "Std_MacroStopDebug", "Std_OpenMacrosFolder", "Std_ToggleBreakpoint"}:
|
|
macro = PROBE_ROOT / "WebMacroCommands.h"
|
|
adapter = ROOT / "web/src/web-desktop-adapter.ts"
|
|
facade = ROOT / "web/src/part-python-family-facade.ts"
|
|
worker = ROOT / "web/src/part.worker.ts"
|
|
route = web_routes.get(("Part", "part.pythonFamilyBatch"))
|
|
if route and all(path.is_file() for path in (macro, adapter, facade, worker)):
|
|
mt = macro.read_text(encoding="utf-8")
|
|
if (command in mt and command in adapter.read_text(encoding="utf-8")
|
|
and "dispatchPartPythonFamily" in worker.read_text(encoding="utf-8")
|
|
and "PART_PYTHON_FAMILY_COMMAND" in facade.read_text(encoding="utf-8")):
|
|
return {**route, "worker": "part.worker.ts", "executionModule": "Part", "transport": "family-batch", "canonicalId": f"Web:desktop:{command}", "nativeRegistration": str(macro.relative_to(ROOT))}
|
|
if module == "Web" and command in native_desktop_commands.get(module, set()):
|
|
catalog = ROOT / "web/src/web-macro-routes.json"
|
|
if catalog.is_file():
|
|
endpoint = json.loads(catalog.read_text(encoding="utf-8")).get(command)
|
|
route = web_routes.get(("Part", endpoint)) if isinstance(endpoint, str) else None
|
|
files = {
|
|
"adapter": ROOT / "web/src/web-desktop-adapter.ts",
|
|
"facade": ROOT / "web/src/part-python-family-facade.ts",
|
|
"worker": ROOT / "web/src/part.worker.ts",
|
|
"dispatcher": ROOT / "web/src/desktop-command-dispatcher.ts",
|
|
"native": PROBE_ROOT / "PartBoxProbe.cpp",
|
|
"macro": PROBE_ROOT / "WebMacroCommands.h",
|
|
"recording": PROBE_ROOT / "WebMacroRecording.h",
|
|
}
|
|
if route and all(path.is_file() for path in files.values()):
|
|
texts = {key: path.read_text(encoding="utf-8") for key, path in files.items()}
|
|
# Macro requests use the live Part owner, not the standalone
|
|
# Web runtime. Resolve the same endpoint used by the adapter.
|
|
required = {
|
|
"adapter": ('./web-macro-routes.json', 'macroRoutes[command as keyof typeof macroRoutes]',
|
|
'router.request("Part", endpoint', 'desktop-command-commandmacro',
|
|
'documentId, storedFiles', 'freecad-macro-result'),
|
|
"facade": (f'PART_PYTHON_FAMILY_COMMAND = "{endpoint}"',
|
|
'fc_web_dispatch_json', 'payload.records'),
|
|
"worker": ('FreeCADWebPartBoxProbe.js', 'isPartPythonFamilyRequest(message)',
|
|
'record.family === "desktop-command"',
|
|
'dispatchPartPythonFamily(queue, message, source)'),
|
|
"dispatcher": ('executeWebDesktop(this.router, record.command, args, documentId, applySelection)',),
|
|
"native": ('desktop-command-commandmacro', f'QStringLiteral("{command}")',
|
|
'executeWebMacroCommand(request.constData(), nativeOutput)'),
|
|
"macro": (f'"{command}"', 'exec(compile(source, path,', 'debugpy.connect((host, port))',
|
|
'recorder.recording', 'App.setActiveDocument(document)'),
|
|
"recording": ('webMacroDispatch(request)', 'PyList_Append(lines, text)'),
|
|
}
|
|
if (all(all(token in texts[key] for token in tokens) for key, tokens in required.items())
|
|
and command in workbench_text):
|
|
return {**route, "worker": "part.worker.ts", "executionModule": "Part",
|
|
"transport": "family-batch", "canonicalId": f"Web:desktop:{command}",
|
|
"nativeRegistration": str(files["native"].relative_to(ROOT))}
|
|
if module == "Test" and command in native_desktop_commands.get(module, set()):
|
|
catalog = ROOT / "web/src/test-desktop-routes.json"
|
|
adapter = ROOT / "web/src/test-desktop-adapter.ts"
|
|
facade = ROOT / "web/src/test-facade.ts"
|
|
worker = ROOT / "web/src/test.worker.ts"
|
|
dispatcher = ROOT / "web/src/desktop-command-dispatcher.ts"
|
|
if all(path.is_file() for path in (catalog, adapter, facade, worker, dispatcher)):
|
|
endpoint = json.loads(catalog.read_text(encoding="utf-8")).get(command)
|
|
route = web_routes.get((module, endpoint))
|
|
mdi_host = ROOT / "web/src/test-mdi-host.tsx"
|
|
mdi_connected = not command.startswith("Std_MDITest") or (mdi_host.is_file() and
|
|
"executeTestDesktopWithHost" in workbench_text and "<TestMdiViewHost" in workbench_text
|
|
and "registerTestMdiHost" in mdi_host.read_text(encoding="utf-8"))
|
|
if route and route.get("worker") == "test.worker.ts" and mdi_connected \
|
|
and "test-desktop-routes.json" in adapter.read_text(encoding="utf-8") \
|
|
and all(token in worker.read_text(encoding="utf-8") for token in ("isTestRequest", "dispatchTest")) \
|
|
and all(token in facade.read_text(encoding="utf-8") for token in ("test.inspectRegistry", "desktop-command", "fc_web_test_desktop")) \
|
|
and all(token in dispatcher.read_text(encoding="utf-8") for token in ('testDesktopRoutes[record.command', 'this.router.request("Test", endpoint')):
|
|
return {**route, "worker": "test.worker.ts", "executionModule": "Test", "transport": "direct-command",
|
|
"nativeRegistration": native_desktop_sources.get("Test")}
|
|
if module == "BIM" and command in {
|
|
"BIM_Glue", "BIM_IfcElements", "BIM_IfcExplorer", "BIM_IfcProperties",
|
|
"BIM_TDView", "BIM_Text", "BIM_TogglePanels", "BIM_Trash", "BIM_Report",
|
|
"BIM_ResetCloneColors", "BIM_Setup", "BIM_TDPage", "BIM_Tutorial", "BIM_Unclone",
|
|
"BIM_Ungroup", "BIM_Views", "BIM_Welcome", "BIM_Windows", "IFC_UpdateIOS",
|
|
}:
|
|
registry = PROBE_ROOT / "BIMDesktopCommandRegistry.h"
|
|
native = PROBE_ROOT / "BIMDesktopCommands.h"
|
|
runtime = PROBE_ROOT / "BimRuntimeProbe.cpp"
|
|
adapter = ROOT / "web/src/bim-desktop-adapter.ts"
|
|
worker = ROOT / "web/src/bim.worker.ts"
|
|
shell = ROOT / "web/src/workbench-shell.tsx"
|
|
if all(path.is_file() for path in (registry, native, runtime, adapter, worker, shell)):
|
|
registry_text = registry.read_text(encoding="utf-8")
|
|
native_text = native.read_text(encoding="utf-8")
|
|
runtime_text = runtime.read_text(encoding="utf-8")
|
|
adapter_text = adapter.read_text(encoding="utf-8")
|
|
shell_text = shell.read_text(encoding="utf-8")
|
|
if command == "BIM_Windows":
|
|
task = ROOT / "web/src/bim-windows-task.tsx"
|
|
if (not task.is_file() or '<BimWindowsTask' not in shell_text
|
|
or 'validBimWindowsResult' not in adapter_text
|
|
or not all(token in task.read_text(encoding="utf-8") for token in (
|
|
'selection, changes', 'freecad-bim-focus', 'value.windows'))
|
|
or 'setattr(obj, prop, value)' not in native_text):
|
|
return None
|
|
if command in {"BIM_Ungroup", "BIM_Unclone"}:
|
|
task = ROOT / "web/src/bim-relationship-task.tsx"
|
|
helpers = re.search(r'selected_document_helper = _panel_ifc and _command in \((.*?)\)', runtime_text, re.DOTALL)
|
|
if (not task.is_file() or command not in task.read_text(encoding="utf-8")
|
|
or '<BimRelationshipTask' not in shell_text or not helpers
|
|
or command not in ast.literal_eval('(' + helpers[1] + ')')):
|
|
return None
|
|
entry = re.search(r'\{"' + re.escape(command) + r'",\s*BimDesktopCommandRoute::(?:Document|ExternalUrl),\s*(\w+)(?:\.c_str\(\))?\s*\}', registry_text)
|
|
facade = ROOT / "web/src/bim-python-family-batch-facade.ts"
|
|
route = web_routes.get(("BIM", "bim.pythonFamilyBatch"))
|
|
shell_connected = command in shell_text
|
|
if command == "IFC_UpdateIOS":
|
|
# The updater uses the mounted shell's generic BIM dispatch.
|
|
# It has no command-specific task component in that shell.
|
|
shell_connected = all(token in shell_text for token in (
|
|
'prepareBimDesktopArgs(desktopRecord, desktopArgs, bimNudge)',
|
|
'desktopCommands.execute(desktopRecord, { ...args,',
|
|
'...(["Material", "BIM"].includes(desktopRecord.module) ? preparedDesktopArgs : {})',
|
|
))
|
|
if (route and entry and entry[1] in native_text and command in adapter_text
|
|
and shell_connected and facade.is_file()
|
|
and 'bimDesktopCommandImplementation(nameValue.c_str())->script' in runtime_text
|
|
and 'dispatchBimPythonFamilyBatch(queue, message, kernel)' in worker.read_text(encoding="utf-8")
|
|
and 'fc_web_dispatch_json' in facade.read_text(encoding="utf-8")):
|
|
return {**route, "worker": "bim.worker.ts", "executionModule": "BIM",
|
|
"transport": "family-batch", "canonicalId": f"BIM:desktop:{command}",
|
|
"nativeRegistration": str(registry.relative_to(ROOT))}
|
|
if module in {"Web", "Std"}:
|
|
if command in {"Std_Test1", "Std_Test2", "Std_Test3", "Std_Test4"}:
|
|
catalog = ROOT / "web/src/document-view-routes.json"
|
|
files = {
|
|
"desktop": ROOT / "web/src/web-desktop-adapter.ts",
|
|
"adapter": ROOT / "web/src/web-owner-standard-adapter.ts",
|
|
"contract": ROOT / "web/src/web-standard-command-contract.ts",
|
|
"facade": ROOT / "web/src/part-view-facade.ts",
|
|
"worker": ROOT / "web/src/part.worker.ts",
|
|
"bridge": PROBE_ROOT / "NativeDocumentView.h",
|
|
"native": PROBE_ROOT / "WebSourceTestCommands.h",
|
|
}
|
|
if catalog.is_file() and all(path.is_file() for path in files.values()):
|
|
routes = json.loads(catalog.read_text(encoding="utf-8"))
|
|
owner = routes.get("owners", {}).get("PartBox", {})
|
|
route = web_routes.get(("Part", owner.get("endpoint")))
|
|
texts = {key: path.read_text(encoding="utf-8") for key, path in files.items()}
|
|
required = {
|
|
"desktop": (f'"{command}"', 'executeOwnerStandard(router, command, args, documentId)'),
|
|
"adapter": ('routes.standardCommands.includes(command)', 'router.request("Part", viewEndpoint(owner)'),
|
|
"contract": (f'"{command}"', 'result.changed === false'),
|
|
"facade": ('isStandardCommand(p.command)', 'validStandardResult(reply.result, p.command, p.document)', 'fc_web_dispatch_json'),
|
|
"worker": ('dispatchDocumentView(queue, message, kernel)', '/kernel/FreeCADWebPartBoxProbe.js'),
|
|
"bridge": (f'"{command}"', 'executeWebSourceTestCommand(input.constData(), output)', 'completed.value("result").toObject()'),
|
|
"native": (f'"{command}"', 'App::GetApplication().getDocument', '"document_not_found"'),
|
|
}
|
|
if (route and command in routes.get("standardCommands", [])
|
|
and all(token in texts[key] for key, tokens in required.items() for token in tokens)):
|
|
return {**route, "worker": "part.worker.ts", "executionModule": "Part",
|
|
"transport": "direct-command", "canonicalId": f"Web:desktop:{command}",
|
|
"nativeRegistration": str(files["bridge"].relative_to(ROOT))}
|
|
if command in {f"Std_Test{i}" for i in range(1, 7)}:
|
|
source = ROOT / "freecad-source/src/Gui/CommandTest.cpp"
|
|
adapter = ROOT / "web/src/web-source-test-adapter.ts"
|
|
desktop = ROOT / "web/src/web-desktop-adapter.ts"
|
|
route = web_routes.get(("Part", "part.viewCommand"))
|
|
if route and all(path.is_file() for path in (source, adapter, desktop)):
|
|
number = command.removeprefix("Std_Test")
|
|
body = re.search(r'void FCCmdTest' + number + r'::activated\(int iMsg\)\s*\{(.*?)\n\}', source.read_text(encoding="utf-8"), re.DOTALL)
|
|
expected = "Q_UNUSED(iMsg);" if number in {"1", "2"} else "Q_UNUSED(iMsg); App::Document* pcDoc = getDocument(); if (!pcDoc) { return; }"
|
|
adapter_text = adapter.read_text(encoding="utf-8")
|
|
if (body and re.sub(r'\s+', '', body[1]) == re.sub(r'\s+', '', expected)
|
|
and f'"{command}"' in adapter_text and 'operation: "source-no-op"' in adapter_text
|
|
and 'router.request("Part", viewEndpoint(owner)' in adapter_text
|
|
and 'executeSourceTest(router, command, args, documentId)' in desktop.read_text(encoding="utf-8")):
|
|
return {**route, "worker": "part.worker.ts", "executionModule": "Part",
|
|
"transport": "direct-command", "canonicalId": f"Web:desktop:{command}",
|
|
"nativeRegistration": str((PROBE_ROOT / "NativeDocumentView.h").relative_to(ROOT))}
|
|
catalog = ROOT / "web/src/document-view-routes.json"
|
|
if catalog.is_file():
|
|
routes = json.loads(catalog.read_text(encoding="utf-8"))
|
|
if command == "Std_DescriptionMode":
|
|
owner = routes["owners"]["PartBox"]
|
|
route = web_routes.get(("Part", owner["endpoint"]))
|
|
adapter = ROOT / "web/src/web-desktop-adapter.ts"
|
|
native = PROBE_ROOT / "WebViewCommands.h"
|
|
if route and native.is_file() and adapter.is_file() and "'Std_DescriptionMode'" in native.read_text(encoding="utf-8") and 'executeOwnerStandard(router, command, args, documentId)' in adapter.read_text(encoding="utf-8"):
|
|
return {**route, "worker": owner["worker"], "executionModule": "Part", "transport": "direct-command", "canonicalId": f"Web:desktop:{command}", "nativeRegistration": str(native.relative_to(ROOT))}
|
|
if command in {"Std_WhatsThis", "Std_Workbench"} and command in routes.get("standardCommands", []):
|
|
owner = routes.get("owners", {}).get("PartBox", {})
|
|
route = web_routes.get(("Part", owner.get("endpoint")))
|
|
files = {
|
|
"desktop": ROOT / "web/src/web-desktop-adapter.ts",
|
|
"adapter": ROOT / "web/src/web-owner-standard-adapter.ts",
|
|
"contract": ROOT / "web/src/web-standard-command-contract.ts",
|
|
"facade": ROOT / "web/src/part-view-facade.ts",
|
|
"worker": ROOT / "web/src/part.worker.ts",
|
|
"dispatcher": ROOT / "web/src/desktop-command-dispatcher.ts",
|
|
"bridge": PROBE_ROOT / "NativeDocumentView.h",
|
|
"native": PROBE_ROOT / "WebViewCommands.h",
|
|
}
|
|
if route and all(path.is_file() for path in files.values()):
|
|
texts = {key: path.read_text(encoding="utf-8") for key, path in files.items()}
|
|
required = {
|
|
"desktop": (f'"{command}"', 'executeOwnerStandard(router, command, args, documentId)'),
|
|
"adapter": ('routes.standardCommands.includes(command)', 'router.request("Part", viewEndpoint(owner)',
|
|
'schema: "freecad-web/document-view/v1", owner, document: documentId, command',
|
|
'validStandardResult(response.result, command, documentId)'),
|
|
"contract": (f'"{command}"',),
|
|
"facade": ('isStandardCommand(p.command)', 'validStandardResult(reply.result, p.command, p.document)',
|
|
'fc_web_dispatch_json'),
|
|
"worker": ('dispatchDocumentView(queue, message, kernel)', '/kernel/FreeCADWebPartBoxProbe.js'),
|
|
"dispatcher": ('executeWebDesktop(this.router, record.command',),
|
|
"bridge": (f'"{command}"', 'executeDocumentCommand(input.constData(), output)'),
|
|
"native": (f"'{command}'", 'elif name in gui_standard_names:', 'if not App.GuiUp:',
|
|
'native_command.isActive()'),
|
|
}
|
|
behavior = ('Gui.runCommand(name, mode if name == \'Std_UserEditMode\' else 0)',)
|
|
mounted = True
|
|
if command == "Std_Workbench":
|
|
behavior = ('list(Gui.listWorkbenches())', "args.get('operation') == 'list'",
|
|
'Gui.activateWorkbench(workbench)', 'if active_workbench != workbench:')
|
|
mounted = (all(token in texts["desktop"] for token in (
|
|
'operation: "list"', 'freecad-native-workbench-picker',
|
|
'workbench: selected, operation: "activate"'))
|
|
and all(token in workbench_text for token in (
|
|
'window.addEventListener("freecad-native-workbench-picker", receive)',
|
|
'detail.select(select.value)', 'detail.select(undefined)')))
|
|
if (mounted and all(token in texts["native"] for token in behavior)
|
|
and all(token in texts[key] for key, tokens in required.items() for token in tokens)):
|
|
return {**route, "worker": owner["worker"], "executionModule": "Part",
|
|
"transport": "direct-command", "canonicalId": f"Web:desktop:{command}",
|
|
"nativeRegistration": str(files["bridge"].relative_to(ROOT))}
|
|
if command in routes.get("commandFeatCommands", []):
|
|
route = web_routes.get(("Part", "part.pythonFamilyBatch"))
|
|
native = PROBE_ROOT / "WebMacroCommands.h"
|
|
bridge = PROBE_ROOT / "PartBoxProbe.cpp"
|
|
adapter = ROOT / "web/src/web-desktop-adapter.ts"
|
|
behaviors = {
|
|
"Std_Recompute": ("elif record['name'] == 'Std_Recompute':", "# CommandFeat.cpp intentionally leaves this command's activation empty.\n pass"),
|
|
"Std_SendToPythonConsole": ("elif record['name'] == 'Std_SendToPythonConsole':", "len(grouped) != 1", "namespace['doc'] = doc", "namespace['obj'] = obj", "namespace['shp'] = getattr(obj, property_name)", "obj.getSubObject"),
|
|
}
|
|
if route and command in behaviors and all(path.is_file() for path in (native, bridge, adapter)):
|
|
native_text = native.read_text(encoding="utf-8")
|
|
bridge_text = bridge.read_text(encoding="utf-8")
|
|
adapter_text = adapter.read_text(encoding="utf-8")
|
|
if (all(token in native_text for token in behaviors[command])
|
|
and f'QStringLiteral("{command}")' in bridge_text
|
|
and 'family == QStringLiteral("desktop-command-commandfeat")' in bridge_text
|
|
and 'executeWebMacroCommand(request.constData(), nativeOutput)' in bridge_text
|
|
and all(token in adapter_text for token in (
|
|
f'"{command}"', 'router.request("Part", "part.pythonFamilyBatch"',
|
|
'"desktop-command-commandfeat"', 'args: { ...args, documentId }'))
|
|
and (command != "Std_SendToPythonConsole" or
|
|
'executeWebDesktop(router, "Std_CommandLine", { runtimeOwner: "PartBox" }, documentId)' in adapter_text)):
|
|
return {**route, "executionModule": "Part", "transport": "direct-command",
|
|
"canonicalId": f"Web:desktop:{command}",
|
|
"nativeRegistration": str(native.relative_to(ROOT))}
|
|
if command in routes.get("standardCommands", []) and command in {
|
|
"Std_RestartInSafeMode", "Std_TextDocument", "Std_UnitsCalculator", "Std_UserEditMode"
|
|
}:
|
|
owner = routes["owners"]["PartBox"]
|
|
route = web_routes.get(("Part", owner["endpoint"]))
|
|
paths = {name: ROOT / "web/src" / name for name in (
|
|
"web-desktop-adapter.ts", "web-standard-command-contract.ts", "web-standard-task.tsx",
|
|
"desktop-command-dispatcher.ts", "part-view-facade.ts", "part.worker.ts", "part-save.worker.ts")}
|
|
bridge = PROBE_ROOT / "NativeDocumentView.h"
|
|
native = PROBE_ROOT / "WebViewCommands.h"
|
|
if route and all(path.is_file() for path in [*paths.values(), bridge, native]):
|
|
texts = {name: path.read_text(encoding="utf-8") for name, path in paths.items()}
|
|
native_text = native.read_text(encoding="utf-8")
|
|
# These commands share the document owner's standard-command
|
|
# transport. GUI activation failures remain native failures.
|
|
behavior = (("elif name == 'Std_TextDocument':", "App::TextDocument")
|
|
if command == "Std_TextDocument" else (
|
|
"elif name in gui_standard_names:", "if not App.GuiUp:",
|
|
"native_command.isActive()",
|
|
"Gui.runCommand(name, mode if name == 'Std_UserEditMode' else 0)"))
|
|
if (all(token in native_text for token in behavior)
|
|
and f"'{command}'" in native_text
|
|
and f'"{command}"' in bridge.read_text(encoding="utf-8")
|
|
and "executeDocumentCommand(input.constData(), output)" in bridge.read_text(encoding="utf-8")
|
|
and all(token in texts["web-desktop-adapter.ts"] for token in (
|
|
"recomputeCommand || isStandardCommand(command)",
|
|
'router.request("Part", viewEndpoint(owner)',
|
|
'schema: "freecad-web/document-view/v1", owner, document: documentId, command',
|
|
"validStandardResult(response.result, command, documentId)",
|
|
"freecad-web/document-view-batch/v1"))
|
|
and "executeWebDesktop(this.router, record.command" in texts["desktop-command-dispatcher.ts"]
|
|
and all("dispatchDocumentView" in texts[name] for name in ("part.worker.ts", "part-save.worker.ts"))
|
|
and "validStandardResult" in texts["part-view-facade.ts"]
|
|
and f'"{command}"' in texts["web-standard-command-contract.ts"]
|
|
and f'command === "{command}"' in texts["web-standard-task.tsx"]
|
|
and "<WebStandardTask" in workbench_text):
|
|
return {**route, "worker": owner["worker"], "executionModule": "Part", "transport": "direct-command",
|
|
"canonicalId": f"Web:desktop:{command}", "nativeRegistration": str(native.relative_to(ROOT))}
|
|
if command in routes.get("standardCommands", []) and command in {
|
|
"Std_CommandLine", "Std_RecentFiles", "Std_RecentMacros", "Std_ReloadStyleSheet"
|
|
}:
|
|
owner = routes["owners"]["PartBox"]
|
|
route = web_routes.get(("Part", owner["endpoint"]))
|
|
paths = {name: ROOT / "web/src" / name for name in (
|
|
"web-recent-standard-adapter.ts", "web-standard-task.tsx",
|
|
"web-desktop-adapter.ts", "web-standard-command-contract.ts",
|
|
"part-view-facade.ts", "part.worker.ts", "part-save.worker.ts")}
|
|
bridge = PROBE_ROOT / "NativeDocumentView.h"
|
|
native = PROBE_ROOT / "WebViewCommands.h"
|
|
if route and all(path.is_file() for path in [*paths.values(), bridge, native]):
|
|
texts = {name: path.read_text(encoding="utf-8") for name, path in paths.items()}
|
|
native_text = native.read_text(encoding="utf-8")
|
|
bridge_text = bridge.read_text(encoding="utf-8")
|
|
behavior = {
|
|
"Std_CommandLine": ("console_code.InteractiveConsole(namespace)", "console.push(line)"),
|
|
"Std_RecentFiles": ("execute_recent_item(args, document_id, 'Std_RecentFiles')", "App.openDocument(path)"),
|
|
"Std_RecentMacros": ("execute_recent_item(args, document_id, 'Std_RecentMacros')", "exec(compile(stream.read(), path, 'exec'), namespace, namespace)"),
|
|
"Std_ReloadStyleSheet": ("with open(path, encoding='utf-8') as stream:", "content = stream.read()"),
|
|
}[command]
|
|
if (f"def execute_{command}(args, document_id):" in native_text
|
|
and f"'{command}': execute_{command}" in native_text
|
|
and all(token in native_text for token in behavior)
|
|
and "value = native_standard_handlers[name](args, document_id)" in native_text
|
|
and f'"{command}"' in bridge_text and "executeDocumentCommand(input.constData(), output)" in bridge_text
|
|
and "return executeRecentStandard(router, command, args, documentId, applySelection)" in texts["web-desktop-adapter.ts"]
|
|
and all(token in texts["web-recent-standard-adapter.ts"] for token in (
|
|
'router.request("Part", viewEndpoint(owner)', 'freecad-web/document-view-batch/v1',
|
|
'readDocumentFile(filename)', 'parameters.source = macro.source',
|
|
'style.textContent = value.styleSheet'))
|
|
and all("dispatchDocumentView" in texts[name] for name in ("part.worker.ts", "part-save.worker.ts"))
|
|
and "validStandardResult" in texts["part-view-facade.ts"]
|
|
and f'"{command}"' in texts["web-standard-command-contract.ts"]
|
|
and f'command === "{command}"' in texts["web-standard-task.tsx"]
|
|
and "<WebStandardTask" in workbench_text):
|
|
return {**route, "worker": owner["worker"], "executionModule": "Part", "transport": "direct-command",
|
|
"canonicalId": f"Web:desktop:{command}", "nativeRegistration": str(native.relative_to(ROOT))}
|
|
if command in routes.get("websiteCommands", []) and command in {
|
|
"Std_DevHandbook", "Std_FreeCADDonation", "Std_FreeCADForum", "Std_FreeCADUserHub",
|
|
"Std_FreeCADWebsite", "Std_OnlineHelp", "Std_OnlineHelpWebsite", "Std_ReportBug"}:
|
|
owner = routes["owners"]["PartBox"]
|
|
route = web_routes.get(("Part", owner["endpoint"]))
|
|
paths = {name: ROOT / "web/src" / name for name in (
|
|
"web-website-adapter.ts", "web-website-task.tsx", "web-desktop-adapter.ts",
|
|
"part-view-facade.ts", "part.worker.ts", "part-save.worker.ts")}
|
|
bridge = PROBE_ROOT / "NativeDocumentView.h"
|
|
native = PROBE_ROOT / "WebViewCommands.h"
|
|
if route and all(path.is_file() for path in [*paths.values(), bridge, native]):
|
|
texts = {name: path.read_text(encoding="utf-8") for name, path in paths.items()}
|
|
if (f'"{command}"' in bridge.read_text(encoding="utf-8")
|
|
and f"'{command}'" in native.read_text(encoding="utf-8")
|
|
and 'const website = executeWebsite(router, command, args, documentId)' in texts["web-desktop-adapter.ts"]
|
|
and 'if (website) return website;' in texts["web-desktop-adapter.ts"]
|
|
and all(token in texts["web-website-adapter.ts"] for token in (
|
|
'routes.websiteCommands', 'router.request("Part", viewEndpoint(owner)',
|
|
'freecad-web/document-view-batch/v1'))
|
|
and all('dispatchDocumentView' in texts[name] for name in ("part.worker.ts", "part-save.worker.ts"))
|
|
and 'validStdWorkflow' in texts["part-view-facade.ts"]
|
|
and 'websiteResult(response, command, documentId, owner)' in texts["web-website-task.tsx"]
|
|
and '<a href={url}' in texts["web-website-task.tsx"] and '<WebWebsiteTask' in workbench_text):
|
|
return {**route, "worker": owner["worker"], "executionModule": "Part", "transport": "direct-command",
|
|
"canonicalId": f"Web:desktop:{command}", "nativeRegistration": str(bridge.relative_to(ROOT))}
|
|
if command in {"Std_LinkActions", "Std_LinkImport", "Std_LinkImportAll", "Std_LinkMake", "Std_LinkMakeGroup", "Std_LinkMakeRelative", "Std_LinkReplace", "Std_LinkUnlink"}:
|
|
owner = routes["owners"]["PartBox"]
|
|
route = web_routes.get(("Part", owner["endpoint"]))
|
|
native = PROBE_ROOT / "WebViewCommands.h"
|
|
bridge = PROBE_ROOT / "NativeDocumentView.h"
|
|
adapter = ROOT / "web/src/web-desktop-adapter.ts"
|
|
task = ROOT / "web/src/workbench-shell.tsx"
|
|
dispatcher = ROOT / "web/src/desktop-command-dispatcher.ts"
|
|
if route and all(path.is_file() for path in (native, bridge, adapter, task)):
|
|
native_text = native.read_text(encoding="utf-8")
|
|
adapter_text = adapter.read_text(encoding="utf-8")
|
|
if command in {"Std_LinkMakeRelative", "Std_LinkReplace", "Std_LinkUnlink"}:
|
|
mutation = PROBE_ROOT / "WebLinkMutationCommands.h"
|
|
controls = ROOT / "web/src/web-link-mutation-task.tsx"
|
|
if (not mutation.is_file() or not controls.is_file()
|
|
or '<WebLinkMutationTask' not in workbench_text
|
|
or command not in controls.read_text(encoding="utf-8")
|
|
or 'executeWebLinkMutation(input, output)' not in native_text
|
|
or not all(token in mutation.read_text(encoding="utf-8") for token in (
|
|
command, 'Data::noElementName', 'CopyOnLinkReplace(parent, obj, replacement)', 'app.commitTransaction(tid)'))):
|
|
return None
|
|
if (f'"{command}"' in native_text
|
|
and f'"{command}"' in bridge.read_text(encoding="utf-8")
|
|
and 'statefulCommandDoc' in adapter_text and 'command.startsWith("Std_Link")' in adapter_text
|
|
and 'src/Gui/CommandLink.cpp' in adapter_text
|
|
and 'const documentViewCommand = isNativeLinkCommand(command)' in adapter_text
|
|
and 'router.request("Part", viewEndpoint("PartBox")' in adapter_text
|
|
and 'validLinkResult(reply.result, p.command, p.document)' in (ROOT / "web/src/part-view-facade.ts").read_text(encoding="utf-8")
|
|
and 'executeWebDesktop(this.router, record.command' in dispatcher.read_text(encoding="utf-8")):
|
|
return {**route, "worker": owner["worker"], "executionModule": "Part", "transport": "direct-command",
|
|
"canonicalId": f"Web:desktop:{command}", "nativeRegistration": str(bridge.relative_to(ROOT))}
|
|
if command in routes.get("standardCommands", []) and command in {"Std_About", "Std_AboutQt", "Std_AnnotationLabel", "Std_DlgParameter", "Std_DlgPreferences", "Std_DlgCustomize", "Std_PythonHelp"}:
|
|
owner = routes["owners"]["PartBox"]
|
|
route = web_routes.get(("Part", owner["endpoint"]))
|
|
native = PROBE_ROOT / "WebViewCommands.h"
|
|
bridge = PROBE_ROOT / "NativeDocumentView.h"
|
|
files = {name: ROOT / "web/src" / name for name in (
|
|
"web-owner-standard-adapter.ts", "web-desktop-adapter.ts", "part-view-facade.ts",
|
|
"part.worker.ts", "part-save.worker.ts", "web-standard-task.tsx")}
|
|
if route and all(path.is_file() for path in [native, bridge, *files.values()]):
|
|
texts = {name: path.read_text(encoding="utf-8") for name, path in files.items()}
|
|
if (f'def execute_{command}(' in native.read_text(encoding="utf-8")
|
|
and f'"{command}"' in bridge.read_text(encoding="utf-8")
|
|
and re.search(r'\bexecuteOwnerStandard\s*\(\s*router\s*,\s*command\s*,\s*args\s*,\s*documentId\s*\)', texts["web-desktop-adapter.ts"])
|
|
and all(token in texts["web-owner-standard-adapter.ts"] for token in (
|
|
'routes.standardCommands.includes(command)', 'router.request("Part", viewEndpoint(owner)',
|
|
'validStandardResult', 'freecad-web/document-view-batch/v1'))
|
|
and all('dispatchDocumentView' in texts[name] for name in ("part.worker.ts", "part-save.worker.ts"))
|
|
and 'validStandardResult' in texts["part-view-facade.ts"]
|
|
and '<WebStandardTask' in workbench_text
|
|
and 'batch.records?.find(item => item.name === command)' in texts["web-standard-task.tsx"]):
|
|
return {**route, "worker": owner["worker"], "executionModule": "Part", "transport": "direct-command",
|
|
"canonicalId": f"Web:desktop:{command}", "nativeRegistration": str(bridge.relative_to(ROOT))}
|
|
if command in routes.get("commands", []):
|
|
if command == "Std_Open":
|
|
native = PROBE_ROOT / "WebOpenCommand.h"
|
|
files = [ROOT / "web/src" / name for name in ("web-open-adapter.ts", "web-open-contract.ts", "web-open-task.tsx")]
|
|
if not all(path.is_file() for path in [native, *files]) or '<WebOpenTask' not in workbench_text:
|
|
return None
|
|
if (not all(token in native.read_text(encoding="utf-8") for token in (
|
|
'App.openDocument(path)', 'App.getImportType()', 'module.open(path)', 'App.closeDocument(name)'))
|
|
or 'executeWebOpenCommand(input.constData(), output)' not in (PROBE_ROOT / "NativeDocumentView.h").read_text(encoding="utf-8")
|
|
or 'executeOpenDocument(router, args, documentId, applySelection)' not in (ROOT / "web/src/web-desktop-adapter.ts").read_text(encoding="utf-8")
|
|
or 'router.request("Part", viewEndpoint(owner)' not in files[0].read_text(encoding="utf-8")
|
|
or 'validOpenResult(reply.result, p.document)' not in (ROOT / "web/src/part-view-facade.ts").read_text(encoding="utf-8")):
|
|
return None
|
|
if command in {"Std_Part", "Std_VarSet"}:
|
|
native = PROBE_ROOT / "WebStructureCommands.h"
|
|
files = [ROOT / "web/src" / name for name in ("web-structure-adapter.ts", "web-structure-contract.ts", "web-structure-task.tsx")]
|
|
if not all(path.is_file() for path in [native, *files]) or '<WebStructureTask' not in workbench_text:
|
|
return None
|
|
if (not all(token in native.read_text(encoding="utf-8") for token in (
|
|
"App::Part", "App::VarSet", "doc.addObject(type_id, name)", "target.setExpression", "doc.commitTransaction()"))
|
|
or 'executeWebStructureCommand(input.constData(), output)' not in (PROBE_ROOT / "NativeDocumentView.h").read_text(encoding="utf-8")
|
|
or 'executeStructure(router, command, args, documentId, applySelection)' not in (ROOT / "web/src/web-desktop-adapter.ts").read_text(encoding="utf-8")
|
|
or 'router.request("Part", viewEndpoint(owner)' not in files[0].read_text(encoding="utf-8")
|
|
or 'validStructureResult(reply.result, p.command, p.document)' not in (ROOT / "web/src/part-view-facade.ts").read_text(encoding="utf-8")):
|
|
return None
|
|
if command in {"Std_ToggleFreeze", "Std_ToggleSkipRecompute"}:
|
|
native = (PROBE_ROOT / "NativeDocumentView.h").read_text(encoding="utf-8")
|
|
task = (ROOT / "web/src/web-commanddoc-task.tsx").read_text(encoding="utf-8")
|
|
required_native = "obj->unfreeze(); else obj->freeze();" if command == "Std_ToggleFreeze" else "doc->setStatus(App::Document::SkipRecompute, checked)"
|
|
if required_native not in native or f'"{command}"' not in task or '<WebCommandDocTask' not in workbench_text:
|
|
return None
|
|
if command in {"Std_SaveAs", "Std_SaveCopy", "Std_SelectAll", "Std_Undo"}:
|
|
native = (PROBE_ROOT / "WebViewCommands.h").read_text(encoding="utf-8")
|
|
adapter = (ROOT / "web/src/web-desktop-adapter.ts").read_text(encoding="utf-8")
|
|
task = (ROOT / "web/src/web-commanddoc-task.tsx").read_text(encoding="utf-8")
|
|
required_native = {"Std_SaveAs": "target.saveAs(destination)", "Std_SaveCopy": "target.saveCopy(destination)",
|
|
"Std_SelectAll": "[identity(obj) for obj in doc.Objects]", "Std_Undo": "doc.undo()"}
|
|
if (required_native[command] not in native or f'"{command}"' not in task
|
|
or "<WebCommandDocTask" not in workbench_text
|
|
or 'router.request("Part", viewEndpoint("PartBox")' not in adapter
|
|
or 'writeDocumentFile' not in adapter):
|
|
return None
|
|
if command == "Std_Group":
|
|
group_paths = [ROOT / "web/src" / name for name in ("web-group-adapter.ts", "web-group-task.tsx", "web-group-contract.ts")]
|
|
group_native = PROBE_ROOT / "WebGroupCommand.h"
|
|
if not all(path.is_file() for path in [*group_paths, group_native]) or '<WebGroupTask' not in workbench_text:
|
|
return None
|
|
if not all(token in group_native.read_text(encoding="utf-8") for token in (
|
|
"App::DocumentObjectGroup", "parent.allowObject(created)", "doc.commitTransaction()")):
|
|
return None
|
|
if ('executeGroup(router, args, documentId)' not in (ROOT / "web/src/web-desktop-adapter.ts").read_text(encoding="utf-8")
|
|
or 'executeWebGroupCommand(input.constData(), output)' not in (PROBE_ROOT / "NativeDocumentView.h").read_text(encoding="utf-8")
|
|
or 'router.request("Part", viewEndpoint(owner)' not in group_paths[0].read_text(encoding="utf-8")
|
|
or 'validGroupResult' not in (ROOT / "web/src/part-view-facade.ts").read_text(encoding="utf-8")):
|
|
return None
|
|
owner = routes["owners"]["PartBox"]
|
|
route = web_routes.get(("Part", owner["endpoint"]))
|
|
required = [ROOT / "web/src" / name for name in ("part-view-facade.ts", "part.worker.ts", "web-desktop-adapter.ts", "web-view-task.tsx")]
|
|
bridge = PROBE_ROOT / "NativeDocumentView.h"
|
|
if route and all(path.is_file() for path in [*required, bridge]) \
|
|
and "document-view-routes.json" in required[0].read_text(encoding="utf-8") \
|
|
and "dispatchDocumentView" in required[1].read_text(encoding="utf-8") \
|
|
and 'router.request("Part", viewEndpoint(owner)' in required[2].read_text(encoding="utf-8") \
|
|
and "executeWebDesktop" in required[3].read_text(encoding="utf-8") \
|
|
and "<WebViewTask" in workbench_text \
|
|
and f'"{command}"' in bridge.read_text(encoding="utf-8") \
|
|
and 'nativeDocumentView(object, QStringLiteral("PartBox"))' in (PROBE_ROOT / "PartBoxProbe.cpp").read_text(encoding="utf-8"):
|
|
return {**route, "worker": owner["worker"], "executionModule": "Part", "transport": "direct-command",
|
|
"canonicalId": f"Web:desktop:{command}", "nativeRegistration": str(bridge.relative_to(ROOT))}
|
|
view_table = ROOT / "web/src/scene-display-commands.json"
|
|
view_host = ROOT / "web/src/scene-command-host.ts"
|
|
view_adapter = ROOT / "web/src/scene-command-adapter.ts"
|
|
view_bridge = ROOT / "web/src/import-render-bridge.ts"
|
|
dispatcher = ROOT / "web/src/desktop-command-dispatcher.ts"
|
|
if (all(path.is_file() for path in (view_table, view_host, view_adapter, view_bridge, dispatcher))
|
|
and command in json.loads(view_table.read_text(encoding="utf-8"))
|
|
and "registerSceneCommandHost" in view_host.read_text(encoding="utf-8")
|
|
and "attachSceneCommands" in view_bridge.read_text(encoding="utf-8")
|
|
and "executeSceneDisplayCommand" in dispatcher.read_text(encoding="utf-8")):
|
|
return {"command": command, "worker": None, "executionModule": "Web", "transport": "browser-ui",
|
|
"nativeRegistration": None, "browserHandler": "web/src/scene-command-adapter.ts"}
|
|
table = ROOT / "web/src/dock-overlay-commands.json"
|
|
adapter = ROOT / "web/src/dock-overlay-adapter.ts"
|
|
host = ROOT / "web/src/dock-overlay-host.tsx"
|
|
dispatcher = ROOT / "web/src/desktop-command-dispatcher.ts"
|
|
worker = ROOT / "web/src/web-dock-overlay.worker.ts"
|
|
facade = ROOT / "web/src/web-dock-overlay-facade.ts"
|
|
native = PROBE_ROOT / "WebDockOverlayCommands.h"
|
|
route = web_routes.get(("Web", "web.dockOverlay"))
|
|
if (table.is_file() and adapter.is_file() and host.is_file() and dispatcher.is_file()
|
|
and route and all(path.is_file() for path in (worker, facade, native))
|
|
and command in json.loads(table.read_text(encoding="utf-8"))
|
|
and "registerDockOverlayHost" in host.read_text(encoding="utf-8")
|
|
and 'router.request("Web", "web.dockOverlay"' in host.read_text(encoding="utf-8")
|
|
and 'dispatchDockOverlay(queue, message, kernel)' in worker.read_text(encoding="utf-8")
|
|
and 'fc_web_dock_overlay_dispatch' in facade.read_text(encoding="utf-8")
|
|
and f'"{command}"' in native.read_text(encoding="utf-8")
|
|
and "executeDockOverlayCommand" in dispatcher.read_text(encoding="utf-8")
|
|
and re.search(r'<DockOverlayHost(?:\s|>)', workbench_text)):
|
|
return {**route, "worker": worker.name, "executionModule": "Web", "transport": "direct-command",
|
|
"nativeRegistration": str(native.relative_to(ROOT))}
|
|
if module == "Part" and command in {"Part_Box", "Part_Box2", "Part_Box3"}:
|
|
route = web_routes.get((module, "part.document"))
|
|
adapter = ROOT / "web/src/part-desktop-adapter.ts"
|
|
if route is not None and "part-save.worker.ts" in route.get("workers", []) and adapter.is_file() and "executePartFixedBoxDesktop" in adapter.read_text(encoding="utf-8"):
|
|
return {**route, "worker": "part-save.worker.ts", "executionModule": module, "transport": "direct-command",
|
|
"nativeRegistration": "freecad-source/src/WebAssembly/FreeCADCoreProbe/DocumentProbe.cpp"}
|
|
native_supported = command in native_desktop_commands.get(module, set())
|
|
if native_supported and module in direct_desktop_workers:
|
|
exact = web_routes.get((module, command))
|
|
if exact is None and module not in desktop_adapter_modules:
|
|
# A dedicated desktop facade/worker may intentionally expose
|
|
# a command that is not duplicated in module-registry.generated
|
|
# (Start is the canonical example). Native ownership plus the
|
|
# typed direct worker is sufficient to synthesize the route;
|
|
# do not require a second per-command registry entry.
|
|
exact = {
|
|
"id": f"{module}:{command}",
|
|
"module": module,
|
|
"command": command,
|
|
"worker": direct_desktop_workers[module],
|
|
"workers": [],
|
|
"artifactReady": module in artifact_modules,
|
|
"uiStatus": "generic-command-entry",
|
|
"implementationStatus": "routed",
|
|
"validationStatus": "deferred",
|
|
}
|
|
if exact is not None:
|
|
return {
|
|
**exact,
|
|
"worker": direct_desktop_workers[module],
|
|
"executionModule": module,
|
|
"transport": "direct-command",
|
|
"nativeRegistration": native_desktop_sources.get(module),
|
|
}
|
|
if native_supported and module in desktop_adapter_modules:
|
|
route = family_route(module)
|
|
if route is not None:
|
|
return {
|
|
**route,
|
|
"executionModule": module,
|
|
"transport": "family-batch",
|
|
"nativeRegistration": native_desktop_sources.get(module),
|
|
}
|
|
return None
|
|
|
|
local_routes: dict[str, dict[str, object]] = {}
|
|
canonical_by_command: dict[str, tuple[dict[str, object], dict[str, object]]] = {}
|
|
for row in desktop_rows:
|
|
module = str(row.get("module") or "")
|
|
command = str(row.get("command") or "")
|
|
if is_ui_container(row):
|
|
continue
|
|
route = local_route(module, command)
|
|
if route is None:
|
|
continue
|
|
if not isinstance(route.get("command"), str) or not route["command"].strip():
|
|
raise ValueError(f"Invalid generated route for {module}:{command}: missing endpoint")
|
|
if route.get("transport") != "browser-ui" and not route.get("worker"):
|
|
raise ValueError(f"Invalid generated route for {module}:{command}: missing worker")
|
|
row_id = str(row.get("id") or f"{module}:desktop:{command}")
|
|
local_routes[row_id] = route
|
|
current = canonical_by_command.get(command)
|
|
owner_hint = command.split("_", 1)[0] if "_" in command else ""
|
|
if current is None or (module == owner_hint and str(current[0].get("module")) != owner_hint):
|
|
canonical_by_command[command] = (row, route)
|
|
|
|
desktop_route_records: list[dict[str, object]] = []
|
|
for row in desktop_rows:
|
|
module = str(row.get("module") or "")
|
|
command = str(row.get("command") or "")
|
|
if not module or not command:
|
|
continue
|
|
record_id = str(row.get("id") or f"{module}:desktop:{command}")
|
|
ui_container = is_ui_container(row)
|
|
portable_route = local_routes.get(record_id)
|
|
canonical = canonical_by_command.get(command) if "_" in command else None
|
|
canonical_row = row
|
|
if portable_route is None and canonical is not None:
|
|
canonical_row, portable_route = canonical
|
|
implementation_ready = ui_container or portable_route is not None
|
|
execution_module = str(portable_route.get("executionModule") or module) if portable_route else module
|
|
execution_family_route = family_route(execution_module)
|
|
transport = str(portable_route.get("transport") or "family-batch") if portable_route else "family-batch"
|
|
execution_route = portable_route or execution_family_route
|
|
route_payload = ({
|
|
"module": module,
|
|
"command": command,
|
|
"worker": None,
|
|
"nativeRegistration": None,
|
|
"canonicalId": record_id,
|
|
"transport": "ui-container",
|
|
} if ui_container else {
|
|
"module": execution_module,
|
|
"command": execution_route.get("command") if execution_route else portable_route.get("command"),
|
|
"worker": execution_route.get("worker") if execution_route else portable_route.get("worker"),
|
|
"nativeRegistration": portable_route.get("nativeRegistration"),
|
|
"canonicalId": portable_route.get("canonicalId", f"{execution_module}:desktop:{command}"),
|
|
"transport": transport,
|
|
**({"browserHandler": portable_route["browserHandler"], "nativeOperationRequired": False}
|
|
if transport == "browser-ui" else {}),
|
|
}) if implementation_ready and (ui_container or portable_route) else None
|
|
records.append(
|
|
{
|
|
"id": record_id,
|
|
"module": module,
|
|
"command": command,
|
|
"category": "navigation" if ui_container else category(command),
|
|
"kind": "desktop-gui",
|
|
"uiRole": "container" if ui_container else "command",
|
|
"worker": route_payload.get("worker") if route_payload else None,
|
|
"workers": module_rows.get(module, {}).get("workers", []),
|
|
"artifactReady": module in artifact_modules,
|
|
"uiStatus": "react-navigation" if ui_container else "generic-command-entry" if implementation_ready else "needs-react-workflow",
|
|
"implementationStatus": "routed" if implementation_ready else "implementation_required",
|
|
"validationStatus": "deferred",
|
|
"sourcePath": row.get("sourcePath"),
|
|
"sourceLine": row.get("sourceLine"),
|
|
"uiSurfaces": row.get("uiSurfaces", []),
|
|
"capabilityReason": "FreeCAD Workbench menu/toolbar container rendered by React" if ui_container else row.get("capabilityReason"),
|
|
"route": route_payload,
|
|
"implementationGaps": [] if implementation_ready else [{
|
|
"stage": "native" if command not in native_desktop_commands.get(module, set()) else "transport",
|
|
"reason": "no command-specific native implementation is registered" if command not in native_desktop_commands.get(module, set()) else "native command has no matching Worker/Facade route",
|
|
"sourcePath": row.get("sourcePath"), "sourceLine": row.get("sourceLine"),
|
|
"repair": "complete this command's real business behavior and route; registration alone is insufficient",
|
|
}],
|
|
}
|
|
)
|
|
if route_payload:
|
|
desktop_route_records.append(
|
|
{
|
|
"id": record_id,
|
|
"appearanceModule": module,
|
|
"desktopCommand": command,
|
|
"sourcePath": canonical_row.get("sourcePath"),
|
|
"sourceLine": canonical_row.get("sourceLine"),
|
|
**route_payload,
|
|
}
|
|
)
|
|
if module in module_rows:
|
|
module_rows[module]["desktopCommands"] = int(module_rows[module]["desktopCommands"]) + 1
|
|
module_rows[module]["implementationRequired"] = int(module_rows[module]["implementationRequired"]) + int(not implementation_ready)
|
|
native_gap_kinds = describe_native_gaps(records, rejected_desktop_markers)
|
|
policy_run = subprocess.run(
|
|
["node", str(ROOT / "web/tools/audit-native-route-policy.mjs")],
|
|
input=json.dumps({"records": records}), text=True, capture_output=True, check=True,
|
|
cwd=ROOT, timeout=30,
|
|
)
|
|
blocked_routes = json.loads(policy_run.stdout)["blocked"]
|
|
for row in records:
|
|
if row["id"] in blocked_routes:
|
|
row["runtimeRouteBlock"] = blocked_routes[row["id"]]
|
|
row.setdefault("implementationGaps", []).append({"stage": "policy", "reason": blocked_routes[row["id"]]["message"],
|
|
"sourcePath": "web/src/native-source-policy.ts"})
|
|
row["implementationStatus"] = "implementation_required"
|
|
row["uiStatus"] = "needs-react-workflow"
|
|
for module_row in modules:
|
|
module_row["implementationRequired"] = sum(
|
|
row["module"] == module_row["module"] and row["implementationStatus"] != "routed"
|
|
for row in records
|
|
)
|
|
statuses = Counter(str(row["uiStatus"]) for row in records)
|
|
payload = {
|
|
"schema": "freecad-web-feature-capability-registry/v1",
|
|
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
|
"strategy": "feature-first-deferred-validation",
|
|
"sources": {
|
|
"routes": "web/src/module-registry.generated.ts",
|
|
"workbench": "web/src/workbench-shell.tsx",
|
|
"artifacts": "web/public/modules/manifest.json",
|
|
"pythonSymbols": "docs/format-api-inventory.json",
|
|
},
|
|
"counts": {
|
|
"modules": len(modules),
|
|
"commands": len(records),
|
|
"routed": sum(row["implementationStatus"] == "routed" for row in records),
|
|
"implementationRequired": sum(row["implementationStatus"] == "implementation_required" for row in records),
|
|
"interactive": statuses["interactive"],
|
|
"genericApiEntry": statuses["generic-api-entry"],
|
|
"genericCommandEntry": statuses["generic-command-entry"],
|
|
"reactNavigation": statuses["react-navigation"],
|
|
"needsParameterUi": statuses["needs-parameter-ui"],
|
|
"needsReactWorkflow": statuses["needs-react-workflow"],
|
|
"artifactModules": sum(bool(row["artifactReady"]) for row in modules),
|
|
"validationDeferred": len(records),
|
|
},
|
|
"diagnostics": {
|
|
"nativeGapKinds": native_gap_kinds,
|
|
"blockedRuntimeRoutes": blocked_routes,
|
|
"familyMarkersRequireExplicitCommands": sorted(native_desktop_families),
|
|
"rejectedDesktopMarkers": {
|
|
module: sorted(commands)
|
|
for module, commands in sorted(rejected_desktop_markers.items())
|
|
},
|
|
"rejectedMarkerReason": "marker requires a command-specific branch that explicitly inserts status=exposed",
|
|
},
|
|
"modules": modules,
|
|
"records": records,
|
|
"boundaries": [
|
|
"This registry tracks Web capabilities and routes, not Python source symbols.",
|
|
"Routed or interactive is an implementation state, not a validation pass.",
|
|
"Runtime issues reopen only the affected capability; they do not trigger a full API gate.",
|
|
],
|
|
}
|
|
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = OUTPUT.with_suffix(".tmp")
|
|
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
temporary.replace(OUTPUT)
|
|
route_payload = {
|
|
"schema": "freecad-web-desktop-command-routes/v1",
|
|
"generatedAt": payload["generatedAt"],
|
|
"records": desktop_route_records,
|
|
}
|
|
ROUTE_OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
|
route_temporary = ROUTE_OUTPUT.with_suffix(".tmp")
|
|
route_temporary.write_text(json.dumps(route_payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
|
|
route_temporary.replace(ROUTE_OUTPUT)
|
|
if file_stamps(generation_inputs(command_matrix)) == inputs:
|
|
cache_temporary = CACHE_PATH.with_suffix(".tmp")
|
|
cache_temporary.write_text(json.dumps({"schema": "freecad-web-feature-capability-cache/v1", "inputs": inputs,
|
|
"outputs": file_stamps({OUTPUT, ROUTE_OUTPUT}), "counts": payload["counts"]}) + "\n", encoding="utf-8")
|
|
cache_temporary.replace(CACHE_PATH)
|
|
print(json.dumps({"status": "generated", **payload["counts"]}, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|