#!/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(/^