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
scan_ui.py ADDED
@@ -0,0 +1,1485 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only multi-platform UI inventory scanner.
3
+
4
+ The scanner intentionally produces a conservative inventory. It does not run,
5
+ build, import, or modify the target repository.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import datetime as dt
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import re
16
+ import sys
17
+ import tempfile
18
+ from pathlib import Path
19
+ from typing import Any, Iterable
20
+
21
+ from android_resource_resolver import android_layout_role
22
+ from android_xml_support import (
23
+ android_layout_metadata,
24
+ android_layout_refs,
25
+ parse_android_navigation,
26
+ reconcile_android_screen_candidates,
27
+ )
28
+ from fidelity_adapters import SourceContext, registered_adapters, translate_sources
29
+ from fidelity_core import FIDELITY_SCHEMA_VERSION, TokenResolver, property_evidence, seal_baseline
30
+
31
+
32
+ EXCLUDED_DIRS = {
33
+ ".git", ".gradle", ".idea", ".next", ".nuxt", ".dart_tool",
34
+ "build", "dist", "out", "target", "node_modules", "Pods", "DerivedData",
35
+ "vendor", "coverage", ".codegraph", ".worktrees", ".claude", ".lavish", ".codex",
36
+ ".agents", ".cursor", ".gemini", ".opencode", ".ui-design-workbench",
37
+ ".superpowers",
38
+ }
39
+ SCANNER_VERSION = 10
40
+ SOURCE_EXTENSIONS = {
41
+ ".kt", ".kts", ".xml", ".swift", ".storyboard", ".xib", ".dart",
42
+ ".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte", ".html",
43
+ ".css", ".scss", ".sass", ".less", ".json", ".yaml", ".yml", ".go",
44
+ ".cs", ".xaml", ".csproj", ".m", ".mm", ".cpp", ".h", ".hpp",
45
+ }
46
+ ASSET_EXTENSIONS = {
47
+ ".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".avif",
48
+ ".ttf", ".otf", ".woff", ".woff2", ".pdf", ".ico", ".icns",
49
+ }
50
+ MAX_FILE_BYTES = 1_500_000
51
+
52
+
53
+ def relative(path: Path, root: Path) -> str:
54
+ return path.relative_to(root).as_posix()
55
+
56
+
57
+ def line_number(text: str, offset: int) -> int:
58
+ return text.count("\n", 0, offset) + 1
59
+
60
+
61
+ def unique_dicts(items: Iterable[dict[str, Any]], keys: tuple[str, ...]) -> list[dict[str, Any]]:
62
+ seen: set[tuple[Any, ...]] = set()
63
+ result: list[dict[str, Any]] = []
64
+ for item in items:
65
+ identity = tuple(item.get(key) for key in keys)
66
+ if identity not in seen:
67
+ seen.add(identity)
68
+ result.append(item)
69
+ return result
70
+
71
+
72
+ def read_text(path: Path) -> str | None:
73
+ try:
74
+ if path.stat().st_size > MAX_FILE_BYTES:
75
+ return None
76
+ return path.read_text(encoding="utf-8", errors="ignore")
77
+ except OSError:
78
+ return None
79
+
80
+
81
+ def detect_platforms(path: Path, text: str) -> list[str]:
82
+ suffix = path.suffix.lower()
83
+ normalized = path.as_posix().lower()
84
+ lower = text.lower()
85
+ found: list[str] = []
86
+ unsupported_android_variant = any(marker in lower for marker in (
87
+ "androidx.tv.", "androidx.leanback.", "android.software.leanback",
88
+ "leanback_launcher", "tv-material", "compose for tv",
89
+ ))
90
+ if suffix in {".kt", ".kts"}:
91
+ if not unsupported_android_variant and ("@composable" in lower or "androidx.compose" in text):
92
+ found.append("android-compose")
93
+ if not unsupported_android_variant and re.search(
94
+ r"\b(Activity|Fragment|RecyclerView|ViewBinding)\b"
95
+ r"|\bclass\s+[A-Z]\w*(?:Activity|Fragment|DialogFragment)\b"
96
+ r"|\bBase(?:Activity|Fragment|Dialog|BottomSheet)\w*\s*<"
97
+ r"|\bR\.layout\.[A-Za-z0-9_]+"
98
+ r"|\.databinding\.[A-Z]\w+Binding\b",
99
+ text,
100
+ ):
101
+ found.append("android-views")
102
+ if suffix == ".xml" and "/res/" in f"/{normalized}":
103
+ if not unsupported_android_variant and any(segment in normalized for segment in ("/layout", "/navigation", "/values")):
104
+ found.append("android-views")
105
+ if suffix in {".swift", ".storyboard", ".xib", ".m", ".mm"}:
106
+ appkit = any(marker in text for marker in ("import AppKit", "NSApplication", "NSWindow", "NSViewController", "#import <Cocoa/Cocoa.h>", "NSWindowController")) or 'targetRuntime="MacOSX.Cocoa"' in text
107
+ macos = appkit or any(marker in text for marker in ("MenuBarExtra", "#if os(macOS)", ".macOS("))
108
+ swiftui = re.search(r"\b(struct|class)\s+\w+\s*:\s*View\b", text) or "import SwiftUI" in text
109
+ if swiftui:
110
+ found.append("swiftui-macos" if macos else "swiftui")
111
+ if appkit:
112
+ found.append("appkit")
113
+ elif "UIViewController" in text or "import UIKit" in text or 'targetRuntime="iOS.CocoaTouch"' in text:
114
+ found.append("uikit")
115
+ if suffix in {".cs", ".xaml", ".csproj", ".cpp", ".h", ".hpp"}:
116
+ winui = any(marker in text for marker in ("Microsoft.UI.Xaml", "Microsoft.WindowsAppSDK", "AppWindow", "<NavigationView", "<muxc:", "winrt::Microsoft::UI::Xaml"))
117
+ wpf = any(marker in text for marker in ("System.Windows", "UseWPF", "PresentationFramework"))
118
+ wpf = wpf or (not winui and suffix == ".xaml" and any(marker in text for marker in ("<Window", "<Application")))
119
+ if winui:
120
+ found.append("windows-winui")
121
+ if wpf:
122
+ found.append("windows-wpf")
123
+ if suffix == ".xaml" and not (winui or wpf) and any(marker in text for marker in ("x:Class=", "<Page", "<UserControl", "<ResourceDictionary")):
124
+ found.append("windows-xaml")
125
+ if suffix == ".dart" and ("package:flutter" in text or "extends StatelessWidget" in text or "extends StatefulWidget" in text):
126
+ found.append("flutter")
127
+ if suffix in {".js", ".jsx", ".ts", ".tsx"}:
128
+ if "react-native" not in lower and "stylesheet.create" not in lower and re.search(r"\b(import|require).*react|<[A-Z][A-Za-z0-9_.]*", text):
129
+ found.append("react-web")
130
+ elif any(marker in text for marker in ("document.querySelector", "querySelectorAll(", ".innerHTML", "getElementById(")):
131
+ found.append("web")
132
+ if suffix in {".vue", ".svelte", ".html", ".css", ".scss", ".sass", ".less"}:
133
+ found.append("web")
134
+ if suffix == ".go" and re.search(r"\b(?:http\.(?:Handle|HandleFunc)|HandleFunc|ParseFS|ExecuteTemplate|ServeHTTP)\b", text):
135
+ found.append("web")
136
+ if suffix in {".json", ".yaml", ".yml", ".xml", ".kt", ".swift", ".dart", ".ts"} and any(
137
+ word in path.name.lower() for word in ("theme", "token", "color", "typography", "style")
138
+ ):
139
+ found.append("shared-ui")
140
+ return list(dict.fromkeys(found))
141
+
142
+
143
+ def classify_role(path: Path, text: str) -> str:
144
+ name = path.name.lower()
145
+ normalized = path.as_posix().lower()
146
+ if path.suffix.lower() == ".xml" and "/res/navigation" in f"/{normalized}":
147
+ return "navigation"
148
+ if path.suffix.lower() == ".xml" and "/res/color" in f"/{normalized}":
149
+ return "theme"
150
+ if any(word in name for word in ("route", "router", "navigation", "navgraph")) or "navhost" in text.lower():
151
+ return "navigation"
152
+ if any(word in name for word in ("theme", "token", "color", "typography", "style")) or "/res/values" in normalized:
153
+ return "theme"
154
+ layout_role = android_layout_role(path, text)
155
+ if layout_role != "unknown":
156
+ return layout_role
157
+ if any(word in name for word in ("screen", "page", "view", "activity", "fragment", "window", "dialog")):
158
+ return "screen"
159
+ if any(word in name for word in ("component", "widget", "control")):
160
+ return "component"
161
+ return "ui-source"
162
+
163
+
164
+ SYMBOL_RULES: dict[str, list[tuple[str, re.Pattern[str]]]] = {
165
+ "android-compose": [
166
+ ("composable", re.compile(r"(?:@Composable\s+)?(?:public\s+|private\s+|internal\s+)?fun\s+([A-Z]\w+)\s*\(")),
167
+ ("component", re.compile(r"\b(?:Row|Column|Box|LazyColumn|LazyRow|Scaffold|Card|Button|Text|Image|Icon|TextField)\s*\(")),
168
+ ],
169
+ "android-views": [
170
+ ("class", re.compile(r"\bclass\s+([A-Z]\w+)\s*(?:\([^)]*\)\s*)?(?::|extends)")),
171
+ ],
172
+ "swiftui": [
173
+ ("view", re.compile(r"\bstruct\s+([A-Z]\w+)\s*:\s*View\b")),
174
+ ],
175
+ "uikit": [
176
+ ("view-controller", re.compile(r"\bclass\s+([A-Z]\w+)\s*:\s*UI\w*ViewController\b")),
177
+ ],
178
+ "swiftui-macos": [
179
+ ("view", re.compile(r"\bstruct\s+([A-Z]\w+)\s*:\s*View\b")),
180
+ ],
181
+ "appkit": [
182
+ ("view", re.compile(r"\bclass\s+([A-Z]\w+)\s*:\s*NS(?:View|Window)Controller\b")),
183
+ ("view", re.compile(r"@interface\s+([A-Z]\w+)\s*:\s*NS(?:View|Window)Controller\b")),
184
+ ],
185
+ "windows-winui": [
186
+ ("class", re.compile(r"\b(?:public\s+|internal\s+|partial\s+)*class\s+([A-Z]\w+)")),
187
+ ("view", re.compile(r"x:Class\s*=\s*[\"'][\w.]*\.?([A-Z]\w+)[\"']")),
188
+ ],
189
+ "windows-wpf": [
190
+ ("class", re.compile(r"\b(?:public\s+|internal\s+|partial\s+)*class\s+([A-Z]\w+)")),
191
+ ("view", re.compile(r"x:Class\s*=\s*[\"'][\w.]*\.?([A-Z]\w+)[\"']")),
192
+ ],
193
+ "windows-xaml": [
194
+ ("view", re.compile(r"x:Class\s*=\s*[\"'][\w.]*\.?([A-Z]\w+)[\"']")),
195
+ ],
196
+ "flutter": [
197
+ ("widget", re.compile(r"\bclass\s+([A-Z]\w+)\s+extends\s+(?:StatelessWidget|StatefulWidget)\b")),
198
+ ],
199
+ "react-web": [
200
+ ("component", re.compile(r"\b(?:function|class)\s+([A-Z]\w+)|\bconst\s+([A-Z]\w+)\s*=\s*(?:\([^)]*\)|\w+)\s*=>")),
201
+ ],
202
+ }
203
+
204
+
205
+ ROUTE_PATTERNS = [
206
+ re.compile(r"\bcomposable\s*\(\s*[\"']([^\"']+)[\"']"),
207
+ re.compile(r"\bpath\s*[:=]\s*[\"']([^\"']+)[\"']"),
208
+ re.compile(r"\broute\s*[:=]\s*[\"']([^\"']+)[\"']"),
209
+ re.compile(r"\bGoRoute\s*\([^)]*?path\s*:\s*[\"']([^\"']+)[\"']", re.S),
210
+ re.compile(r"\bNavigator\.(?:pushNamed|popAndPushNamed)\s*\([^,]+,\s*[\"']([^\"']+)[\"']"),
211
+ re.compile(r"\b(?:navigate|push|replace)\s*\(\s*[\"']([^\"']+)[\"']"),
212
+ ]
213
+ GO_PAGE_ROUTE_PATTERN = re.compile(
214
+ r"\bHandleFunc\s*\(\s*[\"']GET\s+([^\"']+)[\"']\s*,\s*(.*?)\)",
215
+ re.S,
216
+ )
217
+
218
+ HTML_SECTION_PATTERN = re.compile(r"<section\b(?P<attrs>[^>]*)>", re.I)
219
+ HTML_NAV_PATTERN = re.compile(
220
+ r"<a\b(?P<attrs>[^>]*\bhref\s*=\s*[\"']#[^\"']+[\"'][^>]*)>(?P<body>.*?)</a>",
221
+ re.I | re.S,
222
+ )
223
+ HTML_TAB_PATTERN = re.compile(
224
+ r"<button\b(?P<attrs>[^>]*\bid\s*=\s*[\"']tab-([^\"']+)[\"'][^>]*)>(?P<body>.*?)</button>",
225
+ re.I | re.S,
226
+ )
227
+ HTML_NAV_BLOCK_PATTERN = re.compile(r"<nav\b(?P<attrs>[^>]*)>(?P<body>.*?)</nav>", re.I | re.S)
228
+ HTML_NAV_GROUP_OR_LINK_PATTERN = re.compile(
229
+ r"<div\b(?P<group_attrs>[^>]*\bclass\s*=\s*[\"'][^\"']*\bnav-group-label\b[^\"']*[\"'][^>]*)>(?P<group_body>.*?)</div>"
230
+ r"|<a\b(?P<link_attrs>[^>]*\bhref\s*=\s*[\"']#[^\"']+[\"'][^>]*)>(?P<link_body>.*?)</a>",
231
+ re.I | re.S,
232
+ )
233
+ HTML_DATA_TAB_PATTERN = re.compile(
234
+ r"<button\b(?P<attrs>[^>]*\bdata-(?P<family>[A-Za-z0-9_-]+)-tab\s*=\s*[\"'](?P<value>[^\"']+)[\"'][^>]*)>(?P<body>.*?)</button>",
235
+ re.I | re.S,
236
+ )
237
+ HTML_DATA_PANEL_PATTERN = re.compile(
238
+ r"<section\b(?P<attrs>[^>]*\bdata-(?P<family>[A-Za-z0-9_-]+)-panel\s*=\s*[\"'](?P<value>[^\"']+)[\"'][^>]*)>",
239
+ re.I,
240
+ )
241
+ JS_TAB_ARRAY_PATTERN = re.compile(
242
+ r"(?P<array>\[(?:\s*\[\s*[\"'][^\"']+[\"']\s*,\s*[\"'][^\"']+[\"']\s*\]\s*,?){2,}\s*\])"
243
+ r"(?P<tail>.{0,900}?data-(?P<family>[A-Za-z0-9_-]+)-tab\s*=)",
244
+ re.I | re.S,
245
+ )
246
+ JS_TAB_ITEM_PATTERN = re.compile(r"\[\s*[\"'](?P<id>[^\"']+)[\"']\s*,\s*[\"'](?P<label>[^\"']+)[\"']\s*\]")
247
+ HTML_DIALOG_PATTERN = re.compile(
248
+ r"<dialog\b(?P<attrs>[^>]*)>(?P<body>.*?)</dialog>",
249
+ re.I | re.S,
250
+ )
251
+ HTML_ELEMENT_PATTERN = re.compile(
252
+ r"<(?P<tag>form|div|section|aside)\b(?P<attrs>[^>]*)>",
253
+ re.I,
254
+ )
255
+ HTML_SECTION_TOKEN_PATTERN = re.compile(r"<section\b(?P<attrs>[^>]*)>|</section\s*>", re.I)
256
+
257
+
258
+ def html_attr(attrs: str, name: str) -> str:
259
+ match = re.search(rf"\b{re.escape(name)}\s*=\s*[\"']([^\"']+)[\"']", attrs, re.I)
260
+ return match.group(1).strip() if match else ""
261
+
262
+
263
+ def visible_html_text(value: str) -> str:
264
+ return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", value)).strip()
265
+
266
+
267
+ def preferred_html_label(value: str) -> str:
268
+ strong = re.search(r"<strong\b[^>]*>(.*?)</strong>", value, re.I | re.S)
269
+ return visible_html_text(strong.group(1) if strong else value)
270
+
271
+
272
+ def view_name(value: str) -> str:
273
+ return "".join(part.capitalize() for part in re.split(r"[^A-Za-z0-9]+", value) if part) + "View"
274
+
275
+
276
+ def html_navigation_groups(text: str) -> dict[str, dict[str, str]]:
277
+ """Map hash destinations to their visible label and nearest sidebar group."""
278
+ result: dict[str, dict[str, str]] = {}
279
+ current_group = ""
280
+ for match in HTML_NAV_GROUP_OR_LINK_PATTERN.finditer(text):
281
+ if match.group("group_attrs") is not None:
282
+ current_group = visible_html_text(match.group("group_body") or "")
283
+ continue
284
+ attrs = match.group("link_attrs") or ""
285
+ href = html_attr(attrs, "href")
286
+ if href:
287
+ result.setdefault(href, {
288
+ "label": preferred_html_label(match.group("link_body") or "") or href,
289
+ "group": current_group,
290
+ })
291
+ return result
292
+
293
+
294
+ def html_tab_groups(text: str) -> dict[str, dict[str, str]]:
295
+ result: dict[str, dict[str, str]] = {}
296
+ for nav in HTML_NAV_BLOCK_PATTERN.finditer(text):
297
+ group = html_attr(nav.group("attrs"), "aria-label")
298
+ for match in HTML_TAB_PATTERN.finditer(nav.group("body")):
299
+ attrs = match.group("attrs")
300
+ tab_id = html_attr(attrs, "id")
301
+ controlled = html_attr(attrs, "aria-controls")
302
+ target = f"#{controlled}" if controlled else f"#panel-{tab_id.removeprefix('tab-')}"
303
+ result[target] = {
304
+ "label": preferred_html_label(match.group("body")) or tab_id,
305
+ "group": group,
306
+ }
307
+ return result
308
+
309
+
310
+ def html_data_tab_candidates(source: str, text: str, navigation: dict[str, dict[str, str]]) -> list[dict[str, Any]]:
311
+ """Discover source-backed tab panels that represent persistent logical views."""
312
+ panels = {
313
+ (match.group("family").lower(), match.group("value")): match
314
+ for match in HTML_DATA_PANEL_PATTERN.finditer(text)
315
+ }
316
+ candidates: list[dict[str, Any]] = []
317
+ for match in HTML_DATA_TAB_PATTERN.finditer(text):
318
+ family = match.group("family").lower()
319
+ value = match.group("value")
320
+ panel = panels.get((family, value))
321
+ if panel is None:
322
+ continue
323
+ parent_fragment = f"#{family.split('-')[0]}"
324
+ parent_nav = navigation.get(parent_fragment, {})
325
+ label = visible_html_text(match.group("body")) or value
326
+ group_path = [item for item in (parent_nav.get("group", ""), parent_nav.get("label", "")) if item]
327
+ candidates.append({
328
+ "name": view_name(f"{family}-{value}"),
329
+ "label": label,
330
+ "file": source,
331
+ "line": line_number(text, panel.start()),
332
+ "platform": "web",
333
+ "confidence": "high",
334
+ "logicalView": True,
335
+ "fragment": f"{parent_fragment}/{value}",
336
+ "selector": f'section[data-{family}-panel="{value}"]',
337
+ "parentFragment": parent_fragment,
338
+ "groupPath": group_path,
339
+ "sourceKind": "html-tab-panel",
340
+ })
341
+ return candidates
342
+
343
+
344
+ def js_data_tab_candidates(source: str, path: Path, text: str) -> list[dict[str, Any]]:
345
+ """Discover deterministic tab sets rendered from literal JS tuple arrays."""
346
+ candidates: list[dict[str, Any]] = []
347
+ source_hint = re.sub(r"^(?:dashboard|admin|cabinet)[_-]?", "", path.stem, flags=re.I)
348
+ for match in JS_TAB_ARRAY_PATTERN.finditer(text):
349
+ family = match.group("family").lower()
350
+ parent_hint = family.split("-")[0] or source_hint
351
+ if parent_hint in {"queue", "detail"} and source_hint:
352
+ parent_hint = source_hint.split("_")[0].split("-")[0]
353
+ parent_fragment = f"#{parent_hint}"
354
+ for item in JS_TAB_ITEM_PATTERN.finditer(match.group("array")):
355
+ value = item.group("id")
356
+ candidates.append({
357
+ "name": view_name(f"{parent_hint}-{value}"),
358
+ "label": item.group("label"),
359
+ "file": source,
360
+ "line": line_number(text, match.start() + item.start()),
361
+ "platform": "web",
362
+ "confidence": "high",
363
+ "logicalView": True,
364
+ "fragment": f"{parent_fragment}/{value}",
365
+ "selector": f'[data-{family}-tab="{value}"]',
366
+ "parentFragment": parent_fragment,
367
+ "groupPath": [],
368
+ "sourceKind": "js-generated-tab",
369
+ })
370
+ return candidates
371
+
372
+
373
+ def section_fragment_at(text: str, offset: int) -> str:
374
+ """Return the innermost explicitly identified section open at offset."""
375
+ stack: list[str] = []
376
+ for match in HTML_SECTION_TOKEN_PATTERN.finditer(text, 0, offset):
377
+ if match.group("attrs") is not None:
378
+ section_id = html_attr(match.group("attrs"), "id")
379
+ stack.append(f"#{section_id}" if section_id else "")
380
+ elif stack:
381
+ stack.pop()
382
+ return next((item for item in reversed(stack) if item), "")
383
+
384
+
385
+ def extract_surface_candidates(source: str, text: str) -> list[dict[str, Any]]:
386
+ """Discover explicit overlays and view states without inflating screen count."""
387
+ surfaces: list[dict[str, Any]] = []
388
+ for match in HTML_DIALOG_PATTERN.finditer(text):
389
+ attrs = match.group("attrs")
390
+ dialog_id = html_attr(attrs, "id")
391
+ if not dialog_id:
392
+ continue
393
+ label = html_attr(attrs, "aria-label")
394
+ if not label:
395
+ heading = re.search(r"<h[1-6]\b[^>]*>(.*?)</h[1-6]>", match.group("body"), re.I | re.S)
396
+ label = visible_html_text(heading.group(1)) if heading else dialog_id.replace("-", " ")
397
+ surfaces.append({
398
+ "id": f"{source}#dialog-{dialog_id}",
399
+ "label": label,
400
+ "kind": "overlay",
401
+ "state": "dialog-open",
402
+ "file": source,
403
+ "line": line_number(text, match.start()),
404
+ "selector": f"dialog#{dialog_id}",
405
+ "parentFragment": section_fragment_at(text, match.start()),
406
+ "sourceKind": "html-dialog",
407
+ "confidence": "high",
408
+ })
409
+
410
+ view_pairs: dict[str, dict[str, re.Match[str]]] = {}
411
+ state_tokens = {"loading", "empty", "error", "success"}
412
+ for match in HTML_ELEMENT_PATTERN.finditer(text):
413
+ attrs = match.group("attrs")
414
+ element_id = html_attr(attrs, "id")
415
+ classes = set(html_attr(attrs, "class").split())
416
+ if element_id:
417
+ pair = re.fullmatch(r"(.+)-(list|detail)-view", element_id, re.I)
418
+ if pair:
419
+ view_pairs.setdefault(pair.group(1).lower(), {})[pair.group(2).lower()] = match
420
+ semantic_state = next((token for token in state_tokens if token in re.split(r"[^a-z0-9]+", element_id.lower())), "")
421
+ if not semantic_state:
422
+ semantic_state = next((token for token in state_tokens if token in {value.lower() for value in classes}), "")
423
+ role = html_attr(attrs, "role").lower()
424
+ if element_id and semantic_state and ("hidden" in attrs.lower() or role in {"alert", "status"}):
425
+ surfaces.append({
426
+ "id": f"{source}#state-{element_id}",
427
+ "label": element_id.replace("-", " "),
428
+ "kind": "state",
429
+ "state": semantic_state,
430
+ "file": source,
431
+ "line": line_number(text, match.start()),
432
+ "selector": f"#{element_id}",
433
+ "parentFragment": section_fragment_at(text, match.start()),
434
+ "sourceKind": "html-state-marker",
435
+ "confidence": "high",
436
+ })
437
+ login_marker = " ".join([element_id, *classes]).lower()
438
+ if element_id and "login" in login_marker and match.group("tag").lower() in {"form", "section"}:
439
+ surfaces.append({
440
+ "id": f"{source}#auth-{element_id}",
441
+ "label": element_id.replace("-", " "),
442
+ "kind": "state",
443
+ "state": "auth-required",
444
+ "file": source,
445
+ "line": line_number(text, match.start()),
446
+ "selector": f"#{element_id}",
447
+ "parentFragment": section_fragment_at(text, match.start()),
448
+ "sourceKind": "html-auth-gate",
449
+ "confidence": "high",
450
+ })
451
+
452
+ for prefix, pair in view_pairs.items():
453
+ if set(pair) != {"list", "detail"}:
454
+ continue
455
+ for state, match in pair.items():
456
+ element_id = html_attr(match.group("attrs"), "id")
457
+ surfaces.append({
458
+ "id": f"{source}#logical-state-{element_id}",
459
+ "label": state.title(),
460
+ "kind": "logical-state",
461
+ "state": state,
462
+ "file": source,
463
+ "line": line_number(text, match.start()),
464
+ "selector": f"#{element_id}",
465
+ "parentFragment": section_fragment_at(text, match.start()),
466
+ "stateGroup": prefix,
467
+ "sourceKind": "html-list-detail-pair",
468
+ "confidence": "high",
469
+ })
470
+ return unique_dicts(surfaces, ("id",))
471
+
472
+
473
+ def extract_theme_candidates(path: Path, text: str, source: str) -> list[dict[str, Any]]:
474
+ """Detect only source-evidenced color themes; light remains the display fallback."""
475
+ normalized = path.as_posix().lower()
476
+ candidates: list[dict[str, Any]] = []
477
+
478
+ def add(theme_id: str, kind: str, label: str, offset: int, evidence: str) -> None:
479
+ candidates.append({
480
+ "id": slug(theme_id, "theme"),
481
+ "label": label,
482
+ "kind": kind,
483
+ "confidence": "high",
484
+ "sourceRefs": [{"file": source, "line": line_number(text, offset), "evidence": evidence}],
485
+ })
486
+
487
+ custom_pattern = re.compile(r"data-theme\s*=\s*[\"']([A-Za-z][A-Za-z0-9_-]*)[\"']", re.I)
488
+ for match in custom_pattern.finditer(text):
489
+ raw = match.group(1)
490
+ kind = "dark" if raw.lower() == "dark" else "light" if raw.lower() == "light" else "custom"
491
+ add(raw, kind, raw.replace("-", " ").title(), match.start(), "data-theme")
492
+
493
+ dark_markers = (
494
+ r"prefers-color-scheme\s*:\s*dark",
495
+ r"\bdarkColorScheme\b",
496
+ r"\bisSystemInDarkTheme\s*\(",
497
+ r"\bThemeMode\.dark\b",
498
+ r"\bdarkTheme\s*[:=]",
499
+ r"\bpreferredColorScheme\s*\(\s*\.dark\s*\)",
500
+ r"\bcolorScheme\s*==\s*\.dark\b",
501
+ r"RequestedTheme\s*=\s*[\"']Dark[\"']",
502
+ r"x:Key\s*=\s*[\"']Dark[\"']",
503
+ r"\bTheme\.Dark\b",
504
+ r"\bDayNight\b",
505
+ )
506
+ dark_match = next((match for pattern in dark_markers if (match := re.search(pattern, text, re.I))), None)
507
+ if "/values-night" in normalized and dark_match is None:
508
+ dark_match = re.match(r"", text)
509
+ if dark_match:
510
+ add("dark", "dark", "Dark", dark_match.start(), "platform-dark-theme")
511
+
512
+ light_markers = (
513
+ r"\blightColorScheme\b",
514
+ r"\bThemeMode\.light\b",
515
+ r"\blightTheme\s*[:=]",
516
+ r"\bpreferredColorScheme\s*\(\s*\.light\s*\)",
517
+ r"RequestedTheme\s*=\s*[\"']Light[\"']",
518
+ r"x:Key\s*=\s*[\"']Light[\"']",
519
+ )
520
+ light_match = next((match for pattern in light_markers if (match := re.search(pattern, text, re.I))), None)
521
+ if light_match:
522
+ add("light", "light", "Light", light_match.start(), "platform-light-theme")
523
+ return unique_dicts(candidates, ("id", "kind"))
524
+
525
+
526
+ def extract_symbols(text: str, platforms: list[str]) -> list[dict[str, Any]]:
527
+ symbols: list[dict[str, Any]] = []
528
+ for platform in platforms:
529
+ for kind, pattern in SYMBOL_RULES.get(platform, []):
530
+ for match in pattern.finditer(text):
531
+ groups = [value for value in match.groups() if value]
532
+ name = groups[0] if groups else match.group(0).split("(", 1)[0].strip()
533
+ symbols.append({"name": name, "kind": kind, "line": line_number(text, match.start())})
534
+ return unique_dicts(symbols, ("name", "line", "kind"))[:100]
535
+
536
+
537
+ def extract_routes(text: str, source: str) -> list[dict[str, Any]]:
538
+ routes: list[dict[str, Any]] = []
539
+ if source.lower().endswith(".go"):
540
+ for match in GO_PAGE_ROUTE_PATTERN.finditer(text):
541
+ handler = match.group(2)
542
+ if re.search(r"(?:Page|HTML|Login|Index)Handler", handler, re.I):
543
+ routes.append({"route": match.group(1), "file": source, "line": line_number(text, match.start())})
544
+ return unique_dicts(routes, ("route", "file", "line"))[:100]
545
+ for pattern in ROUTE_PATTERNS:
546
+ for match in pattern.finditer(text):
547
+ route = match.group(1).strip()
548
+ if not route.startswith(("/", "#")):
549
+ continue
550
+ routes.append({"route": route, "file": source, "line": line_number(text, match.start())})
551
+ return unique_dicts(routes, ("route", "file", "line"))[:100]
552
+
553
+
554
+ def screen_candidates(source: str, path: Path, text: str, platforms: list[str], symbols: list[dict[str, Any]], role: str) -> list[dict[str, Any]]:
555
+ candidates: list[dict[str, Any]] = []
556
+ screen_suffixes = ("Screen", "Page", "View", "Activity", "Fragment", "Dialog", "ViewController", "Window", "WindowController", "Pane")
557
+ android_source = any(platform.startswith("android-") for platform in platforms)
558
+ for symbol in symbols:
559
+ abstract_class = bool(android_source and re.search(rf"\babstract\s+class\s+{re.escape(str(symbol['name']))}\b", text))
560
+ suffixes = ("Activity", "Fragment", "Dialog") if android_source and symbol.get("kind") == "class" else screen_suffixes
561
+ if symbol["name"].endswith(suffixes) or role == "screen" and not android_source:
562
+ candidates.append({
563
+ "name": symbol["name"],
564
+ "file": source,
565
+ "line": symbol["line"],
566
+ "platform": platforms[0] if platforms else "unknown",
567
+ "confidence": "high" if symbol["name"].endswith(suffixes) else "approximate",
568
+ "abstractClass": abstract_class,
569
+ })
570
+ android_layout = android_layout_metadata(path)
571
+ if android_layout and android_layout["role"] == "screen":
572
+ candidates.append({
573
+ "name": android_layout["name"],
574
+ "file": source,
575
+ "line": 1,
576
+ "platform": "android-views",
577
+ "confidence": "high",
578
+ "layoutResource": android_layout["resource"],
579
+ "resourceQualifier": android_layout["qualifier"],
580
+ "sourceKind": android_layout["sourceKind"],
581
+ })
582
+ if path.suffix.lower() in {".kt", ".kts"} and any(platform.startswith("android-") for platform in platforms):
583
+ layout_refs = android_layout_refs(text)
584
+ for candidate in candidates:
585
+ name = str(candidate.get("name") or "")
586
+ if name.endswith(("Activity", "Fragment", "Dialog")):
587
+ candidate["androidClass"] = name
588
+ candidate["layoutRefs"] = layout_refs
589
+ declaration = re.search(rf"\bclass\s+{re.escape(name)}\b[^{{:]*:\s*([A-Z][A-Za-z0-9_]*)", text)
590
+ if declaration:
591
+ candidate["androidSuperClass"] = declaration.group(1)
592
+ candidate["sourceKind"] = "android-class-template" if candidate.pop("abstractClass", False) else "android-class"
593
+ if path.suffix.lower() in {".html", ".htm"}:
594
+ navigation = html_navigation_groups(text)
595
+ navigation.update(html_tab_groups(text))
596
+ for match in HTML_SECTION_PATTERN.finditer(text):
597
+ attrs = match.group("attrs")
598
+ section_id = html_attr(attrs, "id")
599
+ data_section = html_attr(attrs, "data-section")
600
+ classes = set(html_attr(attrs, "class").split())
601
+ if not section_id or not (data_section or {"panel", "view", "screen"} & classes):
602
+ continue
603
+ label = data_section or section_id
604
+ nav_item = navigation.get(f"#{section_id}", {})
605
+ candidates.append({
606
+ "name": view_name(label),
607
+ "label": nav_item.get("label") or label,
608
+ "file": source,
609
+ "line": line_number(text, match.start()),
610
+ "platform": "web",
611
+ "confidence": "high",
612
+ "logicalView": True,
613
+ "fragment": f"#{section_id}",
614
+ "selector": f"section#{section_id}",
615
+ "groupPath": [nav_item["group"]] if nav_item.get("group") else [],
616
+ "sourceKind": "html-section",
617
+ })
618
+ candidates.extend(html_data_tab_candidates(source, text, navigation))
619
+ if path.suffix.lower() in {".js", ".jsx", ".ts", ".tsx"}:
620
+ candidates.extend(js_data_tab_candidates(source, path, text))
621
+ return unique_dicts(candidates, ("name", "file", "line"))
622
+
623
+
624
+ def extract_navigation_targets(text: str, source: str) -> list[dict[str, Any]]:
625
+ targets: list[dict[str, Any]] = []
626
+ for match in HTML_NAV_PATTERN.finditer(text):
627
+ href = html_attr(match.group("attrs"), "href")
628
+ if href:
629
+ targets.append({
630
+ "target": href,
631
+ "label": visible_html_text(match.group("body")) or href,
632
+ "file": source,
633
+ "line": line_number(text, match.start()),
634
+ })
635
+ section_ids = {
636
+ html_attr(match.group("attrs"), "id") for match in HTML_SECTION_PATTERN.finditer(text)
637
+ if html_attr(match.group("attrs"), "id")
638
+ }
639
+ for match in HTML_TAB_PATTERN.finditer(text):
640
+ tab_id = html_attr(match.group("attrs"), "id")
641
+ suffix = tab_id.removeprefix("tab-")
642
+ target = f"#panel-{suffix}"
643
+ if target[1:] in section_ids:
644
+ targets.append({
645
+ "target": target,
646
+ "label": visible_html_text(match.group("body")) or suffix,
647
+ "file": source,
648
+ "line": line_number(text, match.start()),
649
+ })
650
+ return unique_dicts(targets, ("target", "file", "line"))
651
+
652
+
653
+ def component_candidates(source: str, path: Path, platforms: list[str], symbols: list[dict[str, Any]], role: str) -> list[dict[str, Any]]:
654
+ """Return conservative project-component candidates for later source inspection."""
655
+ candidates: list[dict[str, Any]] = []
656
+ screen_suffixes = ("Screen", "Page", "Activity", "Fragment", "Dialog", "ViewController", "Window", "WindowController", "Pane")
657
+ primitive_names = {
658
+ "Row", "Column", "Box", "LazyColumn", "LazyRow", "Scaffold", "Card",
659
+ "Button", "Text", "Image", "Icon", "TextField",
660
+ "Carousel", "ImmersiveList", "Surface", "NavigationView",
661
+ }
662
+ component_kinds = {"composable", "component", "view", "widget", "class"}
663
+ android_source = any(platform.startswith("android-") for platform in platforms)
664
+ normalized = path.as_posix().lower()
665
+ if role == "component" and path.suffix.lower() == ".xml" and "/res/layout" in f"/{normalized}":
666
+ candidates.append({
667
+ "id": f"{source}#{path.stem}",
668
+ "name": "".join(part.capitalize() for part in path.stem.split("_")),
669
+ "platform": platforms[0] if platforms else "android-views",
670
+ "kind": "project",
671
+ "source": {"file": source, "line": 1, "symbol": path.stem},
672
+ "inspection": "pending",
673
+ "confidence": "high",
674
+ "variants": [],
675
+ "states": [],
676
+ "tokenRefs": [],
677
+ })
678
+ for symbol in symbols:
679
+ name = str(symbol.get("name", ""))
680
+ symbol_screen_suffixes = ("Activity", "Fragment", "Dialog") if android_source and symbol.get("kind") == "class" else screen_suffixes
681
+ if (
682
+ not name
683
+ or name in primitive_names
684
+ or name.endswith(symbol_screen_suffixes)
685
+ or symbol.get("kind") not in component_kinds
686
+ ):
687
+ continue
688
+ confidence = "high" if role == "component" else "approximate"
689
+ platform = platforms[0] if platforms else "unknown"
690
+ candidates.append({
691
+ "id": f"{source}#{name}",
692
+ "name": name,
693
+ "platform": platform,
694
+ "kind": "project",
695
+ "source": {"file": source, "line": symbol.get("line", 1), "symbol": name},
696
+ "inspection": "pending",
697
+ "confidence": confidence,
698
+ "variants": [],
699
+ "states": [],
700
+ "tokenRefs": [],
701
+ })
702
+ return unique_dicts(candidates, ("id",))
703
+
704
+
705
+ def iter_files(root: Path) -> Iterable[Path]:
706
+ for current, dirs, files in os.walk(root):
707
+ dirs[:] = [directory for directory in dirs if directory not in EXCLUDED_DIRS]
708
+ current_path = Path(current)
709
+ for filename in files:
710
+ yield current_path / filename
711
+
712
+
713
+ def analyze_file(root: Path, path: Path) -> dict[str, Any] | None:
714
+ """Analyze one candidate file so callers can cache results by content hash."""
715
+ suffix = path.suffix.lower()
716
+ source = relative(path, root)
717
+ if suffix in ASSET_EXTENSIONS:
718
+ return {"path": source, "kind": "asset", "asset": {"path": source, "type": suffix.lstrip(".")}}
719
+ if suffix not in SOURCE_EXTENSIONS:
720
+ return None
721
+ text = read_text(path)
722
+ if text is None:
723
+ return {"path": source, "kind": "skipped", "reason": "large-or-unreadable"}
724
+ platforms = detect_platforms(path, text)
725
+ if not platforms:
726
+ return {"path": source, "kind": "source", "platforms": [], "uiFile": None}
727
+ role = classify_role(path, text)
728
+ symbols = extract_symbols(text, platforms)
729
+ file_routes = extract_routes(text, source)
730
+ file_navigation_targets = extract_navigation_targets(text, source) if suffix in {".html", ".htm"} else []
731
+ android_navigation = parse_android_navigation(text, source) if role == "navigation" and suffix == ".xml" and any(platform.startswith("android-") for platform in platforms) else {"screens": [], "routes": [], "navigationTargets": []}
732
+ file_routes = [*file_routes, *android_navigation["routes"]]
733
+ file_navigation_targets = [*file_navigation_targets, *android_navigation["navigationTargets"]]
734
+ surfaces = extract_surface_candidates(source, text) if suffix in {".html", ".htm"} else []
735
+ themes = extract_theme_candidates(path, text, source)
736
+ return {
737
+ "path": source,
738
+ "kind": "source",
739
+ "platforms": platforms,
740
+ "uiFile": {
741
+ "path": source,
742
+ "platforms": platforms,
743
+ "role": role,
744
+ "symbols": symbols,
745
+ "routes": file_routes,
746
+ "navigationTargets": file_navigation_targets,
747
+ },
748
+ "screens": [*screen_candidates(source, path, text, platforms, symbols, role), *android_navigation["screens"]],
749
+ "routes": file_routes,
750
+ "navigationTargets": file_navigation_targets,
751
+ "surfaces": surfaces,
752
+ "components": component_candidates(source, path, platforms, symbols, role),
753
+ "themes": themes,
754
+ "tokenFile": source if role == "theme" else None,
755
+ }
756
+
757
+
758
+ def assemble_scan(root: Path, file_records: Iterable[dict[str, Any]]) -> dict[str, Any]:
759
+ """Assemble a repository inventory from cached per-file analyses."""
760
+ ui_files: list[dict[str, Any]] = []
761
+ screens: list[dict[str, Any]] = []
762
+ routes: list[dict[str, Any]] = []
763
+ assets: list[dict[str, Any]] = []
764
+ components: list[dict[str, Any]] = []
765
+ navigation_targets: list[dict[str, Any]] = []
766
+ surfaces: list[dict[str, Any]] = []
767
+ theme_candidates: list[dict[str, Any]] = []
768
+ token_files: list[str] = []
769
+ detected: list[str] = []
770
+ skipped_large = 0
771
+ policy_file = ""
772
+ for candidate in (
773
+ root / ".agents" / "ui-policy.json",
774
+ root / ".codex" / "ui-policy.json",
775
+ root / "ui-policy.json",
776
+ ):
777
+ if candidate.is_file():
778
+ policy_file = relative(candidate, root)
779
+ break
780
+
781
+ for record in file_records:
782
+ if not isinstance(record, dict):
783
+ continue
784
+ if record.get("kind") == "asset" and isinstance(record.get("asset"), dict):
785
+ assets.append(record["asset"])
786
+ continue
787
+ if record.get("kind") == "skipped":
788
+ skipped_large += 1
789
+ continue
790
+ platforms = record.get("platforms", [])
791
+ if not platforms:
792
+ continue
793
+ detected.extend(platforms)
794
+ if isinstance(record.get("uiFile"), dict):
795
+ ui_files.append(record["uiFile"])
796
+ routes.extend(record.get("routes", []))
797
+ navigation_targets.extend(record.get("navigationTargets", []))
798
+ surfaces.extend(record.get("surfaces", []))
799
+ screens.extend(record.get("screens", []))
800
+ components.extend(record.get("components", []))
801
+ theme_candidates.extend(record.get("themes", []))
802
+ if record.get("tokenFile"):
803
+ token_files.append(str(record["tokenFile"]))
804
+
805
+ role_order = {"navigation": 0, "theme": 1, "screen": 2, "collection-item": 3, "partial": 4, "component": 5, "ui-source": 6}
806
+ ui_files.sort(key=lambda item: (role_order.get(item["role"], 9), item["path"]))
807
+ screens = reconcile_android_screen_candidates(unique_dicts(screens, ("name", "file", "line")))
808
+ by_fragment: dict[str, list[dict[str, Any]]] = {}
809
+ for candidate in screens:
810
+ if candidate.get("fragment"):
811
+ by_fragment.setdefault(str(candidate["fragment"]), []).append(candidate)
812
+ for item in screens:
813
+ parent_options = by_fragment.get(str(item.get("parentFragment") or ""), [])
814
+ same_file = [candidate for candidate in parent_options if candidate.get("file") == item.get("file")]
815
+ parent = same_file[0] if len(same_file) == 1 else parent_options[0] if len(parent_options) == 1 else None
816
+ if parent is None:
817
+ continue
818
+ inherited = [str(value) for value in parent.get("groupPath", []) if value]
819
+ parent_label = str(parent.get("label") or parent.get("name") or "")
820
+ item["groupPath"] = list(dict.fromkeys([*inherited, parent_label, *item.get("groupPath", [])]))
821
+ routes = unique_dicts(routes, ("route", "file", "line"))
822
+ components = unique_dicts(components, ("id",))
823
+ navigation_targets = unique_dicts(navigation_targets, ("target", "file", "line"))
824
+ surfaces = unique_dicts(surfaces, ("id",))
825
+ themes_by_id: dict[str, dict[str, Any]] = {
826
+ "light": {"id": "light", "label": "Light", "kind": "light", "confidence": "default", "sourceRefs": []}
827
+ }
828
+ for candidate in theme_candidates:
829
+ theme_id = str(candidate.get("id") or "").strip()
830
+ if not theme_id:
831
+ continue
832
+ current = themes_by_id.setdefault(theme_id, {
833
+ "id": theme_id,
834
+ "label": candidate.get("label") or theme_id,
835
+ "kind": candidate.get("kind") or "custom",
836
+ "confidence": candidate.get("confidence") or "high",
837
+ "sourceRefs": [],
838
+ })
839
+ for source_ref in candidate.get("sourceRefs", []):
840
+ if source_ref not in current["sourceRefs"]:
841
+ current["sourceRefs"].append(source_ref)
842
+ if current["sourceRefs"]:
843
+ current["confidence"] = "high"
844
+ themes = list(themes_by_id.values())
845
+ assets.sort(key=lambda item: item["path"])
846
+ platforms = [platform for platform in dict.fromkeys(detected) if platform != "shared-ui"]
847
+ warnings: list[str] = []
848
+ if not platforms:
849
+ warnings.append("No supported UI platform markers were found.")
850
+ if skipped_large:
851
+ warnings.append(f"Skipped {skipped_large} source files larger than {MAX_FILE_BYTES} bytes or unreadable.")
852
+ return {
853
+ "version": 1,
854
+ "repoRoot": str(root.resolve()),
855
+ "policyFile": policy_file,
856
+ "generatedAt": dt.datetime.now(dt.timezone.utc).isoformat(),
857
+ "detectedPlatforms": platforms,
858
+ "summary": {
859
+ "uiFiles": len(ui_files),
860
+ "screenCandidates": len(screens),
861
+ "routes": len(routes),
862
+ "assets": len(assets),
863
+ "tokenFiles": len(set(token_files)),
864
+ "componentCandidates": len(components),
865
+ "navigationTargets": len(navigation_targets),
866
+ "surfaces": len(surfaces),
867
+ "themes": len(themes),
868
+ },
869
+ "screens": screens,
870
+ "routes": routes,
871
+ "tokenFiles": sorted(set(token_files)),
872
+ "assets": assets[:2000],
873
+ "components": components[:2000],
874
+ "themes": themes,
875
+ "navigationTargets": navigation_targets[:2000],
876
+ "surfaces": surfaces[:2000],
877
+ "uiFiles": ui_files[:2000],
878
+ "warnings": warnings,
879
+ }
880
+
881
+
882
+ def scan(root: Path) -> dict[str, Any]:
883
+ records: list[dict[str, Any]] = []
884
+ for path in iter_files(root):
885
+ record = analyze_file(root, path)
886
+ if record is not None:
887
+ records.append(record)
888
+ return assemble_scan(root, records)
889
+
890
+
891
+ def slug(value: str, fallback: str) -> str:
892
+ result = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
893
+ return result or fallback
894
+
895
+
896
+ def evidence_for_screen(screen: dict[str, Any], discovered: list[dict[str, Any]]) -> dict[str, Any]:
897
+ source_file = str(screen.get("source", {}).get("file") or "")
898
+ fragment = str(screen.get("fragment") or "")
899
+ symbol = str(screen.get("source", {}).get("symbol") or screen.get("name") or "")
900
+ return next((
901
+ candidate for candidate in discovered
902
+ if str(candidate.get("file") or candidate.get("source", {}).get("file") or "") == source_file
903
+ and (
904
+ (fragment and str(candidate.get("fragment") or "") == fragment)
905
+ or str(candidate.get("name") or candidate.get("source", {}).get("symbol") or "") == symbol
906
+ )
907
+ ), {})
908
+
909
+
910
+ def reconcile_translated_android_screens(
911
+ translated_screens: list[dict[str, Any]],
912
+ discovered: list[dict[str, Any]],
913
+ ) -> None:
914
+ """Attach translated layouts to source-evidenced Fragment/Activity destinations."""
915
+ by_layout: dict[tuple[str, str], list[dict[str, Any]]] = {}
916
+ for candidate in discovered:
917
+ for layout_ref in candidate.get("layoutRefs", []):
918
+ by_layout.setdefault((str(layout_ref), ""), []).append(candidate)
919
+ if candidate.get("layoutResource"):
920
+ by_layout.setdefault((str(candidate["layoutResource"]), str(candidate.get("resourceQualifier") or "")), []).append(candidate)
921
+ used_ids = {str(screen.get("id") or "") for screen in translated_screens}
922
+
923
+ def attach(screen: dict[str, Any], candidate: dict[str, Any]) -> None:
924
+ screen["name"] = str(candidate.get("label") or candidate.get("name") or screen.get("name"))
925
+ screen["label"] = screen["name"]
926
+ screen["fragment"] = str(candidate.get("fragment") or "")
927
+ screen["route"] = str(candidate.get("route") or candidate.get("fragment") or "")
928
+ screen["discoveredKey"] = f'{candidate.get("file", "")}#{candidate.get("name", "")}'
929
+ screen["navigationSource"] = {
930
+ "file": candidate.get("file", ""),
931
+ "line": candidate.get("line", 1),
932
+ "kind": candidate.get("sourceKind", ""),
933
+ }
934
+
935
+ reconciled: list[dict[str, Any]] = []
936
+ for screen in translated_screens:
937
+ if screen.get("platform") not in {"android", "android-tv"}:
938
+ reconciled.append(screen)
939
+ continue
940
+ resource = str(screen.get("androidLayout") or "")
941
+ matches = by_layout.get((resource, str(screen.get("resourceQualifier") or "")), [])
942
+ if not matches:
943
+ reconciled.append(screen)
944
+ continue
945
+ for index, candidate in enumerate(matches):
946
+ mapped = screen if index == 0 else dict(screen)
947
+ if index:
948
+ base = slug(str(candidate.get("fragment") or candidate.get("name") or screen.get("id")), "android-screen")
949
+ candidate_id = base
950
+ suffix = 2
951
+ while candidate_id in used_ids:
952
+ candidate_id = f"{base}-{suffix}"
953
+ suffix += 1
954
+ mapped["id"] = candidate_id
955
+ used_ids.add(candidate_id)
956
+ attach(mapped, candidate)
957
+ reconciled.append(mapped)
958
+ translated_screens[:] = reconciled
959
+
960
+
961
+ def resolve_translated_android_includes(translated: Any) -> None:
962
+ """Link statically discoverable include nodes to translated partial layouts."""
963
+ component_roots: dict[tuple[str, str], list[str]] = {}
964
+ for component in translated.components:
965
+ resource = str(component.get("resource") or "")
966
+ if resource and component.get("root"):
967
+ component_roots.setdefault((resource, str(component.get("resourceQualifier") or "")), []).append(str(component["root"]))
968
+ for node in translated.nodes.values():
969
+ include_layout = str(node.get("includeLayout") or "")
970
+ match = re.fullmatch(r"@layout/([A-Za-z0-9_]+)", include_layout)
971
+ if not match:
972
+ continue
973
+ metadata = android_layout_metadata(Path(str(node.get("source", {}).get("file") or ""))) or {}
974
+ qualifier = str(metadata.get("qualifier") or "")
975
+ roots = component_roots.get((match.group(1), qualifier), []) or component_roots.get((match.group(1), ""), [])
976
+ if len(roots) == 1:
977
+ node["children"] = [roots[0]]
978
+ node["confidence"] = "high"
979
+ node["resolvedInclude"] = True
980
+ continue
981
+ node["type"] = "custom"
982
+ node["confidence"] = "unsupported"
983
+ node["text"] = f"Unresolved include: {include_layout}"
984
+ translated.unsupported.append({
985
+ "adapter": "android-xml",
986
+ "file": node.get("source", {}).get("file", ""),
987
+ "line": node.get("source", {}).get("line", 1),
988
+ "expression": include_layout,
989
+ "reason": "unresolved-android-include",
990
+ })
991
+
992
+
993
+ def build_screen_tree(screens: list[dict[str, Any]], discovered: list[dict[str, Any]]) -> list[dict[str, Any]]:
994
+ """Compile a deterministic platform → evidence group path → screen tree."""
995
+ tree_groups: dict[str, dict[str, Any]] = {}
996
+ for screen in screens:
997
+ platform_label = str(screen.get("platform") or "unknown")
998
+ source_file = str(screen.get("source", {}).get("file") or "inventory")
999
+ metadata = evidence_for_screen(screen, discovered)
1000
+ group_path = [str(value) for value in metadata.get("groupPath", screen.get("groupPath", [])) if value]
1001
+ if not group_path:
1002
+ group_path = [Path(source_file).stem or "Screens"]
1003
+ root = tree_groups.setdefault(platform_label, {"children": {}, "leaves": []})
1004
+ current = root
1005
+ for label in group_path:
1006
+ current = current["children"].setdefault(label, {"children": {}, "leaves": []})
1007
+ current["leaves"].append({
1008
+ "screenId": str(screen["id"]),
1009
+ "label": str(metadata.get("label") or screen.get("label") or screen["name"]),
1010
+ })
1011
+
1012
+ def children_for(branch: dict[str, Any], prefix: str) -> list[dict[str, Any]]:
1013
+ groups = [
1014
+ {
1015
+ "id": f"group-{slug(prefix + '-' + label, 'screens')}-{hashlib.sha1((prefix + '/' + label).encode('utf-8')).hexdigest()[:8]}",
1016
+ "label": label,
1017
+ "children": children_for(child, f"{prefix}-{label}"),
1018
+ }
1019
+ for label, child in branch["children"].items()
1020
+ ]
1021
+ return [*groups, *branch["leaves"]]
1022
+
1023
+ return [
1024
+ {
1025
+ "id": f"platform-{slug(platform_label, 'unknown')}",
1026
+ "label": platform_label,
1027
+ "children": children_for(branch, platform_label),
1028
+ }
1029
+ for platform_label, branch in tree_groups.items()
1030
+ ]
1031
+
1032
+
1033
+ def build_navigation_graph(
1034
+ screens: list[dict[str, Any]],
1035
+ discovered: list[dict[str, Any]],
1036
+ navigation_targets: list[dict[str, Any]],
1037
+ ) -> dict[str, Any]:
1038
+ """Compile source-evidenced logical transitions separately from rendered actions."""
1039
+ evidence_by_fragment = {
1040
+ str(item.get("fragment")): item for item in discovered if item.get("fragment")
1041
+ }
1042
+ screen_by_fragment = {
1043
+ str(screen.get("fragment")): str(screen.get("id")) for screen in screens if screen.get("fragment")
1044
+ }
1045
+ screen_metadata = {str(screen.get("id")): evidence_for_screen(screen, discovered) for screen in screens}
1046
+
1047
+ def flow_for(screen: dict[str, Any]) -> str:
1048
+ metadata = screen_metadata.get(str(screen.get("id")), {})
1049
+ seen: set[str] = set()
1050
+ while metadata.get("parentFragment") and str(metadata["parentFragment"]) not in seen:
1051
+ seen.add(str(metadata["parentFragment"]))
1052
+ parent = evidence_by_fragment.get(str(metadata["parentFragment"]))
1053
+ if not parent:
1054
+ break
1055
+ metadata = parent
1056
+ source_file = str(metadata.get("file") or screen.get("source", {}).get("file") or "screens")
1057
+ return slug(Path(source_file).stem, "screens")
1058
+
1059
+ nodes = []
1060
+ flow_ids: dict[str, str] = {}
1061
+ for screen in screens:
1062
+ screen_id = str(screen.get("id"))
1063
+ flow_id = flow_for(screen)
1064
+ flow_ids[screen_id] = flow_id
1065
+ metadata = screen_metadata.get(screen_id, {})
1066
+ nodes.append({
1067
+ "screenId": screen_id,
1068
+ "label": str(metadata.get("label") or screen.get("name") or screen_id),
1069
+ "fragment": str(screen.get("fragment") or ""),
1070
+ "flowId": flow_id,
1071
+ "entry": not bool(metadata.get("parentFragment")),
1072
+ })
1073
+
1074
+ edges: list[dict[str, Any]] = []
1075
+ for screen in screens:
1076
+ screen_id = str(screen.get("id"))
1077
+ metadata = screen_metadata.get(screen_id, {})
1078
+ parent_id = screen_by_fragment.get(str(metadata.get("parentFragment") or ""))
1079
+ if not parent_id:
1080
+ continue
1081
+ evidence = {
1082
+ "file": metadata.get("file", ""),
1083
+ "line": metadata.get("line", 1),
1084
+ "sourceKind": metadata.get("sourceKind", ""),
1085
+ }
1086
+ edges.extend([
1087
+ {"from": parent_id, "to": screen_id, "kind": "open-logical-view", "evidence": evidence},
1088
+ {"from": screen_id, "to": parent_id, "kind": "return-to-parent", "evidence": evidence},
1089
+ ])
1090
+
1091
+ resolved_targets: list[dict[str, Any]] = []
1092
+ unresolved: set[str] = set()
1093
+ for item in navigation_targets:
1094
+ target = str(item.get("target") or "")
1095
+ source = str(item.get("source") or "")
1096
+ if not target:
1097
+ continue
1098
+ target_screen_id = screen_by_fragment.get(target)
1099
+ source_screen_id = screen_by_fragment.get(source) if source else None
1100
+ if not target_screen_id:
1101
+ unresolved.add(target)
1102
+ continue
1103
+ if source and not source_screen_id:
1104
+ unresolved.add(source)
1105
+ evidence = {
1106
+ "file": item.get("file", ""),
1107
+ "line": item.get("line", 1),
1108
+ "sourceKind": item.get("sourceKind", ""),
1109
+ "actionId": item.get("actionId", ""),
1110
+ }
1111
+ if source_screen_id:
1112
+ edges.append({"from": source_screen_id, "to": target_screen_id, "kind": "navigate", "evidence": evidence})
1113
+ resolved_targets.append({
1114
+ "target": target,
1115
+ "screenId": target_screen_id,
1116
+ "source": source,
1117
+ "sourceScreenId": source_screen_id or "",
1118
+ })
1119
+ flows = []
1120
+ for flow_id in dict.fromkeys(flow_ids.values()):
1121
+ flow_nodes = [node for node in nodes if node["flowId"] == flow_id]
1122
+ flows.append({
1123
+ "id": flow_id,
1124
+ "screenIds": [node["screenId"] for node in flow_nodes],
1125
+ "entryScreenIds": [node["screenId"] for node in flow_nodes if node["entry"]],
1126
+ })
1127
+ return {
1128
+ "version": 1,
1129
+ "source": "scan-evidence",
1130
+ "nodes": nodes,
1131
+ "edges": edges,
1132
+ "flows": flows,
1133
+ "navigationTargets": resolved_targets,
1134
+ "unresolvedTargets": sorted(unresolved),
1135
+ }
1136
+
1137
+
1138
+ def starter_ir(scan_result: dict[str, Any]) -> dict[str, Any]:
1139
+ repo_root = Path(scan_result["repoRoot"])
1140
+ adapter_contexts: list[SourceContext] = []
1141
+ for item in scan_result.get("uiFiles", []):
1142
+ source = str(item.get("path") or "")
1143
+ path = repo_root / source
1144
+ if not source or not path.is_file():
1145
+ continue
1146
+ text = read_text(path)
1147
+ if text is None:
1148
+ continue
1149
+ adapter_contexts.append(SourceContext(
1150
+ root=repo_root,
1151
+ path=path,
1152
+ source=source,
1153
+ text=text,
1154
+ platforms=tuple(str(value) for value in item.get("platforms", [])),
1155
+ role=str(item.get("role") or "ui-source"),
1156
+ ))
1157
+ included_android_layouts = {
1158
+ match.group(1)
1159
+ for context in adapter_contexts
1160
+ if context.path.suffix.lower() == ".xml" and any(platform.startswith("android-") for platform in context.platforms)
1161
+ for match in re.finditer(r"<include\b[^>]*\blayout\s*=\s*[\"']@layout/([A-Za-z0-9_]+)[\"']", context.text, re.I)
1162
+ }
1163
+ adapter_contexts = [
1164
+ SourceContext(context.root, context.path, context.source, context.text, context.platforms, "partial")
1165
+ if context.path.stem in included_android_layouts and android_layout_metadata(context.path) and context.role not in {"screen", "partial"}
1166
+ else context
1167
+ for context in adapter_contexts
1168
+ ]
1169
+ translated = translate_sources(adapter_contexts)
1170
+ resolve_translated_android_includes(translated)
1171
+ token_resolver = TokenResolver(translated.tokens)
1172
+ for node in translated.nodes.values():
1173
+ for key in ("text", "placeholder"):
1174
+ value = node.get(key)
1175
+ if isinstance(value, str) and value.startswith("$"):
1176
+ resolved = token_resolver.resolve(value)
1177
+ if not isinstance(resolved, dict) and resolved != value:
1178
+ node[key] = resolved
1179
+ raw_scan_screens = scan_result.get("screens", [])
1180
+ reconcile_translated_android_screens(translated.screens, raw_scan_screens)
1181
+ scan_screens = raw_scan_screens
1182
+ if not scan_screens:
1183
+ fallback_ui_file = next((str(item.get("path") or "") for item in scan_result.get("uiFiles", []) if item.get("path")), "")
1184
+ scan_screens = [{
1185
+ "name": Path(fallback_ui_file).stem or "UI inventory",
1186
+ "file": fallback_ui_file,
1187
+ "line": 1,
1188
+ "platform": scan_result.get("detectedPlatforms", ["unknown"])[0] if scan_result.get("detectedPlatforms") else "unknown",
1189
+ "confidence": "unsupported",
1190
+ }]
1191
+ screens: list[dict[str, Any]] = []
1192
+ nodes: dict[str, Any] = {}
1193
+ used_ids: set[str] = set()
1194
+
1195
+ def target_family(platform: str) -> str | None:
1196
+ if platform.startswith("android"):
1197
+ return "android"
1198
+ if platform.startswith("windows"):
1199
+ return "windows"
1200
+ if platform in {"swiftui-macos", "appkit"}:
1201
+ return "macos"
1202
+ if platform in {"swiftui", "uikit"}:
1203
+ return "ios"
1204
+ if platform == "flutter":
1205
+ return "flutter"
1206
+ if platform in {"web", "react-web"}:
1207
+ return "web"
1208
+ return None
1209
+
1210
+ for index, candidate in enumerate(scan_screens, start=1):
1211
+ base = slug(candidate.get("name", "screen"), f"screen-{index}")
1212
+ screen_id = base
1213
+ suffix = 2
1214
+ while screen_id in used_ids:
1215
+ screen_id = f"{base}-{suffix}"
1216
+ suffix += 1
1217
+ used_ids.add(screen_id)
1218
+ root_id = f"{screen_id}-root"
1219
+ title_id = f"{screen_id}-title"
1220
+ source_id = f"{screen_id}-source"
1221
+ inventory_id = f"{screen_id}-inventory"
1222
+ source = {
1223
+ "file": candidate.get("file", ""),
1224
+ "line": candidate.get("line", 1),
1225
+ "symbol": candidate.get("name", ""),
1226
+ }
1227
+ screens.append({
1228
+ "id": screen_id,
1229
+ "name": candidate.get("name", screen_id),
1230
+ "route": candidate.get("route", ""),
1231
+ "discoveredKey": f'{candidate.get("file", "")}#{candidate.get("name", "")}',
1232
+ "source": source,
1233
+ "confidence": candidate.get("confidence", "approximate"),
1234
+ "translationStatus": "inventory",
1235
+ "root": root_id,
1236
+ "platform": target_family(candidate.get("platform", "")) or candidate.get("platform", "unknown"),
1237
+ "fragment": candidate.get("fragment", ""),
1238
+ "logicalView": candidate.get("logicalView", False),
1239
+ "label": candidate.get("label", candidate.get("name", screen_id)),
1240
+ "groupPath": candidate.get("groupPath", []),
1241
+ "parentFragment": candidate.get("parentFragment", ""),
1242
+ "layoutRefs": candidate.get("layoutRefs", []),
1243
+ })
1244
+ root_provenance = {}
1245
+ title_provenance = {}
1246
+ source_provenance = {}
1247
+ if source["file"]:
1248
+ root_provenance = {
1249
+ "component": property_evidence(source["file"], source["line"], source["symbol"], "inventory", "approximate"),
1250
+ "layout.direction": property_evidence(source["file"], source["line"], "source screen container", "inventory", "approximate"),
1251
+ "layout.width": property_evidence(source["file"], source["line"], "source screen bounds", "inventory", "approximate"),
1252
+ "layout.height": property_evidence(source["file"], source["line"], "source screen bounds", "inventory", "approximate"),
1253
+ }
1254
+ title_provenance = {"text": property_evidence(source["file"], source["line"], source["symbol"], "inventory", candidate.get("confidence", "approximate"))}
1255
+ source_provenance = {"text": property_evidence(source["file"], source["line"], "source location", "inventory", "exact")}
1256
+ nodes[root_id] = {
1257
+ "type": "container",
1258
+ "component": "DiscoveredScreen",
1259
+ "layout": {"direction": "column", "width": "fill", "height": "fill"},
1260
+ "style": {},
1261
+ "children": [title_id, source_id, inventory_id],
1262
+ "source": source,
1263
+ "confidence": "approximate",
1264
+ "provenance": root_provenance,
1265
+ }
1266
+ nodes[title_id] = {"type": "text", "text": candidate.get("name", "Screen"), "style": {}, "source": source, "confidence": candidate.get("confidence", "approximate"), "provenance": title_provenance}
1267
+ nodes[source_id] = {"type": "text", "text": f"{candidate.get('file', '')}:{candidate.get('line', 1)}", "style": {}, "source": source, "confidence": "exact", "provenance": source_provenance}
1268
+ nodes[inventory_id] = {"type": "custom", "text": "The AI agent should inspect this source file and replace this inventory placeholder with translated UI nodes.", "component": "UntranslatedSource", "source": source, "confidence": "unsupported"}
1269
+
1270
+ if translated.screens:
1271
+ inventory_screens = screens
1272
+ inventory_nodes = nodes
1273
+ logical_files = {
1274
+ str(candidate.get("file") or "")
1275
+ for candidate in raw_scan_screens
1276
+ if candidate.get("logicalView")
1277
+ }
1278
+ screens = [
1279
+ item for item in translated.screens
1280
+ if str(item.get("source", {}).get("file") or "") not in logical_files
1281
+ ]
1282
+ nodes = {}
1283
+ for screen in screens:
1284
+ stack = [str(screen.get("root") or "")]
1285
+ while stack:
1286
+ node_id = stack.pop()
1287
+ if not node_id or node_id in nodes or node_id not in translated.nodes:
1288
+ continue
1289
+ node = translated.nodes[node_id]
1290
+ nodes[node_id] = node
1291
+ stack.extend(str(child) for child in node.get("children", []))
1292
+ represented = {
1293
+ (str(item.get("source", {}).get("file") or ""), str(item.get("fragment") or ""))
1294
+ for item in screens
1295
+ }
1296
+ represented_discovered = {str(item.get("discoveredKey") or "") for item in screens if item.get("discoveredKey")}
1297
+ for inventory in inventory_screens:
1298
+ key = (str(inventory.get("source", {}).get("file") or ""), str(inventory.get("fragment") or ""))
1299
+ if key in represented or str(inventory.get("discoveredKey") or "") in represented_discovered:
1300
+ continue
1301
+ screens.append(inventory)
1302
+ stack = [str(inventory.get("root") or "")]
1303
+ while stack:
1304
+ node_id = stack.pop()
1305
+ if not node_id or node_id in nodes or node_id not in inventory_nodes:
1306
+ continue
1307
+ node = inventory_nodes[node_id]
1308
+ nodes[node_id] = node
1309
+ stack.extend(str(child) for child in node.get("children", []))
1310
+
1311
+ detected_platforms = scan_result.get("detectedPlatforms", [])
1312
+ handheld_native = any(platform in {"android-compose", "android-views", "swiftui", "uikit", "flutter"} for platform in detected_platforms)
1313
+ target_platforms = list(dict.fromkeys(
1314
+ family for platform in scan_result.get("detectedPlatforms", [])
1315
+ if (family := target_family(platform))
1316
+ ))
1317
+ default_profiles = {
1318
+ "android": "material3",
1319
+ "ios": "apple-hig",
1320
+ "macos": "macos-hig",
1321
+ "windows": "windows-fluent",
1322
+ "flutter": "flutter-adaptive",
1323
+ "web": "web-platform",
1324
+ }
1325
+ standard_profiles = {
1326
+ family: {"id": default_profiles[family], "source": "official-default"}
1327
+ for family in target_platforms
1328
+ if family in default_profiles
1329
+ }
1330
+ screen_tree = build_screen_tree(screens, raw_scan_screens)
1331
+ navigation_graph = build_navigation_graph(screens, raw_scan_screens, scan_result.get("navigationTargets", []))
1332
+ translated_themes = {str(item.get("id")): item for item in translated.themes if isinstance(item, dict) and item.get("id")}
1333
+ theme_items: list[dict[str, Any]] = []
1334
+ for theme in scan_result.get("themes", [{"id": "light", "label": "Light", "kind": "light", "sourceRefs": []}]):
1335
+ merged_theme = {**theme, "tokenOverrides": {}, "nodeOverrides": {}}
1336
+ translated_theme = translated_themes.pop(str(theme.get("id")), None)
1337
+ if translated_theme:
1338
+ merged_theme.update(translated_theme)
1339
+ theme_items.append(merged_theme)
1340
+ theme_items.extend(translated_themes.values())
1341
+ translated_screen_keys = {
1342
+ (str(item.get("source", {}).get("file") or ""), str(item.get("source", {}).get("symbol") or item.get("name") or ""))
1343
+ for item in translated.screens
1344
+ }
1345
+ catalog_components = [
1346
+ item for item in scan_result.get("components", [])
1347
+ if (str(item.get("source", {}).get("file") or ""), str(item.get("source", {}).get("symbol") or item.get("name") or "")) not in translated_screen_keys
1348
+ ]
1349
+ catalog_by_name: dict[str, list[dict[str, Any]]] = {}
1350
+ for item in catalog_components:
1351
+ catalog_by_name.setdefault(str(item.get("name") or ""), []).append(item)
1352
+ for node in nodes.values():
1353
+ component_name = str(node.get("component") or "").rsplit(".", 1)[-1]
1354
+ matches = catalog_by_name.get(component_name, [])
1355
+ if len(matches) == 1:
1356
+ node["componentRef"] = matches[0]["id"]
1357
+ node["standardRef"] = f"project.android.component.{slug(component_name, 'custom')}"
1358
+ ir = {
1359
+ "version": 1,
1360
+ "project": {"name": Path(scan_result["repoRoot"]).name, "root": scan_result["repoRoot"]},
1361
+ "platforms": scan_result.get("detectedPlatforms", []),
1362
+ "design": {
1363
+ "mode": "reconstruct",
1364
+ "targetPlatforms": target_platforms,
1365
+ "standardProfiles": standard_profiles,
1366
+ },
1367
+ "screenTree": screen_tree,
1368
+ "navigationGraph": navigation_graph,
1369
+ "policyFile": scan_result.get("policyFile", ""),
1370
+ "discoveredScreens": [
1371
+ {
1372
+ "name": candidate.get("name", ""),
1373
+ "file": candidate.get("file", candidate.get("source", {}).get("file", "")),
1374
+ "line": candidate.get("line", candidate.get("source", {}).get("line", 1)),
1375
+ "platform": candidate.get("platform", "unknown"),
1376
+ "logicalView": candidate.get("logicalView", False),
1377
+ "fragment": candidate.get("fragment", ""),
1378
+ "selector": candidate.get("selector", ""),
1379
+ "label": candidate.get("label", candidate.get("name", "")),
1380
+ "groupPath": candidate.get("groupPath", []),
1381
+ "parentFragment": candidate.get("parentFragment", ""),
1382
+ "sourceKind": candidate.get("sourceKind", ""),
1383
+ }
1384
+ for candidate in (raw_scan_screens if raw_scan_screens else translated.screens)
1385
+ ],
1386
+ "discoveredRoutes": scan_result.get("routes", []),
1387
+ "discoveredNavigationTargets": scan_result.get("navigationTargets", []),
1388
+ "discoveredSurfaces": scan_result.get("surfaces", []),
1389
+ "viewport": {
1390
+ "width": 390 if handheld_native else 1280,
1391
+ "height": 844 if handheld_native else 800,
1392
+ "device": "phone" if handheld_native else "desktop",
1393
+ },
1394
+ "fidelity": {
1395
+ "schemaVersion": FIDELITY_SCHEMA_VERSION,
1396
+ "status": "translated" if translated.screens else "inventory",
1397
+ "sourceDerived": bool(translated.screens),
1398
+ "adapters": [adapter.id for adapter in registered_adapters()],
1399
+ "unsupported": translated.unsupported,
1400
+ },
1401
+ "tokens": translated.tokens,
1402
+ "themes": {
1403
+ "defaultThemeId": "light",
1404
+ "items": theme_items,
1405
+ },
1406
+ "componentCatalog": {
1407
+ "status": "inventory" if catalog_components else "ready",
1408
+ "enforce": bool(catalog_components),
1409
+ "components": catalog_components,
1410
+ },
1411
+ "review": {
1412
+ "sessionId": "initial-review",
1413
+ "baselineVersion": "baseline",
1414
+ "activeVersion": "baseline",
1415
+ "diagnostics": {
1416
+ "profiles": [
1417
+ {"id": "current", "label": "Current window", "viewport": "current", "zoomLevels": [0.2, 1, 2]},
1418
+ {"id": "desktop", "label": "Desktop", "viewport": {"width": 1440, "height": 900}, "zoomLevels": [0.5, 1, 2]},
1419
+ {"id": "compact", "label": "Compact", "viewport": {"width": 768, "height": 900}, "zoomLevels": [0.5, 1, 1.5]},
1420
+ ],
1421
+ "scenarios": [
1422
+ {"id": "zoom-reset", "label": "Zoom reset", "kind": "zoom-reset"},
1423
+ {"id": "overview-geometry", "label": "Overview geometry", "kind": "overview-geometry"},
1424
+ {"id": "menu-exclusivity", "label": "Menu exclusivity", "kind": "menu-exclusivity"},
1425
+ {"id": "layout-integrity", "label": "Layout integrity", "kind": "layout-integrity"},
1426
+ {"id": "accessibility-basics", "label": "Accessibility basics", "kind": "accessibility-basics"},
1427
+ ],
1428
+ },
1429
+ "versions": [
1430
+ {"id": "baseline", "label": "Before", "kind": "baseline", "status": "approved", "nodeOverrides": {}}
1431
+ ],
1432
+ "annotations": [],
1433
+ },
1434
+ "screens": screens,
1435
+ "nodes": nodes,
1436
+ "warnings": ["Starter IR is an inventory skeleton and must be enriched from prioritized source files before fidelity review."] + scan_result.get("warnings", []),
1437
+ }
1438
+ if translated.screens:
1439
+ ir["warnings"] = ([f"Adapters left {len(translated.unsupported)} source expressions explicitly unsupported."] if translated.unsupported else []) + scan_result.get("warnings", [])
1440
+ seal_baseline(ir)
1441
+ return ir
1442
+
1443
+
1444
+ def write_json(path: Path, data: dict[str, Any]) -> None:
1445
+ path.parent.mkdir(parents=True, exist_ok=True)
1446
+ payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
1447
+ descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
1448
+ try:
1449
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
1450
+ handle.write(payload)
1451
+ handle.flush()
1452
+ os.fsync(handle.fileno())
1453
+ os.replace(temporary, path)
1454
+ except BaseException:
1455
+ try:
1456
+ os.unlink(temporary)
1457
+ except OSError:
1458
+ pass
1459
+ raise
1460
+
1461
+
1462
+ def parse_args() -> argparse.Namespace:
1463
+ parser = argparse.ArgumentParser(description="Find UI source in a repository without running it.")
1464
+ parser.add_argument("repo", type=Path, help="Repository or project root to scan")
1465
+ parser.add_argument("--output", type=Path, required=True, help="Path for ui-scan.json")
1466
+ parser.add_argument("--starter-ir", type=Path, help="Optional path for a conservative starter ui-ir.json")
1467
+ return parser.parse_args()
1468
+
1469
+
1470
+ def main() -> int:
1471
+ args = parse_args()
1472
+ root = args.repo.resolve()
1473
+ if not root.is_dir():
1474
+ print(f"Repository directory does not exist: {root}", file=sys.stderr)
1475
+ return 2
1476
+ result = scan(root)
1477
+ write_json(args.output.resolve(), result)
1478
+ if args.starter_ir:
1479
+ write_json(args.starter_ir.resolve(), starter_ir(result))
1480
+ print(json.dumps(result["summary"], ensure_ascii=False))
1481
+ return 0
1482
+
1483
+
1484
+ if __name__ == "__main__":
1485
+ raise SystemExit(main())