deveco_hmigbot 0.21.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 (101) hide show
  1. package/LICENSE +26 -0
  2. package/README.md +50 -0
  3. package/agents/hmigbot-worker.md +61 -0
  4. package/agents/hmigbot.md +22 -0
  5. package/agents/workflow-subagent.md +55 -0
  6. package/commands/hmigbot.md +17 -0
  7. package/dist/index.js +1 -0
  8. package/manifest.json +19 -0
  9. package/package.json +29 -0
  10. package/skills/migrate-core/FILES.md +26 -0
  11. package/skills/migrate-core/SKILL.md +484 -0
  12. package/skills/migrate-core/references/README.md +64 -0
  13. package/skills/migrate-core/references/flow/arkts-vector-gate.md +55 -0
  14. package/skills/migrate-core/references/flow/build-error-patterns.md +52 -0
  15. package/skills/migrate-core/references/flow/conventions-template.md +244 -0
  16. package/skills/migrate-core/references/flow/navigation-migration.md +42 -0
  17. package/skills/migrate-core/references/flow/platform-api-guards.md +59 -0
  18. package/skills/migrate-core/references/flow/platform-model-gaps.md +43 -0
  19. package/skills/migrate-core/references/flow/resource-conversion.md +46 -0
  20. package/skills/migrate-core/references/flow/ui-layout-semantics.md +124 -0
  21. package/skills/migrate-core/references/flow/unit-breakdown.md +42 -0
  22. package/skills/migrate-core/references/host-capabilities.md +24 -0
  23. package/skills/migrate-core/references/topics/app-identity.md +214 -0
  24. package/skills/migrate-core/references/topics/env-doctor.md +245 -0
  25. package/skills/migrate-core/references/topics/i18n/README.md +458 -0
  26. package/skills/migrate-core/references/topics/i18n/references/code-examples.md +304 -0
  27. package/skills/migrate-core/references/topics/i18n/references/common-pitfalls.md +354 -0
  28. package/skills/migrate-core/references/topics/i18n/references/dynamic-language-switch.md +464 -0
  29. package/skills/migrate-core/references/topics/i18n/references/language-codes.md +104 -0
  30. package/skills/migrate-core/references/topics/icon-sizing.md +98 -0
  31. package/skills/migrate-core/references/topics/library-migration/README.md +234 -0
  32. package/skills/migrate-core/references/topics/library-migration/closed-source-sdk.md +128 -0
  33. package/skills/migrate-core/references/topics/library-migration/download-api-decision.md +84 -0
  34. package/skills/migrate-core/references/topics/library-migration/library-mapping-table.md +100 -0
  35. package/skills/migrate-core/references/topics/library-migration/napi-compile-guide.md +84 -0
  36. package/skills/migrate-core/references/topics/library-migration/ohpm-search-guide.md +73 -0
  37. package/skills/migrate-core/references/topics/library-migration/stdlib-mapping-table.md +34 -0
  38. package/skills/migrate-core/references/topics/resources/aar-decompile.md +25 -0
  39. package/skills/migrate-core/references/topics/resources/conversion-rules.md +625 -0
  40. package/skills/migrate-core/references/topics/resources/dependency-analysis-rules.md +328 -0
  41. package/skills/migrate-core/references/topics/resources/material-design-icons.md +173 -0
  42. package/skills/migrate-core/references/topics/resources/svg-fix-patterns.md +175 -0
  43. package/skills/migrate-core/references/topics/resources/xml-drawable-to-svg-rules.md +513 -0
  44. package/skills/migrate-core/references/topics/system-capabilities/README.md +331 -0
  45. package/skills/migrate-core/references/topics/system-capabilities/avplayer-guide.md +161 -0
  46. package/skills/migrate-core/references/topics/system-capabilities/background-tasks.md +403 -0
  47. package/skills/migrate-core/references/topics/system-capabilities/browser-intent.md +121 -0
  48. package/skills/migrate-core/references/topics/system-capabilities/camera-picker.md +118 -0
  49. package/skills/migrate-core/references/topics/system-capabilities/document-picker.md +246 -0
  50. package/skills/migrate-core/references/topics/system-capabilities/file-utils.md +131 -0
  51. package/skills/migrate-core/references/topics/system-capabilities/permission-helper.md +112 -0
  52. package/skills/migrate-core/references/topics/system-capabilities/photo-access-helper.md +208 -0
  53. package/skills/migrate-core/references/topics/system-capabilities/print-management.md +213 -0
  54. package/skills/migrate-core/references/topics/system-capabilities/share-panel.md +177 -0
  55. package/skills/migrate-core/references/topics/system-capabilities/system-settings.md +322 -0
  56. package/skills/migrate-core/references/topics/system-capabilities/telephony-dial.md +49 -0
  57. package/skills/migrate-core/references/topics/system-capabilities/video-playback.md +42 -0
  58. package/skills/migrate-core/references/topics/system-capabilities/webview-patterns.md +38 -0
  59. package/skills/migrate-core/references/topics/ui-alignment/README.md +344 -0
  60. package/skills/migrate-core/references/topics/ui-alignment/references/dark-mode.md +47 -0
  61. package/skills/migrate-core/references/topics/ui-alignment/references/layout-mapping.md +301 -0
  62. package/skills/migrate-core/references/topics/ui-alignment/references/visual-patterns.md +411 -0
  63. package/skills/migrate-core/scripts/closure/check-anchors.mjs +186 -0
  64. package/skills/migrate-core/scripts/closure/check-api-guards.mjs +175 -0
  65. package/skills/migrate-core/scripts/closure/check-consumers.mjs +301 -0
  66. package/skills/migrate-core/scripts/closure/check-permissions.mjs +165 -0
  67. package/skills/migrate-core/scripts/closure/check-resources.mjs +130 -0
  68. package/skills/migrate-core/scripts/closure/check-routes.mjs +527 -0
  69. package/skills/migrate-core/scripts/closure/check-safearea.mjs +122 -0
  70. package/skills/migrate-core/scripts/closure/check-stubs.mjs +69 -0
  71. package/skills/migrate-core/scripts/closure/closure-suite.mjs +256 -0
  72. package/skills/migrate-core/scripts/closure/idioms.json +105 -0
  73. package/skills/migrate-core/scripts/convert/convert-resources.mjs +437 -0
  74. package/skills/migrate-core/scripts/feasibility/feasibility.mjs +235 -0
  75. package/skills/migrate-core/scripts/feasibility/tables/cross-platform.json +11 -0
  76. package/skills/migrate-core/scripts/feasibility/tables/deprecated-api.json +10 -0
  77. package/skills/migrate-core/scripts/feasibility/tables/imported-arkts-core.json +425 -0
  78. package/skills/migrate-core/scripts/feasibility/tables/lib-equivalence.json +206 -0
  79. package/skills/migrate-core/scripts/feasibility/tables/system-capabilities.json +22 -0
  80. package/skills/migrate-core/scripts/front.mjs +107 -0
  81. package/skills/migrate-core/scripts/interface/ark-extract.mjs +172 -0
  82. package/skills/migrate-core/scripts/interface/interface.mjs +152 -0
  83. package/skills/migrate-core/scripts/ledger/ledger.mjs +383 -0
  84. package/skills/migrate-core/scripts/ledger/parse-cards.mjs +98 -0
  85. package/skills/migrate-core/scripts/lib/literals.mjs +37 -0
  86. package/skills/migrate-core/scripts/lib/scan.mjs +315 -0
  87. package/skills/migrate-core/scripts/smoke/align-sdk.mjs +118 -0
  88. package/skills/migrate-core/scripts/smoke/ensure-sign.mjs +53 -0
  89. package/skills/migrate-core/scripts/smoke/smoke.mjs +238 -0
  90. package/skills/migrate-core/scripts/smoke/verdict.mjs +31 -0
  91. package/skills/migrate-core/scripts/smoke/walk.mjs +480 -0
  92. package/skills/migrate-core/scripts/transpile/mapping.json +76 -0
  93. package/skills/migrate-core/scripts/transpile/transpile-layout.mjs +404 -0
  94. package/skills/migrate-core/scripts/vectors/run-arkts-vectors.mjs +107 -0
  95. package/skills/migrate-core/scripts/vectors/setup-arkts-test.mjs +90 -0
  96. package/skills/migrate-core/scripts/wire/extractors.mjs +258 -0
  97. package/skills/migrate-core/scripts/wire/wire-routes.mjs +507 -0
  98. package/skills/migrate-core/templates/acceptance.js +365 -0
  99. package/skills/migrate-core/templates/explore.js +86 -0
  100. package/skills/migrate-core/templates/implement.js +211 -0
  101. package/skills/migrate-core/templates/mig_slices.js +491 -0
