@harmonyos-arkts/d2h 0.1.3 → 0.1.6
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.
- package/README.md +78 -44
- package/agents/android-to-hmos-00-orchestrator.md +34 -28
- package/agents/d2h.md +266 -95
- package/dist/bin/install-opencode.js +137 -43
- package/dist/src/install-opencode.js +44 -0
- package/dist/src/install-vscode.js +226 -0
- package/dist/src/toolchain.js +87 -0
- package/docs/environment-check.md +86 -0
- package/opencode/agents.json +1 -1
- package/opencode/vscode-d2h-package.json +7 -0
- package/opencode/vscode-d2h-plugin.mjs +29 -0
- package/package.json +6 -4
- package/schemas/task-envelope.schema.json +58 -4
- package/schemas/task-plan.schema.json +281 -63
- package/scripts/check-environment.mjs +321 -0
- package/skills/d2h-hypium-forge/SKILL.md +252 -0
- package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -0
- package/skills/d2h-hypium-forge/references/official-api.md +135 -0
- package/skills/d2h-hypium-forge/references/pitfalls.md +98 -0
- package/skills/d2h-hypium-forge/references/testability-template.md +71 -0
- package/skills/d2h-hypium-forge/scripts/_common_template.py +614 -0
- package/skills/d2h-hypium-forge/scripts/analyze_results.py +156 -0
- package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -0
- package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -0
- package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -0
- package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -0
- package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -0
- package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -0
- package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -0
- package/skills/d2h-hypium-forge/scripts/precondition.py +140 -0
- package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -0
- package/skills/d2h-hypium-forge/scripts/run_direct.py +392 -0
- package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -0
- package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -0
- package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -0
- package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -0
- package/skills/d2h-utg-ai-generate/SKILL.md +128 -0
- package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -0
- package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -0
- package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -0
- package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -0
- package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -0
- package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -0
- package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -0
- package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -0
- package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -0
- package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -0
- package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -0
- package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -0
- package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -0
- package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -0
- package/skills/d2h-utg-static-generate/SKILL.md +73 -0
- package/skills/d2h-utg-static-generate/references/discoveries.md +38 -0
- package/skills/d2h-utg-static-generate/scripts/generate.py +499 -0
- package/skills/d2h-utg-static-generate/tests/test_static.py +335 -0
- package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -0
- package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -0
- package/skills/harmonyos-migration-playbook/references/ui-automation.md +4 -3
- package/skills/harmonyos-migration-playbook/references/windows-environment.md +1 -1
- package/skills/managing-d2h-toolchain/SKILL.md +23 -0
- package/skills/managing-d2h-toolchain/environment-requirements.json +16 -0
- package/skills/managing-d2h-toolchain/toolchain.json +16 -0
- package/skills/preparing-migration-workspace/SKILL.md +9 -7
- package/agents/d2h_v2.md +0 -351
- package/skills/preparing-migration-workspace/toolchain.json +0 -19
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import heapq
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections import Counter, defaultdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
LOCATION_RE = re.compile(r"^(?P<path>.+):(?P<line>[1-9][0-9]*)$")
|
|
14
|
+
CONFIDENCE_NAMES = {"high": 0.9, "medium": 0.6, "low": 0.3}
|
|
15
|
+
ENTRY_MECHANISMS = {"deep-link", "app-link", "universal-link"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class UtgError(ValueError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_json(path: Path) -> dict[str, Any]:
|
|
23
|
+
try:
|
|
24
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
25
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
26
|
+
raise UtgError(f"cannot read JSON {path}: {exc}") from exc
|
|
27
|
+
if not isinstance(value, dict):
|
|
28
|
+
raise UtgError(f"JSON root must be an object: {path}")
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def atomic_write_json(path: Path, value: Any) -> None:
|
|
33
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
35
|
+
try:
|
|
36
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
37
|
+
json.dump(value, handle, ensure_ascii=False, indent=2)
|
|
38
|
+
handle.write("\n")
|
|
39
|
+
os.replace(temp_name, path)
|
|
40
|
+
except Exception:
|
|
41
|
+
try:
|
|
42
|
+
os.unlink(temp_name)
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
pass
|
|
45
|
+
raise
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _required_string(obj: dict[str, Any], key: str, where: str) -> str:
|
|
49
|
+
value = obj.get(key)
|
|
50
|
+
if not isinstance(value, str) or not value.strip():
|
|
51
|
+
raise UtgError(f"{where}.{key} must be a non-empty string")
|
|
52
|
+
return value.strip()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def normalize_location(value: Any, project_root: Path, where: str) -> str:
|
|
56
|
+
if not isinstance(value, str):
|
|
57
|
+
raise UtgError(f"{where} must be a project-relative file:line string")
|
|
58
|
+
match = LOCATION_RE.match(value.strip())
|
|
59
|
+
if not match:
|
|
60
|
+
raise UtgError(f"{where} must end in :<positive-line>: {value!r}")
|
|
61
|
+
source = Path(match.group("path"))
|
|
62
|
+
line = int(match.group("line"))
|
|
63
|
+
root = project_root.resolve()
|
|
64
|
+
full = source.resolve() if source.is_absolute() else (root / source).resolve()
|
|
65
|
+
try:
|
|
66
|
+
relative = full.relative_to(root)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
raise UtgError(f"{where} escapes project root: {value!r}") from exc
|
|
69
|
+
if not full.is_file():
|
|
70
|
+
raise UtgError(f"{where} source file does not exist: {relative.as_posix()}")
|
|
71
|
+
with full.open("r", encoding="utf-8", errors="replace") as handle:
|
|
72
|
+
line_count = sum(1 for _ in handle)
|
|
73
|
+
if line > line_count:
|
|
74
|
+
raise UtgError(
|
|
75
|
+
f"{where} line {line} exceeds {relative.as_posix()} line count {line_count}"
|
|
76
|
+
)
|
|
77
|
+
return f"{relative.as_posix()}:{line}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def normalize_confidence(value: Any, where: str, normalizations: list[str]) -> float:
|
|
81
|
+
if isinstance(value, bool):
|
|
82
|
+
raise UtgError(f"{where} must be a number in [0, 1]")
|
|
83
|
+
if isinstance(value, str) and value.lower() in CONFIDENCE_NAMES:
|
|
84
|
+
normalized = CONFIDENCE_NAMES[value.lower()]
|
|
85
|
+
normalizations.append(f"{where}: {value!r} -> {normalized}")
|
|
86
|
+
return normalized
|
|
87
|
+
if not isinstance(value, (int, float)) or not 0 <= float(value) <= 1:
|
|
88
|
+
raise UtgError(f"{where} must be a number in [0, 1]")
|
|
89
|
+
return float(value)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def normalize_facts(
|
|
93
|
+
facts: dict[str, Any], project_root: Path
|
|
94
|
+
) -> tuple[dict[str, Any], list[str], list[dict[str, Any]]]:
|
|
95
|
+
if facts.get("schema_version") != "d2h-utg-facts/v1":
|
|
96
|
+
raise UtgError("schema_version must be 'd2h-utg-facts/v1'")
|
|
97
|
+
graph_in = facts.get("graph")
|
|
98
|
+
if not isinstance(graph_in, dict):
|
|
99
|
+
raise UtgError("graph must be an object")
|
|
100
|
+
graph = dict(graph_in)
|
|
101
|
+
for key in ("app", "architecture", "root"):
|
|
102
|
+
graph[key] = _required_string(graph, key, "graph")
|
|
103
|
+
for key in ("package", "start_destination"):
|
|
104
|
+
if key in graph and not isinstance(graph[key], str):
|
|
105
|
+
raise UtgError(f"graph.{key} must be a string")
|
|
106
|
+
|
|
107
|
+
node_inputs = facts.get("nodes")
|
|
108
|
+
edge_inputs = facts.get("edges")
|
|
109
|
+
unresolved = facts.get("unresolved", [])
|
|
110
|
+
if not isinstance(node_inputs, list) or not node_inputs:
|
|
111
|
+
raise UtgError("nodes must be a non-empty array")
|
|
112
|
+
if not isinstance(edge_inputs, list):
|
|
113
|
+
raise UtgError("edges must be an array")
|
|
114
|
+
if not isinstance(unresolved, list):
|
|
115
|
+
raise UtgError("unresolved must be an array")
|
|
116
|
+
|
|
117
|
+
nodes: list[dict[str, Any]] = []
|
|
118
|
+
ids: set[str] = set()
|
|
119
|
+
normalizations: list[str] = []
|
|
120
|
+
for index, raw in enumerate(node_inputs):
|
|
121
|
+
where = f"nodes[{index}]"
|
|
122
|
+
if not isinstance(raw, dict):
|
|
123
|
+
raise UtgError(f"{where} must be an object")
|
|
124
|
+
node_id = _required_string(raw, "id", where)
|
|
125
|
+
if node_id in ids:
|
|
126
|
+
raise UtgError(f"duplicate node id: {node_id}")
|
|
127
|
+
ids.add(node_id)
|
|
128
|
+
node = {
|
|
129
|
+
"id": node_id,
|
|
130
|
+
"kind": _required_string(raw, "kind", where),
|
|
131
|
+
"label": _required_string(raw, "label", where),
|
|
132
|
+
"declared_at": normalize_location(
|
|
133
|
+
raw.get("declared_at"), project_root, f"{where}.declared_at"
|
|
134
|
+
),
|
|
135
|
+
"status": raw.get("status", "static-only"),
|
|
136
|
+
"provenance": raw.get("provenance", "ai-source-analysis"),
|
|
137
|
+
"widgets": raw.get("widgets", []),
|
|
138
|
+
"expected": raw.get("expected", {}),
|
|
139
|
+
"required_state": raw.get("required_state", []),
|
|
140
|
+
"extra": raw.get("extra"),
|
|
141
|
+
}
|
|
142
|
+
if not isinstance(node["status"], str) or not node["status"]:
|
|
143
|
+
raise UtgError(f"{where}.status must be a non-empty string")
|
|
144
|
+
if not isinstance(node["provenance"], str) or not node["provenance"]:
|
|
145
|
+
raise UtgError(f"{where}.provenance must be a non-empty string")
|
|
146
|
+
if not isinstance(node["widgets"], list):
|
|
147
|
+
raise UtgError(f"{where}.widgets must be an array")
|
|
148
|
+
if not isinstance(node["expected"], dict):
|
|
149
|
+
raise UtgError(f"{where}.expected must be an object")
|
|
150
|
+
if not isinstance(node["required_state"], list) or not all(
|
|
151
|
+
isinstance(item, str) and item for item in node["required_state"]
|
|
152
|
+
):
|
|
153
|
+
raise UtgError(f"{where}.required_state must be an array of strings")
|
|
154
|
+
if len(set(node["required_state"])) != len(node["required_state"]):
|
|
155
|
+
raise UtgError(f"{where}.required_state contains duplicates")
|
|
156
|
+
if node["extra"] is not None and not isinstance(node["extra"], dict):
|
|
157
|
+
raise UtgError(f"{where}.extra must be null or an object")
|
|
158
|
+
nodes.append(node)
|
|
159
|
+
|
|
160
|
+
if graph["root"] not in ids:
|
|
161
|
+
raise UtgError(f"graph.root references missing node: {graph['root']}")
|
|
162
|
+
|
|
163
|
+
links: list[dict[str, Any]] = []
|
|
164
|
+
keys: defaultdict[tuple[str, str], int] = defaultdict(int)
|
|
165
|
+
for index, raw in enumerate(edge_inputs):
|
|
166
|
+
where = f"edges[{index}]"
|
|
167
|
+
if not isinstance(raw, dict):
|
|
168
|
+
raise UtgError(f"{where} must be an object")
|
|
169
|
+
source = _required_string(raw, "source", where)
|
|
170
|
+
target = _required_string(raw, "target", where)
|
|
171
|
+
if source not in ids or target not in ids:
|
|
172
|
+
raise UtgError(f"{where} references missing node: {source} -> {target}")
|
|
173
|
+
guard = raw.get("guard")
|
|
174
|
+
if guard is not None:
|
|
175
|
+
if not isinstance(guard, dict):
|
|
176
|
+
raise UtgError(f"{where}.guard must be null or an object")
|
|
177
|
+
_required_string(guard, "kind", f"{where}.guard")
|
|
178
|
+
_required_string(guard, "text", f"{where}.guard")
|
|
179
|
+
args = raw.get("args", {})
|
|
180
|
+
if not isinstance(args, dict):
|
|
181
|
+
raise UtgError(f"{where}.args must be an object")
|
|
182
|
+
if "label_ambiguous" in args and not isinstance(args["label_ambiguous"], bool):
|
|
183
|
+
raise UtgError(f"{where}.args.label_ambiguous must be boolean")
|
|
184
|
+
pair = (source, target)
|
|
185
|
+
link = {
|
|
186
|
+
"source": source,
|
|
187
|
+
"target": target,
|
|
188
|
+
"mechanism": _required_string(raw, "mechanism", where),
|
|
189
|
+
"trigger": normalize_location(
|
|
190
|
+
raw.get("trigger"), project_root, f"{where}.trigger"
|
|
191
|
+
),
|
|
192
|
+
"guard": guard,
|
|
193
|
+
"args": args,
|
|
194
|
+
"confidence": normalize_confidence(
|
|
195
|
+
raw.get("confidence"), f"{where}.confidence", normalizations
|
|
196
|
+
),
|
|
197
|
+
"provenance": raw.get("provenance", "ai-source-analysis"),
|
|
198
|
+
"key": keys[pair],
|
|
199
|
+
"status": raw.get("status", "static-only"),
|
|
200
|
+
}
|
|
201
|
+
keys[pair] += 1
|
|
202
|
+
if "id" in raw:
|
|
203
|
+
link["id"] = _required_string(raw, "id", where)
|
|
204
|
+
if not isinstance(link["provenance"], str) or not link["provenance"]:
|
|
205
|
+
raise UtgError(f"{where}.provenance must be a non-empty string")
|
|
206
|
+
if not isinstance(link["status"], str) or not link["status"]:
|
|
207
|
+
raise UtgError(f"{where}.status must be a non-empty string")
|
|
208
|
+
links.append(link)
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
"directed": True,
|
|
212
|
+
"multigraph": True,
|
|
213
|
+
"graph": graph,
|
|
214
|
+
"nodes": nodes,
|
|
215
|
+
"links": links,
|
|
216
|
+
}, normalizations, unresolved
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def replay_action_reason(step: dict[str, Any]) -> str | None:
|
|
220
|
+
"""Shared executable-operand checks for path selection and page planning."""
|
|
221
|
+
action = step.get('action', 'tap')
|
|
222
|
+
selector = step.get('selector') or {}
|
|
223
|
+
ready = isinstance(selector, dict) and any(
|
|
224
|
+
(isinstance(selector.get(key), str) and bool(selector[key]))
|
|
225
|
+
or (isinstance(selector.get(key), list) and bool(selector[key])
|
|
226
|
+
and all(isinstance(value, str) and value for value in selector[key]))
|
|
227
|
+
for key in ('text', 'text_any', 'content_desc', 'content_desc_any', 'resource_id', 'resource_id_any'))
|
|
228
|
+
if action in {'tap', 'long-press'} and not ready and not (
|
|
229
|
+
step.get('label') and step.get('label_source') in {'string', 'content-description', 'system'}
|
|
230
|
+
and not step.get('label_ambiguous')):
|
|
231
|
+
return 'missing-executable-selector'
|
|
232
|
+
if action not in {'launch', 'start-activity', 'deep-link', 'home', 'back', 'default', 'tap', 'swipe', 'long-press'}:
|
|
233
|
+
return 'unsupported-replay-action'
|
|
234
|
+
if action == 'start-activity' and not re.fullmatch(r'[\w.]+/[\w.$]+', step.get('component', '')):
|
|
235
|
+
return 'missing-launch-component'
|
|
236
|
+
if action == 'deep-link' and not re.match(r'^[a-z][a-z0-9+.-]*:', step.get('label') or '', re.I):
|
|
237
|
+
return 'missing-entry-uri'
|
|
238
|
+
if action == 'swipe' and not step.get('direction'):
|
|
239
|
+
return 'missing-swipe-direction'
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _shortest_paths(
|
|
244
|
+
root: str, links: list[dict[str, Any]], allow_entry_edges: bool, prefer_executable: bool = False
|
|
245
|
+
) -> dict[str, tuple[str, dict[str, Any]]]:
|
|
246
|
+
adjacency: defaultdict[str, list[tuple[int, dict[str, Any]]]] = defaultdict(list)
|
|
247
|
+
for index, edge in enumerate(links):
|
|
248
|
+
if edge["mechanism"] in ENTRY_MECHANISMS and not allow_entry_edges:
|
|
249
|
+
continue
|
|
250
|
+
adjacency[edge["source"]].append((index, edge))
|
|
251
|
+
|
|
252
|
+
# Runtime replay must prefer a universally executable path over a shorter
|
|
253
|
+
# guarded path. Keeping guards first also preserves the guarded edge in
|
|
254
|
+
# the graph instead of forcing callers to delete a true source fact merely
|
|
255
|
+
# to obtain a replayable click path.
|
|
256
|
+
best = {root: (0, 0, 0, 0, ())}
|
|
257
|
+
previous: dict[str, tuple[str, dict[str, Any]]] = {}
|
|
258
|
+
queue = [(0, 0, 0, 0, (), root)]
|
|
259
|
+
while queue:
|
|
260
|
+
unready, guarded, entries, hops, order_path, source = heapq.heappop(queue)
|
|
261
|
+
if best.get(source) != (unready, guarded, entries, hops, order_path):
|
|
262
|
+
continue
|
|
263
|
+
for index, edge in adjacency.get(source, []):
|
|
264
|
+
target = edge["target"]
|
|
265
|
+
candidate = (
|
|
266
|
+
unready + (int(replay_action_reason(edge.get('args') or {}) is not None) if prefer_executable else 0),
|
|
267
|
+
guarded + (1 if edge.get("guard") else 0),
|
|
268
|
+
entries + (int(edge['mechanism'] in ENTRY_MECHANISMS) if prefer_executable else 0),
|
|
269
|
+
hops + 1,
|
|
270
|
+
order_path + (index,),
|
|
271
|
+
)
|
|
272
|
+
if target not in best or candidate < best[target]:
|
|
273
|
+
best[target] = candidate
|
|
274
|
+
previous[target] = (source, edge)
|
|
275
|
+
heapq.heappush(queue, (*candidate, target))
|
|
276
|
+
return previous
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _reconstruct(
|
|
280
|
+
target: str, root: str, previous: dict[str, tuple[str, dict[str, Any]]]
|
|
281
|
+
) -> list[dict[str, Any]] | None:
|
|
282
|
+
if target == root:
|
|
283
|
+
return []
|
|
284
|
+
edges: list[dict[str, Any]] = []
|
|
285
|
+
current = target
|
|
286
|
+
seen: set[str] = set()
|
|
287
|
+
while current != root:
|
|
288
|
+
if current in seen or current not in previous:
|
|
289
|
+
return None
|
|
290
|
+
seen.add(current)
|
|
291
|
+
source, edge = previous[current]
|
|
292
|
+
edges.append(edge)
|
|
293
|
+
current = source
|
|
294
|
+
edges.reverse()
|
|
295
|
+
return edges
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def build_click_paths(utg: dict[str, Any]) -> dict[str, Any]:
|
|
299
|
+
root = utg["graph"]["root"]
|
|
300
|
+
kinds = {node["id"]: node["kind"] for node in utg["nodes"]}
|
|
301
|
+
prefer_executable = utg['graph'].get('path_policy') == 'source-executable-first'
|
|
302
|
+
normal = _shortest_paths(root, utg["links"], allow_entry_edges=prefer_executable, prefer_executable=prefer_executable)
|
|
303
|
+
with_entries = _shortest_paths(root, utg["links"], allow_entry_edges=True, prefer_executable=prefer_executable)
|
|
304
|
+
paths: dict[str, Any] = {}
|
|
305
|
+
unreachable: list[str] = []
|
|
306
|
+
|
|
307
|
+
for node in utg["nodes"]:
|
|
308
|
+
node_id = node["id"]
|
|
309
|
+
edges = _reconstruct(node_id, root, normal)
|
|
310
|
+
if edges is None:
|
|
311
|
+
edges = _reconstruct(node_id, root, with_entries)
|
|
312
|
+
if edges is None:
|
|
313
|
+
unreachable.append(node_id)
|
|
314
|
+
continue
|
|
315
|
+
steps: list[dict[str, Any]] = [
|
|
316
|
+
{
|
|
317
|
+
"action": "launch",
|
|
318
|
+
"label": None,
|
|
319
|
+
"target_id": root,
|
|
320
|
+
"target_kind": kinds[root],
|
|
321
|
+
"guard": None,
|
|
322
|
+
}
|
|
323
|
+
]
|
|
324
|
+
labels: list[str] = []
|
|
325
|
+
for edge in edges:
|
|
326
|
+
args = edge.get("args") or {}
|
|
327
|
+
label = args.get("label")
|
|
328
|
+
step: dict[str, Any] = {
|
|
329
|
+
"action": args.get("action", "tap"),
|
|
330
|
+
"label": label if isinstance(label, str) else None,
|
|
331
|
+
"label_source": args.get("label_source"),
|
|
332
|
+
"label_ambiguous": bool(args.get("label_ambiguous", False)),
|
|
333
|
+
"target_id": edge["target"],
|
|
334
|
+
"target_kind": kinds[edge["target"]],
|
|
335
|
+
"guard": edge.get("guard"),
|
|
336
|
+
"mechanism": edge["mechanism"],
|
|
337
|
+
}
|
|
338
|
+
if isinstance(args.get("nl_hint"), str) and args["nl_hint"]:
|
|
339
|
+
step["nl_hint"] = args["nl_hint"]
|
|
340
|
+
for key in ("selector", "direction", "distance", "duration_ms", "max_scrolls", "component"):
|
|
341
|
+
if key in args:
|
|
342
|
+
step[key] = args[key]
|
|
343
|
+
steps.append(step)
|
|
344
|
+
if isinstance(label, str) and label:
|
|
345
|
+
labels.append(label)
|
|
346
|
+
depth = len(edges)
|
|
347
|
+
paths[node_id] = {
|
|
348
|
+
"depth": depth,
|
|
349
|
+
"steps": steps,
|
|
350
|
+
"labels": labels,
|
|
351
|
+
"clicks": depth,
|
|
352
|
+
"kind": kinds[node_id],
|
|
353
|
+
"path_text": " > ".join(labels) if labels else node_id,
|
|
354
|
+
}
|
|
355
|
+
return {"root": root, "paths": paths, "unreachable": unreachable}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def validate_pair(
|
|
359
|
+
utg: dict[str, Any], click_paths: dict[str, Any], project_root: Path | None = None
|
|
360
|
+
) -> list[str]:
|
|
361
|
+
errors: list[str] = []
|
|
362
|
+
if utg.get("directed") is not True:
|
|
363
|
+
errors.append("utg.directed must be true")
|
|
364
|
+
if utg.get("multigraph") is not True:
|
|
365
|
+
errors.append("utg.multigraph must be true")
|
|
366
|
+
graph = utg.get("graph")
|
|
367
|
+
nodes = utg.get("nodes")
|
|
368
|
+
links = utg.get("links")
|
|
369
|
+
if not isinstance(graph, dict):
|
|
370
|
+
return errors + ["utg.graph must be an object"]
|
|
371
|
+
if not isinstance(nodes, list) or not nodes:
|
|
372
|
+
return errors + ["utg.nodes must be a non-empty array"]
|
|
373
|
+
if not isinstance(links, list):
|
|
374
|
+
return errors + ["utg.links must be an array"]
|
|
375
|
+
|
|
376
|
+
ids: dict[str, dict[str, Any]] = {}
|
|
377
|
+
for index, node in enumerate(nodes):
|
|
378
|
+
where = f"nodes[{index}]"
|
|
379
|
+
if not isinstance(node, dict):
|
|
380
|
+
errors.append(f"{where} must be an object")
|
|
381
|
+
continue
|
|
382
|
+
node_id = node.get("id")
|
|
383
|
+
if not isinstance(node_id, str) or not node_id:
|
|
384
|
+
errors.append(f"{where}.id must be a non-empty string")
|
|
385
|
+
continue
|
|
386
|
+
if node_id in ids:
|
|
387
|
+
errors.append(f"duplicate node id: {node_id}")
|
|
388
|
+
ids[node_id] = node
|
|
389
|
+
for key, expected in (
|
|
390
|
+
("kind", str), ("label", str), ("declared_at", str),
|
|
391
|
+
("status", str), ("provenance", str), ("widgets", list),
|
|
392
|
+
("expected", dict), ("required_state", list),
|
|
393
|
+
):
|
|
394
|
+
if not isinstance(node.get(key), expected):
|
|
395
|
+
errors.append(f"{where}.{key} has wrong type")
|
|
396
|
+
if node.get("extra") is not None and not isinstance(node.get("extra"), dict):
|
|
397
|
+
errors.append(f"{where}.extra must be null or an object")
|
|
398
|
+
if project_root and isinstance(node.get("declared_at"), str):
|
|
399
|
+
try:
|
|
400
|
+
normalize_location(node["declared_at"], project_root, f"{where}.declared_at")
|
|
401
|
+
except UtgError as exc:
|
|
402
|
+
errors.append(str(exc))
|
|
403
|
+
|
|
404
|
+
root = graph.get("root")
|
|
405
|
+
if root not in ids:
|
|
406
|
+
errors.append(f"graph.root references missing node: {root!r}")
|
|
407
|
+
|
|
408
|
+
seen_keys: set[tuple[str, str, int]] = set()
|
|
409
|
+
edge_index: set[tuple[str, str, str]] = set()
|
|
410
|
+
for index, edge in enumerate(links):
|
|
411
|
+
where = f"links[{index}]"
|
|
412
|
+
if not isinstance(edge, dict):
|
|
413
|
+
errors.append(f"{where} must be an object")
|
|
414
|
+
continue
|
|
415
|
+
source, target = edge.get("source"), edge.get("target")
|
|
416
|
+
if source not in ids or target not in ids:
|
|
417
|
+
errors.append(f"{where} references missing node: {source!r} -> {target!r}")
|
|
418
|
+
mechanism = edge.get("mechanism")
|
|
419
|
+
if not isinstance(mechanism, str) or not mechanism:
|
|
420
|
+
errors.append(f"{where}.mechanism must be a non-empty string")
|
|
421
|
+
else:
|
|
422
|
+
edge_index.add((source, target, mechanism))
|
|
423
|
+
confidence = edge.get("confidence")
|
|
424
|
+
if isinstance(confidence, bool) or not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1:
|
|
425
|
+
errors.append(f"{where}.confidence must be numeric in [0, 1]")
|
|
426
|
+
key = edge.get("key")
|
|
427
|
+
if not isinstance(key, int) or isinstance(key, bool) or key < 0:
|
|
428
|
+
errors.append(f"{where}.key must be a non-negative integer")
|
|
429
|
+
elif isinstance(source, str) and isinstance(target, str):
|
|
430
|
+
identity = (source, target, key)
|
|
431
|
+
if identity in seen_keys:
|
|
432
|
+
errors.append(f"duplicate parallel-edge identity: {identity}")
|
|
433
|
+
seen_keys.add(identity)
|
|
434
|
+
if not isinstance(edge.get("args"), dict):
|
|
435
|
+
errors.append(f"{where}.args must be an object")
|
|
436
|
+
guard = edge.get("guard")
|
|
437
|
+
if guard is not None and (
|
|
438
|
+
not isinstance(guard, dict)
|
|
439
|
+
or not isinstance(guard.get("kind"), str)
|
|
440
|
+
or not guard.get("kind")
|
|
441
|
+
or not isinstance(guard.get("text"), str)
|
|
442
|
+
or not guard.get("text")
|
|
443
|
+
):
|
|
444
|
+
errors.append(f"{where}.guard must be null or contain kind/text")
|
|
445
|
+
if project_root and isinstance(edge.get("trigger"), str):
|
|
446
|
+
try:
|
|
447
|
+
normalize_location(edge["trigger"], project_root, f"{where}.trigger")
|
|
448
|
+
except UtgError as exc:
|
|
449
|
+
errors.append(str(exc))
|
|
450
|
+
|
|
451
|
+
if not isinstance(click_paths, dict):
|
|
452
|
+
return errors + ["click_paths root must be an object"]
|
|
453
|
+
if click_paths.get("root") != root:
|
|
454
|
+
errors.append("click_paths.root must equal utg.graph.root")
|
|
455
|
+
paths = click_paths.get("paths")
|
|
456
|
+
unreachable = click_paths.get("unreachable")
|
|
457
|
+
if not isinstance(paths, dict):
|
|
458
|
+
return errors + ["click_paths.paths must be an object"]
|
|
459
|
+
if not isinstance(unreachable, list) or not all(isinstance(item, str) for item in unreachable):
|
|
460
|
+
return errors + ["click_paths.unreachable must be an array of node ids"]
|
|
461
|
+
if len(set(unreachable)) != len(unreachable):
|
|
462
|
+
errors.append("click_paths.unreachable contains duplicates")
|
|
463
|
+
covered = set(paths) | set(unreachable)
|
|
464
|
+
if covered != set(ids):
|
|
465
|
+
errors.append(
|
|
466
|
+
"paths and unreachable must partition all nodes; "
|
|
467
|
+
f"missing={sorted(set(ids) - covered)}, extra={sorted(covered - set(ids))}"
|
|
468
|
+
)
|
|
469
|
+
if set(paths) & set(unreachable):
|
|
470
|
+
errors.append("a node cannot be both reachable and unreachable")
|
|
471
|
+
|
|
472
|
+
for node_id, path in paths.items():
|
|
473
|
+
where = f"paths[{node_id!r}]"
|
|
474
|
+
if node_id not in ids or not isinstance(path, dict):
|
|
475
|
+
continue
|
|
476
|
+
steps = path.get("steps")
|
|
477
|
+
labels = path.get("labels")
|
|
478
|
+
if not isinstance(steps, list) or not steps:
|
|
479
|
+
errors.append(f"{where}.steps must be a non-empty array")
|
|
480
|
+
continue
|
|
481
|
+
if steps[0].get("target_id") != root or steps[0].get("action") != "launch":
|
|
482
|
+
errors.append(f"{where} must start with launch to root")
|
|
483
|
+
if steps[-1].get("target_id") != node_id:
|
|
484
|
+
errors.append(f"{where} final step must target {node_id}")
|
|
485
|
+
depth = len(steps) - 1
|
|
486
|
+
if path.get("depth") != depth:
|
|
487
|
+
errors.append(f"{where}.depth must equal len(steps)-1")
|
|
488
|
+
if path.get("clicks") != depth:
|
|
489
|
+
errors.append(f"{where}.clicks must equal depth")
|
|
490
|
+
if path.get("kind") != ids[node_id].get("kind"):
|
|
491
|
+
errors.append(f"{where}.kind does not match node kind")
|
|
492
|
+
derived_labels = [
|
|
493
|
+
step.get("label") for step in steps[1:]
|
|
494
|
+
if isinstance(step.get("label"), str) and step.get("label")
|
|
495
|
+
]
|
|
496
|
+
if labels != derived_labels:
|
|
497
|
+
errors.append(f"{where}.labels are not derived from steps")
|
|
498
|
+
expected_text = " > ".join(derived_labels) if derived_labels else node_id
|
|
499
|
+
if path.get("path_text") != expected_text:
|
|
500
|
+
errors.append(f"{where}.path_text is not derived from labels")
|
|
501
|
+
current = root
|
|
502
|
+
for step_index, step in enumerate(steps[1:], start=1):
|
|
503
|
+
target = step.get("target_id")
|
|
504
|
+
mechanism = step.get("mechanism")
|
|
505
|
+
if (current, target, mechanism) not in edge_index:
|
|
506
|
+
errors.append(
|
|
507
|
+
f"{where}.steps[{step_index}] has no matching UTG edge: "
|
|
508
|
+
f"{current!r} -> {target!r} via {mechanism!r}"
|
|
509
|
+
)
|
|
510
|
+
current = target
|
|
511
|
+
return errors
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def summary(utg: dict[str, Any], click_paths: dict[str, Any]) -> dict[str, Any]:
|
|
515
|
+
return {
|
|
516
|
+
"nodes": len(utg["nodes"]),
|
|
517
|
+
"nodes_by_kind": dict(sorted(Counter(n["kind"] for n in utg["nodes"]).items())),
|
|
518
|
+
"edges": len(utg["links"]),
|
|
519
|
+
"edges_by_mechanism": dict(
|
|
520
|
+
sorted(Counter(e["mechanism"] for e in utg["links"]).items())
|
|
521
|
+
),
|
|
522
|
+
"reachable": len(click_paths["paths"]),
|
|
523
|
+
"unreachable": len(click_paths["unreachable"]),
|
|
524
|
+
}
|