开源后第一天,我被自己的 npm 包啪啪打脸 5 次
Dogfood 第一天:5 个 smoke 测试到达不了的真实盲区
@ruobai/lingshu 上线日的 5 个真实 bug 复盘
我把灵枢架构(LingShu)开源到 GitHub,把它的官方脚手架 @ruobai/lingshu 发布到 npm 公网。
发布前我自信满满:
- 5 个 smoke 测试全过
- 11 文件零依赖纯 Node.js
- npm pack –dry-run 看了,模板内容齐全
- 完整 README + 中文文档 + 双语 About
然后第一个用户跑了 lingshu init demo1 —— 翻车。
之后是连续 5 轮迭代:v0.2.2 → v0.2.3 → v0.2.4 → v0.2.5 → v0.2.6。每一版修一个真实 bug,每一个 bug 我事先都没意识到它存在。
这篇文章把这 5 个 bug 一个个拆开看。它们的共同点是:
smoke 测试根本测不到 —— 因为我测试的环境,永远不是用户拿到包之后的那个环境。
Bug #1:copyTemplate 把整个模板都过滤掉了(v0.2.2)
现象
第一个用户在 Linux 上跑:
1 | $ lingshu init demo1 |
诡异:模板”已拷贝”,但下一步说找不到模板里明明存在的文件。
根因
我去翻 copyTemplate 的实现:
1 | cpSync(srcDir, dstDir, { |
filter 用 完整源路径 判断 node_modules。
我本地测试的源路径是:
1 | D:/projects/lingshu-cli/templates/default/.lingshu/config/adapters.mjs |
不含 node_modules,过滤通过 ✅
但用户全局装 npm 包后,源路径变成:
1 | /usr/lib/node_modules/@ruobai/lingshu/templates/default/.lingshu/config/adapters.mjs |
整个路径都包含 node_modules —— filter 把全部模板文件都过滤了。
cpSync 不会因为 0 文件被拷贝而报错,所以”模板已拷贝”是假成功,目标目录其实是空的。下一步找不到 adapters.mjs 才暴露问题。
修复
filter 改为只看 src 相对 srcDir 的部分:
1 | const rel = abs.slice(srcRoot.length + 1).split(sep).join('/'); |
无论 CLI 装在哪,都不再误杀。
教训
本地开发的路径几乎从不含
node_modules,但 npm 全局安装的路径必然含 —— 这种路径敏感的逻辑,本地永远测不出来。
smoke 测试的盲区不是”我没写这个测试”,是”我根本想象不到这个场景存在”。
Bug #2:init 默默给你装了 4 个你没要的 AI 工具(v0.2.3)
现象
第二个用户 init 之后 ls 一看,目录里多出 4 个不认识的隐藏目录:
1 | .agent/ .cursor/ .qoder/ .trae/ |
他困惑:”我没让你装这些啊?”
根因
我之前定的策略明明白白:
baseline 工具(Claude Code、Codex)的产物入库,personal 工具(Cursor / Trae / Qoder / Antigravity)由开发者本地按需生成。
策略写在 README 里,写得漂亮。
但代码里 init 调用 distribute({ projectRoot }) —— 没传 baselineOnly 参数。distribute 默认遍历全部适配器,包括 personal 工具的产物目录。
写在文档里的策略,和写在代码里的策略,完全是两件事。
修复
1 | const result = await distribute({ |
新增 --all-tools flag 让需要全装的用户显式触发。默认行为变干净:只生成 CLAUDE.md 和 AGENTS.md,其它工具按需 lingshu sync --only=cursor。
教训
默认行为要符合”最小惊讶原则” ——你不能让用户跑一条命令,然后给他装一堆他根本没声明要的东西。
约定要靠代码强制,不是靠 README。
Bug #3:sync 默认还是粗暴全装(v0.2.4)
现象
修了 #2 之后,用户 init 完干干净净。然后他跑了一句 lingshu sync —— 桌面又出现了 .agent/.trae/.qoder/.cursor。
“不是修过了吗?”
根因
init 修了,但 sync 没修。sync 默认 = 全装 personal 工具的产物。用户想要的语义其实是:
- baseline 必须装(CLAUDE.md / AGENTS.md)
- personal 工具:已经在用的继续维护,没用过的别打扰
也就是说,”已激活” vs “未激活” 的概念以前从未在代码里被表达过。
修复
引入 auto 模式作为默认:
1 | if (tools) { |
“产物目录已存在”= 用户已经激活了这个工具。这是约定俗成的隐式语义,不需要新增 enabled 状态字段。
典型流变成:
1 | lingshu sync # 平时这一条就够,不动未激活工具 |
教训
隐式语义有时比显式状态字段更优雅。 我本来想加
enabled列表 +tool enable/disable子命令,但”产物存在性”已经天然表达了这个状态,无需新增元数据。
Bug #4:派生项目居然没有 .gitignore(v0.2.5)
现象
用户 ls 项目目录,眼尖发现:
1 | rui@rui-ubt:demo4$ ls -la |
我惊讶:”不可能啊,模板里明明有 .gitignore,npm pack 也看到了。”
根因
我去翻 npm pack 的输出:
1 | $ npm pack --dry-run | grep -i ignore |
只有一条 warn,没有任何 .gitignore 进入 tarball。
那条 warn 我以前看到过,没在意。读一下 npm 文档才明白:
当包内没有
.npmignore时,npm 会用.gitignore作为排除规则的 fallback —— 同时把.gitignore自身也排除掉。
也就是说:模板里的 .gitignore 自己被 npm 吞了,永远进不了发布的包。
这意味着 v0.2.0 ~ v0.2.4 全部派生项目都没有 .gitignore。肢体仓不被忽略、node_modules/ 不被忽略、.env 不被忽略 —— 满地是雷。
修复
经典的 _gitignore 招(yeoman / create-react-app / vite 都用过):
- 模板内文件改名
_gitignore - npm publish 不再吞它
init命令拷贝完模板后,把_gitignorerename 回.gitignore
1 | const stowed = join(targetDir, '_gitignore'); |
教训
npm 的隐藏行为不读到死人。 一条 warn 信息我看了 6 个版本,每次都觉得”这个不影响功能”,直到第 5 天才意识到它在告诉我”你的 .gitignore 被吞了”。
而且这个 bug 影响所有派生项目,是用户量乘以严重度最高的那种 —— 越早开源越好,否则积累的”无 .gitignore”项目越多,后续兜底越难。
Bug #5:非约定命名的肢体仓不被忽略(v0.2.6)
现象
用户跑:
1 | $ lingshu limb init demo5-abc # 我故意用了非约定命名 |
根因
模板的 .gitignore 用了通配规则忽略肢体仓:
1 | *-server/ |
但只覆盖约定命名。用户用 demo5-abc/ 这种名字,通配匹配不到,于是 git 老老实实跟踪。
灵枢的命名约定是建议、不是硬约束。limb 命令也只 warn 提示。但工具不能”提示完就甩锅” —— 用户既然让你创建了肢体,你就该让它正确工作。
修复
limb add / init / adopt 三个子命令,成功后都调用一次 ensureLimbIgnored(root, name):
1 | function ensureLimbIgnored(projectRoot, name) { |
约定命名仍走通配(不重复追加);非约定命名自动追加一行。幂等。
教训
约定是建议,不是硬约束。 工具应当优雅地处理偏离约定的合法用法 —— 而不是甩个 warn 给用户,等他自己去 .gitignore 加规则。
一些副作用
5 轮迭代下来,副带的产物:
测试覆盖从 5 → 11 条,每条都对应上面这 5 个 bug 的回归断言:
- 源路径含
node_modules时模板仍能拷贝 - init 默认 baseline-only
- sync auto 模式区分已激活/未激活
- init 后
.gitignore必须存在且含关键规则 - limb 自动维护 .gitignore(约定命名跳过、非约定追加)
每个断言都是一个用真实 bug 的代价换来的护栏。比一次”补全 80% 覆盖率”的运动式刷数字有用得多。
Dogfood 的方法论
回头看,这 5 个 bug 没有一个是”我想得到但没写”的。它们都是**”我想象不到这个场景存在”**:
- 我从没意识到 npm 全局安装路径里都有
node_modules - 我从没意识到
.gitignore会被 npm 吞掉 - 我从没意识到用户会用非约定命名的肢体仓
- 我从没意识到”装了 cursor 但又跑 sync 默认值”会变成强行污染
这些场景不是测试覆盖率能覆盖的。它们是”测试集本身的盲点”。
让用户进入测试集,是唯一的破解办法。
smoke 测试的边界 = 你能想到的场景的边界。
dogfood 的边界 = 真实世界本身。
我以前总觉得”先把测试写完再开源”。这次开源完才发现:开源就是测试。第一天 5 个真实 bug,胜过一周关起门来猜测。
写在最后
灵枢架构(LingShu)是为 AI 原生开发设计的中枢-肢体解耦架构 —— 逻辑收敛于中枢,执行弥散于全栈。
CLI 工具 @ruobai/lingshu 一条命令初始化完整的项目骨架:
1 | npm install -g @ruobai/lingshu |
GitHub 仓库已开源 MIT 协议:
- 中枢模板:https://github.com/imrui/lingshu-template
- 脚手架 CLI:https://github.com/imrui/lingshu-cli
- npm 包:https://www.npmjs.com/package/@ruobai/lingshu
如果你也在搭 AI 编码工具的工程化基础设施,欢迎试用并提反馈。每一个 issue,都比你想象的更有杠杆。
若白知行 · Rubai AI