Files
t/tools/check-cloud-methods.js
T
root 3117146281 feat: 打通 HBuilderX CLI 构建 + 修复文章闭环静态盲区
构建:
- 新增 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 构建失败
2026-09-12 01:02:45 +08:00

410 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 }