Publish Mini Program Dev Version / publish (push) Successful in 1m5s
settings.js 同步绑定名 loggedIn→logged、login→wxLogin、navTo→navigateTo,恢复登录按钮与全部菜单项响应;npm test 接入新增 ci/check-bindings.cjs 门禁;calendar/wish-detail 各 1 处历史遗留漂移登记为仅告警另行跟进
246 lines
7.9 KiB
JavaScript
246 lines
7.9 KiB
JavaScript
#!/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();
|