开源后第一天,我被自己的 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
2
3
4
5
6
7
8
9
$ lingshu init demo1
▶ 拷贝模板
✓ 模板已拷贝 ← 看起来成功了

▶ 注入项目身份
✓ 占位符已替换为 demo1

▶ 生成基线工具产物
✗ 未找到 .lingshu/config/adapters.mjs(不是灵枢项目?)

诡异:模板”已拷贝”,但下一步说找不到模板里明明存在的文件。

根因

我去翻 copyTemplate 的实现:

1
2
3
4
5
6
7
8
cpSync(srcDir, dstDir, {
recursive: true,
filter: (src) => {
const lower = src.toLowerCase();
if (lower.includes('node_modules')) return false; // ⚠️ 这里
// ...
}
});

filter 用 完整源路径 判断 node_modules

我本地测试的源路径是:

1
D:/projects/lingshu-cli/templates/default/.lingshu/config/adapters.mjs

不含 node_modules,过滤通过 ✅

但用户全局装 npm 包后,源路径变成:

1
2
/usr/lib/node_modules/@ruobai/lingshu/templates/default/.lingshu/config/adapters.mjs
^^^^^^^^^^^^^

整个路径都包含 node_modules —— filter 把全部模板文件都过滤了

cpSync 不会因为 0 文件被拷贝而报错,所以”模板已拷贝”是假成功,目标目录其实是空的。下一步找不到 adapters.mjs 才暴露问题。

修复

filter 改为只看 src 相对 srcDir 的部分:

1
2
const rel = abs.slice(srcRoot.length + 1).split(sep).join('/');
if (rel.includes('/node_modules/')) return false;

无论 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
2
3
4
5
const result = await distribute({
projectRoot: targetDir,
baselineOnly: !allTools,
all: allTools,
});

新增 --all-tools flag 让需要全装的用户显式触发。默认行为变干净:只生成 CLAUDE.mdAGENTS.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
2
3
4
5
6
7
8
9
10
11
12
13
if (tools) {
targets = tools; // --only=<list>
} else if (baselineOnly) {
targets = baseline; // --baseline
} else if (all) {
targets = Object.keys(adapters); // --all
} else {
// auto = baseline + 已存在产物的 personal
targets = Object.keys(adapters).filter((name) => {
if (baseline.includes(name)) return true;
return existsSync(join(projectRoot, adapters[name].target));
});
}

“产物目录已存在”= 用户已经激活了这个工具。这是约定俗成的隐式语义,不需要新增 enabled 状态字段。

典型流变成:

1
2
3
4
lingshu sync                # 平时这一条就够,不动未激活工具
lingshu sync --only=cursor # 首次接入 cursor(激活 + 生成产物)
lingshu sync # 之后 .cursor 自动维护
lingshu sync --all # 偶尔想全装时显式

教训

隐式语义有时比显式状态字段更优雅。 我本来想加 enabled 列表 + tool enable/disable 子命令,但”产物存在性”已经天然表达了这个状态,无需新增元数据。


Bug #4:派生项目居然没有 .gitignore(v0.2.5)

现象

用户 ls 项目目录,眼尖发现:

1
2
3
4
5
6
7
8
9
rui@rui-ubt:demo4$ ls -la
-rw-rw-r-- AGENTS.md
-rw-rw-r-- CLAUDE.md
drwxrwxr-x .git/
drwxrwxr-x .github/
drwxrwxr-x .lingshu/
-rw-rw-r-- README.md
drwxrwxr-x reference/
^^^^^^ 没有 .gitignore

我惊讶:”不可能啊,模板里明明有 .gitignore,npm pack 也看到了。”

根因

我去翻 npm pack 的输出:

1
2
3
$ npm pack --dry-run | grep -i ignore
npm warn gitignore-fallback No .npmignore file found,
using .gitignore for file exclusion.

只有一条 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 命令拷贝完模板后,把 _gitignore rename 回 .gitignore
1
2
3
4
5
6
7
8
9
10
const stowed = join(targetDir, '_gitignore');
const real = join(targetDir, '.gitignore');
if (existsSync(stowed)) {
if (existsSync(real)) {
// --here 时目标可能已有用户的 .gitignore,保留
rmSync(stowed);
} else {
renameSync(stowed, real);
}
}

教训

npm 的隐藏行为不读到死人。 一条 warn 信息我看了 6 个版本,每次都觉得”这个不影响功能”,直到第 5 天才意识到它在告诉我”你的 .gitignore 被吞了”。

而且这个 bug 影响所有派生项目,是用户量乘以严重度最高的那种 —— 越早开源越好,否则积累的”无 .gitignore”项目越多,后续兜底越难。


Bug #5:非约定命名的肢体仓不被忽略(v0.2.6)

现象

用户跑:

1
2
3
4
$ lingshu limb init demo5-abc       # 我故意用了非约定命名
$ git status
未跟踪的文件:
demo5-abc/ ← 没被忽略

根因

模板的 .gitignore 用了通配规则忽略肢体仓:

1
2
3
4
*-server/
*-ui/
*-app/
*-mobile/

但只覆盖约定命名。用户用 demo5-abc/ 这种名字,通配匹配不到,于是 git 老老实实跟踪。

灵枢的命名约定是建议、不是硬约束。limb 命令也只 warn 提示。但工具不能”提示完就甩锅” —— 用户既然让你创建了肢体,你就该让它正确工作。

修复

limb add / init / adopt 三个子命令,成功后都调用一次 ensureLimbIgnored(root, name)

1
2
3
4
5
6
7
8
9
10
11
12
13
function ensureLimbIgnored(projectRoot, name) {
const giPath = join(projectRoot, '.gitignore');
if (!existsSync(giPath)) {
log.hint(`项目无 .gitignore,建议创建并加入 "${name}/"`);
return;
}
const content = readFileSync(giPath, 'utf8');
if (isIgnoredByPatterns(name, content)) {
return; // 已被通配覆盖,幂等
}
appendFileSync(giPath, `${name}/\n`);
log.ok(`已在 .gitignore 追加 "${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
2
npm install -g @ruobai/lingshu
lingshu init my-app

GitHub 仓库已开源 MIT 协议:

如果你也在搭 AI 编码工具的工程化基础设施,欢迎试用并提反馈。每一个 issue,都比你想象的更有杠杆。

若白知行 · Rubai AI