Files
lunar-mini/mini/ci/check-bindings.cjs
T
gouki 0e21d8e63f
Publish Mini Program Dev Version / publish (push) Successful in 1m5s
fix(mini): 修复设置页绑定漂移并新增 WXML-JS 一致性检查
settings.js 同步绑定名 loggedIn→logged、login→wxLogin、navTo→navigateTo,恢复登录按钮与全部菜单项响应;npm test 接入新增 ci/check-bindings.cjs 门禁;calendar/wish-detail 各 1 处历史遗留漂移登记为仅告警另行跟进
2026-09-30 00:48:50 +00:00

246 lines
7.9 KiB
JavaScript
Raw 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.
#!/usr/bin/env node
/**
* WXML-JS 绑定一致性静态检查(npm test / CI 门禁)
*
* 小程序不会在编译期校验 bind/catch 绑定名与 {{}} 数据引用:
* wxml 与页面 JS 一旦命名漂移(例如 wxml 写 wxLogin/navigateTo,
* 而 js 只有 login/navTo),页面不会报错,只是点击与渲染静默失效。
* 本脚本把这类漂移拦在提交与 CI 阶段。
*
* 检查范围:pages/<name>/<name>.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(/<!--[\s\S]*?-->/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(/(?<![.\w$])([A-Za-z_$][\w$]*)/g)) {
if (!LITERAL_KEYWORDS.has(match[1])) {
roots.add(match[1]);
}
}
return roots;
}
/** 将 JS 的字符串与注释内容置为空白(保留换行),避免干扰键名提取与跨行匹配 */
function stripJsNoise(source) {
let result = "";
let index = 0;
while (index < source.length) {
const ch = source[index];
const next = source[index + 1];
if (ch === "/" && next === "/") {
while (index < source.length && source[index] !== "\n") {
result += " ";
index++;
}
continue;
}
if (ch === "/" && next === "*") {
while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) {
result += source[index] === "\n" ? "\n" : " ";
index++;
}
result += " ";
index += 2;
continue;
}
if (ch === "'" || ch === '"' || ch === "`") {
result += " ";
index++;
while (index < source.length) {
const current = source[index];
if (current === "\\") {
result += source[index + 1] === "\n" ? "\n" : " ";
index += 2;
continue;
}
if (current === ch) {
result += " ";
index++;
break;
}
result += current === "\n" ? "\n" : " ";
index++;
}
continue;
}
result += ch;
index++;
}
return result;
}
/** 提取页面 JS 中 Page({}) 的数据字段、setData 键与方法名 */
function collectJsNames(source) {
const clean = stripJsNoise(source);
const names = new Set();
// 对象键:{ 或 , 之后的 name:(data 初始字段、setData 键、回调键)
for (const match of clean.matchAll(/[{,]\s*([A-Za-z_$][\w$]*)\s*:/g)) {
names.add(match[1]);
}
// 方法定义:行首可带 async 的 name(
for (const match of clean.matchAll(/^\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(/gm)) {
names.add(match[1]);
}
return names;
}
function main() {
if (!fs.existsSync(pagesPath)) {
console.error(`[check-bindings] 找不到页面目录:${pagesPath}`);
process.exitCode = 2;
return;
}
const pages = listPageNames();
const problems = [];
const legacyHits = [];
let checkedRefs = 0;
for (const name of pages) {
const dir = path.join(pagesPath, name);
const wxmlFile = path.join(dir, `${name}.wxml`);
const jsFile = path.join(dir, `${name}.js`);
const { loopVars, handlers, roots } = collectWxmlRefs(fs.readFileSync(wxmlFile, "utf8"));
const known = collectJsNames(fs.readFileSync(jsFile, "utf8"));
const ignored = new Set([...DEFAULT_LOOP_VARS, ...loopVars]);
const wxmlRel = path.relative(projectPath, wxmlFile);
const jsRel = path.relative(projectPath, jsFile);
for (const handler of handlers) {
checkedRefs++;
if (!known.has(handler.name)) {
if (LEGACY_DRIFT.has(`${wxmlRel} ${handler.ref}`)) {
legacyHits.push(`${wxmlRel}:${handler.line} ${handler.ref}`);
} else {
problems.push(`${wxmlRel}:${handler.line} ${handler.ref} 在 ${jsRel} 中找不到对应方法`);
}
}
}
for (const root of roots) {
if (ignored.has(root.name)) {
continue;
}
checkedRefs++;
if (!known.has(root.name)) {
if (LEGACY_DRIFT.has(`${wxmlRel} ${root.ref}`)) {
legacyHits.push(`${wxmlRel}:${root.line} ${root.ref}`);
} else {
problems.push(`${wxmlRel}:${root.line} ${root.ref} 在 ${jsRel} 的 data/方法中找不到`);
}
}
}
}
if (problems.length > 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();