@@ -0,0 +1,480 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * walk.mjs — 4b 运行时走查驱动(确定性·单飞·可证)。取代每轮现场自写的 python/node uitest 脚本:
4
+ * 实证 20 个基准应用 + 本地试点全在自写同一套东西(qianfeng 一轮 4b 73 分钟,其中 40 分钟在改自写驱动)。
5
+ * 只依赖 uitest 控件树公共字段(type/text/id/key/bounds/clickable)与 uiInput,无任何应用知识。
6
+ *
7
+ * 用法(单条命令,供逐步交互):
8
+ * walk.mjs devices 列出 hdc 目标(不取锁、不要求指定设备)
9
+ * walk.mjs start <bundle> [--route <名>] 冷启(--route 走 smokeRoute 直达钩子)
10
+ * walk.mjs stop <bundle>
11
+ * walk.mjs alive <bundle> 进程在且前台 → 0;否则 4
12
+ * walk.mjs dump [<匹配>] 打印控件树(缩进 type#id "text" bounds [click])
13
+ * walk.mjs tap <匹配> [--nth k] [--within <容器匹配>] 点击命中节点中心:被弹层/对话框盖住的候选自动靠后;--within 只在该容器子树内找(弹层内按钮);
14
+ * 多命中且无法消歧 → 并列带序号的候选,--nth k 即序号;步骤数据里 tap 会记 changed(点后控件树是否变化)与 occludedBy
15
+ * walk.mjs type <匹配> <文本> 点中输入框后输入
16
+ * walk.mjs back | key <Back|Home|…>
17
+ * walk.mjs swipe <up|down|left|right> [--within <匹配>] [--nth k] 定向滑动:在命中容器(缺省整屏)内从 70% 滑到 30%(up=内容上滚)
18
+ * walk.mjs scroll <匹配> [--dir up] [--within <匹配>] [--max 8] 反复滑到目标出现(列表深处的项/触底加载/横滑区);超 max 次未见 → 4
19
+ * walk.mjs expect <匹配>... 全部命中 → 0;缺 → 4 并列出
20
+ * walk.mjs probe <匹配>... 只报出现率(见到/未见清单),永远 0——给不确定该不该在当前状态出现的文案用
21
+ * walk.mjs wait <匹配> [--timeout 8000] 轮询等到出现(超时 4)
22
+ * walk.mjs shot <名字|路径.png> 截图落盘:含路径分隔符或绝对路径按路径存,否则存到 --shots 目录(缺省 ./spec/4b/shots)
23
+ * walk.mjs faults <bundle> 列 faultlog 里该包的崩溃文件
24
+ * walk.mjs start <bundle> [--route R] [--fresh] --fresh 先清应用数据(bm clean -d)再冷启:首启/fixture/持久化断言要在干净态验(评测用例每条都清数据)
25
+ * 用法(步骤数据,供整条路径一次执行并落证据):
26
+ * walk.mjs plan --ledger <ledger.json> --project <工程根> --out <目录> 从台账机械生成逐页计划(每个带 route 的切片一份:
27
+ * 直达→存活→probe 该片 ui 断言引用的字面量→截图;无需设备)
28
+ * 逐页计划是**出现率证据**不是断言:断言天然含空态/校验提示/保存后提示/用户键入等状态相关文案,落地页不可能一次全显
29
+ * (实证 6 份计划 5 份假失败),所以用 probe 只记见到/未见;真正的断言写在核心路径计划里。
30
+ * walk.mjs run <steps.json> --out <report.json> [--shots <目录>] [--continue]
31
+ * steps.json = { "bundle": "...", "name": "<计划名,失败截图用>", "scenarios": ["S12","S13"], ← 有 PRD 时写本流覆盖的编号场景,ledger render --walks 据此汇总
32
+ * "steps": [ {"op":"start","route":"Timer"}, {"op":"expect","match":["=设置","画中画"]},
33
+ * {"op":"probe","match":["已保存","尚未选择"]}, {"op":"tap","match":"设置"}, {"op":"type","match":"gearNameInput","text":"水壶"},
34
+ * {"op":"wait","match":"已保存"}, {"op":"back"}, {"op":"key","name":"Home"}, {"op":"shot","name":"timer"}, {"op":"alive"}, {"op":"sleep","ms":800},
35
+ * {"op":"swipe","dir":"up","within":"type:List"}, {"op":"scrollUntil","match":"加载更多","dir":"up","max":8},
36
+ * {"op":"tap","match":"=提交","expectNoChange":true}, {"op":"tap","match":"=加入购物车","within":"type:^SheetPage$"},
37
+ * {"op":"start","fresh":true} ] } ← 本就不该有反馈的点击(禁用态按钮)标 expectNoChange,不计入 tapsNoChange;弹层内目标用 within;fresh 清数据冷启
38
+ * 报告:每个失败步带 kind(target_missing/target_ambiguous/target_occluded 提示见 occludedBy/expect_missing/process_died/io_error)与页面字典;
39
+ * 崩溃带 crashDetail(Reason/Error message/首个应用帧);failStreak=同一 --out 连续失败次数;seen=全程见过的 text/id 并集
40
+ * 默认首个失败步即停(后续记 skipped,失败现场补一张 fail-<计划名>-step-<i>.png);--continue 继续跑完。run 结束自动查 faultlog:新增崩溃 → exit 10。
41
+ * 直达边界:`start --route` 经 smokeRoute 钩子直接推入目标页,**不经过首页**;首页承载的状态恢复(读偏好/数据库)不会执行,看到的是种子/默认态。
42
+ * 持久化类断言必须 `start`(不带 route)正常冷启 + 应用内导航;重启核持久化前先把状态改成非默认值,否则默认值证明不了任何事。
43
+ * 匹配:默认正则(不分大小写;非法正则自动退化为原文子串)撞 text/id/key;以 = 开头为全等(text 或 id);以 type: 开头撞组件类型。
44
+ * 设备:--device <key> > 环境变量 HMIG_HDC_TARGET > 唯一目标;多目标未指定 → exit 3 列出。命令经 execFile 直达 hdc,不过本机 shell。
45
+ * 单飞:同设备同一时刻只允许一个 walk(锁目录 <tmp>/hmig-walk-<key>,原子改名建锁,持锁进程死才视为陈旧)——并发 uitest/hdc 实测 8 并发 7 空输出。
46
+ * 退出码:0 通过 / 3 环境(无设备·多设备未指定·锁等待超时·参数错)/ 4 断言未命中·未存活·等待超时 / 5 匹配歧义或无命中 / 10 崩溃
47
+ */
48
+ import { execFileSync } from 'node:child_process';
49
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, renameSync, mkdtempSync, realpathSync, unlinkSync } from 'node:fs';
50
+ import { join, resolve, dirname, relative, isAbsolute, sep } from 'node:path';
51
+ import { tmpdir } from 'node:os';
52
+ import { fileURLToPath } from 'node:url';
53
+ import { probeOf, escapeRe } from '../lib/literals.mjs';
54
+
55
+ // ---------- 纯函数(可测) ----------
56
+ export function flatten(tree) {
57
+ const out = [];
58
+ (function walk(n, depth) {
59
+ if (!n || typeof n !== 'object') return;
60
+ const a = n.attributes || {};
61
+ out.push({ depth, type: String(a.type || ''), text: String(a.text || ''), id: String(a.id || ''), key: String(a.key || ''), bounds: String(a.bounds || ''), clickable: a.clickable === 'true' || a.clickable === true, enabled: a.enabled !== 'false' });
62
+ for (const c of n.children || []) walk(c, depth + 1);
63
+ })(tree, 0);
64
+ return out;
65
+ }
66
+ export function center(bounds) {
67
+ const m = String(bounds).match(/\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/);
68
+ return m ? [Math.round((+m[1] + +m[3]) / 2), Math.round((+m[2] + +m[4]) / 2)] : null;
69
+ }
70
+ // 非法正则("C++"、"(1)"、"[未完成")退化为原文子串,不让一条断言文本炸掉整轮走查
71
+ const safeRe = (p) => { try { return new RegExp(p, 'i'); } catch { return new RegExp(escapeRe(p), 'i'); } };
72
+ export function matchNodes(nodes, pattern) {
73
+ const p = String(pattern);
74
+ if (p.startsWith('=')) { const t = p.slice(1); return nodes.filter(n => n.text === t || n.id === t); }
75
+ if (p.startsWith('type:')) { const re = safeRe(p.slice(5)); return nodes.filter(n => re.test(n.type)); }
76
+ const re = safeRe(p);
77
+ return nodes.filter(n => (n.text && re.test(n.text)) || (n.id && re.test(n.id)) || (n.key && re.test(n.key)));
78
+ }
79
+ // 遮挡判定(几何,按 ArkUI 命中测试同序):bindSheet/CustomDialog/Menu/Popup 的包装层挂在页面之后、盖住大半屏;
80
+ // 节点 N 之后出现、不是 N 后代、bounds 含 N 中心且面积 ≥ 屏幕一半的节点 M → N 被 M 盖住(点击落在 M 上)。
81
+ // 小面积的后续重叠(底栏/FAB 压住列表末项)不算"模态遮挡"——真机复核:弹层页 2 个"加入购物车"只判底栏那个被盖,普通页 0 误判。
82
+ const boxOf = (b) => { const m = String(b).match(/\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/); return m ? [+m[1], +m[2], +m[3], +m[4]] : null; };
83
+ export function occludedBy(nodes, idx) {
84
+ const n = nodes[idx]; const c = center(n && n.bounds); if (!c) return null;
85
+ const rootBox = boxOf((nodes.find(x => boxOf(x.bounds)) || {}).bounds); if (!rootBox) return null;
86
+ const rootArea = Math.max(1, (rootBox[2] - rootBox[0]) * (rootBox[3] - rootBox[1]));
87
+ let end = idx + 1; while (end < nodes.length && nodes[end].depth > n.depth) end++; // 跳过 N 的后代
88
+ for (let k = end; k < nodes.length; k++) {
89
+ const b = boxOf(nodes[k].bounds); if (!b) continue;
90
+ const area = (b[2] - b[0]) * (b[3] - b[1]);
91
+ if (area < rootArea / 2) continue;
92
+ if (c[0] > b[0] && c[0] < b[2] && c[1] > b[1] && c[1] < b[3]) return nodes[k];
93
+ }
94
+ return null;
95
+ }
96
+ // 点击目标选择:优先可点击且有坐标;多个时先剔被模态层盖住的,再全等文本唯一者优先;仍歧义 → null + 带序号候选(--nth 即该序号)
97
+ // within:把候选限定在某容器子树内(如 --within type:SheetPage 只在弹层里找)
98
+ export function pickTarget(nodes, pattern, nth, within) {
99
+ let scope = nodes;
100
+ if (within) {
101
+ const cont = matchNodes(nodes, within).find(x => center(x.bounds));
102
+ if (!cont) return { node: null, candidates: [], reason: `within 容器无命中:${within}` };
103
+ const ci = nodes.indexOf(cont); let end = ci + 1; while (end < nodes.length && nodes[end].depth > cont.depth) end++;
104
+ scope = nodes.slice(ci + 1, end);
105
+ }
106
+ const hit = matchNodes(scope, pattern);
107
+ if (!hit.length) return { node: null, candidates: [], reason: within ? `容器 ${within} 内无命中` : '无命中' };
108
+ const clickable = hit.filter(n => n.clickable && center(n.bounds));
109
+ let pool = clickable.length ? clickable : hit.filter(n => center(n.bounds));
110
+ if (!pool.length) return { node: null, candidates: hit, reason: '命中节点无坐标' };
111
+ const cover = (n) => occludedBy(nodes, nodes.indexOf(n));
112
+ if (nth !== undefined && nth !== null && nth !== '') { const n = pool[Number(nth)]; return n ? { node: n, candidates: pool, occludedBy: cover(n) } : { node: null, candidates: pool, reason: `--nth ${nth} 越界(候选 0..${pool.length - 1})` }; }
113
+ const visible = pool.filter(n => !cover(n));
114
+ if (visible.length && visible.length < pool.length) pool = visible; // 有未被盖住的就只在它们里选
115
+ if (pool.length === 1) return { node: pool[0], candidates: pool, occludedBy: cover(pool[0]) };
116
+ const plain = String(pattern).replace(/^=/, '');
117
+ const exact = pool.filter(n => n.text === plain || n.id === plain);
118
+ if (exact.length === 1) return { node: exact[0], candidates: pool, occludedBy: cover(exact[0]) };
119
+ return { node: null, candidates: pool, reason: `歧义 ${pool.length} 个,用 --nth <序号>、--within <容器> 或更具体的匹配` };
120
+ }
121
+ export const describe = (n, i) => `${i != null ? `[${i}] ` : ''}${' '.repeat(n.depth)}${n.type}${n.id ? '#' + n.id : ''} "${n.text}" ${n.bounds}${n.clickable ? ' [click]' : ''}`;
122
+ // 页面字典:当前页全部可见文本与 id(去重、保序、各至多 120 条)——写核心路径计划时照它取 text/id,一次写准
123
+ // 几何量测(只量不判):节点中心相对根节点的归一化坐标 (x,y),0=左/上边、1=右/下边——位置类断言由模型拿它对照源/PRD 裁决,脚本不划区
124
+ export function relCenter(bounds, root) {
125
+ const c = center(bounds); const m = root ? String(root).match(/\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/) : null;
126
+ if (!c || !m) return null;
127
+ const r = m.slice(1).map(Number);
128
+ return [Number(((c[0] - r[0]) / Math.max(1, r[2] - r[0])).toFixed(2)), Number(((c[1] - r[1]) / Math.max(1, r[3] - r[1])).toFixed(2))];
129
+ }
130
+ export const pageDict = (nodes) => {
131
+ const root = (nodes.find(n => center(n.bounds)) || {}).bounds;
132
+ const geometry = {};
133
+ for (const n of nodes) { if (!(n.clickable || n.id)) continue; const k = n.id ? '#' + n.id : n.text.trim(); if (!k || geometry[k]) continue; const g = relCenter(n.bounds, root); if (g) geometry[k] = { center: g, bounds: n.bounds }; if (Object.keys(geometry).length >= 60) break; }
134
+ return {
135
+ texts: [...new Set(nodes.map(n => n.text.trim()).filter(Boolean))].slice(0, 120),
136
+ ids: [...new Set(nodes.map(n => n.id).filter(Boolean))].slice(0, 120),
137
+ clickable: [...new Set(nodes.filter(n => n.clickable).map(n => n.text.trim() || (n.id ? '#' + n.id : '')).filter(Boolean))].slice(0, 60),
138
+ geometry,
139
+ };
140
+ };
141
+ const listCandidates = (cs) => cs.slice(0, 8).map((c, i) => describe(c, i).trim()).join(' | ');
142
+ // 控件树签名:节点数 + 逐节点 type/text/id/bounds 串——同页同态则相同;用于判"这一下点击有没有产生变化"
143
+ export const pageSig = (nodes) => `${nodes.length}|` + nodes.map(n => `${n.type}:${n.text}:${n.id}:${n.bounds}`).join('\n');
144
+ // 滑动几何:容器 = --within 命中的节点(多命中按 --nth,否则取首个有坐标的),缺省整屏(根节点);线段沿滑动轴 70%→30%、另一轴居中
145
+ export function swipeBox(nodes, within, nth) {
146
+ if (!within) { const root = nodes.find(n => center(n.bounds)); return root ? root.bounds : null; }
147
+ const hit = matchNodes(nodes, within).filter(n => center(n.bounds));
148
+ if (!hit.length) return null;
149
+ const k = nth !== undefined && nth !== null && nth !== '' ? Number(nth) : 0;
150
+ return hit[k] ? hit[k].bounds : null;
151
+ }
152
+ export function swipeLine(bounds, dir = 'up') {
153
+ const m = String(bounds).match(/\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/); if (!m) return null;
154
+ const [x0, y0, x1, y1] = [+m[1], +m[2], +m[3], +m[4]]; const cx = Math.round((x0 + x1) / 2), cy = Math.round((y0 + y1) / 2);
155
+ const at = (a, b, f) => Math.round(a + (b - a) * f);
156
+ switch (dir) {
157
+ case 'up': return [cx, at(y0, y1, 0.7), cx, at(y0, y1, 0.3)];
158
+ case 'down': return [cx, at(y0, y1, 0.3), cx, at(y0, y1, 0.7)];
159
+ case 'left': return [at(x0, x1, 0.7), cy, at(x0, x1, 0.3), cy];
160
+ case 'right': return [at(x0, x1, 0.3), cy, at(x0, x1, 0.7), cy];
161
+ default: return null;
162
+ }
163
+ }
164
+
165
+ // 从台账机械生成逐页走查计划:每个带 route 的切片一份 —— 直达该页 → 存活 → probe 该片 ui 断言引用的字面量(parse-cards 抽的)→ 截图。
166
+ // probe 只记出现率不判失败(断言含状态相关文案,落地页不可能一次全显);不含跨页交互(那是核心路径计划,由 agent 按 PRD 场景写)。
167
+ // 字面量按"前缀:值"只探前缀,正则转义后子串匹配。
168
+ export function buildPlans(ledger, bundle) {
169
+ const feats = new Map((ledger.features || []).map(f => [f.id, f]));
170
+ const plans = []; const used = new Set();
171
+ for (const s of ledger.slices || []) {
172
+ if (!s.route) continue;
173
+ const lits = new Set();
174
+ for (const id of s.featureIds || []) { const f = feats.get(id); if (!f || f.kind !== 'ui' || f.status === 'waived') continue; for (const l of f.literals || []) lits.add(escapeRe(probeOf(l))); }
175
+ const steps = [{ op: 'start', route: s.route }, { op: 'alive' }];
176
+ if (lits.size) steps.push({ op: 'probe', match: [...lits] });
177
+ steps.push({ op: 'shot', name: s.route });
178
+ let name = String(s.route).replace(/[^\w.-]/g, '_'); if (used.has(name)) name = `${name}__${String(s.id).replace(/[^\w.-]/g, '_')}`; used.add(name);
179
+ plans.push({ name, plan: { bundle, name, kind: 'skeleton', slice: s.id, route: s.route, generated: 'walk.mjs plan(台账派生,勿手改;probe 只记出现率;跨页交互另写核心路径计划)', steps } });
180
+ }
181
+ return plans;
182
+ }
183
+
184
+ // faultlog 头部解析(jscrash:Error message + 'at xxx entry (entry/…/X.ets:行:列)';cppcrash:Reason + 首帧)——只取应用可归因的第一帧
185
+ export function parseFault(file, head) {
186
+ const lines = String(head || '').split(/\r?\n/);
187
+ const pick = (re) => { const l = lines.find(x => re.test(x)); return l ? l.replace(re, '').trim() : ''; };
188
+ const reason = pick(/^Reason:/), message = pick(/^Error message:/);
189
+ const frameLine = lines.find(x => /\bentry\/[^\s)]+\.(ets|ts):\d+/.test(x)) || lines.find(x => /^\s*at\s|^#0?0\s/.test(x)) || '';
190
+ const fm = frameLine.match(/(entry\/[^\s)]+\.(?:ets|ts):\d+(?::\d+)?)/);
191
+ return { file, reason, message, frame: fm ? fm[1] : frameLine.trim().slice(0, 160) };
192
+ }
193
+ // 步骤执行器:io = { dump(): nodes[], tap(x,y), input(x,y,text), key(name), swipe(x1,y1,x2,y2,speed?), start(bundle,route,fresh?), stop(bundle), alive(bundle), faults(bundle), faultHead?(file): bool, shot(name): path, sleep(ms), faults(bundle): string[] }
194
+ export function runSteps(plan, io, { stopOnFail = true, label } = {}) {
195
+ const tag = String(label || plan.name || plan.route || plan.slice || 'plan').replace(/[^\w.-]/g, '_');
196
+ const report = { bundle: plan.bundle || null, name: tag, steps: [], ok: true, crash: null };
197
+ const base = new Set(plan.bundle ? io.faults(plan.bundle) : []);
198
+ // 全程见过的 text/id 并集(上限 3000):阶段五 render 用它核"静态 passed 的断言字面量真机是否出现过"
199
+ const seenT = new Set(), seenI = new Set();
200
+ const dump = () => { const ns = io.dump(); for (const x of ns) { if (seenT.size < 3000 && x.text) seenT.add(x.text); if (seenI.size < 3000 && x.id) seenI.add(x.id); } return ns; };
201
+ let halted = false;
202
+ for (const [i, s] of (plan.steps || []).entries()) {
203
+ const r = { i, op: s.op, ok: true, detail: '' };
204
+ if (halted) { r.ok = false; r.detail = 'skipped(前步失败)'; r.skipped = true; report.steps.push(r); continue; }
205
+ const t0 = Date.now();
206
+ try {
207
+ switch (s.op) {
208
+ case 'start': io.start(plan.bundle, s.route || null, !!s.fresh); io.sleep(s.ms || 3000); r.detail = `${s.fresh ? '清数据后' : ''}${s.route ? `直达 ${s.route}` : '冷启'}`; break;
209
+ case 'stop': io.stop(plan.bundle); break;
210
+ case 'sleep': io.sleep(s.ms || 500); break;
211
+ case 'back': io.key('Back'); io.sleep(s.ms || 600); break;
212
+ case 'key': io.key(s.name); io.sleep(s.ms || 600); break;
213
+ case 'alive': { const ok = io.alive(plan.bundle); r.ok = ok; r.detail = ok ? '进程在·前台' : '进程不在或不在前台'; if (!ok) r.kind = 'process_died'; break; }
214
+ case 'shot': r.shot = io.shot(s.name || `step-${i}`); r.detail = r.shot; break;
215
+ case 'expect': {
216
+ const nodes = dump(); const pats = Array.isArray(s.match) ? s.match : [s.match];
217
+ const miss = pats.filter(p => !matchNodes(nodes, p).length);
218
+ r.ok = !miss.length; r.detail = miss.length ? `缺 [${miss.join(', ')}](页面 ${nodes.length} 节点)` : `命中 ${pats.length}/${pats.length}`;
219
+ if (miss.length) { r.kind = 'expect_missing'; r.page = pageDict(nodes); } // 失败时附页面字典:按实际 text/id 改计划,不用再手工 dump
220
+ break;
221
+ }
222
+ case 'probe': { // 出现率证据:不判失败,只记见到/未见(状态相关文案给核心路径计划去断言);附页面字典供写核心路径计划
223
+ const nodes = dump(); const pats = Array.isArray(s.match) ? s.match : [s.match];
224
+ const seen = pats.filter(p => matchNodes(nodes, p).length), unseen = pats.filter(p => !seen.includes(p));
225
+ r.seen = seen; r.unseen = unseen; r.page = pageDict(nodes);
226
+ r.detail = `出现 ${seen.length}/${pats.length}(页面 ${nodes.length} 节点)${unseen.length ? ` 未见 [${unseen.join(', ')}]` : ''}`;
227
+ break;
228
+ }
229
+ case 'wait': {
230
+ const deadline = Date.now() + (s.timeout || 8000); let found = false, n = 0;
231
+ let last = [];
232
+ while (Date.now() < deadline) { const nodes = dump(); last = nodes; n = nodes.length; if (matchNodes(nodes, s.match).length) { found = true; break; } io.sleep(s.interval || 500); }
233
+ r.ok = found; r.detail = found ? `出现 ${s.match}` : `等待 ${s.match} 超时(最后 ${n} 节点)`;
234
+ if (!found) { r.kind = 'expect_missing'; r.page = pageDict(last); }
235
+ break;
236
+ }
237
+ case 'tap': case 'type': {
238
+ const before = dump();
239
+ // 点击效果信号需要一个静态基线:点前再取一次树,两次不同 = 页面自变(倒计时/轮播/时钟),信号记 null 不硬判
240
+ const before2 = s.op === 'tap' ? dump() : null;
241
+ const { node, candidates, reason, occludedBy: cover } = pickTarget(before, s.match, s.nth, s.within);
242
+ if (!node) {
243
+ r.ok = false; r.kind = /歧义/.test(reason) ? 'target_ambiguous' : /越界/.test(reason) ? 'target_out_of_range' : 'target_missing';
244
+ r.detail = `${reason}:${s.match}${candidates.length ? ';候选 ' + listCandidates(candidates) : ''}`;
245
+ if (r.kind === 'target_missing') r.page = pageDict(before); // 无命中附页面字典:照实际 text/id 改计划
246
+ break;
247
+ }
248
+ if (cover) { r.occludedBy = `${cover.type}${cover.id ? '#' + cover.id : ''} ${cover.bounds}`; }
249
+ const [x, y] = center(node.bounds);
250
+ if (s.op === 'tap') { io.tap(x, y); r.detail = `点 ${node.type} "${node.text || node.id}" @${x},${y}`; }
251
+ else { io.input(x, y, String(s.text ?? '')); r.detail = `输入到 ${node.type} "${node.text || node.id}" @${x},${y}`; }
252
+ io.sleep(s.ms || 800);
253
+ // 点击效果信号:点前/点后控件树签名不变 = 这一下没产生任何可见变化(不判失败——有的点击本就无界面反馈),
254
+ // 记 changed:false 供核心路径复盘:连续多步"命中但无变化"是吞点击/接线断的典型体征(实证一轮全应用列表项点击死,
255
+ // 逐页计划却全"过",因为只核可达与出现率)
256
+ if (s.op === 'tap') {
257
+ const volatile = pageSig(before2) !== pageSig(before);
258
+ const after = dump();
259
+ r.changed = volatile ? null : pageSig(after) !== pageSig(before);
260
+ if (s.expectNoChange) { r.expectNoChange = true; if (r.changed === true) r.detail += '(预期无变化但页面变了)'; }
261
+ else if (r.changed === false) r.detail += cover ? `(点后页面无变化——命中节点被 ${cover.type} 盖住,点在遮罩上;用 --within 指定弹层内目标)` : '(点后页面无变化)';
262
+ else if (volatile) r.detail += '(页面自变,无法判定点击效果)';
263
+ }
264
+ break;
265
+ }
266
+ case 'swipe': { // 定向滑动(列表翻页/横滑区/触底加载):within 容器缺省整屏
267
+ const box = swipeBox(dump(), s.within, s.nth); const line = box && swipeLine(box, s.dir || 'up');
268
+ if (!line) { r.ok = false; r.kind = box ? 'bad_step' : 'target_missing'; r.detail = box ? `dir 非法:${s.dir}` : `滑动容器无命中:${s.within}`; break; }
269
+ io.swipe(...line, s.speed); io.sleep(s.ms || 800);
270
+ r.detail = `滑 ${s.dir || 'up'} ${line[0]},${line[1]}→${line[2]},${line[3]}${s.within ? ` 于 ${s.within}` : ''}`;
271
+ break;
272
+ }
273
+ case 'scrollUntil': { // 反复滑到目标出现(最多 max 次,默认 8);未见 → 失败并附页面字典
274
+ const max = Number.isInteger(s.max) && s.max >= 0 ? s.max : 8; let found = false, n = 0, nodes = dump();
275
+ for (;;) {
276
+ if (matchNodes(nodes, s.match).length) { found = true; break; }
277
+ if (n >= max) break;
278
+ const box = swipeBox(nodes, s.within, s.nth); const line = box && swipeLine(box, s.dir || 'up');
279
+ if (!line) { r.detail = box ? `dir 非法:${s.dir}` : `滑动容器无命中:${s.within}`; break; }
280
+ io.swipe(...line, s.speed); io.sleep(s.ms || 800); n++; nodes = dump();
281
+ }
282
+ r.ok = found; r.detail = found ? `滑 ${n} 次后出现 ${s.match}` : (r.detail || `滑 ${n} 次仍未见 ${s.match}(页面 ${nodes.length} 节点)`);
283
+ if (!found) { r.kind = r.kind || 'expect_missing'; r.page = pageDict(nodes); }
284
+ break;
285
+ }
286
+ default: r.ok = false; r.kind = 'bad_step'; r.detail = `未知 op ${s.op}`;
287
+ }
288
+ } catch (e) { r.ok = false; r.kind = 'io_error'; r.detail = `执行异常:${String(e && e.message || e).slice(0, 200)}`; }
289
+ r.ms = Date.now() - t0;
290
+ if (!r.ok) {
291
+ report.ok = false;
292
+ if (stopOnFail) { halted = true; try { r.shot = io.shot(`fail-${tag}-step-${i}`); } catch { /* 截图失败不改判定 */ } }
293
+ }
294
+ report.steps.push(r);
295
+ }
296
+ if (plan.bundle) {
297
+ const fresh = io.faults(plan.bundle).filter(f => !base.has(f));
298
+ if (fresh.length) {
299
+ report.crash = fresh; report.ok = false;
300
+ // 崩溃详情:faultlog 头部的 Reason / Error message / 第一处落在应用源码的栈帧——模型不必再去设备上 cat 再二分
301
+ report.crashDetail = fresh.map(f => parseFault(f, typeof io.faultHead === 'function' ? io.faultHead(f) : ''));
302
+ const died = report.steps.filter(x => !x.ok && !x.skipped && x.kind === 'process_died'); for (const x of died) x.crash = report.crashDetail[0];
303
+ }
304
+ }
305
+ report.seen = { texts: [...seenT], ids: [...seenI] };
306
+ const taps = report.steps.filter(x => x.op === 'tap' && x.ok && !x.skipped);
307
+ if (taps.length) report.tapsNoChange = taps.filter(x => x.changed === false && !x.expectNoChange).length; // 未预期的"命中却无变化"点击数,复盘时先看它(禁用态按钮这类写 expectNoChange:true 不计)
308
+ return report;
309
+ }
310
+
311
+ // ---------- 参数解析(--continue 是布尔;其余 --flag 取一个值,值不得以 -- 开头) ----------
312
+ const BOOL_FLAGS = new Set(['--continue']);
313
+ export function parseArgs(argv) {
314
+ const positional = []; const flags = {};
315
+ for (let i = 0; i < argv.length; i++) {
316
+ const a = argv[i];
317
+ if (a.startsWith('--')) {
318
+ if (BOOL_FLAGS.has(a)) { flags[a] = true; continue; }
319
+ const v = argv[i + 1];
320
+ if (v === undefined || v.startsWith('--')) { flags[a] = ''; continue; }
321
+ flags[a] = v; i++;
322
+ } else positional.push(a);
323
+ }
324
+ return { positional, flags };
325
+ }
326
+
327
+ // ---------- 设备 IO(真机/模拟器) ----------
328
+ function hdc(args, timeout = 60000) {
329
+ try { return execFileSync('hdc', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout }).trim(); }
330
+ catch (e) { return ((e.stdout || '') + (e.stderr || '')).trim(); }
331
+ }
332
+ export function listTargets() {
333
+ const out = hdc(['list', 'targets'], 20000);
334
+ return out.split('\n').map(s => s.trim()).filter(s => s && !/\[Empty\]|\[Fail\]/i.test(s));
335
+ }
336
+ function makeIo(flags) {
337
+ const list = listTargets();
338
+ let key = flags['--device'] || (process.env.HMIG_HDC_TARGET || '').trim();
339
+ if (!key) {
340
+ if (!list.length) { console.error('[walk] 环境阻塞(exit 3):无设备/模拟器'); process.exit(3); }
341
+ if (list.length > 1) { console.error(`[walk] 环境阻塞(exit 3):多个设备,--device 或 HMIG_HDC_TARGET 指定其一:${list.join(' ')}`); process.exit(3); }
342
+ key = list[0];
343
+ } else if (list.length && !list.includes(key)) { console.error(`[walk] 环境阻塞(exit 3):设备 ${key} 不在列表:${list.join(' ') || '(空)'}`); process.exit(3); }
344
+ const shell = (c) => hdc(['-t', key, 'shell', c]);
345
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms));
346
+ const shotsDir = flags['--shots'] || null;
347
+
348
+ // 单飞锁:先建临时目录写 pid,再原子改名为锁目录(锁一旦可见 pid 必在);只有持锁进程已死才视为陈旧;等待上限 60 秒
349
+ const lock = join(tmpdir(), `hmig-walk-${key.replace(/[^\w.-]/g, '_')}`);
350
+ const pidOf = () => { try { const n = Number(readFileSync(join(lock, 'pid'), 'utf8').trim()); return Number.isInteger(n) && n > 0 ? n : null; } catch { return null; } };
351
+ const alivePid = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === 'EPERM'; } };
352
+ const acquire = () => {
353
+ const until = Date.now() + 60000;
354
+ for (;;) {
355
+ const tmp = mkdtempSync(join(tmpdir(), 'hmig-walk-tmp-'));
356
+ writeFileSync(join(tmp, 'pid'), String(process.pid));
357
+ try { renameSync(tmp, lock); return; } catch { try { rmSync(tmp, { recursive: true, force: true }); } catch { /* 忽略 */ } }
358
+ const pid = pidOf();
359
+ if (pid === null || !alivePid(pid)) { try { rmSync(lock, { recursive: true, force: true }); } catch { /* 下轮再试 */ } }
360
+ if (Date.now() > until) { console.error(`[walk] 环境阻塞(exit 3):设备 ${key} 的走查锁被进程 ${pid ?? '?'} 持有超过 60 秒(4b 走查必须单飞,不要并行跑 hdc)`); process.exit(3); }
361
+ sleep(500);
362
+ }
363
+ };
364
+ acquire();
365
+ const localTree = join(tmpdir(), `hmig-walk-${process.pid}.json`);
366
+ process.on('exit', () => {
367
+ if (pidOf() === process.pid) { try { rmSync(lock, { recursive: true, force: true }); } catch { /* 忽略 */ } }
368
+ try { unlinkSync(localTree); } catch { /* 忽略 */ }
369
+ });
370
+ const dumpRaw = () => {
371
+ for (let i = 0; i < 3; i++) {
372
+ const out = shell('uitest dumpLayout');
373
+ const m = out.match(/\/\S+\.json/);
374
+ if (m) {
375
+ try { unlinkSync(localTree); } catch { /* 无旧文件 */ }
376
+ hdc(['-t', key, 'file', 'recv', m[0], localTree]);
377
+ try { if (existsSync(localTree)) { const nodes = flatten(JSON.parse(readFileSync(localTree, 'utf8'))); if (nodes.length) return nodes; } } catch { /* 重试 */ }
378
+ }
379
+ sleep(500);
380
+ }
381
+ return [];
382
+ };
383
+ const shotPath = (name) => {
384
+ const s = String(name);
385
+ if (isAbsolute(s) || s.includes('/') || s.includes(sep)) { const p = resolve(/\.png$/i.test(s) ? s : s + '.png'); mkdirSync(dirname(p), { recursive: true }); return p; }
386
+ const dir = shotsDir || join(process.cwd(), 'spec', '4b', 'shots'); mkdirSync(dir, { recursive: true });
387
+ return join(dir, `${s.replace(/\.png$/i, '').replace(/[^\w.-]/g, '_')}.png`);
388
+ };
389
+ return {
390
+ key, list, sleep,
391
+ dump: dumpRaw,
392
+ tap: (x, y) => shell(`uitest uiInput click ${x} ${y}`),
393
+ input: (x, y, text) => { shell(`uitest uiInput click ${x} ${y}`); sleep(300); shell(`uitest uiInput inputText ${x} ${y} '${String(text).replace(/'/g, "'\\''")}'`); },
394
+ key: (name) => shell(`uitest uiInput keyEvent ${name}`),
395
+ swipe: (x1, y1, x2, y2, speed) => shell(`uitest uiInput swipe ${x1} ${y1} ${x2} ${y2} ${Number(speed) > 0 ? Number(speed) : 600}`),
396
+ start: (bundle, route, fresh) => { shell(`aa force-stop ${bundle}`); sleep(500); if (fresh) { shell(`bm clean -n ${bundle} -d`); sleep(500); } shell(`aa start -a EntryAbility -b ${bundle}${route ? ` --ps smokeRoute ${route}` : ''}`); },
397
+ stop: (bundle) => shell(`aa force-stop ${bundle}`),
398
+ alive: (bundle) => { const pid = shell(`pidof ${bundle}`); const d = shell('aa dump -l'); return !!pid && d.split(/Mission ID #/).some(b => b.includes(`bundle name [${bundle}]`) && /state #FOREGROUND/.test(b)); },
399
+ shot: (name) => { const local = shotPath(name); shell('uitest screenCap -p /data/local/tmp/hmig-walk.png'); hdc(['-t', key, 'file', 'recv', '/data/local/tmp/hmig-walk.png', local]); return local; },
400
+ faults: (bundle) => shell('ls /data/log/faultlog/faultlogger/ 2>/dev/null').split(/\s+/).filter(f => f && /jscrash|cppcrash/.test(f) && f.includes(bundle)),
401
+ faultHead: (file) => shell(`head -60 /data/log/faultlog/faultlogger/${String(file).replace(/[^\w.-]/g, '')}`),
402
+ };
403
+ }
404
+
405
+ // ---------- CLI ----------
406
+ const isMain = (() => { try { return process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); } catch { return false; } })();
407
+ if (isMain) {
408
+ const [cmd, ...rest] = process.argv.slice(2);
409
+ const { positional, flags } = parseArgs(rest);
410
+ const fail = (code, msg) => { console.error(`[walk] ${msg}`); process.exit(code); };
411
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') { console.log(readFileSync(new URL(import.meta.url), 'utf8').split('\n').filter(l => /^ \* /.test(l)).map(l => l.slice(3)).join('\n')); process.exit(0); }
412
+ if (cmd === 'devices') { const t = listTargets(); console.log(t.join('\n') || '(无设备)'); process.exit(0); }
413
+ if (cmd === 'plan') {
414
+ const ledgerPath = flags['--ledger'], project = flags['--project'], out = flags['--out'];
415
+ if (!ledgerPath || !existsSync(ledgerPath) || !out) fail(3, 'plan 需要 --ledger <ledger.json> --out <目录>(--project 取 bundleName,或 --bundle)');
416
+ let bundle = flags['--bundle'] || null;
417
+ if (!bundle && project) { const app = join(project, 'AppScope', 'app.json5'); const m = existsSync(app) ? readFileSync(app, 'utf8').match(/"bundleName"\s*:\s*"([^"]+)"/) : null; bundle = m ? m[1] : null; }
418
+ if (!bundle) fail(3, '解析不到 bundleName:给 --project 或 --bundle');
419
+ let ledger; try { ledger = JSON.parse(readFileSync(ledgerPath, 'utf8')); } catch (e) { fail(3, `台账解析失败:${e.message}`); }
420
+ const plans = buildPlans(ledger, bundle);
421
+ mkdirSync(out, { recursive: true });
422
+ for (const p of plans) writeFileSync(join(out, `${p.name}.json`), JSON.stringify(p.plan, null, 2));
423
+ const withProbe = plans.filter(p => p.plan.steps.some(s => s.op === 'probe')).length;
424
+ const self = relative(process.cwd(), fileURLToPath(import.meta.url)) || fileURLToPath(import.meta.url);
425
+ console.log(`[walk] 台账 ${(ledger.slices || []).length} 片 → ${plans.length} 份逐页计划(${withProbe} 份带字面量 probe,只记出现率)→ ${out}/`);
426
+ for (const p of plans) console.log(` node ${self} run ${join(out, p.name + '.json')} --out ${join(out, p.name + '.report.json')}`);
427
+ process.exit(0);
428
+ }
429
+ const io = makeIo(flags);
430
+ switch (cmd) {
431
+ case 'start': { if (!positional[0]) fail(3, 'start 需要 <bundle>'); const route = flags['--route'] || null; io.start(positional[0], route, !!flags['--fresh']); io.sleep(3000); console.log(`started ${positional[0]}${route ? ' → ' + route : ''}`); break; }
432
+ case 'stop': if (!positional[0]) fail(3, 'stop 需要 <bundle>'); io.stop(positional[0]); console.log('stopped'); break;
433
+ case 'alive': { if (!positional[0]) fail(3, 'alive 需要 <bundle>'); const ok = io.alive(positional[0]); console.log(ok ? `alive ${positional[0]}(前台)` : `dead/background ${positional[0]}`); process.exit(ok ? 0 : 4); }
434
+ case 'dump': { const nodes = io.dump(); const pat = positional[0]; const pick = pat ? matchNodes(nodes, pat) : nodes.filter(n => n.text || n.id || n.clickable); for (const n of pick) console.log(describe(n)); const gm = pageDict(nodes).geometry; if (!pat && Object.keys(gm).length) console.log('geometry(center x,y 归一化; 0=左/上 1=右/下): ' + Object.entries(gm).map(([k, v]) => `${k}=(${v.center[0]},${v.center[1]})`).join(' ')); if (!nodes.length) fail(4, '控件树为空(三次重试)'); break; }
435
+ case 'tap': case 'type': {
436
+ if (!positional[0]) fail(3, `${cmd} 需要 <匹配>`);
437
+ const { node, candidates, reason, occludedBy: cover } = pickTarget(io.dump(), positional[0], flags['--nth'], flags['--within']);
438
+ if (!node) { candidates.slice(0, 8).forEach((c, i) => console.log(describe(c, i))); fail(5, `${reason}:${positional[0]}`); }
439
+ const [x, y] = center(node.bounds);
440
+ if (cmd === 'tap') { io.tap(x, y); console.log(`tap ${node.type} "${node.text || node.id}" @${x},${y}${cover ? `(⚠ 该节点被 ${cover.type} 盖住,点击落在遮罩上;弹层内目标用 --within)` : ''}`); }
441
+ else { io.input(x, y, positional.slice(1).join(' ')); console.log(`type into ${node.type} "${node.text || node.id}" @${x},${y}`); }
442
+ break;
443
+ }
444
+ case 'back': io.key('Back'); console.log('back'); break;
445
+ case 'key': if (!positional[0]) fail(3, 'key 需要 <键名>'); io.key(positional[0]); console.log(`key ${positional[0]}`); break;
446
+ case 'swipe': {
447
+ const dir = positional[0] || 'up'; const box = swipeBox(io.dump(), flags['--within'], flags['--nth']); const line = box && swipeLine(box, dir);
448
+ if (!line) fail(box ? 3 : 5, box ? `dir 须为 up|down|left|right:${dir}` : `滑动容器无命中:${flags['--within']}`);
449
+ io.swipe(...line); io.sleep(800); console.log(`swipe ${dir} ${line[0]},${line[1]}→${line[2]},${line[3]}`); break;
450
+ }
451
+ case 'scroll': {
452
+ if (!positional[0]) fail(3, 'scroll 需要 <匹配>');
453
+ const rep = runSteps({ steps: [{ op: 'scrollUntil', match: positional[0], dir: flags['--dir'] || 'up', within: flags['--within'], nth: flags['--nth'], max: Number(flags['--max'] || 8) }] }, io, { label: 'scroll' });
454
+ const st = rep.steps[0]; console.log(st.detail); if (!st.ok) { if (st.page) console.log(`页面文本:${st.page.texts.slice(0, 40).join(' | ')}`); process.exit(4); } break;
455
+ }
456
+ case 'expect': { if (!positional.length) fail(3, 'expect 需要至少一个 <匹配>'); const nodes = io.dump(); const miss = positional.filter(p => !matchNodes(nodes, p).length); if (miss.length) fail(4, `缺 [${miss.join(', ')}](页面 ${nodes.length} 节点)`); console.log(`expect 命中 ${positional.length}/${positional.length}`); break; }
457
+ case 'probe': { if (!positional.length) fail(3, 'probe 需要至少一个 <匹配>'); const nodes = io.dump(); const seen = positional.filter(p => matchNodes(nodes, p).length); const unseen = positional.filter(p => !seen.includes(p)); console.log(`probe 出现 ${seen.length}/${positional.length}(页面 ${nodes.length} 节点)${unseen.length ? ` 未见 [${unseen.join(', ')}]` : ''}`); break; }
458
+ case 'wait': { if (!positional[0]) fail(3, 'wait 需要 <匹配>'); const deadline = Date.now() + Number(flags['--timeout'] || 8000); let found = false; while (Date.now() < deadline) { if (matchNodes(io.dump(), positional[0]).length) { found = true; break; } io.sleep(500); } if (!found) fail(4, `等待 ${positional[0]} 超时`); console.log(`wait 出现 ${positional[0]}`); break; }
459
+ case 'shot': { const p = io.shot(positional[0] || 'shot'); console.log(p); break; }
460
+ case 'faults': { if (!positional[0]) fail(3, 'faults 需要 <bundle>'); const f = io.faults(positional[0]); console.log(f.join('\n') || '(无崩溃日志)'); break; }
461
+ case 'run': {
462
+ const planPath = positional[0]; if (!planPath || !existsSync(planPath)) fail(3, 'run 需要 steps.json 路径');
463
+ let plan; try { plan = JSON.parse(readFileSync(planPath, 'utf8')); } catch (e) { fail(3, `steps.json 解析失败:${e.message}`); }
464
+ if (!plan.bundle || !Array.isArray(plan.steps) || !plan.steps.length) fail(3, 'steps.json 须含 bundle 与非空 steps');
465
+ const report = runSteps(plan, io, { stopOnFail: !flags['--continue'], label: plan.name || plan.route || planPath.replace(/^.*[\\/]/, '').replace(/\.json$/i, '') });
466
+ const out = flags['--out'] || null;
467
+ // 同一份计划连续失败计数(同 --out 覆盖写时读上一份):≥2 即"修复循环"信号,4b 纪律按它止损
468
+ let prevStreak = 0; if (out && existsSync(resolve(out))) { try { prevStreak = Number(JSON.parse(readFileSync(resolve(out), 'utf8')).failStreak) || 0; } catch { prevStreak = 0; } }
469
+ report.failStreak = report.ok ? 0 : prevStreak + 1;
470
+ if (out) { mkdirSync(dirname(resolve(out)), { recursive: true }); writeFileSync(resolve(out), JSON.stringify({ plan: relative(process.cwd(), resolve(planPath)) || planPath, device: io.key, at: new Date().toISOString(), ...report }, null, 2)); }
471
+ for (const s of report.steps) console.log(` ${s.skipped ? '·' : s.ok ? '✓' : '✗'} [${s.i}] ${s.op} ${s.detail}${s.kind ? ` [${s.kind}]` : ''}${s.ms != null ? ` (${s.ms}ms)` : ''}${s.shot && !s.ok ? ` 现场 ${s.shot}` : ''}`);
472
+ if (report.failStreak >= 2) console.log(`⚠ 本计划连续第 ${report.failStreak} 次失败——同一缺陷只允许一次定向修+一次复验,仍败即记 failed 交阶段五,不再改代码`);
473
+ if (report.crash) { console.log(`✗ 走查期间崩溃:${report.crash.join(', ')}`); for (const c of report.crashDetail || []) console.log(` ${c.file}: ${c.message || c.reason || '?'}${c.frame ? ` @ ${c.frame}` : ''}`); process.exit(10); }
474
+ const bad = report.steps.filter(s => !s.ok && !s.skipped).length;
475
+ console.log(`[walk] ${bad ? `${bad} 步未过` : `${report.steps.length} 步全过`}${report.tapsNoChange ? `(${report.tapsNoChange} 次点击命中但页面无变化——查吞点击/接线)` : ''}${out ? ` → ${out}` : ''}`);
476
+ process.exit(bad ? 4 : 0);
477
+ }
478
+ default: fail(3, `未知命令 ${cmd}(walk.mjs help 看用法)`);
479
+ }
480
+ }
@@ -0,0 +1,76 @@
1
+ {
2
+ "version": "0.1.0",
3
+ "_doc": "布局转译映射表(数据即语料)。规则来源:references/ui-alignment(组件映射/布局映射 301 行)与 references/flow/ui-layout-semantics.md。新控件=加条目→过 benchmarks 转译率靶。widget.kind: container(容器)|leaf(叶子)|scroll(滚动容器)|list(列表)|passthrough(透传children)。",
4
+ "widgets": {
5
+ "LinearLayout": { "kind": "container", "byOrientation": { "vertical": "Column", "horizontal": "Row" }, "default": "Column" },
6
+ "FrameLayout": { "kind": "container", "component": "Stack" },
7
+ "RelativeLayout": { "kind": "container", "component": "Stack", "todo": "RelativeLayout 相对约束简化为 Stack——切片阶段按源约束改 RelativeContainer 或对齐修正" },
8
+ "androidx.constraintlayout.widget.ConstraintLayout": { "kind": "container", "component": "Column", "todo": "ConstraintLayout 约束未转译——切片阶段按源约束重排(参考 ui-layout-semantics §2)" },
9
+ "ScrollView": { "kind": "scroll", "component": "Scroll" },
10
+ "androidx.core.widget.NestedScrollView": { "kind": "scroll", "component": "Scroll" },
11
+ "HorizontalScrollView": { "kind": "scroll", "component": "Scroll", "todo": "水平滚动:.scrollable(ScrollDirection.Horizontal)" },
12
+ "TextView": { "kind": "leaf", "component": "Text", "contentAttr": "android:text" },
13
+ "Button": { "kind": "leaf", "component": "Button", "contentAttr": "android:text" },
14
+ "com.google.android.material.button.MaterialButton": { "kind": "leaf", "component": "Button", "contentAttr": "android:text" },
15
+ "ImageView": { "kind": "leaf", "component": "Image", "contentAttr": "android:src" },
16
+ "ImageButton": { "kind": "leaf", "component": "Image", "contentAttr": "android:src", "todo": "ImageButton:外包可点击语义(onClick 由切片接)" },
17
+ "EditText": { "kind": "leaf", "component": "TextInput", "placeholderAttr": "android:hint" },
18
+ "androidx.appcompat.widget.AppCompatEditText": { "kind": "leaf", "component": "TextInput", "placeholderAttr": "android:hint" },
19
+ "CheckBox": { "kind": "leaf", "component": "Checkbox" },
20
+ "Switch": { "kind": "leaf", "component": "Toggle", "todo": "Toggle(type: ToggleType.Switch)" },
21
+ "androidx.appcompat.widget.SwitchCompat": { "kind": "leaf", "component": "Toggle", "todo": "Toggle(type: ToggleType.Switch)" },
22
+ "RadioButton": { "kind": "leaf", "component": "Radio" },
23
+ "ProgressBar": { "kind": "leaf", "component": "Progress", "todo": "不定进度用 LoadingProgress" },
24
+ "SeekBar": { "kind": "leaf", "component": "Slider" },
25
+ "Spinner": { "kind": "leaf", "component": "Select" },
26
+ "View": { "kind": "leaf", "component": "Divider", "onlyWhenThin": true, "fallback": "Blank" },
27
+ "Space": { "kind": "leaf", "component": "Blank" },
28
+ "androidx.recyclerview.widget.RecyclerView": { "kind": "list", "component": "List", "todo": "列表项模板与数据源由切片接(LazyForEach)" },
29
+ "ListView": { "kind": "list", "component": "List", "todo": "列表项模板与数据源由切片接" },
30
+ "GridView": { "kind": "list", "component": "Grid", "todo": "列宽/项模板由切片接" },
31
+ "androidx.viewpager2.widget.ViewPager2": { "kind": "container", "component": "Swiper" },
32
+ "androidx.viewpager.widget.ViewPager": { "kind": "container", "component": "Swiper" },
33
+ "WebView": { "kind": "leaf", "component": "Web", "todo": "Web({src, controller}) 控制器由切片接" },
34
+ "androidx.cardview.widget.CardView": { "kind": "container", "component": "Column", "extraMods": [".borderRadius(8)", ".shadow({ radius: 4, color: '#1F000000' })"], "todo": "CardView 阴影/圆角按源值核对" },
35
+ "com.google.android.material.card.MaterialCardView": { "kind": "container", "component": "Column", "extraMods": [".borderRadius(8)", ".shadow({ radius: 4, color: '#1F000000' })"] },
36
+ "androidx.appcompat.widget.Toolbar": { "kind": "container", "component": "Row", "todo": "Toolbar:标题/导航钮/菜单由切片按源接(或用 Navigation 标题栏)" },
37
+ "com.google.android.material.appbar.AppBarLayout": { "kind": "passthrough" },
38
+ "com.google.android.material.floatingactionbutton.FloatingActionButton": { "kind": "leaf", "component": "Button", "extraMods": [".type(ButtonType.Circle)"], "contentAttr": "android:src" },
39
+ "com.google.android.material.textfield.TextInputLayout": { "kind": "passthrough" },
40
+ "com.google.android.material.textfield.TextInputEditText": { "kind": "leaf", "component": "TextInput", "placeholderAttr": "android:hint" },
41
+ "merge": { "kind": "passthrough" },
42
+ "Toolbar": { "kind": "container", "component": "Row", "todo": "Toolbar:标题/导航钮/菜单由切片按源接" },
43
+ "include": { "kind": "include" },
44
+ "ViewAnimator": { "kind": "container", "component": "Stack", "todo": "ViewAnimator 子视图切换:切片用 if/visibility 控制" },
45
+ "ViewFlipper": { "kind": "container", "component": "Stack", "todo": "同 ViewAnimator" },
46
+ "androidx.coordinatorlayout.widget.CoordinatorLayout": { "kind": "container", "component": "Stack", "todo": "Coordinator 行为(折叠/锚定)由切片按源实现" },
47
+ "TableLayout": { "kind": "container", "component": "Column", "todo": "表格列对齐由切片核对" },
48
+ "TableRow": { "kind": "container", "component": "Row" },
49
+ "CheckedTextView": { "kind": "leaf", "component": "Text", "contentAttr": "android:text", "todo": "选中态由切片接" },
50
+ "RadioGroup": { "kind": "container", "component": "Column", "todo": "互斥选择语义由切片接" },
51
+ "AutoCompleteTextView": { "kind": "leaf", "component": "TextInput", "placeholderAttr": "android:hint", "todo": "补全弹层由切片接" },
52
+ "ViewStub": { "kind": "leaf", "component": "Blank", "todo": "懒加载占位:切片按需装配" },
53
+ "TextureView": { "kind": "leaf", "component": "XComponent", "todo": "渲染表面:切片接控制器" },
54
+ "SurfaceView": { "kind": "leaf", "component": "XComponent", "todo": "渲染表面:切片接控制器" },
55
+ "fragment": { "kind": "leaf", "component": "Column", "todo": "fragment 容器:宿主页面由切片装配" },
56
+ "androidx.fragment.app.FragmentContainerView": { "kind": "leaf", "component": "Column", "todo": "fragment 容器:宿主页面由切片装配" },
57
+ "androidx.swiperefreshlayout.widget.SwipeRefreshLayout": { "kind": "passthrough", "todo": "下拉刷新:切片外包 Refresh({refreshing}) 并把源 onRefresh 接到 onRefreshing" },
58
+ "androidx.drawerlayout.widget.DrawerLayout": { "kind": "container", "component": "Stack", "todo": "抽屉导航:切片按 SideBarContainer(SideBarContainerType.Overlay) 重构(主内容+抽屉两子)" },
59
+ "com.google.android.material.bottomnavigation.BottomNavigationView": { "kind": "leaf", "component": "Row", "todo": "底部导航:壳层用 Tabs+TabContent 重构(menu XML 项→tabBar),本节点仅占位" }
60
+ },
61
+ "fallbackSuffixes": {
62
+ "_doc": "未知控件按类名后缀回退到标准词条(自定义子类极常见:MyTextView/CurrencyTextView→TextView)。命中即按标准词条转译并加『custom subclass』注记。顺序即优先级;不含裸 View(过于贪婪)。",
63
+ "order": ["RecyclerView", "AutoCompleteTextView", "TextInputEditText", "TextInputLayout", "EditText", "TextView", "ImageButton", "ImageView", "MaterialSwitch", "Switch", "Checkbox", "CheckBox", "RadioButton", "RadioGroup", "FloatingActionButton", "MaterialButton", "Button", "AppBarLayout", "Toolbar", "ProgressBar", "SeekBar", "Spinner", "ViewPager2", "ViewPager", "NestedScrollView", "ScrollView", "CardView", "LinearLayout", "FrameLayout", "RelativeLayout", "ConstraintLayout", "CoordinatorLayout", "ListView", "GridView", "WebView", "TableLayout", "TableRow", "SwipeRefreshLayout", "DrawerLayout", "BottomNavigationView"],
64
+ "alias": { "MaterialSwitch": "Switch", "Checkbox": "CheckBox", "ConstraintLayout": "androidx.constraintlayout.widget.ConstraintLayout", "CoordinatorLayout": "androidx.coordinatorlayout.widget.CoordinatorLayout", "NestedScrollView": "androidx.core.widget.NestedScrollView", "ViewPager2": "androidx.viewpager2.widget.ViewPager2", "ViewPager": "androidx.viewpager.widget.ViewPager", "RecyclerView": "androidx.recyclerview.widget.RecyclerView", "CardView": "androidx.cardview.widget.CardView", "FloatingActionButton": "com.google.android.material.floatingactionbutton.FloatingActionButton", "MaterialButton": "com.google.android.material.button.MaterialButton", "TextInputLayout": "com.google.android.material.textfield.TextInputLayout", "TextInputEditText": "com.google.android.material.textfield.TextInputEditText", "AppBarLayout": "com.google.android.material.appbar.AppBarLayout", "SwipeRefreshLayout": "androidx.swiperefreshlayout.widget.SwipeRefreshLayout", "DrawerLayout": "androidx.drawerlayout.widget.DrawerLayout", "BottomNavigationView": "com.google.android.material.bottomnavigation.BottomNavigationView" }
65
+ },
66
+ "dimenUnits": { "dp": "vp", "dip": "vp", "sp": "fp", "px": "px" },
67
+ "gravity": {
68
+ "_doc": "gravity → 主轴 justifyContent / 交叉轴 alignItems,由代码按容器方向分派",
69
+ "center": { "h": "Center", "v": "Center" },
70
+ "center_horizontal": { "h": "Center" },
71
+ "center_vertical": { "v": "Center" },
72
+ "start": { "h": "Start" }, "left": { "h": "Start" },
73
+ "end": { "h": "End" }, "right": { "h": "End" },
74
+ "top": { "v": "Start" }, "bottom": { "v": "End" }
75
+ }
76
+ }