cua:用AST解析与依赖图谱,生成Markdown导读快速理解代码库 1. 为什么会有 cua一个关于“看不懂别人的代码”的故事先交代一下背景。前阵子接手了一个合作方移交过来的后端项目仓库存量大概有十几万行代码微服务和公共库混在一起模块之间互相引用得像一盘意大利面。交接文档不是没有但里面写的内容基本停留在“项目初始化”阶段翻完了都不知道订单状态流转到底在哪个函数里。当时我对着目录结构愣了很久最后做了一个决定与其手动啃不如写一个工具帮我把代码仓库变成一份“能快速读懂的地图”。这个工具就是 cua全称叫 Code Understanding Assistant一个跑在本地的命令行工具。cua 做的事情说穿了很简单扫描指定目录下的源代码文件解析它们之间的依赖关系提取函数和类的签名整理成一份带目录、带依赖图的 Markdown 导读文档。如果你感兴趣它还可以把解析结果喂给本地的大模型服务让你直接问它“这个模块的入口在哪里”之类的问题。它的定位不是代码搜索也不是代码格式化而是“理解代码结构”的辅助工具专门解决人面对陌生项目时的第一道难题不知道该从哪里开始看。我在过去半年里用 cua 翻过四个交接项目和三个开源库最明显的感觉是分析新项目的耗时从“一整天”变成“半小时”。它不能替代你去读代码但它能帮你把阅读路线画出来让你知道哪些文件是核心哪些是次要哪些只是被间接依赖的边角料。如果你是经常需要接手他人代码的开发人员、做项目审计的技术专家或者单纯想快速了解一个开源仓库的架构cua 这类的思路应该能给你不少启发。这篇文章会把 cua 的整个设计过程、核心实现、实战演示和踩过的坑都记录下来。代码量不大但里面涉及依赖扫描、AST 解析、图谱构建和文档生成这几个环节每块都有一些值得聊的细节。1.1 一切的起点被历史代码支配的恐惧接手陌生项目的痛点做过的人都知道。你打开 IDE看到二三十个顶层目录每个目录底下还有四五层嵌套函数名和变量名因为历史原因变得词不达意注释量感人得少。你想用编辑器自带的全局搜索搜一个关键词结果搜出来三百多处你根本不知道哪一段才是真正的主逻辑。这种恐惧不是技术不行而是信息过载代码量太大人脑的短期记忆根本装不下完整拓扑。我最初试过用现成的代码检索工具它们能搜文件、搜符号但给不出“哪个模块被最多人依赖”这种结构性结论。也试过画依赖图的商业插件跑出来的图密密麻麻连缩放都看不清。真正让我下决心自己做是因为某次排查线上问题时折腾了半宿才发现调用链绕了四个服务才回到主库——而那个主库入口藏在一个名字叫 utils 的目录下面。这种藏在眼皮底下的意外恰恰是人肉扫描最容易漏掉的东西。所以 cua 的第一条设计原则是产出物必须是结构化的、能一眼抓住重点的“导读”而不是又一个搜索框。它要把“谁依赖谁”这种关系做成拓扑图把最核心的文件排到最前面把没有业务含义的野文件标出来让阅读者按照权重去扫代码。1.2 cua 想解决什么问题具体落到功能上我期望 cua 能回答这几个问题这个项目由哪些模块组成模块之间的依赖关系长什么样每个模块的入口点和核心函数有哪些哪些文件明显是被间接引用的“叶子节点”如果要找某条业务逻辑应该先打开哪个文件围绕这些问题我把 cua 的核心能力限定为四块。第一扫描并统计项目文件类型分布快速判断这是什么技术栈的项目。第二解析 import、require 甚至注释里人为标注的依赖标记构建出完整的依赖关系图。第三利用抽象语法树提取函数声明、类声明、接口定义和关键注释给每个代码单元生成一句话摘要。第四把以上所有结果合成一份 Markdown 文档并在文档里插入 ASCII 风格的关系图方便直接阅读和分享。这四块能力听起来不复杂但每块都有不少坑。AST 解析对语法版本很敏感依赖图构建要考虑循环引用摘要生成不能只靠截断代码否则看着比源码还累。后面我会一节一节展开讲。1.3 什么样的读者适合看这个项目如果你本身对“元编程”“代码分析”这类话题感兴趣或者你正在写类似的项目管理工具、代码统计工具这篇文章里的思路可以当成一个参考骨架。如果你只是个普通开发者不想写工具只是被困在某个老项目里那直接借用文中的方法去手动梳理代码结构也能省下很多时间。方法不外乎是先画依赖关系再找高频引用最后按核心链路精读。为了方便阅读下面所有代码片段使用的是 TypeScript 语法因为 cua 本身就是用 TypeScript 写的但背后的逻辑用任何语言都能复刻。你完全可以用 Python、Go 或者 Rust 重写一遍核心思路是一样的。2. cua 的整体架构与设计思路2.1 核心流程扫描、解析、建模、输出cua 的运行流程被我刻意设计成流水线式每一级只做一件事级与级之间通过标准结构体传递数据。这样做的好处是想加新的语言支持时不需要改动其他环节。流水线分四步。第一步是扫描遍历目录过滤掉 node_modules、.git、dist 这类生成目录按后缀名归类文件。第二步是解析对每个源码文件使用对应的解析器生成 AST然后从 AST 里提取两类信息文件之间的依赖关系、文件内部暴露的函数和类。第三步是建模把所有文件的依赖关系汇总成一张图做一次拓扑排序计算每个节点的入度和出度把核心文件权重排出来。第四步是输出根据模型生成 Markdown 文档、依赖关系预览图或者调用大模型接口生成更自然语言的导读说明。这个流程里最容易被低估的是“建模”。很多人拿到依赖列表就直接画图但真实的项目里依赖关系往往是网状的不是树状的。如果不做环检测和连通分量分析生成的文档会非常混乱。我在建模阶段加了两件事把循环依赖的环路单独列出来给使用者提示“这里有循环要小心”按 SCC强连通分量来识别真正独立的模块边界这样输出文档的模块划分才符合人的直觉。2.2 为什么选择 AST 而不是正则匹配项目最初的原型是用正则写的当时想得很简单抓文件里的 import 语句和 require 调用提取括号里的路径就完事了。跑了几个项目之后发现漏洞百出有人通过变量拼接路径 require有人用动态 import 加载模块有人在同一行写了多个 import还有人在注释里写了带引号的模块名。正则很难优雅地处理这些情况而且就算匹配到了路径你也拿不到这个文件导出了什么符号更别提函数参数列表了。所以我把正则方案推翻换成了解析器生成 AST。AST 才是代码真正结构化的表达你不仅能看到 import 声明还能看到它在哪个作用域里被什么条件包裹。对 JavaScript/TypeScript 生态来说可选方案很成熟我用的是社区常用的解析器封装。写成伪代码大致是这样import { parse } from some-parser; import { visit } from some-traversal; function extractDependencies(sourceCode: string, lang: string) { const ast parse(sourceCode, { ecmaVersion: latest, sourceType: module }); const deps: string[] []; visit(ast, { ImportDeclaration(path) { deps.push(path.node.source.value); }, CallExpression(path) { const callee path.node.callee; if (callee.name require) { deps.push(path.node.arguments[0].value); } } }); return deps; }用 AST 之后漏检的情况大幅减少还能顺便拿到 imported 符号名比如 import { debounce } from lodash可以知道 debounce 被关联到了哪个模块。这对后面生成文档很有用因为我可以在文档里明确写出“当前文件从外部引入了 debounce用在了第 32 行”。2.3 模块划分解析层、图谱层、生成层为了避免代码越写越乱我从一开始就把项目拆成了三个层次。解析层只负责输入源代码输出文件级的依赖列表和符号列表不关心文件名也不关心目录层级。图谱层负责接收解析层输出的数据构建并分析图结构。生成层负责把图谱结果渲染成可读的文档或交互界面。这样分层让我在调试时能快速定位问题。比如某次解析结果不对那一定是解析层的 bug跟图算法无关某次文档里的依赖关系画得方向反了那一定是图谱层的遍历方向写反了。每个层之间都定义了 DTO 接口哪怕后来我从同步处理改成 Worker 多线程处理也只是调整了解析层的调用方式其他层几乎没动。2.4 技术选型Node.js TypeScript 的原因这个命令行工具为什么选择 Node.js 而不是 Python 或者 Go主要理由有三个。第一解析层生态足够成熟特别是对 JS/TS 项目来说用 Node 技术栈可以复用大量现成的解析器类型定义天然自带不需要自己造车轮子。第二TypeScript 的类型系统能帮我把“文件节点”“依赖边”“符号表”这些结构定义得很清楚重构的时候不会因为字段拼写错误导致静默 bug。第三Node 单进程处理几千个文件虽然不快但配合 Worker 多线程和增量缓存实测下来也足够用。如果你要拿这个思路去处理 Java 或者 C我可能会建议你把解析层单独做成子进程服务用 JSON-RPC 跟主程序通信因为不同语言的解析器对于运行时开销的差异很大。cua 目前专注在 JavaScript/TypeScript 项目但架构上已经预留了这种扩展。3. 核心功能实现与实操细节3.1 命令行设计cua scan、cua serve、cua gencua 的命令设计遵循“单一动词”原则主命令下挂三个子命令。第一次面对一个项目时你会先执行cua scan --entry ./src --format md --output ./docs/overview.md让工具扫描目录并生成一篇导读。如果你想跟已经生成的结果做交互式问答就执行cua serve --port 4312它会把解析好的图谱数据加载进内存并通过本地接口提供一个简易询答页面。第三个命令是cua gen专门用来重新生成报告方便你改了代码或者换了扫描参数之后快速更新文档。这三个命令覆盖了“扫描一次、多形态使用”的需求。scan 是核心serve 是延伸gen 是辅助。每个命令都支持--include参数来指定额外要扫描的文件比如某些项目把类型定义单独放在 typings 目录下不在常规 src 路径里你可以手动把它加进来。3.2 解析依赖关系映射模块路径与实际文件依赖解析里最容易踩的坑是路径解析。假设代码里写的是import { User } from /models/user这里的/通常对应项目根目录下的某个具体路径。为了让依赖图反映真实文件关系必须在解析层做 alias 别名映射。我的做法是优先读取项目的 tsconfig.json 或 jsconfig.json 中的 paths 配置加上 webpack 或 vite 配置里常见 alias 字段把它们合并成一张查找表。解析依赖时先对依赖字符串做正则替换把别名开头的部分替换成真实路径然后再尝试补全扩展名——.js、.ts、.tsx、.jsx最后还要处理 index 文件这种约定。如果文件经过映射后依然不存在我不会直接报错而是把这条依赖标记为 unresolved保留原始字符串让使用者在报告里看到警告信息。这里有个实操建议不要试图覆盖所有构建工具的魔法行为。像通过 webpack 的 loader 解析.css?module这种带 query 的依赖AST 解析拿到之后做一次 query 截断就不会误判。真正难的是像 monorepo 里 workspace 包名转路径的 case这种情况我干脆先用字符串前缀匹配把很快消耗掉的常见路径映射提前写死在配置由使用者自己去补充。3.3 提取函数签名与文档骨架依赖关系解决的是“从哪里来”的问题符号提取解决的是“里面有什么”的问题。一个文件里的函数很可能有十几个如果全部展示在导读文档里篇幅会失控。我在实现时选了三个维度的过滤只提升默认导出、模块级导出的函数和类去掉只在本文件内部使用的辅助函数对剩余函数按代码行数做降序排序行数越长排序越靠前。提取签名并不是简单地把函数名和参数列表切出来。我提取的字段包括函数名、参数名、参数默认值、返回值类型、修饰符async/generator、以及函数开头的注释块。如果函数名本身是fetchUserList注释块又有“获取用户列表分页数据”这种描述那就能拼出非常友好的摘要。注释块还可以用简单的关键词规则做权重比如“deprecated”“todo”“hack”会被特别标出来提醒阅读者这里有坑。为了节省输出我会对同一目录下类似签名的文件做合并统计。比如 models 目录下十几个文件都 export 了一个 class只是字段不同文档里就会合并成一张表而不是每个文件都重复罗列一遍。这样导读文档的长度能控制在一个可以被快速扫视的范围内。3.4 生成 Markdown 导读文档的过程生成器是我花时间调得最多的地方因为它的产物是会被人直接阅读的格式稍微不顺手使用者就想关掉。我的生成策略是分成三块内容第一部分是项目概览表格列出文件数、代码行数、语言分布、未解析依赖数第二部分是依赖拓扑说明按照权重排序给出 Top 10 核心文件链接并用 Markdown 链接锚点跳跃到对应文件详情第三部分才是每个文件详情的目录包含文件路径、依赖列表、符号概况和关键注释。为了保证链接在 GitHub 这类平台上能正确跳锚点我特意对文件路径做了编码处理比如把src/api/user.ts转化成srcapiuserts这种 GitHub 兼容格式。这个细节不写进代码的人很难想到但真漏了会导致文档里的目录点击全部失效。Markdown 文件里还插入了一段 ASCII 字符画的简易依赖图受限于 Markdown 的能力画不了复杂图形所以只展示第一层直接依赖把“是否连通”这种问题交给旁边的关系矩阵表。4. 用 cua 跑通一个真实项目的完整记录4.1 准备一个演示项目为了让记录可复现我在本地准备了一个模拟项目就叫 demo-project。目录结构大概长这样demo-project ├── package.json ├── tsconfig.json ├── src │ ├── main.ts │ ├── services │ │ ├── order.service.ts │ │ └── user.service.ts │ ├── models │ │ ├── user.model.ts │ │ └── order.model.ts │ ├── utils │ │ ├── date.ts │ │ └── logger.ts │ └── api │ └── router.ts这个项目有非常典型的特点utils 被多个模块依赖services 之间有交叉调用api/router 是顶层入口models 是底层叶子节点。用 cua 扫描时我希望它能在文档开头就把 router 标记为核心文件把 utils/date 标记为“被高频依赖的公共工具”。4.2 从零搭建 cua 的核心代码我不会在这里把整个项目代码贴出来工程上有三百多个文件贴出来是灾难。但核心扫描驱动代码很短逻辑就藏在里面。import { glob } from glob; import { parseAndExtract } from ./parser; import { buildGraph, summarizeGraph } from ./graph; import { renderToMarkdown } from ./renderer; export async function scanProject(entryDir: string) { const files await glob(src/**/*.{ts,js,tsx,jsx}, { cwd: entryDir, ignore: [**/node_modules/**, **/dist/**], }); const fileResults await Promise.all( files.map(async (file) { return parseAndExtract(file, path.join(entryDir, file)); }) ); const graph buildGraph(fileResults); const summary summarizeGraph(graph); return renderToMarkdown(summary); }这是 cua 的骨架。看到这里你会发现真正复杂的是 parseAndExtract 内部对 AST 的遍历操作以及 buildGraph 里对非函数式依赖的处理。我在跑 demo 项目时加了一个 debug 参数可以打印出每个文件的解析耗时方便确信瓶颈在哪一环节。4.3 运行 cua scan 并解读输出内容在 demo-project 根目录执行完一条 cua scan 后生成器会在 docs 目录写一个效果不错的 Markdown 文件。文件开头是一个项目总览表实测结果如下指标数值扫描文件数8代码总行数632依赖边数14未解析依赖0核心模块 Top1src/api/router.ts接下来是依赖关系概览我用文本缩进表示树形结构src/api/router.ts ├─ services/order.service.ts ├─ services/user.service.ts └─ models/order.model.ts src/main.ts ├─ api/router.ts └─ utils/logger.ts这样的输出虽然简单但一眼就能看出 router 是核心汇聚点。紧接着是每个文件的详情order.service 会显示它被 router 引用同时它又依赖 user.service 和 models。如果有循环引用这里会多一个环警告块用显眼的红色块标记出来。4.4 接入大模型做本地问答实测因为 cua 已经解析出结构化了的数据后面接大模型比直接扔代码给大模型高效得多。实测中我启动 serve 命令把图谱序列化成 JSON以精简上下文的方式发给本地模型。提问“查看订单的API入口在哪”模型能结合图谱中的节点和锚点返回推荐文件列表和关键函数位置。要提醒的是这种方案不要追求大模型自己去阅读所有源码。上下文窗口再大塞满全量代码也是浪费并且容易答非所问。正确的姿势是只把文件路径、符号摘要、依赖边这些“元信息”喂进去让模型做结构推理具体某一行代码需要时再用工具读取。这样算下来一个万行项目的图谱元信息只有几十 KB不仅响应快精准度还比我之前直接“喂源码”的方式好很多。5. 踩坑记录与排查思路5.1 解析异常那些不按常理出牌的代码写 AST 解析时我第一批测试就遇到了两个经典案例。第一个是代码里使用了未定义的全局变量解析器会报错但不会告诉你是哪个文件导致的。解决办法是先做语法级别校验如果解析失败就把该文件标记为 parse-error单独收集起来而不是让整个任务崩溃。第二个是带有装饰器语法的旧版本 TypeScript 文件某些老项目还在用实验性装饰器默认解析器不认识。处理起来也很直接在解析配置里开启experimentalDecorators同时对个别仍失败的文件做一次降级处理尝试按纯 JavaScript 模式解析能提取多少算多少。这里的经验是解析器一定要容错一次失败就把整个文件抛弃会让许多老项目的有效信息白白浪费。5.2 循环依赖图谱的环检测依赖不是环路的项目其实很少见。我在 demo-project 里特意插了一个循环user.service 引用 order.serviceorder.service 又引用 user.service。如果只做普通 DFS生成的文档会显示无限循环的连接使用者看着就是乱的一团。我的解决方案是运行一次 Tarjan 强连通分量算法把所有大小大于 1 的连通分量识别成“环”。在最终文档中我不会强行破坏环而是把环内的文件单独成组给这个组加一个醒目的注释“以下文件存在循环依赖重构时注意”。这既保留了事实又给了阅读者提示。环检测逻辑图论含量不高但调试 Tarjan 的边界条件还是花了几个晚上尤其是处理只有一个节点的自环时条件判断稍有闪失就会漏掉。5.3 大仓库扫描的性能优化当文件数量超过三千个时逐文件同步解析的速度会慢到令人发指。第一次测试一个中型仓库整个扫描用了将近三分钟。性能分析显示大部分时间花在了磁盘读取和 AST 解析上而不是依赖图分析。优化的方式有两个方向。第一是利用 Node.js 的 worker_threads 做并行解析按 CPU 核数分成若干 Worker每个 Worker 处理一批文件然后把结果汇总到主线程。改完之后三千个文件的扫描时间降到四十秒左右。第二是加入增量缓存把文件的修改时间、文件大小和解析结果一起缓存下来。下次扫描时只有改动过的文件才会重新解析别的直接读缓存。这个“脏文件判断 重新解析”的思路后来也应用到很多别的工具里非常通用。5.4 容易被忽略的解析缓存失效问题提到缓存就必须提一个我踩得比较惨的坑缓存不能只存文件内容 hash还要存解析器版本。某次我把解析器升级了修正了动态 import 的一个 bug但缓存依然命中旧版本导致扫描结果还是老问题。排查半天才发现是缓存 key 缺少版本号。最终的缓存策略是复合 key解析器版本号 文件内容 hash 文件大小。任何一项发生变化都视为缓存失效。如果你也要实现类似功能可以不用 hash 整个文件改用文件修改时间加文件大小做快速判断内容 hash 作为第二道校验这样能兼顾速度和准确性。6. 实测体会与后续扩展方向6.1 用 cua 复盘项目的个人感受把 cua 用到正式的交接项目上之后我最大的体会是它改变了我的浏览路径以前我只能按目录一层层往下点现在先看一眼末尾的“核心依赖排行”直接打开排名靠前的三个文件再把它们调用的叶子函数逐个跳过去看基本能在一个小时左右形成整棵代码树的认知。过去必须把代码毫无头绪地翻个底朝天现在这种干预方式显著减少了我花在“找入口”上的时间。不过 cua 并不能替你判断业务对错它只是一个结构导航工具。如果一个模块叫utils但实际上里面藏了业务逻辑AST 是看不出来的工具只会按依赖数量把它标为公共节点。这种“语义偏差”还得阅读者自己把握。所以我建议任何导读文档都只作为起点不要完全相信机器排序的“核心模块”标签。6.2 后续想加的功能我最想加的第一个功能是多语言扩展。目前只支持 JS/TS但手边不少项目是 Java 和 Python特别是 Python 的动态导入比 JS 还要难缠需要处理__init__.py目录包结构、importlib动态加载这些情况。后续如果做会把解析层抽象成统一接口每种语言写一个插件。第二个功能是生成可点击的交互式网页替代 Markdown。虽然 Markdown 在 GitHub 上已经很方便但网页版可以加依赖图的力导向可视化支持点击节点聚焦、展开子依赖体验会比静态文档好很多。我已经用简单的前端框架搭了雏形但在大图渲染性能上还需要优化。第三个功能是把“git 历史改动频率”合并进权重计算。如果一个文件被很多模块引用而且最近还在频繁修改那它大概率值得优先阅读。这种“结构 演进”的综合评分对判断系统最容易出问题的地方会很有参考价值。6.3 给别人用之前需要做的两件事如果你也想写类似工具我建议你在公开发布前做好两件事。一是配套完整的测试样例里面要故意包含解析失败的文件、循环依赖、别名映射缺失这些边界情况否则用户一跑就崩溃体验非常差。二是写一份清晰的“扫描范围配置”说明让使用者知道工具并不会分析所有文件默认忽略目录要解释清楚免得别人误以为工具漏文件。很多人在第一次用这类工具时的第一反应是“是不是有问题”提前把这些话说清楚能省去不少答疑成本。最后分享一个小技巧实际使用 cua 的时候我经常配合--max-file-line 300的参数使用先把文件按代码长度分组只看小文件和超大文件的边界在哪里。超长文件往往是长期累积的“上帝类”也是重构成本最高的地方这个信号比依赖数量更直观。工具最终还是要为人的判断服务把最有价值的信号提出来剩下的事就交给你自己了。