|
|
@@ -0,0 +1,558 @@
|
|
|
+#!/usr/bin/env node
|
|
|
+/**
|
|
|
+ * 小程序模板「未定义实例属性」审计 + options 重复顶层键审计
|
|
|
+ *
|
|
|
+ * 背景:Vue 2 模板中引用未在 data/computed/methods/props/mixins 定义的实例属性时,
|
|
|
+ * 微信小程序端只会在控制台打 `Property or method "xxx" is not defined on the instance`
|
|
|
+ * 警告、不抛错,该绑定静默失效——这类 bug 无法靠 HBuilderX 编译拦截(编译通过、白板/不渲染),
|
|
|
+ * 只能靠「模板标识符 ↔ 实例定义名」语义级比对发现。本脚本对 `.vue` 文件做真实求值:
|
|
|
+ *
|
|
|
+ * - 剥离注释 → ESM→CJS 转换(保留命名导出语义)→ 沙箱 `new Function` 求值 options
|
|
|
+ * (含 mixins 展平、`@/` 别名、相对导入、`.vue` 导入递归求值、容错 this)
|
|
|
+ * - `vue-template-compiler` 编译模板得 render → `new Function(render).call(proxy)`
|
|
|
+ * 用 Proxy `has` 陷阱采集 `with(this)` 内访问过的标识符
|
|
|
+ * - 与 defined(data/computed/methods/props/filters ∪ mixins ∪ 全局注入 ∪ 全局对象)比对,
|
|
|
+ * 未命中的即为 ERROR
|
|
|
+ *
|
|
|
+ * 附带第二条规则:`export default` options 对象字面量「重复顶层键」——JS 对象字面量同名键
|
|
|
+ * 后者静默覆盖前者(如两个 `mixins:` / 两个 `computed:`),功能整体丢失。用 @babel/parser
|
|
|
+ * AST 检测(节点类型为 ObjectProperty/ObjectMethod)。
|
|
|
+ *
|
|
|
+ * 用法:
|
|
|
+ * node scripts/audit-undefined-template-props.js # 全站扫描
|
|
|
+ * node scripts/audit-undefined-template-props.js a.vue b.vue # 仅指定文件
|
|
|
+ * node scripts/audit-undefined-template-props.js --json # 机器可读
|
|
|
+ * node scripts/audit-undefined-template-props.js --self-test # 灵敏度自测
|
|
|
+ *
|
|
|
+ * 退出码: 0 = 无 ERROR / 1 = 存在 ERROR / 2 = 基础设施缺失(无法扫描)
|
|
|
+ */
|
|
|
+const fs = require('fs')
|
|
|
+const path = require('path')
|
|
|
+
|
|
|
+const ROOT = path.resolve(__dirname, '..')
|
|
|
+const SKIP_DIRS = new Set(['node_modules', 'unpackage', 'dist', '.git', 'mp-html'])
|
|
|
+const SKIP_FILES = new Set(['components/mp-html/node/node.vue'])
|
|
|
+const JSON_OUT = process.argv.includes('--json')
|
|
|
+const SELF_TEST = process.argv.includes('--self-test')
|
|
|
+const ARGS = process.argv.slice(2).filter(a => !a.startsWith('--'))
|
|
|
+
|
|
|
+let compiler = null
|
|
|
+try { compiler = require(path.join(ROOT, 'node_modules/vue-template-compiler')) } catch (e) { compiler = null }
|
|
|
+
|
|
|
+// ---------------- 宽容桩 ----------------
|
|
|
+const DUMMY = (() => {
|
|
|
+ const fn = function () { return DUMMY }
|
|
|
+ return new Proxy(fn, {
|
|
|
+ get(t, k) {
|
|
|
+ if (k === Symbol.toPrimitive) return () => ''
|
|
|
+ if (k === Symbol.iterator) return undefined
|
|
|
+ if (k === 'length') return 0
|
|
|
+ if (k === 'toString' || k === 'valueOf') return () => ''
|
|
|
+ return DUMMY
|
|
|
+ },
|
|
|
+ apply: () => DUMMY, construct: () => DUMMY, has: () => true
|
|
|
+ })
|
|
|
+})()
|
|
|
+function permissiveThis(extra) {
|
|
|
+ const base = extra || {}
|
|
|
+ return new Proxy(base, {
|
|
|
+ has: () => true,
|
|
|
+ get(t, k) { return k in t ? t[k] : DUMMY }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------- 注释剥离 ----------------
|
|
|
+function stripComments(src) {
|
|
|
+ let out = ''
|
|
|
+ let i = 0
|
|
|
+ let mode = null
|
|
|
+ let prevSig = ''
|
|
|
+ let inClass = false
|
|
|
+ const buf = []
|
|
|
+ const flush = () => { if (buf.length) { out += ' '; buf.length = 0 } }
|
|
|
+ while (i < src.length) {
|
|
|
+ const c = src[i]
|
|
|
+ if (mode === 'line') {
|
|
|
+ if (c === '\n') { flush(); mode = null; out += '\n'; i++; continue }
|
|
|
+ buf.push(c); i++; continue
|
|
|
+ }
|
|
|
+ if (mode === 'block') {
|
|
|
+ if (c === '*' && src[i + 1] === '/') { buf.push('*/'); i += 2; flush(); mode = null; continue }
|
|
|
+ buf.push(c); i++; continue
|
|
|
+ }
|
|
|
+ if (mode === '"' || mode === "'" || mode === '`') {
|
|
|
+ out += c
|
|
|
+ if (c === '\\' && src[i + 1]) { out += src[i + 1]; i += 2; prevSig = '\\'; continue }
|
|
|
+ if (c === mode) mode = null
|
|
|
+ i++; continue
|
|
|
+ }
|
|
|
+ if (mode === 'reg') {
|
|
|
+ out += c
|
|
|
+ if (c === '\\' && src[i + 1]) { out += src[i + 1]; i += 2; continue }
|
|
|
+ if (c === '[') inClass = true
|
|
|
+ else if (c === ']') inClass = false
|
|
|
+ else if (c === '/' && !inClass) { mode = null; prevSig = '/' }
|
|
|
+ i++; continue
|
|
|
+ }
|
|
|
+ if (c === '/' && src[i + 1] === '/') { mode = 'line'; buf.push('//'); i += 2; continue }
|
|
|
+ if (c === '/' && src[i + 1] === '*') { mode = 'block'; buf.push('/*'); i += 2; continue }
|
|
|
+ if (c === '"' || c === "'" || c === '`') { mode = c; out += c; i++; continue }
|
|
|
+ if (c === '/') {
|
|
|
+ if (prevSig === '' || /[({[,:=+\-*/%<>!&|?^~;.]|\n/.test(prevSig)) {
|
|
|
+ mode = 'reg'; inClass = false; out += c; prevSig = c; i++; continue
|
|
|
+ }
|
|
|
+ out += c; prevSig = c; i++; continue
|
|
|
+ }
|
|
|
+ out += c
|
|
|
+ if (!/\s/.test(c)) prevSig = c
|
|
|
+ i++
|
|
|
+ }
|
|
|
+ flush()
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------- ESM -> CJS(保留命名导出) ----------------
|
|
|
+/* `import { a as b }` → 解构语法 `{ a: b }` */
|
|
|
+function normalizeNamed(named) {
|
|
|
+ return named.split(',').map(one => {
|
|
|
+ const t = one.trim()
|
|
|
+ if (!t) return ''
|
|
|
+ const m = t.match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/)
|
|
|
+ return m ? `${m[1]}: ${m[2]}` : t
|
|
|
+ }).filter(Boolean).join(', ')
|
|
|
+}
|
|
|
+
|
|
|
+function transformESM(src) {
|
|
|
+ let s = stripComments(src)
|
|
|
+ const exportsList = [] // [{name, local}]
|
|
|
+ let hasDefault = false
|
|
|
+
|
|
|
+ // 1) import X, {a} from / import {a,b as c} from / import * as ns from / import X from / import 'x'
|
|
|
+ s = s.replace(/import\s+([A-Za-z_$][\w$]*)\s*,\s*\{([\s\S]*?)\}\s*from\s*['"]([^'"]+)['"]/g,
|
|
|
+ (_m, def, named, p) => `const ${def} = __reqDefault(${JSON.stringify(p)}); const {${normalizeNamed(named)}} = __reqNamed(${JSON.stringify(p)});`)
|
|
|
+ s = s.replace(/import\s*\{([\s\S]*?)\}\s*from\s*['"]([^'"]+)['"]/g,
|
|
|
+ (_m, named, p) => `const {${normalizeNamed(named)}} = __reqNamed(${JSON.stringify(p)});`)
|
|
|
+ s = s.replace(/import\s*\*\s*as\s+([A-Za-z_$][\w$]*)\s*from\s*['"]([^'"]+)['"]/g,
|
|
|
+ (_m, ns, p) => `const ${ns} = __reqAll(${JSON.stringify(p)});`)
|
|
|
+ s = s.replace(/import\s+([A-Za-z_$][\w$]*)\s+from\s*['"]([^'"]+)['"]/g,
|
|
|
+ (_m, def, p) => `const ${def} = __reqDefault(${JSON.stringify(p)});`)
|
|
|
+ s = s.replace(/^\s*import\s*['"][^'"]+['"]\s*$/gm, '')
|
|
|
+
|
|
|
+ // 2) export default 任意表达式(含对象/函数/箭头)
|
|
|
+ s = s.replace(/\bexport\s+default\b/g, '__default =')
|
|
|
+ hasDefault = /\b__default\s*=/.test(s)
|
|
|
+
|
|
|
+ // 3) export { a, b as c }(不带动词)
|
|
|
+ s = s.replace(/export\s*\{([^}]*)\}/g, (_m, body) => {
|
|
|
+ for (const item of body.split(',')) {
|
|
|
+ const t = item.trim()
|
|
|
+ if (!t) continue
|
|
|
+ if (/\s+as\s+/.test(t)) {
|
|
|
+ const [local, name] = t.split(/\s+as\s+/)
|
|
|
+ exportsList.push({ name: name.trim(), local: local.trim() })
|
|
|
+ } else exportsList.push({ name: t, local: t })
|
|
|
+ }
|
|
|
+ return ''
|
|
|
+ })
|
|
|
+
|
|
|
+ // 4) export function NAME / export class NAME
|
|
|
+ s = s.replace(/export\s+function\s+([A-Za-z_$][\w$]*)/g, (_m, n) => { exportsList.push({ name: n, local: n }); return 'function ' + n })
|
|
|
+ s = s.replace(/export\s+class\s+([A-Za-z_$][\w$]*)/g, (_m, n) => { exportsList.push({ name: n, local: n }); return 'class ' + n })
|
|
|
+
|
|
|
+ // 5) export const|let|var A = ..., B = ...
|
|
|
+ s = s.replace(/export\s+(const|let|var)\s+([\s\S]*?)(?=\n|;)/g, (_m, kw, decls) => {
|
|
|
+ const names = []
|
|
|
+ for (const seg of decls.split(/,(?=[A-Za-z_$])/)) {
|
|
|
+ const m = seg.match(/^\s*([A-Za-z_$][\w$]*)/)
|
|
|
+ if (m) names.push(m[1])
|
|
|
+ }
|
|
|
+ for (const n of names) exportsList.push({ name: n, local: n })
|
|
|
+ return `${kw} ${decls}`
|
|
|
+ })
|
|
|
+
|
|
|
+ // 6) export * from 'x' / export {a} from 'x'(本项目不存在,防御性移除)
|
|
|
+ s = s.replace(/export\s*\*\s*from\s*['"][^'"]+['"]/g, '')
|
|
|
+ s = s.replace(/export\s*\{[^}]*\}\s*from\s*['"][^'"]+['"]/g, '')
|
|
|
+
|
|
|
+ // 组装:把具名导出分配到 __exports(default 单独)
|
|
|
+ let assigns = ''
|
|
|
+ for (const e of exportsList) assigns += `\n__exports.${e.name} = ${e.local};`
|
|
|
+ if (hasDefault) assigns += '\n__exports.default = __default;'
|
|
|
+ const header = 'var __default; var __exports = {};'
|
|
|
+ return { code: header + '\n' + s + assigns, hasDefault }
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------- 模块求值 ----------------
|
|
|
+const modCache = new Map()
|
|
|
+const UNI = new Proxy({
|
|
|
+ getStorageSync: () => DUMMY, setStorageSync: () => undefined,
|
|
|
+ getAccountInfoSync: () => ({ miniProgram: { envVersion: 'develop' } }),
|
|
|
+ request: () => undefined
|
|
|
+}, { get(t, k) { return k in t ? t[k] : (() => DUMMY) } })
|
|
|
+const WX = new Proxy({}, { get: () => (...a) => DUMMY, apply: () => DUMMY })
|
|
|
+
|
|
|
+function moduleResultFor(spec, fromDir) {
|
|
|
+ if (spec === 'vue' || spec === 'vuex') {
|
|
|
+ try {
|
|
|
+ const m = require(path.join(ROOT, 'node_modules', spec))
|
|
|
+ return { __isReal: true, mod: m, default: m.default !== undefined ? m.default : m, named: m }
|
|
|
+ } catch (e) { return { mod: null } }
|
|
|
+ }
|
|
|
+ let p = null
|
|
|
+ if (spec.startsWith('@/')) p = path.resolve(ROOT, spec.slice(2))
|
|
|
+ else if (spec.startsWith('.')) p = path.resolve(fromDir, spec)
|
|
|
+ if (!p) return { mod: null }
|
|
|
+ if (!/\.[a-zA-Z0-9]+$/.test(p)) p = p + '.js'
|
|
|
+ if (!fs.existsSync(p)) return { mod: null }
|
|
|
+ if (p.endsWith('.vue')) return { mod: evalVueFileModule(p) }
|
|
|
+ return { mod: evalModuleFile(p) }
|
|
|
+}
|
|
|
+
|
|
|
+function evalVueFileModule(abs) {
|
|
|
+ if (modCache.has(abs)) return modCache.get(abs)
|
|
|
+ modCache.set(abs, {})
|
|
|
+ try {
|
|
|
+ const src = fs.readFileSync(abs, 'utf8')
|
|
|
+ const sm = src.match(/^<script([^>]*)>([\s\S]*?)^<\/script>/m)
|
|
|
+ if (!sm) { modCache.set(abs, null); return null }
|
|
|
+ const res = evalVueScript(sm[2], path.dirname(abs))
|
|
|
+ modCache.set(abs, res)
|
|
|
+ return res
|
|
|
+ } catch (e) { modCache.set(abs, null); return null }
|
|
|
+}
|
|
|
+
|
|
|
+function evalModuleFile(abs) {
|
|
|
+ if (modCache.has(abs)) return modCache.get(abs)
|
|
|
+ modCache.set(abs, {})
|
|
|
+ let t
|
|
|
+ try { t = transformESM(fs.readFileSync(abs, 'utf8')) } catch (e) { modCache.set(abs, null); return null }
|
|
|
+ const m = { exports: {} }
|
|
|
+ try {
|
|
|
+ const fn = new Function('module', 'exports', 'require', '__reqDefault', '__reqNamed', '__reqAll', 'uni', 'wx', 'console',
|
|
|
+ t.code + '\n;return __exports;')
|
|
|
+ const result = fn(m, m.exports, require,
|
|
|
+ (spec) => (moduleResultFor(spec, path.dirname(abs)).mod || {}).default,
|
|
|
+ (spec) => (moduleResultFor(spec, path.dirname(abs)).mod || {}),
|
|
|
+ (spec) => (moduleResultFor(spec, path.dirname(abs)).mod || {}),
|
|
|
+ UNI, WX, console)
|
|
|
+ if (result !== undefined && result !== null) m.exports = result
|
|
|
+ } catch (e) {
|
|
|
+ if (process.env.DEBUG_EVAL) console.error(' [mod eval err]', abs, '->', e && e.message)
|
|
|
+ modCache.set(abs, null)
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ modCache.set(abs, m.exports)
|
|
|
+ return m.exports
|
|
|
+}
|
|
|
+
|
|
|
+function evalVueScript(code, fileDir) {
|
|
|
+ let t
|
|
|
+ try { t = transformESM(code) } catch (e) { return null }
|
|
|
+ const m = { exports: {} }
|
|
|
+ let ret = null
|
|
|
+ try {
|
|
|
+ const fn = new Function('module', 'exports', 'require', '__reqDefault', '__reqNamed', '__reqAll', 'uni', 'wx', 'console',
|
|
|
+ t.code + '\n;return __exports;')
|
|
|
+ const result = fn(m, m.exports, require,
|
|
|
+ (spec) => (moduleResultFor(spec, fileDir).mod || {}).default,
|
|
|
+ (spec) => (moduleResultFor(spec, fileDir).mod || {}),
|
|
|
+ (spec) => (moduleResultFor(spec, fileDir).mod || {}),
|
|
|
+ UNI, WX, console)
|
|
|
+ ret = t.hasDefault ? result.default : result
|
|
|
+ } catch (e) {
|
|
|
+ if (process.env.DEBUG_EVAL) console.error(' [script eval err]', fileDir, '->', e && e.message)
|
|
|
+ return null
|
|
|
+ }
|
|
|
+ return ret
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------- 组件定义名 ----------------
|
|
|
+function callData(dataFn, thisExtra) {
|
|
|
+ try {
|
|
|
+ const d = dataFn.call(permissiveThis(thisExtra))
|
|
|
+ return (d && typeof d === 'object') ? Object.keys(d) : []
|
|
|
+ } catch (e) { return [] }
|
|
|
+}
|
|
|
+
|
|
|
+function definedNames(options) {
|
|
|
+ const def = new Set()
|
|
|
+ const chain = [options]
|
|
|
+ for (let i = 0; i < chain.length; i++) {
|
|
|
+ const o = chain[i]
|
|
|
+ if (!o || typeof o !== 'object') continue
|
|
|
+ if (Array.isArray(o.mixins)) for (const mx of o.mixins) chain.push(mx)
|
|
|
+ }
|
|
|
+ for (const o of chain) {
|
|
|
+ if (!o || typeof o !== 'object') continue
|
|
|
+ if (typeof o.data === 'function') for (const k of callData(o.data)) def.add(k)
|
|
|
+ else if (o.data && typeof o.data === 'object') for (const k of Object.keys(o.data)) def.add(k)
|
|
|
+ if (o.computed) for (const k of Object.keys(o.computed)) def.add(k)
|
|
|
+ if (o.methods) for (const k of Object.keys(o.methods)) def.add(k)
|
|
|
+ if (o.props) for (const k of (Array.isArray(o.props) ? o.props : Object.keys(o.props))) def.add(k)
|
|
|
+ if (o.filters) for (const k of Object.keys(o.filters)) def.add(k)
|
|
|
+ }
|
|
|
+ return def
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------- 全局注入 ----------------
|
|
|
+let GLOBAL_NAMES = null
|
|
|
+function globalNames() {
|
|
|
+ if (GLOBAL_NAMES) return GLOBAL_NAMES
|
|
|
+ const set = new Set()
|
|
|
+ const mainSrc = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8')
|
|
|
+ const imports = {}
|
|
|
+ const stripped = stripComments(mainSrc)
|
|
|
+ for (const m of stripped.matchAll(/import\s+([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+)['"]/g)) imports[m[1]] = m[2]
|
|
|
+ for (const m of stripped.matchAll(/import\s*\{([^}]*)\}\s+from\s+['"]([^'"]+)['"]/g)) {
|
|
|
+ for (const one of m[1].split(',')) {
|
|
|
+ const nm = one.split(/\s+as\s+/).pop().trim()
|
|
|
+ if (nm) imports[nm] = m[2]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ for (const m of stripped.matchAll(/Vue\.prototype\.([A-Za-z_$][\w$]*)\s*=/g)) set.add(m[1])
|
|
|
+ for (const m of stripped.matchAll(/Vue\.mixin\(\s*([A-Za-z_$][\w$]*)\s*\)/g)) {
|
|
|
+ const id = m[1]
|
|
|
+ if (imports[id]) {
|
|
|
+ const spec = imports[id]
|
|
|
+ let p = spec.startsWith('@/') ? path.resolve(ROOT, spec.slice(2)) : path.resolve(ROOT, spec)
|
|
|
+ if (!/\.[a-zA-Z0-9]+$/.test(p)) p = p + '.js'
|
|
|
+ if (fs.existsSync(p)) {
|
|
|
+ const modRaw = evalModuleFile(p)
|
|
|
+ const mod = modRaw && (modRaw.default || modRaw)
|
|
|
+ if (mod) {
|
|
|
+ if (typeof mod.data === 'function') for (const k of callData(mod.data)) set.add(k)
|
|
|
+ if (mod.computed) for (const k of Object.keys(mod.computed)) set.add(k)
|
|
|
+ if (mod.methods) for (const k of Object.keys(mod.methods)) set.add(k)
|
|
|
+ if (mod.props) for (const k of (Array.isArray(mod.props) ? mod.props : Object.keys(mod.props))) set.add(k)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ GLOBAL_NAMES = set
|
|
|
+ return set
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------- 模板标识符采集 ----------------
|
|
|
+const GLOBALS = new Set([
|
|
|
+ 'Math','JSON','Date','Object','Array','String','Number','Boolean','RegExp','Promise','Symbol','Map','Set','WeakMap','WeakSet',
|
|
|
+ 'BigInt','Proxy','Reflect','Error','TypeError','RangeError','EvalError','SyntaxError','ReferenceError','URIError','Function',
|
|
|
+ 'parseInt','parseFloat','isNaN','isFinite','encodeURI','decodeURI','encodeURIComponent','decodeURIComponent',
|
|
|
+ 'escape','unescape','setTimeout','clearTimeout','setInterval','clearInterval','queueMicrotask',
|
|
|
+ 'NaN','Infinity','undefined','globalThis','console','Intl','URL','URLSearchParams',
|
|
|
+ 'Blob','File','FileReader','TextEncoder','TextDecoder','AbortController','AbortSignal',
|
|
|
+ 'XMLHttpRequest','WebSocket','fetch','performance',
|
|
|
+ 'getApp','getCurrentPages','requirePlugin','requireMiniProgram','requireNativePlugin',
|
|
|
+ 'uni','wx','tt','swan','my'
|
|
|
+])
|
|
|
+const VUE_INTERNALS = new Set(['_c','_v','_s','_l','_t','_e','_m','_f','_k','_q','_i','_n','_o','_p','_u','_d','_b','_g','_vm'])
|
|
|
+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'])
|
|
|
+
|
|
|
+function collectTemplateIdentifiers(renderSrc) {
|
|
|
+ const accessed = new Set()
|
|
|
+ const target = {}
|
|
|
+ for (const k of VUE_INTERNALS) target[k] = DUMMY
|
|
|
+ for (const k of INSTANCE_PROPS) target[k] = DUMMY
|
|
|
+ const handler = {
|
|
|
+ has(t, key) {
|
|
|
+ if (typeof key === 'string') {
|
|
|
+ accessed.add(key)
|
|
|
+ if (key in t) return true
|
|
|
+ if (key in globalThis) return false
|
|
|
+ return true
|
|
|
+ }
|
|
|
+ return key in t
|
|
|
+ },
|
|
|
+ get(t, key) {
|
|
|
+ if (key === Symbol.unscopables) return undefined
|
|
|
+ if (key in t) return t[key]
|
|
|
+ if (typeof key === 'string' && key in globalThis) return globalThis[key]
|
|
|
+ return DUMMY
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const proxy = new Proxy(target, handler)
|
|
|
+ try {
|
|
|
+ const fn = new Function(renderSrc)
|
|
|
+ fn.call(proxy)
|
|
|
+ } catch (e) { }
|
|
|
+ return accessed
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------- options 重复顶层键(@babel/parser AST) ----------------
|
|
|
+let parser = null
|
|
|
+try { parser = require(path.join(ROOT, 'node_modules/@babel/parser')) } catch (e) { parser = null }
|
|
|
+
|
|
|
+function duplicateTopKeys(code) {
|
|
|
+ if (!parser) return null // 基础设施缺失,调用方自行降级
|
|
|
+ const findings = []
|
|
|
+ try {
|
|
|
+ const ast = parser.parse(code, { sourceType: 'module', plugins: [] })
|
|
|
+ let decl = null
|
|
|
+ for (const item of ast.program.body) {
|
|
|
+ if (item.type === 'ExportDefaultDeclaration') { decl = item; break }
|
|
|
+ }
|
|
|
+ if (!decl) return findings
|
|
|
+ const obj = decl.declaration
|
|
|
+ if (!obj || obj.type !== 'ObjectExpression') return findings
|
|
|
+ const seen = new Map()
|
|
|
+ for (const prop of obj.properties) {
|
|
|
+ if (!prop || !prop.key) continue
|
|
|
+ let name = null
|
|
|
+ if (prop.type === 'ObjectProperty' || prop.type === 'ObjectMethod') {
|
|
|
+ if (prop.key.type === 'Identifier') name = prop.key.name
|
|
|
+ else if (prop.key.type === 'StringLiteral' || prop.key.type === 'Literal') name = String(prop.key.value)
|
|
|
+ }
|
|
|
+ if (name) {
|
|
|
+ if (seen.has(name)) findings.push({ name, firstLine: seen.get(name), secondLine: prop.loc.start.line })
|
|
|
+ else seen.set(name, prop.loc.start.line)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (e) { return findings }
|
|
|
+ return findings
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------- 主流程 ----------------
|
|
|
+function collect(dir, out) {
|
|
|
+ out = out || []
|
|
|
+ let entries = []
|
|
|
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch (e) { return out }
|
|
|
+ for (const e of entries) {
|
|
|
+ const p = path.join(dir, e.name)
|
|
|
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) collect(p, out) }
|
|
|
+ else if (e.name.endsWith('.vue')) out.push(p)
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+/** 检查单个 .vue 文件,返回 { missing: [], dupKeys: [], evalFailed: bool } */
|
|
|
+function checkVueFile(abs) {
|
|
|
+ const src = fs.readFileSync(abs, 'utf8')
|
|
|
+ const tm = src.match(/^<template[^>]*>([\s\S]*?)^<\/template>/m)
|
|
|
+ const sm = src.match(/^<script([^>]*)>([\s\S]*?)^<\/script>/m)
|
|
|
+ if (!sm) return { missing: [], dupKeys: [], evalFailed: false }
|
|
|
+ if (/lang\s*=\s*['"]ts/.test(sm[1])) return { missing: [], dupKeys: [], evalFailed: false }
|
|
|
+
|
|
|
+ // 重复顶层键只依赖 script 块,无模板也能查
|
|
|
+ const dupKeys = duplicateTopKeys(sm[2]) || []
|
|
|
+
|
|
|
+ if (!tm) return { missing: [], dupKeys, evalFailed: false }
|
|
|
+
|
|
|
+ const options = evalVueScript(sm[2], path.dirname(abs))
|
|
|
+ if (options == null || typeof options !== 'object') return { missing: [], dupKeys, evalFailed: true }
|
|
|
+ if (!compiler) return { missing: [], dupKeys, evalFailed: false }
|
|
|
+
|
|
|
+ const comp = compiler.compile(tm[1])
|
|
|
+ if (comp.errors && comp.errors.length) return { missing: [], dupKeys, evalFailed: false }
|
|
|
+ const gSet = globalNames()
|
|
|
+ const def = definedNames(options)
|
|
|
+ for (const k of gSet) def.add(k)
|
|
|
+
|
|
|
+ const accessed = collectTemplateIdentifiers(comp.render)
|
|
|
+ const missing = []
|
|
|
+ for (const name of accessed) {
|
|
|
+ if (def.has(name)) continue
|
|
|
+ if (GLOBALS.has(name)) continue
|
|
|
+ if (VUE_INTERNALS.has(name) || INSTANCE_PROPS.has(name)) continue
|
|
|
+ if (name.startsWith('_') || name.startsWith('$')) continue
|
|
|
+ if (name === 'this') continue
|
|
|
+ if (name.length <= 1) continue
|
|
|
+ missing.push(name)
|
|
|
+ }
|
|
|
+ return { missing: [...new Set(missing)], dupKeys, evalFailed: false }
|
|
|
+}
|
|
|
+
|
|
|
+// ---------------- 灵敏度自测 ----------------
|
|
|
+function selfTest() {
|
|
|
+ let pass = true
|
|
|
+ const log = []
|
|
|
+ // 用例1:模板引用未定义属性,应命中
|
|
|
+ const tpl1 = '<view v-if="notDefinedProp">{{ ok }}</view>'
|
|
|
+ const script1 = 'export default { data() { return { ok: 1 } }, methods: { go() {} } }'
|
|
|
+ const comp1 = compiler ? compiler.compile(tpl1) : null
|
|
|
+ const def1 = definedNames(evalVueScript(script1, __dirname))
|
|
|
+ const acc1 = comp1 ? collectTemplateIdentifiers(comp1.render) : new Set()
|
|
|
+ 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)
|
|
|
+ const ok1 = miss1.includes('notDefinedProp')
|
|
|
+ log.push((ok1 ? 'PASS' : 'FAIL') + ' 模板未定义属性应命中 notDefinedProp(实际: ' + miss1.join(',') + ')')
|
|
|
+ if (!ok1) pass = false
|
|
|
+
|
|
|
+ // 用例2:全部已定义,应零命中
|
|
|
+ const tpl2 = '<view v-if="ok">{{ txt }}</view>'
|
|
|
+ const script2 = 'export default { data() { return { ok: true, txt: "a" } } }'
|
|
|
+ const comp2 = compiler ? compiler.compile(tpl2) : null
|
|
|
+ const def2 = definedNames(evalVueScript(script2, __dirname))
|
|
|
+ const acc2 = comp2 ? collectTemplateIdentifiers(comp2.render) : new Set()
|
|
|
+ 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)
|
|
|
+ log.push((miss2.length === 0 ? 'PASS' : 'FAIL') + ' 已定义属性应零命中(实际: ' + miss2.join(',') + ')')
|
|
|
+ if (miss2.length !== 0) pass = false
|
|
|
+
|
|
|
+ // 用例3:重复顶层键,应命中
|
|
|
+ const script3 = 'export default {\n mixins: [a],\n components: {},\n mixins: [b]\n}'
|
|
|
+ const dup3 = duplicateTopKeys(script3) || []
|
|
|
+ const ok3 = dup3.some(d => d.name === 'mixins')
|
|
|
+ log.push((ok3 ? 'PASS' : 'FAIL') + ' 重复顶层键应命中 mixins(实际: ' + dup3.map(d => d.name).join(',') + ')')
|
|
|
+ if (!ok3) pass = false
|
|
|
+
|
|
|
+ // 用例4:无重复键,应零命中
|
|
|
+ const script4 = 'export default { data() { return {} }, methods: {} }'
|
|
|
+ const dup4 = duplicateTopKeys(script4) || []
|
|
|
+ log.push((dup4.length === 0 ? 'PASS' : 'FAIL') + ' 无重复键应零命中(实际: ' + dup4.map(d => d.name).join(',') + ')')
|
|
|
+ if (dup4.length !== 0) pass = false
|
|
|
+
|
|
|
+ console.log(log.join('\n'))
|
|
|
+ return pass ? 0 : 1
|
|
|
+}
|
|
|
+
|
|
|
+function main() {
|
|
|
+ if (SELF_TEST) { process.exit(selfTest()) }
|
|
|
+ if (!compiler) {
|
|
|
+ console.error('[FATAL] vue-template-compiler 不可用,无法扫描。请确认 node_modules 已安装。')
|
|
|
+ process.exit(2)
|
|
|
+ }
|
|
|
+
|
|
|
+ const files = ARGS.length
|
|
|
+ ? ARGS.map(a => path.isAbsolute(a) ? a : path.resolve(ROOT, a))
|
|
|
+ : collect(ROOT)
|
|
|
+
|
|
|
+ const missingFindings = [] // { file, missing: [] }
|
|
|
+ const dupFindings = [] // { file, name, firstLine, secondLine }
|
|
|
+ const evalFailed = []
|
|
|
+ for (const abs of files) {
|
|
|
+ const rel = path.relative(ROOT, abs).split(path.sep).join('/')
|
|
|
+ if (SKIP_FILES.has(rel)) continue
|
|
|
+ if (!fs.existsSync(abs)) { console.log(' [missing]', abs); continue }
|
|
|
+ const res = checkVueFile(abs)
|
|
|
+ for (const d of res.dupKeys) dupFindings.push({ file: rel, ...d })
|
|
|
+ if (res.evalFailed) { evalFailed.push(rel); continue }
|
|
|
+ if (res.missing.length) missingFindings.push({ file: rel, missing: res.missing })
|
|
|
+ }
|
|
|
+ const totalMiss = missingFindings.reduce((n, f) => n + f.missing.length, 0)
|
|
|
+ const out = []
|
|
|
+ out.push('扫描文件: ' + files.length + ' | 求值失败: ' + evalFailed.length + ' | 未定义属性命中: ' + totalMiss + ' | 重复顶层键命中: ' + dupFindings.length)
|
|
|
+ out.push('全局注入名: ' + [...globalNames()].join(', '))
|
|
|
+
|
|
|
+ if (evalFailed.length) {
|
|
|
+ out.push('\n[!] 脚本求值失败(未检查,需人工确认):')
|
|
|
+ for (const f of evalFailed) out.push(' ' + f)
|
|
|
+ }
|
|
|
+ if (missingFindings.length) {
|
|
|
+ out.push('\n=== 未定义实例属性(模板引用) ===')
|
|
|
+ for (const f of missingFindings) out.push(' ' + f.file + ' -> ' + f.missing.join(', '))
|
|
|
+ }
|
|
|
+ if (dupFindings.length) {
|
|
|
+ out.push('\n=== options 重复顶层键(后者覆盖前者) ===')
|
|
|
+ for (const f of dupFindings) out.push(' ' + f.file + ' -> ' + f.name + ' (L' + f.firstLine + ' / L' + f.secondLine + ')')
|
|
|
+ }
|
|
|
+
|
|
|
+ console.log(out.join('\n'))
|
|
|
+ if (JSON_OUT) {
|
|
|
+ console.log('\n---JSON---')
|
|
|
+ console.log(JSON.stringify({ missing: missingFindings, dupKeys: dupFindings, evalFailed }, null, 2))
|
|
|
+ }
|
|
|
+
|
|
|
+ const hasError = missingFindings.length > 0 || dupFindings.length > 0
|
|
|
+ // 求值失败 = 该文件未被检查,按 ERROR 对待(需人工确认),避免静默漏检
|
|
|
+ process.exit(hasError || evalFailed.length > 0 ? 1 : 0)
|
|
|
+}
|
|
|
+
|
|
|
+if (require.main === module) main()
|
|
|
+module.exports = { stripComments, transformESM, evalModuleFile, evalVueScript, definedNames, collectTemplateIdentifiers, globalNames, ROOT, compiler, modCache, DUMMY, duplicateTopKeys, checkVueFile }
|