16 KiB
第十五章:biome.json 的 42 条规则关闭 —— 反编译产物的指纹
42 条 lint 规则被关闭不是偷懒,是反编译代码对 linter 提出的最后通牒
一份任何现代项目都不敢提交的 biome 配置
打开 biome.json:24,你会看到一个让大多数 linter 爱好者血压升高的配置。suspicious 组关了 12 条,style 组关了 9 条,complexity 组关了 12 条,correctness 组关了 9 条。加上 a11y 和 nursery 两个 recommended 集整体关闭,总共 44 处 "off"。CLAUDE.md 说的"42 条"是其中 42 个具名规则(不算 a11y/nursery 的 recommended 整体关闭)。
// biome.json:26-38
"suspicious": {
"noExplicitAny": "off",
"noAssignInExpressions": "off",
"noDoubleEquals": "off",
"noRedeclare": "off",
"noImplicitAnyLet": "off",
"noGlobalIsNan": "off",
"noFallthroughSwitchClause": "off",
"noShadowRestrictedNames": "off",
"noArrayIndexKey": "off",
"noConsole": "off",
"noConfusingLabels": "off",
"useIterableCallbackReturn": "off"
}
如果你在一个全新项目中提交这样的配置,code review 的第一条评论一定是:"你确定要关 noExplicitAny?noDoubleEquals?noConsole?" 在正常项目中,这些是底线中的底线。
但这个项目不是正常项目。这是一个反编译重建的 CLI,几十万行 TypeScript 的每一行都经过 decompiler 的洗礼,变量名是合成的,类型信息是推断的,控制流是还原的。逐行修复 42 条规则意味着重写整个代码库——这恰好是反编译重建工作要避免的。
关闭的每一条规则背后都有一个反编译的必然
关掉的 42 条规则可以分成四个阵营,每个阵营对应反编译产物的一个系统性特征。
suspicious 组:decompiler 不生成的代码
noExplicitAny(biome.json:27)—— 反编译器在无法还原类型标注时,默认产出 any。src/services/api/ 下的流适配器满是 any,因为原始代码的类型在编译为 JavaScript 后被擦除。decompiler 只能从运行时行为推断,推断不出来就给 any。
noDoubleEquals(biome.json:29)—— decompiler 还原比较表达式时偶尔产出 == 而非 ===,因为原始 JavaScript 中的 == 和 === 编译到同一份字节码后,decompiler 无法区分原始意图。全局搜索项目中的 ==,你会发现它们集中在 decompiler 输出的早期模块中。
noRedeclare(biome.json:30)—— decompiler 有时会为同一个变量生成多个声明(来自不同作用域的合并或 switch-case 的变量提升)。这不是你手写的代码会犯的错误,但 decompiler 的控制流重建算法不可避免。
noFallthroughSwitchClause(biome.json:33)—— 原始代码可能利用了 switch fallthrough,decompiler 如实还原。手写代码不应该用 fallthrough,但反编译产物必须忠实于原始行为。
noConsole(biome.json:36)—— 29 个文件在文件顶部声明 biome-ignore-all lint/suspicious/noConsole。打开 src/utils/claudeInChrome/chromeNativeHost.ts:1:
// biome-ignore-all lint/suspicious/noConsole: file uses console intentionally
这个文件作为 Chrome Native Host 运行,console.log 是它与宿主通信的标准通道。反编译产物中大量 console.log 用于调试桥接层,关掉规则比逐个审查每一条 console 调用的意图更务实。
style 组:decompiler 的代码风格不是你的代码风格
useConst(biome.json:41)—— decompiler 统一产出 let,即使在语义上应该是 const。这因为 JavaScript 运行时不区分 let 和 const(除了 TDZ),字节码中只有一个变量声明指令。decompiler 不知道原始源码用的是 let 还是 const,保守地全部输出 let。
useTemplate(biome.json:46)—— 字符串拼接 vs 模板字面量的选择在编译后完全消失。decompiler 还原时,有时输出 'hello' + name,有时输出 `hello${name}`,取决于它如何重建 AST。这不是一个可以在不改变语义的情况下批量修复的问题。
useImportType(biome.json:49)—— import type { X } vs import { X } 在编译后都是同样的 require 调用。decompiler 无法判断一个导入是否只在类型位置使用,所以统一生成普通 import。
complexity 组:decompiler 的 AST 还原策略
noForEach(biome.json:52)—— decompiler 将 for...of 和 .forEach() 互相转换没有固定偏好。原始代码用 for...of 的地方可能被还原成 .forEach(),反之亦然。批量统一风格的工作量与收益不成比例。
useArrowFunction(biome.json:62)—— 同理。function 和箭头函数在编译后只有微妙的 this 绑定差异,decompiler 不一定能正确还原。全局搜索你会发现项目里两种风格并存——反编译产物中 this 绑定的原始上下文已经丢失。
noBannedTypes(biome.json:53)—— Function、Object、{} 这些 banned types 在反编译产物的类型声明中大量出现,因为 decompiler 的类型推断粒度就是 Object。
correctness 组:死代码与 unreachable 的诚实保留
noUnreachable(biome.json:70)—— 反编译产物中有大量 feature-gated 的不可达代码。当 feature('X') 被 Bun 编译器 DCE 后变成 if (false) 时,分支内的代码变成 unreachable。但 source 层面它们仍然存在——你需要它们存在,因为 dev 模式下 feature() 返回 true。
noConstantCondition(biome.json:73)—— 同理。if ('production' === 'development') 是 MACRO 替换后的永假比较。这个判断在 build.ts 中通过 Bun.build({ define }) 把 'production' 注入为字面量,dev 模式下注入 'development'。tsc 不理解 define 注入,报错——只能用 @ts-expect-error 压制。
noUnusedVariables(biome.json:66)和 noUnusedImports(biome.json:67)—— 反编译产物的变量使用模式经常是"先声明后使用在另一个 switch-case 分支中",decompiler 的作用域重建不一定能正确识别跨分支的引用关系。
useExhaustiveDependencies(biome.json:68)—— React hooks 的依赖数组在编译后完全消失。decompiler 无法还原 useEffect / useMemo 的原始依赖数组,只能产出空数组或不完整的数组。这是 React Compiler 的 _c() memoization 模板出现后尤其明显的问题(参见第十章)。
.tsx 的特权:lineWidth 120 + 强制分号
biome.json:102-113 的 overrides 区域有一条令人好奇的规则:
// biome.json:102-113
"overrides": [
{
"includes": ["**/*.tsx"],
"javascript": {
"formatter": {
"semicolons": "always"
}
},
"formatter": {
"lineWidth": 120
}
}
]
所有 .tsx 文件享有 120 字符行宽(其他文件 80)和强制分号(其他文件 asNeeded)。这不是拍脑袋的决定。
120 字符行宽是因为 JSX 的嵌套结构天然占宽度。一个包含 className、onClick、condition && <Component /> 的 JSX 表达式,80 字符行宽下几乎必然被格式化器断成碎片——每个属性一行、每个嵌套标签一行。120 字符让一个完整的组件调用能留在同一行,可读性显著提升。
强制分号的原因更微妙。.tsx 文件使用 React Compiler 输出(_c() memoization 调用),这些调用在 decompiler 还原时已经定型。asNeeded 模式下 Biome 可能删除某些 ASI(Automatic Semicolon Insertion)安全位置的分号,但 React Compiler 的 _c() 模板假设分号存在——去掉分号可能改变 ASI 边界的行为。always 是最安全的选择。
52 个 biome-ignore-all:ANT-ONLY 标记的禁区
全局搜索 biome-ignore-all,你会发现 src/ 下有 30 个文件、packages/ 下也有若干文件在文件顶部声明了这个指令。其中最常见的一条是:
// src/commands.ts:1
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
29 个文件使用完全相同的 ANT-ONLY import markers must not be reordered 理由。这些文件的 import 语句中混入了特殊标记——ANT-ONLY 注释标记了只有内部版本才会编译进去的 import 路径。Biome 的 organizeImports assist 功能会重排 import 语句,但这些标记的位置和顺序不能被打乱,否则 bun:bundle 的编译期处理会出错。
打开 src/commands.ts:1,紧跟着 import 标记注释的就是一大段命令注册代码——每个命令都是一个独立的 import。反编译产物的 import 顺序不是按字母序的,而是按原始模块的注册顺序。organizeImports 会把它们重排成字母序,破坏隐含的初始化顺序依赖。
biome-ignore-all 在这些文件中是 // 行级注释——整文件生效,不分具体规则。这说明"不要碰这个文件的 import"是一条不可妥协的红线。
tsc vs biome 的零和博弈
biome.json 关了 42 条规则,但有一条它没关:noUnusedPrivateClassMembers(correctness/recommended 默认启用)。这条规则与 TypeScript 的严格模式产生了一个有趣的两难。
打开 src/native-ts/file-index/index.ts:51:
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: used via destructuring in search()
tsc 在 strict 模式下要求类属性必须有类型声明。某些情况下,一个类属性只在赋值时使用(通过解构赋值读取),tsc 要求声明但不读取——biome 则报告"声明了但从未读取"。两个工具的语义不兼容:tsc 要求声明是为了类型完整性,biome 报 unused 是因为它只看读取行为。
解决方案是 biome-ignore 注释——逐个压制。这不是一个能通过改 biome 配置解决的问题,因为关掉这条规则会让真正未使用的私有成员溜过去。CLAUDE.md 里的指导原则是:
用
// biome-ignore lint/correctness/noUnusedPrivateClassMembers: <原因>抑制 lint 警告,保留类型声明。
每个 biome-ignore 必须附带原因——这是防止"关规则变成文化"的最后防线。
@ts-expect-error 的维护纪律
@ts-expect-error 在反编译代码中有两类用途,维护纪律截然不同。
第一类是 MACRO 替换产生的永假比较。scripts/defines.ts:18 定义了 MACRO.VERSION 等编译期常量,build.ts 和 scripts/dev.ts 分别用 Bun.build({ define }) 和 bun -d 注入。当 NODE_ENV 被替换为 'production' 时,'production' === 'development' 永假——tsc 不知道 define 注入,会报 TS2578。这个 @ts-expect-error 必须永久保留。
第二类是类型系统更新后变为多余的 directive。当 TypeScript 版本升级或类型声明补全后,原来需要 @ts-expect-error 的代码可能不再有类型错误。此时 tsc 报 TS2578(Unused '@ts-expect-error' directive),意味着 directive 本身变成了错误。CLAUDE.md 的规则是:
如果类型系统已更新导致 directive 变为 unused(TS2578),直接移除注释。
这是 bun run precheck 能通过的前提——precheck 同时跑 tsc 和 biome,任何多余的 @ts-expect-error 或不足的 biome-ignore 都会导致 CI 失败。
CI 的 biome ci . 零容忍
biome.json 关了 42 条规则,但 CI 的 ci.yml 仍然跑 bunx biome ci .。这不是矛盾——42 条关闭之外,所有 recommended 规则仍然生效。
ci.yml 的工作流是:先安装依赖,然后 lint,再 typecheck,最后 build 和 test。biome ci 如果发现任何 warning,CI 就失败。这意味着:
- 新代码不能引入新的
any(除非你也在biome.json里关掉noExplicitAny,而它已经关了)。 - 新代码不能引入新的
console.log(除非文件顶部有biome-ignore-all)。 - 每个局部
biome-ignore必须附带原因注释,否则 PR review 会打回。
42 条规则关闭是"历史债"的合法化。biome ci 零容忍是"不再积累新债"的纪律。两者并存,构成一个有趣的平衡:承认过去无法重写,但也不允许未来继续退化。
如果不这么做——如果不关这 42 条规则——你有两个选择:(A) 逐行重构几十万行反编译代码(工程量相当于重写),或者 (B) 不用 biome(lint 基线完全丧失)。A 不现实,B 不可接受。所以 42 条关闭是唯一的可行路径。
using _ 的脆弱 transpile
biome.json 本身不涉及 transpile,但整个 lint 配置的生存依赖于一条脆弱的构建期替换。
打开 scripts/vite-plugin-feature-flags.ts:68-74:
// 2. Transpile `using _ = expr;` to `const _ = expr;` for Node.js compat.
// Node.js v22 does not support `using` declarations (Explicit Resource Management).
// Safe because: SLOW_OPERATION_LOGGING is not enabled, so slowLogging returns
// a no-op disposable whose [Symbol.dispose]() is empty.
if (transformed.includes('using _')) {
transformed = transformed.replace(/\busing\s+(_\w*)\s*=/g, 'const $1 =')
modified = true
}
Vite 构建插件把所有 using _ = slowLogging\...`正则替换为const _ = slowLogging`...`。这是因为 Node.js v22 不支持 using` 声明(Explicit Resource Management 提案),而构建产物必须兼容 Node.js 运行。
打开 src/utils/slowOperations.ts:191,你会看到源码中使用 using 的典型模式:
using _ = slowLogging`JSON.stringify(${value})`
return JSON.stringify(value, replacer as Parameters<typeof JSON.stringify>[1], space)
slowLogging 是一个 tagged template,返回 Disposable(slowOperations.ts:155-160)。当 SLOW_OPERATION_LOGGING 未启用时(默认情况),它返回一个 no-op disposable(slowOperations.ts:126),[Symbol.dispose]() 是空函数。正则替换把 using _ 换成 const _ 后,这个 no-op 对象被赋值给 _ 然后立刻丢弃——行为等价,但不再依赖 ESM Explicit Resource Management。
这条 transpile 的安全性依赖于一个前提:SLOW_OPERATION_LOGGING 未启用。如果启用了,slowLogging 返回 AntSlowLogger(slowOperations.ts:95),它的 [Symbol.dispose]() 真正执行计时和日志——替换成 const 后 dispose 永远不会被调用,慢操作检测静默失效。DEFAULT_BUILD_FEATURES 列表(scripts/defines.ts:39)里没有 SLOW_OPERATION_LOGGING,所以当前构建安全。但这是一种隐式契约——如果将来有人把 SLOW_OPERATION_LOGGING 加到默认 features 里,biome ci . 仍然通过(因为 using 已被 transpile 掉),但慢操作检测会静默失效。没有编译期或运行时的机制阻止这种错误。
如果不这么做会怎样
假设你决定不关这 42 条规则——逐行修复反编译产物。你面对的第一个问题是 noExplicitAny:src/services/api/ 下的流适配器有数百个 any,每个都需要手动推断原始类型。由于类型在编译时被擦除,你的推断只有"合理猜测"的精度。猜错了,运行时行为就变了——反编译产物最脆弱的地方就是"看起来对但行为不同"的代码。
第二个问题是 noUnusedVariables 和 noUnusedImports。decompiler 产出的变量使用模式中,跨 switch-case 分支的引用、feature-gated 的条件使用、React Compiler _c() 的隐式引用——这些都不是简单的"声明了但没用",而是"在反编译器的控制流重建中,使用点被放到了 lint 工具看不到的地方"。批量删除这些"unused"变量,你会破坏运行时逻辑。
第三个问题是工程成本。几十万行代码逐条修复 42 类 lint 问题,保守估计需要数人月。而反编译重建工作的核心目标是恢复功能,不是美化代码。42 条关闭是一个理性的资源分配决策:把有限的人力放在功能恢复和测试覆盖上,而不是放在让 linter 满意上。
延伸阅读
- 想看 feature flag 编译期替换如何与 linter 交互(
if (false)产生 unreachable),见 第五章:Feature Flag 系统的三个硬约束 - 想看 React Compiler 的
_c()模板如何在反编译产物中大量出现并与 lint 规则冲突,见 第十章:自研 Fork 的 Ink 框架 - 想看
using _transpile 所在的 Vite 构建管线,见 第一章:Code Splitting 不是优化,是生存需求 - 想看测试如何与 mock 污染共存(另一个"承认现状、守住底线"的案例),见 第十四章:测试策略