☰
GSD Core 退役运行时解析守卫:从「静默误装 Claude Code」到「指名道姓地拒绝」的 4709 修复实践
2026/9/28 8:22:49 网站建设 项目流程

【免费下载链接】gsd-core

Git. Ship. Done - Core

项目地址:https://gitcode.com/gh_mirrors/ge/gsd-core
点击查看免费下载

本文以 GSD Core 的 changeset 修复记录.changeset/sturdy-goats-leap.md(PR #4756,对应 epic #4709)为核心,深入解析「已退役 runtime id 不得静默解析」这一运行时名称策略的修复全过程:缺陷成因、四个解析 accessor 的拒绝行为、未知 id 与退役 id 的刻意区分,以及贯穿源码、构建期 lint 与测试的完整证据链。读完你将掌握 GSD Core 如何用一套可扩展的退役表 + 错误类型 + 规范化匹配机制,杜绝「请求一个已下线的运行时却把配置写进另一个产品目录」这类静默错误,并能将同一模式复用到你自己的工具链中。

背景:一个字节相同的路径引发的静默误装

GSD Core 支持在多款 AI 编程运行时(Claude Code、Codex、OpenCode、Kimi、Cursor 等)上安装自身的 hooks、agents 与 skills,每个运行时对应一个全局配置目录与一个展示标签。2026-06-18,Google 正式将 Gemini CLI 下架(sunset),Antigravity CLI 成为官方继任者;GSD Core 也随之在 #1928 中移除了gemini运行时。

问题出在移除方式上:修复之前,请求一个已退役的运行时 id 并不会失败,而是静默落到默认分支上。源码注释记录了这个被实测到的缺陷(src/runtime-name-policy.cts 第 26-29 行):

Measured before this guard existed: `getGlobalConfigDir('gemini')` and `getGlobalConfigDir('claude')` returned byte-identical paths, so asking for a runtime Google sunset on 2026-06-18 wrote into Claude Code's config home and labelled the install "Claude Code".

四个解析 accessor 在退役 id 输入下的旧行为,测试注释逐条记录(tests/runtime-name-policy.test.cjs 第 265-270 行):

accessor修复前对gemini的返回含义
getRuntimeLabel'Claude Code'安装控制台把退役运行时显示成另一个产品
getProjectInstructionFile'AGENTS.md'指令文件指向错误的跨 agent 默认值
getGlobalConfigHomeFragment"'.claude'"生成的 hook 脚本里写入.claude
getGlobalConfigDir~/.claude配置真实写入 Claude Code 的配置目录

也就是说,用户或脚本只要打错一次gemini,GSD Core 就会把安装报告成 Claude Code、并把产物写进 Claude 的目录——不是崩溃、不是警告,而是一个看似成功的错误答案。这正是 #4709 AC#1 要消灭的缺陷类别。

修复核心:四个解析 accessor 对退役 id 直接抛错

修复后的行为非常干脆:退役 id 一律拒绝解析。在 tests/runtime-name-policy.test.cjs 第 283-288 行,测试把被 epic 点名的四个 accessor 收进一张表,逐个断言它们对gemini抛出Error:

const ACCESSORS = [ { label: 'getRuntimeLabel', call: (id) => getRuntimeLabel(id) }, { label: 'getProjectInstructionFile', call: (id) => getProjectInstructionFile(id) }, { label: 'getGlobalConfigHomeFragment', call: (id) => getGlobalConfigHomeFragment(id) }, { label: 'getGlobalConfigDir', call: (id) => getGlobalConfigDir(id) }, ];

在实现侧,assertNotRetiredRuntime(runtime)作为统一闸门被插入各 accessor 的函数体入口。以getGlobalConfigDir为例(src/runtime-homes.cts 第 606-609 行),守卫被刻意放在explicitDir处理之前:

export function getGlobalConfigDir(runtime: string, explicitDir?: string | null): string { // A retired runtime id must never resolve — checked before `explicitDir` so // an explicit directory cannot mask the fact that the runtime itself is gone. assertNotRetiredRuntime(runtime); if (explicitDir) return expandTilde(explicitDir); ... }

注释解释了这里的顺序博弈:即便调用方显式传入了--config-dir,也不能用它来「洗白」一个已经下线的运行时——目录覆盖只影响路径,不影响「运行时是否还存在」这个事实。类似地,getDirName(本地配置目录投影)也挂上了同一守卫,因为它的注释明确指出它「与四个 accessor 属于同一类静默错误」(src/runtime-name-policy.cts 第 297-299 行)。

有一个例外值得注意:getRuntimeNewProjectCommand故意不加退役守卫,其注释(src/runtime-name-policy.cts 第 491-494 行)说明——这个返回值不随 runtime 变化到「退役即错误答案」的程度,在这里抛错只会白费调用方的崩溃成本而不纠正任何东西。这体现了该守卫的设计哲学:只在「静默给出错误答案」时才拒绝,不为拒绝而拒绝。

核心区分:未知 id 与退役 id,刻意保留两套命运

这是整个修复中最容易被误读、也最被强调的设计决策。测试注释(tests/runtime-name-policy.test.cjs 第 272-276 行)记录:

The decision this encodes (maintainer, in chat): RETIRED ids throw; unknown and future ids keep their documented fallback. Absence of knowledge is not the same as recorded retirement.

  • 未知 / 未来 runtime id:GSD 从没听说过它,因此无法替它做决定,继续走「安全跨 agent 默认」——getRuntimeLabel返回'Claude Code'、getProjectInstructionFile返回'AGENTS.md'、getGlobalConfigHomeFragment返回"'.claude'"、getGlobalConfigDir不抛错。这是 #1529 合约里getProjectInstructionFile明确写下的默认行为(unknown / future runtimes →AGENTS.md)。
  • 已退役 runtime id:仓库里有记录在案的决定——它下线了、继任者是谁、由哪个 issue 拍板——此时若解析成另一个产品的标签和配置目录,就不是「不精确」,而是「明确错误」。

实现上,这个区分落在两张表上:RETIRED_RUNTIME_DETAILS(退役事实:successor、retiredBy、sunset)与运行时别名表(aliasManifest/FALLBACK_ALIASES,负责把codex-cli归一化成codex)。assertNotRetiredRuntime只查第一张表,所以「别名解析不到」和「命中退役表」是两条互不干扰的路径。测试对这一点有正反两面的断言(tests/runtime-name-policy.test.cjs 第 387-407 行):notarealruntime和空字符串都保留原默认分支,绝不被误伤。

源码级实现:退役表、规范化匹配与错误类型

核心实现集中在 src/runtime-name-policy.cts(ADR-457 构建时发布产物,由手写.cjs折叠为 TypeScript 单一事实源)。

退役表:一个 Map,而不是对象字面量

const RETIRED_RUNTIME_DETAILS: ReadonlyMap<string, { successor: string; retiredBy: string; sunset: string }> = new Map([ ['gem' + 'ini', { successor: 'Antigravity', retiredBy: '#1928', sunset: '2026-06-18' }], ]);

表结构是「一个退役运行时一行」,天然可扩展——下一次有运行时下线,只需追加一行。'gem' + 'ini'这种字符串拼接是为了避免源码自身被scripts/lint-retired-runtime-name.cjs的/bGemini/b正则命中(守卫脚本不得标记自己)。选Map 而非对象字面量是刻意的:对象字面量按计算键索引会命中继承属性,'__proto__'和'constructor'在旧实现里是 truthy 且每个字段都是undefined——而isRetiredRuntimeId(走 Set)对同样的输入正确地返回 false,两个守卫对同一个 id 给出相反答案,比任何单个错误都糟。Map 没有原型键,从结构上根除了这个隐患。

匹配规范化:NFKC + 小写 + 去非字母数字

function normalizeForRetirementMatch(value: string): string { return value.normalize('NFKC').toLowerCase().replace(/[^a-z0-9]/g, ''); }

这一行把gemini-cli、gemini_cli、gemini.cli、geminicli折叠成同一个键,避免审查者逐个追四个近似的拼写;NFKC 还能折叠 CJK 键盘输入的全角gemini。但这仍是成员匹配,不是前缀或子串测试:gemini-2.5-pro折叠为gemini25pro、gemini-3.1-pro-preview折叠为gemini31propreview,都不在集合里。这一点极其关键——Google 的模型 id 是 Antigravity 真实磁盘合约的一部分,过度宽泛的匹配会误伤模型轴,而 #4709 这个 epic 恰好在这个陷阱上跌倒过三次。

同形字(homoglyph)折叠则被刻意放弃:西里尔і冒充i会漏网,但这是接受的代价——这些值来自 argv 和 env(可信输入),而宽到能抓故意同形字的映射必然开始误伤合法 id。

错误类型:可机检的判别符 + 可读的错误消息

export class RetiredRuntimeError extends Error { readonly code = 'GSD_RETIRED_RUNTIME'; readonly runtimeId: string; constructor(runtimeId: string, detail: { successor: string; retiredBy: string; sunset: string }) { super( `Runtime "${runtimeId}" was retired by ${detail.retiredBy} (sunset ${detail.sunset}); ` + `use "${detail.successor}" instead. Refusing to resolve it, because the previous ` + `behaviour silently returned Claude Code's values.`, ); this.name = 'RetiredRuntimeError'; this.runtimeId = runtimeId; } }

错误消息点名三件事:退役的运行时、继任者、拍板的 issue——直接对应 changeset 原文「fail with a message naming the successor and the retiring issue」。同时code = 'GSD_RETIRED_RUNTIME'提供一个不依赖字符串匹配的机器判别符,测试专门断言了这一点(tests/runtime-name-policy.test.cjs 第 318-334 行):需要专门处理此异常的调用方应该检查code或name,而不是 grep 一段可能被改写的自然语言。

构建期防线:与运行期守卫刻意解耦的散文 lint

运行时守卫防的是id,但仓库里还有大量散文(prose)——文档、changeset、PR 模板——如果它们继续把 Gemini CLI 当活运行时介绍,读者和 agent 依然会被引向退役运行时。为此 scripts/lint-retired-runtime-name.cjs(#4753)在构建期扫描全部被 git 跟踪的.md文件,用/bGemini/b(大小写敏感是机制本身)抓「把退役运行时当活的来写」的表述。

两个守卫刻意不共享代码:若共享,构建期 lint 就会依赖它本不需要的src/编译产物。代价由一组 parity 测试补上——tests/runtime-name-policy.test.cjs 第 460-477 行断言运行期RETIRED_RUNTIME_IDS与 lint 的RETIRED_RUNTIMES表描述的是同一组退役事实,任何一边遗忘另一边都会立即变红(对应 CLAUDE.md 的「Generative Fix Divergence」原则)。

lint 的豁免体系同样值得一提,它按「最窄优先」分了三层:通用规则(Gemini-style这类 Antigravity 真正继承的 hook 方言、模型轴上的Gemini 2.5 Pro版本展示——但同行出现 runtime 词则否决)、逐行 pin 的ALLOWLIST_OCCURRENCES(span 包含判定,防止短 snippet 洗白同行的新声明)、以及仅限追加型内容(CHANGELOG.md、.changeset/、docs/adr/)的整文件/整目录豁免。配合至少读取 150 个文件的反真空底线(anti-vacuity),空走查永远不会虚报「干净」。

测试验证:四重维度的行为契约

tests/runtime-name-policy.test.cjs 对 #4709 AC#1 的验证可归纳为四个维度,均围绕前述ACCESSORS表展开:

  1. 逐 accessor 抛错:四个函数对gemini全部抛Error(此前断言的是 fallback,断言被反转而非删除,以保留旧行为的历史记录)。
  2. 错误可操作且可机检:消息点名退役 id、Antigravity、#1928;code === 'GSD_RETIRED_RUNTIME'或name === 'RetiredRuntimeError'可作为机器判别符。
  3. 拼写变体全覆盖:gemini、gemini-cli、GEMINI、带空白/大小写变体全部抛错(argv 值在此做大小写归一,恰与 #4753 散文 lint 的「大小写敏感即机制」相反)。
  4. 成员匹配负例(承重负例):gemini-2.5-pro、gemini-3.1-pro-preview、gemini-2.5-flash-lite、gemini x、gemini截断、__proto__、constructor一律不抛——子串或前缀匹配会在模型轴上误炸。
  5. 无回归:claude、codex、opencode、antigravity、copilot、cursor、kimi、pi等规范 id 在四个 accessor 上全部正常解析,getGlobalConfigHomeFragment的字节级旧值(如"'.codex'"、"'.config', 'opencode'"、"'.pi', 'agent'")保持不变。

更宏观的回归保障在 tests/gemini-runtime-removed.test.cjs:它验证--gemini单独出现时打印 sunset 通知并以退出码 1 失败(不静默装 Claude)、--gemini --help仍输出用法、--gemini --uninstall不误卸已存在的 Claude 安装、退役 id 不出现在任何 shipped 工作流文本与 PR 模板中——同时用「negative space」测试锁定 Antigravity 的GEMINI.md、~/.gemini/antigravity等真实磁盘合约必须保留,防止「热心」的全量gemini → antigravity字符串清扫矫枉过正。

对调用方的影响与适用前提

如果你正在消费这四个 accessor(或getDirName),需要注意:

  • 退役 id 现在会抛RetiredRuntimeError(code: 'GSD_RETIRED_RUNTIME'),不再静默返回默认值;请用code/name判别后向用户展示继任者提示,而非捕捉后吞掉继续执行。
  • 未知与未来的 runtime id 不受影响,继续保留各自文档化的安全默认('Claude Code'/'AGENTS.md'/"'.claude'"/~/.claude)——这是刻意保留的合约,不是遗漏。
  • 匹配覆盖gemini与gemini-cli两种拼写(由RETIRED_RUNTIME_SPELLINGS生成),大小写与全角均会被规范化;但gemini-2.5-pro这类模型 id绝不会被误判,可放心在 Antigravity 的磁盘合约中继续使用。
  • 当前退役表只含gemini一行;后续退役任何运行时,只需在 src/runtime-name-policy.cts 的RETIRED_RUNTIME_DETAILS与 scripts/lint-retired-runtime-name.cjs 的RETIRED_RUNTIMES各加一行,parity 测试会自动确保两边一致。

这套「记录在案的退役表 + 统一断言闸门 + 可机检错误类型 + 构建期散文 lint + 双向 parity 测试」的组合,把一次静默误装事故变成了一类可防御的系统性错误——「不知道」与「已记录退役」的区分,正是让安全默认(fail-safe)与显式失败(fail-loud)各得其所的关键。

【免费下载链接】gsd-core

Git. Ship. Done - Core

项目地址:https://gitcode.com/gh_mirrors/ge/gsd-core
点击查看免费下载
上一篇:OpenRGB:打破厂商壁垒,一站式掌控所有RGB设备的开源解决方案
下一篇:OpenBoardView终极指南:如何免费快速查看.brd电路板设计文件

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询