t3code 这个项目最初是我在处理一批技术调研代码时被逼出来的。手头堆了几十个零散的实验脚本有的只写了函数没写注释有的根本想不起来当时为什么这么写等到真要复用时只能重新读一遍源码去猜。那段时间我反复在找代码、读代码、重写代码之间打转效率低得让人烦躁。后来我索性花了一个周末写了一个名为 t3code 的小工具专门解决这类问题输入一个目录自动扫描里面的实验代码解析注释、导出符号和依赖关系然后生成一份结构化的 Markdown 文档和一套可直接运行的冒烟测试。它不能替代人工整理业务文档但能帮你把随手写的代码快速变成能看懂、能跑通的模块。如果你也经常写临时脚本、做技术验证或者在多个小项目里反复横跳这篇文章里的设计思路和踩坑记录应该能让你少走不少弯路。1. 为什么会有 t3code一次实验代码失控后的自救1.1 我真正想解决的不是整理而是复用做过技术调研的人都有体会实验代码和时间呈指数增长可复用性却常常不升反降。最开始我只是想找一个笔记工具把散落的代码片段整理成文档。但仔细想了一个下午我发现整理只是表象复用才是痛点所在。我的日常场景是这样的先从文档或论坛里看到一个思路随手写一段验证代码跑通之后就不再管它。隔了两周另一个项目需要类似逻辑我依稀记得自己写过但不知道写在了哪个目录更不知道那段代码有没有隐藏 bug。最后只能重新写一遍而且会犯和上次一样的错。t3code 要解决的就是这条链路扫描代码抽取出足够清晰的结构化信息让一段实验代码在三个月后还能被快速理解、直接使用。整理文档只是手段节约查找和重写的时间才是目的。所以我给 t3code 定了三个基本要求。第一命令要足够简单最好一条命令就能完成整个扫描和生成流程不需要额外维护复杂的配置。第二输出的产物必须有实际价值不能只生成一个看着很完整的 Markdown 表格而是要给出能跑起来的测试文件。第三工具本身必须是本地优先的代码不出本机避免把实验代码传到一个自己都没法控制的外部服务上。这三点决定了 t3code 的整个产品走向。1.2 t3code 到底是什么一条命令把脚本变成文档和测试你可以把 t3code 理解成一个代码整理流水线。它读入一个目录下的源码文件提取每个文件中的导出函数、类、常量以及对应的 JSDoc 注释和 import 依赖然后生成两个产物一份人类可读的 Markdown 文档一份可执行的测试文件。跑一次核心命令大概是这样的t3code scan --input src/experiments --output docs/reports --format md执行完之后docs/reports下会出现一个按目录结构组织好的报告文件。里面会写明每个文件导出了什么、依赖了哪些外部模块、每个函数的注释是什么。紧接着你还可以这样跑t3code test --input src/experiments --runner vitest它会为每个导出函数生成一个冒烟测试文件里面已经写好了基本的导入语句和断言框架。你只需要往里面补充具体的测试用例即可。这个流程的价值不只是整理它强迫我把视角从写一段能用就行调整为写一段以后还能跑通。当我知道会有一个测试文件生成出来写代码的时候自然会更注意边界情况和命名规范。1.3 名字里藏着的三条原则Type、Tidy、Test取名 t3code 不是灵光一现而是把三个词各取首字母拼出来的Type、Tidy、Test。这三个词也正好是我对可复用代码的理解。Type 指的是类型先行。我写实验代码的时候经常图省事能跳过类型就跳过。但 t3code 用 TypeScript Compiler API 做解析时就发现如果源码里有明确的类型标注生成文档和测试的准确率会提高很多。没有类型信息的函数生成的测试只能做基础冒烟根本无法校验参数边界。既然要做面向复用的工具类型信息就必须成为一等公民。Tidy 是整洁指的是输出的文档结构必须清晰。我并不追求代码本身一定优雅但生成出来的报告必须让人扫一眼就知道这里有什么、怎么用。Test 则兜底保证整理出来的东西是真的能跑而不是又一份仅供参考的纸面文档。这三个词放在一起就成了 t3code 的核心理念用整理对抗失控用测试保障复用。2. 技术选型与架构设计我为什么这样搭架子2.1 技术栈Node.js TypeScript 不是唯一解但最适合我一开始我也纠结过技术栈想过用 Python也想过用 Go。最后选了 Node.js 加 TypeScript原因很实际。第一t3code 要解析的主要对象是 TypeScript 和 JavaScript 代码。Node 生态里直接有官方提供的typescript包里面包含了完整的 Compiler API我们没必要重复造轮子。用 Python 解析 TypeScript 代码虽然也有现成库但语法树的信息完整度和更新速度都不如官方 SDK。第二工具本身需要跑在各种开发机上Node 的安装覆盖面很广前端工程师几乎人手一份。第三jest、vitest 这些测试生态就是围绕 Node 建的生成测试文件之后直接调用测试命令体验非常顺滑。Go 的分发确实更好能编译成单个二进制文件用户不用装 Node。但回到需求本质t3code 的目标用户是在本地跑实验代码的开发者基本都有 Node 环境为了分发便利去选 Go意味着我要重新实现一套 TypeScript AST 解析成本太高了。工具开发的收益比很重要我倾向在已有生态上做增量。2.2 CLI 框架与核心依赖五件套的选择理由t3code 依赖的核心库不多我列一下实际使用的东西以及选择理由依赖作用为什么选它commander命令行参数解析成熟稳定子命令支持完善拼参数足够直观prompts交互式提问体积小交互反馈舒服init 命令里引导配置很合适typescriptTypeScript Compiler API官方 SDK负责 AST 解析、类型信息提取准确率最高ejs模板渲染语法简单Markdown 模板写起来一目了然picocolors终端输出上色比 chalk 更轻量日志区分度够用这里没有选择诸如ts-morph这样的高层封装库原因是它封装太厚很多 AST 细节不够透明。我在开发过程中需要频繁调试语法树节点直接用 Compiler API 反而好排查。校验命令也只用vitest作为外部调用对象通过 execa 在子进程里跑。把命令和运行时解耦用户想接 jest 或 node:test 都可以只要换成对应的--runner参数就行。这种依赖组合还有一层考量不要因为工具小就随意堆依赖。每个依赖都承担一个明确职责升级迁移的时候只需要盯住这几个包。实际用下来这个选型平衡了开发速度和维护成本我觉得是对的。2.3 架构与数据流从源码到文档经历了什么t3code 的架构可以拆成五个阶段我后来在文档里画成了一张数据流图读取阶段、解析阶段、结构化阶段、渲染阶段、校验阶段。读取阶段负责遍历目录按扩展名过滤出需要处理的文件默认支持 ts、tsx、js、mjs、cjs。解析阶段最重要用 TypeScript Compiler API 把源码转成抽象语法树在这一层拿到的不是正则匹配的文本碎片而是真正理解语义的节点。结构化阶段将 AST 中提取的导出声明、JSDoc 注释、import 语句、类型标注组装成统一对象。渲染阶段拿到结构化对象后根据用户指定的格式交给 EJS 模板或 JSON 序列化器。校验阶段则读取生成物的路径调用测试框架执行。这个数据流最重要的特点就是阶段之间完全不互相污染解析阶段不知道后面要渲染成 Markdown 还是 JSON渲染阶段也完全不碰源码只消费结构化对象。好处是扩展新输出格式不用改解析逻辑同理如果要支持 Python 源码也只需要新增一个新的解析器实现后面全部复用。我当时在架构上花费最多的时间就是理清这层边界目前看值得。3. 核心功能实现手把手拆开 t3code 的每一层3.1 命令设计为什么拆成 init、scan、test、cleant3code 的命令拆得比较克制目前只有四个init、scan、test、clean。没有像一些大工具那样动辄十几个子命令因为我发现实验代码整理场景其实很简单命令多了只会让用户记住更多不需要记的东西。init用于生成配置文件默认交互式询问你输入目录、输出目录、要忽略的文件夹列表。会生成一个 t3code.config.jsonscan 和 test 命令都会读取它当然也可以在命令行直接传参覆盖。这样设计是为了让第一次使用的用户能快速进入状态不用先读一遍长篇文档。scan是主命令做的是扫描、解析、渲染三件事。参数大致如下t3code scan \ --input src/experiments \ --output docs/reports \ --format md \ --ignore node_modules,dist,.gittest会在扫描后直接生成测试文件并尝试运行这个命令让生成测试和确认测试真的能跑在一步内完成。我一开始也想过要不要把逻辑全部塞进 scan 里加个--with-test参数后来发现这样会让主命令承担太多职责。测试生成失败的时候你就分不清到底是扫描出错了还是测试运行环境有问题拆开之后日志会清晰很多。clean则是移除所有生成物。它读取配置文件里的 output 路径逐个删除本质上是个安全删除器。设计这个命令是考虑到实验代码目录会频繁重跑留一份旧报告容易造成混淆。3.2 解析器实现别用正则用 TypeScript Compiler API我第一次写 t3code 时确实犯过傻想用正则去匹配函数定义和注释结果被各种含嵌套花括号、字符串模板、装饰器的代码直接打穿。后来全部推倒改用 TypeScript Compiler API才真正稳定下来。核心思路很简单用ts.createSourceFile把源码转成 SourceFile 节点然后遍历 statements 或者直接查导出声明。下面是我实际使用的简化版本import ts from typescript; import { readFileSync } from node:fs; interface ExportInfo { name: string; kind: string; comment: string; dependencies: string[]; } export function analyzeSource(filePath: string): ExportInfo[] { const code readFileSync(filePath, utf-8); const sourceFile ts.createSourceFile( filePath, code, ts.ScriptTarget.Latest, true, filePath.endsWith(.tsx) ? ts.ScriptKind.TSX : ts.ScriptKind.TS ); const result: ExportInfo[] []; for (const statement of sourceFile.statements) { let name: string | undefined; let kind unknown; if ( ts.isFunctionDeclaration(statement) statement.modifiers?.some((m) m.kind ts.SyntaxKind.ExportKeyword) ) { name statement.name?.text; kind function; } else if (ts.isClassDeclaration(statement) statement.modifiers?.some((m) m.kind ts.SyntaxKind.ExportKeyword)) { name statement.name?.text; kind class; } else if (ts.isVariableStatement(statement)) { // 处理 export const / export let 场景 } else if (ts.isExportDeclaration(statement)) { // 处理 export { foo } from ./bar 场景 } if (!name) continue; const comment ts.getJSDocCommentsAndTags(statement) .map((c) c.getText()) .join(\n); result.push({ name, kind, comment, dependencies: collectImports(sourceFile) }); } return result; }用 Compiler API 的好处是它天然支持复杂的语法泛型、可选链、装饰器、同名导出重载甚至 tsx 文件里的 JSX 语法。你完全不用担心正则匹配漏掉哪一行。唯一要留意的是内存消耗如果你扫描一个超大目录建议用 worker 或分批读取不过普通实验目录基本不在乎。我还有一个小心得解析阶段不要试图做太多智能判断比如帮用户自动总结函数用途。这类语义理解很容易出错宁可把 JSDoc 原文提取出来让用户自己写清楚也不要生成一堆看起来有逻辑其实没用的废话。3.3 模板渲染与输出Markdown 才是默认第一公民渲染层我选了 EJS因为 Markdown 模板本身是纯文本用 EJS 写循环和条件非常顺手。核心模板大概长这样# % projectName % 代码报告 生成时间% generatedAt % % modules.forEach((mod) { % ## % mod.filePath % ### 导出符号 % mod.exports.forEach((exp) { % - **% exp.name %**% exp.kind % - 注释% exp.comment || 暂无注释 % - 依赖% exp.dependencies.join(, ) || 无 % % }) % % }) %输出格式我用--format控制支持 md、json、text 三种。其中 md 是默认值因为绝大多数团队现在都依赖 Markdown 知识库无论是飞书、语雀还是 GitHub Wiki直接粘贴就能用。JSON 格式主要是给后续自动化脚本消费的text 则是在终端里快速浏览用的摘要格式。这里有个细节值得说不要为了格式丰富去支持 HTML 或 PDF。我一开始也想支持 HTML 导出后来发现维护成本翻倍而且实验代码报告根本不需要那么强的排版能力。砍掉之后渲染层逻辑从三套变成两套反而更稳。与其什么都支持不如把少数格式做扎实。3.4 测试生成让每段实验代码都有冒烟测试测试生成是 t3code 里最有特色的一部分。它不是为了生成全面覆盖的测试而是确保被整理的代码至少能导入、能调用、不崩。我在实践中发现很多代码写着能用只是因为当时运行环境的巧合换个环境立刻歇菜。生成逻辑是针对每个导出函数生成一个测试文件里面自动导入该函数并生成几个基础断言。比如遇到一个normalizeUrl函数生成的测试大概是这样的import { describe, it, expect } from vitest; import { normalizeUrl } from ../experiments/normalizeUrl; describe(normalizeUrl, () { it(should be callable with default params, () { expect(typeof normalizeUrl).toBe(function); }); it(should return a value when called, () { const result normalizeUrl(https://example.com/); expect(result).toBeDefined(); }); });如果函数有显式的参数类型或默认值我还会尽量生成一个合理的调用示例参数类型是 string 就传空字符串是 number 就传 0。这样生成的测试在最坏情况下也能跑通用户可以在其基础上扩展边界用例。处理默认导出和异步函数时逻辑会多一些默认导出统一用import target from ./xxx异步函数则用await target()包裹。有几次我生成的测试里忘了处理 Promise导致一大批测试直接超时后来在生成器里加了isAsync判断才好。这个细节几乎每个用 t3code 的人都会遇到但官方文档不会写你只有跑了才知道。4. 从本地脚本到开源小工具完整落地流程实录4.1 开发环境与调试流程tsx watch 让迭代进入快车道开发 t3code 时我用了很简单的环境Node 20 加 pnpm。构建工具用的是 tsup开发时用 tsx watch 做热重载。主要工作流是这样的起一个终端跑tsx watch src/index.ts起另一个终端做各种扫描实验。这样改完代码立马能在终端看到效果省去手动编译的等待时间。配置文件里把入口指向src/index.tsCLI 名暂定为dev-t3等正式发布再替换{ scripts: { dev: tsx watch src/index.ts, build: tsup src/index.ts --format esm,cjs --clean, test: vitest run, prepublishOnly: npm run build npm test } }开发过程中最常用的调试命令是dev-t3 scan --input ./tmp/test-fixture --output ./tmp/out --format md。我在tmp/test-fixture里放了很多故意写得比较刁钻的样例文件包括带泛型的函数、默认导出、循环依赖、JSX 混淆。每次解析器改动我先跑这些 fixture确认旧功能没有回退再继续加新逻辑。这种固定夹具回归的方式帮我抓了不少 bug新手做工具开发时建议也建一套自己的测试 fixture别天真地去拿真实项目做验证。4.2 npm 发布配置bin、files、exports 这些坑一次填平把本地工具变成 npm 全局命令关键就在 package.json。最核心的字段是bin它决定了用户在安装后能敲哪个命令。我是直接指定到编译产物{ name: t3code, version: 0.3.1, type: module, bin: { t3code: ./dist/index.js }, files: [dist, templates], exports: { .: { import: ./dist/index.js, require: ./dist/index.cjs } }, engines: { node: 18 } }第一个坑是 shebang。入口文件的头部必须加上#!/usr/bin/env node否则 npm 安装后的命令无法直接执行。这个很容易被忽略加了之后还要记得在构建时保留 it我自己就曾在 tsup 打包时把 shebang 去掉过一次调试了很久才发现。第二个坑是files白名单。如果你的模板文件不在files列表里用户全局安装后在 EJS 渲染阶段就会找不到模板。我第一次发布就漏了templates目录导致有用户安装后报错找不到模板后来紧急发了一个补丁版本才修复。由此我养成了一个习惯每次发布前先npm pack --dry-run看一下产物列表确认模板文件真的在包里。第三个坑是 CJS 和 ESM 双格式。如果只发 ESM老项目的require(t3code)就会直接挂掉。用 tsup 的--format esm,cjs能同时产出两种格式再配合exports字段的分发规则大多数场景都能覆盖。这里的经验就是发布前在本地用两个环境真实验证不要只看文档。4.3 真实演示扫描一个实验目录并生成报告我拿一个真实的实验目录来演示完整流程。假设目录结构如下src/experiments/ ├── normalizeUrl.ts ├── debounce.ts └── fetcher/ └── request.ts先安装 t3codenpm i -g t3code然后在项目根目录生成默认配置t3code init --dir src/experiments --out docs/reports终端会出现一次交互询问确认后生成 t3code.config.json。接着跑主命令t3code scan扫描完成后docs/reports下会出现两个文件experiments-report.md和experiments-report.json。随便看一段生成的 Markdown## src/experiments/normalizeUrl.ts ### 导出符号 - **normalizeUrl**function - 注释将输入的 URL 标准化为合法链接。 - 依赖无 ## src/experiments/debounce.ts ### 导出符号 - **debounce**function - 注释返回一个带有防抖逻辑的新函数。 - 依赖无确实很简单但已经很管用了。至少我现在回忆一个目录里有什么不用再逐个文件去翻直接看报告就行。接着生成测试并运行t3code test --input src/experiments --runner vitest它会生成tests/generated/下的测试文件并执行 vitest。跑完会提示哪些函数导入失败哪些能正常调用。这一步帮我发现了几个当时能用但换个 Node 版本就爆的代码更坚定了我加测试生成功能的决定。5. 踩坑记录与排查技巧给后来者的排雷手册5.1 Compiler API 版本碎片化锁版本是最省心的选择TypeScript 的 Compiler API 其实没有完全稳定的对外保证不同小版本之间AST 节点的判断方式可能会有细微差别。我一开始直接用 peerDependencies 里的 typescript 版本结果用户环境装了 TS 5.4我自己本地用的是 5.3解析同一个文件时结果不同。后来我把typescript直接放进 dependencies 锁定版本而不是放在 peerDependencies。虽然这让工具包体积变大了一些但换来的是解析行为的一致性。用我自己的话说写代码解析工具最忌讳的就是被宿主环境的 TS 版本绑架。如果你也要做类似工具建议直接锁一个具体版本号不要用^5.0.0这种范围。5.2 Windows 路径与文件编码小工具最容易翻车的地方开发时我一直在 macOS 上跑觉得一切正常。直到有一天下班前在一个 Windows 机器上演示发现生成的报告路径全是反斜杠文件还莫名出现了乱码。查了半个小时才定位到是路径分隔符和文件编码两个问题叠加。路径问题用path.relative加统一转/解决比如relPath.split(path.sep).join(/)。编码问题更隐蔽很多 Windows 上的旧文件不是 UTF-8 编码readFileSync默认按 utf-8 解析会直接乱码。我的处理是先尝试 utf-8发现替换字符\uFFFD后自动退回latin1解码虽然牺牲了一点精确性但至少不再出现整个文件崩溃的情况。你如果也在做类似工具千万别假设所有源码文件都是干净的 UTF-8。5.3 ESM/CJS 双格式npm 老用户不能放弃发布阶段最大的坑我已经提过但值得再说一次如果exports字段写得不对有些老项目通过require(t3code)加载时会直接抛 ERR_PACKAGE_PATH_NOT_EXPORTED。特别是那些还在用 CommonJS 的用户他们的项目没有type: module对 ESM 解析有天然障碍。我在 0.2.0 版本时只发了 ESM结果有用户反馈说他们的工具脚本无法引入。虽然我用一句请升级到支持 ES Module 的 Node 版本可以搪塞过去但那不是解决问题的态度。后来加了 CJS 产物才算是真正把老用户顾及到了。给所有想发布 npm 工具的人建议无论你的工具多新双格式发布基本是底线除非你只用内部私有源。5.4 常见错误速查表我把实际运营中遇到的报错整理成一个速查表放在项目 README 里这里也贴一份错误信息产生原因解决办法Cannot find module typescript依赖未正确安装或 peerDependencies 冲突将 typescript 固定为 dependencies 并重新安装ENOENT: no such file or directory ... .ejs模板文件未被 npm 包带出在 package.json files 中显式加入 templates 目录Missing shebang in bin script构建时丢掉了#!/usr/bin/env node检查 tsup 配置开启 banner 或在源头文件加入 shebangSyntaxError: Unexpected token export老环境只支持 CommonJS确保发布了 CJS 产物并正确配置 exports 字段Path is not absolute ...配置使用了错误拼接方式用 path.resolve 处理 input/output拒绝裸字符串拼接Generated test timeout异步函数未使用 await 包裹在测试生成逻辑中增加 isAsync 判断5.5 一个不断提醒自己的心得我维护 t3code 这几周最大的体会是工具不要贪大求全把用户最痛的一两个场景做透比塞进去一堆花哨功能重要得多。最初我还想加自动代码统计可视化图表等功能后来全部砍掉了。不是因为做不出来而是这些功能让测试生成这个核心路径变得很臃肿每次改动都要多回归很多地方。砍完之后工具体量小了用户反而更容易理解怎么用。另外如果你打算用 t3code 做日常整理建议在你的 shell 配置里加一个 alias比如alias t3t3code scan --input src/experiments --output docs/reports。这样你每天只需要敲两个字母就能完成一次代码盘点。等你的实验目录积累到几十个模块的时候就会发现这种低摩擦的整理动作才是让代码资产活起来的关键。