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,186 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-anchors.mjs — 字面锚点与 PRD 覆盖记账(不做语义判断,只对格式和行号记账)。
4
+ * 实证:0.14.1 基准 13 条失败用例 11 条是 PRD 明写的 id/文本没落地(resumeInput、recordStart、"工作通知"…),且逐轮随机。
5
+ * 语义理解归 2b 的模型(读 PRD/源码产断言卡);本脚本只核两件事:
6
+ * ① 字面量真有:台账 features[].literals(parse-cards 从断言卡机械抽的反引号/「」/“” 与 "id 为 X")在产物源码/资源里必须出现
7
+ * → literal_missing(case)。探针:"前缀:值"复合文本只探前缀(值多为动态数据);对账两边都做 NFKC+小写归一化
8
+ * (全半角标点/大小写写法差异不算缺),颜色 #RRGGBB 只比十六进制位(产物常写 0xFFRRGGBB)。
9
+ * 严宽只看来源,不看措辞:锚点指向 PRD 或断言用 "id 为 X" 句式的字面量是契约,缺失立 case(实证基准失败 11/13 属此类);
10
+ * 锚点指向源码的字面量是描述(调用签名/注解/API 名/源资源名混在其中,实证一轮 111 条 literal_missing 109 条属此),
11
+ * 缺失只记 info `literal_unmatched`,不进闭合立案,报告里可看。
12
+ * ② PRD 逐行覆盖(有 PRD 时):PRD 的每条需求行(列表项)须被至少一条断言的锚点 `PRD.md:行` 指向,
13
+ * 或该行标记的字面量(反引号/「」/“”)全部出现在台账断言的字面量里(模型原样转写了、只是锚点指向别处);
14
+ * 指向的行连同其缩进子项算覆盖,指向标题算覆盖整节;锚点行号越界=PRD 可能已变更
15
+ * → prd_uncovered / prd_anchor_stale(case)。PRD 不是每次都有:--prd 未给时从断言锚点里找 basename 含 prd 的文件,找不到即跳过。
16
+ * 全部为软立案(final 阶段修或带理由豁免),绝不设硬违规——字面匹配与行号记账都有天然误差。
17
+ * 用法:check-anchors.mjs --project <root> [--ledger <ledger.json>] [--prd <PRD 文件>] [--json out]
18
+ * 缺省自动找 <project>/spec/ledger/ledger.json;2b 装账后可直接跑本命令看 PRD 覆盖差集,按差集补卡。
19
+ */
20
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
21
+ import { join, resolve, relative, basename, dirname, isAbsolute } from 'node:path';
22
+ import { probeOf, MARK_RE, isPlaceholder, ID_RE, normText, hexForms } from '../lib/literals.mjs';
23
+
24
+ const argv = process.argv.slice(2);
25
+ if (argv.includes('--help')) { 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); }
26
+ const argOf = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : d; };
27
+ const PROJECT = argOf('--project', null);
28
+ if (!PROJECT || !existsSync(PROJECT)) { console.error('usage: check-anchors.mjs --project <root> [--ledger <ledger.json>] [--prd <file>] [--json out]'); process.exit(3); }
29
+ const LEDGER = argOf('--ledger', join(PROJECT, 'spec', 'ledger', 'ledger.json'));
30
+ const OUT = argOf('--json', null);
31
+ const rel = (p) => relative(process.cwd(), p) || '.'; // 落盘产物不写机器绝对路径(工作区须可搬运)
32
+
33
+ // 语料:所有模块 src/main + AppScope 下的 ets/ts/json/json5(跳过构建产物、依赖与 spec)
34
+ function corpusFiles(root) {
35
+ const acc = [];
36
+ (function walk(d, depth) {
37
+ let ents; try { ents = readdirSync(d, { withFileTypes: true }); } catch { return; }
38
+ for (const e of ents) {
39
+ const p = join(d, e.name);
40
+ if (e.isDirectory()) { if (/^(build|oh_modules|node_modules|\.hvigor|\.git|\.idea|spec)$/.test(e.name)) continue; if (depth < 8) walk(p, depth + 1); }
41
+ else if (/(^|[\\/])(src[\\/]main|AppScope)([\\/]|$)/.test(relative(root, p) + '/')) {
42
+ // 源码与资源:resources/ 下任何文本文件都算语料(rawfile 里的 RSS/CSV/JSON fixture 是字面量的真正落点——实证一张票就是它),大文件跳过
43
+ const inRes = /[\\/]resources[\\/]/.test(p);
44
+ if (/\.(ets|ts|json|json5)$/.test(e.name) || (inRes && /\.(xml|txt|csv|md|html|htm|properties|yaml|yml)$/i.test(e.name))) { try { if (statSync(p).size <= 4 * 1024 * 1024) acc.push(p); } catch { /* 读不到不入语料 */ } }
45
+ }
46
+ }
47
+ })(root, 0);
48
+ return acc;
49
+ }
50
+
51
+ // PRD 需求行 = 代码围栏外的列表项(- * + 或 "1."),带缩进;标题带级别。纯结构,不理解内容。
52
+ function prdLines(md) {
53
+ const lines = String(md).split(/\r?\n/); if (lines.length && lines[lines.length - 1] === '') lines.pop(); // 末尾换行不算一行
54
+ const req = new Map(); const heads = new Map(); let fence = false;
55
+ lines.forEach((raw, i) => {
56
+ if (/^\s*(```|~~~)/.test(raw)) { fence = !fence; return; }
57
+ if (fence) return;
58
+ const h = raw.match(/^(#{1,6})\s+\S/); if (h) { heads.set(i + 1, h[1].length); return; }
59
+ const m = raw.match(/^(\s*)(?:[-*+]|(\d+)[.)])\s+(\S.*)$/); if (m) req.set(i + 1, { indent: m[1].length, text: m[3].trim(), ordered: m[2] !== undefined, no: m[2] !== undefined ? Number(m[2]) : null });
60
+ });
61
+ return { total: lines.length, req, heads, lines };
62
+ }
63
+ // 锚点 "path:58" / "path:58-60" / "path:58,61" → { file, lines[] }
64
+ function parseAnchor(a) {
65
+ const m = String(a || '').match(/^(.*?)[::](\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*)$/); if (!m) return null; // 全角冒号也认(卡片里常见)
66
+ const nums = [];
67
+ for (const part of m[2].split(',')) { const r = part.split('-').map(Number); if (r.length === 2) { for (let k = r[0]; k <= Math.min(r[1], r[0] + 50); k++) nums.push(k); } else nums.push(r[0]); }
68
+ return { file: m[1], lines: nums };
69
+ }
70
+ // 覆盖两种算法都是结构记账:① 锚点行号(含缩进子项、标题整节);② 需求行里用反引号/「」/“”标记的字面量(去占位符,口径同 parse-cards)
71
+ // 全部出现在台账断言的字面量里(模型把 id/文本原样转写进了断言,只是锚点指向了别的文件,如 semantic.json——实证 bikepacking 卡片如此)。
72
+ // refLines: Map<行号, 断言 id[]>。返回逐行对照表(mode: anchor|child|section|step|literal|uncovered,by: 断言 id)供报告直接投影
73
+ // step:有序列表里被锚定的末项(结果位)之前的连续步骤(锚定结果行即覆盖其前置动作步骤;锚在中间项不外扩)
74
+ function coverage(md, refLines, ledgerLits = new Set(), litOwners = new Map()) {
75
+ const { total, req, heads, lines } = prdLines(md);
76
+ const reqKeys = [...req.keys()].sort((a, b) => a - b);
77
+ const covered = new Map(); const stale = [];
78
+ const mark = (k, mode, ids) => { const c = covered.get(k); if (!c) covered.set(k, { mode, by: new Set(ids) }); else for (const i of ids) c.by.add(i); };
79
+ for (const [L, ids] of refLines) {
80
+ if (L < 1 || L > total) { stale.push(L); continue; }
81
+ if (req.has(L)) {
82
+ mark(L, 'anchor', ids);
83
+ const ind = req.get(L).indent;
84
+ for (const k of reqKeys) { if (k <= L) continue; if (req.get(k).indent > ind) mark(k, 'child', ids); else break; }
85
+ } else if (heads.has(L)) {
86
+ const lvl = heads.get(L);
87
+ for (let k = L + 1; k <= total; k++) { if (heads.has(k) && heads.get(k) <= lvl) break; if (req.has(k)) mark(k, 'section', ids); }
88
+ } // 指向散文行:不算错也不扩散
89
+ }
90
+ let byLiteral = 0;
91
+ for (const k of reqKeys) {
92
+ if (covered.has(k)) continue;
93
+ const marks = [...req.get(k).text.matchAll(MARK_RE)].map(m => (m[1] ?? m[2] ?? m[3] ?? '').trim()).filter(v => v && !isPlaceholder(v));
94
+ if (marks.length && marks.every(x => ledgerLits.has(x) || ledgerLits.has(probeOf(x)))) {
95
+ const ids = new Set(); for (const x of marks) for (const id of (litOwners.get(x) || litOwners.get(probeOf(x)) || [])) ids.add(id);
96
+ mark(k, 'literal', [...ids]); byLiteral++;
97
+ }
98
+ }
99
+ // 有序列表 = 步骤序列:锚定第 k 项即断言了 1..k 步之后的状态——同一列表里(同缩进、连续编号项)它之前的步骤行随之覆盖;之后的项不覆盖(可能是另一结果)
100
+ for (const k of reqKeys) {
101
+ const c = covered.get(k); if (!c || c.mode === 'step' || !req.get(k).ordered) continue;
102
+ const ind = req.get(k).indent; let prevNo = req.get(k).no;
103
+ // 只有锚定"列表末项"(结果位)才回溯覆盖前置步骤;锚在中间项不外扩——功能清单式有序列表不会因锚了一条而整段算覆盖
104
+ { let last = true; for (let L = k + 1; L <= total; L++) { if (heads.has(L)) break; const r = req.get(L); if (!r) { if (/\S/.test(lines[L - 1] || '')) continue; break; } if (r.indent > ind) continue; if (r.indent < ind || !r.ordered) break; if (r.no === prevNo + 1) last = false; break; } if (!last) continue; }
105
+ for (let L = k - 1; L >= 1; L--) {
106
+ if (heads.has(L)) break;
107
+ const r = req.get(L); if (!r) { if (/\S/.test(lines[L - 1] || '')) continue; break; } // 散文行不断链;空行断链
108
+ if (r.indent > ind) continue; // 更深的子项属于上一步,跳过
109
+ if (r.indent < ind || !r.ordered || r.no !== prevNo - 1) break; // 出列表 / 非有序 / 编号不连续 → 到头
110
+ prevNo = r.no;
111
+ if (!covered.has(L)) mark(L, 'step', [...c.by]);
112
+ }
113
+ }
114
+ const table = reqKeys.map(k => { const c = covered.get(k); return { line: k, text: req.get(k).text, mode: c ? c.mode : 'uncovered', by: c ? [...c.by] : [] }; });
115
+ const uncovered = table.filter(r => r.mode === 'uncovered').map(r => ({ line: r.line, text: r.text }));
116
+ return { total, requirementLines: reqKeys.length, covered: reqKeys.length - uncovered.length, byLiteral, uncovered, stale, table };
117
+ }
118
+
119
+ // PRD 文件名先定下来(① 的严宽判据用;② 的覆盖记账也用同一个):--prd 显式 > 按断言锚点自动定位(工程→工作区根→启动目录)
120
+ let PRD = argOf('--prd', null);
121
+ let PRD_BASE = PRD ? basename(PRD).toLowerCase() : null;
122
+ const files = corpusFiles(PROJECT);
123
+ const corpus = normText(files.map(f => { try { return readFileSync(f, 'utf8'); } catch { return ''; } }).join('\n'));
124
+ const has = (probe) => { const p = normText(probe).trim(); return p.length > 0 && corpus.includes(p); };
125
+ const findings = []; let prdCoverage = null;
126
+ const stats = { corpusFiles: files.length, ledger: null, literalsChecked: 0, literalMissing: 0, literalUnmatched: 0, prd: null, prdRequirementLines: 0, prdCovered: 0, prdUncovered: 0, prdStaleAnchors: 0 };
127
+
128
+ let feats = [];
129
+ if (existsSync(LEDGER)) {
130
+ let ledger = null; try { ledger = JSON.parse(readFileSync(LEDGER, 'utf8')); } catch { /* 坏 JSON 由 ledger verify 报 */ }
131
+ feats = ledger && Array.isArray(ledger.features) ? ledger.features : [];
132
+ stats.ledger = relative(PROJECT, LEDGER);
133
+ }
134
+
135
+ if (!PRD) {
136
+ // 自动定位:先工程、再工作区根(工程的上级)、最后启动目录——避免启动目录里恰好有个同名 PRD 抢先
137
+ const cand = [...new Set(feats.map(f => parseAnchor(f.anchor)).filter(Boolean).map(a => a.file).filter(p => /prd/i.test(basename(p))))];
138
+ for (const c of cand) { for (const base of [PROJECT, dirname(PROJECT), process.cwd()]) { const p = isAbsolute(c) ? c : resolve(base, c); if (existsSync(p)) { PRD = p; break; } } if (PRD) break; }
139
+ if (PRD) PRD_BASE = basename(PRD).toLowerCase();
140
+ }
141
+ // ① 字面量真有(探针与严宽口径见文件头)
142
+ // PRD 归属:--prd 已给(或按锚点自动定位到)时按该文件名判,PRD 不叫 *prd* 也不漏;只有 PRD 未知时才退回名字含 prd
143
+ const anchorFile = (f) => basename(String(f.anchor || '').replace(/\\/g, '/').replace(/[::]\d.*$/, '')); // Windows 反斜杠锚点也认
144
+ const isPrdAnchored = (f) => PRD_BASE ? anchorFile(f).toLowerCase() === PRD_BASE : /prd/i.test(anchorFile(f));
145
+ const idLiterals = (f) => new Set([...String(f.note || '').matchAll(ID_RE)].map(m => m[1].replace(/[-_]+$/, '')));
146
+ for (const f of feats) {
147
+ if (!Array.isArray(f.literals) || !f.literals.length || f.status === 'waived') continue;
148
+ for (const lit of f.literals) {
149
+ stats.literalsChecked++;
150
+ const probes = [...new Set([String(lit), probeOf(lit), ...hexForms(lit)].filter(Boolean))];
151
+ if (probes.some(has)) continue;
152
+ const where = `断言 ${f.id}(${f.card || f.domain || '?'})引用「${lit}」在工程源码/资源中未出现${probes.length > 1 ? `(探针 ${probes.map(p => `「${p}」`).join(' ')})` : ''}`;
153
+ if (isPrdAnchored(f) || idLiterals(f).has(String(lit))) {
154
+ stats.literalMissing++;
155
+ findings.push({ severity: 'case', type: 'literal_missing', name: `${f.id}:${lit}`, file: '', evidence: where });
156
+ } else {
157
+ stats.literalUnmatched++;
158
+ findings.push({ severity: 'info', type: 'literal_unmatched', name: `${f.id}:${lit}`, file: '', evidence: `${where};锚点指向源码,字面量是描述不是契约,不立案` });
159
+ }
160
+ }
161
+ }
162
+
163
+ // ② PRD 逐行覆盖
164
+ const anchors = feats.map(f => ({ id: f.id, a: parseAnchor(f.anchor) })).filter(x => x.a);
165
+ if (PRD && existsSync(PRD)) {
166
+ stats.prd = rel(resolve(PRD));
167
+ const md = readFileSync(PRD, 'utf8');
168
+ const pb = basename(PRD).toLowerCase();
169
+ const refs = new Set(); const byLine = new Map();
170
+ for (const { id, a } of anchors) { if (basename(a.file).toLowerCase() !== pb) continue; for (const L of a.lines) { refs.add(L); if (!byLine.has(L)) byLine.set(L, []); byLine.get(L).push(id); } }
171
+ const ledgerLits = new Set(); const litOwners = new Map();
172
+ for (const f of feats) for (const l of f.literals || []) { for (const k of [String(l), probeOf(l)]) { ledgerLits.add(k); if (!litOwners.has(k)) litOwners.set(k, []); litOwners.get(k).push(f.id); } }
173
+ const cov = coverage(md, byLine, ledgerLits, litOwners);
174
+ stats.prdRequirementLines = cov.requirementLines; stats.prdCovered = cov.covered; stats.prdCoveredByLiteral = cov.byLiteral; stats.prdUncovered = cov.uncovered.length; stats.prdStaleAnchors = cov.stale.length;
175
+ prdCoverage = { prd: stats.prd, table: cov.table }; // 逐行对照表:ledger render --anchors 直接投影成 PRD 覆盖对照,不再由模型手写
176
+ for (const u of cov.uncovered) findings.push({ severity: 'case', type: 'prd_uncovered', name: `PRD:${u.line}`, file: relative(dirname(PROJECT), PRD), evidence: `PRD 第 ${u.line} 行需求无断言锚点指向(转写漏项或应并入邻行):${u.text.slice(0, 90)}` });
177
+ for (const L of cov.stale) findings.push({ severity: 'case', type: 'prd_anchor_stale', name: (byLine.get(L) || []).join(','), file: relative(dirname(PROJECT), PRD), evidence: `断言 ${(byLine.get(L) || []).join(',')} 的 PRD 锚点第 ${L} 行越界(PRD 共 ${cov.total} 行,PRD 可能已变更,需重对行号)` });
178
+ } else if (PRD) {
179
+ findings.push({ severity: 'case', type: 'prd_anchor_stale', name: 'prd', file: '', evidence: `--prd 指定的文件不存在:${PRD}` });
180
+ }
181
+
182
+ const report = { project: rel(resolve(PROJECT)), findings, stats, ...(prdCoverage ? { prdCoverage } : {}) };
183
+ if (OUT) writeFileSync(OUT, JSON.stringify(report, null, 2));
184
+ console.log(`[check-anchors] 语料 ${stats.corpusFiles} 文件 · 断言字面量 ${stats.literalsChecked} 缺 ${stats.literalMissing}${stats.literalUnmatched ? `(另 ${stats.literalUnmatched} 个源码锚点字面量未对上,info 不立案)` : ''}` + (stats.prd ? ` · PRD 需求行 ${stats.prdRequirementLines} 已覆盖 ${stats.prdCovered}(其中字面量对上 ${stats.prdCoveredByLiteral || 0})未覆盖 ${stats.prdUncovered}${stats.prdStaleAnchors ? ` 锚点越界 ${stats.prdStaleAnchors}` : ''}` : ' · 无 PRD(锚点里没有 prd 文件或 --prd 未给)'));
185
+ for (const f of findings.filter(x => x.severity !== 'info').slice(0, 12)) console.log(` ▲ ${f.type} [${f.name}] ${f.evidence}`.slice(0, 180));
186
+ process.exit(0);
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-api-guards.mjs — 崩溃防护门(补门禁盲区:同步会抛的平台 API 未 try/catch → 未捕获异常杀进程)。
4
+ *
5
+ * 病根:安卓"尽力而为"能力(触感/传感器)设备不支持时静默 no-op;鸿蒙对应**同步 API 会抛**
6
+ * (如 vibrator.isSupportEffectSync 无振动器时抛 "Device operation failed")。`.catch()` 只接异步拒绝,
7
+ * 接不住同步抛。迁移照搬调用不 try/catch → 崩(实测应用长按闪退即此)。详见 methodology/platform-api-guards.md。
8
+ *
9
+ * 判据(确定性):调用了已知同步会抛的 API,但该调用**不在任何 try 块体内** → 硬违规。
10
+ * 用 brace 追踪精确判断"是否在 try{} 内",避免注释/字符串误配。
11
+ * 分档(按所在栈,模拟器实测):事件回调/定时器/生命周期等同步栈上裸调 → 违规(未捕获即杀进程);
12
+ * async 函数体或 .then/.catch/.finally 回调内裸调 → 立案(同步抛转为未处理拒绝,不杀进程、不进 faultlog,静默失败)。
13
+ * 规则③:accessSync 返回值弃置——它返回布尔且不存在时不抛,弃置即被当成"不存在则抛"的检查,属确定性语义误用。
14
+ *
15
+ * 用法:check-api-guards.mjs --project <鸿蒙工程根> [--json out] 退出码:0 都防护 / 10 有裸调 / 3 用法错
16
+ */
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
18
+ import { join, relative, resolve, dirname } from 'node:path';
19
+ import { walkFiles, stripComments, blankStrings, moduleSrcRoots, tryCatchRanges, enclosingTry, functionContexts, innermostContext } from '../lib/scan.mjs';
20
+
21
+ const argv = process.argv.slice(2);
22
+ const argOf = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
23
+ const PROJECT = argOf('--project', null);
24
+ if (!PROJECT) { console.error('usage: check-api-guards.mjs --project <root> [--json out]'); process.exit(3); }
25
+
26
+ // 已知"同步会抛"的平台 API(保守清单:确认同步抛、.catch 接不住的;异步 API 走 .catch 不在此列)
27
+ const SYNC_THROW = [
28
+ { re: /\bvibrator\s*\.\s*(isSupportEffectSync|isHdHapticSupported)/g, api: 'vibrator 同步触感查询', why: '无振动器(模拟器)同步抛 Device operation failed' },
29
+ { re: /\.\s*beginTransaction\s*\(/g, api: 'RDB beginTransaction', why: '并发/锁冲突同步抛 801' },
30
+ { re: /\bsensor\s*\.\s*getSingleSensorSync\b/g, api: 'sensor 同步查询', why: '无传感器同步抛' },
31
+ { re: /\bdisplay\s*\.\s*getDefaultDisplaySync\b/g, api: 'display 同步查询', why: '早期调用可同步抛' },
32
+ // 文件同步 API:路径无权限/不存在/只读同步抛 BusinessError(13900002/13900012 等);实测下载回调里裸调 openSync 直接杀进程。
33
+ // accessSync/存在性判断类返回布尔不抛,不在此列。
34
+ { re: /\b(?:fileIo|fs)\s*\.\s*(?:openSync|readSync|writeSync|readTextSync|writeTextSync|unlinkSync|mkdirSync|rmdirSync|copyFileSync|moveFileSync|renameSync|statSync|listFileSync|truncateSync)\s*\(/g, api: '文件同步读写', why: '路径无权限/不存在/只读时同步抛 BusinessError' },
35
+ ];
36
+
37
+ const rel = f => relative(resolve(PROJECT), f).replace(/\\/g, '/');
38
+ // 全模块扫描 + try/catch 追踪走共享层 scan.mjs(反手拷;原本地 tryRanges 已并入 tryCatchRanges)
39
+ const ROOTS = moduleSrcRoots(resolve(PROJECT));
40
+
41
+ const findings = [];
42
+ let scanned = 0, naked = 0, nakedDeferred = 0;
43
+ for (const ROOT of ROOTS) for (const f of walkFiles(ROOT, { match: (_p, e) => e.endsWith('.ets') || e.endsWith('.ts') })) {
44
+ const raw = readFileSync(f, 'utf8');
45
+ const code = blankStrings(stripComments(raw)); // 中和字符串+注释,保留偏移
46
+ let hit = false;
47
+ for (const rule of SYNC_THROW) { rule.re.lastIndex = 0; if (rule.re.test(code)) { hit = true; break; } }
48
+ if (!hit) continue;
49
+ scanned++;
50
+ const tries = tryCatchRanges(code);
51
+ const ctxs = functionContexts(code);
52
+ const inTry = (off) => !!enclosingTry(tries, off);
53
+ const upto = (off) => raw.slice(0, off).split('\n').length; // 偏移→行号
54
+ for (const rule of SYNC_THROW) {
55
+ rule.re.lastIndex = 0; let m;
56
+ while ((m = rule.re.exec(code)) !== null) {
57
+ if (inTry(m.index)) continue;
58
+ naked++;
59
+ const ctx = innermostContext(ctxs, m.index);
60
+ if (ctx && ctx.kind !== 'sync') {
61
+ // async 体 / Promise 回调:同步抛=未处理拒绝——不杀进程也不进 faultlog(实测),静默失败,立案不定罪
62
+ nakedDeferred++;
63
+ findings.push({ severity: 'case', type: 'unguarded_throwing_api_async', name: rule.api,
64
+ file: rel(f), line: upto(m.index),
65
+ basis: `判据:裸调位于${ctx.kind === 'async' ? ' async 函数体' : ' Promise 回调'}——同步抛转为未处理拒绝,不杀进程但也无错误出口(静默失败),除非调用链有 catch`,
66
+ evidence: `${rel(f)}:${upto(m.index)} ${rule.api} 未包 try/catch——${rule.why};此处不崩但会静默失败,须 try/catch 转成明确错误出口,或确认调用链已 catch` });
67
+ continue;
68
+ }
69
+ findings.push({ severity: 'violation', type: 'unguarded_throwing_api', name: rule.api,
70
+ file: rel(f), line: upto(m.index),
71
+ evidence: `${rel(f)}:${upto(m.index)} ${rule.api} 未包 try/catch——${rule.why};未捕获异常会杀进程,须 try/catch 降级 no-op` });
72
+ }
73
+ }
74
+ }
75
+
76
+ // ---- 规则③:accessSync 返回值弃置(确定性语义误用)----
77
+ // accessSync 返回布尔、不存在时返回 false 且不抛,唯一用途就是它的返回值。语句位弃置说明被当成
78
+ // "不存在则抛"的检查:不存在分支永远不执行(目录/文件永远建不出来),只在下游 openSync 以 13900002 露头。
79
+ // 判据:调用前一个非空白字符是 ; { } 或文件开头(语句位);有接收者(= ( ! , ? : && || return =>)即返回值被使用。
80
+ let discardedAccess = 0;
81
+ for (const ROOT of ROOTS) for (const f of walkFiles(ROOT, { match: (_p, e) => e.endsWith('.ets') || e.endsWith('.ts') })) {
82
+ const raw = readFileSync(f, 'utf8');
83
+ if (!/accessSync/.test(raw)) continue;
84
+ const code = blankStrings(stripComments(raw));
85
+ for (const m of code.matchAll(/\b(?:fileIo|fs)\s*\.\s*accessSync\s*\(/g)) {
86
+ let k = m.index - 1; while (k >= 0 && /\s/.test(code[k])) k--;
87
+ const prev = k >= 0 ? code[k] : '';
88
+ if (prev !== '' && !';{}'.includes(prev)) continue;
89
+ discardedAccess++;
90
+ const line = raw.slice(0, m.index).split('\n').length;
91
+ findings.push({ severity: 'violation', type: 'discarded_boolean_check', name: 'accessSync 返回值弃置',
92
+ file: rel(f), line,
93
+ evidence: `${rel(f)}:${line} accessSync 的返回值被弃置——它返回布尔且不存在时不抛,弃置即被当成"不存在则抛"的检查,不存在分支永远不执行。改法:if (!fileIo.accessSync(p)) { 建目录/文件 }` });
94
+ }
95
+ }
96
+
97
+ // ---- 规则②:@BuilderParam 传参的三种形态(官方 arkts-builderparam 指南 + FAQ faqs-arkui-229,真机复核 2026-09-21)----
98
+ // ① 裸引用 `键: this.xxxBuilder`:builder 体内 this 指向**接收方**子组件——体内一旦用 this.成员/方法,
99
+ // 真机 TypeError("xxx is not callable"/"Cannot read property",coolmall 4 份 jscrash 同型);体内无 this 碰巧安全。
100
+ // ② 箭头包裹 `键: () => { this.xxxBuilder() }`:this 指向宿主,**官方推荐写法**,真机复核正常渲染。
101
+ // ③ `键: this.xxxBuilder.bind(this)`:可用但触 ArkTS 约束 arkts-no-func-apply-bind-call(lint 告警,可构建),不推荐。
102
+ // 真正必崩的只有一种:键值 lambda 体内**直接构造组件**(`键: () => { Comp({...}) }`,非 @Builder 语境构造 struct)。
103
+ // 分级:①体内有 this → violation(不可豁免,改法=②);①无 this → case;lambda 直构组件 → violation;③ → info。
104
+ // 合法形态不误伤:ForEach/Repeat 的 itemGenerator 是位置参数 lambda(无键前缀);lambda 体内调普通方法不算。
105
+ const BUILDER_DECL = /@Builder\s+(?:(?:private|public|protected|static)\s+)*([\w$]+)\s*\(/g; // 声明容忍修饰符(语料 45 工程 560 个带 private/public 的 @Builder)
106
+ function builderBody(code, name) {
107
+ const m = new RegExp(`@Builder\\s+(?:(?:private|public|protected|static)\\s+)*${name}\\s*\\(`).exec(code);
108
+ if (!m) return null;
109
+ let i = m.index + m[0].length - 1, d = 0;
110
+ for (; i < code.length; i++) { if (code[i] === '(') d++; else if (code[i] === ')') { d--; if (!d) break; } } // 跳过参数表
111
+ const open = code.indexOf('{', i); if (open < 0) return null;
112
+ d = 0;
113
+ for (let j = open; j < code.length; j++) { if (code[j] === '{') d++; else if (code[j] === '}') { d--; if (!d) return code.slice(open + 1, j); } }
114
+ return null;
115
+ }
116
+ let builderLambda = 0;
117
+ for (const ROOT of ROOTS) for (const f of walkFiles(ROOT, { match: (_p, e) => e.endsWith('.ets') })) {
118
+ const raw = readFileSync(f, 'utf8');
119
+ const code = blankStrings(stripComments(raw));
120
+ const builderMethods = new Set([...code.matchAll(BUILDER_DECL)].map(m => m[1]));
121
+ for (const m of code.matchAll(/[\w$]+\s*:\s*(?:\(\s*\)|\([^)]*\))(?:\s*:\s*[\w$<>\[\], .|]+?)?\s*=>\s*\{/g)) {
122
+ const open = code.indexOf('{', m.index + m[0].length - 1);
123
+ let d = 0, end = -1;
124
+ for (let j = open; j < code.length; j++) {
125
+ if (code[j] === '{') d++;
126
+ else if (code[j] === '}') { d--; if (!d) { end = j; break; } }
127
+ }
128
+ if (end < 0) continue;
129
+ const body = code.slice(open + 1, end);
130
+ const line = raw.slice(0, m.index).split('\n').length;
131
+ const hit = body.match(/(?<![.\w])([A-Z][\w$]*)\s*\(\s*\{/);
132
+ if (hit) {
133
+ builderLambda++;
134
+ findings.push({ severity: 'violation', type: 'builder_lambda_component', name: hit[1],
135
+ file: rel(f), line,
136
+ evidence: `${rel(f)}:${line} 组件参数以普通 lambda 传入且体内直接构造组件 ${hit[1]}({...})——非 @Builder 语境构造 struct 必崩。改法:把该 lambda 提升为本类 @Builder 方法,再以箭头包裹传入(键: () => { this.xxxBuilder() },官方推荐)` });
137
+ }
138
+ }
139
+ // ① 裸引用:`键: this.xxxBuilder`(无 .bind、无箭头)——按 builder 体内是否用 this 分级
140
+ for (const m of code.matchAll(/[\w$]+\s*:\s*this\.([\w$]+)\s*(?=[,}\n])/g)) {
141
+ if (!builderMethods.has(m[1])) continue;
142
+ builderLambda++;
143
+ const line = raw.slice(0, m.index).split('\n').length;
144
+ const body = builderBody(code, m[1]) || '';
145
+ const deps = [...new Set([...body.matchAll(/\bthis\s*\.\s*([\w$]+)/g)].map(x => x[1]))];
146
+ if (deps.length) {
147
+ findings.push({ severity: 'violation', type: 'builder_ref_unbound', name: m[1],
148
+ file: rel(f), line,
149
+ evidence: `${rel(f)}:${line} 裸引用 this.${m[1]} 传给子组件,而该 @Builder 体内用了 this.${deps.slice(0, 4).join('/this.')}${deps.length > 4 ? '…' : ''}——执行时 this 指向接收方子组件,这些成员不存在即 TypeError(官方 FAQ faqs-arkui-229 同题)。改法(官方):键: () => { this.${m[1]}() }` });
150
+ } else {
151
+ findings.push({ severity: 'case', type: 'builder_ref_unbound', name: m[1],
152
+ file: rel(f), line,
153
+ basis: '判据:@Builder 方法以裸引用传参且体内无 this 依赖——this 指向接收方但未被使用,碰巧安全;改成箭头包裹恒安全',
154
+ evidence: `${rel(f)}:${line} 键值裸引用 this.${m[1]}(体内无 this)——建议改 键: () => { this.${m[1]}() };确认无需宿主状态可豁免` });
155
+ }
156
+ }
157
+ // ③ bind:可用但违 ArkTS 约束,只提示
158
+ for (const m of code.matchAll(/[\w$]+\s*:\s*this\.([\w$]+)\.bind\(\s*this\s*\)/g)) {
159
+ if (!builderMethods.has(m[1])) continue;
160
+ const line = raw.slice(0, m.index).split('\n').length;
161
+ findings.push({ severity: 'info', type: 'builder_bind_call', name: m[1], file: rel(f), line,
162
+ evidence: `${rel(f)}:${line} this.${m[1]}.bind(this)——可用,但触 ArkTS 约束 arkts-no-func-apply-bind-call(lint 告警);官方推荐 键: () => { this.${m[1]}() }` });
163
+ }
164
+ }
165
+
166
+ const report = { project: relative(process.cwd(), resolve(PROJECT)) || '.', stats: { scannedFiles: scanned, nakedCalls: naked, nakedDeferred, discardedAccess, builderLambda }, findings };
167
+ const out = argOf('--json', null);
168
+ if (out) { mkdirSync(dirname(resolve(out)), { recursive: true }); writeFileSync(out, JSON.stringify(report, null, 2)); }
169
+ const vio = findings.filter(f => f.severity === 'violation');
170
+ console.log(`[check-api-guards] 涉险文件 ${scanned} · 裸调(未防护) ${naked}(其中 async/Promise 回调内静默失败档 ${nakedDeferred})· accessSync 弃置 ${discardedAccess}`);
171
+ for (const v of vio.slice(0, 12)) console.log(` ✗ ${v.evidence}`);
172
+ const cas = findings.filter(f => f.severity === 'case');
173
+ for (const c of cas.slice(0, 8)) console.log(` ▲ CASE ${c.type} — ${c.evidence}`);
174
+ if (!vio.length && !cas.length) console.log(' ✓ 会抛平台 API 均已 try/catch 防护');
175
+ process.exit(vio.length ? 10 : 0);