android2harmony 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -407
- package/agents/self-tester.md +55 -376
- package/dist/index.js +238 -124
- package/dist/index.js.map +4 -4
- package/package.json +36 -32
- package/skills/a2h-resource-convert/SKILL.md +931 -0
- package/skills/a2h-resource-convert/references/code-vector-icon-rules.md +335 -0
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/conversion-rules.md +15 -2
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/dependency-analysis-rules.md +24 -2
- package/skills/a2h-resource-convert/references/lottie-conversion-rules.md +219 -0
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/resource-mapping-rules.md +27 -2
- package/skills/{hmos-resources-convert → a2h-resource-convert}/references/xml-drawable-to-svg-rules.md +118 -1
- package/skills/a2h-resource-convert/scripts/a2h_resource_convert.js +2186 -0
- package/skills/a2h-resource-convert/scripts/app_identity.js +741 -0
- package/skills/a2h-resource-convert/scripts/code_vector_icons.js +607 -0
- package/skills/a2h-resource-convert/scripts/package.json +3 -0
- package/skills/a2h-resource-convert/scripts/svg_fidelity_check.js +632 -0
- package/skills/a2h-ui-transfer/SKILL.md +431 -0
- package/skills/a2h-ui-transfer/references/conversion-procedure.md +547 -0
- package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/_/350/214/203/345/274/217/351/200/211/346/213/251/350/257/264/346/230/216.md +1 -1
- package/skills/a2h-ui-transfer/references/mvvm-v2/_/350/243/205/351/245/260/345/231/250/351/200/237/346/237/245.md +133 -0
- package/skills/{hmos-batch-ui-align/scripts/android_parse_fast.ts → a2h-ui-transfer/scripts/android_parse_fast.js} +532 -301
- package/skills/a2h-ui-transfer/scripts/arkts_static_check.js +1624 -0
- package/skills/a2h-ui-transfer/scripts/measure_pack.js +1005 -0
- package/skills/a2h-ui-transfer/scripts/package.json +3 -0
- package/skills/hmos-fix-build-errors/SKILL.md +1 -1
- package/skills/hmos-incremental-ui-align/README.md +15 -15
- package/skills/hmos-incremental-ui-align/SKILL.md +15 -15
- package/skills/hmos-incremental-ui-align/references/State_Model_Template.md +2 -2
- package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.js +790 -0
- package/skills/hmos-incremental-ui-align/scripts/extract_checklist.js +285 -0
- package/skills/hmos-incremental-ui-align/scripts/navigation-capure.md +76 -76
- package/skills/hmos-incremental-ui-align/scripts/page_capture.js +756 -0
- package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.js +155 -0
- package/skills/hmos-integration-test/README.md +341 -0
- package/skills/hmos-integration-test/SKILL.md +446 -0
- package/skills/hmos-integration-test/scripts/report-tool.mjs +646 -0
- package/skills/hmos-integration-test/scripts/resolve-metadata-tool.mjs +147 -0
- package/skills/hmos-integration-test/scripts/self-test-runner.mjs +1006 -0
- package/skills/hmos-integration-test/scripts/testcases-tool.mjs +189 -0
- package/skills/hmos-spec-generate/SKILL.md +26 -24
- package/skills/hmos-spec-generate/scripts/parse_requirements.ts +515 -0
- package/skills/hmos-spec-generate/template/REQ.txt +22 -0
- package/skills/hmos-spec-generate/template/REQ.xlsx +0 -0
- package/skills/hmos-batch-ui-align/SKILL.md +0 -141
- package/skills/hmos-batch-ui-align/references/conversion-procedure.md +0 -217
- package/skills/hmos-incremental-ui-align/scripts/app_feature_verify.ts +0 -999
- package/skills/hmos-incremental-ui-align/scripts/extract_checklist.ts +0 -343
- package/skills/hmos-incremental-ui-align/scripts/page_capture.ts +0 -977
- package/skills/hmos-incremental-ui-align/scripts/page_capture_burst.ts +0 -188
- package/skills/hmos-resources-convert/SKILL.md +0 -654
- package/skills/hmos-resources-convert/template/AppScope/app.json5 +0 -10
- package/skills/hmos-resources-convert/template/AppScope/resources/base/element/string.json +0 -8
- package/skills/hmos-resources-convert/template/AppScope/resources/base/media/background.png +0 -0
- package/skills/hmos-resources-convert/template/AppScope/resources/base/media/foreground.png +0 -0
- package/skills/hmos-resources-convert/template/AppScope/resources/base/media/layered_image.json +0 -7
- package/skills/hmos-resources-convert/template/build-profile.json5 +0 -42
- package/skills/hmos-resources-convert/template/code-linter.json5 +0 -32
- package/skills/hmos-resources-convert/template/entry/build-profile.json5 +0 -33
- package/skills/hmos-resources-convert/template/entry/hvigorfile.ts +0 -6
- package/skills/hmos-resources-convert/template/entry/obfuscation-rules.txt +0 -23
- package/skills/hmos-resources-convert/template/entry/oh-package.json5 +0 -10
- package/skills/hmos-resources-convert/template/entry/src/main/ets/entryability/EntryAbility.ets +0 -48
- package/skills/hmos-resources-convert/template/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets +0 -16
- package/skills/hmos-resources-convert/template/entry/src/main/ets/pages/Index.ets +0 -23
- package/skills/hmos-resources-convert/template/entry/src/main/module.json5 +0 -55
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/color.json +0 -8
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/float.json +0 -8
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/element/string.json +0 -16
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/background.png +0 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/foreground.png +0 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/layered_image.json +0 -7
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/media/startIcon.png +0 -0
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/backup_config.json +0 -3
- package/skills/hmos-resources-convert/template/entry/src/main/resources/base/profile/main_pages.json +0 -5
- package/skills/hmos-resources-convert/template/entry/src/main/resources/dark/element/color.json +0 -8
- package/skills/hmos-resources-convert/template/entry/src/mock/mock-config.json5 +0 -2
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/Ability.test.ets +0 -35
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/ets/test/List.test.ets +0 -5
- package/skills/hmos-resources-convert/template/entry/src/ohosTest/module.json5 +0 -16
- package/skills/hmos-resources-convert/template/entry/src/test/List.test.ets +0 -5
- package/skills/hmos-resources-convert/template/entry/src/test/LocalUnit.test.ets +0 -33
- package/skills/hmos-resources-convert/template/hvigor/hvigor-config.json5 +0 -23
- package/skills/hmos-resources-convert/template/hvigorfile.ts +0 -6
- package/skills/hmos-resources-convert/template/oh-package-lock.json5 +0 -28
- package/skills/hmos-resources-convert/template/oh-package.json5 +0 -10
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-atomic-component-mapping-reference.md +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-interaction-mapping-reference.md +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mappings/android-to-harmonyOS-ui-layout-mapping-reference.md +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Link/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Observed/350/243/205/351/245/260/345/231/250/345/222/214@ObjectLink/350/243/205/351/245/260/345/231/250/357/274/232/345/265/214/345/245/227/347/261/273/345/257/271/350/261/241/345/261/236/346/200/247/345/217/230/345/214/226.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Prop/350/243/205/351/245/260/345/231/250/357/274/232/347/210/266/345/255/220/345/215/225/345/220/221/345/220/214/346/255/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Provide/350/243/205/351/245/260/345/231/250/345/222/214@Consume/350/243/205/351/245/260/345/231/250/357/274/232/344/270/216/345/220/216/344/273/243/347/273/204/344/273/266/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@State/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/347/212/266/346/200/201.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Track/350/243/205/351/245/260/345/231/250/357/274/232class/345/257/271/350/261/241/345/261/236/346/200/247/347/272/247/346/233/264/346/226/260.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/@Watch/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/346/233/264/346/224/271/351/200/232/347/237/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/AppStorage/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200/347/232/204UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/Environment/357/274/232/350/256/276/345/244/207/347/216/257/345/242/203/346/237/245/350/257/242.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/LocalStorage/357/274/232/351/241/265/351/235/242/347/272/247UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/MVVM/346/250/241/345/274/217/357/274/210V1/357/274/211.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm/PersistentStorage/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm//347/256/241/347/220/206/345/272/224/347/224/250/346/213/245/346/234/211/347/232/204/347/212/266/346/200/201/346/246/202/350/277/260.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/!!/350/257/255/346/263/225/357/274/232/345/217/214/345/220/221/347/273/221/345/256/232.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Computed/350/243/205/351/245/260/345/231/250/357/274/232/350/256/241/347/256/227/345/261/236/346/200/247.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Event/350/243/205/351/245/260/345/231/250/357/274/232/350/247/204/350/214/203/347/273/204/344/273/266/350/276/223/345/207/272.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Local/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/206/205/351/203/250/347/212/266/346/200/201.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Monitor/350/243/205/351/245/260/345/231/250/357/274/232/347/212/266/346/200/201/345/217/230/351/207/217/344/277/256/346/224/271/345/274/202/346/255/245/347/233/221/345/220/254.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@ObservedV2/350/243/205/351/245/260/345/231/250/345/222/214@Trace/350/243/205/351/245/260/345/231/250/357/274/232/347/261/273/345/261/236/346/200/247/345/217/230/345/214/226/350/247/202/346/265/213.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Once/350/243/205/351/245/260/345/231/250/357/274/232/345/210/235/345/247/213/345/214/226/345/220/214/346/255/245/344/270/200/346/254/241.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Param/350/243/205/351/245/260/345/231/250/357/274/232/347/273/204/344/273/266/345/244/226/351/203/250/350/276/223/345/205/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Provider/350/243/205/351/245/260/345/231/250/345/222/214@Consumer/350/243/205/351/245/260/345/231/250/357/274/232/350/267/250/347/273/204/344/273/266/345/261/202/347/272/247/345/217/214/345/220/221/345/220/214/346/255/245.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/@Type/350/243/205/351/245/260/345/231/250/357/274/232/346/240/207/350/256/260/347/261/273/345/261/236/346/200/247/347/232/204/347/261/273/345/236/213.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/AppStorageV2/357/274/232/345/272/224/347/224/250/345/205/250/345/261/200UI/347/212/266/346/200/201/345/255/230/345/202/250.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/MVVM/346/250/241/345/274/217/357/274/210V2/357/274/211.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2/PersistenceV2/357/274/232/346/214/201/344/271/205/345/214/226/345/255/230/345/202/250UI/347/212/266/346/200/201.md" +0 -0
- /package/skills/{hmos-batch-ui-align → a2h-ui-transfer}/references/mvvm-v2//347/212/266/346/200/201/347/256/241/347/220/206V1/345/220/221V2/350/277/201/347/247/273/344/270/216/346/267/267/347/224/250/346/214/207/345/257/274.md" +0 -0
|
@@ -0,0 +1,1624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* arkts_static_check — 转换后 ArkTS 代码的静态缺陷检查器。
|
|
3
|
+
*
|
|
4
|
+
* 定位:补上「编译期发现不了、但纯词法即可判定」的那一类缺陷。
|
|
5
|
+
* 纯 JavaScript(ESM),直接 node 运行,无需构建、无依赖:
|
|
6
|
+
*
|
|
7
|
+
* node arkts_static_check.js --ets <harmony_project>/<ui_module>/src/main/ets \
|
|
8
|
+
* [--resources <harmony_project>/<ui_module>/src/main/resources]
|
|
9
|
+
* <ui_module> 不一定是 entry;分层工程如 products/phone。
|
|
10
|
+
*
|
|
11
|
+
* 输出分两段,语义完全不同:
|
|
12
|
+
*
|
|
13
|
+
* [GATE] 零歧义缺陷。**必须清零**,否则 exit code 1。
|
|
14
|
+
* [CANDIDATES] 仅缩小人工核对范围,**不判定对错**、不影响 exit code。
|
|
15
|
+
* 这些位置合法与否取决于 Android 源侧的意图(锚定方向、层叠次序),
|
|
16
|
+
* 词法分析无法判断 —— 由 Phase 5 对应检查项逐条标注来源。
|
|
17
|
+
*
|
|
18
|
+
* 设计要点:先把注释替换成等长空白再匹配。否则「解释某个坑」的文档注释
|
|
19
|
+
* 本身就会被当成那个坑命中 —— 检查器被自己要防的说明骗了。
|
|
20
|
+
*
|
|
21
|
+
* 注:R7/R8/R9 需要 --resources,因为它们要读 SVG 文件内容(而非只看代码);
|
|
22
|
+
* 未传 --resources 时这些规则整条跳过,与 string.json 检查一致。
|
|
23
|
+
*
|
|
24
|
+
* R7/R8/R9 共同针对同一个失效模式:**「资源存在」不等于「资源能渲染对」**。
|
|
25
|
+
* 页面转换时惯于用 `ls media/` 确认名字存在就通过,从不打开文件,于是
|
|
26
|
+
* 「引用名正确 + 文件存在 + 编译通过」三项全过,图标却是黑块/白框/灰框。
|
|
27
|
+
*
|
|
28
|
+
* R11(entry-route-not-converted)针对另一个同源模式:
|
|
29
|
+
* **「注册过」不等于「会被加载」**。main_pages.json 里有某页只说明它可被路由到,
|
|
30
|
+
* 真正决定启动画面的是 UIAbility 的 loadContent()。二者不一致时编译与静态引用
|
|
31
|
+
* 检查全过,唯一症状是启动后停在脚手架的 Hello World,转换成果一次也没显示过 ——
|
|
32
|
+
* 只有真机启动能看见,但判据完全是词法的,故定为 GATE。
|
|
33
|
+
* 它以「工程内已有转换产出页」为前提:空工程加载脚手架页是正确状态,不报。
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import * as fs from 'fs';
|
|
37
|
+
import * as path from 'path';
|
|
38
|
+
|
|
39
|
+
const BACKTICK = String.fromCharCode(96);
|
|
40
|
+
|
|
41
|
+
// type Severity = 'GATE' | 'CAND';
|
|
42
|
+
|
|
43
|
+
// interface Finding {
|
|
44
|
+
// sev: Severity;
|
|
45
|
+
// rule: string;
|
|
46
|
+
// file: string;
|
|
47
|
+
// line: number;
|
|
48
|
+
// msg: string;
|
|
49
|
+
// }
|
|
50
|
+
|
|
51
|
+
const findings = [];
|
|
52
|
+
|
|
53
|
+
function report(sev, rule, file, line, msg) {
|
|
54
|
+
findings.push({ sev, rule, file, line, msg });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------- 预处理
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 把注释替换成等长空白,保持行号与列号不变。
|
|
61
|
+
* 字符串与模板字符串内的 // 和 /* 不算注释。
|
|
62
|
+
*/
|
|
63
|
+
function stripComments(src) {
|
|
64
|
+
let out = '';
|
|
65
|
+
let i = 0;
|
|
66
|
+
let state = 0; // 0 code, 1 line-comment, 2 block-comment, 3 string, 4 template
|
|
67
|
+
let quote = '';
|
|
68
|
+
|
|
69
|
+
while (i < src.length) {
|
|
70
|
+
const c = src[i];
|
|
71
|
+
const n = src[i + 1];
|
|
72
|
+
|
|
73
|
+
if (state === 0) {
|
|
74
|
+
if (c === '/' && n === '/') { state = 1; i += 2; out += ' '; continue; }
|
|
75
|
+
if (c === '/' && n === '*') { state = 2; i += 2; out += ' '; continue; }
|
|
76
|
+
if (c === '"' || c === "'") { state = 3; quote = c; out += c; i++; continue; }
|
|
77
|
+
if (c === BACKTICK) { state = 4; out += c; i++; continue; }
|
|
78
|
+
out += c; i++; continue;
|
|
79
|
+
}
|
|
80
|
+
if (state === 1) {
|
|
81
|
+
if (c === '\n') { state = 0; out += '\n'; } else { out += ' '; }
|
|
82
|
+
i++; continue;
|
|
83
|
+
}
|
|
84
|
+
if (state === 2) {
|
|
85
|
+
if (c === '*' && n === '/') { state = 0; i += 2; out += ' '; continue; }
|
|
86
|
+
out += (c === '\n' ? '\n' : ' '); i++; continue;
|
|
87
|
+
}
|
|
88
|
+
if (state === 3) {
|
|
89
|
+
out += c;
|
|
90
|
+
if (c === '\\') { out += (src[i + 1] || ''); i += 2; continue; }
|
|
91
|
+
if (c === quote) { state = 0; }
|
|
92
|
+
i++; continue;
|
|
93
|
+
}
|
|
94
|
+
// state === 4
|
|
95
|
+
out += c;
|
|
96
|
+
if (c === '\\') { out += (src[i + 1] || ''); i += 2; continue; }
|
|
97
|
+
if (c === BACKTICK) { state = 0; }
|
|
98
|
+
i++;
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 标记哪些行处于 /* ... *\/ 块注释内部(用于「注释提前闭合」检查)。 */
|
|
104
|
+
function blockCommentLines(src) {
|
|
105
|
+
const inside = new Set();
|
|
106
|
+
let depth = 0;
|
|
107
|
+
const lines = src.split('\n');
|
|
108
|
+
|
|
109
|
+
for (let idx = 0; idx < lines.length; idx++) {
|
|
110
|
+
if (depth > 0) { inside.add(idx + 1); }
|
|
111
|
+
const line = lines[idx];
|
|
112
|
+
let i = 0;
|
|
113
|
+
while (i < line.length) {
|
|
114
|
+
if (line[i] === '/' && line[i + 1] === '*') { depth++; inside.add(idx + 1); i += 2; continue; }
|
|
115
|
+
if (line[i] === '*' && line[i + 1] === '/') { if (depth > 0) { depth--; } i += 2; continue; }
|
|
116
|
+
if (line[i] === '/' && line[i + 1] === '/' && depth === 0) { break; }
|
|
117
|
+
i++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return inside;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 把代码切成「属性链块」:组件行 + 其后连续的 .xxx() 行。
|
|
125
|
+
* 同一元素的属性共现判定必须在链内进行,否则会跨元素误判。
|
|
126
|
+
*/
|
|
127
|
+
// interface Chain {
|
|
128
|
+
// start: number; // 1-based 行号
|
|
129
|
+
// lines: string[];
|
|
130
|
+
// }
|
|
131
|
+
|
|
132
|
+
function attributeChains(codeLines) {
|
|
133
|
+
const chains = [];
|
|
134
|
+
let cur = null;
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < codeLines.length; i++) {
|
|
137
|
+
const line = codeLines[i];
|
|
138
|
+
if (/^\s*\./.test(line)) {
|
|
139
|
+
if (cur === null) {
|
|
140
|
+
// 链首行取上一行(组件调用行)
|
|
141
|
+
cur = { start: i, lines: [codeLines[i - 1] || ''] };
|
|
142
|
+
}
|
|
143
|
+
cur.lines.push(line);
|
|
144
|
+
} else if (cur !== null) {
|
|
145
|
+
chains.push(cur);
|
|
146
|
+
cur = null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (cur !== null) { chains.push(cur); }
|
|
150
|
+
return chains;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------- 逐文件规则
|
|
154
|
+
|
|
155
|
+
const RE_PCT_WIDTH = /\.width\((['"])100%\1\)/;
|
|
156
|
+
const RE_PCT_HEIGHT = /\.height\((['"])100%\1\)/;
|
|
157
|
+
const RE_H_MARGIN = /\.margin\(\s*\{[^}]*(left|right|start|end)\s*:/;
|
|
158
|
+
// interface GetterHit {
|
|
159
|
+
// name: string;
|
|
160
|
+
// line: number;
|
|
161
|
+
// }
|
|
162
|
+
|
|
163
|
+
// interface ObservedTrackClass {
|
|
164
|
+
// name: string;
|
|
165
|
+
// getters: GetterHit[];
|
|
166
|
+
// }
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 找出所有「带 @Observed 且类体内至少有一个 @Track」的类,并列出该类体内声明的 getter。
|
|
170
|
+
*
|
|
171
|
+
* 为什么按类体而不是整文件:一个 .ets 里常常同时有 @Observed 的 VM/Model 和普通
|
|
172
|
+
* class / interface(如纯数据的 DTO、工具类),整文件判定会把后者的 getter 也算进来。
|
|
173
|
+
* 类体范围用花括号配对求得(注释已在 stripComments 阶段被替换成等长空白,
|
|
174
|
+
* 因此不会有注释里的花括号干扰配对)。
|
|
175
|
+
*/
|
|
176
|
+
function observedTrackClassBodies(code, codeLines) {
|
|
177
|
+
const out = [];
|
|
178
|
+
// @Observed 之后允许有若干行(export / 注释留下的空行)再到 class 声明
|
|
179
|
+
const reClass = /@Observed[\s\S]{0,200}?\bclass\s+([A-Za-z_$][\w$]*)/g;
|
|
180
|
+
let m;
|
|
181
|
+
while ((m = reClass.exec(code)) !== null) {
|
|
182
|
+
const clsName = m[1];
|
|
183
|
+
// 从 class 声明之后的第一个 { 开始做花括号配对,求出类体范围
|
|
184
|
+
const open = code.indexOf('{', m.index + m[0].length);
|
|
185
|
+
if (open < 0) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
let depth = 0;
|
|
189
|
+
let end = -1;
|
|
190
|
+
for (let i = open; i < code.length; i++) {
|
|
191
|
+
const c = code[i];
|
|
192
|
+
if (c === '{') {
|
|
193
|
+
depth++;
|
|
194
|
+
} else if (c === '}') {
|
|
195
|
+
depth--;
|
|
196
|
+
if (depth === 0) {
|
|
197
|
+
end = i;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (end < 0) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const body = code.slice(open, end);
|
|
206
|
+
// 只有类体内真的用了 @Track 才受本约束(全无 @Track 的类走的是整类观测,不报)
|
|
207
|
+
if (!/@Track\b/.test(body)) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const bodyStartLine = code.slice(0, open).split('\n').length;
|
|
211
|
+
const getters = [];
|
|
212
|
+
const bodyLines = body.split('\n');
|
|
213
|
+
for (let i = 0; i < bodyLines.length; i++) {
|
|
214
|
+
// `get name()` —— 必须排除 `getXxx()` 普通方法,故 get 后要求空白
|
|
215
|
+
const g = /^\s*(?:public\s+|private\s+|protected\s+)?get\s+([A-Za-z_$][\w$]*)\s*\(\s*\)/.exec(bodyLines[i]);
|
|
216
|
+
if (g) {
|
|
217
|
+
getters.push({ name: g[1], line: bodyStartLine + i });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (getters.length > 0) {
|
|
221
|
+
out.push({ name: clsName, getters });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
void codeLines;
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const RE_V1_DECO = /^\s*@(State|Prop|Link|Observed|Track|Provide|Consume|Watch|CustomDialog)\b/gm;
|
|
229
|
+
const RE_V2_DECO = /^\s*@(Local|Param|Once|Event|ObservedV2|Trace|Monitor|Computed|Provider|Consumer)\b/gm;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* ArkUI 通用属性方法名 —— CustomComponent 基类已把它们定义为方法,
|
|
233
|
+
* 自定义组件里再声明同名**状态字段**会冲突(编译期 10505001)。
|
|
234
|
+
* 取自 SDK common.d.ts 的 CommonMethod,只收最容易被当成字段名的那些。
|
|
235
|
+
*/
|
|
236
|
+
const RESERVED_ATTR_NAMES = new Set([
|
|
237
|
+
'size', 'width', 'height', 'margin', 'padding', 'position', 'scale', 'opacity',
|
|
238
|
+
'border', 'clip', 'visibility', 'direction', 'align', 'offset', 'rotate',
|
|
239
|
+
'translate', 'zIndex', 'enabled', 'id', 'key'
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
// interface TintedMedia {
|
|
243
|
+
// name: string;
|
|
244
|
+
// line: number;
|
|
245
|
+
// }
|
|
246
|
+
|
|
247
|
+
// interface FileScan {
|
|
248
|
+
// rel: string;
|
|
249
|
+
// hasExpandSafeArea: boolean;
|
|
250
|
+
// isPage: boolean;
|
|
251
|
+
// hasWindowFullScreen: boolean;
|
|
252
|
+
// tinted: TintedMedia[];
|
|
253
|
+
// /** 本文件引用的**全部** app.media.X(不限于被染色的),供媒体内容体检使用。 */
|
|
254
|
+
// mediaRefs: TintedMedia[];
|
|
255
|
+
// text: string; // 去注释后的代码文本,供 checkWindowMode 使用
|
|
256
|
+
// }
|
|
257
|
+
|
|
258
|
+
function checkFile(file, rel) {
|
|
259
|
+
const src = fs.readFileSync(file, 'utf8');
|
|
260
|
+
const code = stripComments(src);
|
|
261
|
+
const rawLines = src.split('\n');
|
|
262
|
+
const codeLines = code.split('\n');
|
|
263
|
+
const inBlock = blockCommentLines(src);
|
|
264
|
+
|
|
265
|
+
// ---- GATE R1:块注释被 */ 序列提前闭合
|
|
266
|
+
// 典型来源:把含通配符的证据路径(如 foo_*/view.xml)写进 JSDoc。
|
|
267
|
+
// 后果:其后正文被当作代码解析,编译器报出大量指向注释正文的假坐标错误。
|
|
268
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
269
|
+
if (!inBlock.has(i + 1)) { continue; }
|
|
270
|
+
// 判据:块内任何位置出现 */ 都会闭合外层注释,**与它后面跟什么无关**。
|
|
271
|
+
// 早期版本用 /\*\/[^\s]/(要求 */ 后紧跟非空白),只抓到 foo_*(斜杠)view.xml 这类,
|
|
272
|
+
// 漏掉了行尾形态 —— 把示例代码贴进 JSDoc 时最常见的那种(内层 /* … */ 收在行尾),
|
|
273
|
+
// 因为它后面是行尾空白。实测漏检:ListPreferenceDialogHost.ets 由此产生 126 个
|
|
274
|
+
// 坐标指向注释正文的假错误,而 GATE 报 0 条。
|
|
275
|
+
// 注意 JS 块注释**不可嵌套**,故内层的 /* 本身无害,危险的只有 */ 。
|
|
276
|
+
if (/^\s*\/\*/.test(rawLines[i])) { continue; } // 自身即开块(含单行 /** … */)
|
|
277
|
+
if (/^\s*\*\/\s*$/.test(rawLines[i])) { continue; } // 正常终止行:*/ 独占一行
|
|
278
|
+
if (rawLines[i].includes('*/')) {
|
|
279
|
+
report('GATE', 'block-comment-break', rel, i + 1,
|
|
280
|
+
'JSDoc 块内出现 */ 序列,注释在此提前闭合,其后正文将被当作代码解析' +
|
|
281
|
+
'(内层示例里的 /* … */ 请改成 // 行注释形式)');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---- GATE R2:$r() 传模板字符串($r 要求字面量)
|
|
286
|
+
for (let i = 0; i < codeLines.length; i++) {
|
|
287
|
+
if (codeLines[i].indexOf('$r(' + BACKTICK) >= 0) {
|
|
288
|
+
report('GATE', 'r-template-string', rel, i + 1,
|
|
289
|
+
'$r() 要求字面量,模板字符串拼接在运行时不可靠 —— 改为显式映射表');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const chains = attributeChains(codeLines);
|
|
294
|
+
|
|
295
|
+
for (const ch of chains) {
|
|
296
|
+
const blob = ch.lines.join('\n');
|
|
297
|
+
|
|
298
|
+
// ---- GATE R3:同一元素上 width('100%') 与左右 margin 叠加
|
|
299
|
+
if (RE_PCT_WIDTH.test(blob) && RE_H_MARGIN.test(blob)) {
|
|
300
|
+
report('GATE', 'pct-width-with-hmargin', rel, ch.start,
|
|
301
|
+
"width('100%') 与左右 margin 叠加 → 100% 不扣除自身 margin,实际宽度超出父容器,右侧溢出");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---- CAND R7:撑满容器且未声明 hitTestBehavior
|
|
305
|
+
// 只有「同时是层叠末位子节点」且「其下有可点击元素」才真正构成缺陷,
|
|
306
|
+
// 词法分析判不了这两点 —— 故仅列为候选。
|
|
307
|
+
if (RE_PCT_WIDTH.test(blob) && RE_PCT_HEIGHT.test(blob) && blob.indexOf('hitTestBehavior') < 0) {
|
|
308
|
+
report('CAND', 'fullsize-container', rel, ch.start,
|
|
309
|
+
'撑满容器未声明 hitTestBehavior:若它是层叠末位子节点,会吞掉其下元素的点击');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---- CAND R8:常量坐标
|
|
314
|
+
// 合法与否取决于 Android 侧锚定方向:起始边固定 margin 合法,右/下锚定则是缺陷。
|
|
315
|
+
for (let i = 0; i < codeLines.length; i++) {
|
|
316
|
+
const m = codeLines[i].match(/\.(position|offset)\(\s*\{([^}]*)\}/);
|
|
317
|
+
if (m !== null && /[xy]\s*:\s*-?\d/.test(m[2])) {
|
|
318
|
+
report('CAND', 'constant-coordinate', rel, i + 1,
|
|
319
|
+
'坐标含常量:需回读 Android 源确认是「固定偏移」还是「由父尺寸推导」');
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---- GATE R3b:状态变量名与 ArkUI 通用属性同名
|
|
324
|
+
//
|
|
325
|
+
// 自定义组件继承 CustomComponent,后者已把 width/height/size/margin/... 定义为
|
|
326
|
+
// **方法**。用同名字段(如 `@Prop size: number`)会与基类成员冲突,编译器报
|
|
327
|
+
// 10505001「Property 'size' in type 'X' is not assignable to the same property in
|
|
328
|
+
// base type 'CustomComponent'」—— 报错信息指向类型不兼容,读起来像泛型/赋值问题,
|
|
329
|
+
// 与"换个名字就好"毫无字面关联,实测要靠猜。
|
|
330
|
+
//
|
|
331
|
+
// 值得单列一条:`size` 对一个图标组件是**最自然的**命名,四个图标组件会一次性
|
|
332
|
+
// 全部命中(实测首轮 8 个编译错误里有 4 个是这一条),而且它跨页复现 ——
|
|
333
|
+
// 任何新写的图标/头像/徽标组件都会再踩一次。
|
|
334
|
+
for (let i = 0; i < codeLines.length; i++) {
|
|
335
|
+
const dm = codeLines[i].match(
|
|
336
|
+
/@(?:Prop|State|Link|Local|Param|Provide|Consume|ObjectLink)\s+([A-Za-z_$][\w$]*)\s*[:?]/);
|
|
337
|
+
if (dm !== null && RESERVED_ATTR_NAMES.has(dm[1])) {
|
|
338
|
+
report('GATE', 'state-name-shadows-attribute', rel, i + 1,
|
|
339
|
+
`状态变量名 \`${dm[1]}\` 与 ArkUI 通用属性方法同名 —— CustomComponent 基类已定义 ` +
|
|
340
|
+
`${dm[1]}() 方法,同名字段会冲突,编译器报 10505001「not assignable to the same ` +
|
|
341
|
+
`property in base type 'CustomComponent'」(报错文本不会提示改名,极难反查)。` +
|
|
342
|
+
`改成带前缀的名字,如 \`icon${dm[1].charAt(0).toUpperCase() + dm[1].slice(1)}\``);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---- GATE R3c:@Builder 形参声明为 () => void 并被裸调用
|
|
347
|
+
//
|
|
348
|
+
// 把"子内容"作为回调传进 @Builder 是命令式思维下最顺手的写法,但 ArkUI 的
|
|
349
|
+
// @Builder 体必须是**声明式 UI 语句**,裸 `icon();` 不是,编译器报 10905204
|
|
350
|
+
// 「'icon();' does not meet UI component syntax」。正确做法是传 @Builder 引用
|
|
351
|
+
// (@BuilderParam),或直接把子内容内联展开。
|
|
352
|
+
//
|
|
353
|
+
// 与上一条同源:两者都是**编译期**才暴露、且报错信息不指向真正原因,
|
|
354
|
+
// 于是每次都要付一整轮构建(实测单轮 44s~70s)才能发现。
|
|
355
|
+
for (let i = 0; i < codeLines.length; i++) {
|
|
356
|
+
if (!/@Builder/.test(codeLines[i])) { continue; }
|
|
357
|
+
const sig = codeLines[i + 1] || '';
|
|
358
|
+
// 形参表必须按括号配平截取:`() => void` 里的 `)` 会让 `[^)]*` 提前收尾,
|
|
359
|
+
// 把 `tap: () => void, icon: () => void` 截断成 `tap: (` —— 回调形参恰好
|
|
360
|
+
// 因此全部漏掉,规则静默失效(本条自身就踩过一次)。
|
|
361
|
+
const nm = sig.match(/\b([A-Za-z_$][\w$]*)\s*\(/);
|
|
362
|
+
if (nm === null || nm.index === undefined) { continue; }
|
|
363
|
+
let depth = 0;
|
|
364
|
+
let open = sig.indexOf('(', nm.index);
|
|
365
|
+
let close = -1;
|
|
366
|
+
for (let c = open; c < sig.length; c++) {
|
|
367
|
+
if (sig[c] === '(') { depth++; }
|
|
368
|
+
else if (sig[c] === ')') { depth--; if (depth === 0) { close = c; break; } }
|
|
369
|
+
}
|
|
370
|
+
if (close < 0) { continue; }
|
|
371
|
+
const params = sig.slice(open + 1, close);
|
|
372
|
+
const sm = [nm[0], nm[1], params];
|
|
373
|
+
const cbs = [...params.matchAll(/([A-Za-z_$][\w$]*)\s*:\s*\(\s*\)\s*=>\s*void/g)]
|
|
374
|
+
.map((x) => x[1]);
|
|
375
|
+
if (cbs.length === 0) { continue; }
|
|
376
|
+
const body = codeLines.slice(i + 1, i + 40).join('\n');
|
|
377
|
+
for (const c of cbs) {
|
|
378
|
+
if (new RegExp('(^|[^.\\w$])' + c + '\\s*\\(\\s*\\)\\s*;').test(body)) {
|
|
379
|
+
report('GATE', 'builder-invokes-callback-param', rel, i + 2,
|
|
380
|
+
`@Builder ${sm[1]} 的形参 \`${c}: () => void\` 在体内被裸调用 \`${c}();\` —— ` +
|
|
381
|
+
`@Builder 体只能是声明式 UI 语句,编译器报 10905204「does not meet UI component ` +
|
|
382
|
+
`syntax」。改用 @BuilderParam 传入 @Builder 引用,或把该子内容内联展开`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---- GATE R4:V1/V2 状态管理范式混用
|
|
388
|
+
const v1 = (code.match(RE_V1_DECO) || []).length;
|
|
389
|
+
const v2 = (code.match(RE_V2_DECO) || []).length;
|
|
390
|
+
if (v1 > 0 && v2 > 0) {
|
|
391
|
+
report('GATE', 'paradigm-mix', rel, 1,
|
|
392
|
+
`同文件内 V1(${v1} 处) 与 V2(${v2} 处) 装饰器混用 —— 两套范式不得在同一工程混用`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---- GATE R5:@ObservedV2 类无任何 @Trace(不配合则完全不生效)
|
|
396
|
+
if (/@ObservedV2/.test(code) && (code.match(/@Trace/g) || []).length === 0) {
|
|
397
|
+
report('GATE', 'observedv2-without-trace', rel, 1,
|
|
398
|
+
'@ObservedV2 类中没有任何 @Trace 属性,状态变更不会触发 UI 刷新');
|
|
399
|
+
}
|
|
400
|
+
// 反向:有 @Trace 但类上没有 @ObservedV2
|
|
401
|
+
if (/@Trace/.test(code) && !/@ObservedV2/.test(code)) {
|
|
402
|
+
report('GATE', 'trace-without-observedv2', rel, 1,
|
|
403
|
+
'出现 @Trace 但类上缺少 @ObservedV2,二者必须配合,单独使用不生效');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ---- GATE R5b:@Observed + @Track 的类里用 getter 暴露派生值(V1 专有陷阱)
|
|
407
|
+
//
|
|
408
|
+
// 判据来自 references/mvvm/@Track装饰器:class对象属性级更新.md:15 与 :198 ——
|
|
409
|
+
// 「如果 class 类中使用了 @Track 装饰器,则未被 @Track 装饰的属性不能在 UI 中使用,
|
|
410
|
+
// 如果使用,会发生运行时报错」。
|
|
411
|
+
//
|
|
412
|
+
// getter **不是数据属性,无法被 @Track 装饰**,于是 @Track 的属性级代理会把它判为
|
|
413
|
+
// 「未被 @Track 的属性」,在 build() 里首次读取即抛
|
|
414
|
+
// BusinessError 140110: Illegal usage of not @Track'ed property 'x' on UI!
|
|
415
|
+
//
|
|
416
|
+
// 为什么这一条必须是 GATE 而不是 CAND:
|
|
417
|
+
// * 字段侧完备性检查会**全部通过** —— 15 个字段都规规矩矩带了 @Track;
|
|
418
|
+
// * 编译通过、零告警,GATE 其余各条也全部为 0;
|
|
419
|
+
// * V1 没有 @Computed(那是 V2 的能力),所以「派生值该怎么暴露」在 V1 文档里
|
|
420
|
+
// 没有正面答案,极易被推理成「普通 TS getter 就行,反正读取会重新求值」——
|
|
421
|
+
// 该推理关于**响应性**是对的,关于**合法性**是错的,两者极易混为一谈。
|
|
422
|
+
// * 只有真机首帧崩溃能发现它,而判据完全是词法的(同文件内即可判定)。
|
|
423
|
+
//
|
|
424
|
+
// 修法:把 getter 改成**普通方法**(`title(): string` + UI 侧 `vm.title()`)。
|
|
425
|
+
// 原型方法不是数据属性,不经过 @Track 代理,故不受本约束限制。
|
|
426
|
+
// 也可把派生值提升为真正的 @Track 字段,在赋值处同步维护。
|
|
427
|
+
//
|
|
428
|
+
// 精度说明:按**类体**逐个判定,而不是整文件 —— 同一文件里可能既有 @Observed
|
|
429
|
+
// 类也有普通 class/interface,整文件判定会误报后者的 getter。
|
|
430
|
+
for (const cls of observedTrackClassBodies(code, codeLines)) {
|
|
431
|
+
for (const g of cls.getters) {
|
|
432
|
+
report('GATE', 'track-class-getter-in-ui', rel, g.line,
|
|
433
|
+
`@Observed 类 ${cls.name} 同时使用了 @Track,其 getter '${g.name}' 无法被 @Track 装饰;` +
|
|
434
|
+
'一旦在 build()/@Builder 中读取,首帧即抛 BusinessError 140110 ' +
|
|
435
|
+
`(Illegal usage of not @Track'ed property '${g.name}' on UI)。` +
|
|
436
|
+
'改成普通方法(原型方法不受 @Track 代理约束),或提升为真正的 @Track 字段');
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---- 收集 .fillColor() 染色的 media 名(GATE R7 在跨文件阶段核对 SVG 内容)
|
|
441
|
+
//
|
|
442
|
+
// 不能用 attributeChains():注释已被替换成空白行,而空白行会**打断**链,
|
|
443
|
+
// 于是 Image( 与它的 .fillColor( 常常落在不同的"链"里(本项目实测两者相隔 23 行,
|
|
444
|
+
// 中间全是解释性注释)。改为直接按行回溯:从每个 .fillColor( 向上找最近的 Image(,
|
|
445
|
+
// 把该区间内出现的所有 app.media.<NAME> 视为被这次染色作用的候选
|
|
446
|
+
// (Image() 里常是三元选图,两个分支都要算)。
|
|
447
|
+
const tinted = [];
|
|
448
|
+
for (let i = 0; i < codeLines.length; i++) {
|
|
449
|
+
if (!codeLines[i].includes('.fillColor(')) { continue; }
|
|
450
|
+
|
|
451
|
+
// 向上找最近的 Image(。窗口给足:属性链之间夹大段注释是常态。
|
|
452
|
+
let from = -1;
|
|
453
|
+
for (let k = i; k >= 0 && k >= i - 80; k--) {
|
|
454
|
+
if (codeLines[k].includes('Image(')) { from = k; break; }
|
|
455
|
+
}
|
|
456
|
+
if (from < 0) { continue; }
|
|
457
|
+
|
|
458
|
+
// 区间 = Image( 那行 .. .fillColor( 之后的若干行(染色参数本身可能跨行,
|
|
459
|
+
// 但 media 名只会出现在 Image() 的实参里,取到 i 即可)
|
|
460
|
+
const span = codeLines.slice(from, i + 1).join('\n');
|
|
461
|
+
|
|
462
|
+
const re = /app\.media\.([A-Za-z0-9_]+)/g;
|
|
463
|
+
let m = re.exec(span);
|
|
464
|
+
while (m !== null) {
|
|
465
|
+
tinted.push({ name: m[1], line: i + 1 });
|
|
466
|
+
m = re.exec(span);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---- 收集本文件引用的全部 app.media.X(与染色无关),供跨文件的媒体内容体检使用
|
|
471
|
+
const mediaRefs = [];
|
|
472
|
+
for (let i = 0; i < codeLines.length; i++) {
|
|
473
|
+
const re = /app\.media\.([A-Za-z0-9_]+)/g;
|
|
474
|
+
let m = re.exec(codeLines[i]);
|
|
475
|
+
while (m !== null) {
|
|
476
|
+
mediaRefs.push({ name: m[1], line: i + 1 });
|
|
477
|
+
m = re.exec(codeLines[i]);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ---- 预扫:哪些 @Builder 方法在自己的顶层直接渲染 Text
|
|
482
|
+
//
|
|
483
|
+
// 必需,否则 R8 会漏掉最常见的写法:容器的直接子节点是 `this.SectionTitle(title)`
|
|
484
|
+
// 而不是字面的 `Text(...)`。实测 mihon-statistics 的居中缺陷正是这种形状 ——
|
|
485
|
+
// 只认字面 Text 时该页零命中。
|
|
486
|
+
const textBuilders = new Set();
|
|
487
|
+
for (let i = 0; i < codeLines.length; i++) {
|
|
488
|
+
if (!/^\s*@Builder\b/.test(codeLines[i])) { continue; }
|
|
489
|
+
// 方法签名可能在 @Builder 的下一行或再下一行
|
|
490
|
+
let sig = -1;
|
|
491
|
+
for (let k = i + 1; k < Math.min(i + 4, codeLines.length); k++) {
|
|
492
|
+
if (/^\s*(?:private\s+|public\s+)?[A-Za-z_$][A-Za-z0-9_$]*\s*\(/.test(codeLines[k])) { sig = k; break; }
|
|
493
|
+
}
|
|
494
|
+
if (sig < 0) { continue; }
|
|
495
|
+
const nm =
|
|
496
|
+
codeLines[sig].match(/(?:private\s+|public\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/);
|
|
497
|
+
if (nm === null) { continue; }
|
|
498
|
+
|
|
499
|
+
// 从签名行的 { 起做大括号配平,取方法体;判断体内 depth===1 处是否有 Text(
|
|
500
|
+
let depth = 0;
|
|
501
|
+
let started = false;
|
|
502
|
+
let direct = false;
|
|
503
|
+
for (let k = sig; k < codeLines.length; k++) {
|
|
504
|
+
const ln = codeLines[k];
|
|
505
|
+
for (let c = 0; c < ln.length; c++) {
|
|
506
|
+
if (ln[c] === '{') { depth++; started = true; continue; }
|
|
507
|
+
if (ln[c] === '}') { depth--; continue; }
|
|
508
|
+
if (started && depth === 1 && ln.startsWith('Text(', c)) {
|
|
509
|
+
const prev = c > 0 ? ln[c - 1] : ' ';
|
|
510
|
+
if (!/[A-Za-z0-9_.$]/.test(prev)) { direct = true; }
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (started && depth <= 0) { break; }
|
|
514
|
+
}
|
|
515
|
+
if (direct) { textBuilders.add(nm[1]); }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ---- CAND R8:Column/Row 含收缩宽度子节点但未显式设 alignItems
|
|
519
|
+
//
|
|
520
|
+
// ArkUI Column 默认 HorizontalAlign.Center、Row 默认 VerticalAlign.Center;
|
|
521
|
+
// Android LinearLayout / Compose Column 默认 **start**。两侧都不写对齐属性时
|
|
522
|
+
// 字面完全一致,逐属性对照会把它确认成「翻译正确」,而渲染结果一个居中一个齐左。
|
|
523
|
+
//
|
|
524
|
+
// 放 CAND 不放 GATE:居中有时正是对的(空态插图、按钮内图标、卡片内数值列)。
|
|
525
|
+
// 只在容器直接含 Text( 时才报,避免把纯布局容器全部列出来。
|
|
526
|
+
for (const ch of attributeChains(codeLines)) {
|
|
527
|
+
// attributeChains 的链首只回溯一行,对**带子节点**的容器而言那行是 `}` 而不是
|
|
528
|
+
// `Column() {`。这里从链首行往上做大括号配平,找到真正的容器声明行,
|
|
529
|
+
// 并把 { ... } 之间的子节点内容一并取出(判断容器是否直接含 Text 要用它)。
|
|
530
|
+
let head = ch.lines[0] || '';
|
|
531
|
+
let bodyLines = [];
|
|
532
|
+
if (/^\s*\}/.test(head)) {
|
|
533
|
+
let depth = 0;
|
|
534
|
+
let open = -1;
|
|
535
|
+
for (let k = ch.start; k >= 0; k--) {
|
|
536
|
+
const ln = codeLines[k];
|
|
537
|
+
for (let c = ln.length - 1; c >= 0; c--) {
|
|
538
|
+
if (ln[c] === '}') { depth++; }
|
|
539
|
+
else if (ln[c] === '{') {
|
|
540
|
+
depth--;
|
|
541
|
+
if (depth === 0) { open = k; break; }
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (open >= 0) { break; }
|
|
545
|
+
}
|
|
546
|
+
if (open < 0) { continue; }
|
|
547
|
+
head = codeLines[open];
|
|
548
|
+
bodyLines = codeLines.slice(open, ch.start + 1);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const m = head.match(/\b(Column|Row)\s*\(/);
|
|
552
|
+
if (m === null) { continue; }
|
|
553
|
+
const attrs = ch.lines.join('\n');
|
|
554
|
+
// 必须检查**决定水平位置的那个轴**,而两种容器的该轴属性不同名:
|
|
555
|
+
// Column -> 水平是交叉轴 -> alignItems(HorizontalAlign)
|
|
556
|
+
// Row -> 水平是主轴 -> justifyContent(FlexAlign)
|
|
557
|
+
// 若一律只看 alignItems,则 Row 上一个**完全正确的**
|
|
558
|
+
// .alignItems(VerticalAlign.Center)(纵向居中,通常正是 Android 的
|
|
559
|
+
// verticalAlignment=CenterVertically)会把本条静默放行 —— 而它管的是纵轴,
|
|
560
|
+
// 跟"文字齐左还是居中"无关。实测漏检:日期分隔行因此居中,而该容器
|
|
561
|
+
// "已写 alignItems",检查器不报、编译通过、逐属性对照亦"忠实"。
|
|
562
|
+
const axisAttr = m[1] === 'Column' ? 'alignItems' : 'justifyContent';
|
|
563
|
+
if (new RegExp('\\.' + axisAttr + '\\(').test(attrs)) { continue; }
|
|
564
|
+
// 只在容器**直接**含 Text 时才报。必须按大括号深度筛:否则最外层容器
|
|
565
|
+
// 会因为某个深层嵌套的子容器里有 Text 而被命中(该 Text 的对齐由那个
|
|
566
|
+
// 子容器决定,与本容器无关),典型如 build() 里包住整页的那个 Column。
|
|
567
|
+
if (bodyLines.length > 0) {
|
|
568
|
+
let depth = 0;
|
|
569
|
+
let directText = false;
|
|
570
|
+
for (let k = 0; k < bodyLines.length && !directText; k++) {
|
|
571
|
+
const ln = bodyLines[k];
|
|
572
|
+
for (let c = 0; c < ln.length; c++) {
|
|
573
|
+
if (ln[c] === '{') { depth++; continue; }
|
|
574
|
+
if (ln[c] === '}') { depth--; continue; }
|
|
575
|
+
// depth === 1 即本容器大括号内的顶层位置
|
|
576
|
+
if (depth !== 1) { continue; }
|
|
577
|
+
// (a) 字面 Text(
|
|
578
|
+
if (ln.startsWith('Text(', c)) {
|
|
579
|
+
const prev = c > 0 ? ln[c - 1] : ' ';
|
|
580
|
+
if (!/[A-Za-z0-9_.$]/.test(prev)) { directText = true; break; }
|
|
581
|
+
}
|
|
582
|
+
// (b) this.SomeBuilder(...) 且该 @Builder 顶层直接渲染 Text
|
|
583
|
+
if (ln.startsWith('this.', c)) {
|
|
584
|
+
const bm =
|
|
585
|
+
ln.slice(c).match(/^this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/);
|
|
586
|
+
if (bm !== null && textBuilders.has(bm[1])) { directText = true; break; }
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (!directText) { continue; }
|
|
591
|
+
} else if (!/\bText\s*\(/.test(attrs)) {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const kind = m[1];
|
|
595
|
+
const want = kind === 'Column'
|
|
596
|
+
? 'alignItems(HorizontalAlign.Start)'
|
|
597
|
+
: 'justifyContent(FlexAlign.Start)';
|
|
598
|
+
report('CAND', 'container-align-unset', rel, ch.start,
|
|
599
|
+
`${kind} 直接含 Text 子节点但未显式 .${axisAttr}() —— 它是本容器**水平方向**的` +
|
|
600
|
+
`对齐属性(Column 的水平是交叉轴 alignItems,Row 的水平是主轴 justifyContent)。` +
|
|
601
|
+
`Android LinearLayout / Compose Column 默认 start。若 Android 侧未写水平 gravity,` +
|
|
602
|
+
`应显式译成 .${want};若该处本就该居中(空态插图、按钮内图标),忽略本条。` +
|
|
603
|
+
`注意:Row 上已有的 .alignItems(VerticalAlign.*) 管的是纵轴,不满足本条`);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
rel,
|
|
608
|
+
hasExpandSafeArea: /expandSafeArea/.test(code),
|
|
609
|
+
isPage: /[\\/]pages[\\/]/.test(file),
|
|
610
|
+
hasWindowFullScreen: /setWindowLayoutFullScreen/.test(code),
|
|
611
|
+
tinted,
|
|
612
|
+
mediaRefs,
|
|
613
|
+
text: code
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ---------------------------------------------------------------- 跨文件规则
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* GATE R7:用 .fillColor() 给 stroke 着色的 SVG 染色 —— 颜色完全不生效。
|
|
621
|
+
*
|
|
622
|
+
* fillColor 只叠加 fill 通道(SDK image.d.ts: "Sets the fill color to be superimposed
|
|
623
|
+
* on the image"),对 stroke 无效。而保真规则**要求**描边图形写成 fill="none",
|
|
624
|
+
* 于是两条各自正确的规则组合出一个静默缺陷:文件内容对、引用名对、代码看起来也对,
|
|
625
|
+
* 图形却保持原始色(通常是黑)。
|
|
626
|
+
*
|
|
627
|
+
* 判据(纯文件内容,零歧义):某个被 fillColor 染色的 SVG 里,存在带具体 stroke 颜色
|
|
628
|
+
* 且自身没有具体 fill 颜色的元素 —— 该元素的颜色染不上。
|
|
629
|
+
*/
|
|
630
|
+
function checkSvgTintChannel(scans, resDir) {
|
|
631
|
+
const mediaDir = path.join(resDir, 'base', 'media');
|
|
632
|
+
if (!fs.existsSync(mediaDir)) { return; }
|
|
633
|
+
|
|
634
|
+
for (const s of scans) {
|
|
635
|
+
const seen = new Set();
|
|
636
|
+
for (const t of s.tinted) {
|
|
637
|
+
if (seen.has(t.name)) { continue; }
|
|
638
|
+
seen.add(t.name);
|
|
639
|
+
|
|
640
|
+
const svg = path.join(mediaDir, `${t.name}.svg`);
|
|
641
|
+
if (!fs.existsSync(svg)) { continue; }
|
|
642
|
+
|
|
643
|
+
let src;
|
|
644
|
+
try {
|
|
645
|
+
src = fs.readFileSync(svg, 'utf8');
|
|
646
|
+
} catch {
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// 逐个元素看:有具体 stroke 颜色、且同元素内无具体 fill 颜色 => 染不上
|
|
651
|
+
const els = src.match(/<(?:path|circle|rect|ellipse|line|polyline|polygon)\b[^>]*>/g) || [];
|
|
652
|
+
const bad = [];
|
|
653
|
+
for (const el of els) {
|
|
654
|
+
const stroke = el.match(/stroke\s*=\s*"([^"]*)"/);
|
|
655
|
+
if (stroke === null) { continue; }
|
|
656
|
+
const sv = stroke[1].trim().toLowerCase();
|
|
657
|
+
if (sv === '' || sv === 'none' || sv === 'currentcolor') { continue; }
|
|
658
|
+
const fill = el.match(/fill\s*=\s*"([^"]*)"/);
|
|
659
|
+
const fv = fill === null ? '' : fill[1].trim().toLowerCase();
|
|
660
|
+
if (fv === '' || fv === 'none') { bad.push(sv); }
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (bad.length > 0) {
|
|
664
|
+
report('GATE', 'fillcolor-on-stroke-svg', s.rel, t.line,
|
|
665
|
+
`对 app.media.${t.name} 使用了 .fillColor(),但该 SVG 有 ${bad.length} 个 stroke 着色` +
|
|
666
|
+
`(${bad.slice(0, 3).join(', ')})且无 fill —— fillColor 只作用于 fill 通道,` +
|
|
667
|
+
'这些几何的颜色不会改变(保持原色,通常为黑)。' +
|
|
668
|
+
'改法:把描边几何改写成等价 fill 几何(等宽圆环 = 外圆 + 内圆同 path + fill-rule="evenodd")、' +
|
|
669
|
+
'改用原生组件、或按状态备两份已着色素材');
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* GATE R8:用 .fillColor() 染色的 SVG 里存在**铺满 viewBox 的 fill="none" 包围盒 path**
|
|
677
|
+
* —— 整个图标会被染成实心色块。
|
|
678
|
+
*
|
|
679
|
+
* 与 R7 同族但方向相反:R7 查「stroke 几何染不上」,本条查「fill="none" 的包围盒被染上」。
|
|
680
|
+
* fillColor 是叠加到**全部**几何上的,fill="none" 并不能豁免它。
|
|
681
|
+
*
|
|
682
|
+
* 成因:Material 图标的 web SVG 标准写法带一条 `<path d="M0 0h24v24H0z" fill="none"/>`
|
|
683
|
+
* 包围盒(占位/对齐用)。Android 的 `<vector>` 源里**没有**这条 path —— 实测 10 个安卓仓
|
|
684
|
+
* 零命中 —— 所以它只会出现在「凭记忆手写 SVG」的产物里。因此本规则同时是
|
|
685
|
+
* 「图标不得凭记忆手写」这条禁令的机械抓手:命中即证明该 SVG 不是从 Android 源转换而来。
|
|
686
|
+
*
|
|
687
|
+
* 判据(纯文件内容,零歧义):某个被 fillColor 染色的 SVG 里,存在 fill="none" 且其 d
|
|
688
|
+
* 恰好是 viewBox 全矩形的 path。
|
|
689
|
+
*/
|
|
690
|
+
function isFullViewportRect(d, w, h) {
|
|
691
|
+
// 归一化:去空白、统一逗号
|
|
692
|
+
const s = d.replace(/\s+/g, '').replace(/,/g, ' ').trim();
|
|
693
|
+
// 允许 M0 0 / m0 0 起手,H/h + V/v + H/h 回 0 + Z/z 的各种大小写与相对/绝对混写。
|
|
694
|
+
// 只匹配最常见的三种等价写法,宁可漏报也不误报。
|
|
695
|
+
const nw = String(w);
|
|
696
|
+
const nh = String(h);
|
|
697
|
+
const variants = [
|
|
698
|
+
`M0 0h${nw}v${nh}H0z`,
|
|
699
|
+
`M0 0h${nw}v${nh}h-${nw}z`,
|
|
700
|
+
`M0 0H${nw}V${nh}H0Z`,
|
|
701
|
+
`M0 0l${nw} 0 0 ${nh} -${nw} 0z`
|
|
702
|
+
];
|
|
703
|
+
const norm = (x) => x.replace(/\s+/g, '').toLowerCase();
|
|
704
|
+
return variants.some((v) => norm(v) === norm(s));
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function checkFullViewportTint(scans, resDir) {
|
|
708
|
+
const mediaDir = path.join(resDir, 'base', 'media');
|
|
709
|
+
if (!fs.existsSync(mediaDir)) { return; }
|
|
710
|
+
|
|
711
|
+
// 对**全部**被引用的 media 检查,不限于 tinted。
|
|
712
|
+
//
|
|
713
|
+
// 原因有两层:
|
|
714
|
+
// (1) tinted 靠「从 .fillColor( 向上找最近的 Image(」配对,跨不过方法边界 ——
|
|
715
|
+
// 实测 readyou-accounts 的 ic_rss_feed 由 iconFor() 返回、在另一个 @Builder 里
|
|
716
|
+
// 被染色,只查 tinted 时漏检,而它恰是列表项里最显眼的那个黑框。
|
|
717
|
+
// (2) 更根本的:这条包围盒 path 是 Material web SVG 的写法,Android <vector> 源
|
|
718
|
+
// 不会产生它(实测 10 个安卓仓零命中)。所以它的出现本身就证明该文件是
|
|
719
|
+
// 凭记忆手写而非从 Android 源转换 —— 无论当前有没有染色,都该修。
|
|
720
|
+
const reportedFvp = new Set();
|
|
721
|
+
for (const s of scans) {
|
|
722
|
+
const tintedNames = new Set(s.tinted.map((x) => x.name));
|
|
723
|
+
for (const t of s.mediaRefs) {
|
|
724
|
+
if (reportedFvp.has(t.name)) { continue; }
|
|
725
|
+
|
|
726
|
+
const svg = path.join(mediaDir, `${t.name}.svg`);
|
|
727
|
+
if (!fs.existsSync(svg)) { continue; }
|
|
728
|
+
|
|
729
|
+
let src;
|
|
730
|
+
try { src = fs.readFileSync(svg, 'utf8'); } catch { continue; }
|
|
731
|
+
|
|
732
|
+
// 取 viewBox 尺寸(缺失时退回 width/height)
|
|
733
|
+
const vb = src.match(/viewBox\s*=\s*"([^"]+)"/);
|
|
734
|
+
let w = 0;
|
|
735
|
+
let h = 0;
|
|
736
|
+
if (vb !== null) {
|
|
737
|
+
const p = vb[1].trim().split(/[\s,]+/);
|
|
738
|
+
if (p.length === 4) { w = parseFloat(p[2]); h = parseFloat(p[3]); }
|
|
739
|
+
}
|
|
740
|
+
if (!(w > 0 && h > 0)) { continue; }
|
|
741
|
+
|
|
742
|
+
const els = src.match(/<path\b[^>]*>/g) || [];
|
|
743
|
+
for (const el of els) {
|
|
744
|
+
const fill = el.match(/fill\s*=\s*"([^"]*)"/);
|
|
745
|
+
if (fill === null || fill[1].trim().toLowerCase() !== 'none') { continue; }
|
|
746
|
+
const dm = el.match(/\bd\s*=\s*"([^"]*)"/);
|
|
747
|
+
if (dm === null) { continue; }
|
|
748
|
+
if (isFullViewportRect(dm[1], w, h)) {
|
|
749
|
+
reportedFvp.add(t.name);
|
|
750
|
+
const tintNote = tintedNames.has(t.name)
|
|
751
|
+
? `本文件对它用了 .fillColor(),fillColor 会把这条包围盒一并染上(fill="none" 拦不住),` +
|
|
752
|
+
'整个图标渲染为**实心色块**'
|
|
753
|
+
: '本文件未直接对它染色,但只要任何位置用 .fillColor() 染它,整个图标就会变成实心色块' +
|
|
754
|
+
'(注:跨方法返回资源时染色点可能在别的 @Builder 里)';
|
|
755
|
+
report('GATE', 'fillcolor-on-full-viewport-path', s.rel, t.line,
|
|
756
|
+
`app.media.${t.name} 的 SVG 含一条铺满 viewBox(${w}x${h})的 fill="none" ` +
|
|
757
|
+
`包围盒 path。${tintNote}。` +
|
|
758
|
+
'根因:这条 path 是 Material **web SVG** 的写法,Android <vector> 源不会产生它 —— ' +
|
|
759
|
+
'该文件是凭记忆手写而非从 Android 源转换。' +
|
|
760
|
+
'修法:从 res/drawable 的 <vector> 重新转换;确实无对应 drawable 时,' +
|
|
761
|
+
'按 Phase 4.3 的图标来源分级登记为「已降级」,不要手写');
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* GATE R9:引用了内容无法渲染的 media —— 文件存在、引用名正确,却画不出图形。
|
|
771
|
+
*
|
|
772
|
+
* 起因:页面 agent 惯于用 `ls media/` 确认资源"存在"就通过,从不打开文件。
|
|
773
|
+
* 两类都能编译、都能过引用存在性检查:
|
|
774
|
+
* (a) 转换器留下的未解析 Android 主题属性(`?attr/...`、`?colorPrimary`)进了 fill/stroke
|
|
775
|
+
* —— SVG 不认识这种值,该几何不着色;
|
|
776
|
+
* (b) 占位 SVG(a2h-resource-convert 在缺资源时生成的灰底 + 资源名文本)被当真图标用。
|
|
777
|
+
*
|
|
778
|
+
* 判据均为纯文件内容,零歧义。不依赖读 resource_mapping.md —— 那份文档很长,
|
|
779
|
+
* 且 (a) 类根本不在它的 Placeholder 章节里(它来自真实 drawable 的转换失败)。
|
|
780
|
+
*/
|
|
781
|
+
function checkMediaContentUsable(scans, resDir) {
|
|
782
|
+
const mediaDir = path.join(resDir, 'base', 'media');
|
|
783
|
+
if (!fs.existsSync(mediaDir)) { return; }
|
|
784
|
+
|
|
785
|
+
// 同一资源在整个工程里只报一次,附带首个引用位置
|
|
786
|
+
const reported = new Set();
|
|
787
|
+
|
|
788
|
+
for (const s of scans) {
|
|
789
|
+
for (const t of s.mediaRefs) {
|
|
790
|
+
if (reported.has(t.name)) { continue; }
|
|
791
|
+
|
|
792
|
+
const svg = path.join(mediaDir, `${t.name}.svg`);
|
|
793
|
+
if (!fs.existsSync(svg)) { continue; }
|
|
794
|
+
|
|
795
|
+
let src;
|
|
796
|
+
try { src = fs.readFileSync(svg, 'utf8'); } catch { continue; }
|
|
797
|
+
|
|
798
|
+
// (0) 同一元素上出现重复属性 —— XML 规范下这是 **fatal error**,
|
|
799
|
+
// 整份文档会被解析器拒绝,于是**整个图标一个像素都画不出来**(表现为
|
|
800
|
+
// 空白/看不见,而不是画错)。
|
|
801
|
+
//
|
|
802
|
+
// 为什么必须单列一条:其余所有检查(包括本函数的 a/b 两项、
|
|
803
|
+
// fillcolor-on-* 两项、以及资源侧的 svg_fidelity_check)都是**先当作
|
|
804
|
+
// 有效 SVG 再检查语义**,没有任何一条验证过"这份文件解析得开"。
|
|
805
|
+
// 引用名对、文件存在、`ls media/` 通过、语义层也挑不出错 —— 只是文件
|
|
806
|
+
// 根本不合法。实测来源:转换器给每个 path 先写死 fill-opacity="1",
|
|
807
|
+
// 再把 Android 的 fillAlpha 作为**第二个** fill-opacity 追加,命中率
|
|
808
|
+
// 11/93。修法是保留后者(即 Android 源里的真实 alpha)、删掉前者。
|
|
809
|
+
const dupHit = (() => {
|
|
810
|
+
for (const el of src.matchAll(/<([a-zA-Z][\w:-]*)\b([^>]*?)\/?>/g)) {
|
|
811
|
+
const seen = new Set();
|
|
812
|
+
for (const a of el[2].matchAll(/([a-zA-Z_][\w:.-]*)\s*=\s*"/g)) {
|
|
813
|
+
if (seen.has(a[1])) { return `<${el[1]}> 上的 ${a[1]}`; }
|
|
814
|
+
seen.add(a[1]);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return null;
|
|
818
|
+
})();
|
|
819
|
+
if (dupHit !== null) {
|
|
820
|
+
reported.add(t.name);
|
|
821
|
+
report('GATE', 'media-duplicate-attribute', s.rel, t.line,
|
|
822
|
+
`app.media.${t.name} 的 SVG 不是合法 XML:${dupHit} 出现了两次。` +
|
|
823
|
+
'重复属性在 XML 规范下是致命错误,解析器会拒绝整份文档 —— ' +
|
|
824
|
+
'**该图标会完全不显示**(空白),而不是显示得不对。' +
|
|
825
|
+
'注意「文件存在 + 引用名正确 + 语义检查通过」全都拦不住这一条。' +
|
|
826
|
+
'修法:回到 Android <vector> 源核对该属性(如 fillAlpha/strokeAlpha),' +
|
|
827
|
+
'只保留一个正确取值;转换器常见 bug 是先写默认 "1" 再追加真实值,此时应保留后者');
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// (a) 未解析的 Android 主题属性残留在颜色通道里
|
|
832
|
+
const attrHit =
|
|
833
|
+
src.match(/(?:fill|stroke)\s*=\s*"(\?[A-Za-z0-9_./:]+)"/);
|
|
834
|
+
if (attrHit !== null) {
|
|
835
|
+
reported.add(t.name);
|
|
836
|
+
report('GATE', 'media-unresolved-theme-attr', s.rel, t.line,
|
|
837
|
+
`app.media.${t.name} 的 SVG 里 fill/stroke 仍是未解析的 Android 主题属性 ` +
|
|
838
|
+
`"${attrHit[1]}" —— SVG 不认识该值,这部分几何不会着色(常表现为白框/透明块)。` +
|
|
839
|
+
'需回到 Android <vector> 源,把 ?attr/ 主题属性解析成 theme 里对应的具体颜色后重新转换');
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// (b) 占位 SVG:灰底矩形 + 资源名文本(见 a2h-resource-convert 的 placeholder 模板)
|
|
844
|
+
const isPlaceholder =
|
|
845
|
+
/<rect\b[^>]*fill\s*=\s*"#CCCCCC"/i.test(src) && /<text\b/i.test(src);
|
|
846
|
+
if (isPlaceholder) {
|
|
847
|
+
reported.add(t.name);
|
|
848
|
+
report('GATE', 'media-placeholder-in-use', s.rel, t.line,
|
|
849
|
+
`app.media.${t.name} 是资源转换阶段生成的**占位 SVG**(灰底矩形 + 资源名文本),` +
|
|
850
|
+
'不是真实图标 —— 界面上会渲染成一个灰框。' +
|
|
851
|
+
'需补齐真实素材(从 Android res/drawable 转换,或按 Phase 4.3 的图标来源分级处理),' +
|
|
852
|
+
'拿不到时必须在报告中登记为「已降级」,不得直接消费占位图');
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* GATE R8/R9: SVG 内容自动验证 + 图标语义形状比对。
|
|
860
|
+
*
|
|
861
|
+
* R8 检查 SVG 文件内容是否可用(空文件、重复属性、主题属性残留、占位 SVG)。
|
|
862
|
+
* R9 比对图标的**几何元素计数**这一粗粒度拓扑特征:当 measure_pack.json 里记录了
|
|
863
|
+
* 该图标的参考签名时,数当前 SVG 的 <path>/<circle>/<rect>/<ellipse> 个数并与之比对。
|
|
864
|
+
*
|
|
865
|
+
* **能力边界(勿按"形状比对"理解)**:本规则不栅格化 SVG、不比对轮廓,只数元素个数。
|
|
866
|
+
* 它能抓到「三点 overflow 译成单个圆」这类元素数量级差异,抓不到元素数相同而轮廓不同
|
|
867
|
+
* (下载箭头 vs 上传箭头、圆角矩形 vs 圆)的情形。`measure_pack.js --probe` 只接受
|
|
868
|
+
* PNG,无法对 SVG 取 runs —— 轮廓一致性仍须人工打开 SVG 与参考截图比对。
|
|
869
|
+
*
|
|
870
|
+
* **前提**:--resources 必须提供,且页面目录下 measure_pack.json 的 regions
|
|
871
|
+
* 里有 shape_sig 字段(`{ resource_name, runs, coverage }`,其中 runs 按元素计数口径填写)。
|
|
872
|
+
* 该字段目前**没有任何随包脚本产出**,须人工写入;无 --resources 或无 shape_sig 时本规则整条跳过。
|
|
873
|
+
*/
|
|
874
|
+
function checkSvgSemantics(scans, resDir, uiInfoDir) {
|
|
875
|
+
// 1. 收集所有被引用的 SVG(从代码中的 $r('app.media.xxx') 提取)
|
|
876
|
+
const mediaRefs = new Set();
|
|
877
|
+
for (const s of scans) {
|
|
878
|
+
for (const t of s.mediaRefs) {
|
|
879
|
+
if (t.line > 0) {
|
|
880
|
+
mediaRefs.add(t.name);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (mediaRefs.size === 0) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const mediaDir = path.join(resDir, 'base', 'media');
|
|
890
|
+
if (!fs.existsSync(mediaDir)) {
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// 2. 读取 ui_info 下所有 measure_pack.json,提取图标形状签名
|
|
895
|
+
// interface IconShapeSig {
|
|
896
|
+
// name: string; // 资源名(不含扩展名)
|
|
897
|
+
// runs: number; // 连续段数
|
|
898
|
+
// coverage: number; // 覆盖率
|
|
899
|
+
// pageName: string; // 来源页面(用于报错时定位)
|
|
900
|
+
// }
|
|
901
|
+
|
|
902
|
+
const refShapes = new Map();
|
|
903
|
+
let pagedirs = [];
|
|
904
|
+
try {
|
|
905
|
+
const entries = fs.readdirSync(uiInfoDir, { withFileTypes: true });
|
|
906
|
+
pagedirs = entries
|
|
907
|
+
.filter((e) => e.isDirectory() && /^(page|manual)_\d{4}_/.test(e.name))
|
|
908
|
+
.map((e) => path.join(uiInfoDir, e.name));
|
|
909
|
+
} catch {
|
|
910
|
+
// ui_info 不存在时整条跳过
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
for (const pageDir of pagedirs) {
|
|
915
|
+
const measurePackPath = path.join(pageDir, 'measure_pack.json');
|
|
916
|
+
if (!fs.existsSync(measurePackPath)) {
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
let measureData;
|
|
921
|
+
try {
|
|
922
|
+
measureData = JSON.parse(fs.readFileSync(measurePackPath, 'utf8'));
|
|
923
|
+
} catch {
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const pageName = path.basename(pageDir);
|
|
928
|
+
if (Array.isArray(measureData.regions)) {
|
|
929
|
+
for (const r of measureData.regions) {
|
|
930
|
+
// 只处理标记为图标的节点(role=ImageView/Image 且尺寸 < 96dp)
|
|
931
|
+
if (!r.shape_sig || typeof r.shape_sig !== 'object') {
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
// shape_sig: { resource_name, runs, coverage }
|
|
935
|
+
const resName = r.shape_sig.resource_name;
|
|
936
|
+
if (typeof resName === 'string' && resName.length > 0) {
|
|
937
|
+
refShapes.set(resName, {
|
|
938
|
+
name: resName,
|
|
939
|
+
runs: r.shape_sig.runs || 0,
|
|
940
|
+
coverage: r.shape_sig.coverage || 0,
|
|
941
|
+
pageName
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// 3. 逐个检查被引用的 SVG
|
|
949
|
+
for (const mediaName of mediaRefs) {
|
|
950
|
+
const svgPath = path.join(mediaDir, mediaName + '.svg');
|
|
951
|
+
if (!fs.existsSync(svgPath)) {
|
|
952
|
+
continue; // 可能是其他格式(png/jpg),跳过
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
let svgContent;
|
|
956
|
+
try {
|
|
957
|
+
svgContent = fs.readFileSync(svgPath, 'utf8');
|
|
958
|
+
} catch {
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// 找到第一个引用该资源的文件(用于报错定位)
|
|
963
|
+
let refFile = '';
|
|
964
|
+
let refLine = 1;
|
|
965
|
+
for (const s of scans) {
|
|
966
|
+
const hit = s.mediaRefs.find((t) => t.name === mediaName);
|
|
967
|
+
if (hit && hit.line > 0) {
|
|
968
|
+
refFile = s.rel;
|
|
969
|
+
refLine = hit.line;
|
|
970
|
+
break;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
if (refFile === '') {
|
|
974
|
+
continue; // 理论上不会到这里
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// R8a: 空 SVG 文件
|
|
978
|
+
if (svgContent.trim().length === 0 || /<svg[^>]*>\s*<\/svg>/.test(svgContent)) {
|
|
979
|
+
report('GATE', 'media-empty-svg', refFile, refLine,
|
|
980
|
+
`app.media.${mediaName} 是空 SVG 文件(无任何几何内容),界面上该位置为空白。` +
|
|
981
|
+
'需补齐真实图标素材或按 L3 规则用等尺寸空 Row/Column 占位 + 降级登记');
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// R8b: 重复属性(已在 checkMediaContentUsable 的 media-duplicate-attribute 里检查,此处不重复)
|
|
986
|
+
// R8c: 主题属性残留(已在 checkMediaContentUsable 的 media-unresolved-theme-attr 里检查)
|
|
987
|
+
// R8d: 占位 SVG(已在 checkMediaContentUsable 的 media-placeholder-in-use 里检查)
|
|
988
|
+
|
|
989
|
+
// R9: 形状语义比对(当 refShapes 里有该资源的参考签名时)
|
|
990
|
+
const refSig = refShapes.get(mediaName);
|
|
991
|
+
if (!refSig) {
|
|
992
|
+
continue; // 无参考签名,跳过形状比对
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// 几何元素计数作为粗粒度拓扑特征。
|
|
996
|
+
// 不用 measure_pack.js --probe:它只解码 PNG,对 SVG 取不到 runs。
|
|
997
|
+
// 因此本规则的判据是「元素个数」而非「轮廓」,见函数头的能力边界说明。
|
|
998
|
+
const actualElems = (() => {
|
|
999
|
+
const pathCount = (svgContent.match(/<path\b/g) || []).length;
|
|
1000
|
+
const circleCount = (svgContent.match(/<circle\b/g) || []).length;
|
|
1001
|
+
const rectCount = (svgContent.match(/<rect\b/g) || []).length;
|
|
1002
|
+
const ellipseCount = (svgContent.match(/<ellipse\b/g) || []).length;
|
|
1003
|
+
return pathCount + circleCount + rectCount + ellipseCount;
|
|
1004
|
+
})();
|
|
1005
|
+
|
|
1006
|
+
// 判据:几何元素数量差异 > 50% 且绝对差 >= 2 即判定拓扑不符
|
|
1007
|
+
const diff = Math.abs(actualElems - refSig.runs);
|
|
1008
|
+
const ratio = refSig.runs > 0 ? diff / refSig.runs : (actualElems > 0 ? 1 : 0);
|
|
1009
|
+
|
|
1010
|
+
if (ratio > 0.5 && diff >= 2) {
|
|
1011
|
+
report('GATE', 'media-element-count-mismatch', refFile, refLine,
|
|
1012
|
+
`app.media.${mediaName} 的几何元素个数与参考签名不符:` +
|
|
1013
|
+
`参考签名记录 ${refSig.runs} 个(来自 ${refSig.pageName}),` +
|
|
1014
|
+
`当前 SVG 有 ${actualElems} 个(偏差 ${Math.round(ratio * 100)}%)。` +
|
|
1015
|
+
'文件名选对但像素内容错 —— 需回溯资源转换阶段修正,或换用 L3 等尺寸空 Row 占位 + 降级登记。' +
|
|
1016
|
+
'禁止只抽查部分文件后对未检查文件下全称断言');
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* CAND R10:Android 侧有 edge-to-edge 调用,但鸿蒙工程里两层都没声明。
|
|
1023
|
+
*
|
|
1024
|
+
* 为什么必须查源码而不能看截图:edge-to-edge 的页面会自行对 insets 做 padding,
|
|
1025
|
+
* 其截图形态与「避让系统栏」**完全同形** —— 状态栏可见、内容起点在其下方。
|
|
1026
|
+
* 于是「依据截图判断是否沉浸式」在沉浸式场景下必然误判成非沉浸式,
|
|
1027
|
+
* 再叠加「与基线保持一致」(基线全是非沉浸式)就形成一条两步都"有据"、
|
|
1028
|
+
* 结论却错的通过路径。实测:10 个安卓仓全部调用了 edge-to-edge,
|
|
1029
|
+
* 而 15 个转换页面的 expandSafeArea / setWindowLayoutFullScreen 出现次数全为 0。
|
|
1030
|
+
*
|
|
1031
|
+
* 为什么是 CAND 而不是 GATE:命中的调用点可能属于别的 Activity
|
|
1032
|
+
* (如 CrashActivity / ReaderActivity —— Mihon 实测 3 处命中),
|
|
1033
|
+
* 而本检查器不知道 page → Activity 的映射,做成 GATE 会误报。
|
|
1034
|
+
* 作为 CAND 已经足够:它把①的证据从「可选」变成「必须逐条回应的清单」。
|
|
1035
|
+
*/
|
|
1036
|
+
const E2E_KEYS = [
|
|
1037
|
+
'enableEdgeToEdge',
|
|
1038
|
+
'EdgeToEdge.enable',
|
|
1039
|
+
'setDecorFitsSystemWindows',
|
|
1040
|
+
'statusBarColor',
|
|
1041
|
+
'colorPrimaryDark',
|
|
1042
|
+
'setStatusBarColor'
|
|
1043
|
+
];
|
|
1044
|
+
|
|
1045
|
+
function grepAndroidEdgeToEdge(dir) {
|
|
1046
|
+
const hits = [];
|
|
1047
|
+
const SKIP = new Set([
|
|
1048
|
+
'build', '.git', '.gradle', 'node_modules', 'oh_modules', '.idea',
|
|
1049
|
+
'test', 'androidTest', 'debug', 'benchmark', 'fastlane', 'docs', 'metadata'
|
|
1050
|
+
]);
|
|
1051
|
+
const MAX_HITS = 12;
|
|
1052
|
+
const MAX_FILES = 6000;
|
|
1053
|
+
let scanned = 0;
|
|
1054
|
+
|
|
1055
|
+
const walk = (d, depth) => {
|
|
1056
|
+
if (depth > 10 || hits.length >= MAX_HITS || scanned >= MAX_FILES) { return; }
|
|
1057
|
+
let entries;
|
|
1058
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
1059
|
+
for (const e of entries) {
|
|
1060
|
+
if (hits.length >= MAX_HITS || scanned >= MAX_FILES) { return; }
|
|
1061
|
+
const p = path.join(d, e.name);
|
|
1062
|
+
if (e.isDirectory()) {
|
|
1063
|
+
if (SKIP.has(e.name)) { continue; }
|
|
1064
|
+
walk(p, depth + 1);
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
// 只看 Kotlin/Java 源:XML 主题里的 windowTranslucentStatus 常来自
|
|
1068
|
+
// 三方库或 dialog 主题,单独出现时判别力太低,交给 Phase 5 人工核对
|
|
1069
|
+
if (!/\.(kt|java)$/.test(e.name)) { continue; }
|
|
1070
|
+
scanned++;
|
|
1071
|
+
let src;
|
|
1072
|
+
try { src = fs.readFileSync(p, 'utf8'); } catch { continue; }
|
|
1073
|
+
// 先整体判一次,绝大多数文件在这里就被排除,避免逐行扫
|
|
1074
|
+
let touched = false;
|
|
1075
|
+
for (const k of E2E_KEYS) {
|
|
1076
|
+
if (src.includes(k)) { touched = true; break; }
|
|
1077
|
+
}
|
|
1078
|
+
if (!touched) { continue; }
|
|
1079
|
+
|
|
1080
|
+
const lines = src.split('\n');
|
|
1081
|
+
for (let i = 0; i < lines.length && hits.length < MAX_HITS; i++) {
|
|
1082
|
+
if (/^\s*import\b/.test(lines[i])) { continue; }
|
|
1083
|
+
for (const k of E2E_KEYS) {
|
|
1084
|
+
if (lines[i].includes(k)) {
|
|
1085
|
+
hits.push(`${path.relative(dir, p).replace(/\\/g, '/')}:${i + 1} (${k})`);
|
|
1086
|
+
break;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
walk(dir, 0);
|
|
1093
|
+
return hits;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function checkAndroidImmersive(scans, androidDir) {
|
|
1097
|
+
const pages = scans.filter((s) => s.isPage);
|
|
1098
|
+
if (pages.length === 0) { return; }
|
|
1099
|
+
const anyComponentLayer = pages.some((s) => s.hasExpandSafeArea);
|
|
1100
|
+
const anyWindowLayer = scans.some((s) => s.hasWindowFullScreen);
|
|
1101
|
+
if (anyComponentLayer && anyWindowLayer) { return; }
|
|
1102
|
+
|
|
1103
|
+
const hits = grepAndroidEdgeToEdge(androidDir);
|
|
1104
|
+
if (hits.length === 0) { return; }
|
|
1105
|
+
|
|
1106
|
+
const shown = hits.slice(0, 6).join('; ');
|
|
1107
|
+
const more = hits.length > 6 ? ` 等 ${hits.length} 处` : '';
|
|
1108
|
+
const missing = [];
|
|
1109
|
+
if (!anyWindowLayer) { missing.push('窗口层 setWindowLayoutFullScreen'); }
|
|
1110
|
+
if (!anyComponentLayer) { missing.push('组件层 expandSafeArea'); }
|
|
1111
|
+
|
|
1112
|
+
report('CAND', 'android-immersive-not-mirrored', pages[0].rel, 1,
|
|
1113
|
+
`Android 侧存在 edge-to-edge 调用(${shown}${more}),但鸿蒙工程缺少:${missing.join(' + ')}。` +
|
|
1114
|
+
'请按 Phase 5「窗口模式证据」①逐项核对:命中的调用点是否属于本页宿主 Activity ' +
|
|
1115
|
+
'(属于 CrashActivity/ReaderActivity 等其他 Activity 的不算本页证据)。' +
|
|
1116
|
+
'**注意不要用截图判断** —— edge-to-edge 页面自行对 insets 做 padding,' +
|
|
1117
|
+
'截图形态与避让系统栏完全同形;「与基线一致」也不构成依据,基线只说明现状。' +
|
|
1118
|
+
'若确认本页宿主沉浸,需补齐窗口层 + 组件层(含改 EntryAbility)');
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function checkWindowMode(scans) {
|
|
1122
|
+
const pages = scans.filter((s) => s.isPage);
|
|
1123
|
+
const withExpand = pages.filter((s) => s.hasExpandSafeArea);
|
|
1124
|
+
const anyWindowLayer = scans.some((s) => s.hasWindowFullScreen);
|
|
1125
|
+
|
|
1126
|
+
// GATE R6:声明了组件层却完全没有窗口层 —— 沉浸式必须两层配合。
|
|
1127
|
+
// 只设组件层时全批页面会「一致地错」,跨页一致性检查照样满分通过。
|
|
1128
|
+
if (withExpand.length > 0 && !anyWindowLayer) {
|
|
1129
|
+
report('GATE', 'window-layer-missing', withExpand[0].rel, 1,
|
|
1130
|
+
`${withExpand.length} 个页面声明了 expandSafeArea,但工程内无 setWindowLayoutFullScreen —— ` +
|
|
1131
|
+
'系统仍为状态栏保留空间,页面顶部出现空白带');
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// GATE R7:全屏模式下缺内容层 inset 避让 —— 两层开关已设,但内容从 y=0 起绘与状态栏重合
|
|
1135
|
+
if (anyWindowLayer && withExpand.length > 0) {
|
|
1136
|
+
const noAvoid = pages.filter((s) =>
|
|
1137
|
+
s.hasExpandSafeArea && !s.text.includes('getWindowAvoidArea') &&
|
|
1138
|
+
!s.text.includes('avoidAreaChange') && !s.text.includes('statusBarHeight')
|
|
1139
|
+
);
|
|
1140
|
+
if (noAvoid.length > 0) {
|
|
1141
|
+
report('CAND', 'immersive-missing-inset-padding', noAvoid[0].rel, 1,
|
|
1142
|
+
`${noAvoid.length} 个页面已设全屏模式(setWindowLayoutFullScreen) + expandSafeArea,` +
|
|
1143
|
+
'但页面内未见 getWindowAvoidArea/statusBarHeight 等内容避让实现 —— ' +
|
|
1144
|
+
'若内容顶部未自加状态栏高度 padding,会与系统状态栏文字/图标重合。' +
|
|
1145
|
+
'仅背景层 expandSafeArea 而内容层不延伸的设计(Stack 分层)无需此项。' +
|
|
1146
|
+
'请按 Phase 5 窗口模式证据⑤核对内容避让实现 file:line');
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// CAND:页间策略不一致(可能有据可依,例如该页本身不沉浸)
|
|
1151
|
+
if (withExpand.length > 0 && withExpand.length < pages.length) {
|
|
1152
|
+
const missing = pages.filter((s) => !s.hasExpandSafeArea).map((s) => s.rel);
|
|
1153
|
+
report('CAND', 'window-mode-inconsistent', missing[0], 1,
|
|
1154
|
+
`expandSafeArea 在页面间不一致,未声明的页面:${missing.join(', ')} —— ` +
|
|
1155
|
+
'需在报告中给出理由,或补齐');
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** 字符串资源残留 Android 转义引号:"…" 是转义语法,不是文本内容。 */
|
|
1160
|
+
function checkStringResources(resourcesDir) {
|
|
1161
|
+
const files = [];
|
|
1162
|
+
const walkRes = (dir) => {
|
|
1163
|
+
let entries;
|
|
1164
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
1165
|
+
for (const e of entries) {
|
|
1166
|
+
const p = path.join(dir, e.name);
|
|
1167
|
+
if (e.isDirectory()) { walkRes(p); } else if (e.name === 'string.json') { files.push(p); }
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
walkRes(resourcesDir);
|
|
1171
|
+
|
|
1172
|
+
for (const f of files) {
|
|
1173
|
+
let parsed;
|
|
1174
|
+
try {
|
|
1175
|
+
parsed = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
1176
|
+
} catch {
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
const arr = parsed['string'];
|
|
1180
|
+
if (!Array.isArray(arr)) { continue; }
|
|
1181
|
+
for (const item of arr) {
|
|
1182
|
+
const v = item['value'];
|
|
1183
|
+
if (typeof v === 'string' && v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
|
|
1184
|
+
report('GATE', 'string-quote-leak', path.basename(f), 1,
|
|
1185
|
+
`资源 ${item['name']} 的值首尾残留字面引号,UI 上会显示出引号`);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// ---------------------------------------------------------------- 主流程
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* GATE R11:转换后的页面从未成为启动路由。
|
|
1195
|
+
*
|
|
1196
|
+
* 「注册过」不等于「会被加载」:main_pages.json 里有 pages/X,只说明它可被路由到;
|
|
1197
|
+
* 真正决定启动画面的是 UIAbility 里的 loadContent('pages/Y')。二者不一致时:
|
|
1198
|
+
* - 编译永远通过(两个文件都合法、资源都存在);
|
|
1199
|
+
* - 静态引用检查全过;
|
|
1200
|
+
* - 唯一症状是启动后看到脚手架的 Hello World,而转换成果一次也没被显示。
|
|
1201
|
+
* 该缺陷只有真机启动能发现,但它的判据完全是词法的 —— 所以定为 GATE。
|
|
1202
|
+
*
|
|
1203
|
+
* 判据(三支,命中任一即报):
|
|
1204
|
+
* (1) loadContent 的目标 .ets 不存在;
|
|
1205
|
+
* (2) 目标是 DevEco 脚手架残留页(@Entry + 'Hello World' 字面量 + 不引用任何 app 资源);
|
|
1206
|
+
* (3) 工程内有多个 @Entry 页,但没有任何一个非脚手架页被任何 loadContent 加载。
|
|
1207
|
+
* 判据 (2) 判的是脚手架模板的特征,对所有 DevEco 新工程一致,不含任何 app 专属知识。
|
|
1208
|
+
*/
|
|
1209
|
+
function checkEntryRoute(scans, etsDir) {
|
|
1210
|
+
const abilityFiles = scans.filter((s) => /ability/i.test(s.rel));
|
|
1211
|
+
if (abilityFiles.length === 0) {
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// FileScan 只带相对路径(无 abs 字段),故由 etsDir + rel 复原绝对路径,
|
|
1216
|
+
// 不改动共享的 FileScan 接口。
|
|
1217
|
+
const root = etsDir.replace(/[\\/]+$/, '');
|
|
1218
|
+
const absOfRel = (rel) => root + '/' + rel;
|
|
1219
|
+
|
|
1220
|
+
// 收集全部 loadContent 目标
|
|
1221
|
+
const targets = [];
|
|
1222
|
+
for (const a of abilityFiles) {
|
|
1223
|
+
const lines = fs.readFileSync(absOfRel(a.rel), 'utf8').split('\n');
|
|
1224
|
+
lines.forEach((l, i) => {
|
|
1225
|
+
const m = /loadContent\(\s*['"]([^'"]+)['"]/.exec(l);
|
|
1226
|
+
if (m !== null) {
|
|
1227
|
+
targets.push({ rel: a.rel, line: i + 1, target: m[1] });
|
|
1228
|
+
}
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
if (targets.length === 0) {
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* 是否为 DevEco 脚手架残留页 —— **按特征打分,不按「缺少某物」判定**。
|
|
1237
|
+
*
|
|
1238
|
+
* 曾经的写法要求「零 app 资源引用」,结果漏报:真实脚手架页**确实**引用了
|
|
1239
|
+
* 一个 app 资源(`$r('app.float.page_text_font_size')`),于是它被当成已转换页,
|
|
1240
|
+
* 规则在真工程上静默失效 —— 而简化过的测试夹具里没有那句引用,测试照样全绿。
|
|
1241
|
+
* 教训:判据一旦依赖「不存在某物」,就会被夹具与真实产物的细微差异掩盖。
|
|
1242
|
+
*
|
|
1243
|
+
* 改为对 DevEco 默认模板的固有特征打分,命中 >= 3 项即认定为脚手架。
|
|
1244
|
+
* 这些特征是模板自带的,与被转换的 app 无关;逐项都可能随 DevEco 版本微调,
|
|
1245
|
+
* 但不会同时消失 3 项以上。
|
|
1246
|
+
*/
|
|
1247
|
+
const isScaffold = (abs) => {
|
|
1248
|
+
if (!fs.existsSync(abs)) {
|
|
1249
|
+
return false;
|
|
1250
|
+
}
|
|
1251
|
+
const t = fs.readFileSync(abs, 'utf8');
|
|
1252
|
+
if (!/@Entry/.test(t)) {
|
|
1253
|
+
return false;
|
|
1254
|
+
}
|
|
1255
|
+
let score = 0;
|
|
1256
|
+
if (/Hello\s+World/.test(t)) { score++; }
|
|
1257
|
+
if (/@State\s+message\s*:\s*string/.test(t)) { score++; }
|
|
1258
|
+
if (/\.id\(\s*['"]HelloWorld['"]\s*\)/.test(t)) { score++; }
|
|
1259
|
+
if (/struct\s+Index\b/.test(t)) { score++; }
|
|
1260
|
+
if (/RelativeContainer\s*\(/.test(t) && /__container__/.test(t)) { score++; }
|
|
1261
|
+
if (/['"]Welcome['"]/.test(t)) { score++; }
|
|
1262
|
+
return score >= 3;
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
const absOf = (target) => root + '/' + target.replace(/^\/+/, '') + '.ets';
|
|
1266
|
+
|
|
1267
|
+
// **前置条件:工程内必须至少存在一个已转换页面。**
|
|
1268
|
+
// 尚未转换任何页面的空工程里,loadContent 指向脚手架页是**正确**状态 ——
|
|
1269
|
+
// 此时无「转换成果」可要求,报错纯属噪声,而 GATE 的噪声会训练使用者忽略它。
|
|
1270
|
+
// 因此三支判据全部以「已有转换产出」为前提。
|
|
1271
|
+
const convertedPages = scans.filter((s) => s.isPage && !isScaffold(absOfRel(s.rel)));
|
|
1272
|
+
if (convertedPages.length === 0) {
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
let loadsAConvertedPage = false;
|
|
1277
|
+
|
|
1278
|
+
for (const t of targets) {
|
|
1279
|
+
const abs = absOf(t.target);
|
|
1280
|
+
if (!fs.existsSync(abs)) {
|
|
1281
|
+
report('GATE', 'entry-route-not-converted', t.rel, t.line,
|
|
1282
|
+
`loadContent 的目标页不存在:'${t.target}' —— 期望文件 ${abs.replace(/\\/g, '/')}`);
|
|
1283
|
+
continue;
|
|
1284
|
+
}
|
|
1285
|
+
if (isScaffold(abs)) {
|
|
1286
|
+
report('GATE', 'entry-route-not-converted', t.rel, t.line,
|
|
1287
|
+
`loadContent 加载的是 DevEco 脚手架残留页 '${t.target}'(命中模板特征 >= 3 项),` +
|
|
1288
|
+
`而工程内已有 ${convertedPages.length} 个转换产出页(${convertedPages.map((s) => s.rel).join(', ')})` +
|
|
1289
|
+
' —— 转换后的页面从未成为启动路由。改为本批产出的入口页' +
|
|
1290
|
+
'(通常对应 Android 侧持 MAIN/LAUNCHER 的那个 Activity)');
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
loadsAConvertedPage = true;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// 判据 (3):有已转换页,但没有任何一个被加载(目标既非脚手架、也不在转换产出里,
|
|
1297
|
+
// 例如指向了一个中间页或占位页)
|
|
1298
|
+
if (!loadsAConvertedPage) {
|
|
1299
|
+
report('GATE', 'entry-route-not-converted', targets[0].rel, targets[0].line,
|
|
1300
|
+
`工程内有 ${convertedPages.length} 个已转换页面(${convertedPages.map((s) => s.rel).join(', ')}),` +
|
|
1301
|
+
'但没有任何一个被 loadContent 加载 —— 至少一个非脚手架页必须成为启动路由');
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* GATE R12: 代码字面尺寸与 measure_pack.json 实测值偏差超阈值。
|
|
1307
|
+
*
|
|
1308
|
+
* 针对「测归测、写归写」的失效模式:agent 跑了 measure_pack 并在轨迹里写出正确换算
|
|
1309
|
+
* (如「84px = 32dp」),但代码里写的是另一套数字(84vp / Material 记忆值 24vp)。
|
|
1310
|
+
*
|
|
1311
|
+
* 判据:
|
|
1312
|
+
* 1. 页面目录下存在 measure_pack.json(有实测值)
|
|
1313
|
+
* 2. 代码中出现字面数字尺寸(.width(N) / .height(N) / .size({width:N,height:N}))
|
|
1314
|
+
* 3. 该数字与 measure_pack 中任一关键节点的实测尺寸(vp 换算后)偏差 > 30%
|
|
1315
|
+
*
|
|
1316
|
+
* 排除:
|
|
1317
|
+
* - 百分比字符串 width('100%')
|
|
1318
|
+
* - 资源引用 $r('app.float.xxx')
|
|
1319
|
+
* - layoutWeight(1) 等弹性布局
|
|
1320
|
+
* - 小于 8vp 的数字(padding/间距等,不属于"关键区域尺寸")
|
|
1321
|
+
*
|
|
1322
|
+
* **密度置信度决定本规则的档位**:measure_pack.json 的所有 vp 都由密度换算而来,
|
|
1323
|
+
* 密度错一次,全页 vp 整体差 2~3 倍,此时本规则会对几乎每个字面尺寸报错 —— 一批
|
|
1324
|
+
* 假阳性比没有门禁更糟(会把 agent 推去"修"正确的代码)。因此:
|
|
1325
|
+
* - `density.confident === true`(设备真值,或反解命中 >= 2 个 dp 档且优势明确)→ `[GATE]`
|
|
1326
|
+
* - `confident === false` 或字段缺失(旧版 measure_pack.json)→ 降级为 `[CANDIDATES]`
|
|
1327
|
+
* 降级不是放过:CANDIDATES 仍进 Phase 5 待核清单,只是不再当零歧义缺陷卡构建。
|
|
1328
|
+
* 重跑 measure_pack.js(最好是重采一次带 device 字段的 meta.json)即可恢复 GATE 档。
|
|
1329
|
+
*
|
|
1330
|
+
* 报告时附带最接近的实测节点信息,供快速定位。
|
|
1331
|
+
*/
|
|
1332
|
+
function checkSizeMismatch(scans, uiInfoDir) {
|
|
1333
|
+
// 遍历 ui_info/page_NNNN_ActivityName/ 目录
|
|
1334
|
+
let pagedirs = [];
|
|
1335
|
+
try {
|
|
1336
|
+
const entries = fs.readdirSync(uiInfoDir, { withFileTypes: true });
|
|
1337
|
+
pagedirs = entries
|
|
1338
|
+
.filter((e) => e.isDirectory() && /^(page|manual)_\d{4}_/.test(e.name))
|
|
1339
|
+
.map((e) => path.join(uiInfoDir, e.name));
|
|
1340
|
+
} catch {
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
// interface MeasuredNode {
|
|
1345
|
+
// id: string;
|
|
1346
|
+
// widthVp: number;
|
|
1347
|
+
// heightVp: number;
|
|
1348
|
+
// }
|
|
1349
|
+
|
|
1350
|
+
for (const pageDir of pagedirs) {
|
|
1351
|
+
const measurePackPath = path.join(pageDir, 'measure_pack.json');
|
|
1352
|
+
if (!fs.existsSync(measurePackPath)) {
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
let measureData;
|
|
1357
|
+
try {
|
|
1358
|
+
measureData = JSON.parse(fs.readFileSync(measurePackPath, 'utf8'));
|
|
1359
|
+
} catch {
|
|
1360
|
+
continue;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// measure_pack.js 的 schema:density 是对象 { value, source, evidence },
|
|
1364
|
+
// 节点在 `regions` 数组里且 wVp/hVp 已换算好(density 无法反解时为 null)。
|
|
1365
|
+
// 勿改回 measureData.density / measureData.nodes —— 那两个键不存在,
|
|
1366
|
+
// 读到 undefined 后本规则会静默跳过每一页,等于没有门禁。
|
|
1367
|
+
const density = measureData.density && typeof measureData.density.value === 'number'
|
|
1368
|
+
? measureData.density.value
|
|
1369
|
+
: 0;
|
|
1370
|
+
if (density <= 0) {
|
|
1371
|
+
continue; // density 反解失败时 wVp/hVp 全为 null,无实测值可比
|
|
1372
|
+
}
|
|
1373
|
+
// 密度置信度 → 本规则档位。字段缺失(旧版 measure_pack.json)按**不可信**处理:
|
|
1374
|
+
// 那些包正是用有偏的反解算法产出的,拿它们卡 GATE 会造成成批假阳性。
|
|
1375
|
+
const densityConfident = measureData.density.confident === true;
|
|
1376
|
+
const densitySource = typeof measureData.density.source === 'string'
|
|
1377
|
+
? measureData.density.source
|
|
1378
|
+
: 'unknown';
|
|
1379
|
+
const sev = densityConfident ? 'GATE' : 'CAND';
|
|
1380
|
+
const densityNote = densityConfident
|
|
1381
|
+
? ''
|
|
1382
|
+
: `【降级为 CANDIDATES:该页 density=${density} 来源 ${densitySource} 且未通过置信判据` +
|
|
1383
|
+
(measureData.density.confident === undefined ? '(旧版 measure_pack.json 无 confident 字段)' : '') +
|
|
1384
|
+
(measureData.density.rival
|
|
1385
|
+
? `,次优候选 ${measureData.density.rival.density}` : '') +
|
|
1386
|
+
';请先用 `adb shell wm density` 核实密度或重跑 measure_pack.js,再判断本条是否成立】'
|
|
1387
|
+
;
|
|
1388
|
+
|
|
1389
|
+
// 提取关键节点尺寸(wVp/hVp 已由 measure_pack 换算,>= 8vp 才算"关键区域")
|
|
1390
|
+
const nodes = [];
|
|
1391
|
+
if (Array.isArray(measureData.regions)) {
|
|
1392
|
+
for (const r of measureData.regions) {
|
|
1393
|
+
if (typeof r.wVp !== 'number' || typeof r.hVp !== 'number') {
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
if (r.wVp < 8 || r.hVp < 8) {
|
|
1397
|
+
continue; // 跳过过小节点
|
|
1398
|
+
}
|
|
1399
|
+
const label = r.text || r.desc || r.role || `node_${r.node}`;
|
|
1400
|
+
nodes.push({
|
|
1401
|
+
id: `${r.role || 'node'}#${r.node}${label !== r.role ? ` (${label})` : ''}`,
|
|
1402
|
+
widthVp: r.wVp,
|
|
1403
|
+
heightVp: r.hVp
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
if (nodes.length === 0) {
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// 推测对应的页面文件(page_NNNN_ActivityName → ActivityNamePage.ets 或 ActivityName.ets)
|
|
1413
|
+
const pageName = path.basename(pageDir).replace(/^(page|manual)_\d{4}_/, '');
|
|
1414
|
+
const candidates = [
|
|
1415
|
+
`${pageName}Page.ets`,
|
|
1416
|
+
`${pageName}.ets`,
|
|
1417
|
+
`${pageName.replace(/Activity$/, '')}Page.ets`
|
|
1418
|
+
];
|
|
1419
|
+
|
|
1420
|
+
let targetScan = null;
|
|
1421
|
+
for (const c of candidates) {
|
|
1422
|
+
const s = scans.find((x) => x.rel.endsWith('/' + c));
|
|
1423
|
+
if (s) {
|
|
1424
|
+
targetScan = s;
|
|
1425
|
+
break;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
if (!targetScan) {
|
|
1430
|
+
continue;
|
|
1431
|
+
}
|
|
1432
|
+
// 绑定为非空 const:下面的 checkDim 是闭包,捕获 targetScan 时会丢掉上面这次收窄。
|
|
1433
|
+
const scan = targetScan;
|
|
1434
|
+
|
|
1435
|
+
// 在代码中匹配 .width(N) / .height(N) / .size({width:N,height:N})
|
|
1436
|
+
const code = scan.text;
|
|
1437
|
+
const lines = code.split('\n');
|
|
1438
|
+
|
|
1439
|
+
// 正则:捕获数字字面量尺寸
|
|
1440
|
+
const reWidth = /\.width\((\d+(?:\.\d+)?)\)/g;
|
|
1441
|
+
const reHeight = /\.height\((\d+(?:\.\d+)?)\)/g;
|
|
1442
|
+
const reSize = /\.size\(\s*\{\s*width\s*:\s*(\d+(?:\.\d+)?)\s*,\s*height\s*:\s*(\d+(?:\.\d+)?)\s*\}\s*\)/g;
|
|
1443
|
+
|
|
1444
|
+
const checkDim = (val, lineIdx, axis) => {
|
|
1445
|
+
if (val < 8) {
|
|
1446
|
+
return; // 跳过小尺寸
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// 找最接近的实测节点
|
|
1450
|
+
let closest = null;
|
|
1451
|
+
let minDiff = Infinity;
|
|
1452
|
+
|
|
1453
|
+
for (const n of nodes) {
|
|
1454
|
+
const measured = axis === 'width' ? n.widthVp : n.heightVp;
|
|
1455
|
+
const diff = Math.abs(measured - val);
|
|
1456
|
+
if (diff < minDiff) {
|
|
1457
|
+
minDiff = diff;
|
|
1458
|
+
closest = n;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
if (!closest) {
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const measured = axis === 'width' ? closest.widthVp : closest.heightVp;
|
|
1467
|
+
const ratio = minDiff / measured;
|
|
1468
|
+
|
|
1469
|
+
// 阈值:偏差 > 30%
|
|
1470
|
+
if (ratio > 0.3 && minDiff > 8) {
|
|
1471
|
+
report(
|
|
1472
|
+
sev,
|
|
1473
|
+
'size-mismatch-vs-measure',
|
|
1474
|
+
scan.rel,
|
|
1475
|
+
lineIdx + 1,
|
|
1476
|
+
`代码写 .${axis}(${val}),但 measure_pack.json 中最接近节点 ${closest.id} 的实测值为 ${measured}vp ` +
|
|
1477
|
+
`(偏差 ${minDiff}vp,${Math.round(ratio * 100)}%)。` +
|
|
1478
|
+
`measure_pack 已产出实测值时必须采用,不得用 Material 规范记忆值或"与兄弟页保持一致"等惯例覆盖 ` +
|
|
1479
|
+
`—— 参见 conversion-procedure.md Phase 4.2 硬性要求(尺寸)` + densityNote
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
};
|
|
1483
|
+
|
|
1484
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1485
|
+
const line = lines[i];
|
|
1486
|
+
|
|
1487
|
+
let m;
|
|
1488
|
+
|
|
1489
|
+
reWidth.lastIndex = 0;
|
|
1490
|
+
while ((m = reWidth.exec(line)) !== null) {
|
|
1491
|
+
checkDim(parseFloat(m[1]), i, 'width');
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
reHeight.lastIndex = 0;
|
|
1495
|
+
while ((m = reHeight.exec(line)) !== null) {
|
|
1496
|
+
checkDim(parseFloat(m[1]), i, 'height');
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
reSize.lastIndex = 0;
|
|
1500
|
+
while ((m = reSize.exec(line)) !== null) {
|
|
1501
|
+
checkDim(parseFloat(m[1]), i, 'width');
|
|
1502
|
+
checkDim(parseFloat(m[2]), i, 'height');
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function walkEts(dir, acc) {
|
|
1509
|
+
let entries;
|
|
1510
|
+
try {
|
|
1511
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1512
|
+
} catch {
|
|
1513
|
+
return acc;
|
|
1514
|
+
}
|
|
1515
|
+
for (const e of entries) {
|
|
1516
|
+
const p = path.join(dir, e.name);
|
|
1517
|
+
if (e.isDirectory()) {
|
|
1518
|
+
if (e.name === 'build' || e.name === 'oh_modules' || e.name === 'node_modules') { continue; }
|
|
1519
|
+
walkEts(p, acc);
|
|
1520
|
+
} else if (e.name.endsWith('.ets')) {
|
|
1521
|
+
acc.push(p);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
return acc;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function argOf(flag) {
|
|
1528
|
+
const argv = process.argv;
|
|
1529
|
+
const i = argv.indexOf(flag);
|
|
1530
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : '';
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function main() {
|
|
1534
|
+
const etsDir = argOf('--ets');
|
|
1535
|
+
const resDir = argOf('--resources');
|
|
1536
|
+
const androidDir = argOf('--android');
|
|
1537
|
+
const uiInfoArg = argOf('--ui-info');
|
|
1538
|
+
|
|
1539
|
+
if (etsDir === '') {
|
|
1540
|
+
console.error('用法: node arkts_static_check.js --ets <.../<ui_module>/src/main/ets> [--resources <.../<ui_module>/src/main/resources>] [--android <android_project_dir>] [--ui-info <ui_info_root>]');
|
|
1541
|
+
process.exit(2);
|
|
1542
|
+
}
|
|
1543
|
+
if (!fs.existsSync(etsDir)) {
|
|
1544
|
+
console.error(`ets 目录不存在: ${etsDir}`);
|
|
1545
|
+
process.exit(2);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const files = walkEts(etsDir, []);
|
|
1549
|
+
const scans = [];
|
|
1550
|
+
for (const f of files) {
|
|
1551
|
+
scans.push(checkFile(f, path.relative(etsDir, f).replace(/\\/g, '/')));
|
|
1552
|
+
}
|
|
1553
|
+
checkWindowMode(scans);
|
|
1554
|
+
if (resDir !== '') {
|
|
1555
|
+
checkStringResources(resDir);
|
|
1556
|
+
checkSvgTintChannel(scans, resDir);
|
|
1557
|
+
checkFullViewportTint(scans, resDir);
|
|
1558
|
+
checkMediaContentUsable(scans, resDir);
|
|
1559
|
+
}
|
|
1560
|
+
if (androidDir !== '' && fs.existsSync(androidDir)) {
|
|
1561
|
+
checkAndroidImmersive(scans, androidDir);
|
|
1562
|
+
}
|
|
1563
|
+
checkEntryRoute(scans, etsDir);
|
|
1564
|
+
|
|
1565
|
+
// GATE R12: 代码字面尺寸与 measure_pack 实测偏差超阈值。
|
|
1566
|
+
// --ui-info 显式指定 ui_info_root;不给时按 SKILL.md 的默认位置
|
|
1567
|
+
// ${harmony_project_dir}/.hometrans/ui_info 推导。UI 模块名不一定是 entry
|
|
1568
|
+
// (分层工程如 products/phone,层数也不固定),所以不能剥固定前缀:先剥 src/main/ets
|
|
1569
|
+
// 得模块目录,再逐层向上找真实存在的 .hometrans/ui_info。都找不到就退回模块目录的
|
|
1570
|
+
// 上一层候选(单模块工程的原行为),由下方 existsSync 判 false。
|
|
1571
|
+
const defaultUiInfoDir = (dir) => {
|
|
1572
|
+
const moduleDir = dir.replace(/[\\/]+$/, '').replace(/[\\/]src[\\/]main[\\/]ets$/, '');
|
|
1573
|
+
let cur = moduleDir;
|
|
1574
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
1575
|
+
const cand = path.join(cur, '.hometrans', 'ui_info');
|
|
1576
|
+
if (fs.existsSync(cand)) return cand;
|
|
1577
|
+
const up = path.dirname(cur);
|
|
1578
|
+
if (up === cur) break;
|
|
1579
|
+
cur = up;
|
|
1580
|
+
}
|
|
1581
|
+
return path.join(path.dirname(moduleDir), '.hometrans', 'ui_info');
|
|
1582
|
+
};
|
|
1583
|
+
const uiInfoDir = uiInfoArg !== '' ? uiInfoArg : defaultUiInfoDir(etsDir);
|
|
1584
|
+
if (fs.existsSync(uiInfoDir)) {
|
|
1585
|
+
checkSizeMismatch(scans, uiInfoDir);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// GATE R8/R9: SVG 内容验证 + 形状语义比对(需要 --resources 和 ui_info)
|
|
1589
|
+
if (resDir !== '' && fs.existsSync(uiInfoDir)) {
|
|
1590
|
+
checkSvgSemantics(scans, resDir, uiInfoDir);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
const gate = findings.filter((f) => f.sev === 'GATE');
|
|
1594
|
+
const cand = findings.filter((f) => f.sev === 'CAND');
|
|
1595
|
+
|
|
1596
|
+
console.log(`arkts_static_check — 扫描 ${files.length} 个 .ets 文件\n`);
|
|
1597
|
+
|
|
1598
|
+
console.log(`[GATE] 零歧义缺陷,必须清零 —— ${gate.length} 条`);
|
|
1599
|
+
if (gate.length === 0) {
|
|
1600
|
+
console.log(' (无)');
|
|
1601
|
+
} else {
|
|
1602
|
+
for (const f of gate) {
|
|
1603
|
+
console.log(` ${f.rule.padEnd(26)} ${f.file}:${f.line}`);
|
|
1604
|
+
console.log(` ${' '.repeat(26)} ${f.msg}`);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
console.log(`\n[CANDIDATES] 仅供人工核对,不判定对错 —— ${cand.length} 条`);
|
|
1609
|
+
if (cand.length === 0) {
|
|
1610
|
+
console.log(' (无)');
|
|
1611
|
+
} else {
|
|
1612
|
+
for (const f of cand) {
|
|
1613
|
+
console.log(` ${f.rule.padEnd(26)} ${f.file}:${f.line}`);
|
|
1614
|
+
console.log(` ${' '.repeat(26)} ${f.msg}`);
|
|
1615
|
+
}
|
|
1616
|
+
console.log('\n ↑ 这些位置是否正确取决于 Android 源侧意图(锚定方向、层叠次序、是否沉浸),');
|
|
1617
|
+
console.log(' 词法分析无法判定。请在 Phase 5 的「位置锚点证据」「交互可达性证据」');
|
|
1618
|
+
console.log(' 「窗口模式证据」中逐条标注来源,不要因为本段有输出就直接改代码。');
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
process.exit(gate.length > 0 ? 1 : 0);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
main();
|