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,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
+ }