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
ir_contracts.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Canonical interaction and navigation contracts shared by UIDW stages."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
INTERACTION_CONTRACT_VERSION = 1
|
|
10
|
+
DIAGNOSTICS_ENGINE_VERSION = 3
|
|
11
|
+
|
|
12
|
+
NATIVE_INTERACTIVE_NODE_TYPES = frozenset({"button", "input"})
|
|
13
|
+
INTERACTIVE_SEMANTIC_ROLES = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"button",
|
|
16
|
+
"checkbox",
|
|
17
|
+
"combobox",
|
|
18
|
+
"gridcell",
|
|
19
|
+
"link",
|
|
20
|
+
"menuitem",
|
|
21
|
+
"option",
|
|
22
|
+
"radio",
|
|
23
|
+
"searchbox",
|
|
24
|
+
"slider",
|
|
25
|
+
"spinbutton",
|
|
26
|
+
"switch",
|
|
27
|
+
"tab",
|
|
28
|
+
"textbox",
|
|
29
|
+
"treeitem",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
SUPPORTED_ACTION_TYPES = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"navigate",
|
|
35
|
+
"back",
|
|
36
|
+
"set-node-state",
|
|
37
|
+
"toggle-node-state",
|
|
38
|
+
"toggle",
|
|
39
|
+
"reset-state",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def interaction_contract(node: dict[str, Any]) -> dict[str, Any]:
|
|
45
|
+
"""Return the only supported interpretation of a node's interactivity."""
|
|
46
|
+
|
|
47
|
+
node_type = str(node.get("type", "")).strip().lower()
|
|
48
|
+
action_type = str(node.get("action", {}).get("type", "")).strip()
|
|
49
|
+
semantic_role = str(node.get("semantics", {}).get("role", "")).strip().lower()
|
|
50
|
+
sources: list[str] = []
|
|
51
|
+
if node_type in NATIVE_INTERACTIVE_NODE_TYPES:
|
|
52
|
+
sources.append("native-node")
|
|
53
|
+
if action_type in SUPPORTED_ACTION_TYPES:
|
|
54
|
+
sources.append("action")
|
|
55
|
+
if semantic_role in INTERACTIVE_SEMANTIC_ROLES:
|
|
56
|
+
sources.append("semantic-role")
|
|
57
|
+
return {
|
|
58
|
+
"version": INTERACTION_CONTRACT_VERSION,
|
|
59
|
+
"interactive": bool(sources),
|
|
60
|
+
"sources": sources,
|
|
61
|
+
"nodeType": node_type,
|
|
62
|
+
"actionType": action_type,
|
|
63
|
+
"semanticRole": semantic_role,
|
|
64
|
+
"keyboardExpected": bool(sources),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def browser_interaction_contract() -> dict[str, Any]:
|
|
69
|
+
"""Serializable constants embedded into the standalone workbench."""
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
"version": INTERACTION_CONTRACT_VERSION,
|
|
73
|
+
"nativeNodeTypes": sorted(NATIVE_INTERACTIVE_NODE_TYPES),
|
|
74
|
+
"semanticRoles": sorted(INTERACTIVE_SEMANTIC_ROLES),
|
|
75
|
+
"actionTypes": sorted(SUPPORTED_ACTION_TYPES),
|
|
76
|
+
}
|
native_render_android.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only Android native-render provider discovery.
|
|
3
|
+
|
|
4
|
+
Discovery never invokes Gradle, starts an emulator, or edits the target repo.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Iterator
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
IGNORED_DIRS = {".git", ".gradle", ".idea", ".ui-design-workbench", "build", "dist", "node_modules", "out", "vendor"}
|
|
18
|
+
TEXT_LIMIT = 2 * 1024 * 1024
|
|
19
|
+
FILE_LIMIT = 30000
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _relative(path: Path, root: Path) -> str:
|
|
23
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _files(root: Path) -> Iterator[Path]:
|
|
27
|
+
seen = 0
|
|
28
|
+
for directory, names, files in os.walk(root):
|
|
29
|
+
names[:] = [name for name in names if name not in IGNORED_DIRS and not name.startswith(".")]
|
|
30
|
+
for name in files:
|
|
31
|
+
seen += 1
|
|
32
|
+
if seen > FILE_LIMIT:
|
|
33
|
+
return
|
|
34
|
+
yield Path(directory) / name
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _read(path: Path) -> str:
|
|
38
|
+
try:
|
|
39
|
+
if path.stat().st_size > TEXT_LIMIT:
|
|
40
|
+
return ""
|
|
41
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
42
|
+
except OSError:
|
|
43
|
+
return ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fingerprint(root: Path, files: list[Path]) -> str:
|
|
47
|
+
digest = hashlib.sha256()
|
|
48
|
+
for path in sorted(set(files)):
|
|
49
|
+
try:
|
|
50
|
+
stat = path.stat()
|
|
51
|
+
except OSError:
|
|
52
|
+
continue
|
|
53
|
+
digest.update(f"{_relative(path, root)}\0{stat.st_size}\0{stat.st_mtime_ns}\n".encode("utf-8"))
|
|
54
|
+
return digest.hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _settings_modules(text: str) -> set[str]:
|
|
58
|
+
modules: set[str] = set()
|
|
59
|
+
for match in re.finditer(r"(?ms)^\s*include\s*\((.*?)\)", text):
|
|
60
|
+
modules.update(re.findall(r"[\"'](:[^\"']+)[\"']", match.group(1)))
|
|
61
|
+
for match in re.finditer(r"(?m)^\s*include\s+([^\n]+)", text):
|
|
62
|
+
modules.update(re.findall(r"[\"'](:[^\"']+)[\"']", match.group(1)))
|
|
63
|
+
return modules
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _provider(provider_id: str, configured: bool, reason: str, supports: list[str]) -> dict[str, Any]:
|
|
67
|
+
return {
|
|
68
|
+
"id": provider_id,
|
|
69
|
+
"platform": "android",
|
|
70
|
+
"status": "configured" if configured else "not-configured",
|
|
71
|
+
"availableFidelityTier": "native-preview",
|
|
72
|
+
"supports": supports,
|
|
73
|
+
"reason": reason,
|
|
74
|
+
"execution": "explicit-only",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def discover_android(root: Path) -> dict[str, Any]:
|
|
79
|
+
root = root.resolve()
|
|
80
|
+
all_files = list(_files(root))
|
|
81
|
+
settings = [path for path in all_files if path.name in {"settings.gradle", "settings.gradle.kts"}]
|
|
82
|
+
build_files = [path for path in all_files if path.name in {"build.gradle", "build.gradle.kts", "libs.versions.toml", "gradle.properties"}]
|
|
83
|
+
manifests = [path for path in all_files if path.name == "AndroidManifest.xml"]
|
|
84
|
+
kotlin = [path for path in all_files if path.suffix.lower() in {".kt", ".kts"} and path.name not in {"build.gradle.kts", "settings.gradle.kts"}]
|
|
85
|
+
layouts = [
|
|
86
|
+
path for path in all_files
|
|
87
|
+
if path.suffix.lower() == ".xml" and any(part.startswith("layout") for part in path.parts) and "res" in path.parts
|
|
88
|
+
]
|
|
89
|
+
resources = [path for path in all_files if "res" in path.parts and "src" in path.parts]
|
|
90
|
+
settings_text = {path: _read(path) for path in settings}
|
|
91
|
+
build_text = {path: _read(path) for path in build_files}
|
|
92
|
+
combined_build = "\n".join(build_text.values())
|
|
93
|
+
modules = sorted({module for text in settings_text.values() for module in _settings_modules(text)})
|
|
94
|
+
android_markers = (
|
|
95
|
+
"com.android.application",
|
|
96
|
+
"com.android.library",
|
|
97
|
+
"com.android.test",
|
|
98
|
+
"com.android.dynamic-feature",
|
|
99
|
+
"android {",
|
|
100
|
+
)
|
|
101
|
+
detected = bool(manifests or any(marker in combined_build for marker in android_markers))
|
|
102
|
+
wrapper = next((path for path in (root / "gradlew.bat", root / "gradlew") if path.is_file()), None)
|
|
103
|
+
|
|
104
|
+
preview_entries: list[dict[str, Any]] = []
|
|
105
|
+
compose_function_count = 0
|
|
106
|
+
screenshot_test_count = 0
|
|
107
|
+
for path in kotlin:
|
|
108
|
+
text = _read(path)
|
|
109
|
+
if not text:
|
|
110
|
+
continue
|
|
111
|
+
compose_function_count += len(re.findall(r"@Composable\b", text))
|
|
112
|
+
screenshot_test_count += len(re.findall(r"(?:Paparazzi|Roborazzi|PreviewTest|captureRoboImage|snapshot\s*\()", text))
|
|
113
|
+
for match in re.finditer(r"@(?:[\w.]*Preview)\b(?:\s*\([^)]*\))?[\s\S]{0,800}?\bfun\s+(\w+)\s*\(", text):
|
|
114
|
+
preview_entries.append({"symbol": match.group(1), "file": _relative(path, root)})
|
|
115
|
+
|
|
116
|
+
official = "com.android.compose.screenshot" in combined_build or "android.experimental.enableScreenshotTest" in combined_build
|
|
117
|
+
paparazzi = "app.cash.paparazzi" in combined_build or "paparazzi-gradle-plugin" in combined_build
|
|
118
|
+
roborazzi = "io.github.takahirom.roborazzi" in combined_build or re.search(r"\broborazzi\b", combined_build, re.I) is not None
|
|
119
|
+
providers = [
|
|
120
|
+
_provider("android-compose-screenshot", official, "Official Compose screenshot plugin marker found." if official else "Compose screenshot plugin is not configured.", ["compose"]),
|
|
121
|
+
_provider("paparazzi", paparazzi, "Paparazzi marker found; Layoutlib capture can run without an emulator." if paparazzi else "Paparazzi is not configured.", ["compose", "views"]),
|
|
122
|
+
_provider("roborazzi", roborazzi, "Roborazzi marker found." if roborazzi else "Roborazzi is not configured.", ["compose", "views"]),
|
|
123
|
+
]
|
|
124
|
+
sdk_present = bool(os.environ.get("ANDROID_SDK_ROOT") or os.environ.get("ANDROID_HOME"))
|
|
125
|
+
emulator_ready = bool(detected and wrapper and sdk_present and (shutil.which("emulator") or shutil.which("emulator.exe")))
|
|
126
|
+
providers.append({
|
|
127
|
+
"id": "android-emulator",
|
|
128
|
+
"platform": "android",
|
|
129
|
+
"status": "configured" if emulator_ready else "available" if detected else "not-applicable",
|
|
130
|
+
"availableFidelityTier": "device-verified",
|
|
131
|
+
"supports": ["compose", "views", "runtime-state"],
|
|
132
|
+
"reason": "Gradle wrapper, Android SDK, and emulator executable are available." if emulator_ready else "Requires an explicit emulator run with a configured SDK and Gradle wrapper.",
|
|
133
|
+
"execution": "explicit-only",
|
|
134
|
+
})
|
|
135
|
+
configured = [provider["id"] for provider in providers if provider["status"] == "configured"]
|
|
136
|
+
inputs = settings + build_files + manifests + resources + kotlin
|
|
137
|
+
return {
|
|
138
|
+
"platform": "android",
|
|
139
|
+
"status": "detected" if detected else "not-applicable",
|
|
140
|
+
"sourceFingerprint": _fingerprint(root, inputs),
|
|
141
|
+
"project": {
|
|
142
|
+
"gradleWrapper": _relative(wrapper, root) if wrapper else None,
|
|
143
|
+
"settingsFiles": [_relative(path, root) for path in settings],
|
|
144
|
+
"modules": modules,
|
|
145
|
+
"manifestCount": len(manifests),
|
|
146
|
+
"composeFunctionCount": compose_function_count,
|
|
147
|
+
"previewEntryCount": len(preview_entries),
|
|
148
|
+
"layoutResourceCount": len(layouts),
|
|
149
|
+
"screenshotTestMarkerCount": screenshot_test_count,
|
|
150
|
+
},
|
|
151
|
+
"entryPoints": preview_entries[:200],
|
|
152
|
+
"providers": providers,
|
|
153
|
+
"recommendedProvider": configured[0] if configured else None,
|
|
154
|
+
"currentFidelityTier": "structural",
|
|
155
|
+
"nativeExecutionStarted": False,
|
|
156
|
+
"limitations": [
|
|
157
|
+
"Discovery does not execute Gradle or render a screen.",
|
|
158
|
+
"A configured provider is capability evidence, not visual fidelity evidence.",
|
|
159
|
+
],
|
|
160
|
+
}
|
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
|
+
}
|
|
@@ -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()
|
|
@@ -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
|