| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558 |
- #!/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 }
|