ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

TypeScript + Nx 工程化实践:构建可版本化、可验证的原子能力单元

TypeScript + Nx 工程化实践:构建可版本化、可验证的原子能力单元 1. 项目概述一个被严重低估的“技能容器”设计范式“agent-skills”这四个字乍看像某个开源库的包名或是某篇技术文档里的小节标题但如果你在 TypeScript 生态里摸爬滚过三年以上尤其参与过 Nx 工作区治理、语义化发布流程搭建或亲手维护过跨团队共享能力模块——你一眼就能认出这不是功能列表而是一套可组合、可验证、可版本化的能力封装协议。它解决的不是“怎么写个函数”而是“当 12 个微前端应用、7 个后端服务、3 类 CLI 工具都需要调用‘文件解析’‘权限校验’‘异步重试’时如何让同一段逻辑既不重复、不冲突、不漂移还能被自动测试、精准溯源、按需加载”。我去年在一家做工业低代码平台的团队落地这套设计把原先散落在 5 个仓库、命名风格各异utils-file,core-permission,shared-retry,common-ai-adapter,lib-semantic-validator的 23 个能力模块统一收编进company/agent-skills这个单一工作区。结果不是简单“合并代码”而是实现了三件事第一Nx 的affected命令能精确识别出“修改了parseExcel技能后哪些应用需要重新构建”第二每次npm publish都触发semantic-release自动生成带feat: add skill parseCsv标题的 GitHub Release并同步更新所有依赖方的package.json中的peerDependencies版本范围第三新入职工程师打开 Nx Console输入agent-skills就能直接看到所有技能的类型定义、使用示例、测试覆盖率、变更日志——不再需要翻 4 个 Wiki 页面、3 条 Slack 记录、2 次 Code Review 才搞懂“这个 Excel 解析器到底支持 .xlsx 还是 .xls”。核心关键词agent-skills在这里不是指 AI Agent 的技能虽然概念上可延伸而是指面向业务动作的、原子级可插拔的能力单元。它天然绑定 TypeScript 的类型系统定义输入/输出契约、Node 的模块机制提供运行时上下文、Nx 的工作区拓扑管理依赖与构建边界、semantic-release 的语义化版本保障演进可追溯。你不需要懂 LLM 或 RAG只要写过fetch封装、做过表单校验、实现过 WebSocket 心跳重连你就已经具备了构建第一个agent-skill的全部能力。适合谁参考不是只给架构师看的幻灯片而是给一线开发者准备的“可抄作业”手册正在用 Nx 管理多项目却苦于共享逻辑混乱的前端/全栈工程师需要为内部工具链提供稳定 API 但又不想写一堆index.ts导出的 Node CLI 开发者被npm link和yarn workspace各种路径问题折磨过的团队技术负责人准备 TypeScript 面试时想展示“不止会写组件更懂工程化落地”的求职者——因为agent-skills的设计本身就是一道极佳的 TypeScript 工程化综合面试题。2. 整体设计思路为什么必须是“技能”而非“工具函数”2.1 从“函数复用”到“能力契约”的认知跃迁很多团队第一步就想建shared-utils库把debounce、throttle、deepClone往里塞。这没错但很快就会遇到三个硬伤类型漂移A 项目用debounce(fn, 300, { leading: true })B 项目传debounce(fn, 300, { maxWait: 500 })C 项目干脆自己重写了第三个版本——TypeScript 的any类型警告被关掉ts-ignore成了标配副作用失控formatDate里悄悄调用了Intl.DateTimeFormat结果在 Node.js 环境跑测试时报错ReferenceError: Intl is not defined没人记得这个函数还依赖浏览器 API演进不可控某天有人给validateEmail加了个正则优化发版后发现 B 项目里有个邮箱格式是usertagexample.com旧规则允许新规则拒绝——但没人知道 B 项目依赖的是shared-utils^2.1.0而^2.1.0允许升级到2.2.0于是线上报错。agent-skills的设计起点就是把每个能力当作一个有明确边界、有严格契约、有独立生命周期的“小服务”。它不叫utils因为utils暗示“随便用”它也不叫lib因为lib暗示“底层基础”。它叫skill意味着它必须声明自己的输入约束Input Schema比如parseExcel要求file: Buffer | Blob且options?: { sheetIndex?: number; headerRow?: boolean }它必须声明自己的输出承诺Output Contract比如返回{ data: any[]; meta: { rowCount: number; columnNames: string[] } }且data的每一项都通过 Zod 或 TypeScript Interface 校验它必须声明自己的运行时依赖Runtime Requirements比如requires: [node:fs, exceljs]并在构建时由 Nx 自动检查目标环境是否满足它必须声明自己的演进策略Versioning Policy比如breakingChanges: [input.schema, output.contract]这样semantic-release就知道只要改了InputSchema就必须发major版本。这种设计不是增加复杂度而是把原本藏在注释里、口头约定中、Code Review 时才被发现的隐性规则变成显性的、可机器验证的、可自动生成文档的代码契约。2.2 为什么选 TypeScript 而非 JavaScriptTypeScript 在这里不是“为了用而用”而是承担了三个不可替代的角色契约编译器InputSchema和OutputContract不是文档字符串而是type Input { file: Buffer; options?: { sheetIndex: number } };这样的真实类型。Nx 构建时会用tsc --noEmit检查所有技能的类型兼容性如果某个技能的Input类型引用了未导出的私有接口构建直接失败——这比任何 CI 脚本都早一步拦截错误。IDE 友好引擎当你在应用里import { parseExcel } from company/agent-skillsVS Code 不仅提示函数签名还能跳转到parseExcel.skill.ts文件看到它的README.md自动生成、CHANGELOG.mdsemantic-release 生成、test/parseExcel.spec.tsJest 测试用例——所有信息在一个地方触手可及。迁移安全阀我们曾把一个 Python 写的creditScoreCalculator技能用 TypeScript 重写。旧版只有def calculate(score: str) - dict新版则是export const creditScoreCalculator: AgentSkillCreditInput, CreditOutput { ... }。TypeScript 编译器强制要求CreditInput和CreditOutput必须满足AgentSkill接口定义哪怕只是加了一个version: v2字段也会在所有调用处报错逼着你去改消费方代码——这正是语义化版本想要的效果而 TypeScript 让它在编码阶段就发生。提示不要把 TypeScript 当作“加类型注解的 JS”。在这里它是整个agent-skills协议的基石。如果团队还在用any或// ts-ignore绕过类型检查那agent-skills的价值会打七折。我们强制规定所有技能文件必须以.skill.ts结尾CI 会扫描该后缀文件对any类型使用率超过 5% 的提交直接拒绝。2.3 为什么必须基于 Nx 工作区单看agent-skills目录结构你可能觉得“用普通 npm 包也行”。但实际落地时Nx 提供了三个关键能力是其他方案无法替代的拓扑感知的依赖分析Nx 的nx graph不仅画出agent-skills→app-web→app-mobile的箭头还能标出app-web只用了agent-skills里的authLogin和uploadFile两个技能而app-mobile只用了geolocation和cacheManager。这意味着nx affected:build --basemain --headHEAD能精准告诉 CI“这次只改了parseExcel只需构建app-web和cli-tools不用碰app-mobile”。一致的构建与测试流水线所有技能共享同一套tsconfig.base.json、同一套 ESLint 规则、同一套 Jest 配置。你不需要为每个技能单独配jest.config.js只需要在libs/agent-skills/.eslintrc.json里写一次规则所有子技能自动继承。增量缓存与远程缓存Nx 的--remote-cache让parseExcel的测试在 CI 上只需跑一次后续所有分支只要没改它的源码和依赖就直接复用缓存结果。我们实测一个包含 87 个技能的仓库全量测试从 12 分钟降到 2.3 分钟其中 76% 的测试用例来自缓存。注意Nx 不是必须用nx workspace创建的项目才能用。你可以把现有 Monorepo 改造成 Nx 工作区只需运行npx nxlatest init它会自动识别你的package.json结构并生成project.json。我们团队就是从 Yarn Workspaces 迁移过来的耗时不到半天。3. 核心细节解析一个agent-skill的完整构成要素3.1 技能文件的标准结构.skill.ts是唯一入口每个技能必须是一个独立的.skill.ts文件放在libs/agent-skills/src/lib/skill-name/下。以parseExcel为例它的完整结构如下libs/agent-skills/ ├── src/ │ └── lib/ │ └── parseExcel/ │ ├── parseExcel.skill.ts ← 技能主文件唯一入口 │ ├── parseExcel.spec.ts ← 单元测试 │ ├── parseExcel.e2e.spec.ts ← 端到端测试可选 │ └── README.md ← 自动生成的文档 ├── project.json ← Nx 项目配置 └── package.json ← 发布配置含 semantic-releaseparseExcel.skill.ts不是普通函数而是一个符合AgentSkill接口的对象import { AgentSkill, SkillInput, SkillOutput } from company/agent-skills-core; import * as ExcelJS from exceljs; // 输入契约严格定义参数结构 type ParseExcelInput SkillInput{ file: Buffer; options?: { sheetIndex?: number; headerRow?: boolean; }; }; // 输出契约严格定义返回结构 type ParseExcelOutput SkillOutput{ data: ArrayRecordstring, any; meta: { rowCount: number; columnNames: string[]; }; }; // 技能主体必须导出名为 skill 的常量 export const skill: AgentSkillParseExcelInput, ParseExcelOutput { // 技能元数据用于自动生成文档和版本控制 metadata: { id: parseExcel, version: 1.2.0, // 语义化版本由 semantic-release 管理 description: 解析 Excel 文件为结构化 JSON 数据, author: Data Team, requires: [node:fs, exceljs], // 运行时依赖声明 }, // 输入校验使用 Zod 或原生 TS 类型 validateInput: (input) { if (!input.file || !(input.file instanceof Buffer)) { throw new Error(Input file must be a Buffer); } return input; }, // 主执行逻辑 execute: async (input) { const workbook new ExcelJS.Workbook(); await workbook.xlsx.load(input.file); const worksheet workbook.getWorksheet(input.options?.sheetIndex ?? 1); if (!worksheet) throw new Error(Sheet ${input.options?.sheetIndex} not found); const rows []; const headers input.options?.headerRow ? worksheet.getRow(1).values.slice(1) as string[] : []; for (let i input.options?.headerRow ? 2 : 1; i worksheet.rowCount; i) { const row worksheet.getRow(i); const rowData: Recordstring, any {}; headers.forEach((header, idx) { rowData[header] row.values[idx 1]; }); rows.push(rowData); } return { data: rows, meta: { rowCount: rows.length, columnNames: headers, }, }; }, };这个结构的关键在于metadata是机器可读的说明书id用于 Nx 依赖图谱version用于 semantic-releaserequires用于构建时检查validateInput是第一道防火墙它在execute执行前强制校验避免无效输入进入业务逻辑execute是纯函数不访问全局变量、不修改外部状态、不依赖process.env除非显式声明在requires中skill常量名是约定Nx 插件会扫描所有.skill.ts文件查找导出的skill常量自动注册为可调用能力。3.2 类型系统深度整合Zod TypeScript 双保险光靠 TypeScript 类型还不够。比如input.file是Buffer但Buffer本身不保证内容是合法 Excel 文件。所以我们引入 Zod 做运行时校验import { z } from zod; const ParseExcelInputSchema z.object({ file: z.instanceof(Buffer).refine( (buf) buf.length 0 buf[0] 0x50 buf[1] 0x4B, // PK header { message: File must be a valid Excel (.xlsx) file } ), options: z .object({ sheetIndex: z.number().min(1).optional(), headerRow: z.boolean().default(true), }) .optional(), }); export const skill: AgentSkillParseExcelInput, ParseExcelOutput { validateInput: (input) { const result ParseExcelInputSchema.safeParse(input); if (!result.success) { throw new Error(Invalid input: ${result.error.flatten().fieldErrors}); } return result.data; }, // ... execute logic };为什么用 Zod 而不是纯 TypeScript因为运行时校验不可绕过TypeScript 类型只在编译时存在Zod 校验在 Node.js 运行时执行确保即使通过any强制转换的输入也会被拦住错误信息友好Zod 报错是Invalid input: {file: [File must be a valid Excel (.xlsx) file]}比TypeError: Cannot read property values of undefined易于定位可序列化Zod Schema 可以JSON.stringify()方便集成到 OpenAPI 文档生成工具中。我们规定所有技能的validateInput必须使用 Zod Schema且 Schema 必须导出为InputSchema常量便于其他工具如 Swagger UI复用。3.3 Nx 工作区配置让技能真正“活”起来libs/agent-skills/project.json是技能库的“宪法”它定义了构建、测试、发布的全部规则{ name: agent-skills, root: libs/agent-skills, sourceRoot: libs/agent-skills/src, projectType: library, targets: { build: { executor: nrwl/node:build, outputs: [{workspaceRoot}/dist/libs/agent-skills], options: { outputPath: dist/libs/agent-skills, main: libs/agent-skills/src/index.ts, tsConfig: libs/agent-skills/tsconfig.lib.json, assets: [libs/agent-skills/*.md] } }, test: { executor: nrwl/jest:jest, options: { jestConfig: libs/agent-skills/jest.config.ts, passWithNoTests: true } }, release: { executor: semantic-release/exec:exec, options: { cmd: npx semantic-release } } } }关键点解析assets字段libs/agent-skills/*.md确保每个技能的README.md在构建时被复制到dist/目录消费方npm install后能直接看到文档test目标Jest 配置里启用了collectCoverageFrom自动收集所有*.skill.ts文件的覆盖率CI 会强制要求coverageThreshold达到 90%release目标不是直接调用semantic-release而是用semantic-release/exec执行器这样可以和 Nx 的缓存机制兼容——如果package.json没变release任务就不会重复运行。libs/agent-skills/package.json则定义了发布行为{ name: company/agent-skills, version: 0.0.0, // 占位符由 semantic-release 动态覆盖 main: dist/libs/agent-skills/index.js, types: dist/libs/agent-skills/index.d.ts, files: [dist], publishConfig: { registry: https://npm.company.com }, release: { branches: [main, next], plugins: [ semantic-release/commit-analyzer, semantic-release/release-notes-generator, semantic-release/npm, semantic-release/github ] } }实操心得semantic-release的branches配置必须和团队 Git Flow 一致。我们用main作为生产分支next作为预发布分支。每次 PR 合入next都会触发prerelease版本如1.2.0-next.1合入main才触发正式1.2.0。这样 QA 团队可以安装company/agent-skillsnext测试新技能而不影响线上环境。4. 实操过程从零搭建agent-skills工作区的完整步骤4.1 环境准备Node Nx TypeScript 的最小可行配置别被“TypeScript Node Nx”吓到实际初始化只需 5 分钟。我们用的是 Node 18.17.0LTS这是目前最稳定的版本避免node:util导出问题SyntaxError: The requested module node:util does not provide an export named这类错误在 Node 16 以下很常见。第一步安装 Node 与 nvm推荐Windows 用户注意PowerShell 默认禁止脚本执行报错npm : 无法加载文件 d:\node\npm.ps1时运行Set-ExecutionPolicy RemoteSigned -Scope CurrentUser即可。Mac/Linux 用户用nvm管理版本# 安装 nvm curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash # 安装 Node 18 nvm install 18.17.0 nvm use 18.17.0 # 验证 node -v # v18.17.0 npm -v # 9.6.7第二步创建 Nx 工作区不要用npm init nx-workspace它会引导你选框架Angular/React但我们只需要 Node 库npx create-nx-workspacelatest agent-skills-demo \ --presetapps \ --clinx \ --nxCloudskip \ --packageManagerpnpm选择apps预设后Nx 会创建一个空工作区。接着添加 Node 插件pnpm add -D nrwl/node nx g nrwl/node:library agent-skills --directorylibs --no-interactive这会生成libs/agent-skills目录并自动配置好project.json和tsconfig.json。第三步初始化 TypeScript 类型系统libs/agent-skills/tsconfig.lib.json默认只包含基础配置。我们需要强化类型安全{ extends: ./tsconfig.json, compilerOptions: { composite: true, declaration: true, declarationMap: true, skipLibCheck: false, strict: true, noImplicitAny: true, strictNullChecks: true, strictFunctionTypes: true, strictBindCallApply: true, strictPropertyInitialization: true, noImplicitThis: true, alwaysStrict: true, esModuleInterop: true, moduleResolution: node, resolveJsonModule: true, isolatedModules: true, forceConsistentCasingInFileNames: true, allowSyntheticDefaultImports: true, noFallthroughCasesInSwitch: true, noUnusedLocals: true, noUnusedParameters: true, noImplicitReturns: true, noUncheckedIndexedAccess: true, noPropertyAccessFromIndexSignature: true, useUnknownInCatchVariables: true }, include: [**/*.ts], exclude: [**/*.spec.ts, **/*.e2e.spec.ts] }注意strict: true是底线不能妥协。我们曾因关闭strictNullChecks导致parseExcel在worksheet.getRow(i)返回undefined时没报错最终在生产环境崩溃。开启后TypeScript 强制你写if (row) { ... }问题在编码阶段就暴露。4.2 创建第一个技能helloWorld的完整实现用 Nx 生成技能目录骨架nx g nrwl/workspace:library helloWorld --directorylibs/agent-skills/src/lib --no-interactive这会在libs/agent-skills/src/lib/helloWorld/下创建helloWorld.ts。把它重命名为helloWorld.skill.ts并替换为标准技能结构import { AgentSkill, SkillInput, SkillOutput } from company/agent-skills-core; type HelloWorldInput SkillInput{ name: string; language?: en | zh | ja; }; type HelloWorldOutput SkillOutput{ message: string; timestamp: Date; }; export const skill: AgentSkillHelloWorldInput, HelloWorldOutput { metadata: { id: helloWorld, version: 1.0.0, description: 返回个性化问候语, author: Dev Team, requires: [], }, validateInput: (input) { if (!input.name || typeof input.name ! string || input.name.trim().length 0) { throw new Error(Input name must be a non-empty string); } return input; }, execute: async (input) { const greeting input.language zh ? 你好${input.name} : input.language ja ? こんにちは、${input.name}さん : Hello, ${input.name}!; return { message: greeting, timestamp: new Date(), }; }, };然后在libs/agent-skills/src/index.ts中导出它export * from ./lib/helloWorld/helloWorld.skill; // 如果有更多技能继续添加 // export * from ./lib/parseExcel/parseExcel.skill;第四步编写测试libs/agent-skills/src/lib/helloWorld/helloWorld.spec.tsimport { skill } from ./helloWorld.skill; describe(helloWorld skill, () { it(should return greeting in English by default, async () { const result await skill.execute({ name: Alice }); expect(result.message).toBe(Hello, Alice!); expect(result.timestamp).toBeInstanceOf(Date); }); it(should return greeting in Chinese when languagezh, async () { const result await skill.execute({ name: 张三, language: zh }); expect(result.message).toBe(你好张三); }); it(should throw error for empty name, async () { await expect(skill.execute({ name: })).rejects.toThrow(Input name must be a non-empty string); }); });运行测试nx test agent-skills。首次运行会安装 Jest之后每次修改技能逻辑nx test agent-skills --watch就能实时反馈。4.3 集成 semantic-release自动化版本与发布安装 semantic-release 及其插件pnpm add -D semantic-release semantic-release/commit-analyzer semantic-release/release-notes-generator semantic-release/npm semantic-release/github在libs/agent-skills/package.json中添加release配置如前所述。关键是要配置 Git 提交规范让commit-analyzer能识别语义化提交pnpm add -D commitlint/config-conventional commitlint/cli echo module.exports { extends: [commitlint/config-conventional] }; commitlint.config.js然后在nx.json中添加提交钩子{ plugins: [ { plugin: nrwl/workspace, options: { cacheDirectory: .nx/cache } } ], tasksRunnerOptions: { default: { runner: nrwl/workspace/tasks-runner, options: { cacheableOperations: [build, test, lint, e2e] } } }, namedInputs: { default: [{projectRoot}/**/*, sharedGlobals], production: [default, !{projectRoot}/**/?(*.)(spec|test).[jt]s?(x), !{projectRoot}/tsconfig.spec.json] } }现在每次提交必须符合 Conventional Commits 规范git add . git commit -m feat(helloWorld): add support for Japanese language git push origin mainCI如 GitHub Actions检测到main分支推送会自动运行nx run agent-skills:releasesemantic-release 会分析提交历史发现feat提交决定发minor版本读取当前package.json版本假设是1.0.0升级为1.1.0更新package.json并提交生成 GitHub Releasenpm publish到私有 registry。实操心得semantic-release 默认不发布alpha/beta版本。如果需要预发布加--prerelease参数或在package.json的release配置里加prerelease: [next]。我们用next分支做灰度发布QA 团队npm install company/agent-skillsnext就能拿到最新技能。4.4 在应用中消费技能三种调用方式对比技能发布后其他项目如何使用我们提供三种方式按推荐度排序方式一直接导入推荐适用于同工作区应用如果消费方也在同一个 Nx 工作区如apps/web-app直接导入import { skill as helloWorldSkill } from company/agent-skills; // 在 React 组件中 const handleGreet async () { try { const result await helloWorldSkill.execute({ name: Bob, language: zh }); console.log(result.message); // 你好Bob } catch (error) { console.error(Skill execution failed:, error); } };优势零网络请求、类型完全匹配、IDE 全链路跳转。方式二npm install推荐适用于外部项目对于不在工作区的项目如独立的 Electron 应用npm install company/agent-skills后import { skill as helloWorldSkill } from company/agent-skills; // 注意必须用动态 import() 加载因为技能是 ESM 格式 const helloWorld await import(company/agent-skills).then(m m.skill); const result await helloWorld.execute({ name: Charlie });方式三HTTP API可选适用于跨语言调用如果 Java/Spring Boot 服务也要用parseExcel我们提供company/agent-skills-http包把技能包装成 Express 路由import express from express; import { skill as parseExcelSkill } from company/agent-skills; const app express(); app.use(express.json()); app.use(express.raw({ type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet })); app.post(/api/parse-excel, async (req, res) { try { const result await parseExcelSkill.execute({ file: req.body, options: req.query, }); res.json(result); } catch (error) { res.status(400).json({ error: error.message }); } });注意HTTP 方式牺牲了类型安全但换来跨语言能力。我们只对高频、高价值技能如parseExcel,creditScoreCalculator提供 HTTP 封装其他技能坚持直接导入。5. 常见问题与排查技巧实录踩过的坑都给你标好了5.1 “找不到模块”错误路径解析的三大陷阱问题现象nx build agent-skills成功但nx serve web-app时出现Cannot find module company/agent-skills。根本原因Nx 的tsconfig.base.json里paths配置未生效或消费方tsconfig.json未继承。排查步骤检查tsconfig.base.json是否有正确paths{ compilerOptions: { baseUrl: ., paths: { company/agent-skills: [libs/agent-skills/src/index.ts] } } }检查消费方apps/web-app/tsconfig.json是否extends了tsconfig.base.json运行nx dep-graph确认web-app→agent-skills的依赖箭头是绿色已解析不是灰色未解析。终极解决方案在nx.json中启用targetDefaults{ targetDefaults: { build: { dependsOn: [^build] } } }这会让 Nx 自动确保web-app构建前先构建agent-skills并注入正确的路径映射。5.2 “类型不匹配”错误SkillInput与InputSchema的协同失效问题现象parseExcel.skill.ts里InputSchema用 Zod 校验file: Buffer但消费方传file: ArrayBufferTypeScript 不报错运行时报instanceof Buffer失败。原因分析TypeScript 的Buffer类型是node:buffer的导出而ArrayBuffer是 Web API 类型两者在类型系统里不兼容但any类型能绕过。修复方案在validateInput里加双重校验validateInput: (input) { if (!(input.file instanceof Buffer)) { // 尝试转换 if (input.file instanceof ArrayBuffer) { input.file Buffer.from(input.file); } else if (typeof input.file string) { input.file Buffer.from(input.file, base64); } else { throw new Error(Input file must be Buffer, ArrayBuffer or base64 string); } } return ParseExcelInputSchema.parse(input); },在消费方文档里明确标注file参数支持Buffer | ArrayBuffer | string (base64)并给出转换示例。5.3 “构建失败”错误semantic-release与 Nx 缓存的冲突问题现象CI 上nx run agent-skills:release第一次成功第二次报错Cannot publish over existing version。原因semantic-release修改了package.json的version字段并提交但 Nx 的--remote-cache认为package.json没变因为缓存键基于文件哈希所以复用旧构建产物导致npm publish试图发布相同版本。解决方案在project.json的release目标里禁用缓存release: { executor: semantic-release/exec:exec, options: { cmd: npx semantic-release, cache: false } }或者更优雅的方式是让semantic-release的提交触发 Nx 的affected检测在
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进