cmux-architecture

cmux-architecture

热门

cmux 包架构、重构分层、依赖反转、文件组织、DocC 文档规范、包设计纪律、可测试性以及 Swift 6 并发规则。在新增或大幅重构 Swift 文件、Swift 包、Coordinator(协调器)、Service(服务)、Repository(仓储)或包的 public API 之前使用。

2.5万Star
2120Fork
更新于 2026/8/1
SKILL.md
只读
名称
cmux-architecture
描述

cmux 包架构、重构分层、依赖反转、文件组织、DocC 文档规范、包设计纪律、可测试性以及 Swift 6 并发规则。在新增或大幅重构 Swift 文件、Swift 包、Coordinator(协调器)、Service(服务)、Repository(仓储)或包的 public API 之前使用。

cmux 架构设计指南

包架构设计 (Package Architecture)

cmux 正从单一 app target 迁移为 Packages/ 目录下的多个 Swift Package。每个新拆分的 Package 必须满足以下要求:

  • 易用友好 (Ergonomic):默认使用 internal 访问级别;仅对下游调用方真正需要的符号声明为 public
  • 无环依赖 (Acyclic):Package 之间必须构成严格的有向无环图 (DAG)。如需跨包共享类型,应将其下沉到更底层的 Package,或在调用方定义协议接缝 (protocol seam)。每次新增依赖关系时,都必须重新确认依赖图无环。
  • 完整业务域 (Whole-domain):一个 Package 应完整承载一个业务域(如设置、外观、工作区、终端、浏览器、命令面板等)。CmuxAppearanceMath + CmuxAppearanceTheme + CmuxAppearanceSettings 应当是 CmuxAppearance 内部的文件目录结构,而非模块结构。只有当某个模块被多个调用方复用,或者确实需要构建/测试解耦接缝时,才应该划分模块边界。

拿不准时,优先从叶子节点开始拆分(即无内部依赖的 Package)。Packages/ 下早期创建的现有包早于本规范,切勿将其作为设计参考。

将新 Package 接入 cmux.xcodeproj 时,必须在 cmuxcmux-unit 两个 target 中显式配置 pbxproj 条目。详见 references/package-boundaries.md

Group 目录结构:每个 Package 在物理磁盘上必须归属于唯一的 Group 目录:Packages/Shared/<pkg>(双端共用)、Packages/iOS/<pkg>(仅 iOS)或 Packages/macOS/<pkg>(仅 macOS)。cmux.xcworkspace/contents.xcworkspacedata 的组织方式与物理目录完全保持一致:对应包含三个 Group,以文件夹路径为容器位置,Package 目录作为该 Group 下的 FileRef。磁盘目录是唯一事实来源:移动 Package 时,需先执行 git mv 目录,然后运行 python3 scripts/check-workspace-package-groups.py --write。跨 Group 的 .package(path:) 依赖使用相对路径 ../../<Group>/<Name>。严禁手动修改 workspace group 结构,CI 会运行 python3 scripts/check-workspace-package-groups.py --check 检查偏离情况,如有漂移将直接报错。

Lockfile 锁定文件:切勿对 cmux 自身的 Package.resolved 文件设置 gitignore,必须让 SwiftPM 的依赖解析变更在 PR diff 中清晰可见。需要 Git 追踪根目录的 Xcode lockfile,以及通过独立执行 swift package resolve / swift build / swift test 生成的每个 cmux 自有 Package 的 Package.resolved。Package 本地的 lockfile 是该包独立解析时的唯一事实来源,不会被 cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved 覆盖。引用的第三方目录可保留上游的忽略规则。CI 会运行 python3 scripts/check-package-resolved-policy.py 进行校验。

Feature Flag(特性开关)即远程 PostHog 运行时开关:除非用户明确要求使用编译期开关、本地设置或环境变量,否则一律通过 CmuxFeatureFlags 实现 Feature Flag,且必须包含 PostHog key、显式的不可用降级回退机制(fallback)、注册表元数据、实时更新响应以及针对性的单元测试。本地覆盖(local override)仅用于内部 Dogfood 调试,绝不可作为线上生产环境的控制面。

架构分层 (Layers)

全局划分为 5 个层级,依赖关系只能单向向下:

  1. 核心层 Core (CmuxCore):纯 Sendable 值类型、ID、DTO、错误定义、共享协议接缝。不包含任何 AppKit/SwiftUI/IO 代码。当两个业务域需要共享同个类型时,下沉提取到此层。
  2. 服务与基础设施层 Services / infrastructure:针对外部世界(进程/PTY、文件系统、Socket、Web API、通知、鉴权)实现核心协议的 actor。按高内聚能力拆分,一能力一 Package。
  3. 领域与状态层 Domain / state:包含 @MainActor @Observable 模型和 Coordinator。每个功能域对应一个 Package,持有该领域的可变状态。标准示例:CmuxSettings
  4. 视图层 UI:SwiftUI/AppKit 视图。每个领域 Package 对应一个 UI Package,仅依赖其对应的领域 Package 和 Core,绝不直接依赖 Service。标准示例:CmuxSettingsUI
  5. 可执行层 Executable (cmuxApp / AppDelegate):极薄的依赖组装胶水层,不包含任何业务逻辑。

