1. 项目概述:一个面向工程化落地的 TypeScript Agent 能力库设计实践
“agent-skills”这个名称乍看像某个开源库的代号,但结合热搜词里高频出现的TypeScript、Node、Nx、semantic-release,再叠加上大量围绕TypeScript 面试、Node 环境配置、Nx 二次开发、NestJS、AI 相关 TypeScript 实践的搜索行为,就能立刻判断:这不是一个玩具 Demo,而是一个真实存在于企业级 Node.js 工程体系中的、用于支撑 AI Agent 核心能力复用的基础设施模块。它解决的不是“能不能跑通一个 LLM 调用”,而是“如何让几十个业务服务、上百个微前端、数十个 CLI 工具,在统一技术栈下,安全、可测、可维护、可灰度地复用同一套 Agent 行为逻辑”。
我去年在一家做智能客服中台的团队主导过类似模块的落地。当时我们面临的真实困境是:销售侧要快速上线“合同条款自动比对 Agent”,售后侧要接入“工单意图识别 Agent”,产品侧又想搞“用户反馈聚类分析 Agent”。三个需求背后,都依赖大模型调用、工具函数注册、记忆管理、错误重试、链路追踪、输入输出 Schema 校验——但每个团队各自实现一套,连重试策略的指数退避参数都五花八门。最后上线两周,光是排查“为什么 A 服务的 Agent 在下午 3 点总是超时”就花了三天,因为没人知道 B 团队改过底层 HTTP Client 的 timeout 配置。
所以,“agent-skills”的本质,是一个被强制收敛的、带强契约约束的 Agent 能力协议层。它不封装 LLM Provider(OpenAI / Anthropic / 国产模型 API),也不决定你用 LangChain 还是 LlamaIndex,但它规定:所有接入它的技能函数,必须返回Promise<SkillResult>;所有技能必须声明inputSchema和outputSchema;所有异步操作必须支持 cancellation token;所有错误必须继承自AgentSkillError并携带errorCode: 'SKILL_TIMEOUT' | 'VALIDATION_FAILED' | 'TOOL_NOT_FOUND'。这种设计,让 QA 可以写一套通用测试用例跑遍所有技能,让 SRE 可以基于errorCode做统一告警聚合,让前端同学调用时,IDE 能直接提示result.data.confidenceScore存在且是 number 类型——这才是 TypeScript 在真实工程里该有的样子,而不是写满any和// @ts-ignore的“类型注释”。
它和你搜到的“typescript面试题”里那些泛泛而谈的泛型练习完全不同:这里的SkillResult<T>是经过 7 次线上事故复盘后才定稿的,T不仅承载业务数据,还必须包含traceId、durationMs、retryCount;它和“nx二次开发教程”里教你改个 builder 插件也不同:这里 Nx 不是用来搭架子的,而是用nx affected --target=lint精确锁定某次提交影响了哪些技能的类型定义,再用nx run-many --targets=test --projects=skill-email,skill-db-query并行验证——没有这套机制,当“邮件发送技能”升级了 SMTP 客户端版本,你根本不敢保证“合同生成技能”不会因此崩溃。
2. 整体架构设计与核心选型逻辑
2.1 为什么必须是 TypeScript 而非 JavaScript?
这个问题在团队立项会上被问了三次。表面看,JavaScript 也能跑通所有功能,但真实代价藏在看不见的地方。举个最典型的例子:我们曾用 JS 实现过一个fetchUserProfile技能,它调用内部 REST API,返回{ id: string, name: string, email?: string }。后来业务方要求增加avatarUrl字段,后端同学改了 API,但忘了通知前端。JS 版本的技能函数没有任何约束,前端调用时直接result.avatarUrl.toLowerCase()报错Cannot read property 'toLowerCase' of undefined,错误堆栈指向的是业务代码,而非技能本身。而 TypeScript 版本,只要后端更新 OpenAPI Spec,通过openapi-typescript自动生成类型,agent-skills的 CI 流程就会失败:“TypeScript error: Property 'avatarUrl' does not exist on type '{ id: string; name: string; email?: string | undefined; }'”。这个失败不是阻碍上线,而是把问题拦截在集成阶段,避免了线上 5 分钟的故障排查。
更深层的原因在于类型即文档。当新同学接手“知识库检索技能”时,他不需要去翻 300 行 JS 代码猜options参数长什么样,只需要看SearchOptions接口定义:
export interface SearchOptions { /** 检索关键词,必填 */ query: string; /** 最大返回条目数,默认 5,范围 1-20 */ limit?: number; /** 是否启用语义重排,默认 true */ rerank?: boolean; /** 指定知识库 ID,不传则使用默认库 */ kbId?: string; }这比任何 Wiki 页面都可靠,而且 IDE 能实时校验。我们统计过,采用 TS 后,新成员上手单个技能的平均时间从 1.8 天降到 0.6 天,因为不再需要反复问“这个参数到底要不要传”、“返回的 data 字段嵌套几层”。
2.2 为什么选择 Nx 而非单一 monorepo 工具?
市面上有 pnpm workspaces、TurboRepo、Lerna,甚至直接用 npm 7+ 的 workspaces。但我们最终锁死 Nx,核心就两个字:可预测性。Nx 的project.json不是配置文件,而是项目拓扑的声明式描述。比如skill-db-query项目定义里明确写着:
{ "targets": { "test": { "executor": "@nrwl/jest:jest", "options": { "jestConfig": "libs/skills/db-query/jest.config.ts" } }, "lint": { "executor": "@nrwl/eslint:eslint", "options": { "lintFilePatterns": ["libs/skills/db-query/src/**/*.ts"] } } }, "implicitDependencies": ["@myorg/types"], "tags": ["type:skill", "domain:database"] }这个implicitDependencies字段,让 Nx 能精确计算出:当你修改了@myorg/types包里的BaseSkillInput接口,哪些技能会受影响?答案是所有implicitDependencies包含它的项目。而 TurboRepo 的pipeline配置是基于 glob 模式匹配,libs/**/src/**/*.ts这种写法,会导致修改一个类型定义,触发全部技能的测试——在我们有 47 个技能的规模下,全量测试耗时从 3 分钟飙升到 12 分钟,CI 成本翻了四倍。
另一个关键点是 Nx 的Computation Caching。它不只是缓存构建产物,而是缓存整个执行过程的输入哈希。比如nx test skill-email的缓存键,由jest.config.ts内容、src/*.ts文件内容、node_modules/jest/package.json版本共同决定。这意味着:如果你只改了README.md,Nx 会秒级返回 “Cached output for skill-email:test”,完全跳过 Jest 启动和测试运行。我们实测,在 20 个并行 CI job 的场景下,Nx 缓存命中率稳定在 89% 以上,而 pnpm + Turborepo 组合的平均命中率只有 63%,差距直接体现在月度云资源账单上。
2.3 semantic-release 如何解决“谁该发版”的权力之争?
在没有 semantic-release 之前,我们用过两种模式:一种是“负责人制”,每个技能由 owner 手动npm publish;另一种是“主干发布”,所有人 push 到 main 后自动发版。前者导致版本混乱:skill-calc@1.2.3修复了一个小 bug,但skill-calc@1.2.4却引入了不兼容的 API 变更,因为 owner 忘记改minor为major;后者更灾难:A 同学提交了一个feat: add retry logic,B 同学同时提交了fix: handle null input,CI 自动合并后发版1.3.0,结果 QA 发现skill-calc的calculate()函数签名变了,但 changelog 里只写了“add retry logic”,没人知道fix提交实际重构了输入校验逻辑。
semantic-release 的解法非常暴力:版本号和 changelog 完全由 commit message 决定,人不能干预。我们约定 commit message 格式为<type>(<scope>): <subject>,其中type必须是feat、fix、chore、docs,scope是技能名(如skill-email)。CI 流程中,semantic-release 解析所有未发布的 commits:
- 如果存在
feat类型,版本号minor(如1.2.0→1.3.0); - 如果存在
fix类型,版本号patch(如1.2.0→1.2.1); - 如果存在
BREAKING CHANGE,版本号major(如1.2.0→2.0.0)。
最关键的是,它生成的 changelog 不是人工写的,而是按type分组,自动提取subject。skill-email@1.5.2的 changelog 长这样:
## [1.5.2](https://github.com/myorg/agent-skills/compare/skill-email@1.5.1...skill-email@1.5.2) (2024-06-15) ### Bug Fixes * handle empty recipient list ([#421](https://github.com/myorg/agent-skills/commit/abc123)) * prevent duplicate attachment uploads ([#425](https://github.com/myorg/agent-skills/commit/def456)) ### Features * support inline image embedding via data URL ([#418](https://github.com/myorg/agent-skills/commit/xyz789))这个 changelog 直接成为 Release Note,也成了 QA 验证的 checklist。当skill-email@1.5.2上线,QA 只需确认 #421、#425、#418 三个 PR 对应的功能是否正常,而不是去读一段模糊的“优化了邮件发送稳定性”。
3. 核心能力模块拆解与实操细节
3.1 Skill 基础协议:从execute()到SkillResult<T>
所有技能的入口函数必须遵循统一签名:
export interface SkillInput { /** 技能执行上下文,由 Agent Runtime 注入 */ context: { /** 当前会话唯一标识,用于链路追踪 */ sessionId: string; /** 用户身份信息,已脱敏 */ userId: string; /** 请求发起时间戳 */ timestamp: Date; }; /** 技能专属输入参数,由具体技能定义 */ params: Record<string, unknown>; } export interface SkillResult<T> { /** 业务数据主体 */ data: T; /** 执行元信息 */ meta: { /** 技能名称,用于日志归类 */ skillName: string; /** 执行耗时(毫秒) */ durationMs: number; /** 重试次数 */ retryCount: number; /** 链路追踪 ID */ traceId: string; }; /** 错误信息,成功时为 undefined */ error?: { code: string; message: string; details?: Record<string, unknown>; }; } export type SkillExecutor = (input: SkillInput) => Promise<SkillResult<unknown>>;这个设计看似简单,但每个字段都有血泪教训。比如context里的sessionId,早期我们只传字符串,结果在分布式环境下,不同服务生成的 session ID 格式不一致(有的带前缀sess_,有的纯 UUID),导致日志无法关联。后来强制要求context.sessionId必须是符合^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$正则的 UUIDv4,并在SkillInput的构造函数里做校验,不合法直接 throw,把问题拦在最外层。
SkillResult<T>的泛型T是另一个关键点。我们曾尝试过data: any,结果在skill-db-query里,data可能是User[]或OrderDetail,调用方必须手动as User[],一旦类型写错,TS 不报错,运行时报result.data.map is not a function。现在,每个技能导出自己的ResultType:
// libs/skills/db-query/src/lib/index.ts export interface DbQueryResult { rows: Array<Record<string, unknown>>; rowCount: number; columns: string[]; } export const execute: SkillExecutor = async (input) => { // ... 实现 return { data: { rows, rowCount, columns } as DbQueryResult, meta: { /* ... */ }, }; };调用方import { DbQueryResult } from '@myorg/skill-db-query';,IDE 自动补全result.data.rows,零成本获得类型安全。
3.2 输入校验与 Schema 声明:Zod 为何不可替代?
技能输入校验不是可选项,而是安全底线。我们曾因一个未校验的limit参数,被恶意请求传入limit: 9999999,导致数据库查询超时雪崩。最初用joi,但它的错误提示是字符串"\"limit\" must be less than or equal to 100",前端无法结构化解析。换成 Zod 后,错误对象是:
{ issues: [ { code: "too_big", maximum: 100, type: "number", inclusive: true, message: "Number must be less than or equal to 100", path: ["params", "limit"] } ] }这个path字段,让前端能精准定位到params.limit字段报错,直接高亮表单控件。
每个技能的inputSchema必须导出为常量:
// libs/skills/email/src/lib/schema.ts import { z } from 'zod'; export const EmailInputSchema = z.object({ to: z.array(z.string().email()).min(1, "收件人列表不能为空"), subject: z.string().min(1, "邮件主题不能为空").max(200, "邮件主题不能超过 200 字符"), body: z.string().min(1, "邮件正文不能为空"), attachments: z.array( z.object({ filename: z.string().min(1), content: z.string(), // base64 encoded contentType: z.enum(["application/pdf", "image/png", "text/plain"]) }) ).max(5, "附件数量不能超过 5 个") }); export type EmailInput = z.infer<typeof EmailInputSchema>;在技能执行函数里,第一行就是:
export const execute: SkillExecutor = async (input) => { try { const validated = EmailInputSchema.parse(input.params); // ... 后续逻辑 } catch (err) { if (err instanceof z.ZodError) { return { data: null, meta: { /* ... */ }, error: { code: "VALIDATION_FAILED", message: "输入参数校验失败", details: err.flatten() // 返回 { fieldErrors: { to: [...], subject: [...] } } } }; } throw err; } };这个err.flatten()返回的结构,让前端能直接绑定到表单字段,fieldErrors.to[0]就是第一条错误提示。我们做过 AB 测试,用 Zod 后,因参数错误导致的客服工单下降了 73%。
3.3 工具函数注册中心:如何让 Agent 知道“我能做什么”?
Agent Runtime 需要知道所有可用技能及其元信息,才能做规划(Planning)。我们没用动态require,而是用 Nx 的project.json自动生成注册表。每个技能项目在project.json里声明tags:
{ "name": "skill-email", "tags": ["type:skill", "category:communication", "capability:send-email"] }CI 流程中,一个专用的generate-skill-registryscript 会扫描所有project.json,提取name、tags、description(来自README.md第一行),生成libs/registry/src/generated/skills.ts:
export const SKILL_REGISTRY = [ { name: "skill-email", description: "发送 HTML 邮件,支持附件和模板", tags: ["type:skill", "category:communication", "capability:send-email"], inputSchema: "z.object({ to: z.array(z.string().email()), ... })", version: "1.5.2" }, // ... 其他技能 ] as const;这个SKILL_REGISTRY是类型安全的 const 断言,typeof SKILL_REGISTRY[number].name就是"skill-email" | "skill-db-query" | ...,Agent 的plan()函数可以据此做类型守卫:
if (toolName === "skill-email") { // TS 知道这里一定是 EmailInputSchema 的 params const result = await executeEmail({ params: toolParams }); }避免了运行时switch语句里漏掉default分支导致的静默失败。
3.4 错误处理与重试策略:不是加个try/catch就完事
Agent 场景下的错误,必须区分对待。我们定义了 5 类错误码:
SKILL_TIMEOUT: 技能执行超时(如调用外部 API 超过 5s)VALIDATION_FAILED: 输入校验失败(客户端错误)TOOL_NOT_FOUND: Agent 规划了不存在的技能名(配置错误)EXECUTION_FAILED: 技能内部异常(如数据库连接失败)RATE_LIMIT_EXCEEDED: 外部服务限流(如 OpenAI API)
每种错误的处理策略不同:
VALIDATION_FAILED:直接返回给用户,提示“请检查输入格式”TOOL_NOT_FOUND:触发 Agent 的 fallback 规划,尝试其他技能RATE_LIMIT_EXCEEDED:自动重试,但指数退避(1s, 2s, 4s)SKILL_TIMEOUT和EXECUTION_FAILED:记录详细日志,触发告警,不重试(避免雪崩)
重试逻辑封装在withRetry高阶函数里:
export const withRetry = <T>( fn: () => Promise<T>, options: { maxRetries: number; baseDelayMs: number; shouldRetry: (error: unknown) => boolean; } ): Promise<T> => { const attempt = async (retryCount: number): Promise<T> => { try { return await fn(); } catch (err) { if ( retryCount >= options.maxRetries || !options.shouldRetry(err) ) { throw err; } const delay = Math.pow(2, retryCount) * options.baseDelayMs; await new Promise(resolve => setTimeout(resolve, delay)); return attempt(retryCount + 1); } }; return attempt(0); }; // 在 skill-db-query 中使用 export const execute: SkillExecutor = async (input) => { return withRetry( () => doDbQuery(input.params), { maxRetries: 2, baseDelayMs: 1000, shouldRetry: (err) => err instanceof Error && (err.message.includes("ECONNRESET") || err.message.includes("timeout")) } ); };这个设计让重试逻辑与业务逻辑分离,doDbQuery只关心怎么查,不关心重试。我们监控发现,skill-db-query的RATE_LIMIT_EXCEEDED错误 92% 发生在凌晨 2-4 点(DB 维护窗口),所以shouldRetry里还加入了时间判断,避免在维护期无效重试。
4. Nx 工程化落地关键步骤与避坑指南
4.1 初始化:从npx create-nx-workspace@latest到第一个技能
不要直接npx create-nx-workspace,那会生成一个带 Angular/React 的完整模板,而agent-skills是纯库集合,需要精简。正确流程是:
npx create-nx-workspace@latest agent-skills --preset=apps-and-libraries --cli=nx --nxCloud=false --packageManager=pnpm- 删除默认生成的
apps/目录(我们不需要应用,只需要库) - 创建
libs/skills/目录作为所有技能的根目录 - 运行
nx g @nrwl/node:library skills/email --directory=skills --importPath=@myorg/skill-email --publishable --buildable --no-interactive
关键参数解释:
--publishable: 生成package.json,允许npm publish--buildable: 生成project.json的buildtarget,支持nx build skill-email--importPath=@myorg/skill-email: 设置包名,避免libs/skills/email/src/index.ts的导入路径过长
生成后,libs/skills/email/project.json里会自动添加:
{ "targets": { "build": { "executor": "@nrwl/js:tsc", "outputs": ["{workspaceRoot}/dist/libs/skills/email"], "options": { "tsConfig": "libs/skills/email/tsconfig.lib.json", "packageJson": "libs/skills/email/package.json", "outDir": "{workspaceRoot}/dist/libs/skills/email", "main": "libs/skills/email/src/index.ts" } } } }这个outDir路径很重要,它决定了nx build的产物位置,后续semantic-release会读取dist/下的package.json和index.js。
4.2 TypeScript 配置:tsconfig.base.json的隐藏陷阱
Nx 默认的tsconfig.base.json里compilerOptions很精简,但agent-skills需要额外配置:
{ "compilerOptions": { "strict": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictBindCallApply": true, "strictPropertyInitialization": true, "noImplicitThis": true, "alwaysStrict": true, "skipLibCheck": false, "forceConsistentCasingInFileNames": true, "moduleResolution": "node", "resolveJsonModule": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "isolatedModules": true, "incremental": true, "composite": true, "declaration": true, "declarationMap": true, "sourceMap": true, "inlineSources": true, "lib": ["es2020", "dom"], "target": "es2020", "module": "commonjs", "outDir": "./dist/out-tsc", "rootDir": ".", "baseUrl": ".", "paths": { "@myorg/*": ["libs/*/src/index.ts"], "@myorg/types": ["libs/types/src/index.ts"] } } }最关键的三个配置:
"declaration": true: 生成.d.ts声明文件,否则下游项目无法获得类型提示"composite": true: 支持增量编译,Nx 的affected命令依赖此特性"paths"映射:让import { EmailInput } from '@myorg/skill-email'能正确解析,而不是写../../../libs/skills/email/src/lib/schema.ts
常见坑:如果忘记设"declaration": true,nx build skill-email会成功,但dist/目录下没有.d.ts文件,下游项目tsc时会报Cannot find module '@myorg/skill-email'。这个错误不会在构建时报出,而是在消费方tsc时才暴露,极难定位。
4.3 semantic-release 集成:绕过 GitHub Actions 的本地调试法
官方文档推荐用 GitHub Actions,但本地开发时频繁 push 测试太慢。我们用npx semantic-release --dry-run --debug做本地验证:
- 确保
package.json里有"release": { "branches": ["main"] } - 在
libs/skills/email目录下,git checkout -b feat/test-local git commit -m "feat(skill-email): add html template support"npx semantic-release --dry-run --debug --ci=false --no-ci
--dry-run不真正发版,--ci=false --no-ci跳过 CI 环境检查,--debug输出详细日志。你会看到:
[8:45:23 AM] [semantic-release] › ℹ Running semantic-release version 19.0.5 [8:45:23 AM] [semantic-release] › ✔ Loaded plugin "verifyConditions" from "@semantic-release/github" [8:45:23 AM] [semantic-release] › ✔ Loaded plugin "analyzeCommits" from "@semantic-release/commit-analyzer" [8:45:23 AM] [semantic-release] › ℹ Analysis of 1 commits starting with "feat(skill-email): add html template support" [8:45:23 AM] [semantic-release] › ℹ The release type for the commit is "minor" [8:45:23 AM] [semantic-release] › ℹ Current version is 1.5.1 [8:45:23 AM] [semantic-release] › ℹ New version is 1.6.0确认New version is 1.6.0正确后,再 push 到远程,CI 自动执行真实发布。
另一个坑:semantic-release默认只处理main分支,如果你用develop作为开发分支,必须在package.json里显式配置:
"release": { "branches": ["main", "develop"] }否则develop上的feat提交会永远不触发发版。
4.4 CI/CD 流水线:用 Nx Cloud 替代自建 Runner 的真实收益
我们曾用自建的 GitLab Runner,配置复杂且不稳定。切换到 Nx Cloud 后,核心优势是分布式任务缓存。Nx Cloud 不是简单的 artifact cache,而是将nx test skill-email的整个执行环境(包括 node_modules 的 hash、源码 hash、配置 hash)上传,其他机器下载后直接复用结果。
流水线配置 (nx.json) 关键片段:
{ "tasksRunnerOptions": { "default": { "runner": "@nrwl/nx-cloud", "options": { "accessToken": "your-access-token", "cacheableOperations": ["build", "test", "lint", "e2e"] } } } }效果对比:
| 指标 | 自建 Runner | Nx Cloud |
|---|---|---|
| 平均 test 时间 | 42s | 1.8s (缓存命中) |
| 全量 test 耗时 | 18min | 3min 20s |
| 构建失败率 | 12% (网络超时) | <0.3% |
| 资源成本 | 4 台 8C16G 专用 Runner | 按需付费,月均 $280 |
最值钱的是跨团队缓存共享。当北京团队在skill-db-query里修复了一个 bug,上海团队在skill-report-gen里依赖它,nx affected --target=test会自动从 Nx Cloud 下载skill-db-query的缓存测试结果,无需重新运行,加速了整个生态的协作效率。
5. 常见问题与实战排查技巧
5.1 “TypeScript error: Cannot find module 'node:util'” —— Node.js 版本与 TS 配置的隐性冲突
这个错误在搜索热词里高频出现,根本原因不是缺少包,而是Node.js 版本与 TypeScript 的lib配置不匹配。node:util是 Node.js 14.18+ 引入的 ESM 模块,但你的tsconfig.json里如果lib只写了["es2017"],TS 就不认识node:util。
解决方案分三步:
- 确认 Node.js 版本:
node -v,确保 ≥ 14.18(推荐 18.17+ 或 20.9+) - 升级 TypeScript:
pnpm add -D typescript@latest,旧版 TS 对 Node.js 新 API 支持不全 - 修正
tsconfig.json:{ "compilerOptions": { "lib": ["es2020", "dom", "dom.iterable", "scripthost"], "types": ["node"], // 关键!告诉 TS 加载 @types/node "moduleResolution": "node" } }提示:
"types": ["node"]是必须的,否则即使装了@types/node,TS 也不会自动加载。很多团队只装包不配types,导致import { promisify } from 'node:util'报错。
5.2 “npm : 无法加载文件 ... 因为在此系统上禁止运行脚本” —— Windows PowerShell 执行策略
这是 Windows 开发者必踩的坑。PowerShell 默认策略是Restricted,禁止运行本地脚本(包括npm安装的node_modules/.bin/npm.ps1)。
临时解决(不推荐):
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser永久规范方案(推荐):
- 在项目根目录创建
ps1-profile.ps1:# Allow scripts in this workspace only Set-ExecutionPolicy RemoteSigned -Scope CurrentUser -Force Write-Host "PowerShell execution policy set for this user." - 在
package.json的scripts里,用cross-env统一命令:"scripts": { "dev": "cross-env NODE_ENV=development nx serve", "build": "cross-env NODE_ENV=production nx build" }cross-env会自动处理不同 shell 的环境变量设置,避免直接调用 PowerShell 脚本。
5.3 Nx 项目里nx affected不生效?检查这 3 个致命配置
nx affected是 monorepo 的灵魂,但它失效往往是因为:
- Git 未正确初始化:
nx affected依赖git diff计算变更。确保工作区是 git repo,且git status显示 clean。如果刚git clone,先git fetch origin。 nx.json里affectedProjectDependencies配置错误:默认是"always",但如果你改成"direct",它只会检查直接依赖,忽略间接依赖。例如skill-email依赖@myorg/types,而@myorg/types又依赖@myorg/utils,direct模式下修改utils不会触发email的测试。project.json里implicitDependencies缺失:如前所述,skill-email必须声明implicitDependencies: ["@myorg/types"],否则 Nx 不知道它们之间的关系。
验证方法:nx print-affected --base=HEAD~1 --head=HEAD,它会输出 JSON 格式的受影响项目列表。如果为空,逐项检查上述三点。
5.4 semantic-release 发版失败:No release published的 5 种可能
这是最让人抓狂的问题。npx semantic-release --dry-run显示New version is X.Y.Z,但真实 CI 里却失败。常见原因:
- GitHub Token 权限不足:Token 必须有
public_repo权限(如果是私有库,需要repo)。在 GitHub Settings → Developer settings → Personal access tokens → Generate new token。 - 分支保护规则冲突:如果
main分支启用了Require pull request reviews before merging,但 semantic-release 的 commit 是 bot 推送的,没有 reviewer,会被拒绝。解决方案:在分支保护规则里,勾选Include administrators,并添加github-actions[bot]到 bypass list。 - Changelog 插件配置错误:
@semantic-release/changelog的changelogFile路径写错,如"changelogFile": "libs/skills/email/CHANGELOG.md",但实际文件在libs/skills/email/CHANGELOG.md,少了个libs/。 - Git 用户信息未设置:CI 环境里
git config --global user.name和git config --global user.email为空,导致 commit 无法创建。在 CI 脚本开头加:git config --global user.name 'semantic-release' git config --global user.email 'release@myorg.com' package.json的repository字段缺失或错误:semantic-release 需要repository.url来确定发布目标。必须是https://github.com/owner/repo.git格式,不能是git@github.com:owner/repo.git。
实操心得:在 CI 日志里搜索
semantic-release,找到EINVALIDREPO或EGITNOPERMISSION错误码,比盲目