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:
+17
-3
@@ -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
@@ -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()
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 = []) {
|
||||
|
||||
Reference in New Issue
Block a user