从一次 Bug 说起:如何用 AST + 依赖图 + Git diff 实现自动风险定位

96 阅读7分钟

小改动也会引发线上事故

有时候,一个小改动就能引发连锁反应,问题不在于改动本身的复杂度,而在于我们不知道这个改动可能影响到谁

举个真实例子:我们有一个公共组件被多个页面引用,某次改了内部逻辑,没有引起警觉,结果多个页面上线后都出现了白屏。问题回头看其实很好修——但上线前没人意识到要测这些页面。

从这次问题中我们反思到:

哪些页面用了这个组件?中间经过了哪些中间层?页面是不是通过三层封装才引到它?有没有什么静态手段可以自动识别到这些路径?

说到底,这不是“组件写得复杂”的问题,而是我们对代码的波及路径没有工具支撑,缺少自动化分析手段。

所以我们着手做了一套工具,目标是清晰回答一个问题:

某个文件改了,它可能影响到哪些路由页面?这次改动到底要不要测某个页面?

这个思路最初的触发点,其实来自转转技术团队在一篇文章中提到的一个观点:「前端缺少变更影响范围自动化分析工具,导致测试策略全靠经验判断」。他们的尝试给了我很大启发

juejin.cn/post/752996…

想解决的问题

手动去查找影响范围有几个问题:

  1. 组件嵌套、引用情况容易漏掉
  2. 改动可能会穿透多个中间层,开发者很难一眼看出
  3. 改动之后开发者可能会漏测某个页面

因此我们想实现的是这样的能力:通过git diff 计算得到可能会影响到的路由页面,再结合AI输出测试建议。拆解一下就是:

Git diff → 改动文件 → 引用链路 → 路由页面 → 测试建议

核心流程

获取改动文件

直接执行命令获取就行

const changedFiles = execSync('git diff origin/master --name-only')
  .split('\n')
  .filter(f => f.startsWith('src/'))

构建依赖图

我们需要知道,给定一个文件,哪些文件引用了他,并且得到他的引用链,一直到路由定义,这样就可以知道影响的url,为此,我们需要用babel parser去收集import语句,然后构建依赖关系,从而得到依赖链

使用 Babel Parser + Traverse 收集 import/require/import() 语句

核心代码如下:

traverse(ast, {
  ImportDeclaration({ node }) {
    result.push(node.source.value)
  },
  CallExpression({ node }) {
    if (node.callee.name === 'require') {
      result.push(node.arguments[0].value)
    }
  },
  Import({ parent }) {
    result.push(parent.arguments[0].value)
  }
})

使用 enhanced-resolve 模拟 Webpack resolve 规则,还原真实绝对路径

const resolver = ResolverFactory.createResolver({
  alias: {
    COMPONENTS: path.resolve(__dirname, 'src/components'),
    ...
  },
  extensions: ['.ts', '.tsx', '.js'],
})

然后我们对整个 src 做全量扫描,构建出:

  • forwardMap: A 引用了哪些模块
  • reverseMap: B 被哪些模块引用了

反向查找路由页面

当我们拿到改动文件后 :

  • reverseMap 上,从改动文件出发,DFS 搜索调用链;
  • 一旦链路中包含 src/routes/index.tsx,就说明已经传导到页面路由层;
  • 如果能定位到那个路由组件的文件路径,就可以知道对应的 URL。

核心函数简化如下:

function queryReverseChains(filePath) {
  const stack = [[filePath]]
  const chains = []
  while (stack.length > 0) {
    const chain = stack.pop()
    const cur = chain[chain.length - 1]
    const parents = reverseMap.get(cur) || []
    if (parents.length === 0) {
      chains.push(chain)
    } else {
      for (const p of parents) {
        if (!chain.includes(p)) stack.push([...chain, p])
      }
    }
  }
  return chains
}

AI prompt 构建

需要把尽可能多的信息喂给AI,包含:

  1. 角色设置
  2. prd、技术文档、接口文档
  3. code diff
  4. 引用链+影响的路由

prompt如下:

你是一个前端稳定性测试分析助手,目标是根据提供的结构化代码变更数据,输出一份详细的风险评估和测试建议报告。

本次输入信息包含:

1. 改动文件路径和 Git diff 内容;
2. 改动文件的被引用路径调用链(从文件出发,向上传递到路由层);
3. 匹配到的页面路由路径(即最终影响的 URL);
4. 改动文件中的函数变动信息,包括函数名称、变动类型(add / delete / modified / rename)、是否为导出函数、是否签名变化;
5. 项目信息,如:路由框架类型、主路由配置文件路径、目标环境。

