@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,369 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ SKILL = Path(__file__).resolve().parents[1]
12
+ BUILD = SKILL / "scripts" / "build_utg.py"
13
+ VALIDATE = SKILL / "scripts" / "validate_utg.py"
14
+ BUILD_PAGE = SKILL / "scripts" / "build_page_json.py"
15
+ CAPTURE = SKILL / "scripts" / "capture_from_page_json.ts"
16
+
17
+
18
+ class PipelineTest(unittest.TestCase):
19
+ def test_build_and_validate(self) -> None:
20
+ with tempfile.TemporaryDirectory() as temp:
21
+ root = Path(temp)
22
+ project = root / "project"
23
+ workspace = root / "workspace"
24
+ project.mkdir()
25
+ workspace.mkdir()
26
+ (project / "App.kt").write_text(
27
+ "fun root() = Unit\nfun openHome() = Unit\nfun openDialog() = Unit\n",
28
+ encoding="utf-8",
29
+ )
30
+ facts = {
31
+ "schema_version": "d2h-utg-facts/v1",
32
+ "graph": {
33
+ "app": "Demo",
34
+ "package": "com.example.demo",
35
+ "architecture": "compose",
36
+ "root": "MainActivity",
37
+ "start_destination": "home",
38
+ },
39
+ "nodes": [
40
+ {
41
+ "id": "MainActivity", "kind": "activity", "label": "Demo",
42
+ "declared_at": "App.kt:1", "extra": {"launcher": True},
43
+ "expected": {"activity_any": ["com.example.demo"]},
44
+ },
45
+ {
46
+ "id": "home", "kind": "compose-route", "label": "Home",
47
+ "declared_at": "App.kt:2", "expected": {"activity_any": ["com.example.demo"]},
48
+ },
49
+ {
50
+ "id": "overlay:Help", "kind": "dialog", "label": "Help",
51
+ "declared_at": "App.kt:3", "expected": {"text_any": ["Help"]},
52
+ },
53
+ ],
54
+ "edges": [
55
+ {
56
+ "source": "MainActivity", "target": "home",
57
+ "mechanism": "navhost-start", "trigger": "App.kt:2",
58
+ "args": {"action": "default", "label": "Demo", "label_source": "string"},
59
+ "confidence": "high",
60
+ },
61
+ {
62
+ "source": "home", "target": "overlay:Help",
63
+ "mechanism": "open-overlay", "trigger": "App.kt:3",
64
+ "args": {"action": "tap", "label": "Help", "label_source": "string"},
65
+ "confidence": 0.95,
66
+ },
67
+ ],
68
+ "unresolved": [],
69
+ }
70
+ facts_path = workspace / "utg-facts.json"
71
+ facts_path.write_text(json.dumps(facts), encoding="utf-8")
72
+ built = subprocess.run(
73
+ [sys.executable, str(BUILD), "--facts", str(facts_path),
74
+ "--project-root", str(project), "--workspace", str(workspace)],
75
+ check=False, capture_output=True, text=True,
76
+ )
77
+ self.assertEqual(built.returncode, 0, built.stderr)
78
+ checked = subprocess.run(
79
+ [sys.executable, str(VALIDATE), "--utg", str(workspace / "utg.json"),
80
+ "--click-paths", str(workspace / "click_paths.json"),
81
+ "--page-json", str(workspace / "page.json"),
82
+ "--project-root", str(project)],
83
+ check=False, capture_output=True, text=True,
84
+ )
85
+ self.assertEqual(checked.returncode, 0, checked.stderr)
86
+ click_paths = json.loads((workspace / "click_paths.json").read_text())
87
+ self.assertEqual(click_paths["paths"]["overlay:Help"]["depth"], 2)
88
+ self.assertEqual(click_paths["paths"]["overlay:Help"]["path_text"], "Demo > Help")
89
+ page_plan = json.loads((workspace / "page.json").read_text())
90
+ self.assertEqual(page_plan["schema_version"], "d2h-page-plan/v1")
91
+ self.assertEqual(page_plan["source"]["version_name"], "")
92
+ self.assertEqual(page_plan["summary"]["capture_planned"], 2)
93
+ self.assertEqual(
94
+ [page["node_id"] for page in page_plan["pages"]],
95
+ ["MainActivity", "home", "overlay:Help"],
96
+ )
97
+ self.assertEqual(page_plan["pages"][2]["steps"], click_paths["paths"]["overlay:Help"]["steps"])
98
+ capture_keys = [page["capture_key"] for page in page_plan["pages"] if page["capture"]]
99
+ self.assertEqual(len(set(capture_keys)), 2)
100
+ self.assertFalse(page_plan["pages"][0]["capture"])
101
+ self.assertEqual(page_plan["pages"][0]["skip_reason"], "container-node:activity")
102
+ report = json.loads((workspace / "generation-report.json").read_text())
103
+ self.assertEqual(report["normalizations"], ["edges[0].confidence: 'high' -> 0.9"])
104
+ self.assertEqual(report["page_plan"]["capture_planned"], 2)
105
+
106
+ dry_run = subprocess.run(
107
+ ["node", str(CAPTURE), "--page-json", str(workspace / "page.json"),
108
+ "--output-workspace", str(workspace / "ui_info"), "--dry-run"],
109
+ check=False, capture_output=True, text=True,
110
+ )
111
+ self.assertEqual(dry_run.returncode, 0, dry_run.stderr)
112
+ preview = json.loads(dry_run.stdout)
113
+ self.assertEqual(preview["nodes"], ["home", "overlay:Help"])
114
+
115
+ mutated = dict(page_plan)
116
+ mutated["summary"] = dict(page_plan["summary"])
117
+ mutated["summary"]["capture_planned"] = 99
118
+ mutated_path = workspace / "page-mutated.json"
119
+ mutated_path.write_text(json.dumps(mutated), encoding="utf-8")
120
+ rejected = subprocess.run(
121
+ ["node", str(CAPTURE), "--page-json", str(mutated_path),
122
+ "--output-workspace", str(workspace / "ui_info"), "--dry-run"],
123
+ check=False, capture_output=True, text=True,
124
+ )
125
+ self.assertEqual(rejected.returncode, 2)
126
+ self.assertIn("not the deterministic plan", rejected.stderr)
127
+
128
+ rebuilt = workspace / "page-rebuilt.json"
129
+ built_page = subprocess.run(
130
+ [sys.executable, str(BUILD_PAGE), "--utg", str(workspace / "utg.json"),
131
+ "--click-paths", str(workspace / "click_paths.json"),
132
+ "--output", str(rebuilt), "--project-root", str(project)],
133
+ check=False, capture_output=True, text=True,
134
+ )
135
+ self.assertEqual(built_page.returncode, 0, built_page.stderr)
136
+ rebuilt_plan = json.loads(rebuilt.read_text())
137
+ self.assertEqual(rebuilt_plan["pages"], page_plan["pages"])
138
+
139
+ # Runtime orchestration contract without a real device: a fake ADB
140
+ # implements only the commands used by the standalone TS capture.
141
+ fake_adb = root / "fake-adb.py"
142
+ fake_adb.write_text(
143
+ """#!/usr/bin/env python3
144
+ import sys
145
+ from pathlib import Path
146
+ a=sys.argv[1:]
147
+ if len(a)>=2 and a[0]=='-s': a=a[2:]
148
+ state=Path(__file__).with_name('fake-adb-scrolled')
149
+ if a==['devices']:
150
+ print('List of devices attached\\nfake\\tdevice')
151
+ elif a[:2]==['shell','pm']:
152
+ print('package:/data/app/demo.apk')
153
+ elif a[:3]==['shell','dumpsys','window']:
154
+ print('mCurrentFocus=Window{1 u0 com.example.demo/.MainActivity}')
155
+ elif a[:3]==['shell','dumpsys','activity']:
156
+ print('topResumedActivity=ActivityRecord{1 com.example.demo/.MainActivity}')
157
+ elif a[:3]==['shell','dumpsys','package']:
158
+ print('versionCode=1 versionName=')
159
+ elif a[:3]==['shell','wm','density']:
160
+ print('Physical density: 480')
161
+ elif a[:3]==['shell','wm','size']:
162
+ print('Physical size: 1080x1920')
163
+ elif a[:3]==['shell','uiautomator','dump']:
164
+ print('UI hierchary dumped')
165
+ elif a[:2]==['shell','cat']:
166
+ if state.exists():
167
+ print('<hierarchy><node text="" content-desc="" resource-id="" clickable="true" long-clickable="false" scrollable="true" enabled="true" bounds="[0,0][200,100]"><node text="Help" content-desc="" resource-id="" clickable="false" long-clickable="false" enabled="true" bounds="[10,10][90,90]"/><node text="" content-desc="Help" resource-id="" clickable="false" long-clickable="false" enabled="true" bounds="[100,10][190,90]"/></node></hierarchy>')
168
+ else:
169
+ print('<hierarchy><node text="" content-desc="" resource-id="" clickable="false" long-clickable="false" scrollable="true" enabled="true" bounds="[0,0][200,100]"/></hierarchy>')
170
+ elif a[:3]==['shell','input','swipe']:
171
+ state.write_text('scrolled')
172
+ print('OK')
173
+ elif a[:2]==['exec-out','screencap']:
174
+ sys.stdout.buffer.write(b'\\x89PNG\\r\\n\\x1a\\nFAKE')
175
+ else:
176
+ print('OK')
177
+ """,
178
+ encoding="utf-8",
179
+ )
180
+ fake_adb.chmod(0o755)
181
+ # Windows cannot execute a Python shebang. Intercept only this
182
+ # exact fake path in the test process; production ADB is unchanged.
183
+ hook = root / 'fake-adb-hook.mjs'
184
+ hook.write_text(
185
+ "import cp from 'node:child_process';\n"
186
+ "import { syncBuiltinESMExports } from 'node:module';\n"
187
+ "const original = cp.spawnSync;\n"
188
+ f"const fake = {json.dumps(str(fake_adb))};\n"
189
+ f"const python = {json.dumps(sys.executable)};\n"
190
+ "cp.spawnSync = (file, args, options) => file === fake "
191
+ "? original(python, [fake, ...args], options) : original(file, args, options);\n"
192
+ "syncBuiltinESMExports();\n", encoding='utf-8',
193
+ )
194
+ runtime_workspace = workspace / "runtime"
195
+ captured = subprocess.run(
196
+ ["node", "--import", hook.as_uri(), str(CAPTURE), "--page-json", str(workspace / "page.json"),
197
+ "--output-workspace", str(runtime_workspace),
198
+ "--device", "fake", "--adb-path", str(fake_adb), "--settle-ms", "0"],
199
+ check=False, capture_output=True, text=True,
200
+ )
201
+ self.assertEqual(captured.returncode, 0, captured.stderr)
202
+ capture_report = json.loads((runtime_workspace / "capture-report.json").read_text())
203
+ self.assertEqual(capture_report["status"], "complete")
204
+ self.assertEqual(capture_report["summary"]["captured"], 2)
205
+ self.assertEqual(capture_report["summary"]["failed"], 0)
206
+ self.assertEqual(capture_report["summary"]["blocked"], 0)
207
+ self.assertTrue((runtime_workspace / "pages.json").is_file())
208
+ for result in capture_report["pages"]:
209
+ if result["status"] != "captured":
210
+ continue
211
+ self.assertTrue(Path(result["screenshot"]).is_file())
212
+ self.assertTrue(Path(result["view_tree"]).is_file())
213
+ self.assertTrue(Path(result["metadata"]).is_file())
214
+ metadata = json.loads(Path(result["metadata"]).read_text())
215
+ self.assertTrue(metadata["verification"]["ok"])
216
+ self.assertTrue((runtime_workspace / metadata["screenshot"]).is_file())
217
+ self.assertTrue((runtime_workspace / metadata["view_xml"]).is_file())
218
+ if result["node_id"] == "overlay:Help":
219
+ self.assertEqual(metadata["replay_trace"][-1]["matched"]["scrolls"], 1)
220
+
221
+ def test_rejects_missing_evidence(self) -> None:
222
+ with tempfile.TemporaryDirectory() as temp:
223
+ root = Path(temp)
224
+ project = root / "project"
225
+ workspace = root / "workspace"
226
+ project.mkdir()
227
+ workspace.mkdir()
228
+ facts = {
229
+ "schema_version": "d2h-utg-facts/v1",
230
+ "graph": {"app": "Demo", "architecture": "compose", "root": "root"},
231
+ "nodes": [
232
+ {"id": "root", "kind": "activity", "label": "Root", "declared_at": "Missing.kt:1"}
233
+ ],
234
+ "edges": [],
235
+ "unresolved": [],
236
+ }
237
+ facts_path = workspace / "utg-facts.json"
238
+ facts_path.write_text(json.dumps(facts), encoding="utf-8")
239
+ built = subprocess.run(
240
+ [sys.executable, str(BUILD), "--facts", str(facts_path),
241
+ "--project-root", str(project), "--workspace", str(workspace)],
242
+ check=False, capture_output=True, text=True,
243
+ )
244
+ self.assertEqual(built.returncode, 2)
245
+ self.assertIn("source file does not exist", built.stderr)
246
+
247
+ def test_rejects_capture_page_without_arrival_rule(self) -> None:
248
+ with tempfile.TemporaryDirectory() as temp:
249
+ root = Path(temp)
250
+ project = root / "project"
251
+ workspace = root / "workspace"
252
+ project.mkdir()
253
+ workspace.mkdir()
254
+ (project / "App.kt").write_text("root\n", encoding="utf-8")
255
+ facts = {
256
+ "schema_version": "d2h-utg-facts/v1",
257
+ "graph": {
258
+ "app": "Demo",
259
+ "package": "com.example.demo",
260
+ "architecture": "views",
261
+ "root": "root",
262
+ },
263
+ "nodes": [
264
+ {
265
+ "id": "root",
266
+ "kind": "activity",
267
+ "label": "Root",
268
+ "declared_at": "App.kt:1",
269
+ "expected": {},
270
+ }
271
+ ],
272
+ "edges": [],
273
+ "unresolved": [],
274
+ }
275
+ facts_path = workspace / "utg-facts.json"
276
+ facts_path.write_text(json.dumps(facts), encoding="utf-8")
277
+ built = subprocess.run(
278
+ [
279
+ sys.executable,
280
+ str(BUILD),
281
+ "--facts",
282
+ str(facts_path),
283
+ "--project-root",
284
+ str(project),
285
+ "--workspace",
286
+ str(workspace),
287
+ ],
288
+ check=False,
289
+ capture_output=True,
290
+ text=True,
291
+ )
292
+ self.assertEqual(built.returncode, 2)
293
+ self.assertIn("supported non-empty arrival rule", built.stderr)
294
+
295
+ def test_page_plan_keeps_unreachable_and_skips_nav_graph(self) -> None:
296
+ with tempfile.TemporaryDirectory() as temp:
297
+ root = Path(temp)
298
+ project = root / "project"
299
+ workspace = root / "workspace"
300
+ project.mkdir()
301
+ workspace.mkdir()
302
+ (project / "App.kt").write_text("root\ngraph\nhidden\n", encoding="utf-8")
303
+ facts = {
304
+ "schema_version": "d2h-utg-facts/v1",
305
+ "graph": {"app": "Demo", "package": "com.example.demo", "architecture": "compose", "root": "root"},
306
+ "nodes": [
307
+ {"id": "root", "kind": "activity", "label": "Root", "declared_at": "App.kt:1"},
308
+ {"id": "graph", "kind": "nav-graph", "label": "Graph", "declared_at": "App.kt:2"},
309
+ {"id": "hidden", "kind": "dialog", "label": "Hidden", "declared_at": "App.kt:3"},
310
+ ],
311
+ "edges": [
312
+ {"source": "root", "target": "graph", "mechanism": "navhost-start", "trigger": "App.kt:2", "args": {"action": "default"}, "confidence": 1.0}
313
+ ],
314
+ "unresolved": [],
315
+ }
316
+ facts_path = workspace / "utg-facts.json"
317
+ facts_path.write_text(json.dumps(facts), encoding="utf-8")
318
+ built = subprocess.run(
319
+ [sys.executable, str(BUILD), "--facts", str(facts_path),
320
+ "--project-root", str(project), "--workspace", str(workspace)],
321
+ check=False, capture_output=True, text=True,
322
+ )
323
+ self.assertEqual(built.returncode, 0, built.stderr)
324
+ plan = json.loads((workspace / "page.json").read_text())
325
+ by_id = {page["node_id"]: page for page in plan["pages"]}
326
+ self.assertFalse(by_id["root"]["capture"])
327
+ self.assertEqual(by_id["root"]["skip_reason"], "container-node:activity")
328
+ self.assertFalse(by_id["graph"]["capture"])
329
+ self.assertEqual(by_id["graph"]["skip_reason"], "non-capture-kind:nav-graph")
330
+ self.assertFalse(by_id["hidden"]["reachable"])
331
+ self.assertEqual(by_id["hidden"]["skip_reason"], "unreachable-from-root")
332
+
333
+ def test_click_path_prefers_unguarded_route(self) -> None:
334
+ with tempfile.TemporaryDirectory() as temp:
335
+ root = Path(temp)
336
+ project = root / "project"
337
+ workspace = root / "workspace"
338
+ project.mkdir()
339
+ workspace.mkdir()
340
+ (project / "App.kt").write_text("root\nmid\ntarget\n", encoding="utf-8")
341
+ facts = {
342
+ "schema_version": "d2h-utg-facts/v1",
343
+ "graph": {"app": "Demo", "package": "com.example.demo", "architecture": "compose", "root": "root"},
344
+ "nodes": [
345
+ {"id": "root", "kind": "activity", "label": "Root", "declared_at": "App.kt:1", "expected": {"activity_any": ["com.example.demo"]}},
346
+ {"id": "mid", "kind": "compose-route", "label": "Mid", "declared_at": "App.kt:2", "expected": {"text_any": ["Mid"]}},
347
+ {"id": "target", "kind": "compose-route", "label": "Target", "declared_at": "App.kt:3", "expected": {"text_any": ["Target"]}},
348
+ ],
349
+ "edges": [
350
+ {"source": "root", "target": "target", "mechanism": "guarded-shortcut", "trigger": "App.kt:3", "guard": {"kind": "setting", "text": "optional start"}, "args": {"action": "default", "label": "Target"}, "confidence": 1.0},
351
+ {"source": "root", "target": "mid", "mechanism": "tap", "trigger": "App.kt:2", "args": {"action": "tap", "label": "Mid"}, "confidence": 1.0},
352
+ {"source": "mid", "target": "target", "mechanism": "tap", "trigger": "App.kt:3", "args": {"action": "tap", "label": "Target"}, "confidence": 1.0},
353
+ ],
354
+ "unresolved": [],
355
+ }
356
+ facts_path = workspace / "utg-facts.json"
357
+ facts_path.write_text(json.dumps(facts), encoding="utf-8")
358
+ built = subprocess.run(
359
+ [sys.executable, str(BUILD), "--facts", str(facts_path), "--project-root", str(project), "--workspace", str(workspace)],
360
+ check=False, capture_output=True, text=True,
361
+ )
362
+ self.assertEqual(built.returncode, 0, built.stderr)
363
+ paths = json.loads((workspace / "click_paths.json").read_text())
364
+ self.assertEqual(paths["paths"]["target"]["depth"], 2)
365
+ self.assertEqual([step["target_id"] for step in paths["paths"]["target"]["steps"]], ["root", "mid", "target"])
366
+
367
+
368
+ if __name__ == "__main__":
369
+ unittest.main()
@@ -0,0 +1,73 @@
1
+ ---
2
+ name: d2h-utg-static-generate
3
+ description: 迁移主流程的 Android 静态分析入口,在 Skill 内调用 droid2hmos/AppGraph CLI 生成只读 Facts,再复用应用图生成 UTG、点击路径和采集计划,以源码证据驱动 AI 补充纠错并输出离线 utg.html。也支持独立 UTG 分析;不操作设备或修改应用源码。
4
+ ---
5
+
6
+ # AppGraph 驱动的静态 UTG
7
+
8
+ 这是迁移流程的默认静态分析入口。主 Agent 加载本 Skill 并提供 Android 源码目录、迁移工作区,由本 Skill 调用分析 CLI;主流程不另行触发分析。AppGraph 提供工具生成的确定性事实,生成后保持只读;UTG 和 AI 发现属于消费方派生产物,不把 AI 结论注入 AppGraph 或 `.migration/spec/facts/`。
9
+
10
+ 所需 AppGraph 和 droid2hmos 能力由 `managing-d2h-toolchain` 的清单统一检查和准备;本 Skill 不声明版本,也不安装或升级 CLI。
11
+
12
+ ## 迁移主流程入口
13
+
14
+ 进入本 Skill 前,主 Agent 已完成工程配置及当前阶段所需的准备检查。迁移工作区为 `<源码父目录>/<源码目录名>-migration/.migration/`,已有现场时沿用其实际路径。
15
+
16
+ 1. 在本 Skill 内执行受管分析,检查退出码及返回结果;失败时报告并停止依赖该结果的步骤:
17
+
18
+ ```bash
19
+ droid2hmos analyze android <android-source>
20
+ ```
21
+
22
+ 2. 确认本次生成的 `<migration-workspace>/spec/index.json`、`spec/facts/` 和 `appgraph/app-graph.json` 存在,核对源码与分析结果。复用这份 AppGraph,不再执行第二次 `appgraph build`:
23
+
24
+ ```bash
25
+ python <skill>/scripts/generate.py --project-root <android-source> --workspace <migration-workspace>/utg --appgraph <migration-workspace>/appgraph/app-graph.json
26
+ ```
27
+
28
+ 3. 按下文完成源码核验、独立 AI 修补和统一重建。已有 UTG 工作区需显式使用 `--force`;源码或 AppGraph 变化时重新核验旧修补,不能沿用过期指纹。
29
+ 4. 将 Facts 位置、受管分析结果、UTG 产物及未解析项交回主 Agent,由主 Agent 接续报告初始化与 Task Plan。UTG discoveries 不替代 Kernel 的 correction/review,也不替代受管分析证据。
30
+
31
+ Android 分析不运行 Gradle、不启用 `--resolve-dependencies`,不需要 APK 或 Android 设备。已有图缺失时报告分析产物不完整,不在主流程模式中静默切换到独立建图。
32
+
33
+ ## 独立 UTG 输入与运行
34
+
35
+ 要求 Python 3.9+、已有的 `appgraph` CLI,以及真实 Android 源码目录。默认工作区为 `<源码父目录>/<源码目录名>-migration/.migration/utg/`;已有迁移现场时显式传入其 UTG 目录。
36
+
37
+ ```bash
38
+ python <skill>/scripts/generate.py --project-root <android-source> --workspace <utg-workspace>
39
+ # 使用显式选定的已有 AppGraph,跳过 build:
40
+ python <skill>/scripts/generate.py --project-root <android-source> --workspace <utg-workspace> --appgraph <app-graph.json>
41
+ ```
42
+
43
+ 独立模式未提供现有图时执行 `appgraph build --platform android --out ...`;只生成 UTG,不替代迁移主流程所需的受管 Facts 分析。不启用 Gradle 依赖解析,不安装或升级 CLI。分析失败必须报告,不能静默改为纯 AI 建图。当前仅接受 Android AppGraph schemaVersion 1。
44
+
45
+ ## 静态投影与核验
46
+
47
+ 脚本优先消费 AppGraph 的 `interactionGraph`,保留页面身份、粗细图别名、控件使用上下文、导航调用点和调用链证据;没有该字段时兼容旧版图。候选节点、歧义归属、无源码行号和未解析目标进入 `review-worklist.json`,不会猜测目标或选择首个页面。
48
+
49
+ 多个已确认启动页面使用不可采集的逻辑入口节点,各入口保持独立边。只有 Manifest 明确提供的启动组件才生成 `start-activity` 操作;缺少启动参数时路径不可执行。宿主关系不直接视为进入关系,只有有证据的初始路由才能生成 `navhost-start`。
50
+
51
+ 返回、弹层关闭和外部 Intent 保留在图元数据的 `boundary_transitions` 及对齐表中:未确定实际目标页面时不建立猜测的页间边。外部 Intent 的实际处理应用仍由运行时解析,不代表已确认浏览器或设备行为。Compose testTag 仅为静态选择器候选,未证明设备树中的可执行映射时不直接转为 ADB resource-id。
52
+
53
+ 读取 `appgraph-utg-facts.json` 和核验清单,结合源码全面检查路由注册、封装函数、方法引用、弹窗、返回、入口和状态条件。不能仅检查脚本已列出的缺口。核验已有导航的触发位置和归属;判断到达条件及实际可执行选择器时读取资源与源码。自然语言操作提示不能作为运行时选择器。
54
+
55
+ `review-worklist.json` 的 `navigation_candidates` 保留 AppGraph 导航候选两端、路由键和证据;`navigation_review` 显示导航待核验数量。必须逐条以源码处置导航候选(确认新增、修正或排除),并扫描候选之外的封装调用与方法引用。用 `resolved_findings` 关闭已处置的导航告警,保留排除理由与源码行;`navigation_review.status=pending` 时只可称为基础投影或未完成的源码核验,不可报告完整静态 UTG 识别率。导航已核验不意味着所有到达条件和设备选择器已确认。
56
+
57
+ 可复用弹层组件的定义不等同于可进入的独立页面。检查各调用位置与显示条件;同一组件在多个宿主页面使用时,为有证据的调用实例建立独立节点与宿主边,并排除孤立的通用组件投影。不要仅因类型名包含 `Screen` 就接受页面身份;先确认它不是主题枚举、网络状态等数据值。
58
+
59
+ 按 [修补契约](references/discoveries.md) 写入 `discoveries.json`。有明确证据时自动修补,不要求逐条用户确认;不确定的保留 `unresolved`,不编造反向边或运行时确认。无有效入口时先补充有证据的入口再重建;不要伪造根节点。
60
+
61
+ ```bash
62
+ python <skill>/scripts/generate.py --project-root <android-source> --workspace <utg-workspace> --appgraph <app-graph.json> --discoveries <utg-workspace>/discoveries.json --force
63
+ ```
64
+
65
+ `--force` 仅用于本次已选定工作区的派生产物重建。源码或输入图发生变化时旧修补被拒绝;重新生成基础图并重新核验修补,不能只更改指纹绕过检查。
66
+
67
+ ## 交付和下游
68
+
69
+ 输出 `appgraph-utg-facts.json`、`review-worklist.json`、`discoveries.json`、`utg-facts.json`、`utg.json`、`click_paths.json`、`page.json`、`generation-report.json`、`utg.html`。新契约输入还输出 `appgraph-utg-alignment.json`,记录基础来源映射、未投影对象、修补审计及最终节点边身份。复用相邻 `d2h-utg-ai-generate/scripts/` 的规范化、路径、采集计划和可视化代码,两项 Skill 必须一起安装。
70
+
71
+ HTML 离线展示图、来源、源码证据、修补前对象和未解析项。`static-only` 不代表设备可达;缺少到达规则或路径操作选择器的页面标记 `capture=false`。按清单继续源码核验,而不是为了提高数量猜测条件。
72
+
73
+ 需要设备采集时交给 `d2h-utg-ai-generate` 的已有计划采集入口,传入本工作区的 `page.json`,不要再次建图。报告基础/修补节点边数量、采集不可执行原因、未解析入口或关系及绝对输出路径。
@@ -0,0 +1,38 @@
1
+ # 独立 AI 修补契约
2
+
3
+ ```json
4
+ {
5
+ "schema_version": "d2h-utg-discoveries/v1",
6
+ "input_fingerprint": "复制 review-worklist.json 的指纹",
7
+ "operations": [
8
+ {
9
+ "kind": "edge",
10
+ "op": "add",
11
+ "id": "ai:settings-about",
12
+ "reason": "导航封装导致 AppGraph 漏掉调用关系",
13
+ "evidence": "app/src/main/java/Settings.kt:42",
14
+ "value": {
15
+ "id": "ai:settings-about",
16
+ "source": "现有来源节点ID",
17
+ "target": "现有目标节点ID",
18
+ "mechanism": "compose-navigate",
19
+ "trigger": "app/src/main/java/Settings.kt:42",
20
+ "confidence": 0.9,
21
+ "args": {"action": "tap", "selector": {"text": "关于"}, "label_source": "string"},
22
+ "guard": null
23
+ }
24
+ }
25
+ ],
26
+ "unresolved": []
27
+ }
28
+ ```
29
+
30
+ `kind=node|edge` 支持 `add|replace|exclude`。replace 是完整对象替换,不是字段补丁,必须保持目标 id。node 使用现有 UTG facts 节点契约,包括 `declared_at`、`kind`、`label`;有证据时补 `expected`、`required_state`。edge 使用上述字段。id 不得采用可能碰撞的页面显示名称。
31
+
32
+ 入口纠错使用 `kind=graph, op=replace, id=root, value=<页面ID>`,并提供入口源码证据。
33
+
34
+ 每项必须有非空 reason 和真实项目相对 file:line evidence。脚本验证位置存在,不代表自动证明语义;Agent 必须核对该行和上下文确实支持结论。同一对象只能有一个操作;重复新增、目标不存在、悬空边及输入图/源码指纹不一致会失败。排除节点时必须显式处理关联边,不隐式删除导航。
35
+
36
+ 结果统一标记 `ai-source-review` 和 `static-only`,审计保存在图元数据的 discovery_audit(含原对象)。不会修改 AppGraph 输入。未知运行时目标保留 unresolved;登录等条件使用真实证据,不以置信度代替证据。
37
+
38
+ 已核验解决的工作清单项可写入顶层 `resolved_findings` 数组,每项包含清单中的 `id`、`reason`、`evidence`。脚本验证引用和证据后从最终 unresolved 中移除,并保留解决记录;基础清单仍保持原样。不要把仍需运行时验证的问题标记已解决。