
上周五晚上十一点我盯着终端里一条Cannot find module的报错心里只有一个念头这个锅一半是 Claude Code 的另一半是我自己的。那是一次再普通不过的 Node.js 包发布用 Claude Code 写完新功能、本地构建测试全部通过结果包发出去之后在干净环境里一运行就崩。后来我花了整整一个周末复盘把所有时间线上的坑整理成一套检查规则最后做成了一个小工具取名 Refinex-Code。这篇文章就是这场打包事故的完整记录外加 Refinex-Code 的设计思路和实现拆解。如果你也在用 Claude Code 或者任何 AI 编码助手写业务代码并且已经做过几次“构建通过但发布后跑不起来”的经历这篇内容能帮你少踩几个坑。我会把事故现场、根因分析、工具设计、核心代码和接入方式都讲清楚方便你直接在自己的项目里复现和使用。1. 事故复盘一次“本地全绿、线上崩溃”的发布1.1 事故发生的环境和前置操作先说项目背景免得后面读起来没有落点。这是一个中型 TypeScript 项目目标是一个 npm 包内部有几个业务模块平时用 pnpm 管理依赖发布走pnpm publish。项目的构建脚本是标准的三板斧rimraf dist清空产物、tsc -p tsconfig.json编译、然后由prepack钩子执行一些收尾检查。事故发生前我让 Claude Code 实现了一个新模块导出某个报表组件和对应的数据类型并且要求它同步更新package.json的exports字段让使用方可以按子路径导入。Claude Code 很快写完了代码改了配置本地跑测试也都通过。我当时顺手执行了npm run build npm pack --dry-run看输出dist 目录下文件齐全tar 包内容列表也没发现问题就正常pnpm publish了。问题出在发布后的验收环节。我习惯每次发包之后用一个全新的目录从 tarball 安装再跑一遍冒烟脚本。这次在/tmp/verify下执行npm install --prefix /tmp/verify ./my-report-sdk-1.4.0.tgz node -e require(my-report-sdk/feature/report)结果直接报出Error: Cannot find module my-report-sdk/feature/report Require stack: - /tmp/verify/node_modules/my-report-sdk/dist/index.js我当时的第一个反应是“Claude Code 改坏了 exports”。但仔细一想不对因为它在本地已经把exports字段写对了而且我从仓库根目录用相对路径访问dist/feature/report.js也是成功的。那为什么通过包名一访问就失败呢1.2 从“本地通过”到“线上报错”的完整链路这其实是一个典型的路径契约问题。dist/feature/report.js确实存在于产物目录中但package.json里exports字段写的是{ exports: { ./feature/report: { types: ./dist/feature/report.d.ts, import: ./dist/feature/report.js, require: ./dist/feature/report.js } } }单看这配置没有任何问题。但 Clauude Code 在改代码的时候同时调整了源码结构它把src/feature/report.ts重构成了src/feature/report/index.ts并且在一次构建中重建过dist/feature/report/index.js。我只能说它在改源码入口时的确生成了新的目录结构但漏掉了同步更新package.json里的 exports 目标路径。于是exports还指向旧的./dist/feature/report.js而实际产物已经变成了./dist/feature/report/index.js。为什么会漏因为我在本地验证时用的是相对路径直接require(./dist/feature/report)Node 的模块解析会自动补全.js、/index.js。但一旦通过包名触发exports映射Node 就会严格按照exports字段里的字符串去解析不会再去猜index.js或扩展名。同一个文件两种访问方式一个通一个挂。1.3 回滚后按时间线梳理的排查记录事故之后的处理流程很简单先撤销发布版本回滚到 1.3.9保住线上使用方然后开始按时间线排查。我列了一张表记下每一步操作和对应的异常点时间点操作结果说明开发阶段Claude Code 重构入口路径单元测试全过测试只静态引用了模块没验证 npm 包名导入构建阶段npm run build成功tsc 能编译不代表 exports 契约正确包装阶段npm pack --dry-run文件列表看起来正常没检查目录结构和 exports 目标是否匹配发布后干净环境安装并按子路径导入失败暴露 exports 指向过时路径回滚发布原版本成功恢复线上稳定真正难堪的是这场事故完全可以在发布前被拦截。我需要的是一个“以 package.json 为契约、以最终构建产物为事实”的检查器而不是又一个 lint 工具。这个念头后来直接长成了 Refinex-Code。2. 根因分析Claude Code 生成产物为什么总在打包环节翻车2.1 AI 编码的典型盲区只改逻辑不改发布元数据用 Claude Code 写过一阵子代码的人应该都有同感它非常擅长在“局部代码层面”做到自洽但往往不关心包发布这个动作背后的边界条件。原因不难理解——模型在生成代码时注意力主要放在源码语义、类型推导和函数调用关系上它看到的是一个完整的源码仓库而 npm 发布出去的只是构建后的一个子集这两者之间隔着package.json、构建配置、文件白名单和产物目录结构这四道关卡。我不是说 Claude Code 不会改package.json。事实恰恰相反它在收到明确指令时会把exports、files、scripts改得让你挑不出毛病。问题在于它不会“主动联想到”这次改动是否会影响发布边界。比如把src/feature/report.ts改成src/feature/report/index.ts对源码层面只是路径变化但对发布产物来说可能意味着exports字段需要同步更新而这一步经常被遗漏。这类问题的本质不是 AI 能力不够而是 AI 的“心智模型”里缺少发布态约束。它默认构建后的结构会和源码结构对称但现实世界里有太多不对称有拷贝静态资源的脚本、有 tsc 的rootDir配置、有构建时替换模块的插件。所有这些都可能让源码路径和产物路径产生偏差。2.2 三个高频打包事故模式复盘这半年踩过的坑我把 AI 参与编码后最容易出现的打包事故归纳成三个模式。第一个是“入口路径错位”。最常见就是上一节描述的场景。AI 改了源码目录结构但没同步改 exports 里的目标路径。子路径导入时Node 拒绝兜底解析。第二个是“构建脚本清场太激进”。不少模板项目为了产物干净习惯在build前先rimraf dist。这样没问题但如果 build 脚本只负责编译 TypeScript不负责拷贝src/assets、.node、.wasm、.prisma这类非编译资源那每次发布都等于只发了一半内容。Claude Code 写出这种“能编译但产物不完整”的脚本我见过不止一次。第三个是“发布内容白名单和实际依赖不一致”。假设files字段写的是{ files: [dist, README.md, LICENSE] }那么dist里如果有动态加载的 JSON 配置、模板文件或者原生模块但目录本身被打包进去倒还好。真正危险的是白名单漏掉某个子目录例如dist/generated而运行时代码却依赖它。本地从源码目录启动一切正常因为dist/generated确实在代码库里但发布时因为白名单精确匹配它不会进 tar 包。2.3 为什么传统 lint 和现有 CI 拦不住这是我最想交代清楚的一点。项目里其实已经有了比较完整的质量闸口ESLint、Prettier、tsc 编译、vitest、还有一套 GitHub Actions 的 CI里面跑了 lint 和测试。可这些工具全部停留在“源码世界”里没有任何一个把“发布后的包”当作检查对象。ESLint 检查的是代码风格tsc 检查的是类型正确测试验证的是业务逻辑。它们都不关心package.json的exports字段是否指向真实存在的产物文件也不关心files白名单是否覆盖运行时依赖的资源。真正能拦截这场事故的检查应该是“发布视角”的把 package.json 的 exports - 产物目录 - npm pack 内容这三层串起来逐项核对。传统 CI 里缺的恰恰就是这个。3. Refinex-Code 的诞生把事故清单拆解成自动化检查规则3.1 复盘后沉淀的第一版规则清单从事故现场回到工位我做的第一件事不是写代码而是把整个时间线里每一个“本可以在发布前发现”的点单独拎出来翻译成可执行的检查规则。这个过程很简单对每个事故点问自己三个问题——它发生在哪一层它可以在哪一步被拦截拦截条件能否用脚本判断最终沉淀出一张初始规则表规则编号检查内容检查对象R01exports 的每个 target 路径必须真实存在于构建产物目录package.json distR02files 白名单中的每个路径必须存在且不能包含源码目录package.json 实际文件系统R03模拟“按包名导入”验证 exports 映射在 Node 解析下可命中package.json distR04prepack 脚本不允许修改 lockfile 等发布核心文件package.json git diffR05非 JS 资源.node、.wasm、.prisma、.pem必须被 files 白名单覆盖package.json 产物扫描我刻意把规则数量控制在五条以内。规则太多会变成形式主义而且每一条都必须能在毫秒级完成否则没人愿意把它挂进发布流程。3.2 命名与定位Refinex-Code 不是什么大平台工具名用了“Refinex-Code”取的是“refine”加上“x”这个常用于扩展插件名的后缀意思是从事故中持续提炼规则让代码发布这件事越做越稳。定位上我明确了两条边界第一它不是一个包管理器替代品也不干预npm pack、pnpm publish的实际执行。它只做发布前的“健康检查”输出一份结构化报告如果发现 error 级别问题就返回非零退出码。第二它不追求覆盖所有场景而是采用“规则可插拔”的设计。新事故发生了就沉淀一条新规则作为独立模块塞进去而不是把判断逻辑硬编码在主流程里。这样工具会跟着项目一起进化而不是写完就过期。3.3 功能规划先解决 80% 的发布问题当时我给自己定的目标是“30 分钟内能接入现有项目”所以 Refinex-Code 的第一个版本只做四件事读取package.json、读取构建产物目录、执行规则检查、输出报告。不引入配置文件加载框架不连接远程服务不搞可视化面板。报告格式直接输出到终端同时支持--report json输出 JSON 文件方便 CI 解析。第一批使用场景也只有两个本地发布前手动跑一遍CI 里在npm pack之前作为阻塞闸口跑一遍。4. 实现拆解Refinex-Code 的核心检查规则与架构设计4.1 整体架构与数据流Refinex-Code 本身是一个 Node.js CLI 工具核心数据流分三个阶段。第一阶段是 Parse。读取package.json和可选配置文件把它们解析成结构化对象。同时对files、exports、scripts等字段做标准化处理。第二阶段是 Verify。拿到构建产物目录的绝对路径遍历已注册的规则执行检查。每条规则都是一个独立的异步函数输入是解析后的 package 信息和产物目录上下文输出是一组 Finding 对象。第三阶段是 Report。把所有 Finding 按error、warning分级格式化输出并根据是否存在 error 决定进程退出码。整体上没有引入复杂框架全部基于 Node 内置的fs、path、child_process实现。一方面是因为这个工具要能在任何 Node 环境里跑起来另一方面是保持依赖极少对使用者来说更容易接受。4.2 核心代码exports 目标路径的遍历与验证R01 规则是整个工具里最关键的一条也是事故的直接原因。先实现一个简化版的 exports 目标收集函数import fs from node:fs import path from node:path type Finding { ruleId: string level: error | warning message: string } function collectExportTargets(pkgExports: unknown): string[] { const targets: string[] [] const walk (value: unknown) { // 字符串类型直接就是目标路径 if (typeof value string) { targets.push(value) return } // 数组类型通常表示候选回退全部收集 if (Array.isArray(value)) { value.forEach((item) walk(item)) return } // 对象类型可能是条件对象也可能是子路径映射 if (value typeof value object) { const obj value as Recordstring, unknown const isConditional Object.keys(obj).every((key) [types, import, require, default, node, browser].includes(key) ) if (isConditional) { Object.values(obj).forEach((item) walk(item)) } } } if (typeof pkgExports string) { walk(pkgExports) } else if (Array.isArray(pkgExports)) { pkgExports.forEach((item) walk(item)) } else if (pkgExports typeof pkgExports object) { Object.values(pkgExports as Recordstring, unknown).forEach((item) walk(item)) } return targets }然后是 R01 规则本身const ruleExportsTargetExist { id: R01, async check(pkg: any, distDir: string): PromiseFinding[] { const findings: Finding[] [] if (!pkg.exports) return findings const targets collectExportTargets(pkg.exports) for (const target of targets) { const cleanTarget target.replace(/^\.\//, ) const filePath path.join(distDir, cleanTarget) if (!fs.existsSync(filePath)) { findings.push({ ruleId: R01, level: error, message: exports 指向 ${target}但构建产物目录 ${distDir} 中不存在对应文件, }) } } return findings }, }这个实现里有个小细节值得说明exports的 target 全部是相对路径以./开头。我在拼接时要先去掉前导./否则path.join会把它当成一个特殊片段处理。这个细节看起来简单但如果直接拿./dist/index.js去拼容易拼出意外的根路径。类似这样的边角案例在真正调试过程中出现过好几轮。R03 规则比 R01 稍微复杂一些它要真正模拟 Node 的模块解析。我没有直接调用child_process去跑node -e require.resolve因为每个 exports 子路径都要单独启动一个进程太慢了。这里我用一个简化策略解析出 target 后先判断文件路径是否真实存在如果路径不存在再检查是否存在同名的.js、.json、.node文件或同名目录下的index.js。这个逻辑完全复刻了 Node 在无 exports 情况下的兜底解析。虽然这个实现没有做到 100% 的 Node 兼容比如没有处理 symlink 场景但对发布前检查这个目的来说已经足够。假如某天遇到一个极端边界情况规则会返回 warning而不是误杀整个发布。4.3 规则的版本化与配置化规则本身是可进化的所以我把每个规则都设计成独立的模块并且统一暴露一个元信息对象。Refinex-Code 初始化时读取refinex.config.json决定每条规则是error、warning还是off。{ $schema: ./refinex.schema.json, dist: dist, rules: { R01: error, R02: error, R03: error, R04: warning, R05: warning }, allowedMissing: [dist/.gitignore] }这里的allowedMissing是一个后来追加的配置。原因也很现实有些项目会把.gitignore这类非必要文件放进 dist但检查时不需要因为缺少它而报错。工具的灵活度必须为真实项目留出口子。5. 接入实战30 分钟让 Refinex-Code 跑进发布流程5.1 安装与初始化接入方式非常简单先装开发依赖pnpm add -D refinex-code然后生成一份默认配置npx refinex init这个命令会在项目根目录生成refinex.config.json并且自动探测常见的构建产物目录比如dist。如果你用的是 monorepo可以放在每个需要发布包的子包目录下Refinex-Code 会把当前目录的 package.json 作为检查对象。本地手动检查的命令是npx refinex check --dist dist成功的情况下输出大概长这样[Refinex] R01 exports 目标路径检查: 3 项通过 [Refinex] R02 files 白名单检查: 4 项通过 [Refinex] R03 包名导入解析检查: 2 项通过 [Refinex] R05 非 JS 资源检查: 1 项通过0 项失败 [Refinex] 检查完成未发现 error 级别问题如果失败会在对应规则下方打印具体的路径和原因退出码变为 1。5.2 与现有 CI/CD 的集成我实际用的集成方式是把它加进发布流水线放在pnpm publish之前。- name: 安装依赖 run: pnpm install --frozen-lockfile - name: 构建 run: pnpm build - name: 发布前健康检查 run: npx refinex check --dist dist --config refinex.config.json --report json - name: 发布 run: pnpm publish这里有一个操作顺序上的坑检查必须在npm pack之前而不是之后执行。原因很简单Refinex-Code 检查的是文件系统上的产物目录而npm pack会生成一个 tgz 文件如果你先执行 pack 再检查规则对象反而变成了 tgz 而不是目录解析逻辑会有偏差。另一个更好的做法是把 Refinex-Code 挂进prepack钩子{ scripts: { prepack: refinex check --dist dist } }npm 在执行npm pack或npm publish时会自动先跑prepack一旦报告里存在 error就会中断发布流程。不过要注意如果你的项目里已经有其他prepack脚本需要把两者合并成一行避免 npm 只执行最后一个。5.3 实战效果一次被成功拦截的隐患Refinex-Code 上线两周之后就碰上一次真实拦截。当时另一个同事在重构一个内部 SDK把公共类型从src/types.ts拆到了src/types/index.ts顺手把exports里的类型路径也改了。改动看起来完全合情合理测试也全过。但 CI 跑到 Refinex-Code 时R03 报出一条错误提示exports[.].types指向的./dist/types/index.d.ts在产物里不存在原因是 tsc 的rootDir配置把输出结构压平了最终产物是dist/types.d.ts。这种问题靠人眼很难在发布前发现因为源码结构和产物结构的不对称性已经被构建工具藏起来了。但发布后一旦使用方的 TypeScript 类型解析失败几乎等于发布事故。被拦截后同事只需要修掉 exports 里的 types 路径重新构建再跑检查确认全绿后发布。整个过程不到五分钟。6. 边界与扩展Refinex-Code 解决不了什么6.1 它只检查“包能不能被正确加载”不检查“包运行起来对不对”Refinex-Code 的所有规则都围绕一个目标保证发布后的包从入口到资源文件都结构完整、路径可解析。它不会去替你跑业务逻辑也不做端到端验证。如果你把一个 calculatePrice 函数内部的公式写错了Refinex-Code 照样会大放绿灯。这不是缺陷而是定位选择。发布健康检查是工程问题业务正确性是测试问题两者不该混在一起。6.2 规则覆盖存在滞后性新事故可能钻规则的空子工具的本质是把“已知事故”沉淀成“自动拦截器”所以它天然只能拦截已经发生过且被总结成规则的问题。一个新的坑比如某种特殊的外链配置文件被压缩插件遗漏只要还没写进规则它就不会被发现。对此我的做法是每当团队里出现一起发布事故第一反应不是骂工具没用而是把事故行为抽象成一条新规则补丁式地合进 Refinex-Code 的规则合集。这种“事故驱动开发”听起来不够优雅但在工程稳定性上非常有效。每补一条规则等于把一次烧到线上的风险永久性地关进笼子里。6.3 对 AI 编码工作流的启发规则才是人机协作的边界这场事故和 Refinex-Code 给我的最大启发不是“AI 写代码不靠谱”而是“人类在 AI 编码工作流里的新职责是把规则沉淀下来”。Claude Code 的代码生成能力强在这个时代已经是共识但它不会主动建立发布契约。你可以在对话里反复叮嘱它“记得更新 exports”但这种提醒是易失的换个 session 就归零。规则不一样规则是可执行的资产它们不依赖任何一次会话也不依赖某个人的记忆力。现在我再启动一个新功能开发时会让 Claude Code 把代码写完自己把增量规则补进 Refinex-Code然后让这个检查器在发布前给双方兜底。效果比我过去“人工核对 package.json”靠谱得多因为规则不会疲倦也不会被深夜赶工时的焦虑影响。如果你手上也有一个用 AI 编程助手维护的、需要对外发布的包我的建议很简单把最近三个月踩过的发布问题全部列出来逐条问自己“这条能否用脚本判断”能判断的就写成规则然后让工具在发布前挡住你。这个过程不复杂但它会彻底改变你对“发布稳定”这件事的掌控感。