@harmonyos-arkts/d2h 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +78 -44
  2. package/agents/android-to-hmos-00-orchestrator.md +34 -28
  3. package/agents/d2h.md +266 -95
  4. package/dist/bin/install-opencode.js +137 -43
  5. package/dist/src/install-opencode.js +44 -0
  6. package/dist/src/install-vscode.js +226 -0
  7. package/dist/src/toolchain.js +87 -0
  8. package/docs/environment-check.md +86 -0
  9. package/opencode/agents.json +1 -1
  10. package/opencode/vscode-d2h-package.json +7 -0
  11. package/opencode/vscode-d2h-plugin.mjs +29 -0
  12. package/package.json +6 -4
  13. package/schemas/task-envelope.schema.json +58 -4
  14. package/schemas/task-plan.schema.json +281 -63
  15. package/scripts/check-environment.mjs +321 -0
  16. package/skills/d2h-hypium-forge/SKILL.md +252 -0
  17. package/skills/d2h-hypium-forge/references/functional-spec-template.md +140 -0
  18. package/skills/d2h-hypium-forge/references/official-api.md +135 -0
  19. package/skills/d2h-hypium-forge/references/pitfalls.md +98 -0
  20. package/skills/d2h-hypium-forge/references/testability-template.md +71 -0
  21. package/skills/d2h-hypium-forge/scripts/_common_template.py +614 -0
  22. package/skills/d2h-hypium-forge/scripts/analyze_results.py +156 -0
  23. package/skills/d2h-hypium-forge/scripts/assert_audit.py +107 -0
  24. package/skills/d2h-hypium-forge/scripts/collect_runtime_pages.py +169 -0
  25. package/skills/d2h-hypium-forge/scripts/forge_report.py +325 -0
  26. package/skills/d2h-hypium-forge/scripts/inject_resource_ids.py +114 -0
  27. package/skills/d2h-hypium-forge/scripts/lint_asserts.py +80 -0
  28. package/skills/d2h-hypium-forge/scripts/lint_locate.py +60 -0
  29. package/skills/d2h-hypium-forge/scripts/merge_appgraph.py +249 -0
  30. package/skills/d2h-hypium-forge/scripts/precondition.py +140 -0
  31. package/skills/d2h-hypium-forge/scripts/preinject_ids.py +232 -0
  32. package/skills/d2h-hypium-forge/scripts/run_direct.py +392 -0
  33. package/skills/d2h-hypium-forge/scripts/run_parallel.py +121 -0
  34. package/skills/d2h-hypium-forge/scripts/scan_features.py +255 -0
  35. package/skills/d2h-hypium-forge/scripts/upgrade_asserts.py +66 -0
  36. package/skills/d2h-hypium-forge/scripts/validate_scenarios.py +84 -0
  37. package/skills/d2h-utg-ai-generate/SKILL.md +128 -0
  38. package/skills/d2h-utg-ai-generate/references/click-paths-schema.json +76 -0
  39. package/skills/d2h-utg-ai-generate/references/extraction-guide.md +34 -0
  40. package/skills/d2h-utg-ai-generate/references/facts-contract.md +96 -0
  41. package/skills/d2h-utg-ai-generate/references/page-schema.json +61 -0
  42. package/skills/d2h-utg-ai-generate/references/utg-schema.json +89 -0
  43. package/skills/d2h-utg-ai-generate/scripts/build_page_json.py +314 -0
  44. package/skills/d2h-utg-ai-generate/scripts/build_utg.py +123 -0
  45. package/skills/d2h-utg-ai-generate/scripts/capture_from_page_json.ts +280 -0
  46. package/skills/d2h-utg-ai-generate/scripts/utg_common.py +524 -0
  47. package/skills/d2h-utg-ai-generate/scripts/utg_html.py +489 -0
  48. package/skills/d2h-utg-ai-generate/scripts/validate_utg.py +69 -0
  49. package/skills/d2h-utg-ai-generate/scripts/vendor/D3-LICENSE +13 -0
  50. package/skills/d2h-utg-ai-generate/scripts/vendor/d3-7.9.0.min.js +2 -0
  51. package/skills/d2h-utg-ai-generate/tests/test_pipeline.py +369 -0
  52. package/skills/d2h-utg-static-generate/SKILL.md +73 -0
  53. package/skills/d2h-utg-static-generate/references/discoveries.md +38 -0
  54. package/skills/d2h-utg-static-generate/scripts/generate.py +499 -0
  55. package/skills/d2h-utg-static-generate/tests/test_static.py +335 -0
  56. package/skills/d2h-utg-static-generate/tests/verify-offline-html.py +60 -0
  57. package/skills/d2h-utg-static-generate/tests/verify-produced-fixture.py +131 -0
  58. package/skills/harmonyos-migration-playbook/references/ui-automation.md +4 -3
  59. package/skills/harmonyos-migration-playbook/references/windows-environment.md +1 -1
  60. package/skills/managing-d2h-toolchain/SKILL.md +23 -0
  61. package/skills/managing-d2h-toolchain/environment-requirements.json +16 -0
  62. package/skills/managing-d2h-toolchain/toolchain.json +16 -0
  63. package/skills/preparing-migration-workspace/SKILL.md +9 -7
  64. package/agents/d2h_v2.md +0 -351
  65. package/skills/preparing-migration-workspace/toolchain.json +0 -19
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { parseArgs } from 'node:util';
8
+
9
+ const DEFAULT_REQUIREMENTS = fileURLToPath(new URL('../skills/managing-d2h-toolchain/environment-requirements.json', import.meta.url));
10
+ const D2H_VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
11
+ const VERSION_TOOLS = ['node', 'python', 'git', 'npm', 'java', 'python-json5', 'appgraph', 'droid2hmos', 'hvigor', 'ohpm', 'devecocli'];
12
+
13
+ function versionParts(value) {
14
+ if (typeof value !== 'string' || !/^\d+(?:\.\d+){0,3}$/.test(value)) throw new Error(`无效版本:${value}`);
15
+ const parts = value.split('.').map(Number);
16
+ if (parts.some(part => !Number.isSafeInteger(part))) throw new Error(`无效版本:${value}`);
17
+ return [...parts, ...Array(4 - parts.length).fill(0)];
18
+ }
19
+
20
+ function compareVersions(left, right) {
21
+ const a = versionParts(left);
22
+ const b = versionParts(right);
23
+ for (let index = 0; index < a.length; index++) {
24
+ if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1;
25
+ }
26
+ return 0;
27
+ }
28
+
29
+ export function loadRequirements(file = DEFAULT_REQUIREMENTS) {
30
+ const config = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
31
+ if (config?.schemaVersion !== 1 || !config.tools || typeof config.tools !== 'object' || Array.isArray(config.tools)) {
32
+ throw new Error('版本配置必须包含 schemaVersion: 1 和 tools 对象');
33
+ }
34
+ for (const key of Object.keys(config)) {
35
+ if (!['schemaVersion', 'tools'].includes(key)) throw new Error(`未知配置字段:${key}`);
36
+ }
37
+ for (const key of Object.keys(config.tools)) {
38
+ if (!VERSION_TOOLS.includes(key)) throw new Error(`未知工具:${key}`);
39
+ }
40
+ for (const key of VERSION_TOOLS) {
41
+ if (!Object.hasOwn(config.tools, key)) throw new Error(`版本配置缺少工具:${key};不约束版本请设为 null`);
42
+ const rule = config.tools[key];
43
+ if (rule === null) continue;
44
+ if (typeof rule !== 'object' || Array.isArray(rule) || !Object.keys(rule).length) throw new Error(`无效工具约束:${key}`);
45
+ for (const bound of Object.keys(rule)) {
46
+ if (!['minimum', 'maximumExclusive'].includes(bound)) throw new Error(`未知版本边界:${key}.${bound}`);
47
+ versionParts(rule[bound]);
48
+ }
49
+ if (rule.minimum && rule.maximumExclusive && compareVersions(rule.minimum, rule.maximumExclusive) >= 0) {
50
+ throw new Error(`版本区间为空:${key}`);
51
+ }
52
+ }
53
+ return config;
54
+ }
55
+
56
+ function constraintLabel(rule) {
57
+ return rule ? [rule.minimum && `>=${rule.minimum}`, rule.maximumExclusive && `<${rule.maximumExclusive}`].filter(Boolean).join(' ') : '仅检查可用性';
58
+ }
59
+
60
+ export function meetsVersionRule(output, rule, id = '') {
61
+ if (!rule) return true;
62
+ const match = output.match(/\d+\.\d+(?:\.\d+){0,2}/);
63
+ if (!match) return false;
64
+ let version = match[0];
65
+ if (id === 'java' && version.startsWith('1.')) version = version.slice(2);
66
+ return (!rule.minimum || compareVersions(version, rule.minimum) >= 0) &&
67
+ (!rule.maximumExclusive || compareVersions(version, rule.maximumExclusive) < 0);
68
+ }
69
+
70
+ export function resolveExecutable(command, env = process.env, platform = process.platform) {
71
+ // npm ships Unix shims beside .cmd files; Windows cannot spawn the shims.
72
+ const extensions = platform === 'win32' && !path.extname(command)
73
+ ? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';') : [''];
74
+ const directories = /[/\\]/.test(command) ? [''] : (env.PATH || '').split(platform === 'win32' ? ';' : ':');
75
+ for (const directory of directories) {
76
+ for (const extension of extensions) {
77
+ const candidate = directory ? path.join(directory, command + extension) : command + extension;
78
+ try {
79
+ if (!fs.statSync(candidate).isFile()) continue;
80
+ if (platform !== 'win32') fs.accessSync(candidate, fs.constants.X_OK);
81
+ return path.resolve(candidate);
82
+ } catch { /* Try the next candidate. */ }
83
+ }
84
+ }
85
+ return command;
86
+ }
87
+
88
+ export function invocation(executable, args, platform = process.platform, env = process.env) {
89
+ const resolved = resolveExecutable(executable, env, platform);
90
+ if (/\.m?js$/i.test(resolved)) return [process.execPath, resolved, ...args];
91
+ if (platform === 'win32' && /\.(cmd|bat)$/i.test(resolved)) {
92
+ const quote = (value) => `'${String(value).replaceAll("'", "''")}'`;
93
+ const source = `& ${[resolved, ...args].map(quote).join(' ')}; exit $LASTEXITCODE`;
94
+ return ['powershell.exe', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(source, 'utf16le').toString('base64')];
95
+ }
96
+ return [resolved, ...args];
97
+ }
98
+
99
+ export async function probe(executable, args, timeout = 15, options = {}) {
100
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'd2h-env-'));
101
+ const log = fs.openSync(path.join(directory, 'output.log'), 'w+');
102
+ const errorLog = fs.openSync(path.join(directory, 'error.log'), 'w+');
103
+ let exitCode = null;
104
+ let error = null;
105
+ try {
106
+ const [command, ...commandArgs] = invocation(executable, args);
107
+ await new Promise((resolve) => {
108
+ // Files and the exit event avoid waiting on inherited output handles.
109
+ const child = spawn(command, commandArgs, {
110
+ cwd: options.cwd || directory, windowsHide: true, detached: process.platform !== 'win32',
111
+ env: { ...process.env, DEVECO_CLI_SKIP_VERSION_CHECK: '1' }, stdio: ['ignore', log, errorLog],
112
+ });
113
+ const timer = setTimeout(() => {
114
+ error = 'timeout';
115
+ if (child.pid) {
116
+ if (process.platform === 'win32') {
117
+ spawnSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore', timeout: 5000 });
118
+ } else {
119
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { /* Already exited. */ }
120
+ }
121
+ }
122
+ clearTimeout(timer);
123
+ resolve();
124
+ }, timeout * 1000);
125
+ child.once('error', (err) => { error ??= err.message; clearTimeout(timer); resolve(); });
126
+ child.once('exit', (code) => { exitCode = code; clearTimeout(timer); resolve(); });
127
+ });
128
+ const read = (descriptor) => {
129
+ const buffer = Buffer.alloc(65536);
130
+ const length = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
131
+ return buffer.subarray(0, length).toString('utf8').trim();
132
+ };
133
+ const stdout = read(log);
134
+ const stderr = read(errorLog);
135
+ return { command: [executable, ...args], exitCode, ok: exitCode === 0 && !error, error, stdout, stderr, output: [stdout, stderr].filter(Boolean).join('\n') };
136
+ } catch (err) {
137
+ return { command: [executable, ...args], exitCode, ok: false, error: err.message, output: '' };
138
+ } finally {
139
+ fs.closeSync(log);
140
+ fs.closeSync(errorLog);
141
+ try { fs.rmSync(directory, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); }
142
+ catch { /* Windows scanners may retain a handle briefly; do not hide the probe result. */ }
143
+ }
144
+ }
145
+
146
+ function isDirectory(value) {
147
+ try { return Boolean(value && fs.statSync(value).isDirectory()); } catch { return false; }
148
+ }
149
+
150
+ function isFile(value) {
151
+ try { return Boolean(value && fs.statSync(value).isFile()); } catch { return false; }
152
+ }
153
+
154
+ export function firstDirectory(values) {
155
+ const found = values.find(isDirectory);
156
+ return found ? path.resolve(found) : null;
157
+ }
158
+
159
+ export function pythonJson5Required(source) {
160
+ function visit(directory) {
161
+ if (!isDirectory(directory)) return false;
162
+ return fs.readdirSync(directory, { withFileTypes: true }).some((entry) => {
163
+ if (['__pycache__', 'node_modules', '.git', 'tests'].includes(entry.name)) return false;
164
+ const file = path.join(directory, entry.name);
165
+ return entry.isDirectory() ? visit(file) : entry.name.endsWith('.py') &&
166
+ /^\s*(?:import\s+json5\b|from\s+json5\b)/m.test(fs.readFileSync(file, 'utf8'));
167
+ });
168
+ }
169
+ return visit(path.join(source, 'skills'));
170
+ }
171
+
172
+ export function parseDevEcoJson(output) {
173
+ // DevEco CLI 1.3.3 appends its updater notice to stdout even with --format json.
174
+ // Strip only that known trailing line; unrelated malformed output still fails.
175
+ return JSON.parse(output.replace(/\r?\n\s*New version [\w.+-]+ available, run `devecocli update` to upgrade\s*$/, '').trim());
176
+ }
177
+
178
+ export async function collect(options, { runner = probe, env = process.env, platform = process.platform, home = os.homedir(), directoryFinder = firstDirectory, nodeVersion = process.version } = {}) {
179
+ const requirementsFile = path.resolve(options.requirementsFile || DEFAULT_REQUIREMENTS);
180
+ const requirements = loadRequirements(requirementsFile);
181
+ const ruleFor = id => requirements.tools[id];
182
+ const checks = [];
183
+ const add = (id, required, passed, summary, remediation = '', evidence = [], skipped = false) => {
184
+ checks.push({ id, required, status: skipped ? 'skipped' : passed ? 'passed' : required ? 'failed' : 'warning', summary,
185
+ remediation: passed ? '' : remediation, evidence });
186
+ };
187
+ const tool = async (id, executable, params, remediation, predicate = () => true) => {
188
+ const result = await runner(executable, params, options.timeout);
189
+ const rule = ruleFor(id);
190
+ const passed = result.ok && predicate(result.output) && meetsVersionRule(result.output, rule, id);
191
+ const summary = result.output.split(/\r?\n/)[0] || result.error || '命令未成功';
192
+ add(id, true, passed, `${summary}${rule ? `(要求 ${constraintLabel(rule)})` : ''}`,
193
+ `${remediation}${rule ? ` 版本须满足 ${constraintLabel(rule)}。` : ''}`, [result]);
194
+ return result;
195
+ };
196
+ add('node', true, meetsVersionRule(nodeVersion, ruleFor('node')), `${nodeVersion} (${process.execPath})(要求 ${constraintLabel(ruleFor('node'))})`,
197
+ `请安装满足 ${constraintLabel(ruleFor('node'))} 的 Node.js,然后重新运行脚本。`);
198
+ await tool('git', 'git', ['--version'], '安装 Git 并将 git 加入 PATH。');
199
+ await tool('npm', 'npm', ['--version'], '使用 Node.js 自带的 npm,检查 PATH。');
200
+ await tool('java', 'java', ['-version'], '安装项目 Gradle/DevEco 所需 JDK,设置 JAVA_HOME 和 PATH;本检查不限定 JDK 主版本。');
201
+ // Python is a migration dependency, no longer a prerequisite to run this doctor.
202
+ const candidates = [...new Set([env.PYTHON_BINARY, platform === 'win32' ? 'python' : 'python3', 'python',
203
+ platform === 'darwin' ? '/opt/homebrew/bin/python3' : null, platform === 'darwin' ? '/usr/local/bin/python3' : null,
204
+ platform === 'darwin' ? '/opt/homebrew/anaconda3/bin/python3' : null].filter(Boolean))];
205
+ const pythonProbes = [];
206
+ let python = null;
207
+ for (const candidate of candidates) {
208
+ const result = await runner(candidate, ['--version'], options.timeout);
209
+ pythonProbes.push(result);
210
+ if (result.ok && meetsVersionRule(result.output, ruleFor('python'))) { python = candidate; break; }
211
+ }
212
+ add('python', true, Boolean(python), `${python ? `${pythonProbes.at(-1).output} (${python})` : '未找到符合要求的 Python'}(要求 ${constraintLabel(ruleFor('python'))})`,
213
+ `安装满足 ${constraintLabel(ruleFor('python'))} 的 Python 并加入 PATH,或设置 PYTHON_BINARY 为解释器路径。`, pythonProbes);
214
+ if (pythonJson5Required(path.resolve(options.pluginRoot))) {
215
+ if (python) {
216
+ await tool('python-json5', python, ['-c', 'import json5; from importlib.metadata import version; print("json5 " + version("json5"))'],
217
+ `用检测到的 Python 安装:${platform === 'win32' ? '& ' : ''}'${python.replaceAll("'", platform === 'win32' ? "''" : "'\\''")}' -m pip install json5`);
218
+ } else {
219
+ add('python-json5', true, false, 'Python 不可用,尚未检查 json5', '安装 Python 后重新检查;使用同一解释器执行 -m pip install json5。', [], true);
220
+ }
221
+ }
222
+ for (const name of ['appgraph', 'droid2hmos']) {
223
+ await tool(name, name, ['--version'], `运行 npx --yes @harmonyos-arkts/d2h@${D2H_VERSION} install 安装兼容工具组合;本地 npm link 需由开发者显式管理。`);
224
+ }
225
+ const deveco = directoryFinder([env.DEVECO_STUDIO_HOME, env.DEVECO_HOME,
226
+ platform === 'darwin' ? '/Applications/DevEco-Studio.app/Contents' : null,
227
+ ...(platform === 'win32' ? ['C:/Program Files/Huawei/DevEco Studio', 'D:/Program Files/Huawei/DevEco Studio'] : [])]);
228
+ add('deveco', true, Boolean(deveco), deveco || '未找到 DevEco Studio', '安装 DevEco Studio,设置 DEVECO_STUDIO_HOME 为安装根目录(macOS 为 Contents)。');
229
+ for (const [id, variable, relative, fallback] of [
230
+ ['hvigor', 'DROID2HMOS_HVIGOR', 'tools/hvigor/bin/hvigorw.js', 'hvigorw'],
231
+ ['ohpm', 'DROID2HMOS_OHPM', 'tools/ohpm/bin/pm-cli.js', 'ohpm'],
232
+ ]) {
233
+ const bundled = deveco && path.join(deveco, relative);
234
+ await tool(id, env[variable] || (isFile(bundled) ? bundled : fallback), ['--version'], `安装 DevEco 配套工具;设置 ${variable} 为可执行文件或 JS 入口路径。`);
235
+ }
236
+ const sdk = directoryFinder([options.sdkPath, env.HARMONYOS_SDK_HOME, env.OHOS_SDK_HOME, env.DEVECO_SDK_HOME, deveco && path.join(deveco, 'sdk')]);
237
+ add('harmony-sdk-location', false, Boolean(sdk), sdk || '未发现声明 SDK 根目录', '设置 HARMONYOS_SDK_HOME 或 --sdk-path;实际声明有效性由 droid2hmos preflight 检查。');
238
+ const cli = env.DEVECO_CLI_BINARY || 'devecocli';
239
+ await tool('devecocli', cli, ['--version'], '安装 npm install -g @deveco/deveco-cli。');
240
+ const auth = await runner(cli, ['auth', 'status'], options.timeout);
241
+ const loggedIn = auth.ok && /Current user:\s*[^\r\n]+/i.test(auth.output) && !/Not logged in/i.test(auth.output);
242
+ add('devecocli-auth', true, loggedIn, loggedIn ? '已登录' : '未登录或无法确认登录状态',
243
+ '运行 droid2hmos auth login <android-project>,在浏览器完成授权;不要并发登录。',
244
+ [{ command: auth.command, exitCode: auth.exitCode, error: auth.error }]);
245
+ if (options.harmonyProject) {
246
+ const project = path.resolve(options.harmonyProject);
247
+ const versions = await runner(cli, ['check', 'compat', 'versions', '--format', 'json'], options.timeout, { cwd: project });
248
+ let available = [];
249
+ try { const value = parseDevEcoJson(versions.stdout ?? versions.output); available = Array.isArray(value.versions) ? value.versions.filter((v) => typeof v === 'string') : []; } catch { /* Keep diagnostic. */ }
250
+ add('harmony-sdk-compatibility', false, versions.ok && available.length > 0,
251
+ available.length ? `DevEco CLI 可识别 SDK:${available.join(', ')}` : '无法确认工程可用 SDK 版本',
252
+ '在 DevEco 中安装工程所需 SDK;构建与 API 兼容性仍由项目 preflight 验证。', [versions]);
253
+ }
254
+ if (options.androidProject) {
255
+ const root = path.resolve(options.androidProject);
256
+ add('android-source-root', true, ['settings.gradle', 'settings.gradle.kts', 'build.gradle', 'build.gradle.kts'].some((f) => isFile(path.join(root, f))),
257
+ root, '用 --android-project 指向包含 Gradle 配置标记的 Android 源码根目录;不要求 Wrapper 或 Android 编译环境。');
258
+ }
259
+ const result = checks.some((c) => c.required && c.status === 'failed') ? 'failed'
260
+ : checks.some((c) => c.required && c.status === 'skipped') ? 'incomplete' : 'passed';
261
+ return { schemaVersion: '1.0.0', createdAt: new Date().toISOString(), platform: `${platform} ${os.arch()} ${os.release()}`, result, checks,
262
+ requirementsFile, versionRequirements: requirements.tools,
263
+ scope: '主机依赖检查;不证明构建、签名、SDK API 匹配或端到端验收成功。' };
264
+ }
265
+
266
+ export function markdown(report) {
267
+ const escape = (value) => String(value).replaceAll('|', '\\|').replaceAll('\r', '').replaceAll('\n', '<br>');
268
+ return ['# Android → HarmonyOS 环境检查', '', `结果:**${report.result}**`, '', report.scope, '', `版本配置:${report.requirementsFile || ''}`, '',
269
+ '| 检查 | 必需 | 状态 | 说明 |', '| --- | --- | --- | --- |',
270
+ ...report.checks.map((c) => `| ${c.id} | ${c.required ? '是' : '否'} | ${c.status} | ${escape(c.summary)} |`),
271
+ '', '## 修复指导', '', ...report.checks.filter((c) => c.remediation).map((c) => `- **${c.id}**:${c.remediation}`), '',
272
+ '修复后重新运行本脚本。主机检查通过后,运行 `droid2hmos preflight <android-project>` 验证真实项目门禁;该命令可能初始化工程并执行构建。', ''].join('\n');
273
+ }
274
+
275
+ export function parseOptions(argv) {
276
+ const { values } = parseArgs({ args: argv, options: {
277
+ 'plugin-root': { type: 'string' },
278
+ requirements: { type: 'string' },
279
+ 'android-project': { type: 'string' }, 'harmony-project': { type: 'string' }, 'sdk-path': { type: 'string' },
280
+ timeout: { type: 'string', default: '15' }, 'output-dir': { type: 'string' }, help: { type: 'boolean', short: 'h' },
281
+ } });
282
+ if (!/^\d+$/.test(values.timeout) || Number(values.timeout) < 1 || Number(values.timeout) > 60) throw new Error('--timeout 必须为 1–60 的整数');
283
+ return { pluginRoot: values['plugin-root'] || fileURLToPath(new URL('../', import.meta.url)),
284
+ requirementsFile: values.requirements ? path.resolve(values.requirements) : DEFAULT_REQUIREMENTS,
285
+ androidProject: values['android-project'], sdkPath: values['sdk-path'],
286
+ harmonyProject: values['harmony-project'],
287
+ timeout: Number(values.timeout),
288
+ outputDir: path.resolve(values['output-dir'] || 'environment-report'), help: values.help };
289
+ }
290
+
291
+ export async function main(argv = process.argv.slice(2)) {
292
+ let options;
293
+ try { options = parseOptions(argv); } catch (error) { console.error(error.message); return 2; }
294
+ if (options.help) {
295
+ console.log('用法:node scripts/check-environment.mjs [--plugin-root <path>] [--android-project <path>]\n [--harmony-project <path>] [--sdk-path <path>] [--requirements <json>]\n [--timeout <1–60 秒>] [--output-dir <path>]\n默认版本约束来自 skills/managing-d2h-toolchain/environment-requirements.json。只写报告,不自动安装依赖或修改配置。');
296
+ return 0;
297
+ }
298
+ const report = await collect(options);
299
+ fs.mkdirSync(options.outputDir, { recursive: true });
300
+ fs.writeFileSync(path.join(options.outputDir, 'environment.json'), JSON.stringify(report, null, 2) + '\n');
301
+ fs.writeFileSync(path.join(options.outputDir, 'environment.md'), markdown(report));
302
+ const passed = report.checks.filter((check) => check.status === 'passed').length;
303
+ const failures = report.checks.filter((check) => check.status === 'failed');
304
+ const warnings = report.checks.filter((check) => check.status === 'warning').length;
305
+ const skipped = report.checks.filter((check) => check.status === 'skipped').length;
306
+ const resultLabel = { passed: '通过', failed: '未通过', incomplete: '未完成' }[report.result];
307
+ console.log(`环境检查:${resultLabel}(通过 ${passed},失败 ${failures.length}${warnings ? `,提醒 ${warnings}` : ''}${skipped ? `,未检查 ${skipped}` : ''})`);
308
+ const statusLabels = { passed: '通过', failed: '未通过', warning: '提醒', skipped: '未检查' };
309
+ for (const check of report.checks) {
310
+ console.log(`[${statusLabels[check.status] || check.status}] ${check.id}:${check.summary}`);
311
+ if (check.status !== 'passed' && check.remediation) {
312
+ console.log(` 修复:${check.remediation}`);
313
+ }
314
+ }
315
+ console.log(`详细报告:${path.join(options.outputDir, 'environment.md')}`);
316
+ return report.result === 'failed' ? 1 : report.result === 'incomplete' ? 2 : 0;
317
+ }
318
+
319
+ if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
320
+ try { process.exitCode = await main(); } catch (error) { console.error(`环境检查失败:${error.message}`); process.exitCode = 1; }
321
+ }
@@ -0,0 +1,252 @@
1
+ ---
2
+ name: d2h-hypium-forge
3
+ description: 鸿蒙应用/元服务测试用例设计与 hypium 执行(两阶段流水线第二段,部署于 .opencode 迁移 agent)。宿主 agent 代码理解产出功能场景库(功能/说明/是否已转化)→ 本 skill 按场景测试目的选断言范式、LLM 白盒精写 Python 脚本 → 裸跑执行与失败四簇分析。适用于任何 ArkTS 工程的功能测试自动化。触发词:hypium-forge、功能用例设计、测试用例生成执行、覆盖度评估、鸿蒙 UI 测试全流程。
4
+ ---
5
+
6
+ # d2h-hypium-forge — LLM 白盒精写 → 裸跑执行
7
+
8
+ ## 体系边界(先分清)
9
+
10
+ 本 skill **只服务 Python 版 Hypium**(devicetest 框架,`testcases/*.py`,UiDriver 经 uitest RPC 驱动设备)。
11
+ **ArkTS @ohos/hypium 是另一条链路,不在本 skill 范围**——模拟器上应用内 uitest napi 是 stub
12
+ (`Driver.create()` 恒 null,ENV 实证),ArkTS 用例仅适用于真机且需独立工程配置;文档中两套
13
+ API 同名不同构(BY 定位/断言语义有差异),混用必然执行失败。涉及 ArkTS 用例需求请走
14
+ 单独链路(ohosTest 模板),禁止在本 skill 产物目录混建。
15
+
16
+ ## 三条铁律
17
+
18
+ 1. **必须先预置控件 ID(写脚本之前)**:`preinject_ids.py` 对全部交互点机械注入,
19
+ 脚本只认 ids.json/既有 ID(BY.key)。文本定位(BY.text)慢 10~100 倍且有歧义。
20
+ 2. **Level 0 前置自适应(冷启三件套,launch 原语内置,用例不得自带)**:
21
+ 首启弹窗 dismiss(多文案轮询幂等)+ 首页 tab 归位(HOME_TAB 应用级配置,
22
+ 冷启默认 tab 常与目标 tab 不一致)+ 语义锚等待(禁依赖注入 id 等 Loading 层控件)。
23
+ 隐私弹窗常为内存态(冷启必弹)——每条冷启都必须 dismiss(TD-007)。
24
+ 3. **权限和账号前置完成**:precondition.py 预授可授权限/登录策略/首启弹窗 dismiss,
25
+ 绝不留给用例执行时撞。**登录态跨用例保持**:软隔离(stop_app/start_app)不清应用数据;
26
+ `reset_data=True`(bm clean)只用于首启态用例且**须排尾/独立分组执行**——与登录用例
27
+ 交错会反复清登录态导致重复登录注册(CloudReader TD-004 实证:交错执行重复注册 30 次)。
28
+
29
+ ## 五步流水线
30
+
31
+ 设 `$F = 本skill/scripts`,工程 `$P`,产物目录 `$P/.hypium_forge/`。
32
+
33
+ ### 1. 功能场景库(测试设计的基础输入)
34
+
35
+ 本 skill 是两阶段流水线的第二段:代码理解由宿主 agent(.opencode 迁移 agent)完成,
36
+ 产出功能场景清单后调用本 skill 生成并执行测试。**场景库是测试设计的唯一基础**——
37
+ 它的完备性与准确度决定下游全部产出的质量,宁可在此多花时间。
38
+
39
+ **阶段一(宿主 agent)**——按 `references/functional-spec-template.md` 产出功能规格
40
+ `functional_spec.md`,再翻译为 `scenarios.json`:
41
+ ```json
42
+ {"id":"WF-01","name":"搜索选中城市并收藏","domain":"位置管理",
43
+ "desc":"用户在搜索页输入关键字,点选结果后返回管理页,列表新增该城市并提示收藏成功",
44
+ "converted":true, "source_ref":"SearchActivity.java:181-195",
45
+ "test_intent":"功能交互类"}
46
+ ```
47
+ 生成规则:
48
+ - 场景单位 = **用户动宾行为**(禁 Activity/Fragment/surface 类名当场景);
49
+ - `desc` 写清「输入→动作→**可观察业务效果**」,效果描述到可翻译成断言的程度;
50
+ - 行为预期从源码推导(默认值/联动/守卫),不从直觉——直觉必错;
51
+ - `converted` 与迁移报告对齐,未转化标 false 并跳过。
52
+
53
+ **阶段二(本 skill)**——先过**场景库质量门禁**再消费:
54
+ ```bash
55
+ python3 $F/validate_scenarios.py scenarios.json # 不通过项入 scenarios_blocker.md
56
+ ```
57
+ 校验:①必备字段(id/name/desc/converted/test_intent)②desc 含可观察业务效果关键词
58
+ (新增/删除/修改/保存/收藏/取消/成功/失败/提示/计数/状态/切换——纯"进入XX页"类导航词
59
+ 无效果=拦截)③入口可定位(深层链路 desc 须写前置路径)④行为预期有源码/PRD 依据标注
60
+ ⑤converted=false 必须带 reason(未迁移/能力缺失/暂不测试)。**blocker 场景不进用例生成**,
61
+ 退回宿主 agent 修复。通过后:converted=true 逐场景设计用例
62
+ (按 test_intent 选断言范式)→ 白盒精写 → 执行 → 分诊 → defects/报告。
63
+ 用例与场景一一对应,无场景不写用例。
64
+
65
+ ### 1.5 机械扫描(覆盖性评估的补充)
66
+
67
+ ```bash
68
+ python3 $F/scan_features.py $P --out $P/.hypium_forge/features.json
69
+ ```
70
+ 产出:页面(路由+@Entry)× 交互点 × 状态矩阵 + 权限/登录检测。有安卓迁移现场时
71
+ `merge_appgraph.py --features ... --facts <android>/.migration/spec/facts` 注入权威行为语义
72
+ (handler 链揭示联动场景)。
73
+
74
+ **定位是补充而非基础**:features 条目是定位事实不是业务功能,机械扫描可靠性不足,
75
+ 不得直接作为测试设计输入。两个用途:
76
+ 1. **覆盖校验**:对照 scenarios.json,找出未被任何场景覆盖的页面/交互点 →
77
+ 反馈宿主 agent 裁决「补场景」或「登记不测并说明理由」——遗漏即测试盲区;
78
+ 2. **ID 预置清单**:交互点列表供步骤 3 preinject_ids.py 机械注入。
79
+
80
+ 覆盖结论写入 `coverage.md`,**双口径分列(防机械冒充功能覆盖)**:
81
+ - **场景覆盖度(主口径)** = 被采纳用例触达的场景数 / scenarios.json 中 converted=true 场景数;
82
+ - **控件触达率(辅助口径)** = 已触达交互点 / 全部交互点——仅暴露机械盲区,**不得单独
83
+ 作为功能覆盖率汇报**(把扫到的控件数当功能覆盖是自欺);
84
+ coverage.md 逐条列出并按规则处置:
85
+ - 未覆盖场景(如 WF-12, WF-19)→ 必须反馈宿主 agent 补场景或登记不测;
86
+ - 未触达交互点(如 HomePage.ad_banner)→ 不一定补用例,人工裁决是否为
87
+ 广告位/运营位/未迁移能力(判定理由随条目记录)。
88
+ 未覆盖交互点及其裁决结果。
89
+
90
+ ### 2. 运行时文本采集
91
+ ```bash
92
+ python3 $F/collect_runtime_pages.py --device <sn> --bundle <b> --out runtime_pages.json
93
+ ```
94
+ 脚本引用的控件文本**只允许**来自采集结果;断言锚定业务数据,禁止导航回声断言。
95
+ **并行提速**:首页文本采集完成后即可开始写该页用例(其余页面采集继续)——采集与编写
96
+ 流水线并行,禁串行等全量采集完再动笔。
97
+ 启动参数门控应用:先 `aa start --ps k v` 预热,singleton 的 onNewWant 不重置状态可继续采集。
98
+
99
+ ### 3. ID 预置(铁律①,先于脚本编写)——机械注入的边界与补强
100
+ ```bash
101
+ python3 $F/preinject_ids.py --features features.json --out ids.json # 交互点清单来自 §1.5
102
+ # 之后 rebuild + uninstall&&install
103
+ ```
104
+ 批量注入 `tc_<page>_<n>`,产出 ids.json 映射表。脚本定位只认 ids.json/既有 ID(BY.key);
105
+ 仅输入框用 BY.hint。
106
+
107
+ **静态注入三盲区**(preinject 只扫静态代码,以下场景必然漏注):
108
+ 1. 动态列表控件(ForEach 运行时生成,静态无挂载点)→ 用 `collect_runtime_pages.py`
109
+ 的 dumpLayout 控件树交叉核对:dump 中 key 为空但语义可定位的条目,回填至
110
+ runtime_pages.json 供 BY.text 兜底并登记例外;
111
+ 2. 条件渲染分支(if/else 二选一支路)→ 场景变体执行前先 dump 该分支实况;
112
+ 3. 异步加载页(网络数据到位才渲染)→ 依赖步骤 2 采集的等待锚,禁用盲等。
113
+
114
+ **无 ID 例外审批制**:例外必须三件套——docstring 理由 + runtime_pages.json 中的实测
115
+ 证据(该控件确实无 id 且文案唯一)+ lint_locate 通过;例外清单汇总进 coverage.md
116
+ 「无 ID 例外」章节备查,审计时逐条复核(防例外变成 BY.text 后门)。
117
+
118
+ ### 3.5 输入契约与空库守卫(宿主 agent ↔ 本 skill 的稳定边界)
119
+
120
+ **只接受以下输入**:scenarios.json / features.json / ids.json / runtime_pages.json /
121
+ _common_template.py / ArkTS 源码路径。**场景库为空或全部 converted=false 时直接报错
122
+ 退出,不生成空用例集**(空跑=伪覆盖)。生成规则见步骤 4;执行后必须产出:
123
+ testcases/*.py、results.json、coverage.md、summary.json、test-report.html、失败截图与 hilog 片段。
124
+
125
+ ### 4. LLM 白盒精写 testcases/*.py(核心)
126
+
127
+ 从 `$F/_common_template.py` 复制为 `testcases/_common.py`(launch 带 `--ps k v` 空格分隔、
128
+ 3 轮重试探活、首启弹窗 dismiss、强断言原语全套——**写前必读**),逐条编写 `TCxx_<slug>.py`。
129
+
130
+ **编写规则:**
131
+
132
+ - **先读源码**:默认值/联动规则/时序以 ArkTS 源码为准——臆断默认值必返工;
133
+ - **按场景测试目的选断言范式**(问"本场景要证明什么"再选型):
134
+
135
+ | 场景类型 | 测试目的 | 正确断言范式 |
136
+ |---|---|---|
137
+ | 资源渲染类 | 图标/文案加载成功 | exist/equals 即目的本身 |
138
+ | 功能交互类 | 操作产生业务效果 | 效果断言(值变化/计数增减/checked 翻转/absent) |
139
+ | 配置生效类 | 设置改变全局行为 | 生效链断言(改设置→回业务页→断言输出变化) |
140
+ | 导航流转类 | 状态迁移到目的地 | 终点效果(城市选中→顶栏变新城市名) |
141
+ | 状态机类 | 状态转移规律 | 前置态断言→触发→后置态断言(成对) |
142
+ | 数据输入类 | 输入被正确处理 | 输入→输出值断言(搜索词→结果计数) |
143
+
144
+ - **强断言比例按 test_intent 分档**(全局硬门禁 ≥30% 整体维持 40~70%;比例是目的的
145
+ 结果不是目标——**禁止加"页面存在/按钮可见"类弱断言凑比例**,单条低于档位下限报
146
+ "弱断言风险"提示、超上限检查是否断言了实现细节):
147
+
148
+ | test_intent | 强断言区间 | 说明 |
149
+ |---|---|---|
150
+ | 资源渲染 | 20~40% | 证明图标/文案/占位正确,exist/equals 即目的 |
151
+ | 功能交互 | 50~70% | 必须断言业务效果,不能只断言按钮存在 |
152
+ | 配置生效 | 60~80% | 改设置→回业务页→验证输出变化的完整生效链 |
153
+ | 导航流转 | 40~60% | 终点页面 + 关键状态都要验证 |
154
+ | 状态机 | 60~80% | 前置态、触发、后置态成对断言 |
155
+ | 数据输入 | 50~70% | 输入→结果列表/计数/排序必须验证 |
156
+
157
+ 核心判据:**断言必须证明场景声明的目的**——目的是"功能发生"时仅判存在 = 无效用例;
158
+ - **Toast** 用 hilog 关键字轮询或 toast_text_seen(dump 轮询)、**剪贴板**用 hdc pasteboard、
159
+ 对话框流转逐步断言;
160
+ - **视口外控件**必须用 `touch(BY.text("xx"), scroll_target=BY.type("Scroll"))` 原生滚动查找,
161
+ 禁止手工 swipe + find 循环(时序敏感且不稳定);
162
+ - **深链路用例内显式 setup**:上游状态准备写成用例内步骤,禁止假设页面已就位;
163
+ - **失败 = 信息**:产品缺陷保持 FAIL 暴露,绝不弱化断言换绿灯;用例自身错误可修(≤2 轮);
164
+ - **用例数量由功能场景覆盖决定**,禁止固定条数或上限——每个 converted=true 场景至少
165
+ 1 条用例真实触达;**不允许脱离场景新增用例、不允许把页面跳转当最终业务效果**;
166
+ 文本断言只能来自 runtime_pages.json 实采,不能臆造文案;
167
+ - **定位门禁**:`lint_locate.py --dir testcases --ids ids.json`,text 定位 >20% FAIL——
168
+ LLM 常绕过预置 ID 用 text_click,唯机械门禁有效;
169
+ - 每脚本 docstring 写清行为链与覆盖的功能场景 id;
170
+ - 执行后产出 `coverage.md`(含「总覆盖度: N%」行)与 `summary.json`。
171
+
172
+ ### 4.5 前置条件分组编排(共用前置,提升执行效率)
173
+
174
+ 用例文件声明 `PRECOND`(缺省 anonymous),run_direct 按 **anonymous → logged_in →
175
+ first_launch** 分组排序执行(破坏性递增,bm clean 类强制排尾不再回头破坏登录态)。
176
+ 组内软隔离共享前置:`launch()` 默认 `reset_data=False`,preferences 保留 → 组首一次
177
+ 完整 `ensure_login`,组内后续幂等复用。组首执行时 `FORGE_GROUP_FIRST=1`。
178
+ results.json 每条附 `group` 字段可分组统计。**禁把 reset_data=True 用在 logged_in 组**。
179
+
180
+ ### 5. 裸跑执行 + 四簇分析 + HTML 报告
181
+ ```bash
182
+ python3 $F/run_direct.py --all --dir testcases --device <sn> # 自动采集 end/fail 截图
183
+ python3 $F/analyze_results.py --results results.json --runtime-pages runtime_pages.json
184
+ python3 $F/forge_report.py # 产出 test-report.html(Allure 风格:概况/用例卡片/关键截图内嵌)
185
+ ```
186
+ 失败四簇:DEVICE(环境,重跑)/ DESIGN-ERROR(脚本定位/步骤错误,修脚本 ≤2 轮)/
187
+ NAV-CONTEXT-LOSS(页面上下文丢失,hilog 定案)/ ASSERT-FAIL(**优先怀疑产品缺陷,保留 FAIL**)。
188
+
189
+ **自愈边界(允许 vs 禁止,修≠换绿灯)**:
190
+ - 允许:修正错误控件 ID;补缺失等待条件;修正滚动/输入/弹窗关闭方式;调整用例内前置步骤。
191
+ - 禁止:强断言→弱断言;`equals`→`contains` 规避差异;固定等待无限加长;模糊 text 匹配
192
+ 绕过定位失败;把产品缺陷标 SKIP/PASS;**删除失败断言而不是修复原因**。
193
+ - **FAIL 现场证据自动附言**:run_direct 失败时 msg 自动附带当前页面 Text 摘要
194
+ (`| 现场Text: …`)——下轮分诊直读证据,禁重复现场取证。
195
+ - **原始失败记录保留**:修复重跑前 run_direct 自动把 results.json 滚动备份为
196
+ results.prev.json——修复轨迹可审计(改了什么、为什么改),防"驯化测试"。
197
+ - **分诊三律(TD-018,防定案实验失控)**:①**同消息聚类**——多例同错误消息(如
198
+ text not found: X×N)视为同族,一次分析一次定案,禁逐条重复实验;②**同族定案实验
199
+ ≤2 次**(清数据/冷启/对照类),超限即按现有证据登记缺陷转人工——保留 FAIL 是合规
200
+ 产出,不是未完成;③**成功模式对照先行**——定案前先查同场景已 PASS 用例的断言
201
+ 范式(如冷启持久化断言),判据升级优先于现场重取证。
202
+ - **修复轮增量重试**:`run_direct.py --retry-failed` 只重跑 FAIL/SKIP 条(PASS 保留
203
+ 原结果合并写出)——禁无谓全量重跑浪费设备时间(首轮 11 条全量 286s,增量仅重跑 2 条
204
+ ~52s,省 80%)。**编写期自检批量做**:全部脚本写完统一 lint+语法批检,禁逐条单跑
205
+ 调试(单条 LLM 往返间隔是隐形大头)。
206
+ - **增量重试回归门禁(TD-019,防旧 PASS 掩盖回归)**:`run_direct.py` 自动对
207
+ `entry/src/main/ets` 计算内容指纹(srcHash,存 `results.meta.json` 侧车)。增量重试前
208
+ 重算比对:产品代码有变更 / 无上轮指纹 / 定位不到源码目录 → **自动升级全量重跑**
209
+ (实证:Profiles TC09/TC14 PASS→FAIL 靠全量重跑才抓到,走增量通道会被旧 PASS 掩盖);
210
+ 确认仅测试脚本变更可 `--force-incremental` 强制增量。顺带修复:--retry-failed 现写入
211
+ results.json(旧版误写 results-single.json)。
212
+
213
+ ## 度量与沉淀
214
+
215
+ - 记录:用例数、覆盖度、耗时、首轮通过率、调试轮次、强断言占比。
216
+ - 失败模式沉淀 testability-requirements.md(可测试性需求)。
217
+
218
+ ## References(按需加载)
219
+
220
+ | 文件 | 内容 | 何时读 |
221
+ |---|---|---|
222
+ | `references/official-api.md` | Hypium 官方 API 速查(BY 定位/模糊匹配/触摸/滑动/输入/断言 Checker/截图) | 编写脚本前查阅定位与断言 API 用法 |
223
+ | `references/pitfalls.md` | 7 项目实证踩坑清单(swipe 百分比/BY.text 全匹配/press_back 语义/系统子窗口不可见/视口外滚动/DatePicker) | 遇到定位/滑动/输入/Toast 问题时查阅 |
224
+ | `references/functional-spec-template.md` | 功能规格文档模板(9 段结构 + test_intent 选型表 + 边界异常) | 阶段一宿主 agent 产出规格时 |
225
+ | `references/testability-template.md` | 可测试性需求模板 | 沉淀失败模式时 |
226
+
227
+ ## 断言能力速查
228
+
229
+ | 层 | 能力 |
230
+ |---|---|
231
+ | 存在/消失 | BY.key/id/text/hint/type + absent |
232
+ | 属性状态 | BY.checked/selected/enabled/focused/clickable/scrollable |
233
+ | 值断言 | Checker:equal/not_equal/contains/match_regexp/greater/less |
234
+ | 几何关系 | getBounds()/中心点/相对定位 |
235
+ | Toast/系统级 | hilog 轮询 / hdc pasteboard / 外部跳转 absent 级 |
236
+ | 结构断言 | find_all_components 计数 |
237
+ | 边界 | 颜色/像素 diff/性能时序(勿硬造) |
238
+
239
+ ## 定位原语速查
240
+
241
+ | 场景 | 原语 |
242
+ |---|---|
243
+ | 已注入 ID | `BY.key(tid)` |
244
+ | 输入框 | `BY.hint('占位符')`(BY.text 命中 hint 节点静默失败) |
245
+ | 底部 Tab | find_all(BY.text CONTAINS) 取最靠下 bounds 中心 |
246
+ | 视口外 | `touch(BY.text("xx"), scroll_target=BY.type("Scroll"))` 原生滚动查找 |
247
+ | mock 数据 | `hdc rport tcp:8787 tcp:8787` |
248
+
249
+ ## 相关 skill
250
+
251
+ - `hypium-id-test`:无业务语义 ID 冒烟(转化后即跑)
252
+ - `hypium-test-gen`:白盒逐条精写(7 项目踩坑清单)