audit-duplicate-api-calls.js 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. /**
  2. * 重复 API 调用审计脚本
  3. *
  4. * 检测三类违规(违反 AGENTS.md「API 调用去重」规范):
  5. * A. 生命周期重复触发:同一个方法 / API 同时在 onLoad 和 onShow(或 onLoad+mounted 等
  6. * 两个生命周期钩子)里被调用,首次进入页面时会双发。
  7. * B. 同接口多封装:utils/api.js 里多个导出函数指向同一个后端 URL(params 不同算
  8. * 合法变体,不算违规;纯重复封装算违规)。
  9. * C. N+1 循环调用:在 .map / forEach / for 循环里调用 API 包装函数,N 个元素 → N 次请求。
  10. *
  11. * 用法:node scripts/audit-duplicate-api-calls.js [--pages-dir=pages]
  12. * 退出码:0 = 无违规;1 = 发现违规(CI 可直接用作门禁)
  13. */
  14. var fs = require('fs')
  15. var path = require('path')
  16. var root = path.resolve(__dirname, '..')
  17. var pagesDir = process.argv.slice(2).reduce(function (acc, a) {
  18. var m = a.match(/^--pages-dir=(.+)$/)
  19. return m ? m[1] : acc
  20. }, 'pages')
  21. var scanRoot = path.join(root, pagesDir)
  22. if (!fs.existsSync(scanRoot)) {
  23. console.error('[audit] pages dir not found: ' + scanRoot)
  24. process.exit(2)
  25. }
  26. // ---------- 工具:递归收集 .vue ----------
  27. function walk(dir, out) {
  28. var entries = fs.readdirSync(dir)
  29. for (var i = 0; i < entries.length; i++) {
  30. var p = path.join(dir, entries[i])
  31. var st = fs.statSync(p)
  32. if (st.isDirectory()) walk(p, out)
  33. else if (p.slice(-4) === '.vue') out.push(p)
  34. }
  35. return out
  36. }
  37. // ---------- 工具:取 <script> 块 ----------
  38. function extractScript(src) {
  39. var m = src.match(/<script[^>]*>([\s\S]*?)<\/script>/)
  40. return m ? m[1] : ''
  41. }
  42. // ---------- 工具:按大括号深度截取某个方法体 ----------
  43. // 在 script 文本里定位 `name(args) {`,从 `{` 开始做大括号配对,返回方法体字符串
  44. function hookBody(script, hookName) {
  45. // 匹配 onShow / onLoad / onShow(options) / onTabItemTap(e) 等,允许前面有空格/换行
  46. var re = new RegExp('\\b' + hookName + '\\s*\\([^)]*\\)\\s*\\{', 'g')
  47. var m
  48. while ((m = re.exec(script)) !== null) {
  49. var braceStart = script.indexOf('{', m.index)
  50. if (braceStart < 0) return null
  51. var depth = 0
  52. var i = braceStart
  53. var inStr = null // ' " `
  54. var inLineComment = false
  55. var inBlockComment = false
  56. for (; i < script.length; i++) {
  57. var c = script[i]
  58. var next = script[i + 1]
  59. if (inLineComment) { if (c === '\n') inLineComment = false; continue }
  60. if (inBlockComment) { if (c === '*' && next === '/') { inBlockComment = false; i++ }; continue }
  61. if (inStr) {
  62. if (c === '\\') { i++; continue }
  63. if (c === inStr) inStr = null
  64. continue
  65. }
  66. if (c === '/' && next === '/') { inLineComment = true; i++; continue }
  67. if (c === '/' && next === '*') { inBlockComment = true; i++; continue }
  68. if (c === '"' || c === "'" || c === '`') { inStr = c; continue }
  69. if (c === '{') depth++
  70. else if (c === '}') {
  71. depth--
  72. if (depth === 0) return script.slice(braceStart + 1, i)
  73. }
  74. }
  75. }
  76. return null
  77. }
  78. // ---------- 工具:提取方法调用名 ----------
  79. // this.foo( -> foo ;以及裸函数调用 foo( 当作直接 API 调用候选
  80. function callsIn(body) {
  81. var calls = []
  82. if (!body) return calls
  83. // this.xxx( / that.xxx( / self.xxx(
  84. var reThis = /\b(?:this|that|self)\.([A-Za-z_$][\w$]*)\s*\(/g
  85. var m
  86. while ((m = reThis.exec(body)) !== null) calls.push(m[1])
  87. return calls
  88. }
  89. // ---------- 规则 A:生命周期双发 ----------
  90. var HOOKS = ['onLoad', 'onShow', 'onTabItemTap', 'mounted', 'created', 'beforeMount']
  91. var violations = []
  92. var files = walk(scanRoot, [])
  93. for (var f = 0; f < files.length; f++) {
  94. var file = files[f]
  95. var rel = path.relative(root, file).replace(/\\/g, '/')
  96. var src = fs.readFileSync(file, 'utf8')
  97. var script = extractScript(src)
  98. if (!script) continue
  99. var hookCalls = {} // hookName -> Set(method)
  100. HOOKS.forEach(function (h) {
  101. var body = hookBody(script, h)
  102. hookCalls[h] = callsIn(body)
  103. })
  104. // 任两个钩子共享同一方法名 → 双发风险
  105. for (var i = 0; i < HOOKS.length; i++) {
  106. for (var j = i + 1; j < HOOKS.length; j++) {
  107. var a = HOOKS[i], b = HOOKS[j]
  108. if (!hookCalls[a] || !hookCalls[b]) continue
  109. var shared = hookCalls[a].filter(function (m) { return hookCalls[b].indexOf(m) !== -1 })
  110. if (shared.length === 0) continue
  111. // 看该方法体里有没有"已初始化"守卫标志(_initDone / _inited / _loaded 等)
  112. // 如果有守卫,降级为 warning 而非 error
  113. var guarded = false
  114. shared.forEach(function (m) {
  115. var mbody = hookBody(script, m)
  116. if (mbody && /(?:_initDone|_inited|_loaded|_hasInit|loggedInInitDone|_firstLoad|_onLoadFired|_initialLoadDone|_mounted|_pageShown)\b/.test(mbody)) guarded = true
  117. })
  118. // 也检查生命周期钩子本身是否含守卫标志(_onLoadFired 通常写在 onLoad/onShow 里)
  119. ;[a, b].forEach(function (h) {
  120. var hbody = hookBody(script, h)
  121. if (hbody && /(?:_initDone|_inited|_loaded|_hasInit|loggedInInitDone|_firstLoad|_onLoadFired|_initialLoadDone|_mounted|_pageShown)\b/.test(hbody)) guarded = true
  122. })
  123. violations.push({
  124. type: 'LIFECYCLE_DOUBLE',
  125. severity: guarded ? 'warn' : 'error',
  126. file: rel,
  127. hooks: a + ' + ' + b,
  128. methods: shared.join(', '),
  129. guarded: guarded
  130. })
  131. }
  132. }
  133. // 规则 C:N+1 —— methods 里某方法在 .map( / .forEach( / for(...) 里调用 API 包装
  134. // 粗略检测:方法体里同时出现 \.(map|forEach)\s*\( 和某个形如 getXxx( 的调用
  135. // 进一步限定:只看 methods 块。这里简化:在整个 script 里查 .map(/.forEach( 后续出现
  136. // 的 API 包装函数调用(名称以 get/fetch/load/list 开头)。
  137. var nplus = script.match(/\.map\s*\([^]*?\b(get|fetch|load|list)[A-Za-z]*\s*\(/)
  138. if (nplus) {
  139. // 更精确:逐方法体检查
  140. var methodRe = /\b([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/g
  141. var mm
  142. while ((mm = methodRe.exec(script)) !== null) {
  143. // 跳过生命周期钩子
  144. if (HOOKS.indexOf(mm[1]) !== -1) continue
  145. var mbody = hookBody(script, mm[1])
  146. if (!mbody) continue
  147. if (/\.(map|forEach)\s*\(/.test(mbody) && /\b(get|fetch|load|list)[A-Za-z]*\s*\(/.test(mbody)) {
  148. violations.push({
  149. type: 'N_PLUS_1',
  150. severity: 'warn',
  151. file: rel,
  152. hooks: 'method:' + mm[1],
  153. methods: mm[1],
  154. guarded: false
  155. })
  156. }
  157. }
  158. }
  159. }
  160. // ---------- 规则 B:同接口多封装(扫 utils/api.js)----------
  161. var apiFile = path.join(root, 'utils', 'api.js')
  162. if (fs.existsSync(apiFile)) {
  163. var apiSrc = fs.readFileSync(apiFile, 'utf8')
  164. // url -> [{ name, params }] params = request() 第 3 个参数字符串
  165. var urlMap = {}
  166. var reWrap = /export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*\([^)]*\)\s*=>\s*\{[^}]*?request\(\s*([^,)]+),\s*['"`]POST['"`]\s*(?:,\s*([^)]+?))?\s*\)/g
  167. var mw
  168. while ((mw = reWrap.exec(apiSrc)) !== null) {
  169. var name = mw[1]
  170. var urlRaw = mw[2].trim()
  171. var url = urlRaw.replace(/['"`]/g, '')
  172. if (/\$\{/.test(urlRaw)) url = url.replace(/\$\{[^}]+\}/g, '{id}')
  173. var params = (mw[3] || '').trim()
  174. if (!urlMap[url]) urlMap[url] = []
  175. if (!urlMap[url].some(function (e) { return e.name === name })) urlMap[url].push({ name: name, params: params })
  176. }
  177. Object.keys(urlMap).forEach(function (url) {
  178. var entries = urlMap[url]
  179. if (entries.length <= 1) return
  180. // 判断"真重复"(参数实质相同)vs"过滤变体/不同操作"(参数不同)
  181. var isTrueDup = true
  182. for (var i = 0; i < entries.length - 1; i++) {
  183. var a = entries[i].params, b = entries[i + 1].params
  184. var am = a.match(/_method:\s*['"`](\w+)['"`]/)
  185. var bm = b.match(/_method:\s*['"`](\w+)['"`]/)
  186. if (am && bm && am[1] !== bm[1]) { isTrueDup = false; break }
  187. var aVar = /^[A-Za-z_$][\w$]*$/.test(a)
  188. var bVar = /^[A-Za-z_$][\w$]*$/.test(b)
  189. var aEmpty = a === '' || a === '{}' || /\|\|\s*\{\}/.test(a)
  190. var bEmpty = b === '' || b === '{}' || /\|\|\s*\{\}/.test(b)
  191. if ((aVar && bEmpty) || (bVar && aEmpty)) { isTrueDup = false; break }
  192. if (a && b && a !== b && !/\|\|\s*\{\}/.test(a) && !/\|\|\s*\{\}/.test(b)) { isTrueDup = false; break }
  193. }
  194. violations.push({
  195. type: 'DUPLICATE_WRAPPER',
  196. severity: isTrueDup ? 'error' : 'warn',
  197. file: 'utils/api.js',
  198. hooks: url,
  199. methods: entries.map(function (e) { return e.name }).join(', '),
  200. guarded: !isTrueDup
  201. })
  202. })
  203. }
  204. // ---------- 输出 ----------
  205. var errors = violations.filter(function (v) { return v.severity === 'error' })
  206. var warns = violations.filter(function (v) { return v.severity === 'warn' })
  207. function print(list) {
  208. list.forEach(function (v) {
  209. var tag = v.severity === 'error' ? 'ERROR' : 'WARN '
  210. var extra = v.guarded ? ' [guarded]' : ''
  211. console.log('[' + tag + '] ' + v.type + ' ' + v.file + ' | ' + v.hooks + ' | ' + v.methods + extra)
  212. })
  213. }
  214. console.log('=== Duplicate API Call Audit ===')
  215. console.log('Scanned ' + files.length + ' .vue files in ' + pagesDir + '/\n')
  216. if (errors.length) { console.log('--- ERRORS (' + errors.length + ') ---'); print(errors) }
  217. if (warns.length) { console.log('\n--- WARNINGS (' + warns.length + ') ---'); print(warns) }
  218. if (!errors.length && !warns.length) console.log('No violations found.')
  219. console.log('\nSummary: ' + errors.length + ' error(s), ' + warns.length + ' warning(s)')
  220. process.exit(errors.length > 0 ? 1 : 0)