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.
- package/LICENSE +26 -0
- package/README.md +50 -0
- package/agents/hmigbot-worker.md +61 -0
- package/agents/hmigbot.md +22 -0
- package/agents/workflow-subagent.md +55 -0
- package/commands/hmigbot.md +17 -0
- package/dist/index.js +1 -0
- package/manifest.json +19 -0
- package/package.json +29 -0
- package/skills/migrate-core/FILES.md +26 -0
- package/skills/migrate-core/SKILL.md +484 -0
- package/skills/migrate-core/references/README.md +64 -0
- package/skills/migrate-core/references/flow/arkts-vector-gate.md +55 -0
- package/skills/migrate-core/references/flow/build-error-patterns.md +52 -0
- package/skills/migrate-core/references/flow/conventions-template.md +244 -0
- package/skills/migrate-core/references/flow/navigation-migration.md +42 -0
- package/skills/migrate-core/references/flow/platform-api-guards.md +59 -0
- package/skills/migrate-core/references/flow/platform-model-gaps.md +43 -0
- package/skills/migrate-core/references/flow/resource-conversion.md +46 -0
- package/skills/migrate-core/references/flow/ui-layout-semantics.md +124 -0
- package/skills/migrate-core/references/flow/unit-breakdown.md +42 -0
- package/skills/migrate-core/references/host-capabilities.md +24 -0
- package/skills/migrate-core/references/topics/app-identity.md +214 -0
- package/skills/migrate-core/references/topics/env-doctor.md +245 -0
- package/skills/migrate-core/references/topics/i18n/README.md +458 -0
- package/skills/migrate-core/references/topics/i18n/references/code-examples.md +304 -0
- package/skills/migrate-core/references/topics/i18n/references/common-pitfalls.md +354 -0
- package/skills/migrate-core/references/topics/i18n/references/dynamic-language-switch.md +464 -0
- package/skills/migrate-core/references/topics/i18n/references/language-codes.md +104 -0
- package/skills/migrate-core/references/topics/icon-sizing.md +98 -0
- package/skills/migrate-core/references/topics/library-migration/README.md +234 -0
- package/skills/migrate-core/references/topics/library-migration/closed-source-sdk.md +128 -0
- package/skills/migrate-core/references/topics/library-migration/download-api-decision.md +84 -0
- package/skills/migrate-core/references/topics/library-migration/library-mapping-table.md +100 -0
- package/skills/migrate-core/references/topics/library-migration/napi-compile-guide.md +84 -0
- package/skills/migrate-core/references/topics/library-migration/ohpm-search-guide.md +73 -0
- package/skills/migrate-core/references/topics/library-migration/stdlib-mapping-table.md +34 -0
- package/skills/migrate-core/references/topics/resources/aar-decompile.md +25 -0
- package/skills/migrate-core/references/topics/resources/conversion-rules.md +625 -0
- package/skills/migrate-core/references/topics/resources/dependency-analysis-rules.md +328 -0
- package/skills/migrate-core/references/topics/resources/material-design-icons.md +173 -0
- package/skills/migrate-core/references/topics/resources/svg-fix-patterns.md +175 -0
- package/skills/migrate-core/references/topics/resources/xml-drawable-to-svg-rules.md +513 -0
- package/skills/migrate-core/references/topics/system-capabilities/README.md +331 -0
- package/skills/migrate-core/references/topics/system-capabilities/avplayer-guide.md +161 -0
- package/skills/migrate-core/references/topics/system-capabilities/background-tasks.md +403 -0
- package/skills/migrate-core/references/topics/system-capabilities/browser-intent.md +121 -0
- package/skills/migrate-core/references/topics/system-capabilities/camera-picker.md +118 -0
- package/skills/migrate-core/references/topics/system-capabilities/document-picker.md +246 -0
- package/skills/migrate-core/references/topics/system-capabilities/file-utils.md +131 -0
- package/skills/migrate-core/references/topics/system-capabilities/permission-helper.md +112 -0
- package/skills/migrate-core/references/topics/system-capabilities/photo-access-helper.md +208 -0
- package/skills/migrate-core/references/topics/system-capabilities/print-management.md +213 -0
- package/skills/migrate-core/references/topics/system-capabilities/share-panel.md +177 -0
- package/skills/migrate-core/references/topics/system-capabilities/system-settings.md +322 -0
- package/skills/migrate-core/references/topics/system-capabilities/telephony-dial.md +49 -0
- package/skills/migrate-core/references/topics/system-capabilities/video-playback.md +42 -0
- package/skills/migrate-core/references/topics/system-capabilities/webview-patterns.md +38 -0
- package/skills/migrate-core/references/topics/ui-alignment/README.md +344 -0
- package/skills/migrate-core/references/topics/ui-alignment/references/dark-mode.md +47 -0
- package/skills/migrate-core/references/topics/ui-alignment/references/layout-mapping.md +301 -0
- package/skills/migrate-core/references/topics/ui-alignment/references/visual-patterns.md +411 -0
- package/skills/migrate-core/scripts/closure/check-anchors.mjs +186 -0
- package/skills/migrate-core/scripts/closure/check-api-guards.mjs +175 -0
- package/skills/migrate-core/scripts/closure/check-consumers.mjs +301 -0
- package/skills/migrate-core/scripts/closure/check-permissions.mjs +165 -0
- package/skills/migrate-core/scripts/closure/check-resources.mjs +130 -0
- package/skills/migrate-core/scripts/closure/check-routes.mjs +527 -0
- package/skills/migrate-core/scripts/closure/check-safearea.mjs +122 -0
- package/skills/migrate-core/scripts/closure/check-stubs.mjs +69 -0
- package/skills/migrate-core/scripts/closure/closure-suite.mjs +256 -0
- package/skills/migrate-core/scripts/closure/idioms.json +105 -0
- package/skills/migrate-core/scripts/convert/convert-resources.mjs +437 -0
- package/skills/migrate-core/scripts/feasibility/feasibility.mjs +235 -0
- package/skills/migrate-core/scripts/feasibility/tables/cross-platform.json +11 -0
- package/skills/migrate-core/scripts/feasibility/tables/deprecated-api.json +10 -0
- package/skills/migrate-core/scripts/feasibility/tables/imported-arkts-core.json +425 -0
- package/skills/migrate-core/scripts/feasibility/tables/lib-equivalence.json +206 -0
- package/skills/migrate-core/scripts/feasibility/tables/system-capabilities.json +22 -0
- package/skills/migrate-core/scripts/front.mjs +107 -0
- package/skills/migrate-core/scripts/interface/ark-extract.mjs +172 -0
- package/skills/migrate-core/scripts/interface/interface.mjs +152 -0
- package/skills/migrate-core/scripts/ledger/ledger.mjs +383 -0
- package/skills/migrate-core/scripts/ledger/parse-cards.mjs +98 -0
- package/skills/migrate-core/scripts/lib/literals.mjs +37 -0
- package/skills/migrate-core/scripts/lib/scan.mjs +315 -0
- package/skills/migrate-core/scripts/smoke/align-sdk.mjs +118 -0
- package/skills/migrate-core/scripts/smoke/ensure-sign.mjs +53 -0
- package/skills/migrate-core/scripts/smoke/smoke.mjs +238 -0
- package/skills/migrate-core/scripts/smoke/verdict.mjs +31 -0
- package/skills/migrate-core/scripts/smoke/walk.mjs +480 -0
- package/skills/migrate-core/scripts/transpile/mapping.json +76 -0
- package/skills/migrate-core/scripts/transpile/transpile-layout.mjs +404 -0
- package/skills/migrate-core/scripts/vectors/run-arkts-vectors.mjs +107 -0
- package/skills/migrate-core/scripts/vectors/setup-arkts-test.mjs +90 -0
- package/skills/migrate-core/scripts/wire/extractors.mjs +258 -0
- package/skills/migrate-core/scripts/wire/wire-routes.mjs +507 -0
- package/skills/migrate-core/templates/acceptance.js +365 -0
- package/skills/migrate-core/templates/explore.js +86 -0
- package/skills/migrate-core/templates/implement.js +211 -0
- package/skills/migrate-core/templates/mig_slices.js +491 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-consumers.mjs — 开关/偏好键消费对账(三态:case / pass / unresolved,本检查不产 violation)
|
|
4
|
+
*
|
|
5
|
+
* 对账两本账:每个持久化键的"写入路径"(putSync/put/setOrCreate + 包裹它们的 setter 方法及其调用方)
|
|
6
|
+
* × "读取路径"(getSync/get + 包裹它们的 getter 方法及其调用方)
|
|
7
|
+
*
|
|
8
|
+
* 立案类型(全部软性,供裁决,不定罪——写而不读可能是迁移标记等合法场景):
|
|
9
|
+
* - write_only :有写入、全工程无任何读取路径
|
|
10
|
+
* - read_path_unused :store 里有 getter,但全工程没人调它(读取路径存在但未接线)
|
|
11
|
+
* - self_loop_consumer:唯一的读取方就是写入它的那个 UI 文件(开关只用来渲染自己,无功能消费)
|
|
12
|
+
* - read_only :有读取、无写入(可能靠默认值工作,或写入方漏迁)
|
|
13
|
+
*
|
|
14
|
+
* 用法:node check-consumers.mjs --project <鸿蒙工程根> [--json out.json] [--strict]
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { join, resolve, sep, dirname, relative } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { stripComments, blankStrings, walkFiles } 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
|
+
const OUT = argOf('--json', null);
|
|
25
|
+
const STRICT = argv.includes('--strict');
|
|
26
|
+
if (!PROJECT) { console.error('usage: check-consumers.mjs --project <root> [--json out] [--strict] [--idioms i.json]'); process.exit(3); }
|
|
27
|
+
// 写法知识表:数据不硬编码。默认随包 idioms.json,可 --idioms 覆盖(深合并顶层键)。
|
|
28
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const IDIOMS = { ...JSON.parse(readFileSync(join(HERE, 'idioms.json'), 'utf8')) };
|
|
30
|
+
const idiomsOverride = argOf('--idioms', null);
|
|
31
|
+
if (idiomsOverride) { const o = JSON.parse(readFileSync(idiomsOverride, 'utf8')); for (const k of Object.keys(o)) IDIOMS[k] = { ...IDIOMS[k], ...o[k] }; }
|
|
32
|
+
|
|
33
|
+
const files = walkFiles(resolve(PROJECT), { match: (p, e) => e.endsWith('.ets') });
|
|
34
|
+
const src = new Map(files.map(f => [f, stripComments(readFileSync(f, 'utf8'))]));
|
|
35
|
+
// 字符串脱敏视图(调用/引用扫描用:日志串里的类名/调用样例不算证据;偏移与 src 一致)
|
|
36
|
+
const blankCache = new Map();
|
|
37
|
+
const bl = f => { if (!blankCache.has(f)) blankCache.set(f, blankStrings(src.get(f))); return blankCache.get(f); };
|
|
38
|
+
const rel = f => String(f).replace(resolve(PROJECT) + sep, '');
|
|
39
|
+
const lineOf = (code, idx) => code.slice(0, idx).split('\n').length;
|
|
40
|
+
|
|
41
|
+
// ---------- 常量表(含枚举——与 check-routes 同游标:enum PrefKey { DARK='dark' } 的键必须可解析) ----------
|
|
42
|
+
const constMap = new Map();
|
|
43
|
+
for (const [, code] of src) {
|
|
44
|
+
for (const m of code.matchAll(/(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*(?::\s*string)?\s*=\s*(['"])((?:(?!\2).)*)\2/g)) constMap.set(m[1], m[3]);
|
|
45
|
+
for (const m of code.matchAll(/static\s+(?:readonly\s+)?([A-Za-z_$][\w$]*)\s*(?::\s*string)?\s*=\s*(['"])((?:(?!\2).)*)\2/g)) constMap.set(m[1], m[3]);
|
|
46
|
+
for (const em of code.matchAll(/(?:const\s+)?enum\s+([A-Za-z_$][\w$]*)\s*\{([\s\S]*?)\}/g)) {
|
|
47
|
+
for (const mm of em[2].matchAll(/([A-Za-z_$][\w$]*)\s*=\s*(['"])((?:(?!\2).)*)\2/g)) {
|
|
48
|
+
constMap.set(`${em[1]}.${mm[1]}`, mm[3]);
|
|
49
|
+
if (!constMap.has(mm[1])) constMap.set(mm[1], mm[3]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function keyOf(expr) {
|
|
54
|
+
const e = (expr || '').trim();
|
|
55
|
+
const lit = e.match(/^(['"])((?:(?!\1).)*)\1$/);
|
|
56
|
+
if (lit) return { key: lit[2], kind: 'literal' };
|
|
57
|
+
if (constMap.has(e)) return { key: constMap.get(e), kind: 'const', via: e };
|
|
58
|
+
const q = e.match(/^[A-Za-z_$][\w$]*\.([A-Za-z_$][\w$]*)$/);
|
|
59
|
+
if (q) {
|
|
60
|
+
if (constMap.has(e)) return { key: constMap.get(e), kind: 'const', via: e };
|
|
61
|
+
if (constMap.has(q[1])) return { key: constMap.get(q[1]), kind: 'const', via: e };
|
|
62
|
+
}
|
|
63
|
+
return { key: null, kind: 'dynamic', via: e.slice(0, 40) };
|
|
64
|
+
}
|
|
65
|
+
function firstArg(code, idx) {
|
|
66
|
+
let depth = 0, out = '';
|
|
67
|
+
for (let i = idx; i < code.length && out.length < 120; i++) {
|
|
68
|
+
const c = code[i];
|
|
69
|
+
if ('({['.includes(c)) depth++;
|
|
70
|
+
else if (')}]'.includes(c)) { if (depth === 0) break; depth--; }
|
|
71
|
+
else if (c === ',' && depth === 0) break;
|
|
72
|
+
out += c;
|
|
73
|
+
}
|
|
74
|
+
return out.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------- 采集读写点 ----------
|
|
78
|
+
const WRITE_RE = new RegExp(`\\.\\s*(${IDIOMS.prefs.writeApis.join('|')})\\s*(?:<[^<>]{0,80}>)?\\s*\\(`, 'g'); // 容泛型 get<string>((漏检实证:smokeRoute 误报 write_only)
|
|
79
|
+
const READ_RE = new RegExp(`\\.\\s*(${IDIOMS.prefs.readApis.join('|')})\\s*(?:<[^<>]{0,80}>)?\\s*\\(`, 'g');
|
|
80
|
+
const DELETE_RE = new RegExp(`\\.\\s*(${IDIOMS.prefs.deleteApis.join('|')})\\s*\\(`, 'g');
|
|
81
|
+
// AppStorage/普通 Map.set 噪音控制:只统计 arg 可解析为字符串键、且键名不像 Map 泛型用法的
|
|
82
|
+
const sites = []; // {op:'w'|'r', key, kind, via, file, line, api}
|
|
83
|
+
const dynamicReadFiles = new Map(); // 文件 -> 变量键读取次数(持久化 API 上 get(k) 之类)
|
|
84
|
+
for (const [f, code] of src) {
|
|
85
|
+
for (const [re, op] of [[WRITE_RE, 'w'], [READ_RE, 'r'], [DELETE_RE, 'w']]) {
|
|
86
|
+
re.lastIndex = 0;
|
|
87
|
+
let m;
|
|
88
|
+
while ((m = re.exec(code)) !== null) {
|
|
89
|
+
const arg = firstArg(code, re.lastIndex);
|
|
90
|
+
const k = keyOf(arg);
|
|
91
|
+
// 接收者提示:prefs.get / AppStorage.get / this.store.put 之类
|
|
92
|
+
const before = code.slice(Math.max(0, m.index - 60), m.index);
|
|
93
|
+
const recv = (before.match(/([A-Za-z_$][\w$]*)\s*$/) || [])[1] || '';
|
|
94
|
+
// 噪音闸:只收字面量/常量键;Map/Set 等容器的 .get(变量) 直接忽略——但持久化 API 上的变量键读取要记下来:
|
|
95
|
+
// 该文件里的键读取路径静态不可判(实证:逐 key 循环 getSync(k) 的 store 被判 8 键自消费,模型为过门改成字面量重写)
|
|
96
|
+
if (!k.key) { if (op === 'r' && (IDIOMS.prefs.strongApis.includes(m[1]) || new RegExp(IDIOMS.prefs.recvHint, 'i').test(recv))) dynamicReadFiles.set(f, (dynamicReadFiles.get(f) || 0) + 1); continue; }
|
|
97
|
+
sites.push({ op, key: k.key, kind: k.kind, via: k.via, file: f, line: lineOf(code, m.index), api: m[1], recv });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// 键宇宙三条准入(防 Map.get 噪音):①强 API;②接收者名含 pref/storage/store/settings/config;③同键同时有读有写触点
|
|
102
|
+
const STRONG_API = new Set(IDIOMS.prefs.strongApis);
|
|
103
|
+
const RECV_HINT = new RegExp(IDIOMS.prefs.recvHint, 'i');
|
|
104
|
+
const rwByKey = new Map();
|
|
105
|
+
for (const s of sites) {
|
|
106
|
+
if (!rwByKey.has(s.key)) rwByKey.set(s.key, new Set());
|
|
107
|
+
rwByKey.get(s.key).add(s.op);
|
|
108
|
+
}
|
|
109
|
+
const PERSIST_KEY_SHAPE = new RegExp(IDIOMS.prefs.persistKeyShape, 'i'); // 成对准入还要长得像持久化键,滤掉协议/Map 短键
|
|
110
|
+
const strongKeys = new Set(sites.filter(s =>
|
|
111
|
+
STRONG_API.has(s.api) || RECV_HINT.test(s.recv) ||
|
|
112
|
+
((rwByKey.get(s.key) || new Set()).size === 2 && PERSIST_KEY_SHAPE.test(s.key))
|
|
113
|
+
).map(s => s.key));
|
|
114
|
+
const keySites = sites.filter(s => strongKeys.has(s.key));
|
|
115
|
+
|
|
116
|
+
// ---------- 方法归属(store getter/setter 的外部调用一跳) ----------
|
|
117
|
+
function* methodBodies(code) {
|
|
118
|
+
const blanked = blankStrings(code); // 括号平衡在字符串脱敏视图上做——字符串里的 {}() 不再破坏边界
|
|
119
|
+
const re = /(?:static\s+|private\s+|public\s+|export\s+|async\s+)*(?:function\s+)?([A-Za-z_$][\w$]*)\s*\(/g;
|
|
120
|
+
let m;
|
|
121
|
+
while ((m = re.exec(blanked)) !== null) {
|
|
122
|
+
if (['if', 'for', 'while', 'switch', 'catch', 'constructor', 'return'].includes(m[1])) continue;
|
|
123
|
+
const accessor = /\b(?:get|set)\s+$/.test(code.slice(Math.max(0, m.index - 20), m.index));
|
|
124
|
+
let depth = 1, i = re.lastIndex;
|
|
125
|
+
while (i < blanked.length && depth > 0) { if (blanked[i] === '(') depth++; else if (blanked[i] === ')') depth--; i++; }
|
|
126
|
+
const after = blanked.slice(i);
|
|
127
|
+
const bm = after.match(/^\s*(?::\s*[^{;=]*?)?\{/);
|
|
128
|
+
if (!bm) continue;
|
|
129
|
+
const bodyStart = i + bm[0].length;
|
|
130
|
+
let d = 1, j = bodyStart;
|
|
131
|
+
while (j < blanked.length && d > 0) { if (blanked[j] === '{') d++; else if (blanked[j] === '}') d--; j++; }
|
|
132
|
+
// 方法→类按包围位置绑定(多类文件取 pop() 会挂错类)
|
|
133
|
+
const cls = ([...code.slice(0, m.index).matchAll(/class\s+([A-Za-z_$][\w$]*)/g)].pop() || [])[1] || null;
|
|
134
|
+
yield { name: m[1], start: bodyStart, end: j, body: code.slice(bodyStart, j), accessor, cls };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// 构造函数体(methodBodies 按关键字跳过它):`this.loadSavedPhone()` 这类源 init 块惯用法都在这里,可达性 = 别处 new 了该类
|
|
138
|
+
function* ctorBodies(code) {
|
|
139
|
+
const blanked = blankStrings(code);
|
|
140
|
+
const re = /\bconstructor\s*\(/g; let m;
|
|
141
|
+
while ((m = re.exec(blanked)) !== null) {
|
|
142
|
+
let depth = 1, i = re.lastIndex;
|
|
143
|
+
while (i < blanked.length && depth > 0) { if (blanked[i] === '(') depth++; else if (blanked[i] === ')') depth--; i++; }
|
|
144
|
+
const bm = blanked.slice(i).match(/^\s*\{/); if (!bm) continue;
|
|
145
|
+
const bodyStart = i + bm[0].length; let d = 1, j = bodyStart;
|
|
146
|
+
while (j < blanked.length && d > 0) { if (blanked[j] === '{') d++; else if (blanked[j] === '}') d--; j++; }
|
|
147
|
+
const cls = ([...code.slice(0, m.index).matchAll(/class\s+([A-Za-z_$][\w$]*)/g)].pop() || [])[1] || null;
|
|
148
|
+
yield { name: 'constructor', start: bodyStart, end: j, body: code.slice(bodyStart, j), accessor: false, cls };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// key -> {readMethods:[{fn,file}], writeMethods:[], directReads:[files], directWrites:[files]}
|
|
152
|
+
const ledger = new Map();
|
|
153
|
+
const ensure = k => { if (!ledger.has(k)) ledger.set(k, { readMethods: [], writeMethods: [], readFiles: new Set(), writeFiles: new Set(), evidence: [] }); return ledger.get(k); };
|
|
154
|
+
const methodIndex = new Map(); // fn -> [{file, keys:{r:Set,w:Set}}]
|
|
155
|
+
for (const [f, code] of src) {
|
|
156
|
+
for (const fn of methodBodies(code)) {
|
|
157
|
+
const inKeys = keySites.filter(s => s.file === f && s.line >= lineOf(code, fn.start) && s.line <= lineOf(code, fn.end));
|
|
158
|
+
if (!inKeys.length) continue;
|
|
159
|
+
const r = new Set(inKeys.filter(s => s.op === 'r').map(s => s.key));
|
|
160
|
+
const w = new Set(inKeys.filter(s => s.op === 'w').map(s => s.key));
|
|
161
|
+
if (!methodIndex.has(fn.name)) methodIndex.set(fn.name, []);
|
|
162
|
+
methodIndex.get(fn.name).push({ file: f, r, w, accessor: fn.accessor, cls: fn.cls });
|
|
163
|
+
for (const k of r) ensure(k).readMethods.push({ fn: fn.name, file: f });
|
|
164
|
+
for (const k of w) ensure(k).writeMethods.push({ fn: fn.name, file: f });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const s of keySites) {
|
|
168
|
+
const e = ensure(s.key);
|
|
169
|
+
(s.op === 'r' ? e.readFiles : e.writeFiles).add(s.file);
|
|
170
|
+
}
|
|
171
|
+
// 方法调用一跳:谁调用了含键读写的方法(扫描走字符串脱敏视图)。
|
|
172
|
+
// 同文件调用也算消费——实证 coolmall `AuthStore.getAuth()` 被同类其他(外部可达)方法调、`this.isGuideShown()` 在本 VM 内用,
|
|
173
|
+
// 旧规则"定义文件内调用不算"把它们都判成 read_path_unused(抽样 3 条 2 条误报);真死路径(cofi getDismissedInfoBoxes 无任何调用)照抓。
|
|
174
|
+
// 同文件调用先记为 internal,再按"调用它的方法是否本身被外部调用"传播 chainRounds 轮(结构判据:调用图,不看命名)。
|
|
175
|
+
const internalCalls = new Map(); // `${file}::${fn}` -> Set(callerFn)
|
|
176
|
+
for (const [f] of src) {
|
|
177
|
+
const code = bl(f);
|
|
178
|
+
for (const [fn, defs] of methodIndex) {
|
|
179
|
+
const selfDef = defs.some(d => d.file === f);
|
|
180
|
+
const isAccessor = defs.some(d => d.accessor);
|
|
181
|
+
if (selfDef) {
|
|
182
|
+
// 同文件:找调用它的其他方法体
|
|
183
|
+
for (const caller of [...methodBodies(src.get(f)), ...ctorBodies(src.get(f))]) {
|
|
184
|
+
if (caller.name === fn) continue;
|
|
185
|
+
const re = isAccessor ? new RegExp(`\\.${fn}\\b`) : new RegExp(`(?:\\.|(?<![.\\w$]))${fn}\\s*\\(`); // 方法形 .fn( 与模块级裸函数 fn( 都算
|
|
186
|
+
if (re.test(blankStrings(caller.body))) { const k = `${f}::${fn}`; if (!internalCalls.has(k)) internalCalls.set(k, new Set()); internalCalls.get(k).add(caller.name === 'constructor' ? `constructor@${caller.cls || ''}` : caller.name); }
|
|
187
|
+
}
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const re = isAccessor ? new RegExp(`\\.${fn}\\b`, 'g') : new RegExp(`\\.${fn}\\s*\\(`, 'g');
|
|
191
|
+
let m2;
|
|
192
|
+
while ((m2 = re.exec(code)) !== null) {
|
|
193
|
+
// 访问器名太容易撞(.value/.enabled 全工程皆是):要求接收者提示(recvHint 数据驱动)
|
|
194
|
+
if (isAccessor) {
|
|
195
|
+
const before = code.slice(Math.max(0, m2.index - 60), m2.index);
|
|
196
|
+
const recv = (before.match(/([A-Za-z_$][\w$]*)\s*$/) || [])[1] || '';
|
|
197
|
+
if (!RECV_HINT.test(recv)) continue;
|
|
198
|
+
}
|
|
199
|
+
// 属性访问器按使用点判读写:`.x =` 写;`.x +=`/`??=`/`||=` 等复合赋值=读+写;其余读
|
|
200
|
+
let usage = null; // true=写 false=读 'both'=读写
|
|
201
|
+
if (isAccessor) {
|
|
202
|
+
const tail = code.slice(re.lastIndex, re.lastIndex + 12);
|
|
203
|
+
const wm = tail.match(/^\s*([+\-*/%&|^]|\|\||&&|\?\?)?=(?!=)/);
|
|
204
|
+
usage = wm ? (wm[1] ? 'both' : true) : false;
|
|
205
|
+
}
|
|
206
|
+
for (const d of defs) {
|
|
207
|
+
const applyR = isAccessor ? (usage === false || usage === 'both') : true;
|
|
208
|
+
const applyW = isAccessor ? (usage === true || usage === 'both') : true;
|
|
209
|
+
if (applyR) for (const k of d.r) { const e = ensure(k); e.readFiles.add(f); e.evidence.push(`读经 ${fn} ← ${rel(f)}`); }
|
|
210
|
+
if (applyW) for (const k of d.w) { const e = ensure(k); e.writeFiles.add(f); e.evidence.push(`写经 ${fn} ← ${rel(f)}`); }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 传播:同文件内被调用的方法,若调用者本身有外部调用(任意文件 `.caller(`)或调用者也被已消费方法调用 → 该方法视为已消费
|
|
217
|
+
{
|
|
218
|
+
const callerFilesCache = new Map(); // `${file}::${fn}` -> 外部调用它的文件集
|
|
219
|
+
const importsFrom = (g, fn, file) => { // g 是否 `import { fn } from '<解析到 file>'`
|
|
220
|
+
for (const m of (src.get(g) || '').matchAll(/import\s*\{([^}]*)\}\s*from\s*['"](\.[^'"]+)['"]/g)) {
|
|
221
|
+
if (!m[1].split(',').some(x => x.trim().split(/\s+as\s+/)[0].trim() === fn)) continue;
|
|
222
|
+
const base = resolve(dirname(g), m[2]);
|
|
223
|
+
for (const c of [base, base + '.ets', base + '.ts', join(base, 'index.ets'), join(base, 'index.ts')]) if (resolve(c) === resolve(file)) return true;
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
};
|
|
227
|
+
const callerFiles = (file, fn) => { const k = `${file}::${fn}`; if (callerFilesCache.has(k)) return callerFilesCache.get(k); const out = new Set();
|
|
228
|
+
if (fn.startsWith('constructor@')) { const cls = fn.slice('constructor@'.length); if (cls) { const reN = new RegExp(`\\bnew\\s+${cls}\\s*\\(`); for (const [g] of src) { if (g !== file && reN.test(bl(g))) out.add(g); } } callerFilesCache.set(k, out); return out; }
|
|
229
|
+
const reM = new RegExp(`\\.${fn}\\s*\\(`), reB = new RegExp(`(?<![.\\w$])${fn}\\s*\\(`); for (const [g] of src) { if (g === file) continue; const code = bl(g); if (reM.test(code) || (reB.test(code) && importsFrom(g, fn, file))) out.add(g); } callerFilesCache.set(k, out); return out; };
|
|
230
|
+
const consumedBy = new Map(); // `${file}::${fn}` -> 最终外部消费文件集
|
|
231
|
+
for (let round = 0; round < (IDIOMS.wrappers?.chainRounds || 3); round++) {
|
|
232
|
+
for (const [k, callers] of internalCalls) {
|
|
233
|
+
if (consumedBy.has(k)) continue;
|
|
234
|
+
const [file, fn] = k.split('::');
|
|
235
|
+
const ext = new Set();
|
|
236
|
+
for (const c of callers) { for (const g of callerFiles(file, c)) ext.add(g); for (const g of consumedBy.get(`${file}::${c}`) || []) ext.add(g); }
|
|
237
|
+
if (!ext.size) continue;
|
|
238
|
+
consumedBy.set(k, ext);
|
|
239
|
+
// 消费文件记为真正的外部调用方(链首文件),这样 store 文件自身不计、跨文件消费成立
|
|
240
|
+
for (const d of methodIndex.get(fn) || []) { if (d.file !== file) continue;
|
|
241
|
+
for (const key of d.r) { const e = ensure(key); for (const g of ext) { e.readFiles.add(g); e.evidence.push(`读经 ${fn} ← 同文件 ${[...callers].join('/')}() ← ${rel(g)}`); } }
|
|
242
|
+
for (const key of d.w) { const e = ensure(key); for (const g of ext) { e.writeFiles.add(g); e.evidence.push(`写经 ${fn} ← 同文件 ${[...callers].join('/')}() ← ${rel(g)}`); } } }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---------- 裁决 ----------
|
|
248
|
+
const isUiFile = f => /@(Component|ComponentV2|Entry|CustomDialog)\b/.test(src.get(f) || '');
|
|
249
|
+
const storeFiles = new Set([...ledger.values()].flatMap(e => [...e.readMethods, ...e.writeMethods].map(m => m.file)));
|
|
250
|
+
// 仓储模式兜底(实测 9 误报回流):直接调用扫描找不到读者时,若键的读方法住在
|
|
251
|
+
// Repo/Store 类里且该类名被其他文件引用,按类级一跳把引用文件计为消费方(弱证据,降误报)
|
|
252
|
+
const REPO_HINT = /repo|store|manager|dao/i;
|
|
253
|
+
for (const [key, e] of ledger.entries()) {
|
|
254
|
+
if (e.readFiles.size > 0 || !e.readMethods.length) continue;
|
|
255
|
+
for (const m of e.readMethods) {
|
|
256
|
+
// 类按方法包围位置绑定(methodIndex 已带 cls);证据要求成员访问/实例化,纯提及类名不算
|
|
257
|
+
const def = (methodIndex.get(m.fn) || []).find(d => d.file === m.file);
|
|
258
|
+
const cls = def && def.cls;
|
|
259
|
+
if (!cls || !REPO_HINT.test(cls)) continue;
|
|
260
|
+
const useRe = new RegExp(`(?:\\b${cls}\\s*\\.|\\bnew\\s+${cls}\\b)`);
|
|
261
|
+
for (const [f] of src) {
|
|
262
|
+
if (f === m.file) continue;
|
|
263
|
+
if (useRe.test(bl(f))) { e.readFiles.add(f); e.evidence.push(`类级一跳:${cls} 被 ${rel(f)} 成员访问/实例化(弱证据)`); }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const findings = [];
|
|
268
|
+
// 写入文件里有变量键读取 → 该键的读取路径静态不可判:只记 info,不立案(同 check-routes 的动态跳转降级原则)
|
|
269
|
+
const dynamicOf = e => [...e.writeFiles].find(f => dynamicReadFiles.has(f));
|
|
270
|
+
for (const [key, e] of [...ledger.entries()].sort()) {
|
|
271
|
+
const dyn = dynamicOf(e);
|
|
272
|
+
if (dyn) { findings.push({ type: 'read_unresolvable', severity: 'info', key, basis: '判据:写入文件存在持久化 API 的变量键读取(如 getSync(k) 循环),读取路径静态不可判', evidence: `${rel(dyn)} 有 ${dynamicReadFiles.get(dyn)} 处变量键读取——该键消费与否静态不可判,不立案;不要为过门把变量键改写成字面量` }); continue; }
|
|
273
|
+
const extReadFiles = [...e.readFiles].filter(f => !storeFiles.has(f) || isUiFile(f));
|
|
274
|
+
const extWriteFiles = [...e.writeFiles].filter(f => !storeFiles.has(f) || isUiFile(f));
|
|
275
|
+
const readerFilesBeyondWriters = extReadFiles.filter(f => !extWriteFiles.includes(f));
|
|
276
|
+
if (e.readFiles.size === 0 && e.readMethods.length === 0) {
|
|
277
|
+
findings.push({ type: 'write_only', severity: 'case', key, basis: '判据:写触点存在且全工程无读取路径(读取路径=直接读触点∪store读方法的外部调用∪类级一跳)', evidence: `写入见 ${[...e.writeFiles].slice(0, 2).map(rel).join(', ')};全工程无读取路径` });
|
|
278
|
+
} else if (extReadFiles.length === 0 && e.readMethods.length > 0) {
|
|
279
|
+
findings.push({ type: 'read_path_unused', severity: 'case', key, basis: '判据:store 内存在含该键读触点的方法,但全工程无该方法调用点', evidence: `store 内有读取方法 ${e.readMethods.map(m => m.fn + '()').slice(0, 2).join('/')},但全工程无人调用` });
|
|
280
|
+
} else if (extWriteFiles.length > 0 && readerFilesBeyondWriters.length === 0 && extReadFiles.length > 0) {
|
|
281
|
+
findings.push({ type: 'self_loop_consumer', severity: 'case', key, basis: '判据:存在外部读取文件,但全部与写入文件重合(自渲染类合法场景需回源确认)', evidence: `唯一读取方 ${extReadFiles.map(rel).slice(0, 2).join(', ')} 即写入方本身;未发现跨文件功能消费——与源码同构即合法:带理由豁免或写进 $SPEC/closure-idioms.json;不要为过门重构代码` });
|
|
282
|
+
} else if (extWriteFiles.length === 0 && e.writeFiles.size === 0) {
|
|
283
|
+
findings.push({ type: 'read_only', severity: 'case', key, basis: '判据:读触点存在且全工程无写入触点(默认值驱动可豁免)', evidence: `读取见 ${extReadFiles.slice(0, 2).map(rel).join(', ')};全工程无写入(若靠默认值可豁免)` });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const report = {
|
|
287
|
+
project: relative(process.cwd(), resolve(PROJECT)) || '.',
|
|
288
|
+
stats: { files: files.length, keys: ledger.size, sites: keySites.length },
|
|
289
|
+
keys: Object.fromEntries([...ledger.entries()].map(([k, e]) => [k, {
|
|
290
|
+
readMethods: e.readMethods.map(m => `${m.fn}@${rel(m.file)}`),
|
|
291
|
+
writeMethods: e.writeMethods.map(m => `${m.fn}@${rel(m.file)}`),
|
|
292
|
+
readFiles: [...e.readFiles].map(rel), writeFiles: [...e.writeFiles].map(rel),
|
|
293
|
+
}])),
|
|
294
|
+
findings,
|
|
295
|
+
};
|
|
296
|
+
if (OUT) writeFileSync(OUT, JSON.stringify(report, null, 2));
|
|
297
|
+
console.log(`[check-consumers] ${resolve(PROJECT)}`);
|
|
298
|
+
console.log(` 键 ${ledger.size} · 触点 ${keySites.length}`);
|
|
299
|
+
for (const f of findings) console.log(` ${f.severity === 'info' ? '· INFO' : '▲ CASE'} ${f.type} [${f.key}] — ${f.evidence}`);
|
|
300
|
+
if (!findings.length) console.log(' ✓ 全部键读写闭合');
|
|
301
|
+
process.exit(STRICT && findings.some(f => f.severity !== 'info') ? 2 : 0);
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-permissions.mjs — 平台能力契约门(权限声明 + 契约链 + 静默降级;数据驱动,扩展只改 API_PERMS 表)。
|
|
4
|
+
*
|
|
5
|
+
* 病根:鸿蒙平台能力是"声明 + 授权 + 调用 + 兜底"**整条链**,断任何一环运行时静默失效——
|
|
6
|
+
* 模拟器/dev 环境常宽松放行 → build 过、走查过,真机上功能悄悄不工作(实证:长驻任务启动失败被
|
|
7
|
+
* catch 只 log 吞掉,用户看到的是"后台计时没了、也没弹权限")。单点查声明拦不住链断。
|
|
8
|
+
*
|
|
9
|
+
* 三层判定:
|
|
10
|
+
* 1) 权限声明(确定性 → violation):用了要权限的 API,module.json5 未声明。
|
|
11
|
+
* 2) 契约链(表条目可选 chain):
|
|
12
|
+
* - chain.declare:module.json5 级配套声明(如 taskKeeping 需 abilities.backgroundModes)。
|
|
13
|
+
* hard=true 时缺失为确定性 violation(API 必失败);否则软立案。
|
|
14
|
+
* - chain.authorize:全工程须存在的授权动作(如长驻任务依赖通知授权)。静态只能证"存在与否",
|
|
15
|
+
* 缺失记软立案(case)由模型回源裁决,绝不硬拦。
|
|
16
|
+
* 3) 静默降级(通用,对所有表条目命中点):能力调用在 try 内且对应 catch 体**只有日志/取错**
|
|
17
|
+
* (无状态回退/用户提示/重抛等任何补救)→ 软立案 silent_degrade。
|
|
18
|
+
* 4) 受限开放权限(表条目 restricted;官方"受限开放权限"清单:normal 应用须 ACL 才能申请):用到即软立案
|
|
19
|
+
* restricted_permission,证据给免权限替代路线(系统 Picker / 安全控件);不判 violation——ACL 静态看不见。
|
|
20
|
+
* 条目可带 scope(文件须含该 import 才算,避免同名方法误伤)与 legal(文件里有此形态即合法,如 SaveButton)。
|
|
21
|
+
*
|
|
22
|
+
* 用法:check-permissions.mjs --project <鸿蒙工程根> [--json out]
|
|
23
|
+
* 退出码:0 无硬违规 / 10 有硬违规 / 3 用法错(软立案不改退出码——closure-suite final 阶段汇总裁决)
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
26
|
+
import { join, relative, resolve, dirname } from 'node:path';
|
|
27
|
+
import { walkFiles, stripComments, blankStrings, moduleSrcRoots, tryCatchRanges, enclosingTry } from '../lib/scan.mjs';
|
|
28
|
+
|
|
29
|
+
const argv = process.argv.slice(2);
|
|
30
|
+
const argOf = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
|
|
31
|
+
const PROJECT = argOf('--project', null);
|
|
32
|
+
if (!PROJECT) { console.error('usage: check-permissions.mjs --project <root> [--json out]'); process.exit(3); }
|
|
33
|
+
|
|
34
|
+
// API 用法 → 所需权限(只收清晰的"用到即必需"映射)+ 可选 chain(契约链环节)。扩展在此一处。
|
|
35
|
+
const API_PERMS = [
|
|
36
|
+
{ re: /\bvibrator\s*\.\s*(startVibration|isSupportEffect|isSupportEffectSync|stopVibration)/, perm: 'ohos.permission.VIBRATE', cap: '振动/触感反馈' },
|
|
37
|
+
{
|
|
38
|
+
re: /\b(backgroundTaskManager|continuousTask)\s*\.\s*startBackgroundRunning|@ohos\.resourceschedule\.backgroundTaskManager/,
|
|
39
|
+
perm: 'ohos.permission.KEEP_BACKGROUND_RUNNING', cap: '长时后台任务(ContinuousTask)',
|
|
40
|
+
chain: {
|
|
41
|
+
declare: {
|
|
42
|
+
re: /backgroundModes[\s\S]{0,160}?taskKeeping/, hard: true,
|
|
43
|
+
miss: 'abilities 未声明 backgroundModes:["taskKeeping"]——startBackgroundRunning 必然运行时失败',
|
|
44
|
+
},
|
|
45
|
+
authorize: {
|
|
46
|
+
re: /requestEnableNotification/,
|
|
47
|
+
miss: '全工程未见通知授权请求(requestEnableNotification)——taskKeeping 长驻依赖 ongoing 通知,未授权时启动失败;应在启动长驻链路前确保授权',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{ re: /\breminderAgentManager\s*\.\s*(publishReminder|addReminder)/, perm: 'ohos.permission.PUBLISH_AGENT_REMINDER', cap: '提醒代理/精确定时' },
|
|
52
|
+
{ re: /\bgeoLocationManager\s*\.\s*(getCurrentLocation|on\s*\(\s*['"]locationChange)/, perm: 'ohos.permission.APPROXIMATELY_LOCATION', cap: '定位' },
|
|
53
|
+
{ re: /\bcameraManager|@ohos\.multimedia\.camera|\bcamera\s*\.\s*getCameraManager/, perm: 'ohos.permission.CAMERA', cap: '相机' },
|
|
54
|
+
{ re: /\baudioCapturer|\.createAudioCapturer|AudioCapturer/, perm: 'ohos.permission.MICROPHONE', cap: '麦克风录音' },
|
|
55
|
+
{ re: /\bhttp\s*\.\s*createHttp|@ohos\.net\.http|\.connectServer|new\s+WebSocket|(?<!\.)\bfetch\s*\(/, perm: 'ohos.permission.INTERNET', cap: '网络访问' },
|
|
56
|
+
{ re: /\bprint\s*\.\s*print\s*\(/, scope: /@ohos\.print\b|\{[^}]*\bprint\b[^}]*\}\s*from\s*['"]@kit\.BasicServicesKit/, perm: 'ohos.permission.PRINT', cap: '打印' },
|
|
57
|
+
// ---- 受限开放权限(用到即软立案;权限名与是否受限以 SDK permissions.d.ts + 官方受限清单为准)----
|
|
58
|
+
{ re: /\bcontact\s*\.\s*(queryContacts?|queryMyCard|isLocalContact|queryKey|queryHolders|queryGroups)\s*\(/, perm: 'ohos.permission.READ_CONTACTS', cap: '读通讯录', restricted: 'contact.selectContacts(系统选人,免权限)' },
|
|
59
|
+
{ re: /\bcontact\s*\.\s*(addContact|deleteContact|updateContact)\s*\(/, perm: 'ohos.permission.WRITE_CONTACTS', cap: '写通讯录', restricted: '写入交给系统通讯录应用,应用侧只存自己的数据' },
|
|
60
|
+
{ re: /\.(getAssets|getAlbums)\s*\(/, scope: /@kit\.MediaLibraryKit|@ohos\.file\.photoAccessHelper/, perm: 'ohos.permission.READ_IMAGEVIDEO', cap: '全量读媒体库', restricted: 'PhotoViewPicker.select(免权限)' },
|
|
61
|
+
{ re: /\.(createAsset|applyChanges)\s*\(/, scope: /@kit\.MediaLibraryKit|@ohos\.file\.photoAccessHelper/, legal: /\bSaveButton\s*\(/, perm: 'ohos.permission.WRITE_IMAGEVIDEO', cap: '直写媒体库', restricted: 'SaveButton.onClick 内 applyChanges 或 showAssetsCreationDialog(免权限)' },
|
|
62
|
+
{ re: /\bcall\s*\.\s*dial\s*\(/, perm: 'ohos.permission.PLACE_CALL', cap: '直接拨号(dial)' }, // makeCall 拉起拨号盘不需权限(SDK d.ts 各重载无 @permission),只有 dial 要
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const rel = f => relative(resolve(PROJECT), f).replace(/\\/g, '/');
|
|
66
|
+
// 全模块扫描(entry + 任意 HSP/HAR 模块——实证多模块工程九成源码曾不被查)
|
|
67
|
+
const ROOTS = moduleSrcRoots(resolve(PROJECT));
|
|
68
|
+
|
|
69
|
+
// 各模块 module.json5 的权限声明并集(文本级抽取,免 JSON5 解析)
|
|
70
|
+
const modPaths = ROOTS.map(r => join(r, '..', 'module.json5')).filter(existsSync);
|
|
71
|
+
const declared = new Set();
|
|
72
|
+
let moduleTxt = '';
|
|
73
|
+
for (const mp of modPaths) {
|
|
74
|
+
const txt = stripComments(readFileSync(mp, 'utf8'));
|
|
75
|
+
moduleTxt += '\n' + txt;
|
|
76
|
+
for (const m of txt.matchAll(/ohos\.permission\.[A-Z_]+/g)) declared.add(m[0]);
|
|
77
|
+
}
|
|
78
|
+
const modPath = modPaths[0] || null;
|
|
79
|
+
|
|
80
|
+
// try/catch 追踪走共享层 scan.mjs(tryCatchRanges/enclosingTry)——反手拷。
|
|
81
|
+
// catch 体只有 日志调用 / 取错变量(const err = e as X)/ 空 → 判"静默吞";有任何其它语句(状态回退、
|
|
82
|
+
// 用户提示、重抛、函数调用)→ 视为有补救,放行。
|
|
83
|
+
function catchOnlyLogs(body) {
|
|
84
|
+
const stmts = body.split(/[;\n]/).map(s => s.trim()).filter(Boolean);
|
|
85
|
+
if (!stmts.length) return true;
|
|
86
|
+
return stmts.every(s =>
|
|
87
|
+
/^(console|hilog|Logger|log)\s*[.(]/.test(s) ||
|
|
88
|
+
/^[\w$.]+\.(error|warn|info|debug|log|fatal)\s*\(/.test(s) ||
|
|
89
|
+
/^(const|let|var)\s+[\w$]+\s*(:\s*[\w$.<>[\], ]+)?\s*=\s*[\w$]+(\s+as\s+[\w$.]+)?$/.test(s));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 扫源码:命中点收集 + 授权动作探测 + 静默降级判定
|
|
93
|
+
const used = new Map(); // perm -> {cap, rule, sites:[{file,line}]}
|
|
94
|
+
const restrictedHits = new Map(); // perm -> {rule, sites}(受限开放:单独记账,不进 missing_permission)
|
|
95
|
+
const authorizeSeen = new Set(); // authorize.re.source 集合
|
|
96
|
+
const silent = [];
|
|
97
|
+
for (const ROOT of ROOTS) for (const f of walkFiles(ROOT, { match: (_p, e) => e.endsWith('.ets') || e.endsWith('.ts') })) {
|
|
98
|
+
const raw = readFileSync(f, 'utf8');
|
|
99
|
+
const plain = stripComments(raw); // 保留字符串(import 路径在字符串里)
|
|
100
|
+
const code = blankStrings(plain);
|
|
101
|
+
let tries = null;
|
|
102
|
+
for (const rule of API_PERMS) {
|
|
103
|
+
if (rule.scope && !rule.scope.test(plain)) continue; // 同名方法不在该 Kit 的文件里不算
|
|
104
|
+
if (rule.legal && rule.legal.test(code)) continue; // 文件里有合法形态(如 SaveButton 安全控件)→ 放行
|
|
105
|
+
const g = new RegExp(rule.re.source, rule.re.flags.includes('g') ? rule.re.flags : rule.re.flags + 'g');
|
|
106
|
+
let m;
|
|
107
|
+
while ((m = g.exec(code)) !== null) {
|
|
108
|
+
const line = raw.slice(0, m.index).split('\n').length;
|
|
109
|
+
const book = rule.restricted ? restrictedHits : used;
|
|
110
|
+
if (!book.has(rule.perm)) book.set(rule.perm, { cap: rule.cap, rule, sites: [] });
|
|
111
|
+
book.get(rule.perm).sites.push({ file: rel(f), line });
|
|
112
|
+
// 静默降级(通用环节)
|
|
113
|
+
tries = tries || tryCatchRanges(code);
|
|
114
|
+
const t = enclosingTry(tries, m.index);
|
|
115
|
+
if (t && t.catchStart >= 0 && catchOnlyLogs(code.slice(t.catchStart + 1, t.catchEnd))) {
|
|
116
|
+
silent.push({ severity: 'case', type: 'silent_degrade', name: rule.cap, file: rel(f), line,
|
|
117
|
+
evidence: `${rel(f)}:${line} ${rule.cap} 调用失败被静默吞(catch 只有日志/取错,无状态回退/用户提示/重抛)——真机上表现为功能悄悄不工作(裁决:catch 内补状态回退或用户可见提示/上抛;确属可忽略则带理由豁免)` });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (rule.chain && rule.chain.authorize && !authorizeSeen.has(rule.chain.authorize.re.source)
|
|
121
|
+
&& rule.chain.authorize.re.test(code)) authorizeSeen.add(rule.chain.authorize.re.source);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const findings = [];
|
|
126
|
+
for (const [perm, info] of used) {
|
|
127
|
+
if (!declared.has(perm)) {
|
|
128
|
+
const site = info.sites[0];
|
|
129
|
+
findings.push({ severity: 'violation', type: 'missing_permission', name: perm,
|
|
130
|
+
file: site.file, line: site.line,
|
|
131
|
+
evidence: `${site.file}:${site.line} 用到${info.cap}(需 ${perm}) 但 module.json5 requestPermissions 未声明——真机上能力失效/抛异常(用了 ${info.sites.length} 处)` });
|
|
132
|
+
}
|
|
133
|
+
const ch = info.rule.chain;
|
|
134
|
+
if (!ch) continue;
|
|
135
|
+
if (ch.declare && !ch.declare.re.test(moduleTxt)) {
|
|
136
|
+
findings.push({ severity: ch.declare.hard ? 'violation' : 'case', type: 'missing_declare', name: info.cap,
|
|
137
|
+
file: modPath ? rel(modPath) : 'module.json5', line: 1,
|
|
138
|
+
evidence: `${info.cap}:${ch.declare.miss}(调用见 ${info.sites[0].file}:${info.sites[0].line})` });
|
|
139
|
+
}
|
|
140
|
+
if (ch.authorize && !authorizeSeen.has(ch.authorize.re.source)) {
|
|
141
|
+
findings.push({ severity: 'case', type: 'missing_authorize', name: info.cap,
|
|
142
|
+
file: info.sites[0].file, line: info.sites[0].line,
|
|
143
|
+
evidence: `${info.cap}:${ch.authorize.miss}` });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const [perm, info] of restrictedHits) {
|
|
147
|
+
const site = info.sites[0];
|
|
148
|
+
findings.push({ severity: 'case', type: 'restricted_permission', name: perm, file: site.file, line: site.line,
|
|
149
|
+
evidence: declared.has(perm)
|
|
150
|
+
? `${site.file}:${site.line} ${info.cap}用到受限开放权限 ${perm}(normal 应用须 ACL)且已声明——无 ACL 则签名/安装即失败:改走 ${info.rule.restricted};确有 ACL 带理由豁免(用了 ${info.sites.length} 处)`
|
|
151
|
+
: `${site.file}:${site.line} ${info.cap}用到受限开放权限 ${perm}(normal 应用须 ACL 才能申请)且未声明——改走 ${info.rule.restricted};确有 ACL 则声明并带理由豁免(用了 ${info.sites.length} 处)` });
|
|
152
|
+
}
|
|
153
|
+
findings.push(...silent);
|
|
154
|
+
|
|
155
|
+
const vio = findings.filter(f => f.severity === 'violation');
|
|
156
|
+
const cases = findings.filter(f => f.severity === 'case');
|
|
157
|
+
const report = { project: relative(process.cwd(), resolve(PROJECT)) || '.', module: modPath ? rel(modPath) : null,
|
|
158
|
+
declared: [...declared], usedPermissions: [...used.keys()], findings };
|
|
159
|
+
const out = argOf('--json', null);
|
|
160
|
+
if (out) { mkdirSync(dirname(resolve(out)), { recursive: true }); writeFileSync(out, JSON.stringify(report, null, 2)); }
|
|
161
|
+
console.log(`[check-permissions] 声明 ${declared.size} · 用到需权限 API ${used.size + restrictedHits.size} 类 · 硬违规 ${vio.length} · 软立案 ${cases.length}(链断${cases.filter(c => c.type !== 'silent_degrade').length}/静默降级${cases.filter(c => c.type === 'silent_degrade').length})`);
|
|
162
|
+
for (const v of vio.slice(0, 10)) console.log(` ✗ ${v.evidence}`);
|
|
163
|
+
for (const c of cases.slice(0, 10)) console.log(` ▲ ${c.evidence}`);
|
|
164
|
+
if (!vio.length && !cases.length) console.log(' ✓ 能力权限/契约链/失败兜底齐全');
|
|
165
|
+
process.exit(vio.length ? 10 : 0);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-resources.mjs — $r 资源引用闭合检查。
|
|
4
|
+
*
|
|
5
|
+
* 两本账:代码中的 $r('app.<桶>.<名>') 引用 × resources 实际声明。
|
|
6
|
+
* 运行时 app.* 只在**本模块 + AppScope** 解析,故声明账按模块分桶对账
|
|
7
|
+
* (并桶会让 A 模块借 B 模块声明蒙混——真悬空漏报)。
|
|
8
|
+
* 引用了本模块∪AppScope 都没有的名字 = violation;动态引用计 unresolved;
|
|
9
|
+
* element JSON 解析失败**不吞**:记 parse_error 立案,且该模块的悬空判定降级
|
|
10
|
+
* 为 case(声明账不完整时不定罪)。$r('sys.*') 不在对账范围。
|
|
11
|
+
*
|
|
12
|
+
* 用法:node check-resources.mjs --project <鸿蒙工程根> [--json out] 退出码 0/10/3
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { resolve, sep, relative, join } from 'node:path';
|
|
16
|
+
import { stripComments, walkFiles } from '../lib/scan.mjs';
|
|
17
|
+
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const argOf = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
|
|
20
|
+
const PROJECT = argOf('--project', null);
|
|
21
|
+
const OUT = argOf('--json', null);
|
|
22
|
+
if (!PROJECT) { console.error('usage: check-resources.mjs --project <root> [--json out]'); process.exit(3); }
|
|
23
|
+
|
|
24
|
+
const files = walkFiles(resolve(PROJECT), {}); // spec/ 由 walkFiles 缺省排除(迁移工作区不是交付物)
|
|
25
|
+
const norm = p => p.replace(/\\/g, '/');
|
|
26
|
+
const rel = f => String(f).replace(resolve(PROJECT) + sep, '');
|
|
27
|
+
|
|
28
|
+
// ---------- 声明账(按模块分桶;AppScope 全局共享) ----------
|
|
29
|
+
const BUCKETS = ['string', 'color', 'media', 'float', 'plural', 'integer', 'boolean'];
|
|
30
|
+
const emptyDecl = () => Object.fromEntries(BUCKETS.map(b => [b, new Set()]));
|
|
31
|
+
const modules = new Map(); // moduleRoot -> decl
|
|
32
|
+
const appScope = emptyDecl();
|
|
33
|
+
const parseErrors = []; // {file, module, err}
|
|
34
|
+
const modBroken = new Set(); // 声明账不完整的模块(含 AppScope 标记 '*')
|
|
35
|
+
function declFor(n) {
|
|
36
|
+
if (n.includes('/AppScope/')) return { decl: appScope, mod: '*AppScope*' };
|
|
37
|
+
const m = n.match(/^(.*)\/src\/main\/resources\//);
|
|
38
|
+
if (!m) return { decl: appScope, mod: '*AppScope*' }; // 非常规布局并入全局(保守)
|
|
39
|
+
if (!modules.has(m[1])) modules.set(m[1], emptyDecl());
|
|
40
|
+
return { decl: modules.get(m[1]), mod: m[1] };
|
|
41
|
+
}
|
|
42
|
+
for (const f of files) {
|
|
43
|
+
const n = norm(f);
|
|
44
|
+
if (n.match(/\/resources\/[^/]+\/element\/[^/]+\.json$/)) {
|
|
45
|
+
const { decl, mod } = declFor(n);
|
|
46
|
+
try {
|
|
47
|
+
const j = JSON.parse(readFileSync(f, 'utf8'));
|
|
48
|
+
for (const [bucket, arr] of Object.entries(j)) {
|
|
49
|
+
if (decl[bucket]) for (const it of arr || []) if (it.name) decl[bucket].add(it.name);
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
parseErrors.push({ file: rel(f), module: mod, err: String(e.message).slice(0, 120) });
|
|
53
|
+
modBroken.add(mod === '*AppScope*' ? '*' : mod);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const mm = n.match(/\/resources\/[^/]+\/media\/([^/]+)\.\w+$/);
|
|
57
|
+
if (mm) declFor(n).decl.media.add(mm[1]);
|
|
58
|
+
}
|
|
59
|
+
const moduleOfFile = (n) => {
|
|
60
|
+
for (const root of modules.keys()) if (n.startsWith(root + '/')) return root;
|
|
61
|
+
return null;
|
|
62
|
+
};
|
|
63
|
+
const lookup = (mod, bucket, name) => {
|
|
64
|
+
const inScope = appScope[bucket] && appScope[bucket].has(name);
|
|
65
|
+
if (mod && modules.has(mod)) return (modules.get(mod)[bucket] && modules.get(mod)[bucket].has(name)) || inScope;
|
|
66
|
+
// 模块未知:对全部模块∪AppScope 查(保守放行,不误伤)
|
|
67
|
+
return inScope || [...modules.values()].some(d => d[bucket] && d[bucket].has(name));
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// ---------- 引用账(共享库注释脱敏:文档注释里的 $r 示例是头号误报源) ----------
|
|
71
|
+
const refs = [];
|
|
72
|
+
let unresolved = 0;
|
|
73
|
+
for (const f of files.filter(x => x.endsWith('.ets') || x.endsWith('.ts'))) {
|
|
74
|
+
const n = norm(f);
|
|
75
|
+
const code = stripComments(readFileSync(f, 'utf8'));
|
|
76
|
+
const mod = moduleOfFile(n);
|
|
77
|
+
for (const m of code.matchAll(/\$r\s*\(\s*(['"])app\.(\w+)\.([\w.]+)\1/g)) {
|
|
78
|
+
refs.push({ bucket: m[2], name: m[3], file: f, mod, line: code.slice(0, m.index).split('\n').length });
|
|
79
|
+
}
|
|
80
|
+
for (const m of code.matchAll(/\$r\s*\(\s*[^'")]/g)) unresolved++;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------- 裁决 ----------
|
|
84
|
+
const findings = [];
|
|
85
|
+
for (const p of parseErrors) findings.push({ severity: 'case', type: 'resource_parse_error', evidence: `${p.file} 解析失败(${p.err})——该模块声明账不完整,悬空判定已降级` });
|
|
86
|
+
for (const r of refs) {
|
|
87
|
+
if (!BUCKETS.includes(r.bucket)) { findings.push({ severity: 'case', type: 'unknown_bucket', evidence: `${rel(r.file)}:${r.line} $r('app.${r.bucket}.${r.name}') 桶未知` }); continue; }
|
|
88
|
+
if (!lookup(r.mod, r.bucket, r.name)) {
|
|
89
|
+
const broken = modBroken.has('*') || (r.mod && modBroken.has(r.mod));
|
|
90
|
+
findings.push(broken
|
|
91
|
+
? { severity: 'case', type: 'dangling_resource_unverifiable', name: `app.${r.bucket}.${r.name}`, file: rel(r.file), line: r.line, evidence: `${rel(r.file)}:${r.line} $r('app.${r.bucket}.${r.name}') 未见声明,但该模块有 element 解析失败——修 JSON 后复查` }
|
|
92
|
+
: { severity: 'violation', type: 'dangling_resource', name: `app.${r.bucket}.${r.name}`, file: rel(r.file), line: r.line, evidence: `${rel(r.file)}:${r.line} $r('app.${r.bucket}.${r.name}') 未在本模块/AppScope 声明(运行时取不到)` });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// 悬空 media ↔ 静态资源转换器的跳过账(spec/front/resource-manifest.json skipped[]:"<源路径> (<原因>)"):
|
|
96
|
+
// 源 drawable 存在但转换器没搬(形状/选择器 XML 等)→ 证据直接给源文件与原因,修法明确(手写等价),不再让模型回源找
|
|
97
|
+
const MANIFEST = join(resolve(PROJECT), 'spec', 'front', 'resource-manifest.json');
|
|
98
|
+
const skippedByName = new Map();
|
|
99
|
+
if (existsSync(MANIFEST)) {
|
|
100
|
+
try {
|
|
101
|
+
for (const s of JSON.parse(readFileSync(MANIFEST, 'utf8')).skipped || []) {
|
|
102
|
+
const m = String(s).match(/^(.*?)\s+\((.*)\)$/); const path = m ? m[1] : String(s);
|
|
103
|
+
const base = path.replace(/\\/g, '/').split('/').pop().replace(/\.[^.]+$/, '');
|
|
104
|
+
if (!skippedByName.has(base)) skippedByName.set(base, { path, reason: m ? m[2] : '' });
|
|
105
|
+
}
|
|
106
|
+
} catch { /* 清单坏了不影响裁决 */ }
|
|
107
|
+
}
|
|
108
|
+
let linkedSkipped = 0;
|
|
109
|
+
for (const f of findings) {
|
|
110
|
+
if (f.type !== 'dangling_resource' || !String(f.name).startsWith('app.media.')) continue;
|
|
111
|
+
const s = skippedByName.get(String(f.name).slice('app.media.'.length)); if (!s) continue;
|
|
112
|
+
f.sourceSkipped = s; linkedSkipped++;
|
|
113
|
+
f.evidence += `——源 ${s.path} 转换器已跳过(${s.reason}):手写等价(形状/选择器 drawable → 组件属性或 SVG)`;
|
|
114
|
+
}
|
|
115
|
+
const declTotals = emptyDecl();
|
|
116
|
+
for (const d of [...modules.values(), appScope]) for (const b of BUCKETS) for (const x of d[b]) declTotals[b].add(x);
|
|
117
|
+
const report = {
|
|
118
|
+
project: relative(process.cwd(), resolve(PROJECT)) || '.',
|
|
119
|
+
stats: { refs: refs.length, unresolvedDynamic: unresolved, modules: [...modules.keys()].map(rel), parseErrors: parseErrors.length, declared: Object.fromEntries(BUCKETS.map(b => [b, declTotals[b].size])), linkedSkipped },
|
|
120
|
+
findings,
|
|
121
|
+
};
|
|
122
|
+
if (OUT) writeFileSync(OUT, JSON.stringify(report, null, 2));
|
|
123
|
+
const vio = findings.filter(f => f.severity === 'violation');
|
|
124
|
+
const cases = findings.filter(f => f.severity === 'case');
|
|
125
|
+
console.log(`[check-resources] 引用 ${refs.length}(动态 ${unresolved}) · 模块 ${modules.size} · 声明 string:${declTotals.string.size} color:${declTotals.color.size} media:${declTotals.media.size}${parseErrors.length ? ` · ⚠ 解析失败 ${parseErrors.length}` : ''}`);
|
|
126
|
+
for (const f of vio.slice(0, 15)) console.log(` ✗ VIOLATION ${f.evidence}`);
|
|
127
|
+
if (vio.length > 15) console.log(` …共 ${vio.length} 条`);
|
|
128
|
+
for (const f of cases.slice(0, 8)) console.log(` ▲ CASE ${f.type} ${f.evidence}`.slice(0, 160));
|
|
129
|
+
if (!vio.length && !cases.length) console.log(' ✓ 引用全部闭合(0 悬空)');
|
|
130
|
+
process.exit(vio.length ? 10 : 0);
|