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
uidw.py ADDED
@@ -0,0 +1,2821 @@
1
+ #!/usr/bin/env python3
2
+ """Provider-neutral UI Design Workbench command line interface.
3
+
4
+ The cache is lazy and deterministic: source contents are read only on the
5
+ initial scan or after a candidate UI file fingerprint changes. No watcher,
6
+ server, application runtime, emulator, or development server is started.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import contextlib
13
+ import copy
14
+ import datetime as dt
15
+ import hashlib
16
+ import html
17
+ import importlib.util
18
+ import json
19
+ import os
20
+ import re
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ import tempfile
25
+ import time
26
+ import webbrowser
27
+ import zipfile
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from quality_common import node_screen_map
32
+ from fidelity_core import fidelity_report, seal_baseline
33
+ from fidelity_adapters import adapter_capabilities
34
+ from scoped_context import (
35
+ apply_patch_file,
36
+ build_scoped_context,
37
+ patch_template,
38
+ read_json as read_scoped_json,
39
+ validate_patch as validate_ir_patch,
40
+ write_json as write_scoped_json,
41
+ )
42
+ from scan_ui import (
43
+ ASSET_EXTENSIONS,
44
+ SCANNER_VERSION,
45
+ SOURCE_EXTENSIONS,
46
+ analyze_file,
47
+ assemble_scan,
48
+ iter_files,
49
+ starter_ir,
50
+ write_json,
51
+ )
52
+
53
+
54
+ CACHE_VERSION = 8
55
+ CLI_VERSION = "0.5.0"
56
+ CONFIG_VERSION = 5
57
+ STATE_DIR_NAME = ".ui-design-workbench"
58
+ CONFIG_NAME = "config.json"
59
+ UI_MODE_KEY = "uiMode"
60
+ MOCK_DATA_KEY = "mockData"
61
+ SETUP_KEY = "setup"
62
+ DETAIL_KEY = "detailLevel"
63
+ PREVIEW_KEY = "preview"
64
+ REVIEW_KEY = "review"
65
+ MOCK_DATA_BY_DETAIL = {
66
+ "low": "minimal",
67
+ "medium": "representative",
68
+ "high": "exhaustive",
69
+ }
70
+ CACHE_NAME = "cache-state.json"
71
+ SCAN_NAME = "ui-scan.json"
72
+ IR_NAME = "ui-ir.json"
73
+ GRAPH_NAME = "ui-graph.json"
74
+ CONTEXT_NAME = "ui-context.json"
75
+ SYNC_REPORT_NAME = "sync-report.json"
76
+ DESIGN_MODEL_NAME = "design-model.json"
77
+ REVIEW_STATE_NAME = "review-state.json"
78
+ NATIVE_RENDER_STATE_NAME = "native-render-state.json"
79
+ STATE_LOCK_NAME = ".state.lock"
80
+ STATE_GITIGNORE = "*\n!.gitignore\n!config.json\n"
81
+ SKILL_NAME = "ui-design-workbench"
82
+ SKILL_MARKER_NAME = ".uidw-skill.json"
83
+ SUPPORTED_SKILL_AGENTS = ("codex", "claude", "cursor", "gemini", "copilot", "opencode", "agents")
84
+ SKILL_SCRIPT_FILES = (
85
+ "android_resource_resolver.py",
86
+ "android_xml_support.py",
87
+ "apple_resource_resolver.py",
88
+ "coverage_report.py",
89
+ "fidelity_adapter_api.py",
90
+ "fidelity_adapters.py",
91
+ "fidelity_core.py",
92
+ "fidelity_platform_adapters.py",
93
+ "generate_interaction_matrix.py",
94
+ "ir_contracts.py",
95
+ "merge_review_state.py",
96
+ "native_render_android.py",
97
+ "native_render_apple.py",
98
+ "native_render_contracts.py",
99
+ "native_render_registry.py",
100
+ "quality_common.py",
101
+ "render_preview.py",
102
+ "scan_ui.py",
103
+ "scoped_context.py",
104
+ "smoke_preview.js",
105
+ "uidw.py",
106
+ "uidw_mcp.py",
107
+ "validate_platform_profiles.py",
108
+ "visual_regression.py",
109
+ )
110
+
111
+
112
+ def utc_now() -> str:
113
+ return dt.datetime.now(dt.timezone.utc).isoformat()
114
+
115
+
116
+ def read_json(path: Path, default: Any = None) -> Any:
117
+ try:
118
+ return json.loads(path.read_text(encoding="utf-8"))
119
+ except (OSError, json.JSONDecodeError):
120
+ return default
121
+
122
+
123
+ def skill_source_dir() -> Path:
124
+ """Locate the complete skill payload in a checkout or installed wheel."""
125
+ module = Path(__file__).resolve()
126
+ candidates = (
127
+ module.parent.parent,
128
+ Path(sys.prefix) / "share" / "ui-design-workbench",
129
+ module.parent / "share" / "ui-design-workbench",
130
+ )
131
+ for candidate in candidates:
132
+ if (candidate / "SKILL.md").is_file() and (candidate / "references").is_dir():
133
+ return candidate
134
+ checked = ", ".join(str(path) for path in candidates)
135
+ raise RuntimeError(f"Packaged Agent Skill is unavailable. Checked: {checked}")
136
+
137
+
138
+ def agent_skill_paths(agent: str, home: Path | None = None) -> dict[str, Path]:
139
+ base = (home or Path.home()).resolve()
140
+ paths = {
141
+ "agents": base / ".agents" / "skills" / SKILL_NAME,
142
+ "codex": base / ".codex" / "skills" / SKILL_NAME,
143
+ "claude": base / ".claude" / "skills" / SKILL_NAME,
144
+ "cursor": base / ".cursor" / "skills" / SKILL_NAME,
145
+ "gemini": base / ".gemini" / "skills" / SKILL_NAME,
146
+ "copilot": base / ".copilot" / "skills" / SKILL_NAME,
147
+ "opencode": base / ".config" / "opencode" / "skills" / SKILL_NAME,
148
+ }
149
+ if agent == "all":
150
+ return paths
151
+ if agent not in paths:
152
+ raise ValueError(f"Unknown agent: {agent}. Available: {', '.join((*SUPPORTED_SKILL_AGENTS, 'all'))}")
153
+ return {agent: paths[agent]}
154
+
155
+
156
+ def copy_skill_payload(source: Path, destination: Path) -> None:
157
+ destination.mkdir(parents=True, exist_ok=True)
158
+ shutil.copy2(source / "SKILL.md", destination / "SKILL.md")
159
+ for folder, patterns in (("references", ("*.md", "*.json")), ("schemas", ("*.json",))):
160
+ source_dir = source / folder
161
+ target_dir = destination / folder
162
+ target_dir.mkdir(parents=True, exist_ok=True)
163
+ for pattern in patterns:
164
+ for path in source_dir.glob(pattern):
165
+ if path.is_file():
166
+ shutil.copy2(path, target_dir / path.name)
167
+ scripts_source = source / "scripts"
168
+ scripts_target = destination / "scripts"
169
+ scripts_target.mkdir(parents=True, exist_ok=True)
170
+ for name in SKILL_SCRIPT_FILES:
171
+ path = scripts_source / name
172
+ if not path.is_file():
173
+ raise RuntimeError(f"Packaged Agent Skill is missing scripts/{name}")
174
+ shutil.copy2(path, scripts_target / name)
175
+
176
+
177
+ def install_skill(agent: str, target: Path | None = None, source: Path | None = None) -> dict[str, Any]:
178
+ """Install or refresh UIDW-managed Agent Skill copies without a Git checkout."""
179
+ payload_source = (source or skill_source_dir()).resolve()
180
+ if target is not None:
181
+ if agent == "all":
182
+ raise ValueError("--target cannot be combined with agent 'all'")
183
+ targets = {agent: target.expanduser().resolve()}
184
+ else:
185
+ targets = agent_skill_paths(agent)
186
+ for destination in targets.values():
187
+ if not destination.exists():
188
+ continue
189
+ with contextlib.suppress(OSError):
190
+ if destination.resolve() == payload_source:
191
+ continue
192
+ marker = read_json(destination / SKILL_MARKER_NAME, {})
193
+ if not isinstance(marker, dict) or marker.get("type") != "ui-design-workbench-skill":
194
+ raise ValueError(f"Skill target already exists and is not UIDW-managed: {destination}")
195
+ installations = []
196
+ for name, destination in targets.items():
197
+ if destination.exists():
198
+ with contextlib.suppress(OSError):
199
+ if destination.resolve() == payload_source:
200
+ installations.append({"agent": name, "status": "linked", "path": str(destination)})
201
+ continue
202
+ copy_skill_payload(payload_source, destination)
203
+ status = "updated"
204
+ else:
205
+ destination.parent.mkdir(parents=True, exist_ok=True)
206
+ staging_root = Path(tempfile.mkdtemp(prefix=".uidw-skill-", dir=destination.parent))
207
+ staged = staging_root / SKILL_NAME
208
+ try:
209
+ copy_skill_payload(payload_source, staged)
210
+ os.replace(staged, destination)
211
+ finally:
212
+ shutil.rmtree(staging_root, ignore_errors=True)
213
+ status = "installed"
214
+ write_json(destination / SKILL_MARKER_NAME, {
215
+ "type": "ui-design-workbench-skill",
216
+ "version": 1,
217
+ "cliVersion": CLI_VERSION,
218
+ "agent": name,
219
+ })
220
+ installations.append({"agent": name, "status": status, "path": str(destination)})
221
+ return {"version": 1, "status": "ok", "skillInstallations": installations, "cliVersion": CLI_VERSION}
222
+
223
+
224
+ def write_text_atomic(path: Path, value: str) -> None:
225
+ path.parent.mkdir(parents=True, exist_ok=True)
226
+ temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
227
+ try:
228
+ with temporary.open("w", encoding="utf-8", newline="\n") as handle:
229
+ handle.write(value)
230
+ handle.flush()
231
+ os.fsync(handle.fileno())
232
+ os.replace(temporary, path)
233
+ finally:
234
+ with contextlib.suppress(OSError):
235
+ temporary.unlink()
236
+
237
+
238
+ @contextlib.contextmanager
239
+ def state_lock(path: Path, timeout_seconds: float = 10.0):
240
+ """Serialize cache writers without requiring a daemon or third-party lock package."""
241
+ path.parent.mkdir(parents=True, exist_ok=True)
242
+ deadline = time.monotonic() + timeout_seconds
243
+ descriptor: int | None = None
244
+ while descriptor is None:
245
+ try:
246
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
247
+ os.write(descriptor, f"pid={os.getpid()}\ncreated={utc_now()}\n".encode("utf-8"))
248
+ except FileExistsError:
249
+ try:
250
+ stale = time.time() - path.stat().st_mtime > 120
251
+ except OSError:
252
+ stale = False
253
+ if stale:
254
+ with contextlib.suppress(OSError):
255
+ path.unlink()
256
+ continue
257
+ if time.monotonic() >= deadline:
258
+ raise TimeoutError(f"UI cache is locked by another process: {path}")
259
+ time.sleep(0.05)
260
+ try:
261
+ yield
262
+ finally:
263
+ if descriptor is not None:
264
+ os.close(descriptor)
265
+ with contextlib.suppress(OSError):
266
+ path.unlink()
267
+
268
+
269
+ def sha256_file(path: Path) -> str:
270
+ digest = hashlib.sha256()
271
+ with path.open("rb") as handle:
272
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
273
+ digest.update(chunk)
274
+ return digest.hexdigest()
275
+
276
+
277
+ def state_paths(root: Path) -> dict[str, Path]:
278
+ project_config_path = root / STATE_DIR_NAME / CONFIG_NAME
279
+ project_config = read_json(project_config_path, {})
280
+ if not isinstance(project_config, dict):
281
+ project_config = {}
282
+ if project_config.get("cacheMode") == "project":
283
+ directory = root / STATE_DIR_NAME
284
+ config_path = project_config_path
285
+ else:
286
+ override = os.environ.get("UIDW_CACHE_HOME")
287
+ if override:
288
+ cache_root = Path(override).expanduser()
289
+ elif os.name == "nt":
290
+ cache_root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "UI Design Workbench" / "Cache"
291
+ elif sys.platform == "darwin":
292
+ cache_root = Path.home() / "Library" / "Caches" / "ui-design-workbench"
293
+ else:
294
+ cache_root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "ui-design-workbench"
295
+ normalized = str(root.resolve()).replace("\\", "/")
296
+ if os.name == "nt":
297
+ normalized = normalized.lower()
298
+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
299
+ label = re.sub(r"[^a-z0-9]+", "-", root.name.lower()).strip("-") or "project"
300
+ directory = cache_root / "projects" / f"{label}-{digest}"
301
+ config_path = project_config_path if project_config_path.is_file() else directory / CONFIG_NAME
302
+ return {
303
+ "dir": directory,
304
+ "config": config_path,
305
+ "cache": directory / CACHE_NAME,
306
+ "scan": directory / SCAN_NAME,
307
+ "ir": directory / IR_NAME,
308
+ "graph": directory / GRAPH_NAME,
309
+ "context": directory / CONTEXT_NAME,
310
+ "sync": directory / SYNC_REPORT_NAME,
311
+ "design": directory / DESIGN_MODEL_NAME,
312
+ "review": directory / REVIEW_STATE_NAME,
313
+ "native": directory / NATIVE_RENDER_STATE_NAME,
314
+ "lock": directory / STATE_LOCK_NAME,
315
+ "gitignore": directory / ".gitignore",
316
+ }
317
+
318
+
319
+ def default_config() -> dict[str, Any]:
320
+ return {
321
+ "version": CONFIG_VERSION,
322
+ SETUP_KEY: {"completed": False, "answered": []},
323
+ DETAIL_KEY: None,
324
+ "autoSync": True,
325
+ "cacheMode": "user",
326
+ "compactContext": True,
327
+ "maxContextScreens": 100,
328
+ "maxContextComponents": 200,
329
+ "handoffProvider": "generic",
330
+ UI_MODE_KEY: {"enabled": False},
331
+ MOCK_DATA_KEY: {"mode": "minimal", "seed": "stable", "explicit": False},
332
+ PREVIEW_KEY: {"themeLayout": "auto", "defaultView": "overview", "language": "auto"},
333
+ REVIEW_KEY: {"depth": "auto", "validation": "auto"},
334
+ }
335
+
336
+
337
+ def config_hash(config: dict[str, Any]) -> str:
338
+ scan_config = {key: value for key, value in config.items() if key not in {SETUP_KEY, DETAIL_KEY, UI_MODE_KEY, MOCK_DATA_KEY, PREVIEW_KEY, REVIEW_KEY}}
339
+ payload = json.dumps(scan_config, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
340
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
341
+
342
+
343
+ def normalized_config(value: Any) -> dict[str, Any]:
344
+ config = default_config()
345
+ if isinstance(value, dict):
346
+ config.update(value)
347
+ mode = config.get(UI_MODE_KEY)
348
+ config[UI_MODE_KEY] = {"enabled": bool(mode.get("enabled", False))} if isinstance(mode, dict) else {"enabled": False}
349
+ detail = config.get(DETAIL_KEY)
350
+ config[DETAIL_KEY] = detail if detail in {"low", "medium", "high"} else None
351
+ configured = config[DETAIL_KEY] in {"low", "medium", "high"}
352
+ config[SETUP_KEY] = {"completed": configured, "answered": ["detail"] if configured else []}
353
+ config[MOCK_DATA_KEY] = {
354
+ "mode": MOCK_DATA_BY_DETAIL.get(config[DETAIL_KEY], "minimal"),
355
+ "seed": "stable",
356
+ "explicit": False,
357
+ }
358
+ preview = config.get(PREVIEW_KEY) if isinstance(config.get(PREVIEW_KEY), dict) else {}
359
+ theme_layout = preview.get("themeLayout", "auto")
360
+ default_view = preview.get("defaultView", "overview")
361
+ language = preview.get("language", "auto")
362
+ config[PREVIEW_KEY] = {
363
+ "themeLayout": theme_layout if theme_layout in {"auto", "themes", "states", "matrix"} else "auto",
364
+ "defaultView": default_view if default_view in {"overview", "single", "prototype", "states"} else "overview",
365
+ "language": language if language in {"auto", "ru", "en"} else "auto",
366
+ }
367
+ review = config.get(REVIEW_KEY) if isinstance(config.get(REVIEW_KEY), dict) else {}
368
+ depth = review.get("depth", "auto")
369
+ validation = review.get("validation", "auto")
370
+ config[REVIEW_KEY] = {
371
+ "depth": depth if depth in {"auto", "standard", "deep"} else "auto",
372
+ "validation": validation if validation in {"auto", "quick", "full"} else "auto",
373
+ }
374
+ config["version"] = CONFIG_VERSION
375
+ return config
376
+
377
+
378
+ def load_config(path: Path, migrate: bool = True) -> dict[str, Any]:
379
+ raw = read_json(path, {})
380
+ config = normalized_config(raw)
381
+ old_version = int(raw.get("version", 1)) if isinstance(raw, dict) else 1
382
+ if migrate and path.is_file() and old_version < CONFIG_VERSION:
383
+ backup = path.with_name(f"config.v{old_version}.backup.json")
384
+ if not backup.exists() and isinstance(raw, dict):
385
+ write_json(backup, raw)
386
+ write_json(path, config)
387
+ return config
388
+
389
+
390
+ DETAIL_PROFILES: dict[str, dict[str, Any]] = {
391
+ "low": {
392
+ "label": "Low",
393
+ "description": "Screens, basic layout, and minimal mock data.",
394
+ },
395
+ "medium": {
396
+ "label": "Medium",
397
+ "description": "Navigation, interactions, relevant states, and representative mock data.",
398
+ },
399
+ "high": {
400
+ "label": "High",
401
+ "description": "Everything in Medium, plus expanded mock data, detected themes, and exhaustive reconstruction/HTML checks.",
402
+ },
403
+ }
404
+
405
+ SETTING_CATALOG: dict[str, dict[str, Any]] = {
406
+ "detail": {
407
+ "values": ["low", "medium", "high"],
408
+ "description": "Low: basic layout and minimal data. Medium: interactions, states, and representative data. High: expanded data, themes, and exhaustive reconstruction/HTML checks. No level starts UI/UX review.",
409
+ },
410
+ "ui-mode": {"values": ["on", "off"], "description": "Apply platform-aware UI guidance to ordinary UI tasks without an explicit workbench request."},
411
+ "theme-layout": {"values": ["auto", "themes", "states", "matrix"], "description": "Default organization for multi-theme and multi-state canvas variants."},
412
+ "default-view": {"values": ["overview", "single", "prototype", "states"], "description": "Initial workbench view."},
413
+ "language": {"values": ["auto", "ru", "en"], "description": "Workbench interface language."},
414
+ "review-depth": {"values": ["auto", "standard", "deep"], "description": "Default expert review breadth; auto follows the detail profile."},
415
+ "validation": {"values": ["auto", "quick", "full"], "description": "Default deterministic validation level; auto uses full for high detail."},
416
+ "auto-sync": {"values": ["on", "off"], "description": "Refresh the UI index only when relevant source fingerprints change."},
417
+ "cache-mode": {"values": ["user", "project"], "description": "Store derived state in the OS cache or an ignored project directory."},
418
+ }
419
+
420
+
421
+ def effective_review_config(config: dict[str, Any]) -> dict[str, str]:
422
+ raw = config.get(REVIEW_KEY, {})
423
+ high = config.get(DETAIL_KEY) == "high"
424
+ depth = raw.get("depth", "auto")
425
+ validation = raw.get("validation", "auto")
426
+ return {
427
+ "depth": "deep" if depth == "auto" and high else "standard" if depth == "auto" else depth,
428
+ "validation": "full" if validation == "auto" and high else "quick" if validation == "auto" else validation,
429
+ }
430
+
431
+
432
+ def effective_preview_config(config: dict[str, Any]) -> dict[str, str]:
433
+ raw = config.get(PREVIEW_KEY, {})
434
+ theme_layout = raw.get("themeLayout", "auto")
435
+ return {
436
+ "themeLayout": ("matrix" if config.get(DETAIL_KEY) == "high" else "states") if theme_layout == "auto" else theme_layout,
437
+ "defaultView": raw.get("defaultView", "overview"),
438
+ "language": raw.get("language", "auto"),
439
+ }
440
+
441
+
442
+ def configuration_context(config: dict[str, Any]) -> dict[str, Any]:
443
+ completed = config.get(DETAIL_KEY) in DETAIL_PROFILES
444
+ questions = [] if completed else [
445
+ {
446
+ "key": "detail",
447
+ "question": "Выберите детализацию: Low — базовая разметка и минимум данных; Medium — взаимодействия, состояния и репрезентативные данные; High — расширенные данные, темы и полная проверка реконструкции/HTML. UI/UX-ревью ни один уровень не запускает.",
448
+ },
449
+ ]
450
+ detail = config.get(DETAIL_KEY)
451
+ return {
452
+ "status": "configured" if completed else "needs-setup",
453
+ "setupRequired": not completed,
454
+ "detailLevel": detail,
455
+ "detailProfile": DETAIL_PROFILES.get(detail),
456
+ UI_MODE_KEY: config.get(UI_MODE_KEY),
457
+ MOCK_DATA_KEY: mock_data_context(config),
458
+ PREVIEW_KEY: {**config.get(PREVIEW_KEY, {}), "effective": effective_preview_config(config)},
459
+ REVIEW_KEY: {**config.get(REVIEW_KEY, {}), "effective": effective_review_config(config)},
460
+ "autoSync": bool(config.get("autoSync", True)),
461
+ "cacheMode": config.get("cacheMode", "user"),
462
+ "questionsForUser": questions,
463
+ "changeCommand": "uidw --repo <repo> config set <setting> <value>",
464
+ }
465
+
466
+
467
+ def parse_on_off(value: str) -> bool:
468
+ normalized = value.strip().lower()
469
+ if normalized in {"on", "true", "yes", "1", "enable", "enabled"}:
470
+ return True
471
+ if normalized in {"off", "false", "no", "0", "disable", "disabled"}:
472
+ return False
473
+ raise ValueError(f"Expected on/off, got: {value}")
474
+
475
+
476
+ def set_config_value(config: dict[str, Any], key: str, value: str) -> None:
477
+ key = key.strip().lower()
478
+ if key not in SETTING_CATALOG:
479
+ raise ValueError(f"Unknown setting: {key}. Run `uidw help config`.")
480
+ allowed = SETTING_CATALOG[key]["values"]
481
+ if allowed != ["<text>"] and value not in allowed:
482
+ raise ValueError(f"Invalid {key}: {value}. Expected: {', '.join(allowed)}")
483
+ if key == "detail":
484
+ config[DETAIL_KEY] = value
485
+ config[MOCK_DATA_KEY] = {
486
+ "mode": MOCK_DATA_BY_DETAIL[value],
487
+ "seed": "stable",
488
+ "explicit": False,
489
+ }
490
+ elif key == "ui-mode":
491
+ config[UI_MODE_KEY] = {"enabled": parse_on_off(value)}
492
+ elif key in {"theme-layout", "default-view", "language"}:
493
+ field = {"theme-layout": "themeLayout", "default-view": "defaultView", "language": "language"}[key]
494
+ config[PREVIEW_KEY] = {**config.get(PREVIEW_KEY, {}), field: value}
495
+ elif key in {"review-depth", "validation"}:
496
+ field = "depth" if key == "review-depth" else "validation"
497
+ config[REVIEW_KEY] = {**config.get(REVIEW_KEY, {}), field: value}
498
+ elif key == "auto-sync":
499
+ config["autoSync"] = parse_on_off(value)
500
+ elif key == "cache-mode":
501
+ config["cacheMode"] = value
502
+ configured = config.get(DETAIL_KEY) in DETAIL_PROFILES
503
+ config[SETUP_KEY] = {"completed": configured, "answered": ["detail"] if configured else []}
504
+
505
+
506
+ def configure_project(root: Path, action: str, key: str | None = None, value: str | None = None) -> dict[str, Any]:
507
+ paths = state_paths(root)
508
+ config = load_config(paths["config"])
509
+ if action == "reset":
510
+ config = default_config()
511
+ elif action == "set":
512
+ if not key or value is None:
513
+ raise ValueError("Usage: uidw config set <setting> <value>")
514
+ set_config_value(config, key, value)
515
+ elif action not in {"show", "setup"}:
516
+ raise ValueError(f"Unknown config action: {action}")
517
+ if action in {"set", "reset"}:
518
+ destination = root / STATE_DIR_NAME / CONFIG_NAME if config.get("cacheMode") == "project" else paths["config"]
519
+ destination.parent.mkdir(parents=True, exist_ok=True)
520
+ write_json(destination, normalized_config(config))
521
+ ensure_initialized(root, synchronize=True)
522
+ paths = state_paths(root)
523
+ config = load_config(paths["config"])
524
+ return {
525
+ "version": 1,
526
+ "status": configuration_context(config)["status"],
527
+ "repoRoot": str(root),
528
+ "configuration": configuration_context(config),
529
+ "catalog": SETTING_CATALOG,
530
+ "configFile": str(paths["config"]),
531
+ "contextFile": str(paths["context"]),
532
+ }
533
+
534
+
535
+ def help_topic(topic: str | None = None) -> dict[str, Any]:
536
+ selected = topic or "overview"
537
+ topics = {
538
+ "overview": "Для сборки макета используйте `uidw workbench`: он проверяет только точность переноса и работу HTML. `uidw review` запускайте отдельно только по явному запросу на UI/UX-аудит. Ручной `init` не нужен: первый запуск автоматически создаст UI-кеш, последующие переиспользуют его. Для диагностики установки используйте `uidw doctor`.",
539
+ "config": "Первичная настройка спрашивает только детализацию: Low — базовая разметка и минимум данных; Medium — взаимодействия, состояния и репрезентативные данные; High — расширенные данные, темы и полная проверка реконструкции/HTML. Ни один уровень не включает UI/UX-аудит. Команды: `uidw config setup`, `uidw config show`, `uidw config set detail <low|medium|high>`.",
540
+ "themes": "В workbench попадают только темы, подтверждённые исходниками. Варианты группируются по темам, состояниям или раздельной матрице.",
541
+ "review": "`uidw review` — единственная команда, которая явно запускает UI/UX-аудит и его три шага: Проверка → Проблемы → Исправление. `uidw review prepare` создаёт только задание для внешнего AI-ревью, а `uidw review import <result.json>` импортирует его результат.",
542
+ "workbench": "`uidw workbench --output-dir <dir>` собирает HTML и проверяет только точность переноса, варианты, ссылки переходов и механику workbench. Он не оценивает UI/UX и не создаёт проблемы.",
543
+ "apply": "`uidw apply` работает только после выбора проблем, подготовки и подтверждения нового макета. `uidw apply --direct` — отдельный явный путь без предварительного макета.",
544
+ "advanced": "Расширенные команды: context, map, render, validate, workbench, diff, scenarios, findings, proposal, pack, unpack, visual-test и fidelity. `uidw fidelity capabilities` показывает установленные платформенные адаптеры и их ограничения. Используйте `<команда> --help` для точных параметров.",
545
+ }
546
+ if selected not in topics:
547
+ raise ValueError(f"Неизвестный раздел справки: {selected}. Доступно: {', '.join(topics)}")
548
+ return {"version": 1, "status": "ok", "topic": selected, "text": topics[selected], "topics": sorted(topics), "settings": SETTING_CATALOG if selected == "config" else None}
549
+
550
+
551
+ def about() -> dict[str, Any]:
552
+ return {
553
+ "version": 1,
554
+ "status": "ok",
555
+ "name": "UI Design Workbench",
556
+ "cliVersion": CLI_VERSION,
557
+ "description": "Provider-neutral CLI for reconstructing, reviewing, redesigning, and validating repository UI as standalone interactive HTML without running the application.",
558
+ "runtime": "No server, emulator, or application runtime required.",
559
+ }
560
+
561
+
562
+ def mock_data_context(config: dict[str, Any]) -> dict[str, Any]:
563
+ detail = config.get(DETAIL_KEY)
564
+ mode = MOCK_DATA_BY_DETAIL.get(detail, "minimal")
565
+ instructions = {
566
+ "minimal": (
567
+ "Create the smallest deterministic, non-sensitive data set that makes every visible data-bound view readable. "
568
+ "Use one value per scalar field and two compact synthetic items per list, table, grid, or collection. "
569
+ "Do not generate alternate scenarios at this level."
570
+ ),
571
+ "representative": (
572
+ "Create deterministic, non-sensitive, platform-appropriate representative data for the main user flow. "
573
+ "Populate collections with several varied synthetic items and add only task-critical alternate states supported "
574
+ "by source branches. Do not stamp the same loading/error/success set onto every screen."
575
+ ),
576
+ "exhaustive": (
577
+ "Create deterministic, non-sensitive, platform-appropriate expanded data that exercises density, scrolling, "
578
+ "long values, and source-evidenced boundary states. Keep variants screen-specific; never invent a universal "
579
+ "loading/error/success trio."
580
+ ),
581
+ }
582
+ result: dict[str, Any] = {
583
+ "enabled": True,
584
+ "mode": mode,
585
+ "source": "detailLevel",
586
+ "seed": "stable",
587
+ "instruction": instructions[mode] + " Represent collections with repeated synthetic item nodes; one summary text line is not a populated collection.",
588
+ }
589
+ return result
590
+
591
+
592
+ def fidelity_command(ir: dict[str, Any], action: str, identifier: str | None = None, output: Path | None = None, output_format: str = "json") -> dict[str, Any]:
593
+ if action == "capabilities":
594
+ adapters = adapter_capabilities()
595
+ return {"version": 1, "status": "pass", "adapterCount": len(adapters), "adapters": adapters}
596
+ if action == "report":
597
+ report = fidelity_report(ir)
598
+ if output:
599
+ output = output.resolve()
600
+ if output_format == "markdown":
601
+ coverage = report["propertyProvenance"]
602
+ lines = [
603
+ "# Fidelity report", "", f"Status: **{report['status']}**", "",
604
+ f"Property provenance: {coverage['covered']}/{coverage['total']} ({coverage['percent']}%)", "",
605
+ "## Strict errors", "",
606
+ ]
607
+ lines.extend(f"- {item}" for item in report["strictErrors"] or ["None"])
608
+ write_text_atomic(output, "\n".join(lines) + "\n")
609
+ else:
610
+ write_json(output, report)
611
+ report["reportFile"] = str(output)
612
+ return report
613
+ if not identifier:
614
+ raise ValueError("fidelity explain requires a node or evidence id")
615
+ nodes = ir.get("nodes", {})
616
+ if identifier in nodes:
617
+ node = nodes[identifier]
618
+ return {"version": 1, "status": "pass", "nodeId": identifier, "source": node.get("source", {}), "confidence": node.get("confidence"), "properties": node.get("provenance", {})}
619
+ for node_id, node in nodes.items():
620
+ for path, evidence in node.get("provenance", {}).items() if isinstance(node.get("provenance"), dict) else ():
621
+ if isinstance(evidence, dict) and evidence.get("id") == identifier:
622
+ return {"version": 1, "status": "pass", "evidenceId": identifier, "nodeId": node_id, "property": path, "evidence": evidence}
623
+ raise ValueError(f"Unknown node or evidence id: {identifier}")
624
+
625
+
626
+ def ui_mode_context(config: dict[str, Any], detected_platforms: list[str] | None = None) -> dict[str, Any]:
627
+ enabled = bool(config.get(UI_MODE_KEY, {}).get("enabled", False))
628
+ result: dict[str, Any] = {"enabled": enabled, "default": "off"}
629
+ if enabled:
630
+ result.update({
631
+ "scope": "ui-related tasks only",
632
+ "detectedPlatforms": detected_platforms or [],
633
+ "instruction": (
634
+ "For UI-related implementation tasks, preserve the project design system and apply the matching "
635
+ "platform conventions, accessibility, states, input methods, and adaptive behavior. Read only the "
636
+ "relevant platform guidance. Do not start a full review, redesign, or HTML workbench unless requested."
637
+ ),
638
+ })
639
+ return result
640
+
641
+
642
+ def candidate_files(root: Path) -> list[Path]:
643
+ result: list[Path] = []
644
+ supported = SOURCE_EXTENSIONS | ASSET_EXTENSIONS
645
+ for path in iter_files(root):
646
+ if path.suffix.lower() in supported:
647
+ result.append(path)
648
+ return sorted(result, key=lambda item: item.relative_to(root).as_posix())
649
+
650
+
651
+ def build_manifest(
652
+ root: Path,
653
+ previous: dict[str, Any] | None = None,
654
+ verify_content: bool = False,
655
+ ) -> dict[str, dict[str, Any]]:
656
+ previous = previous or {}
657
+ manifest: dict[str, dict[str, Any]] = {}
658
+ for path in candidate_files(root):
659
+ relative = path.relative_to(root).as_posix()
660
+ try:
661
+ stat = path.stat()
662
+ except OSError:
663
+ continue
664
+ old = previous.get(relative, {}) if isinstance(previous.get(relative), dict) else {}
665
+ metadata_same = old.get("size") == stat.st_size and old.get("mtimeNs") == stat.st_mtime_ns
666
+ try:
667
+ digest = old.get("sha256") if metadata_same and not verify_content else sha256_file(path)
668
+ except OSError:
669
+ continue
670
+ manifest[relative] = {
671
+ "size": stat.st_size,
672
+ "mtimeNs": stat.st_mtime_ns,
673
+ "sha256": digest,
674
+ "kind": "asset" if path.suffix.lower() in ASSET_EXTENSIONS else "source",
675
+ }
676
+ return manifest
677
+
678
+
679
+ def manifest_diff(previous: dict[str, Any], current: dict[str, Any]) -> dict[str, list[str]]:
680
+ old_paths = set(previous)
681
+ new_paths = set(current)
682
+ added = sorted(new_paths - old_paths)
683
+ removed = sorted(old_paths - new_paths)
684
+ modified: list[str] = []
685
+ metadata_only: list[str] = []
686
+ for path in sorted(old_paths & new_paths):
687
+ old = previous[path]
688
+ new = current[path]
689
+ if old.get("sha256") != new.get("sha256"):
690
+ modified.append(path)
691
+ elif old.get("size") != new.get("size") or old.get("mtimeNs") != new.get("mtimeNs"):
692
+ metadata_only.append(path)
693
+ return {"added": added, "modified": modified, "removed": removed, "metadataOnly": metadata_only}
694
+
695
+
696
+ def changed_paths(diff: dict[str, list[str]]) -> list[str]:
697
+ return sorted(set(diff["added"] + diff["modified"] + diff["removed"]))
698
+
699
+
700
+ def ui_reference_paths(scan: dict[str, Any] | None, ir: dict[str, Any] | None) -> set[str]:
701
+ """Collect source and asset paths that currently participate in the rendered UI graph."""
702
+ result: set[str] = set()
703
+ scan = scan if isinstance(scan, dict) else {}
704
+ ir = ir if isinstance(ir, dict) else {}
705
+ for item in scan.get("uiFiles", []):
706
+ if isinstance(item, dict) and item.get("path"):
707
+ result.add(str(item["path"]))
708
+ for item in (*scan.get("screens", []), *scan.get("routes", []), *scan.get("navigationTargets", []), *scan.get("components", [])):
709
+ if not isinstance(item, dict):
710
+ continue
711
+ source = item.get("source", {}) if isinstance(item.get("source"), dict) else {}
712
+ path = item.get("file") or source.get("file")
713
+ if path:
714
+ result.add(str(path))
715
+ result.update(str(path) for path in scan.get("tokenFiles", []) if path)
716
+ for screen in ir.get("screens", []):
717
+ if isinstance(screen, dict) and screen.get("source", {}).get("file"):
718
+ result.add(str(screen["source"]["file"]))
719
+ for node in ir.get("nodes", {}).values():
720
+ if not isinstance(node, dict):
721
+ continue
722
+ if node.get("source", {}).get("file"):
723
+ result.add(str(node["source"]["file"]))
724
+ asset = node.get("asset")
725
+ if isinstance(asset, str):
726
+ result.add(asset)
727
+ elif isinstance(asset, dict):
728
+ for key in ("path", "file", "src"):
729
+ if asset.get(key):
730
+ result.add(str(asset[key]))
731
+ return result
732
+
733
+
734
+ def classify_ui_changes(
735
+ root: Path,
736
+ diff: dict[str, list[str]],
737
+ previous_records: dict[str, Any],
738
+ scan: dict[str, Any] | None,
739
+ ir: dict[str, Any] | None,
740
+ ) -> tuple[dict[str, list[str]], dict[str, Any]]:
741
+ """Separate repository source changes from changes that can affect the current UI target."""
742
+ references = ui_reference_paths(scan, ir)
743
+ updates: dict[str, Any] = {}
744
+
745
+ def relevant(path: str, current: dict[str, Any] | None, previous: dict[str, Any] | None) -> bool:
746
+ records = [record for record in (current, previous) if isinstance(record, dict)]
747
+ return path in references or any(isinstance(record.get("uiFile"), dict) for record in records)
748
+
749
+ classified = {key: [] for key in ("added", "modified", "removed", "metadataOnly")}
750
+ for key in ("added", "modified"):
751
+ for relative in diff[key]:
752
+ record = analyze_file(root, root / Path(relative))
753
+ if record is not None:
754
+ updates[relative] = record
755
+ previous = previous_records.get(relative) if isinstance(previous_records.get(relative), dict) else None
756
+ if relevant(relative, record, previous):
757
+ classified[key].append(relative)
758
+ for relative in diff["removed"]:
759
+ previous = previous_records.get(relative) if isinstance(previous_records.get(relative), dict) else None
760
+ if relevant(relative, None, previous):
761
+ classified["removed"].append(relative)
762
+ for relative in diff["metadataOnly"]:
763
+ previous = previous_records.get(relative) if isinstance(previous_records.get(relative), dict) else None
764
+ if relevant(relative, previous, previous):
765
+ classified["metadataOnly"].append(relative)
766
+ return classified, updates
767
+
768
+
769
+ def load_project_ir(paths: dict[str, Path]) -> dict[str, Any] | None:
770
+ value = read_json(paths["ir"])
771
+ return value if isinstance(value, dict) else None
772
+
773
+
774
+ def graph_id(kind: str, *parts: str) -> str:
775
+ key = "\x1f".join([kind, *parts])
776
+ return f"{kind}:{hashlib.sha256(key.encode('utf-8')).hexdigest()[:16]}"
777
+
778
+
779
+ def build_ui_graph(root: Path, inventory: dict[str, Any]) -> dict[str, Any]:
780
+ """Build a stable, provider-neutral source UI graph from the scan inventory."""
781
+ nodes: dict[str, dict[str, Any]] = {}
782
+ edges: dict[tuple[str, str, str], dict[str, Any]] = {}
783
+
784
+ def add_node(kind: str, key: str, **data: Any) -> str:
785
+ node_id = graph_id(kind, key)
786
+ nodes[node_id] = {"id": node_id, "kind": kind, **data}
787
+ return node_id
788
+
789
+ def add_edge(source: str, target: str, kind: str, **data: Any) -> None:
790
+ edges[(source, target, kind)] = {"source": source, "target": target, "kind": kind, **data}
791
+
792
+ files: dict[str, str] = {}
793
+
794
+ def file_node(path: str) -> str:
795
+ if path not in files:
796
+ files[path] = add_node("source", path, path=path)
797
+ return files[path]
798
+
799
+ screen_nodes: dict[str, str] = {}
800
+ for screen in inventory.get("screens", []):
801
+ path = str(screen.get("file") or "")
802
+ key = f"{path}\x1f{screen.get('name')}\x1f{screen.get('kind')}"
803
+ node_id = add_node("screen", key, name=screen.get("name"), screenKind=screen.get("kind"), file=path, evidence=screen.get("evidence", []))
804
+ screen_nodes[key] = node_id
805
+ if path:
806
+ add_edge(file_node(path), node_id, "declares")
807
+
808
+ route_nodes: dict[str, str] = {}
809
+ for route in inventory.get("routes", []):
810
+ path = str(route.get("file") or "")
811
+ value = str(route.get("route") or "")
812
+ key = f"{path}\x1f{value}"
813
+ node_id = add_node("route", key, value=value, file=path, line=route.get("line"))
814
+ route_nodes[value] = node_id
815
+ if path:
816
+ add_edge(file_node(path), node_id, "declares")
817
+
818
+ for component in inventory.get("components", []):
819
+ path = str(component.get("file") or "")
820
+ symbol = str(component.get("symbol") or "")
821
+ key = f"{path}\x1f{symbol}"
822
+ node_id = add_node("component", key, symbol=symbol, file=path, platform=component.get("platform"), line=component.get("line"))
823
+ if path:
824
+ source_id = file_node(path)
825
+ add_edge(source_id, node_id, "declares")
826
+ for screen in inventory.get("screens", []):
827
+ if screen.get("file") == path:
828
+ screen_key = f"{path}\x1f{screen.get('name')}\x1f{screen.get('kind')}"
829
+ if screen_key in screen_nodes:
830
+ add_edge(screen_nodes[screen_key], node_id, "uses-local-component")
831
+
832
+ for token in inventory.get("tokenFiles", []):
833
+ path = str(token.get("path") or "") if isinstance(token, dict) else str(token or "")
834
+ node_id = add_node("token-file", path, path=path)
835
+ if path:
836
+ add_edge(file_node(path), node_id, "defines")
837
+
838
+ for navigation in inventory.get("navigationTargets", []):
839
+ path = str(navigation.get("file") or "")
840
+ target = str(navigation.get("target") or "")
841
+ key = f"{path}\x1f{target}\x1f{navigation.get('line')}"
842
+ node_id = add_node("navigation-target", key, value=target, file=path, line=navigation.get("line"), navigationKind=navigation.get("kind"))
843
+ if path:
844
+ add_edge(file_node(path), node_id, "declares")
845
+ route_id = route_nodes.get(target) or route_nodes.get(target.lstrip("#/"))
846
+ if route_id:
847
+ add_edge(node_id, route_id, "navigates-to", resolved=True)
848
+
849
+ starter = starter_ir(inventory)
850
+ return {
851
+ "version": 1,
852
+ "graphType": "ui-source-map",
853
+ "repoRoot": str(root),
854
+ "project": inventory.get("project", {}),
855
+ "detectedPlatforms": inventory.get("detectedPlatforms", []),
856
+ "summary": {"nodes": len(nodes), "edges": len(edges), "screens": len(inventory.get("screens", [])), "routes": len(inventory.get("routes", []))},
857
+ "screenTree": starter.get("screenTree", []),
858
+ "nodes": sorted(nodes.values(), key=lambda item: (str(item.get("kind")), str(item.get("id")))),
859
+ "edges": sorted(edges.values(), key=lambda item: (str(item.get("source")), str(item.get("kind")), str(item.get("target")))),
860
+ }
861
+
862
+
863
+ def impacted_screens(
864
+ ir: dict[str, Any] | None,
865
+ old_scan: dict[str, Any],
866
+ new_scan: dict[str, Any],
867
+ changed: list[str],
868
+ invalidate_all: bool,
869
+ ) -> list[str]:
870
+ if not ir:
871
+ return sorted({str(item.get("name")) for item in new_scan.get("screens", []) if item.get("file") in changed})
872
+ all_screens = {str(item.get("id")) for item in ir.get("screens", []) if item.get("id")}
873
+ if invalidate_all:
874
+ return sorted(all_screens)
875
+ changed_set = set(changed)
876
+ roles: set[str] = set()
877
+ for inventory in (old_scan, new_scan):
878
+ for item in inventory.get("uiFiles", []):
879
+ if item.get("path") in changed_set:
880
+ roles.add(str(item.get("role")))
881
+ if roles & {"theme", "navigation"}:
882
+ return sorted(all_screens)
883
+ impacted: set[str] = set()
884
+ for screen in ir.get("screens", []):
885
+ if screen.get("source", {}).get("file") in changed_set:
886
+ impacted.add(str(screen.get("id")))
887
+ node_screens = node_screen_map(ir)
888
+ for node_id, node in ir.get("nodes", {}).items():
889
+ source_file = node.get("source", {}).get("file")
890
+ component_ref = str(node.get("componentRef") or "")
891
+ serialized = json.dumps(node, ensure_ascii=False)
892
+ if source_file in changed_set or any(path in component_ref or path in serialized for path in changed_set):
893
+ impacted.update(node_screens.get(str(node_id), set()))
894
+ if "component" in roles and not impacted:
895
+ return sorted(all_screens)
896
+ return sorted(impacted)
897
+
898
+
899
+ def semantic_diff(old_scan: dict[str, Any], new_scan: dict[str, Any]) -> dict[str, list[str]]:
900
+ def keys(scan: dict[str, Any], section: str, fields: tuple[str, ...]) -> set[str]:
901
+ return {
902
+ "#".join(str(item.get(field) or "") for field in fields)
903
+ for item in scan.get(section, [])
904
+ if isinstance(item, dict)
905
+ }
906
+
907
+ result: dict[str, list[str]] = {}
908
+ for label, section, fields in (
909
+ ("screens", "screens", ("file", "name")),
910
+ ("routes", "routes", ("file", "route")),
911
+ ("components", "components", ("file", "symbol")),
912
+ ):
913
+ before, after = keys(old_scan, section, fields), keys(new_scan, section, fields)
914
+ result[f"added{label.title()}"] = sorted(after - before)
915
+ result[f"removed{label.title()}"] = sorted(before - after)
916
+ return result
917
+
918
+
919
+ def extract_design_model(ir: dict[str, Any] | None) -> dict[str, Any]:
920
+ """Keep authored screen/node/scenario detail separate from the generated source index."""
921
+ if not isinstance(ir, dict):
922
+ return {"version": 1, "screens": {}, "nodes": {}, "scenarioFixtures": {}, "themes": {}, "tokens": {}}
923
+ return {
924
+ "version": 1,
925
+ "screens": {
926
+ str(item["id"]): copy.deepcopy(item)
927
+ for item in ir.get("screens", [])
928
+ if isinstance(item, dict) and item.get("id")
929
+ },
930
+ "nodes": copy.deepcopy(ir.get("nodes", {})) if isinstance(ir.get("nodes"), dict) else {},
931
+ "scenarioFixtures": copy.deepcopy(ir.get("scenarioFixtures", {})) if isinstance(ir.get("scenarioFixtures"), dict) else {},
932
+ "themes": copy.deepcopy(ir.get("themes", {})) if isinstance(ir.get("themes"), dict) else {},
933
+ "tokens": copy.deepcopy(ir.get("tokens", {})) if isinstance(ir.get("tokens"), dict) else {},
934
+ "savedAt": utc_now(),
935
+ }
936
+
937
+
938
+ def extract_review_state(ir: dict[str, Any] | None) -> dict[str, Any]:
939
+ review = copy.deepcopy(ir.get("review", {})) if isinstance(ir, dict) and isinstance(ir.get("review"), dict) else {}
940
+ return {"version": 1, "review": review, "savedAt": utc_now()}
941
+
942
+
943
+ _MISSING_TOKEN = object()
944
+
945
+
946
+ def deep_merge_dict(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
947
+ result = copy.deepcopy(base)
948
+ for key, value in override.items():
949
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
950
+ result[key] = deep_merge_dict(result[key], value)
951
+ else:
952
+ result[key] = copy.deepcopy(value)
953
+ return result
954
+
955
+
956
+ def prune_missing_token_refs(value: Any, tokens: dict[str, Any]) -> Any:
957
+ """Drop authored values whose token path no longer exists after cache migration."""
958
+
959
+ if isinstance(value, str) and value.startswith("$"):
960
+ resolved: Any = tokens
961
+ for part in value[1:].split("."):
962
+ if not isinstance(resolved, dict) or part not in resolved:
963
+ return _MISSING_TOKEN
964
+ resolved = resolved[part]
965
+ return value
966
+ if isinstance(value, dict):
967
+ result = {}
968
+ for key, item in value.items():
969
+ cleaned = prune_missing_token_refs(item, tokens)
970
+ if cleaned is not _MISSING_TOKEN:
971
+ result[key] = cleaned
972
+ return result
973
+ if isinstance(value, list):
974
+ items = [prune_missing_token_refs(item, tokens) for item in value]
975
+ return [item for item in items if item is not _MISSING_TOKEN]
976
+ return copy.deepcopy(value)
977
+
978
+
979
+ def merge_authored_state(
980
+ generated: dict[str, Any],
981
+ design_model: dict[str, Any],
982
+ review_state: dict[str, Any],
983
+ impacted_screen_ids: list[str],
984
+ ) -> dict[str, Any]:
985
+ """Overlay durable authored detail while marking changed source bindings as stale."""
986
+ result = copy.deepcopy(generated)
987
+ impacted = set(impacted_screen_ids)
988
+ generated_tokens = result.get("tokens", {}) if isinstance(result.get("tokens"), dict) else {}
989
+ authored_tokens = design_model.get("tokens", {}) if isinstance(design_model.get("tokens"), dict) else {}
990
+ merged_tokens = deep_merge_dict(generated_tokens, authored_tokens)
991
+ generated_screens = {str(item.get("id")): item for item in result.get("screens", []) if item.get("id")}
992
+ preserved_screens = design_model.get("screens", {}) if isinstance(design_model.get("screens"), dict) else {}
993
+ for screen_id, authored in preserved_screens.items():
994
+ current = generated_screens.get(str(screen_id))
995
+ if current is None or not isinstance(authored, dict):
996
+ continue
997
+ source = copy.deepcopy(current.get("source", {}))
998
+ current.update(copy.deepcopy(authored))
999
+ if source:
1000
+ current["source"] = source
1001
+ if screen_id in impacted:
1002
+ current["sourceState"] = "stale"
1003
+ else:
1004
+ current.pop("sourceState", None)
1005
+ result["screens"] = list(generated_screens.values())
1006
+
1007
+ current_screen_ids = set(generated_screens)
1008
+ old_nodes = design_model.get("nodes", {}) if isinstance(design_model.get("nodes"), dict) else {}
1009
+ old_screen_map = node_screen_map({"screens": list(preserved_screens.values()), "nodes": old_nodes})
1010
+ for node_id, authored in old_nodes.items():
1011
+ owners = old_screen_map.get(str(node_id), set())
1012
+ if owners and not owners.intersection(current_screen_ids):
1013
+ continue
1014
+ cleaned_authored = prune_missing_token_refs(authored, merged_tokens)
1015
+ if not isinstance(cleaned_authored, dict):
1016
+ continue
1017
+ existing = result.setdefault("nodes", {}).get(node_id, {})
1018
+ merged = copy.deepcopy(existing)
1019
+ for key, value in cleaned_authored.items():
1020
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
1021
+ merged[key] = {**merged[key], **value}
1022
+ else:
1023
+ merged[key] = copy.deepcopy(value)
1024
+ if owners.intersection(impacted):
1025
+ merged["sourceState"] = "stale"
1026
+ else:
1027
+ merged.pop("sourceState", None)
1028
+ result["nodes"][node_id] = merged
1029
+
1030
+ fixtures = design_model.get("scenarioFixtures", {})
1031
+ if isinstance(fixtures, dict) and fixtures:
1032
+ result["scenarioFixtures"] = copy.deepcopy(fixtures)
1033
+
1034
+ if merged_tokens:
1035
+ result["tokens"] = merged_tokens
1036
+
1037
+ generated_themes = result.get("themes", {}) if isinstance(result.get("themes"), dict) else {}
1038
+ authored_themes = design_model.get("themes", {}) if isinstance(design_model.get("themes"), dict) else {}
1039
+ generated_items = generated_themes.get("items", []) if isinstance(generated_themes.get("items"), list) else []
1040
+ authored_items = {
1041
+ str(item.get("id")): item
1042
+ for item in authored_themes.get("items", [])
1043
+ if isinstance(item, dict) and item.get("id")
1044
+ }
1045
+ merged_theme_items: list[dict[str, Any]] = []
1046
+ for generated_theme in generated_items:
1047
+ if not isinstance(generated_theme, dict) or not generated_theme.get("id"):
1048
+ continue
1049
+ theme_id = str(generated_theme["id"])
1050
+ merged_theme = copy.deepcopy(generated_theme)
1051
+ authored_theme = authored_items.get(theme_id)
1052
+ if isinstance(authored_theme, dict):
1053
+ generated_refs = copy.deepcopy(merged_theme.get("sourceRefs", []))
1054
+ merged_theme.update(copy.deepcopy(authored_theme))
1055
+ if generated_refs:
1056
+ merged_theme["sourceRefs"] = generated_refs
1057
+ merged_theme_items.append(merged_theme)
1058
+ if merged_theme_items:
1059
+ requested_default = authored_themes.get("defaultThemeId")
1060
+ known_theme_ids = {str(item.get("id")) for item in merged_theme_items}
1061
+ generated_themes["items"] = merged_theme_items
1062
+ generated_themes["defaultThemeId"] = requested_default if requested_default in known_theme_ids else generated_themes.get("defaultThemeId", "light")
1063
+ result["themes"] = generated_themes
1064
+
1065
+ old_review = review_state.get("review", {}) if isinstance(review_state.get("review"), dict) else {}
1066
+ if old_review:
1067
+ base_review = result.setdefault("review", {})
1068
+ preserved = copy.deepcopy(old_review)
1069
+ known_screens = set(current_screen_ids)
1070
+ audit = preserved.get("audit", {}) if isinstance(preserved.get("audit"), dict) else {}
1071
+ findings = []
1072
+ for finding in audit.get("findings", []):
1073
+ if not isinstance(finding, dict) or finding.get("screenId") not in known_screens:
1074
+ continue
1075
+ item = copy.deepcopy(finding)
1076
+ if item.get("screenId") in impacted:
1077
+ item["sourceState"] = "stale"
1078
+ findings.append(item)
1079
+ if findings or "findings" in audit:
1080
+ audit["findings"] = findings
1081
+ preserved["audit"] = audit
1082
+ generated_baseline = next((item for item in base_review.get("versions", []) if item.get("kind") == "baseline"), None)
1083
+ proposals = [item for item in preserved.get("versions", []) if item.get("kind") != "baseline"]
1084
+ preserved["versions"] = ([generated_baseline] if generated_baseline else []) + proposals
1085
+ base_review.update(preserved)
1086
+ if generated_baseline:
1087
+ base_review["baselineVersion"] = generated_baseline.get("id", "baseline")
1088
+ seal_baseline(result)
1089
+ return result
1090
+
1091
+
1092
+ def compact_context(
1093
+ root: Path,
1094
+ inventory: dict[str, Any],
1095
+ report: dict[str, Any],
1096
+ ir: dict[str, Any] | None,
1097
+ config: dict[str, Any],
1098
+ ) -> dict[str, Any]:
1099
+ max_screens = max(1, int(config.get("maxContextScreens", 100)))
1100
+ max_components = max(1, int(config.get("maxContextComponents", 200)))
1101
+ impacted = set(report.get("impactedScreenIds", []))
1102
+ screens = inventory.get("screens", [])
1103
+ if impacted and ir:
1104
+ source_by_id = {
1105
+ str(screen.get("id")): str(screen.get("source", {}).get("file") or "")
1106
+ for screen in ir.get("screens", [])
1107
+ }
1108
+ impacted_files = {source_by_id[item] for item in impacted if source_by_id.get(item)}
1109
+ screens = [item for item in screens if item.get("file") in impacted_files] or screens
1110
+ priority_files = list(report.get("changedUiFiles", []))
1111
+ for item in screens:
1112
+ file = str(item.get("file") or "")
1113
+ if file and file not in priority_files:
1114
+ priority_files.append(file)
1115
+ return {
1116
+ "version": 1,
1117
+ "repoRoot": str(root),
1118
+ "cacheStatus": report.get("status"),
1119
+ "syncedAt": report.get("syncedAt"),
1120
+ "detectedPlatforms": inventory.get("detectedPlatforms", []),
1121
+ "summary": inventory.get("summary", {}),
1122
+ "changedUiFiles": report.get("changedUiFiles", []),
1123
+ "impactedScreenIds": report.get("impactedScreenIds", []),
1124
+ "prioritySourceFiles": priority_files[:300],
1125
+ "screens": screens[:max_screens],
1126
+ "routes": inventory.get("routes", [])[:200],
1127
+ "navigationTargets": inventory.get("navigationTargets", [])[:200],
1128
+ "tokenFiles": inventory.get("tokenFiles", [])[:100],
1129
+ "themes": inventory.get("themes", []),
1130
+ "components": inventory.get("components", [])[:max_components],
1131
+ "warnings": inventory.get("warnings", []),
1132
+ "configuration": configuration_context(config),
1133
+ UI_MODE_KEY: ui_mode_context(config, inventory.get("detectedPlatforms", [])),
1134
+ MOCK_DATA_KEY: mock_data_context(config),
1135
+ "instructions": "Read prioritySourceFiles only when cacheStatus is stale or the requested screen is not fully represented in ui-ir.json.",
1136
+ }
1137
+
1138
+
1139
+ def inspect_cache(root: Path, verify_content: bool = False, include_internal: bool = False) -> tuple[dict[str, Any], dict[str, Any], dict[str, Path], dict[str, Any]]:
1140
+ paths = state_paths(root)
1141
+ config = load_config(paths["config"])
1142
+ cache = read_json(paths["cache"], {})
1143
+ if not isinstance(cache, dict):
1144
+ cache = {}
1145
+ previous_manifest = cache.get("manifest", {}) if isinstance(cache.get("manifest"), dict) else {}
1146
+ current_manifest = build_manifest(root, previous_manifest, verify_content)
1147
+ source_diff = manifest_diff(previous_manifest, current_manifest)
1148
+ previous_records = cache.get("fileRecords", {}) if isinstance(cache.get("fileRecords"), dict) else {}
1149
+ old_scan = read_json(paths["scan"], {}) if paths["scan"].is_file() else {}
1150
+ old_ir = load_project_ir(paths)
1151
+ diff, record_updates = classify_ui_changes(root, source_diff, previous_records, old_scan, old_ir)
1152
+ reasons: list[str] = []
1153
+ if not paths["cache"].is_file() or not paths["scan"].is_file() or not paths["graph"].is_file():
1154
+ reasons.append("cache-missing")
1155
+ if cache.get("cacheVersion") != CACHE_VERSION:
1156
+ reasons.append("cache-version-changed")
1157
+ if cache.get("scannerVersion") != SCANNER_VERSION:
1158
+ reasons.append("scanner-version-changed")
1159
+ if cache.get("configHash") != config_hash(config):
1160
+ reasons.append("config-changed")
1161
+ if changed_paths(diff):
1162
+ reasons.append("ui-files-changed")
1163
+ status = "stale" if reasons else "clean"
1164
+ result = {
1165
+ "version": 1,
1166
+ "status": status,
1167
+ "repoRoot": str(root),
1168
+ "reasons": reasons,
1169
+ "changes": diff,
1170
+ "sourceChanges": source_diff,
1171
+ "changedUiFiles": changed_paths(diff),
1172
+ "cacheFile": str(paths["cache"]),
1173
+ "configFile": str(paths["config"]),
1174
+ "scanFile": str(paths["scan"]),
1175
+ "irFile": str(paths["ir"]),
1176
+ "graphFile": str(paths["graph"]),
1177
+ "configuration": configuration_context(config),
1178
+ UI_MODE_KEY: ui_mode_context(config),
1179
+ MOCK_DATA_KEY: mock_data_context(config),
1180
+ }
1181
+ if include_internal:
1182
+ result["_recordUpdates"] = record_updates
1183
+ return result, current_manifest, paths, config
1184
+
1185
+
1186
+ def sync_project(root: Path, force: bool = False, verify_content: bool = False) -> dict[str, Any]:
1187
+ initial_paths = state_paths(root)
1188
+ with state_lock(initial_paths["lock"]):
1189
+ status, current_manifest, paths, config = inspect_cache(root, verify_content, include_internal=True)
1190
+ record_updates = status.pop("_recordUpdates", {})
1191
+ cache = read_json(paths["cache"], {}) or {}
1192
+ old_scan = read_json(paths["scan"], {}) or {}
1193
+ old_ir = load_project_ir(paths)
1194
+ design_model = read_json(paths["design"], None)
1195
+ review_state = read_json(paths["review"], None)
1196
+ if isinstance(old_ir, dict):
1197
+ design_model = extract_design_model(old_ir)
1198
+ review_state = extract_review_state(old_ir)
1199
+ elif not isinstance(design_model, dict):
1200
+ design_model = extract_design_model(None)
1201
+ if not isinstance(review_state, dict):
1202
+ review_state = extract_review_state(None)
1203
+ invalidate_all = force or any(reason in status["reasons"] for reason in ("cache-missing", "cache-version-changed", "scanner-version-changed", "config-changed"))
1204
+ if status["status"] == "clean" and not force:
1205
+ inventory = old_scan
1206
+ if any(status.get("sourceChanges", {}).values()) and cache:
1207
+ cache["manifest"] = current_manifest
1208
+ file_records = dict(cache.get("fileRecords", {})) if isinstance(cache.get("fileRecords"), dict) else {}
1209
+ for relative in status["sourceChanges"]["removed"]:
1210
+ file_records.pop(relative, None)
1211
+ file_records.update(record_updates)
1212
+ cache["fileRecords"] = file_records
1213
+ write_json(paths["cache"], cache)
1214
+ report = {**status, "syncedAt": cache.get("syncedAt"), "impactedScreenIds": []}
1215
+ else:
1216
+ previous_records = cache.get("fileRecords", {}) if isinstance(cache.get("fileRecords"), dict) else {}
1217
+ if invalidate_all or not previous_records:
1218
+ file_records: dict[str, Any] = {}
1219
+ for relative in current_manifest:
1220
+ record = record_updates.get(relative)
1221
+ if record is None:
1222
+ record = analyze_file(root, root / Path(relative))
1223
+ if record is not None:
1224
+ file_records[relative] = record
1225
+ else:
1226
+ file_records = dict(previous_records)
1227
+ for relative in status["changes"]["removed"]:
1228
+ file_records.pop(relative, None)
1229
+ for relative in status["changes"]["added"] + status["changes"]["modified"]:
1230
+ record = record_updates.get(relative)
1231
+ if record is None:
1232
+ record = analyze_file(root, root / Path(relative))
1233
+ if record is None:
1234
+ file_records.pop(relative, None)
1235
+ else:
1236
+ file_records[relative] = record
1237
+ inventory = assemble_scan(root, file_records.values())
1238
+ generated_ir = starter_ir(inventory)
1239
+ impacted = sorted(set(
1240
+ impacted_screens(old_ir, old_scan, inventory, status["changedUiFiles"], invalidate_all)
1241
+ + impacted_screens(generated_ir, old_scan, inventory, status["changedUiFiles"], invalidate_all)
1242
+ ))
1243
+ new_ir = merge_authored_state(generated_ir, design_model, review_state, impacted)
1244
+ synced_at = utc_now()
1245
+ paths["dir"].mkdir(parents=True, exist_ok=True)
1246
+ write_json(paths["scan"], inventory)
1247
+ write_json(paths["graph"], build_ui_graph(root, inventory))
1248
+ write_json(paths["ir"], new_ir)
1249
+ write_json(paths["design"], extract_design_model(new_ir))
1250
+ write_json(paths["review"], extract_review_state(new_ir))
1251
+ write_json(paths["cache"], {
1252
+ "cacheVersion": CACHE_VERSION,
1253
+ "scannerVersion": SCANNER_VERSION,
1254
+ "repoRoot": str(root),
1255
+ "configHash": config_hash(config),
1256
+ "syncedAt": synced_at,
1257
+ "manifest": current_manifest,
1258
+ "fileRecords": file_records,
1259
+ })
1260
+ report = {
1261
+ **status,
1262
+ "status": "synced",
1263
+ "syncedAt": synced_at,
1264
+ "impactedScreenIds": impacted,
1265
+ "semanticChanges": semantic_diff(old_scan, inventory),
1266
+ }
1267
+ current_ir = load_project_ir(paths)
1268
+ if current_ir and not paths["design"].is_file():
1269
+ write_json(paths["design"], extract_design_model(current_ir))
1270
+ if current_ir and not paths["review"].is_file():
1271
+ write_json(paths["review"], extract_review_state(current_ir))
1272
+ context = compact_context(root, inventory, report, current_ir, config)
1273
+ context["uiGraphFile"] = str(paths["graph"])
1274
+ context["designModelFile"] = str(paths["design"])
1275
+ context["reviewStateFile"] = str(paths["review"])
1276
+ write_json(paths["context"], context)
1277
+ write_json(paths["sync"], report)
1278
+ return report
1279
+
1280
+
1281
+ def ensure_initialized(
1282
+ root: Path,
1283
+ force: bool = False,
1284
+ verify_content: bool = False,
1285
+ synchronize: bool | None = None,
1286
+ ) -> tuple[dict[str, Any], dict[str, Path], dict[str, Any]]:
1287
+ """Create or reuse the project UI cache through one observable bootstrap path."""
1288
+
1289
+ paths = state_paths(root)
1290
+ required_keys = ("cache", "scan", "graph", "ir")
1291
+ cache_ready_before = all(paths[key].is_file() for key in required_keys)
1292
+ config_created = not paths["config"].is_file()
1293
+ if config_created:
1294
+ paths["config"].parent.mkdir(parents=True, exist_ok=True)
1295
+ write_json(paths["config"], normalized_config(default_config()))
1296
+ paths = state_paths(root)
1297
+
1298
+ config = load_config(paths["config"])
1299
+ should_sync = force or synchronize is True or (synchronize is None and bool(config.get("autoSync", True)))
1300
+ if should_sync:
1301
+ result = sync_project(root, force=force, verify_content=verify_content)
1302
+ else:
1303
+ result, _, paths, config = inspect_cache(root, verify_content)
1304
+ paths = state_paths(root)
1305
+ config = load_config(paths["config"])
1306
+ cache_ready_after = all(paths[key].is_file() for key in required_keys)
1307
+
1308
+ if not cache_ready_before and cache_ready_after:
1309
+ bootstrap_status = "created"
1310
+ message = "Инициализация: кеш проекта создан."
1311
+ elif should_sync and result.get("status") == "synced":
1312
+ bootstrap_status = "updated"
1313
+ message = "Инициализация: существующий UI-кеш обновлён."
1314
+ elif cache_ready_after and result.get("status") == "clean":
1315
+ bootstrap_status = "reused"
1316
+ message = "Инициализация: используется существующий UI-кеш."
1317
+ else:
1318
+ bootstrap_status = "stale"
1319
+ message = "Инициализация: UI-кеш требует обновления, но автоматическая синхронизация отключена."
1320
+
1321
+ initialization = {
1322
+ "status": bootstrap_status,
1323
+ "firstRun": not cache_ready_before,
1324
+ "configCreated": config_created,
1325
+ "cacheCreated": bootstrap_status == "created",
1326
+ "cacheReused": bootstrap_status == "reused",
1327
+ "synchronizationChecked": should_sync,
1328
+ "synchronized": result.get("status") == "synced",
1329
+ "sourceAnalysisRun": result.get("status") == "synced",
1330
+ "cacheDir": str(paths["dir"]),
1331
+ "message": message,
1332
+ }
1333
+ inventory = read_json(paths["scan"], {})
1334
+ detected_platforms = inventory.get("detectedPlatforms", []) if isinstance(inventory, dict) else []
1335
+ result = {
1336
+ **result,
1337
+ "initialization": initialization,
1338
+ "configuration": configuration_context(config),
1339
+ UI_MODE_KEY: ui_mode_context(config, detected_platforms),
1340
+ MOCK_DATA_KEY: mock_data_context(config),
1341
+ }
1342
+ context = read_json(paths["context"], {})
1343
+ if isinstance(context, dict) and context:
1344
+ context["initialization"] = initialization
1345
+ write_json(paths["context"], context)
1346
+ return result, paths, config
1347
+
1348
+
1349
+ def initialize(
1350
+ root: Path,
1351
+ force: bool = False,
1352
+ project_cache: bool = False,
1353
+ ui_mode: bool = False,
1354
+ detail_level: str | None = None,
1355
+ setup_completed: bool = False,
1356
+ ) -> dict[str, Any]:
1357
+ if project_cache:
1358
+ config_path = root / STATE_DIR_NAME / CONFIG_NAME
1359
+ config = load_config(config_path)
1360
+ config["cacheMode"] = "project"
1361
+ else:
1362
+ initial_paths = state_paths(root)
1363
+ config_path = initial_paths["config"]
1364
+ config = load_config(config_path)
1365
+ config[UI_MODE_KEY] = {"enabled": bool(ui_mode)}
1366
+ config[DETAIL_KEY] = detail_level if detail_level in DETAIL_PROFILES else None
1367
+ config[MOCK_DATA_KEY] = {
1368
+ "mode": MOCK_DATA_BY_DETAIL.get(config[DETAIL_KEY], "minimal"),
1369
+ "seed": "stable",
1370
+ "explicit": False,
1371
+ }
1372
+ config[SETUP_KEY] = {
1373
+ "completed": bool(setup_completed and config[DETAIL_KEY]),
1374
+ "answered": ["detail"] if setup_completed else [],
1375
+ }
1376
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1377
+ write_json(config_path, config)
1378
+ paths = state_paths(root)
1379
+ paths["dir"].mkdir(parents=True, exist_ok=True)
1380
+ if project_cache and not paths["gitignore"].exists():
1381
+ paths["gitignore"].write_text(STATE_GITIGNORE, encoding="utf-8")
1382
+ result, paths, config = ensure_initialized(root, force=force, synchronize=True)
1383
+ return {
1384
+ **result,
1385
+ "configuration": configuration_context(config),
1386
+ UI_MODE_KEY: ui_mode_context(config, read_json(paths["scan"], {}).get("detectedPlatforms", [])),
1387
+ MOCK_DATA_KEY: mock_data_context(config),
1388
+ }
1389
+
1390
+
1391
+ def configure_ui_mode(root: Path, enabled: bool | None = None) -> dict[str, Any]:
1392
+ paths = state_paths(root)
1393
+ config = load_config(paths["config"])
1394
+ if enabled is not None:
1395
+ config[UI_MODE_KEY] = {"enabled": enabled}
1396
+ paths["config"].parent.mkdir(parents=True, exist_ok=True)
1397
+ write_json(paths["config"], normalized_config(config))
1398
+ ensure_initialized(root, synchronize=True)
1399
+ paths = state_paths(root)
1400
+ inventory = read_json(paths["scan"], {})
1401
+ detected = inventory.get("detectedPlatforms", []) if isinstance(inventory, dict) else []
1402
+ mode = ui_mode_context(config, detected)
1403
+ return {
1404
+ "version": 1,
1405
+ "status": "enabled" if mode["enabled"] else "disabled",
1406
+ "repoRoot": str(root),
1407
+ UI_MODE_KEY: mode,
1408
+ "configFile": str(paths["config"]),
1409
+ "contextFile": str(paths["context"]),
1410
+ }
1411
+
1412
+
1413
+ def chrome_path() -> str | None:
1414
+ candidates = [
1415
+ shutil.which("google-chrome"),
1416
+ shutil.which("chromium"),
1417
+ shutil.which("chromium-browser"),
1418
+ shutil.which("msedge"),
1419
+ ]
1420
+ if os.name == "nt":
1421
+ candidates.extend([
1422
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
1423
+ r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
1424
+ r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
1425
+ ])
1426
+ return next((str(item) for item in candidates if item and Path(item).exists()), None)
1427
+
1428
+
1429
+ def doctor(root: Path) -> dict[str, Any]:
1430
+ status, _, paths, config = inspect_cache(root)
1431
+ return {
1432
+ "version": 1,
1433
+ "repoRoot": str(root),
1434
+ "python": {"available": True, "version": sys.version.split()[0], "executable": sys.executable},
1435
+ "node": {"available": bool(shutil.which("node")), "path": shutil.which("node")},
1436
+ "chromium": {"available": bool(chrome_path()), "path": chrome_path()},
1437
+ "mcp": {
1438
+ "available": importlib.util.find_spec("mcp") is not None,
1439
+ "transport": "stdio",
1440
+ "command": f'uidw-mcp --repo "{root}"',
1441
+ "requiredForCli": False,
1442
+ },
1443
+ "cache": status,
1444
+ "stateDir": str(paths["dir"]),
1445
+ "stateFiles": {"sourceIndex": str(paths["scan"]), "designModel": str(paths["design"]), "reviewState": str(paths["review"])},
1446
+ "cliVersion": CLI_VERSION,
1447
+ "configVersion": CONFIG_VERSION,
1448
+ "configuration": configuration_context(config),
1449
+ UI_MODE_KEY: ui_mode_context(config),
1450
+ }
1451
+
1452
+
1453
+ def write_screen_context(paths: dict[str, Path], screen_query: str) -> Path:
1454
+ ir = load_project_ir(paths)
1455
+ base = read_json(paths["context"], {})
1456
+ if not ir or not isinstance(base, dict):
1457
+ raise ValueError("UI context is unavailable; run init or sync first")
1458
+ normalized = screen_query.strip().lower()
1459
+ screen = next((
1460
+ item for item in ir.get("screens", [])
1461
+ if str(item.get("id", "")).lower() == normalized or str(item.get("name", "")).lower() == normalized
1462
+ ), None)
1463
+ if not screen:
1464
+ raise ValueError(f"Unknown screen: {screen_query}")
1465
+ nodes = ir.get("nodes", {})
1466
+ selected_nodes: dict[str, Any] = {}
1467
+
1468
+ def visit(node_id: str) -> None:
1469
+ if node_id in selected_nodes or node_id not in nodes:
1470
+ return
1471
+ node = nodes[node_id]
1472
+ selected_nodes[node_id] = node
1473
+ for child in node.get("children", []):
1474
+ visit(str(child))
1475
+
1476
+ visit(str(screen.get("root", "")))
1477
+ source_files = {
1478
+ str(item.get("source", {}).get("file"))
1479
+ for item in [screen, *selected_nodes.values()]
1480
+ if item.get("source", {}).get("file")
1481
+ }
1482
+ component_refs = sorted({
1483
+ str(item.get("componentRef"))
1484
+ for item in selected_nodes.values()
1485
+ if item.get("componentRef")
1486
+ })
1487
+ payload = {
1488
+ "version": 1,
1489
+ "repoRoot": base.get("repoRoot"),
1490
+ "cacheStatus": base.get("cacheStatus"),
1491
+ "screen": screen,
1492
+ "nodes": selected_nodes,
1493
+ "sourceFiles": sorted(source_files),
1494
+ "componentRefs": component_refs,
1495
+ "platforms": ir.get("platforms", []),
1496
+ "design": ir.get("design", {}),
1497
+ "tokens": ir.get("tokens", {}),
1498
+ "themes": ir.get("themes", {}),
1499
+ "warnings": base.get("warnings", []),
1500
+ "configuration": base.get("configuration", configuration_context(load_config(paths["config"]))),
1501
+ UI_MODE_KEY: base.get(UI_MODE_KEY, {"enabled": False, "default": "off"}),
1502
+ MOCK_DATA_KEY: base.get(MOCK_DATA_KEY, {"enabled": False, "mode": "none", "default": "off"}),
1503
+ "uiGraphFile": str(paths["graph"]),
1504
+ }
1505
+ slug = re.sub(r"[^a-z0-9]+", "-", str(screen.get("id", "screen")).lower()).strip("-") or "screen"
1506
+ output = paths["dir"] / f"ui-context-{slug}.json"
1507
+ write_json(output, payload)
1508
+ return output
1509
+
1510
+
1511
+ def trim_context_to_budget(payload: dict[str, Any], token_budget: int) -> dict[str, Any]:
1512
+ result = copy.deepcopy(payload)
1513
+ limit = max(256, token_budget) * 4
1514
+
1515
+ def size() -> int:
1516
+ return len(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
1517
+
1518
+ for key in ("components", "navigationTargets", "routes", "tokenFiles", "screens", "prioritySourceFiles", "nodes"):
1519
+ value = result.get(key)
1520
+ while size() > limit and isinstance(value, list) and len(value) > 1:
1521
+ del value[(len(value) + 1) // 2:]
1522
+ if size() <= limit:
1523
+ break
1524
+ if isinstance(value, dict):
1525
+ keys = list(value)
1526
+ while size() > limit and len(keys) > 1:
1527
+ for item in keys[(len(keys) + 1) // 2:]:
1528
+ value.pop(item, None)
1529
+ keys = list(value)
1530
+ if size() <= limit:
1531
+ break
1532
+ result["contextBudget"] = {
1533
+ "requestedTokens": token_budget,
1534
+ "estimatedTokens": max(1, size() // 4),
1535
+ "truncated": size() > limit or size() < len(json.dumps(payload, ensure_ascii=False, separators=(",", ":"))),
1536
+ }
1537
+ return result
1538
+
1539
+
1540
+ def context_markdown(payload: dict[str, Any]) -> str:
1541
+ lines = [
1542
+ "# UI Design Workbench context",
1543
+ "",
1544
+ f"- Repository: `{payload.get('repoRoot', '')}`",
1545
+ f"- Cache: `{payload.get('cacheStatus', 'unknown')}`",
1546
+ f"- Platforms: {', '.join(payload.get('detectedPlatforms', payload.get('platforms', []))) or 'unknown'}",
1547
+ f"- Changed UI files: {len(payload.get('changedUiFiles', []))}",
1548
+ f"- Impacted screens: {len(payload.get('impactedScreenIds', []))}",
1549
+ "",
1550
+ "## Priority source files",
1551
+ "",
1552
+ ]
1553
+ lines.extend(f"- `{item}`" for item in payload.get("prioritySourceFiles", payload.get("sourceFiles", [])))
1554
+ screens = payload.get("screens", [])
1555
+ if payload.get("screen"):
1556
+ screens = [payload["screen"]]
1557
+ lines.extend(["", "## Screens", ""])
1558
+ lines.extend(f"- `{item.get('id', item.get('name', 'screen'))}` — {item.get('name', item.get('file', ''))}" for item in screens if isinstance(item, dict))
1559
+ budget = payload.get("contextBudget")
1560
+ if budget:
1561
+ lines.extend(["", f"Estimated tokens: {budget.get('estimatedTokens')} / {budget.get('requestedTokens')}."])
1562
+ return "\n".join(lines).rstrip() + "\n"
1563
+
1564
+
1565
+ def write_context_variant(
1566
+ paths: dict[str, Path],
1567
+ screen: str | None = None,
1568
+ token_budget: int | None = None,
1569
+ changed_only: bool = False,
1570
+ output_format: str = "json",
1571
+ ) -> Path:
1572
+ source = write_screen_context(paths, screen) if screen else paths["context"]
1573
+ payload = read_json(source, {})
1574
+ if not isinstance(payload, dict):
1575
+ raise ValueError("UI context is unavailable; run init or sync first")
1576
+ if changed_only:
1577
+ changed = set(payload.get("changedUiFiles", []))
1578
+ payload["prioritySourceFiles"] = [item for item in payload.get("prioritySourceFiles", []) if item in changed]
1579
+ for key in ("screens", "routes", "navigationTargets", "components"):
1580
+ if isinstance(payload.get(key), list):
1581
+ payload[key] = [item for item in payload[key] if item.get("file") in changed]
1582
+ payload["contextScope"] = "changed-only"
1583
+ if token_budget:
1584
+ payload = trim_context_to_budget(payload, token_budget)
1585
+ if not token_budget and not changed_only and output_format == "json":
1586
+ return source
1587
+ suffix = ".md" if output_format == "markdown" else ".json"
1588
+ label = re.sub(r"[^a-z0-9]+", "-", (screen or "project").lower()).strip("-") or "project"
1589
+ output = paths["dir"] / f"ui-context-{label}-bounded{suffix}"
1590
+ if output_format == "markdown":
1591
+ write_text_atomic(output, context_markdown(payload))
1592
+ else:
1593
+ write_json(output, payload)
1594
+ return output
1595
+
1596
+
1597
+ def load_ir_argument(paths: dict[str, Path], ir_path: Path | None) -> tuple[Path, dict[str, Any]]:
1598
+ path = ir_path.resolve() if ir_path else paths["ir"]
1599
+ ir = read_json(path)
1600
+ if not isinstance(ir, dict):
1601
+ raise ValueError(f"Cannot read UI IR: {path}")
1602
+ return path, ir
1603
+
1604
+
1605
+ def scenario_report(ir: dict[str, Any], screen_query: str | None = None) -> dict[str, Any]:
1606
+ fixtures = ir.get("scenarioFixtures", {}) if isinstance(ir.get("scenarioFixtures"), dict) else {}
1607
+ nodes = ir.get("nodes", {}) if isinstance(ir.get("nodes"), dict) else {}
1608
+ issues: list[dict[str, str]] = []
1609
+ screens: list[dict[str, Any]] = []
1610
+ seen: set[tuple[str, str]] = set()
1611
+ query = screen_query.lower() if screen_query else None
1612
+ for screen in ir.get("screens", []):
1613
+ if query and query not in {str(screen.get("id", "")).lower(), str(screen.get("name", "")).lower()}:
1614
+ continue
1615
+ declared = []
1616
+ reachable: set[str] = set()
1617
+ queue = [str(screen.get("root") or "")]
1618
+ while queue:
1619
+ node_id = queue.pop(0)
1620
+ if not node_id or node_id in reachable or node_id not in nodes:
1621
+ continue
1622
+ reachable.add(node_id)
1623
+ queue.extend(str(child) for child in nodes[node_id].get("children", []))
1624
+ collections = {
1625
+ node_id: node
1626
+ for node_id in reachable
1627
+ if (node := nodes.get(node_id, {})).get("type") in {"list", "collection", "grid", "table"}
1628
+ and (node.get("dataDriven") is True or isinstance(node.get("collection"), dict))
1629
+ }
1630
+ for scenario in screen.get("scenarios", []):
1631
+ scenario_id = str(scenario.get("id") or "")
1632
+ key = (str(screen.get("id")), scenario_id)
1633
+ if not scenario_id:
1634
+ issues.append({"screenId": str(screen.get("id")), "code": "missing-id", "message": "Scenario has no id"})
1635
+ elif key in seen:
1636
+ issues.append({"screenId": str(screen.get("id")), "code": "duplicate-id", "message": f"Duplicate scenario id: {scenario_id}"})
1637
+ seen.add(key)
1638
+ fixture_ref = scenario.get("fixtureRef")
1639
+ if fixture_ref and fixture_ref not in fixtures:
1640
+ issues.append({"screenId": str(screen.get("id")), "code": "missing-fixture", "message": f"Missing fixture: {fixture_ref}"})
1641
+ fixture = fixtures.get(fixture_ref, {}) if fixture_ref else {}
1642
+ fixture_overrides = fixture.get("nodeOverrides", {}) if isinstance(fixture, dict) and isinstance(fixture.get("nodeOverrides"), dict) else {}
1643
+ scenario_overrides = scenario.get("nodeOverrides", {}) if isinstance(scenario.get("nodeOverrides"), dict) else {}
1644
+ overrides = {**fixture_overrides, **scenario_overrides}
1645
+ collection_counts: dict[str, int] = {}
1646
+ for collection_id, collection in collections.items():
1647
+ effective_children = overrides.get(collection_id, {}).get("children", collection.get("children", []))
1648
+ item_ids = [
1649
+ str(child) for child in effective_children or []
1650
+ if not nodes.get(str(child), {}).get("emptyState")
1651
+ ]
1652
+ collection_counts[collection_id] = len(item_ids)
1653
+ minimum = int(collection.get("collection", {}).get("minMockItems", 2))
1654
+ if scenario_id == "mock-data" and len(item_ids) < minimum:
1655
+ issues.append({
1656
+ "screenId": str(screen.get("id")),
1657
+ "code": "mock-collection-empty",
1658
+ "message": f"Mock scenario must populate {collection_id} with at least {minimum} items; found {len(item_ids)}",
1659
+ })
1660
+ for child in effective_children or []:
1661
+ if str(child) not in nodes:
1662
+ issues.append({"screenId": str(screen.get("id")), "code": "missing-collection-item", "message": f"Collection {collection_id} references missing item node: {child}"})
1663
+ declared.append({"id": scenario_id, "label": scenario.get("label", scenario_id), "fixtureRef": fixture_ref, "collectionCounts": collection_counts})
1664
+ default_id = screen.get("defaultScenarioId")
1665
+ if default_id and default_id not in {item["id"] for item in declared}:
1666
+ issues.append({"screenId": str(screen.get("id")), "code": "missing-default", "message": f"Default scenario is not declared: {default_id}"})
1667
+ screens.append({"id": screen.get("id"), "name": screen.get("name"), "defaultScenarioId": screen.get("defaultScenarioId"), "collections": sorted(collections), "scenarios": declared})
1668
+ if query and not screens:
1669
+ issues.append({"screenId": screen_query or "", "code": "unknown-screen", "message": "Screen was not found"})
1670
+ return {"version": 1, "status": "pass" if not issues else "fail", "screens": screens, "fixtures": sorted(fixtures), "issues": issues}
1671
+
1672
+
1673
+ FINDING_RANK = {"blocker": 4, "high": 3, "medium": 2, "low": 1}
1674
+
1675
+
1676
+ def finding_verified(item: dict[str, Any]) -> bool:
1677
+ verification = item.get("verification", {}) if isinstance(item.get("verification"), dict) else {}
1678
+ return (
1679
+ verification.get("result") == "pass"
1680
+ or verification.get("status") == "verified"
1681
+ or bool(item.get("verifiedAt"))
1682
+ )
1683
+
1684
+
1685
+ def finding_proposals(ir: dict[str, Any], finding_id: str) -> list[dict[str, Any]]:
1686
+ proposals: list[dict[str, Any]] = []
1687
+ for version in ir.get("review", {}).get("versions", []):
1688
+ if not isinstance(version, dict) or version.get("kind") != "proposal":
1689
+ continue
1690
+ covered = set(version.get("resolvedFindingIds", [])) | set(version.get("findingIds", []))
1691
+ if finding_id in covered:
1692
+ proposals.append(version)
1693
+ return proposals
1694
+
1695
+
1696
+ def finding_lifecycle_state(ir: dict[str, Any], item: dict[str, Any], decision: str = "pending") -> str:
1697
+ """Return the same evidence-based lifecycle state used by the workbench UI."""
1698
+ if finding_verified(item):
1699
+ return "verified"
1700
+ implementation = item.get("implementation", {}) if isinstance(item.get("implementation"), dict) else {}
1701
+ if item.get("appliedAt") or implementation.get("status") in {"applied", "implemented"}:
1702
+ return "applied"
1703
+ proposals = finding_proposals(ir, str(item.get("id") or ""))
1704
+ if proposals:
1705
+ review_decision = str(ir.get("review", {}).get("versionDecision") or "")
1706
+ if review_decision == "accepted" or any(str(version.get("status")) in {"accepted", "approved"} for version in proposals):
1707
+ return "approved"
1708
+ return "addressed"
1709
+ if decision == "accepted":
1710
+ return "selected"
1711
+ if decision in {"rejected", "deferred"}:
1712
+ return decision
1713
+ return "pending"
1714
+
1715
+
1716
+ def ordered_findings(ir: dict[str, Any], feedback: dict[str, Any] | None = None) -> list[dict[str, Any]]:
1717
+ values: dict[str, dict[str, Any]] = {}
1718
+ for item in ir.get("review", {}).get("audit", {}).get("findings", []):
1719
+ if isinstance(item, dict) and item.get("id"):
1720
+ values[str(item["id"])] = copy.deepcopy(item)
1721
+ if isinstance(feedback, dict):
1722
+ for item in feedback.get("runtimeFindings", []):
1723
+ if isinstance(item, dict) and item.get("id"):
1724
+ values[str(item["id"])] = copy.deepcopy(item)
1725
+ source_order = {item_id: index for index, item_id in enumerate(values)}
1726
+ return sorted(values.values(), key=lambda item: (-FINDING_RANK.get(str(item.get("severity")), 0), source_order[str(item["id"])]))
1727
+
1728
+
1729
+ def finding_report(
1730
+ ir: dict[str, Any],
1731
+ feedback: dict[str, Any] | None = None,
1732
+ screen: str | None = None,
1733
+ status_filter: str | None = None,
1734
+ ) -> dict[str, Any]:
1735
+ findings = ordered_findings(ir, feedback)
1736
+ decisions = copy.deepcopy(ir.get("review", {}).get("audit", {}).get("findingDecisions", {}))
1737
+ if isinstance(feedback, dict) and isinstance(feedback.get("findingDecisions"), dict):
1738
+ decisions.update(feedback["findingDecisions"])
1739
+ rows = []
1740
+ for number, item in enumerate(findings, 1):
1741
+ decision = decisions.get(item["id"], "pending")
1742
+ state = finding_lifecycle_state(ir, item, decision)
1743
+ if screen and str(item.get("screenId", "")).lower() != screen.lower():
1744
+ continue
1745
+ if status_filter and state != status_filter:
1746
+ continue
1747
+ rows.append({
1748
+ "number": number,
1749
+ "id": item["id"],
1750
+ "screenId": item.get("screenId"),
1751
+ "severity": item.get("severity", "medium"),
1752
+ "status": state,
1753
+ "legacyStatus": item.get("status") if item.get("status") == "resolved" else None,
1754
+ "title": item.get("title", "Untitled finding"),
1755
+ "sourceState": item.get("sourceState", "current"),
1756
+ })
1757
+ return {"version": 1, "status": "ok", "total": len(findings), "shown": len(rows), "screen": screen, "findings": rows}
1758
+
1759
+
1760
+ def update_finding_decisions(ir: dict[str, Any], identifiers: list[str], decision: str) -> dict[str, Any]:
1761
+ if decision == "resolved":
1762
+ raise ValueError("A finding can be verified only by importing a passing targeted verification result")
1763
+ findings = ordered_findings(ir)
1764
+ by_id = {str(item["id"]): item for item in findings}
1765
+ by_number = {str(index): item for index, item in enumerate(findings, 1)}
1766
+ resolved: list[str] = []
1767
+ unknown: list[str] = []
1768
+ decisions = ir.setdefault("review", {}).setdefault("audit", {}).setdefault("findingDecisions", {})
1769
+ for identifier in identifiers:
1770
+ item = by_id.get(identifier) or by_number.get(identifier)
1771
+ if not item:
1772
+ unknown.append(identifier)
1773
+ continue
1774
+ finding_id = str(item["id"])
1775
+ if decision == "pending":
1776
+ decisions.pop(finding_id, None)
1777
+ else:
1778
+ decisions[finding_id] = decision
1779
+ resolved.append(finding_id)
1780
+ return {"version": 1, "status": "updated" if resolved else "unchanged", "decision": decision, "findingIds": resolved, "unknown": unknown}
1781
+
1782
+
1783
+ def source_targets_for_findings(root: Path, findings: list[dict[str, Any]]) -> list[str]:
1784
+ targets: set[str] = set()
1785
+ for item in findings:
1786
+ explicit = item.get("sourceTarget") or item.get("sourceTargets") or []
1787
+ if isinstance(explicit, (str, dict)):
1788
+ explicit = [explicit]
1789
+ candidates: list[str] = []
1790
+ for value in explicit:
1791
+ candidates.append(str(value.get("file") if isinstance(value, dict) else value))
1792
+ source = item.get("source")
1793
+ if isinstance(source, dict) and source.get("file"):
1794
+ candidates.append(str(source["file"]))
1795
+ for evidence in item.get("evidence", []):
1796
+ if evidence.get("type") == "source" and evidence.get("ref"):
1797
+ candidates.append(str(evidence["ref"]).split("#", 1)[0])
1798
+ for candidate in candidates:
1799
+ if not candidate:
1800
+ continue
1801
+ path = Path(candidate)
1802
+ resolved = (root / path).resolve() if not path.is_absolute() else path.resolve()
1803
+ try:
1804
+ relative = resolved.relative_to(root.resolve()).as_posix()
1805
+ except ValueError:
1806
+ continue
1807
+ if resolved.is_file():
1808
+ targets.add(relative)
1809
+ return sorted(targets)
1810
+
1811
+
1812
+ def prepare_agent_job(
1813
+ root: Path,
1814
+ ir_path: Path,
1815
+ ir: dict[str, Any],
1816
+ kind: str,
1817
+ output: Path,
1818
+ provider: str = "generic",
1819
+ identifiers: list[str] | None = None,
1820
+ scope: str = "all",
1821
+ direct: bool = False,
1822
+ ) -> dict[str, Any]:
1823
+ findings = ordered_findings(ir)
1824
+ by_id = {str(item["id"]): item for item in findings}
1825
+ by_number = {str(index): item for index, item in enumerate(findings, 1)}
1826
+ decisions = ir.get("review", {}).get("audit", {}).get("findingDecisions", {})
1827
+ selected_ids: list[str] = []
1828
+ for identifier in identifiers or []:
1829
+ item = by_id.get(identifier) or by_number.get(identifier)
1830
+ if item and item["id"] not in selected_ids:
1831
+ selected_ids.append(str(item["id"]))
1832
+ if not selected_ids and kind in {"proposal", "implementation"}:
1833
+ selected_ids = [str(item["id"]) for item in findings if decisions.get(item["id"]) == "accepted"]
1834
+ selected = [by_id[item] for item in selected_ids if item in by_id]
1835
+ if kind in {"proposal", "implementation"} and not selected:
1836
+ raise ValueError("No findings selected. Open step 2 in `uidw review` and select the issues to continue")
1837
+ source_change_allowed = kind == "implementation"
1838
+ if source_change_allowed and not direct:
1839
+ uncovered = [item["id"] for item in selected if not finding_proposals(ir, str(item["id"])) and not finding_verified(item)]
1840
+ approved = str(ir.get("review", {}).get("versionDecision") or "") == "accepted"
1841
+ approved = approved or all(
1842
+ finding_verified(item)
1843
+ or any(str(version.get("status")) in {"accepted", "approved"} for version in finding_proposals(ir, str(item["id"])))
1844
+ for item in selected
1845
+ )
1846
+ if uncovered:
1847
+ raise ValueError("A new mockup is required before applying these findings: " + ", ".join(uncovered))
1848
+ if not approved:
1849
+ raise ValueError("Approve the new mockup in step 3 before applying it, or use `uidw apply --direct` for the explicit no-preview path")
1850
+ source_targets = source_targets_for_findings(root, selected)
1851
+ if source_change_allowed and not source_targets:
1852
+ raise ValueError("Selected findings have no verified source targets inside the repository")
1853
+ screen_ids = sorted({str(item.get("screenId")) for item in selected if item.get("screenId")})
1854
+ if not selected and scope == "current":
1855
+ screen_ids = [str(item.get("id")) for item in ir.get("screens", [])[:1] if item.get("id")]
1856
+ context_file = output.resolve().parent / "ui-agent-context.json"
1857
+ patch_file = output.resolve().parent / "ui-ir.patch.json"
1858
+ scoped = build_scoped_context(
1859
+ ir,
1860
+ screen_ids=screen_ids,
1861
+ finding_ids=selected_ids,
1862
+ token_budget=5000 if selected_ids else 1800,
1863
+ ui_ir_file=str(ir_path.resolve()),
1864
+ )
1865
+ write_scoped_json(context_file, scoped)
1866
+ write_scoped_json(patch_file, patch_template(ir, str(ir_path.resolve()), str(context_file)))
1867
+ allowed_writes = source_targets if source_change_allowed else ["ui-ir.patch.json", "ui-ir.proposed.json", "ui-preview.html", "*.report.json"]
1868
+ config = load_config(state_paths(root)["config"])
1869
+ preview_config = effective_preview_config(config)
1870
+ review_config = effective_review_config(config)
1871
+ job = {
1872
+ "type": "ui-design-workbench-agent-job",
1873
+ "version": 3,
1874
+ "provider": provider,
1875
+ "kind": kind,
1876
+ "project": ir.get("project", {}).get("name", root.name),
1877
+ "projectRoot": str(root.resolve()),
1878
+ "artifactDir": str(ir_path.resolve().parent),
1879
+ "uiIrFile": str(ir_path.resolve()),
1880
+ "contextFile": str(context_file),
1881
+ "patchFile": str(patch_file),
1882
+ "contextBudget": scoped.get("contextBudget", {}),
1883
+ "scope": scope,
1884
+ "screenIds": screen_ids,
1885
+ "acceptedFindingIds": selected_ids,
1886
+ "sourceTargets": source_targets,
1887
+ "allowedWrites": allowed_writes,
1888
+ "sourceChangeAllowed": source_change_allowed,
1889
+ "directSourceAuthorization": bool(source_change_allowed and direct),
1890
+ "configuration": {
1891
+ "detailLevel": config.get(DETAIL_KEY),
1892
+ "validation": review_config.get("validation"),
1893
+ "defaultView": preview_config.get("defaultView"),
1894
+ "uiModeEnabled": bool(config.get(UI_MODE_KEY, {}).get("enabled")),
1895
+ },
1896
+ "requiredChatReport": ["number", "findingId", "screen", "problem", "implementedFix", "changedFiles", "verification", "remainingReason"],
1897
+ "requestedAction": {
1898
+ "expert": "Read only contextFile. Review its catalog in small screen scopes, then write findings and sparse proposal versions as operations in patchFile. Never load the complete UI IR into the prompt.",
1899
+ "proposal": "Read only contextFile. Create sparse proposal operations in patchFile for the selected findings without changing project source.",
1900
+ "implementation": "Implement the selected findings in the verified project source targets, run incremental uidw sync and targeted verification, and do not repeat the full AI review.",
1901
+ }[kind],
1902
+ "createdAt": utc_now(),
1903
+ }
1904
+ write_json(output.resolve(), job)
1905
+ return {
1906
+ "version": 1,
1907
+ "status": "prepared",
1908
+ "kind": kind,
1909
+ "jobFile": str(output.resolve()),
1910
+ "contextFile": str(context_file),
1911
+ "patchFile": str(patch_file),
1912
+ "contextTokens": scoped.get("contextBudget", {}).get("estimatedTokens"),
1913
+ "findingIds": selected_ids,
1914
+ "sourceTargets": source_targets,
1915
+ }
1916
+
1917
+
1918
+ def import_review_result(ir_path: Path, result_path: Path, output: Path) -> dict[str, Any]:
1919
+ from merge_review_state import merge, validate_feedback
1920
+
1921
+ ir = read_json(ir_path)
1922
+ payload = read_json(result_path)
1923
+ if not isinstance(ir, dict) or not isinstance(payload, dict):
1924
+ raise ValueError("IR or review result is not valid JSON")
1925
+ incoming_ir = payload.get("uiIr") or payload.get("result", {}).get("uiIr")
1926
+ if isinstance(incoming_ir, dict):
1927
+ if incoming_ir.get("project", {}).get("name") != ir.get("project", {}).get("name"):
1928
+ raise ValueError("Review result belongs to another project")
1929
+ merged = incoming_ir
1930
+ else:
1931
+ errors = validate_feedback(ir, payload)
1932
+ if errors:
1933
+ raise ValueError("; ".join(errors))
1934
+ merged = merge(copy.deepcopy(ir), payload)
1935
+ write_json(output.resolve(), merged)
1936
+ return {"version": 1, "status": "imported", "irFile": str(output.resolve()), "source": str(result_path.resolve())}
1937
+
1938
+
1939
+ def diff_project(root: Path, synchronize: bool = False) -> dict[str, Any]:
1940
+ if synchronize:
1941
+ ensure_initialized(root, synchronize=True)
1942
+ status, _, paths, _ = inspect_cache(root)
1943
+ last = read_json(paths["sync"], {}) if paths["sync"].is_file() else {}
1944
+ if status.get("status") == "clean" and isinstance(last, dict):
1945
+ return {"version": 1, "status": "clean", "repoRoot": str(root), "lastSync": last}
1946
+ old_scan = read_json(paths["scan"], {}) or {}
1947
+ ir = load_project_ir(paths)
1948
+ impacted = impacted_screens(ir, old_scan, old_scan, status.get("changedUiFiles", []), False)
1949
+ return {**status, "version": 1, "impactedScreenIds": impacted, "lastSync": last}
1950
+
1951
+
1952
+ def print_result(value: dict[str, Any], as_json: bool) -> None:
1953
+ if as_json:
1954
+ print(json.dumps(value, ensure_ascii=False, separators=(",", ":")))
1955
+ return
1956
+ initialization = value.get("initialization")
1957
+ if isinstance(initialization, dict) and initialization.get("message"):
1958
+ print(initialization["message"])
1959
+ if value.get("name") == "UI Design Workbench":
1960
+ print(f"{value['name']} {value.get('cliVersion', '')}")
1961
+ print(value.get("description", ""))
1962
+ print(value.get("runtime", ""))
1963
+ return
1964
+ if value.get("topic") and value.get("text"):
1965
+ print(f"Справка: {value['topic']}\n{value['text']}")
1966
+ if isinstance(value.get("settings"), dict):
1967
+ for key, item in value["settings"].items():
1968
+ print(f"{key}: {'|'.join(item['values'])} — {item['description']}")
1969
+ return
1970
+ if isinstance(value.get("configuration"), dict) and isinstance(value.get("catalog"), dict):
1971
+ configuration = value["configuration"]
1972
+ print(f"Настройка: {value.get('status', 'ok')} · детализация {configuration.get('detailLevel') or 'не выбрана'} · UI-подсказки {'включены' if configuration.get(UI_MODE_KEY, {}).get('enabled') else 'выключены'} · тестовые данные {configuration.get(MOCK_DATA_KEY, {}).get('mode', 'minimal')}")
1973
+ print(f"Файл настроек: {value.get('configFile', '')}")
1974
+ if configuration.get("setupRequired"):
1975
+ print("Следующий шаг: `uidw config setup`")
1976
+ return
1977
+ if isinstance(value.get("skillInstallations"), list):
1978
+ print("Agent Skill готов:")
1979
+ for item in value["skillInstallations"]:
1980
+ print(f"- {item.get('agent')}: {item.get('status')} · {item.get('path')}")
1981
+ print("Перезапустите агент или откройте новую сессию.")
1982
+ return
1983
+ if value.get("type") == "ui-design-workbench-native-render-state":
1984
+ summary = value.get("summary", {})
1985
+ platforms = ", ".join(summary.get("detectedPlatforms", [])) or "не обнаружены"
1986
+ configured = ", ".join(summary.get("configuredProviders", [])) or "нет"
1987
+ print(f"Нативный рендер: {value.get('currentFidelityTier', 'structural')} · платформы: {platforms}")
1988
+ print(f"Настроенные провайдеры: {configured}")
1989
+ print(f"Нативные снимки: {summary.get('nativeCaptureCount', 0)} · устаревшие: {summary.get('staleCaptureCount', 0)}")
1990
+ print(f"Важно: нативный запуск {'выполнялся' if value.get('nativeExecutionStarted') else 'не выполнялся'}.")
1991
+ if value.get("next"):
1992
+ print(f"Следующий шаг: {value['next']}")
1993
+ return
1994
+ if "propertyProvenance" in value and "schemaVersion" in value:
1995
+ coverage = value.get("propertyProvenance", {})
1996
+ labels = {"pass": "пройдено", "fail": "ошибка", "not-applicable": "не применимо"}
1997
+ print(f"Fidelity: {labels.get(value.get('status'), value.get('status', 'неизвестно'))}")
1998
+ if value.get("applicabilityReason"):
1999
+ print(f"Причина: {value['applicabilityReason']}")
2000
+ print(f"Происхождение свойств: {coverage.get('covered', 0)}/{coverage.get('total', 0)} ({coverage.get('percent', 0)}%)")
2001
+ baseline = value.get("baseline", {})
2002
+ print(f"Неизменность исходного макета: {'подтверждена' if baseline.get('valid') else 'не подтверждена'}")
2003
+ for error in value.get("strictErrors", []):
2004
+ print(f"! {error}")
2005
+ if value.get("reportFile"):
2006
+ print(f"Отчёт: {value['reportFile']}")
2007
+ return
2008
+ if isinstance(value.get("findings"), list):
2009
+ labels = {"pending": "найдена", "selected": "выбрана", "addressed": "учтена в макете", "approved": "макет подтверждён", "applied": "применена", "verified": "проверена", "rejected": "не исправлять", "deferred": "позже"}
2010
+ print(f"Проблемы: показано {value.get('shown', len(value['findings']))} из {value.get('total', len(value['findings']))}")
2011
+ for item in value["findings"]:
2012
+ state = labels.get(str(item.get("status")), str(item.get("status", "")))
2013
+ print(f"#{item['number']:<3} {str(item.get('severity', '')):<7} {state:<18} {item.get('screenId', '—')} | {item.get('title', '')}")
2014
+ return
2015
+ if isinstance(value.get("screens"), list) and "fixtures" in value:
2016
+ print(f"{value.get('status', 'ok')} | screens={len(value['screens'])} | fixtures={len(value.get('fixtures', []))} | issues={len(value.get('issues', []))}")
2017
+ for screen in value["screens"]:
2018
+ print(f"{screen.get('id', 'screen')}: {len(screen.get('scenarios', []))} scenarios")
2019
+ for issue in value.get("issues", []):
2020
+ print(f"! {issue.get('screenId', 'project')} [{issue.get('code', 'issue')}] {issue.get('message', '')}")
2021
+ return
2022
+ if isinstance(value.get("checks"), dict):
2023
+ print(f"{value.get('status', 'ok')} | level={value.get('level', 'quick')}")
2024
+ for name, check in value["checks"].items():
2025
+ print(f"{name}: {check.get('status', 'unknown')}")
2026
+ if value.get("reportFile"):
2027
+ print(f"reportFile: {value['reportFile']}")
2028
+ return
2029
+ if isinstance(value.get("render"), dict) and isinstance(value.get("check"), dict):
2030
+ print(f"Ревью готово · проверка {value['check'].get('status', 'unknown')}")
2031
+ print(f"HTML: {value.get('previewFile', '')}")
2032
+ print(f"Ссылка: {value.get('url', '')}")
2033
+ if value.get("configurationNotice"):
2034
+ print(value["configurationNotice"])
2035
+ print("Следующий шаг: откройте «Ревью» и начните с шага «Проверка».")
2036
+ return
2037
+ if isinstance(value.get(UI_MODE_KEY), dict) and not value.get("changedUiFiles"):
2038
+ print(f"UI-подсказки: {'включены' if value[UI_MODE_KEY].get('enabled') else 'выключены'}")
2039
+ if value.get("contextFile"):
2040
+ print(f"Контекст: {value['contextFile']}")
2041
+ return
2042
+ if isinstance(value.get(MOCK_DATA_KEY), dict) and not value.get("changedUiFiles"):
2043
+ print(f"Тестовые данные: {value[MOCK_DATA_KEY].get('mode', 'none')}")
2044
+ if value.get("contextFile"):
2045
+ print(f"Контекст: {value['contextFile']}")
2046
+ return
2047
+ if isinstance(value.get("lastSync"), dict):
2048
+ last = value["lastSync"]
2049
+ print(f"{value.get('status', 'ok')} | changed={len(last.get('changedUiFiles', value.get('changedUiFiles', [])))} | impacted={len(last.get('impactedScreenIds', value.get('impactedScreenIds', [])))}")
2050
+ for key, items in last.get("semanticChanges", {}).items():
2051
+ if items:
2052
+ print(f"{key}: {', '.join(items)}")
2053
+ return
2054
+ changed = len(value.get("changedUiFiles", []))
2055
+ impacted = len(value.get("impactedScreenIds", []))
2056
+ mode = value.get(UI_MODE_KEY)
2057
+ mode_text = f" | ui-mode={'on' if mode.get('enabled') else 'off'}" if isinstance(mode, dict) else ""
2058
+ mock_data = value.get(MOCK_DATA_KEY)
2059
+ mock_text = f" | mock-data={mock_data.get('mode')}" if isinstance(mock_data, dict) else ""
2060
+ if "changedUiFiles" in value or "impactedScreenIds" in value:
2061
+ print(f"{value.get('status', 'ok')} | UI-файлов изменено: {changed} | экранов затронуто: {impacted}{mode_text}{mock_text}")
2062
+ else:
2063
+ print(str(value.get("status", "ok")))
2064
+ for key in ("contextFile", "graphFile", "previewFile", "reportFile", "jobFile", "irFile", "bundleFile", "outputDir", "diffImage", "url"):
2065
+ if value.get(key):
2066
+ print(f"{key}: {value[key]}")
2067
+ if value.get("findingIds"):
2068
+ print("findings: " + ", ".join(value["findingIds"]))
2069
+ if value.get("sourceTargets"):
2070
+ print("source targets: " + ", ".join(value["sourceTargets"]))
2071
+
2072
+
2073
+ def resolve_init_detail(explicit: str | None, as_json: bool) -> str | None:
2074
+ if explicit in DETAIL_PROFILES:
2075
+ return explicit
2076
+ if as_json or not sys.stdin.isatty():
2077
+ return None
2078
+ print(
2079
+ "Low: basic layout and minimal mock data.\n"
2080
+ "Medium: interactions, states, and representative mock data.\n"
2081
+ "High: expanded mock data, themes, and exhaustive reconstruction/HTML checks; no automatic UI/UX review.",
2082
+ file=sys.stderr,
2083
+ )
2084
+ try:
2085
+ answer = input("Preview detail [l]ow/[m]edium/[h]igh: ").strip().lower()
2086
+ except EOFError:
2087
+ return None
2088
+ return {"l": "low", "low": "low", "m": "medium", "medium": "medium", "h": "high", "high": "high"}.get(answer)
2089
+
2090
+
2091
+ def initialization_preferences(root: Path, args: argparse.Namespace) -> tuple[bool, str | None, bool]:
2092
+ paths = state_paths(root)
2093
+ existing = load_config(paths["config"])
2094
+ already_configured = not configuration_context(existing)["setupRequired"]
2095
+ if args.detail is not None:
2096
+ detail = args.detail
2097
+ elif already_configured:
2098
+ detail = existing.get(DETAIL_KEY)
2099
+ else:
2100
+ detail = resolve_init_detail(None, args.json)
2101
+ if args.ui_mode is not None:
2102
+ ui_mode = bool(args.ui_mode)
2103
+ elif already_configured:
2104
+ ui_mode = bool(existing.get(UI_MODE_KEY, {}).get("enabled", False))
2105
+ else:
2106
+ ui_mode = False
2107
+ completed = detail in DETAIL_PROFILES
2108
+ return ui_mode, detail, completed
2109
+
2110
+
2111
+ def configure_setup(
2112
+ root: Path,
2113
+ detail: str | None,
2114
+ as_json: bool,
2115
+ ) -> dict[str, Any]:
2116
+ paths = state_paths(root)
2117
+ config = load_config(paths["config"])
2118
+ interactive = not as_json and sys.stdin.isatty()
2119
+ if detail is None and interactive:
2120
+ detail = resolve_init_detail(None, False)
2121
+ if detail in DETAIL_PROFILES:
2122
+ config[DETAIL_KEY] = detail
2123
+ config[MOCK_DATA_KEY] = {
2124
+ "mode": MOCK_DATA_BY_DETAIL[detail],
2125
+ "seed": "stable",
2126
+ "explicit": False,
2127
+ }
2128
+ configured = config.get(DETAIL_KEY) in DETAIL_PROFILES
2129
+ config[SETUP_KEY] = {
2130
+ "answered": ["detail"] if configured else [],
2131
+ "completed": configured,
2132
+ }
2133
+ paths["config"].parent.mkdir(parents=True, exist_ok=True)
2134
+ write_json(paths["config"], normalized_config(config))
2135
+ if paths["scan"].is_file():
2136
+ ensure_initialized(root, synchronize=True)
2137
+ config = load_config(state_paths(root)["config"])
2138
+ return {
2139
+ "version": 1,
2140
+ "status": configuration_context(config)["status"],
2141
+ "repoRoot": str(root),
2142
+ "configuration": configuration_context(config),
2143
+ "catalog": SETTING_CATALOG,
2144
+ "configFile": str(state_paths(root)["config"]),
2145
+ }
2146
+
2147
+
2148
+ def render_artifact(ir_path: Path, output: Path, allow_draft: bool, agent: str) -> dict[str, Any]:
2149
+ from render_preview import fidelity_audit, render_html, resolve_assets, validate
2150
+
2151
+ ir = read_json(ir_path)
2152
+ if not isinstance(ir, dict):
2153
+ raise ValueError(f"Cannot read UI IR: {ir_path}")
2154
+ errors = validate(ir)
2155
+ if errors:
2156
+ raise ValueError("; ".join(errors))
2157
+ audit = fidelity_audit(ir)
2158
+ if audit["status"] == "blocked" and not allow_draft:
2159
+ raise ValueError("Preview blocked by fidelity audit: " + "; ".join(audit["reasons"]))
2160
+ ir["fidelityAudit"] = audit
2161
+ if audit["reasons"]:
2162
+ ir.setdefault("warnings", []).extend(audit["reasons"])
2163
+ destination = output.resolve()
2164
+ destination.parent.mkdir(parents=True, exist_ok=True)
2165
+ destination.write_text(render_html(resolve_assets(ir), destination.parent, agent), encoding="utf-8")
2166
+ return {"version": 1, "status": "rendered", "irFile": str(ir_path.resolve()), "previewFile": str(destination), "agent": agent, "fidelity": audit}
2167
+
2168
+
2169
+ def validate_artifact(ir_path: Path, output_dir: Path, purpose: str = "projection") -> dict[str, Any]:
2170
+ from coverage_report import build_report
2171
+ from validate_platform_profiles import validate_profiles
2172
+
2173
+ ir = read_json(ir_path)
2174
+ if not isinstance(ir, dict):
2175
+ raise ValueError(f"Cannot read UI IR: {ir_path}")
2176
+ destination = output_dir.resolve()
2177
+ destination.mkdir(parents=True, exist_ok=True)
2178
+ platform_report = validate_profiles(ir)
2179
+ coverage_report = build_report(ir, purpose)
2180
+ strict_fidelity = fidelity_report(ir)
2181
+ platform_path = destination / "platform-profile-report.json"
2182
+ coverage_path = destination / "ui-coverage.json"
2183
+ fidelity_path = destination / "fidelity-report.json"
2184
+ write_json(platform_path, platform_report)
2185
+ write_json(coverage_path, coverage_report)
2186
+ write_json(fidelity_path, strict_fidelity)
2187
+ fidelity_ok = strict_fidelity.get("status") in {"pass", "not-applicable"}
2188
+ status = "pass" if platform_report.get("status") == "pass" and coverage_report.get("status") == "pass" and fidelity_ok else "fail"
2189
+ return {"version": 1, "status": status, "platformReport": str(platform_path), "coverageReport": str(coverage_path), "fidelityReport": str(fidelity_path)}
2190
+
2191
+
2192
+ def preview_uri(path: Path, view: str | None = None, screen: str | None = None, lang: str | None = None, theme: str | None = None, axis: str | None = None) -> str:
2193
+ from urllib.parse import urlencode
2194
+
2195
+ query = {key: value for key, value in {"view": view, "screen": screen, "lang": lang, "theme": theme, "axis": axis}.items() if value}
2196
+ return path.resolve().as_uri() + ("?" + urlencode(query) if query else "")
2197
+
2198
+
2199
+ def open_preview(path: Path, launch: bool = False, view: str | None = None, screen: str | None = None, lang: str | None = None, theme: str | None = None, axis: str | None = None) -> dict[str, Any]:
2200
+ if not path.resolve().is_file():
2201
+ raise ValueError(f"Preview does not exist: {path.resolve()}")
2202
+ uri = preview_uri(path, view, screen, lang, theme, axis)
2203
+ launched = bool(webbrowser.open(uri)) if launch else False
2204
+ return {"version": 1, "status": "opened" if launched else "ready", "previewFile": str(path.resolve()), "url": uri, "launched": launched}
2205
+
2206
+
2207
+ def run_headless_smoke(preview: Path, output_dir: Path, purpose: str = "projection") -> dict[str, Any]:
2208
+ if purpose not in {"projection", "review"}:
2209
+ raise ValueError(f"Unknown smoke purpose: {purpose}")
2210
+ node = shutil.which("node")
2211
+ chrome = chrome_path()
2212
+ if not node or not chrome:
2213
+ return {"version": 1, "status": "unavailable", "reason": "Node.js and Chromium/Edge are required for full smoke"}
2214
+ script = Path(__file__).resolve().parent / "smoke_preview.js"
2215
+ report = output_dir / "ui-diagnostics.json"
2216
+ command = [node, str(script), str(preview.resolve()), "--output", str(report), "--mode", purpose]
2217
+ try:
2218
+ completed = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, check=False)
2219
+ except subprocess.TimeoutExpired:
2220
+ return {"version": 1, "status": "fail", "reason": "Headless smoke timed out after 60 seconds"}
2221
+ data = read_json(report, {}) if report.is_file() else {}
2222
+ status = "pass" if completed.returncode == 0 and data.get("status") in {"pass", "complete"} else "fail"
2223
+ return {
2224
+ "version": 1,
2225
+ "status": status,
2226
+ "purpose": purpose,
2227
+ "report": str(report),
2228
+ "exitCode": completed.returncode,
2229
+ "stderr": (completed.stderr or "").strip()[-2000:],
2230
+ }
2231
+
2232
+
2233
+ def write_ci_report(report: dict[str, Any], output_dir: Path, output_format: str) -> Path | None:
2234
+ if output_format == "json":
2235
+ path = output_dir / "uidw-check.json"
2236
+ write_json(path, report)
2237
+ return path
2238
+ failures = []
2239
+ for name, result in report.get("checks", {}).items():
2240
+ if result.get("status") not in {"pass", "ok"}:
2241
+ failures.append((name, str(result.get("reason") or result.get("status"))))
2242
+ if output_format == "sarif":
2243
+ path = output_dir / "uidw-check.sarif"
2244
+ payload = {
2245
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
2246
+ "version": "2.1.0",
2247
+ "runs": [{
2248
+ "tool": {"driver": {"name": "UI Design Workbench", "version": CLI_VERSION}},
2249
+ "results": [{"ruleId": f"uidw/{name}", "level": "error", "message": {"text": message}} for name, message in failures],
2250
+ }],
2251
+ }
2252
+ write_json(path, payload)
2253
+ return path
2254
+ if output_format == "junit":
2255
+ path = output_dir / "uidw-check.xml"
2256
+ cases = []
2257
+ for name, result in report.get("checks", {}).items():
2258
+ failure = "" if result.get("status") in {"pass", "ok"} else f'<failure message="{html.escape(str(result.get("reason") or result.get("status")), quote=True)}" />'
2259
+ cases.append(f'<testcase classname="uidw" name="{html.escape(name, quote=True)}">{failure}</testcase>')
2260
+ xml = f'<?xml version="1.0" encoding="UTF-8"?><testsuite name="uidw" tests="{len(cases)}" failures="{len(failures)}">{"".join(cases)}</testsuite>\n'
2261
+ write_text_atomic(path, xml)
2262
+ return path
2263
+ return None
2264
+
2265
+
2266
+ def check_artifact(
2267
+ ir_path: Path,
2268
+ output_dir: Path,
2269
+ level: str = "quick",
2270
+ output_format: str = "json",
2271
+ preview: Path | None = None,
2272
+ purpose: str = "projection",
2273
+ ) -> dict[str, Any]:
2274
+ destination = output_dir.resolve()
2275
+ destination.mkdir(parents=True, exist_ok=True)
2276
+ validation = validate_artifact(ir_path, destination, purpose)
2277
+ ir = read_json(ir_path, {})
2278
+ from render_preview import validate as validate_ir
2279
+ structural_errors = validate_ir(ir)
2280
+ scenarios = scenario_report(ir)
2281
+ structure = {"version": 1, "status": "pass" if not structural_errors else "fail", "errors": structural_errors}
2282
+ checks: dict[str, Any] = {"irStructure": structure, "platformAndCoverage": validation, "scenarios": scenarios}
2283
+ preview_path = preview.resolve() if preview else destination / "ui-preview.html"
2284
+ if level == "full":
2285
+ if not preview_path.is_file():
2286
+ render_artifact(ir_path, preview_path, True, "generic")
2287
+ checks["headlessSmoke"] = run_headless_smoke(preview_path, destination, purpose)
2288
+ status = "pass" if all(item.get("status") in {"pass", "ok"} for item in checks.values()) else "fail"
2289
+ report = {"version": 1, "status": status, "level": level, "purpose": purpose, "irFile": str(ir_path.resolve()), "previewFile": str(preview_path) if preview_path.is_file() else None, "checks": checks}
2290
+ ci_path = write_ci_report(report, destination, output_format)
2291
+ if ci_path:
2292
+ report["reportFile"] = str(ci_path)
2293
+ if output_format == "json":
2294
+ write_json(ci_path, report)
2295
+ return report
2296
+
2297
+
2298
+ def build_workbench(
2299
+ root: Path,
2300
+ ir_path: Path | None,
2301
+ output_dir: Path,
2302
+ level: str,
2303
+ allow_draft: bool,
2304
+ agent: str,
2305
+ launch: bool,
2306
+ view: str | None,
2307
+ screen: str | None,
2308
+ lang: str | None,
2309
+ theme: str | None = None,
2310
+ axis: str | None = None,
2311
+ purpose: str = "projection",
2312
+ ) -> dict[str, Any]:
2313
+ paths = state_paths(root)
2314
+ initialization = None
2315
+ if ir_path is None:
2316
+ ensured, paths, _ = ensure_initialized(root)
2317
+ initialization = ensured.get("initialization")
2318
+ ir_path = paths["ir"]
2319
+ allow_draft = True
2320
+ destination = output_dir.resolve()
2321
+ destination.mkdir(parents=True, exist_ok=True)
2322
+ preview = destination / "ui-preview.html"
2323
+ rendered = render_artifact(ir_path, preview, allow_draft, agent)
2324
+ checked = check_artifact(ir_path, destination / "validation", level, "json", preview, purpose)
2325
+ opened = open_preview(preview, launch, view, screen, lang, theme, axis)
2326
+ status = "pass" if checked["status"] == "pass" else "warning"
2327
+ result = {"version": 1, "status": status, "purpose": purpose, "render": rendered, "check": checked, "previewFile": str(preview), "url": opened["url"], "launched": opened["launched"]}
2328
+ if initialization:
2329
+ result["initialization"] = initialization
2330
+ return result
2331
+
2332
+
2333
+ def pack_artifact(ir_path: Path, output: Path) -> dict[str, Any]:
2334
+ ir_path = ir_path.resolve()
2335
+ if not ir_path.is_file():
2336
+ raise ValueError(f"Cannot read UI IR: {ir_path}")
2337
+ base = ir_path.parent
2338
+ ir = read_json(ir_path, {})
2339
+ candidates = [ir_path]
2340
+ for pattern in ("ui-preview.html", "*.report.json", "ui-coverage.json", "ui-diagnostics*.json", "ui-agent-job*.json"):
2341
+ candidates.extend(path for path in base.glob(pattern) if path.is_file())
2342
+ files = []
2343
+ for path in sorted(set(candidates), key=lambda item: item.name.lower()):
2344
+ if path.name.startswith("ui-agent-job"):
2345
+ job = read_json(path, {})
2346
+ if isinstance(job, dict) and job.get("sourceChangeAllowed") is True:
2347
+ continue
2348
+ files.append(path)
2349
+ project_root = str(ir.get("project", {}).get("root") or "") if isinstance(ir, dict) else ""
2350
+ replacements = {
2351
+ str(base.resolve()): "<artifact-dir>",
2352
+ base.resolve().as_posix(): "<artifact-dir>",
2353
+ }
2354
+ if project_root:
2355
+ replacements[project_root] = "<project-root>"
2356
+ replacements[project_root.replace("\\", "/")] = "<project-root>"
2357
+
2358
+ def sanitized_bytes(path: Path) -> bytes:
2359
+ raw = path.read_bytes()
2360
+ if path.suffix.lower() not in {".json", ".html", ".md", ".txt"}:
2361
+ return raw
2362
+ text = raw.decode("utf-8")
2363
+ for original, replacement in sorted(replacements.items(), key=lambda item: -len(item[0])):
2364
+ if original:
2365
+ text = text.replace(original, replacement)
2366
+ return text.encode("utf-8")
2367
+
2368
+ payloads = {path: sanitized_bytes(path) for path in files}
2369
+ manifest_files = []
2370
+ for path in files:
2371
+ relative = path.relative_to(base).as_posix()
2372
+ payload = payloads[path]
2373
+ manifest_files.append({"path": relative, "sha256": hashlib.sha256(payload).hexdigest(), "bytes": len(payload)})
2374
+ manifest = {
2375
+ "version": 1,
2376
+ "type": "ui-design-workbench-bundle",
2377
+ "createdAt": utc_now(),
2378
+ "project": ir.get("project", {}).get("name", base.name),
2379
+ "sourceIncluded": False,
2380
+ "files": manifest_files,
2381
+ }
2382
+ destination = output.resolve()
2383
+ destination.parent.mkdir(parents=True, exist_ok=True)
2384
+ temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
2385
+ try:
2386
+ with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED) as archive:
2387
+ archive.writestr("uidw-bundle.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n")
2388
+ for path in files:
2389
+ archive.writestr(path.relative_to(base).as_posix(), payloads[path])
2390
+ os.replace(temporary, destination)
2391
+ finally:
2392
+ with contextlib.suppress(OSError):
2393
+ temporary.unlink()
2394
+ return {"version": 1, "status": "packed", "bundleFile": str(destination), "files": len(files)}
2395
+
2396
+
2397
+ def unpack_artifact(bundle: Path, output_dir: Path) -> dict[str, Any]:
2398
+ source = bundle.resolve()
2399
+ destination = output_dir.resolve()
2400
+ destination.mkdir(parents=True, exist_ok=True)
2401
+ extracted: list[str] = []
2402
+ with zipfile.ZipFile(source, "r") as archive:
2403
+ for info in archive.infolist():
2404
+ target = (destination / info.filename).resolve()
2405
+ try:
2406
+ target.relative_to(destination)
2407
+ except ValueError as exc:
2408
+ raise ValueError(f"Unsafe bundle path: {info.filename}") from exc
2409
+ if info.is_dir():
2410
+ target.mkdir(parents=True, exist_ok=True)
2411
+ continue
2412
+ target.parent.mkdir(parents=True, exist_ok=True)
2413
+ write_text = archive.read(info)
2414
+ temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp")
2415
+ try:
2416
+ temporary.write_bytes(write_text)
2417
+ os.replace(temporary, target)
2418
+ finally:
2419
+ with contextlib.suppress(OSError):
2420
+ temporary.unlink()
2421
+ extracted.append(info.filename)
2422
+ manifest = read_json(destination / "uidw-bundle.json", {})
2423
+ for item in manifest.get("files", []) if isinstance(manifest, dict) else []:
2424
+ path = destination / item.get("path", "")
2425
+ if not path.is_file() or sha256_file(path) != item.get("sha256"):
2426
+ raise ValueError(f"Bundle integrity check failed: {item.get('path')}")
2427
+ return {"version": 1, "status": "unpacked", "outputDir": str(destination), "files": extracted}
2428
+
2429
+
2430
+ def visual_test(
2431
+ baseline: Path,
2432
+ candidate: Path,
2433
+ output_dir: Path,
2434
+ baseline_geometry: Path | None = None,
2435
+ candidate_geometry: Path | None = None,
2436
+ ) -> dict[str, Any]:
2437
+ destination = output_dir.resolve()
2438
+ destination.mkdir(parents=True, exist_ok=True)
2439
+ report = destination / "visual-regression.json"
2440
+ diff_image = destination / "visual-diff.png"
2441
+ script = Path(__file__).resolve().parent / "visual_regression.py"
2442
+ command = [sys.executable, str(script), "--baseline", str(baseline.resolve()), "--candidate", str(candidate.resolve()), "--output", str(report), "--diff-image", str(diff_image), "--strict"]
2443
+ if baseline_geometry and candidate_geometry:
2444
+ command.extend(["--baseline-geometry", str(baseline_geometry.resolve()), "--candidate-geometry", str(candidate_geometry.resolve())])
2445
+ try:
2446
+ completed = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, check=False)
2447
+ except subprocess.TimeoutExpired:
2448
+ return {"version": 1, "status": "fail", "reportFile": str(report), "diffImage": None, "details": {}, "stderr": "Visual regression timed out after 60 seconds"}
2449
+ result = read_json(report, {}) if report.is_file() else {}
2450
+ return {"version": 1, "status": "pass" if completed.returncode == 0 else "fail", "reportFile": str(report), "diffImage": str(diff_image) if diff_image.is_file() else None, "details": result, "stderr": (completed.stderr or "").strip()[-2000:]}
2451
+
2452
+
2453
+ def parse_args() -> argparse.Namespace:
2454
+ parser = argparse.ArgumentParser(
2455
+ prog="uidw",
2456
+ description="Проверка и улучшение интерфейса без запуска приложения.",
2457
+ epilog="Основной путь: uidw review. Расширенные команды: uidw help advanced.",
2458
+ add_help=False,
2459
+ )
2460
+ parser.add_argument("-h", "--help", action="help", help="Показать эту справку")
2461
+ parser.add_argument("--version", action="version", version=f"uidw {CLI_VERSION}", help="Показать версию")
2462
+ parser.add_argument("--repo", type=Path, default=Path.cwd(), help="Корень проекта")
2463
+ parser.add_argument("--json", action="store_true", help="Вывести JSON для автоматизации")
2464
+ subparsers = parser.add_subparsers(dest="command", required=True, metavar="КОМАНДА")
2465
+ advanced_help = argparse.SUPPRESS
2466
+ init_parser = subparsers.add_parser("init", help="Необязательно: настроить или принудительно пересоздать UI-кеш")
2467
+ init_parser.add_argument("--force", action="store_true", help="Recreate config and scan even when the cache is clean")
2468
+ init_parser.add_argument("--project-cache", action="store_true", help="Store ignored derived state inside the repository instead of the OS cache")
2469
+ init_mode = init_parser.add_mutually_exclusive_group()
2470
+ init_mode.add_argument("--ui-mode", dest="ui_mode", action="store_true", default=None, help="Enable platform guidance for ordinary UI tasks without prompting")
2471
+ init_mode.add_argument("--no-ui-mode", dest="ui_mode", action="store_false", default=None, help="Keep platform guidance disabled without prompting")
2472
+ init_parser.add_argument("--detail", choices=("low", "medium", "high"), default=None, help="Low: minimal data; Medium: representative data; High: expanded data, themes, and reconstruction/HTML checks; no automatic UI/UX review")
2473
+ status_parser = subparsers.add_parser("status", help="Показать, актуален ли UI-кеш")
2474
+ status_parser.add_argument("--verify-content", action="store_true", help="Hash every candidate file instead of trusting unchanged metadata")
2475
+ native_parser = subparsers.add_parser("native", help="Показать готовность точного Android/iOS-рендера")
2476
+ native_parser.add_argument("action", choices=("status",), nargs="?", default="status")
2477
+ native_parser.add_argument("--platform", choices=("all", "android", "apple"), default="all")
2478
+ sync_parser = subparsers.add_parser("sync", help=advanced_help)
2479
+ sync_parser.add_argument("--force", action="store_true", help="Force a full UI rescan")
2480
+ sync_parser.add_argument("--verify-content", action="store_true", help="Hash every candidate file before deciding")
2481
+ context_parser = subparsers.add_parser("context", help=advanced_help)
2482
+ context_parser.add_argument("--no-sync", action="store_true", help="Do not refresh a stale cache")
2483
+ context_parser.add_argument("--screen", help="Write a bounded context containing only one translated screen and its source references")
2484
+ context_parser.add_argument("--budget", type=int, help="Approximate maximum token budget for the exported context")
2485
+ context_parser.add_argument("--changed-only", action="store_true", help="Include only files and entities affected by the latest UI change")
2486
+ context_parser.add_argument("--format", choices=("json", "markdown"), default="json", help="Context artifact format")
2487
+ scope_parser = subparsers.add_parser("scope", help=advanced_help)
2488
+ scope_parser.add_argument("--ir", type=Path, help="UI IR; defaults to the cached project IR")
2489
+ scope_parser.add_argument("--screen", action="append", default=[], help="Screen id or name; repeat for a small multi-screen scope")
2490
+ scope_parser.add_argument("--finding", action="append", default=[], help="Stable finding id or displayed number; repeat as needed")
2491
+ scope_parser.add_argument("--budget", type=int, default=4000, help="Context token budget; structure is never cut to fit")
2492
+ scope_parser.add_argument("--output", type=Path, help="Output path; defaults beside the IR")
2493
+ patch_parser = subparsers.add_parser("patch", help=advanced_help)
2494
+ patch_parser.add_argument("action", choices=("template", "validate", "apply"))
2495
+ patch_parser.add_argument("patch", type=Path, nargs="?", help="Path to ui-ir.patch.json for validate/apply")
2496
+ patch_parser.add_argument("--ir", type=Path, help="UI IR; defaults to the cached project IR")
2497
+ patch_parser.add_argument("--context", type=Path, help="Scoped context path recorded by a new template")
2498
+ patch_parser.add_argument("--output", type=Path, help="Template or patched IR output path")
2499
+ mcp_parser = subparsers.add_parser("mcp", help=advanced_help)
2500
+ mcp_parser.add_argument("--name", default="UI Design Workbench", help="Local MCP server name")
2501
+ map_parser = subparsers.add_parser("map", help=advanced_help)
2502
+ map_parser.add_argument("--output", type=Path, help="Copy ui-graph.json to this explicit path")
2503
+ render_parser = subparsers.add_parser("render", help=advanced_help)
2504
+ render_parser.add_argument("ir", type=Path, help="Path to ui-ir.json")
2505
+ render_parser.add_argument("--output", type=Path, required=True, help="Output HTML path")
2506
+ render_parser.add_argument("--allow-draft", action="store_true", help="Render an incomplete IR for internal diagnostics")
2507
+ render_parser.add_argument("--agent", choices=("generic", "codex"), default="generic", help="Optional agent handoff adapter")
2508
+ validate_parser = subparsers.add_parser("validate", help=advanced_help)
2509
+ validate_parser.add_argument("ir", type=Path, help="Path to ui-ir.json")
2510
+ validate_parser.add_argument("--output-dir", type=Path, required=True, help="Directory for validation reports")
2511
+ check_parser = subparsers.add_parser("check", help="Проверить точность переноса и HTML без UI/UX-аудита")
2512
+ check_parser.add_argument("--ir", type=Path, help="UI IR; defaults to the cached project IR")
2513
+ check_parser.add_argument("--output-dir", type=Path, help="Report directory; defaults to the project cache")
2514
+ check_parser.add_argument("--level", choices=("quick", "full"), default=None)
2515
+ check_parser.add_argument("--format", choices=("json", "sarif", "junit"), default="json")
2516
+ check_parser.add_argument("--preview", type=Path, help="Existing preview for the full smoke gate")
2517
+ workbench_parser = subparsers.add_parser("workbench", help=advanced_help)
2518
+ workbench_parser.add_argument("--ir", type=Path, help="Review IR; defaults to the cached project IR")
2519
+ workbench_parser.add_argument("--output-dir", type=Path, help="Artifact directory; defaults to the project cache")
2520
+ workbench_parser.add_argument("--level", choices=("quick", "full"), default=None)
2521
+ workbench_parser.add_argument("--allow-draft", action="store_true")
2522
+ workbench_parser.add_argument("--agent", choices=("generic", "codex"), default="generic")
2523
+ workbench_parser.add_argument("--open", action="store_true", dest="launch")
2524
+ workbench_parser.add_argument("--view", choices=("overview", "prototype", "single", "states", "compare"))
2525
+ workbench_parser.add_argument("--screen")
2526
+ workbench_parser.add_argument("--lang", choices=("ru", "en"))
2527
+ workbench_parser.add_argument("--theme", help="Initial detected theme id")
2528
+ workbench_parser.add_argument("--axis", choices=("themes", "states", "matrix"), help="Initial variants-canvas organization")
2529
+ open_parser = subparsers.add_parser("open", help="Открыть последнее ревью")
2530
+ open_parser.add_argument("preview", type=Path, nargs="?", help="Preview HTML; defaults to the cached workbench")
2531
+ open_parser.add_argument("--launch", action="store_true")
2532
+ open_parser.add_argument("--view", choices=("overview", "prototype", "single", "states", "compare"))
2533
+ open_parser.add_argument("--screen")
2534
+ open_parser.add_argument("--lang", choices=("ru", "en"))
2535
+ open_parser.add_argument("--theme", help="Initial detected theme id")
2536
+ open_parser.add_argument("--axis", choices=("themes", "states", "matrix"))
2537
+ diff_parser = subparsers.add_parser("diff", help=advanced_help)
2538
+ diff_parser.add_argument("--sync", action="store_true", help="Synchronize before returning the semantic diff")
2539
+ mode_parser = subparsers.add_parser("ui-mode", help=advanced_help)
2540
+ mode_group = mode_parser.add_mutually_exclusive_group()
2541
+ mode_group.add_argument("--enable", action="store_true", help="Enable UI guidance without rescanning unchanged UI source")
2542
+ mode_group.add_argument("--disable", action="store_true", help="Disable UI guidance without rescanning unchanged UI source")
2543
+ config_parser = subparsers.add_parser("config", help=advanced_help)
2544
+ config_parser.add_argument("action", choices=("show", "set", "setup", "reset"), nargs="?", default="show")
2545
+ config_parser.add_argument("key", nargs="?", help="Setting name for `config set`")
2546
+ config_parser.add_argument("value", nargs="?", help="Setting value for `config set`")
2547
+ config_parser.add_argument("--detail", choices=("low", "medium", "high"))
2548
+ help_parser = subparsers.add_parser("help", help="Показать справку по задаче")
2549
+ help_parser.add_argument("topic", nargs="?", choices=("overview", "config", "themes", "review", "workbench", "apply", "advanced"))
2550
+ subparsers.add_parser("about", help=advanced_help)
2551
+ scenarios_parser = subparsers.add_parser("scenarios", help=advanced_help)
2552
+ scenarios_parser.add_argument("action", choices=("list", "validate"), nargs="?", default="list")
2553
+ scenarios_parser.add_argument("--ir", type=Path)
2554
+ scenarios_parser.add_argument("--screen")
2555
+ findings_parser = subparsers.add_parser("findings", help=advanced_help)
2556
+ findings_parser.add_argument("action", choices=("list", "accept", "reject", "defer", "reset"), nargs="?", default="list")
2557
+ findings_parser.add_argument("identifiers", nargs="*", help="Stable finding IDs or displayed global numbers")
2558
+ findings_parser.add_argument("--ir", type=Path)
2559
+ findings_parser.add_argument("--feedback", type=Path, help="Optional exported browser feedback containing runtime findings")
2560
+ findings_parser.add_argument("--screen")
2561
+ findings_parser.add_argument("--status", choices=("pending", "selected", "addressed", "approved", "applied", "verified", "rejected", "deferred"))
2562
+ review_parser = subparsers.add_parser("review", help="Запустить понятное трёхшаговое ревью")
2563
+ review_parser.add_argument("action", choices=("start", "prepare", "import"), nargs="?", default="start", help="start открывает workbench; prepare/import нужны для внешнего AI")
2564
+ review_parser.add_argument("result", type=Path, nargs="?", help="Result JSON for review import")
2565
+ review_parser.add_argument("--ir", type=Path)
2566
+ review_parser.add_argument("--output", type=Path)
2567
+ review_parser.add_argument("--output-dir", type=Path, help="Каталог HTML и отчётов")
2568
+ review_parser.add_argument("--provider", default="generic")
2569
+ review_parser.add_argument("--scope", choices=("all", "current"), default="all")
2570
+ review_parser.add_argument("--level", choices=("quick", "full"), help="Глубина автоматических проверок")
2571
+ review_parser.add_argument("--no-open", dest="launch", action="store_false", default=True, help="Не открывать HTML автоматически")
2572
+ review_parser.add_argument("--view", choices=("overview", "prototype", "single", "states", "compare"))
2573
+ review_parser.add_argument("--screen")
2574
+ review_parser.add_argument("--lang", choices=("ru", "en"), default="ru")
2575
+ proposal_parser = subparsers.add_parser("proposal", help=advanced_help)
2576
+ proposal_parser.add_argument("action", choices=("prepare",), nargs="?", default="prepare")
2577
+ proposal_parser.add_argument("identifiers", nargs="*")
2578
+ proposal_parser.add_argument("--ir", type=Path)
2579
+ proposal_parser.add_argument("--output", type=Path)
2580
+ proposal_parser.add_argument("--provider", default="generic")
2581
+ apply_parser = subparsers.add_parser("apply", help="Применить подтверждённый новый макет к проекту")
2582
+ apply_parser.add_argument("action", choices=("prepare",), nargs="?", default="prepare")
2583
+ apply_parser.add_argument("identifiers", nargs="*")
2584
+ apply_parser.add_argument("--ir", type=Path)
2585
+ apply_parser.add_argument("--output", type=Path)
2586
+ apply_parser.add_argument("--provider", default="generic")
2587
+ apply_parser.add_argument("--direct", action="store_true", help="Явно разрешить исправление без предварительного нового макета")
2588
+ pack_parser = subparsers.add_parser("pack", help=advanced_help)
2589
+ pack_parser.add_argument("--ir", type=Path)
2590
+ pack_parser.add_argument("--output", type=Path, required=True)
2591
+ unpack_parser = subparsers.add_parser("unpack", help=advanced_help)
2592
+ unpack_parser.add_argument("bundle", type=Path)
2593
+ unpack_parser.add_argument("--output-dir", type=Path, required=True)
2594
+ visual_parser = subparsers.add_parser("visual-test", help=advanced_help)
2595
+ visual_parser.add_argument("--baseline", type=Path, required=True)
2596
+ visual_parser.add_argument("--candidate", type=Path, required=True)
2597
+ visual_parser.add_argument("--output-dir", type=Path, required=True)
2598
+ visual_parser.add_argument("--baseline-geometry", type=Path)
2599
+ visual_parser.add_argument("--candidate-geometry", type=Path)
2600
+ fidelity_parser = subparsers.add_parser("fidelity", help=advanced_help)
2601
+ fidelity_parser.add_argument("action", choices=("report", "explain", "capabilities"), nargs="?", default="report")
2602
+ fidelity_parser.add_argument("identifier", nargs="?", help="Node id or stable evidence id for explain")
2603
+ fidelity_parser.add_argument("--ir", type=Path, help="UI IR; defaults to the cached project IR")
2604
+ fidelity_parser.add_argument("--output", type=Path, help="Optional report file")
2605
+ fidelity_parser.add_argument("--format", choices=("json", "markdown"), default="json")
2606
+ install_skill_parser = subparsers.add_parser("install-skill", help="Установить Agent Skill без клонирования репозитория")
2607
+ install_skill_parser.add_argument("agent", choices=(*SUPPORTED_SKILL_AGENTS, "all"), nargs="?", default="codex", help="Агент или all")
2608
+ install_skill_parser.add_argument("--target", type=Path, help="Явный каталог назначения для одного агента")
2609
+ subparsers.add_parser("doctor", help="Проверить установку и зависимости")
2610
+ hidden_commands = {
2611
+ "sync", "context", "scope", "patch", "mcp", "map", "render", "validate", "workbench", "diff", "ui-mode",
2612
+ "config", "about", "scenarios", "findings", "proposal", "pack", "unpack", "visual-test", "fidelity",
2613
+ }
2614
+ subparsers._choices_actions = [
2615
+ action for action in subparsers._choices_actions if action.dest not in hidden_commands
2616
+ ]
2617
+ parser._positionals.title = "Команды"
2618
+ parser._optionals.title = "Параметры"
2619
+ for command_parser in subparsers.choices.values():
2620
+ command_parser._positionals.title = "Аргументы"
2621
+ command_parser._optionals.title = "Параметры"
2622
+ for action in command_parser._actions:
2623
+ if action.dest == "help":
2624
+ action.help = "Показать эту справку"
2625
+ argv = sys.argv[1:]
2626
+ if "--json" in argv:
2627
+ argv = [item for item in argv if item != "--json"]
2628
+ argv.insert(0, "--json")
2629
+ return parser.parse_args(argv)
2630
+
2631
+
2632
+ def main() -> int:
2633
+ for stream in (sys.stdout, sys.stderr):
2634
+ if hasattr(stream, "reconfigure"):
2635
+ with contextlib.suppress(OSError):
2636
+ stream.reconfigure(encoding="utf-8")
2637
+ args = parse_args()
2638
+ root = args.repo.resolve()
2639
+ if not root.is_dir():
2640
+ print(f"Repository directory does not exist: {root}", file=sys.stderr)
2641
+ return 2
2642
+ paths = state_paths(root)
2643
+ try:
2644
+ if args.command == "init":
2645
+ ui_mode, detail, setup_completed = initialization_preferences(root, args)
2646
+ result = initialize(root, args.force, args.project_cache, ui_mode, detail, setup_completed)
2647
+ elif args.command == "status":
2648
+ result, _, _, _ = inspect_cache(root, args.verify_content)
2649
+ elif args.command == "native":
2650
+ from native_render_registry import native_render_status
2651
+
2652
+ result = native_render_status(root, paths["native"], args.platform)
2653
+ elif args.command == "sync":
2654
+ result, paths, _ = ensure_initialized(root, args.force, args.verify_content, synchronize=True)
2655
+ elif args.command == "context":
2656
+ result, paths, _ = ensure_initialized(root, synchronize=False if args.no_sync else None)
2657
+ context_path = write_context_variant(paths, args.screen, args.budget, args.changed_only, args.format)
2658
+ result = {**result, "contextFile": str(context_path)}
2659
+ elif args.command == "scope":
2660
+ ir_path, ir = load_ir_argument(paths, args.ir)
2661
+ output = (args.output or ir_path.with_name("ui-agent-context.json")).resolve()
2662
+ payload = build_scoped_context(
2663
+ ir,
2664
+ screen_ids=args.screen,
2665
+ finding_ids=args.finding,
2666
+ token_budget=args.budget,
2667
+ ui_ir_file=str(ir_path),
2668
+ )
2669
+ write_scoped_json(output, payload)
2670
+ result = {
2671
+ "version": 1,
2672
+ "status": "prepared",
2673
+ "contextFile": str(output),
2674
+ "scope": payload.get("scope", {}),
2675
+ "contextBudget": payload.get("contextBudget", {}),
2676
+ }
2677
+ elif args.command == "patch":
2678
+ ir_path, ir = load_ir_argument(paths, args.ir)
2679
+ if args.action == "template":
2680
+ output = (args.output or ir_path.with_name("ui-ir.patch.json")).resolve()
2681
+ write_scoped_json(output, patch_template(ir, str(ir_path), str(args.context.resolve()) if args.context else None))
2682
+ result = {"version": 1, "status": "prepared", "patchFile": str(output), "uiIrFile": str(ir_path)}
2683
+ else:
2684
+ if not args.patch:
2685
+ raise ValueError(f"patch {args.action} requires a ui-ir.patch.json path")
2686
+ patch_payload = read_scoped_json(args.patch.resolve())
2687
+ errors = validate_ir_patch(ir, patch_payload)
2688
+ if errors:
2689
+ raise ValueError("; ".join(errors))
2690
+ if args.action == "validate":
2691
+ result = {"version": 1, "status": "pass", "patchFile": str(args.patch.resolve()), "operations": len(patch_payload.get("operations", []))}
2692
+ else:
2693
+ output = (args.output or ir_path.with_name("ui-ir.patched.json")).resolve()
2694
+ result = apply_patch_file(ir_path, args.patch, output)
2695
+ elif args.command == "mcp":
2696
+ from uidw_mcp import run_server
2697
+
2698
+ run_server(root, args.name)
2699
+ return 0
2700
+ elif args.command == "map":
2701
+ result, paths, _ = ensure_initialized(root)
2702
+ graph_path = paths["graph"]
2703
+ if args.output:
2704
+ destination = args.output.resolve()
2705
+ destination.parent.mkdir(parents=True, exist_ok=True)
2706
+ shutil.copyfile(graph_path, destination)
2707
+ graph_path = destination
2708
+ result = {**result, "graphFile": str(graph_path)}
2709
+ elif args.command == "render":
2710
+ result = render_artifact(args.ir, args.output, args.allow_draft, args.agent)
2711
+ elif args.command == "validate":
2712
+ result = validate_artifact(args.ir, args.output_dir)
2713
+ elif args.command == "check":
2714
+ ir_path, _ = load_ir_argument(paths, args.ir)
2715
+ output_dir = args.output_dir or paths["dir"] / "validation"
2716
+ configured_level = effective_review_config(load_config(paths["config"]))["validation"]
2717
+ result = check_artifact(ir_path, output_dir, args.level or configured_level, args.format, args.preview)
2718
+ elif args.command == "workbench":
2719
+ config = load_config(paths["config"])
2720
+ preview_config = effective_preview_config(config)
2721
+ review_config = effective_review_config(config)
2722
+ output_dir = args.output_dir or paths["dir"] / "workbench"
2723
+ configured_lang = preview_config.get("language")
2724
+ configured_axis = preview_config.get("themeLayout")
2725
+ result = build_workbench(root, args.ir, output_dir, args.level or review_config.get("validation", "quick"), args.allow_draft, args.agent, args.launch, args.view or preview_config.get("defaultView"), args.screen, args.lang or (configured_lang if configured_lang in {"ru", "en"} else None), args.theme, args.axis or (configured_axis if configured_axis in {"themes", "states", "matrix"} else None))
2726
+ elif args.command == "open":
2727
+ preview_config = effective_preview_config(load_config(paths["config"]))
2728
+ preview = args.preview or paths["dir"] / "workbench" / "ui-preview.html"
2729
+ configured_lang = preview_config.get("language")
2730
+ configured_axis = preview_config.get("themeLayout")
2731
+ result = open_preview(preview, args.launch, args.view or preview_config.get("defaultView"), args.screen, args.lang or (configured_lang if configured_lang in {"ru", "en"} else None), args.theme, args.axis or (configured_axis if configured_axis in {"themes", "states", "matrix"} else None))
2732
+ elif args.command == "diff":
2733
+ result = diff_project(root, args.sync)
2734
+ elif args.command == "ui-mode":
2735
+ result = configure_ui_mode(root, True if args.enable else False if args.disable else None)
2736
+ elif args.command == "config":
2737
+ result = configure_setup(root, args.detail, args.json) if args.action == "setup" else configure_project(root, args.action, args.key, args.value)
2738
+ elif args.command == "help":
2739
+ result = help_topic(args.topic)
2740
+ elif args.command == "about":
2741
+ result = about()
2742
+ elif args.command == "scenarios":
2743
+ _, ir = load_ir_argument(paths, args.ir)
2744
+ result = scenario_report(ir, args.screen)
2745
+ elif args.command == "findings":
2746
+ ir_path, ir = load_ir_argument(paths, args.ir)
2747
+ feedback = read_json(args.feedback.resolve(), {}) if args.feedback else None
2748
+ if args.action == "list":
2749
+ result = finding_report(ir, feedback, args.screen, args.status)
2750
+ else:
2751
+ if not args.identifiers:
2752
+ raise ValueError("Specify at least one finding id or number")
2753
+ decision = {"accept": "accepted", "reject": "rejected", "defer": "deferred", "reset": "pending"}[args.action]
2754
+ result = update_finding_decisions(ir, args.identifiers, decision)
2755
+ write_json(ir_path, ir)
2756
+ elif args.command == "review":
2757
+ if args.action == "start":
2758
+ config = load_config(paths["config"])
2759
+ setup = configuration_context(config)
2760
+ preview_config = effective_preview_config(config)
2761
+ review_config = effective_review_config(config)
2762
+ output_dir = args.output_dir or paths["dir"] / "workbench"
2763
+ result = build_workbench(
2764
+ root,
2765
+ args.ir,
2766
+ output_dir,
2767
+ args.level or review_config.get("validation", "quick"),
2768
+ False,
2769
+ args.provider,
2770
+ args.launch,
2771
+ args.view or preview_config.get("defaultView"),
2772
+ args.screen,
2773
+ args.lang,
2774
+ None,
2775
+ preview_config.get("themeLayout") if preview_config.get("themeLayout") in {"themes", "states", "matrix"} else None,
2776
+ "review",
2777
+ )
2778
+ result["workflow"] = "review"
2779
+ if setup.get("setupRequired"):
2780
+ result["configurationNotice"] = "Постоянная детализация не выбрана. Задайте Low, Medium или High командой `uidw config setup`."
2781
+ else:
2782
+ ir_path, ir = load_ir_argument(paths, args.ir)
2783
+ if args.action == "prepare":
2784
+ output = args.output or ir_path.parent / "ui-agent-job.json"
2785
+ result = prepare_agent_job(root, ir_path, ir, "expert", output, args.provider, scope=args.scope)
2786
+ elif args.action == "import":
2787
+ if not args.result:
2788
+ raise ValueError("Review import requires a result JSON path")
2789
+ output = args.output or ir_path.with_name("ui-ir.imported.json")
2790
+ result = import_review_result(ir_path, args.result, output)
2791
+ elif args.command in {"proposal", "apply"}:
2792
+ ir_path, ir = load_ir_argument(paths, args.ir)
2793
+ kind = "proposal" if args.command == "proposal" else "implementation"
2794
+ output = args.output or ir_path.parent / f"ui-agent-job-{kind}.json"
2795
+ result = prepare_agent_job(root, ir_path, ir, kind, output, args.provider, args.identifiers, direct=bool(getattr(args, "direct", False)))
2796
+ elif args.command == "pack":
2797
+ ir_path, _ = load_ir_argument(paths, args.ir)
2798
+ result = pack_artifact(ir_path, args.output)
2799
+ elif args.command == "unpack":
2800
+ result = unpack_artifact(args.bundle, args.output_dir)
2801
+ elif args.command == "visual-test":
2802
+ result = visual_test(args.baseline, args.candidate, args.output_dir, args.baseline_geometry, args.candidate_geometry)
2803
+ elif args.command == "fidelity":
2804
+ ir = {} if args.action == "capabilities" else load_ir_argument(paths, args.ir)[1]
2805
+ result = fidelity_command(ir, args.action, args.identifier, args.output, args.format)
2806
+ elif args.command == "install-skill":
2807
+ result = install_skill(args.agent, args.target)
2808
+ else:
2809
+ result = doctor(root)
2810
+ except (ValueError, RuntimeError, TimeoutError, OSError, zipfile.BadZipFile) as exc:
2811
+ print(f"Ошибка: {exc}", file=sys.stderr)
2812
+ if getattr(args, "command", None):
2813
+ print(f"Подсказка: `uidw {args.command} --help`; общий маршрут — `uidw help overview`", file=sys.stderr)
2814
+ return 3
2815
+ print_result(result, args.json)
2816
+ gate_failed = result.get("status") not in {"pass", "not-applicable"}
2817
+ return 4 if args.command in {"validate", "check", "visual-test", "fidelity"} and gate_failed else 0
2818
+
2819
+
2820
+ if __name__ == "__main__":
2821
+ raise SystemExit(main())