请你根据这些信息,输出一份结构化分析报告,内容包括:

---

### 一、影响概述

- 改动属于组件级 / 页面级 / 跨页面级;
- 传播路径调用链有几层,是否命中了核心页面入口;
- 是否为公共组件或导出函数改动,是否存在调用方未同步修改的风险。

---

### 二、影响页面列表(由路由路径决定)

- 列出所有受到影响的页面路径(如 /activity/detail);
- 可按改动链路深度或页面权重排序(可选)。

---

### 三、推荐测试点

对每个页面列出手工测试建议,包括但不限于:
- 页面是否正常加载,无白屏、无异常;
- 表单是否正常渲染、交互是否正常;
- 若表单组件有提交逻辑,是否触发了受影响的函数;
- 重要按钮、交互项是否未受变动影响;
- 是否建议补充回归自动化测试。

---

### 四、函数级风险分析(来自函数变动信息)

- 有无导出函数签名变更或删除,是否存在调用方兼容性风险;
- 有无逻辑大改动、错误处理缺失、magic number、嵌套过深等编码规范问题;
- 是否需要额外编写或补充单测、Mock 数据。

---

### 五、最终建议

- 是否建议执行完整页面回归测试;
- 是否建议补充自动化 E2E 脚本;
- 是否建议通知调用方联动修改;
- 是否建议对核心函数加入参数校验、try/catch 等防御式措施。

---

请使用中文自然语言进行输出,输出格式清晰,必要时使用 Markdown 分点、表格等方式。不要输出代码,仅输出结论和建议。

以下是结构化 JSON 输入(请按此格式解析):

完整代码

业务场景脱敏后代码如下,其中路由部分可能要根据自己的项目修改一下:

/* eslint-disable no-console */
const { execSync } = require('child_process')
const path = require('path')
const fs = require('fs')
const { ResolverFactory, CachedInputFileSystem } = require('enhanced-resolve')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default

const systemSettings = `...`

const basePath = '/project'
const routes = [
  {
    path: `${basePath}/example`,
    name: '示例页面',
    compPath: 'src/view/example/index.tsx',
  }
]

const routesMap = new Map()
routes.forEach((route) => {
  routesMap.set(route.compPath, route)
})

function getDiffChangedFiles() {
  const changedFiles = execSync('git diff origin/master --name-only', { encoding: 'utf-8' })
    .split('\n')
    .filter((f) => f.trim() !== '')
    .filter((f) => f.startsWith('src/'))
  return changedFiles
}

function getFileDiff(filePath) {
  try {
    return execSync(`git diff --minimal origin/master -- ${filePath}`, { encoding: 'utf-8' })
  } catch {
    return ''
  }
}

const changedFiles = getDiffChangedFiles()

const resolver = ResolverFactory.createResolver({
  fileSystem: new CachedInputFileSystem(fs, 4000),
  extensions: ['.ts', '.tsx', '.js', '.jsx', '.less', '.scss', '.css'],
  alias: {
    COMPONENTS: path.resolve(__dirname, 'src/components'),
    HOOKS: path.resolve(__dirname, 'src/hooks'),
    VIEW: path.resolve(__dirname, 'src/view'),
    MODULES: path.resolve(__dirname, 'src/modules'),
    CONSTANTS: path.resolve(__dirname, 'src/constants'),
    SERVICES: path.resolve(__dirname, 'src/services'),
  },
  modules: [path.resolve(__dirname, 'src')],
  mainFiles: ['index'],
})

function tryResolve(importPath, fromFile) {
  return new Promise((resolve) => {
    resolver.resolve({}, path.dirname(fromFile), importPath, {}, (err, result) => {
      resolve(err ? null : result)
    })
  })
}

const babelPlugins = [
  'jsx', 'typescript', 'classProperties', 'decorators-legacy',
  'optionalChaining', 'nullishCoalescingOperator', 'dynamicImport'
]

function normalizePath(filePath) {
  return path.relative(path.resolve(__dirname), filePath)
}

function parseImports(filePath) {
  const result = []
  try {
    const code = fs.readFileSync(filePath, 'utf-8')
    const ast = parser.parse(code, { sourceType: 'unambiguous', plugins: babelPlugins })
    traverse(ast, {
      ImportDeclaration({ node }) { result.push(node.source.value) },
      CallExpression({ node }) {
        if (node.callee.name === 'require' && node.arguments[0]?.value) {
          result.push(node.arguments[0].value)
        }
      },
      Import({ parent }) {
        if (parent?.arguments?.[0]?.value) {
          result.push(parent.arguments[0].value)
        }
      },
    })
  } catch {}
  return result
}

