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 gen
cua 的命令设计遵循“单一动词”原则,主命令下挂三个子命令。第一次面对一个项目时,你会先执行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 |
| 核心模块 Top1 | src/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.service,order.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的参数使用,先把文件按代码长度分组,只看小文件和超大文件的边界在哪里。超长文件往往是长期累积的“上帝类”,也是重构成本最高的地方,这个信号比依赖数量更直观。工具最终还是要为人的判断服务,把最有价值的信号提出来,剩下的事就交给你自己了。