@harmonyos-arkts/d2h 0.1.2 → 0.1.5
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 +83 -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/android2hmos_resources_convert/SKILL.md +37 -19
- package/skills/appgraph-rule-audit/SKILL.md +24 -5
- 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/SKILL.md +30 -3
- package/skills/harmonyos-migration-playbook/references/lesson-lifecycle.md +4 -4
- package/skills/harmonyos-migration-playbook/references/ui-automation.md +4 -3
- package/skills/harmonyos-migration-playbook/references/windows-environment.md +2 -2
- package/skills/maintaining-migration-report/SKILL.md +88 -99
- package/skills/maintaining-migration-report/references/final-acceptance.md +48 -0
- package/skills/maintaining-migration-report/references/source-provenance.md +66 -0
- 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 +76 -69
- package/skills/preparing-migration-workspace/toolchain.json +0 -43
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# `utg-facts.json` contract
|
|
2
|
+
|
|
3
|
+
The facts file is the only AI-authored data input. It describes source evidence; it is not the final UTG format.
|
|
4
|
+
|
|
5
|
+
```json
|
|
6
|
+
{
|
|
7
|
+
"schema_version": "d2h-utg-facts/v1",
|
|
8
|
+
"graph": {
|
|
9
|
+
"app": "Example",
|
|
10
|
+
"package": "com.example.app",
|
|
11
|
+
"architecture": "jetpack-compose/navigation-compose",
|
|
12
|
+
"root": "MainActivity",
|
|
13
|
+
"start_destination": "home",
|
|
14
|
+
"version_name": "1.4.2",
|
|
15
|
+
"build_variant": "debug"
|
|
16
|
+
},
|
|
17
|
+
"nodes": [
|
|
18
|
+
{
|
|
19
|
+
"id": "MainActivity",
|
|
20
|
+
"kind": "activity",
|
|
21
|
+
"label": "MainActivity (launcher)",
|
|
22
|
+
"declared_at": "app/src/main/java/com/example/MainActivity.kt:20",
|
|
23
|
+
"widgets": [],
|
|
24
|
+
"expected": {"activity_any": ["com.example.app/.MainActivity"]},
|
|
25
|
+
"required_state": [],
|
|
26
|
+
"extra": {"launcher": true},
|
|
27
|
+
"confidence": 1.0
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"edges": [
|
|
31
|
+
{
|
|
32
|
+
"source": "MainActivity",
|
|
33
|
+
"target": "home",
|
|
34
|
+
"mechanism": "navhost-start",
|
|
35
|
+
"trigger": "app/src/main/java/com/example/MainActivity.kt:45",
|
|
36
|
+
"guard": null,
|
|
37
|
+
"args": {
|
|
38
|
+
"action": "default",
|
|
39
|
+
"label": "Example",
|
|
40
|
+
"label_source": "string"
|
|
41
|
+
},
|
|
42
|
+
"confidence": 0.98
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"unresolved": [
|
|
46
|
+
{
|
|
47
|
+
"subject": "dynamic route from server config",
|
|
48
|
+
"reason": "target value is not present in source",
|
|
49
|
+
"evidence": "app/src/main/java/com/example/Router.kt:81"
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Graph
|
|
56
|
+
|
|
57
|
+
Required fields are `app`, `architecture`, and `root`. `package` and `start_destination` may be empty only when the source ecosystem genuinely has no equivalent. Record `version_name` and `build_variant` when they are determinable from the analyzed source/build configuration; runtime replay refuses a non-empty version that differs from the installed package. Additional graph metadata is preserved.
|
|
58
|
+
|
|
59
|
+
## Nodes
|
|
60
|
+
|
|
61
|
+
Required:
|
|
62
|
+
|
|
63
|
+
- `id`: stable, unique code identity. Do not use display text as identity.
|
|
64
|
+
- `kind`: framework-independent semantic kind such as `activity`, `fragment`, `compose-route`, `voyager-screen`, `dialog`, `sheet`, `menu`, `mode`, `nav-graph`, `hybrid`, or `hybrid-page`.
|
|
65
|
+
- `label`: human-readable description.
|
|
66
|
+
- `declared_at`: project-relative `file:line` that declares or hosts the UI.
|
|
67
|
+
|
|
68
|
+
Optional fields default to `status="static-only"`, `provenance="ai-source-analysis"`, empty `widgets`, empty `expected`, empty `required_state`, and `extra=null`. Use an object for `extra` only when the node has extension metadata. A node intended for runtime capture must not leave `expected` empty. Supported arrival rules are string arrays: `text_any`, `text_all`, `content_desc_any`, `resource_id_any`, and `activity_any`. Derive them from source literals/resources or stable component identities, not guesses.
|
|
69
|
+
|
|
70
|
+
Use `required_state` only for a node-level state axis, such as `content`, `login`, `permission`, or `network`. Put edge-specific conditions on the edge guard.
|
|
71
|
+
|
|
72
|
+
## Edges
|
|
73
|
+
|
|
74
|
+
Required:
|
|
75
|
+
|
|
76
|
+
- `source`, `target`: existing node IDs.
|
|
77
|
+
- `mechanism`: navigation mechanism, not a prose summary.
|
|
78
|
+
- `trigger`: project-relative navigation call-site `file:line`.
|
|
79
|
+
- `confidence`: numeric `[0, 1]`.
|
|
80
|
+
|
|
81
|
+
`guard` is either `null` or an object containing non-empty `kind` and `text`. `args` may contain:
|
|
82
|
+
|
|
83
|
+
- `action`: `tap`, `back`, `default`, `launch`, `home`, `swipe`, or another concrete driver action.
|
|
84
|
+
- `label`: visible text or URI. It is a compatibility selector only when `selector` is absent.
|
|
85
|
+
- `label_source`: normally `string`, `content-description`, `system`, `code-name`, or `nl_hint`.
|
|
86
|
+
- `label_ambiguous`: boolean.
|
|
87
|
+
- `nl_hint`: source-grounded instruction for human interpretation. It is not executable.
|
|
88
|
+
- `selector`: deterministic ADB selector. It may contain `text`, `text_any`, `content_desc`, `content_desc_any`, `resource_id`, or `resource_id_any`. `normalized_point: [x, y]` uses 0–1 screen fractions and is an explicitly enabled last resort, never an inferred default.
|
|
89
|
+
- `direction`, `distance`, `duration_ms`, and `max_scrolls`: optional deterministic parameters for swipe/search behavior.
|
|
90
|
+
- route arguments or other mechanism-specific metadata.
|
|
91
|
+
|
|
92
|
+
The generator assigns parallel-edge `key` values. Do not supply them.
|
|
93
|
+
|
|
94
|
+
## Unresolved findings
|
|
95
|
+
|
|
96
|
+
Use `unresolved` when the source proves a boundary but not a safe graph fact: runtime/server routes, reflection, computed destinations, missing bundles, build-flavor-only pages, or ambiguous callback ownership. These entries remain in `generation-report.json` and never enter the graph silently.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://hometrans.local/schemas/page-plan-v1.json",
|
|
4
|
+
"title": "HomeTrans UTG-directed runtime capture plan",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schema_version", "source", "summary", "pages"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schema_version": {"const": "d2h-page-plan/v1"},
|
|
9
|
+
"source": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"required": ["utg", "click_paths", "root", "app", "package", "architecture", "version_name", "build_variant"],
|
|
12
|
+
"properties": {
|
|
13
|
+
"utg": {"type": "string", "minLength": 1},
|
|
14
|
+
"click_paths": {"type": "string", "minLength": 1},
|
|
15
|
+
"root": {"type": "string", "minLength": 1},
|
|
16
|
+
"app": {"type": "string", "minLength": 1},
|
|
17
|
+
"package": {"type": "string"},
|
|
18
|
+
"architecture": {"type": "string", "minLength": 1},
|
|
19
|
+
"version_name": {"type": "string"},
|
|
20
|
+
"build_variant": {"type": "string"}
|
|
21
|
+
},
|
|
22
|
+
"additionalProperties": false
|
|
23
|
+
},
|
|
24
|
+
"summary": {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"required": ["nodes", "capture_planned", "unreachable", "excluded_by_kind"],
|
|
27
|
+
"properties": {
|
|
28
|
+
"nodes": {"type": "integer", "minimum": 1},
|
|
29
|
+
"capture_planned": {"type": "integer", "minimum": 0},
|
|
30
|
+
"unreachable": {"type": "integer", "minimum": 0},
|
|
31
|
+
"excluded_by_kind": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}}
|
|
32
|
+
},
|
|
33
|
+
"additionalProperties": false
|
|
34
|
+
},
|
|
35
|
+
"pages": {
|
|
36
|
+
"type": "array",
|
|
37
|
+
"items": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"required": ["node_id", "kind", "label", "declared_at", "required_state", "expected", "reachable", "capture", "capture_key", "depth", "ui_sequence", "path_text", "steps", "skip_reason"],
|
|
40
|
+
"properties": {
|
|
41
|
+
"node_id": {"type": "string", "minLength": 1},
|
|
42
|
+
"kind": {"type": "string", "minLength": 1},
|
|
43
|
+
"label": {"type": "string", "minLength": 1},
|
|
44
|
+
"declared_at": {"type": "string", "pattern": ":[1-9][0-9]*$"},
|
|
45
|
+
"required_state": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
|
|
46
|
+
"expected": {"type": "object"},
|
|
47
|
+
"reachable": {"type": "boolean"},
|
|
48
|
+
"capture": {"type": "boolean"},
|
|
49
|
+
"capture_key": {"type": ["string", "null"]},
|
|
50
|
+
"depth": {"type": ["integer", "null"], "minimum": 0},
|
|
51
|
+
"ui_sequence": {"type": "string", "minLength": 1},
|
|
52
|
+
"path_text": {"type": ["string", "null"]},
|
|
53
|
+
"steps": {"type": "array"},
|
|
54
|
+
"skip_reason": {"type": ["string", "null"]}
|
|
55
|
+
},
|
|
56
|
+
"additionalProperties": false
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"additionalProperties": false
|
|
61
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://hometrans.local/schemas/utg-v2.json",
|
|
4
|
+
"title": "HomeTrans source-derived UTG",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["directed", "multigraph", "graph", "nodes", "links"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"directed": {"const": true},
|
|
9
|
+
"multigraph": {"const": true},
|
|
10
|
+
"graph": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"required": ["app", "architecture", "root"],
|
|
13
|
+
"properties": {
|
|
14
|
+
"app": {"type": "string", "minLength": 1},
|
|
15
|
+
"package": {"type": "string"},
|
|
16
|
+
"architecture": {"type": "string", "minLength": 1},
|
|
17
|
+
"root": {"type": "string", "minLength": 1},
|
|
18
|
+
"start_destination": {"type": "string"},
|
|
19
|
+
"version_name": {"type": "string"},
|
|
20
|
+
"build_variant": {"type": "string"}
|
|
21
|
+
},
|
|
22
|
+
"additionalProperties": true
|
|
23
|
+
},
|
|
24
|
+
"nodes": {
|
|
25
|
+
"type": "array",
|
|
26
|
+
"items": {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"required": ["id", "kind", "label", "declared_at", "status", "provenance", "widgets", "expected", "required_state", "extra"],
|
|
29
|
+
"properties": {
|
|
30
|
+
"id": {"type": "string", "minLength": 1},
|
|
31
|
+
"kind": {"type": "string", "minLength": 1},
|
|
32
|
+
"label": {"type": "string", "minLength": 1},
|
|
33
|
+
"declared_at": {"type": "string", "pattern": ":[1-9][0-9]*$"},
|
|
34
|
+
"status": {"type": "string", "minLength": 1},
|
|
35
|
+
"provenance": {"type": "string", "minLength": 1},
|
|
36
|
+
"widgets": {"type": "array"},
|
|
37
|
+
"expected": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"properties": {
|
|
40
|
+
"text_any": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
|
|
41
|
+
"text_all": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
|
|
42
|
+
"content_desc_any": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
|
|
43
|
+
"resource_id_any": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}},
|
|
44
|
+
"activity_any": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}
|
|
45
|
+
},
|
|
46
|
+
"additionalProperties": false
|
|
47
|
+
},
|
|
48
|
+
"required_state": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
|
|
49
|
+
"extra": {"type": ["object", "null"]}
|
|
50
|
+
},
|
|
51
|
+
"additionalProperties": true
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"links": {
|
|
55
|
+
"type": "array",
|
|
56
|
+
"items": {
|
|
57
|
+
"type": "object",
|
|
58
|
+
"required": ["source", "target", "mechanism", "trigger", "guard", "args", "confidence", "provenance", "key", "status"],
|
|
59
|
+
"properties": {
|
|
60
|
+
"source": {"type": "string", "minLength": 1},
|
|
61
|
+
"target": {"type": "string", "minLength": 1},
|
|
62
|
+
"mechanism": {"type": "string", "minLength": 1},
|
|
63
|
+
"trigger": {"type": "string", "pattern": ":[1-9][0-9]*$"},
|
|
64
|
+
"guard": {
|
|
65
|
+
"oneOf": [
|
|
66
|
+
{"type": "null"},
|
|
67
|
+
{
|
|
68
|
+
"type": "object",
|
|
69
|
+
"required": ["kind", "text"],
|
|
70
|
+
"properties": {
|
|
71
|
+
"kind": {"type": "string", "minLength": 1},
|
|
72
|
+
"text": {"type": "string", "minLength": 1}
|
|
73
|
+
},
|
|
74
|
+
"additionalProperties": true
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
},
|
|
78
|
+
"args": {"type": "object"},
|
|
79
|
+
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
|
80
|
+
"provenance": {"type": "string", "minLength": 1},
|
|
81
|
+
"key": {"type": "integer", "minimum": 0},
|
|
82
|
+
"status": {"type": "string", "minLength": 1}
|
|
83
|
+
},
|
|
84
|
+
"additionalProperties": true
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
"additionalProperties": false
|
|
89
|
+
}
|
|
@@ -0,0 +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())
|