@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,335 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import importlib.util
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
SCRIPT = Path(__file__).resolve().parents[1] / 'scripts' / 'generate.py'
|
|
12
|
+
spec = importlib.util.spec_from_file_location('static_generate', SCRIPT)
|
|
13
|
+
module = importlib.util.module_from_spec(spec)
|
|
14
|
+
spec.loader.exec_module(module)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StaticTests(unittest.TestCase):
|
|
18
|
+
def setUp(self):
|
|
19
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
20
|
+
self.addCleanup(self.temp.cleanup)
|
|
21
|
+
self.root = Path(self.temp.name) / 'source'
|
|
22
|
+
self.root.mkdir()
|
|
23
|
+
(self.root / 'Screen.kt').write_text('fun home() {}\nfun detail() {}\nfun dialog() {}\n', encoding='utf-8')
|
|
24
|
+
self.ref = {'file': 'Screen.kt', 'line': 1}
|
|
25
|
+
self.graph = {'schemaVersion': 1, 'platform': 'android', 'app': {'name': 'Demo', 'packageName': 'demo'},
|
|
26
|
+
'nodes': [{'id': identity, 'kind': 'Screen', 'name': 'SameName', 'platform': 'android',
|
|
27
|
+
'subtype': kind, 'platformRef': self.ref}
|
|
28
|
+
for identity, kind in [('home', 'activity'), ('detail', 'compose'), ('dialog', 'dialog'), ('fragment', 'fragment')]],
|
|
29
|
+
'edges': [{'id': identity, 'kind': 'navigates_to', 'from': source, 'to': target,
|
|
30
|
+
'attrs': {'evidence': [self.ref]}, 'confidence': 1}
|
|
31
|
+
for identity, source, target in [('nav1', 'home', 'detail'), ('nav2', 'home', 'detail'),
|
|
32
|
+
('back', 'detail', 'home'), ('external', 'home', 'outside')]],
|
|
33
|
+
'entryPoints': [{'id': 'launch', 'kind': 'app-launch', 'sourceRef': 'home', 'resolution': 'resolved'},
|
|
34
|
+
{'id': 'click', 'kind': 'ui-action', 'sourceRef': 'button', 'ownerRef': 'home', 'resolution': 'resolved'}],
|
|
35
|
+
'uiComponents': {'nodes': [{'id': 'button', 'kind': 'control', 'name': 'Button', 'evidence': [self.ref]}],
|
|
36
|
+
'edges': [{'id': 'owns', 'kind': 'contains', 'from': 'home', 'to': 'button'}]},
|
|
37
|
+
'callTraces': [{'id': 'trace', 'entryPointId': 'click', 'effects': [
|
|
38
|
+
{'id': 'effect', 'kind': 'navigation', 'targetRefs': ['dialog'], 'origin': self.ref}]}]}
|
|
39
|
+
self.base = module.project(self.graph, self.root)
|
|
40
|
+
self.fingerprint = module.digest({'appgraph': self.graph, 'source': module.source_digest(self.root)})
|
|
41
|
+
|
|
42
|
+
def discoveries(self, operations=None):
|
|
43
|
+
return {'schema_version': 'd2h-utg-discoveries/v1', 'input_fingerprint': self.fingerprint,
|
|
44
|
+
'operations': operations or [], 'unresolved': []}
|
|
45
|
+
|
|
46
|
+
def operation(self, kind, op, identity, value=None):
|
|
47
|
+
return {'kind': kind, 'op': op, 'id': identity, 'reason': 'source verified',
|
|
48
|
+
'evidence': 'Screen.kt:1', 'value': value}
|
|
49
|
+
|
|
50
|
+
def test_projection(self):
|
|
51
|
+
self.assertEqual(len(self.base['nodes']), 4)
|
|
52
|
+
self.assertEqual(len(self.base['edges']), 4)
|
|
53
|
+
self.assertEqual(self.base['graph']['root'], 'home')
|
|
54
|
+
home = next(n for n in self.base['nodes'] if n['id'] == 'home')
|
|
55
|
+
self.assertEqual(home['widgets'][0]['id'], 'button')
|
|
56
|
+
self.assertTrue(any(x['subject'] == 'external' for x in self.base['unresolved']))
|
|
57
|
+
|
|
58
|
+
def test_source_page_coalesces_manifest_and_synthetic_compose_screen(self):
|
|
59
|
+
graph = {'schemaVersion': 1, 'platform': 'android', 'app': {'name': 'Demo'},
|
|
60
|
+
'nodes': [
|
|
61
|
+
{'id': 'manifest', 'kind': 'Screen', 'name': 'MainActivity', 'subtype': 'activity',
|
|
62
|
+
'platformRef': {'file': 'missing.xml', 'symbol': '.MainActivity'}},
|
|
63
|
+
{'id': 'entry', 'kind': 'AppEntry', 'subtype': 'launcher-activity',
|
|
64
|
+
'attrs': {'screen': 'manifest'}},
|
|
65
|
+
{'id': 'route', 'kind': 'Screen', 'name': 'SplashRoute', 'subtype': 'compose',
|
|
66
|
+
'matchKey': 'screen:splashroute', 'platformRef': {'file': 'Screen.kt'}},
|
|
67
|
+
{'id': 'synthetic', 'kind': 'Screen', 'name': 'SplashRoute', 'subtype': 'compose',
|
|
68
|
+
'matchKey': 'screen:splashroute:compose', 'platformRef': self.ref,
|
|
69
|
+
'attrs': {'classificationRule': 'compose.destination.register'}}],
|
|
70
|
+
'uiComponents': {'nodes': [
|
|
71
|
+
{'id': 'activity-page', 'kind': 'page', 'name': 'MainActivity', 'subtype': 'activity',
|
|
72
|
+
'evidence': [self.ref], 'attrs': {'screenId': 'manifest'}},
|
|
73
|
+
{'id': 'route-page', 'kind': 'page', 'name': 'SplashRoute', 'subtype': 'compose',
|
|
74
|
+
'evidence': [self.ref], 'attrs': {'screenId': 'route',
|
|
75
|
+
'screenMatchKey': 'screen:splashroute', 'classificationRule': 'COMPOSE-SCREEN-AST'}}],
|
|
76
|
+
'edges': []}, 'edges': [{'id': 'nav', 'kind': 'navigates_to',
|
|
77
|
+
'from': 'manifest', 'to': 'synthetic', 'attrs': {'evidence': [self.ref]}}],
|
|
78
|
+
'entryPoints': [], 'callTraces': []}
|
|
79
|
+
base = module.project(graph, self.root)
|
|
80
|
+
self.assertEqual({n['id'] for n in base['nodes']}, {'activity-page', 'route-page'})
|
|
81
|
+
self.assertEqual(base['graph']['root'], 'activity-page')
|
|
82
|
+
self.assertEqual([(e['source'], e['target']) for e in base['edges']],
|
|
83
|
+
[('activity-page', 'route-page')])
|
|
84
|
+
|
|
85
|
+
def test_interaction_projection_preserves_stable_ids_and_alignment(self):
|
|
86
|
+
self.graph['interactionGraph'] = {'schemaVersion': 1,
|
|
87
|
+
'surfaces': [{'id': identity, 'name': identity, 'kind': kind, 'sourceRefs': [identity, 'ui:' + identity],
|
|
88
|
+
'hostSurfaceIds': [], 'evidence': [self.ref], 'presentation': 'screen'}
|
|
89
|
+
for identity, kind in [('home', 'activity'), ('detail', 'compose')]],
|
|
90
|
+
'controlBindings': [{'id': 'binding:button:home', 'controlRef': 'button', 'surfaceId': 'home',
|
|
91
|
+
'evidence': [self.ref], 'selectorCandidates': []}],
|
|
92
|
+
'entries': [{'id': 'launch', 'kind': 'app-launch', 'surfaceIds': ['home'], 'resolution': 'resolved'}],
|
|
93
|
+
'transitions': [{'id': 'transition:stable', 'sourceSurfaceId': 'home',
|
|
94
|
+
'target': {'kind': 'internal', 'surfaceId': 'detail'}, 'operation': 'navigate',
|
|
95
|
+
'resolution': 'resolved', 'evidence': [self.ref], 'supportingRefs': ['edge', 'trace', 'effect'],
|
|
96
|
+
'actionRef': 'click', 'conditions': []}], 'unresolved': []}
|
|
97
|
+
base = module.project(self.graph, self.root)
|
|
98
|
+
self.assertEqual(len(base['edges']), 1) # Legacy edges must not be appended a second time.
|
|
99
|
+
self.assertEqual(base['graph']['root'], 'home')
|
|
100
|
+
self.assertEqual(base['alignment']['edges'][0]['source_refs'], ['edge', 'trace', 'effect'])
|
|
101
|
+
self.assertEqual(base['nodes'][1]['extra']['source_refs'], ['home', 'ui:home'])
|
|
102
|
+
utg, _, _ = module.normalize_facts(base, self.root)
|
|
103
|
+
self.assertEqual(utg['links'][0]['id'], 'transition:stable')
|
|
104
|
+
self.assertEqual(utg['links'][0]['status'], 'static-only')
|
|
105
|
+
self.graph['interactionGraph']['transitions'][0]['conditions'] = [
|
|
106
|
+
{'expression': 'loggedIn', 'branch': 'else', 'group': 'login'}]
|
|
107
|
+
guarded = module.project(self.graph, self.root)
|
|
108
|
+
self.assertEqual(guarded['edges'][0]['guard']['text'], '!(loggedIn)')
|
|
109
|
+
self.assertEqual(guarded['edges'][0]['guard']['conditions'][0]['branch'], 'else')
|
|
110
|
+
self.graph['interactionGraph']['transitions'][0]['conditions'] = []
|
|
111
|
+
from build_page_json import build_page_plan
|
|
112
|
+
plan = build_page_plan(utg, module.build_click_paths(utg), utg_path='utg.json', click_paths_path='click_paths.json')
|
|
113
|
+
self.assertFalse(any(p['capture'] for p in plan['pages']))
|
|
114
|
+
# Multiple declared launch pages use a non-capturable logical boundary.
|
|
115
|
+
self.graph['interactionGraph']['entries'].append({'id': 'second-launch', 'kind': 'app-launch',
|
|
116
|
+
'surfaceIds': ['detail'], 'resolution': 'resolved', 'launch': {'action': 'start-activity', 'component': 'demo/demo.DetailActivity'}})
|
|
117
|
+
base = module.project(self.graph, self.root)
|
|
118
|
+
self.assertEqual(base['graph']['root'], 'utg:entry-root')
|
|
119
|
+
detail = next(n for n in base['nodes'] if n['id'] == 'detail')
|
|
120
|
+
detail['expected'] = {'text_any': ['Detail']}
|
|
121
|
+
utg, _, _ = module.normalize_facts(base, self.root)
|
|
122
|
+
paths = module.build_click_paths(utg)
|
|
123
|
+
plan = build_page_plan(utg, paths, utg_path='utg.json', click_paths_path='click_paths.json')
|
|
124
|
+
self.assertFalse(next(p for p in plan['pages'] if p['node_id'] == 'utg:entry-root')['capture'])
|
|
125
|
+
page = next(p for p in plan['pages'] if p['node_id'] == 'detail')
|
|
126
|
+
self.assertTrue(page['capture'])
|
|
127
|
+
self.assertEqual(page['steps'][-1]['component'], 'demo/demo.DetailActivity')
|
|
128
|
+
self.graph['interactionGraph']['entries'][-1].pop('launch')
|
|
129
|
+
utg, _, _ = module.normalize_facts(module.project(self.graph, self.root), self.root)
|
|
130
|
+
plan = build_page_plan(utg, module.build_click_paths(utg), utg_path='utg.json', click_paths_path='click_paths.json')
|
|
131
|
+
self.assertFalse(next(p for p in plan['pages'] if p['node_id'] == 'detail')['capture'])
|
|
132
|
+
self.graph['interactionGraph']['entries'][-1].update(kind='deep-link', launch={'action': 'deep-link', 'uri': 'demo://app/detail'})
|
|
133
|
+
base = module.project(self.graph, self.root)
|
|
134
|
+
next(n for n in base['nodes'] if n['id'] == 'detail')['expected'] = {'text_any': ['Detail']}
|
|
135
|
+
# Retain every source-proven edge: the executable entry must win over
|
|
136
|
+
# the ordinary navigation whose control lacks a replay selector.
|
|
137
|
+
self.assertTrue(any(e['id'] == 'transition:stable' for e in base['edges']))
|
|
138
|
+
utg, _, _ = module.normalize_facts(base, self.root)
|
|
139
|
+
plan = build_page_plan(utg, module.build_click_paths(utg), utg_path='utg.json', click_paths_path='click_paths.json')
|
|
140
|
+
page = next(p for p in plan['pages'] if p['node_id'] == 'detail')
|
|
141
|
+
self.assertTrue(page['capture'])
|
|
142
|
+
self.assertEqual(page['steps'][-1]['action'], 'deep-link')
|
|
143
|
+
self.assertEqual(page['steps'][-1]['label'], 'demo://app/detail')
|
|
144
|
+
|
|
145
|
+
def test_source_selector_candidates_do_not_guess_test_tag_resource_ids(self):
|
|
146
|
+
self.graph['interactionGraph'] = {'schemaVersion': 1,
|
|
147
|
+
'surfaces': [{'id': identity, 'name': identity, 'kind': kind, 'sourceRefs': [identity],
|
|
148
|
+
'hostSurfaceIds': [], 'evidence': [self.ref], 'presentation': 'screen'}
|
|
149
|
+
for identity, kind in [('home', 'activity'), ('detail', 'compose')]],
|
|
150
|
+
'controlBindings': [{'id': 'binding', 'controlRef': 'button', 'surfaceId': 'home',
|
|
151
|
+
'evidence': [self.ref], 'selectorCandidates': []}],
|
|
152
|
+
'entries': [{'id': 'launch', 'kind': 'app-launch', 'surfaceIds': ['home'], 'resolution': 'resolved'},
|
|
153
|
+
{'id': 'click', 'kind': 'ui-action', 'sourceRef': 'button', 'surfaceIds': ['home'],
|
|
154
|
+
'event': 'onClick', 'resolution': 'resolved'}],
|
|
155
|
+
'transitions': [{'id': 'navigate', 'sourceSurfaceId': 'home', 'actionRef': 'click',
|
|
156
|
+
'target': {'kind': 'internal', 'surfaceId': 'detail'}, 'operation': 'navigate',
|
|
157
|
+
'resolution': 'resolved', 'evidence': [self.ref], 'supportingRefs': ['click']}],
|
|
158
|
+
'unresolved': []}
|
|
159
|
+
binding = self.graph['interactionGraph']['controlBindings'][0]
|
|
160
|
+
from build_page_json import build_page_plan
|
|
161
|
+
for kind, ready in [('resource-id', True), ('test-tag', False)]:
|
|
162
|
+
binding['selectorCandidates'] = [{'kind': kind, 'value': 'demo:id/open', 'evidence': [self.ref]}]
|
|
163
|
+
base = module.project(self.graph, self.root)
|
|
164
|
+
next(node for node in base['nodes'] if node['id'] == 'detail')['expected'] = {'text_any': ['Detail']}
|
|
165
|
+
utg, _, _ = module.normalize_facts(base, self.root)
|
|
166
|
+
page = next(page for page in build_page_plan(utg, module.build_click_paths(utg),
|
|
167
|
+
utg_path='utg.json', click_paths_path='click_paths.json')['pages'] if page['node_id'] == 'detail')
|
|
168
|
+
self.assertEqual(page['capture'], ready)
|
|
169
|
+
if ready:
|
|
170
|
+
self.assertEqual(page['steps'][-1]['selector'], {'resource_id': 'demo:id/open'})
|
|
171
|
+
else:
|
|
172
|
+
self.assertNotIn('selector', page['steps'][-1])
|
|
173
|
+
|
|
174
|
+
def test_sheet_projection_and_external_entry_operand_boundary(self):
|
|
175
|
+
self.graph['interactionGraph'] = {'schemaVersion': 1,
|
|
176
|
+
'surfaces': [{'id': 'home', 'name': 'Home', 'kind': 'activity', 'sourceRefs': ['home'],
|
|
177
|
+
'hostSurfaceIds': [], 'evidence': [self.ref], 'presentation': 'screen'},
|
|
178
|
+
{'id': 'sheet', 'name': 'Sheet', 'kind': 'bottom-sheet', 'sourceRefs': ['sheet'],
|
|
179
|
+
'hostSurfaceIds': ['home'], 'evidence': [self.ref], 'presentation': 'overlay'}],
|
|
180
|
+
'controlBindings': [],
|
|
181
|
+
'entries': [{'id': 'launch', 'kind': 'app-launch', 'surfaceIds': ['home'], 'resolution': 'resolved',
|
|
182
|
+
'launch': {'action': 'start-activity', 'component': 'demo/demo.Home'}},
|
|
183
|
+
{'id': 'share', 'kind': 'external-entry', 'surfaceIds': ['sheet'],
|
|
184
|
+
'resolution': 'resolved', 'evidence': [self.ref]}],
|
|
185
|
+
'transitions': [{'id': 'show-sheet', 'sourceSurfaceId': 'home',
|
|
186
|
+
'target': {'kind': 'internal', 'surfaceId': 'sheet'}, 'operation': 'show',
|
|
187
|
+
'resolution': 'resolved', 'evidence': [self.ref], 'supportingRefs': ['trigger']}],
|
|
188
|
+
'unresolved': []}
|
|
189
|
+
base = module.project(self.graph, self.root)
|
|
190
|
+
sheet = next(node for node in base['nodes'] if node['id'] == 'sheet')
|
|
191
|
+
self.assertEqual(sheet['kind'], 'sheet')
|
|
192
|
+
self.assertEqual(sheet['extra']['host_surface_ids'], ['home'])
|
|
193
|
+
self.assertTrue(any(issue['subject'] == 'share' and 'operands' in issue['reason'] for issue in base['unresolved']))
|
|
194
|
+
sheet['expected'] = {'text_any': ['Sheet']}
|
|
195
|
+
utg, _, _ = module.normalize_facts(base, self.root)
|
|
196
|
+
from build_page_json import build_page_plan
|
|
197
|
+
plan = build_page_plan(utg, module.build_click_paths(utg), utg_path='utg.json', click_paths_path='click_paths.json')
|
|
198
|
+
self.assertFalse(next(page for page in plan['pages'] if page['node_id'] == 'sheet')['capture'])
|
|
199
|
+
self.assertTrue(any(edge['id'] == 'show-sheet' for edge in utg['links']))
|
|
200
|
+
|
|
201
|
+
def test_manifest_exact_declaration_and_entry(self):
|
|
202
|
+
(self.root / 'AndroidManifest.xml').write_text(
|
|
203
|
+
'<manifest xmlns:android="http://schemas.android.com/apk/res/android">\n'
|
|
204
|
+
'<application><activity android:name=".MainActivity"/></application>\n</manifest>', encoding='utf-8')
|
|
205
|
+
self.graph['nodes'][0]['platformRef'] = {'file': 'AndroidManifest.xml', 'symbol': '.MainActivity'}
|
|
206
|
+
self.graph['nodes'].append({'id': 'manifest-entry', 'kind': 'AppEntry',
|
|
207
|
+
'subtype': 'launcher-activity', 'attrs': {'screen': 'home'}})
|
|
208
|
+
self.graph['entryPoints'] = []
|
|
209
|
+
self.graph['edges'][0]['attrs']['evidence'] = self.ref # Actual producer also emits a single ref.
|
|
210
|
+
base = module.project(self.graph, self.root)
|
|
211
|
+
self.assertEqual(base['graph']['root'], 'home')
|
|
212
|
+
self.assertEqual(next(n for n in base['nodes'] if n['id'] == 'home')['declared_at'], 'AndroidManifest.xml:2')
|
|
213
|
+
|
|
214
|
+
def test_ai_edge_guard_and_capture_readiness(self):
|
|
215
|
+
node = copy.deepcopy(next(n for n in self.base['nodes'] if n['id'] == 'fragment'))
|
|
216
|
+
node['expected'] = {'text_any': ['Fragment']}
|
|
217
|
+
edge = {'id': 'ai:fragment', 'source': 'home', 'target': 'fragment',
|
|
218
|
+
'mechanism': 'fragment-transaction', 'trigger': 'Screen.kt:1', 'confidence': 0.9,
|
|
219
|
+
'guard': {'kind': 'login', 'text': 'requires login'},
|
|
220
|
+
'args': {'action': 'tap', 'selector': {'text': 'Fragment'}}}
|
|
221
|
+
merged = module.apply_discoveries(self.base, self.discoveries([
|
|
222
|
+
self.operation('node', 'replace', 'fragment', node),
|
|
223
|
+
self.operation('edge', 'add', 'ai:fragment', edge)]), self.root, self.fingerprint)
|
|
224
|
+
utg, _, _ = module.normalize_facts(merged, self.root)
|
|
225
|
+
paths = module.build_click_paths(utg)
|
|
226
|
+
from build_page_json import build_page_plan
|
|
227
|
+
plan = build_page_plan(utg, paths, utg_path='utg.json', click_paths_path='click_paths.json')
|
|
228
|
+
page = next(n for n in plan['pages'] if n['node_id'] == 'fragment')
|
|
229
|
+
self.assertTrue(page['capture'])
|
|
230
|
+
self.assertEqual(page['steps'][-1]['guard']['kind'], 'login')
|
|
231
|
+
next(e for e in merged['edges'] if e['id'] == 'ai:fragment')['args'] = {'nl_hint': 'click Fragment'}
|
|
232
|
+
utg, _, _ = module.normalize_facts(merged, self.root)
|
|
233
|
+
plan = build_page_plan(utg, module.build_click_paths(utg), utg_path='utg.json', click_paths_path='click_paths.json')
|
|
234
|
+
self.assertFalse(next(n for n in plan['pages'] if n['node_id'] == 'fragment')['capture'])
|
|
235
|
+
|
|
236
|
+
def test_add_replace_exclude_audit(self):
|
|
237
|
+
node = copy.deepcopy(self.base['nodes'][0])
|
|
238
|
+
node.update(id='ai:new', label='New')
|
|
239
|
+
detail = copy.deepcopy(next(n for n in self.base['nodes'] if n['id'] == 'detail'))
|
|
240
|
+
detail['expected'] = {'text_any': ['Details']}
|
|
241
|
+
operations = [self.operation('node', 'add', 'ai:new', node),
|
|
242
|
+
self.operation('node', 'replace', 'detail', detail),
|
|
243
|
+
self.operation('edge', 'exclude', 'nav2')]
|
|
244
|
+
merged = module.apply_discoveries(self.base, self.discoveries(operations), self.root, self.fingerprint)
|
|
245
|
+
self.assertEqual(len(merged['graph']['discovery_audit']), 3)
|
|
246
|
+
self.assertEqual(len(self.base['edges']), 4)
|
|
247
|
+
self.assertEqual(next(n for n in merged['nodes'] if n['id'] == 'detail')['provenance'], 'ai-source-review')
|
|
248
|
+
|
|
249
|
+
def test_source_review_cannot_claim_runtime_verification(self):
|
|
250
|
+
node = copy.deepcopy(next(n for n in self.base['nodes'] if n['id'] == 'detail'))
|
|
251
|
+
node.update(status='runtime-verified', reached=True, screenshot='fake.png')
|
|
252
|
+
edge = copy.deepcopy(next(e for e in self.base['edges'] if e['id'] == 'nav2'))
|
|
253
|
+
edge.update(status='runtime-verified', reached=True)
|
|
254
|
+
merged = module.apply_discoveries(self.base, self.discoveries([
|
|
255
|
+
self.operation('node', 'replace', 'detail', node),
|
|
256
|
+
self.operation('edge', 'replace', 'nav2', edge)]), self.root, self.fingerprint)
|
|
257
|
+
utg, _, _ = module.normalize_facts(merged, self.root)
|
|
258
|
+
reviewed = [next(n for n in utg['nodes'] if n['id'] == 'detail'),
|
|
259
|
+
next(e for e in utg['links'] if e['id'] == 'nav2')]
|
|
260
|
+
for item in reviewed:
|
|
261
|
+
self.assertEqual(item['status'], 'static-only')
|
|
262
|
+
self.assertEqual(item['provenance'], 'ai-source-review')
|
|
263
|
+
self.assertNotIn('reached', item)
|
|
264
|
+
self.assertNotIn('screenshot', item)
|
|
265
|
+
self.assertEqual(next(n for n in self.base['nodes'] if n['id'] == 'detail')['status'], 'static-only')
|
|
266
|
+
|
|
267
|
+
def test_resolved_findings_preserve_audit(self):
|
|
268
|
+
discoveries = self.discoveries()
|
|
269
|
+
discoveries['resolved_findings'] = [{'id': self.base['unresolved'][0]['id'],
|
|
270
|
+
'reason': 'verified boundary', 'evidence': 'Screen.kt:1'}]
|
|
271
|
+
merged = module.apply_discoveries(self.base, discoveries, self.root, self.fingerprint)
|
|
272
|
+
self.assertEqual(len(merged['unresolved']), len(self.base['unresolved']) - 1)
|
|
273
|
+
self.assertEqual(merged['graph']['resolved_findings'], discoveries['resolved_findings'])
|
|
274
|
+
discoveries['resolved_findings'][0]['id'] = 'unknown'
|
|
275
|
+
with self.assertRaises(module.UtgError):
|
|
276
|
+
module.apply_discoveries(self.base, discoveries, self.root, self.fingerprint)
|
|
277
|
+
|
|
278
|
+
def test_reject_invalid_stale_duplicate_and_dangling(self):
|
|
279
|
+
bad = self.discoveries()
|
|
280
|
+
bad['input_fingerprint'] = 'old'
|
|
281
|
+
with self.assertRaises(module.UtgError):
|
|
282
|
+
module.apply_discoveries(self.base, bad, self.root, self.fingerprint)
|
|
283
|
+
for operations in [[self.operation('node', 'exclude', 'home')],
|
|
284
|
+
[self.operation('edge', 'exclude', 'nav1')] * 2,
|
|
285
|
+
[self.operation('node', 'add', 'home', self.base['nodes'][0])]]:
|
|
286
|
+
with self.assertRaises(module.UtgError):
|
|
287
|
+
module.apply_discoveries(self.base, self.discoveries(operations), self.root, self.fingerprint)
|
|
288
|
+
op = self.operation('edge', 'exclude', 'nav1')
|
|
289
|
+
op['evidence'] = 'Screen.kt:99'
|
|
290
|
+
with self.assertRaises(module.UtgError):
|
|
291
|
+
module.apply_discoveries(self.base, self.discoveries([op]), self.root, self.fingerprint)
|
|
292
|
+
old = module.source_digest(self.root)
|
|
293
|
+
(self.root / 'Screen.kt').write_text('changed\n')
|
|
294
|
+
self.assertNotEqual(old, module.source_digest(self.root))
|
|
295
|
+
|
|
296
|
+
def test_ambiguous_root_not_guessed(self):
|
|
297
|
+
self.graph['entryPoints'].append({'id': 'launch2', 'kind': 'app-launch', 'sourceRef': 'detail', 'resolution': 'resolved'})
|
|
298
|
+
self.assertEqual(module.project(self.graph, self.root)['graph']['root'], '')
|
|
299
|
+
|
|
300
|
+
def test_end_to_end_determinism_offline_and_readonly(self):
|
|
301
|
+
graph_path = Path(self.temp.name) / 'graph.json'
|
|
302
|
+
graph_path.write_text(json.dumps(self.graph), encoding='utf-8')
|
|
303
|
+
original = graph_path.read_bytes()
|
|
304
|
+
ws = Path(self.temp.name) / 'utg'
|
|
305
|
+
command = [sys.executable, str(SCRIPT), '--project-root', str(self.root), '--appgraph', str(graph_path), '--workspace', str(ws)]
|
|
306
|
+
subprocess.run(command, check=True, capture_output=True)
|
|
307
|
+
review = json.loads((ws / 'review-worklist.json').read_text(encoding='utf-8'))
|
|
308
|
+
self.assertEqual(review['navigation_review']['status'], 'pending')
|
|
309
|
+
self.assertEqual(review['navigation_review']['total_findings'], 1)
|
|
310
|
+
self.assertEqual(len(review['navigation_candidates']), 4)
|
|
311
|
+
outputs = ['utg.json', 'click_paths.json', 'page.json', 'utg.html']
|
|
312
|
+
before = {n: (ws / n).read_bytes() for n in outputs}
|
|
313
|
+
subprocess.run(command + ['--force'], check=True, capture_output=True)
|
|
314
|
+
self.assertEqual(before, {n: (ws / n).read_bytes() for n in outputs})
|
|
315
|
+
self.assertEqual(graph_path.read_bytes(), original)
|
|
316
|
+
plan = json.loads((ws / 'page.json').read_text())
|
|
317
|
+
self.assertFalse(any(n['capture'] for n in plan['pages']))
|
|
318
|
+
html = (ws / 'utg.html').read_text(encoding='utf-8')
|
|
319
|
+
self.assertNotIn('<script src=', html)
|
|
320
|
+
self.assertIn('source-filter', html)
|
|
321
|
+
for i, script in enumerate(re.findall(r'<script>(.*?)</script>', html, re.S)):
|
|
322
|
+
js = Path(self.temp.name) / f'html-{i}.js'
|
|
323
|
+
js.write_text(script, encoding='utf-8')
|
|
324
|
+
result = subprocess.run(['node', '--check', str(js)], capture_output=True, text=True)
|
|
325
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
326
|
+
# The existing TS consumer validates this static plan without ADB.
|
|
327
|
+
result = subprocess.run(['node', str(module.SHARED / 'capture_from_page_json.ts'),
|
|
328
|
+
'--page-json', str(ws / 'page.json'), '--output-workspace', str(ws / 'captures'), '--dry-run'],
|
|
329
|
+
capture_output=True, text=True)
|
|
330
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
331
|
+
self.assertFalse((ws / 'captures').exists())
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
if __name__ == '__main__':
|
|
335
|
+
unittest.main()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Exercise a generated offline UTG in a local Chromium browser; no devices."""
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
parser = argparse.ArgumentParser()
|
|
13
|
+
parser.add_argument('--html', required=True, type=Path)
|
|
14
|
+
parser.add_argument('--browser', required=True, type=Path)
|
|
15
|
+
args = parser.parse_args()
|
|
16
|
+
original = args.html.read_bytes()
|
|
17
|
+
html = original.decode('utf-8')
|
|
18
|
+
# Add a test-only self-edge to the in-memory visualization, leaving facts intact.
|
|
19
|
+
html = html.replace('const REVIEW =', "GRAPH.links.push({...GRAPH.links[0], id:'visual-self-loop', source:GRAPH.nodes[1].id, target:GRAPH.nodes[1].id});\nconst REVIEW =", 1)
|
|
20
|
+
probe = r"""<script>
|
|
21
|
+
setTimeout(() => {
|
|
22
|
+
const checks = {};
|
|
23
|
+
const links = [...document.querySelectorAll('path.link')];
|
|
24
|
+
checks.distinctPaths = links.length > 1 && new Set(links.map(link => link.getAttribute('d'))).size === links.length;
|
|
25
|
+
checks.selfLoop = links.some(link => link.getAttribute('d').includes(' C'));
|
|
26
|
+
checks.logicalEntry = document.getElementById('legend-items').textContent.includes('逻辑入口');
|
|
27
|
+
document.querySelector('path.link-hit').dispatchEvent(new MouseEvent('click', {bubbles:true}));
|
|
28
|
+
checks.edgeDetail = document.getElementById('detail-content').style.display !== 'none';
|
|
29
|
+
checks.edgeEvidence = document.getElementById('detail-content').textContent.includes('appgraph');
|
|
30
|
+
const filter = document.getElementById('source-filter');
|
|
31
|
+
for (const source of ['ai-source-review', 'appgraph']) {
|
|
32
|
+
filter.value = source; filter.dispatchEvent(new Event('change'));
|
|
33
|
+
checks[source] = [...document.querySelectorAll('g.node')].every(node =>
|
|
34
|
+
(node.style.display !== 'none') === (node.__data__.provenance === source));
|
|
35
|
+
}
|
|
36
|
+
const result = document.createElement('pre'); result.id='utg-qa-result'; result.textContent=JSON.stringify(checks); document.body.appendChild(result);
|
|
37
|
+
}, 1500);
|
|
38
|
+
</script>"""
|
|
39
|
+
html = html.replace('</body>', probe + '</body>', 1)
|
|
40
|
+
with tempfile.TemporaryDirectory(prefix='d2h-offline-html-') as directory:
|
|
41
|
+
folder = Path(directory).resolve()
|
|
42
|
+
test_html = folder / 'utg.html'
|
|
43
|
+
test_html.write_text(html, encoding='utf-8')
|
|
44
|
+
result = subprocess.run([str(args.browser.resolve()), '--headless', '--disable-gpu',
|
|
45
|
+
'--no-first-run', '--no-default-browser-check', '--disable-background-networking',
|
|
46
|
+
'--disable-component-update', '--disable-extensions', '--proxy-server=127.0.0.1:9',
|
|
47
|
+
f'--user-data-dir={folder / "profile"}', '--virtual-time-budget=2500',
|
|
48
|
+
'--dump-dom', test_html.as_uri()], capture_output=True, timeout=45, check=True)
|
|
49
|
+
match = re.search(r'<pre id="utg-qa-result">(.*?)</pre>', result.stdout.decode('utf-8', errors='replace'))
|
|
50
|
+
if not match:
|
|
51
|
+
raise AssertionError('Offline renderer or browser probe did not execute')
|
|
52
|
+
checks = json.loads(match.group(1))
|
|
53
|
+
if not all(checks.values()):
|
|
54
|
+
raise AssertionError(checks)
|
|
55
|
+
assert args.html.read_bytes() == original, 'Browser verification must not alter the input HTML'
|
|
56
|
+
print(json.dumps({'status': 'passed', 'checks': checks}, ensure_ascii=False))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == '__main__':
|
|
60
|
+
main()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Explicit local AppGraph CLI -> UTG integration; never clones or updates repositories."""
|
|
2
|
+
import argparse
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sha(path):
|
|
13
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main():
|
|
17
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
18
|
+
parser.add_argument('--appgraph-repo', required=True, type=Path)
|
|
19
|
+
args = parser.parse_args()
|
|
20
|
+
repo = args.appgraph_repo.resolve()
|
|
21
|
+
cli = repo / 'dist/appgraph/cli.js'
|
|
22
|
+
fixture = repo / '__tests__/appgraph/interaction-fixtures'
|
|
23
|
+
if not cli.is_file() or not fixture.is_dir():
|
|
24
|
+
raise RuntimeError('explicit AppGraph checkout must be built and contain the alignment fixture')
|
|
25
|
+
commit = subprocess.check_output(['git', '-C', str(repo), 'rev-parse', 'HEAD'], text=True).strip()
|
|
26
|
+
generate = Path(__file__).resolve().parents[1] / 'scripts/generate.py'
|
|
27
|
+
with tempfile.TemporaryDirectory(prefix='d2h-produced-fixture-') as temporary:
|
|
28
|
+
folder = Path(temporary)
|
|
29
|
+
source = folder / 'interaction-fixtures'
|
|
30
|
+
shutil.copytree(fixture, source, ignore=shutil.ignore_patterns('.codegraph*', '.migration', '.appgraph', 'build'))
|
|
31
|
+
source_files = [p for p in source.rglob('*') if p.is_file()]
|
|
32
|
+
source_before = {str(p.relative_to(source)): sha(p) for p in source_files}
|
|
33
|
+
graph_file = folder / 'app-graph.json'
|
|
34
|
+
workspace = folder / 'utg'
|
|
35
|
+
build = ['node', str(cli), 'build', str(source), '--platform', 'android', '--out', str(graph_file)]
|
|
36
|
+
subprocess.run(build, cwd=repo, check=True, capture_output=True)
|
|
37
|
+
graph_before = sha(graph_file)
|
|
38
|
+
graph = json.loads(graph_file.read_text(encoding='utf-8'))
|
|
39
|
+
interaction = graph['interactionGraph']
|
|
40
|
+
assert len(interaction['surfaces']) == 4, 'coarse/fine Activity and Compose identities must align'
|
|
41
|
+
navigation = [t for t in interaction['transitions'] if t['operation'] == 'navigate' and t['resolution'] == 'resolved']
|
|
42
|
+
assert len(navigation) == 2, 'two controls must retain two transitions without duplicate trace edges'
|
|
43
|
+
assert len({t['actionRef'] for t in navigation}) == 2
|
|
44
|
+
assert any(t['operation'] == 'back' and t['target']['kind'] == 'back' for t in interaction['transitions'])
|
|
45
|
+
assert any(t['operation'] == 'navhost-start' for t in interaction['transitions'])
|
|
46
|
+
command = [sys.executable, str(generate), '--project-root', str(source), '--appgraph', str(graph_file), '--workspace', str(workspace)]
|
|
47
|
+
subprocess.run(command, check=True, capture_output=True)
|
|
48
|
+
outputs = {p.name: sha(p) for p in workspace.iterdir() if p.is_file()}
|
|
49
|
+
subprocess.run(command + ['--force'], check=True, capture_output=True)
|
|
50
|
+
assert outputs == {p.name: sha(p) for p in workspace.iterdir() if p.is_file()}, 'derived outputs must be byte deterministic'
|
|
51
|
+
assert sha(graph_file) == graph_before, 'UTG must not modify AppGraph input'
|
|
52
|
+
assert source_before == {str(p.relative_to(source)): sha(p) for p in source_files}, 'source files must remain unchanged'
|
|
53
|
+
utg = json.loads((workspace / 'utg.json').read_text(encoding='utf-8'))
|
|
54
|
+
plan = json.loads((workspace / 'page.json').read_text(encoding='utf-8'))
|
|
55
|
+
assert utg['graph']['root'] == 'utg:entry-root'
|
|
56
|
+
assert len(utg['nodes']) == 5 and len(utg['links']) == 5
|
|
57
|
+
assert all(link.get('id') and link['status'] == 'static-only' for link in utg['links'])
|
|
58
|
+
assert plan['summary']['capture_planned'] == 0, 'missing arrival/selectors cannot become executable'
|
|
59
|
+
capture = generate.parents[2] / 'd2h-utg-ai-generate/scripts/capture_from_page_json.ts'
|
|
60
|
+
dry_run = subprocess.run(['node', str(capture), '--page-json', str(workspace / 'page.json'),
|
|
61
|
+
'--output-workspace', str(folder / 'captures'), '--dry-run'],
|
|
62
|
+
check=True, capture_output=True, text=True)
|
|
63
|
+
assert not (folder / 'captures').exists(), 'dry-run must not create runtime artifacts'
|
|
64
|
+
preview = json.loads(dry_run.stdout)
|
|
65
|
+
assert preview['selected_count'] == 0 and preview['nodes'] == [], 'capture CLI must agree on the non-executable plan'
|
|
66
|
+
assert 'boundary_transitions' in (workspace / 'utg.html').read_text(encoding='utf-8')
|
|
67
|
+
subprocess.run(build, cwd=repo, check=True, capture_output=True)
|
|
68
|
+
assert sha(graph_file) == graph_before, 'AppGraph repeated analysis must be deterministic'
|
|
69
|
+
# Coordinate-only edits must not change the interaction identity sets.
|
|
70
|
+
main_source = source / 'app/src/main/java/fixture/alignment/MainActivity.kt'
|
|
71
|
+
main_source.write_text('\n\n' + main_source.read_text(encoding='utf-8'), encoding='utf-8')
|
|
72
|
+
subprocess.run(build, cwd=repo, check=True, capture_output=True)
|
|
73
|
+
moved = json.loads(graph_file.read_text(encoding='utf-8'))['interactionGraph']
|
|
74
|
+
for collection in ('surfaces', 'controlBindings', 'entries', 'transitions'):
|
|
75
|
+
assert {o['id'] for o in interaction[collection]} == {o['id'] for o in moved[collection]}, json.dumps({
|
|
76
|
+
'collection': collection, 'before': interaction[collection], 'after': moved[collection],
|
|
77
|
+
'original_screens': [(n['id'], n.get('attrs')) for n in graph['nodes'] if n['kind'] == 'Screen'],
|
|
78
|
+
'changed_screens': [(n['id'], n.get('attrs')) for n in json.loads(graph_file.read_text(encoding='utf-8'))['nodes'] if n['kind'] == 'Screen'],
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
# Prove AST branch facts survive rule execution, lifting and the UTG consumer.
|
|
82
|
+
guarded_source = main_source.read_text(encoding='utf-8').replace(
|
|
83
|
+
'onClick = { nav.navigate("details") }', 'onClick = { if (allowed) nav.navigate("details") }', 1)
|
|
84
|
+
main_source.write_text(guarded_source, encoding='utf-8')
|
|
85
|
+
subprocess.run(build, cwd=repo, check=True, capture_output=True)
|
|
86
|
+
guarded_graph = json.loads(graph_file.read_text(encoding='utf-8'))['interactionGraph']
|
|
87
|
+
guarded_navigation = [t for t in guarded_graph['transitions']
|
|
88
|
+
if t['operation'] == 'navigate' and t['resolution'] == 'resolved']
|
|
89
|
+
assert len(guarded_navigation) == 2, 'guard reconciliation cannot duplicate or erase navigation facts'
|
|
90
|
+
assert any(t['conditions'] and t['conditions'][0]['expression'] == 'allowed' for t in guarded_navigation)
|
|
91
|
+
guarded_workspace = folder / 'utg-guarded'
|
|
92
|
+
guarded_command = [sys.executable, str(generate), '--project-root', str(source), '--appgraph', str(graph_file), '--workspace', str(guarded_workspace)]
|
|
93
|
+
subprocess.run(guarded_command, check=True, capture_output=True)
|
|
94
|
+
guarded_utg = json.loads((guarded_workspace / 'utg.json').read_text(encoding='utf-8'))
|
|
95
|
+
assert any(link.get('guard', {}).get('text') == '(allowed)' for link in guarded_utg['links'] if link.get('guard'))
|
|
96
|
+
|
|
97
|
+
# Source-derived overlay facts must become actual interaction and UTG edges.
|
|
98
|
+
overlay_source = guarded_source.replace('import androidx.compose.material3.Button',
|
|
99
|
+
'import androidx.compose.material3.Button\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.ModalBottomSheet')
|
|
100
|
+
overlay_source = overlay_source.replace('@Composable fun HomePage(nav: NavController) {',
|
|
101
|
+
'@Composable fun HomePage(nav: NavController) {\n'
|
|
102
|
+
' Button(onClick = { if (allowed) uiState = uiState.copy(showDialog = true) }) {}\n'
|
|
103
|
+
' Button(onClick = { uiState = uiState.copy(showSheet = true) }) {}\n'
|
|
104
|
+
' if (uiState.showDialog) ConfirmDialog()\n'
|
|
105
|
+
' if (uiState.showSheet) DetailsBottomSheet()')
|
|
106
|
+
overlay_source += '\n@Composable fun ConfirmDialog() { AlertDialog(onDismissRequest = {}, confirmButton = {}) }\n'
|
|
107
|
+
overlay_source += '@Composable fun DetailsBottomSheet() { ModalBottomSheet(onDismissRequest = {}) {} }\n'
|
|
108
|
+
main_source.write_text(overlay_source, encoding='utf-8')
|
|
109
|
+
subprocess.run(build, cwd=repo, check=True, capture_output=True)
|
|
110
|
+
overlay_graph = json.loads(graph_file.read_text(encoding='utf-8'))['interactionGraph']
|
|
111
|
+
shown = [t for t in overlay_graph['transitions'] if t['operation'] == 'show' and t['resolution'] == 'resolved']
|
|
112
|
+
overlay_kinds = {surface['id']: surface['kind'] for surface in overlay_graph['surfaces']}
|
|
113
|
+
assert {'dialog', 'bottom-sheet'} <= {overlay_kinds[t['target']['surfaceId']] for t in shown}
|
|
114
|
+
assert any(t['conditions'] and t['conditions'][0]['expression'] == 'allowed' for t in shown)
|
|
115
|
+
overlay_workspace = folder / 'utg-overlays'
|
|
116
|
+
subprocess.run([sys.executable, str(generate), '--project-root', str(source), '--appgraph', str(graph_file),
|
|
117
|
+
'--workspace', str(overlay_workspace)], check=True, capture_output=True)
|
|
118
|
+
overlay_utg = json.loads((overlay_workspace / 'utg.json').read_text(encoding='utf-8'))
|
|
119
|
+
assert {t['id'] for t in shown} <= {link['id'] for link in overlay_utg['links']}
|
|
120
|
+
main_source.write_text('\n\n' + overlay_source, encoding='utf-8')
|
|
121
|
+
subprocess.run(build, cwd=repo, check=True, capture_output=True)
|
|
122
|
+
moved_overlays = json.loads(graph_file.read_text(encoding='utf-8'))['interactionGraph']
|
|
123
|
+
for collection in ('surfaces', 'controlBindings', 'entries', 'transitions'):
|
|
124
|
+
assert {fact['id'] for fact in overlay_graph[collection]} == {fact['id'] for fact in moved_overlays[collection]}, collection + ' overlay coordinate stability'
|
|
125
|
+
print(json.dumps({'status': 'passed', 'appgraph_commit': commit, 'appgraph_input_sha256': graph_before,
|
|
126
|
+
'surfaces': 4, 'parallel_navigation': 2, 'utg_nodes': 5, 'utg_edges': 5,
|
|
127
|
+
'deterministic': True, 'readonly': True, 'coordinate_stability': True, 'guarded_navigation': True, 'overlay_projection': True}))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == '__main__':
|
|
131
|
+
main()
|
|
@@ -1,20 +1,46 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: harmonyos-migration-playbook
|
|
3
|
-
description: Diagnose
|
|
3
|
+
description: Diagnose Android-to-HarmonyOS migration failures, check whether verified lessons apply, and return the smallest scoped fix or recommendation for ArkTS compilation, HarmonyOS runtime, UI automation, protocol E2E, and Windows toolchains. Use during implementation, failure recovery, validation, or retrospectives; do not control checkpoints or outer workflow state.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# HarmonyOS migration playbook
|
|
7
7
|
|
|
8
8
|
Use verified experience to shorten diagnosis without replacing current-project evidence.
|
|
9
9
|
|
|
10
|
+
## Inputs and domain result
|
|
11
|
+
|
|
12
|
+
Accept the current symptom, in-scope files, SDK/tool versions, relevant logs or managed evidence, and any project lesson candidates. Do not infer success from an earlier project.
|
|
13
|
+
|
|
14
|
+
Return the same result shape whether called directly or from an orchestrator:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"status": "completed | partial | failed",
|
|
19
|
+
"changedFiles": [],
|
|
20
|
+
"sourceFiles": [],
|
|
21
|
+
"evidence": [],
|
|
22
|
+
"domainChecks": [],
|
|
23
|
+
"unresolved": [],
|
|
24
|
+
"verificationRequests": [],
|
|
25
|
+
"suggestedRemediation": [],
|
|
26
|
+
"details": {
|
|
27
|
+
"validatedRootCause": null,
|
|
28
|
+
"applicability": {},
|
|
29
|
+
"lessonCandidates": []
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`status` describes diagnosis and any in-scope fix only. `validatedRootCause` requires current-project evidence. Put repository-level build, test, device, or review needs in `verificationRequests`; do not choose the caller's checkpoint or workflow transition.
|
|
35
|
+
|
|
10
36
|
## Required operating loop
|
|
11
37
|
|
|
12
38
|
1. Identify the current symptom and task boundary.
|
|
13
39
|
2. Read only the relevant reference below.
|
|
14
40
|
3. Check that the recorded applicability conditions match the current SDK, UI stack, runtime and code path.
|
|
15
41
|
4. Reproduce or inspect evidence before applying a fix. Historical experience is a hypothesis until confirmed in the current project.
|
|
16
|
-
5.
|
|
17
|
-
6.
|
|
42
|
+
5. If the request authorizes a change, apply the smallest in-scope fix; otherwise return the smallest concrete recommendation.
|
|
43
|
+
6. Perform only domain-local checks available within scope. Return repository-level verification as `verificationRequests` and a reusable `lessonCandidates` entry only when the root cause and fix are supported by current-project evidence.
|
|
18
44
|
|
|
19
45
|
## Reference routing
|
|
20
46
|
|
|
@@ -42,6 +68,7 @@ Prefer narrower classifications. Public does not mean universal: a project-valid
|
|
|
42
68
|
- Do not convert implementation defects into platform limitations or external blockers.
|
|
43
69
|
- Do not claim a lesson is verified from a report, comment, code existence or compile success alone when runtime behavior is relevant.
|
|
44
70
|
- Do not modify the public playbook from every task. First record the lesson in the current project; a reviewed, non-obvious and well-verified single-project lesson may be promoted as `project-validated` under `lesson-lifecycle.md` instead of waiting invisibly for another project.
|
|
71
|
+
- Do not stage or commit files, create a checkpoint, update the migration report, call `complete`, schedule full regression, or decide an outer workflow state. The caller owns those actions.
|
|
45
72
|
|
|
46
73
|
## Quick triage
|
|
47
74
|
|
|
@@ -6,7 +6,7 @@ Read `.migration/experience/lessons.md` when it exists. Select lessons by task t
|
|
|
6
6
|
|
|
7
7
|
## Record after validation and commit
|
|
8
8
|
|
|
9
|
-
The
|
|
9
|
+
The playbook returns a lesson candidate only after the root cause and fix are supported by current-project evidence. The caller decides whether validation and checkpoint requirements are satisfied and whether to persist or promote the candidate. Use this structure:
|
|
10
10
|
|
|
11
11
|
```markdown
|
|
12
12
|
## <short title>
|
|
@@ -22,13 +22,13 @@ The orchestrator records a lesson only after the implementation is validated and
|
|
|
22
22
|
|
|
23
23
|
Skip routine success, generic coding advice, speculative causes, duplicate lessons and details recoverable from the commit alone.
|
|
24
24
|
|
|
25
|
-
If the project tracks `.migration/experience/lessons.md`,
|
|
25
|
+
If the project tracks `.migration/experience/lessons.md`, the caller may persist an accepted candidate there. The migration workspace `<android-project>-migration/` lives outside the Android Git repository, so the lesson remains workspace-local run memory unless that workspace is versioned independently.
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
This reference does not inspect, create, or time checkpoints and never recommends broad staging merely to capture a lesson.
|
|
28
28
|
|
|
29
29
|
## Promote to the public playbook
|
|
30
30
|
|
|
31
|
-
`.migration/experience/lessons.md` is the source-project evidence log, not the cross-project knowledge base.
|
|
31
|
+
`.migration/experience/lessons.md` is the source-project evidence log, not the cross-project knowledge base. After the caller confirms acceptance, review whether a lesson is non-obvious, reproducible, useful beyond one task, bounded by explicit applicability conditions, and backed by real verification evidence. Only then may its generalized form enter the relevant public Playbook reference as `project-validated`.
|
|
32
32
|
|
|
33
33
|
Public entries must contain:
|
|
34
34
|
|