PR #102:给 eyrie 仓库装上一整套护栏

EyrieAI/eyrie · ci-constraints-hardening(origin/main...688dbda)· 2026-07-05 · 自包含,读完即弃

29 commits(含 3 次 merge main)
51 文件
+1700 / −258
≈29% 是守卫自己的测试
18 条新约束

写法说明:这个 PR 几乎全是「规则」而不是「功能」,所以本文不走旅程式叙事,改为把每条约束逐条摆开——守什么、为什么、挂在哪一层、被拦了怎么办。代码片段取自分支真实文件(经裁剪),青色斜体注释为解读所加,灰色斜体是源码原注释。每张卡片右上角的彩色小标签标注该守卫挂在哪一层(第 2 节有分层地图)。

1TL;DR

起点是 6 月底一次对整个仓库的「该加哪些 CI 约束」审计:把所有现有门禁抓不住、但一旦发生就很疼的劣化路径列了一遍(每条都在当时的代码里找到了实证)。这个 PR 分两轮把其中高置信的条目全部做成机械守卫——不靠 review 时人眼盯、不靠自觉,靠脚本在 commit 和 CI 上直接拦。

两轮合计 18 条约束:Round 1 侧重「已经疼过或差点疼」的高危项(native 依赖构建、Electron 安全基线、死代码、主题 token 打错字),Round 2 补齐中低危的架构边界、供应链和跨平台项。每个守卫脚本都带自己的测试,且测试覆盖两条臂:违例真的被抓到 + 干净的树不误报

顺手带出一个大件:zod 从 3.25 全仓统一升到 4.4.3,由此解开了 knip 版本被钉死的死结(第 5 节,写 schema 的同学必读)。

scripts/
≈1240 行 · 守卫+测试
apps/desktop
≈190 行 · 死码清理+修复
bun.lock
≈140 行 · 机械
api/daemon 源码
≈70 行 · zod 4 迁移
eslint/biome/knip
≈60 行 · 规则配置
.github/
50 行 · workflow

2门禁分层:什么时候被谁拦

先记住一件事:仓库的门禁分快慢两层,快层每次 commit 都跑,慢层攒到 verify / CI 再跑。这个 PR 往两层各塞了新东西,还把三个太慢的守卫从快层挪去了慢层——所以 pre-commit 的体感不会变慢。