const forwardMap = new Map()
const reverseMap = new Map()

async function processAllFiles(files) {
  for (const filePath of files) {
    const absPath = path.resolve(__dirname, filePath)
    const imports = parseImports(absPath)
    const resolvedList = []
    for (const imp of imports) {
      const resolved = await tryResolve(imp, absPath)
      if (resolved) {
        const norm = normalizePath(resolved)
        resolvedList.push(norm)
        if (!reverseMap.has(norm)) reverseMap.set(norm, [])
        reverseMap.get(norm).push(normalizePath(absPath))
      }
    }
    forwardMap.set(normalizePath(absPath), resolvedList)
  }
}

function getRouteByFilePath(filePath) {
  const normalizedPath = normalizePath(filePath)
  return routesMap.get(normalizedPath) || null
}

function queryReverseChains(filePath, maxDepth = 10) {
  const key = normalizePath(filePath)
  if (!reverseMap.has(key)) return []

  const seen = new Set()
  const stack = [[key]]
  const chains = []

  while (stack.length > 0) {
    const chain = stack.pop()
    const cur = chain[chain.length - 1]
    if (chain.length > maxDepth || seen.has(chain.join('>'))) continue
    seen.add(chain.join('>'))

    const parents = reverseMap.get(cur) || []
    if (parents.length === 0) {
      chains.push(chain)
    } else {
      for (const p of parents) {
        if (!chain.includes(p)) stack.push([...chain, p])
      }
    }
  }

  return chains
}

function collectFiles(root, exts = ['.ts', '.tsx', '.js', '.jsx', '.less', '.scss', '.css']) {
  const res = []
  const walk = (dir) => {
    const entries = fs.readdirSync(dir, { withFileTypes: true })
    for (const entry of entries) {
      const fullPath = path.join(dir, entry.name)
      if (entry.isDirectory()) walk(fullPath)
      else if (exts.includes(path.extname(fullPath))) res.push(fullPath)
    }
  }
  walk(path.resolve(__dirname, root))
  return res
}

function dedupeChains(chains) {
  const seen = new Set()
  const result = []
  for (const chain of chains) {
    const key = chain.join('>')
    if (!seen.has(key)) {
      seen.add(key)
      result.push(chain)
    }
  }
  return result
}

;(async () => {
  console.time('🔥 全流程耗时')

  const allSrcFiles = collectFiles('src')
  console.time('依赖图构建耗时')
  await processAllFiles(allSrcFiles)
  console.timeEnd('依赖图构建耗时')

  const fileDiffInfo = {}

  for (const filePath of changedFiles) {
    console.log(`处理文件: ${filePath}`)
    const diff = getFileDiff(filePath)
    const chains = queryReverseChains(filePath)
    const uniqueChains = dedupeChains(chains.map((chain) => chain.map(normalizePath)))
    const affectedRoutes = new Set()
    const newChains = dedupeChains(
      uniqueChains.map((chain) => {
        if (chain.includes('src/routes/index.tsx')) {
          const index = chain.indexOf('src/routes/index.tsx')
          affectedRoutes.add(getRouteByFilePath(chain[index - 1])?.path || '未知路径')
          return chain.slice(0, index - 1)
        } else {
          return chain
        }
      })
    )
    fileDiffInfo[filePath] = {
      diff,
      chainMaxDepth: Math.max(...newChains.map((chain) => chain.length)),
      affectedRoutes: Array.from(affectedRoutes),
    }
  }

  const outFilePath = path.resolve(__dirname, 'AICR.json')
  fs.writeFileSync(outFilePath, JSON.stringify({
    systemSettings,
    project: {},
    fileDiffInfo
  }, null, 2), 'utf-8')
  console.log(`分析结果已写入: ${outFilePath}`)
  console.timeEnd('全流程耗时')
})()


招人

京东零售交易核心部门招人了,满足下列条件的欢迎发简历到 baichen3@jd.com ,直推大老板,流程很快:

  1. 3-5年工作经验
  2. 至少本科全日制,985 211 更优
  3. 5年内工作经历不超过2家
  4. 熟悉Web应用的性能优化,了解微前端、lowcode项目优先