提取出的每个实体需按设计意图进行归类:

  • Coordinator(协调器)@MainActor @Observable 的编排器,负责调度用户交互流程,持有导航/选中/生命周期状态,并调用 Service 和子 Model。Coordinator 本身不执行任何 IO 操作。
  • Service(服务):负责对外执行单一能力的 actor(仅在被 AppKit 主线程 API 强绑定时才使用 @MainActor);对外暴露 async/await 以及 AsyncStream;仅持有自身资源句柄,绝不持有 UI 状态。
  • Repository(仓储):作为单一持久化事实来源(文件、Defaults、Web API)的中介 actor,对外暴露返回值类型的增删改查(CRUD)异步方法。先例参照:JSONConfigStoreUserDefaultsSettingsStore

依赖反转 (Dependency Inversion):底层 Package 发布 Protocol;具体 Service/Repository 实现该 Protocol;上层模块仅依赖 any Protocol,决不依赖具体类型,更不允许跨模块访问存储属性。统一采用构造器 (init) 注入:禁止使用全局容器、单例或 static let shared。App 可执行 target 是唯一的依赖组装根节点 (Composition Root),也是唯一可以实例化具体类并装配对象图的地方。SwiftUI 的 Environment 仅用于向视图树下发已构建好的 @Observable 模型(例如 SettingsRuntime),严禁用于组装 Service 依赖。

状态与 SwiftUI:领域状态统一保存在 @MainActor @Observable 模型中,严禁使用 ObservableObject/@Published。庞大的上帝模型 (God Model) 必须拆解为高内聚的子 @Observable 模型,由各自的领域 Package 持有并通过引用进行组合;跨域读取需通过只读协议屏蔽。在 View 中,使用 @State(组件自身持有)、@Bindable 或普通 let(外部传入)、或 @Environment(M.self) + .environment(...)(注入)。严禁使用 @StateObject / @ObservedObject / @EnvironmentObject / .environmentObject(_:)

App Target 边界控制(通过依赖反转治理,拒绝绕过机制):

  1. @main cmuxAppAppDelegate 留存在 App 可执行 target 中,充当薄薄的装配胶水层。这部分存量代码是符合预期的最终形态,而非技术债务。
  2. 每个类型有且仅在一个模块中声明,底层 Package 无法扩展上层定义的类型,因此 AppDelegate+* / cmuxApp+* / Workspace+* 等 Extension 无法直接下沉。正确的做法是将行为抽离为 Coordinator/Service/Repository,由上帝对象持有其实例,并将原本的 Extension 简化为单行方法转发。
  3. 存储属性无法跨模块边界移动。应将上帝模型的状态拆解为由领域 Package 持有的子 @Observable 模型,通过引用组合,并用只读协议暴露跨域读取接口。

文件组织规范 (File Organization)

单个文件仅包含一个主要类型,文件名必须与类型名完全一致(例如 Control.swiftLabeledChoice.swiftListControl.swift,不得合并写在同一个 SettingControl.swift 中)。本规则适用于 Packages/ 下的所有新代码及 App target 的所有新文件。

  • 仅在当前文件内部使用的简单 private 辅助函数、嵌套类型和单行 Extension 可以保留在主类型文件中。任何带有实质性代码块的类型(哪怕是嵌套在其他类型内部的 private final class)都必须独立成文件。
  • 为其他模块定义的类型实现协议扩展时,应放在 TypeName+Conformance.swiftTypeName+Feature.swift 中,切勿直接塞在调用方的业务功能文件中。
  • 类型擦除包装器(Type-erased wrapper)应与被擦除类型物理相邻放置:如 Foo.swiftAnyFoo.swift
  • 制定此规则的核心目的就是为了消除巨型文件(如 ContentView.swiftWorkspace.swiftTabManager.swiftcmuxApp.swift)。哪怕拆分后文件数量翻三倍也是完全正确的。文件数量增多的成本微乎其微;而“找不到某个类型在哪里”带来的维护成本却极其高昂。

文档规范 (Documentation)

