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.
Files changed (73) hide show
  1. android_resource_resolver.py +370 -0
  2. android_xml_support.py +230 -0
  3. apple_resource_resolver.py +141 -0
  4. coverage_report.py +134 -0
  5. fidelity_adapter_api.py +89 -0
  6. fidelity_adapters.py +508 -0
  7. fidelity_core.py +241 -0
  8. fidelity_platform_adapters.py +902 -0
  9. ir_contracts.py +76 -0
  10. native_render_android.py +160 -0
  11. native_render_apple.py +165 -0
  12. native_render_contracts.py +148 -0
  13. native_render_registry.py +86 -0
  14. quality_common.py +217 -0
  15. render_preview.py +1797 -0
  16. scan_ui.py +1485 -0
  17. scoped_context.py +458 -0
  18. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/SKILL.md +52 -0
  19. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/adapter-api.md +20 -0
  20. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/agent-integrations.md +39 -0
  21. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/cache-protocol.md +61 -0
  22. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/design-modes.md +109 -0
  23. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/fidelity.md +53 -0
  24. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ir-schema.md +412 -0
  25. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/native-rendering.md +40 -0
  26. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platform-profiles.json +84 -0
  27. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platform-standards.md +131 -0
  28. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platforms.md +40 -0
  29. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/quality-automation.md +77 -0
  30. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/review-workflow.md +135 -0
  31. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-guidance-mode.md +22 -0
  32. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-policy-schema.md +83 -0
  33. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-reviewer.md +175 -0
  34. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/validation.md +34 -0
  35. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/workbench-ui.md +108 -0
  36. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/native-render-state.schema.json +71 -0
  37. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-agent-job.schema.json +36 -0
  38. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-graph.schema.json +46 -0
  39. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-ir.patch.schema.json +36 -0
  40. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-ir.schema.json +296 -0
  41. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/uidw-config.schema.json +68 -0
  42. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/android_resource_resolver.py +370 -0
  43. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/android_xml_support.py +230 -0
  44. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/apple_resource_resolver.py +141 -0
  45. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/coverage_report.py +134 -0
  46. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_adapter_api.py +89 -0
  47. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_adapters.py +508 -0
  48. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_core.py +241 -0
  49. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_platform_adapters.py +902 -0
  50. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/generate_interaction_matrix.py +166 -0
  51. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/ir_contracts.py +76 -0
  52. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/merge_review_state.py +267 -0
  53. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_android.py +160 -0
  54. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_apple.py +165 -0
  55. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_contracts.py +148 -0
  56. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_registry.py +86 -0
  57. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/quality_common.py +217 -0
  58. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/render_preview.py +1797 -0
  59. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/scan_ui.py +1485 -0
  60. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/scoped_context.py +458 -0
  61. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/smoke_preview.js +978 -0
  62. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/uidw.py +2821 -0
  63. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/uidw_mcp.py +282 -0
  64. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/validate_platform_profiles.py +93 -0
  65. ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/visual_regression.py +141 -0
  66. ui_design_workbench_cli-0.5.0.dist-info/METADATA +133 -0
  67. ui_design_workbench_cli-0.5.0.dist-info/RECORD +73 -0
  68. ui_design_workbench_cli-0.5.0.dist-info/WHEEL +5 -0
  69. ui_design_workbench_cli-0.5.0.dist-info/entry_points.txt +3 -0
  70. ui_design_workbench_cli-0.5.0.dist-info/top_level.txt +20 -0
  71. uidw.py +2821 -0
  72. uidw_mcp.py +282 -0
  73. validate_platform_profiles.py +93 -0
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env python3
2
+ """Generate a bounded interaction/state scenario plan from UI IR actions and states."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import copy
8
+ import re
9
+ import sys
10
+ from collections import defaultdict
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from quality_common import node_screen_map, profile_catalog, read_json, target_families, write_json
15
+
16
+
17
+ def slug(value: str) -> str:
18
+ return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "item"
19
+
20
+
21
+ def action_step(node_id: str, action: dict[str, Any]) -> dict[str, Any]:
22
+ return {
23
+ "type": "activate",
24
+ "nodeId": node_id,
25
+ "actionType": action.get("type"),
26
+ "target": action.get("target"),
27
+ "state": action.get("state"),
28
+ "offState": action.get("offState"),
29
+ }
30
+
31
+
32
+ def expected_for(action: dict[str, Any]) -> list[str]:
33
+ kind = action.get("type")
34
+ if kind == "navigate":
35
+ return [f"active screen becomes {action.get('target')}", "browser history and screen tree agree"]
36
+ if kind == "back":
37
+ return ["previous reachable screen is restored", "focus returns predictably"]
38
+ if kind in {"toggle", "toggle-node-state"}:
39
+ return [f"target alternates between {action.get('state')} and {action.get('offState', 'default')}", "visible and semantic state stay synchronized"]
40
+ if kind == "set-node-state":
41
+ return [f"target state becomes {action.get('state')}", "dependent controls and visible values agree"]
42
+ if kind == "reset-state":
43
+ return ["affected state returns to its declared default", "visible value and internal state agree"]
44
+ return ["declared action produces visible feedback", "focus and geometry remain valid"]
45
+
46
+
47
+ def build_matrix(ir: dict[str, Any], max_pairs: int = 24) -> dict[str, Any]:
48
+ nodes = ir.get("nodes", {})
49
+ screen_map = node_screen_map(ir)
50
+ by_screen: dict[str, list[tuple[str, dict[str, Any]]]] = defaultdict(list)
51
+ scenarios: list[dict[str, Any]] = []
52
+
53
+ for node_id, node in sorted(nodes.items()):
54
+ action = node.get("action") if isinstance(node.get("action"), dict) else {}
55
+ if not action.get("type"):
56
+ continue
57
+ screens = sorted(screen_map.get(str(node_id), [])) or [None]
58
+ for screen_id in screens:
59
+ by_screen[str(screen_id or "global")].append((str(node_id), action))
60
+ base = f"{screen_id or 'global'}-{node_id}-{action['type']}"
61
+ step = action_step(str(node_id), action)
62
+ scenarios.append({
63
+ "id": f"single-{slug(base)}",
64
+ "kind": "single",
65
+ "screenId": screen_id,
66
+ "nodeIds": [node_id],
67
+ "startState": "declared default",
68
+ "steps": [step],
69
+ "assertions": expected_for(action) + ["no clipping, overlap, or unexpected scroll jump"],
70
+ })
71
+ scenarios.append({
72
+ "id": f"repeat-{slug(base)}",
73
+ "kind": "repeated",
74
+ "screenId": screen_id,
75
+ "nodeIds": [node_id],
76
+ "startState": "declared default",
77
+ "steps": [step, step],
78
+ "assertions": expected_for(action) + ["second activation is deterministic and does not duplicate transient UI"],
79
+ })
80
+ if action.get("type") in {"toggle", "toggle-node-state", "set-node-state", "reset-state"}:
81
+ scenarios.append({
82
+ "id": f"reverse-{slug(base)}",
83
+ "kind": "reverse",
84
+ "screenId": screen_id,
85
+ "nodeIds": [node_id, str(action.get("target") or node_id)],
86
+ "startState": "declared default",
87
+ "steps": [step, {"type": "reverse-or-reset", "nodeId": node_id, "target": action.get("target")}],
88
+ "assertions": ["initial visible, semantic, and geometry state is restored", "no stale labels, pressed states, or dependent values remain"],
89
+ })
90
+
91
+ pair_count = 0
92
+ for screen_id, controls in sorted(by_screen.items()):
93
+ for index, first in enumerate(controls):
94
+ for second in controls[index + 1:]:
95
+ if pair_count >= max_pairs:
96
+ break
97
+ first_id, first_action = first
98
+ second_id, second_action = second
99
+ scenarios.append({
100
+ "id": f"chain-{slug(screen_id)}-{slug(first_id)}-{slug(second_id)}",
101
+ "kind": "chained",
102
+ "screenId": None if screen_id == "global" else screen_id,
103
+ "nodeIds": [first_id, second_id],
104
+ "startState": "declared default",
105
+ "steps": [action_step(first_id, first_action), action_step(second_id, second_action)],
106
+ "assertions": ["the second control resolves or composes with the first state", "only one mutually exclusive surface remains open", "focus, history, visible values, and geometry remain consistent"],
107
+ })
108
+ pair_count += 1
109
+ if pair_count >= max_pairs:
110
+ break
111
+
112
+ for node_id, node in sorted(nodes.items()):
113
+ if node.get("type") != "input" or node.get("inputType") != "range":
114
+ continue
115
+ semantics = node.get("semantics", {})
116
+ minimum = node.get("min", semantics.get("min", 0))
117
+ maximum = node.get("max", semantics.get("max", 100))
118
+ midpoint = (minimum + maximum) / 2 if isinstance(minimum, (int, float)) and isinstance(maximum, (int, float)) else None
119
+ scenarios.append({
120
+ "id": f"boundary-{slug(str(node_id))}",
121
+ "kind": "boundary",
122
+ "screenId": next(iter(sorted(screen_map.get(str(node_id), []))), None),
123
+ "nodeIds": [node_id],
124
+ "startState": "declared default",
125
+ "steps": [{"type": "set-value", "nodeId": node_id, "value": value} for value in (minimum, midpoint, maximum) if value is not None],
126
+ "assertions": ["visible value matches the control value at min, midpoint, and max", "dependent geometry remains inside scroll bounds"],
127
+ })
128
+
129
+ profiles = profile_catalog().get("profiles", {})
130
+ required_states = sorted({state for profile in profiles.values() if profile.get("family") in target_families(ir) for state in profile.get("requiredInteractionStates", [])})
131
+ workbench = [
132
+ {"id": "workbench-zoom-cycle", "kind": "workbench", "steps": ["Fit", "20%", "100%", "200%", "Reset"], "assertions": ["one scale state drives geometry and label", "no card reflow or overlap", "scroll bounds match scaled canvas"]},
133
+ {"id": "workbench-menu-exclusivity", "kind": "workbench", "steps": ["open each menu", "click outside", "Escape", "change view"], "assertions": ["at most one menu is open", "focus returns to its trigger"]},
134
+ {"id": "workbench-panel-continuity", "kind": "workbench", "steps": ["select node", "resize panel", "hide panel", "restore panel", "change viewport"], "assertions": ["selection and scroll are preserved", "compact panels are mutually exclusive"]},
135
+ ]
136
+ return {
137
+ "version": 1,
138
+ "status": "ready" if scenarios else "empty",
139
+ "source": {"screens": len(ir.get("screens", [])), "actionNodes": sum(1 for node in nodes.values() if node.get("action", {}).get("type"))},
140
+ "requiredPlatformStates": required_states,
141
+ "scenarioCount": len(scenarios) + len(workbench),
142
+ "scenarios": scenarios + workbench,
143
+ "limits": {"maxCrossControlPairs": max_pairs, "generatedCrossControlPairs": pair_count},
144
+ }
145
+
146
+
147
+ def main() -> int:
148
+ parser = argparse.ArgumentParser(description=__doc__)
149
+ parser.add_argument("ir", help="Path to ui-ir.json")
150
+ parser.add_argument("--output", required=True, help="Path for ui-interaction-matrix.json")
151
+ parser.add_argument("--merge-output", help="Optional new IR containing review.scenarioPlan")
152
+ parser.add_argument("--max-pairs", type=int, default=24, help="Maximum generated cross-control pairs")
153
+ args = parser.parse_args()
154
+ ir = read_json(args.ir)
155
+ matrix = build_matrix(ir, max(0, args.max_pairs))
156
+ write_json(args.output, matrix)
157
+ if args.merge_output:
158
+ merged = copy.deepcopy(ir)
159
+ merged.setdefault("review", {})["scenarioPlan"] = matrix
160
+ write_json(args.merge_output, merged)
161
+ print(f"Interaction matrix: {matrix['scenarioCount']} scenarios")
162
+ return 0
163
+
164
+
165
+ if __name__ == "__main__":
166
+ sys.exit(main())
@@ -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
+ }
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env python3
2
+ """Merge exported HTML review feedback into UI IR without touching app source."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ ALLOWED_STATUSES = {"new", "in-progress", "proposed", "accepted", "rejected", "resolved"}
14
+ FIX_REQUEST_TYPES = {"ui-design-workbench-fix-request", "ui-code-preview-fix-request"}
15
+ ALLOWED_DECISIONS = {"pending", "accepted", "rejected"}
16
+ ALLOWED_FINDING_DECISIONS = {"accepted", "rejected", "deferred"}
17
+ ALLOWED_SEVERITIES = {"blocker", "high", "medium", "low"}
18
+ ALLOWED_CONFIDENCE = {"high", "medium", "low"}
19
+ ALLOWED_EFFORT = {"small", "medium", "large"}
20
+ ALLOWED_EVIDENCE = {
21
+ "requirement", "user-feedback", "research", "analytics", "source",
22
+ "project-pattern", "platform-standard", "accessibility-standard", "heuristic",
23
+ }
24
+
25
+
26
+ def load_object(path: Path, label: str) -> dict[str, Any]:
27
+ try:
28
+ value = json.loads(path.read_text(encoding="utf-8"))
29
+ except (OSError, json.JSONDecodeError) as exc:
30
+ raise ValueError(f"Cannot read {label}: {exc}") from exc
31
+ if not isinstance(value, dict):
32
+ raise ValueError(f"{label} must be a JSON object")
33
+ return value
34
+
35
+
36
+ def validate_runtime_findings(
37
+ runtime_findings: Any,
38
+ *,
39
+ nodes: dict[str, Any],
40
+ screen_ids: set[Any],
41
+ existing_ids: set[Any],
42
+ ) -> tuple[list[str], set[str]]:
43
+ errors: list[str] = []
44
+ valid_ids: set[str] = set()
45
+ if runtime_findings is None:
46
+ return errors, valid_ids
47
+ if not isinstance(runtime_findings, list):
48
+ return ["Feedback runtimeFindings must be an array"], valid_ids
49
+ required = (
50
+ "id", "title", "category", "severity", "confidence", "screenId",
51
+ "observation", "impact", "recommendation", "effort",
52
+ )
53
+ for index, finding in enumerate(runtime_findings):
54
+ if not isinstance(finding, dict):
55
+ errors.append(f"runtimeFindings[{index}] must be an object")
56
+ continue
57
+ finding_id = finding.get("id")
58
+ for field in required:
59
+ if not finding.get(field):
60
+ errors.append(f"runtimeFindings[{index}] is missing {field}")
61
+ if finding_id in existing_ids or finding_id in valid_ids:
62
+ errors.append(f"Duplicate runtime finding id: {finding_id}")
63
+ elif finding_id:
64
+ valid_ids.add(finding_id)
65
+ if finding.get("severity") not in ALLOWED_SEVERITIES:
66
+ errors.append(f"Runtime finding {finding_id} has invalid severity")
67
+ if finding.get("confidence") not in ALLOWED_CONFIDENCE:
68
+ errors.append(f"Runtime finding {finding_id} has invalid confidence")
69
+ if finding.get("effort") not in ALLOWED_EFFORT:
70
+ errors.append(f"Runtime finding {finding_id} has invalid effort")
71
+ if finding.get("screenId") not in screen_ids:
72
+ errors.append(f"Runtime finding {finding_id} references a missing screen")
73
+ if finding.get("nodeId") and finding.get("nodeId") not in nodes:
74
+ errors.append(f"Runtime finding {finding_id} references a missing node")
75
+ if not finding.get("proposalVersionId") and not finding.get("noProposalReason"):
76
+ errors.append(f"Runtime finding {finding_id} needs noProposalReason")
77
+ evidence = finding.get("evidence")
78
+ if not isinstance(evidence, list) or not evidence:
79
+ errors.append(f"Runtime finding {finding_id} needs evidence")
80
+ else:
81
+ for evidence_index, item in enumerate(evidence):
82
+ if (
83
+ not isinstance(item, dict)
84
+ or item.get("type") not in ALLOWED_EVIDENCE
85
+ or not item.get("ref")
86
+ or not item.get("note")
87
+ ):
88
+ errors.append(
89
+ f"Runtime finding {finding_id} has invalid evidence[{evidence_index}]"
90
+ )
91
+ return errors, valid_ids
92
+
93
+
94
+ def validate_feedback(ir: dict[str, Any], feedback: dict[str, Any]) -> list[str]:
95
+ errors: list[str] = []
96
+ review = ir.get("review", {})
97
+ nodes = ir.get("nodes", {})
98
+ screen_ids = {item.get("id") for item in ir.get("screens", [])}
99
+ version_ids = {item.get("id") for item in review.get("versions", [])}
100
+ if feedback.get("version") not in {1, 2, 3}:
101
+ errors.append("Feedback version must be 1, 2, or 3")
102
+ if review.get("sessionId") and feedback.get("sessionId") != review.get("sessionId"):
103
+ errors.append("Feedback sessionId does not match this IR review session")
104
+ if feedback.get("activeVersion") not in version_ids:
105
+ errors.append("Feedback activeVersion is not present in the IR")
106
+ if feedback.get("versionDecision", "pending") not in ALLOWED_DECISIONS:
107
+ errors.append("Feedback versionDecision is invalid")
108
+ finding_ids = {
109
+ item.get("id") for item in review.get("audit", {}).get("findings", [])
110
+ if isinstance(item, dict) and item.get("id")
111
+ }
112
+ runtime_errors, runtime_finding_ids = validate_runtime_findings(
113
+ feedback.get("runtimeFindings", []),
114
+ nodes=nodes,
115
+ screen_ids=screen_ids,
116
+ existing_ids=finding_ids,
117
+ )
118
+ errors.extend(runtime_errors)
119
+ finding_ids.update(runtime_finding_ids)
120
+ finding_decisions = feedback.get("findingDecisions", {})
121
+ if not isinstance(finding_decisions, dict):
122
+ errors.append("Feedback findingDecisions must be an object")
123
+ else:
124
+ for finding_id, decision in finding_decisions.items():
125
+ if finding_id not in finding_ids:
126
+ errors.append(f"Feedback references missing finding {finding_id}")
127
+ if decision not in ALLOWED_FINDING_DECISIONS:
128
+ errors.append(f"Finding {finding_id} has invalid decision {decision}")
129
+ diagnostics = feedback.get("diagnostics")
130
+ if diagnostics is not None:
131
+ if not isinstance(diagnostics, dict):
132
+ errors.append("Feedback diagnostics must be an object or null")
133
+ else:
134
+ if diagnostics.get("version") not in {1, 2}:
135
+ errors.append("Feedback diagnostics version must be 1 or 2")
136
+ if diagnostics.get("status") != "complete":
137
+ errors.append("Feedback diagnostics status must be complete")
138
+ if not isinstance(diagnostics.get("summary"), dict):
139
+ errors.append("Feedback diagnostics summary must be an object")
140
+ if not isinstance(diagnostics.get("checks"), list):
141
+ errors.append("Feedback diagnostics checks must be an array")
142
+ annotation_ids: set[str] = set()
143
+ for index, item in enumerate(feedback.get("annotations", [])):
144
+ if not isinstance(item, dict):
145
+ errors.append(f"annotations[{index}] must be an object")
146
+ continue
147
+ annotation_id = item.get("id")
148
+ if not annotation_id:
149
+ errors.append(f"annotations[{index}] is missing id")
150
+ elif annotation_id in annotation_ids:
151
+ errors.append(f"Duplicate annotation id: {annotation_id}")
152
+ annotation_ids.add(annotation_id or "")
153
+ if not str(item.get("text", "")).strip():
154
+ errors.append(f"Annotation {annotation_id} has no text")
155
+ if item.get("nodeId") and item.get("nodeId") not in nodes:
156
+ errors.append(f"Annotation {annotation_id} references missing node {item.get('nodeId')}")
157
+ if item.get("screenId") and item.get("screenId") not in screen_ids:
158
+ errors.append(f"Annotation {annotation_id} references missing screen {item.get('screenId')}")
159
+ if item.get("versionId") and item.get("versionId") not in version_ids:
160
+ errors.append(f"Annotation {annotation_id} references missing version {item.get('versionId')}")
161
+ if item.get("status", "new") not in ALLOWED_STATUSES:
162
+ errors.append(f"Annotation {annotation_id} has invalid status {item.get('status')}")
163
+ return errors
164
+
165
+
166
+ def validate_fix_request(ir: dict[str, Any], request: dict[str, Any]) -> list[str]:
167
+ if request.get("type") not in FIX_REQUEST_TYPES:
168
+ return []
169
+ errors: list[str] = []
170
+ accepted = request.get("acceptedFindingIds", [])
171
+ if request.get("version") not in {1, 2}:
172
+ errors.append("Fix request version must be 1 or 2")
173
+ if not isinstance(accepted, list) or not accepted:
174
+ errors.append("Fix request acceptedFindingIds must be a non-empty array")
175
+ accepted = []
176
+ if len(accepted) != len(set(accepted)):
177
+ errors.append("Fix request acceptedFindingIds contains duplicates")
178
+ finding_ids = {
179
+ item.get("id") for item in ir.get("review", {}).get("audit", {}).get("findings", [])
180
+ if isinstance(item, dict) and item.get("id")
181
+ }
182
+ review_feedback = request.get("reviewFeedback", {})
183
+ finding_ids.update(
184
+ item.get("id") for item in review_feedback.get("runtimeFindings", [])
185
+ if isinstance(item, dict) and item.get("id")
186
+ )
187
+ decisions = review_feedback.get("findingDecisions", {})
188
+ for finding_id in accepted:
189
+ if finding_id not in finding_ids:
190
+ errors.append(f"Fix request references missing finding {finding_id}")
191
+ if decisions.get(finding_id) != "accepted":
192
+ errors.append(f"Fix request finding {finding_id} is not accepted in reviewFeedback")
193
+ return errors
194
+
195
+
196
+ def merge(ir: dict[str, Any], feedback: dict[str, Any]) -> dict[str, Any]:
197
+ review = ir.setdefault("review", {})
198
+ audit = review.setdefault("audit", {})
199
+ merged_findings = {
200
+ item.get("id"): item for item in audit.get("findings", [])
201
+ if isinstance(item, dict) and item.get("id")
202
+ }
203
+ for item in feedback.get("runtimeFindings", []):
204
+ merged_findings[item["id"]] = item
205
+ if merged_findings:
206
+ audit["findings"] = list(merged_findings.values())
207
+ existing = {
208
+ item.get("id"): item for item in review.get("annotations", [])
209
+ if isinstance(item, dict) and item.get("id")
210
+ }
211
+ for item in feedback.get("annotations", []):
212
+ existing[item["id"]] = item
213
+ review["annotations"] = list(existing.values())
214
+ review["activeVersion"] = feedback["activeVersion"]
215
+ review["versionDecision"] = feedback.get("versionDecision", "pending")
216
+ if feedback.get("findingDecisions"):
217
+ audit["findingDecisions"] = feedback["findingDecisions"]
218
+ if feedback.get("diagnostics"):
219
+ review["diagnosticsReport"] = feedback["diagnostics"]
220
+ review["feedbackExportedAt"] = feedback.get("exportedAt")
221
+ review["feedbackRevision"] = feedback.get("revision")
222
+ return ir
223
+
224
+
225
+ def parse_args() -> argparse.Namespace:
226
+ parser = argparse.ArgumentParser(description="Merge exported ui-review-state.json or ui-fix-request.json into ui-ir.json.")
227
+ parser.add_argument("ir", type=Path, help="Existing ui-ir.json")
228
+ parser.add_argument("feedback", type=Path, help="Exported ui-review-state.json (legacy ui-review-feedback.json is accepted)")
229
+ parser.add_argument("--output", type=Path, required=True, help="Output IR; use a new file for auditability")
230
+ return parser.parse_args()
231
+
232
+
233
+ def main() -> int:
234
+ args = parse_args()
235
+ try:
236
+ ir = load_object(args.ir, "IR")
237
+ payload = load_object(args.feedback, "feedback or fix request")
238
+ except ValueError as exc:
239
+ print(exc, file=sys.stderr)
240
+ return 2
241
+ is_fix_request = payload.get("type") in FIX_REQUEST_TYPES
242
+ feedback = payload.get("reviewFeedback") if is_fix_request else payload
243
+ if not isinstance(feedback, dict):
244
+ print("Fix request reviewFeedback must be an object", file=sys.stderr)
245
+ return 2
246
+ errors = validate_feedback(ir, feedback) + validate_fix_request(ir, payload)
247
+ if errors:
248
+ for error in errors:
249
+ print(error, file=sys.stderr)
250
+ return 2
251
+ output = args.output.resolve()
252
+ output.parent.mkdir(parents=True, exist_ok=True)
253
+ merged = merge(ir, feedback)
254
+ if is_fix_request:
255
+ merged.setdefault("review", {})["correctionRequest"] = {
256
+ "acceptedFindingIds": payload.get("acceptedFindingIds", []),
257
+ "existingProposalVersionIds": payload.get("existingProposalVersionIds", []),
258
+ "requestedAction": payload.get("requestedAction"),
259
+ "createdAt": payload.get("createdAt"),
260
+ }
261
+ output.write_text(json.dumps(merged, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
262
+ print(str(output))
263
+ return 0
264
+
265
+
266
+ if __name__ == "__main__":
267
+ raise SystemExit(main())
@@ -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
+ }