gstack iOS 调试桥再同步:/ios-sync 技能如何确定性重生成 DebugBridge 与状态访问器
【免费下载链接】gstackUse Garry Tan's exact Claude Code setup: 23 opinionated tools that serve as CEO, Designer, Eng Manager, Release Manager, Doc Engineer, and QA项目地址: https://gitcode.com/GitHub_Trending/gs/gstack
gstack 的 iOS QA 能力会在目标 app 内安装一个仅 DEBUG 生效的调试桥(DebugBridgeSwift 包 + 生成的StateAccessor.swift)。当 app 新增了需要快照的状态字段、或 gstack 本身升级到带加固修复的新版本后,这些已安装文件就会与上游模板漂移。本文围绕 ios-sync 技能文档 展开:它讲解/ios-sync何时被触发、按哪四个阶段完成再同步,以及底层 gstack-ios-qa-regen 启动器 和 gen-accessors 生成器 如何用模板白名单、@Snapshotable标记契约和复合哈希缓存,做到字节级可重复、失败即显式的确定性重生成。
1. 背景与触发时机
/ios-sync的定位是 /ios-qa 的后续维护工具:/ios-qa负责首次安装调试桥,/ios-sync负责把已安装的桥文件重新对齐到最新版上游 gstack 模板,/ios-clean则负责发版前拆除。技能文档给出的三个典型触发场景:
- 你新增了
@Observable类或字段,需要访问器覆盖(accessor coverage); - 你把 gstack 升级到带有加固修复的新版本;
- 你把
// @Snapshotable生成器标记注释移动到了别的字段上。
技能 frontmatter 里声明的触发语(triggers / voice triggers)为:resync the ios debug bridge、regenerate iOS accessors、update the gstack iOS instrumentation。
一个关键设计前提写在文档中:模板存放在上游 gstack 里。已安装到 app 中的gstack-ios-qa-regen启动器会自己解析 gstack 根目录,只从ios-qa/templates/拷贝受支持的桥文件;旧分支(fork)里"从 HTTP 抓取模板 + 通配符拷贝"的模式已被移除。这意味着再同步永远以本地 gstack 安装为单一事实源,不会引入网络依赖。
2. 四阶段工作流总览
技能正文从 Preamble 执行完之后开始,分四个阶段:
- Phase 1:检测已安装版本——对比 app 内的版本标记与上游 VERSION,无变化则提前退出;
- Phase 2:重新生成 codegen 产物——运行确定性的再生成器;
- Phase 3:审查生成物 diff——确认没有误伤手写文件;
- Phase 4:验证——构建、真机重连、快照 schema hash 校验。
下面逐阶段展开,并对照仓库源码说明每一步的底层保证。
3. Phase 1:检测已安装版本
- 读取
<app>/DebugBridgeGenerated/.gstack-version(该文件由/ios-qa安装时写入)。文件缺失时,把安装视为"未知旧版本"。 - 读取上游版本
$GSTACK_ROOT/VERSION。 - 若版本一致且没有新增
@Observable类,直接以 "already up to date" 提前退出。
这个.gstack-version文件是整个再同步流程的完成标记:启动器在改动任何包源码之前会先删除它,只在访问器生成成功之后才重新写入。因此"标记存在"等价于"这是一次完整且与当前 gstack 版本对齐的安装",而"标记缺失"则说明上次生成被中断或失败,下次/ios-sync必须完整重跑。
4. Phase 2:运行确定性再生成器
文档给出的标准调用:
~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ --app-source "$APP_SOURCE_DIR" \ --bridge-dir "$APP_SOURCE_DIR/DebugBridge"参数语义:
--app-source:访问器扫描器应检查的 Swift 源码目录(建议传尽量窄的 app 源码目录,减少扫描面);--bridge-dir:app 在 Debug 构建中链接的本地 Swift 包目录。
4.1 启动器做了什么
bstack-ios-qa-regen 是一个 bash 脚本(set -euo pipefail),行为可以从源码逐段确认:
- 参数校验:
--app-source与--bridge-dir缺一即报错并退出码 2;未知参数同样退出 2。测试 专门钉死了这个契约:缺少--bridge-dir时 stderr 必须包含both --app-source and --bridge-dir are required。 - 环境检查:
$APP_SOURCE必须是存在的目录(否则退出 1);bun必须在 PATH 上(生成器是 TypeScript,由 bun 运行)。 - 解析 gstack 根:启动器取自身所在目录的上级作为
$GSTACK_ROOT,随后定位三个关键资源:
| 资源 | 路径 | 用途 |
|---|---|---|
| 生成器 | ios-qa/scripts/gen-accessors.ts | 扫描源码、写出 StateAccessor.swift |
| 模板目录 | ios-qa/templates/ | 规范桥文件 |
| 版本文件 | VERSION | 写完成标记 |
两个必需文件缺失时退出 1。这也解释了文档中"启动器解析自己的 gstack 根"的含义——它只依赖脚本自身的安装位置,不依赖当前工作目录。 4.先失效完成标记:在拷贝任何文件之前执行rm -f -- "$GENERATED_DIR/.gstack-version",注释写明"失败或被中断的再生成绝不能对 ios-sync 伪装成最新状态"。测试 用一个伪造的、以退出码 17 失败的bun验证了这一点:生成失败后.gstack-version必须不存在(连之前遗留的旧标记也会被清掉)。 5.白名单拷贝 7 个规范包文件(见 4.2)。 6.删除旧版平铺布局遗留文件(见 4.3)。 7.运行生成器:bun run "$GENERATOR" --input "$APP_SOURCE" --output "$GENERATED_DIR"。 8.成功后才打标记:把$GSTACK_ROOT/VERSION的内容装进DebugBridgeGenerated/.gstack-version。 9. 打印两行就绪信息:bridge 包位置与 accessors 位置。
拷贝用的install_file函数有两个值得注意的细节:目标文件已存在且与源模板逐字节一致(cmp -s)时直接返回,不碰文件;只有内容变化时才经由destination.tmp.$$临时文件 +mv原子替换。注释说明这保证"中断永远不会留下截断的生成源码",且重复运行时未变化的文件字节与元数据都保持不变——这正是测试里"二次运行树哈希不变"断言的来源。
4.2 模板白名单:7 个规范文件,刻意不用通配符
启动器中的注释明确写道"这是白名单,不是模板 glob",因为它要把 app 自己拥有的接线文件排除在生成范围之外。安装映射与 测试中的 SAFE_TEMPLATE_MAP 完全一致:
| 模板(ios-qa/templates/) | 安装到($BRIDGE_DIR 下) |
|---|---|
Package.swift.template | Package.swift |
StateServer.swift.template | Sources/DebugBridgeCore/StateServer.swift |
DebugBridgeManager.swift.template | Sources/DebugBridgeCore/DebugBridgeManager.swift |
Bridges.swift.template | Sources/DebugBridgeUI/Bridges.swift |
DebugOverlay.swift.template | Sources/DebugBridgeUI/DebugOverlay.swift |
DebugBridgeTouch.m.template | Sources/DebugBridgeTouch/DebugBridgeTouch.m |
DebugBridgeTouch.h.template | Sources/DebugBridgeTouch/include/DebugBridgeTouch.h |
注意StateAccessor.swift不在拷贝列表里——它由生成器解析源码后产出,不是模板。测试里专门往伪造模板目录塞了带FORBIDDEN-WIRING-SENTINEL/FORBIDDEN-STATE-SENTINEL内容的DebugBridgeWiring.swift.template和StateAccessor.swift.template,断言它们绝不逃进 app;如果启动器哪天回归成通配符拷贝,这个哨兵内容就会泄漏并使断言失败。
生成后的包由三个 SwiftPM target 组成,可用swift package dump-package验证 target 集合为DebugBridgeCore、DebugBridgeTouch、DebugBridgeUI(测试中的校验 在环境存在 swift 时执行)。其中StateServer是嵌入 app 的 loopback-only HTTP 服务(默认端口 9999,#if DEBUG门控,boot token 在 daemon 启动后约 5 秒内轮转),完整架构见 iOS 测试 howto。
4.3 旧版平铺布局的定点清理
更老版本的 ios-sync 会把整组模板平铺进 app 的生成目录。那些文件会遮蔽(shadow)新包模块,或让 Xcode 编译出第二套过时的桥实现。启动器因此维护一份显式废弃清单(源码中的 obsolete 循环),逐项删除以下 10 个路径,绝不使用通配符,绝不动手写文件:
$BRIDGE_DIR/DebugBridgeWiring.swift$BRIDGE_DIR/StateAccessor.swift$GENERATED_DIR/Package.swift$GENERATED_DIR/StateServer.swift$GENERATED_DIR/DebugBridgeManager.swift$GENERATED_DIR/Bridges.swift$GENERATED_DIR/DebugOverlay.swift$GENERATED_DIR/DebugBridgeTouch.m$GENERATED_DIR/DebugBridgeTouch.h$GENERATED_DIR/DebugBridgeWiring.swift
这与技能文档"命令只删除已知过时的生成文件"一一对应。
5.@Snapshotable标记契约:生成器的准入规则
生成器 gen-accessors.ts 是一个轻量 Swift 词法扫描器(TS 快速路径,避免首次构建 swift-syntax 工具链的 2-5 分钟等待)。它的行为边界就是技能文档里"Generation accepts … rejects …"那句话的完整展开。
5.1 标记如何被识别
扫描前先做一遍"掩码"(maskSwiftSource):把注释与字符串字面量整体空白化,但保持字节偏移与换行不变;同时只把独立的// @Snapshotable注释行重写为等价的@Snapshotable属性 token。注释强调这是刻意选择的词法级处理而非全文件正则:嵌套块注释、普通/三引号/原始字符串、行尾注释和正文注释里的@Snapshotable都绝不能让字段"被标记"。
5.2 字段准入条件(生成器会硬性拒绝)
- 必须是文件作用域
@Observable类的可写实例var,带显式类型注解,internal 或 public setter; - 拒绝
let("must be declared var, not let")、private/fileprivate/private(set)/fileprivate(set)、static/class属性; - 拒绝无类型注解、计算属性、多绑定声明;
- 类型只接受JSON 原生类型:标量(
String、Bool、各符号/无符号整型宽度、Float、Double、CGFloat)、数组、String 键字典,以及这些类型的Optional组合; - 拒绝自定义类型、隐式解包
Optional(T!,提示"用T?代替")、嵌套 Optional、非 String 键字典。
文档中"在写完成标记之前拒绝无效声明"对应的实现是:以上任何违规都会累积进诊断信息,最终以AccessorGenerationError抛出(CLI 退出码 4),而不会产出到 xcodebuild 或快照时才失败的坏 Swift。另外,多个@Observable类之间的字段 key 必须全局唯一,冲突同样直接报错(snapshot key '<name>' is declared by both A and B)。
5.3 生成物形态
render()为每个@Observable类生成一个@MainActor的<ClassName>Accessor枚举,注册buildId、accessorHash与两阶段atomicRestore处理器,并为每个标记字段注册 read/write 访问器。有两处细节体现"app 为中心"的设计:
- 生成的枚举是非 public的。源码注释解释:访问器编译在 app target 内,与通常是 internal 的状态类型同居;若把 API 声明为 public,public 签名就会暴露 internal 类型,导致 Swift 类型检查直接失败。测试也断言产物"包含
enum AppStateAccessor但不包含public enum AppStateAccessor"。 atomicRestore是两阶段的:先校验全部 key 与值类型,再按序赋值;apply为真时才真正写状态。这与 StateServer 模板 中的注释一致——"服务器先验证所有模型,才允许任何模型被修改,非法输入永远不能造成跨模型的部分恢复"。
6. 复合哈希缓存与 schema hash:~50 毫秒的空操作
技能文档说:"复合哈希缓存键决定了是否真的需要重生成;若 Swift 版本、生成器 git rev、lockfile、源码内容与平台三元组全部命中缓存,这就是一次约 50ms 的空操作。"对照 computeCacheKey 的实现,缓存键是 SHA-256,输入依次为:
- 生成器格式版本(当前为
accessor-generator-v5); swift=<Swift 版本>(取SWIFT_VERSION环境变量或swift --version,探测失败记unknown);tool=<生成器 git rev>(GEN_ACCESSORS_REV环境变量或 gstack 仓库的短 SHA,非 git 环境记dev);platform=<平台三元组>(darwin 下为darwin-arm64);build=<构建 ID>(APP_BUILD_ID/MARKETING_VERSION+CURRENT_PROJECT_VERSION,否则unknown);- 每个 Swift 源文件的
字节数 + 内容。
两个设计点值得强调:源码文件按排序后的相对集合遍历,绝对 checkout 路径不参与哈希,等价源码树在任何机器上都产出同一缓存键;扫描时自动排除DebugBridgeGenerated目录与任何位置的StateAccessor.swift(否则移动一份旧副本就会污染下一次缓存键)。
缓存根目录默认为~/.gstack/cache/gen-accessors,可用GSTACK_IOS_CACHE_ROOT覆盖(defaultCacheRoot);命中时直接把缓存里的StateAccessor.swift拷回输出目录。缓存另有 30 天自动清理(pruneCache)。
与缓存键不同,schema hash(accessorHash)由 computeAccessorHash 计算:对snapshot-schema-v1签名逐类逐字段写入类名、字段名、类型文本(均带字节长度前缀,保持源码顺序),再取 SHA-256。注释说明它刻意独立于缓存 ABI、源码路径、构建来源与未标记源码——"字段/类顺序就是源码顺序,因为 restore 载荷兼容性是一个有序的契约"。这个 hash 会被打进生成的StateAccessor.swift并注册进StateServer,Phase 4 用它验证真机上的/state/snapshot是否反映了新 schema。
7. Phase 3:审查生成 diff
文档要求:
- 检查
<app>/DebugBridge/下的改动,以及<app>/DebugBridgeGenerated/StateAccessor.swift>; - 确认命令没有修改 app 的手写 Swift 文件——启动器只写白名单内的 7 个路径加生成目录里的
StateAccessor.swift与.gstack-version,其余文件不可能被动到; - app 特定的接线(wiring)留在 app target 里;规范的桥包文件一律由上游重新生成,不手工编辑。
仓库测试为此提供了可验证的基线:安装后的每个包文件必须与其源模板逐字节一致("持久的模板/输出一致性契约"),且整个 bridge + generated 目录的树哈希在二次运行后不变、accessorHash不变、stderr 为空——这就是"确定性、幂等"的机器化定义。
8. Phase 4:验证
技能文档列出的四条验证,逐条对应仓库中的可检查事实:
swift build对 app 的包成功——DebugBridgeCore是跨平台 target,CI 的 Mac 主机上不用 UIKit 就能验证包的大部分代码;xcodebuild -scheme <SchemeName>成功;- 在真机重启 app;daemon 重连并完成 token 轮转;
GET /state/snapshot返回新的accessor schema hash——即第 6 节中计算并注册进StateServer的那个值,用它确认真机跑的是新访问器而非旧缓存状态。
9. 失败模式与处理
文档给出的失败模式表(原文完整继承):
| 症状 | 处理 |
|---|---|
| regen 后 Swift 编译失败 | 用git restore回滚,并通过 AskUserQuestion 展示编译错误 |
| codegen 报告标记声明无效 | 使用文件作用域 observable 类 + 带显式 JSON 原生类型的可写实例var,internal/public setter,key 跨模型唯一;否则移除// @Snapshotable标记 |
新增@Observable后 schema hash 没变 | 没有字段带独立的// @Snapshotable标记注释——codegen 正确排除了未标记状态。把注释加到每个需要快照的字段正上方 |
| 扫描器看到了生成的桥源码 | 传入更窄的 app 源码目录;再生成器本身会自动排除DebugBridgeGenerated与StateAccessor.swift |
第三行对应"漏标记"这一最常见的软故障:未标记字段从不进入快照,这是刻意设计(避免 token、PII、鉴权状态默认流入录制 fixture,见 howto 文档 中的示例),所以"hash 没变"不是 bug 而是提示你忘了标记。
10. 共享 Preamble、计划模式与安全操作
ios-sync/SKILL.md中篇幅最大的 Preamble 是 gstack 所有技能共用的环境探测脚本(由 SKILL.md.tmpl 经bun run gen:skill-docs生成),要点:
- 探测版本更新(
UPGRADE_AVAILABLE/JUST_UPGRADED)、分支、会话类型(spawned/headless/interactive)、Conductor 会话、遥测与explain_level配置,并写入本地 timeline 与 learnings 加载; - 计划模式安全操作:计划模式下允许执行会"为计划提供信息"的操作——
$B、$D、codex exec/codex review、对~/.gstack/的写入、对计划文件的写入、open生成产物;技能调用优先于通用计划模式行为,技能文件被视为可执行指令逐步遵循; - Conductor 会话下 AskUserQuestion 不可靠,决策改为以 prose 形式呈现,而非调用工具。
对/ios-sync这类运维型技能来说,Preamble 的意义是:无论交互、无头还是编排器拉起的会话,再生成都遵循同一套"先探测环境、失败即 BLOCKED、绝不静默自动决策"的纪律。
11. 测试如何钉死这套契约
test/ios-qa-regen.test.ts 用伪造安装(临时目录里的bin/gstack-ios-qa-regen、模板与 VERSION9.8.7.6)在沙箱中真实执行启动器,覆盖五类断言:
- 启动器可执行(
mode & 0o111); - 参数契约:缺参退出码 2 且 stderr 含指定文案;
- 生成失败(伪造 bun 退出 17)后完成标记必须不存在;
- 幂等再生成:安装 7 文件逐字节等于模板、废弃清单全部清除、哨兵内容不泄漏、
.gstack-version等于安装 VERSION、二次运行输出gen-accessors: cache hit且树哈希与accessorHash不变; - 有 Swift 工具链时
swift package dump-package验证 target 集合恰为三个规范模块。
另有 gen-accessors.test.ts 在无 Swift 工具链的环境下验证解析与缓存行为。
12. 小结:何时运行 /ios-sync,如何确认成功
把技能文档与源码合起来,/ios-sync的可执行心智模型是:
- 触发:新增需要快照的
@Observable状态、升级 gstack、或移动了// @Snapshotable标记之后,用 "resync the iOS debug bridge" 触发技能。 - 核心命令:
~/.claude/skills/gstack/bin/gstack-ios-qa-regen --app-source <app源码目录> --bridge-dir <app>/DebugBridge;它先清完成标记、白名单装 7 个模板、定点删 10 个旧文件、跑生成器、成功后打.gstack-version标记。 - 成功信号:
swift build与xcodebuild通过;真机重启后 daemon 重连并轮转 token;GET /state/snapshot返回新的accessorHash;手写 Swift 文件零改动;.gstack-version内容等于 gstack 安装的VERSION。 - 失败兜底:编译失败就
git restore回滚并展示错误;codegen 拒绝无效标记时按诊断修正声明或移除标记——"完成标记只在成功时写入"保证下一次/ios-sync永远不会把一次中断的安装误判为最新。
整条链路的设计目标是:调试桥的每一次再生成都可被测试复现、可被哈希验证、失败时显式可见——这正是"确定性重生成"在 gstack iOS QA 栈里的含义。
【免费下载链接】gstackUse Garry Tan's exact Claude Code setup: 23 opinionated tools that serve as CEO, Designer, Eng Manager, Release Manager, Doc Engineer, and QA项目地址: https://gitcode.com/GitHub_Trending/gs/gstack
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考