ui-design-workbench-cli 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- android_resource_resolver.py +370 -0
- android_xml_support.py +230 -0
- apple_resource_resolver.py +141 -0
- coverage_report.py +134 -0
- fidelity_adapter_api.py +89 -0
- fidelity_adapters.py +508 -0
- fidelity_core.py +241 -0
- fidelity_platform_adapters.py +902 -0
- ir_contracts.py +76 -0
- native_render_android.py +160 -0
- native_render_apple.py +165 -0
- native_render_contracts.py +148 -0
- native_render_registry.py +86 -0
- quality_common.py +217 -0
- render_preview.py +1797 -0
- scan_ui.py +1485 -0
- scoped_context.py +458 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/SKILL.md +52 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/adapter-api.md +20 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/agent-integrations.md +39 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/cache-protocol.md +61 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/design-modes.md +109 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/fidelity.md +53 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ir-schema.md +412 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/native-rendering.md +40 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platform-profiles.json +84 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platform-standards.md +131 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/platforms.md +40 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/quality-automation.md +77 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/review-workflow.md +135 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-guidance-mode.md +22 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-policy-schema.md +83 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/ui-reviewer.md +175 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/validation.md +34 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/references/workbench-ui.md +108 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/native-render-state.schema.json +71 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-agent-job.schema.json +36 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-graph.schema.json +46 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-ir.patch.schema.json +36 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/ui-ir.schema.json +296 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/schemas/uidw-config.schema.json +68 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/android_resource_resolver.py +370 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/android_xml_support.py +230 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/apple_resource_resolver.py +141 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/coverage_report.py +134 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_adapter_api.py +89 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_adapters.py +508 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_core.py +241 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/fidelity_platform_adapters.py +902 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/generate_interaction_matrix.py +166 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/ir_contracts.py +76 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/merge_review_state.py +267 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_android.py +160 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_apple.py +165 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_contracts.py +148 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/native_render_registry.py +86 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/quality_common.py +217 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/render_preview.py +1797 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/scan_ui.py +1485 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/scoped_context.py +458 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/smoke_preview.js +978 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/uidw.py +2821 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/uidw_mcp.py +282 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/validate_platform_profiles.py +93 -0
- ui_design_workbench_cli-0.5.0.data/data/share/ui-design-workbench/scripts/visual_regression.py +141 -0
- ui_design_workbench_cli-0.5.0.dist-info/METADATA +133 -0
- ui_design_workbench_cli-0.5.0.dist-info/RECORD +73 -0
- ui_design_workbench_cli-0.5.0.dist-info/WHEEL +5 -0
- ui_design_workbench_cli-0.5.0.dist-info/entry_points.txt +3 -0
- ui_design_workbench_cli-0.5.0.dist-info/top_level.txt +20 -0
- uidw.py +2821 -0
- uidw_mcp.py +282 -0
- validate_platform_profiles.py +93 -0
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())
|