@harmonyos-arkts/d2h 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/.claude-plugin/marketplace.json +15 -13
  2. package/.claude-plugin/plugin.json +13 -7
  3. package/AGENTS.md +5 -5
  4. package/README.md +212 -212
  5. package/agents/android-to-hmos-00-orchestrator.md +318 -318
  6. package/agents/d2h.md +157 -157
  7. package/dist/bin/install-opencode.js +30 -30
  8. package/docs/environment-check.md +86 -86
  9. package/install-opencode.sh +160 -160
  10. package/opencode/agents.json +16 -16
  11. package/opencode/vscode-d2h-package.json +7 -7
  12. package/opencode/vscode-d2h-plugin.mjs +29 -29
  13. package/package.json +54 -54
  14. package/schemas/android-source-manifest.schema.json +25 -25
  15. package/schemas/checkpoint-provenance.schema.json +67 -67
  16. package/schemas/final-acceptance.schema.json +29 -29
  17. package/schemas/managed-evidence-index.schema.json +15 -15
  18. package/schemas/managed-evidence.schema.json +114 -114
  19. package/schemas/migration-config.schema.json +51 -51
  20. package/schemas/migration-report-index.schema.json +39 -39
  21. package/schemas/migration-report-item.schema.json +51 -51
  22. package/schemas/migration-report-summary.schema.json +25 -25
  23. package/schemas/migration-status.schema.json +202 -202
  24. package/schemas/preflight.schema.json +60 -60
  25. package/schemas/source-order-audit.schema.json +16 -16
  26. package/schemas/source-provenance-event.schema.json +19 -19
  27. package/schemas/spec-app-shard.schema.json +45 -45
  28. package/schemas/spec-fact-corrections-shard.schema.json +13 -13
  29. package/schemas/spec-features-shard.schema.json +43 -43
  30. package/schemas/spec-index.schema.json +26 -26
  31. package/schemas/spec-interactions-shard.schema.json +40 -40
  32. package/schemas/spec-page.schema.json +98 -98
  33. package/schemas/spec-pages-index.schema.json +1 -1
  34. package/schemas/spec-unresolved-shard.schema.json +1 -1
  35. package/schemas/task-envelope.schema.json +109 -109
  36. package/schemas/task-plan.schema.json +341 -341
  37. package/scripts/check-environment.mjs +320 -320
  38. package/skills/android2hmos_resources_convert/SKILL.md +180 -180
  39. package/skills/android2hmos_resources_convert/references/image-conversion-rules.md +230 -230
  40. package/skills/android2hmos_resources_convert/references/svg-fix-patterns.md +175 -175
  41. package/skills/android2hmos_resources_convert/references/xml-drawable-to-svg-rules.md +513 -513
  42. package/skills/arkts-app-identity/SKILL.md +238 -238
  43. package/skills/arkts-i18n/SKILL.md +496 -496
  44. package/skills/arkts-i18n/evals/evals.json +84 -84
  45. package/skills/arkts-i18n/references/code-examples.md +302 -302
  46. package/skills/arkts-i18n/references/common-pitfalls.md +391 -391
  47. package/skills/arkts-i18n/references/dynamic-language-switch.md +604 -604
  48. package/skills/arkts-i18n/references/hardcoded-string-scanner.md +348 -348
  49. package/skills/arkts-i18n/references/language-codes.md +104 -104
  50. package/skills/arkts-i18n/references/resource-file-structure.md +775 -775
  51. package/skills/arkts-i18n/references/static-vs-dynamic.md +242 -242
  52. package/skills/arkts-i18n/references/v1-compat.md +244 -244
  53. package/skills/arkts-i18n/scripts/audit_i18n_completeness.sh +174 -174
  54. package/skills/arkts-icon-sizing/SKILL.md +211 -211
  55. package/skills/arkts-icon-sizing/scripts/icon_audit.py +131 -131
  56. package/skills/arkts-icon-sizing/scripts/icon_autofix.py +88 -88
  57. package/skills/arkts-icon-sizing/scripts/icon_dims.py +179 -179
  58. package/skills/arkts-icon-sizing/scripts/icon_fix.py +119 -119
  59. package/skills/arkts-mvvm-architecture/SKILL.md +613 -613
  60. package/skills/d2h-hypium-forge/SKILL.md +309 -309
  61. package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -140
  62. package/skills/d2h-hypium-forge/references/official-api.md +135 -135
  63. package/skills/d2h-hypium-forge/references/pitfalls.md +98 -98
  64. package/skills/d2h-hypium-forge/references/testability-template.md +71 -71
  65. package/skills/d2h-hypium-forge/scripts/_common_template.py +659 -659
  66. package/skills/d2h-hypium-forge/scripts/analyze_results.py +165 -165
  67. package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -107
  68. package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -169
  69. package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -325
  70. package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -114
  71. package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -80
  72. package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -60
  73. package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -249
  74. package/skills/d2h-hypium-forge/scripts/precondition.py +140 -140
  75. package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -232
  76. package/skills/d2h-hypium-forge/scripts/probe.py +197 -197
  77. package/skills/d2h-hypium-forge/scripts/run_direct.py +823 -823
  78. package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -121
  79. package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -255
  80. package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -66
  81. package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -84
  82. package/skills/d2h-page-develop/SKILL.md +112 -112
  83. package/skills/d2h-page-develop/agents/openai.yaml +7 -7
  84. package/skills/d2h-page-spec-generate/SKILL.md +206 -206
  85. package/skills/d2h-page-spec-generate/scripts/build_batch_report.py +227 -227
  86. package/skills/d2h-utg-ai-generate/SKILL.md +128 -128
  87. package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -76
  88. package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -34
  89. package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -96
  90. package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -61
  91. package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -89
  92. package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -314
  93. package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -123
  94. package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -280
  95. package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -524
  96. package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -489
  97. package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -69
  98. package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -13
  99. package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -2
  100. package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -369
  101. package/skills/d2h-utg-static-generate/SKILL.md +73 -73
  102. package/skills/d2h-utg-static-generate/references/discoveries.md +38 -38
  103. package/skills/d2h-utg-static-generate/scripts/generate.py +499 -499
  104. package/skills/d2h-utg-static-generate/tests/test_static.py +335 -335
  105. package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -60
  106. package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -131
  107. package/skills/harmonyos-migration-playbook/SKILL.md +83 -83
  108. package/skills/harmonyos-migration-playbook/agents/openai.yaml +7 -7
  109. package/skills/harmonyos-migration-playbook/references/arkts-compile.md +24 -24
  110. package/skills/harmonyos-migration-playbook/references/harmony-runtime.md +53 -53
  111. package/skills/harmonyos-migration-playbook/references/lesson-lifecycle.md +45 -45
  112. package/skills/harmonyos-migration-playbook/references/protocol-e2e.md +23 -23
  113. package/skills/harmonyos-migration-playbook/references/ui-automation.md +53 -53
  114. package/skills/harmonyos-migration-playbook/references/windows-environment.md +38 -38
  115. package/skills/maintaining-migration-report/SKILL.md +144 -144
  116. package/skills/maintaining-migration-report/references/final-acceptance.md +48 -48
  117. package/skills/maintaining-migration-report/references/source-provenance.md +66 -66
  118. package/skills/managing-d2h-toolchain/SKILL.md +24 -24
  119. package/skills/managing-d2h-toolchain/environment-requirements.json +16 -16
  120. package/skills/managing-d2h-toolchain/scripts/manage-toolchain.mjs +98 -98
  121. package/skills/managing-d2h-toolchain/toolchain.json +16 -16
  122. package/skills/native-library-substitution/SKILL.md +384 -384
  123. package/skills/native-library-substitution/references/native-library-substitution.json +56905 -56905
  124. package/skills/native-library-substitution/references/native-library-substitution.md +163 -163
  125. package/skills/preparing-migration-workspace/SKILL.md +131 -131
  126. package/skills/reviewing-migration-process/SKILL.md +62 -62
