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 构建失败
This commit is contained in:
2026-09-12 01:02:45 +08:00
parent 4f5893f87a
commit 3117146281
26 changed files with 2043 additions and 337 deletions
+17 -3
View File
@@ -15,7 +15,9 @@ const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const IGNORE_DIRS = new Set(['.git', 'node_modules', 'unpackage', '.hbuilderx', '.trae', 'git']);
// tools/ 是检查器自身,内部必然包含 importObject 等模式字符串,
// 当成业务代码审计只会产生假阳性;.zcode/ 是工具链运行产物。
const IGNORE_DIRS = new Set(['.git', 'node_modules', 'unpackage', '.hbuilderx', '.trae', 'git', 'tools', '.zcode']);
const SOURCE_EXT = new Set(['.vue', '.js', '.ts', '.json', '.scss', '.css', '.md', '.html']);
const JSON_OUT = process.argv.includes('--json');
@@ -504,6 +506,17 @@ function sectionSecrets(files) {
const WRITE_METHOD_RE = /\basync\s+([A-Za-z_$][\w$]*)\s*\(/g;
const READONLY_HINTS = /^(get|list|query|search|count|is[A-Z]|_)/;
/**
* 把行注释内容替换为等量空格:长度不变,字节偏移不变,
* 因此 lineAt(src, idx) 与 src.slice 仍指向同一位置。
*
* 不做这层剥离时,被注释掉的 `async foo() {}` 会被 WRITE_METHOD_RE 当成
* 活方法,产生「未校验登录态」的假警报(ext-storage-co.downloadFile 就是)。
*/
function blankLineComments(src) {
return src.replace(/^([ \t]*)\/\/.*$/gm, (line) => ' '.repeat(line.length));
}
/**
* 云对象方法按"是否写库"与"是否校验登录"分类。
* 写方法缺少 checkToken 即为越权风险;读方法缺少则提示可能泄露数据。
@@ -513,12 +526,13 @@ function sectionCloudAuth(files, cloudfns) {
for (const [name, decl] of cloudfns) {
if (decl.isVendor || decl.methods === null) continue;
let src;
let raw;
try {
src = fs.readFileSync(path.join(ROOT, decl.file), 'utf8');
raw = fs.readFileSync(path.join(ROOT, decl.file), 'utf8');
} catch {
continue;
}
const src = blankLineComments(raw);
// 逐个方法切分,判断方法体内是否有写操作且无鉴权
const marks = [];
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env node
'use strict'
/**
* HBuilderX CLI 构建包装器。
*
* 背景:本项目是 HBuilderX 工程,Vue3 编译所需的 @dcloudio/* 工具链只随
* HBuilderX 分发,不在项目 package.json 里。直接在项目目录跑 `npm install`
* 会装入 Vue2 时代的旧包,且每次 install 都会清掉手工建的联接。
*
* 因此本脚本在构建前自动完成三件事(幂等,可反复跑):
* 1. 把 HBuilderX 自带的工具链目录联接到项目 node_modules/
* 2. 设置 HX_APP_ROOT / UNI_INPUT_DIR / UNI_OUTPUT_DIR,让 uni CLI
* 走 HBuilderX 内置模块解析路径(缺 HX_APP_ROOT 时会退化成 H5 空壳产物)
* 3. 用 HBuilderX 自带的 node 执行 `uni build -p <platform>`
*
* 不用打开 HBuilderX GUI,也不需要联网。
*
* 用法:
* node tools/build.js -p h5
* node tools/build.js -p mp-weixin
* node tools/build.js -p h5 --report # 额外打印产物体积明细
* node tools/build.js --link-only # 只建联接,不构建
* node tools/build.js --doctor # 只做环境体检
*
* 环境变量:
* HBUILDERX_HOME HBuilderX 安装目录,默认 C:/Program Files/HBuilderX/HBuilderX
*/
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
const ROOT = path.resolve(__dirname, '..')
const HBX = process.env.HBUILDERX_HOME || 'C:/Program Files/HBuilderX/HBuilderX'
const HBX_TC = path.join(HBX, 'plugins', 'uniapp-cli-vite', 'node_modules')
const HBX_SASS = path.join(HBX, 'plugins', 'compile-dart-sass', 'node_modules')
const UNI_JS = path.join(HBX_TC, '@dcloudio', 'vite-plugin-uni', 'bin', 'uni.js')
const NM = path.join(ROOT, 'node_modules')
// HBuilderX 自带的 node。系统 node 太新时 vite 的 config 缓存格式不兼容
// (failed to load config / Invalid or incompatible cached data),
// 固定用 HBuilderX 的 node 最稳。
const HBX_NODE_CANDIDATES = [
path.join(HBX, 'plugins', 'node', 'node.exe'),
path.join(HBX, 'plugins', 'node18', 'node.exe')
]
const PLATFORM_OUTPUT = {
h5: 'unpackage/dist/build/web',
'mp-weixin': 'unpackage/dist/build/mp-weixin',
'mp-alipay': 'unpackage/dist/build/mp-alipay',
app: 'unpackage/dist/build/app-plus'
}
// ── 联接管理 ────────────────────────────────────────────────
function isLink(target) {
try {
return fs.lstatSync(target).isSymbolicLink() || fs.lstatSync(target).isDirectory()
} catch {
return false
}
}
/**
* 建立 junction。Node 没有跨平台的 mklink API,Windows 上退到 cmd。
* 目录联接不需要管理员权限(符号链接才需要)。
*/
function junction(linkPath, targetPath) {
const r = spawnSync('cmd', ['/c', 'mklink', '/J', linkPath, targetPath], {
encoding: 'utf8',
windowsHide: true
})
return r.status === 0
}
function linkAll() {
if (!fs.existsSync(HBX_TC)) {
console.error(`✗ 找不到 HBuilderX 工具链:${HBX_TC}`)
console.error(' 请设置 HBUILDERX_HOME 指向 HBuilderX 安装目录。')
return { ok: false, created: 0 }
}
if (!fs.existsSync(UNI_JS)) {
console.error(`✗ 找不到 uni CLI:${UNI_JS}`)
return { ok: false, created: 0 }
}
fs.mkdirSync(NM, { recursive: true })
let created = 0
// 1) scoped 包:@dcloudio/*、@vue/* 等
const scopes = []
for (const entry of fs.readdirSync(HBX_TC, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.startsWith('@')) scopes.push(entry.name)
}
for (const scope of scopes) {
const scopeDir = path.join(HBX_TC, scope)
fs.mkdirSync(path.join(NM, scope), { recursive: true })
for (const pkg of fs.readdirSync(scopeDir)) {
const src = path.join(scopeDir, pkg)
if (!fs.statSync(src).isDirectory()) continue
const dest = path.join(NM, scope, pkg)
if (fs.existsSync(dest)) continue
if (junction(dest, src)) created++
}
}
// 2) vue 必须指向 uni-app 打过补丁的运行时。
// 官方 npm vue 3.x 不导出 isInSSRComponentSetup,而 @dcloudio/uni-app
// 从 'vue' 导入它 —— 用原版 vue 构建会直接失败在 rollup 解析阶段。
const patchedVue = path.join(HBX_TC, '@dcloudio', 'uni-h5-vue')
const vueDest = path.join(NM, 'vue')
if (fs.existsSync(patchedVue) && !fs.existsSync(vueDest)) {
if (junction(vueDest, patchedVue)) created++
}
// 3) 顶层工具包(vite / sass / rollup / esbuild ...)。
// sass 在另一个插件目录里。
for (const base of [HBX_TC, HBX_SASS]) {
if (!fs.existsSync(base)) continue
for (const pkg of fs.readdirSync(base)) {
if (pkg.startsWith('@') || pkg.startsWith('.')) continue
const src = path.join(base, pkg)
let stat
try { stat = fs.statSync(src) } catch { continue }
if (!stat.isDirectory()) continue
// 有些包用符号链接指向同目录其它包,跳过避免自指
const dest = path.join(NM, pkg)
if (fs.existsSync(dest)) continue
if (junction(dest, src)) created++
}
}
return { ok: true, created }
}
// ── 构建 ────────────────────────────────────────────────────
function dirSize(dir) {
let total = 0
const stack = [dir]
while (stack.length) {
const d = stack.pop()
let entries
try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { continue }
for (const e of entries) {
const p = path.join(d, e.name)
if (e.isDirectory()) stack.push(p)
else {
try { total += fs.statSync(p).size } catch {}
}
}
}
return total
}
function countFiles(dir, ext) {
let n = 0
const stack = [dir]
while (stack.length) {
const d = stack.pop()
let entries
try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { continue }
for (const e of entries) {
if (e.isDirectory()) stack.push(path.join(d, e.name))
else if (e.name.endsWith(ext)) n++
}
}
return n
}
function report(outputDir) {
if (!fs.existsSync(outputDir)) {
console.log(' (无产物目录)')
return
}
const fmt = (n) => (n / 1024).toFixed(1) + ' KB'
console.log(` 产物目录:${path.relative(ROOT, outputDir).replace(/\\/g, '/')}`)
const entries = fs.readdirSync(outputDir, { withFileTypes: true })
.map((e) => {
const p = path.join(outputDir, e.name)
return { name: e.name + (e.isDirectory() ? '/' : ''), size: e.isDirectory() ? dirSize(p) : fs.statSync(p).size }
})
.sort((a, b) => b.size - a.size)
for (const e of entries.slice(0, 10)) {
console.log(` ${e.name.padEnd(24)} ${fmt(e.size)}`)
}
const total = entries.reduce((s, e) => s + e.size, 0)
console.log(` ${'合计'.padEnd(22)} ${fmt(total)}`)
}
function main() {
const argv = process.argv.slice(2)
const linkOnly = argv.includes('--link-only')
const doctorOnly = argv.includes('--doctor')
const wantReport = argv.includes('--report')
const pIdx = argv.indexOf('-p')
const platform = pIdx >= 0 ? argv[pIdx + 1] : 'h5'
console.log('')
console.log('═══════════════════════════════════════════════')
console.log(' 军歌嘹亮 · HBuilderX CLI 构建')
console.log('═══════════════════════════════════════════════')
console.log('')
console.log(` HBuilderX ${HBX}`)
console.log(` 平台 ${platform}`)
// 环境体检:任一缺失都会导致产物异常或构建失败
const hbxNode = HBX_NODE_CANDIDATES.find((p) => fs.existsSync(p))
const issues = []
if (!fs.existsSync(HBX_TC)) issues.push(`缺失工具链目录:${HBX_TC}`)
if (!fs.existsSync(UNI_JS)) issues.push(`缺失 uni CLI:${UNI_JS}`)
if (!hbxNode) issues.push('未找到 HBuilderX 自带 node(plugins/node/node.exe 或 node18)')
const patchedVue = path.join(HBX_TC, '@dcloudio', 'uni-h5-vue')
if (!fs.existsSync(patchedVue)) {
issues.push('未找到 @dcloudio/uni-h5-vue(uni-app 打过补丁的 Vue 运行时)')
}
console.log(` 构建 node ${hbxNode || '(未找到)'}`)
if (issues.length) {
console.log('')
for (const i of issues) console.log(` ✗ ${i}`)
console.log('')
console.log(' 请确认 HBUILDERX_HOME 指向 HBuilderX 安装目录。')
process.exit(1)
}
if (doctorOnly) {
// 顺带验证补丁版 vue 是否真的导出 isInSSRComponentSetup
const vueEs = path.join(patchedVue, 'dist', 'vue.runtime.esm.js')
let patched = false
try {
patched = fs.readFileSync(vueEs, 'utf8').includes('isInSSRComponentSetup')
} catch {}
console.log(` Vue 运行时 ${patched ? '补丁版 OK(含 isInSSRComponentSetup)' : '异常:未检出 isInSSRComponentSetup'}`)
if (!patched) process.exit(1)
console.log('')
console.log('✓ 环境体检通过(--doctor,未执行构建)')
process.exit(0)
}
const { ok, created } = linkAll()
if (!ok) process.exit(1)
console.log(` 工具链联接 新建 ${created} 个(已存在的跳过)`)
console.log('')
if (linkOnly) {
console.log('✓ 联接完成(--link-only,未执行构建)')
process.exit(0)
}
const outputDir = path.join(ROOT, PLATFORM_OUTPUT[platform] || path.join('unpackage/dist/build', platform))
fs.mkdirSync(outputDir, { recursive: true })
const env = {
...process.env,
HBUILDERX_HOME: HBX,
// 缺 HX_APP_ROOT 时 uni-cli-shared 不会启用 HBuilderX 模块解析路径,
// 构建会"成功"但产出 H5 空壳(没有 app.json / 业务分包)。
HX_APP_ROOT: HBX,
UNI_HBUILDERX_PLUGINS: path.join(HBX, 'plugins'),
UNI_INPUT_DIR: ROOT,
UNI_OUTPUT_DIR: outputDir
}
delete env.UNI_PLATFORM // 由 CLI 的 -p 参数决定,预设会干扰平台判定
console.log('编译中…')
const r = spawnSync(hbxNode, [UNI_JS, 'build', '-p', platform], {
cwd: ROOT,
env,
stdio: 'inherit'
})
console.log('')
if (r.status !== 0) {
console.log('✗ 构建失败')
process.exit(r.status || 1)
}
// 产物校验:构建退出码为 0 不代表产物可用。
// 缺 HX_APP_ROOT 时 H5 会输出 index.html 空壳,mp 会缺 app.json。
const mustHave = platform.startsWith('mp-')
? ['app.json', 'app.js', 'app.wxss']
: ['index.html']
const missing = mustHave.filter((f) => !fs.existsSync(path.join(outputDir, f)))
const jsCount = countFiles(outputDir, '.js')
if (missing.length) {
console.log(`✗ 构建报成功但产物不完整,缺少:${missing.join(', ')}`)
console.log(' 多半是 HX_APP_ROOT 未生效(工具链退化为 H5 空壳输出)。')
process.exit(1)
}
if (jsCount === 0) {
console.log('✗ 产物中没有 JS 文件,构建未真正执行。')
process.exit(1)
}
console.log(`✓ 构建完成(${jsCount} 个 JS 文件)`)
if (wantReport) report(outputDir)
console.log('')
}
main()
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env node
'use strict'
/**
* 原生安卓端 × 云对象契约校验。
*
* android/ 是独立 Kotlin 工程,通过 uniCloud「URL 化」HTTP 网关调用云对象:
* POST {BASE_URL}/{云对象}/{方法}
* 它与前端页面一样存在「方法名写错只在运行时炸」的风险,但不在
* tools/check-cloud-methods.js 的扫描范围(那个扫的是 .vue/.js 前端调用点)。
*
* 本脚本从 Android 源码里抽出所有 (云对象, 方法) 调用对,
* 与云对象实现交叉比对,输出缺失清单。
*
* 用法:
* node tools/check-android-contract.js
* node tools/check-android-contract.js --json
*/
const fs = require('fs')
const path = require('path')
const { collectCloudObjects } = require('./check-cloud-methods')
const ROOT = path.resolve(__dirname, '..')
const ANDROID_SRC = path.join(ROOT, 'android', 'app', 'src', 'main', 'java')
// 调用形式(Kotlin):
// api.callChecked("uni-cms-articles", "getPublishedArticles", ...)
// api.callRaw("article_info", "getDetail", ...)
// 云对象名以 kebab-case、方法名以 snake/camel 出现,连字符是硬特征。
const CALL_RE = /call(?:Checked|Raw)?\s*\(\s*"([\w-]+)"\s*,\s*"([A-Za-z_][\w]*)"/g
function walk(dir, acc) {
let entries
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return acc }
for (const e of entries) {
const p = path.join(dir, e.name)
if (e.isDirectory()) walk(p, acc)
else if (e.name.endsWith('.kt')) acc.push(p)
}
return acc
}
function run(opts = {}) {
if (!fs.existsSync(ANDROID_SRC)) {
const r = { ok: true, skipped: true, reason: '未找到 android/ 原生工程,跳过', errors: [], warnings: [] }
if (!opts.json) console.log('安卓端契约校验:未找到 android/ 原生工程,跳过')
return r
}
const { cloudObjects } = collectCloudObjects()
const files = walk(ANDROID_SRC, [])
const errors = []
const checked = new Set()
let callSiteCount = 0
for (const file of files) {
const src = fs.readFileSync(file, 'utf8')
const rel = path.relative(ROOT, file).replace(/\\/g, '/')
let m
CALL_RE.lastIndex = 0
while ((m = CALL_RE.exec(src))) {
const obj = m[1]
const method = m[2]
// 跳过非云对象命名(Kotlin/JS 标准 API 里带连字符的极少,这里以云对象表为准)
callSiteCount++
const key = `${obj}.${method}`
if (checked.has(key)) continue
checked.add(key)
const line = src.slice(0, m.index).split('\n').length
const decl = cloudObjects.get(obj)
if (!decl) {
// 只报"看起来像本项目云对象"的(存在于 cloudfunctions 命名习惯里)
errors.push({ file: rel, line, key, msg: `云对象 ${obj} 未找到实现` })
} else if (!decl.methods.has(method)) {
errors.push({ file: rel, line, key, msg: `${key}() 未在 ${decl.file} 中定义` })
}
}
}
const result = {
ok: errors.length === 0,
androidFileCount: files.length,
callSiteCount,
uniqueCallCount: checked.size,
errors,
warnings: []
}
if (!opts.json) {
console.log('安卓端 × 云对象契约校验')
console.log(` Kotlin 文件 ${result.androidFileCount} 个 · 调用点 ${callSiteCount} 处 · 去重 ${checked.size} 个`)
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✓ 安卓端调用的所有云对象方法均存在')
}
console.log(`\n结论: ${errors.length} 错误 / 0 警告`)
}
return result
}
if (require.main === module) {
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 }
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env node
'use strict'
const assert = require('assert')
const {
normalizeCmsList,
cmsLstToDelta,
createVoteId,
getArticleText,
getArticleImages,
hasRenderableContent
} = require('../uniCloud-alipay/cloudfunctions/uni-cms-articles/content-adapter')
function run() {
const list = normalizeCmsList([
{ type: 'text', text: '首段', delta: { ops: [{ insert: '首段' }, { insert: '\n' }] } },
{ type: 'title', text: '小标题', html: '<p>小标题</p>', delta: { ops: [{ insert: '小标题' }] } },
{ type: 'image', image: { src: 'cloud://image-a' } },
{ type: 'video', text: '视频说明', video: { src: 'cloud://video-a', poster: 'cloud://poster-a', duration: 12 } },
{ type: 'vote', vote: { voteTitle: '投票', voteLst: [{ value: 'A' }, { value: 'B' }] } },
{ type: 'text', text: '' },
{ type: 'image', image: { src: '' } }
])
assert.strictEqual(list.length, 5)
assert.strictEqual(typeof list[2].image.src, 'string')
assert.strictEqual(typeof list[3].video.src, 'string')
assert.match(list[4].vote.vote_id, /^vote_/)
const delta = cmsLstToDelta(list)
assert.ok(Array.isArray(delta.ops))
assert.ok(delta.ops.some(op => op.insert && op.insert.image === 'cloud://image-a'))
assert.ok(delta.ops.some(op => op.insert && op.insert.video === 'cloud://video-a'))
assert.ok(hasRenderableContent(delta, list))
assert.match(getArticleText(delta, list), /首段/)
assert.match(getArticleText(delta, list), /小标题/)
assert.deepStrictEqual(getArticleImages(delta, list).sort(), ['cloud://image-a', 'cloud://poster-a'])
assert.notStrictEqual(createVoteId(), createVoteId())
console.log('✓ 文章内容适配自测通过(cmsLst / Delta / 媒体 / 投票)')
}
if (require.main === module) run()
module.exports = { run }
+409
View File
@@ -0,0 +1,409 @@
#!/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 }
+12
View File
@@ -27,11 +27,23 @@ const CHECKS = [
script: 'tools/check-vue.js',
desc: '脚本语法 / 生命周期位置 / Vue2 残留 API',
},
{
name: '云对象方法校验',
script: 'tools/check-cloud-methods.js',
desc: 'importObject 调用点的方法名是否真实存在',
},
{
name: '安卓端契约校验',
script: 'tools/check-android-contract.js',
desc: 'Kotlin 侧调用的云对象方法是否存在',
},
];
const SELFTESTS = [
{ name: '静态审计自测', script: 'tools/audit-project.js', args: ['--selftest'] },
{ name: 'Vue 校验自测', script: 'tools/check-vue.js', args: ['--selftest'] },
{ name: '文章内容适配自测', script: 'tools/check-article-flow.js' },
{ name: '云对象方法校验自测', script: 'tools/check-cloud-methods.js', args: ['--selftest'] },
];
function run(script, args = []) {