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
|
@@ -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
|
+
}
|
ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/merge_review_state.py
ADDED
|
@@ -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())
|
ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/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
|
+
}
|