@harmonyos-arkts/d2h 0.1.2 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -44
- package/agents/android-to-hmos-00-orchestrator.md +34 -28
- package/agents/d2h.md +266 -95
- package/dist/bin/install-opencode.js +137 -43
- package/dist/src/install-opencode.js +44 -0
- package/dist/src/install-vscode.js +226 -0
- package/dist/src/toolchain.js +83 -0
- package/docs/environment-check.md +86 -0
- package/opencode/agents.json +1 -1
- package/opencode/vscode-d2h-package.json +7 -0
- package/opencode/vscode-d2h-plugin.mjs +29 -0
- package/package.json +6 -4
- package/schemas/task-envelope.schema.json +58 -4
- package/schemas/task-plan.schema.json +281 -63
- package/scripts/check-environment.mjs +321 -0
- package/skills/android2hmos_resources_convert/SKILL.md +37 -19
- package/skills/appgraph-rule-audit/SKILL.md +24 -5
- package/skills/d2h-hypium-forge/SKILL.md +252 -0
- package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -0
- package/skills/d2h-hypium-forge/references/official-api.md +135 -0
- package/skills/d2h-hypium-forge/references/pitfalls.md +98 -0
- package/skills/d2h-hypium-forge/references/testability-template.md +71 -0
- package/skills/d2h-hypium-forge/scripts/_common_template.py +614 -0
- package/skills/d2h-hypium-forge/scripts/analyze_results.py +156 -0
- package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -0
- package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -0
- package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -0
- package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -0
- package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -0
- package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -0
- package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -0
- package/skills/d2h-hypium-forge/scripts/precondition.py +140 -0
- package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -0
- package/skills/d2h-hypium-forge/scripts/run_direct.py +392 -0
- package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -0
- package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -0
- package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -0
- package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -0
- package/skills/d2h-utg-ai-generate/SKILL.md +128 -0
- package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -0
- package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -0
- package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -0
- package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -0
- package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -0
- package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -0
- package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -0
- package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -0
- package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -0
- package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -0
- package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -0
- package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -0
- package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -0
- package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -0
- package/skills/d2h-utg-static-generate/SKILL.md +73 -0
- package/skills/d2h-utg-static-generate/references/discoveries.md +38 -0
- package/skills/d2h-utg-static-generate/scripts/generate.py +499 -0
- package/skills/d2h-utg-static-generate/tests/test_static.py +335 -0
- package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -0
- package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -0
- package/skills/harmonyos-migration-playbook/SKILL.md +30 -3
- package/skills/harmonyos-migration-playbook/references/lesson-lifecycle.md +4 -4
- package/skills/harmonyos-migration-playbook/references/ui-automation.md +4 -3
- package/skills/harmonyos-migration-playbook/references/windows-environment.md +2 -2
- package/skills/maintaining-migration-report/SKILL.md +88 -99
- package/skills/maintaining-migration-report/references/final-acceptance.md +48 -0
- package/skills/maintaining-migration-report/references/source-provenance.md +66 -0
- package/skills/managing-d2h-toolchain/SKILL.md +23 -0
- package/skills/managing-d2h-toolchain/environment-requirements.json +16 -0
- package/skills/managing-d2h-toolchain/toolchain.json +16 -0
- package/skills/preparing-migration-workspace/SKILL.md +76 -69
- package/skills/preparing-migration-workspace/toolchain.json +0 -43
|
@@ -0,0 +1,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())
|