Files
WorkBuddy d036c9b986 fix(miniapp): 修复 2 个页面 WXML 结构缺陷,并补第 15 项门禁(WXML 结构守卫)
现象:微信开发者工具报
  [ WXML 文件编译错误] ./pages/service-history/service-history.wxml
  get tag end without start, near `</`

真实缺陷(实测 2 处,非 1 处):
- pages/service-history/service-history.wxml 多 1 个 </view>
- pages/service-program/service-program.wxml  多 1 个 </view>
(均为单行压缩写法,随更早提交入库;全仓另外 84 个 WXML 结构自洽)

根因不是这两个文件写错,而是**门禁覆盖盲区**:
既有 14 项 UI 契约全是「源码内容 / 清单断言」,没有任何一条会解析 WXML,
所以「14/14 全绿」与「小程序能不能编译」之间没有因果关系。

修复:
1. 删掉两处多余闭合标签 → 86 个 WXML 结构全部自洽。
2. 新增 scripts/lib/wxml-structure.mjs:按**引号状态机**逐字符扫描标签。
   不能用懒惰正则 —— 属性值里可以合法出现 `>`(`subtitle="{{unreadTotal > 0 ? ...}}"`),
   正则会在引号内的 `>` 处提前收尾,把自闭合标签的 `/` 读掉,报出假阳性。
   (首版实现即踩此坑,曾把合法的 messages.wxml 误判为坏文件;已复核排除。)
3. 新增 scripts/check-wxml-structure.mjs(CLI,退出码 0/1/2)。
4. 新增 ui-tests/wxml-structure-contracts.mjs:全量扫描 + **3 组内置自检样例**
   (正向样例故意在属性值内放 `>`,把「假阳性」这个坑钉进门禁)。
5. scripts/run-ui-contracts.mjs:MIN_GUARDS 14 → 15(守卫被删/改名仍会拒绝报绿)。
6. 文档口径同步:AGENTS.md、STATUS.md(4 处当前口径 + 新增 rc.17 版本行);
   ci.yml ui-contracts job 注释 14 → 15。历史时点行保留原值。

负向验证(三项,全部通过):
- 注入「多余闭合 / 未闭合」→ 退出 1,且只报坏文件、合法的 `>` 属性文件未被误报;
- UI_GUARDS_MIN=99 → 拒绝报绿(数量下限分支可自检);
- CLI 直扫坏目录 → 退出 1。

验证:node scripts/run-ui-contracts.mjs → PASS 15 / 15,0 失败,退出码 0。
未跑:微信开发者工具真实编译(本机无该环境),结论仅由结构判据支撑。
2026-09-19 11:25:05 +08:00

161 lines
4.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// WXML 结构扫描器(lib):供 CLI 与 UI 契约门禁共用,避免两份实现漂移。
//
// 关键设计:**不能**用「一条懒惰正则」判断标签配对。
// 属性值里可以合法出现 `>`:`subtitle="{{unreadTotal > 0 ? unreadTotal + ' 条未读' : '全部已读'}}"`
// 若用 `<(\/?)([a-zA-Z][\w-]*)([^>]*?)(\/?)>`,`[^>]*?` 会在**引号内的 `>`** 处提前收尾,
// 于是 `<ui-section-header ... />` 的自闭合斜杠被读掉 → 报出「未闭合/错配」的**假阳性**。
// 2026-09-19 首次实现即踩此坑(把 messages.wxml 误判为坏文件),故改为按引号状态机逐字符扫描。
//
// 本模块只做**结构**判断:标签名与嵌套关系。它不解析属性语义、不校验组件是否存在。
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
export const SKIP_DIRS = new Set(['node_modules', '.git', 'miniprogram_npm', 'dist', 'build']);
/** 递归收集目录下的 .wxml(跳过 SKIP_DIRS) */
export function walkWxml(dir, out = []) {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return out;
}
for (const e of entries) {
const p = join(dir, e.name);
if (e.isDirectory()) {
if (!SKIP_DIRS.has(e.name)) walkWxml(p, out);
} else if (e.name.endsWith('.wxml')) {
out.push(p);
}
}
return out;
}
/**
* 按引号状态机扫描出全部标签。
* @returns {{tag:string, close:boolean, selfClose:boolean, index:number, line:number}[]}
*/
export function scanTags(src) {
const tags = [];
let i = 0;
let line = 1;
while (i < src.length) {
const ch = src[i];
if (ch === '\n') {
line++;
i++;
continue;
}
// 注释 <!-- ... --> 整体跳过(其中的 < > 不参与配对)
if (src.startsWith('<!--', i)) {
const end = src.indexOf('-->', i + 4);
const stop = end === -1 ? src.length : end + 3;
for (let k = i; k < stop; k++) if (src[k] === '\n') line++;
i = stop;
continue;
}
if (ch !== '<') {
i++;
continue;
}
const startLine = line;
const startIdx = i;
i++; // 吃掉 '<'
let close = false;
if (src[i] === '/') {
close = true;
i++;
}
// 非标签起始(裸文本里的 "<")→ 当作普通字符跳过
if (!/[a-zA-Z]/.test(src[i] || '')) continue;
let name = '';
while (i < src.length && /[\w:.-]/.test(src[i])) name += src[i++];
// 属性区:引号内的一切(含 '>')都不算标签结束
let quote = null;
let selfClose = false;
while (i < src.length) {
const c = src[i];
if (c === '\n') line++;
if (quote) {
if (c === quote) quote = null;
i++;
continue;
}
if (c === '"' || c === "'") {
quote = c;
i++;
continue;
}
if (c === '/' && src[i + 1] === '>') {
selfClose = true;
i += 2;
break;
}
if (c === '>') {
i++;
break;
}
i++;
}
tags.push({ tag: name, close, selfClose, index: startIdx, line: startLine });
}
return tags;
}
/**
* 校验 WXML 结构。
* @returns {string[]} 错误描述(空数组 = 结构合法)
*/
export function checkWxml(src) {
const stack = [];
const errors = [];
for (const t of scanTags(src)) {
if (t.selfClose) continue;
if (t.close) {
const top = stack.pop();
if (!top) {
errors.push(`第 ${t.line} 行:多余的闭合标签 </${t.tag}>(此时没有任何未闭合的标签)`);
break;
}
if (top.tag !== t.tag) {
errors.push(
`第 ${t.line} 行:闭合标签 </${t.tag}> 与栈顶 <${top.tag}>(第 ${top.line} 行)不匹配`,
);
}
} else {
stack.push(t);
}
}
if (stack.length) {
errors.push(
`文件结束时仍有 ${stack.length} 个标签未闭合:` +
stack.map((s) => `<${s.tag}>@第${s.line}行`).join('、'),
);
}
return errors;
}
export function isFile(p) {
try {
return statSync(p).isFile();
} catch {
return false;
}
}
export { readFileSync };