|
|
@@ -0,0 +1,234 @@
|
|
|
+/**
|
|
|
+ * 重复 API 调用审计脚本
|
|
|
+ *
|
|
|
+ * 检测三类违规(违反 AGENTS.md「API 调用去重」规范):
|
|
|
+ * A. 生命周期重复触发:同一个方法 / API 同时在 onLoad 和 onShow(或 onLoad+mounted 等
|
|
|
+ * 两个生命周期钩子)里被调用,首次进入页面时会双发。
|
|
|
+ * B. 同接口多封装:utils/api.js 里多个导出函数指向同一个后端 URL(params 不同算
|
|
|
+ * 合法变体,不算违规;纯重复封装算违规)。
|
|
|
+ * C. N+1 循环调用:在 .map / forEach / for 循环里调用 API 包装函数,N 个元素 → N 次请求。
|
|
|
+ *
|
|
|
+ * 用法:node scripts/audit-duplicate-api-calls.js [--pages-dir=pages]
|
|
|
+ * 退出码:0 = 无违规;1 = 发现违规(CI 可直接用作门禁)
|
|
|
+ */
|
|
|
+var fs = require('fs')
|
|
|
+var path = require('path')
|
|
|
+
|
|
|
+var root = path.resolve(__dirname, '..')
|
|
|
+var pagesDir = process.argv.slice(2).reduce(function (acc, a) {
|
|
|
+ var m = a.match(/^--pages-dir=(.+)$/)
|
|
|
+ return m ? m[1] : acc
|
|
|
+}, 'pages')
|
|
|
+var scanRoot = path.join(root, pagesDir)
|
|
|
+if (!fs.existsSync(scanRoot)) {
|
|
|
+ console.error('[audit] pages dir not found: ' + scanRoot)
|
|
|
+ process.exit(2)
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- 工具:递归收集 .vue ----------
|
|
|
+function walk(dir, out) {
|
|
|
+ var entries = fs.readdirSync(dir)
|
|
|
+ for (var i = 0; i < entries.length; i++) {
|
|
|
+ var p = path.join(dir, entries[i])
|
|
|
+ var st = fs.statSync(p)
|
|
|
+ if (st.isDirectory()) walk(p, out)
|
|
|
+ else if (p.slice(-4) === '.vue') out.push(p)
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- 工具:取 <script> 块 ----------
|
|
|
+function extractScript(src) {
|
|
|
+ var m = src.match(/<script[^>]*>([\s\S]*?)<\/script>/)
|
|
|
+ return m ? m[1] : ''
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- 工具:按大括号深度截取某个方法体 ----------
|
|
|
+// 在 script 文本里定位 `name(args) {`,从 `{` 开始做大括号配对,返回方法体字符串
|
|
|
+function hookBody(script, hookName) {
|
|
|
+ // 匹配 onShow / onLoad / onShow(options) / onTabItemTap(e) 等,允许前面有空格/换行
|
|
|
+ var re = new RegExp('\\b' + hookName + '\\s*\\([^)]*\\)\\s*\\{', 'g')
|
|
|
+ var m
|
|
|
+ while ((m = re.exec(script)) !== null) {
|
|
|
+ var braceStart = script.indexOf('{', m.index)
|
|
|
+ if (braceStart < 0) return null
|
|
|
+ var depth = 0
|
|
|
+ var i = braceStart
|
|
|
+ var inStr = null // ' " `
|
|
|
+ var inLineComment = false
|
|
|
+ var inBlockComment = false
|
|
|
+ for (; i < script.length; i++) {
|
|
|
+ var c = script[i]
|
|
|
+ var next = script[i + 1]
|
|
|
+ if (inLineComment) { if (c === '\n') inLineComment = false; continue }
|
|
|
+ if (inBlockComment) { if (c === '*' && next === '/') { inBlockComment = false; i++ }; continue }
|
|
|
+ if (inStr) {
|
|
|
+ if (c === '\\') { i++; continue }
|
|
|
+ if (c === inStr) inStr = null
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if (c === '/' && next === '/') { inLineComment = true; i++; continue }
|
|
|
+ if (c === '/' && next === '*') { inBlockComment = true; i++; continue }
|
|
|
+ if (c === '"' || c === "'" || c === '`') { inStr = c; continue }
|
|
|
+ if (c === '{') depth++
|
|
|
+ else if (c === '}') {
|
|
|
+ depth--
|
|
|
+ if (depth === 0) return script.slice(braceStart + 1, i)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- 工具:提取方法调用名 ----------
|
|
|
+// this.foo( -> foo ;以及裸函数调用 foo( 当作直接 API 调用候选
|
|
|
+function callsIn(body) {
|
|
|
+ var calls = []
|
|
|
+ if (!body) return calls
|
|
|
+ // this.xxx( / that.xxx( / self.xxx(
|
|
|
+ var reThis = /\b(?:this|that|self)\.([A-Za-z_$][\w$]*)\s*\(/g
|
|
|
+ var m
|
|
|
+ while ((m = reThis.exec(body)) !== null) calls.push(m[1])
|
|
|
+ return calls
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- 规则 A:生命周期双发 ----------
|
|
|
+var HOOKS = ['onLoad', 'onShow', 'onTabItemTap', 'mounted', 'created', 'beforeMount']
|
|
|
+var violations = []
|
|
|
+
|
|
|
+var files = walk(scanRoot, [])
|
|
|
+for (var f = 0; f < files.length; f++) {
|
|
|
+ var file = files[f]
|
|
|
+ var rel = path.relative(root, file).replace(/\\/g, '/')
|
|
|
+ var src = fs.readFileSync(file, 'utf8')
|
|
|
+ var script = extractScript(src)
|
|
|
+ if (!script) continue
|
|
|
+
|
|
|
+ var hookCalls = {} // hookName -> Set(method)
|
|
|
+ HOOKS.forEach(function (h) {
|
|
|
+ var body = hookBody(script, h)
|
|
|
+ hookCalls[h] = callsIn(body)
|
|
|
+ })
|
|
|
+
|
|
|
+ // 任两个钩子共享同一方法名 → 双发风险
|
|
|
+ for (var i = 0; i < HOOKS.length; i++) {
|
|
|
+ for (var j = i + 1; j < HOOKS.length; j++) {
|
|
|
+ var a = HOOKS[i], b = HOOKS[j]
|
|
|
+ if (!hookCalls[a] || !hookCalls[b]) continue
|
|
|
+ var shared = hookCalls[a].filter(function (m) { return hookCalls[b].indexOf(m) !== -1 })
|
|
|
+ if (shared.length === 0) continue
|
|
|
+ // 看该方法体里有没有"已初始化"守卫标志(_initDone / _inited / _loaded 等)
|
|
|
+ // 如果有守卫,降级为 warning 而非 error
|
|
|
+ var guarded = false
|
|
|
+ shared.forEach(function (m) {
|
|
|
+ var mbody = hookBody(script, m)
|
|
|
+ if (mbody && /(?:_initDone|_inited|_loaded|_hasInit|loggedInInitDone|_firstLoad|_onLoadFired|_initialLoadDone|_mounted)\b/.test(mbody)) guarded = true
|
|
|
+ })
|
|
|
+ // 也检查生命周期钩子本身是否含守卫标志(_onLoadFired 通常写在 onLoad/onShow 里)
|
|
|
+ ;[a, b].forEach(function (h) {
|
|
|
+ var hbody = hookBody(script, h)
|
|
|
+ if (hbody && /(?:_initDone|_inited|_loaded|_hasInit|loggedInInitDone|_firstLoad|_onLoadFired|_initialLoadDone|_mounted)\b/.test(hbody)) guarded = true
|
|
|
+ })
|
|
|
+ violations.push({
|
|
|
+ type: 'LIFECYCLE_DOUBLE',
|
|
|
+ severity: guarded ? 'warn' : 'error',
|
|
|
+ file: rel,
|
|
|
+ hooks: a + ' + ' + b,
|
|
|
+ methods: shared.join(', '),
|
|
|
+ guarded: guarded
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 规则 C:N+1 —— methods 里某方法在 .map( / .forEach( / for(...) 里调用 API 包装
|
|
|
+ // 粗略检测:方法体里同时出现 \.(map|forEach)\s*\( 和某个形如 getXxx( 的调用
|
|
|
+ // 进一步限定:只看 methods 块。这里简化:在整个 script 里查 .map(/.forEach( 后续出现
|
|
|
+ // 的 API 包装函数调用(名称以 get/fetch/load/list 开头)。
|
|
|
+ var nplus = script.match(/\.map\s*\([^]*?\b(get|fetch|load|list)[A-Za-z]*\s*\(/)
|
|
|
+ if (nplus) {
|
|
|
+ // 更精确:逐方法体检查
|
|
|
+ var methodRe = /\b([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/g
|
|
|
+ var mm
|
|
|
+ while ((mm = methodRe.exec(script)) !== null) {
|
|
|
+ // 跳过生命周期钩子
|
|
|
+ if (HOOKS.indexOf(mm[1]) !== -1) continue
|
|
|
+ var mbody = hookBody(script, mm[1])
|
|
|
+ if (!mbody) continue
|
|
|
+ if (/\.(map|forEach)\s*\(/.test(mbody) && /\b(get|fetch|load|list)[A-Za-z]*\s*\(/.test(mbody)) {
|
|
|
+ violations.push({
|
|
|
+ type: 'N_PLUS_1',
|
|
|
+ severity: 'warn',
|
|
|
+ file: rel,
|
|
|
+ hooks: 'method:' + mm[1],
|
|
|
+ methods: mm[1],
|
|
|
+ guarded: false
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- 规则 B:同接口多封装(扫 utils/api.js)----------
|
|
|
+var apiFile = path.join(root, 'utils', 'api.js')
|
|
|
+if (fs.existsSync(apiFile)) {
|
|
|
+ var apiSrc = fs.readFileSync(apiFile, 'utf8')
|
|
|
+ // url -> [{ name, params }] params = request() 第 3 个参数字符串
|
|
|
+ var urlMap = {}
|
|
|
+ var reWrap = /export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*\([^)]*\)\s*=>\s*\{[^}]*?request\(\s*([^,)]+),\s*['"`]POST['"`]\s*(?:,\s*([^)]+?))?\s*\)/g
|
|
|
+ var mw
|
|
|
+ while ((mw = reWrap.exec(apiSrc)) !== null) {
|
|
|
+ var name = mw[1]
|
|
|
+ var urlRaw = mw[2].trim()
|
|
|
+ var url = urlRaw.replace(/['"`]/g, '')
|
|
|
+ if (/\$\{/.test(urlRaw)) url = url.replace(/\$\{[^}]+\}/g, '{id}')
|
|
|
+ var params = (mw[3] || '').trim()
|
|
|
+ if (!urlMap[url]) urlMap[url] = []
|
|
|
+ if (!urlMap[url].some(function (e) { return e.name === name })) urlMap[url].push({ name: name, params: params })
|
|
|
+ }
|
|
|
+ Object.keys(urlMap).forEach(function (url) {
|
|
|
+ var entries = urlMap[url]
|
|
|
+ if (entries.length <= 1) return
|
|
|
+ // 判断"真重复"(参数实质相同)vs"过滤变体/不同操作"(参数不同)
|
|
|
+ var isTrueDup = true
|
|
|
+ for (var i = 0; i < entries.length - 1; i++) {
|
|
|
+ var a = entries[i].params, b = entries[i + 1].params
|
|
|
+ var am = a.match(/_method:\s*['"`](\w+)['"`]/)
|
|
|
+ var bm = b.match(/_method:\s*['"`](\w+)['"`]/)
|
|
|
+ if (am && bm && am[1] !== bm[1]) { isTrueDup = false; break }
|
|
|
+ var aVar = /^[A-Za-z_$][\w$]*$/.test(a)
|
|
|
+ var bVar = /^[A-Za-z_$][\w$]*$/.test(b)
|
|
|
+ var aEmpty = a === '' || a === '{}' || /\|\|\s*\{\}/.test(a)
|
|
|
+ var bEmpty = b === '' || b === '{}' || /\|\|\s*\{\}/.test(b)
|
|
|
+ if ((aVar && bEmpty) || (bVar && aEmpty)) { isTrueDup = false; break }
|
|
|
+ if (a && b && a !== b && !/\|\|\s*\{\}/.test(a) && !/\|\|\s*\{\}/.test(b)) { isTrueDup = false; break }
|
|
|
+ }
|
|
|
+ violations.push({
|
|
|
+ type: 'DUPLICATE_WRAPPER',
|
|
|
+ severity: isTrueDup ? 'error' : 'warn',
|
|
|
+ file: 'utils/api.js',
|
|
|
+ hooks: url,
|
|
|
+ methods: entries.map(function (e) { return e.name }).join(', '),
|
|
|
+ guarded: !isTrueDup
|
|
|
+ })
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- 输出 ----------
|
|
|
+var errors = violations.filter(function (v) { return v.severity === 'error' })
|
|
|
+var warns = violations.filter(function (v) { return v.severity === 'warn' })
|
|
|
+
|
|
|
+function print(list) {
|
|
|
+ list.forEach(function (v) {
|
|
|
+ var tag = v.severity === 'error' ? 'ERROR' : 'WARN '
|
|
|
+ var extra = v.guarded ? ' [guarded]' : ''
|
|
|
+ console.log('[' + tag + '] ' + v.type + ' ' + v.file + ' | ' + v.hooks + ' | ' + v.methods + extra)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+console.log('=== Duplicate API Call Audit ===')
|
|
|
+console.log('Scanned ' + files.length + ' .vue files in ' + pagesDir + '/\n')
|
|
|
+if (errors.length) { console.log('--- ERRORS (' + errors.length + ') ---'); print(errors) }
|
|
|
+if (warns.length) { console.log('\n--- WARNINGS (' + warns.length + ') ---'); print(warns) }
|
|
|
+if (!errors.length && !warns.length) console.log('No violations found.')
|
|
|
+console.log('\nSummary: ' + errors.length + ' error(s), ' + warns.length + ' warning(s)')
|
|
|
+process.exit(errors.length > 0 ? 1 : 0)
|