audit-mp-antipatterns.js 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
  1. #!/usr/bin/env node
  2. /**
  3. * 小程序模板/脚本反模式审计(AGENTS.md「ANTI-PATTERNS」中可机器判定的部分)
  4. *
  5. * 分级:
  6. * ERROR — 确定会在微信小程序端出错/违反硬规范,必须修
  7. * 1) 模板可选链 `?.`
  8. * 2) `:key` 使用运算符表达式
  9. * 3) CSS 选择器含中文
  10. * 4) `uni.getSystemInfoSync()`
  11. * 5) `transition: all`
  12. * 6) `v-for` 缺 `:key`(支持跨行)
  13. * 7) 日期用 `toLocaleString` / `toLocaleDateString` / `toLocaleTimeString` 格式化
  14. * WARN — 需人工确认
  15. * 8) `new Date(<非字面量>)`:iOS 解析空格分隔/Java 格式会失败,应改 `parseDate()`
  16. * (`new Date()` 与 `new Date(Date 对象)` 不计入)
  17. * 9) `:class` / `:style` 内带参方法调用:uni-app 渲染函数下可用,
  18. * 但 AGENTS.md 列为禁用项,仅提示(WXML 原生表达式不支持)
  19. *
  20. * 用法: node scripts/audit-mp-antipatterns.js [--json] [--error-only]
  21. * 退出码: 0 无 ERROR / 1 存在 ERROR
  22. */
  23. const fs = require('fs')
  24. const path = require('path')
  25. const ROOT = path.resolve(__dirname, '..')
  26. const SKIP_DIRS = new Set(['node_modules', 'dist', 'unpackage', '.git', 'dist_bak_1792213156', '.tmp_equiv'])
  27. const SKIP_FILES = new Set(['components/mp-html/node/node.vue'])
  28. const JSON_OUT = process.argv.includes('--json')
  29. const ERROR_ONLY = process.argv.includes('--error-only')
  30. function collect(dir, out = []) {
  31. for (const name of fs.readdirSync(dir)) {
  32. if (SKIP_DIRS.has(name)) continue
  33. const p = path.join(dir, name)
  34. const st = fs.statSync(p)
  35. if (st.isDirectory()) collect(p, out)
  36. else if (name.endsWith('.vue')) out.push(p)
  37. }
  38. return out
  39. }
  40. const findings = []
  41. function add(level, kind, file, line, text) {
  42. findings.push({ level, kind, file, line, text: String(text).trim().slice(0, 140) })
  43. }
  44. for (const abs of collect(ROOT)) {
  45. const rel = path.relative(ROOT, abs).split(path.sep).join('/')
  46. if (SKIP_FILES.has(rel)) continue
  47. const src = fs.readFileSync(abs, 'utf8')
  48. const lines = src.split(/\r?\n/)
  49. const tplMatch = src.match(/^<template[^>]*>/m)
  50. const tplStart = tplMatch ? src.indexOf(tplMatch[0]) : -1
  51. const tplEnd = tplStart === -1 ? -1 : src.indexOf('\n</template>', tplStart)
  52. let offset = 0
  53. for (let i = 0; i < lines.length; i++) {
  54. const l = lines[i]
  55. const pos = offset
  56. const inTpl = tplStart !== -1 && pos > tplStart && pos < tplEnd
  57. offset += l.length + 1
  58. if (inTpl && /\?\./.test(l)) add('ERROR', '模板可选链 ?.', rel, i + 1, l)
  59. if (inTpl && /:key="[^"]*(\|\||&&|\+|\?)/.test(l)) add('ERROR', ':key 运算符表达式', rel, i + 1, l)
  60. if (/uni\.getSystemInfoSync\s*\(/.test(l)) add('ERROR', 'getSystemInfoSync 弃用', rel, i + 1, l)
  61. if (/transition\s*:\s*all\b/.test(l)) add('ERROR', 'transition: all', rel, i + 1, l)
  62. // toLocale* 仅针对日期;数字千分位(toLocaleString)不算违规
  63. const localeHit = l.match(/(new\s+Date\s*\([^)]*\)|[A-Za-z_$][\w$.]*)\s*\.\s*toLocale(Date|Time)?String\s*\(/)
  64. if (localeHit) {
  65. const receiver = localeHit[1]
  66. if (/Date|date|time|Time|day|Day|created|updated/.test(receiver)) {
  67. add('ERROR', 'toLocale* 日期格式化', rel, i + 1, l)
  68. }
  69. }
  70. if (inTpl && /:(class|style)="[^"]*\w+\([^)]*\)/.test(l)) add('WARN', ':class/:style 方法调用', rel, i + 1, l)
  71. // new Date(<非字面量>):排除无参、数字字面量与多参数字构造(多参为合法且 iOS 安全)
  72. const dm = l.match(/new\s+Date\s*\(\s*([^)]*?)\s*\)/)
  73. if (dm && dm[1] && !dm[1].includes(',') && !/^['"`\d]/.test(dm[1]) && !/^[A-Za-z_$][\w$.]*\s*[-+*/]/.test(dm[1])) {
  74. add('WARN', 'new Date(非字面量)', rel, i + 1, l)
  75. }
  76. }
  77. // v-for 缺 :key(允许属性跨行,检查标签范围)
  78. for (const tag of src.matchAll(/<[a-zA-Z][\w-]*(\s[^>]*?)?>/g)) {
  79. const body = tag[0]
  80. if (!/\bv-for=/.test(body)) continue
  81. if (/:key=|v-bind:key=/.test(body)) continue
  82. const line = src.slice(0, tag.index).split('\n').length
  83. add('ERROR', 'v-for 缺 :key', rel, line, body.replace(/\s+/g, ' '))
  84. }
  85. // CSS 选择器含中文(排除注释行)
  86. for (const m of src.matchAll(/^<style[^>]*>([\s\S]*?)^<\/style>/gm)) {
  87. const base = src.slice(0, m.index).split('\n').length
  88. m[1].split(/\r?\n/).forEach((l, i) => {
  89. if (/^\s*(\/\/|\/\*|\*)/.test(l)) return
  90. if (/^[^{}\n]*[\u4e00-\u9fa5][^{}\n]*\{/.test(l)) add('ERROR', 'CSS 选择器含中文', rel, base + i, l)
  91. })
  92. }
  93. }
  94. const shown = ERROR_ONLY ? findings.filter(f => f.level === 'ERROR') : findings
  95. if (JSON_OUT) {
  96. console.log(JSON.stringify(shown, null, 2))
  97. } else {
  98. const kinds = [...new Set(findings.map(f => f.kind))]
  99. for (const kind of kinds) {
  100. const list = findings.filter(f => f.kind === kind)
  101. const level = list[0].level
  102. if (ERROR_ONLY && level !== 'ERROR') continue
  103. console.log('[' + level + '] ' + kind + ': ' + list.length)
  104. for (const f of list.slice(0, 6)) console.log(' ' + f.file + ':' + f.line + ' ' + f.text)
  105. if (list.length > 6) console.log(' ... 另有 ' + (list.length - 6) + ' 处')
  106. }
  107. const errors = findings.filter(f => f.level === 'ERROR').length
  108. const warns = findings.length - errors
  109. console.log('')
  110. console.log('ERROR ' + errors + ' 处 / WARN ' + warns + ' 处')
  111. }
  112. process.exit(findings.some(f => f.level === 'ERROR') ? 1 : 0)