@harmonyos-arkts/d2h 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/.claude-plugin/marketplace.json +15 -13
  2. package/.claude-plugin/plugin.json +13 -7
  3. package/AGENTS.md +5 -5
  4. package/README.md +212 -212
  5. package/agents/android-to-hmos-00-orchestrator.md +318 -318
  6. package/agents/d2h.md +157 -157
  7. package/dist/bin/install-opencode.js +30 -30
  8. package/docs/environment-check.md +86 -86
  9. package/install-opencode.sh +160 -160
  10. package/opencode/agents.json +16 -16
  11. package/opencode/vscode-d2h-package.json +7 -7
  12. package/opencode/vscode-d2h-plugin.mjs +29 -29
  13. package/package.json +54 -54
  14. package/schemas/android-source-manifest.schema.json +25 -25
  15. package/schemas/checkpoint-provenance.schema.json +67 -67
  16. package/schemas/final-acceptance.schema.json +29 -29
  17. package/schemas/managed-evidence-index.schema.json +15 -15
  18. package/schemas/managed-evidence.schema.json +114 -114
  19. package/schemas/migration-config.schema.json +51 -51
  20. package/schemas/migration-report-index.schema.json +39 -39
  21. package/schemas/migration-report-item.schema.json +51 -51
  22. package/schemas/migration-report-summary.schema.json +25 -25
  23. package/schemas/migration-status.schema.json +202 -202
  24. package/schemas/preflight.schema.json +60 -60
  25. package/schemas/source-order-audit.schema.json +16 -16
  26. package/schemas/source-provenance-event.schema.json +19 -19
  27. package/schemas/spec-app-shard.schema.json +45 -45
  28. package/schemas/spec-fact-corrections-shard.schema.json +13 -13
  29. package/schemas/spec-features-shard.schema.json +43 -43
  30. package/schemas/spec-index.schema.json +26 -26
  31. package/schemas/spec-interactions-shard.schema.json +40 -40
  32. package/schemas/spec-page.schema.json +98 -98
  33. package/schemas/spec-pages-index.schema.json +1 -1
  34. package/schemas/spec-unresolved-shard.schema.json +1 -1
  35. package/schemas/task-envelope.schema.json +109 -109
  36. package/schemas/task-plan.schema.json +341 -341
  37. package/scripts/check-environment.mjs +320 -320
  38. package/skills/android2hmos_resources_convert/SKILL.md +180 -180
  39. package/skills/android2hmos_resources_convert/references/image-conversion-rules.md +230 -230
  40. package/skills/android2hmos_resources_convert/references/svg-fix-patterns.md +175 -175
  41. package/skills/android2hmos_resources_convert/references/xml-drawable-to-svg-rules.md +513 -513
  42. package/skills/arkts-app-identity/SKILL.md +238 -238
  43. package/skills/arkts-i18n/SKILL.md +496 -496
  44. package/skills/arkts-i18n/evals/evals.json +84 -84
  45. package/skills/arkts-i18n/references/code-examples.md +302 -302
  46. package/skills/arkts-i18n/references/common-pitfalls.md +391 -391
  47. package/skills/arkts-i18n/references/dynamic-language-switch.md +604 -604
  48. package/skills/arkts-i18n/references/hardcoded-string-scanner.md +348 -348
  49. package/skills/arkts-i18n/references/language-codes.md +104 -104
  50. package/skills/arkts-i18n/references/resource-file-structure.md +775 -775
  51. package/skills/arkts-i18n/references/static-vs-dynamic.md +242 -242
  52. package/skills/arkts-i18n/references/v1-compat.md +244 -244
  53. package/skills/arkts-i18n/scripts/audit_i18n_completeness.sh +174 -174
  54. package/skills/arkts-icon-sizing/SKILL.md +211 -211
  55. package/skills/arkts-icon-sizing/scripts/icon_audit.py +131 -131
  56. package/skills/arkts-icon-sizing/scripts/icon_autofix.py +88 -88
  57. package/skills/arkts-icon-sizing/scripts/icon_dims.py +179 -179
  58. package/skills/arkts-icon-sizing/scripts/icon_fix.py +119 -119
  59. package/skills/arkts-mvvm-architecture/SKILL.md +613 -613
  60. package/skills/d2h-hypium-forge/SKILL.md +309 -309
  61. package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -140
  62. package/skills/d2h-hypium-forge/references/official-api.md +135 -135
  63. package/skills/d2h-hypium-forge/references/pitfalls.md +98 -98
  64. package/skills/d2h-hypium-forge/references/testability-template.md +71 -71
  65. package/skills/d2h-hypium-forge/scripts/_common_template.py +659 -659
  66. package/skills/d2h-hypium-forge/scripts/analyze_results.py +165 -165
  67. package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -107
  68. package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -169
  69. package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -325
  70. package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -114
  71. package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -80
  72. package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -60
  73. package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -249
  74. package/skills/d2h-hypium-forge/scripts/precondition.py +140 -140
  75. package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -232
  76. package/skills/d2h-hypium-forge/scripts/probe.py +197 -197
  77. package/skills/d2h-hypium-forge/scripts/run_direct.py +823 -823
  78. package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -121
  79. package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -255
  80. package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -66
  81. package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -84
  82. package/skills/d2h-page-develop/SKILL.md +112 -112
  83. package/skills/d2h-page-develop/agents/openai.yaml +7 -7
  84. package/skills/d2h-page-spec-generate/SKILL.md +206 -206
  85. package/skills/d2h-page-spec-generate/scripts/build_batch_report.py +227 -227
  86. package/skills/d2h-utg-ai-generate/SKILL.md +128 -128
  87. package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -76
  88. package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -34
  89. package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -96
  90. package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -61
  91. package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -89
  92. package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -314
  93. package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -123
  94. package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -280
  95. package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -524
  96. package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -489
  97. package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -69
  98. package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -13
  99. package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -2
  100. package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -369
  101. package/skills/d2h-utg-static-generate/SKILL.md +73 -73
  102. package/skills/d2h-utg-static-generate/references/discoveries.md +38 -38
  103. package/skills/d2h-utg-static-generate/scripts/generate.py +499 -499
  104. package/skills/d2h-utg-static-generate/tests/test_static.py +335 -335
  105. package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -60
  106. package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -131
  107. package/skills/harmonyos-migration-playbook/SKILL.md +83 -83
  108. package/skills/harmonyos-migration-playbook/agents/openai.yaml +7 -7
  109. package/skills/harmonyos-migration-playbook/references/arkts-compile.md +24 -24
  110. package/skills/harmonyos-migration-playbook/references/harmony-runtime.md +53 -53
  111. package/skills/harmonyos-migration-playbook/references/lesson-lifecycle.md +45 -45
  112. package/skills/harmonyos-migration-playbook/references/protocol-e2e.md +23 -23
  113. package/skills/harmonyos-migration-playbook/references/ui-automation.md +53 -53
  114. package/skills/harmonyos-migration-playbook/references/windows-environment.md +38 -38
  115. package/skills/maintaining-migration-report/SKILL.md +144 -144
  116. package/skills/maintaining-migration-report/references/final-acceptance.md +48 -48
  117. package/skills/maintaining-migration-report/references/source-provenance.md +66 -66
  118. package/skills/managing-d2h-toolchain/SKILL.md +24 -24
  119. package/skills/managing-d2h-toolchain/environment-requirements.json +16 -16
  120. package/skills/managing-d2h-toolchain/scripts/manage-toolchain.mjs +98 -98
  121. package/skills/managing-d2h-toolchain/toolchain.json +16 -16
  122. package/skills/native-library-substitution/SKILL.md +384 -384
  123. package/skills/native-library-substitution/references/native-library-substitution.json +56905 -56905
  124. package/skills/native-library-substitution/references/native-library-substitution.md +163 -163
  125. package/skills/preparing-migration-workspace/SKILL.md +131 -131
  126. package/skills/reviewing-migration-process/SKILL.md +62 -62
@@ -1,524 +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
- }
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
+ }