#!/usr/bin/env node /** * 小程序模板/脚本反模式审计(AGENTS.md「ANTI-PATTERNS」中可机器判定的部分) * * 分级: * ERROR — 确定会在微信小程序端出错/违反硬规范,必须修 * 1) 模板可选链 `?.` * 2) `:key` 使用运算符表达式 * 3) CSS 选择器含中文 * 4) `uni.getSystemInfoSync()` * 5) `transition: all` * 6) `v-for` 缺 `:key`(支持跨行) * 7) 日期用 `toLocaleString` / `toLocaleDateString` / `toLocaleTimeString` 格式化 * WARN — 需人工确认 * 8) `new Date(<非字面量>)`:iOS 解析空格分隔/Java 格式会失败,应改 `parseDate()` * (`new Date()` 与 `new Date(Date 对象)` 不计入) * 9) `:class` / `:style` 内带参方法调用:uni-app 渲染函数下可用, * 但 AGENTS.md 列为禁用项,仅提示(WXML 原生表达式不支持) * * 用法: node scripts/audit-mp-antipatterns.js [--json] [--error-only] * 退出码: 0 无 ERROR / 1 存在 ERROR */ const fs = require('fs') const path = require('path') const ROOT = path.resolve(__dirname, '..') const SKIP_DIRS = new Set(['node_modules', 'dist', 'unpackage', '.git', 'dist_bak_1792213156', '.tmp_equiv']) const SKIP_FILES = new Set(['components/mp-html/node/node.vue']) const JSON_OUT = process.argv.includes('--json') const ERROR_ONLY = process.argv.includes('--error-only') function collect(dir, out = []) { for (const name of fs.readdirSync(dir)) { if (SKIP_DIRS.has(name)) continue const p = path.join(dir, name) const st = fs.statSync(p) if (st.isDirectory()) collect(p, out) else if (name.endsWith('.vue')) out.push(p) } return out } const findings = [] function add(level, kind, file, line, text) { findings.push({ level, kind, file, line, text: String(text).trim().slice(0, 140) }) } for (const abs of collect(ROOT)) { const rel = path.relative(ROOT, abs).split(path.sep).join('/') if (SKIP_FILES.has(rel)) continue const src = fs.readFileSync(abs, 'utf8') const lines = src.split(/\r?\n/) const tplMatch = src.match(/^]*>/m) const tplStart = tplMatch ? src.indexOf(tplMatch[0]) : -1 const tplEnd = tplStart === -1 ? -1 : src.indexOf('\n', tplStart) let offset = 0 for (let i = 0; i < lines.length; i++) { const l = lines[i] const pos = offset const inTpl = tplStart !== -1 && pos > tplStart && pos < tplEnd offset += l.length + 1 if (inTpl && /\?\./.test(l)) add('ERROR', '模板可选链 ?.', rel, i + 1, l) if (inTpl && /:key="[^"]*(\|\||&&|\+|\?)/.test(l)) add('ERROR', ':key 运算符表达式', rel, i + 1, l) if (/uni\.getSystemInfoSync\s*\(/.test(l)) add('ERROR', 'getSystemInfoSync 弃用', rel, i + 1, l) if (/transition\s*:\s*all\b/.test(l)) add('ERROR', 'transition: all', rel, i + 1, l) // toLocale* 仅针对日期;数字千分位(toLocaleString)不算违规 const localeHit = l.match(/(new\s+Date\s*\([^)]*\)|[A-Za-z_$][\w$.]*)\s*\.\s*toLocale(Date|Time)?String\s*\(/) if (localeHit) { const receiver = localeHit[1] if (/Date|date|time|Time|day|Day|created|updated/.test(receiver)) { add('ERROR', 'toLocale* 日期格式化', rel, i + 1, l) } } if (inTpl && /:(class|style)="[^"]*\w+\([^)]*\)/.test(l)) add('WARN', ':class/:style 方法调用', rel, i + 1, l) // new Date(<非字面量>):排除无参、数字字面量与多参数字构造(多参为合法且 iOS 安全) const dm = l.match(/new\s+Date\s*\(\s*([^)]*?)\s*\)/) if (dm && dm[1] && !dm[1].includes(',') && !/^['"`\d]/.test(dm[1]) && !/^[A-Za-z_$][\w$.]*\s*[-+*/]/.test(dm[1])) { add('WARN', 'new Date(非字面量)', rel, i + 1, l) } } // v-for 缺 :key(允许属性跨行,检查标签范围) for (const tag of src.matchAll(/<[a-zA-Z][\w-]*(\s[^>]*?)?>/g)) { const body = tag[0] if (!/\bv-for=/.test(body)) continue if (/:key=|v-bind:key=/.test(body)) continue const line = src.slice(0, tag.index).split('\n').length add('ERROR', 'v-for 缺 :key', rel, line, body.replace(/\s+/g, ' ')) } // CSS 选择器含中文(排除注释行) for (const m of src.matchAll(/^]*>([\s\S]*?)^<\/style>/gm)) { const base = src.slice(0, m.index).split('\n').length m[1].split(/\r?\n/).forEach((l, i) => { if (/^\s*(\/\/|\/\*|\*)/.test(l)) return if (/^[^{}\n]*[\u4e00-\u9fa5][^{}\n]*\{/.test(l)) add('ERROR', 'CSS 选择器含中文', rel, base + i, l) }) } } const shown = ERROR_ONLY ? findings.filter(f => f.level === 'ERROR') : findings if (JSON_OUT) { console.log(JSON.stringify(shown, null, 2)) } else { const kinds = [...new Set(findings.map(f => f.kind))] for (const kind of kinds) { const list = findings.filter(f => f.kind === kind) const level = list[0].level if (ERROR_ONLY && level !== 'ERROR') continue console.log('[' + level + '] ' + kind + ': ' + list.length) for (const f of list.slice(0, 6)) console.log(' ' + f.file + ':' + f.line + ' ' + f.text) if (list.length > 6) console.log(' ... 另有 ' + (list.length - 6) + ' 处') } const errors = findings.filter(f => f.level === 'ERROR').length const warns = findings.length - errors console.log('') console.log('ERROR ' + errors + ' 处 / WARN ' + warns + ' 处') } process.exit(findings.some(f => f.level === 'ERROR') ? 1 : 0)