什么时候跑这个 PR 之后包含什么
check 快层 每次 git commit(pre-commit)+ CI 11 个脚本:包管理器锁、工具链版本钉死*workspace 脚本*.gitattributes*、英文-only(扩容*)、注释覆盖、包边界(扩容*)、密钥扫描、i18n、theme-tokens*CSP*(* = 本 PR 新增/扩展)
check:ci 慢层 bun run verify 开头 + CI trusted-deps、knip 死码门禁、boundaries 金丝雀(都是本 PR 新增,太慢所以不进 pre-commit)
lint / typecheck verify + CI(pre-commit 跑 staged 文件的 lint + 全量 typecheck) eslint 新规则(no-new-func*、幻影依赖*)、biome 新规则(禁 skip 测试*)、tsc 覆盖面扩大(config.ts*
workflow 自身 GitHub Actions 运行时 最小权限块*、action 大版本*、bun 精确 pin*、loopback fail-closed 环境变量*

CI 跑 bun run check && bun run verify 两条命令,所以四层最终都会在 CI 上过一遍;pre-commit 是它的本地子集(check + staged lint + typecheck + 全量测试,不含 build 和慢层)。

3Round 1 守卫(7 条)

Round 1 挑的都是「已经真实疼过、或者审计时发现一改就炸」的高危项。

① trusted-deps:native 依赖必须上白名单check:ci 慢层scripts/check-trusted-deps.mjs

守什么:bun 有个安全设计——只有列在根 package.jsontrustedDependencies 里的包,安装时才允许跑 install/postinstall 钩子。native 模块(node-pty、better-sqlite3 这种带 C++ 编译的)恰恰靠这个钩子编译出 .node 二进制。忘了上白名单 = 钩子被静默跳过 = 二进制缺失 = require() 时崩。最阴的是:你本机有预编译缓存所以全绿,只有需要从源码编译的 ubuntu CI 会红——node-pty 就这么炸过一次。

怎么守:脚本走一遍 node_modules(含嵌套和 @scope),识别所有 install 钩子会触发 native 编译的包(node-gyp / prebuild-install / cmake-js / napi 等模式),逐个核对是否都在白名单里,缺谁报谁。

被拦了怎么办:把报出来的包名加进根 package.jsontrustedDependencies 数组,重新 bun install
② Electron webPreferences 安全基线不许翻转check 快层scripts/check-boundaries.mjs(内容规则)

守什么:Electron 窗口的 webPreferences 是渲染进程的「牢门」——contextIsolation: falsenodeIntegration: truesandbox: false 任何一个被翻转,页面里的 XSS 就能直接摸到 Node API,升级成本机代码执行。以前只有一个「配置对象长这样」的测试,既抓不住值被翻转,也抓不住新加一个危险 key(webviewTagenableRemoteModule 这类)。

怎么守:对 apps/desktop/src/main/ 下的源码做文本级匹配——翻转值(如 sandbox: false)和危险 key(无论什么值)出现即红。文本匹配的好处是不依赖配置对象怎么组装。

被拦了怎么办:别改基线。真有需求先来讨论,这是安全红线不是风格问题。
③ 禁永久跳过的测试biome(lint)biome.json · noSkippedTests

守什么describe.skip / it.skip 是「暂时关掉回头再修」的标准起手式,然后就没有回头了——被 skip 的测试在报告里显示为跳过、不算失败,腐烂得无声无息。

怎么守:biome 的 noSkippedTests 升为 error。注意运行时条件跳过仍然合法describe.skipIf(条件) 这种按环境降级的写法(比如绑不了 loopback 的机器跳过集成套件)不受影响,禁的只是写死的 .skip

被拦了怎么办:要么修好测试,要么删掉它,要么改成 skipIf(环境条件)。真有必须永久 skip 的场景,用 biome-ignore 注释并写清理由。
④ loopback fail-closed:CI 不许静默跳过集成套件CI 环境变量 + 测试 harnessci.yml · apps/cli/tests/cli-helpers.ts

守什么:daemon 集成测试要在 127.0.0.1 上起真服务。测试 harness 的原设计是「绑不了 loopback 就跳过这批测试」——对开发机是贴心降级,对 CI 是灾难:一台网络配置有问题的 CI 机器会静默跳过 29 个集成测试然后报绿,没人知道少测了。

怎么守:CI 的 verify 步骤设 EYRIE_REQUIRE_LOOPBACK=1;这个变量存在时,绑不上 loopback 不再是「跳过」而是直接抛错红掉。本地不设这个变量,降级行为保持不变。

被拦了怎么办:这说明 CI 机器本身坏了(绑不了 127.0.0.1),修 runner,不是修代码。
⑤ boundaries 金丝雀:证明分层 lint 还活着check:ci 慢层scripts/check-boundaries-canary.mjs

守什么:渲染层的分层规则(shared → entities → features → app 单向依赖)由 eslint-plugin-boundaries 强制。但这个插件有个致命特性:它得先把 import 语句解析成真实文件才能判层,解析器一坏(比如 @/ 别名配置失效),所有边界规则会静默全部通过——不是报错,是假绿。

怎么守:像煤矿里带金丝雀。脚本往真实的 entities/task 目录里丢一个故意违反分层的 fixture 文件(entity import feature),跑 eslint,断言它真的报错,然后删掉 fixture。金丝雀不叫 = 守卫已死 = CI 红。

scripts/check-boundaries-canary.mjs真实代码(节选)
// An entity importing a feature — the dependency direction the layer rule forbids.
const fixtureSource = `import { Board } from '${illegalImport}'\n…`  // illegalImport = '@/features/tasks'
// 同时踩中 resolver 的两个易坏点:@/ 别名解析 + .tsx 解析——任何一个失效金丝雀都会“不叫”
被拦了怎么办:金丝雀挂了说明 eslint 的 resolver 配置坏了,查 eslint.config.js 里 boundaries 的 resolver 段。已知小坑:金丝雀运行中被强杀(超时/Ctrl-C)会留下孤儿 fixture boundaries-canary-*.tsx,之后 lint 突然报 entities/task 下有违例——删掉那个文件即可。
⑥ knip:死文件和没人用的依赖不许赖着check:ci 慢层knip.json · check:knip

守什么:knip 是死代码探测器——从每个 workspace 的入口出发做可达性分析,找出没人 import 的文件和 package.json 里声明了却没人用的依赖。此前它只是「可以手动跑跑看」的软信号,现在 knip --include files,dependencies 进了门禁:死文件 = 红,未使用依赖 = 红。落地当天就顺手删掉了一批死掉的终端/shell 模块和 7 个僵尸依赖。

怎么守:注意门禁只收了 files 和 dependencies 两类;「未使用的 export / 类型」不在门禁里——barrel 桶文件的 re-export 天然噪音太大,等清理完 barrel 再说。

被拦了怎么办:真死码就删;确认是误报(比如动态引用的入口文件)就在 knip.json 给对应 workspace 补 entry。别无脑加 ignore。
⑦ theme-tokens:CSS 变量打错字不再渲染成透明check 快层scripts/check-theme-tokens.mjs

守什么:渲染层颜色统一走 rgb(var(--color-x)),token 的真相源是 theme.ts。CSS 变量有个恶心的特性:引用一个不存在的变量不报任何错,颜色直接渲染成透明。这个守卫的起因就是真实事故——两处写成了 var(--ink-faint)(正确是 --color-ink-faint),UI 上那块颜色悄悄消失,build/lint/test 全绿。

怎么守:两条规则。renderer 里出现的每个 var(--…) 必须能在 theme.ts 的 tokenCssVars 或 tokens.css 里找到定义;.css 文件里不许出现字面色值(hex/rgb 裸写——eslint 的同类禁令只覆盖 .ts/.tsx,这里补上 .css 的洞)。那两处事故本体也在本 PR 里修掉了。

被拦了怎么办:报「未定义 token」多半是打错字,对照 theme.ts 改名;真要新颜色,先去 theme.ts 加 token 再引用。

4Round 2 守卫(11 条)

Round 2 补齐审计里的中低危项:架构边界、依赖卫生、供应链和跨平台。前两条是分量最重的。

① @eyrie/db 只有 daemon 能碰 + 边界规则自动覆盖未来的 appcheck 快层scripts/check-boundaries.mjs

守什么@eyrie/db 里是 Drizzle 表结构和从它推导的数据库行类型(ProjectRow 这类)。这些类型的形状跟着数据库 schema 走,一旦被 CLI、desktop 或 api/client 包直接 import,以后每次改表结构,整条调用链都会跟着抖。架构上早就规定「DB 类型只住 daemon,外面用 @eyrie/api 的 DTO」,但此前没有任何机械检查。

怎么守:boundaries 脚本加一条 import 规则(源码里 import @eyrie/db 即红)+ 一条 manifest 规则(非 daemon app 的 dependencies 里出现它也红)。同一个 commit 里还把「app 源码不许 import @eyrie/daemon」的老规则从写死的 cli/desktop 路径泛化成任意 apps/* 目录自动生效——将来加 apps/web 不需要回来改守卫:

scripts/check-boundaries.mjs真实代码(节选)
// Matches source dirs of every app except the daemon, so boundary rules cover future apps
// (apps/web, apps/mobile, …) without editing this file again.
const isNonDaemonAppSource = (path) =>
  path.startsWith('apps/') && !path.startsWith('apps/daemon/') && path.includes('/src/')
被拦了怎么办:需要的数据形状去 @eyrie/api 找对应 DTO;没有就加一个 DTO,而不是把 DB 类型漏出来。
② 幻影依赖:import 的包必须自己声明eslint(lint)eslint.config.js · import-x/no-extraneous-dependencies

守什么:monorepo 有个隐形陷阱叫「幻影依赖」——你 import 一个自己 package.json 里没声明的包,恰好因为别的包依赖它、被 hoist(提升安装)到了根 node_modules,于是能跑。直到某天那个「别人」不再依赖它,你的代码原地爆炸,而且没人改过你一行代码。对将来要开源、包边界要自描述的仓库,这是必须堵的洞。

怎么守:每个 import 必须声明在自己 workspace 的 manifest 或根 manifest 里。两个精心设计的豁免(是正确语义,不是开后门):测试/config/scripts 文件可以用根上声明的共享 dev 工具(vitest 等 183 处测试 import 因此合法——它们永不进出厂产物);apps/desktop 整体放宽 devDependencies——electron-vite 把 app 全量打包,electron 本体从 src import devDep 是 Electron 生态的标准姿势。没声明在任何地方的包,在哪都会红

eslint.config.js真实代码(节选)
// Discovered at config load so a new workspace inherits the phantom-import rule automatically.
const workspaceDirs = ['apps', 'packages'].flatMap((group) =>
  readdirSync(resolve(import.meta.dirname, group), { withFileTypes: true })
    .filter((entry) => entry.isDirectory())
    .map((entry) => `${group}/${entry.name}`),
)  // 每个 workspace 一个规则块:packageDir = [根, 本 workspace],新 workspace 自动继承

落地时全仓扫描零违例——manifests 比预想干净(比如 packages/db 对 drizzle-orm 用的是 peerDependencies,正是库的标准姿势,规则默认放行 peer)。

被拦了怎么办:把包声明进你所在 workspace 的 dependencies(运行时用)或根 devDependencies(纯 dev 工具)。别扩豁免。
③ 禁 new Function():字符串变代码的最后一条路eslint(lint)eslint.config.js · no-new-func

守什么:biome 早就禁了 eval(),但 new Function('x', 'return x') 是功能完全等价的绕路——同样把运行时字符串变成可执行代码,在 Electron 的 main/preload 信任边界里就是现成的远程代码执行入口。当前全仓零使用,规则是纯预防。

被拦了怎么办:没有豁免路径,重新设计。需要动态行为用查表 / 策略对象,不是拼代码字符串。
④ CSP script-src 不许出现 unsafe-*check 快层scripts/check-csp.mjs

守什么:desktop 渲染页的 CSP(内容安全策略,写在 index.html 的 meta 里)是 XSS 和「XSS 升级成本机代码执行」之间的最后一道墙。已有测试只检查「CSP 这块还在」,往 script-src 里加 'unsafe-eval''unsafe-inline'(两大放水关键词)可以一路绿过

怎么守:脚本解析 meta 里的策略,断言 script-src 存在、且不含任何 'unsafe-*'。注意只打 script-src——style-src 里的 'unsafe-inline' 是 Tailwind 注入样式需要的,合法保留。

被拦了怎么办:别加 unsafe。第三方库要求 eval 的,找不需要 eval 的替代或构建期方案。
⑤ 工具链版本钉死:CI 和本地必须装同一个 buncheck 快层scripts/check-toolchain-pins.mjs + ci.yml

守什么:审计抓到一个现行漂移:本地 packageManager 精确钉在 bun@1.3.14,CI 却写着 bun-version: 1.3.x 浮动——bun 出个有行为差异的 patch,就会出现「本地绿、CI 红」且极难排查的日子。同理,engines.node 以后升了大版本,没人会记得回来改 workflow 里的 node-version

怎么守:ci.yml 的 bun 版本改成精确 1.3.14(漂移当场修掉);新守卫脚本断言所有 workflow 的 bun-version 恰好等于 packageManager 的值(不许范围),所有 node-version 的大版本等于 engines.node 的大版本。

被拦了怎么办:升 bun 时同时改 package.json 的 packageManager 和 ci.yml 的 bun-version,两处不一致就过不了 commit。
⑥ 有 TS 源码的 workspace 必须声明 typecheck 脚本check 快层scripts/check-workspace-scripts.mjs

守什么:全仓 typecheck 是 bun --filter '*' typecheck 挨个 workspace 调用的。这个命令对没声明 typecheck 脚本的 workspace 静默跳过、照样 exit 0——新建一个 package 忘写这个脚本,它的代码就永远不过 tsc,而所有门禁常绿。

怎么守:凡是 git 里有 .ts/.tsx 源码的 workspace,package.json 必须声明 typecheck。没有源码的配置包(packages/tsconfig)自然豁免——判定标准是「有没有 tracked 源码」而不是写死的名单。(原计划连 test 脚本一起强制,落地时实证推翻:本仓测试由根 vitest 单配置全局 glob 发现,新 workspace 的测试自动被收集,不存在静默跳过的洞,强制反而会逼出三个没人调用的死脚本——所以只守 typecheck。)

被拦了怎么办:给新 workspace 的 package.json 加 "typecheck": "tsc --noEmit"(和一个 tsconfig)。
⑦ tailwind/drizzle config 纳入类型检查typecheckapps/desktop/tsconfig.main.json · packages/db/tsconfig.json

守什么tailwind.config.tsdrizzle.config.ts 此前不在任何 tsconfig 的 include 里——tsc 从来没看过它们,里面写出类型错误没有任何工具会发现(eslint 对 config 文件也关了类型感知)。

怎么守:各自加进邻近项目的 include。顺手拆了一颗小雷:packages/db 的 tsconfig 原来写着 rootDir: "src",在 noEmit 模式下它只剩「限制文件必须在 src 下」这一个作用,恰好挡住 config 进来,删掉了。

被拦了怎么办:config 里的类型错误现在会在 typecheck 阶段报出来,照常修就行。
⑧ 英文-only 闸扩到 .sql 和 .examplecheck 快层scripts/check-cjk.mjs

守什么:仓库将来开源,源码注释、字符串必须英文(i18n 语言包除外),有专门的 CJK 扫描闸。但闸的扫描范围按扩展名来,漏了 .sql(Drizzle 迁移文件)和 .example(env 示例)——中文注释可以从这两种文件溜进去。改动是正则里加两个扩展名。

被拦了怎么办:和其他文件一样——写英文,用户可见的中文文案进 i18n locale 包。
⑨ .gitattributes 的全局 eol=lf 不许丢check 快层scripts/check-gitattributes.mjs

守什么* text=auto eol=lf 这一行是跨平台协作的地基:没有它,Windows checkout 会把文件换行改写成 CRLF,而 .husky/ 下的钩子是 sh 脚本——sh 遇到 \r 直接罢工,整条本地钩子链在 Windows 上静默失效(顺带所有文件的 diff 被换行符污染)。

怎么守:断言文件存在且全局 LF 规则完好(属性顺序、空白宽容;*.bat 保 CRLF 的窄规则不受影响)。

被拦了怎么办:把那行加回去。想给某类文件例外,加窄规则(如 *.ps1 eol=crlf),别动全局行。
⑩ CI workflow 的 token 收到最小权限workflow.github/workflows/ci.yml

守什么:GitHub Actions 的 workflow 不写 permissions: 块时,拿到的 GITHUB_TOKEN 是默认宽权限——CI 只做 checkout + 跑本地脚本,多出来的权限纯属攻击面(供应链攻击拿到 workflow 上下文时能干的事)。其他 workflow 早已有权限块,只剩 ci.yml 裸奔。

怎么守:顶层 permissions: contents: read,两个 job 都只需要读。

被拦了怎么办:将来某步真需要写权限(比如发 comment),在那个 workflow 里按需加最小的一项,写清用途注释。
⑪ actions 升到 Node 24 运行时workflow全部 5 个 workflow

守什么checkout@v4 / setup-node@v4 跑在 GitHub 正在弃用的 Node 20 action 运行时上,CI 日志一直刷弃用警告。升到 v5(迁到 Node 24 的最小大版本,刻意不冲最新的 v7/v6,减小行为变更面)。setup-bun@v2 已是最新不动。独立成一个 commit,靠 push 后 CI 全绿完成验证——已验证通过。

注意:这只是「换新版本号」;按 SHA 钉死 action(防 tag 被劫持)是另一件事,本轮明确不做(见第 8 节)。

5zod 4 + knip 6:写 schema 的同学看这里

背景:knip 一直被钉死在 5.64.0——因为 5.65 起 knip 内部要用 zod 4,而产品全局 override 钉着 zod 3.25.76,两者在同一棵依赖树里打架。原方案是给 knip 子树做嵌套 override 的隔离实验;拍板改成更彻底的路线:全项目统一一个 zod 版本,直接升 4。于是 zod 3.25.76 → 4.4.3(根 override + api + daemon 三处),knip 顺势解钉到 6.24.0,长期债清偿。

产品代码只有 api 和 daemon 的 9 个文件用 zod,迁移量很小,但有几条 zod 4 的写法变化从此对所有人生效

以前(zod 3)现在(zod 4)性质
z.record(值schema)z.record(z.string(), 值schema)硬破坏,单参数直接编译不过
z.ZodIssueCode.custom字面量 'custom'硬破坏,ZodIssueCode 没了
z.string().uuid()z.uuid()旧写法弃用仍能跑,新代码用新式
z.string().datetime()z.iso.datetime()同上
z.nativeEnum(枚举对象)z.enum(枚举对象)同上,z.enum 现在直接吃对象
issue code invalid_string改名 invalid_format校验错误码变了(见下)
issue.path(string|number)[]变成 PropertyKey[](可含 symbol)错误信封两处已做归一化,业务代码无感
写测试注意:zod 4 把默认报错文案全换了(比如 "Required" 变成 "Invalid input: expected string, received undefined")。断言校验错误时断言 validationCode(错误码),别断言 reason 的具体文案——本次迁移 1833 个测试只有 1 个挂,挂的就是把 code 写死成旧名的那个。

knip 5→6 的可见变化:分析更聪明,会把已经不需要的 ignore 配置和冗余 entry 报成 hints——本 PR 顺手清掉,当前 check:knip 是 0 findings 0 hints 的干净基线,请保持。

6「我被拦了」速查表

换位速查:按你看到的报错找行动。前四行是最可能日常撞上的。

你看到的拦你的是怎么办
eslint:'xxx' should be listed in the project's dependencies幻影依赖 R2-②在你所在 workspace 的 package.json 声明它;纯 dev 工具声明到根
boundaries:only the daemon may import @eyrie/db / must not import @eyrie/daemon包边界 R2-①改用 @eyrie/api 的 DTO / @eyrie/client;缺就去 api 包加
knip:unused file / unused dependencyknip R1-⑥真死就删;误报给 knip.json 补 entry
测试断言 zod 报错文案失败zod 4 迁移断言 validationCode,别断言文案字符串
Native-build dependencies missing from root trustedDependenciestrusted-deps R1-①包名加进根 package.json 的 trustedDependencies
missing a "typecheck" scriptworkspace 脚本 R2-⑥新 workspace 加 "typecheck": "tsc --noEmit"
Toolchain pins out of sync版本钉死 R2-⑤workflow 里 bun/node 版本改到和 package.json 一致(升级要两处一起改)
CJK characters found in non-i18n files(含 .sql/.example)英文-only R2-⑧改英文;用户可见文案进 i18n locale
CSP:script-src contains 'unsafe-…'CSP R2-④撤掉,找不依赖 eval/inline 的方案
eslint:The Function constructor is evalno-new-func R2-③重写,用查表/策略对象替代动态代码
theme-tokens:var(--xxx) 未定义 / .css 有字面色theme-tokens R1-⑦对照 theme.ts 改 token 名;新颜色先加 token
biome:noSkippedTests禁 skip R1-③修好 / 删掉 / 改 skipIf(环境)
webPreferences 基线报错Electron 基线 R1-②别翻转安全开关,先来讨论
.gitattributes 报缺全局规则eol=lf R2-⑨* text=auto eol=lf 加回去
本地 lint 突然报 entities/task 下 boundaries 违例金丝雀孤儿删掉残留的 boundaries-canary-*.tsx(上次运行被强杀留下的)
CI 里 boundaries-canary 步骤挂金丝雀 R1-⑤分层 lint 的 resolver 坏了,查 eslint.config.js 的 resolver 配置

7心智模型补丁

node_modules 里有的包就能 import。 import 的包必须声明在自己 workspace 或根的 manifest 里,hoisting 不是契约。
数据库行类型(ProjectRow 等)哪里方便哪里用。 @eyrie/db 是 daemon 私有物,其他所有地方走 @eyrie/api 的 DTO——现在是机械强制,不是口头约定。
写 zod schema 按 v3 的肌肉记忆来。 全仓 zod 4:record 双参数、z.uuid()、z.iso.datetime()、issue code 用字面量;测试断言错误码不断言文案。
新建 workspace:建目录、写代码、能跑就行。 必须带 typecheck 脚本,否则 commit 直接被拦——这是新 workspace 唯一的额外手续,其余守卫(边界、幻影依赖)都自动覆盖它。
升级 bun / node 版本改一处就行。 packageManager / engines 和 workflow 里的版本被脚本强制同步,升级时一起改,改漏了过不了 commit。
「lint 是绿的」等于「分层规则被检查过了」。 分层 lint 的存活本身也被金丝雀盯着——resolver 悄悄失效会直接红 CI,假绿不再可能。
守卫脚本是零散的 shell 检查。 所有 check-*.mjs 同构:纯函数判定 + __testing 导出 + 违例/干净两臂测试 + file:line 报错——写新守卫照这个骨架。

8没做的 & 可放心略过

刻意不做的(都有明确理由,别顺手补)

读 diff 时可放心略过

9覆盖声明

全量 diff(51 文件,+1700/−258)逐文件精读,无抽样:Round 2 的 12 个工作 commit 为本人实现;Round 1 的 7 个守卫逐个亲读了脚本源码与配置(trusted-deps / boundaries / canary / theme-tokens / biome / loopback harness / knip.json),并对照了两轮执行记录。文中每段代码引自分支真实文件。本 PR 已通过 ubuntu CI 全部检查(check 11 步 + verify 全链 + commit-messages),处于可合并状态。