Twenty 的 ESLint 到 Oxlint 迁移复盘:降级规则、插件替代与再激活计划
Twenty(The open alternative to Salesforce)的仓库根下维护着一份特殊的文档——packages/twenty-oxlint-rules/OXLINT_MIGRATION_TODO.md,它不是普通的功能说明,而是一份“迁移对账单”:记录了 Twenty 从 ESLint 切换到 Oxlint 过程中哪些规则被临时禁用、哪些规则完成了再激活、哪些 ESLint 插件因为 Oxlint 没有对应实现而被丢弃,以及后续的再激活路径。读完后,你将理解在超大型前端/后端 monorepo 中做 Linter 替换时如何评估“安全自动修复”的边界(尤其是 NestJS 装饰器依赖与 import type 的冲突),以及如何用 Oxlint 的 jsPlugins 机制把项目私有规则(twenty/*)重新实现为自定义插件。
迁移的背景与工程载体
这份 TODO 文档所在的 twenty-oxlint-rules 包是 Twenty 的私有 Oxlint 规则包。从仓库结构看,它包含:
- oxlint-plugin.ts:插件注册入口,通过
@oxlint/plugins的definePlugin以meta: { name: 'twenty' }注册了约 20 条twenty/*自定义规则(folder-structure、max-consts-per-file、enforce-module-boundaries、no-hardcoded-colors等),见 插件注册段; - rules/ 目录:每条规则一个实现文件加一个
.spec.ts测试文件,例如 folder-structure.ts 与 max-consts-per-file.ts; - project.json:构建目标用 esbuild 把
oxlint-plugin.ts打包成dist/oxlint-plugin.mjs(ESM、node 平台,并注入createRequirebanner 以兼容 CJS 依赖),测试目标跑 vitest。
各业务包通过各自 .oxlintrc.json 里的 jsPlugins 字段引用这个产物。以 twenty-server/.oxlintrc.json 和 twenty-front/.oxlintrc.json 为例,两者均声明:
"jsPlugins": ["../twenty-oxlint-rules/dist/oxlint-plugin.mjs"]
也就是说,迁移 TODO 中提到的“规则禁用/再激活”,最终都落在这些 per-package 的 .oxlintrc.json 配置上——这正是理解下面每一节的钥匙。
被临时禁用的规则(twenty-server)
TODO 文档的第一张表列出了迁移后在 twenty-server 中被临时禁用的两条规则:
| 规则 | 原因 | 违规数 | 可自动修复 |
|---|---|---|---|
typescript/consistent-type-imports |
存量违规 3814 处。无法自动修复,因为 NestJS 依赖 emitDecoratorMetadata 实现依赖注入——自动修复会把构造函数参数上的类型导入改写为 import type,这些导入在编译产物中会被擦除,从而直接破坏 DI |
3814 | 否(自动修复不安全) |
twenty/max-consts-per-file |
24 个常量文件中存在 94 处“单文件多导出常量”的存量违规 | 94 | 否 |
consistent-type-imports:与 NestJS 依赖注入的冲突
这条规则要求类型导入统一写成 import type。在前端包里这样做完全安全,但在 twenty-server/.oxlintrc.json 中它被显式关闭:
"typescript/consistent-type-imports": "off",
而同一规则在 twenty-front/.oxlintrc.json 里是打开且强制的:
"typescript/consistent-type-imports": [
"error",
{ "prefer": "type-imports", "fixStyle": "inline-type-imports" }
]
这个前后端差异正是 TODO 文档强调的技术背景:NestJS 控制器/服务大量使用构造函数注入(constructor(private readonly repo: SomeRepository)),编译依赖 emitDecoratorMetadata 在运行时反射出参数的真实类型。若把 SomeRepository 的导入改成 import type,运行时该符号不再存在于模块作用域,装饰器元数据拿不到它,DI 容器就会注入失败。因此 3814 处违规不能跑 --fix 一把梭,只能人工甄别“纯类型用途”的位置再改——这也是 TODO 把它标记为 “unsafe auto-fix” 的原因。
max-consts-per-file:常量文件拆分约定
twenty/max-consts-per-file 是 Twenty 的私有规则,实现在 max-consts-per-file.ts:它监听 VariableDeclaration 节点逐次计数,超过选项 max(schema 要求为 minimum: 0 的整数)就报 tooManyConstants。
对照两个业务包当前的配置,可以看到这条规则的实际落地状态:
- 前端包通过
overrides在常量文件上以max: 1强制执行(一个常量文件只允许 1 个导出常量)——见 twenty-front/.oxlintrc.json 的 overrides; - 后端包的 twenty-server/.oxlintrc.json 的 overrides 对
**/constants/*.ts与**/*.constants.ts显式关闭该规则,且全局 rules 段并未启用它——与 TODO 所述“twenty-server 中暂时禁用,等 24 个常量文件手工拆分后再开启”的状态一致。
完成再激活的规则(twenty-front)
TODO 的第二张表记录了一条成功走通“自动修复 → 再激活”流程的规则:
| 规则 | 修复的违规数 | 方法 |
|---|---|---|
twenty/sort-css-properties-alphabetically |
578 | 通过 npx nx lint twenty-front --configuration=fix 自动修复 |
即:先统计存量违规(578 处 styled-components CSS 属性未按字母序排列),用 Nx 的 lint fix 配置批量自动修复,随后在配置中恢复强制。当前 twenty-front/.oxlintrc.json 中该规则确实处于 "error" 状态。这条路径给出了迁移的标准操作范式:能安全自动修复的规则,修完存量立即恢复原严格度;不能的,挂起并记录原因。
被丢弃的 ESLint 插件及其替代策略
Oxlint 对部分 ESLint 插件生态没有等价实现,TODO 用一张完整表格记录了 11 个受影响插件,这是本篇最值得保留的“对照资产”:
| 插件 | 使用位置 | 原本职责 | 替代 / 现状 |
|---|---|---|---|
eslint-plugin-project-structure |
前端 | 强制 src/modules/ 的目录命名与结构约定(kebab-case 目录、允许的子目录如 hooks/utils/components、hooks/utils 的文件命名)。配置仍在 folderStructure.json |
重写为 twenty/folder-structure 自定义 oxlint 规则。以 "warn" 启用——存量违规 403 处(160 处非 kebab-case 命名、215 处嵌套深度 > 4、28 处命名违规) |
lingui/* |
前端、邮件模块 | i18n 词条抽取与一致性 | 无等价 |
@stylistic/* |
后端 | 格式规则(缩进、空格) | 改用 Prettier |
import/order、simple-import-sort/imports |
后端 | import 排序 | 无等价 |
prefer-arrow/prefer-arrow-functions |
前端 | 强制箭头函数而非 function 声明 | 无等价 |
eslint-plugin-mdx |
文档站 | MDX 文件 lint | oxlint 不支持 |
@next/eslint-plugin-next |
官网 | Next.js 专属规则(禁止裸 <img>、链接处理等) |
oxlint 不支持 |
eslint-plugin-unused-imports |
前端 | 保存时删除未使用 import | 由 no-unused-vars 部分覆盖 |
eslint-plugin-storybook |
前端 | Storybook 最佳实践(story 结构、命名) | 无等价 |
eslint-plugin-jsx-a11y |
前端 | 可访问性(alt 文本、aria、roles 等) | Oxlint 内置 jsx-a11y 插件有部分等价,但覆盖有限 |
eslint-plugin-react-refresh |
前端 | React Refresh 边界校验(HMR) | 无等价 |
这张表透露出的迁移决策逻辑很清晰:
- 职责可被其他工具承接的,直接让渡(格式化交给 Prettier、未使用符号交给
no-unused-vars); - 纯目录结构约束这类“与 AST 语义无关”的规则,重写为自定义规则(
project-structure→twenty/folder-structure); - 框架强绑定插件(lingui、Next.js、Storybook、React Refresh、MDX)则接受缺口,在 TODO 中显式留档,避免团队误以为这些约束仍然生效。
folder-structure:从 ESLint 插件到自定义 oxlint 规则
这是本次迁移里“有损”最小、也最能体现 jsPlugins 能力的一个案例。旧的约束声明保留在 folderStructure.json:src 下允许任意一级目录,modules 下每个模块目录须为 kebab-case、允许递归(folderRecursionLimit: 4),并只接受 hooks、utils、states、types、graphql、components、effect-components、constants 等固定子目录;hooks 内文件须匹配 use{PascalCase}.(ts|tsx),utils 内文件须匹配 {camelCase}.ts。
新的实现 folder-structure.ts 把这套约定改写成了一个逐段校验路径的状态机,几个关键常量与旧配置一一对应:
- KEBAB_CASE_REGEX(
/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/)对应 JSON 中的kebab-case正则参数; - MAX_MODULE_DEPTH = 5 约束模块目录嵌套层数,对应 JSON 的
folderRecursionLimit: 4(TODO 统计口径为“嵌套深于 4 层的模块有 215 处”); - LEAF_SUBDIRS_WITHOUT_FILE_NAMING_CONSTRAINT(states/types/graphql/components/effect-components/constants/validation-schemas/contexts/scopes/services/errors)对应 JSON 里
children: []的叶子目录清单; - hooks 目录还额外约束了 internal 子目录深度(MAX_HOOKS_INTERNAL_DEPTH = 2),并禁止除 hook 文件、
__tests__/__mocks__/internal之外的条目。
每条违规都配有可读的诊断文案(moduleNameNotKebabCase、moduleTooDeep、hookFileNaming、utilFileNaming 等,见 meta.messages),例如要求模块目录 graphWidgetBarChart 改名为 graph-widget-bar-chart、hook 文件必须形如 useMyHook.ts。
TODO 给出的 403 处存量违规构成如下:160 处非 kebab-case 模块目录名、215 处嵌套过深的模块、28 处 util/hook 文件命名违规(如残留的 .util.ts 后缀、kebab-case 文件名、PascalCase 文件名)。规则先以 "warn" 接入,等违规清零后再提升为 "error"。
一个值得注意的事实差异:TODO 文档记录的是迁移进行时点的状态,而从当前仓库快照看,twenty-front/.oxlintrc.json 中 twenty/folder-structure 已经是 "error"——可以推断文档计划中的“违规清零后提升为 error”这一步已在当前代码中完成,TODO 文件本身则保留为过程记录。
IDE 集成现状:jsPlugins 的已知短板
TODO 单独用一节说明了开发体验上的缺口:oxc.oxc-vscode 扩展对 oxlint 内置规则提供行内诊断,但尚不支持 jsPlugins(即 twenty/* 自定义规则)。这意味着自定义规则的违规在编辑器里看不到,只能依赖 nx lint 或 CI 兜底。这也是 Twenty 选择把 twenty/* 规则整体挂在各包 jsPlugins 上的代价:规则逻辑跑得快、配置集中,但 IDE 实时性弱于内置规则。对读者而言,如果你在项目中复用这种“自定义 oxlint 插件”模式,需要把这条体验限制纳入团队预期。
剩余再激活计划与决策依据
TODO 最后一节给出了三条未完成项的处置方案,每条都附了为什么这么做:
- consistent-type-imports(twenty-server):不能安全自动修复,给出三个选项——
- 只在对 DI 构造函数参数之外、确认安全的位置手工补
import type; - 启用 TypeScript 的
verbatimModuleSyntax(代价是一次大迁移); - 保持禁用,直到 oxlint 支持“装饰器感知的 type-import 分析”。
- 只在对 DI 构造函数参数之外、确认安全的位置手工补
- max-consts-per-file(twenty-server):手工拆分 24 个常量文件,使每个文件最多 1 个导出常量,然后恢复启用(前端包已按
max: 1的口径执行,可作为后端的验收参照)。 - folder-structure(twenty-front):已重写为
twenty/folder-structure规则(当时以"warn"接入),处理 403 处存量违规(160 处目录命名、215 处深度超限、28 处文件命名),解决后提升为"error"——当前仓库配置中该规则已处于"error",说明这一收尾已落地。
小结:这份 TODO 对做 Linter 迁移的工程参考价值
把 TODO 文档与仓库现状对照起来看,Twenty 的 ESLint → Oxlint 迁移遵循了三条可复用的原则:
- 对账驱动:所有“失去保护”的规则(禁用规则、被丢插件、自动修复不安全的规则)都有表格化的记录、违规计数和再激活条件,迁移不是一锤子买卖而是可审计的过程;
- 安全边界优先于修复率:宁可让
consistent-type-imports带着 3814 处违规挂起,也不执行会破坏 NestJS DI 的批量自动修复; - 私有规则用 jsPlugins 重写承接:像目录结构、常量文件拆分这类组织级约束,通过 twenty-oxlint-rules 以
twenty/*命名空间重建,并保留 vitest 单测(rules/ 下每条规则均有.spec.ts,注册表本身也有 plugin-registry.spec.ts 校验),保证“换引擎”不丢失既有约束力。
对正在评估 Linter 替换的团队,这份文档与其说是一个 TODO,不如说是一份现成的迁移风险评估模板:先盘点存量违规与自动修复安全性,再按“让渡 / 重写 / 接受缺口”三分法处理插件缺口,最后用 warn → 清零 → error 的节奏恢复严格度。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00