ui-design-workbench-cli 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- android_resource_resolver.py +370 -0
- android_xml_support.py +230 -0
- apple_resource_resolver.py +141 -0
- coverage_report.py +134 -0
- fidelity_adapter_api.py +89 -0
- fidelity_adapters.py +508 -0
- fidelity_core.py +241 -0
- fidelity_platform_adapters.py +902 -0
- ir_contracts.py +76 -0
- native_render_android.py +160 -0
- native_render_apple.py +165 -0
- native_render_contracts.py +148 -0
- native_render_registry.py +86 -0
- quality_common.py +217 -0
- render_preview.py +1797 -0
- scan_ui.py +1485 -0
- scoped_context.py +458 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/SKILL.md +52 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/adapter-api.md +20 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/agent-integrations.md +39 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/cache-protocol.md +61 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/design-modes.md +109 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/fidelity.md +53 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ir-schema.md +412 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/native-rendering.md +40 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platform-profiles.json +84 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platform-standards.md +131 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platforms.md +40 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/quality-automation.md +77 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/review-workflow.md +135 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-guidance-mode.md +22 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-policy-schema.md +83 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-reviewer.md +175 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/validation.md +34 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/workbench-ui.md +108 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/native-render-state.schema.json +71 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-agent-job.schema.json +36 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-graph.schema.json +46 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-ir.patch.schema.json +36 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-ir.schema.json +296 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/uidw-config.schema.json +68 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/android_resource_resolver.py +370 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/android_xml_support.py +230 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/apple_resource_resolver.py +141 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/coverage_report.py +134 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_adapter_api.py +89 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_adapters.py +508 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_core.py +241 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_platform_adapters.py +902 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/generate_interaction_matrix.py +166 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/ir_contracts.py +76 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/merge_review_state.py +267 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_android.py +160 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_apple.py +165 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_contracts.py +148 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_registry.py +86 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/quality_common.py +217 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/render_preview.py +1797 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/scan_ui.py +1485 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/scoped_context.py +458 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/smoke_preview.js +978 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/uidw.py +2821 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/uidw_mcp.py +282 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/validate_platform_profiles.py +93 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/visual_regression.py +141 -0
- ui_design_workbench_cli-0.5.0.dist-info/METADATA +133 -0
- ui_design_workbench_cli-0.5.0.dist-info/RECORD +73 -0
- ui_design_workbench_cli-0.5.0.dist-info/WHEEL +5 -0
- ui_design_workbench_cli-0.5.0.dist-info/entry_points.txt +3 -0
- ui_design_workbench_cli-0.5.0.dist-info/top_level.txt +20 -0
- uidw.py +2821 -0
- uidw_mcp.py +282 -0
- validate_platform_profiles.py +93 -0
ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_apple.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only Apple native-render provider discovery."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Iterator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
IGNORED_DIRS = {".git", ".build", ".idea", ".ui-design-workbench", "build", "DerivedData", "node_modules", "Pods", "vendor"}
|
|
16
|
+
TEXT_LIMIT = 2 * 1024 * 1024
|
|
17
|
+
FILE_LIMIT = 30000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _relative(path: Path, root: Path) -> str:
|
|
21
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _files(root: Path) -> Iterator[Path]:
|
|
25
|
+
seen = 0
|
|
26
|
+
for directory, names, files in os.walk(root):
|
|
27
|
+
names[:] = [name for name in names if name not in IGNORED_DIRS and not name.startswith(".")]
|
|
28
|
+
for name in files:
|
|
29
|
+
seen += 1
|
|
30
|
+
if seen > FILE_LIMIT:
|
|
31
|
+
return
|
|
32
|
+
yield Path(directory) / name
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _bundles(root: Path) -> tuple[list[Path], list[Path]]:
|
|
36
|
+
projects: list[Path] = []
|
|
37
|
+
workspaces: list[Path] = []
|
|
38
|
+
for directory, names, _ in os.walk(root):
|
|
39
|
+
names[:] = [name for name in names if name not in IGNORED_DIRS and not name.startswith(".")]
|
|
40
|
+
current = Path(directory)
|
|
41
|
+
for name in list(names):
|
|
42
|
+
path = current / name
|
|
43
|
+
if name.endswith(".xcodeproj"):
|
|
44
|
+
projects.append(path)
|
|
45
|
+
names.remove(name)
|
|
46
|
+
elif name.endswith(".xcworkspace"):
|
|
47
|
+
workspaces.append(path)
|
|
48
|
+
names.remove(name)
|
|
49
|
+
return projects, workspaces
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read(path: Path) -> str:
|
|
53
|
+
try:
|
|
54
|
+
if path.stat().st_size > TEXT_LIMIT:
|
|
55
|
+
return ""
|
|
56
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
57
|
+
except OSError:
|
|
58
|
+
return ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fingerprint(root: Path, files: list[Path]) -> str:
|
|
62
|
+
digest = hashlib.sha256()
|
|
63
|
+
for path in sorted(set(files)):
|
|
64
|
+
try:
|
|
65
|
+
stat = path.stat()
|
|
66
|
+
except OSError:
|
|
67
|
+
continue
|
|
68
|
+
digest.update(f"{_relative(path, root)}\0{stat.st_size}\0{stat.st_mtime_ns}\n".encode("utf-8"))
|
|
69
|
+
return digest.hexdigest()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def discover_apple(root: Path, host_platform: str | None = None) -> dict[str, Any]:
|
|
73
|
+
root = root.resolve()
|
|
74
|
+
host = host_platform or sys.platform
|
|
75
|
+
all_files = list(_files(root))
|
|
76
|
+
swift_files = [path for path in all_files if path.suffix.lower() == ".swift"]
|
|
77
|
+
interfaces = [path for path in all_files if path.suffix.lower() in {".storyboard", ".xib"}]
|
|
78
|
+
package_files = [path for path in all_files if path.name in {"Package.swift", "Package.resolved", "Podfile"}]
|
|
79
|
+
projects, workspaces = _bundles(root)
|
|
80
|
+
detected = bool(swift_files or interfaces or projects or workspaces)
|
|
81
|
+
preview_entries: list[dict[str, Any]] = []
|
|
82
|
+
snapshot_markers = 0
|
|
83
|
+
swiftui_views = 0
|
|
84
|
+
source_inputs: list[Path] = []
|
|
85
|
+
for path in swift_files:
|
|
86
|
+
text = _read(path)
|
|
87
|
+
if not text:
|
|
88
|
+
continue
|
|
89
|
+
if "SwiftUI" in text or "#Preview" in text or "PreviewProvider" in text or "SnapshotTesting" in text:
|
|
90
|
+
source_inputs.append(path)
|
|
91
|
+
swiftui_views += len(re.findall(r"\bstruct\s+\w+\s*:\s*View\b", text))
|
|
92
|
+
snapshot_markers += len(re.findall(r"(?:SnapshotTesting|assertSnapshot|iOSSnapshotTestCase|FBSnapshotTestCase)", text))
|
|
93
|
+
for match in re.finditer(r"#Preview(?:\s*\(\s*[\"']([^\"']+)[\"'][^)]*\))?", text):
|
|
94
|
+
preview_entries.append({"symbol": match.group(1) or "#Preview", "file": _relative(path, root)})
|
|
95
|
+
for match in re.finditer(r"\bstruct\s+(\w+)\s*:\s*PreviewProvider\b", text):
|
|
96
|
+
preview_entries.append({"symbol": match.group(1), "file": _relative(path, root)})
|
|
97
|
+
package_text = "\n".join(_read(path) for path in package_files)
|
|
98
|
+
snapshot_configured = snapshot_markers > 0 or "swift-snapshot-testing" in package_text.lower()
|
|
99
|
+
mac_host = host == "darwin"
|
|
100
|
+
xcode_ready = bool(mac_host and shutil.which("xcodebuild"))
|
|
101
|
+
simulator_ready = bool(xcode_ready and shutil.which("xcrun"))
|
|
102
|
+
|
|
103
|
+
def status(configured: bool, requires_host: bool = True) -> str:
|
|
104
|
+
if not detected:
|
|
105
|
+
return "not-applicable"
|
|
106
|
+
if requires_host and not mac_host:
|
|
107
|
+
return "host-required"
|
|
108
|
+
return "configured" if configured else "not-configured"
|
|
109
|
+
|
|
110
|
+
providers = [
|
|
111
|
+
{
|
|
112
|
+
"id": "xcode-preview",
|
|
113
|
+
"platform": "apple",
|
|
114
|
+
"status": status(bool(preview_entries) and xcode_ready),
|
|
115
|
+
"availableFidelityTier": "native-preview",
|
|
116
|
+
"supports": ["swiftui"],
|
|
117
|
+
"reason": "Xcode and SwiftUI preview entries are available." if preview_entries and xcode_ready else "Requires a macOS Xcode worker and a SwiftUI Preview entry point.",
|
|
118
|
+
"execution": "explicit-only",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"id": "swift-snapshot-testing",
|
|
122
|
+
"platform": "apple",
|
|
123
|
+
"status": status(snapshot_configured and xcode_ready),
|
|
124
|
+
"availableFidelityTier": "native-preview",
|
|
125
|
+
"supports": ["swiftui", "uikit", "appkit"],
|
|
126
|
+
"reason": "Snapshot test markers and Xcode are available." if snapshot_configured and xcode_ready else "Requires a configured snapshot-test target on a macOS Xcode worker.",
|
|
127
|
+
"execution": "explicit-only",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"id": "apple-simulator",
|
|
131
|
+
"platform": "apple",
|
|
132
|
+
"status": status(simulator_ready),
|
|
133
|
+
"availableFidelityTier": "device-verified",
|
|
134
|
+
"supports": ["swiftui", "uikit", "appkit", "runtime-state"],
|
|
135
|
+
"reason": "Xcode simulator tooling is available." if simulator_ready else "Requires an explicit run on a macOS Xcode worker.",
|
|
136
|
+
"execution": "explicit-only",
|
|
137
|
+
},
|
|
138
|
+
]
|
|
139
|
+
configured = [provider["id"] for provider in providers if provider["status"] == "configured"]
|
|
140
|
+
inputs = swift_files + interfaces + package_files
|
|
141
|
+
return {
|
|
142
|
+
"platform": "apple",
|
|
143
|
+
"status": "detected" if detected else "not-applicable",
|
|
144
|
+
"sourceFingerprint": _fingerprint(root, inputs),
|
|
145
|
+
"project": {
|
|
146
|
+
"hostPlatform": host,
|
|
147
|
+
"requiresMacOSWorker": bool(detected and not mac_host),
|
|
148
|
+
"xcodeProjectCount": len(projects),
|
|
149
|
+
"xcodeWorkspaceCount": len(workspaces),
|
|
150
|
+
"swiftFileCount": len(swift_files),
|
|
151
|
+
"swiftUIViewCount": swiftui_views,
|
|
152
|
+
"previewEntryCount": len(preview_entries),
|
|
153
|
+
"interfaceResourceCount": len(interfaces),
|
|
154
|
+
"snapshotTestMarkerCount": snapshot_markers,
|
|
155
|
+
},
|
|
156
|
+
"entryPoints": preview_entries[:200],
|
|
157
|
+
"providers": providers,
|
|
158
|
+
"recommendedProvider": configured[0] if configured else None,
|
|
159
|
+
"currentFidelityTier": "structural",
|
|
160
|
+
"nativeExecutionStarted": False,
|
|
161
|
+
"limitations": [
|
|
162
|
+
"Windows and Linux can discover Apple UI but cannot render it natively.",
|
|
163
|
+
"A configured provider is capability evidence, not visual fidelity evidence.",
|
|
164
|
+
],
|
|
165
|
+
}
|
ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_contracts.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Provider-neutral contracts for opt-in native UI rendering.
|
|
3
|
+
|
|
4
|
+
Native artifacts are deliberately stored outside the baseline UI IR. Source
|
|
5
|
+
adapters may reconstruct structure, but only a native provider can supply
|
|
6
|
+
evidence for pixel appearance.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import asdict, dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Protocol
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
NATIVE_STATE_SCHEMA_VERSION = 1
|
|
21
|
+
CAPTURE_STATUSES = {"ready", "blocked", "failed", "stale"}
|
|
22
|
+
PROVIDER_STATUSES = {"configured", "available", "not-configured", "host-required", "not-applicable"}
|
|
23
|
+
NATIVE_FIDELITY_TIERS = {"native-preview", "device-verified"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _relative_artifact(value: str) -> str:
|
|
27
|
+
normalized = value.replace("\\", "/").strip()
|
|
28
|
+
if not normalized:
|
|
29
|
+
raise ValueError("native artifact reference must not be empty")
|
|
30
|
+
if normalized.startswith("/") or re.match(r"^[A-Za-z]:/", normalized):
|
|
31
|
+
raise ValueError(f"native artifact reference must be relative: {value}")
|
|
32
|
+
parts = [part for part in normalized.split("/") if part not in {"", "."}]
|
|
33
|
+
if not parts or ".." in parts:
|
|
34
|
+
raise ValueError(f"native artifact reference escapes its bundle: {value}")
|
|
35
|
+
return "/".join(parts)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class NativeRenderRequest:
|
|
40
|
+
provider_id: str
|
|
41
|
+
platform: str
|
|
42
|
+
screen_id: str
|
|
43
|
+
state_id: str = "default"
|
|
44
|
+
variant: str = "default"
|
|
45
|
+
viewport: dict[str, Any] = field(default_factory=dict)
|
|
46
|
+
locale: str = ""
|
|
47
|
+
theme: str = ""
|
|
48
|
+
|
|
49
|
+
def cache_key(self, source_fingerprint: str, provider_version: str = "") -> str:
|
|
50
|
+
payload = {
|
|
51
|
+
**asdict(self),
|
|
52
|
+
"sourceFingerprint": source_fingerprint,
|
|
53
|
+
"providerVersion": provider_version,
|
|
54
|
+
}
|
|
55
|
+
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
56
|
+
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class NativeRenderProvider(Protocol):
|
|
60
|
+
id: str
|
|
61
|
+
platforms: tuple[str, ...]
|
|
62
|
+
|
|
63
|
+
def discover(self, root: Path) -> dict[str, Any]: ...
|
|
64
|
+
|
|
65
|
+
def capture(self, root: Path, request: NativeRenderRequest, output_dir: Path) -> dict[str, Any]: ...
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def empty_native_state(repo_root: Path) -> dict[str, Any]:
|
|
69
|
+
return {
|
|
70
|
+
"schemaVersion": NATIVE_STATE_SCHEMA_VERSION,
|
|
71
|
+
"type": "ui-design-workbench-native-render-state",
|
|
72
|
+
"repository": {"root": str(repo_root.resolve())},
|
|
73
|
+
"status": "not-applicable",
|
|
74
|
+
"currentFidelityTier": "structural",
|
|
75
|
+
"nativeExecutionStarted": False,
|
|
76
|
+
"platforms": [],
|
|
77
|
+
"providers": [],
|
|
78
|
+
"captures": [],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def validate_native_state(state: dict[str, Any]) -> list[str]:
|
|
83
|
+
errors: list[str] = []
|
|
84
|
+
if state.get("schemaVersion") != NATIVE_STATE_SCHEMA_VERSION:
|
|
85
|
+
errors.append(f"schemaVersion must be {NATIVE_STATE_SCHEMA_VERSION}")
|
|
86
|
+
if state.get("type") != "ui-design-workbench-native-render-state":
|
|
87
|
+
errors.append("type must be ui-design-workbench-native-render-state")
|
|
88
|
+
if state.get("nativeExecutionStarted") not in {True, False}:
|
|
89
|
+
errors.append("nativeExecutionStarted must be boolean")
|
|
90
|
+
providers = state.get("providers", [])
|
|
91
|
+
if not isinstance(providers, list):
|
|
92
|
+
errors.append("providers must be an array")
|
|
93
|
+
providers = []
|
|
94
|
+
for index, provider in enumerate(providers):
|
|
95
|
+
if not isinstance(provider, dict):
|
|
96
|
+
errors.append(f"providers[{index}] must be an object")
|
|
97
|
+
continue
|
|
98
|
+
if not provider.get("id") or not provider.get("platform"):
|
|
99
|
+
errors.append(f"providers[{index}] requires id and platform")
|
|
100
|
+
if provider.get("status") not in PROVIDER_STATUSES:
|
|
101
|
+
errors.append(f"providers[{index}].status is invalid")
|
|
102
|
+
captures = state.get("captures", [])
|
|
103
|
+
if not isinstance(captures, list):
|
|
104
|
+
errors.append("captures must be an array")
|
|
105
|
+
captures = []
|
|
106
|
+
for index, capture in enumerate(captures):
|
|
107
|
+
if not isinstance(capture, dict):
|
|
108
|
+
errors.append(f"captures[{index}] must be an object")
|
|
109
|
+
continue
|
|
110
|
+
for key in ("id", "providerId", "platform", "screenId", "sourceFingerprint"):
|
|
111
|
+
if not capture.get(key):
|
|
112
|
+
errors.append(f"captures[{index}].{key} is required")
|
|
113
|
+
if capture.get("status") not in CAPTURE_STATUSES:
|
|
114
|
+
errors.append(f"captures[{index}].status is invalid")
|
|
115
|
+
if capture.get("fidelityTier") not in NATIVE_FIDELITY_TIERS:
|
|
116
|
+
errors.append(f"captures[{index}].fidelityTier is invalid")
|
|
117
|
+
for key in ("image", "semantics", "geometry"):
|
|
118
|
+
value = capture.get("artifacts", {}).get(key) if isinstance(capture.get("artifacts"), dict) else None
|
|
119
|
+
if value:
|
|
120
|
+
try:
|
|
121
|
+
_relative_artifact(str(value))
|
|
122
|
+
except ValueError as exc:
|
|
123
|
+
errors.append(f"captures[{index}].artifacts.{key}: {exc}")
|
|
124
|
+
if capture.get("status") == "ready" and not isinstance(capture.get("artifacts"), dict):
|
|
125
|
+
errors.append(f"captures[{index}].artifacts is required for a ready capture")
|
|
126
|
+
return errors
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_native_state(path: Path, repo_root: Path) -> dict[str, Any]:
|
|
130
|
+
try:
|
|
131
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
132
|
+
except (OSError, json.JSONDecodeError):
|
|
133
|
+
return empty_native_state(repo_root)
|
|
134
|
+
return payload if isinstance(payload, dict) and not validate_native_state(payload) else empty_native_state(repo_root)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def write_native_state(path: Path, state: dict[str, Any]) -> None:
|
|
138
|
+
errors = validate_native_state(state)
|
|
139
|
+
if errors:
|
|
140
|
+
raise ValueError("; ".join(errors))
|
|
141
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
143
|
+
try:
|
|
144
|
+
temporary.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
145
|
+
os.replace(temporary, path)
|
|
146
|
+
finally:
|
|
147
|
+
if temporary.exists():
|
|
148
|
+
temporary.unlink()
|
ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_registry.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Native renderer discovery and cache-state orchestration."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import copy
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from native_render_android import discover_android
|
|
11
|
+
from native_render_apple import discover_apple
|
|
12
|
+
from native_render_contracts import empty_native_state, load_native_state, write_native_state
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def discover_native_render(root: Path) -> dict[str, Any]:
|
|
16
|
+
platforms = [discover_android(root), discover_apple(root)]
|
|
17
|
+
detected = [item for item in platforms if item.get("status") == "detected"]
|
|
18
|
+
providers = [provider for item in platforms for provider in item.get("providers", [])]
|
|
19
|
+
configured = [provider for provider in providers if provider.get("status") == "configured"]
|
|
20
|
+
host_required = [provider for provider in providers if provider.get("status") == "host-required"]
|
|
21
|
+
if configured:
|
|
22
|
+
next_action = "Choose one configured provider and explicitly request a native capture."
|
|
23
|
+
elif host_required:
|
|
24
|
+
next_action = "Configure a macOS Xcode worker before requesting Apple native captures."
|
|
25
|
+
elif detected:
|
|
26
|
+
next_action = "Configure a supported screenshot provider; source projection remains structural-only."
|
|
27
|
+
else:
|
|
28
|
+
next_action = "No Android or Apple native project was detected."
|
|
29
|
+
return {
|
|
30
|
+
"schemaVersion": 1,
|
|
31
|
+
"type": "ui-design-workbench-native-render-state",
|
|
32
|
+
"repository": {"root": str(root.resolve())},
|
|
33
|
+
"status": "pass" if detected else "not-applicable",
|
|
34
|
+
"currentFidelityTier": "structural",
|
|
35
|
+
"nativeExecutionStarted": False,
|
|
36
|
+
"platforms": platforms,
|
|
37
|
+
"providers": providers,
|
|
38
|
+
"captures": [],
|
|
39
|
+
"summary": {
|
|
40
|
+
"detectedPlatforms": [item["platform"] for item in detected],
|
|
41
|
+
"configuredProviders": [item["id"] for item in configured],
|
|
42
|
+
"hostRequiredProviders": [item["id"] for item in host_required],
|
|
43
|
+
"nativeCaptureCount": 0,
|
|
44
|
+
"staleCaptureCount": 0,
|
|
45
|
+
},
|
|
46
|
+
"next": next_action,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def native_render_status(root: Path, state_file: Path, platform: str = "all") -> dict[str, Any]:
|
|
51
|
+
root = root.resolve()
|
|
52
|
+
previous = load_native_state(state_file, root)
|
|
53
|
+
discovered = discover_native_render(root)
|
|
54
|
+
current_fingerprints = {
|
|
55
|
+
item.get("platform"): item.get("sourceFingerprint")
|
|
56
|
+
for item in discovered.get("platforms", [])
|
|
57
|
+
if item.get("platform")
|
|
58
|
+
}
|
|
59
|
+
captures: list[dict[str, Any]] = []
|
|
60
|
+
for original in previous.get("captures", []):
|
|
61
|
+
capture = copy.deepcopy(original)
|
|
62
|
+
platform_name = capture.get("platform")
|
|
63
|
+
if capture.get("status") == "ready" and current_fingerprints.get(platform_name) != capture.get("sourceFingerprint"):
|
|
64
|
+
capture["status"] = "stale"
|
|
65
|
+
captures.append(capture)
|
|
66
|
+
discovered["captures"] = captures
|
|
67
|
+
discovered["summary"]["nativeCaptureCount"] = sum(1 for item in captures if item.get("status") == "ready")
|
|
68
|
+
discovered["summary"]["staleCaptureCount"] = sum(1 for item in captures if item.get("status") == "stale")
|
|
69
|
+
comparable_previous = {key: value for key, value in previous.items() if key != "cache"}
|
|
70
|
+
cache_status = "reused" if comparable_previous == discovered else "updated" if state_file.is_file() else "created"
|
|
71
|
+
discovered["cache"] = {"status": cache_status, "stateFile": str(state_file.resolve())}
|
|
72
|
+
if cache_status != "reused":
|
|
73
|
+
write_native_state(state_file, {key: value for key, value in discovered.items() if key != "cache"})
|
|
74
|
+
if platform != "all":
|
|
75
|
+
selected = copy.deepcopy(discovered)
|
|
76
|
+
selected["platforms"] = [item for item in selected["platforms"] if item.get("platform") == platform]
|
|
77
|
+
selected["providers"] = [item for item in selected["providers"] if item.get("platform") == platform]
|
|
78
|
+
selected["captures"] = [item for item in selected["captures"] if item.get("platform") == platform]
|
|
79
|
+
selected["summary"]["detectedPlatforms"] = [item for item in selected["summary"]["detectedPlatforms"] if item == platform]
|
|
80
|
+
selected["summary"]["configuredProviders"] = [item["id"] for item in selected["providers"] if item.get("status") == "configured"]
|
|
81
|
+
selected["summary"]["hostRequiredProviders"] = [item["id"] for item in selected["providers"] if item.get("status") == "host-required"]
|
|
82
|
+
selected["summary"]["nativeCaptureCount"] = sum(1 for item in selected["captures"] if item.get("status") == "ready")
|
|
83
|
+
selected["summary"]["staleCaptureCount"] = sum(1 for item in selected["captures"] if item.get("status") == "stale")
|
|
84
|
+
selected["status"] = "pass" if selected["summary"]["detectedPlatforms"] else "not-applicable"
|
|
85
|
+
return selected
|
|
86
|
+
return discovered
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared deterministic helpers for UI Design Workbench quality gates."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Iterable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolve_profile_catalog() -> Path:
|
|
15
|
+
candidates = []
|
|
16
|
+
if os.environ.get("UIDW_HOME"):
|
|
17
|
+
candidates.append(Path(os.environ["UIDW_HOME"]).expanduser() / "references" / "platform-profiles.json")
|
|
18
|
+
candidates.extend([
|
|
19
|
+
Path(__file__).resolve().parent.parent / "references" / "platform-profiles.json",
|
|
20
|
+
Path(__file__).resolve().parent / "share" / "ui-design-workbench" / "references" / "platform-profiles.json",
|
|
21
|
+
Path(sys.prefix) / "share" / "ui-design-workbench" / "references" / "platform-profiles.json",
|
|
22
|
+
])
|
|
23
|
+
return next((item for item in candidates if item.is_file()), candidates[0])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
PROFILE_CATALOG_PATH = resolve_profile_catalog()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read_json(path: str | Path) -> dict[str, Any]:
|
|
30
|
+
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
31
|
+
if not isinstance(value, dict):
|
|
32
|
+
raise ValueError(f"Expected a JSON object in {path}")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write_json(path: str | Path, value: Any) -> None:
|
|
37
|
+
target = Path(path)
|
|
38
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
target.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def profile_catalog() -> dict[str, Any]:
|
|
43
|
+
return read_json(PROFILE_CATALOG_PATH)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def platform_family(value: Any) -> str | None:
|
|
47
|
+
platform = str(value or "").strip().lower()
|
|
48
|
+
if any(marker in platform for marker in ("android-tv", "compose-tv", "leanback", "react-native", "react_native", "mac-catalyst")):
|
|
49
|
+
return None
|
|
50
|
+
if platform in {"windows", "windows-winui", "windows-wpf", "windows-xaml", "winui", "winui3", "wpf", "windows-app-sdk"} or platform.startswith("windows-"):
|
|
51
|
+
return "windows"
|
|
52
|
+
if platform in {"macos", "swiftui-macos", "appkit"} or "macos" in platform:
|
|
53
|
+
return "macos"
|
|
54
|
+
if "android" in platform:
|
|
55
|
+
return "android"
|
|
56
|
+
if platform in {"ios", "swiftui", "uikit"} or "apple" in platform:
|
|
57
|
+
return "ios"
|
|
58
|
+
if platform == "flutter":
|
|
59
|
+
return "flutter"
|
|
60
|
+
if platform in {"web", "react-web", "vue", "svelte"}:
|
|
61
|
+
return "web"
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def target_families(ir: dict[str, Any]) -> set[str]:
|
|
66
|
+
explicit = ir.get("design", {}).get("targetPlatforms", [])
|
|
67
|
+
values = explicit or ir.get("platforms", [])
|
|
68
|
+
families = {platform_family(item) for item in values}
|
|
69
|
+
families.discard(None)
|
|
70
|
+
return families
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def framework_adapters(ir: dict[str, Any]) -> set[str]:
|
|
74
|
+
known = profile_catalog().get("frameworkAdapters", {})
|
|
75
|
+
found: set[str] = set()
|
|
76
|
+
for value in ir.get("platforms", []):
|
|
77
|
+
normalized = str(value or "").strip().lower()
|
|
78
|
+
if normalized in known:
|
|
79
|
+
found.add(normalized)
|
|
80
|
+
elif "flutter" in normalized:
|
|
81
|
+
found.add("flutter")
|
|
82
|
+
return found
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def walk_tree_screen_ids(items: Iterable[Any]) -> list[str]:
|
|
86
|
+
result: list[str] = []
|
|
87
|
+
for item in items or []:
|
|
88
|
+
if not isinstance(item, dict):
|
|
89
|
+
continue
|
|
90
|
+
screen_id = item.get("screenId") or item.get("screen")
|
|
91
|
+
if screen_id:
|
|
92
|
+
result.append(str(screen_id))
|
|
93
|
+
result.extend(walk_tree_screen_ids(item.get("children", [])))
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def walk_tree_screen_paths(items: Iterable[Any], path: tuple[str, ...] = ()) -> dict[str, list[str]]:
|
|
98
|
+
result: dict[str, list[str]] = {}
|
|
99
|
+
for item in items or []:
|
|
100
|
+
if not isinstance(item, dict):
|
|
101
|
+
continue
|
|
102
|
+
screen_id = item.get("screenId") or item.get("screen")
|
|
103
|
+
if screen_id:
|
|
104
|
+
result[str(screen_id)] = list(path)
|
|
105
|
+
continue
|
|
106
|
+
label = str(item.get("label") or item.get("name") or item.get("id") or "").strip()
|
|
107
|
+
result.update(walk_tree_screen_paths(item.get("children", []), (*path, label) if label else path))
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def hierarchy_issues(ir: dict[str, Any]) -> list[dict[str, Any]]:
|
|
112
|
+
"""Compare authored screenTree paths with source-evidenced hierarchy metadata."""
|
|
113
|
+
paths = walk_tree_screen_paths(ir.get("screenTree", []))
|
|
114
|
+
screens = ir.get("screens", [])
|
|
115
|
+
discovered = [item for item in ir.get("discoveredScreens", []) if isinstance(item, dict)]
|
|
116
|
+
screen_by_evidence: dict[tuple[str, str], str] = {}
|
|
117
|
+
screen_by_fragment: dict[str, str] = {}
|
|
118
|
+
for screen in screens:
|
|
119
|
+
screen_id = str(screen.get("id") or "")
|
|
120
|
+
source = screen.get("source", {}) if isinstance(screen.get("source"), dict) else {}
|
|
121
|
+
screen_by_evidence[(str(source.get("file") or ""), str(source.get("symbol") or screen.get("name") or ""))] = screen_id
|
|
122
|
+
if screen.get("fragment"):
|
|
123
|
+
screen_by_fragment[str(screen["fragment"])] = screen_id
|
|
124
|
+
|
|
125
|
+
def normalized(values: Iterable[Any]) -> list[str]:
|
|
126
|
+
return [re.sub(r"[^\w]+", " ", str(value).casefold()).strip() for value in values if str(value).strip()]
|
|
127
|
+
|
|
128
|
+
def contains_path(actual: list[str], expected: list[str]) -> bool:
|
|
129
|
+
if not expected:
|
|
130
|
+
return True
|
|
131
|
+
return any(actual[index:index + len(expected)] == expected for index in range(len(actual) - len(expected) + 1))
|
|
132
|
+
|
|
133
|
+
issues: list[dict[str, Any]] = []
|
|
134
|
+
evidence_by_fragment = {str(item.get("fragment")): item for item in discovered if item.get("fragment")}
|
|
135
|
+
for candidate in discovered:
|
|
136
|
+
key = (str(candidate.get("file") or ""), str(candidate.get("name") or ""))
|
|
137
|
+
screen_id = screen_by_evidence.get(key) or screen_by_fragment.get(str(candidate.get("fragment") or ""))
|
|
138
|
+
if not screen_id or screen_id not in paths:
|
|
139
|
+
continue
|
|
140
|
+
expected = normalized(candidate.get("groupPath", []))
|
|
141
|
+
actual = normalized(paths[screen_id])
|
|
142
|
+
if expected and not contains_path(actual, expected):
|
|
143
|
+
issues.append({
|
|
144
|
+
"code": "group-path-mismatch",
|
|
145
|
+
"screenId": screen_id,
|
|
146
|
+
"expected": candidate.get("groupPath", []),
|
|
147
|
+
"actual": paths[screen_id],
|
|
148
|
+
})
|
|
149
|
+
parent_fragment = str(candidate.get("parentFragment") or "")
|
|
150
|
+
if not parent_fragment:
|
|
151
|
+
continue
|
|
152
|
+
parent_candidate = evidence_by_fragment.get(parent_fragment, {})
|
|
153
|
+
parent_key = (str(parent_candidate.get("file") or ""), str(parent_candidate.get("name") or ""))
|
|
154
|
+
parent_id = screen_by_evidence.get(parent_key) or screen_by_fragment.get(parent_fragment)
|
|
155
|
+
if not parent_id or parent_id not in paths:
|
|
156
|
+
continue
|
|
157
|
+
parent_path = normalized(paths[parent_id])
|
|
158
|
+
child_path = normalized(paths[screen_id])
|
|
159
|
+
if len(child_path) <= len(parent_path) or child_path[:len(parent_path)] != parent_path:
|
|
160
|
+
issues.append({
|
|
161
|
+
"code": "parent-hierarchy-flattened",
|
|
162
|
+
"screenId": screen_id,
|
|
163
|
+
"parentScreenId": parent_id,
|
|
164
|
+
"parentPath": paths[parent_id],
|
|
165
|
+
"actual": paths[screen_id],
|
|
166
|
+
})
|
|
167
|
+
return issues
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def navigation_graph_issues(ir: dict[str, Any]) -> list[dict[str, Any]]:
|
|
171
|
+
discovered = [item for item in ir.get("discoveredScreens", []) if isinstance(item, dict)]
|
|
172
|
+
nested = [item for item in discovered if item.get("parentFragment")]
|
|
173
|
+
graph = ir.get("navigationGraph")
|
|
174
|
+
if not nested and graph is None:
|
|
175
|
+
return []
|
|
176
|
+
if not isinstance(graph, dict):
|
|
177
|
+
return [{"code": "navigation-graph-missing"}]
|
|
178
|
+
screen_ids = {str(item.get("id")) for item in ir.get("screens", []) if item.get("id")}
|
|
179
|
+
graph_ids = {str(item.get("screenId")) for item in graph.get("nodes", []) if isinstance(item, dict) and item.get("screenId")}
|
|
180
|
+
issues: list[dict[str, Any]] = []
|
|
181
|
+
if graph_ids != screen_ids:
|
|
182
|
+
issues.append({"code": "navigation-graph-node-mismatch", "missing": sorted(screen_ids - graph_ids), "unknown": sorted(graph_ids - screen_ids)})
|
|
183
|
+
edges = {
|
|
184
|
+
(str(item.get("from")), str(item.get("to")), str(item.get("kind")))
|
|
185
|
+
for item in graph.get("edges", []) if isinstance(item, dict)
|
|
186
|
+
}
|
|
187
|
+
for source, target, kind in edges:
|
|
188
|
+
if source not in screen_ids or target not in screen_ids:
|
|
189
|
+
issues.append({"code": "navigation-graph-edge-invalid", "from": source, "to": target, "kind": kind})
|
|
190
|
+
screen_by_fragment = {str(item.get("fragment")): str(item.get("id")) for item in ir.get("screens", []) if item.get("fragment")}
|
|
191
|
+
candidate_screen = {
|
|
192
|
+
str(item.get("fragment")): screen_by_fragment.get(str(item.get("fragment")))
|
|
193
|
+
for item in discovered if item.get("fragment")
|
|
194
|
+
}
|
|
195
|
+
for item in nested:
|
|
196
|
+
child_id = candidate_screen.get(str(item.get("fragment")))
|
|
197
|
+
parent_id = candidate_screen.get(str(item.get("parentFragment")))
|
|
198
|
+
if child_id and parent_id and (parent_id, child_id, "open-logical-view") not in edges:
|
|
199
|
+
issues.append({"code": "navigation-parent-edge-missing", "from": parent_id, "to": child_id})
|
|
200
|
+
return issues
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def node_screen_map(ir: dict[str, Any]) -> dict[str, set[str]]:
|
|
204
|
+
nodes = ir.get("nodes", {})
|
|
205
|
+
result: dict[str, set[str]] = {str(node_id): set() for node_id in nodes}
|
|
206
|
+
|
|
207
|
+
def visit(node_id: str, screen_id: str, seen: set[str]) -> None:
|
|
208
|
+
if node_id in seen or node_id not in nodes:
|
|
209
|
+
return
|
|
210
|
+
seen.add(node_id)
|
|
211
|
+
result.setdefault(node_id, set()).add(screen_id)
|
|
212
|
+
for child in nodes[node_id].get("children", []):
|
|
213
|
+
visit(str(child), screen_id, seen)
|
|
214
|
+
|
|
215
|
+
for screen in ir.get("screens", []):
|
|
216
|
+
visit(str(screen.get("root", "")), str(screen.get("id", "")), set())
|
|
217
|
+
return result
|