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,527 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-routes.mjs — 路由可达性闭合检查(三态:violation / case / pass)
|
|
4
|
+
*
|
|
5
|
+
* 对账三本账:注册表(Navigation pageMap 比较分支 + 路由常量 + main_pages.json)
|
|
6
|
+
* × 跳转调用(pushPathByName 族 / router.pushUrl 族,含工具类包装器链式解析)
|
|
7
|
+
* × 入口连通(@Entry / loadContent 起点的 import 闭包 BFS)
|
|
8
|
+
*
|
|
9
|
+
* 裁决纪律:
|
|
10
|
+
* - violation(硬,exit 10):仅"两本账自相矛盾"——跳转了一个任何地方都没注册的路由名。
|
|
11
|
+
* - case(软,exit 0,--strict 时 exit 2):注册了但找不到静态入口 / 唯一入口在无人调用的
|
|
12
|
+
* 包装器里 / 从入口连通图不可达。立案待裁决,不是定罪。
|
|
13
|
+
* - 动态名(模板串/变量)一律进 unresolved 不定罪;模板串按注册表的 {param} 前缀匹配;
|
|
14
|
+
* 含动态跳转的文件对其引用的路由常量给予 soft-credit(inferred),防"循环跳转表"误伤。
|
|
15
|
+
* - 包装器机制只作用于非组件文件(工具类);组件方法内的跳转按文件归属直接计。
|
|
16
|
+
*
|
|
17
|
+
* - 注册页骨架残留(scaffold_page_residue):route_map 注册的页文件仍含 wire 占位标记——slice 阶段不立案、
|
|
18
|
+
* integration 立案、final 硬违规(--stage 由 closure-suite 透传);pageSourceFile 缺失恒为硬违规。
|
|
19
|
+
*
|
|
20
|
+
* 用法:node check-routes.mjs --project <鸿蒙工程根> [--json out.json] [--strict] [--profile p.json] [--stage slice|integration|final]
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { join, dirname, resolve, sep, relative } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
26
|
+
import { stripComments, blankStrings, functionBodies, walkFiles, moduleSrcRoots } from '../lib/scan.mjs';
|
|
27
|
+
|
|
28
|
+
const argv = process.argv.slice(2);
|
|
29
|
+
const argOf = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
|
|
30
|
+
const PROJECT = argOf('--project', null);
|
|
31
|
+
const OUT = argOf('--json', null);
|
|
32
|
+
const STRICT = argv.includes('--strict');
|
|
33
|
+
// 阶段(closure-suite 透传):只影响"注册页骨架残留"的立案级别——slice 不立案 / integration case / final violation
|
|
34
|
+
const STAGE = ['slice', 'integration', 'final'].includes(argOf('--stage', 'integration')) ? argOf('--stage', 'integration') : 'integration';
|
|
35
|
+
const PROFILE = argOf('--profile', null) ? JSON.parse(readFileSync(argOf('--profile', null), 'utf8')) : {};
|
|
36
|
+
// 写法知识表:数据不硬编码。默认随包 idioms.json,可 --idioms 覆盖(顶层键浅合并)。
|
|
37
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const IDIOMS = { ...JSON.parse(readFileSync(join(HERE, 'idioms.json'), 'utf8')) };
|
|
39
|
+
const idiomsOverride = argOf('--idioms', null);
|
|
40
|
+
if (idiomsOverride) { const o = JSON.parse(readFileSync(idiomsOverride, 'utf8')); for (const k of Object.keys(o)) IDIOMS[k] = { ...IDIOMS[k], ...o[k] }; }
|
|
41
|
+
if (!PROJECT) { console.error('usage: check-routes.mjs --project <root> [--json out] [--strict] [--profile p.json]'); process.exit(3); }
|
|
42
|
+
|
|
43
|
+
const allFiles = walkFiles(resolve(PROJECT), { match: (p, e) => e.endsWith('.ets') || (e === 'main_pages.json' && p.includes(`${sep}profile${sep}`)) });
|
|
44
|
+
const etsFiles = allFiles.filter(f => f.endsWith('.ets'));
|
|
45
|
+
const pageProfiles = allFiles.filter(f => f.endsWith('main_pages.json'));
|
|
46
|
+
// 注释脱敏(共享库:整文本状态机,模板串/正则字面量安全,同长度保偏移)
|
|
47
|
+
const src = new Map(etsFiles.map(f => [f, stripComments(readFileSync(f, 'utf8'))]));
|
|
48
|
+
// 字符串脱敏视图(调用点扫描用:字符串里的调用样例不算调用;偏移与 src 一致,取参回 src)
|
|
49
|
+
const blankCache = new Map();
|
|
50
|
+
const bl = f => { if (!blankCache.has(f)) blankCache.set(f, blankStrings(src.get(f))); return blankCache.get(f); };
|
|
51
|
+
const rel = f => String(f).replace(resolve(PROJECT) + sep, '');
|
|
52
|
+
const COMPONENT_RE = new RegExp(IDIOMS.wrappers.componentDecorators);
|
|
53
|
+
const isComponentFile = f => COMPONENT_RE.test(src.get(f) || '');
|
|
54
|
+
const lineOf = (code, idx) => code.slice(0, idx).split('\n').length;
|
|
55
|
+
|
|
56
|
+
// ---------- 0) 常量 / 枚举成员表 ----------
|
|
57
|
+
// 裸名跨文件同名不同值 = ambiguous(进 unresolved 不定罪);static 成员按 类名.成员 限定存。
|
|
58
|
+
const AMBIG = Symbol('ambiguous');
|
|
59
|
+
const constMap = new Map();
|
|
60
|
+
function setConst(key, val) {
|
|
61
|
+
if (!constMap.has(key)) { constMap.set(key, val); return; }
|
|
62
|
+
if (constMap.get(key) !== val) constMap.set(key, AMBIG);
|
|
63
|
+
}
|
|
64
|
+
for (const [, code] of src) {
|
|
65
|
+
for (const m of code.matchAll(/(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*(?::\s*string)?\s*=\s*(['"])((?:(?!\2).)*)\2/g)) setConst(m[1], m[3]);
|
|
66
|
+
for (const m of code.matchAll(/static\s+(?:readonly\s+)?([A-Za-z_$][\w$]*)\s*(?::\s*string)?\s*=\s*(['"])((?:(?!\2).)*)\2/g)) {
|
|
67
|
+
const cls = [...code.slice(0, m.index).matchAll(/class\s+([A-Za-z_$][\w$]*)/g)].pop();
|
|
68
|
+
if (cls) setConst(`${cls[1]}.${m[1]}`, m[3]);
|
|
69
|
+
setConst(m[1], m[3]); // 裸名仍可查,冲突自动降 ambiguous
|
|
70
|
+
}
|
|
71
|
+
for (const em of code.matchAll(/(?:const\s+)?enum\s+([A-Za-z_$][\w$]*)\s*\{([\s\S]*?)\}/g)) {
|
|
72
|
+
for (const mm of em[2].matchAll(/([A-Za-z_$][\w$]*)\s*=\s*(['"])((?:(?!\2).)*)\2/g)) {
|
|
73
|
+
setConst(`${em[1]}.${mm[1]}`, mm[3]);
|
|
74
|
+
if (!constMap.has(mm[1])) constMap.set(mm[1], mm[3]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function resolveExpr(exprRaw) {
|
|
79
|
+
const e = (exprRaw || '').trim();
|
|
80
|
+
if (!e) return { name: null, kind: 'dynamic', via: '' };
|
|
81
|
+
const lit = e.match(/^(['"])((?:(?!\1).)*)\1$/);
|
|
82
|
+
if (lit) return { name: lit[2], kind: 'literal' };
|
|
83
|
+
if (e.startsWith('`')) {
|
|
84
|
+
const plain = e.match(/^`([^`$\\]*)`$/); // 无插值模板串=字面量
|
|
85
|
+
if (plain) return { name: plain[1], kind: 'literal' };
|
|
86
|
+
const m = e.match(/^`([^`$]*)\$\{/);
|
|
87
|
+
return { name: null, kind: 'template', prefix: m ? m[1] : '', via: e.slice(0, 40) };
|
|
88
|
+
}
|
|
89
|
+
const hit = (k) => constMap.get(k) === AMBIG
|
|
90
|
+
? { name: null, kind: 'ambiguous', via: `${k}(跨文件同名不同值,进 unresolved)` }
|
|
91
|
+
: { name: constMap.get(k), kind: k.includes('.') ? 'enum' : 'const', via: k };
|
|
92
|
+
if (constMap.has(e)) return hit(e);
|
|
93
|
+
const q = e.match(/^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/);
|
|
94
|
+
if (q && constMap.has(`${q[1]}.${q[2]}`)) return hit(`${q[1]}.${q[2]}`);
|
|
95
|
+
return { name: null, kind: 'dynamic', via: e.slice(0, 40) };
|
|
96
|
+
}
|
|
97
|
+
function firstArg(code, idx) {
|
|
98
|
+
let depth = 0, out = '';
|
|
99
|
+
for (let i = idx; i < code.length && out.length < 200; i++) {
|
|
100
|
+
const c = code[i];
|
|
101
|
+
if ('({['.includes(c)) depth++;
|
|
102
|
+
else if (')}]'.includes(c)) { if (depth === 0) break; depth--; }
|
|
103
|
+
else if (c === ',' && depth === 0) break;
|
|
104
|
+
out += c;
|
|
105
|
+
}
|
|
106
|
+
return out.trim();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------- 1) 注册表 ----------
|
|
110
|
+
const registered = new Map();
|
|
111
|
+
function addReg(name, kind, file, line, via) {
|
|
112
|
+
if (!name || registered.has(name)) return;
|
|
113
|
+
registered.set(name, { name, kind, file: rel(file), line, via, pattern: name.includes('{') ? name.slice(0, name.indexOf('{')) : null });
|
|
114
|
+
}
|
|
115
|
+
const entryRootPages = [];
|
|
116
|
+
for (const pf of pageProfiles) {
|
|
117
|
+
try {
|
|
118
|
+
const j = JSON.parse(readFileSync(pf, 'utf8'));
|
|
119
|
+
(j.src || []).forEach((p, i) => { addReg(p, 'page', pf, 0, 'main_pages.json'); if (i === 0) entryRootPages.push(p); });
|
|
120
|
+
} catch { /* ignore */ }
|
|
121
|
+
}
|
|
122
|
+
// 第四本账:systemRouterMap(官方配置化路由)——route_map.json 直读,与 wire-routes 生成器
|
|
123
|
+
// 同源(数据文件秒解析,无自研注册表形态漂移问题)。计入 kind:'nav' → 保险丝认账。
|
|
124
|
+
// 同时记 name → pageSourceFile(模块根相对):单一注册下 Index 不再 import 页面文件,
|
|
125
|
+
// 路由→文件映射与骨架残留判定都靠它。
|
|
126
|
+
const rmPageFile = new Map();
|
|
127
|
+
for (const rmp of moduleSrcRoots(resolve(PROJECT)).map(r => join(r, '..', 'resources', 'base', 'profile', 'route_map.json')).filter(existsSync)) {
|
|
128
|
+
try {
|
|
129
|
+
const rm = JSON.parse(readFileSync(rmp, 'utf8'));
|
|
130
|
+
const modRoot = resolve(dirname(rmp), '..', '..', '..', '..', '..'); // profile/base/resources/main/src → 模块根
|
|
131
|
+
for (const e of (rm.routerMap || [])) {
|
|
132
|
+
addReg(e.name, 'nav', rmp, 0, 'route_map(systemRouterMap)');
|
|
133
|
+
if (e.pageSourceFile && !rmPageFile.has(e.name)) rmPageFile.set(e.name, resolve(modRoot, String(e.pageSourceFile)));
|
|
134
|
+
}
|
|
135
|
+
} catch { /* 坏 JSON 由构建自身报错 */ }
|
|
136
|
+
}
|
|
137
|
+
const REG_VARS = IDIOMS.nav.registrationVars.map(v => `\\b${v}\\b`).join('|');
|
|
138
|
+
const navFiles = etsFiles.filter(f => src.get(f).includes(IDIOMS.nav.registrationMarker));
|
|
139
|
+
const regFiles = new Set(navFiles);
|
|
140
|
+
// 邻接扩展收紧:被 navFile import 的文件必须自身含 NavDestination( 组件(如集中式路由表文件)
|
|
141
|
+
// 才算注册文件——否则普通工具文件里的 `name === 枚举` 解析函数会被误收(实测误伤)。
|
|
142
|
+
for (const f of navFiles) {
|
|
143
|
+
for (const im of src.get(f).matchAll(/import\s+[^;]*?from\s+['"](\.[^'"]+)['"]/g)) {
|
|
144
|
+
const t = resolve(dirname(f), im[1]) + '.ets';
|
|
145
|
+
if (src.has(t) && /\bNavDestination\s*\(/.test(src.get(t))) regFiles.add(t);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const f of regFiles) {
|
|
149
|
+
const code = src.get(f);
|
|
150
|
+
for (const m of code.matchAll(new RegExp(`(?:${REG_VARS})\\s*===?\\s*([A-Za-z_$][\\w$.]*|['"][^'"]+['"])`, 'g'))) {
|
|
151
|
+
const r = resolveExpr(m[1]);
|
|
152
|
+
if (r.name) addReg(r.name, 'nav', f, lineOf(code, m.index), r.via || 'compare');
|
|
153
|
+
}
|
|
154
|
+
for (const m of code.matchAll(/case\s+(['"])((?:(?!\1).)*)\1\s*:/g)) addReg(m[2], 'nav', f, lineOf(code, m.index), 'case');
|
|
155
|
+
for (const m of code.matchAll(/\.startsWith\s*\(\s*([A-Za-z_$][\w$.]*|['"][^'"]+['"])\s*\)/g)) {
|
|
156
|
+
const r = resolveExpr(m[1]);
|
|
157
|
+
// 前缀最短长度门(idioms.nav.patternPrefixMinLen):单字符前缀(如 '/')注册成模式会匹配一切名字
|
|
158
|
+
if (r.name && r.name.endsWith('/') && r.name.length >= (IDIOMS.nav.patternPrefixMinLen ?? 2)) addReg(r.name + '{*}', 'nav-pattern', f, lineOf(code, m.index), r.via || 'startsWith');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// 注册账可解析性保险丝:工程明显在用 Navigation(有 NavDestination()却一条 nav 注册都没解析出来
|
|
162
|
+
// —— 说明注册写法不在知识表内(Map 路由表/装饰器路由等)。此时 ghost 全量降级立案,不定罪。
|
|
163
|
+
const usesNavDest = etsFiles.some(f => /\bNavDestination\s*\(/.test(src.get(f)));
|
|
164
|
+
const navRegUnparseable = usesNavDest && ![...registered.values()].some(r => r.kind === 'nav' || r.kind === 'nav-pattern');
|
|
165
|
+
function matchRegistered(name, tplPrefix) {
|
|
166
|
+
if (typeof name !== 'string') name = null; // AMBIG 哨兵等非串值一律当不可解析
|
|
167
|
+
if (name && registered.has(name)) return registered.get(name);
|
|
168
|
+
for (const r of registered.values()) {
|
|
169
|
+
if (!r.pattern) continue;
|
|
170
|
+
if (name && name.startsWith(r.pattern)) return r;
|
|
171
|
+
if (tplPrefix && (tplPrefix.startsWith(r.pattern) || r.pattern.startsWith(tplPrefix))) return r;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------- 2) 原始跳转点采集 ----------
|
|
177
|
+
const NAV_PUSH_RE = new RegExp(`\\.\\s*(${IDIOMS.nav.pushApis.join('|')})\\s*\\(`, 'g');
|
|
178
|
+
const NAV_PUSHOBJ_RE = new RegExp(`\\.\\s*(?:${IDIOMS.nav.pushObjApis.join('|')})\\s*\\(\\s*\\{[\\s\\S]{0,120}?\\bname\\s*:\\s*([A-Za-z_$][\\w$.]*|['"][^'"]+['"])`, 'g');
|
|
179
|
+
// 官方常见写法 pushPath(new NavPathInfo(name, …))——漏采会造成真 ghost 漏检 + orphan 假立案
|
|
180
|
+
const NAV_PUSHNPI_RE = new RegExp(`\\.\\s*(?:${IDIOMS.nav.pushObjApis.join('|')})\\s*\\(\\s*new\\s+NavPathInfo\\s*\\(\\s*([A-Za-z_$][\\w$.]*|['"][^'"]+['"])`, 'g');
|
|
181
|
+
const URL_PUSH_RE = new RegExp(`\\.\\s*(${IDIOMS.pageRouter.pushApis.join('|')})\\s*\\(`, 'g');
|
|
182
|
+
// 匹配跑在字符串脱敏视图(blanked)上;取参回真实文本(real)——偏移两者一致
|
|
183
|
+
function rawPushesIn(real, blanked, base) {
|
|
184
|
+
const out = [];
|
|
185
|
+
let m;
|
|
186
|
+
NAV_PUSH_RE.lastIndex = 0;
|
|
187
|
+
while ((m = NAV_PUSH_RE.exec(blanked)) !== null) out.push({ system: 'nav', argExpr: firstArg(real, NAV_PUSH_RE.lastIndex), idx: base + m.index });
|
|
188
|
+
NAV_PUSHOBJ_RE.lastIndex = 0;
|
|
189
|
+
while ((m = NAV_PUSHOBJ_RE.exec(real)) !== null) {
|
|
190
|
+
if (isBlankedAt(blanked, m.index)) continue;
|
|
191
|
+
out.push({ system: 'nav', argExpr: m[1], idx: base + m.index });
|
|
192
|
+
}
|
|
193
|
+
NAV_PUSHNPI_RE.lastIndex = 0;
|
|
194
|
+
while ((m = NAV_PUSHNPI_RE.exec(real)) !== null) {
|
|
195
|
+
if (isBlankedAt(blanked, m.index)) continue;
|
|
196
|
+
out.push({ system: 'nav', argExpr: m[1], idx: base + m.index });
|
|
197
|
+
}
|
|
198
|
+
URL_PUSH_RE.lastIndex = 0;
|
|
199
|
+
while ((m = URL_PUSH_RE.exec(blanked)) !== null) {
|
|
200
|
+
const a = firstArg(real, URL_PUSH_RE.lastIndex);
|
|
201
|
+
let expr = a;
|
|
202
|
+
if (a.startsWith('{')) { const u = a.match(/\burl\s*:\s*([A-Za-z_$][\w$.]*|['"][^'"]+['"]|`[^`]*`)/); expr = u ? u[1] : a; }
|
|
203
|
+
out.push({ system: 'page', argExpr: expr, idx: base + m.index, urlIndirect: !a.startsWith('{') && !/^['"`]/.test(a) ? a : null });
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
// 该偏移是否落在被脱敏的字符串内(脱敏视图该处为空格而真实文本非空格)
|
|
208
|
+
function isBlankedAt(blanked, idx) { return blanked[idx] === ' '; }
|
|
209
|
+
// 函数体抽取:共享库版(字符串感知括号平衡;大写开头名不当函数——ArkUI 组件/@Builder 防污染)
|
|
210
|
+
// 变量→表达式 局部回溯(options.url = x / { url: x } / const v = 'lit')
|
|
211
|
+
function resolveLocalVar(body, varName, params) {
|
|
212
|
+
if (params.includes(varName)) return { param: params.indexOf(varName) };
|
|
213
|
+
let m = body.match(new RegExp(`(?:const|let)\\s+${varName}[^=\\n]*=\\s*([^;\\n]+)`));
|
|
214
|
+
if (m) {
|
|
215
|
+
const objUrl = m[1].match(/\burl\s*:\s*([A-Za-z_$][\w$.]*|['"][^'"]+['"])/);
|
|
216
|
+
const expr = objUrl ? objUrl[1] : m[1].trim();
|
|
217
|
+
if (params.includes(expr)) return { param: params.indexOf(expr) };
|
|
218
|
+
return { expr };
|
|
219
|
+
}
|
|
220
|
+
m = body.match(new RegExp(`${varName}\\.url\\s*=\\s*([^;\\n]+)`));
|
|
221
|
+
if (m) { const e = m[1].trim(); return params.includes(e) ? { param: params.indexOf(e) } : { expr: e }; }
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------- 3) 包装器机制(仅非组件文件),链式解析 ×3 轮 ----------
|
|
226
|
+
const utilFns = new Map(); // key file::fn
|
|
227
|
+
for (const [f, code] of src) {
|
|
228
|
+
if (isComponentFile(f)) continue;
|
|
229
|
+
for (const fn of functionBodies(code)) {
|
|
230
|
+
const before = code.slice(0, fn.start);
|
|
231
|
+
const cm = [...before.matchAll(/class\s+([A-Za-z_$][\w$]*)/g)].pop();
|
|
232
|
+
utilFns.set(`${f}::${fn.name}`, { ...fn, file: f, cls: cm ? cm[1] : null, routeNames: new Set(), paramSlot: -1, isWrapper: false });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// 种子:体内含原始 push 的工具函数
|
|
236
|
+
for (const w of utilFns.values()) {
|
|
237
|
+
for (const p of rawPushesIn(w.body, blankStrings(w.body), 0)) {
|
|
238
|
+
let r = resolveExpr(p.argExpr);
|
|
239
|
+
if (r.kind === 'dynamic') {
|
|
240
|
+
const lv = resolveLocalVar(w.body, p.argExpr.trim(), w.params);
|
|
241
|
+
if (lv && lv.param !== undefined) { w.paramSlot = lv.param; w.isWrapper = true; continue; }
|
|
242
|
+
if (lv && lv.expr) r = resolveExpr(lv.expr);
|
|
243
|
+
}
|
|
244
|
+
if (w.params.includes(p.argExpr.trim())) { w.paramSlot = w.params.indexOf(p.argExpr.trim()); w.isWrapper = true; continue; }
|
|
245
|
+
if (r.name) { w.routeNames.add(r.name); w.isWrapper = true; }
|
|
246
|
+
else if (r.kind === 'template' && r.prefix) { w.routeNames.add(r.prefix + '{*}'); w.isWrapper = true; }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// 每文件命名导入表:裸函数跨文件调用要求 import 关联(防跨文件同名裸函数互相串账)
|
|
250
|
+
const namedImports = new Map(); // f -> Map(localName -> targetFile)
|
|
251
|
+
for (const [f, code] of src) {
|
|
252
|
+
const im = new Map();
|
|
253
|
+
for (const m of code.matchAll(/import\s+\{([^}]+)\}\s+from\s+['"](\.[^'"]+)['"]/g)) {
|
|
254
|
+
const t = resolve(dirname(f), m[2]) + '.ets';
|
|
255
|
+
if (!src.has(t)) continue;
|
|
256
|
+
for (const nm of m[1].split(',').map(s => s.trim().split(/\s+as\s+/).pop())) if (nm) im.set(nm, t);
|
|
257
|
+
}
|
|
258
|
+
namedImports.set(f, im);
|
|
259
|
+
}
|
|
260
|
+
const bareCallLinked = (callerFile, fn) => fn.cls ? true
|
|
261
|
+
: (callerFile === fn.file || namedImports.get(callerFile)?.get(fn.name) === fn.file);
|
|
262
|
+
|
|
263
|
+
// 链式:调用已知包装器的工具函数也成为包装器
|
|
264
|
+
for (let round = 0; round < 3; round++) {
|
|
265
|
+
let changed = false;
|
|
266
|
+
for (const w of utilFns.values()) {
|
|
267
|
+
const wBodyBlank = blankStrings(w.body);
|
|
268
|
+
for (const t of utilFns.values()) {
|
|
269
|
+
if (!t.isWrapper || t === w) continue;
|
|
270
|
+
if (!bareCallLinked(w.file, t)) continue;
|
|
271
|
+
const callRe = t.cls ? new RegExp(`\\b(?:${t.cls}|this)\\.${t.name}\\s*\\(`, 'g') : new RegExp(`(?<![.\\w])${t.name}\\s*\\(`, 'g');
|
|
272
|
+
let m;
|
|
273
|
+
while ((m = callRe.exec(wBodyBlank)) !== null) {
|
|
274
|
+
if (t.paramSlot >= 0) {
|
|
275
|
+
const arg = firstArg(w.body, callRe.lastIndex);
|
|
276
|
+
if (w.params.includes(arg)) { if (w.paramSlot < 0) { w.paramSlot = w.params.indexOf(arg); w.isWrapper = true; changed = true; } }
|
|
277
|
+
else {
|
|
278
|
+
const r = resolveExpr(arg);
|
|
279
|
+
if (r.name && !w.routeNames.has(r.name)) { w.routeNames.add(r.name); w.isWrapper = true; changed = true; }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
for (const n of t.routeNames) if (!w.routeNames.has(n)) { w.routeNames.add(n); w.isWrapper = true; changed = true; }
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!changed) break;
|
|
287
|
+
}
|
|
288
|
+
const wrappers = [...utilFns.values()].filter(w => w.isWrapper);
|
|
289
|
+
// 包装器体范围(同文件内互调不算外部调用者)
|
|
290
|
+
const wrapperRanges = new Map();
|
|
291
|
+
for (const w of wrappers) {
|
|
292
|
+
if (!wrapperRanges.has(w.file)) wrapperRanges.set(w.file, []);
|
|
293
|
+
wrapperRanges.get(w.file).push([w.start, w.end]);
|
|
294
|
+
}
|
|
295
|
+
const inWrapperBody = (f, idx) => (wrapperRanges.get(f) || []).some(([s, e]) => idx >= s && idx < e);
|
|
296
|
+
|
|
297
|
+
// ---------- 4) 全量跳转账本 ----------
|
|
298
|
+
const pushes = []; // {system,name,kind,prefix,file,line,via}
|
|
299
|
+
for (const [f, code] of src) {
|
|
300
|
+
const componentFile = isComponentFile(f);
|
|
301
|
+
for (const p of rawPushesIn(code, bl(f), 0)) {
|
|
302
|
+
if (!componentFile && inWrapperBody(f, p.idx)) continue; // 包装器体内的原始 push 由调用点计
|
|
303
|
+
const r = resolveExpr(p.argExpr);
|
|
304
|
+
pushes.push({ system: p.system, name: r.name, kind: r.kind, prefix: r.prefix, file: f, line: lineOf(code, p.idx), via: r.via || p.argExpr.slice(0, 50) });
|
|
305
|
+
}
|
|
306
|
+
for (const w of wrappers) {
|
|
307
|
+
if (!bareCallLinked(f, w)) continue; // 裸函数:无 import 关联的同名调用不记账
|
|
308
|
+
const callRe = w.cls ? new RegExp(`\\b${w.cls}\\.${w.name}\\s*\\(`, 'g') : new RegExp(`(?<![.\\w])${w.name}\\s*\\(`, 'g');
|
|
309
|
+
let m;
|
|
310
|
+
const blanked = bl(f);
|
|
311
|
+
while ((m = callRe.exec(blanked)) !== null) {
|
|
312
|
+
if (f === w.file) continue; // 定义文件内(含链式互调)不算外部调用
|
|
313
|
+
w.externalCallers = (w.externalCallers || 0) + 1;
|
|
314
|
+
const label = `${w.cls ? w.cls + '.' : ''}${w.name}`;
|
|
315
|
+
if (w.paramSlot >= 0) {
|
|
316
|
+
const r = resolveExpr(firstArg(code, callRe.lastIndex));
|
|
317
|
+
pushes.push({ system: 'nav', name: r.name, kind: r.name ? 'wrapper-arg' : r.kind, prefix: r.prefix, file: f, line: lineOf(code, m.index), via: `${label}(${(r.via || r.name || '')})` });
|
|
318
|
+
}
|
|
319
|
+
for (const n of w.routeNames) {
|
|
320
|
+
pushes.push({ system: 'nav', name: n.replace('{*}', ''), kind: 'wrapper-fixed', prefix: n.endsWith('{*}') ? n.replace('{*}', '') : undefined, file: f, line: lineOf(code, m.index), via: `${label}()→'${n}'` });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// 死包装器(固定路由名但外部零调用)
|
|
326
|
+
const deadEntries = new Map();
|
|
327
|
+
for (const w of wrappers) {
|
|
328
|
+
if ((w.externalCallers || 0) > 0) continue;
|
|
329
|
+
for (const n of w.routeNames) {
|
|
330
|
+
deadEntries.set(n.replace('{*}', ''), `${w.cls ? w.cls + '.' : ''}${w.name}() @${rel(w.file)} 内含该跳转,但全工程零调用方`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// soft-credit
|
|
334
|
+
const constByLiteral = new Map();
|
|
335
|
+
for (const [k, v] of constMap) if (typeof v === 'string' && matchRegistered(v, null)) constByLiteral.set(k, v);
|
|
336
|
+
const inferred = [];
|
|
337
|
+
for (const [f, code] of src) {
|
|
338
|
+
if (!pushes.some(p => p.file === f && p.kind === 'dynamic')) continue;
|
|
339
|
+
for (const [ident, lit] of constByLiteral) {
|
|
340
|
+
if (new RegExp(`\\b${ident.replace('.', '\\.')}\\b`).test(code)) inferred.push({ system: 'nav', name: lit, kind: 'inferred', file: f, line: 0, via: `动态跳转文件引用常量 ${ident}` });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---------- 5) 入口连通 ----------
|
|
345
|
+
const importGraph = new Map();
|
|
346
|
+
for (const [f, code] of src) {
|
|
347
|
+
const outs = [];
|
|
348
|
+
for (const m of code.matchAll(/import\s+[^;]*?from\s+['"](\.[^'"]+)['"]/g)) {
|
|
349
|
+
const t = resolve(dirname(f), m[1]) + '.ets';
|
|
350
|
+
if (src.has(t)) outs.push(t);
|
|
351
|
+
}
|
|
352
|
+
importGraph.set(f, outs);
|
|
353
|
+
}
|
|
354
|
+
function closureOf(starts) {
|
|
355
|
+
const seen = new Set(starts); const q = [...starts];
|
|
356
|
+
while (q.length) for (const t of importGraph.get(q.pop()) || []) if (!seen.has(t)) { seen.add(t); q.push(t); }
|
|
357
|
+
return seen;
|
|
358
|
+
}
|
|
359
|
+
const routeFile = new Map();
|
|
360
|
+
for (const r of registered.values()) {
|
|
361
|
+
if (r.kind === 'page') {
|
|
362
|
+
const cand = etsFiles.find(f => rel(f).replace(/\\/g, '/').endsWith(`ets/${r.name}.ets`));
|
|
363
|
+
if (cand) routeFile.set(r.name, cand);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// systemRouterMap 的 pageSourceFile 是权威映射(Index 无 pageMap 时唯一来源)
|
|
367
|
+
for (const [name, file] of rmPageFile) if (src.has(file) && !routeFile.has(name)) routeFile.set(name, file);
|
|
368
|
+
for (const f of regFiles) {
|
|
369
|
+
const code = src.get(f);
|
|
370
|
+
const imports = new Map();
|
|
371
|
+
for (const m of code.matchAll(/import\s+\{([^}]+)\}\s+from\s+['"](\.[^'"]+)['"]/g)) {
|
|
372
|
+
for (const nm of m[1].split(',').map(s => s.trim().split(/\s+as\s+/).pop())) {
|
|
373
|
+
const t = resolve(dirname(f), m[2]) + '.ets';
|
|
374
|
+
if (src.has(t)) imports.set(nm, t);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
for (const m of code.matchAll(/(?:\bname\b|\brouteName\b)\s*===?\s*([A-Za-z_$][\w$.]*|['"][^'"]+['"])[\s\S]{0,80}?([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
378
|
+
const r = resolveExpr(m[1]);
|
|
379
|
+
const target = imports.get(m[2]) || imports.get(m[2].replace(/Builder$/, ''));
|
|
380
|
+
if (r.name && target && !routeFile.has(r.name)) routeFile.set(r.name, target);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const entryFiles = etsFiles.filter(f => /@Entry\b/.test(src.get(f)));
|
|
384
|
+
const abilityLoads = [];
|
|
385
|
+
for (const [, code] of src) for (const m of code.matchAll(/loadContent\s*\(\s*(['"])((?:(?!\1).)*)\1/g)) abilityLoads.push(m[2]);
|
|
386
|
+
let roots = entryFiles.filter(f => abilityLoads.some(l => rel(f).replace(/\\/g, '/').endsWith(`ets/${l}.ets`)));
|
|
387
|
+
if (roots.length === 0) roots = entryFiles;
|
|
388
|
+
if (PROFILE.entryFiles) roots.push(...PROFILE.entryFiles.map(p => resolve(PROJECT, p)).filter(p => src.has(p)));
|
|
389
|
+
const rootRouteNames = new Set(entryRootPages.concat(abilityLoads));
|
|
390
|
+
// 注:死包装器不会产生调用点 push,故无需按名过滤——按名过滤会误杀同名路由的其他真实入口
|
|
391
|
+
const livePushes = [...pushes, ...inferred].filter(p => p.name || p.prefix);
|
|
392
|
+
|
|
393
|
+
const reachable = new Set();
|
|
394
|
+
for (const r of registered.values()) {
|
|
395
|
+
const bare = r.name.replace('{*}', '');
|
|
396
|
+
if (rootRouteNames.has(bare) || roots.some(f => routeFile.get(bare) === f)) reachable.add(r.name);
|
|
397
|
+
}
|
|
398
|
+
let frontier = closureOf(roots);
|
|
399
|
+
let changed = true;
|
|
400
|
+
while (changed) {
|
|
401
|
+
changed = false;
|
|
402
|
+
for (const p of livePushes) {
|
|
403
|
+
if (!frontier.has(p.file)) continue;
|
|
404
|
+
const reg = matchRegistered(p.name, p.prefix);
|
|
405
|
+
if (reg && !reachable.has(reg.name)) {
|
|
406
|
+
reachable.add(reg.name);
|
|
407
|
+
changed = true;
|
|
408
|
+
const rf = routeFile.get(reg.name.replace('{*}', ''));
|
|
409
|
+
if (rf) for (const x of closureOf([rf])) frontier.add(x);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---------- 5b) 首屏链路(冷启第一跳逐跳走到首个实义页)----------
|
|
415
|
+
// 静态门管两件确定性事实:①链上每跳可注册解析 ②链尾不是 wire 骨架占位页
|
|
416
|
+
// (骨架判定用 wire-routes 生成物的固定占位标记——TODO(切片) 页面内容行)。
|
|
417
|
+
// 转发页近似:页内含 replace 调用即视为转发页,取其行号最小跳转继续走;≤3 跳防环。
|
|
418
|
+
const SCAFFOLD_MARK = /TODO\(切片\): 页面内容/;
|
|
419
|
+
const DBG_CHAIN = !!process.env.DEBUG_CHAIN;
|
|
420
|
+
const entryChainFindings = [];
|
|
421
|
+
if (DBG_CHAIN) console.error(`[chain] roots=${roots.map(rel).join(',')}`);
|
|
422
|
+
for (const root of roots) {
|
|
423
|
+
let cur = livePushes.filter(p => p.file === root && p.name).sort((a, b) => a.line - b.line)[0];
|
|
424
|
+
if (DBG_CHAIN) console.error(`[chain] root=${rel(root)} first=${cur ? cur.name : '(无)'}`);
|
|
425
|
+
const chain = [rel(root)];
|
|
426
|
+
const seenHop = new Set();
|
|
427
|
+
for (let hop = 0; cur && hop < 3; hop++) {
|
|
428
|
+
const reg = matchRegistered(cur.name, cur.prefix);
|
|
429
|
+
if (!reg || seenHop.has(reg.name)) break; // 未注册由 ghost 门定罪,此处不重复立案
|
|
430
|
+
seenHop.add(reg.name);
|
|
431
|
+
chain.push(reg.name);
|
|
432
|
+
const pf = routeFile.get(reg.name.replace('{*}', ''));
|
|
433
|
+
if (DBG_CHAIN) console.error(`[chain] hop${hop} ${reg.name} → file=${pf ? rel(pf) : '(未映射)'}`);
|
|
434
|
+
if (!pf) break;
|
|
435
|
+
const pcode = src.get(pf);
|
|
436
|
+
// 骨架标记在注释里——src 是去注释视图,须读原文判定
|
|
437
|
+
if (SCAFFOLD_MARK.test(readFileSync(pf, 'utf8'))) {
|
|
438
|
+
entryChainFindings.push({ type: 'entry_chain_scaffold', severity: 'case', name: reg.name,
|
|
439
|
+
basis: '判据:冷启链路静态走查落在 wire 骨架占位页——首屏是占位模板不是实义页',
|
|
440
|
+
evidence: `${chain.join(' → ')} 止于骨架页 ${rel(pf)}(占位标记仍在)。修复=实现该页,或校正上游转发目标(转发语义:splash/launcher 应转发到源 app 主 NavHost 首 destination 的等价页)` });
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
if (!/\.\s*replace\s*\(/.test(pcode)) break; // 非转发页 = 实义首页,链闭合
|
|
444
|
+
cur = livePushes.filter(p => p.file === pf && p.name).sort((a, b) => a.line - b.line)[0];
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------- 6) 裁决 ----------
|
|
449
|
+
const findings = [];
|
|
450
|
+
const IGNORE = new Set(PROFILE.ignoreRoutes || []);
|
|
451
|
+
findings.push(...entryChainFindings);
|
|
452
|
+
for (const p of pushes) {
|
|
453
|
+
if (['literal', 'const', 'enum', 'wrapper-arg', 'wrapper-fixed'].includes(p.kind) && p.name && !matchRegistered(p.name, null)) {
|
|
454
|
+
if (p.system === 'nav' && navRegUnparseable) {
|
|
455
|
+
findings.push({ type: 'ghost_route_unverifiable', severity: 'case', name: p.name, basis: '判据:跳转名未见于注册账,但注册账不可解析(工程在用 Navigation 而未解析出任何 nav 注册——写法不在知识表内),不定罪待裁决', evidence: `${rel(p.file)}:${p.line} 经 ${p.via} 跳转;注册写法请回源码裁决,普适写法请修订 idioms.json` });
|
|
456
|
+
} else {
|
|
457
|
+
findings.push({ type: 'ghost_route', severity: 'violation', name: p.name, basis: '判据:可解析跳转名未出现在任何注册账(pageMap比较/main_pages/模式前缀)——运行时必失败', evidence: `${rel(p.file)}:${p.line} 经 ${p.via} 跳转,但该名未在任何注册表出现(运行时必失败)` });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const pushedKeys = new Map();
|
|
462
|
+
for (const p of [...pushes, ...inferred]) {
|
|
463
|
+
const reg = matchRegistered(p.name, p.prefix);
|
|
464
|
+
if (!reg) continue;
|
|
465
|
+
if (!pushedKeys.has(reg.name)) pushedKeys.set(reg.name, []);
|
|
466
|
+
pushedKeys.get(reg.name).push(p);
|
|
467
|
+
}
|
|
468
|
+
// ---------- 6a) 注册页骨架残留(全部注册路由,不只冷启链)----------
|
|
469
|
+
// 三道防线同漏的实证:5 个注册路由页到收尾仍是 wire 骨架(实现写在别的目录、壳从未挂载),
|
|
470
|
+
// 静态门只查首屏链、smoke 假绿、verify 只读实现文件。判据确定性:注册页文件仍含占位标记。
|
|
471
|
+
// 阶段语义:slice 阶段其他片未实现属正常,不立案;integration 立案(case);final 硬违规(实现或删注册,不许豁免)。
|
|
472
|
+
if (STAGE !== 'slice') {
|
|
473
|
+
for (const [name, file] of rmPageFile) {
|
|
474
|
+
if (IGNORE.has(name)) continue;
|
|
475
|
+
if (!existsSync(file)) {
|
|
476
|
+
findings.push({ type: 'route_page_missing', severity: 'violation', name, basis: '判据:route_map 注册的 pageSourceFile 不存在——hvigor ProcessRouterMap 必失败', evidence: `${rel(file)} 缺失(删注册或补页面)` });
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (!SCAFFOLD_MARK.test(readFileSync(file, 'utf8'))) continue;
|
|
480
|
+
const ps = pushedKeys.get(name) || [];
|
|
481
|
+
findings.push({ type: 'scaffold_page_residue', severity: STAGE === 'final' ? 'violation' : 'case', name,
|
|
482
|
+
basis: '判据:注册路由页仍是 wire 骨架占位(TODO(切片) 页面内容标记在)——用户可达的是占位模板,不是实义页',
|
|
483
|
+
evidence: `${rel(file)} 占位标记仍在${ps.length ? `,且有 ${ps.length} 处跳转可达(${ps.slice(0, 2).map(p => rel(p.file) + ':' + p.line).join(', ')})` : ',无静态跳转(确属不迁移的页:从 route_map 删注册并删骨架文件)'}。修复=实现该页(拥有它的切片 targetPath 必须含此文件;用 only 续跑该片)` });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
// 可靠性规则:只要工程里存在**无法静态解析的跳转点**(变量/函数返回值/对象字段/模板拼名——实证 45 工程里每家形态都不同:
|
|
487
|
+
// 类字段承载、SettingsRouter 返回字符串、DrawerItem 数据表、`${x}Page` 拼名),就不能证明任何一条注册路由是孤儿——
|
|
488
|
+
// 此时 orphan 只记 info(附动态跳转点清单),不立案;全部跳转都解析得开时才立案。实证:coolmall 31/35、antennapod-ds 53/85、
|
|
489
|
+
// quillpad 6/16 的 orphan 立案抽样全是误报,模型一张 .* 票整体豁免,门形同虚设。
|
|
490
|
+
const dynamicPushes = pushes.filter(p => !p.name && !p.prefix);
|
|
491
|
+
for (const r of registered.values()) {
|
|
492
|
+
if (IGNORE.has(r.name) || reachable.has(r.name) && (pushedKeys.get(r.name) || []).length) continue;
|
|
493
|
+
const bare = r.name.replace('{*}', '');
|
|
494
|
+
if (rootRouteNames.has(bare)) continue;
|
|
495
|
+
const ps = pushedKeys.get(r.name) || [];
|
|
496
|
+
if (ps.length === 0) {
|
|
497
|
+
if (deadEntries.has(bare)) findings.push({ type: 'dead_entry_route', severity: 'case', name: r.name, basis: '判据:该路由的全部跳转都在零外部调用的包装器方法体内', evidence: `唯一入口 ${deadEntries.get(bare)}` });
|
|
498
|
+
else if (dynamicPushes.length) findings.push({ type: 'orphan_route', severity: 'info', name: r.name, basis: '判据:无可解析跳转,但工程有无法静态解析的跳转点——不能证明孤儿,不立案', evidence: `已注册(${r.file}:${r.line})但静态未见跳转;工程另有 ${dynamicPushes.length} 处动态跳转(${dynamicPushes.slice(0, 3).map(p => rel(p.file) + ':' + p.line).join(', ')})可能指向它——4b 走查核可达性` });
|
|
499
|
+
else findings.push({ type: 'orphan_route', severity: 'case', name: r.name, basis: '判据:已注册但全工程无可静态解析的跳转,且工程无任何动态跳转点(深链/通知/卡片入口属合法豁免)', evidence: `已注册(${r.file}:${r.line})但全工程未发现可解析跳转;若为深链/通知/卡片入口请在 profile.ignoreRoutes 豁免` });
|
|
500
|
+
} else if (!reachable.has(r.name)) {
|
|
501
|
+
findings.push({ type: 'unreachable_route', severity: 'case', name: r.name, basis: '判据:存在跳转但发起文件不在入口 import 闭包内', evidence: `存在跳转(${ps.slice(0, 2).map(p => rel(p.file) + ':' + p.line).join(', ')})但从入口连通图不可达` });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const unresolved = pushes.filter(p => !p.name && !p.prefix).map(p => ({ file: rel(p.file), line: p.line, via: p.via, kind: p.kind }));
|
|
505
|
+
if (navRegUnparseable) findings.push({ type: 'nav_registry_unparseable', severity: 'case', name: '(registry)', basis: '判据:存在 NavDestination( 但未解析出任何 nav 注册条目', evidence: '注册写法不在知识表(idioms.json nav.*)内——人工回源码确认注册机制,普适写法回写知识表' });
|
|
506
|
+
|
|
507
|
+
const report = {
|
|
508
|
+
project: relative(process.cwd(), resolve(PROJECT)) || '.',
|
|
509
|
+
stats: {
|
|
510
|
+
etsFiles: etsFiles.length, registered: registered.size,
|
|
511
|
+
pushSites: pushes.length, resolved: pushes.filter(p => p.name || p.prefix).length,
|
|
512
|
+
inferred: inferred.length, dynamic: unresolved.length,
|
|
513
|
+
wrappers: wrappers.map(w => ({ fn: (w.cls ? w.cls + '.' : '') + w.name, file: rel(w.file), names: [...w.routeNames], paramSlot: w.paramSlot, externalCallers: w.externalCallers || 0 })),
|
|
514
|
+
entryRoots: roots.map(rel), reachable: [...reachable],
|
|
515
|
+
},
|
|
516
|
+
registered: [...registered.values()],
|
|
517
|
+
findings, unresolved,
|
|
518
|
+
};
|
|
519
|
+
if (OUT) writeFileSync(OUT, JSON.stringify(report, null, 2));
|
|
520
|
+
const vio = findings.filter(f => f.severity === 'violation');
|
|
521
|
+
const cases = findings.filter(f => f.severity === 'case');
|
|
522
|
+
console.log(`[check-routes] ${resolve(PROJECT)}`);
|
|
523
|
+
console.log(` 注册 ${registered.size} · 跳转点 ${pushes.length}(解析 ${report.stats.resolved} 推断 ${inferred.length} 动态 ${unresolved.length}) · 可达 ${reachable.size} · 包装器 ${wrappers.length}`);
|
|
524
|
+
for (const f of vio) console.log(` ✗ VIOLATION ${f.type} [${f.name}] — ${f.evidence}`);
|
|
525
|
+
for (const f of cases) console.log(` ▲ CASE ${f.type} [${f.name}] — ${f.evidence}`);
|
|
526
|
+
if (!vio.length && !cases.length) console.log(' ✓ 三本账闭合,无立案');
|
|
527
|
+
process.exit(vio.length ? 10 : (STRICT && cases.length ? 2 : 0));
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-safearea.mjs — 安全区/布局保真度门(补门禁盲区:edge-to-edge 页顶部避让脆弱→内容压系统状态栏下)。
|
|
4
|
+
*
|
|
5
|
+
* 病根:迁移把安卓沉浸式(setWindowLayoutFullScreen(true))映射到鸿蒙后,需给顶部内容叠加系统避让
|
|
6
|
+
* (statusBar 高度)。三类脆弱(均为真机实证):
|
|
7
|
+
* ① 竞态门控:inset 赋值被 `if(!isLayoutFullScreen) return` 早退挡住——与全屏设置竞态,回调早于生效则 inset=0;
|
|
8
|
+
* ② 取一次不订阅:getWindowAvoidArea 只算一次、全工程无 avoidAreaChange 重挂——初值/旋转/多窗后 inset 卡 0;
|
|
9
|
+
* ③ 页面根本不消费:壳层建好了避让基建(全局 insets 服务),但某些导航页既不引用它也不自取避让——
|
|
10
|
+
* "每页手动消费"模式必然漏页(两轮真机重合实证,且漏的页每轮不同——系统病)。
|
|
11
|
+
*
|
|
12
|
+
* 判定全为**软立案**(case)——竞态/消费与否静态无法定罪(全屏页、对话框、时序确稳皆属合法),
|
|
13
|
+
* 绝不设硬违规、恒退 0:由 closure-suite final 阶段汇总,模型逐条回源裁决(真重合→修;合法→带理由豁免)。
|
|
14
|
+
* 报告措辞不许造成"零违规=安全区没问题"的误导——立案数与检查覆盖数都必须打出来。
|
|
15
|
+
*
|
|
16
|
+
* 用法:check-safearea.mjs --project <鸿蒙工程根> [--json out] 退出码:0(软立案不失败)/ 3 用法错
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
19
|
+
import { join, relative, resolve, dirname } from 'node:path';
|
|
20
|
+
import { walkFiles, stripComments, moduleSrcRoots } from '../lib/scan.mjs';
|
|
21
|
+
|
|
22
|
+
const argv = process.argv.slice(2);
|
|
23
|
+
const argOf = (n, d) => { const i = argv.indexOf(n); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
|
|
24
|
+
const PROJECT = argOf('--project', null);
|
|
25
|
+
if (!PROJECT) { console.error('usage: check-safearea.mjs --project <root> [--json out]'); process.exit(3); }
|
|
26
|
+
|
|
27
|
+
const rel = f => relative(resolve(PROJECT), f).replace(/\\/g, '/');
|
|
28
|
+
const escRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
29
|
+
|
|
30
|
+
// 全模块扫描(entry + 任意 HSP/HAR 模块)
|
|
31
|
+
const files = moduleSrcRoots(resolve(PROJECT)).flatMap(r => [...walkFiles(r, { match: (_p, e) => e.endsWith('.ets') })]);
|
|
32
|
+
const texts = new Map(files.map(f => [f, stripComments(readFileSync(f, 'utf8'))]));
|
|
33
|
+
const edgeToEdge = [...texts.values()].some(c => /setWindowLayoutFullScreen\s*\(\s*true\s*\)/.test(c));
|
|
34
|
+
const projHasAvoidChange = [...texts.values()].some(c => /avoidAreaChange/.test(c));
|
|
35
|
+
|
|
36
|
+
// 全局 insets 提供者识别(全部为显式语义链,无黑名单):调 getWindowAvoidArea 的文件里——
|
|
37
|
+
// a) 该文件导出的声明(provider 与存储同文件的形态);
|
|
38
|
+
// b) 写入的 AppStorage 键(键名即消费凭据);
|
|
39
|
+
// c) 该文件 **import 进来且在文件内被赋值** 的标识符(provider 写外部存储的形态——
|
|
40
|
+
// import+赋值双条件是显式跨文件链,杜绝把内置全局名误当提供者)。
|
|
41
|
+
// 页面凡引用其一(或自取避让/expandSafeArea)即视为"消费了避让"。
|
|
42
|
+
const providerNames = new Set();
|
|
43
|
+
for (const code of texts.values()) {
|
|
44
|
+
if (!/getWindowAvoidArea/.test(code)) continue;
|
|
45
|
+
for (const m of code.matchAll(/export\s+(?:default\s+)?(?:@\w+\s+)*(?:class|struct|const|let|function|enum)\s+([A-Za-z_$][\w$]*)/g)) providerNames.add(m[1]);
|
|
46
|
+
for (const m of code.matchAll(/AppStorage\.setOrCreate\(\s*['"]([^'"]+)['"]/g)) providerNames.add(m[1]);
|
|
47
|
+
const imported = [];
|
|
48
|
+
// 四形态:命名 { a, b as c } / 默认 x / 混合 x, { y } / 命名空间 * as ns
|
|
49
|
+
for (const m of code.matchAll(/import\s+(?:([A-Za-z_$][\w$]*)\s*,\s*)?(?:\{([^}]*)\}|\*\s+as\s+([A-Za-z_$][\w$]*)|([A-Za-z_$][\w$]*))\s*from/g)) {
|
|
50
|
+
if (m[1]) imported.push(m[1]);
|
|
51
|
+
if (m[2]) imported.push(...m[2].split(',').map(s => s.trim().split(/\s+as\s+/).pop()).filter(Boolean));
|
|
52
|
+
if (m[3]) imported.push(m[3]);
|
|
53
|
+
if (m[4]) imported.push(m[4]);
|
|
54
|
+
}
|
|
55
|
+
for (const n of imported) if (new RegExp(`\\b${escRe(n)}\\b\\s*[.\\[][^=\n]*=(?!=)`).test(code)) providerNames.add(n);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// import 链传播(结构判据:相对 import 解析到工程文件,链长不限,memo 兼作环守卫;不看组件名)
|
|
59
|
+
const avoidMemo = new Map();
|
|
60
|
+
function resolveRel(from, spec) { const base = resolve(dirname(from), spec); for (const c of [base, base + '.ets', base + '.ts', join(base, 'index.ets'), join(base, 'index.ts')]) if (texts.has(c)) return c; return null; }
|
|
61
|
+
function avoidsViaImport(file) {
|
|
62
|
+
if (avoidMemo.has(file)) return avoidMemo.get(file);
|
|
63
|
+
avoidMemo.set(file, false);
|
|
64
|
+
const code = texts.get(file) || '';
|
|
65
|
+
let ok = false;
|
|
66
|
+
for (const m of code.matchAll(/from\s+['"](\.[^'"]+)['"]/g)) {
|
|
67
|
+
const t = resolveRel(file, m[1]); if (!t) continue;
|
|
68
|
+
const c2 = texts.get(t) || '';
|
|
69
|
+
if (/expandSafeArea|getWindowAvoidArea|avoidAreaChange/.test(c2) || [...providerNames].some(n => n && new RegExp(`\\b${escRe(n)}\\b`).test(c2)) || avoidsViaImport(t)) { ok = true; break; }
|
|
70
|
+
}
|
|
71
|
+
avoidMemo.set(file, ok);
|
|
72
|
+
return ok;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const findings = [];
|
|
76
|
+
let insetFiles = 0, pagesChecked = 0;
|
|
77
|
+
if (edgeToEdge) {
|
|
78
|
+
for (const f of files) {
|
|
79
|
+
const code = texts.get(f);
|
|
80
|
+
const line = (re) => { const m = code.match(re); return m ? code.slice(0, m.index).split('\n').length : 0; };
|
|
81
|
+
const takesTopInset = /getWindowAvoidArea/.test(code) && /topRect|TYPE_SYSTEM/.test(code);
|
|
82
|
+
if (takesTopInset) {
|
|
83
|
+
insetFiles++;
|
|
84
|
+
// ① 竞态门控(概率性脆弱)
|
|
85
|
+
if (/isLayoutFullScreen[\s\S]{0,80}?\breturn\b/.test(code)) {
|
|
86
|
+
findings.push({ severity: 'case', type: 'racy_safearea_gate', name: rel(f), file: rel(f),
|
|
87
|
+
line: line(/isLayoutFullScreen/),
|
|
88
|
+
evidence: `${rel(f)}:${line(/isLayoutFullScreen/)} 顶部避让 inset 被 isLayoutFullScreen 早退门控——与 setWindowLayoutFullScreen 竞态,回调早于全屏生效则 inset=0(裁决:去早退+avoidAreaChange 重挂,或时序确稳则豁免)` });
|
|
89
|
+
}
|
|
90
|
+
// ② 取一次不订阅(全工程无 avoidAreaChange 才立案——有中央重挂经全局态分发即合法)
|
|
91
|
+
if (!/avoidAreaChange/.test(code) && !projHasAvoidChange) {
|
|
92
|
+
findings.push({ severity: 'case', type: 'stale_inset_no_subscription', name: rel(f), file: rel(f),
|
|
93
|
+
line: line(/getWindowAvoidArea/),
|
|
94
|
+
evidence: `${rel(f)}:${line(/getWindowAvoidArea/)} 取顶部避让但全工程无 avoidAreaChange 订阅——初值/旋转/多窗变化后 inset 卡死(裁决:中央订阅重算入全局态,或证明场景不变则豁免)` });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// ③ 导航页不消费避让(页判定纯语义:含 NavDestination/@Entry 即页,不靠目录名约定)
|
|
98
|
+
const isPage = /NavDestination\s*\(|@Entry\b/.test(code);
|
|
99
|
+
if (isPage) {
|
|
100
|
+
pagesChecked++;
|
|
101
|
+
const selfAvoids = /expandSafeArea|getWindowAvoidArea|avoidAreaChange/.test(code);
|
|
102
|
+
const consumesProvider = [...providerNames].some(n => n && new RegExp(`\\b${escRe(n)}\\b`).test(code));
|
|
103
|
+
// 页面把避让委托给它 import 的组件(AppScaffold/TopBar 之类)也算消费:沿相对 import 走 ≤3 跳,被引文件自取避让或引用提供者即可
|
|
104
|
+
if (!selfAvoids && !consumesProvider && avoidsViaImport(f)) { continue; }
|
|
105
|
+
if (!selfAvoids && !consumesProvider) {
|
|
106
|
+
findings.push({ severity: 'case', type: 'page_top_unavoided', name: rel(f), file: rel(f), line: 1,
|
|
107
|
+
evidence: `${rel(f)} 导航页在 edge-to-edge 工程中未见任何顶部避让(不引用全局 insets 提供者${providerNames.size ? `[${[...providerNames].slice(0, 4).join('/')}…]` : ''}、无 expandSafeArea/自取避让)——顶部内容可能压状态栏(裁决:接全局 insets 顶部 padding,或该页确属全出血/无顶置内容则豁免)` });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const cases = findings.filter(f => f.severity === 'case');
|
|
114
|
+
const byType = (t) => cases.filter(c => c.type === t).length;
|
|
115
|
+
const report = { project: relative(process.cwd(), resolve(PROJECT)) || '.', edgeToEdge, stats: { insetFiles, pagesChecked, providers: [...providerNames] }, findings };
|
|
116
|
+
const out = argOf('--json', null);
|
|
117
|
+
if (out) { mkdirSync(dirname(resolve(out)), { recursive: true }); writeFileSync(out, JSON.stringify(report, null, 2)); }
|
|
118
|
+
console.log(`[check-safearea] edge-to-edge:${edgeToEdge ? '是' : '否'} · 取顶部avoid文件 ${insetFiles} · 页面消费检查 ${pagesChecked} 页 · 软立案 ${cases.length}(竞态${byType('racy_safearea_gate')}/失订${byType('stale_inset_no_subscription')}/页未避让${byType('page_top_unavoided')})`);
|
|
119
|
+
for (const c of cases.slice(0, 12)) console.log(` ▲ ${c.evidence}`);
|
|
120
|
+
if (!cases.length) console.log(edgeToEdge ? ` ✓ 未发现脆弱写法(软证据:本门非确定性,真机走查仍须逐页顺检顶部重合)` : ' ✓ 非 edge-to-edge,系统自动避让');
|
|
121
|
+
// 软立案不让 checker 失败(恒退 0)——closure-suite final 阶段汇总裁决,绝不逼修
|
|
122
|
+
process.exit(0);
|