@harmonyos-arkts/d2h 0.1.3 → 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.
Files changed (65) hide show
  1. package/README.md +78 -44
  2. package/agents/android-to-hmos-00-orchestrator.md +34 -28
  3. package/agents/d2h.md +266 -95
  4. package/dist/bin/install-opencode.js +137 -43
  5. package/dist/src/install-opencode.js +44 -0
  6. package/dist/src/install-vscode.js +226 -0
  7. package/dist/src/toolchain.js +83 -0
  8. package/docs/environment-check.md +86 -0
  9. package/opencode/agents.json +1 -1
  10. package/opencode/vscode-d2h-package.json +7 -0
  11. package/opencode/vscode-d2h-plugin.mjs +29 -0
  12. package/package.json +6 -4
  13. package/schemas/task-envelope.schema.json +58 -4
  14. package/schemas/task-plan.schema.json +281 -63
  15. package/scripts/check-environment.mjs +321 -0
  16. package/skills/d2h-hypium-forge/SKILL.md +252 -0
  17. package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -0
  18. package/skills/d2h-hypium-forge/references/official-api.md +135 -0
  19. package/skills/d2h-hypium-forge/references/pitfalls.md +98 -0
  20. package/skills/d2h-hypium-forge/references/testability-template.md +71 -0
  21. package/skills/d2h-hypium-forge/scripts/_common_template.py +614 -0
  22. package/skills/d2h-hypium-forge/scripts/analyze_results.py +156 -0
  23. package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -0
  24. package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -0
  25. package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -0
  26. package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -0
  27. package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -0
  28. package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -0
  29. package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -0
  30. package/skills/d2h-hypium-forge/scripts/precondition.py +140 -0
  31. package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -0
  32. package/skills/d2h-hypium-forge/scripts/run_direct.py +392 -0
  33. package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -0
  34. package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -0
  35. package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -0
  36. package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -0
  37. package/skills/d2h-utg-ai-generate/SKILL.md +128 -0
  38. package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -0
  39. package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -0
  40. package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -0
  41. package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -0
  42. package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -0
  43. package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -0
  44. package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -0
  45. package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -0
  46. package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -0
  47. package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -0
  48. package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -0
  49. package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -0
  50. package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -0
  51. package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -0
  52. package/skills/d2h-utg-static-generate/SKILL.md +73 -0
  53. package/skills/d2h-utg-static-generate/references/discoveries.md +38 -0
  54. package/skills/d2h-utg-static-generate/scripts/generate.py +499 -0
  55. package/skills/d2h-utg-static-generate/tests/test_static.py +335 -0
  56. package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -0
  57. package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -0
  58. package/skills/harmonyos-migration-playbook/references/ui-automation.md +4 -3
  59. package/skills/harmonyos-migration-playbook/references/windows-environment.md +1 -1
  60. package/skills/managing-d2h-toolchain/SKILL.md +23 -0
  61. package/skills/managing-d2h-toolchain/environment-requirements.json +16 -0
  62. package/skills/managing-d2h-toolchain/toolchain.json +16 -0
  63. package/skills/preparing-migration-workspace/SKILL.md +9 -7
  64. package/agents/d2h_v2.md +0 -351
  65. package/skills/preparing-migration-workspace/toolchain.json +0 -19
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Interactive HTML visualization of a reconciled UTG (guided crawl output).
4
+
5
+ python scripts/utg_html.py <workspace>
6
+
7
+ Reads <workspace>/utg.json, writes <workspace>/utg.html. Self-contained; D3 v7
8
+ embedded from the bundled MIT-licensed distribution. Extends android-utg-static's renderer with the two things the crawler
9
+ adds:
10
+
11
+ * NODE = a page: the sidebar shows the captured screenshot thumbnail, the
12
+ runtime view signature, reached/unreached status, and a link to view.xml.
13
+ * EDGE = a navigation with SEMANTICS: precondition (guard), operation
14
+ (mechanism + on-screen label), status (confirmed / static-only /
15
+ dynamic-only / blocked) and confidence. Edges are colored by STATUS so the
16
+ reconciliation is visible at a glance.
17
+
18
+ Screenshots are referenced by RELATIVE path, so the HTML must live in the same
19
+ workspace directory as the p*/route_*/overlay_* page folders (it does).
20
+ """
21
+ import json
22
+ import os
23
+ import sys
24
+ from pathlib import Path
25
+ from collections import Counter
26
+
27
+ # ── Node kind palette ─────────────────────────────────────────────────────────
28
+ KIND_META = {
29
+ "activity": {"color": "#e05252", "zh": "Activity"},
30
+ "compose-route": {"color": "#4a90d9", "zh": "路由页面"},
31
+ "compose-route-variant": {"color": "#7db8e8", "zh": "数据状态变体"},
32
+ "fragment": {"color": "#e07d52", "zh": "Fragment"},
33
+ "voyager-screen": {"color": "#9b59b6", "zh": "Voyager 页面"},
34
+ "dialog": {"color": "#f39c12", "zh": "对话框"},
35
+ "sheet": {"color": "#f1c40f", "zh": "底部弹窗"},
36
+ "menu": {"color": "#2ecc71", "zh": "菜单"},
37
+ "mode": {"color": "#8e44ad", "zh": "交互模式"},
38
+ "page": {"color": "#4a90d9", "zh": "页面"},
39
+ "dynamic": {"color": "#1abc9c", "zh": "动态新增页面"},
40
+ "logical-entry": {"color": "#9b59b6", "zh": "逻辑入口"},
41
+ "host-activity": {"color": "#95a5a6", "zh": "页面宿主"},
42
+ "sentinel": {"color": "#c0392b", "zh": "阻断哨兵"},
43
+ "unknown": {"color": "#95a5a6", "zh": "未知"},
44
+ }
45
+
46
+ # ── Edge STATUS palette (the reconciliation dimension) ──────────────────────────
47
+ STATUS_META = {
48
+ "confirmed": {"color": "#2ecc71", "dash": "", "zh": "已确认(动态复现)"},
49
+ "static-only": {"color": "#8894a8", "dash": "5 4", "zh": "仅静态(未验证)"},
50
+ "dynamic-only": {"color": "#4a90d9", "dash": "", "zh": "仅动态(静态漏掉)"},
51
+ "blocked": {"color": "#e05252", "dash": "3 3", "zh": "阻断(敏感/外部)"},
52
+ }
53
+
54
+
55
+ def _guard_text(guard):
56
+ if not guard:
57
+ return ""
58
+ if isinstance(guard, dict):
59
+ kind = guard.get("kind", "")
60
+ txt = guard.get("text", "")
61
+ return f"{txt}({kind})" if kind else txt
62
+ return str(guard)
63
+
64
+
65
+ def main():
66
+ ws = sys.argv[1] if len(sys.argv) > 1 else "."
67
+ utg_path = os.path.join(ws, "utg.json")
68
+ html_path = os.path.join(ws, "utg.html")
69
+ if not os.path.exists(utg_path):
70
+ raise SystemExit(f"utg.json not found in {ws}")
71
+
72
+ utg = json.load(open(utg_path, encoding="utf-8"))
73
+ nodes = utg.get("nodes", [])
74
+ links = utg.get("links") or utg.get("edges") or []
75
+
76
+ for n in nodes:
77
+ n["_display_kind"] = "logical-entry" if (n.get("extra") or {}).get("logical_entry") else n.get("kind", "unknown")
78
+ meta = KIND_META.get(n["_display_kind"], KIND_META["unknown"])
79
+ n["_color"] = meta["color"]
80
+ n["_zh"] = meta["zh"]
81
+ # dim nodes that are unreachable OR never reached by the crawl
82
+ n["_dim"] = (n.get("status") == "unreachable") or (n.get("reached") is False
83
+ and n.get("kind") not in ("sentinel",))
84
+ n["_guardtext"] = ""
85
+
86
+ for e in links:
87
+ st = e.get("status", "static-only")
88
+ sm = STATUS_META.get(st, STATUS_META["static-only"])
89
+ e["_color"] = sm["color"]
90
+ e["_dash"] = sm["dash"]
91
+ e["_guardtext"] = _guard_text(e.get("guard"))
92
+ e["_label"] = (e.get("args") or {}).get("label", "")
93
+
94
+ kind_counts = Counter(n["_display_kind"] for n in nodes)
95
+ status_counts = Counter(e.get("status", "static-only") for e in links)
96
+ reached = sum(1 for n in nodes if n.get("reached"))
97
+ pages = sum(1 for n in nodes if n.get("kind") not in ("sentinel",))
98
+
99
+ stats_html = "\n".join(
100
+ f'<span class="badge" style="background:{KIND_META.get(k, KIND_META["unknown"])["color"]}">'
101
+ f'{KIND_META.get(k, {}).get("zh", k)}: {v}</span>'
102
+ for k, v in sorted(kind_counts.items(), key=lambda x: -x[1])
103
+ )
104
+ status_html = "\n".join(
105
+ f'<span class="badge" style="background:{STATUS_META.get(s, {}).get("color", "#888")}">'
106
+ f'{STATUS_META.get(s, {}).get("zh", s)}: {v}</span>'
107
+ for s, v in sorted(status_counts.items(), key=lambda x: -x[1])
108
+ )
109
+
110
+ graph_json = json.dumps({"nodes": nodes, "links": links}, ensure_ascii=False).replace("<", "\\u003c")
111
+ review_json = json.dumps({"audit": utg.get("graph", {}).get("discovery_audit", []), "unresolved": utg.get("graph", {}).get("unresolved", []), "boundary_transitions": utg.get("graph", {}).get("boundary_transitions", [])}, ensure_ascii=False).replace("<", "\\u003c")
112
+ d3_source = (Path(__file__).parent / "vendor" / "d3-7.9.0.min.js").read_text(encoding="utf-8")
113
+ kind_meta_json = json.dumps(KIND_META, ensure_ascii=False)
114
+ status_meta_json = json.dumps(STATUS_META, ensure_ascii=False)
115
+
116
+ html = f"""<!DOCTYPE html>
117
+ <html lang="zh">
118
+ <head>
119
+ <meta charset="UTF-8">
120
+ <title>UTG — {os.path.basename(os.path.abspath(ws))}</title>
121
+ <script>{d3_source}</script>
122
+ <style>
123
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
124
+ body {{ font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee; display: flex; height: 100vh; overflow: hidden; }}
125
+ #sidebar {{ width: 360px; min-width: 260px; max-width: 520px; background: #16213e; border-right: 1px solid #0f3460; display: flex; flex-direction: column; overflow: hidden; }}
126
+ #sidebar-header {{ padding: 14px 16px 10px; background: #0f3460; flex-shrink: 0; }}
127
+ #sidebar-header h1 {{ font-size: 15px; font-weight: 700; }}
128
+ #sidebar-header .sub {{ font-size: 11px; color: #aaa; margin-top: 3px; }}
129
+ .badge {{ display: inline-block; font-size: 10px; padding: 2px 6px; border-radius: 10px; color: #fff; margin: 2px 2px 2px 0; opacity: .9; }}
130
+ #stats {{ padding: 10px 16px; border-bottom: 1px solid #0f3460; flex-shrink: 0; }}
131
+ #stats .section-label {{ font-size: 10px; color: #888; text-transform: uppercase; letter-spacing: .05em; margin: 6px 0 4px; }}
132
+ #filter-box {{ padding: 10px 16px; border-bottom: 1px solid #0f3460; flex-shrink: 0; }}
133
+ #filter-box input {{ width: 100%; padding: 6px 10px; border-radius: 6px; background: #0d0d1a; border: 1px solid #0f3460; color: #eee; font-size: 13px; }}
134
+ #filter-box input:focus {{ outline: none; border-color: #4a90d9; }}
135
+ #kind-filters {{ display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }}
136
+ #kind-filters button {{ font-size: 10px; border: none; border-radius: 10px; padding: 2px 8px; cursor: pointer; opacity: .6; color: #fff; transition: opacity .15s; }}
137
+ #kind-filters button.active {{ opacity: 1; }}
138
+ #detail-panel {{ flex: 1; overflow-y: auto; padding: 12px 16px; }}
139
+ #detail-panel h2 {{ font-size: 14px; margin-bottom: 8px; }}
140
+ .shot {{ width: 100%; max-height: 340px; object-fit: contain; border: 1px solid #0f3460; border-radius: 6px; background:#000; margin-bottom: 10px; }}
141
+ .detail-kv {{ margin-bottom: 5px; font-size: 12px; }}
142
+ .detail-kv .k {{ color: #888; width: 78px; display: inline-block; vertical-align: top; }}
143
+ .detail-kv .v {{ color: #eee; word-break: break-all; }}
144
+ .edge-list {{ margin-top: 4px; }}
145
+ .edge-item {{ font-size: 11px; padding: 5px 7px; margin: 3px 0; border-radius: 5px; background: #1a1a2e; cursor: pointer; }}
146
+ .edge-item:hover {{ background: #0f3460; }}
147
+ .edge-top {{ display: flex; align-items: center; gap: 6px; }}
148
+ .edge-dot {{ width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }}
149
+ .edge-sub {{ color: #9aa4b8; font-size: 10px; margin-top: 2px; padding-left: 14px; }}
150
+ .edge-sub .guard {{ color: #f39c12; }}
151
+ svg {{ width: 100%; height: 100%; display: block; }}
152
+ #canvas-wrap {{ flex: 1; position: relative; overflow: hidden; }}
153
+ .node circle {{ stroke-width: 2px; cursor: pointer; transition: r .1s; }}
154
+ .node circle.dim {{ stroke-dasharray: 4 3; opacity: .45; }}
155
+ .node text {{ pointer-events: auto; cursor: pointer; font-size: 10px; fill: #ddd; text-shadow: 0 0 3px #000, 0 0 3px #000; }}
156
+ .link {{ fill: none; stroke-width: 1.6px; opacity: .6; pointer-events: none; }}
157
+ .link.highlighted {{ opacity: 1; stroke-width: 2.6px; }}
158
+ .link-hit {{ fill: none; stroke: transparent; stroke-width: 14px; pointer-events: stroke; cursor: pointer; }}
159
+ .edge-nav {{ cursor: pointer; color: #4a90d9; }}
160
+ .edge-nav:hover {{ text-decoration: underline; }}
161
+ .node.dimmed circle {{ opacity: .15; }}
162
+ .node.dimmed text {{ opacity: .15; }}
163
+ .link.dimmed {{ opacity: .06; }}
164
+ #tooltip {{ position: absolute; pointer-events: none; background: rgba(15,52,96,.97); border: 1px solid #4a90d9; border-radius: 6px; padding: 6px 10px; font-size: 12px; max-width: 240px; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.5); }}
165
+ #tooltip img {{ width: 120px; display:block; margin-top:5px; border-radius:4px; }}
166
+ #controls {{ position: absolute; bottom: 14px; right: 14px; display: flex; gap: 8px; }}
167
+ #controls button {{ background: #0f3460; border: 1px solid #4a90d9; color: #eee; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; }}
168
+ #controls button:hover {{ background: #1a4a7a; }}
169
+ #legend {{ position: absolute; top: 10px; right: 10px; background: rgba(15,33,62,.92); border: 1px solid #0f3460; border-radius: 6px; padding: 8px 12px; font-size: 11px; }}
170
+ #legend .l-row {{ display: flex; align-items: center; gap: 6px; margin: 3px 0; }}
171
+ #legend .l-line {{ width: 18px; height: 0; border-top: 3px solid; flex-shrink: 0; }}
172
+ #legend .section-label {{ font-size: 9px; color:#888; text-transform:uppercase; margin: 4px 0 2px; }}
173
+ </style>
174
+ </head>
175
+ <body>
176
+ <div id="sidebar">
177
+ <div id="sidebar-header">
178
+ <h1>UI Transition Graph · 对账结果</h1>
179
+ <div class="sub">{pages} 页面 · {reached} 已到达 · {len(links)} 边</div>
180
+ </div>
181
+ <div id="stats">
182
+ <div class="section-label">节点分类</div>
183
+ {stats_html}
184
+ <div class="section-label">边状态(对账)</div>
185
+ {status_html}
186
+ </div>
187
+ <div id="filter-box">
188
+ <input id="search" type="text" placeholder="搜索节点 (名称 / ID)…">
189
+ <div id="kind-filters"></div>
190
+ <select id="source-filter"><option value="">全部来源</option><option value="appgraph">AppGraph</option><option value="ai-source-review">AI 修补</option><option value="ai-source-analysis">AI 源码分析</option></select>
191
+ <details><summary>修补与未解析项</summary><pre id="review-data" style="white-space:pre-wrap"></pre></details>
192
+ </div>
193
+ <div id="detail-panel">
194
+ <div id="detail-empty" style="color:#666;font-size:12px;margin-top:20px">点击图中节点查看截图与边语义</div>
195
+ <div id="detail-content" style="display:none"></div>
196
+ </div>
197
+ </div>
198
+ <div id="canvas-wrap">
199
+ <svg id="graph"></svg>
200
+ <div id="tooltip"></div>
201
+ <div id="legend"><div id="legend-items"></div></div>
202
+ <div id="controls">
203
+ <button id="btn-reset">重置视图</button>
204
+ <button id="btn-center">居中</button>
205
+ <button id="btn-cluster">聚类模式</button>
206
+ </div>
207
+ </div>
208
+ <script>
209
+ const GRAPH = {graph_json};
210
+ const REVIEW = {review_json};
211
+ document.getElementById("review-data").textContent = JSON.stringify(REVIEW, null, 2);
212
+ const KIND_META = {kind_meta_json};
213
+ const kindOf = node => node._display_kind || node.kind;
214
+ const STATUS_META = {status_meta_json};
215
+
216
+ // ── Legend: node kinds + edge statuses ──
217
+ const legendEl = document.getElementById('legend-items');
218
+ let lg = '<div class="section-label">节点</div>';
219
+ Object.entries(KIND_META).forEach(([k, m]) => {{
220
+ if (!GRAPH.nodes.some(n => kindOf(n) === k)) return;
221
+ lg += `<div class="l-row"><div style="width:10px;height:10px;border-radius:50%;background:${{m.color}}"></div><span>${{m.zh || k}}</span></div>`;
222
+ }});
223
+ lg += '<div class="section-label">边状态</div>';
224
+ Object.entries(STATUS_META).forEach(([s, m]) => {{
225
+ if (!GRAPH.links.some(e => (e.status||'static-only') === s)) return;
226
+ lg += `<div class="l-row"><div class="l-line" style="border-top-style:${{m.dash?'dashed':'solid'}};border-color:${{m.color}}"></div><span>${{m.zh || s}}</span></div>`;
227
+ }});
228
+ legendEl.innerHTML = lg;
229
+
230
+ // ── Kind filters ──
231
+ const kinds = [...new Set(GRAPH.nodes.map(kindOf))];
232
+ let activeKinds = new Set(kinds);
233
+ const kindFiltersEl = document.getElementById('kind-filters');
234
+ kinds.forEach(k => {{
235
+ const meta = KIND_META[k] || {{color:'#888'}};
236
+ const btn = document.createElement('button');
237
+ btn.textContent = (KIND_META[k]?.zh || k);
238
+ btn.style.background = meta.color;
239
+ btn.classList.add('active');
240
+ btn.addEventListener('click', () => {{
241
+ if (activeKinds.has(k)) {{ activeKinds.delete(k); btn.classList.remove('active'); }}
242
+ else {{ activeKinds.add(k); btn.classList.add('active'); }}
243
+ applyFilters();
244
+ }});
245
+ kindFiltersEl.appendChild(btn);
246
+ }});
247
+
248
+ const svg = d3.select('#graph');
249
+ const width = () => document.getElementById('canvas-wrap').clientWidth;
250
+ const height = () => document.getElementById('canvas-wrap').clientHeight;
251
+ const defs = svg.append('defs');
252
+ // one arrow marker per status color
253
+ Object.entries(STATUS_META).forEach(([s, m]) => {{
254
+ defs.append('marker').attr('id','arrow-'+s).attr('viewBox','0 -5 10 10')
255
+ .attr('refX',22).attr('refY',0).attr('markerWidth',6).attr('markerHeight',6)
256
+ .attr('orient','auto').append('path').attr('d','M0,-5L10,0L0,5').attr('fill',m.color);
257
+ }});
258
+ const zoom = d3.zoom().scaleExtent([.05,4]).on('zoom', e => container.attr('transform', e.transform));
259
+ svg.call(zoom);
260
+ const container = svg.append('g');
261
+ const linkGroup = container.append('g');
262
+ const linkHitGroup = container.append('g');
263
+ const nodeGroup = container.append('g');
264
+ const nodeById = Object.fromEntries(GRAPH.nodes.map(n => [n.id, n]));
265
+ const inDeg = {{}}, outDeg = {{}};
266
+ GRAPH.nodes.forEach(n => {{ inDeg[n.id]=0; outDeg[n.id]=0; }});
267
+ GRAPH.links.forEach(e => {{ outDeg[e.source]=(outDeg[e.source]||0)+1; inDeg[e.target]=(inDeg[e.target]||0)+1; }});
268
+
269
+ // Separate parallel and opposing edges; a self-transition needs a visible loop.
270
+ const edgeBundles = new Map();
271
+ GRAPH.links.forEach(edge => {{
272
+ const pair = JSON.stringify([edge.source, edge.target].sort());
273
+ if (!edgeBundles.has(pair)) edgeBundles.set(pair, []);
274
+ edgeBundles.get(pair).push(edge);
275
+ }});
276
+ edgeBundles.forEach(bundle => bundle.forEach((edge, index) => {{
277
+ edge._bend = (index - (bundle.length - 1)/2) * 36 * (edge.source < edge.target ? 1 : -1);
278
+ edge._loop = 32 + index * 18;
279
+ }}));
280
+ function edgePath(edge) {{
281
+ const s=edge.source, t=edge.target;
282
+ if (s.id === t.id) {{ const r=edge._loop; return `M${{s.x}},${{s.y}} C${{s.x+r}},${{s.y-r*2}} ${{s.x-r}},${{s.y-r*2}} ${{s.x}},${{s.y}}`; }}
283
+ const dx=t.x-s.x, dy=t.y-s.y, length=Math.hypot(dx,dy)||1;
284
+ return `M${{s.x}},${{s.y}} Q${{(s.x+t.x)/2-dy/length*edge._bend}},${{(s.y+t.y)/2+dx/length*edge._bend}} ${{t.x}},${{t.y}}`;
285
+ }}
286
+
287
+ const sim = d3.forceSimulation(GRAPH.nodes)
288
+ .force('link', d3.forceLink(GRAPH.links).id(d => d.id).distance(110).strength(0.3))
289
+ .force('charge', d3.forceManyBody().strength(-240))
290
+ .force('center', d3.forceCenter(width()/2, height()/2))
291
+ .force('collision', d3.forceCollide(26))
292
+ .alphaDecay(0.02);
293
+
294
+ const linkSel = linkGroup.selectAll('path').data(GRAPH.links).join('path').attr('fill','none')
295
+ .attr('class','link')
296
+ .attr('stroke', d => d._color)
297
+ .attr('stroke-dasharray', d => d._dash || null)
298
+ .attr('marker-end', d => `url(#arrow-${{d.status||'static-only'}})`);
299
+
300
+ // Wide transparent hitzone lines so thin edges are easy to click/hover.
301
+ const linkHitSel = linkHitGroup.selectAll('path').data(GRAPH.links).join('path').attr('fill','none')
302
+ .attr('class','link-hit');
303
+
304
+ const nodeSel = nodeGroup.selectAll('g').data(GRAPH.nodes).join('g').attr('class','node')
305
+ .call(d3.drag()
306
+ .on('start', (e,d) => {{ if (!e.active) sim.alphaTarget(.3).restart(); d.fx=d.x; d.fy=d.y; }})
307
+ .on('drag', (e,d) => {{ d.fx=e.x; d.fy=e.y; }})
308
+ .on('end', (e,d) => {{ if (!e.active) sim.alphaTarget(0); d.fx=null; d.fy=null; }}));
309
+
310
+ nodeSel.append('circle').attr('r', d => nodeRadius(d))
311
+ .attr('fill', d => d._color).attr('stroke', d => d3.color(d._color).brighter(.8))
312
+ .attr('class', d => d._dim ? 'dim' : '');
313
+ nodeSel.append('text').attr('dy', d => nodeRadius(d) + 11).attr('text-anchor','middle')
314
+ .text(d => truncate(d.label || d.id, 18));
315
+
316
+ function nodeRadius(d) {{ const deg=(inDeg[d.id]||0)+(outDeg[d.id]||0); return Math.max(8, Math.min(20, 8+deg*1.2)); }}
317
+ function truncate(s,n) {{ s=String(s); return s.length>n ? s.slice(0,n-1)+'…' : s; }}
318
+
319
+ sim.on('tick', () => {{
320
+ linkSel.attr('d',edgePath);
321
+ linkHitSel.attr('d',edgePath);
322
+ nodeSel.attr('transform', d => `translate(${{d.x}},${{d.y}})`);
323
+ }});
324
+
325
+ const tooltip = document.getElementById('tooltip');
326
+ nodeSel.on('mouseover', (e,d) => {{
327
+ tooltip.style.display='block';
328
+ tooltip.innerHTML = `<b>${{d.label||d.id}}</b><br><span style="color:#aaa">${{d.kind}}</span>`
329
+ + `<br><span style="color:#4a90d9">入 ${{inDeg[d.id]||0}} · 出 ${{outDeg[d.id]||0}}</span>`
330
+ + (d.reached ? '<br><span style="color:#2ecc71">✓ 已到达</span>' : '<br><span style="color:#e05252">✗ 未到达</span>')
331
+ + (d.screenshot ? `<img src="${{d.screenshot}}">` : '');
332
+ moveTip(e);
333
+ }}).on('mousemove', moveTip).on('mouseout', () => tooltip.style.display='none');
334
+ function moveTip(e) {{ tooltip.style.left=(e.pageX+12)+'px'; tooltip.style.top=(e.pageY-28)+'px'; }}
335
+
336
+ let selectedId = null;
337
+ nodeSel.on('click', (e,d) => {{ e.stopPropagation(); selectedId=d.id; showDetail(d); highlight(d.id); }});
338
+ svg.on('click', () => {{ selectedId=null; clearHi(); document.getElementById('detail-empty').style.display=''; document.getElementById('detail-content').style.display='none'; }});
339
+
340
+ // Edge interaction: the wide transparent hitzone layer catches clicks/hovers so
341
+ // thin edges are easy to hit. Click → edge-semantics detail; hover → tooltip.
342
+ linkHitSel
343
+ .on('click', (e,d) => {{ e.stopPropagation(); selectedId=null; showEdgeDetail(d); }})
344
+ .on('mouseover', (e,d) => {{
345
+ const s=edgeEndpoint(d,'source'), t=edgeEndpoint(d,'target');
346
+ tooltip.style.display='block';
347
+ tooltip.innerHTML = `<b style="color:${{d._color}}">${{d.status||'static-only'}}</b>`
348
+ + `<br>${{nodeById[s]?.label||s}} → ${{nodeById[t]?.label||t}}`
349
+ + `<br><span style="color:#aaa">${{d._label||d.mechanism||''}}</span>`
350
+ + (d._guardtext ? `<br><span style="color:#f39c12">前置: ${{d._guardtext}}</span>` : '');
351
+ moveTip(e);
352
+ }}).on('mousemove', moveTip).on('mouseout', () => tooltip.style.display='none');
353
+
354
+ function edgeEndpoint(l, which) {{ const x=l[which]; return typeof x==='object'?x.id:x; }}
355
+
356
+ function highlight(id) {{
357
+ const nb=new Set([id]);
358
+ GRAPH.links.forEach(l => {{ const s=edgeEndpoint(l,'source'),t=edgeEndpoint(l,'target'); if(s===id||t===id){{nb.add(s);nb.add(t);}} }});
359
+ nodeSel.classed('dimmed', nd => !nb.has(nd.id));
360
+ linkSel.classed('dimmed', l => edgeEndpoint(l,'source')!==id && edgeEndpoint(l,'target')!==id)
361
+ .classed('highlighted', l => edgeEndpoint(l,'source')===id || edgeEndpoint(l,'target')===id);
362
+ }}
363
+ function clearHi() {{ nodeSel.classed('dimmed',false); linkSel.classed('dimmed',false).classed('highlighted',false); }}
364
+
365
+ function edgeHtml(edges, dir) {{
366
+ if (!edges.length) return '<div style="color:#666;font-size:11px">无</div>';
367
+ return edges.map(e => {{
368
+ const other = dir==='out' ? edgeEndpoint(e,'target') : edgeEndpoint(e,'source');
369
+ const on = nodeById[other];
370
+ const col = e._color;
371
+ const op = e._label ? `<b>${{e._label}}</b>` : (e.mechanism||'');
372
+ const conf = (e.confidence!=null && e.confidence<1) ? ` <span style="color:#f39c12">≈${{(e.confidence*100).toFixed(0)}}%</span>` : '';
373
+ const guard = e._guardtext ? `<span class="guard">前置: ${{e._guardtext}}</span> · ` : '';
374
+ const trig = e.trigger ? String(e.trigger).replace(/^onClick@/,'').split(/[\\\\/]/).pop() : '';
375
+ return `<div class="edge-item" onclick="goEdge('${{other}}')">`
376
+ + `<div class="edge-top"><span class="edge-dot" style="background:${{col}}"></span>`
377
+ + `<span style="color:${{col}};font-size:10px">${{e.status||'static-only'}}</span>`
378
+ + `<span style="flex:1">${{dir==='out'?'→':'←'}} ${{on?.label||other}}</span>${{conf}}</div>`
379
+ + `<div class="edge-sub">${{guard}}操作: ${{op}} <span style="color:#667">[${{e.mechanism||'?'}}]</span>${{trig?` · ${{trig}}`:''}}</div>`
380
+ + `</div>`;
381
+ }}).join('');
382
+ }}
383
+
384
+ function showDetail(d) {{
385
+ document.getElementById('detail-empty').style.display='none';
386
+ const panel = document.getElementById('detail-content'); panel.style.display='';
387
+ const outE = GRAPH.links.filter(l => edgeEndpoint(l,'source')===d.id);
388
+ const inE = GRAPH.links.filter(l => edgeEndpoint(l,'target')===d.id);
389
+ const statusColor = d.reached ? '#2ecc71' : (d.status==='unreachable'?'#e05252':'#f39c12');
390
+ panel.innerHTML =
391
+ (d.screenshot ? `<img class="shot" src="${{d.screenshot}}" onerror="this.style.display='none'">` : '')
392
+ + `<h2 style="color:${{d._color}}">${{d.label || d.id}}</h2>`
393
+ + `<div class="detail-kv"><span class="k">ID</span><span class="v">${{d.id}}</span></div>`
394
+ + `<div class="detail-kv"><span class="k">类型</span><span class="v" style="color:${{d._color}}">${{d._zh||d.kind}}</span></div>`
395
+ + `<div class="detail-kv"><span class="k">到达</span><span class="v" style="color:${{statusColor}}">${{d.reached?'✓ 已到达':'✗ 未到达'}} (${{d.status||'static-only'}})</span></div>`
396
+ + (d.reached && d.verify ? `<div class="detail-kv"><span class="k">验证</span><span class="v" style="color:${{d.verify==='structural'?'#f39c12':'#2ecc71'}}">${{d.verify==='structural'?'结构(身份待核)':d.verify}}</span></div>` : '')
397
+ + (d.phase ? `<div class="detail-kv"><span class="k">相位</span><span class="v">${{d.phase}}</span></div>` : '')
398
+ + (d.env_state ? `<div class="detail-kv"><span class="k">旁路原因</span><span class="v" style="color:#c78">${{d.env_state}}</span></div>` : '')
399
+ + (d.view_signature ? `<div class="detail-kv"><span class="k">页面签名</span><span class="v" style="font-size:10px">${{d.view_signature}}</span></div>` : '')
400
+ + (d.view_tree ? `<div class="detail-kv"><span class="k">视图树</span><span class="v"><a href="${{d.view_tree}}" style="color:#4a90d9" target="_blank">${{d.view_tree}}</a></span></div>` : '')
401
+ + (d.declared_at ? `<div class="detail-kv"><span class="k">声明位置</span><span class="v" style="font-size:10px">${{d.declared_at}}</span></div>` : '')
402
+ + `<div class="detail-kv" style="margin-top:10px"><span class="k">跳出边</span><span class="v">${{outE.length}} 条</span></div>`
403
+ + `<div class="edge-list">${{edgeHtml(outE,'out')}}</div>`
404
+ + `<div class="detail-kv" style="margin-top:8px"><span class="k">跳入边</span><span class="v">${{inE.length}} 条</span></div>`
405
+ + `<div class="edge-list">${{edgeHtml(inE,'in')}}</div>`;
406
+ appendEvidence({{provenance:d.provenance, declared_at:d.declared_at, expected:d.expected, widgets:d.widgets, extra:d.extra}});
407
+ }}
408
+
409
+ function appendEvidence(value) {{
410
+ const pre = document.createElement('pre');
411
+ pre.style.cssText = 'white-space:pre-wrap;overflow-wrap:anywhere;font-size:11px';
412
+ pre.textContent = JSON.stringify(value, null, 2);
413
+ document.getElementById('detail-content').appendChild(pre);
414
+ }}
415
+
416
+ function showEdgeDetail(e) {{
417
+ document.getElementById('detail-empty').style.display='none';
418
+ const panel = document.getElementById('detail-content'); panel.style.display='';
419
+ const sid = edgeEndpoint(e,'source'), tid = edgeEndpoint(e,'target');
420
+ const s = nodeById[sid], t = nodeById[tid];
421
+ const st = e.status || 'static-only';
422
+ const conf = (e.confidence!=null && e.confidence<1) ? `≈${{(e.confidence*100).toFixed(0)}}%` : '100%';
423
+ const trig = e.trigger ? String(e.trigger).replace(/^onClick@/,'') : '';
424
+ panel.innerHTML =
425
+ `<h2 style="color:${{e._color}}">${{s?.label||sid}} → ${{t?.label||tid}}</h2>`
426
+ + `<div class="detail-kv"><span class="k">状态</span><span class="v" style="color:${{e._color}}">${{st}}</span></div>`
427
+ + `<div class="detail-kv"><span class="k">机制</span><span class="v">${{e.mechanism||'?'}}</span></div>`
428
+ + (e._label ? `<div class="detail-kv"><span class="k">操作</span><span class="v">${{e._label}}</span></div>` : '')
429
+ + (e._guardtext ? `<div class="detail-kv"><span class="k">前置</span><span class="v" style="color:#f39c12">${{e._guardtext}}</span></div>` : '')
430
+ + `<div class="detail-kv"><span class="k">置信度</span><span class="v">${{conf}}</span></div>`
431
+ + (e.provenance ? `<div class="detail-kv"><span class="k">来源</span><span class="v">${{e.provenance}}</span></div>` : '')
432
+ + (trig ? `<div class="detail-kv"><span class="k">触发</span><span class="v" style="font-size:10px">${{trig}}</span></div>` : '')
433
+ + `<div class="detail-kv" style="margin-top:10px"><span class="k">起点</span><span class="v edge-nav" onclick="goEdge('${{sid}}')">${{s?.label||sid}}</span></div>`
434
+ + `<div class="detail-kv"><span class="k">终点</span><span class="v edge-nav" onclick="goEdge('${{tid}}')">${{t?.label||tid}}</span></div>`;
435
+ appendEvidence({{provenance:e.provenance, trigger:e.trigger, args:e.args, guard:e.guard}});
436
+ }}
437
+
438
+ window.goEdge = function(id) {{
439
+ const d = nodeById[id]; if (!d) return;
440
+ selectedId=id; showDetail(d); highlight(id);
441
+ svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(width()/2-d.x, height()/2-d.y));
442
+ }};
443
+
444
+ document.getElementById('search').addEventListener('input', function() {{
445
+ const q=this.value.toLowerCase().trim();
446
+ if (!q) {{ clearHi(); return; }}
447
+ const m=new Set(GRAPH.nodes.filter(n => (n.label||'').toLowerCase().includes(q) || n.id.toLowerCase().includes(q)).map(n=>n.id));
448
+ nodeSel.classed('dimmed', d => !m.has(d.id));
449
+ linkSel.classed('dimmed', true).classed('highlighted', false);
450
+ }});
451
+
452
+ document.getElementById("source-filter").addEventListener("change", applyFilters);
453
+ function applyFilters() {{
454
+ const source = document.getElementById("source-filter").value;
455
+ nodeSel.style("display", d => !source || d.provenance === source ? null : "none");
456
+ linkSel.style("display", d => !source || d.provenance === source ? null : "none");
457
+ linkHitSel.style("display", d => !source || d.provenance === source ? null : "none");
458
+ nodeSel.classed('dimmed', d => !activeKinds.has(kindOf(d)));
459
+ linkSel.classed('dimmed', l => {{
460
+ const s=nodeById[edgeEndpoint(l,'source')], t=nodeById[edgeEndpoint(l,'target')];
461
+ return !(s&&activeKinds.has(kindOf(s))) || !(t&&activeKinds.has(kindOf(t)));
462
+ }});
463
+ }}
464
+
465
+ let clusterMode=false; const kindPos={{}};
466
+ const R = Math.min(width(),height())*0.38;
467
+ kinds.forEach((k,i) => {{ const a=(i/kinds.length)*2*Math.PI-Math.PI/2; kindPos[k]=[width()/2+R*Math.cos(a), height()/2+R*Math.sin(a)]; }});
468
+ document.getElementById('btn-cluster').addEventListener('click', () => {{
469
+ clusterMode=!clusterMode;
470
+ document.getElementById('btn-cluster').textContent = clusterMode?'自由布局':'聚类模式';
471
+ if (clusterMode) {{
472
+ sim.force('cluster', alpha => GRAPH.nodes.forEach(d => {{ const [cx,cy]=kindPos[kindOf(d)]||[width()/2,height()/2]; d.vx+=(cx-d.x)*alpha*0.08; d.vy+=(cy-d.y)*alpha*0.08; }})).alphaTarget(.3).restart();
473
+ setTimeout(()=>sim.alphaTarget(0),2000);
474
+ }} else {{ sim.force('cluster',null).alphaTarget(.3).restart(); setTimeout(()=>sim.alphaTarget(0),2000); }}
475
+ }});
476
+ document.getElementById('btn-reset').addEventListener('click', () => {{ sim.alpha(1).restart(); clearHi(); }});
477
+ document.getElementById('btn-center').addEventListener('click', () => {{ svg.transition().duration(600).call(zoom.transform, d3.zoomIdentity); }});
478
+ window.addEventListener('resize', () => sim.force('center', d3.forceCenter(width()/2,height()/2)).alpha(.3).restart());
479
+ </script>
480
+ </body>
481
+ </html>
482
+ """
483
+ with open(html_path, "w", encoding="utf-8") as f:
484
+ f.write(html)
485
+ print(f"Written: {html_path} ({os.path.getsize(html_path)//1024} KB)")
486
+
487
+
488
+ if __name__ == "__main__":
489
+ main()
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from utg_common import UtgError, load_json, summary, validate_pair
9
+ from build_page_json import validate_page_plan
10
+
11
+
12
+ def parse_args() -> argparse.Namespace:
13
+ parser = argparse.ArgumentParser(description="Validate a utg.json/click_paths.json pair.")
14
+ parser.add_argument("--utg", required=True, type=Path)
15
+ parser.add_argument("--click-paths", required=True, type=Path)
16
+ parser.add_argument(
17
+ "--page-json",
18
+ type=Path,
19
+ help="page.json to validate; defaults to a sibling of utg.json when present",
20
+ )
21
+ parser.add_argument("--project-root", type=Path)
22
+ return parser.parse_args()
23
+
24
+
25
+ def main() -> int:
26
+ args = parse_args()
27
+ try:
28
+ utg = load_json(args.utg.resolve())
29
+ click_paths = load_json(args.click_paths.resolve())
30
+ except UtgError as exc:
31
+ print(f"ERROR: {exc}", file=sys.stderr)
32
+ return 2
33
+ project_root = args.project_root.resolve() if args.project_root else None
34
+ if project_root and not project_root.is_dir():
35
+ print(f"ERROR: project root is not a directory: {project_root}", file=sys.stderr)
36
+ return 2
37
+ errors = validate_pair(utg, click_paths, project_root)
38
+ page_path = args.page_json.resolve() if args.page_json else args.utg.resolve().parent / "page.json"
39
+ if page_path.exists():
40
+ try:
41
+ page_plan = load_json(page_path)
42
+ errors.extend(
43
+ validate_page_plan(
44
+ page_plan,
45
+ utg,
46
+ click_paths,
47
+ utg_path=str(args.utg.resolve()),
48
+ click_paths_path=str(args.click_paths.resolve()),
49
+ )
50
+ )
51
+ except UtgError as exc:
52
+ errors.append(str(exc))
53
+ elif args.page_json:
54
+ errors.append(f"page.json does not exist: {page_path}")
55
+ if errors:
56
+ print("INVALID", file=sys.stderr)
57
+ for error in errors:
58
+ print(f"- {error}", file=sys.stderr)
59
+ return 1
60
+ counts = summary(utg, click_paths)
61
+ print(
62
+ f"VALID nodes={counts['nodes']} edges={counts['edges']} "
63
+ f"reachable={counts['reachable']} unreachable={counts['unreachable']}"
64
+ )
65
+ return 0
66
+
67
+
68
+ if __name__ == "__main__":
69
+ raise SystemExit(main())
@@ -0,0 +1,13 @@
1
+ Copyright 2010-2023 Mike Bostock
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any purpose
4
+ with or without fee is hereby granted, provided that the above copyright notice
5
+ and this permission notice appear in all copies.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
8
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
9
+ FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
10
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
11
+ OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
12
+ TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
13
+ THIS SOFTWARE.