audit-undefined-template-props.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. #!/usr/bin/env node
  2. /**
  3. * 小程序模板「未定义实例属性」审计 + options 重复顶层键审计
  4. *
  5. * 背景:Vue 2 模板中引用未在 data/computed/methods/props/mixins 定义的实例属性时,
  6. * 微信小程序端只会在控制台打 `Property or method "xxx" is not defined on the instance`
  7. * 警告、不抛错,该绑定静默失效——这类 bug 无法靠 HBuilderX 编译拦截(编译通过、白板/不渲染),
  8. * 只能靠「模板标识符 ↔ 实例定义名」语义级比对发现。本脚本对 `.vue` 文件做真实求值:
  9. *
  10. * - 剥离注释 → ESM→CJS 转换(保留命名导出语义)→ 沙箱 `new Function` 求值 options
  11. * (含 mixins 展平、`@/` 别名、相对导入、`.vue` 导入递归求值、容错 this)
  12. * - `vue-template-compiler` 编译模板得 render → `new Function(render).call(proxy)`
  13. * 用 Proxy `has` 陷阱采集 `with(this)` 内访问过的标识符
  14. * - 与 defined(data/computed/methods/props/filters ∪ mixins ∪ 全局注入 ∪ 全局对象)比对,
  15. * 未命中的即为 ERROR
  16. *
  17. * 附带第二条规则:`export default` options 对象字面量「重复顶层键」——JS 对象字面量同名键
  18. * 后者静默覆盖前者(如两个 `mixins:` / 两个 `computed:`),功能整体丢失。用 @babel/parser
  19. * AST 检测(节点类型为 ObjectProperty/ObjectMethod)。
  20. *
  21. * 用法:
  22. * node scripts/audit-undefined-template-props.js # 全站扫描
  23. * node scripts/audit-undefined-template-props.js a.vue b.vue # 仅指定文件
  24. * node scripts/audit-undefined-template-props.js --json # 机器可读
  25. * node scripts/audit-undefined-template-props.js --self-test # 灵敏度自测
  26. *
  27. * 退出码: 0 = 无 ERROR / 1 = 存在 ERROR / 2 = 基础设施缺失(无法扫描)
  28. */
  29. const fs = require('fs')
  30. const path = require('path')
  31. const ROOT = path.resolve(__dirname, '..')
  32. const SKIP_DIRS = new Set(['node_modules', 'unpackage', 'dist', '.git', 'mp-html'])
  33. const SKIP_FILES = new Set(['components/mp-html/node/node.vue'])
  34. const JSON_OUT = process.argv.includes('--json')
  35. const SELF_TEST = process.argv.includes('--self-test')
  36. const ARGS = process.argv.slice(2).filter(a => !a.startsWith('--'))
  37. let compiler = null
  38. try { compiler = require(path.join(ROOT, 'node_modules/vue-template-compiler')) } catch (e) { compiler = null }
  39. // ---------------- 宽容桩 ----------------
  40. const DUMMY = (() => {
  41. const fn = function () { return DUMMY }
  42. return new Proxy(fn, {
  43. get(t, k) {
  44. if (k === Symbol.toPrimitive) return () => ''
  45. if (k === Symbol.iterator) return undefined
  46. if (k === 'length') return 0
  47. if (k === 'toString' || k === 'valueOf') return () => ''
  48. return DUMMY
  49. },
  50. apply: () => DUMMY, construct: () => DUMMY, has: () => true
  51. })
  52. })()
  53. function permissiveThis(extra) {
  54. const base = extra || {}
  55. return new Proxy(base, {
  56. has: () => true,
  57. get(t, k) { return k in t ? t[k] : DUMMY }
  58. })
  59. }
  60. // ---------------- 注释剥离 ----------------
  61. function stripComments(src) {
  62. let out = ''
  63. let i = 0
  64. let mode = null
  65. let prevSig = ''
  66. let inClass = false
  67. const buf = []
  68. const flush = () => { if (buf.length) { out += ' '; buf.length = 0 } }
  69. while (i < src.length) {
  70. const c = src[i]
  71. if (mode === 'line') {
  72. if (c === '\n') { flush(); mode = null; out += '\n'; i++; continue }
  73. buf.push(c); i++; continue
  74. }
  75. if (mode === 'block') {
  76. if (c === '*' && src[i + 1] === '/') { buf.push('*/'); i += 2; flush(); mode = null; continue }
  77. buf.push(c); i++; continue
  78. }
  79. if (mode === '"' || mode === "'" || mode === '`') {
  80. out += c
  81. if (c === '\\' && src[i + 1]) { out += src[i + 1]; i += 2; prevSig = '\\'; continue }
  82. if (c === mode) mode = null
  83. i++; continue
  84. }
  85. if (mode === 'reg') {
  86. out += c
  87. if (c === '\\' && src[i + 1]) { out += src[i + 1]; i += 2; continue }
  88. if (c === '[') inClass = true
  89. else if (c === ']') inClass = false
  90. else if (c === '/' && !inClass) { mode = null; prevSig = '/' }
  91. i++; continue
  92. }
  93. if (c === '/' && src[i + 1] === '/') { mode = 'line'; buf.push('//'); i += 2; continue }
  94. if (c === '/' && src[i + 1] === '*') { mode = 'block'; buf.push('/*'); i += 2; continue }
  95. if (c === '"' || c === "'" || c === '`') { mode = c; out += c; i++; continue }
  96. if (c === '/') {
  97. if (prevSig === '' || /[({[,:=+\-*/%<>!&|?^~;.]|\n/.test(prevSig)) {
  98. mode = 'reg'; inClass = false; out += c; prevSig = c; i++; continue
  99. }
  100. out += c; prevSig = c; i++; continue
  101. }
  102. out += c
  103. if (!/\s/.test(c)) prevSig = c
  104. i++
  105. }
  106. flush()
  107. return out
  108. }
  109. // ---------------- ESM -> CJS(保留命名导出) ----------------
  110. /* `import { a as b }` → 解构语法 `{ a: b }` */
  111. function normalizeNamed(named) {
  112. return named.split(',').map(one => {
  113. const t = one.trim()
  114. if (!t) return ''
  115. const m = t.match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/)
  116. return m ? `${m[1]}: ${m[2]}` : t
  117. }).filter(Boolean).join(', ')
  118. }
  119. function transformESM(src) {
  120. let s = stripComments(src)
  121. const exportsList = [] // [{name, local}]
  122. let hasDefault = false
  123. // 1) import X, {a} from / import {a,b as c} from / import * as ns from / import X from / import 'x'
  124. s = s.replace(/import\s+([A-Za-z_$][\w$]*)\s*,\s*\{([\s\S]*?)\}\s*from\s*['"]([^'"]+)['"]/g,
  125. (_m, def, named, p) => `const ${def} = __reqDefault(${JSON.stringify(p)}); const {${normalizeNamed(named)}} = __reqNamed(${JSON.stringify(p)});`)
  126. s = s.replace(/import\s*\{([\s\S]*?)\}\s*from\s*['"]([^'"]+)['"]/g,
  127. (_m, named, p) => `const {${normalizeNamed(named)}} = __reqNamed(${JSON.stringify(p)});`)
  128. s = s.replace(/import\s*\*\s*as\s+([A-Za-z_$][\w$]*)\s*from\s*['"]([^'"]+)['"]/g,
  129. (_m, ns, p) => `const ${ns} = __reqAll(${JSON.stringify(p)});`)
  130. s = s.replace(/import\s+([A-Za-z_$][\w$]*)\s+from\s*['"]([^'"]+)['"]/g,
  131. (_m, def, p) => `const ${def} = __reqDefault(${JSON.stringify(p)});`)
  132. s = s.replace(/^\s*import\s*['"][^'"]+['"]\s*$/gm, '')
  133. // 2) export default 任意表达式(含对象/函数/箭头)
  134. s = s.replace(/\bexport\s+default\b/g, '__default =')
  135. hasDefault = /\b__default\s*=/.test(s)
  136. // 3) export { a, b as c }(不带动词)
  137. s = s.replace(/export\s*\{([^}]*)\}/g, (_m, body) => {
  138. for (const item of body.split(',')) {
  139. const t = item.trim()
  140. if (!t) continue
  141. if (/\s+as\s+/.test(t)) {
  142. const [local, name] = t.split(/\s+as\s+/)
  143. exportsList.push({ name: name.trim(), local: local.trim() })
  144. } else exportsList.push({ name: t, local: t })
  145. }
  146. return ''
  147. })
  148. // 4) export function NAME / export class NAME
  149. s = s.replace(/export\s+function\s+([A-Za-z_$][\w$]*)/g, (_m, n) => { exportsList.push({ name: n, local: n }); return 'function ' + n })
  150. s = s.replace(/export\s+class\s+([A-Za-z_$][\w$]*)/g, (_m, n) => { exportsList.push({ name: n, local: n }); return 'class ' + n })
  151. // 5) export const|let|var A = ..., B = ...
  152. s = s.replace(/export\s+(const|let|var)\s+([\s\S]*?)(?=\n|;)/g, (_m, kw, decls) => {
  153. const names = []
  154. for (const seg of decls.split(/,(?=[A-Za-z_$])/)) {
  155. const m = seg.match(/^\s*([A-Za-z_$][\w$]*)/)
  156. if (m) names.push(m[1])
  157. }
  158. for (const n of names) exportsList.push({ name: n, local: n })
  159. return `${kw} ${decls}`
  160. })
  161. // 6) export * from 'x' / export {a} from 'x'(本项目不存在,防御性移除)
  162. s = s.replace(/export\s*\*\s*from\s*['"][^'"]+['"]/g, '')
  163. s = s.replace(/export\s*\{[^}]*\}\s*from\s*['"][^'"]+['"]/g, '')
  164. // 组装:把具名导出分配到 __exports(default 单独)
  165. let assigns = ''
  166. for (const e of exportsList) assigns += `\n__exports.${e.name} = ${e.local};`
  167. if (hasDefault) assigns += '\n__exports.default = __default;'
  168. const header = 'var __default; var __exports = {};'
  169. return { code: header + '\n' + s + assigns, hasDefault }
  170. }
  171. // ---------------- 模块求值 ----------------
  172. const modCache = new Map()
  173. const UNI = new Proxy({
  174. getStorageSync: () => DUMMY, setStorageSync: () => undefined,
  175. getAccountInfoSync: () => ({ miniProgram: { envVersion: 'develop' } }),
  176. request: () => undefined
  177. }, { get(t, k) { return k in t ? t[k] : (() => DUMMY) } })
  178. const WX = new Proxy({}, { get: () => (...a) => DUMMY, apply: () => DUMMY })
  179. function moduleResultFor(spec, fromDir) {
  180. if (spec === 'vue' || spec === 'vuex') {
  181. try {
  182. const m = require(path.join(ROOT, 'node_modules', spec))
  183. return { __isReal: true, mod: m, default: m.default !== undefined ? m.default : m, named: m }
  184. } catch (e) { return { mod: null } }
  185. }
  186. let p = null
  187. if (spec.startsWith('@/')) p = path.resolve(ROOT, spec.slice(2))
  188. else if (spec.startsWith('.')) p = path.resolve(fromDir, spec)
  189. if (!p) return { mod: null }
  190. if (!/\.[a-zA-Z0-9]+$/.test(p)) p = p + '.js'
  191. if (!fs.existsSync(p)) return { mod: null }
  192. if (p.endsWith('.vue')) return { mod: evalVueFileModule(p) }
  193. return { mod: evalModuleFile(p) }
  194. }
  195. function evalVueFileModule(abs) {
  196. if (modCache.has(abs)) return modCache.get(abs)
  197. modCache.set(abs, {})
  198. try {
  199. const src = fs.readFileSync(abs, 'utf8')
  200. const sm = src.match(/^<script([^>]*)>([\s\S]*?)^<\/script>/m)
  201. if (!sm) { modCache.set(abs, null); return null }
  202. const res = evalVueScript(sm[2], path.dirname(abs))
  203. modCache.set(abs, res)
  204. return res
  205. } catch (e) { modCache.set(abs, null); return null }
  206. }
  207. function evalModuleFile(abs) {
  208. if (modCache.has(abs)) return modCache.get(abs)
  209. modCache.set(abs, {})
  210. let t
  211. try { t = transformESM(fs.readFileSync(abs, 'utf8')) } catch (e) { modCache.set(abs, null); return null }
  212. const m = { exports: {} }
  213. try {
  214. const fn = new Function('module', 'exports', 'require', '__reqDefault', '__reqNamed', '__reqAll', 'uni', 'wx', 'console',
  215. t.code + '\n;return __exports;')
  216. const result = fn(m, m.exports, require,
  217. (spec) => (moduleResultFor(spec, path.dirname(abs)).mod || {}).default,
  218. (spec) => (moduleResultFor(spec, path.dirname(abs)).mod || {}),
  219. (spec) => (moduleResultFor(spec, path.dirname(abs)).mod || {}),
  220. UNI, WX, console)
  221. if (result !== undefined && result !== null) m.exports = result
  222. } catch (e) {
  223. if (process.env.DEBUG_EVAL) console.error(' [mod eval err]', abs, '->', e && e.message)
  224. modCache.set(abs, null)
  225. return null
  226. }
  227. modCache.set(abs, m.exports)
  228. return m.exports
  229. }
  230. function evalVueScript(code, fileDir) {
  231. let t
  232. try { t = transformESM(code) } catch (e) { return null }
  233. const m = { exports: {} }
  234. let ret = null
  235. try {
  236. const fn = new Function('module', 'exports', 'require', '__reqDefault', '__reqNamed', '__reqAll', 'uni', 'wx', 'console',
  237. t.code + '\n;return __exports;')
  238. const result = fn(m, m.exports, require,
  239. (spec) => (moduleResultFor(spec, fileDir).mod || {}).default,
  240. (spec) => (moduleResultFor(spec, fileDir).mod || {}),
  241. (spec) => (moduleResultFor(spec, fileDir).mod || {}),
  242. UNI, WX, console)
  243. ret = t.hasDefault ? result.default : result
  244. } catch (e) {
  245. if (process.env.DEBUG_EVAL) console.error(' [script eval err]', fileDir, '->', e && e.message)
  246. return null
  247. }
  248. return ret
  249. }
  250. // ---------------- 组件定义名 ----------------
  251. function callData(dataFn, thisExtra) {
  252. try {
  253. const d = dataFn.call(permissiveThis(thisExtra))
  254. return (d && typeof d === 'object') ? Object.keys(d) : []
  255. } catch (e) { return [] }
  256. }
  257. function definedNames(options) {
  258. const def = new Set()
  259. const chain = [options]
  260. for (let i = 0; i < chain.length; i++) {
  261. const o = chain[i]
  262. if (!o || typeof o !== 'object') continue
  263. if (Array.isArray(o.mixins)) for (const mx of o.mixins) chain.push(mx)
  264. }
  265. for (const o of chain) {
  266. if (!o || typeof o !== 'object') continue
  267. if (typeof o.data === 'function') for (const k of callData(o.data)) def.add(k)
  268. else if (o.data && typeof o.data === 'object') for (const k of Object.keys(o.data)) def.add(k)
  269. if (o.computed) for (const k of Object.keys(o.computed)) def.add(k)
  270. if (o.methods) for (const k of Object.keys(o.methods)) def.add(k)
  271. if (o.props) for (const k of (Array.isArray(o.props) ? o.props : Object.keys(o.props))) def.add(k)
  272. if (o.filters) for (const k of Object.keys(o.filters)) def.add(k)
  273. }
  274. return def
  275. }
  276. // ---------------- 全局注入 ----------------
  277. let GLOBAL_NAMES = null
  278. function globalNames() {
  279. if (GLOBAL_NAMES) return GLOBAL_NAMES
  280. const set = new Set()
  281. const mainSrc = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8')
  282. const imports = {}
  283. const stripped = stripComments(mainSrc)
  284. for (const m of stripped.matchAll(/import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+)['"]/g)) imports[m[1]] = m[2]
  285. for (const m of stripped.matchAll(/import\s*\{([^}]*)\}\s+from\s+['"]([^'"]+)['"]/g)) {
  286. for (const one of m[1].split(',')) {
  287. const nm = one.split(/\s+as\s+/).pop().trim()
  288. if (nm) imports[nm] = m[2]
  289. }
  290. }
  291. for (const m of stripped.matchAll(/Vue\.prototype\.([A-Za-z_$][\w$]*)\s*=/g)) set.add(m[1])
  292. for (const m of stripped.matchAll(/Vue\.mixin\(\s*([A-Za-z_$][\w$]*)\s*\)/g)) {
  293. const id = m[1]
  294. if (imports[id]) {
  295. const spec = imports[id]
  296. let p = spec.startsWith('@/') ? path.resolve(ROOT, spec.slice(2)) : path.resolve(ROOT, spec)
  297. if (!/\.[a-zA-Z0-9]+$/.test(p)) p = p + '.js'
  298. if (fs.existsSync(p)) {
  299. const modRaw = evalModuleFile(p)
  300. const mod = modRaw && (modRaw.default || modRaw)
  301. if (mod) {
  302. if (typeof mod.data === 'function') for (const k of callData(mod.data)) set.add(k)
  303. if (mod.computed) for (const k of Object.keys(mod.computed)) set.add(k)
  304. if (mod.methods) for (const k of Object.keys(mod.methods)) set.add(k)
  305. if (mod.props) for (const k of (Array.isArray(mod.props) ? mod.props : Object.keys(mod.props))) set.add(k)
  306. }
  307. }
  308. }
  309. }
  310. GLOBAL_NAMES = set
  311. return set
  312. }
  313. // ---------------- 模板标识符采集 ----------------
  314. const GLOBALS = new Set([
  315. 'Math','JSON','Date','Object','Array','String','Number','Boolean','RegExp','Promise','Symbol','Map','Set','WeakMap','WeakSet',
  316. 'BigInt','Proxy','Reflect','Error','TypeError','RangeError','EvalError','SyntaxError','ReferenceError','URIError','Function',
  317. 'parseInt','parseFloat','isNaN','isFinite','encodeURI','decodeURI','encodeURIComponent','decodeURIComponent',
  318. 'escape','unescape','setTimeout','clearTimeout','setInterval','clearInterval','queueMicrotask',
  319. 'NaN','Infinity','undefined','globalThis','console','Intl','URL','URLSearchParams',
  320. 'Blob','File','FileReader','TextEncoder','TextDecoder','AbortController','AbortSignal',
  321. 'XMLHttpRequest','WebSocket','fetch','performance',
  322. 'getApp','getCurrentPages','requirePlugin','requireMiniProgram','requireNativePlugin',
  323. 'uni','wx','tt','swan','my'
  324. ])
  325. const VUE_INTERNALS = new Set(['_c','_v','_s','_l','_t','_e','_m','_f','_k','_q','_i','_n','_o','_p','_u','_d','_b','_g','_vm'])
  326. const INSTANCE_PROPS = new Set(['$attrs','$slots','$scopedSlots','$options','$parent','$root','$refs','$el','$data','$props','$listeners','$children','$store','$router','$route','$event','$index','$value','$key','$emit','$set','$delete','$watch','$forceUpdate','$nextTick','$on','$off','$once','$createElement'])
  327. function collectTemplateIdentifiers(renderSrc) {
  328. const accessed = new Set()
  329. const target = {}
  330. for (const k of VUE_INTERNALS) target[k] = DUMMY
  331. for (const k of INSTANCE_PROPS) target[k] = DUMMY
  332. const handler = {
  333. has(t, key) {
  334. if (typeof key === 'string') {
  335. accessed.add(key)
  336. if (key in t) return true
  337. if (key in globalThis) return false
  338. return true
  339. }
  340. return key in t
  341. },
  342. get(t, key) {
  343. if (key === Symbol.unscopables) return undefined
  344. if (key in t) return t[key]
  345. if (typeof key === 'string' && key in globalThis) return globalThis[key]
  346. return DUMMY
  347. }
  348. }
  349. const proxy = new Proxy(target, handler)
  350. try {
  351. const fn = new Function(renderSrc)
  352. fn.call(proxy)
  353. } catch (e) { }
  354. return accessed
  355. }
  356. // ---------------- options 重复顶层键(@babel/parser AST) ----------------
  357. let parser = null
  358. try { parser = require(path.join(ROOT, 'node_modules/@babel/parser')) } catch (e) { parser = null }
  359. function duplicateTopKeys(code) {
  360. if (!parser) return null // 基础设施缺失,调用方自行降级
  361. const findings = []
  362. try {
  363. const ast = parser.parse(code, { sourceType: 'module', plugins: [] })
  364. let decl = null
  365. for (const item of ast.program.body) {
  366. if (item.type === 'ExportDefaultDeclaration') { decl = item; break }
  367. }
  368. if (!decl) return findings
  369. const obj = decl.declaration
  370. if (!obj || obj.type !== 'ObjectExpression') return findings
  371. const seen = new Map()
  372. for (const prop of obj.properties) {
  373. if (!prop || !prop.key) continue
  374. let name = null
  375. if (prop.type === 'ObjectProperty' || prop.type === 'ObjectMethod') {
  376. if (prop.key.type === 'Identifier') name = prop.key.name
  377. else if (prop.key.type === 'StringLiteral' || prop.key.type === 'Literal') name = String(prop.key.value)
  378. }
  379. if (name) {
  380. if (seen.has(name)) findings.push({ name, firstLine: seen.get(name), secondLine: prop.loc.start.line })
  381. else seen.set(name, prop.loc.start.line)
  382. }
  383. }
  384. } catch (e) { return findings }
  385. return findings
  386. }
  387. // ---------------- 主流程 ----------------
  388. function collect(dir, out) {
  389. out = out || []
  390. let entries = []
  391. try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch (e) { return out }
  392. for (const e of entries) {
  393. const p = path.join(dir, e.name)
  394. if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) collect(p, out) }
  395. else if (e.name.endsWith('.vue')) out.push(p)
  396. }
  397. return out
  398. }
  399. /** 检查单个 .vue 文件,返回 { missing: [], dupKeys: [], evalFailed: bool } */
  400. function checkVueFile(abs) {
  401. const src = fs.readFileSync(abs, 'utf8')
  402. const tm = src.match(/^<template[^>]*>([\s\S]*?)^<\/template>/m)
  403. const sm = src.match(/^<script([^>]*)>([\s\S]*?)^<\/script>/m)
  404. if (!sm) return { missing: [], dupKeys: [], evalFailed: false }
  405. if (/lang\s*=\s*['"]ts/.test(sm[1])) return { missing: [], dupKeys: [], evalFailed: false }
  406. // 重复顶层键只依赖 script 块,无模板也能查
  407. const dupKeys = duplicateTopKeys(sm[2]) || []
  408. if (!tm) return { missing: [], dupKeys, evalFailed: false }
  409. const options = evalVueScript(sm[2], path.dirname(abs))
  410. if (options == null || typeof options !== 'object') return { missing: [], dupKeys, evalFailed: true }
  411. if (!compiler) return { missing: [], dupKeys, evalFailed: false }
  412. const comp = compiler.compile(tm[1])
  413. if (comp.errors && comp.errors.length) return { missing: [], dupKeys, evalFailed: false }
  414. const gSet = globalNames()
  415. const def = definedNames(options)
  416. for (const k of gSet) def.add(k)
  417. const accessed = collectTemplateIdentifiers(comp.render)
  418. const missing = []
  419. for (const name of accessed) {
  420. if (def.has(name)) continue
  421. if (GLOBALS.has(name)) continue
  422. if (VUE_INTERNALS.has(name) || INSTANCE_PROPS.has(name)) continue
  423. if (name.startsWith('_') || name.startsWith('$')) continue
  424. if (name === 'this') continue
  425. if (name.length <= 1) continue
  426. missing.push(name)
  427. }
  428. return { missing: [...new Set(missing)], dupKeys, evalFailed: false }
  429. }
  430. // ---------------- 灵敏度自测 ----------------
  431. function selfTest() {
  432. let pass = true
  433. const log = []
  434. // 用例1:模板引用未定义属性,应命中
  435. const tpl1 = '<view v-if="notDefinedProp">{{ ok }}</view>'
  436. const script1 = 'export default { data() { return { ok: 1 } }, methods: { go() {} } }'
  437. const comp1 = compiler ? compiler.compile(tpl1) : null
  438. const def1 = definedNames(evalVueScript(script1, __dirname))
  439. const acc1 = comp1 ? collectTemplateIdentifiers(comp1.render) : new Set()
  440. const miss1 = [...acc1].filter(n => !def1.has(n) && !GLOBALS.has(n) && !VUE_INTERNALS.has(n) && !INSTANCE_PROPS.has(n) && !/^[_$]/.test(n) && n !== 'this' && n.length > 1)
  441. const ok1 = miss1.includes('notDefinedProp')
  442. log.push((ok1 ? 'PASS' : 'FAIL') + ' 模板未定义属性应命中 notDefinedProp(实际: ' + miss1.join(',') + ')')
  443. if (!ok1) pass = false
  444. // 用例2:全部已定义,应零命中
  445. const tpl2 = '<view v-if="ok">{{ txt }}</view>'
  446. const script2 = 'export default { data() { return { ok: true, txt: "a" } } }'
  447. const comp2 = compiler ? compiler.compile(tpl2) : null
  448. const def2 = definedNames(evalVueScript(script2, __dirname))
  449. const acc2 = comp2 ? collectTemplateIdentifiers(comp2.render) : new Set()
  450. const miss2 = [...acc2].filter(n => !def2.has(n) && !GLOBALS.has(n) && !VUE_INTERNALS.has(n) && !INSTANCE_PROPS.has(n) && !/^[_$]/.test(n) && n !== 'this' && n.length > 1)
  451. log.push((miss2.length === 0 ? 'PASS' : 'FAIL') + ' 已定义属性应零命中(实际: ' + miss2.join(',') + ')')
  452. if (miss2.length !== 0) pass = false
  453. // 用例3:重复顶层键,应命中
  454. const script3 = 'export default {\n mixins: [a],\n components: {},\n mixins: [b]\n}'
  455. const dup3 = duplicateTopKeys(script3) || []
  456. const ok3 = dup3.some(d => d.name === 'mixins')
  457. log.push((ok3 ? 'PASS' : 'FAIL') + ' 重复顶层键应命中 mixins(实际: ' + dup3.map(d => d.name).join(',') + ')')
  458. if (!ok3) pass = false
  459. // 用例4:无重复键,应零命中
  460. const script4 = 'export default { data() { return {} }, methods: {} }'
  461. const dup4 = duplicateTopKeys(script4) || []
  462. log.push((dup4.length === 0 ? 'PASS' : 'FAIL') + ' 无重复键应零命中(实际: ' + dup4.map(d => d.name).join(',') + ')')
  463. if (dup4.length !== 0) pass = false
  464. console.log(log.join('\n'))
  465. return pass ? 0 : 1
  466. }
  467. function main() {
  468. if (SELF_TEST) { process.exit(selfTest()) }
  469. if (!compiler) {
  470. console.error('[FATAL] vue-template-compiler 不可用,无法扫描。请确认 node_modules 已安装。')
  471. process.exit(2)
  472. }
  473. const files = ARGS.length
  474. ? ARGS.map(a => path.isAbsolute(a) ? a : path.resolve(ROOT, a))
  475. : collect(ROOT)
  476. const missingFindings = [] // { file, missing: [] }
  477. const dupFindings = [] // { file, name, firstLine, secondLine }
  478. const evalFailed = []
  479. for (const abs of files) {
  480. const rel = path.relative(ROOT, abs).split(path.sep).join('/')
  481. if (SKIP_FILES.has(rel)) continue
  482. if (!fs.existsSync(abs)) { console.log(' [missing]', abs); continue }
  483. const res = checkVueFile(abs)
  484. for (const d of res.dupKeys) dupFindings.push({ file: rel, ...d })
  485. if (res.evalFailed) { evalFailed.push(rel); continue }
  486. if (res.missing.length) missingFindings.push({ file: rel, missing: res.missing })
  487. }
  488. const totalMiss = missingFindings.reduce((n, f) => n + f.missing.length, 0)
  489. const out = []
  490. out.push('扫描文件: ' + files.length + ' | 求值失败: ' + evalFailed.length + ' | 未定义属性命中: ' + totalMiss + ' | 重复顶层键命中: ' + dupFindings.length)
  491. out.push('全局注入名: ' + [...globalNames()].join(', '))
  492. if (evalFailed.length) {
  493. out.push('\n[!] 脚本求值失败(未检查,需人工确认):')
  494. for (const f of evalFailed) out.push(' ' + f)
  495. }
  496. if (missingFindings.length) {
  497. out.push('\n=== 未定义实例属性(模板引用) ===')
  498. for (const f of missingFindings) out.push(' ' + f.file + ' -> ' + f.missing.join(', '))
  499. }
  500. if (dupFindings.length) {
  501. out.push('\n=== options 重复顶层键(后者覆盖前者) ===')
  502. for (const f of dupFindings) out.push(' ' + f.file + ' -> ' + f.name + ' (L' + f.firstLine + ' / L' + f.secondLine + ')')
  503. }
  504. console.log(out.join('\n'))
  505. if (JSON_OUT) {
  506. console.log('\n---JSON---')
  507. console.log(JSON.stringify({ missing: missingFindings, dupKeys: dupFindings, evalFailed }, null, 2))
  508. }
  509. const hasError = missingFindings.length > 0 || dupFindings.length > 0
  510. // 求值失败 = 该文件未被检查,按 ERROR 对待(需人工确认),避免静默漏检
  511. process.exit(hasError || evalFailed.length > 0 ? 1 : 0)
  512. }
  513. if (require.main === module) main()
  514. module.exports = { stripComments, transformESM, evalModuleFile, evalVueScript, definedNames, collectTemplateIdentifiers, globalNames, ROOT, compiler, modCache, DUMMY, duplicateTopKeys, checkVueFile }