@harmonyos-arkts/d2h 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +78 -44
  2. package/agents/android-to-hmos-00-orchestrator.md +34 -28
  3. package/agents/d2h.md +266 -95
  4. package/dist/bin/install-opencode.js +137 -43
  5. package/dist/src/install-opencode.js +44 -0
  6. package/dist/src/install-vscode.js +226 -0
  7. package/dist/src/toolchain.js +87 -0
  8. package/docs/environment-check.md +86 -0
  9. package/opencode/agents.json +1 -1
  10. package/opencode/vscode-d2h-package.json +7 -0
  11. package/opencode/vscode-d2h-plugin.mjs +29 -0
  12. package/package.json +6 -4
  13. package/schemas/task-envelope.schema.json +58 -4
  14. package/schemas/task-plan.schema.json +281 -63
  15. package/scripts/check-environment.mjs +321 -0
  16. package/skills/d2h-hypium-forge/SKILL.md +252 -0
  17. package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -0
  18. package/skills/d2h-hypium-forge/references/official-api.md +135 -0
  19. package/skills/d2h-hypium-forge/references/pitfalls.md +98 -0
  20. package/skills/d2h-hypium-forge/references/testability-template.md +71 -0
  21. package/skills/d2h-hypium-forge/scripts/_common_template.py +614 -0
  22. package/skills/d2h-hypium-forge/scripts/analyze_results.py +156 -0
  23. package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -0
  24. package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -0
  25. package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -0
  26. package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -0
  27. package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -0
  28. package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -0
  29. package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -0
  30. package/skills/d2h-hypium-forge/scripts/precondition.py +140 -0
  31. package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -0
  32. package/skills/d2h-hypium-forge/scripts/run_direct.py +392 -0
  33. package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -0
  34. package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -0
  35. package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -0
  36. package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -0
  37. package/skills/d2h-utg-ai-generate/SKILL.md +128 -0
  38. package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -0
  39. package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -0
  40. package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -0
  41. package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -0
  42. package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -0
  43. package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -0
  44. package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -0
  45. package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -0
  46. package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -0
  47. package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -0
  48. package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -0
  49. package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -0
  50. package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -0
  51. package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -0
  52. package/skills/d2h-utg-static-generate/SKILL.md +73 -0
  53. package/skills/d2h-utg-static-generate/references/discoveries.md +38 -0
  54. package/skills/d2h-utg-static-generate/scripts/generate.py +499 -0
  55. package/skills/d2h-utg-static-generate/tests/test_static.py +335 -0
  56. package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -0
  57. package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -0
  58. package/skills/harmonyos-migration-playbook/references/ui-automation.md +4 -3
  59. package/skills/harmonyos-migration-playbook/references/windows-environment.md +1 -1
  60. package/skills/managing-d2h-toolchain/SKILL.md +23 -0
  61. package/skills/managing-d2h-toolchain/environment-requirements.json +16 -0
  62. package/skills/managing-d2h-toolchain/toolchain.json +16 -0
  63. package/skills/preparing-migration-workspace/SKILL.md +9 -7
  64. package/agents/d2h_v2.md +0 -351
  65. package/skills/preparing-migration-workspace/toolchain.json +0 -19
@@ -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()
@@ -13,13 +13,12 @@ Use device automation as evidence, not as a substitute for understanding the UI.
13
13
  Use the managed evidence commands for collection and packaging:
14
14
 
15
15
  ```text
16
- droid2hmos evidence capture <android|harmony> <android-project> --name <name> [--target <id>]
17
- droid2hmos evidence flow android <android-project> --name <name> [--task <task>]
16
+ droid2hmos evidence capture harmony <android-project> --name <name> [--target <id>]
18
17
  droid2hmos evidence flow harmony <android-project> --name <name> (--class <class> [--case <case>] | --all)
19
18
  droid2hmos evidence compare <android-project> --name <name> --android <android-evidence-dir> --harmony <harmony-evidence-dir>
20
19
  ```
21
20
 
22
- `capture` collects the screenshot, UI tree/`dumpLayout`, and device logs. `flow` creates before/after captures around a configured Android test or HarmonyOS Hypium test. `compare` creates a dual-platform evidence record; it supports human review but does not by itself prove semantic equivalence. Preserve command output paths as report evidence.
21
+ `capture` collects the screenshot, UI tree/`dumpLayout`, and device logs. `flow` creates before/after captures around a HarmonyOS Hypium test (Android-side flow has been removed with the Android runtime dependency). `compare` creates a dual-platform evidence record from existing evidence directories; it supports human review but does not by itself prove semantic equivalence. Preserve command output paths as report evidence.
23
22
 
24
23
  For text input, verify focus and current value after each operation. A click can hit an input field or IME candidate and inject unintended text. Close the IME only with a method proven in the current environment.
25
24
 
@@ -39,6 +38,8 @@ White/non-white screenshot heuristics only detect gross rendering failures. They
39
38
 
40
39
  ## Prefer semantic Hypium UI tests over coordinate injection
41
40
 
41
+ > Update (2026-10): migration-flow Hypium test case creation and execution are now standardized on the Python-based `d2h-hypium-forge` pipeline (`.hypium_forge/` assets; iteration via its `run_direct.py`, managed acceptance via `droid2hmos test harmony --runner python --test-list ...`). The ArkTS `entry_test` module workflow below remains valid only as a project-verified lesson for existing ArkTS assets; the principle "semantic location over coordinate injection" and the exception boundaries continue to apply.
42
+
42
43
  - **Evidence level:** project-validated
