构建: - 新增 tools/build.js:junction HBuilderX 工具链,CLI 构建 h5/mp-weixin - vue 指向补丁版 @dcloudio/uni-h5-vue(官方 npm vue 不导出 isInSSRComponentSetup) - 设 HX_APP_ROOT 避免退化成 H5 空壳产物;产物完整性校验 校验工具: - 新增 check-cloud-methods.js:acorn 解析云对象方法,比对 94 处调用点 - 新增 check-android-contract.js:Kotlin 侧云对象契约校验 - audit-project.js 修 downloadFile 误报(注释未剥离);tools/ 排除出扫描 - package.json 声明此前隐式依赖的 acorn 功能: - 补 uni-cms-articles.getPublishedArticles(安卓端依赖但此前不存在) - 修 u-parse <audio> 引用已移除组件导致 H5 构建失败
410 lines
14 KiB
JavaScript
410 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict'
|
||
|
||
/**
|
||
* 云对象方法存在性校验。
|
||
*
|
||
* tools/audit-project.js 的 sectionCloudCalls 只校验 importObject 引用的
|
||
* 「云函数文件」是否存在,不校验被调用的「方法名」是否存在。
|
||
* 方法名写错在静态审计里 0 报错,只在运行时才炸 "xxx is not a function"。
|
||
*
|
||
* 本脚本补齐这一层:
|
||
* 1. 用 acorn 解析每个 index.obj.js,取 module.exports 对象里的顶层方法名
|
||
* 2. 从页面/组件里找 importObject('x') 的变量绑定与直接链式调用
|
||
* 3. 交叉比对,报告不存在的方法
|
||
*
|
||
* 退出码非 0 表示发现不存在的方法。
|
||
*/
|
||
|
||
const fs = require('fs')
|
||
const path = require('path')
|
||
const acorn = require('acorn')
|
||
|
||
const ROOT = path.resolve(__dirname, '..')
|
||
const CF_DIR = path.join(ROOT, 'uniCloud-alipay', 'cloudfunctions')
|
||
|
||
const SCAN_DIRS = ['pages', 'pages2', 'pages3', 'components', 'common', 'js_sdk', 'uni_modules']
|
||
const SKIP_DIRS = new Set(['node_modules', 'unpackage', '.git', '.zcode', 'uni_modules_bak'])
|
||
|
||
// uni-id-co / uni-captcha-co / uni-pay-co / uni-media-library-co 等随插件分发的
|
||
// 云对象不在本项目 cloudfunctions 下,从其自身的 uniCloud 目录里发现。
|
||
const VENDOR_OBJECT_ROOTS = ['uni_modules', 'pages3/uni_modules']
|
||
|
||
// 云对象内部预处理器,不是业务方法,前端不应调用。
|
||
const INTERNAL_METHODS = new Set(['_before', '_after'])
|
||
|
||
// ── 1. 提取云对象方法(AST) ─────────────────────────────────
|
||
|
||
function parseModule(src, file) {
|
||
try {
|
||
return acorn.parse(src, {
|
||
ecmaVersion: 2022,
|
||
sourceType: 'script',
|
||
allowHashBang: true,
|
||
allowAwaitOutsideFunction: true,
|
||
allowReturnOutsideFunction: true
|
||
})
|
||
} catch (e) {
|
||
return { __parseError: `${file}: ${e.message}` }
|
||
}
|
||
}
|
||
|
||
function walkAst(node, visit) {
|
||
if (!node || typeof node !== 'object') return
|
||
if (Array.isArray(node)) {
|
||
for (const n of node) walkAst(n, visit)
|
||
return
|
||
}
|
||
if (typeof node.type === 'string') visit(node)
|
||
for (const key of Object.keys(node)) {
|
||
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc') continue
|
||
walkAst(node[key], visit)
|
||
}
|
||
}
|
||
|
||
function isModuleExports(node) {
|
||
return (
|
||
node &&
|
||
node.type === 'MemberExpression' &&
|
||
!node.computed &&
|
||
node.object && node.object.type === 'Identifier' && node.object.name === 'module' &&
|
||
node.property && node.property.type === 'Identifier' && node.property.name === 'exports'
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 解析 `require('./x')` 的目标文件,支持省略 .js 与目录 index.js。
|
||
*/
|
||
function resolveLocalRequire(fromFile, req) {
|
||
if (typeof req !== 'string' || !req.startsWith('.')) return null
|
||
const base = path.resolve(path.dirname(fromFile), req)
|
||
const candidates = [base, base + '.js', base + '.json', path.join(base, 'index.js')]
|
||
for (const c of candidates) {
|
||
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 返回 { methods:Set, parseError }。
|
||
*
|
||
* 支持两种导出形态:
|
||
* 1. module.exports = { name(...) {}, ... }
|
||
* 2. module.exports = require('./functions') ← 常见于分包组织的云对象
|
||
* 形态 2 会顺着 require 链再解析一层(递归,带深度上限防环)。
|
||
*/
|
||
function extractMethodsFromSource(src, file, depth = 0) {
|
||
const ast = parseModule(src, file)
|
||
if (ast && ast.__parseError) return { methods: new Set(), parseError: ast.__parseError }
|
||
|
||
const methods = new Set()
|
||
const requires = []
|
||
let sawObjectExport = false
|
||
|
||
walkAst(ast, (node) => {
|
||
if (node.type !== 'AssignmentExpression') return
|
||
if (!isModuleExports(node.left)) return
|
||
const right = node.right
|
||
if (right && right.type === 'ObjectExpression') {
|
||
sawObjectExport = true
|
||
for (const prop of right.properties) {
|
||
if (prop.type !== 'Property' || !prop.key) continue
|
||
const name = prop.key.type === 'Identifier'
|
||
? prop.key.name
|
||
: (prop.key.type === 'Literal' ? String(prop.key.value) : null)
|
||
if (name) methods.add(name)
|
||
}
|
||
} else if (right && right.type === 'CallExpression' &&
|
||
right.callee && right.callee.type === 'Identifier' && right.callee.name === 'require') {
|
||
const arg = right.arguments && right.arguments[0]
|
||
if (arg && arg.type === 'Literal') requires.push(String(arg.value))
|
||
}
|
||
})
|
||
|
||
// 顺着 require 链解析一层(最多 3 层,防循环引用)
|
||
if (!sawObjectExport && depth < 3) {
|
||
for (const req of requires) {
|
||
const target = resolveLocalRequire(file, req)
|
||
if (!target) continue
|
||
const sub = fs.readFileSync(target, 'utf8')
|
||
const r = extractMethodsFromSource(sub, target, depth + 1)
|
||
for (const name of r.methods) methods.add(name)
|
||
}
|
||
}
|
||
|
||
return { methods, parseError: null }
|
||
}
|
||
|
||
function findVendorObjectFiles() {
|
||
const found = new Map()
|
||
for (const root of VENDOR_OBJECT_ROOTS) {
|
||
const abs = path.join(ROOT, root)
|
||
if (!fs.existsSync(abs)) continue
|
||
const stack = [abs]
|
||
while (stack.length) {
|
||
const dir = stack.pop()
|
||
let entries
|
||
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { continue }
|
||
for (const e of entries) {
|
||
if (e.isDirectory()) {
|
||
if (SKIP_DIRS.has(e.name)) continue
|
||
stack.push(path.join(dir, e.name))
|
||
} else if (e.name === 'index.obj.js') {
|
||
found.set(path.basename(dir), path.join(dir, e.name))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return found
|
||
}
|
||
|
||
function collectCloudObjects() {
|
||
const files = []
|
||
if (fs.existsSync(CF_DIR)) {
|
||
for (const name of fs.readdirSync(CF_DIR)) {
|
||
const file = path.join(CF_DIR, name, 'index.obj.js')
|
||
if (fs.existsSync(file)) files.push([name, file])
|
||
}
|
||
}
|
||
const seen = new Set(files.map(([n]) => n))
|
||
for (const [name, file] of findVendorObjectFiles()) {
|
||
if (seen.has(name)) continue
|
||
seen.add(name)
|
||
files.push([name, file])
|
||
}
|
||
|
||
const out = new Map()
|
||
const parseErrors = []
|
||
for (const [name, file] of files) {
|
||
const src = fs.readFileSync(file, 'utf8')
|
||
const { methods, parseError } = extractMethodsFromSource(src, file)
|
||
if (parseError) parseErrors.push(parseError)
|
||
out.set(name, {
|
||
file: path.relative(ROOT, file).replace(/\\/g, '/'),
|
||
methods,
|
||
parseError
|
||
})
|
||
}
|
||
return { cloudObjects: out, parseErrors }
|
||
}
|
||
|
||
// ── 2. 扫描前端调用点 ────────────────────────────────────────
|
||
|
||
function walk(dir, acc) {
|
||
let entries
|
||
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return acc }
|
||
for (const e of entries) {
|
||
if (SKIP_DIRS.has(e.name)) continue
|
||
const p = path.join(dir, e.name)
|
||
if (e.isDirectory()) walk(p, acc)
|
||
else if (/\.(vue|nvue|js)$/.test(e.name)) acc.push(p)
|
||
}
|
||
return acc
|
||
}
|
||
|
||
const relOf = (p) => path.relative(ROOT, p).replace(/\\/g, '/')
|
||
|
||
/**
|
||
* .vue 文件只保留 <script> 块,避免把模板/样式里的文本当成调用。
|
||
* 返回 { code, offset },offset 为 script 内容在原文中的起始下标,
|
||
* 用于把匹配下标换算回原文行号。
|
||
*/
|
||
function scriptOnly(src, file) {
|
||
if (!/\.(vue|nvue)$/.test(file)) return { code: src, offset: 0 }
|
||
const m = /<script[^>]*>([\s\S]*?)<\/script>/.exec(src)
|
||
if (!m) return { code: '', offset: 0 }
|
||
const offset = m.index + m[0].indexOf('>') + 1
|
||
return { code: m[1], offset }
|
||
}
|
||
|
||
// 变量绑定:const/let/var X = uniCloud.importObject('obj'[, opts])
|
||
const BIND_RE = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*uniCloud\s*\.\s*importObject\s*\(\s*['"]([\w-]+)['"]/g
|
||
// 直接链式:uniCloud.importObject('obj'[, opts]).method(
|
||
const DIRECT_RE = /uniCloud\s*\.\s*importObject\s*\(\s*['"]([\w-]+)['"][^)]*\)\s*\.\s*([A-Za-z_$][\w$]*)\s*\(/g
|
||
|
||
// 非云对象方法的链式调用,命中即忽略
|
||
const IGNORED_MEMBERS = new Set(['then', 'catch', 'finally'])
|
||
|
||
function scanCalls(files) {
|
||
const calls = []
|
||
for (const file of files) {
|
||
let src
|
||
try { src = fs.readFileSync(file, 'utf8') } catch { continue }
|
||
const { code, offset } = scriptOnly(src, file)
|
||
if (!code) continue
|
||
|
||
const lineOf = (idx) => src.slice(0, offset + idx).split('\n').length
|
||
|
||
const bindings = new Map()
|
||
let m
|
||
BIND_RE.lastIndex = 0
|
||
while ((m = BIND_RE.exec(code))) bindings.set(m[1], m[2])
|
||
|
||
for (const [varName, objName] of bindings) {
|
||
const callRe = new RegExp(`\\b${varName}\\s*\\.\\s*([A-Za-z_$][\\w$]*)\\s*\\(`, 'g')
|
||
let c
|
||
while ((c = callRe.exec(code))) {
|
||
if (IGNORED_MEMBERS.has(c[1])) continue
|
||
calls.push({ file: relOf(file), line: lineOf(c.index), obj: objName, method: c[1], form: 'binding' })
|
||
}
|
||
}
|
||
|
||
DIRECT_RE.lastIndex = 0
|
||
let d
|
||
while ((d = DIRECT_RE.exec(code))) {
|
||
if (IGNORED_MEMBERS.has(d[2])) continue
|
||
calls.push({ file: relOf(file), line: lineOf(d.index), obj: d[1], method: d[2], form: 'direct' })
|
||
}
|
||
}
|
||
return calls
|
||
}
|
||
|
||
// ── 3. 交叉比对 ─────────────────────────────────────────────
|
||
|
||
function run(opts = {}) {
|
||
const { cloudObjects, parseErrors } = collectCloudObjects()
|
||
const files = SCAN_DIRS.reduce((acc, d) => walk(path.join(ROOT, d), acc), [])
|
||
const calls = scanCalls(files)
|
||
|
||
const errors = []
|
||
const warnings = []
|
||
let checked = 0
|
||
|
||
for (const call of calls) {
|
||
const decl = cloudObjects.get(call.obj)
|
||
if (!decl) {
|
||
errors.push({ ...call, msg: `云对象 ${call.obj} 未找到 index.obj.js` })
|
||
continue
|
||
}
|
||
if (decl.parseError) {
|
||
warnings.push({ file: decl.file, msg: `解析失败,跳过方法校验:${decl.parseError}` })
|
||
continue
|
||
}
|
||
checked++
|
||
if (!decl.methods.has(call.method)) {
|
||
errors.push({ ...call, msg: `${call.obj}.${call.method}() 未在 ${decl.file} 中定义` })
|
||
}
|
||
}
|
||
|
||
// 自检页硬编码的方法名(obj[method] 之外的部分)
|
||
const testPage = path.join(ROOT, 'pages', 'test', 'test.vue')
|
||
if (fs.existsSync(testPage)) {
|
||
const tsrc = fs.readFileSync(testPage, 'utf8')
|
||
const hardRe = /this\.checkCloudFn\(\s*['"]([\w-]+)['"]\s*,\s*['"](\w+)['"]/g
|
||
let h
|
||
while ((h = hardRe.exec(tsrc))) {
|
||
const decl = cloudObjects.get(h[1])
|
||
const line = tsrc.slice(0, h.index).split('\n').length
|
||
if (!decl) {
|
||
errors.push({ file: 'pages/test/test.vue', line, obj: h[1], method: h[2], msg: `自检页引用了不存在的云对象 ${h[1]}` })
|
||
} else if (!decl.methods.has(h[2])) {
|
||
errors.push({ file: 'pages/test/test.vue', line, obj: h[1], method: h[2], msg: `自检页调用了未定义方法 ${h[1]}.${h[2]}()` })
|
||
}
|
||
}
|
||
if (/obj\[method\]/.test(tsrc)) {
|
||
warnings.push({ file: 'pages/test/test.vue', msg: '自检页使用 obj[method] 动态调用,方法名无法静态校验(上面已单独校验硬编码部分)' })
|
||
}
|
||
}
|
||
|
||
for (const pe of parseErrors) warnings.push({ file: pe.split(':')[0], msg: pe })
|
||
|
||
const result = {
|
||
ok: errors.length === 0,
|
||
cloudObjectCount: cloudObjects.size,
|
||
callSiteCount: calls.length,
|
||
checkedCount: checked,
|
||
errors,
|
||
warnings
|
||
}
|
||
|
||
if (!opts.json) {
|
||
console.log('云对象方法存在性校验')
|
||
console.log(` 云对象 ${result.cloudObjectCount} 个 · 调用点 ${result.callSiteCount} 处 · 已校验 ${result.checkedCount} 处`)
|
||
if (errors.length) {
|
||
console.log(`\n✗ 发现 ${errors.length} 个问题:`)
|
||
for (const e of errors) console.log(` ✗ ${e.file}:${e.line || '?'} ${e.msg}`)
|
||
} else {
|
||
console.log('\n✓ 全部调用点的方法均存在')
|
||
}
|
||
if (warnings.length) {
|
||
console.log(`\n⚠ ${warnings.length} 条提示:`)
|
||
for (const w of warnings) console.log(` ▸ ${w.file} — ${w.msg}`)
|
||
}
|
||
console.log(`\n结论: ${errors.length} 错误 / ${warnings.length} 警告`)
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// ── 4. 检查器自测 ───────────────────────────────────────────
|
||
|
||
/**
|
||
* 用内联样本验证提取逻辑本身正确,避免「检查器坏了但报 0 错误」。
|
||
* 覆盖三种导出形态:对象字面量、默认对象参数、require 转出。
|
||
*/
|
||
function selftest() {
|
||
const fails = []
|
||
|
||
const cases = [
|
||
{
|
||
label: '对象字面量',
|
||
src: `module.exports = {\n async foo(a) { return a },\n bar: function (b) { return b },\n baz: async (c) => c\n}`,
|
||
expect: ['foo', 'bar', 'baz']
|
||
},
|
||
{
|
||
label: '默认对象参数不干扰',
|
||
src: `module.exports = {\n _before: function () {},\n async del(data = {}) { return data },\n async after() {}\n}`,
|
||
expect: ['_before', 'del', 'after']
|
||
},
|
||
{
|
||
label: '注释里的假方法不算',
|
||
src: `module.exports = {\n // async ghost() {},\n /* async phantom() {} */\n real() { return 1 }\n}`,
|
||
expect: ['real'],
|
||
notExpect: ['ghost', 'phantom']
|
||
},
|
||
{
|
||
label: '字符串里的花括号不破坏解析',
|
||
src: `module.exports = {\n pick(data = {}) { return data = { a: '{' } },\n next() {}\n}`,
|
||
expect: ['pick', 'next']
|
||
}
|
||
]
|
||
|
||
const tmp = path.join(ROOT, '.zcode', '_selftest_obj.js')
|
||
fs.mkdirSync(path.dirname(tmp), { recursive: true })
|
||
|
||
for (const c of cases) {
|
||
fs.writeFileSync(tmp, c.src, 'utf8')
|
||
const { methods, parseError } = extractMethodsFromSource(c.src, tmp)
|
||
if (parseError) { fails.push(`${c.label}: 解析失败 ${parseError}`); continue }
|
||
for (const name of c.expect) {
|
||
if (!methods.has(name)) fails.push(`${c.label}: 缺少方法 ${name}(实得 ${[...methods].join(',') || '空'})`)
|
||
}
|
||
for (const name of c.notExpect || []) {
|
||
if (methods.has(name)) fails.push(`${c.label}: 误提取 ${name}`)
|
||
}
|
||
}
|
||
|
||
try { fs.unlinkSync(tmp) } catch {}
|
||
|
||
if (fails.length) {
|
||
console.log('云对象方法校验自测')
|
||
for (const f of fails) console.log(` ✗ ${f}`)
|
||
console.log(`\n✗ ${fails.length} 项失败`)
|
||
return false
|
||
}
|
||
console.log('✓ 云对象方法校验自测通过(4 组样本)')
|
||
return true
|
||
}
|
||
|
||
if (require.main === module) {
|
||
if (process.argv.includes('--selftest')) {
|
||
process.exit(selftest() ? 0 : 1)
|
||
}
|
||
const json = process.argv.includes('--json')
|
||
const r = run({ json })
|
||
if (json) console.log(JSON.stringify(r, null, 2))
|
||
process.exit(r.ok ? 0 : 1)
|
||
}
|
||
|
||
module.exports = { run, selftest, collectCloudObjects, extractMethodsFromSource, INTERNAL_METHODS }
|