| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- /**
- * 重复 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|_pageShown)\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|_pageShown)\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)
|