| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124 |
- #!/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(/^<template[^>]*>/m)
- const tplStart = tplMatch ? src.indexOf(tplMatch[0]) : -1
- const tplEnd = tplStart === -1 ? -1 : src.indexOf('\n</template>', 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(/^<style[^>]*>([\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)
|