Vibe Coding 与工程架构:AI 写的代码如何被约束
第 3 篇。系列导航见 README。
背景:Vibe Coding 最容易失控的地方
AI 编程工具擅长"把功能写出来",但它不知道你的产品底线。对清理工具来说,底线是:不能删系统文件、不能删用户文档、不能在用户没确认时删除中高风险数据。这些约束必须在架构层面显式表达,否则生成代码越多,风险面越大。
Pure 在文件清理主链路上的做法是把约束写进类型和调用链:扫描器返回JunkItem,Cleaner 负责校验与删除,安全引擎独立于 UI,规则在载入期被过滤。需要先说明:仓库没有保留生成对话,无法逐文件判定 AI 与人工的贡献;下面讨论的是代码中实际存在的约束、调用链和审查痕迹。
实际架构:比规范更轻,但边界清楚
架构规范设想了Coordinator、UseCase、AppDependencies等层次,实际代码没有采用。真实结构是:
| 层 | 代表类型 | 职责 | 不应该做什么 |
|---|---|---|---|
| App | PureApp、AppDelegate、MenuBarController | 生命周期、菜单栏、开机启动 | 不处理清理逻辑 |
| UI | ScanViewModel、CleanView、DiskHealthViewModel | 状态编排与展示 | 不直接删除文件 |
| Engine | ScanEngine、CleanEngine、UninstallEngine | 扫描与清理流程编排、结果聚合;新清理入口负责应用风险策略 | 不实现具体扫描规则 |
| Scanner | CacheScanner、BrowserScanner、GroupContainerScanner | 发现候选条目 | 不执行删除 |
| Cleaner | TrashCleaner、UninstallCleaner、GroupContainerCleaner | 执行校验与删除 | 不负责选择条目;安全门槛由具体 Cleaner 实现 |
| Core | SafetyEngine、RuleRepository、DatabaseManager、FileSystemKit | 安全、规则、持久化、文件系统工具 | 多数不依赖 UI;L10n依赖 SwiftUI,PermissionManager依赖 AppKit |
两个协议把边界固定下来:
protocolScanner:Sendable{varcategory:JunkCategory{get}funcscan(progress:@escaping@Sendable(Int,Int64)->Void)asyncthrows->[JunkItem]}protocolCleaner{funcvalidate(_item:JunkItem)throwsfuncclean(_item:JunkItem)throws->URL?}Scanner协议要求category属性与scan返回[JunkItem];实现会读取文件系统、汇报进度、写入扫描缓存,但不会删除文件。Cleaner协议只有validate与clean两个要求,没有其他默认实现;三种 Cleaner 的校验内容并不相同:
| Cleaner | validate的实际检查 | clean的行为 |
|---|---|---|
TrashCleaner | 存在性;新建 URL 读取最新大小/修改时间;拒绝符号链接;大小与时间复核;重新执行SafetyEngine;解析完整路径后复核危险区域 | 默认移入废纸篓;永久删除需通过guardPermanentDelete的安全等级限制(仅safe/lowRisk)并再次检查符号链接,但不会完整重跑元信息校验 |
UninstallCleaner | 路径必须精确命中本次allowedPaths;存在性;SafetyLevel != dangerous;最新元信息;符号链接;大小/时间;解析后仍在allowedBases内 | 移入废纸篓 |
GroupContainerCleaner | 精确命中allowedPaths;存在性;runningApplicationBundleIDs()命中bundleID则拒绝;最新元信息;符号链接;大小/时间;解析后仍在allowedBases内 | 移入废纸篓 |
CleanEngine对真正进入执行队列的条目先调用validate,通过后再调用clean。这一步不是装饰,但它也不是"等价于沙盒"的安全保证:校验与删除是两次独立调用,细节见后文。
模块依赖与调用顺序
一次典型的扫描与清理流程:
这张图只覆盖CleanView的主链路。CleanEngine 会按条目来源选择具体 Cleaner:新的CleanupItem入口中,Group Container / Application Container 走GroupContainerCleaner,其余走默认/注入的通用 Cleaner(生产路径是TrashCleaner);旧接口则由调用方传入 Cleaner。大文件、开发者缓存、遗留数据、卸载后剩余数据等入口走旧接口,不经过CleanupAnalyzer与confirmedReview,详见后文。
CleanupAnalyzer确实在扫描结束后运行:ScanViewModel.startScan()拿到ScanResult后调用CleanupAnalyzer.analyze(items:context:),把JunkItem转成带source、role、ownership、applicationState、recoverability、risk、score、confidence、reasons、recommendedAction、safetyOverride的CleanupItem。扫描器不计算CleanupRisk;Group Container 扫描器会按容器内部角色产出条目,但统一的风险评分仍由后续分析完成。
清理历史也不是在 UI 层写入。CleanEngine.process对每个真正尝试过的条目,在validate/clean之后写一条clean_operation,成功、跳过、失败都会写;被风险策略直接拦下、没有进入执行队列的条目不会写历史。卸载流程还会额外写uninstall_operation。
当前代码中的两套安全模型
当前代码里同时存在两套模型。仓库没有 Git 历史,无法证明它们是严格的替代关系;这里只描述当前实现中的分工,不编造演进时间线。
| 模型 | 取值 | 使用位置 |
|---|---|---|
SafetyLevel | safe/lowRisk/caution/dangerous | JunkItem、SafetyEngine、TrashCleaner、UninstallCleaner |
CleanupRisk | safe/low/medium/high/prohibited,另有 0–100 评分 | CleanupItem、CleanupAnalyzer、CleanupRiskScorer、CleanView、CleanEngine的新入口 |
SafetyLevel主要回答"路径是否命中规则或黑名单";CleanupRisk在此基础上综合应用生命周期、所有权、数据角色、来源、可恢复性、置信度和其他应用引用。评分从 50 起算,按维度加减,并映射为:90–100safe、75–89low、50–74medium、20–49high、0–19prohibited。
硬规则由CleanupRiskScorer.safetyOverride计算,输入是路径、来源、角色、所有权与应用状态。关键规则如下:
switchoverride{case.prohibited:risk=.prohibited score=min(score,10)case.high:risk=CleanupRisk.max(riskLevel(forScore:score),.high)case.medium:risk=CleanupRisk.max(riskLevel(forScore:score),.medium)case.none:risk=riskLevel(forScore:score)}Keychain、iCloud、用户文档、核心系统数据、系统库直接prohibited;Apple Group Container、运行中应用数据至少high;Group Container 的非可重建角色至少medium。即使评分为 95,也不能推翻这些硬规则——但"不能被评分推翻"不等于"删除一定安全",它仍然只是策略判断。
confirmedReview的实际含义也需要限定:新入口中,prohibited一律跳过;medium/high只有confirmedReview == true才执行;safe/low直接执行。CleanView在选中项包含中高风险或设置要求确认时弹出确认面板,用户确认后传入true。它不是一个全局"允许删除"开关,也不会让prohibited放行。被风险策略拦下的条目不进入validate,直接计为 skipped;只有真正进入执行队列的条目才走validate → clean。此外,confirmedReview只是一个布尔参数,API 本身不验证用户是否真的看过确认面板,语义仍依赖调用方。
两套模型负责不同阶段的判断:CleanupRisk决定条目是否允许进入清理队列,以及是否需要用户确认;SafetyLevel和 Cleaner 的路径、白名单、元信息检查,则约束实际执行的清理操作。前者不是后者的替代品,后者也不会重新计算CleanupRisk分数。CleanupRisk.max按sortRank取较高等级,所以.high/.medium覆盖是最低风险等级的约束,不是重新评分。
需要注意,validate与clean之间仍存在 TOCTOU 竞争窗口,因此这些检查只能降低误删风险,不能证明文件在整个操作期间始终满足安全条件。
旧接口的兼容性:不是等价安全保证
CleanEngine目前保留了新旧两套入口,且都在生产代码中使用:
| 入口 | 主要生产调用方 | 是否经过风险模型与确认 | Cleaner 选择 |
|---|---|---|---|
clean([CleanupItem], confirmedReview:) | ScanViewModel.clean(CleanView调用) | 是:风险过滤 + 中高风险确认 | Group Container 专用 Cleaner,其余默认/注入的通用 Cleaner(生产路径为TrashCleaner) |
clean([JunkItem])/cleanDetailed([JunkItem]) | ScanViewModel.trashLeftover、trashLargeFile、trashDeveloperItem、ApplicationDetailView.trashRemaining、UninstallEngine.uninstall(先 preflight) | 否:不做CleanupAnalyzer、风险过滤或confirmedReview | 由调用方传入的白名单 Cleaner;默认是TrashCleaner |
两条路径最终都进入CleanEngine.process。对于进入执行队列的条目,流程都会先调用validate,只有校验通过后才调用clean;两条路径使用相同的错误分类和历史记录机制:missing/changed/unsafe计为 skipped,permissionDenied/inUse/failed计为 failed,其他错误计为 failed。风险策略拦下的条目不会进入逐项执行,也不写clean_operation;旧接口没有这层风险拦截,进入队列的条目都会尝试校验。
其中UninstallEngine并不是裸调用旧接口:它在删除前先执行UninstallPreflight,检查废纸篓、系统应用、存在性、符号链接、Bundle ID、版本变化、运行状态与权限,再构造UninstallCleaner的白名单。但 preflight 也不是CleanupRisk模型,二者不能互相替代。
差异在"进入队列之前":旧接口不会把CleanupItem的多维风险与confirmedReview应用到条目上。TrashCleaner仍会拒绝SafetyLevel.dangerous,但允许caution;UninstallCleaner与GroupContainerCleaner依靠本次白名单与基目录限制范围。旧接口调用分别服务于大文件、开发者缓存、遗留数据和卸载后剩余数据等操作;操作范围由allowedPaths与allowedBases限定,但不同入口的确认方式并不一致:卸载流程有选择/预览 Sheet,开发者条目有确认弹窗,而大文件、遗留数据、卸载后剩余数据等入口使用直接操作按钮。操作范围明确,并不意味着这些入口具有与主清理页相同的风险评估和确认保证。
测试中也同时存在两种调用:CleanEngineTests、CleanHistoryTests、TrashIntegrationTests使用旧接口覆盖校验、历史与废纸篓流程,CleanEngineRiskPolicyTests则覆盖新接口的风险策略与确认参数。
因此,旧接口是兼容 API,不是与主清理页等价的安全保证。仅凭静态阅读,没有发现旧入口能绕过Cleaner.validate或删除白名单之外的任意路径;但"显式单条操作即视为确认"是否足够,属于产品策略问题。也就是说,目前不能判定它是已证实的安全缺陷,更准确的分类是确认语义不一致。若未来要求统一,应把这些入口迁移到CleanupItemAPI,或在 ViewModel 层补充确认,而不是假设旧接口继承了新模型的保证。
需要人工兜底的几类问题
从代码与注释可以观察到几类需要人工兜底的地方。先看@unchecked Sendable的实际共享状态:
| 类型 | 共享可变状态 | 线程安全依靠什么 |
|---|---|---|
ScanEngine | 注入的扫描器/存储引用(不可变);ProgressCounter的计数 | ProgressCounter使用NSLock;引擎本身无可变共享状态 |
DirectorySizeCache | entries字典 | NSLock覆盖读写与身份校验 |
ScanCacheSession | 每次扫描的缓存、统计、待写入集合 | NSLock;commit()先快照再落库 |
DatabaseManager/FileEntryStore | SQLite 连接句柄 | NSLock+SQLITE_OPEN_FULLMUTEX;FileEntryStore使用独立连接 |
SettingsStore | settings与业务快照缓存 | UI 绑定驱动settings写入;snapshot()通过NSLock读取 |
@unchecked Sendable的共性是:编译器不再证明线程安全,正确性依赖锁的范围和调用时序。锁覆盖得好,不代表所有并发路径都被测试覆盖;这部分仍需要人工审查。其余标注(如RuleRepository、CleanEngine、UninstallEngine、SnapshotService、系统 Provider)大多共享状态较少,本文不逐一展开。
资源生命周期也需要人工兜底。SQLite 连接、测试用临时数据库、FileEntryStore的独立连接都有显式close();DatabaseManager.close()的注释明确提到"避免 vnode 占用警告",说明这是实际踩过的坑。ScanCacheSession.commit()失败时会记录日志而不是中断清理,历史写入失败也不会回滚已执行的删除。
安全校验的顺序同样值得单独看:TrashCleaner.validate依次检查存在性、最新元信息、符号链接、大小与修改时间、SafetyEngine安全等级,最后解析完整路径复核危险区域。但validate与clean是两次独立调用,之间仍有竞争窗口;trashItem/removeItem按路径操作,guardPermanentDelete只复核安全等级与符号链接,不会重新执行完整元信息校验。这些检查降低误删风险,但不能消除 TOCTOU。
Coordinator/UseCase没有落地,实际是单例 + ViewModel;是否需要补回,应结合多入口的重复流程与编排测试需求判断,而不是类型名称本身。
代码中的审查 P1-1、审查 P2-7、审查 P3-9等标记,表明项目至少经历过按优先级编号的审查或修订,涉及规则护栏、错误分类、隐藏文件统计等问题。但仓库没有对应的审查记录,无法还原完整讨论,也不能据此推断每处修改由谁完成。
可维护性的真实问题
架构边界清楚,不等于代码没有债务。按行数统计,几个明显偏大的文件是:
| 文件 | 行数 | 问题 |
|---|---|---|
UI/Clean/CleanView.swift | 901 | 列表、分组、应用视图、确认页、结果页集中在一个文件 |
Core/Common/Localization/L10n.swift | 744 | 大量计算属性,键与 UI 强绑定 |
UI/Applications/ApplicationDetailView.swift | 657 | 详情、卸载 Sheet、存储分解混在一起 |
Core/Database/DatabaseManager.swift | 562 | 多张表与多组业务查询共用一把锁 |
单例(ScanEngine.shared、CleanEngine.shared、DatabaseManager.shared等)降低了装配成本,但也让测试必须通过构造函数注入替身;项目确实这样做了(ScanEngine(scanners:)、CleanEngine(cleaner:history:)),算是可测试性上的补救。
为什么没有落地规划中的分层
架构规范提出了 Coordinator 与 UseCase,本意是把扫描与清理流程从 UI 中抽离。实际实现选择了 Engine + ViewModel:ScanViewModel.startScan()直接调用ScanEngine.quickScan,清理也直接调用CleanEngine。这个选择的代价是 ViewModel 承担了部分流程编排职责,好处是调用链短、调试路径清晰。是否引入 Coordinator,不应只看调用方数量,而应看多个入口是否存在重复的流程编排、是否需要共享业务流程,以及是否需要独立测试编排逻辑。当前项目已有多个清理入口,但它们承担的业务职责和安全策略并不完全相同;是否抽取统一的编排层,需要结合重复逻辑与实际维护成本判断,而不是为了让架构图更整齐而增加一层类型。
小结
从仓库产物看,真正起约束作用的是主清理链路:Scanner/Cleaner 分离、SafetyEngine独立、风险模型在扫描后统一分析。但这些约束不是完整的安全保证:旧JunkItem接口不经过CleanupAnalyzer与confirmedReview,删除阶段与validate之间仍有 TOCTOU 窗口,@unchecked Sendable的并发正确性依赖锁与人工审查。计划中的分层没有落地、部分文件过大,也是明确的维护成本。
下一篇进入具体功能,讨论磁盘扫描、文件分类、清理安全和应用卸载这四条链路。
- 上一篇:项目规划与技术选型
- 下一篇:核心功能实现与文件系统安全