#!/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 文件只保留