/** * arkts_static_check — 转换后 ArkTS 代码的静态缺陷检查器。 * * 定位:补上「编译期发现不了、但纯词法即可判定」的那一类缺陷。 * 直接用 Node ≥ 22.18 / 23.6 运行(原生剥离类型),无需构建、无依赖: * * node arkts_static_check.ts --ets /entry/src/main/ets \ * [--resources /entry/src/main/resources] * * 输出分两段,语义完全不同: * * [GATE] 零歧义缺陷。**必须清零**,否则 exit code 1。 * [CANDIDATES] 仅缩小人工核对范围,**不判定对错**、不影响 exit code。 * 这些位置合法与否取决于 Android 源侧的意图(锚定方向、层叠次序), * 词法分析无法判断 —— 由 Phase 5 对应检查项逐条标注来源。 * * 设计要点:先把注释替换成等长空白再匹配。否则「解释某个坑」的文档注释 * 本身就会被当成那个坑命中 —— 检查器被自己要防的说明骗了。 * * 注:R7/R8/R9 需要 --resources,因为它们要读 SVG 文件内容(而非只看代码); * 未传 --resources 时这些规则整条跳过,与 string.json 检查一致。 * * R7/R8/R9 共同针对同一个失效模式:**「资源存在」不等于「资源能渲染对」**。 * 页面转换时惯于用 `ls media/` 确认名字存在就通过,从不打开文件,于是 * 「引用名正确 + 文件存在 + 编译通过」三项全过,图标却是黑块/白框/灰框。 * * R11(entry-route-not-converted)针对另一个同源模式: * **「注册过」不等于「会被加载」**。main_pages.json 里有某页只说明它可被路由到, * 真正决定启动画面的是 UIAbility 的 loadContent()。二者不一致时编译与静态引用 * 检查全过,唯一症状是启动后停在脚手架的 Hello World,转换成果一次也没显示过 —— * 只有真机启动能看见,但判据完全是词法的,故定为 GATE。 * 它以「工程内已有转换产出页」为前提:空工程加载脚手架页是正确状态,不报。 */ import * as fs from 'fs'; import * as path from 'path'; const BACKTICK: string = String.fromCharCode(96); type Severity = 'GATE' | 'CAND'; interface Finding { sev: Severity; rule: string; file: string; line: number; msg: string; } const findings: Finding[] = []; function report(sev: Severity, rule: string, file: string, line: number, msg: string): void { findings.push({ sev, rule, file, line, msg }); } // ---------------------------------------------------------------- 预处理 /** * 把注释替换成等长空白,保持行号与列号不变。 * 字符串与模板字符串内的 // 和 /* 不算注释。 */ function stripComments(src: string): string { let out: string = ''; let i: number = 0; let state: number = 0; // 0 code, 1 line-comment, 2 block-comment, 3 string, 4 template let quote: string = ''; while (i < src.length) { const c: string = src[i]; const n: string = src[i + 1]; if (state === 0) { if (c === '/' && n === '/') { state = 1; i += 2; out += ' '; continue; } if (c === '/' && n === '*') { state = 2; i += 2; out += ' '; continue; } if (c === '"' || c === "'") { state = 3; quote = c; out += c; i++; continue; } if (c === BACKTICK) { state = 4; out += c; i++; continue; } out += c; i++; continue; } if (state === 1) { if (c === '\n') { state = 0; out += '\n'; } else { out += ' '; } i++; continue; } if (state === 2) { if (c === '*' && n === '/') { state = 0; i += 2; out += ' '; continue; } out += (c === '\n' ? '\n' : ' '); i++; continue; } if (state === 3) { out += c; if (c === '\\') { out += (src[i + 1] || ''); i += 2; continue; } if (c === quote) { state = 0; } i++; continue; } // state === 4 out += c; if (c === '\\') { out += (src[i + 1] || ''); i += 2; continue; } if (c === BACKTICK) { state = 0; } i++; } return out; } /** 标记哪些行处于 /* ... *\/ 块注释内部(用于「注释提前闭合」检查)。 */ function blockCommentLines(src: string): Set { const inside: Set = new Set(); let depth: number = 0; const lines: string[] = src.split('\n'); for (let idx = 0; idx < lines.length; idx++) { if (depth > 0) { inside.add(idx + 1); } const line: string = lines[idx]; let i: number = 0; while (i < line.length) { if (line[i] === '/' && line[i + 1] === '*') { depth++; inside.add(idx + 1); i += 2; continue; } if (line[i] === '*' && line[i + 1] === '/') { if (depth > 0) { depth--; } i += 2; continue; } if (line[i] === '/' && line[i + 1] === '/' && depth === 0) { break; } i++; } } return inside; } /** * 把代码切成「属性链块」:组件行 + 其后连续的 .xxx() 行。 * 同一元素的属性共现判定必须在链内进行,否则会跨元素误判。 */ interface Chain { start: number; // 1-based 行号 lines: string[]; } function attributeChains(codeLines: string[]): Chain[] { const chains: Chain[] = []; let cur: Chain | null = null; for (let i = 0; i < codeLines.length; i++) { const line: string = codeLines[i]; if (/^\s*\./.test(line)) { if (cur === null) { // 链首行取上一行(组件调用行) cur = { start: i, lines: [codeLines[i - 1] || ''] }; } cur.lines.push(line); } else if (cur !== null) { chains.push(cur); cur = null; } } if (cur !== null) { chains.push(cur); } return chains; } // ---------------------------------------------------------------- 逐文件规则 const RE_PCT_WIDTH = /\.width\((['"])100%\1\)/; const RE_PCT_HEIGHT = /\.height\((['"])100%\1\)/; const RE_H_MARGIN = /\.margin\(\s*\{[^}]*(left|right|start|end)\s*:/; interface GetterHit { name: string; line: number; } interface ObservedTrackClass { name: string; getters: GetterHit[]; } /** * 找出所有「带 @Observed 且类体内至少有一个 @Track」的类,并列出该类体内声明的 getter。 * * 为什么按类体而不是整文件:一个 .ets 里常常同时有 @Observed 的 VM/Model 和普通 * class / interface(如纯数据的 DTO、工具类),整文件判定会把后者的 getter 也算进来。 * 类体范围用花括号配对求得(注释已在 stripComments 阶段被替换成等长空白, * 因此不会有注释里的花括号干扰配对)。 */ function observedTrackClassBodies(code: string, codeLines: string[]): ObservedTrackClass[] { const out: ObservedTrackClass[] = []; // @Observed 之后允许有若干行(export / 注释留下的空行)再到 class 声明 const reClass = /@Observed[\s\S]{0,200}?\bclass\s+([A-Za-z_$][\w$]*)/g; let m: RegExpExecArray | null; while ((m = reClass.exec(code)) !== null) { const clsName: string = m[1]; // 从 class 声明之后的第一个 { 开始做花括号配对,求出类体范围 const open: number = code.indexOf('{', m.index + m[0].length); if (open < 0) { continue; } let depth: number = 0; let end: number = -1; for (let i = open; i < code.length; i++) { const c: string = code[i]; if (c === '{') { depth++; } else if (c === '}') { depth--; if (depth === 0) { end = i; break; } } } if (end < 0) { continue; } const body: string = code.slice(open, end); // 只有类体内真的用了 @Track 才受本约束(全无 @Track 的类走的是整类观测,不报) if (!/@Track\b/.test(body)) { continue; } const bodyStartLine: number = code.slice(0, open).split('\n').length; const getters: GetterHit[] = []; const bodyLines: string[] = body.split('\n'); for (let i = 0; i < bodyLines.length; i++) { // `get name()` —— 必须排除 `getXxx()` 普通方法,故 get 后要求空白 const g = /^\s*(?:public\s+|private\s+|protected\s+)?get\s+([A-Za-z_$][\w$]*)\s*\(\s*\)/.exec(bodyLines[i]); if (g) { getters.push({ name: g[1], line: bodyStartLine + i }); } } if (getters.length > 0) { out.push({ name: clsName, getters }); } } void codeLines; return out; } const RE_V1_DECO = /^\s*@(State|Prop|Link|Observed|Track|Provide|Consume|Watch|CustomDialog)\b/gm; const RE_V2_DECO = /^\s*@(Local|Param|Once|Event|ObservedV2|Trace|Monitor|Computed|Provider|Consumer)\b/gm; /** * ArkUI 通用属性方法名 —— CustomComponent 基类已把它们定义为方法, * 自定义组件里再声明同名**状态字段**会冲突(编译期 10505001)。 * 取自 SDK common.d.ts 的 CommonMethod,只收最容易被当成字段名的那些。 */ const RESERVED_ATTR_NAMES: Set = new Set([ 'size', 'width', 'height', 'margin', 'padding', 'position', 'scale', 'opacity', 'border', 'clip', 'visibility', 'direction', 'align', 'offset', 'rotate', 'translate', 'zIndex', 'enabled', 'id', 'key' ]); interface TintedMedia { name: string; line: number; } interface FileScan { rel: string; hasExpandSafeArea: boolean; isPage: boolean; hasWindowFullScreen: boolean; tinted: TintedMedia[]; /** 本文件引用的**全部** app.media.X(不限于被染色的),供媒体内容体检使用。 */ mediaRefs: TintedMedia[]; text: string; // 去注释后的代码文本,供 checkWindowMode 使用 } function checkFile(file: string, rel: string): FileScan { const src: string = fs.readFileSync(file, 'utf8'); const code: string = stripComments(src); const rawLines: string[] = src.split('\n'); const codeLines: string[] = code.split('\n'); const inBlock: Set = blockCommentLines(src); // ---- GATE R1:块注释被 */ 序列提前闭合 // 典型来源:把含通配符的证据路径(如 foo_*/view.xml)写进 JSDoc。 // 后果:其后正文被当作代码解析,编译器报出大量指向注释正文的假坐标错误。 for (let i = 0; i < rawLines.length; i++) { if (!inBlock.has(i + 1)) { continue; } // 判据:块内任何位置出现 */ 都会闭合外层注释,**与它后面跟什么无关**。 // 早期版本用 /\*\/[^\s]/(要求 */ 后紧跟非空白),只抓到 foo_*(斜杠)view.xml 这类, // 漏掉了行尾形态 —— 把示例代码贴进 JSDoc 时最常见的那种(内层 /* … */ 收在行尾), // 因为它后面是行尾空白。实测漏检:ListPreferenceDialogHost.ets 由此产生 126 个 // 坐标指向注释正文的假错误,而 GATE 报 0 条。 // 注意 JS 块注释**不可嵌套**,故内层的 /* 本身无害,危险的只有 */ 。 if (/^\s*\/\*/.test(rawLines[i])) { continue; } // 自身即开块(含单行 /** … */) if (/^\s*\*\/\s*$/.test(rawLines[i])) { continue; } // 正常终止行:*/ 独占一行 if (rawLines[i].includes('*/')) { report('GATE', 'block-comment-break', rel, i + 1, 'JSDoc 块内出现 */ 序列,注释在此提前闭合,其后正文将被当作代码解析' + '(内层示例里的 /* … */ 请改成 // 行注释形式)'); } } // ---- GATE R2:$r() 传模板字符串($r 要求字面量) for (let i = 0; i < codeLines.length; i++) { if (codeLines[i].indexOf('$r(' + BACKTICK) >= 0) { report('GATE', 'r-template-string', rel, i + 1, '$r() 要求字面量,模板字符串拼接在运行时不可靠 —— 改为显式映射表'); } } const chains: Chain[] = attributeChains(codeLines); for (const ch of chains) { const blob: string = ch.lines.join('\n'); // ---- GATE R3:同一元素上 width('100%') 与左右 margin 叠加 if (RE_PCT_WIDTH.test(blob) && RE_H_MARGIN.test(blob)) { report('GATE', 'pct-width-with-hmargin', rel, ch.start, "width('100%') 与左右 margin 叠加 → 100% 不扣除自身 margin,实际宽度超出父容器,右侧溢出"); } // ---- CAND R7:撑满容器且未声明 hitTestBehavior // 只有「同时是层叠末位子节点」且「其下有可点击元素」才真正构成缺陷, // 词法分析判不了这两点 —— 故仅列为候选。 if (RE_PCT_WIDTH.test(blob) && RE_PCT_HEIGHT.test(blob) && blob.indexOf('hitTestBehavior') < 0) { report('CAND', 'fullsize-container', rel, ch.start, '撑满容器未声明 hitTestBehavior:若它是层叠末位子节点,会吞掉其下元素的点击'); } } // ---- CAND R8:常量坐标 // 合法与否取决于 Android 侧锚定方向:起始边固定 margin 合法,右/下锚定则是缺陷。 for (let i = 0; i < codeLines.length; i++) { const m: RegExpMatchArray | null = codeLines[i].match(/\.(position|offset)\(\s*\{([^}]*)\}/); if (m !== null && /[xy]\s*:\s*-?\d/.test(m[2])) { report('CAND', 'constant-coordinate', rel, i + 1, '坐标含常量:需回读 Android 源确认是「固定偏移」还是「由父尺寸推导」'); } } // ---- GATE R3b:状态变量名与 ArkUI 通用属性同名 // // 自定义组件继承 CustomComponent,后者已把 width/height/size/margin/... 定义为 // **方法**。用同名字段(如 `@Prop size: number`)会与基类成员冲突,编译器报 // 10505001「Property 'size' in type 'X' is not assignable to the same property in // base type 'CustomComponent'」—— 报错信息指向类型不兼容,读起来像泛型/赋值问题, // 与"换个名字就好"毫无字面关联,实测要靠猜。 // // 值得单列一条:`size` 对一个图标组件是**最自然的**命名,四个图标组件会一次性 // 全部命中(实测首轮 8 个编译错误里有 4 个是这一条),而且它跨页复现 —— // 任何新写的图标/头像/徽标组件都会再踩一次。 for (let i = 0; i < codeLines.length; i++) { const dm: RegExpMatchArray | null = codeLines[i].match( /@(?:Prop|State|Link|Local|Param|Provide|Consume|ObjectLink)\s+([A-Za-z_$][\w$]*)\s*[:?]/); if (dm !== null && RESERVED_ATTR_NAMES.has(dm[1])) { report('GATE', 'state-name-shadows-attribute', rel, i + 1, `状态变量名 \`${dm[1]}\` 与 ArkUI 通用属性方法同名 —— CustomComponent 基类已定义 ` + `${dm[1]}() 方法,同名字段会冲突,编译器报 10505001「not assignable to the same ` + `property in base type 'CustomComponent'」(报错文本不会提示改名,极难反查)。` + `改成带前缀的名字,如 \`icon${dm[1].charAt(0).toUpperCase() + dm[1].slice(1)}\``); } } // ---- GATE R3c:@Builder 形参声明为 () => void 并被裸调用 // // 把"子内容"作为回调传进 @Builder 是命令式思维下最顺手的写法,但 ArkUI 的 // @Builder 体必须是**声明式 UI 语句**,裸 `icon();` 不是,编译器报 10905204 // 「'icon();' does not meet UI component syntax」。正确做法是传 @Builder 引用 // (@BuilderParam),或直接把子内容内联展开。 // // 与上一条同源:两者都是**编译期**才暴露、且报错信息不指向真正原因, // 于是每次都要付一整轮构建(实测单轮 44s~70s)才能发现。 for (let i = 0; i < codeLines.length; i++) { if (!/@Builder/.test(codeLines[i])) { continue; } const sig: string = codeLines[i + 1] || ''; // 形参表必须按括号配平截取:`() => void` 里的 `)` 会让 `[^)]*` 提前收尾, // 把 `tap: () => void, icon: () => void` 截断成 `tap: (` —— 回调形参恰好 // 因此全部漏掉,规则静默失效(本条自身就踩过一次)。 const nm: RegExpMatchArray | null = sig.match(/\b([A-Za-z_$][\w$]*)\s*\(/); if (nm === null || nm.index === undefined) { continue; } let depth: number = 0; let open: number = sig.indexOf('(', nm.index); let close: number = -1; for (let c = open; c < sig.length; c++) { if (sig[c] === '(') { depth++; } else if (sig[c] === ')') { depth--; if (depth === 0) { close = c; break; } } } if (close < 0) { continue; } const params: string = sig.slice(open + 1, close); const sm: string[] = [nm[0], nm[1], params]; const cbs: string[] = [...params.matchAll(/([A-Za-z_$][\w$]*)\s*:\s*\(\s*\)\s*=>\s*void/g)] .map((x) => x[1]); if (cbs.length === 0) { continue; } const body: string = codeLines.slice(i + 1, i + 40).join('\n'); for (const c of cbs) { if (new RegExp('(^|[^.\\w$])' + c + '\\s*\\(\\s*\\)\\s*;').test(body)) { report('GATE', 'builder-invokes-callback-param', rel, i + 2, `@Builder ${sm[1]} 的形参 \`${c}: () => void\` 在体内被裸调用 \`${c}();\` —— ` + `@Builder 体只能是声明式 UI 语句,编译器报 10905204「does not meet UI component ` + `syntax」。改用 @BuilderParam 传入 @Builder 引用,或把该子内容内联展开`); } } } // ---- GATE R4:V1/V2 状态管理范式混用 const v1: number = (code.match(RE_V1_DECO) || []).length; const v2: number = (code.match(RE_V2_DECO) || []).length; if (v1 > 0 && v2 > 0) { report('GATE', 'paradigm-mix', rel, 1, `同文件内 V1(${v1} 处) 与 V2(${v2} 处) 装饰器混用 —— 两套范式不得在同一工程混用`); } // ---- GATE R5:@ObservedV2 类无任何 @Trace(不配合则完全不生效) if (/@ObservedV2/.test(code) && (code.match(/@Trace/g) || []).length === 0) { report('GATE', 'observedv2-without-trace', rel, 1, '@ObservedV2 类中没有任何 @Trace 属性,状态变更不会触发 UI 刷新'); } // 反向:有 @Trace 但类上没有 @ObservedV2 if (/@Trace/.test(code) && !/@ObservedV2/.test(code)) { report('GATE', 'trace-without-observedv2', rel, 1, '出现 @Trace 但类上缺少 @ObservedV2,二者必须配合,单独使用不生效'); } // ---- GATE R5b:@Observed + @Track 的类里用 getter 暴露派生值(V1 专有陷阱) // // 判据来自 references/mvvm/@Track装饰器:class对象属性级更新.md:15 与 :198 —— // 「如果 class 类中使用了 @Track 装饰器,则未被 @Track 装饰的属性不能在 UI 中使用, // 如果使用,会发生运行时报错」。 // // getter **不是数据属性,无法被 @Track 装饰**,于是 @Track 的属性级代理会把它判为 // 「未被 @Track 的属性」,在 build() 里首次读取即抛 // BusinessError 140110: Illegal usage of not @Track'ed property 'x' on UI! // // 为什么这一条必须是 GATE 而不是 CAND: // * 字段侧完备性检查会**全部通过** —— 15 个字段都规规矩矩带了 @Track; // * 编译通过、零告警,GATE 其余各条也全部为 0; // * V1 没有 @Computed(那是 V2 的能力),所以「派生值该怎么暴露」在 V1 文档里 // 没有正面答案,极易被推理成「普通 TS getter 就行,反正读取会重新求值」—— // 该推理关于**响应性**是对的,关于**合法性**是错的,两者极易混为一谈。 // * 只有真机首帧崩溃能发现它,而判据完全是词法的(同文件内即可判定)。 // // 修法:把 getter 改成**普通方法**(`title(): string` + UI 侧 `vm.title()`)。 // 原型方法不是数据属性,不经过 @Track 代理,故不受本约束限制。 // 也可把派生值提升为真正的 @Track 字段,在赋值处同步维护。 // // 精度说明:按**类体**逐个判定,而不是整文件 —— 同一文件里可能既有 @Observed // 类也有普通 class/interface,整文件判定会误报后者的 getter。 for (const cls of observedTrackClassBodies(code, codeLines)) { for (const g of cls.getters) { report('GATE', 'track-class-getter-in-ui', rel, g.line, `@Observed 类 ${cls.name} 同时使用了 @Track,其 getter '${g.name}' 无法被 @Track 装饰;` + '一旦在 build()/@Builder 中读取,首帧即抛 BusinessError 140110 ' + `(Illegal usage of not @Track'ed property '${g.name}' on UI)。` + '改成普通方法(原型方法不受 @Track 代理约束),或提升为真正的 @Track 字段'); } } // ---- 收集 .fillColor() 染色的 media 名(GATE R7 在跨文件阶段核对 SVG 内容) // // 不能用 attributeChains():注释已被替换成空白行,而空白行会**打断**链, // 于是 Image( 与它的 .fillColor( 常常落在不同的"链"里(本项目实测两者相隔 23 行, // 中间全是解释性注释)。改为直接按行回溯:从每个 .fillColor( 向上找最近的 Image(, // 把该区间内出现的所有 app.media. 视为被这次染色作用的候选 // (Image() 里常是三元选图,两个分支都要算)。 const tinted: TintedMedia[] = []; for (let i = 0; i < codeLines.length; i++) { if (!codeLines[i].includes('.fillColor(')) { continue; } // 向上找最近的 Image(。窗口给足:属性链之间夹大段注释是常态。 let from: number = -1; for (let k = i; k >= 0 && k >= i - 80; k--) { if (codeLines[k].includes('Image(')) { from = k; break; } } if (from < 0) { continue; } // 区间 = Image( 那行 .. .fillColor( 之后的若干行(染色参数本身可能跨行, // 但 media 名只会出现在 Image() 的实参里,取到 i 即可) const span: string = codeLines.slice(from, i + 1).join('\n'); const re: RegExp = /app\.media\.([A-Za-z0-9_]+)/g; let m: RegExpExecArray | null = re.exec(span); while (m !== null) { tinted.push({ name: m[1], line: i + 1 }); m = re.exec(span); } } // ---- 收集本文件引用的全部 app.media.X(与染色无关),供跨文件的媒体内容体检使用 const mediaRefs: TintedMedia[] = []; for (let i = 0; i < codeLines.length; i++) { const re: RegExp = /app\.media\.([A-Za-z0-9_]+)/g; let m: RegExpExecArray | null = re.exec(codeLines[i]); while (m !== null) { mediaRefs.push({ name: m[1], line: i + 1 }); m = re.exec(codeLines[i]); } } // ---- 预扫:哪些 @Builder 方法在自己的顶层直接渲染 Text // // 必需,否则 R8 会漏掉最常见的写法:容器的直接子节点是 `this.SectionTitle(title)` // 而不是字面的 `Text(...)`。实测 mihon-statistics 的居中缺陷正是这种形状 —— // 只认字面 Text 时该页零命中。 const textBuilders: Set = new Set(); for (let i = 0; i < codeLines.length; i++) { if (!/^\s*@Builder\b/.test(codeLines[i])) { continue; } // 方法签名可能在 @Builder 的下一行或再下一行 let sig: number = -1; for (let k = i + 1; k < Math.min(i + 4, codeLines.length); k++) { if (/^\s*(?:private\s+|public\s+)?[A-Za-z_$][A-Za-z0-9_$]*\s*\(/.test(codeLines[k])) { sig = k; break; } } if (sig < 0) { continue; } const nm: RegExpMatchArray | null = codeLines[sig].match(/(?:private\s+|public\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/); if (nm === null) { continue; } // 从签名行的 { 起做大括号配平,取方法体;判断体内 depth===1 处是否有 Text( let depth: number = 0; let started: boolean = false; let direct: boolean = false; for (let k = sig; k < codeLines.length; k++) { const ln: string = codeLines[k]; for (let c = 0; c < ln.length; c++) { if (ln[c] === '{') { depth++; started = true; continue; } if (ln[c] === '}') { depth--; continue; } if (started && depth === 1 && ln.startsWith('Text(', c)) { const prev: string = c > 0 ? ln[c - 1] : ' '; if (!/[A-Za-z0-9_.$]/.test(prev)) { direct = true; } } } if (started && depth <= 0) { break; } } if (direct) { textBuilders.add(nm[1]); } } // ---- CAND R8:Column/Row 含收缩宽度子节点但未显式设 alignItems // // ArkUI Column 默认 HorizontalAlign.Center、Row 默认 VerticalAlign.Center; // Android LinearLayout / Compose Column 默认 **start**。两侧都不写对齐属性时 // 字面完全一致,逐属性对照会把它确认成「翻译正确」,而渲染结果一个居中一个齐左。 // // 放 CAND 不放 GATE:居中有时正是对的(空态插图、按钮内图标、卡片内数值列)。 // 只在容器直接含 Text( 时才报,避免把纯布局容器全部列出来。 for (const ch of attributeChains(codeLines)) { // attributeChains 的链首只回溯一行,对**带子节点**的容器而言那行是 `}` 而不是 // `Column() {`。这里从链首行往上做大括号配平,找到真正的容器声明行, // 并把 { ... } 之间的子节点内容一并取出(判断容器是否直接含 Text 要用它)。 let head: string = ch.lines[0] || ''; let bodyLines: string[] = []; if (/^\s*\}/.test(head)) { let depth: number = 0; let open: number = -1; for (let k = ch.start; k >= 0; k--) { const ln: string = codeLines[k]; for (let c = ln.length - 1; c >= 0; c--) { if (ln[c] === '}') { depth++; } else if (ln[c] === '{') { depth--; if (depth === 0) { open = k; break; } } } if (open >= 0) { break; } } if (open < 0) { continue; } head = codeLines[open]; bodyLines = codeLines.slice(open, ch.start + 1); } const m: RegExpMatchArray | null = head.match(/\b(Column|Row)\s*\(/); if (m === null) { continue; } const attrs: string = ch.lines.join('\n'); // 必须检查**决定水平位置的那个轴**,而两种容器的该轴属性不同名: // Column -> 水平是交叉轴 -> alignItems(HorizontalAlign) // Row -> 水平是主轴 -> justifyContent(FlexAlign) // 若一律只看 alignItems,则 Row 上一个**完全正确的** // .alignItems(VerticalAlign.Center)(纵向居中,通常正是 Android 的 // verticalAlignment=CenterVertically)会把本条静默放行 —— 而它管的是纵轴, // 跟"文字齐左还是居中"无关。实测漏检:日期分隔行因此居中,而该容器 // "已写 alignItems",检查器不报、编译通过、逐属性对照亦"忠实"。 const axisAttr: string = m[1] === 'Column' ? 'alignItems' : 'justifyContent'; if (new RegExp('\\.' + axisAttr + '\\(').test(attrs)) { continue; } // 只在容器**直接**含 Text 时才报。必须按大括号深度筛:否则最外层容器 // 会因为某个深层嵌套的子容器里有 Text 而被命中(该 Text 的对齐由那个 // 子容器决定,与本容器无关),典型如 build() 里包住整页的那个 Column。 if (bodyLines.length > 0) { let depth: number = 0; let directText: boolean = false; for (let k = 0; k < bodyLines.length && !directText; k++) { const ln: string = bodyLines[k]; for (let c = 0; c < ln.length; c++) { if (ln[c] === '{') { depth++; continue; } if (ln[c] === '}') { depth--; continue; } // depth === 1 即本容器大括号内的顶层位置 if (depth !== 1) { continue; } // (a) 字面 Text( if (ln.startsWith('Text(', c)) { const prev: string = c > 0 ? ln[c - 1] : ' '; if (!/[A-Za-z0-9_.$]/.test(prev)) { directText = true; break; } } // (b) this.SomeBuilder(...) 且该 @Builder 顶层直接渲染 Text if (ln.startsWith('this.', c)) { const bm: RegExpMatchArray | null = ln.slice(c).match(/^this\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/); if (bm !== null && textBuilders.has(bm[1])) { directText = true; break; } } } } if (!directText) { continue; } } else if (!/\bText\s*\(/.test(attrs)) { continue; } const kind: string = m[1]; const want: string = kind === 'Column' ? 'alignItems(HorizontalAlign.Start)' : 'justifyContent(FlexAlign.Start)'; report('CAND', 'container-align-unset', rel, ch.start, `${kind} 直接含 Text 子节点但未显式 .${axisAttr}() —— 它是本容器**水平方向**的` + `对齐属性(Column 的水平是交叉轴 alignItems,Row 的水平是主轴 justifyContent)。` + `Android LinearLayout / Compose Column 默认 start。若 Android 侧未写水平 gravity,` + `应显式译成 .${want};若该处本就该居中(空态插图、按钮内图标),忽略本条。` + `注意:Row 上已有的 .alignItems(VerticalAlign.*) 管的是纵轴,不满足本条`); } return { rel, hasExpandSafeArea: /expandSafeArea/.test(code), isPage: /[\\/]pages[\\/]/.test(file), hasWindowFullScreen: /setWindowLayoutFullScreen/.test(code), tinted, mediaRefs, text: code }; } // ---------------------------------------------------------------- 跨文件规则 /** * GATE R7:用 .fillColor() 给 stroke 着色的 SVG 染色 —— 颜色完全不生效。 * * fillColor 只叠加 fill 通道(SDK image.d.ts: "Sets the fill color to be superimposed * on the image"),对 stroke 无效。而保真规则**要求**描边图形写成 fill="none", * 于是两条各自正确的规则组合出一个静默缺陷:文件内容对、引用名对、代码看起来也对, * 图形却保持原始色(通常是黑)。 * * 判据(纯文件内容,零歧义):某个被 fillColor 染色的 SVG 里,存在带具体 stroke 颜色 * 且自身没有具体 fill 颜色的元素 —— 该元素的颜色染不上。 */ function checkSvgTintChannel(scans: FileScan[], resDir: string): void { const mediaDir: string = path.join(resDir, 'base', 'media'); if (!fs.existsSync(mediaDir)) { return; } for (const s of scans) { const seen: Set = new Set(); for (const t of s.tinted) { if (seen.has(t.name)) { continue; } seen.add(t.name); const svg: string = path.join(mediaDir, `${t.name}.svg`); if (!fs.existsSync(svg)) { continue; } let src: string; try { src = fs.readFileSync(svg, 'utf8'); } catch { continue; } // 逐个元素看:有具体 stroke 颜色、且同元素内无具体 fill 颜色 => 染不上 const els: string[] = src.match(/<(?:path|circle|rect|ellipse|line|polyline|polygon)\b[^>]*>/g) || []; const bad: string[] = []; for (const el of els) { const stroke: RegExpMatchArray | null = el.match(/stroke\s*=\s*"([^"]*)"/); if (stroke === null) { continue; } const sv: string = stroke[1].trim().toLowerCase(); if (sv === '' || sv === 'none' || sv === 'currentcolor') { continue; } const fill: RegExpMatchArray | null = el.match(/fill\s*=\s*"([^"]*)"/); const fv: string = fill === null ? '' : fill[1].trim().toLowerCase(); if (fv === '' || fv === 'none') { bad.push(sv); } } if (bad.length > 0) { report('GATE', 'fillcolor-on-stroke-svg', s.rel, t.line, `对 app.media.${t.name} 使用了 .fillColor(),但该 SVG 有 ${bad.length} 个 stroke 着色` + `(${bad.slice(0, 3).join(', ')})且无 fill —— fillColor 只作用于 fill 通道,` + '这些几何的颜色不会改变(保持原色,通常为黑)。' + '改法:把描边几何改写成等价 fill 几何(等宽圆环 = 外圆 + 内圆同 path + fill-rule="evenodd")、' + '改用原生组件、或按状态备两份已着色素材'); } } } } /** * GATE R8:用 .fillColor() 染色的 SVG 里存在**铺满 viewBox 的 fill="none" 包围盒 path** * —— 整个图标会被染成实心色块。 * * 与 R7 同族但方向相反:R7 查「stroke 几何染不上」,本条查「fill="none" 的包围盒被染上」。 * fillColor 是叠加到**全部**几何上的,fill="none" 并不能豁免它。 * * 成因:Material 图标的 web SVG 标准写法带一条 `` * 包围盒(占位/对齐用)。Android 的 `` 源里**没有**这条 path —— 实测 10 个安卓仓 * 零命中 —— 所以它只会出现在「凭记忆手写 SVG」的产物里。因此本规则同时是 * 「图标不得凭记忆手写」这条禁令的机械抓手:命中即证明该 SVG 不是从 Android 源转换而来。 * * 判据(纯文件内容,零歧义):某个被 fillColor 染色的 SVG 里,存在 fill="none" 且其 d * 恰好是 viewBox 全矩形的 path。 */ function isFullViewportRect(d: string, w: number, h: number): boolean { // 归一化:去空白、统一逗号 const s: string = d.replace(/\s+/g, '').replace(/,/g, ' ').trim(); // 允许 M0 0 / m0 0 起手,H/h + V/v + H/h 回 0 + Z/z 的各种大小写与相对/绝对混写。 // 只匹配最常见的三种等价写法,宁可漏报也不误报。 const nw: string = String(w); const nh: string = String(h); const variants: string[] = [ `M0 0h${nw}v${nh}H0z`, `M0 0h${nw}v${nh}h-${nw}z`, `M0 0H${nw}V${nh}H0Z`, `M0 0l${nw} 0 0 ${nh} -${nw} 0z` ]; const norm = (x: string): string => x.replace(/\s+/g, '').toLowerCase(); return variants.some((v) => norm(v) === norm(s)); } function checkFullViewportTint(scans: FileScan[], resDir: string): void { const mediaDir: string = path.join(resDir, 'base', 'media'); if (!fs.existsSync(mediaDir)) { return; } // 对**全部**被引用的 media 检查,不限于 tinted。 // // 原因有两层: // (1) tinted 靠「从 .fillColor( 向上找最近的 Image(」配对,跨不过方法边界 —— // 实测 readyou-accounts 的 ic_rss_feed 由 iconFor() 返回、在另一个 @Builder 里 // 被染色,只查 tinted 时漏检,而它恰是列表项里最显眼的那个黑框。 // (2) 更根本的:这条包围盒 path 是 Material web SVG 的写法,Android 源 // 不会产生它(实测 10 个安卓仓零命中)。所以它的出现本身就证明该文件是 // 凭记忆手写而非从 Android 源转换 —— 无论当前有没有染色,都该修。 const reportedFvp: Set = new Set(); for (const s of scans) { const tintedNames: Set = new Set(s.tinted.map((x) => x.name)); for (const t of s.mediaRefs) { if (reportedFvp.has(t.name)) { continue; } const svg: string = path.join(mediaDir, `${t.name}.svg`); if (!fs.existsSync(svg)) { continue; } let src: string; try { src = fs.readFileSync(svg, 'utf8'); } catch { continue; } // 取 viewBox 尺寸(缺失时退回 width/height) const vb: RegExpMatchArray | null = src.match(/viewBox\s*=\s*"([^"]+)"/); let w: number = 0; let h: number = 0; if (vb !== null) { const p: string[] = vb[1].trim().split(/[\s,]+/); if (p.length === 4) { w = parseFloat(p[2]); h = parseFloat(p[3]); } } if (!(w > 0 && h > 0)) { continue; } const els: string[] = src.match(/]*>/g) || []; for (const el of els) { const fill: RegExpMatchArray | null = el.match(/fill\s*=\s*"([^"]*)"/); if (fill === null || fill[1].trim().toLowerCase() !== 'none') { continue; } const dm: RegExpMatchArray | null = el.match(/\bd\s*=\s*"([^"]*)"/); if (dm === null) { continue; } if (isFullViewportRect(dm[1], w, h)) { reportedFvp.add(t.name); const tintNote: string = tintedNames.has(t.name) ? `本文件对它用了 .fillColor(),fillColor 会把这条包围盒一并染上(fill="none" 拦不住),` + '整个图标渲染为**实心色块**' : '本文件未直接对它染色,但只要任何位置用 .fillColor() 染它,整个图标就会变成实心色块' + '(注:跨方法返回资源时染色点可能在别的 @Builder 里)'; report('GATE', 'fillcolor-on-full-viewport-path', s.rel, t.line, `app.media.${t.name} 的 SVG 含一条铺满 viewBox(${w}x${h})的 fill="none" ` + `包围盒 path。${tintNote}。` + '根因:这条 path 是 Material **web SVG** 的写法,Android 源不会产生它 —— ' + '该文件是凭记忆手写而非从 Android 源转换。' + '修法:从 res/drawable 的 重新转换;确实无对应 drawable 时,' + '按 Phase 4.3 的图标来源分级登记为「已降级」,不要手写'); break; } } } } } /** * GATE R9:引用了内容无法渲染的 media —— 文件存在、引用名正确,却画不出图形。 * * 起因:页面 agent 惯于用 `ls media/` 确认资源"存在"就通过,从不打开文件。 * 两类都能编译、都能过引用存在性检查: * (a) 转换器留下的未解析 Android 主题属性(`?attr/...`、`?colorPrimary`)进了 fill/stroke * —— SVG 不认识这种值,该几何不着色; * (b) 占位 SVG(hmos-resources-convert 在缺资源时生成的灰底 + 资源名文本)被当真图标用。 * * 判据均为纯文件内容,零歧义。不依赖读 resource_mapping.md —— 那份文档很长, * 且 (a) 类根本不在它的 Placeholder 章节里(它来自真实 drawable 的转换失败)。 */ function checkMediaContentUsable(scans: FileScan[], resDir: string): void { const mediaDir: string = path.join(resDir, 'base', 'media'); if (!fs.existsSync(mediaDir)) { return; } // 同一资源在整个工程里只报一次,附带首个引用位置 const reported: Set = new Set(); for (const s of scans) { for (const t of s.mediaRefs) { if (reported.has(t.name)) { continue; } const svg: string = path.join(mediaDir, `${t.name}.svg`); if (!fs.existsSync(svg)) { continue; } let src: string; try { src = fs.readFileSync(svg, 'utf8'); } catch { continue; } // (0) 同一元素上出现重复属性 —— XML 规范下这是 **fatal error**, // 整份文档会被解析器拒绝,于是**整个图标一个像素都画不出来**(表现为 // 空白/看不见,而不是画错)。 // // 为什么必须单列一条:其余所有检查(包括本函数的 a/b 两项、 // fillcolor-on-* 两项、以及资源侧的 svg_fidelity_check)都是**先当作 // 有效 SVG 再检查语义**,没有任何一条验证过"这份文件解析得开"。 // 引用名对、文件存在、`ls media/` 通过、语义层也挑不出错 —— 只是文件 // 根本不合法。实测来源:转换器给每个 path 先写死 fill-opacity="1", // 再把 Android 的 fillAlpha 作为**第二个** fill-opacity 追加,命中率 // 11/93。修法是保留后者(即 Android 源里的真实 alpha)、删掉前者。 const dupHit: string | null = (() => { for (const el of src.matchAll(/<([a-zA-Z][\w:-]*)\b([^>]*?)\/?>/g)) { const seen: Set = new Set(); for (const a of el[2].matchAll(/([a-zA-Z_][\w:.-]*)\s*=\s*"/g)) { if (seen.has(a[1])) { return `<${el[1]}> 上的 ${a[1]}`; } seen.add(a[1]); } } return null; })(); if (dupHit !== null) { reported.add(t.name); report('GATE', 'media-duplicate-attribute', s.rel, t.line, `app.media.${t.name} 的 SVG 不是合法 XML:${dupHit} 出现了两次。` + '重复属性在 XML 规范下是致命错误,解析器会拒绝整份文档 —— ' + '**该图标会完全不显示**(空白),而不是显示得不对。' + '注意「文件存在 + 引用名正确 + 语义检查通过」全都拦不住这一条。' + '修法:回到 Android 源核对该属性(如 fillAlpha/strokeAlpha),' + '只保留一个正确取值;转换器常见 bug 是先写默认 "1" 再追加真实值,此时应保留后者'); continue; } // (a) 未解析的 Android 主题属性残留在颜色通道里 const attrHit: RegExpMatchArray | null = src.match(/(?:fill|stroke)\s*=\s*"(\?[A-Za-z0-9_./:]+)"/); if (attrHit !== null) { reported.add(t.name); report('GATE', 'media-unresolved-theme-attr', s.rel, t.line, `app.media.${t.name} 的 SVG 里 fill/stroke 仍是未解析的 Android 主题属性 ` + `"${attrHit[1]}" —— SVG 不认识该值,这部分几何不会着色(常表现为白框/透明块)。` + '需回到 Android 源,把 ?attr/ 主题属性解析成 theme 里对应的具体颜色后重新转换'); continue; } // (b) 占位 SVG:灰底矩形 + 资源名文本(见 hmos-resources-convert 的 placeholder 模板) const isPlaceholder: boolean = /]*fill\s*=\s*"#CCCCCC"/i.test(src) && //// 个数并与之比对。 * * **能力边界(勿按"形状比对"理解)**:本规则不栅格化 SVG、不比对轮廓,只数元素个数。 * 它能抓到「三点 overflow 译成单个圆」这类元素数量级差异,抓不到元素数相同而轮廓不同 * (下载箭头 vs 上传箭头、圆角矩形 vs 圆)的情形。`measure_pack.ts --probe` 只接受 * PNG,无法对 SVG 取 runs —— 轮廓一致性仍须人工打开 SVG 与参考截图比对。 * * **前提**:--resources 必须提供,且页面目录下 measure_pack.json 的 regions * 里有 shape_sig 字段(`{ resource_name, runs, coverage }`,其中 runs 按元素计数口径填写)。 * 该字段目前**没有任何随包脚本产出**,须人工写入;无 --resources 或无 shape_sig 时本规则整条跳过。 */ function checkSvgSemantics(scans: FileScan[], resDir: string, uiInfoDir: string): void { // 1. 收集所有被引用的 SVG(从代码中的 $r('app.media.xxx') 提取) const mediaRefs: Set = new Set(); for (const s of scans) { for (const t of s.mediaRefs) { if (t.line > 0) { mediaRefs.add(t.name); } } } if (mediaRefs.size === 0) { return; } const mediaDir: string = path.join(resDir, 'base', 'media'); if (!fs.existsSync(mediaDir)) { return; } // 2. 读取 ui_info 下所有 measure_pack.json,提取图标形状签名 interface IconShapeSig { name: string; // 资源名(不含扩展名) runs: number; // 连续段数 coverage: number; // 覆盖率 pageName: string; // 来源页面(用于报错时定位) } const refShapes: Map = new Map(); let pagedirs: string[] = []; try { const entries: fs.Dirent[] = fs.readdirSync(uiInfoDir, { withFileTypes: true }); pagedirs = entries .filter((e) => e.isDirectory() && /^(page|manual)_\d{4}_/.test(e.name)) .map((e) => path.join(uiInfoDir, e.name)); } catch { // ui_info 不存在时整条跳过 return; } for (const pageDir of pagedirs) { const measurePackPath: string = path.join(pageDir, 'measure_pack.json'); if (!fs.existsSync(measurePackPath)) { continue; } let measureData: any; try { measureData = JSON.parse(fs.readFileSync(measurePackPath, 'utf8')); } catch { continue; } const pageName: string = path.basename(pageDir); if (Array.isArray(measureData.regions)) { for (const r of measureData.regions) { // 只处理标记为图标的节点(role=ImageView/Image 且尺寸 < 96dp) if (!r.shape_sig || typeof r.shape_sig !== 'object') { continue; } // shape_sig: { resource_name, runs, coverage } const resName: string = r.shape_sig.resource_name; if (typeof resName === 'string' && resName.length > 0) { refShapes.set(resName, { name: resName, runs: r.shape_sig.runs || 0, coverage: r.shape_sig.coverage || 0, pageName }); } } } } // 3. 逐个检查被引用的 SVG for (const mediaName of mediaRefs) { const svgPath: string = path.join(mediaDir, mediaName + '.svg'); if (!fs.existsSync(svgPath)) { continue; // 可能是其他格式(png/jpg),跳过 } let svgContent: string; try { svgContent = fs.readFileSync(svgPath, 'utf8'); } catch { continue; } // 找到第一个引用该资源的文件(用于报错定位) let refFile: string = ''; let refLine: number = 1; for (const s of scans) { const hit = s.mediaRefs.find((t) => t.name === mediaName); if (hit && hit.line > 0) { refFile = s.rel; refLine = hit.line; break; } } if (refFile === '') { continue; // 理论上不会到这里 } // R8a: 空 SVG 文件 if (svgContent.trim().length === 0 || /]*>\s*<\/svg>/.test(svgContent)) { report('GATE', 'media-empty-svg', refFile, refLine, `app.media.${mediaName} 是空 SVG 文件(无任何几何内容),界面上该位置为空白。` + '需补齐真实图标素材或按 L3 规则用等尺寸空 Row/Column 占位 + 降级登记'); continue; } // R8b: 重复属性(已在 checkMediaContentUsable 的 media-duplicate-attribute 里检查,此处不重复) // R8c: 主题属性残留(已在 checkMediaContentUsable 的 media-unresolved-theme-attr 里检查) // R8d: 占位 SVG(已在 checkMediaContentUsable 的 media-placeholder-in-use 里检查) // R9: 形状语义比对(当 refShapes 里有该资源的参考签名时) const refSig: IconShapeSig | undefined = refShapes.get(mediaName); if (!refSig) { continue; // 无参考签名,跳过形状比对 } // 几何元素计数作为粗粒度拓扑特征。 // 不用 measure_pack.ts --probe:它只解码 PNG,对 SVG 取不到 runs。 // 因此本规则的判据是「元素个数」而非「轮廓」,见函数头的能力边界说明。 const actualElems: number = (() => { const pathCount: number = (svgContent.match(/ 50% 且绝对差 >= 2 即判定拓扑不符 const diff: number = Math.abs(actualElems - refSig.runs); const ratio: number = refSig.runs > 0 ? diff / refSig.runs : (actualElems > 0 ? 1 : 0); if (ratio > 0.5 && diff >= 2) { report('GATE', 'media-element-count-mismatch', refFile, refLine, `app.media.${mediaName} 的几何元素个数与参考签名不符:` + `参考签名记录 ${refSig.runs} 个(来自 ${refSig.pageName}),` + `当前 SVG 有 ${actualElems} 个(偏差 ${Math.round(ratio * 100)}%)。` + '文件名选对但像素内容错 —— 需回溯资源转换阶段修正,或换用 L3 等尺寸空 Row 占位 + 降级登记。' + '禁止只抽查部分文件后对未检查文件下全称断言'); } } } /** * CAND R10:Android 侧有 edge-to-edge 调用,但鸿蒙工程里两层都没声明。 * * 为什么必须查源码而不能看截图:edge-to-edge 的页面会自行对 insets 做 padding, * 其截图形态与「避让系统栏」**完全同形** —— 状态栏可见、内容起点在其下方。 * 于是「依据截图判断是否沉浸式」在沉浸式场景下必然误判成非沉浸式, * 再叠加「与基线保持一致」(基线全是非沉浸式)就形成一条两步都"有据"、 * 结论却错的通过路径。实测:10 个安卓仓全部调用了 edge-to-edge, * 而 15 个转换页面的 expandSafeArea / setWindowLayoutFullScreen 出现次数全为 0。 * * 为什么是 CAND 而不是 GATE:命中的调用点可能属于别的 Activity * (如 CrashActivity / ReaderActivity —— Mihon 实测 3 处命中), * 而本检查器不知道 page → Activity 的映射,做成 GATE 会误报。 * 作为 CAND 已经足够:它把①的证据从「可选」变成「必须逐条回应的清单」。 */ const E2E_KEYS: string[] = [ 'enableEdgeToEdge', 'EdgeToEdge.enable', 'setDecorFitsSystemWindows', 'statusBarColor', 'colorPrimaryDark', 'setStatusBarColor' ]; function grepAndroidEdgeToEdge(dir: string): string[] { const hits: string[] = []; const SKIP: Set = new Set([ 'build', '.git', '.gradle', 'node_modules', 'oh_modules', '.idea', 'test', 'androidTest', 'debug', 'benchmark', 'fastlane', 'docs', 'metadata' ]); const MAX_HITS: number = 12; const MAX_FILES: number = 6000; let scanned: number = 0; const walk = (d: string, depth: number): void => { if (depth > 10 || hits.length >= MAX_HITS || scanned >= MAX_FILES) { return; } let entries: fs.Dirent[]; try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; } for (const e of entries) { if (hits.length >= MAX_HITS || scanned >= MAX_FILES) { return; } const p: string = path.join(d, e.name); if (e.isDirectory()) { if (SKIP.has(e.name)) { continue; } walk(p, depth + 1); continue; } // 只看 Kotlin/Java 源:XML 主题里的 windowTranslucentStatus 常来自 // 三方库或 dialog 主题,单独出现时判别力太低,交给 Phase 5 人工核对 if (!/\.(kt|java)$/.test(e.name)) { continue; } scanned++; let src: string; try { src = fs.readFileSync(p, 'utf8'); } catch { continue; } // 先整体判一次,绝大多数文件在这里就被排除,避免逐行扫 let touched: boolean = false; for (const k of E2E_KEYS) { if (src.includes(k)) { touched = true; break; } } if (!touched) { continue; } const lines: string[] = src.split('\n'); for (let i = 0; i < lines.length && hits.length < MAX_HITS; i++) { if (/^\s*import\b/.test(lines[i])) { continue; } for (const k of E2E_KEYS) { if (lines[i].includes(k)) { hits.push(`${path.relative(dir, p).replace(/\\/g, '/')}:${i + 1} (${k})`); break; } } } } }; walk(dir, 0); return hits; } function checkAndroidImmersive(scans: FileScan[], androidDir: string): void { const pages: FileScan[] = scans.filter((s) => s.isPage); if (pages.length === 0) { return; } const anyComponentLayer: boolean = pages.some((s) => s.hasExpandSafeArea); const anyWindowLayer: boolean = scans.some((s) => s.hasWindowFullScreen); if (anyComponentLayer && anyWindowLayer) { return; } const hits: string[] = grepAndroidEdgeToEdge(androidDir); if (hits.length === 0) { return; } const shown: string = hits.slice(0, 6).join('; '); const more: string = hits.length > 6 ? ` 等 ${hits.length} 处` : ''; const missing: string[] = []; if (!anyWindowLayer) { missing.push('窗口层 setWindowLayoutFullScreen'); } if (!anyComponentLayer) { missing.push('组件层 expandSafeArea'); } report('CAND', 'android-immersive-not-mirrored', pages[0].rel, 1, `Android 侧存在 edge-to-edge 调用(${shown}${more}),但鸿蒙工程缺少:${missing.join(' + ')}。` + '请按 Phase 5「窗口模式证据」①逐项核对:命中的调用点是否属于本页宿主 Activity ' + '(属于 CrashActivity/ReaderActivity 等其他 Activity 的不算本页证据)。' + '**注意不要用截图判断** —— edge-to-edge 页面自行对 insets 做 padding,' + '截图形态与避让系统栏完全同形;「与基线一致」也不构成依据,基线只说明现状。' + '若确认本页宿主沉浸,需补齐窗口层 + 组件层(含改 EntryAbility)'); } function checkWindowMode(scans: FileScan[]): void { const pages: FileScan[] = scans.filter((s) => s.isPage); const withExpand: FileScan[] = pages.filter((s) => s.hasExpandSafeArea); const anyWindowLayer: boolean = scans.some((s) => s.hasWindowFullScreen); // GATE R6:声明了组件层却完全没有窗口层 —— 沉浸式必须两层配合。 // 只设组件层时全批页面会「一致地错」,跨页一致性检查照样满分通过。 if (withExpand.length > 0 && !anyWindowLayer) { report('GATE', 'window-layer-missing', withExpand[0].rel, 1, `${withExpand.length} 个页面声明了 expandSafeArea,但工程内无 setWindowLayoutFullScreen —— ` + '系统仍为状态栏保留空间,页面顶部出现空白带'); } // GATE R7:全屏模式下缺内容层 inset 避让 —— 两层开关已设,但内容从 y=0 起绘与状态栏重合 if (anyWindowLayer && withExpand.length > 0) { const noAvoid: FileScan[] = pages.filter((s) => s.hasExpandSafeArea && !s.text.includes('getWindowAvoidArea') && !s.text.includes('avoidAreaChange') && !s.text.includes('statusBarHeight') ); if (noAvoid.length > 0) { report('CAND', 'immersive-missing-inset-padding', noAvoid[0].rel, 1, `${noAvoid.length} 个页面已设全屏模式(setWindowLayoutFullScreen) + expandSafeArea,` + '但页面内未见 getWindowAvoidArea/statusBarHeight 等内容避让实现 —— ' + '若内容顶部未自加状态栏高度 padding,会与系统状态栏文字/图标重合。' + '仅背景层 expandSafeArea 而内容层不延伸的设计(Stack 分层)无需此项。' + '请按 Phase 5 窗口模式证据⑤核对内容避让实现 file:line'); } } // CAND:页间策略不一致(可能有据可依,例如该页本身不沉浸) if (withExpand.length > 0 && withExpand.length < pages.length) { const missing: string[] = pages.filter((s) => !s.hasExpandSafeArea).map((s) => s.rel); report('CAND', 'window-mode-inconsistent', missing[0], 1, `expandSafeArea 在页面间不一致,未声明的页面:${missing.join(', ')} —— ` + '需在报告中给出理由,或补齐'); } } /** 字符串资源残留 Android 转义引号:"…" 是转义语法,不是文本内容。 */ function checkStringResources(resourcesDir: string): void { const files: string[] = []; const walkRes = (dir: string): void => { let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { const p: string = path.join(dir, e.name); if (e.isDirectory()) { walkRes(p); } else if (e.name === 'string.json') { files.push(p); } } }; walkRes(resourcesDir); for (const f of files) { let parsed: Record>>; try { parsed = JSON.parse(fs.readFileSync(f, 'utf8')) as Record>>; } catch { continue; } const arr = parsed['string']; if (!Array.isArray(arr)) { continue; } for (const item of arr) { const v: string = item['value']; if (typeof v === 'string' && v.length >= 2 && v.startsWith('"') && v.endsWith('"')) { report('GATE', 'string-quote-leak', path.basename(f), 1, `资源 ${item['name']} 的值首尾残留字面引号,UI 上会显示出引号`); } } } } // ---------------------------------------------------------------- 主流程 /** * GATE R11:转换后的页面从未成为启动路由。 * * 「注册过」不等于「会被加载」:main_pages.json 里有 pages/X,只说明它可被路由到; * 真正决定启动画面的是 UIAbility 里的 loadContent('pages/Y')。二者不一致时: * - 编译永远通过(两个文件都合法、资源都存在); * - 静态引用检查全过; * - 唯一症状是启动后看到脚手架的 Hello World,而转换成果一次也没被显示。 * 该缺陷只有真机启动能发现,但它的判据完全是词法的 —— 所以定为 GATE。 * * 判据(三支,命中任一即报): * (1) loadContent 的目标 .ets 不存在; * (2) 目标是 DevEco 脚手架残留页(@Entry + 'Hello World' 字面量 + 不引用任何 app 资源); * (3) 工程内有多个 @Entry 页,但没有任何一个非脚手架页被任何 loadContent 加载。 * 判据 (2) 判的是脚手架模板的特征,对所有 DevEco 新工程一致,不含任何 app 专属知识。 */ function checkEntryRoute(scans: FileScan[], etsDir: string): void { const abilityFiles: FileScan[] = scans.filter((s) => /ability/i.test(s.rel)); if (abilityFiles.length === 0) { return; } // FileScan 只带相对路径(无 abs 字段),故由 etsDir + rel 复原绝对路径, // 不改动共享的 FileScan 接口。 const root: string = etsDir.replace(/[\\/]+$/, ''); const absOfRel = (rel: string): string => root + '/' + rel; // 收集全部 loadContent 目标 const targets: { rel: string; line: number; target: string }[] = []; for (const a of abilityFiles) { const lines: string[] = fs.readFileSync(absOfRel(a.rel), 'utf8').split('\n'); lines.forEach((l, i) => { const m = /loadContent\(\s*['"]([^'"]+)['"]/.exec(l); if (m !== null) { targets.push({ rel: a.rel, line: i + 1, target: m[1] }); } }); } if (targets.length === 0) { return; } /** * 是否为 DevEco 脚手架残留页 —— **按特征打分,不按「缺少某物」判定**。 * * 曾经的写法要求「零 app 资源引用」,结果漏报:真实脚手架页**确实**引用了 * 一个 app 资源(`$r('app.float.page_text_font_size')`),于是它被当成已转换页, * 规则在真工程上静默失效 —— 而简化过的测试夹具里没有那句引用,测试照样全绿。 * 教训:判据一旦依赖「不存在某物」,就会被夹具与真实产物的细微差异掩盖。 * * 改为对 DevEco 默认模板的固有特征打分,命中 >= 3 项即认定为脚手架。 * 这些特征是模板自带的,与被转换的 app 无关;逐项都可能随 DevEco 版本微调, * 但不会同时消失 3 项以上。 */ const isScaffold = (abs: string): boolean => { if (!fs.existsSync(abs)) { return false; } const t: string = fs.readFileSync(abs, 'utf8'); if (!/@Entry/.test(t)) { return false; } let score: number = 0; if (/Hello\s+World/.test(t)) { score++; } if (/@State\s+message\s*:\s*string/.test(t)) { score++; } if (/\.id\(\s*['"]HelloWorld['"]\s*\)/.test(t)) { score++; } if (/struct\s+Index\b/.test(t)) { score++; } if (/RelativeContainer\s*\(/.test(t) && /__container__/.test(t)) { score++; } if (/['"]Welcome['"]/.test(t)) { score++; } return score >= 3; }; const absOf = (target: string): string => root + '/' + target.replace(/^\/+/, '') + '.ets'; // **前置条件:工程内必须至少存在一个已转换页面。** // 尚未转换任何页面的空工程里,loadContent 指向脚手架页是**正确**状态 —— // 此时无「转换成果」可要求,报错纯属噪声,而 GATE 的噪声会训练使用者忽略它。 // 因此三支判据全部以「已有转换产出」为前提。 const convertedPages: FileScan[] = scans.filter((s) => s.isPage && !isScaffold(absOfRel(s.rel))); if (convertedPages.length === 0) { return; } let loadsAConvertedPage: boolean = false; for (const t of targets) { const abs: string = absOf(t.target); if (!fs.existsSync(abs)) { report('GATE', 'entry-route-not-converted', t.rel, t.line, `loadContent 的目标页不存在:'${t.target}' —— 期望文件 ${abs.replace(/\\/g, '/')}`); continue; } if (isScaffold(abs)) { report('GATE', 'entry-route-not-converted', t.rel, t.line, `loadContent 加载的是 DevEco 脚手架残留页 '${t.target}'(命中模板特征 >= 3 项),` + `而工程内已有 ${convertedPages.length} 个转换产出页(${convertedPages.map((s) => s.rel).join(', ')})` + ' —— 转换后的页面从未成为启动路由。改为本批产出的入口页' + '(通常对应 Android 侧持 MAIN/LAUNCHER 的那个 Activity)'); continue; } loadsAConvertedPage = true; } // 判据 (3):有已转换页,但没有任何一个被加载(目标既非脚手架、也不在转换产出里, // 例如指向了一个中间页或占位页) if (!loadsAConvertedPage) { report('GATE', 'entry-route-not-converted', targets[0].rel, targets[0].line, `工程内有 ${convertedPages.length} 个已转换页面(${convertedPages.map((s) => s.rel).join(', ')}),` + '但没有任何一个被 loadContent 加载 —— 至少一个非脚手架页必须成为启动路由'); } } /** * GATE R12: 代码字面尺寸与 measure_pack.json 实测值偏差超阈值。 * * 针对「测归测、写归写」的失效模式:agent 跑了 measure_pack 并在轨迹里写出正确换算 * (如「84px = 32dp」),但代码里写的是另一套数字(84vp / Material 记忆值 24vp)。 * * 判据: * 1. 页面目录下存在 measure_pack.json(有实测值) * 2. 代码中出现字面数字尺寸(.width(N) / .height(N) / .size({width:N,height:N})) * 3. 该数字与 measure_pack 中任一关键节点的实测尺寸(vp 换算后)偏差 > 30% * * 排除: * - 百分比字符串 width('100%') * - 资源引用 $r('app.float.xxx') * - layoutWeight(1) 等弹性布局 * - 小于 8vp 的数字(padding/间距等,不属于"关键区域尺寸") * * 报告时附带最接近的实测节点信息,供快速定位。 */ function checkSizeMismatch(scans: FileScan[], uiInfoDir: string): void { // 遍历 ui_info/page_NNNN_ActivityName/ 目录 let pagedirs: string[] = []; try { const entries: fs.Dirent[] = fs.readdirSync(uiInfoDir, { withFileTypes: true }); pagedirs = entries .filter((e) => e.isDirectory() && /^(page|manual)_\d{4}_/.test(e.name)) .map((e) => path.join(uiInfoDir, e.name)); } catch { return; } interface MeasuredNode { id: string; widthVp: number; heightVp: number; } for (const pageDir of pagedirs) { const measurePackPath: string = path.join(pageDir, 'measure_pack.json'); if (!fs.existsSync(measurePackPath)) { continue; } let measureData: any; try { measureData = JSON.parse(fs.readFileSync(measurePackPath, 'utf8')); } catch { continue; } // measure_pack.ts 的 schema:density 是对象 { value, source, evidence }, // 节点在 `regions` 数组里且 wVp/hVp 已换算好(density 无法反解时为 null)。 // 勿改回 measureData.density / measureData.nodes —— 那两个键不存在, // 读到 undefined 后本规则会静默跳过每一页,等于没有门禁。 const density: number = measureData.density && typeof measureData.density.value === 'number' ? measureData.density.value : 0; if (density <= 0) { continue; // density 反解失败时 wVp/hVp 全为 null,无实测值可比 } // 提取关键节点尺寸(wVp/hVp 已由 measure_pack 换算,>= 8vp 才算"关键区域") const nodes: MeasuredNode[] = []; if (Array.isArray(measureData.regions)) { for (const r of measureData.regions) { if (typeof r.wVp !== 'number' || typeof r.hVp !== 'number') { continue; } if (r.wVp < 8 || r.hVp < 8) { continue; // 跳过过小节点 } const label: string = r.text || r.desc || r.role || `node_${r.node}`; nodes.push({ id: `${r.role || 'node'}#${r.node}${label !== r.role ? ` (${label})` : ''}`, widthVp: r.wVp, heightVp: r.hVp }); } } if (nodes.length === 0) { continue; } // 推测对应的页面文件(page_NNNN_ActivityName → ActivityNamePage.ets 或 ActivityName.ets) const pageName: string = path.basename(pageDir).replace(/^(page|manual)_\d{4}_/, ''); const candidates: string[] = [ `${pageName}Page.ets`, `${pageName}.ets`, `${pageName.replace(/Activity$/, '')}Page.ets` ]; let targetScan: FileScan | null = null; for (const c of candidates) { const s: FileScan | undefined = scans.find((x) => x.rel.endsWith('/' + c)); if (s) { targetScan = s; break; } } if (!targetScan) { continue; } // 绑定为非空 const:下面的 checkDim 是闭包,捕获 targetScan 时会丢掉上面这次收窄。 const scan: FileScan = targetScan; // 在代码中匹配 .width(N) / .height(N) / .size({width:N,height:N}) const code: string = scan.text; const lines: string[] = code.split('\n'); // 正则:捕获数字字面量尺寸 const reWidth = /\.width\((\d+(?:\.\d+)?)\)/g; const reHeight = /\.height\((\d+(?:\.\d+)?)\)/g; const reSize = /\.size\(\s*\{\s*width\s*:\s*(\d+(?:\.\d+)?)\s*,\s*height\s*:\s*(\d+(?:\.\d+)?)\s*\}\s*\)/g; const checkDim = (val: number, lineIdx: number, axis: 'width' | 'height'): void => { if (val < 8) { return; // 跳过小尺寸 } // 找最接近的实测节点 let closest: MeasuredNode | null = null; let minDiff: number = Infinity; for (const n of nodes) { const measured: number = axis === 'width' ? n.widthVp : n.heightVp; const diff: number = Math.abs(measured - val); if (diff < minDiff) { minDiff = diff; closest = n; } } if (!closest) { return; } const measured: number = axis === 'width' ? closest.widthVp : closest.heightVp; const ratio: number = minDiff / measured; // 阈值:偏差 > 30% if (ratio > 0.3 && minDiff > 8) { report( 'GATE', 'size-mismatch-vs-measure', scan.rel, lineIdx + 1, `代码写 .${axis}(${val}),但 measure_pack.json 中最接近节点 ${closest.id} 的实测值为 ${measured}vp ` + `(偏差 ${minDiff}vp,${Math.round(ratio * 100)}%)。` + `measure_pack 已产出实测值时必须采用,不得用 Material 规范记忆值或"与兄弟页保持一致"等惯例覆盖 ` + `—— 参见 conversion-procedure.md Phase 4.2 硬性要求(尺寸)` ); } }; for (let i = 0; i < lines.length; i++) { const line: string = lines[i]; let m: RegExpExecArray | null; reWidth.lastIndex = 0; while ((m = reWidth.exec(line)) !== null) { checkDim(parseFloat(m[1]), i, 'width'); } reHeight.lastIndex = 0; while ((m = reHeight.exec(line)) !== null) { checkDim(parseFloat(m[1]), i, 'height'); } reSize.lastIndex = 0; while ((m = reSize.exec(line)) !== null) { checkDim(parseFloat(m[1]), i, 'width'); checkDim(parseFloat(m[2]), i, 'height'); } } } } function walkEts(dir: string, acc: string[]): string[] { let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return acc; } for (const e of entries) { const p: string = path.join(dir, e.name); if (e.isDirectory()) { if (e.name === 'build' || e.name === 'oh_modules' || e.name === 'node_modules') { continue; } walkEts(p, acc); } else if (e.name.endsWith('.ets')) { acc.push(p); } } return acc; } function argOf(flag: string): string { const argv: string[] = process.argv; const i: number = argv.indexOf(flag); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : ''; } function main(): void { const etsDir: string = argOf('--ets'); const resDir: string = argOf('--resources'); const androidDir: string = argOf('--android'); const uiInfoArg: string = argOf('--ui-info'); if (etsDir === '') { console.error('用法: node arkts_static_check.ts --ets <.../entry/src/main/ets> [--resources <.../entry/src/main/resources>] [--android ] [--ui-info ]'); process.exit(2); } if (!fs.existsSync(etsDir)) { console.error(`ets 目录不存在: ${etsDir}`); process.exit(2); } const files: string[] = walkEts(etsDir, []); const scans: FileScan[] = []; for (const f of files) { scans.push(checkFile(f, path.relative(etsDir, f).replace(/\\/g, '/'))); } checkWindowMode(scans); if (resDir !== '') { checkStringResources(resDir); checkSvgTintChannel(scans, resDir); checkFullViewportTint(scans, resDir); checkMediaContentUsable(scans, resDir); } if (androidDir !== '' && fs.existsSync(androidDir)) { checkAndroidImmersive(scans, androidDir); } checkEntryRoute(scans, etsDir); // GATE R12: 代码字面尺寸与 measure_pack 实测偏差超阈值。 // --ui-info 显式指定 ui_info_root;不给时按 SKILL.md 的默认位置 // ${harmony_project_dir}/.hometrans/ui_info 推导(剥掉 entry/src/main/ets 即工程根, // 不可再套 path.dirname —— 那会多爬一层到工程的父目录)。 const uiInfoDir: string = uiInfoArg !== '' ? uiInfoArg : path.join( etsDir.replace(/[\\/]+$/, '').replace(/[\\/]entry[\\/]src[\\/]main[\\/]ets$/, ''), '.hometrans', 'ui_info' ); if (fs.existsSync(uiInfoDir)) { checkSizeMismatch(scans, uiInfoDir); } // GATE R8/R9: SVG 内容验证 + 形状语义比对(需要 --resources 和 ui_info) if (resDir !== '' && fs.existsSync(uiInfoDir)) { checkSvgSemantics(scans, resDir, uiInfoDir); } const gate: Finding[] = findings.filter((f) => f.sev === 'GATE'); const cand: Finding[] = findings.filter((f) => f.sev === 'CAND'); console.log(`arkts_static_check — 扫描 ${files.length} 个 .ets 文件\n`); console.log(`[GATE] 零歧义缺陷,必须清零 —— ${gate.length} 条`); if (gate.length === 0) { console.log(' (无)'); } else { for (const f of gate) { console.log(` ${f.rule.padEnd(26)} ${f.file}:${f.line}`); console.log(` ${' '.repeat(26)} ${f.msg}`); } } console.log(`\n[CANDIDATES] 仅供人工核对,不判定对错 —— ${cand.length} 条`); if (cand.length === 0) { console.log(' (无)'); } else { for (const f of cand) { console.log(` ${f.rule.padEnd(26)} ${f.file}:${f.line}`); console.log(` ${' '.repeat(26)} ${f.msg}`); } console.log('\n ↑ 这些位置是否正确取决于 Android 源侧意图(锚定方向、层叠次序、是否沉浸),'); console.log(' 词法分析无法判定。请在 Phase 5 的「位置锚点证据」「交互可达性证据」'); console.log(' 「窗口模式证据」中逐条标注来源,不要因为本段有输出就直接改代码。'); } process.exit(gate.length > 0 ? 1 : 0); } main();