#!/usr/bin/env node /** * WXML-JS 绑定一致性静态检查(npm test / CI 门禁) * * 小程序不会在编译期校验 bind/catch 绑定名与 {{}} 数据引用: * wxml 与页面 JS 一旦命名漂移(例如 wxml 写 wxLogin/navigateTo, * 而 js 只有 login/navTo),页面不会报错,只是点击与渲染静默失效。 * 本脚本把这类漂移拦在提交与 CI 阶段。 * * 检查范围:pages//.wxml 与同目录同名 .js 的配对 * 检查规则: * 1. bind/catch 事件绑定(含 bind:tap 冒号写法)的方法名必须存在于对应 JS * 2. {{}} 表达式中的根标识符(点号前的名字)必须存在于对应 JS(数据字段或方法) * 3. wx:for 默认变量 item/index、wx:for-item/wx:for-index 别名、 * 字符串字面量与 true/false/null/undefined 自动忽略 * 4. LEGACY_DRIFT 中登记的历史遗留条目仅告警不失败(修复后应删除对应条目) * * 存在漂移时以非零退出码结束,使 npm test 与 CI 直接失败。 */ const fs = require("node:fs"); const path = require("node:path"); const projectPath = process.cwd(); const pagesPath = path.join(projectPath, "pages"); const LITERAL_KEYWORDS = new Set(["true", "false", "null", "undefined"]); // wx:for 的默认循环变量属于模板结构名而非页面定义,始终忽略 const DEFAULT_LOOP_VARS = new Set(["item", "index"]); // 引入本检查前就存在的历史遗留漂移(本轮修复范围外,另行跟进)。 // 命中时仅告警不失败;对应页面修复后应删除这里的条目,让检查恢复全量严格。 const LEGACY_DRIFT = new Set([ 'pages/calendar/calendar.wxml catchtap="noop"', 'pages/wish-detail/wish-detail.wxml bindtap="onShare"', ]); function listPageNames() { return fs .readdirSync(pagesPath, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .filter((name) => { const dir = path.join(pagesPath, name); return ( fs.existsSync(path.join(dir, `${name}.wxml`)) && fs.existsSync(path.join(dir, `${name}.js`)) ); }) .sort(); } function lineOf(text, index) { let line = 1; for (let i = 0; i < index; i++) { if (text.charCodeAt(i) === 10) { line++; } } return line; } /** 提取 wxml 中的事件绑定与 {{}} 表达式(去掉注释,保留行号) */ function collectWxmlRefs(source) { const text = source.replace(//g, ""); const loopVars = new Set(); const handlers = []; const roots = []; for (const match of text.matchAll(/wx:for-(?:item|index)="([A-Za-z_$][\w$]*)"/g)) { loopVars.add(match[1]); } for (const match of text.matchAll(/((?:bind|catch):?[a-zA-Z]+)="([^"{}]+)"/g)) { const name = match[2].trim(); if (/^[A-Za-z_$][\w$]*$/.test(name)) { handlers.push({ ref: `${match[1]}="${name}"`, name, line: lineOf(text, match.index) }); } } for (const match of text.matchAll(/\{\{([^{}]*)\}\}/g)) { for (const name of expressionRoots(match[1])) { roots.push({ ref: `{{${name}}}`, name, line: lineOf(text, match.index) }); } } return { loopVars, handlers, roots }; } /** 取 mustache 表达式的根标识符:字符串字面量、点号后的属性名、数字与关键字都不算 */ function expressionRoots(expression) { const stripped = expression .replace(/'(?:[^'\\]|\\.)*'/g, "''") .replace(/"(?:[^"\\]|\\.)*"/g, '""'); const roots = new Set(); for (const match of stripped.matchAll(/(? 0) { console.error(`[check-bindings] 发现 ${problems.length} 处 WXML 与 JS 不一致的引用:`); for (const problem of problems) { console.error(` - ${problem}`); } console.error( "[check-bindings] 请统一 wxml 绑定名与页面 JS 的 data 字段/方法名,避免点击与数据绑定静默失效。", ); } if (legacyHits.length > 0) { console.warn( `[check-bindings] 另有 ${legacyHits.length} 处历史遗留漂移(引入检查前已存在,不计入本次失败,另行跟进):`, ); for (const hit of legacyHits) { console.warn(` - ${hit}`); } } if (problems.length > 0) { process.exitCode = 1; return; } console.log( `[check-bindings] 通过:${pages.length} 个页面、${checkedRefs} 处 bind/catch 与 {{}} 引用均能在同名 JS 中找到定义。`, ); } main();