@harmonyos-arkts/d2h 0.1.11 → 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,314 +1,314 @@
1
- #!/usr/bin/env python3
2
- """Build and validate a deterministic runtime capture plan from a UTG pair.
3
-
4
- `page.json` is deliberately produced before any device work. It is the bridge
5
- between source-derived reachability (`utg.json` + `click_paths.json`) and the
6
- standalone ADB replay script that captures screenshots and UIAutomator trees.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import argparse
12
- import hashlib
13
- import re
14
- import sys
15
- from pathlib import Path
16
- from typing import Any
17
-
18
- from utg_common import UtgError, atomic_write_json, load_json, validate_pair, replay_action_reason
19
-
20
-
21
- SCHEMA_VERSION = "d2h-page-plan/v1"
22
- NON_CAPTURE_KINDS = {"nav-graph", "sentinel", "host-activity"}
23
- CONTAINMENT_MECHANISMS = {
24
- "navhost-start",
25
- "nav-graph-start",
26
- "nav-component-start",
27
- "fragment-embed",
28
- }
29
- ARRIVAL_RULES = {
30
- "text_any",
31
- "text_all",
32
- "content_desc_any",
33
- "resource_id_any",
34
- "activity_any",
35
- }
36
-
37
-
38
- def _slug(node_id: str) -> str:
39
- readable = re.sub(r"[^A-Za-z0-9._-]+", "_", node_id).strip("._-") or "page"
40
- digest = hashlib.sha256(node_id.encode("utf-8")).hexdigest()[:8]
41
- return f"{readable[:72]}-{digest}"
42
-
43
-
44
- def _non_capture_reason(node: dict[str, Any], utg: dict[str, Any]) -> str | None:
45
- kind = node["kind"]
46
- if kind in NON_CAPTURE_KINDS:
47
- return f"non-capture-kind:{kind}"
48
- # A single-Activity app often models both the launcher host and the real
49
- # start route. They render the same screen. Capturing both either duplicates
50
- # the snapshot or triggers the crawler's duplicate-signature rejection.
51
- if kind == "activity" and any(
52
- edge["source"] == node["id"] and edge["mechanism"] in CONTAINMENT_MECHANISMS
53
- for edge in utg["links"]
54
- ):
55
- return "container-node:activity"
56
- return None
57
-
58
-
59
- def _capture_reason(node, utg, path):
60
- reason = _non_capture_reason(node, utg)
61
- if reason or (node.get('extra') or {}).get('capture_policy') != 'strict':
62
- return reason
63
- if not any(isinstance(node.get('expected', {}).get(rule), list)
64
- and node['expected'][rule] for rule in ARRIVAL_RULES):
65
- return 'missing-arrival-rules'
66
- for step in (path or {}).get('steps', []):
67
- action_reason = replay_action_reason(step)
68
- if action_reason:
69
- return action_reason
70
- return None
71
-
72
-
73
- def build_page_plan(
74
- utg: dict[str, Any],
75
- click_paths: dict[str, Any],
76
- *,
77
- utg_path: str,
78
- click_paths_path: str,
79
- ) -> dict[str, Any]:
80
- """Return a page plan whose order is stable for the exact UTG pair."""
81
- nodes = utg["nodes"]
82
- paths = click_paths["paths"]
83
- unreachable = set(click_paths["unreachable"])
84
- source_order = {node["id"]: index for index, node in enumerate(nodes)}
85
-
86
- ordered = sorted(
87
- nodes,
88
- key=lambda node: (
89
- 1 if node["id"] in unreachable else 0,
90
- paths.get(node["id"], {}).get("depth", 1 << 30),
91
- source_order[node["id"]],
92
- ),
93
- )
94
-
95
- pages: list[dict[str, Any]] = []
96
- capture_index = 0
97
- excluded_by_kind: dict[str, int] = {}
98
- for node in ordered:
99
- node_id = node["id"]
100
- kind = node["kind"]
101
- reachable = node_id in paths
102
- non_capture_reason = _capture_reason(node, utg, paths.get(node_id))
103
- capture = reachable and non_capture_reason is None
104
- if non_capture_reason is not None:
105
- excluded_key = f"{kind}(container)" if non_capture_reason.startswith("container-node") else kind
106
- excluded_by_kind[excluded_key] = excluded_by_kind.get(excluded_key, 0) + 1
107
- if capture:
108
- capture_index += 1
109
- path = paths.get(node_id)
110
- labels = path.get("labels", []) if path else []
111
- pages.append(
112
- {
113
- "node_id": node_id,
114
- "kind": kind,
115
- "label": node["label"],
116
- "declared_at": node["declared_at"],
117
- "required_state": node.get("required_state", []),
118
- "expected": node.get("expected", {}),
119
- "reachable": reachable,
120
- "capture": capture,
121
- "capture_key": (
122
- f"page_{capture_index:04d}_{_slug(node_id)}" if capture else None
123
- ),
124
- "depth": path.get("depth") if path else None,
125
- "ui_sequence": " → ".join(labels) if labels else node["label"],
126
- "path_text": path.get("path_text") if path else None,
127
- "steps": path.get("steps", []) if path else [],
128
- "skip_reason": (
129
- None
130
- if capture
131
- else "unreachable-from-root"
132
- if not reachable
133
- else non_capture_reason
134
- ),
135
- }
136
- )
137
-
138
- return {
139
- "schema_version": SCHEMA_VERSION,
140
- "source": {
141
- "utg": utg_path,
142
- "click_paths": click_paths_path,
143
- "root": click_paths["root"],
144
- "app": utg["graph"]["app"],
145
- "package": utg["graph"].get("package", ""),
146
- "architecture": utg["graph"]["architecture"],
147
- "version_name": utg["graph"].get("version_name", ""),
148
- "build_variant": utg["graph"].get("build_variant", ""),
149
- },
150
- "summary": {
151
- "nodes": len(nodes),
152
- "capture_planned": capture_index,
153
- "unreachable": len(unreachable),
154
- "excluded_by_kind": dict(sorted(excluded_by_kind.items())),
155
- },
156
- "pages": pages,
157
- }
158
-
159
-
160
- def validate_page_plan(
161
- plan: dict[str, Any],
162
- utg: dict[str, Any],
163
- click_paths: dict[str, Any],
164
- *,
165
- utg_path: str | None = None,
166
- click_paths_path: str | None = None,
167
- ) -> list[str]:
168
- errors: list[str] = []
169
- if plan.get("schema_version") != SCHEMA_VERSION:
170
- errors.append(f"page.schema_version must be {SCHEMA_VERSION!r}")
171
- source = plan.get("source")
172
- pages = plan.get("pages")
173
- if not isinstance(source, dict):
174
- return errors + ["page.source must be an object"]
175
- if utg_path is not None and source.get("utg") != utg_path:
176
- errors.append("page.source.utg does not match the validated utg.json")
177
- if click_paths_path is not None and source.get("click_paths") != click_paths_path:
178
- errors.append("page.source.click_paths does not match the validated click_paths.json")
179
- if source.get("root") != click_paths.get("root"):
180
- errors.append("page.source.root must equal click_paths.root")
181
- if not isinstance(pages, list):
182
- return errors + ["page.pages must be an array"]
183
-
184
- nodes = {node["id"]: node for node in utg["nodes"]}
185
- ids: set[str] = set()
186
- capture_keys: set[str] = set()
187
- last_order: tuple[int, int] = (-1, -1)
188
- source_order = {node["id"]: index for index, node in enumerate(utg["nodes"])}
189
- for index, page in enumerate(pages):
190
- where = f"page.pages[{index}]"
191
- if not isinstance(page, dict):
192
- errors.append(f"{where} must be an object")
193
- continue
194
- node_id = page.get("node_id")
195
- if not isinstance(node_id, str) or node_id not in nodes:
196
- errors.append(f"{where}.node_id references a missing UTG node: {node_id!r}")
197
- continue
198
- if node_id in ids:
199
- errors.append(f"duplicate page node_id: {node_id}")
200
- ids.add(node_id)
201
- path = click_paths["paths"].get(node_id)
202
- reachable = path is not None
203
- expected_capture = reachable and _capture_reason(nodes[node_id], utg, path) is None
204
- if page.get("reachable") is not reachable:
205
- errors.append(f"{where}.reachable does not match click_paths")
206
- if page.get("capture") is not expected_capture:
207
- errors.append(f"{where}.capture does not match reachability/kind")
208
- if page.get("steps") != (path.get("steps", []) if path else []):
209
- errors.append(f"{where}.steps are not copied from click_paths")
210
- if page.get("expected") != nodes[node_id].get("expected", {}):
211
- errors.append(f"{where}.expected is not copied from the UTG node")
212
- if expected_capture:
213
- expected = page.get("expected")
214
- has_arrival_rule = isinstance(expected, dict) and any(
215
- isinstance(expected.get(rule), list)
216
- and bool(expected[rule])
217
- and all(isinstance(value, str) and value for value in expected[rule])
218
- for rule in ARRIVAL_RULES
219
- )
220
- if not has_arrival_rule:
221
- errors.append(
222
- f"{where}.expected must contain a supported non-empty arrival rule "
223
- "for runtime capture"
224
- )
225
- capture_key = page.get("capture_key")
226
- if expected_capture:
227
- if not isinstance(capture_key, str) or not capture_key:
228
- errors.append(f"{where}.capture_key must be non-empty for capture pages")
229
- elif capture_key in capture_keys:
230
- errors.append(f"duplicate capture_key: {capture_key}")
231
- else:
232
- capture_keys.add(capture_key)
233
- elif capture_key is not None:
234
- errors.append(f"{where}.capture_key must be null when capture=false")
235
- order = (
236
- 1 if not reachable else 0,
237
- path.get("depth", 1 << 30) if path else 1 << 30,
238
- source_order[node_id],
239
- )
240
- comparable = (order[0], order[1])
241
- if comparable < last_order:
242
- errors.append("page.pages must be ordered by reachable/depth")
243
- last_order = comparable
244
-
245
- if ids != set(nodes):
246
- errors.append(
247
- "page.pages must cover every UTG node; "
248
- f"missing={sorted(set(nodes) - ids)}, extra={sorted(ids - set(nodes))}"
249
- )
250
- # The plan is entirely derived. Catch mutations to labels, summaries,
251
- # directory numbering, copied path fields, or skip reasons by rebuilding
252
- # the expected value from the validated pair.
253
- expected = build_page_plan(
254
- utg,
255
- click_paths,
256
- utg_path=utg_path or str(source.get("utg", "")),
257
- click_paths_path=click_paths_path or str(source.get("click_paths", "")),
258
- )
259
- if plan != expected:
260
- errors.append("page.json is not the deterministic plan for this UTG pair")
261
- return errors
262
-
263
-
264
- def parse_args() -> argparse.Namespace:
265
- parser = argparse.ArgumentParser(
266
- description="Build page.json from a validated utg.json/click_paths.json pair."
267
- )
268
- parser.add_argument("--utg", required=True, type=Path)
269
- parser.add_argument("--click-paths", required=True, type=Path)
270
- parser.add_argument("--output", required=True, type=Path)
271
- parser.add_argument("--project-root", type=Path)
272
- return parser.parse_args()
273
-
274
-
275
- def main() -> int:
276
- args = parse_args()
277
- try:
278
- utg_path = args.utg.resolve()
279
- click_paths_path = args.click_paths.resolve()
280
- utg = load_json(utg_path)
281
- click_paths = load_json(click_paths_path)
282
- project_root = args.project_root.resolve() if args.project_root else None
283
- pair_errors = validate_pair(utg, click_paths, project_root)
284
- if pair_errors:
285
- raise UtgError("UTG pair is invalid:\n- " + "\n- ".join(pair_errors))
286
- plan = build_page_plan(
287
- utg,
288
- click_paths,
289
- utg_path=str(utg_path),
290
- click_paths_path=str(click_paths_path),
291
- )
292
- errors = validate_page_plan(
293
- plan,
294
- utg,
295
- click_paths,
296
- utg_path=str(utg_path),
297
- click_paths_path=str(click_paths_path),
298
- )
299
- if errors:
300
- raise UtgError("page plan is invalid:\n- " + "\n- ".join(errors))
301
- atomic_write_json(args.output.resolve(), plan)
302
- except UtgError as exc:
303
- print(f"ERROR: {exc}", file=sys.stderr)
304
- return 2
305
- print(
306
- f"valid page plan: capture={plan['summary']['capture_planned']} "
307
- f"unreachable={plan['summary']['unreachable']}"
308
- )
309
- print(args.output.resolve())
310
- return 0
311
-
312
-
313
- if __name__ == "__main__":
314
- raise SystemExit(main())
1
+ #!/usr/bin/env python3
2
+ """Build and validate a deterministic runtime capture plan from a UTG pair.
3
+
4
+ `page.json` is deliberately produced before any device work. It is the bridge
5
+ between source-derived reachability (`utg.json` + `click_paths.json`) and the
6
+ standalone ADB replay script that captures screenshots and UIAutomator trees.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import hashlib
13
+ import re
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from utg_common import UtgError, atomic_write_json, load_json, validate_pair, replay_action_reason
19
+
20
+
21
+ SCHEMA_VERSION = "d2h-page-plan/v1"
22
+ NON_CAPTURE_KINDS = {"nav-graph", "sentinel", "host-activity"}
23
+ CONTAINMENT_MECHANISMS = {
24
+ "navhost-start",
25
+ "nav-graph-start",
26
+ "nav-component-start",
27
+ "fragment-embed",
28
+ }
29
+ ARRIVAL_RULES = {
30
+ "text_any",
31
+ "text_all",
32
+ "content_desc_any",
33
+ "resource_id_any",
34
+ "activity_any",
35
+ }
36
+
37
+
38
+ def _slug(node_id: str) -> str:
39
+ readable = re.sub(r"[^A-Za-z0-9._-]+", "_", node_id).strip("._-") or "page"
40
+ digest = hashlib.sha256(node_id.encode("utf-8")).hexdigest()[:8]
41
+ return f"{readable[:72]}-{digest}"
42
+
43
+
44
+ def _non_capture_reason(node: dict[str, Any], utg: dict[str, Any]) -> str | None:
45
+ kind = node["kind"]
46
+ if kind in NON_CAPTURE_KINDS:
47
+ return f"non-capture-kind:{kind}"
48
+ # A single-Activity app often models both the launcher host and the real
49
+ # start route. They render the same screen. Capturing both either duplicates
50
+ # the snapshot or triggers the crawler's duplicate-signature rejection.
51
+ if kind == "activity" and any(
52
+ edge["source"] == node["id"] and edge["mechanism"] in CONTAINMENT_MECHANISMS
53
+ for edge in utg["links"]
54
+ ):
55
+ return "container-node:activity"
56
+ return None
57
+
58
+
59
+ def _capture_reason(node, utg, path):
60
+ reason = _non_capture_reason(node, utg)
61
+ if reason or (node.get('extra') or {}).get('capture_policy') != 'strict':
62
+ return reason
63
+ if not any(isinstance(node.get('expected', {}).get(rule), list)
64
+ and node['expected'][rule] for rule in ARRIVAL_RULES):
65
+ return 'missing-arrival-rules'
66
+ for step in (path or {}).get('steps', []):
67
+ action_reason = replay_action_reason(step)
68
+ if action_reason:
69
+ return action_reason
70
+ return None
71
+
72
+
73
+ def build_page_plan(
74
+ utg: dict[str, Any],
75
+ click_paths: dict[str, Any],
76
+ *,
77
+ utg_path: str,
78
+ click_paths_path: str,
79
+ ) -> dict[str, Any]:
80
+ """Return a page plan whose order is stable for the exact UTG pair."""
81
+ nodes = utg["nodes"]
82
+ paths = click_paths["paths"]
83
+ unreachable = set(click_paths["unreachable"])
84
+ source_order = {node["id"]: index for index, node in enumerate(nodes)}
85
+
86
+ ordered = sorted(
87
+ nodes,
88
+ key=lambda node: (
89
+ 1 if node["id"] in unreachable else 0,
90
+ paths.get(node["id"], {}).get("depth", 1 << 30),
91
+ source_order[node["id"]],
92
+ ),
93
+ )
94
+
95
+ pages: list[dict[str, Any]] = []
96
+ capture_index = 0
97
+ excluded_by_kind: dict[str, int] = {}
98
+ for node in ordered:
99
+ node_id = node["id"]
100
+ kind = node["kind"]
101
+ reachable = node_id in paths
102
+ non_capture_reason = _capture_reason(node, utg, paths.get(node_id))
103
+ capture = reachable and non_capture_reason is None
104
+ if non_capture_reason is not None:
105
+ excluded_key = f"{kind}(container)" if non_capture_reason.startswith("container-node") else kind
106
+ excluded_by_kind[excluded_key] = excluded_by_kind.get(excluded_key, 0) + 1
107
+ if capture:
108
+ capture_index += 1
109
+ path = paths.get(node_id)
110
+ labels = path.get("labels", []) if path else []
111
+ pages.append(
112
+ {
113
+ "node_id": node_id,
114
+ "kind": kind,
115
+ "label": node["label"],
116
+ "declared_at": node["declared_at"],
117
+ "required_state": node.get("required_state", []),
118
+ "expected": node.get("expected", {}),
119
+ "reachable": reachable,
120
+ "capture": capture,
121
+ "capture_key": (
122
+ f"page_{capture_index:04d}_{_slug(node_id)}" if capture else None
123
+ ),
124
+ "depth": path.get("depth") if path else None,
125
+ "ui_sequence": " → ".join(labels) if labels else node["label"],
126
+ "path_text": path.get("path_text") if path else None,
127
+ "steps": path.get("steps", []) if path else [],
128
+ "skip_reason": (
129
+ None
130
+ if capture
131
+ else "unreachable-from-root"
132
+ if not reachable
133
+ else non_capture_reason
134
+ ),
135
+ }
136
+ )
137
+
138
+ return {
139
+ "schema_version": SCHEMA_VERSION,
140
+ "source": {
141
+ "utg": utg_path,
142
+ "click_paths": click_paths_path,
143
+ "root": click_paths["root"],
144
+ "app": utg["graph"]["app"],
145
+ "package": utg["graph"].get("package", ""),
146
+ "architecture": utg["graph"]["architecture"],
147
+ "version_name": utg["graph"].get("version_name", ""),
148
+ "build_variant": utg["graph"].get("build_variant", ""),
149
+ },
150
+ "summary": {
151
+ "nodes": len(nodes),
152
+ "capture_planned": capture_index,
153
+ "unreachable": len(unreachable),
154
+ "excluded_by_kind": dict(sorted(excluded_by_kind.items())),
155
+ },
156
+ "pages": pages,
157
+ }
158
+
159
+
160
+ def validate_page_plan(
161
+ plan: dict[str, Any],
162
+ utg: dict[str, Any],
163
+ click_paths: dict[str, Any],
164
+ *,
165
+ utg_path: str | None = None,
166
+ click_paths_path: str | None = None,
167
+ ) -> list[str]:
168
+ errors: list[str] = []
169
+ if plan.get("schema_version") != SCHEMA_VERSION:
170
+ errors.append(f"page.schema_version must be {SCHEMA_VERSION!r}")
171
+ source = plan.get("source")
172
+ pages = plan.get("pages")
173
+ if not isinstance(source, dict):
174
+ return errors + ["page.source must be an object"]
175
+ if utg_path is not None and source.get("utg") != utg_path:
176
+ errors.append("page.source.utg does not match the validated utg.json")
177
+ if click_paths_path is not None and source.get("click_paths") != click_paths_path:
178
+ errors.append("page.source.click_paths does not match the validated click_paths.json")
179
+ if source.get("root") != click_paths.get("root"):
180
+ errors.append("page.source.root must equal click_paths.root")
181
+ if not isinstance(pages, list):
182
+ return errors + ["page.pages must be an array"]
183
+
184
+ nodes = {node["id"]: node for node in utg["nodes"]}
185
+ ids: set[str] = set()
186
+ capture_keys: set[str] = set()
187
+ last_order: tuple[int, int] = (-1, -1)
188
+ source_order = {node["id"]: index for index, node in enumerate(utg["nodes"])}
189
+ for index, page in enumerate(pages):
190
+ where = f"page.pages[{index}]"
191
+ if not isinstance(page, dict):
192
+ errors.append(f"{where} must be an object")
193
+ continue
194
+ node_id = page.get("node_id")
195
+ if not isinstance(node_id, str) or node_id not in nodes:
196
+ errors.append(f"{where}.node_id references a missing UTG node: {node_id!r}")
197
+ continue
198
+ if node_id in ids:
199
+ errors.append(f"duplicate page node_id: {node_id}")
200
+ ids.add(node_id)
201
+ path = click_paths["paths"].get(node_id)
202
+ reachable = path is not None
203
+ expected_capture = reachable and _capture_reason(nodes[node_id], utg, path) is None
204
+ if page.get("reachable") is not reachable:
205
+ errors.append(f"{where}.reachable does not match click_paths")
206
+ if page.get("capture") is not expected_capture:
207
+ errors.append(f"{where}.capture does not match reachability/kind")
208
+ if page.get("steps") != (path.get("steps", []) if path else []):
209
+ errors.append(f"{where}.steps are not copied from click_paths")
210
+ if page.get("expected") != nodes[node_id].get("expected", {}):
211
+ errors.append(f"{where}.expected is not copied from the UTG node")
212
+ if expected_capture:
213
+ expected = page.get("expected")
214
+ has_arrival_rule = isinstance(expected, dict) and any(
215
+ isinstance(expected.get(rule), list)
216
+ and bool(expected[rule])
217
+ and all(isinstance(value, str) and value for value in expected[rule])
218
+ for rule in ARRIVAL_RULES
219
+ )
220
+ if not has_arrival_rule:
221
+ errors.append(
222
+ f"{where}.expected must contain a supported non-empty arrival rule "
223
+ "for runtime capture"
224
+ )
225
+ capture_key = page.get("capture_key")
226
+ if expected_capture:
227
+ if not isinstance(capture_key, str) or not capture_key:
228
+ errors.append(f"{where}.capture_key must be non-empty for capture pages")
229
+ elif capture_key in capture_keys:
230
+ errors.append(f"duplicate capture_key: {capture_key}")
231
+ else:
232
+ capture_keys.add(capture_key)
233
+ elif capture_key is not None:
234
+ errors.append(f"{where}.capture_key must be null when capture=false")
235
+ order = (
236
+ 1 if not reachable else 0,
237
+ path.get("depth", 1 << 30) if path else 1 << 30,
238
+ source_order[node_id],
239
+ )
240
+ comparable = (order[0], order[1])
241
+ if comparable < last_order:
242
+ errors.append("page.pages must be ordered by reachable/depth")
243
+ last_order = comparable
244
+
245
+ if ids != set(nodes):
246
+ errors.append(
247
+ "page.pages must cover every UTG node; "
248
+ f"missing={sorted(set(nodes) - ids)}, extra={sorted(ids - set(nodes))}"
249
+ )
250
+ # The plan is entirely derived. Catch mutations to labels, summaries,
251
+ # directory numbering, copied path fields, or skip reasons by rebuilding
252
+ # the expected value from the validated pair.
253
+ expected = build_page_plan(
254
+ utg,
255
+ click_paths,
256
+ utg_path=utg_path or str(source.get("utg", "")),
257
+ click_paths_path=click_paths_path or str(source.get("click_paths", "")),
258
+ )
259
+ if plan != expected:
260
+ errors.append("page.json is not the deterministic plan for this UTG pair")
261
+ return errors
262
+
263
+
264
+ def parse_args() -> argparse.Namespace:
265
+ parser = argparse.ArgumentParser(
266
+ description="Build page.json from a validated utg.json/click_paths.json pair."
267
+ )
268
+ parser.add_argument("--utg", required=True, type=Path)
269
+ parser.add_argument("--click-paths", required=True, type=Path)
270
+ parser.add_argument("--output", required=True, type=Path)
271
+ parser.add_argument("--project-root", type=Path)
272
+ return parser.parse_args()
273
+
274
+
275
+ def main() -> int:
276
+ args = parse_args()
277
+ try:
278
+ utg_path = args.utg.resolve()
279
+ click_paths_path = args.click_paths.resolve()
280
+ utg = load_json(utg_path)
281
+ click_paths = load_json(click_paths_path)
282
+ project_root = args.project_root.resolve() if args.project_root else None
283
+ pair_errors = validate_pair(utg, click_paths, project_root)
284
+ if pair_errors:
285
+ raise UtgError("UTG pair is invalid:\n- " + "\n- ".join(pair_errors))
286
+ plan = build_page_plan(
287
+ utg,
288
+ click_paths,
289
+ utg_path=str(utg_path),
290
+ click_paths_path=str(click_paths_path),
291
+ )
292
+ errors = validate_page_plan(
293
+ plan,
294
+ utg,
295
+ click_paths,
296
+ utg_path=str(utg_path),
297
+ click_paths_path=str(click_paths_path),
298
+ )
299
+ if errors:
300
+ raise UtgError("page plan is invalid:\n- " + "\n- ".join(errors))
301
+ atomic_write_json(args.output.resolve(), plan)
302
+ except UtgError as exc:
303
+ print(f"ERROR: {exc}", file=sys.stderr)
304
+ return 2
305
+ print(
306
+ f"valid page plan: capture={plan['summary']['capture_planned']} "
307
+ f"unreachable={plan['summary']['unreachable']}"
308
+ )
309
+ print(args.output.resolve())
310
+ return 0
311
+
312
+
313
+ if __name__ == "__main__":
314
+ raise SystemExit(main())