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,458 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bounded agent context and safe sparse patches for UI Design Workbench IR."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import copy
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
CONTEXT_TYPE = "ui-design-workbench-scoped-context"
|
|
16
|
+
PATCH_TYPE = "ui-design-workbench-ir-patch"
|
|
17
|
+
FORMAT_VERSION = 1
|
|
18
|
+
TOKEN_REF = re.compile(r"\$([A-Za-z0-9_.-]+)")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _compact(value: Any) -> str:
|
|
22
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def estimate_tokens(value: Any) -> int:
|
|
26
|
+
"""Return a conservative, dependency-free estimate for routing/budget metadata."""
|
|
27
|
+
return max(1, (len(_compact(value).encode("utf-8")) + 3) // 4)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_json(path: Path) -> dict[str, Any]:
|
|
31
|
+
try:
|
|
32
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
33
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
34
|
+
raise ValueError(f"Cannot read JSON: {path}") from exc
|
|
35
|
+
if not isinstance(value, dict):
|
|
36
|
+
raise ValueError(f"Expected a JSON object: {path}")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_json(path: Path, value: Any) -> None:
|
|
41
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
43
|
+
try:
|
|
44
|
+
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
45
|
+
os.replace(temporary, path)
|
|
46
|
+
finally:
|
|
47
|
+
try:
|
|
48
|
+
temporary.unlink()
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _ordered_findings(ir: dict[str, Any]) -> list[dict[str, Any]]:
|
|
54
|
+
findings = ir.get("review", {}).get("audit", {}).get("findings", [])
|
|
55
|
+
return [item for item in findings if isinstance(item, dict) and item.get("id")]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_screens(ir: dict[str, Any], queries: list[str]) -> list[dict[str, Any]]:
|
|
59
|
+
screens = [item for item in ir.get("screens", []) if isinstance(item, dict)]
|
|
60
|
+
if not queries:
|
|
61
|
+
return []
|
|
62
|
+
normalized = {item.strip().lower() for item in queries if item.strip()}
|
|
63
|
+
selected = [
|
|
64
|
+
screen for screen in screens
|
|
65
|
+
if str(screen.get("id", "")).lower() in normalized
|
|
66
|
+
or str(screen.get("name", "")).lower() in normalized
|
|
67
|
+
]
|
|
68
|
+
missing = normalized - {
|
|
69
|
+
value
|
|
70
|
+
for item in selected
|
|
71
|
+
for value in (str(item.get("id", "")).lower(), str(item.get("name", "")).lower())
|
|
72
|
+
}
|
|
73
|
+
if missing:
|
|
74
|
+
raise ValueError("Unknown screen(s): " + ", ".join(sorted(missing)))
|
|
75
|
+
return selected
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _resolve_findings(ir: dict[str, Any], identifiers: list[str]) -> list[dict[str, Any]]:
|
|
79
|
+
findings = _ordered_findings(ir)
|
|
80
|
+
by_id = {str(item["id"]): item for item in findings}
|
|
81
|
+
by_number = {str(index): item for index, item in enumerate(findings, 1)}
|
|
82
|
+
selected: list[dict[str, Any]] = []
|
|
83
|
+
missing: list[str] = []
|
|
84
|
+
for identifier in identifiers:
|
|
85
|
+
item = by_id.get(identifier) or by_number.get(identifier)
|
|
86
|
+
if item and item not in selected:
|
|
87
|
+
selected.append(item)
|
|
88
|
+
elif not item:
|
|
89
|
+
missing.append(identifier)
|
|
90
|
+
if missing:
|
|
91
|
+
raise ValueError("Unknown finding(s): " + ", ".join(missing))
|
|
92
|
+
return selected
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _node_ids_for_scope(
|
|
96
|
+
ir: dict[str, Any], screens: list[dict[str, Any]], findings: list[dict[str, Any]]
|
|
97
|
+
) -> set[str]:
|
|
98
|
+
nodes = ir.get("nodes", {}) if isinstance(ir.get("nodes"), dict) else {}
|
|
99
|
+
selected: set[str] = set()
|
|
100
|
+
parents: dict[str, str] = {}
|
|
101
|
+
for node_id, node in nodes.items():
|
|
102
|
+
if not isinstance(node, dict):
|
|
103
|
+
continue
|
|
104
|
+
for child in node.get("children", []):
|
|
105
|
+
parents[str(child)] = str(node_id)
|
|
106
|
+
|
|
107
|
+
def descendants(node_id: str) -> None:
|
|
108
|
+
if node_id in selected or node_id not in nodes:
|
|
109
|
+
return
|
|
110
|
+
selected.add(node_id)
|
|
111
|
+
node = nodes[node_id]
|
|
112
|
+
if isinstance(node, dict):
|
|
113
|
+
for child in node.get("children", []):
|
|
114
|
+
descendants(str(child))
|
|
115
|
+
|
|
116
|
+
for screen in screens:
|
|
117
|
+
descendants(str(screen.get("root", "")))
|
|
118
|
+
for finding in findings:
|
|
119
|
+
node_id = str(finding.get("nodeId") or "")
|
|
120
|
+
if node_id:
|
|
121
|
+
descendants(node_id)
|
|
122
|
+
while node_id in parents:
|
|
123
|
+
node_id = parents[node_id]
|
|
124
|
+
selected.add(node_id)
|
|
125
|
+
return selected
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _source_files(items: list[dict[str, Any]]) -> list[str]:
|
|
129
|
+
files: set[str] = set()
|
|
130
|
+
for item in items:
|
|
131
|
+
source = item.get("source")
|
|
132
|
+
if isinstance(source, dict) and source.get("file"):
|
|
133
|
+
files.add(str(source["file"]))
|
|
134
|
+
for evidence in item.get("evidence", []):
|
|
135
|
+
if isinstance(evidence, dict) and evidence.get("ref"):
|
|
136
|
+
files.add(str(evidence["ref"]).split("#", 1)[0])
|
|
137
|
+
return sorted(files)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _token_refs(value: Any) -> set[str]:
|
|
141
|
+
return set(TOKEN_REF.findall(_compact(value)))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _lookup_token(tokens: dict[str, Any], path: str) -> Any:
|
|
145
|
+
current: Any = tokens
|
|
146
|
+
for segment in path.split("."):
|
|
147
|
+
if not isinstance(current, dict) or segment not in current:
|
|
148
|
+
return None
|
|
149
|
+
current = current[segment]
|
|
150
|
+
return current
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _set_token(target: dict[str, Any], path: str, value: Any) -> None:
|
|
154
|
+
current = target
|
|
155
|
+
parts = path.split(".")
|
|
156
|
+
for segment in parts[:-1]:
|
|
157
|
+
current = current.setdefault(segment, {})
|
|
158
|
+
current[parts[-1]] = copy.deepcopy(value)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _relevant_tokens(ir: dict[str, Any], selected_nodes: dict[str, Any]) -> dict[str, Any]:
|
|
162
|
+
tokens = ir.get("tokens", {}) if isinstance(ir.get("tokens"), dict) else {}
|
|
163
|
+
return _select_tokens(tokens, _token_refs(selected_nodes))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _select_tokens(tokens: dict[str, Any], refs: set[str]) -> dict[str, Any]:
|
|
167
|
+
pending = list(refs)
|
|
168
|
+
seen: set[str] = set()
|
|
169
|
+
result: dict[str, Any] = {}
|
|
170
|
+
while pending:
|
|
171
|
+
ref = pending.pop()
|
|
172
|
+
if ref in seen:
|
|
173
|
+
continue
|
|
174
|
+
seen.add(ref)
|
|
175
|
+
value = _lookup_token(tokens, ref)
|
|
176
|
+
if value is None:
|
|
177
|
+
continue
|
|
178
|
+
_set_token(result, ref, value)
|
|
179
|
+
pending.extend(_token_refs(value) - seen)
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _relevant_themes(ir: dict[str, Any], selected_nodes: dict[str, Any], node_ids: set[str]) -> dict[str, Any]:
|
|
184
|
+
themes = ir.get("themes", {}) if isinstance(ir.get("themes"), dict) else {}
|
|
185
|
+
refs = _token_refs(selected_nodes)
|
|
186
|
+
items: list[dict[str, Any]] = []
|
|
187
|
+
for item in themes.get("items", []) if isinstance(themes.get("items"), list) else []:
|
|
188
|
+
if not isinstance(item, dict):
|
|
189
|
+
continue
|
|
190
|
+
selected = {key: copy.deepcopy(value) for key, value in item.items() if key in {"id", "label", "kind", "sourceRefs"}}
|
|
191
|
+
token_overrides = item.get("tokenOverrides", {}) if isinstance(item.get("tokenOverrides"), dict) else {}
|
|
192
|
+
node_overrides = item.get("nodeOverrides", {}) if isinstance(item.get("nodeOverrides"), dict) else {}
|
|
193
|
+
relevant_tokens = _select_tokens(token_overrides, refs)
|
|
194
|
+
relevant_nodes = {key: copy.deepcopy(value) for key, value in node_overrides.items() if str(key) in node_ids}
|
|
195
|
+
if relevant_tokens:
|
|
196
|
+
selected["tokenOverrides"] = relevant_tokens
|
|
197
|
+
if relevant_nodes:
|
|
198
|
+
selected["nodeOverrides"] = relevant_nodes
|
|
199
|
+
items.append(selected)
|
|
200
|
+
return {"defaultThemeId": themes.get("defaultThemeId"), "items": items}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _relevant_fixtures(ir: dict[str, Any], screens: list[dict[str, Any]]) -> dict[str, Any]:
|
|
204
|
+
fixtures = ir.get("scenarioFixtures", {}) if isinstance(ir.get("scenarioFixtures"), dict) else {}
|
|
205
|
+
refs = {
|
|
206
|
+
str(scenario.get("fixtureRef"))
|
|
207
|
+
for screen in screens
|
|
208
|
+
for scenario in screen.get("scenarios", [])
|
|
209
|
+
if isinstance(scenario, dict) and scenario.get("fixtureRef")
|
|
210
|
+
}
|
|
211
|
+
return {key: copy.deepcopy(value) for key, value in fixtures.items() if str(key) in refs}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _relevant_navigation(ir: dict[str, Any], screen_ids: set[str]) -> dict[str, Any]:
|
|
215
|
+
graph = ir.get("navigationGraph", {}) if isinstance(ir.get("navigationGraph"), dict) else {}
|
|
216
|
+
edges = [
|
|
217
|
+
copy.deepcopy(item) for item in graph.get("edges", [])
|
|
218
|
+
if isinstance(item, dict) and (str(item.get("from")) in screen_ids or str(item.get("to")) in screen_ids)
|
|
219
|
+
]
|
|
220
|
+
targets = [
|
|
221
|
+
copy.deepcopy(item) for item in graph.get("navigationTargets", [])
|
|
222
|
+
if isinstance(item, dict) and any(str(item.get(key)) in screen_ids for key in ("screenId", "from", "to", "target"))
|
|
223
|
+
]
|
|
224
|
+
return {"edges": edges, "navigationTargets": targets}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _relevant_components(ir: dict[str, Any], nodes: dict[str, Any]) -> list[Any]:
|
|
228
|
+
refs = {str(node.get("componentRef")) for node in nodes.values() if isinstance(node, dict) and node.get("componentRef")}
|
|
229
|
+
catalog = ir.get("componentCatalog", [])
|
|
230
|
+
if isinstance(catalog, dict):
|
|
231
|
+
return [{"id": key, **copy.deepcopy(value)} if isinstance(value, dict) else {"id": key, "value": value} for key, value in catalog.items() if str(key) in refs]
|
|
232
|
+
return [copy.deepcopy(item) for item in catalog if isinstance(item, dict) and str(item.get("id")) in refs]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _relevant_versions(ir: dict[str, Any], node_ids: set[str], finding_ids: set[str]) -> list[dict[str, Any]]:
|
|
236
|
+
result: list[dict[str, Any]] = []
|
|
237
|
+
versions = ir.get("review", {}).get("versions", [])
|
|
238
|
+
for version in versions if isinstance(versions, list) else []:
|
|
239
|
+
if not isinstance(version, dict):
|
|
240
|
+
continue
|
|
241
|
+
covered = set(map(str, version.get("findingIds", []))) | set(map(str, version.get("resolvedFindingIds", [])))
|
|
242
|
+
overrides = version.get("nodeOverrides", {}) if isinstance(version.get("nodeOverrides"), dict) else {}
|
|
243
|
+
relevant_overrides = {key: copy.deepcopy(value) for key, value in overrides.items() if str(key) in node_ids}
|
|
244
|
+
if covered.intersection(finding_ids) or relevant_overrides:
|
|
245
|
+
sparse = {key: copy.deepcopy(value) for key, value in version.items() if key != "nodeOverrides"}
|
|
246
|
+
sparse["nodeOverrides"] = relevant_overrides
|
|
247
|
+
result.append(sparse)
|
|
248
|
+
return result
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _compact_finding(item: dict[str, Any]) -> dict[str, Any]:
|
|
252
|
+
keep = {
|
|
253
|
+
"id", "title", "severity", "category", "screenId", "nodeId", "reviewVersionId",
|
|
254
|
+
"observation", "impact", "recommendation", "confidence", "sourceTarget",
|
|
255
|
+
"runtimeDiagnosticId", "verification", "proposalVersionId", "status",
|
|
256
|
+
}
|
|
257
|
+
return {key: copy.deepcopy(value) for key, value in item.items() if key in keep}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _scope_catalog(ir: dict[str, Any]) -> dict[str, Any]:
|
|
261
|
+
return {
|
|
262
|
+
"screens": [
|
|
263
|
+
{key: item.get(key) for key in ("id", "name", "platform", "route", "fragment") if item.get(key) is not None}
|
|
264
|
+
for item in ir.get("screens", []) if isinstance(item, dict)
|
|
265
|
+
],
|
|
266
|
+
"findings": [
|
|
267
|
+
{key: item.get(key) for key in ("id", "title", "severity", "screenId", "nodeId") if item.get(key) is not None}
|
|
268
|
+
for item in _ordered_findings(ir)
|
|
269
|
+
],
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def build_scoped_context(
|
|
274
|
+
ir: dict[str, Any],
|
|
275
|
+
*,
|
|
276
|
+
screen_ids: list[str] | None = None,
|
|
277
|
+
finding_ids: list[str] | None = None,
|
|
278
|
+
token_budget: int = 4000,
|
|
279
|
+
ui_ir_file: str | None = None,
|
|
280
|
+
) -> dict[str, Any]:
|
|
281
|
+
"""Build a structurally complete scope. It never cuts a selected node subtree."""
|
|
282
|
+
screen_ids = screen_ids or []
|
|
283
|
+
finding_ids = finding_ids or []
|
|
284
|
+
findings = _resolve_findings(ir, finding_ids)
|
|
285
|
+
inferred_screens = [str(item.get("screenId")) for item in findings if item.get("screenId")]
|
|
286
|
+
screens = _resolve_screens(ir, list(dict.fromkeys([*screen_ids, *inferred_screens])))
|
|
287
|
+
node_ids = _node_ids_for_scope(ir, screens, findings)
|
|
288
|
+
all_nodes = ir.get("nodes", {}) if isinstance(ir.get("nodes"), dict) else {}
|
|
289
|
+
nodes = {node_id: copy.deepcopy(all_nodes[node_id]) for node_id in all_nodes if node_id in node_ids}
|
|
290
|
+
review = ir.get("review", {}) if isinstance(ir.get("review"), dict) else {}
|
|
291
|
+
project = ir.get("project", {}) if isinstance(ir.get("project"), dict) else {}
|
|
292
|
+
payload: dict[str, Any] = {
|
|
293
|
+
"type": CONTEXT_TYPE,
|
|
294
|
+
"version": FORMAT_VERSION,
|
|
295
|
+
"project": {key: project.get(key) for key in ("name", "root") if project.get(key) is not None},
|
|
296
|
+
"target": {
|
|
297
|
+
"uiIrFile": ui_ir_file,
|
|
298
|
+
"baselineHash": review.get("baselineHash"),
|
|
299
|
+
"reviewRevision": review.get("revision"),
|
|
300
|
+
"baselineVersion": review.get("baselineVersion"),
|
|
301
|
+
},
|
|
302
|
+
"scope": {
|
|
303
|
+
"screenIds": [str(item.get("id")) for item in screens],
|
|
304
|
+
"findingIds": [str(item.get("id")) for item in findings],
|
|
305
|
+
"nodeIds": list(nodes),
|
|
306
|
+
},
|
|
307
|
+
"screens": copy.deepcopy(screens),
|
|
308
|
+
"nodes": nodes,
|
|
309
|
+
"findings": [_compact_finding(item) for item in findings],
|
|
310
|
+
"versions": _relevant_versions(ir, node_ids, {str(item.get("id")) for item in findings}),
|
|
311
|
+
"tokens": _relevant_tokens(ir, nodes),
|
|
312
|
+
"themes": _relevant_themes(ir, nodes, node_ids),
|
|
313
|
+
"scenarioFixtures": _relevant_fixtures(ir, screens),
|
|
314
|
+
"navigation": _relevant_navigation(ir, {str(item.get("id")) for item in screens}),
|
|
315
|
+
"components": _relevant_components(ir, nodes),
|
|
316
|
+
"platforms": copy.deepcopy(ir.get("platforms", [])),
|
|
317
|
+
"sourceFiles": _source_files([*screens, *nodes.values(), *findings]),
|
|
318
|
+
"patchContract": {
|
|
319
|
+
"type": PATCH_TYPE,
|
|
320
|
+
"version": FORMAT_VERSION,
|
|
321
|
+
"allowedOperations": ["upsert-findings", "upsert-versions", "merge-annotations", "record-verifications"],
|
|
322
|
+
"forbidden": ["baseline screens", "baseline nodes", "baseline tokens", "baseline themes", "application source"],
|
|
323
|
+
},
|
|
324
|
+
}
|
|
325
|
+
if not screens and not findings:
|
|
326
|
+
payload["catalog"] = _scope_catalog(ir)
|
|
327
|
+
payload["next"] = "Request one screen or a small finding set; do not read the full ui-ir.json."
|
|
328
|
+
requested = max(256, int(token_budget))
|
|
329
|
+
estimated = estimate_tokens(payload)
|
|
330
|
+
payload["contextBudget"] = {
|
|
331
|
+
"requestedTokens": requested,
|
|
332
|
+
"estimatedTokens": estimated,
|
|
333
|
+
"withinBudget": estimated <= requested,
|
|
334
|
+
"structuralTruncation": False,
|
|
335
|
+
"recommendation": None if estimated <= requested else "Narrow screenIds/findingIds; selected structure was preserved intact.",
|
|
336
|
+
}
|
|
337
|
+
return payload
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def write_scoped_context(
|
|
341
|
+
ir_path: Path,
|
|
342
|
+
output: Path,
|
|
343
|
+
*,
|
|
344
|
+
screen_ids: list[str] | None = None,
|
|
345
|
+
finding_ids: list[str] | None = None,
|
|
346
|
+
token_budget: int = 4000,
|
|
347
|
+
) -> dict[str, Any]:
|
|
348
|
+
ir_path = ir_path.resolve()
|
|
349
|
+
payload = build_scoped_context(
|
|
350
|
+
read_json(ir_path), screen_ids=screen_ids, finding_ids=finding_ids,
|
|
351
|
+
token_budget=token_budget, ui_ir_file=str(ir_path),
|
|
352
|
+
)
|
|
353
|
+
write_json(output.resolve(), payload)
|
|
354
|
+
return payload
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def patch_template(ir: dict[str, Any], ui_ir_file: str, context_file: str | None = None) -> dict[str, Any]:
|
|
358
|
+
review = ir.get("review", {}) if isinstance(ir.get("review"), dict) else {}
|
|
359
|
+
return {
|
|
360
|
+
"type": PATCH_TYPE,
|
|
361
|
+
"version": FORMAT_VERSION,
|
|
362
|
+
"target": {
|
|
363
|
+
"uiIrFile": ui_ir_file,
|
|
364
|
+
"project": ir.get("project", {}).get("name"),
|
|
365
|
+
"baselineHash": review.get("baselineHash"),
|
|
366
|
+
"reviewRevision": review.get("revision"),
|
|
367
|
+
},
|
|
368
|
+
"contextFile": context_file,
|
|
369
|
+
"operations": [],
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def validate_patch(ir: dict[str, Any], patch: dict[str, Any]) -> list[str]:
|
|
374
|
+
errors: list[str] = []
|
|
375
|
+
if patch.get("type") != PATCH_TYPE or patch.get("version") != FORMAT_VERSION:
|
|
376
|
+
errors.append("Unsupported ui-ir.patch.json format")
|
|
377
|
+
target = patch.get("target", {}) if isinstance(patch.get("target"), dict) else {}
|
|
378
|
+
review = ir.get("review", {}) if isinstance(ir.get("review"), dict) else {}
|
|
379
|
+
project = ir.get("project", {}) if isinstance(ir.get("project"), dict) else {}
|
|
380
|
+
for label, expected, actual in (
|
|
381
|
+
("project", target.get("project"), project.get("name")),
|
|
382
|
+
("baselineHash", target.get("baselineHash"), review.get("baselineHash")),
|
|
383
|
+
("reviewRevision", target.get("reviewRevision"), review.get("revision")),
|
|
384
|
+
):
|
|
385
|
+
if expected is not None and expected != actual:
|
|
386
|
+
errors.append(f"Patch {label} does not match target IR")
|
|
387
|
+
allowed = {"upsert-findings", "upsert-versions", "merge-annotations", "record-verifications"}
|
|
388
|
+
operations = patch.get("operations")
|
|
389
|
+
if not isinstance(operations, list):
|
|
390
|
+
errors.append("Patch operations must be an array")
|
|
391
|
+
return errors
|
|
392
|
+
for index, operation in enumerate(operations):
|
|
393
|
+
if not isinstance(operation, dict) or operation.get("op") not in allowed:
|
|
394
|
+
errors.append(f"Unsupported operation at index {index}")
|
|
395
|
+
continue
|
|
396
|
+
if operation.get("op") in {"upsert-findings", "upsert-versions", "merge-annotations", "record-verifications"} and not isinstance(operation.get("value"), list):
|
|
397
|
+
errors.append(f"Operation {index} value must be an array")
|
|
398
|
+
if operation.get("op") == "upsert-versions":
|
|
399
|
+
for version in operation.get("value", []):
|
|
400
|
+
if not isinstance(version, dict) or version.get("kind") != "proposal" or not version.get("id"):
|
|
401
|
+
errors.append("Only named sparse proposal versions may be upserted")
|
|
402
|
+
if isinstance(version, dict) and any(key in version for key in ("screens", "nodes", "tokens", "themes")):
|
|
403
|
+
errors.append("Proposal operation cannot replace baseline collections")
|
|
404
|
+
return errors
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def apply_patch(ir: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
|
408
|
+
errors = validate_patch(ir, patch)
|
|
409
|
+
if errors:
|
|
410
|
+
raise ValueError("; ".join(errors))
|
|
411
|
+
result = copy.deepcopy(ir)
|
|
412
|
+
review = result.setdefault("review", {})
|
|
413
|
+
audit = review.setdefault("audit", {})
|
|
414
|
+
for operation in patch.get("operations", []):
|
|
415
|
+
op = operation["op"]
|
|
416
|
+
values = operation["value"]
|
|
417
|
+
if op == "upsert-findings":
|
|
418
|
+
existing = {str(item.get("id")): item for item in audit.setdefault("findings", []) if isinstance(item, dict) and item.get("id")}
|
|
419
|
+
for item in values:
|
|
420
|
+
if not isinstance(item, dict) or not item.get("id"):
|
|
421
|
+
raise ValueError("Every finding must have a stable id")
|
|
422
|
+
existing[str(item["id"])] = copy.deepcopy(item)
|
|
423
|
+
audit["findings"] = list(existing.values())
|
|
424
|
+
elif op == "upsert-versions":
|
|
425
|
+
existing = {str(item.get("id")): item for item in review.setdefault("versions", []) if isinstance(item, dict) and item.get("id")}
|
|
426
|
+
for item in values:
|
|
427
|
+
existing[str(item["id"])] = copy.deepcopy(item)
|
|
428
|
+
review["versions"] = list(existing.values())
|
|
429
|
+
elif op == "merge-annotations":
|
|
430
|
+
existing = {str(item.get("id")): item for item in review.setdefault("annotations", []) if isinstance(item, dict) and item.get("id")}
|
|
431
|
+
for item in values:
|
|
432
|
+
if not isinstance(item, dict) or not item.get("id"):
|
|
433
|
+
raise ValueError("Every annotation must have a stable id")
|
|
434
|
+
existing[str(item["id"])] = copy.deepcopy(item)
|
|
435
|
+
review["annotations"] = list(existing.values())
|
|
436
|
+
elif op == "record-verifications":
|
|
437
|
+
findings = {str(item.get("id")): item for item in audit.setdefault("findings", []) if isinstance(item, dict) and item.get("id")}
|
|
438
|
+
for item in values:
|
|
439
|
+
finding_id = str(item.get("findingId") or "") if isinstance(item, dict) else ""
|
|
440
|
+
if finding_id not in findings or not isinstance(item.get("verification"), dict):
|
|
441
|
+
raise ValueError(f"Invalid verification record for finding: {finding_id or '<missing>'}")
|
|
442
|
+
findings[finding_id]["verification"] = copy.deepcopy(item["verification"])
|
|
443
|
+
return result
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def apply_patch_file(ir_path: Path, patch_path: Path, output: Path) -> dict[str, Any]:
|
|
447
|
+
ir_path, patch_path, output = ir_path.resolve(), patch_path.resolve(), output.resolve()
|
|
448
|
+
source = read_json(ir_path)
|
|
449
|
+
patched = apply_patch(source, read_json(patch_path))
|
|
450
|
+
write_json(output, patched)
|
|
451
|
+
return {
|
|
452
|
+
"version": 1,
|
|
453
|
+
"status": "applied",
|
|
454
|
+
"irFile": str(output),
|
|
455
|
+
"patchFile": str(patch_path),
|
|
456
|
+
"baselineUnchanged": hashlib.sha256(_compact({key: source.get(key) for key in ("screens", "nodes", "tokens", "themes", "scenarioFixtures")}).encode()).hexdigest()
|
|
457
|
+
== hashlib.sha256(_compact({key: patched.get(key) for key in ("screens", "nodes", "tokens", "themes", "scenarioFixtures")}).encode()).hexdigest(),
|
|
458
|
+
}
|