|
|
@@ -0,0 +1,124 @@
|
|
|
+#!/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)
|