@@ -1,499 +1,499 @@
1
- #!/usr/bin/env python3
2
- """Project read-only AppGraph facts and apply source-grounded discovery overlays."""
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import copy
7
- import hashlib
8
- import json
9
- import shutil
10
- import subprocess
11
- import sys
12
- from xml.parsers import expat
13
- from pathlib import Path
14
-
15
- SHARED = Path(__file__).resolve().parents[2] / 'd2h-utg-ai-generate' / 'scripts'
16
- sys.path.insert(0, str(SHARED))
17
- from utg_common import UtgError, atomic_write_json, load_json, normalize_location, normalize_facts, build_click_paths
18
-
19
-
20
- def digest(value):
21
- return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
22
- separators=(',', ':')).encode()).hexdigest()
23
-
24
-
25
- def source_digest(root):
26
- extensions = {'.kt', '.java', '.xml', '.gradle', '.kts', '.json', '.properties'}
27
- ignored = {'.git', '.migration', '.appgraph', '.codegraph', '.codegraph-appgraph', 'node_modules', 'build', '.gradle'}
28
- records = []
29
- for p in sorted(root.rglob('*')):
30
- relative = p.relative_to(root)
31
- if p.is_file() and not any(part in ignored for part in relative.parts) and p.suffix in extensions:
32
- records.append((relative.as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()))
33
- return digest(records)
34
-
35
-
36
- def project_legacy(graph, root):
37
- if graph.get('schemaVersion') != 1 or graph.get('platform') != 'android':
38
- raise UtgError('expected Android AppGraph schemaVersion=1')
39
- raw_nodes = graph.get('nodes', [])
40
- ui = graph.get('uiComponents') or {}
41
- all_nodes = raw_nodes + ui.get('nodes', [])
42
- if len({n['id'] for n in all_nodes}) != len(all_nodes):
43
- raise UtgError('duplicate AppGraph node identity')
44
- index = {n['id']: n for n in all_nodes}
45
- diagnostics, nodes, edges = [], {}, []
46
-
47
- def issue(subject, reason, evidence=None):
48
- finding = {'subject': subject, 'reason': reason, 'evidence': evidence}
49
- finding['id'] = 'review:' + digest(finding)[:20]
50
- diagnostics.append(finding)
51
-
52
- def location(obj):
53
- refs = obj.get('evidence') or [obj.get('platformRef') or obj.get('origin') or {}]
54
- if isinstance(refs, dict):
55
- refs = [refs]
56
- for ref in refs:
57
- if not isinstance(ref, dict):
58
- continue
59
- # Manifest facts carry a symbol but older producers omit a line.
60
- # Resolve that exact XML declaration, never a guessed class-name hit.
61
- if ref.get('file', '').endswith('.xml') and ref.get('symbol') and not ref.get('line'):
62
- path = (root / ref['file']).resolve()
63
- if path.is_relative_to(root) and path.is_file():
64
- parser = expat.ParserCreate()
65
- matches = []
66
- def start(tag, attrs):
67
- if attrs.get('android:name') == ref['symbol']:
68
- matches.append(parser.CurrentLineNumber)
69
- parser.StartElementHandler = start
70
- try:
71
- parser.Parse(path.read_bytes(), True)
72
- if len(matches) == 1:
73
- return normalize_location(f"{ref['file']}:{matches[0]}", root, 'XML declaration')
74
- except expat.ExpatError:
75
- pass
76
- if ref.get('file') and ref.get('line'):
77
- try:
78
- return normalize_location(f"{ref['file']}:{ref['line']}", root, 'AppGraph evidence')
79
- except UtgError:
80
- pass
81
- return None
82
-
83
- kinds = {'compose': 'compose-route', 'activity': 'activity', 'fragment': 'fragment',
84
- 'dialog': 'dialog', 'dialog-fragment': 'dialog', 'sheet': 'sheet', 'bottom-sheet': 'sheet',
85
- 'menu': 'menu', 'voyager': 'voyager-screen'}
86
- for raw in sorted(all_nodes, key=lambda n: n['id']):
87
- if raw.get('kind') not in {'Screen', 'page'}:
88
- continue
89
- if raw.get('evidenceLevel') == 'candidate' or raw.get('platform', 'android') != 'android':
90
- issue(raw['id'], 'candidate or non-Android page requires review')
91
- continue
92
- loc = location(raw)
93
- if not loc:
94
- issue(raw['id'], 'page has no valid source line evidence')
95
- continue
96
- attrs = raw.get('attrs') or {}
97
- # Fine pages explicitly referencing the same Screen are aliases, not new screens.
98
- alias = attrs.get('screenId')
99
- if alias in nodes:
100
- continue
101
- nodes[raw['id']] = {'id': raw['id'], 'kind': kinds.get(raw.get('subtype'), 'page'),
102
- 'label': raw.get('name') or raw['id'], 'declared_at': loc,
103
- 'provenance': 'appgraph', 'status': 'static-only', 'expected': {}, 'widgets': [],
104
- 'extra': {'appgraph_id': raw['id'], 'appgraph_evidence': raw.get('evidence', []),
105
- 'capture_policy': 'strict'}}
106
- aliases = {n['id']: (n.get('attrs') or {}).get('screenId') for n in all_nodes
107
- if (n.get('attrs') or {}).get('screenId') in nodes}
108
- for alias in aliases:
109
- nodes.pop(alias, None)
110
- # A source page can carry the stable Screen ID even when that coarse Screen
111
- # has no line evidence (notably a Manifest activity declaration).
112
- for raw in all_nodes:
113
- if raw.get('kind') != 'page' or raw['id'] not in nodes:
114
- continue
115
- screen_id = (raw.get('attrs') or {}).get('screenId')
116
- if screen_id and screen_id not in nodes:
117
- aliases[screen_id] = raw['id']
118
- # Compose destination registrations may also produce a synthetic Screen at
119
- # the NavHost file's first line. Prefer the unique source-backed page for
120
- # the same Screen match key, preserving both IDs as aliases.
121
- source_pages = {}
122
- for raw in all_nodes:
123
- attrs = raw.get('attrs') or {}
124
- if raw.get('kind') == 'page' and attrs.get('classificationRule') == 'COMPOSE-SCREEN-AST' and raw['id'] in nodes:
125
- source_pages.setdefault(attrs.get('screenMatchKey'), []).append(raw['id'])
126
- for raw in all_nodes:
127
- attrs = raw.get('attrs') or {}
128
- if raw.get('kind') != 'Screen' or attrs.get('classificationRule') != 'compose.destination.register':
129
- continue
130
- match_key = raw.get('matchKey', '')
131
- candidates = source_pages.get(match_key.removesuffix(':compose'), [])
132
- if len(candidates) == 1 and candidates[0] != raw['id']:
133
- aliases[raw['id']] = candidates[0]
134
- nodes.pop(raw['id'], None)
135
- relations = graph.get('edges', []) + ui.get('edges', [])
136
- parent = {}
137
- for e in relations:
138
- if e.get('kind') in {'contains', 'hosts', 'includes', 'renders'}:
139
- parent.setdefault(e['to'], set()).add(e['from'])
140
-
141
- def owners(identity):
142
- seen, result, queue = set(), set(), [identity]
143
- while queue:
144
- current = queue.pop()
145
- if current in seen:
146
- continue
147
- seen.add(current)
148
- current = aliases.get(current, current)
149
- if current in index and index[current].get('kind') == 'AppEntry':
150
- queue.append((index[current].get('attrs') or {}).get('screen'))
151
- continue
152
- if current in nodes:
153
- result.add(current)
154
- else:
155
- queue.extend(parent.get(current, []))
156
- return sorted(result)
157
-
158
- for raw in all_nodes:
159
- if raw.get('kind') == 'control':
160
- own = owners(raw['id'])
161
- if len(own) == 1:
162
- nodes[own[0]]['widgets'].append({'id': raw['id'], 'name': raw.get('name'),
163
- 'evidence': raw.get('evidence', []), 'attrs': raw.get('attrs', {})})
164
- else:
165
- issue(raw['id'], 'control owner missing or ambiguous', raw.get('evidence'))
166
-
167
- def navigation(e, source, target, evidence):
168
- src, dst = owners(source), owners(target)
169
- loc = location(evidence)
170
- if len(src) != 1 or len(dst) != 1 or not loc:
171
- issue(e.get('id', source), 'navigation owner/target/evidence unresolved', evidence)
172
- return
173
- attrs = e.get('attrs') or {}
174
- branch = e.get('branchCondition')
175
- guard = attrs.get('guard')
176
- if branch and branch.get('expression'):
177
- guard = {'kind': 'source-branch', 'text': ('!(' + branch['expression'] + ')') if branch.get('branch') == 'else' else branch['expression']}
178
- edges.append({'id': e.get('id') or 'appgraph:' + digest(e)[:20],
179
- 'source': src[0], 'target': dst[0],
180
- 'mechanism': attrs.get('mechanism') or 'appgraph-navigation',
181
- 'trigger': loc, 'confidence': e.get('confidence', 1), 'provenance': 'appgraph',
182
- 'status': 'static-only', 'guard': guard,
183
- 'args': {'appgraph_edge_id': e.get('id'), 'appgraph_attrs': attrs}})
184
-
185
- for e in sorted(graph.get('edges', []), key=lambda e: e['id']):
186
- if e.get('kind') == 'navigates_to':
187
- attrs = e.get('attrs') or {}
188
- navigation(e, e['from'], e['to'], {'evidence': attrs.get('evidence', []),
189
- 'platformRef': attrs.get('platformRef') or attrs.get('origin') or {}})
190
- entries = {e['id']: e for e in graph.get('entryPoints', [])}
191
- roots = set()
192
- for raw in raw_nodes:
193
- if raw.get('kind') == 'AppEntry' and raw.get('subtype') == 'launcher-activity':
194
- roots.update(owners(raw['id']))
195
- for entry in entries.values():
196
- if entry.get('kind') == 'app-launch' and entry.get('resolution') == 'resolved':
197
- roots.update(owners(entry.get('ownerRef') or entry.get('sourceRef')))
198
- for trace in sorted(graph.get('callTraces', []), key=lambda t: t['id']):
199
- entry = entries.get(trace.get('entryPointId'), {})
200
- if entry.get('resolution') != 'resolved':
201
- issue(trace.get('id'), 'entry unresolved; trace cannot establish navigation')
202
- continue
203
- for effect in trace.get('effects', []):
204
- if effect.get('kind') == 'navigation':
205
- targets = effect.get('targetRefs', [])
206
- if len(targets) != 1:
207
- issue(effect.get('id'), 'navigation effect target missing or ambiguous', effect.get('origin'))
208
- continue
209
- for target in targets:
210
- navigation(effect, entry.get('ownerRef') or entry.get('sourceRef'), target,
211
- {'platformRef': effect.get('origin') or {}})
212
- for warning in graph.get('coverageWarnings', []):
213
- issue('appgraph-coverage', warning.get('message'), warning.get('ref'))
214
- if len(roots) != 1:
215
- issue('root', 'exactly one proven app-launch page is required', sorted(roots))
216
- connected = {e['source'] for e in edges} | {e['target'] for e in edges}
217
- for node in nodes.values():
218
- if node['id'] not in connected:
219
- issue(node['id'], 'isolated page: inspect navigation registry and call sites')
220
- issue(node['id'], 'arrival rules and executable selectors require source review')
221
- return {'schema_version': 'd2h-utg-facts/v1',
222
- 'graph': {'app': graph.get('app', {}).get('name') or root.name,
223
- 'package': graph.get('app', {}).get('packageName', ''),
224
- 'architecture': 'appgraph/android', 'root': next(iter(roots)) if len(roots) == 1 else ''},
225
- 'nodes': sorted(nodes.values(), key=lambda n: n['id']),
226
- 'edges': sorted(edges, key=lambda e: (e['id'], e['source'], e['target'])),
227
- 'unresolved': diagnostics}
228
-
229
-
230
- def project(graph, root):
231
- interaction = graph.get('interactionGraph')
232
- if interaction is None:
233
- return project_legacy(graph, root)
234
- if interaction.get('schemaVersion') != 1:
235
- raise UtgError('unsupported interactionGraph schemaVersion')
236
- surfaces = interaction.get('surfaces', [])
237
- if len({s['id'] for s in surfaces}) != len(surfaces):
238
- raise UtgError('duplicate interaction surface identity')
239
- bindings = interaction.get('controlBindings', [])
240
- raw_index = {n['id']: n for n in (graph.get('uiComponents') or {}).get('nodes', [])}
241
- projected = dict(graph, nodes=[{'id': s['id'], 'kind': 'Screen', 'name': s['name'],
242
- 'subtype': s['kind'], 'evidence': s.get('evidence', []),
243
- **({'evidenceLevel': 'candidate'} if s.get('resolution') in {'ambiguous', 'unresolved'} else {}),
244
- 'attrs': {}} for s in surfaces],
245
- edges=[], entryPoints=[], callTraces=[], uiComponents={'nodes': [], 'edges': []})
246
- for binding in bindings:
247
- original = raw_index.get(binding['controlRef'], {})
248
- projected['uiComponents']['nodes'].append(dict(original, id=binding['id'], kind='control'))
249
- projected['uiComponents']['edges'].append({'id': binding['id'], 'kind': 'contains',
250
- 'from': binding['surfaceId'], 'to': binding['id']})
251
- for transition in interaction.get('transitions', []):
252
- target = transition.get('target', {})
253
- if transition.get('resolution') != 'resolved' or target.get('kind') != 'internal':
254
- continue
255
- attrs = {'evidence': transition.get('evidence', []), 'mechanism': transition['operation'],
256
- 'supportingRefs': transition.get('supportingRefs', []),
257
- 'actionRef': transition.get('actionRef'), 'arguments': transition.get('arguments', {})}
258
- conditions = transition.get('conditions', [])
259
- if conditions:
260
- attrs['guard'] = {'kind': 'source-branch', 'text': ' && '.join(('!(' + c['expression'] + ')') if c.get('branch') == 'else' else '(' + c['expression'] + ')' for c in conditions), 'conditions': conditions}
261
- projected['edges'].append({'id': transition['id'], 'kind': 'navigates_to',
262
- 'from': transition.get('sourceSurfaceId'), 'to': target['surfaceId'],
263
- 'confidence': transition.get('confidence', 1), 'attrs': attrs})
264
- entries = [e for e in interaction.get('entries', [])
265
- if e.get('kind') in {'app-launch', 'deep-link', 'external-entry'} and e.get('resolution') == 'resolved']
266
- for entry in entries:
267
- for identity in entry.get('surfaceIds', []):
268
- projected['entryPoints'].append({'id': entry['id'], 'kind': 'app-launch',
269
- 'resolution': 'resolved', 'ownerRef': identity})
270
- base = project_legacy(projected, root)
271
- base['graph']['path_policy'] = 'source-executable-first'
272
- interaction_entries = {e['id']: e for e in interaction.get('entries', [])}
273
- for edge in base['edges']:
274
- if edge['mechanism'] == 'navhost-start':
275
- edge['args']['action'] = 'default'
276
- continue
277
- metadata = edge['args'].get('appgraph_attrs', {})
278
- entry = interaction_entries.get(metadata.get('actionRef'), {})
279
- applicable = [b for b in bindings if b['surfaceId'] == edge['source']
280
- and b['controlRef'] == entry.get('sourceRef')]
281
- candidates = [s for b in applicable for s in b.get('selectorCandidates', [])
282
- if s.get('kind') == 'resource-id' and isinstance(s.get('value'), str)]
283
- event = entry.get('event')
284
- action = {'click': 'tap', 'onClick': 'tap', 'compose-click': 'tap',
285
- 'long-click': 'long-press', 'onLongClick': 'long-press', 'compose-long-click': 'long-press'}.get(event)
286
- if action and len(candidates) == 1:
287
- edge['args'].update(action=action, selector={'resource_id': candidates[0]['value']},
288
- selector_evidence=candidates[0].get('evidence', []))
289
- proven_ids = {n['id'] for n in base['nodes']}
290
- root_ids = sorted({identity for entry in entries for identity in entry.get('surfaceIds', [])
291
- if identity in proven_ids})
292
- # A logical root is a contract adapter for multiple source-proven entries,
293
- # never an observed page. Unknown launch operands remain non-executable.
294
- if len(root_ids) > 1 or (root_ids and any(e['kind'] != 'app-launch' for e in entries)):
295
- logical_root = 'utg:entry-root'
296
- if logical_root in proven_ids:
297
- raise UtgError('reserved logical entry root identity collision')
298
- anchor = next(n['declared_at'] for n in base['nodes'] if n['id'] in root_ids)
299
- base['nodes'].append({'id': logical_root, 'kind': 'sentinel', 'label': 'Application entries',
300
- 'declared_at': anchor, 'provenance': 'appgraph', 'status': 'static-only',
301
- 'expected': {}, 'widgets': [], 'extra': {'capture_policy': 'strict', 'logical_entry': True}})
302
- base['graph']['root'] = logical_root
303
- base['graph']['entry_roots'] = root_ids
304
- base['unresolved'] = [item for item in base['unresolved'] if item.get('subject') != 'root']
305
- for entry in entries:
306
- for identity in entry.get('surfaceIds', []):
307
- if identity not in proven_ids:
308
- continue
309
- launch = entry.get('launch') or {}
310
- args = {'appgraph_entry_id': entry['id'], 'action': 'unresolved-entry',
311
- 'evidence_role': 'entry-target-declaration', 'appgraph_entry_evidence': entry.get('evidence', [])}
312
- if launch.get('action') == 'start-activity' and isinstance(launch.get('component'), str):
313
- args.update(action='start-activity', component=launch['component'])
314
- elif launch.get('action') == 'deep-link' and isinstance(launch.get('uri'), str):
315
- args.update(action='deep-link', label=launch['uri'], label_source='source-uri')
316
- if args['action'] == 'unresolved-entry':
317
- base['unresolved'].append({'id': 'entry-operands:' + entry['id'], 'subject': entry['id'],
318
- 'reason': 'entry destination is known but source-proven intent or launch operands are missing',
319
- 'evidence': entry.get('evidence', [])})
320
- base['edges'].append({'id': 'utg:entry:' + entry['id'], 'source': logical_root,
321
- 'target': identity, 'mechanism': 'deep-link' if entry['kind'] == 'deep-link' else 'app-launch-entry',
322
- 'trigger': next(n['declared_at'] for n in base['nodes'] if n['id'] == identity),
323
- 'confidence': 1, 'provenance': 'appgraph', 'status': 'static-only', 'args': args})
324
- surface_index = {s['id']: s for s in surfaces}
325
- for node in base['nodes']:
326
- if node['id'] not in surface_index:
327
- continue
328
- surface = surface_index[node['id']]
329
- node['extra'].update(source_refs=surface.get('sourceRefs', []),
330
- host_surface_ids=surface.get('hostSurfaceIds', []), presentation=surface.get('presentation'))
331
- if surface.get('presentation') == 'host-only':
332
- node['kind'] = 'host-activity'
333
- base['alignment'] = {'schema_version': 'd2h-appgraph-utg-alignment/v1',
334
- 'nodes': [{'utg_id': n['id'], 'source_refs': surface_index.get(n['id'], {}).get('sourceRefs', []),
335
- 'mapping': 'surface-projection' if n['id'] in surface_index else 'logical-entry'} for n in base['nodes']],
336
- 'edges': [{'utg_id': e['id'], 'source_refs': e['args'].get('appgraph_attrs', {}).get('supportingRefs', [])
337
- or ([e['args']['appgraph_entry_id']] if 'appgraph_entry_id' in e['args'] else []),
338
- 'mapping': 'transition-projection' if 'appgraph_attrs' in e['args'] else 'entry-projection'} for e in base['edges']],
339
- 'control_bindings': bindings,
340
- 'unmapped_surfaces': [s for s in surfaces if s['id'] not in {n['id'] for n in base['nodes']}],
341
- 'unmapped_transitions': [t for t in interaction.get('transitions', [])
342
- if t['id'] not in {e['id'] for e in base['edges']}]}
343
- base['graph']['boundary_transitions'] = [t for t in interaction.get('transitions', [])
344
- if t.get('target', {}).get('kind') in {'back', 'external'}]
345
- for item in interaction.get('unresolved', []):
346
- base['unresolved'].append({'id': 'interaction:' + item['id'], 'subject': item.get('subjectRefs'),
347
- 'reason': item['reason'], 'evidence': item.get('evidence', [])})
348
- base['nodes'].sort(key=lambda n: n['id'])
349
- base['edges'].sort(key=lambda e: e['id'])
350
- return base
351
-
352
-
353
- def apply_discoveries(base, discoveries, root, fingerprint):
354
- if discoveries.get('schema_version') != 'd2h-utg-discoveries/v1' or discoveries.get('input_fingerprint') != fingerprint:
355
- raise UtgError('invalid or stale discoveries input fingerprint')
356
- if not isinstance(discoveries.get('operations'), list) or not isinstance(discoveries.get('unresolved', []), list):
357
- raise UtgError('discoveries operations and unresolved must be arrays')
358
- result, audit, touched = copy.deepcopy(base), [], set()
359
- for item in discoveries.get('operations', []):
360
- if not isinstance(item, dict):
361
- raise UtgError('discovery operation must be an object')
362
- kind, operation, identity = item.get('kind'), item.get('op'), item.get('id')
363
- if kind not in {'node', 'edge', 'graph'} or operation not in {'add', 'replace', 'exclude'}:
364
- raise UtgError('unsupported discovery operation')
365
- if not identity or not isinstance(item.get('reason'), str) or not item['reason'].strip():
366
- raise UtgError('discovery needs id and reason')
367
- if not isinstance(identity, str):
368
- raise UtgError('discovery id must be a string')
369
- normalize_location(item.get('evidence'), root, 'discovery.evidence')
370
- key = (kind, identity)
371
- if key in touched:
372
- raise UtgError('conflicting operations on ' + str(key))
373
- touched.add(key)
374
- if kind == 'graph':
375
- if identity != 'root' or operation != 'replace' or not isinstance(item.get('value'), str):
376
- raise UtgError('only graph root replacement is supported')
377
- before = result['graph']['root']
378
- result['graph']['root'] = item['value']
379
- else:
380
- collection = result['nodes' if kind == 'node' else 'edges']
381
- matches = [x for x in collection if x['id'] == identity]
382
- if (operation == 'add' and matches) or (operation != 'add' and len(matches) != 1):
383
- raise UtgError('duplicate or missing discovery target: ' + identity)
384
- before = copy.deepcopy(matches[0]) if matches else None
385
- if matches:
386
- collection.remove(matches[0])
387
- if operation != 'exclude':
388
- value = copy.deepcopy(item.get('value'))
389
- if not isinstance(value, dict) or value.get('id') != identity:
390
- raise UtgError('discovery value identity mismatch')
391
- value.update(provenance='ai-source-review', status='static-only')
392
- if kind == 'node':
393
- value.setdefault('extra', {})['capture_policy'] = 'strict'
394
- else:
395
- value.setdefault('args', {})['discovery_id'] = identity
396
- collection.append(value)
397
- audit.append({'operation': copy.deepcopy(item), 'before': before})
398
- resolved = set()
399
- known = {item['id'] for item in base['unresolved']}
400
- resolved_findings = discoveries.get('resolved_findings', [])
401
- if not isinstance(resolved_findings, list):
402
- raise UtgError('resolved_findings must be an array')
403
- for finding in resolved_findings:
404
- if not isinstance(finding, dict) or finding.get('id') not in known or finding['id'] in resolved:
405
- raise UtgError('resolved finding must reference one unique worklist item')
406
- if not isinstance(finding.get('reason'), str) or not finding['reason'].strip():
407
- raise UtgError('resolved finding requires a reason')
408
- normalize_location(finding.get('evidence'), root, 'resolved finding evidence')
409
- resolved.add(finding['id'])
410
- result['unresolved'] = [item for item in result['unresolved'] if item['id'] not in resolved]
411
- result['unresolved'].extend(discoveries.get('unresolved', []))
412
- result['nodes'].sort(key=lambda n: n['id'])
413
- result['edges'].sort(key=lambda e: e['id'])
414
- result['graph'].update(method='appgraph+source-review' if audit or resolved else 'appgraph', input_fingerprint=fingerprint,
415
- discovery_audit=audit, resolved_findings=discoveries.get('resolved_findings', []),
416
- projection_summary={'base_nodes': len(base['nodes']), 'base_edges': len(base['edges']),
417
- 'operations': len(audit), 'resolved_findings': len(resolved)},
418
- unresolved=result['unresolved'])
419
- normalize_facts(result, root) # Reject dangling edges, root, duplicate nodes and invalid evidence.
420
- if len({e['id'] for e in result['edges']}) != len(result['edges']):
421
- raise UtgError('duplicate edge identity')
422
- if 'alignment' in result:
423
- result['alignment']['effective_nodes'] = [{'id': n['id'], 'provenance': n.get('provenance')} for n in result['nodes']]
424
- result['alignment']['effective_edges'] = [{'id': e['id'], 'provenance': e.get('provenance')} for e in result['edges']]
425
- result['alignment']['discovery_audit'] = audit
426
- return result
427
-
428
-
429
- def main():
430
- parser = argparse.ArgumentParser(description=__doc__)
431
- parser.add_argument('--project-root', required=True, type=Path)
432
- parser.add_argument('--workspace', type=Path)
433
- parser.add_argument('--appgraph', type=Path)
434
- parser.add_argument('--discoveries', type=Path)
435
- parser.add_argument('--force', action='store_true')
436
- args = parser.parse_args()
437
- root = args.project_root.resolve()
438
- ws = (args.workspace or root.parent / (root.name + '-migration') / '.migration' / 'utg').resolve()
439
- try:
440
- if not root.is_dir():
441
- raise UtgError('source directory does not exist')
442
- if (ws / 'appgraph-utg-facts.json').exists() and not args.force:
443
- raise UtgError('workspace already exists; use --force for explicit regeneration')
444
- graph_path = args.appgraph.resolve() if args.appgraph else ws / 'app-graph.json'
445
- if not args.appgraph:
446
- executable = shutil.which('appgraph')
447
- if not executable:
448
- raise UtgError('appgraph is missing; configure the existing CLI before retrying')
449
- ws.mkdir(parents=True, exist_ok=True)
450
- subprocess.run([executable, 'build', str(root), '--platform', 'android', '--out', str(graph_path)], check=True)
451
- graph = load_json(graph_path)
452
- fingerprint = digest({'appgraph': graph, 'source': source_digest(root)})
453
- base = project(graph, root)
454
- base['graph']['input_fingerprint'] = fingerprint
455
- base['graph']['appgraph_source'] = str(graph_path)
456
- discovery_path = args.discoveries or ws / 'discoveries.json'
457
- discoveries = load_json(discovery_path) if discovery_path.exists() else {
458
- 'schema_version': 'd2h-utg-discoveries/v1', 'input_fingerprint': fingerprint,
459
- 'operations': [], 'unresolved': []}
460
- atomic_write_json(ws / 'appgraph-utg-facts.json', base)
461
- if 'alignment' in base:
462
- atomic_write_json(ws / 'appgraph-utg-alignment.json', base['alignment'])
463
- navigation_findings = [item for item in base['unresolved']
464
- if item.get('reason') == 'navigation owner/target/evidence unresolved']
465
- resolved_ids = {item.get('id') for item in discoveries.get('resolved_findings', [])}
466
- raw_names = {node['id']: node.get('name', node['id']) for node in graph.get('nodes', [])}
467
- navigation_candidates = [{'id': edge['id'], 'source_id': edge['from'], 'target_id': edge['to'],
468
- 'source_name': raw_names.get(edge['from']), 'target_name': raw_names.get(edge['to']),
469
- 'route_key': (edge.get('attrs') or {}).get('routeKey'),
470
- 'evidence': (edge.get('attrs') or {}).get('evidence', [])}
471
- for edge in graph.get('edges', []) if edge.get('kind') == 'navigates_to']
472
- atomic_write_json(ws / 'review-worklist.json', {'input_fingerprint': fingerprint,
473
- 'appgraph': str(graph_path), 'items': base['unresolved'],
474
- 'navigation_candidates': navigation_candidates,
475
- 'navigation_review': {'total_findings': len(navigation_findings),
476
- 'remaining_findings': len([item for item in navigation_findings if item['id'] not in resolved_ids]),
477
- 'status': 'reviewed' if all(item['id'] in resolved_ids for item in navigation_findings) else 'pending'},
478
- 'required_review': ['route registries', 'wrapper calls', 'method references', 'entry points', 'guards', 'selectors']})
479
- if not discovery_path.exists():
480
- atomic_write_json(discovery_path, discoveries)
481
- if not base['nodes'] or (not base['graph']['root'] and not discoveries['operations']):
482
- raise UtgError('no proven pages or unique root; review-worklist.json requires source-grounded discoveries')
483
- merged = apply_discoveries(base, discoveries, root, fingerprint)
484
- if 'alignment' in merged:
485
- atomic_write_json(ws / 'appgraph-utg-alignment.json', merged['alignment'])
486
- atomic_write_json(ws / 'utg-facts.json', merged)
487
- command = [sys.executable, str(SHARED / 'build_utg.py'), '--facts', str(ws / 'utg-facts.json'),
488
- '--project-root', str(root), '--workspace', str(ws)]
489
- if args.force:
490
- command.append('--force')
491
- subprocess.run(command, check=True)
492
- return 0
493
- except (UtgError, OSError, subprocess.CalledProcessError) as exc:
494
- print('ERROR: ' + str(exc), file=sys.stderr)
495
- return 2
496
-
497
-
498
- if __name__ == '__main__':
499
- raise SystemExit(main())
1
+ #!/usr/bin/env python3
2
+ """Project read-only AppGraph facts and apply source-grounded discovery overlays."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import copy
7
+ import hashlib
8
+ import json
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from xml.parsers import expat
13
+ from pathlib import Path
14
+
15
+ SHARED = Path(__file__).resolve().parents[2] / 'd2h-utg-ai-generate' / 'scripts'
16
+ sys.path.insert(0, str(SHARED))
17
+ from utg_common import UtgError, atomic_write_json, load_json, normalize_location, normalize_facts, build_click_paths
18
+
19
+
20
+ def digest(value):
21
+ return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
22
+ separators=(',', ':')).encode()).hexdigest()
23
+
24
+
25
+ def source_digest(root):
26
+ extensions = {'.kt', '.java', '.xml', '.gradle', '.kts', '.json', '.properties'}
27
+ ignored = {'.git', '.migration', '.appgraph', '.codegraph', '.codegraph-appgraph', 'node_modules', 'build', '.gradle'}
28
+ records = []
29
+ for p in sorted(root.rglob('*')):
30
+ relative = p.relative_to(root)
31
+ if p.is_file() and not any(part in ignored for part in relative.parts) and p.suffix in extensions:
32
+ records.append((relative.as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()))
33
+ return digest(records)
34
+
35
+
36
+ def project_legacy(graph, root):
37
+ if graph.get('schemaVersion') != 1 or graph.get('platform') != 'android':
38
+ raise UtgError('expected Android AppGraph schemaVersion=1')
39
+ raw_nodes = graph.get('nodes', [])
40
+ ui = graph.get('uiComponents') or {}
41
+ all_nodes = raw_nodes + ui.get('nodes', [])
42
+ if len({n['id'] for n in all_nodes}) != len(all_nodes):
43
+ raise UtgError('duplicate AppGraph node identity')
44
+ index = {n['id']: n for n in all_nodes}
45
+ diagnostics, nodes, edges = [], {}, []
46
+
47
+ def issue(subject, reason, evidence=None):
48
+ finding = {'subject': subject, 'reason': reason, 'evidence': evidence}
49
+ finding['id'] = 'review:' + digest(finding)[:20]
50
+ diagnostics.append(finding)
51
+
52
+ def location(obj):
53
+ refs = obj.get('evidence') or [obj.get('platformRef') or obj.get('origin') or {}]
54
+ if isinstance(refs, dict):
55
+ refs = [refs]
56
+ for ref in refs:
57
+ if not isinstance(ref, dict):
58
+ continue
59
+ # Manifest facts carry a symbol but older producers omit a line.
60
+ # Resolve that exact XML declaration, never a guessed class-name hit.
61
+ if ref.get('file', '').endswith('.xml') and ref.get('symbol') and not ref.get('line'):
62
+ path = (root / ref['file']).resolve()
63
+ if path.is_relative_to(root) and path.is_file():
64
+ parser = expat.ParserCreate()
65
+ matches = []
66
+ def start(tag, attrs):
67
+ if attrs.get('android:name') == ref['symbol']:
68
+ matches.append(parser.CurrentLineNumber)
69
+ parser.StartElementHandler = start
70
+ try:
71
+ parser.Parse(path.read_bytes(), True)
72
+ if len(matches) == 1:
73
+ return normalize_location(f"{ref['file']}:{matches[0]}", root, 'XML declaration')
74
+ except expat.ExpatError:
75
+ pass
76
+ if ref.get('file') and ref.get('line'):
77
+ try:
78
+ return normalize_location(f"{ref['file']}:{ref['line']}", root, 'AppGraph evidence')
79
+ except UtgError:
80
+ pass
81
+ return None
82
+
83
+ kinds = {'compose': 'compose-route', 'activity': 'activity', 'fragment': 'fragment',
84
+ 'dialog': 'dialog', 'dialog-fragment': 'dialog', 'sheet': 'sheet', 'bottom-sheet': 'sheet',
85
+ 'menu': 'menu', 'voyager': 'voyager-screen'}
86
+ for raw in sorted(all_nodes, key=lambda n: n['id']):
87
+ if raw.get('kind') not in {'Screen', 'page'}:
88
+ continue
89
+ if raw.get('evidenceLevel') == 'candidate' or raw.get('platform', 'android') != 'android':
90
+ issue(raw['id'], 'candidate or non-Android page requires review')
91
+ continue
92
+ loc = location(raw)
93
+ if not loc:
94
+ issue(raw['id'], 'page has no valid source line evidence')
95
+ continue
96
+ attrs = raw.get('attrs') or {}
97
+ # Fine pages explicitly referencing the same Screen are aliases, not new screens.
98
+ alias = attrs.get('screenId')
99
+ if alias in nodes:
100
+ continue
101
+ nodes[raw['id']] = {'id': raw['id'], 'kind': kinds.get(raw.get('subtype'), 'page'),
102
+ 'label': raw.get('name') or raw['id'], 'declared_at': loc,
103
+ 'provenance': 'appgraph', 'status': 'static-only', 'expected': {}, 'widgets': [],
104
+ 'extra': {'appgraph_id': raw['id'], 'appgraph_evidence': raw.get('evidence', []),
105
+ 'capture_policy': 'strict'}}
106
+ aliases = {n['id']: (n.get('attrs') or {}).get('screenId') for n in all_nodes
107
+ if (n.get('attrs') or {}).get('screenId') in nodes}
108
+ for alias in aliases:
109
+ nodes.pop(alias, None)
110
+ # A source page can carry the stable Screen ID even when that coarse Screen
111
+ # has no line evidence (notably a Manifest activity declaration).
112
+ for raw in all_nodes:
113
+ if raw.get('kind') != 'page' or raw['id'] not in nodes:
114
+ continue
115
+ screen_id = (raw.get('attrs') or {}).get('screenId')
116
+ if screen_id and screen_id not in nodes:
117
+ aliases[screen_id] = raw['id']
118
+ # Compose destination registrations may also produce a synthetic Screen at
119
+ # the NavHost file's first line. Prefer the unique source-backed page for
120
+ # the same Screen match key, preserving both IDs as aliases.
121
+ source_pages = {}
122
+ for raw in all_nodes:
123
+ attrs = raw.get('attrs') or {}
124
+ if raw.get('kind') == 'page' and attrs.get('classificationRule') == 'COMPOSE-SCREEN-AST' and raw['id'] in nodes:
125
+ source_pages.setdefault(attrs.get('screenMatchKey'), []).append(raw['id'])
126
+ for raw in all_nodes:
127
+ attrs = raw.get('attrs') or {}
128
+ if raw.get('kind') != 'Screen' or attrs.get('classificationRule') != 'compose.destination.register':
129
+ continue
130
+ match_key = raw.get('matchKey', '')
131
+ candidates = source_pages.get(match_key.removesuffix(':compose'), [])
132
+ if len(candidates) == 1 and candidates[0] != raw['id']:
133
+ aliases[raw['id']] = candidates[0]
134
+ nodes.pop(raw['id'], None)
135
+ relations = graph.get('edges', []) + ui.get('edges', [])
136
+ parent = {}
137
+ for e in relations:
138
+ if e.get('kind') in {'contains', 'hosts', 'includes', 'renders'}:
139
+ parent.setdefault(e['to'], set()).add(e['from'])
140
+
141
+ def owners(identity):
142
+ seen, result, queue = set(), set(), [identity]
143
+ while queue:
144
+ current = queue.pop()
145
+ if current in seen:
146
+ continue
147
+ seen.add(current)
148
+ current = aliases.get(current, current)
149
+ if current in index and index[current].get('kind') == 'AppEntry':
150
+ queue.append((index[current].get('attrs') or {}).get('screen'))
151
+ continue
152
+ if current in nodes:
153
+ result.add(current)
154
+ else:
155
+ queue.extend(parent.get(current, []))
156
+ return sorted(result)
157
+
158
+ for raw in all_nodes:
159
+ if raw.get('kind') == 'control':
160
+ own = owners(raw['id'])
161
+ if len(own) == 1:
162
+ nodes[own[0]]['widgets'].append({'id': raw['id'], 'name': raw.get('name'),
163
+ 'evidence': raw.get('evidence', []), 'attrs': raw.get('attrs', {})})
164
+ else:
165
+ issue(raw['id'], 'control owner missing or ambiguous', raw.get('evidence'))
166
+
167
+ def navigation(e, source, target, evidence):
168
+ src, dst = owners(source), owners(target)
169
+ loc = location(evidence)
170
+ if len(src) != 1 or len(dst) != 1 or not loc:
171
+ issue(e.get('id', source), 'navigation owner/target/evidence unresolved', evidence)
172
+ return
173
+ attrs = e.get('attrs') or {}
174
+ branch = e.get('branchCondition')
175
+ guard = attrs.get('guard')
176
+ if branch and branch.get('expression'):
177
+ guard = {'kind': 'source-branch', 'text': ('!(' + branch['expression'] + ')') if branch.get('branch') == 'else' else branch['expression']}
178
+ edges.append({'id': e.get('id') or 'appgraph:' + digest(e)[:20],
179
+ 'source': src[0], 'target': dst[0],
180
+ 'mechanism': attrs.get('mechanism') or 'appgraph-navigation',
181
+ 'trigger': loc, 'confidence': e.get('confidence', 1), 'provenance': 'appgraph',
182
+ 'status': 'static-only', 'guard': guard,
183
+ 'args': {'appgraph_edge_id': e.get('id'), 'appgraph_attrs': attrs}})
184
+
185
+ for e in sorted(graph.get('edges', []), key=lambda e: e['id']):
186
+ if e.get('kind') == 'navigates_to':
187
+ attrs = e.get('attrs') or {}
188
+ navigation(e, e['from'], e['to'], {'evidence': attrs.get('evidence', []),
189
+ 'platformRef': attrs.get('platformRef') or attrs.get('origin') or {}})
190
+ entries = {e['id']: e for e in graph.get('entryPoints', [])}
191
+ roots = set()
192
+ for raw in raw_nodes:
193
+ if raw.get('kind') == 'AppEntry' and raw.get('subtype') == 'launcher-activity':
194
+ roots.update(owners(raw['id']))
195
+ for entry in entries.values():
196
+ if entry.get('kind') == 'app-launch' and entry.get('resolution') == 'resolved':
197
+ roots.update(owners(entry.get('ownerRef') or entry.get('sourceRef')))
198
+ for trace in sorted(graph.get('callTraces', []), key=lambda t: t['id']):
199
+ entry = entries.get(trace.get('entryPointId'), {})
200
+ if entry.get('resolution') != 'resolved':
201
+ issue(trace.get('id'), 'entry unresolved; trace cannot establish navigation')
202
+ continue
203
+ for effect in trace.get('effects', []):
204
+ if effect.get('kind') == 'navigation':
205
+ targets = effect.get('targetRefs', [])
206
+ if len(targets) != 1:
207
+ issue(effect.get('id'), 'navigation effect target missing or ambiguous', effect.get('origin'))
208
+ continue
209
+ for target in targets:
210
+ navigation(effect, entry.get('ownerRef') or entry.get('sourceRef'), target,
211
+ {'platformRef': effect.get('origin') or {}})
212
+ for warning in graph.get('coverageWarnings', []):
213
+ issue('appgraph-coverage', warning.get('message'), warning.get('ref'))
214
+ if len(roots) != 1:
215
+ issue('root', 'exactly one proven app-launch page is required', sorted(roots))
216
+ connected = {e['source'] for e in edges} | {e['target'] for e in edges}
217
+ for node in nodes.values():
218
+ if node['id'] not in connected:
219
+ issue(node['id'], 'isolated page: inspect navigation registry and call sites')
220
+ issue(node['id'], 'arrival rules and executable selectors require source review')
221
+ return {'schema_version': 'd2h-utg-facts/v1',
222
+ 'graph': {'app': graph.get('app', {}).get('name') or root.name,
223
+ 'package': graph.get('app', {}).get('packageName', ''),
224
+ 'architecture': 'appgraph/android', 'root': next(iter(roots)) if len(roots) == 1 else ''},
225
+ 'nodes': sorted(nodes.values(), key=lambda n: n['id']),
226
+ 'edges': sorted(edges, key=lambda e: (e['id'], e['source'], e['target'])),
227
+ 'unresolved': diagnostics}
228
+
229
+
230
+ def project(graph, root):
231
+ interaction = graph.get('interactionGraph')
232
+ if interaction is None:
233
+ return project_legacy(graph, root)
234
+ if interaction.get('schemaVersion') != 1:
235
+ raise UtgError('unsupported interactionGraph schemaVersion')
236
+ surfaces = interaction.get('surfaces', [])
237
+ if len({s['id'] for s in surfaces}) != len(surfaces):
238
+ raise UtgError('duplicate interaction surface identity')
239
+ bindings = interaction.get('controlBindings', [])
240
+ raw_index = {n['id']: n for n in (graph.get('uiComponents') or {}).get('nodes', [])}
241
+ projected = dict(graph, nodes=[{'id': s['id'], 'kind': 'Screen', 'name': s['name'],
242
+ 'subtype': s['kind'], 'evidence': s.get('evidence', []),
243
+ **({'evidenceLevel': 'candidate'} if s.get('resolution') in {'ambiguous', 'unresolved'} else {}),
244
+ 'attrs': {}} for s in surfaces],
245
+ edges=[], entryPoints=[], callTraces=[], uiComponents={'nodes': [], 'edges': []})
246
+ for binding in bindings:
247
+ original = raw_index.get(binding['controlRef'], {})
248
+ projected['uiComponents']['nodes'].append(dict(original, id=binding['id'], kind='control'))
249
+ projected['uiComponents']['edges'].append({'id': binding['id'], 'kind': 'contains',
250
+ 'from': binding['surfaceId'], 'to': binding['id']})
251
+ for transition in interaction.get('transitions', []):
252
+ target = transition.get('target', {})
253
+ if transition.get('resolution') != 'resolved' or target.get('kind') != 'internal':
254
+ continue
255
+ attrs = {'evidence': transition.get('evidence', []), 'mechanism': transition['operation'],
256
+ 'supportingRefs': transition.get('supportingRefs', []),
257
+ 'actionRef': transition.get('actionRef'), 'arguments': transition.get('arguments', {})}
258
+ conditions = transition.get('conditions', [])
259
+ if conditions:
260
+ attrs['guard'] = {'kind': 'source-branch', 'text': ' && '.join(('!(' + c['expression'] + ')') if c.get('branch') == 'else' else '(' + c['expression'] + ')' for c in conditions), 'conditions': conditions}
261
+ projected['edges'].append({'id': transition['id'], 'kind': 'navigates_to',
262
+ 'from': transition.get('sourceSurfaceId'), 'to': target['surfaceId'],
263
+ 'confidence': transition.get('confidence', 1), 'attrs': attrs})
264
+ entries = [e for e in interaction.get('entries', [])
265
+ if e.get('kind') in {'app-launch', 'deep-link', 'external-entry'} and e.get('resolution') == 'resolved']
266
+ for entry in entries:
267
+ for identity in entry.get('surfaceIds', []):
268
+ projected['entryPoints'].append({'id': entry['id'], 'kind': 'app-launch',
269
+ 'resolution': 'resolved', 'ownerRef': identity})
270
+ base = project_legacy(projected, root)
271
+ base['graph']['path_policy'] = 'source-executable-first'
272
+ interaction_entries = {e['id']: e for e in interaction.get('entries', [])}
273
+ for edge in base['edges']:
274
+ if edge['mechanism'] == 'navhost-start':
275
+ edge['args']['action'] = 'default'
276
+ continue
277
+ metadata = edge['args'].get('appgraph_attrs', {})
278
+ entry = interaction_entries.get(metadata.get('actionRef'), {})
279
+ applicable = [b for b in bindings if b['surfaceId'] == edge['source']
280
+ and b['controlRef'] == entry.get('sourceRef')]
281
+ candidates = [s for b in applicable for s in b.get('selectorCandidates', [])
282
+ if s.get('kind') == 'resource-id' and isinstance(s.get('value'), str)]
283
+ event = entry.get('event')
284
+ action = {'click': 'tap', 'onClick': 'tap', 'compose-click': 'tap',
285
+ 'long-click': 'long-press', 'onLongClick': 'long-press', 'compose-long-click': 'long-press'}.get(event)
286
+ if action and len(candidates) == 1:
287
+ edge['args'].update(action=action, selector={'resource_id': candidates[0]['value']},
288
+ selector_evidence=candidates[0].get('evidence', []))
289
+ proven_ids = {n['id'] for n in base['nodes']}
290
+ root_ids = sorted({identity for entry in entries for identity in entry.get('surfaceIds', [])
291
+ if identity in proven_ids})
292
+ # A logical root is a contract adapter for multiple source-proven entries,
293
+ # never an observed page. Unknown launch operands remain non-executable.
294
+ if len(root_ids) > 1 or (root_ids and any(e['kind'] != 'app-launch' for e in entries)):
295
+ logical_root = 'utg:entry-root'
296
+ if logical_root in proven_ids:
297
+ raise UtgError('reserved logical entry root identity collision')
298
+ anchor = next(n['declared_at'] for n in base['nodes'] if n['id'] in root_ids)
299
+ base['nodes'].append({'id': logical_root, 'kind': 'sentinel', 'label': 'Application entries',
300
+ 'declared_at': anchor, 'provenance': 'appgraph', 'status': 'static-only',
301
+ 'expected': {}, 'widgets': [], 'extra': {'capture_policy': 'strict', 'logical_entry': True}})
302
+ base['graph']['root'] = logical_root
303
+ base['graph']['entry_roots'] = root_ids
304
+ base['unresolved'] = [item for item in base['unresolved'] if item.get('subject') != 'root']
305
+ for entry in entries:
306
+ for identity in entry.get('surfaceIds', []):
307
+ if identity not in proven_ids:
308
+ continue
309
+ launch = entry.get('launch') or {}
310
+ args = {'appgraph_entry_id': entry['id'], 'action': 'unresolved-entry',
311
+ 'evidence_role': 'entry-target-declaration', 'appgraph_entry_evidence': entry.get('evidence', [])}
312
+ if launch.get('action') == 'start-activity' and isinstance(launch.get('component'), str):
313
+ args.update(action='start-activity', component=launch['component'])
314
+ elif launch.get('action') == 'deep-link' and isinstance(launch.get('uri'), str):
315
+ args.update(action='deep-link', label=launch['uri'], label_source='source-uri')
316
+ if args['action'] == 'unresolved-entry':
317
+ base['unresolved'].append({'id': 'entry-operands:' + entry['id'], 'subject': entry['id'],
318
+ 'reason': 'entry destination is known but source-proven intent or launch operands are missing',
319
+ 'evidence': entry.get('evidence', [])})
320
+ base['edges'].append({'id': 'utg:entry:' + entry['id'], 'source': logical_root,
321
+ 'target': identity, 'mechanism': 'deep-link' if entry['kind'] == 'deep-link' else 'app-launch-entry',
322
+ 'trigger': next(n['declared_at'] for n in base['nodes'] if n['id'] == identity),
323
+ 'confidence': 1, 'provenance': 'appgraph', 'status': 'static-only', 'args': args})
324
+ surface_index = {s['id']: s for s in surfaces}
325
+ for node in base['nodes']:
326
+ if node['id'] not in surface_index:
327
+ continue
328
+ surface = surface_index[node['id']]
329
+ node['extra'].update(source_refs=surface.get('sourceRefs', []),
330
+ host_surface_ids=surface.get('hostSurfaceIds', []), presentation=surface.get('presentation'))
331
+ if surface.get('presentation') == 'host-only':
332
+ node['kind'] = 'host-activity'
333
+ base['alignment'] = {'schema_version': 'd2h-appgraph-utg-alignment/v1',
334
+ 'nodes': [{'utg_id': n['id'], 'source_refs': surface_index.get(n['id'], {}).get('sourceRefs', []),
335
+ 'mapping': 'surface-projection' if n['id'] in surface_index else 'logical-entry'} for n in base['nodes']],
336
+ 'edges': [{'utg_id': e['id'], 'source_refs': e['args'].get('appgraph_attrs', {}).get('supportingRefs', [])
337
+ or ([e['args']['appgraph_entry_id']] if 'appgraph_entry_id' in e['args'] else []),
338
+ 'mapping': 'transition-projection' if 'appgraph_attrs' in e['args'] else 'entry-projection'} for e in base['edges']],
339
+ 'control_bindings': bindings,
340
+ 'unmapped_surfaces': [s for s in surfaces if s['id'] not in {n['id'] for n in base['nodes']}],
341
+ 'unmapped_transitions': [t for t in interaction.get('transitions', [])
342
+ if t['id'] not in {e['id'] for e in base['edges']}]}
343
+ base['graph']['boundary_transitions'] = [t for t in interaction.get('transitions', [])
344
+ if t.get('target', {}).get('kind') in {'back', 'external'}]
345
+ for item in interaction.get('unresolved', []):
346
+ base['unresolved'].append({'id': 'interaction:' + item['id'], 'subject': item.get('subjectRefs'),
347
+ 'reason': item['reason'], 'evidence': item.get('evidence', [])})
348
+ base['nodes'].sort(key=lambda n: n['id'])
349
+ base['edges'].sort(key=lambda e: e['id'])
350
+ return base
351
+
352
+
353
+ def apply_discoveries(base, discoveries, root, fingerprint):
354
+ if discoveries.get('schema_version') != 'd2h-utg-discoveries/v1' or discoveries.get('input_fingerprint') != fingerprint:
355
+ raise UtgError('invalid or stale discoveries input fingerprint')
356
+ if not isinstance(discoveries.get('operations'), list) or not isinstance(discoveries.get('unresolved', []), list):
357
+ raise UtgError('discoveries operations and unresolved must be arrays')
358
+ result, audit, touched = copy.deepcopy(base), [], set()
359
+ for item in discoveries.get('operations', []):
360
+ if not isinstance(item, dict):
361
+ raise UtgError('discovery operation must be an object')
362
+ kind, operation, identity = item.get('kind'), item.get('op'), item.get('id')
363
+ if kind not in {'node', 'edge', 'graph'} or operation not in {'add', 'replace', 'exclude'}:
364
+ raise UtgError('unsupported discovery operation')
365
+ if not identity or not isinstance(item.get('reason'), str) or not item['reason'].strip():
366
+ raise UtgError('discovery needs id and reason')
367
+ if not isinstance(identity, str):
368
+ raise UtgError('discovery id must be a string')
369
+ normalize_location(item.get('evidence'), root, 'discovery.evidence')
370
+ key = (kind, identity)
371
+ if key in touched:
372
+ raise UtgError('conflicting operations on ' + str(key))
373
+ touched.add(key)
374
+ if kind == 'graph':
375
+ if identity != 'root' or operation != 'replace' or not isinstance(item.get('value'), str):
376
+ raise UtgError('only graph root replacement is supported')
377
+ before = result['graph']['root']
378
+ result['graph']['root'] = item['value']
379
+ else:
380
+ collection = result['nodes' if kind == 'node' else 'edges']
381
+ matches = [x for x in collection if x['id'] == identity]
382
+ if (operation == 'add' and matches) or (operation != 'add' and len(matches) != 1):
383
+ raise UtgError('duplicate or missing discovery target: ' + identity)
384
+ before = copy.deepcopy(matches[0]) if matches else None
385
+ if matches:
386
+ collection.remove(matches[0])
387
+ if operation != 'exclude':
388
+ value = copy.deepcopy(item.get('value'))
389
+ if not isinstance(value, dict) or value.get('id') != identity:
390
+ raise UtgError('discovery value identity mismatch')
391
+ value.update(provenance='ai-source-review', status='static-only')
392
+ if kind == 'node':
393
+ value.setdefault('extra', {})['capture_policy'] = 'strict'
394
+ else:
395
+ value.setdefault('args', {})['discovery_id'] = identity
396
+ collection.append(value)
397
+ audit.append({'operation': copy.deepcopy(item), 'before': before})
398
+ resolved = set()
399
+ known = {item['id'] for item in base['unresolved']}
400
+ resolved_findings = discoveries.get('resolved_findings', [])
401
+ if not isinstance(resolved_findings, list):
402
+ raise UtgError('resolved_findings must be an array')
403
+ for finding in resolved_findings:
404
+ if not isinstance(finding, dict) or finding.get('id') not in known or finding['id'] in resolved:
405
+ raise UtgError('resolved finding must reference one unique worklist item')
406
+ if not isinstance(finding.get('reason'), str) or not finding['reason'].strip():
407
+ raise UtgError('resolved finding requires a reason')
408
+ normalize_location(finding.get('evidence'), root, 'resolved finding evidence')
409
+ resolved.add(finding['id'])
410
+ result['unresolved'] = [item for item in result['unresolved'] if item['id'] not in resolved]
411
+ result['unresolved'].extend(discoveries.get('unresolved', []))
412
+ result['nodes'].sort(key=lambda n: n['id'])
413
+ result['edges'].sort(key=lambda e: e['id'])
414
+ result['graph'].update(method='appgraph+source-review' if audit or resolved else 'appgraph', input_fingerprint=fingerprint,
415
+ discovery_audit=audit, resolved_findings=discoveries.get('resolved_findings', []),
416
+ projection_summary={'base_nodes': len(base['nodes']), 'base_edges': len(base['edges']),
417
+ 'operations': len(audit), 'resolved_findings': len(resolved)},
418
+ unresolved=result['unresolved'])
419
+ normalize_facts(result, root) # Reject dangling edges, root, duplicate nodes and invalid evidence.
420
+ if len({e['id'] for e in result['edges']}) != len(result['edges']):
421
+ raise UtgError('duplicate edge identity')
422
+ if 'alignment' in result:
423
+ result['alignment']['effective_nodes'] = [{'id': n['id'], 'provenance': n.get('provenance')} for n in result['nodes']]
424
+ result['alignment']['effective_edges'] = [{'id': e['id'], 'provenance': e.get('provenance')} for e in result['edges']]
425
+ result['alignment']['discovery_audit'] = audit
426
+ return result
427
+
428
+
429
+ def main():
430
+ parser = argparse.ArgumentParser(description=__doc__)
431
+ parser.add_argument('--project-root', required=True, type=Path)
432
+ parser.add_argument('--workspace', type=Path)
433
+ parser.add_argument('--appgraph', type=Path)
434
+ parser.add_argument('--discoveries', type=Path)
435
+ parser.add_argument('--force', action='store_true')
436
+ args = parser.parse_args()
437
+ root = args.project_root.resolve()
438
+ ws = (args.workspace or root.parent / (root.name + '-migration') / '.migration' / 'utg').resolve()
439
+ try:
440
+ if not root.is_dir():
441
+ raise UtgError('source directory does not exist')
442
+ if (ws / 'appgraph-utg-facts.json').exists() and not args.force:
443
+ raise UtgError('workspace already exists; use --force for explicit regeneration')
444
+ graph_path = args.appgraph.resolve() if args.appgraph else ws / 'app-graph.json'
445
+ if not args.appgraph:
446
+ executable = shutil.which('appgraph')
447
+ if not executable:
448
+ raise UtgError('appgraph is missing; configure the existing CLI before retrying')
449
+ ws.mkdir(parents=True, exist_ok=True)
450
+ subprocess.run([executable, 'build', str(root), '--platform', 'android', '--out', str(graph_path)], check=True)
451
+ graph = load_json(graph_path)
452
+ fingerprint = digest({'appgraph': graph, 'source': source_digest(root)})
453
+ base = project(graph, root)
454
+ base['graph']['input_fingerprint'] = fingerprint
455
+ base['graph']['appgraph_source'] = str(graph_path)
456
+ discovery_path = args.discoveries or ws / 'discoveries.json'
457
+ discoveries = load_json(discovery_path) if discovery_path.exists() else {
458
+ 'schema_version': 'd2h-utg-discoveries/v1', 'input_fingerprint': fingerprint,
459
+ 'operations': [], 'unresolved': []}
460
+ atomic_write_json(ws / 'appgraph-utg-facts.json', base)
461
+ if 'alignment' in base:
462
+ atomic_write_json(ws / 'appgraph-utg-alignment.json', base['alignment'])
463
+ navigation_findings = [item for item in base['unresolved']
464
+ if item.get('reason') == 'navigation owner/target/evidence unresolved']
465
+ resolved_ids = {item.get('id') for item in discoveries.get('resolved_findings', [])}
466
+ raw_names = {node['id']: node.get('name', node['id']) for node in graph.get('nodes', [])}
467
+ navigation_candidates = [{'id': edge['id'], 'source_id': edge['from'], 'target_id': edge['to'],
468
+ 'source_name': raw_names.get(edge['from']), 'target_name': raw_names.get(edge['to']),
469
+ 'route_key': (edge.get('attrs') or {}).get('routeKey'),
470
+ 'evidence': (edge.get('attrs') or {}).get('evidence', [])}
471
+ for edge in graph.get('edges', []) if edge.get('kind') == 'navigates_to']
472
+ atomic_write_json(ws / 'review-worklist.json', {'input_fingerprint': fingerprint,
473
+ 'appgraph': str(graph_path), 'items': base['unresolved'],
474
+ 'navigation_candidates': navigation_candidates,
475
+ 'navigation_review': {'total_findings': len(navigation_findings),
476
+ 'remaining_findings': len([item for item in navigation_findings if item['id'] not in resolved_ids]),
477
+ 'status': 'reviewed' if all(item['id'] in resolved_ids for item in navigation_findings) else 'pending'},
478
+ 'required_review': ['route registries', 'wrapper calls', 'method references', 'entry points', 'guards', 'selectors']})
479
+ if not discovery_path.exists():
480
+ atomic_write_json(discovery_path, discoveries)
481
+ if not base['nodes'] or (not base['graph']['root'] and not discoveries['operations']):
482
+ raise UtgError('no proven pages or unique root; review-worklist.json requires source-grounded discoveries')
483
+ merged = apply_discoveries(base, discoveries, root, fingerprint)
484
+ if 'alignment' in merged:
485
+ atomic_write_json(ws / 'appgraph-utg-alignment.json', merged['alignment'])
486
+ atomic_write_json(ws / 'utg-facts.json', merged)
487
+ command = [sys.executable, str(SHARED / 'build_utg.py'), '--facts', str(ws / 'utg-facts.json'),
488
+ '--project-root', str(root), '--workspace', str(ws)]
489
+ if args.force:
490
+ command.append('--force')
491
+ subprocess.run(command, check=True)
492
+ return 0
493
+ except (UtgError, OSError, subprocess.CalledProcessError) as exc:
494
+ print('ERROR: ' + str(exc), file=sys.stderr)
495
+ return 2
496
+
497
+
498
+ if __name__ == '__main__':
499
+ raise SystemExit(main())