Packages/ 下新 Package 中的每个 public 符号在编写时必须同步附带 Swift-DocC /// 注释。文档是 API 暴露的一部分,绝不是后续补写的补充工作。

  • 首行为单句概述,控制在单行内并以句号结尾。长篇说明段落前需留出一个空的 /// 行。有参数或抛错的 initfunc 须使用 - Parameter name: / - Returns: / - Throws: 标签。支持 Markdown 语法。
  • 引用代码符号时使用双反引号(如 `CmuxSetting`);非符号代码短语使用单反引号(如 UserDefaults.standard)。
  • 详细说明类型的代表含义与适用场景、每个 enum case 的具体意义、init 参数默认值及其理由、属性的不变性断言(invariants)、方法行为特征,以及支持哪些泛型 Value/Element 约束及其原因。
  • 非简单 API 至少应包含一个放在 swift 代码围栏里的简短示例,优先使用本项目中的真实声明代码。
  • 当设计意图不够直观时,internalprivate 符号也应添加单行 /// 注释。对外暴露的 Public 边界则是必须做到 100% 覆盖。
  • 修改代码行为或函数签名时,必须在同一 commit 中同步更新文档注释。Doc 注释用于从外部描述契约;行内 // 注释仅保留用于解释非显而易见的 设计原因

主 App target 的存量代码不强制追溯补充文档。

包设计纪律 (Package Design Discipline)

  • 禁止提供共享单例访问器:在持有运行时状态的 Package 类型上定义 static let standard / shared / default 本质上就是变相使用单例。必须在 App 启动处统一构建并显式注入。static let 仅允许用于静态声明(如标识符、Schema 配置、Enum Case),绝不能用于封装运行时行为。
  • 禁止使用 Namespace Enum:使用 enum Foo { static func bar() } 构造无实例的假命名空间,既无法做依赖注入 (DI),也无法留出测试接缝。如果辅助工具后续可能扩展配置,应优先使用在构造函数中传递的值类型 Struct。
  • 禁止维护手动的平行注册表:当某个列表需要映射已声明的项时(例如用 catalog.all 映射存储属性),必须通过 Mirror 反射或 Swift Macro 动态生成。手写两套事实来源极易产生隐蔽的代码偏离漂移。
  • 优先依靠编译期类型约束而非运行时 Trap:针对“开发者逻辑错误”的 guard ... else { assertionFailure(...); return default } 断言,应当尽量直接建模到类型系统中(如利用 Phantom Types 或拆分为独立的具体类型)。因为运行时断言在 Release 构建下很容易变成静默降级的隐患。
  • 禁止使用全局自由函数 (Free Functions):禁止定义顶层 func(包括文件作用域的 private func 在内的任何可见级别);方法必须收拢归属于承担对应职责的类型实体。唯一允许的特例是因对接 C API 强迫使用的 @convention(c) 转换函数,且必须附带单行理由说明。

可测试性规范 (Testability)

新增到 Packages/ 中的每个 Public 类型都必须能够在测试 target 中独立进行测试,无需启动主 App target、无需初始化 AppKit、也不依赖用户的真实文件系统或 UserDefaults.standard

  • UserDefaultsFileManager、磁盘路径、环境变量以及 Clock 时间源均须通过 init 参数传入。测试用例传入指定 suiteName 的测试专用 UserDefaults、临时目录 URL 或固定 Date
  • 任何业务实现严禁硬编码访问 .shared.standard
  • 禁止静态测试 Hook:类似 nonisolated(unsafe) static var fooForTesting 的全局可变覆盖变量(用于测试期替换实现)会导致测试间相互污染,且通常需要额外的锁保障。应当替换为通过 init 注入的 Protocol 接缝,例如 init(commandRunner: any CommandRunning = CommandRunner())。在解耦重构时,删除静态 Hook 及其关联的锁是当前必须完成的任务,而非后续待办事项。
  • 优先返回变更后的新值并由调用方持久化,而非在内部直接修改全局状态并返回 Void
  • 优先暴露 AsyncStream 作为状态观测途径,以便测试可以直接断言产出的数据序列,避免使用需要 RunLoop 轮询等待的纯 NotificationCenter 模式。
  • 必须在 Package 的 README.md 或 DocC Catalog 中展示该包在测试下的实例化范例。

如果一个架构设计难以测试,那它本身就是错的。应该去修改构造函数的参数列表,而不是去造复杂的测试台架。

Swift 6 并发规范 (Swift 6 Concurrency)

Packages/ 下的新代码、App target 的新文件以及重要重构代码,统一使用 actorasync/awaitAsyncStream/AsyncSequence@Observable@MainActor

未经 PR 描述中书面明确理由,严禁使用以下特性:

  • Locks: NSLock, NSRecursiveLock, os_unfair_lock, `OSAllocatedUn

<!-- truncated for translation batch; full body continues in source -->