43
44
  - **Applies when:** HarmonyOS user flows need repeatable device-side verification and the relevant ArkUI controls can expose semantic nodes. Verified with DevEco SDK 6.0.2(22) and `@ohos/hypium` 1.0.25; recheck APIs on other versions.
44
45
  - **Symptom:** a migration relies on repeated coordinate-based device injection, requiring layout dumps and manual coordinate calculation at every step. Scrolling or layout changes cause misclicks, stale-frame dumps and evidence that cannot be replayed reliably.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Check actual paths rather than trusting environment variables:
4
4
 
5
- - Android SDK/JDK/DevEco SDK directories exist and match the invoked tool.
5
+ - JDK/DevEco SDK directories exist and match the invoked tool.
6
6
  - Included Gradle builds have the local configuration they require.
7
7
  - Proxy settings apply to Gradle/Node and do not unexpectedly intercept device or mail ports.
8
8
  - Node/npm pairs come from compatible installations.
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: managing-d2h-toolchain
3
+ description: 安装、检查或升级 D2H 所依赖的 AppGraph 与 droid2hmos CLI 时使用。统一读取 D2H 包内的精确版本锁,保留本地 npm link,并把安装结果交还迁移主流程;其他 Skill 不执行工具安装。
4
+ ---
5
+
6
+ # 管理 D2H 工具链
7
+
8
+ 本 Skill 是 AppGraph、droid2hmos 依赖声明、版本维护和安装的唯一流程入口。**依赖包、精确版本及命令能力只在本目录的 `toolchain.json` 声明**;`environment-requirements.json` 提供对应的主机检查边界。其他 Agent/Skill 不声明这些依赖或版本,也不执行 npm 安装或升级。`d2h` CLI 只解析本 Skill 的清单并实现确定性的探测、安装和校验;本 Skill 负责选择时机、目标 Agent 和失败分流。
9
+
10
+ ## 选择 D2H 版本
11
+
12
+ - 已安装的 OpenCode/DevEco 定义读取配置根目录的 `.d2h-installation.json`;VS Code 定义读取扩展 `resources/opencode/plugin/d2h/.d2h-installation.json`。其中的 `packageVersion` 是本次调用 D2H CLI 的精确版本。
13
+ - `d2h` 命令可用时先执行 `d2h --help`,确认支持 `install`、`upgrade`、`doctor`。需要通过 npx 调用时使用 `npx --yes @harmonyos-arkts/d2h@<packageVersion>`;Windows 使用 `npx.cmd`。不可用或记录缺失时,请调用方提供已批准的**精确 D2H 版本**完成首次安装,不猜测 `latest`。
14
+ - 源码开发使用当前仓库构建出的 `node dist/bin/install-opencode.js`,不要把 npm 上同版本的旧包误当成本地源码。修改 D2H 依赖版本时只改包内 `skills/managing-d2h-toolchain/toolchain.json`,同时更新环境约束、兼容性测试和发布版本。
15
+
16
+ ## 检查与执行
17
+
18
+ 1. 确定目标 `--agent opencode|deveco|vscode`;VS Code 可通过 `--extension-dir` 指定扩展根目录。先运行 `d2h upgrade --check --agent <agent>` 只读预览,再运行 `d2h doctor --agent <agent>` 检查定义完整性、精确版本、命令能力与主机环境。
19
+ 2. 若缺少定义或受管 CLI,运行 `d2h install --agent <agent>`;已安装但需要应用当前锁定组合时运行 `d2h upgrade --agent <agent>`。默认保留开发者的 npm link;只有调用方明确指定 `--force` 时,才重新安装清单中的所有精确版本并替换全局 link,链接指向的源码目录保持不变。
20
+ 3. 安装后重新运行 `doctor`,核对 `.d2h-installation.json`、AppGraph/droid2hmos 的版本及能力,再把状态交给 `preparing-migration-workspace`。项目级 `droid2hmos preflight` 仍由准备 Skill 执行。
21
+ 4. 只想检查主机工具时使用 `d2h --check-env --output-dir <目录>`;读取生成的 JSON/Markdown 报告,不把 DevEco 登录、SDK 或设备缺失误判为需要升级 AppGraph/droid2hmos。
22
+
23
+ 安装失败时保留命令输出和失败阶段。包不可达、版本不匹配、能力不符分别报告;不得把失败掩盖成“使用已安装版本继续”。不允许在其他 Skill 中直接执行 `npm install -g`、`npm update` 或以浮动版本重新安装这两个 CLI。
@@ -0,0 +1,16 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "tools": {
4
+ "node": { "minimum": "22.5.0", "maximumExclusive": "25.0.0" },
5
+ "python": { "minimum": "3.10.0" },
6
+ "git": null,
7
+ "npm": null,
8
+ "java": null,
9
+ "python-json5": null,
10
+ "appgraph": { "minimum": "0.0.10", "maximumExclusive": "0.0.11" },
11
+ "droid2hmos": { "minimum": "0.0.11", "maximumExclusive": "0.0.12" },
12
+ "hvigor": null,
13
+ "ohpm": null,
14
+ "devecocli": null
15
+ }
16
+ }