Long-term knowledge about this project. Append new entries; do not delete history. Format:
## YYYY-MM-DD — Topic
- Project: BrewUI — Homebrew's official macOS GUI
- Stage: Early / scaffolding. AI config layer created. Prototype context migrated from separate repo.
- Primary agents in use: Claude, Cursor. Designed to be agent-agnostic.
- AI config files created:
AGENTS.md,CLAUDE.md,.cursorrules,CONVENTIONS.md,ARCHITECTURE.md,.ai/directory. - Key rule: All agents must read
AGENTS.mdat the start of each session and update.ai/progress.mdat the end. - Next step: Migrate project-specific context from prototype repo. Update placeholder sections in
CONVENTIONS.md,ARCHITECTURE.md, and this file.
- Language: Swift 6.0, strict concurrency mode enabled
- UI: SwiftUI (pure — AppKit only when SwiftUI cannot meet the requirement)
- State:
@Observable(Swift 5.9+);async/awaitthroughout - Package manager: Swift Package Manager
- macOS targets: Tahoe 26, Sequoia 15, Sonoma 14 — minimum Tahoe 26
- Data sources:
brewCLI subprocess + Homebrew JSON API (formulae.brew.sh) - Architecture pattern decided: Views → ViewModels → 仓库/Interactors → Services → external (brew CLI / JSON API)
- 仓库 naming: protocol =
*仓库, real impl =Brew*仓库, mock =Mock*仓库 - Interactor naming: protocol =
*Interacting, impl =*Interactor, mock =Mock*Interactor - Formatter:
swift-format(official Swift project tool) - Test framework: Swift Testing (preferred) or XCTest
- No
try!or force-unwrap anywhere — not in production, not in tests. Use#require/XCTUnwrapin tests. - Accessibility identifiers: single source of truth in
Utilities/AccessibilityIdentifiers.swift, shared between app and UI test targets
- Sandboxing: App is unsandboxed. No entitlements needed for subprocess execution.
- Homebrew detection: Check
/opt/homebrew/bin/brew(Apple Silicon) then/usr/local/bin/brew(Intel). Degrade gracefully if neither found. - JSON API schema drift: Prefer tolerant handling for unknown fields; required-field policy is endpoint-specific (see 2026-05-18 strict catalogue decoding entry).
- Pre-commit enforcement: 仓库-managed pre-commit hook runs staged Swift files through Mint (
mint run swiftformat, thenmint run swiftlintwith--fix, then strictmint run swiftlint). See 2026-04-12 — Mint for SwiftFormat and SwiftLint. After format/lint it onlygit adds fully staged files whose worktree blob changed; partially staged files are blocked only when format/lint actually changed the file on disk (not merely because index ≠ worktree). - Bootstrap integration:
./scripts/bootstrapinstalls git hooks automatically viascripts/install-git-hooksto minimize manual setup for contributors.
- Version pins: SwiftFormat and SwiftLint versions live in root
Mintfile(nicklockwood/SwiftFormat,realm/SwiftLint). - Install path: Homebrew (
Brewfile) installs Mint only;./scripts/bootstraprunsmint bootstrapso pinned tools are built once and cached under Mint. - Enforcement: Pre-commit and
swift_qualityCI invoke tools viamint run swiftformat/mint run swiftlintfrom the repo root (Mintfile discovery), not global Homebrew formulae for those binaries.
- Pinned Swift version for tooling: Added root
.swift-versionwith6.2(aligned to latest installed stable Swift 6.2.4) for deterministic SwiftFormat behavior. - Project formatter config: Added root
.swiftformatwith explicit Swift version and baseline whitespace/line-ending settings. - Project linter config: Added root
.swiftlint.ymlwith scoped includes/excludes and practical early-stage defaults forline_lengthandidentifier_name.
- PR checks policy: Required PR checks are lightweight and path-scoped for fast feedback.
- Workflow split: CI is separated into focused workflows (
swift_quality,pr_build_test,actionlint,ui_smoke) instead of a monolithic pipeline. - Optional heavy check: UI smoke testing is explicitly opt-in via manual
workflow_dispatch(ui_smoke.yml) and is not required by default.
- Xcode project/scheme/targets were renamed from
BrewUItoBrewfor clarity. - Test targets now map as:
BrewTests= unit testsBrewUITests= UI tests
- 仓库 root folder remains
BrewUI(part of a larger parent project layout). - App bundle identifier and installer package identifier changed to
sh.brew.app(wassh.brew.BrewUI). Test bundle identifiers track renamed targets (sh.brew.BrewTestsandsh.brew.BrewUITests).
- GitHub workflow linting should avoid
uses: docker://...because org allowlist policy can reject it. - Preferred pattern for this repo is Homebrew-influenced and allowlist-friendly:
- use
Homebrew/actions/setup-homebrew@1f8e202ffddf94def7f42f6fa3a482e821489f9c # 2026.07.10.1 - use
Homebrew/actions/cache-homebrew-prefix@1f8e202ffddf94def7f42f6fa3a482e821489f9c # 2026.07.10.1to installactionlint/shellcheck - run
actionlintvia arun:step
- use
- Actionlint remains path-scoped to workflow changes for low CI overhead.
- No
docs/adr/: Architecture Decision Records are not used in this repo. - Where “why” lives: Durable decisions, constraints, and rationale go in
.ai/memory.md(dated entries, append-only history). ARCHITECTURE.md: Describes structure and how pieces fit; add extra explanation only when something is unusual or easy to misread.
ARCHITECTURE.mdis the single source of truth for system shape, layers, data flow, file/folder layout, tech stack baseline, and whereAccessibilityIdentifiers.swiftlives.CONVENTIONS.mdowns naming rules, implementation patterns, and contributor-facing how-to; it should reference architecture instead of repeating topology or the stack table.
- Do not add standing checklist items to
.github/PULL_REQUEST_TEMPLATE.mdfor doc deduplication (or similar); the template should stay short and not grow indefinitely. - Doc duplication: rely on
Document scope/ ownership matrix inCONVENTIONS.md, cross-links inARCHITECTURE.md, and review judgment — not extra PR checkboxes.
ARCHITECTURE.mdandCONVENTIONS.mdare intentionally minimal for the early scaffolding phase; grow them as real code and patterns appear.- Product / platform constraints live under Constraints & decisions in
ARCHITECTURE.mdonly (not duplicated inCONVENTIONS.md). CONVENTIONS.mdholds BrewUI-specific naming deltas, tooling pointers, and short implementation notes; generic Swift/SwiftUI guidance defers to Apple docs + SwiftLint/SwiftFormat.- Doc deduplication: use the opening blockquotes and cross-links between the two files; the old standalone “ownership matrix” in
CONVENTIONS.mdwas removed in favour of that lighter approach.
- Added
.cursor/rules/doc-relative-links.mdcto require relative Markdown links for intra-repo doc references (avoid absolute GitHub blob URLs in docs).
- Where it lives:
Brew/Theme/(BrewColors,BrewSpacing/BrewLayout/BrewRadius,BrewFonts) is the single source for UI semantics;CONVENTIONS.mdhas a Design system section; agents use.cursor/rules/design-system.mdc(globsBrew/**/*.swift) alongsideswift-implementation.mdc. - Rule: Extend Theme when adding new semantics — avoid raw hex or magic numbers in feature views (already stated in
BrewColors.swiftcomments).
InstalledViewModelDummyDatalives in its own file with static sample arrays.InstalledViewModel.inittakes optional row arrays and applies?? InstalledViewModelDummyData.*in the initializer body — do not use= InstalledViewModelDummyData.formulaeas default parameter values, or Swift 6 reports main-actor / default-argument isolation issues.
- Flow:
InstalledViewModel→Installed包仓库→BrewCommandRunning+BrewExecutableLocator. Parsing is pureInstalled包Parseronbrew list --versions --formula|caskstdout (ARCHITECTURE.md— tolerant CLI handling). - Tests: Mock
BrewCommandRunningwith a[[String]: CommandOutput]map; never run realbrewin unit tests (CONVENTIONS.mdTesting).BrewExecutableLocator(overrideURL:)exists for tests only.
- Drift: Xcode had
ENABLE_APP_SANDBOX = YESwhileARCHITECTURE.mdspecifies an unsandboxed app (2026-03-03 — Additional Decisions). Sandboxing prevented seeing/executing/opt/homebrew/bin/brewand writing session logs under the repo.cursor/path. - Fix:
ENABLE_APP_SANDBOX = NOfor the Brew app target (Debug and Release). Revisit sandbox + entitlements only if distribution constraints require it.
- Pattern:
InstalledViewModelTestsandBrewInstalled包仓库Testsuse the realBrewInstalled包仓库with boundary fakes only:MockBrewCommandRunner+BrewExecutableLocator(overrideURL:)orMissingBrewExecutableLocator(BrewExecutableLocating). Shared helpers:BrewTests/TestSupport/Installed包仓库TestSupport.swift. Documented inCONVENTIONS.mdTesting.
- When
SidebarItemgains a second case (e.g. Discover), introduce aMainWindowViewModel(orAppShellViewModel) to own selection and any tab rules; keepContentView’sswitchonly for constructing child views. Presentation for sidebar rows can move there for unit tests.
- Layout: The main window uses the three-column
NavigationSplitViewinitializer: sidebar (ShellSidebarView), content (InstalledShellView— list + chrome only), detail (InstalledPackageDetailView/InstalledPackageDetailPlaceholder). The previousHSplitViewinside the detail region is removed; column widths useBrewLayouttokens includinginstalledListColumn*andinstalledDetailColumn*.minWindowWidthis the sum of sidebar, list, and detail minimum column widths. Installed data loading runs fromContentViewvia.task(id: selectedSidebarItem)when the Installed tab is selected.
- Added
MainWindowView+MainWindowViewModelso shell layout/navigation selection/load policy are separated from feature views. InstalledColumnsnow owns Installed feature column composition (contentColumn,detailColumn) and related width modifiers.BrewAppnow presentsMainWindowViewdirectly;ContentViewremains a thin compatibility wrapper for previews/incremental migration.
- For async/failable view-model data that drives UI rendering, prefer a single enum state (for example
.loading,.loaded(Data),.error(String)) over separateisLoading/data/errorproperties. - This pattern is now used by
InstalledDetailsViewModelviaInstalledDetailsLoadState, and documented inCONVENTIONS.mdunder Implementation notes.
BrewInstalled包仓库now hydrates installed list data from a singlebrew info --installed --json=v2call instead ofbrew list --versionstext output.- Existing Installed list UI contract remains stable because repository output is still
Installed包Snapshot, mapped/sorted beforeInstalledViewModelrow mapping. - Tests now validate mixed formula/cask JSON payloads, optional/missing fields, command failure behavior, and invalid JSON decode failures for installed list loading.
BrewCommandServicenow starts concurrent stdout/stderr readers immediately after process launch and only then waits for process termination, avoiding wait-before-read deadlocks on large command output.- Added
BrewCommandServiceTestsincluding a large output regression case (250k chars on each stream) to protect command execution paths used for installed list and details loading.
NoopBrewCommandCenter: keep the existingpreview()andforTesting()helpers; avoid renaming preview/test helpers during visibility-only sweeps.BrewCommandExecutionContext: keepnoopForTestingAndPreviews()for existing noop subprocess wiring.- Main window: keep sidebar selection as local
@StateinMainWindowView;InstalledColumnsRootremains the dependency-composition boundary perCONVENTIONS.md. - Encapsulation:
upgradeOperationPhaseisprivateon list/detail VMs where onlyisUpgrading/showsUpgradeBusyare user-facing.
- Protocol
BrewCommandCenter:actorprotocol —submit(id:command:),phase(for:),phaseByID()(full snapshot map),isActive(id:)(allasyncfrom callers). BrewMutatingCommand:Sendablecommand pattern —var operationKind,func run(in: BrewCommandExecutionContext) async throws(no caller closures; kind is not duplicated atsubmit).BrewCommandExecutionContext:commandRunner(BrewCommandRunning) +brewExecutableURL()vialocator(BrewExecutableLocating).SerialBrewCommandCenter:actor;SerialBrewWorkQueueinner actor ensures one mutating command at a time acrossawait; duplicateBrewOperationIDcoalesces via sharedTaskhandle.OperationFailure:Sendableenum(.brewCommand,.brewLaunchFailed,.brewExecutableNotFound,.generic) forBrewOperationPhase.failed(reason:);init(catching:)mapsBrewCommandError,BrewLookupError, and other errors to cases;userFacingMessageis a derived line for UI.- Transport types (
BrewOperationModels.swift):BrewOperationKind,BrewOperationID,BrewOperationPhase. - Domain package discriminator:
HomebrewPackageKind;InstalledPackageKindtypealias;BrewOperationID.init(kind:name:)inBrewOperationID+Homebrew.swift. - Composition:
BrewAppholdsSerialBrewCommandCenter(executionContext: .live())and applies.environment(\.brewCommandCenter, center)toMainWindowView.MainWindowViewkeeps sidebar selection in local@Stateand embedsInstalledColumnsRoot(). The root view owns dependency composition for the Installed surface by reading@Environment(\.brewCommandCenter)and constructingInstalledColumns(repository:brewCommandCenter:). Previews useNoopBrewCommandCenter.preview()and.environment(\.brewCommandCenter, …); unit tests constructSerialBrewCommandCenter,NoopBrewCommandCenter.forTesting(), orRecordingSerialBrewCommandCenteras needed.
- Upgrade path:
InstalledDetailsViewModelcallsawait brewCommandCenter.submit(id:command:)withBrewOperationID(row: selectedRow)andPackageUpgradeCommand(row: selectedRow)(samekind:nameasInstalledPackageRow/id;PackageUpgradeCommandimplementsBrewMutatingCommandwithBrewCommandExecutionContext; mirrorsbrew upgrade/brew upgrade --caskargv split).BrewOperationID.init(row:)delegates toinit(kind:name:). InstalledViewModeltakesbrewCommandCenter: any BrewCommandCenterininit(repository:brewCommandCenter:);BrewApppasses the sameSerialBrewCommandCenterinstance as for.environment(\.brewCommandCenter, …). RemovedPackageUpgradeRunning/BrewPackageUpgradeService.
InstalledDetailsViewModel.upgradeSelectedPackage()now starts and owns an unstructured task (upgradeTask) so upgrade execution viabrewCommandCenter.submitis not canceled by a view-scoped caller task when navigating away from detail UI.InstalledPackageDetailViewinvokesupgradeSelectedPackage()directly (no view-levelTask { ... }wrapper), keeping task-lifetime policy in the view model.
BrewCommandCenter: addedphaseChanges(for: BrewOperationID) async -> AsyncStream<BrewOperationPhase>— multicast per id inSerialBrewCommandCenterwithcontinuation.onTerminationcleanup;NoopBrewCommandCenteryieldsBrewOperationPhase.idleonce;RecordingSerialBrewCommandCenterforwards toinner.- Use
AsyncStream<Element>(bufferingPolicy: .unbounded) { … }to pick the continuation-based initializer (plainAsyncStream { … }can resolve tounfoldingunder default actor isolation). - Installed list:
InstalledListRowViewModel(observeRowUpdates) +InstalledListRowRootwith.task(id: row.id); removed parentupgradeBusyRowIDspolling loop fromInstalledViewModel/Installed包View.
Installed包仓库: addedloadInstalledPackage(kind:named:) async throws -> InstalledPackageInfoplus shared errorInstalled包仓库Error.packageNotFound(kind:name:).- Default protocol behavior: repository extension falls back to
loadInstalled包()+ section/name lookup so existing test doubles remain source-compatible until they adopt specialized implementations. BrewInstalled包仓库: narrow read now executesbrew info --json=v2 --formula|--cask <name>and maps only the requested section (formulaeorcasks) by exact name/token.- Tests: added dedicated lookup coverage in
BrewInstalled包仓库SinglePackageTestsand new command-fixture helperInstalled包TestSupport.packageInfoJSONResponse(...).
- Row ownership:
InstalledListRowViewModelnow owns mutableInstalledPackageRowsnapshot state (beyond phase) so UI labels can update from narrow refreshes without full-list reloads. - Refresh trigger: Row VM watches
phaseChanges(for:)and performs a narrow repository refresh when a row operation transitionsrunning -> idle, then emitsonRowUpdatedfor parent catalog merge. - View wiring:
Installed包ViewwiresInstalledListRowRootwith explicit closures (refreshedInstalledRow,mergeInstalledRow) so row refresh coordination remains in view/view-model boundaries rather than nested VM factories. - Detail upgrade path:
InstalledViewModelonUpgradeSuccessnow refreshes and merges only the selected row instead of calling full-listrefreshInstalled包PreservingUI().
- Reverted the row-level refresh/patch architecture to reduce complexity:
InstalledListRowViewModelnow observesphaseChanges(for:)for progress only, and no longer owns row-refresh callbacks or repository fetch logic. - Upgrade completion now uses
InstalledViewModel.refreshInstalled包PreservingUI()as the single refresh path (full snapshot, no.loadingtransition), preserving smooth list UX without skeleton flicker. - Kept DI/wiring improvements: view-layer wiring still creates/injects child VMs (
InstalledColumnsfor details VM,Installed包Viewfor row roots / command-center environment injection); list VM does not create child VMs. - Removed narrow single-package repository API (
loadInstalledPackage(kind:named:)) and dedicated lookup tests introduced solely for the abandoned row-refresh approach.
- Project build setting
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActoris enabled for the app target, so non-UI infra code can require explicit actor-neutral annotations. SwiftLintruleunneeded_synthesized_initializeris disabled in.swiftlint.ymlto allow intentional explicit emptyinit/deinitdeclarations when carrying concurrency-isolation intent.- Preferred style: use member-level
nonisolatedfirst (for initializers/factories/helpers/protocol requirements), and avoid type-levelnonisolatedunless a full type-level actor-neutral contract is clearly needed.
- Collapsed Installed feature data models into a single domain model:
BrewPackagenow backs list rows, detail payloads, and repository contracts. - 仓库 now map
brew info --json=v2through sharedBrewInfoJSON+Mappinghelpers and returnBrewPackagevalues (Installed包仓库returns[BrewPackage],PackageDetails仓库returnsBrewPackage). - Installed list/detail presentation formatting moved to feature view models (
InstalledListRowViewModel,InstalledDetailsViewModel) plusInstalledBrewVersionFormatting; models are now presentation-agnostic. - Removed legacy Installed models (
InstalledPackageInfo,InstalledPackageRow,InstalledPackageDetails) and dead parser path (Installed包Parser+ parser tests).
InstalledViewModelowns the catalog and observesBrewCommandCenter.allPhaseChanges()to refresh after mutating operations complete (running → idle).- Detail and row view models no longer fetch from a repository;
PackageDetails仓库and related infrastructure are removed. - Detail and list rows stay in sync by propagating injected
BrewPackagefrom the parent viaonChange(of: package)→update(package:)on the child view models. - Detail-upgrade phase observer caveat: the detail VM still polls phase around submit rather than subscribing to a stream for concurrent operations on the same id; acceptable for now.
- Feature
*Rootviews are the dependency composition boundary for that surface: they read app-level dependencies (for example@Environment), construct/inject content-view dependencies, and own view-model lifecycle boundaries. - Content views should receive dependencies from their root and focus on rendering and behavior; avoid direct app-level dependency acquisition in content views when a root wrapper exists.
- Treat optional content view models introduced solely to compensate for misplaced dependency acquisition as an anti-pattern.
- Keep the Installed list mounted under a stable parent container (
HSplitView) across selection changes; switching between list-only and split layouts can remount the list and reset scroll position. - Prefer native
List(selection:)for Installed row selection state, with view-model-backed selection binding (setSelection(_:)) and row tags by stable package id.
- Installed detail mutations now support both upgrade and uninstall through the shared
BrewCommandCenterpipeline; uninstall usesPackageUninstallCommandwith newBrewOperationKindcases (uninstallFormula,uninstallCask). InstalledPackageDetailViewnow treats the footer as a general package-actions area: show upgrade chrome only whenpackage.outdated, but always show uninstall chrome with a native confirmation dialog and copyable user-facingbrew uninstall ...command.
- Symptom:
vale docs/fails withlstat docs/../Gemfile: no such file or directorywhendocs/Gemfileis missing. - Cause: Vale 3 treats
RakefileandBrewfile(among others) as Ruby-format prose under[formats] rb = mdin.vale.ini. For those paths it expects a resolvableGemfilenext to the Ruby project layout; this repo haddocs/Gemfile.lockand Jekyll binstubs but nodocs/Gemfile, unlikeHomebrew/brewwheredocs/is a full Jekyll tree includingGemfile. - Fix: Commit
docs/Gemfile(matching upstream brew docs, consistent with the lockfile) anddocs/.ruby-versionso Vale and laterbundle execsteps in.github/workflows/docs.ymlboth succeed.
- Cache: In-memory
InstalledInventoryCacheactor storesInstalledInventorySnapshot(packages +PackageDependencyGraph) populated byBrewInstalled包仓库after successfulbrew info --installed --json=v2.BrewAppcreates one cache per app lifetime and injects it via SwiftUI environment (InstalledInventoryEnvironment.swift); feature roots constructBrewInstalled包仓库/BrewInstalledDependents仓库from that shared cache. Previews and tests construct isolated caches withInstalledInventoryCache()when needed. - TTL: Snapshots are stale after 3600 seconds;
load()may return cached packages when fresh;refresh()passesforceRefresh: trueto bypass TTL after mutating brew work. - Used by: Detail dependents come from reverse dependency edges among installed packages; per-selection
brew useswas removed.
- Visibility: 新建 inventory types are module-internal; graph storage and JSON mapping helpers are
private. ProtocolsInstalledDependents仓库/InstalledInventoryReadingstay internal as DI boundaries. - 关注-up (production, separate PR):
InstalledInventoryCache.packages()has no call sites (prefercached包()).EmptyInstalledDependents仓库/EmptyInstalledInventoryReadingare unused in production — used only frommakeInstalledDetailsViewModelin BrewTests; decide whether to delete, wire in previews, or keep for tests only. - Tests:
makeInstalledDetailsViewModelinInstalledDetailsViewModelTestsSupportsupplies empty repo defaults for non-inventory tests; production inits remain four explicit parameters.
- Removed dead installed-inventory API surface from app target:
InstalledInventoryCache.packages(),InstalledInventoryReading.installed包(for:),BrewInstalled包仓库.installed包(for:), andBrewPackage.reference. - Moved test-only empty inventory/dependents stubs out of
Brew/仓库intoBrewTests/TestSupport(EmptyInstalledInventoryReading,EmptyInstalledDependents仓库) so production DI paths remain explicit. - Pruned unused test support helpers
localizedHomebrewCommandFailedMessage()andpackageInfoJSONResponse(...)fromInstalled包仓库TestSupport.
- Installed uninstall presentation mapping now uses a feature-layer
UninstallPackageIteminitialized fromBrewPackage, instead of adding uninstall UI properties directly onBrewPackage. - Team convention clarified: domain model types stay presentation-agnostic; map to UI properties through feature ViewModels (top-level surfaces) or feature
*Itemtypes (subview/action presentation).
- Strengthened
CONVENTIONS.mdand.cursor/rules/swift-implementation.mdcwith an explicit MVVM guardrail: views must not compose multiple ViewModel state primitives inline to derive a single presentation decision. - Preferred pattern: expose one derived ViewModel property per UI concern (for example one spinner-driving busy flag), and have the view bind directly to it.
- Installed detail now uses feature-layer item mappings for co-changing presentation groups:
PackageDetailMetadataItem,UpgradePackageItem, andUninstallPackageItem, all exposed fromInstalledDetailsViewModelfor VM-driven subviews. - For this surface, independently changing async state streams (
isUpgrading,isUninstalling,isMutatingPackage) remain on the top-level ViewModel and are not folded into item types. - Detail-presentation extensions on
BrewPackagewere removed (BrewPackage+Presentation.swiftdeleted); presentation mapping lives in ViewModel/feature item types only.
- Installed detail ViewModel naming now aligns with the package-detail view family:
InstalledDetailsViewModelwas renamed toInstalledPackageDetailViewModeland moved toInstalledPackageDetailViewModel.swift. - Feature-local helper names should stay scoped but respect lint type-length limits (
SwiftLinttype_namemax 40): renamed detail command console helper toInstalledDetailMutationConsoleand mutation-parity test suite/file toInstalledDetailMutationParityTests. - Installed list row presentation value type renamed from
RowVersionPresentationtoInstalledListRowVersionPresentationto keep local naming explicit.
- Feature folders now use explicit subdirectories:
Features/<Feature>/ViewsandFeatures/<Feature>/ViewModels. Models/is now enforced as domain-only; non-domain types moved out:- Installed presentation/UI types moved to
Features/Installed/ViewModels. - Brew command JSON/operation helpers moved to
Services/BrewCommand(with command JSON underServices/BrewCommand/JSON). - Installed inventory snapshot moved to
Services/InstalledInventory.
- Installed presentation/UI types moved to
- Service infra is now grouped by boundary: brew command layer in
Services/BrewCommand, inventory infra inServices/InstalledInventory. - Added durable guidance in
CONVENTIONS.md,ARCHITECTURE.md, and.cursor/rules/folder-boundaries.mdcso future agents keep the same placement policy.
- Shared preview samples and lightweight preview fakes now live in a single source of truth:
Brew/PreviewSupport/AppPreviewSupport.swift. - Previews should consume centralized support types (
AppPreviewSupport, preview fakes) instead of defining one-off inline mock services/repositories per view. - Preview blocks are colocated at the bottom of their view files; standalone
+Previews.swiftfiles for those views were removed. - Enforcement guidance is documented in
CONVENTIONS.mdand.cursor/rules/previews-centralized.mdc(linked fromswift-implementation.mdc).
BrewPackagenow carriesdisplayNamefor UI labels while keepingnameas the canonical Homebrew identifier used for IDs and CLI commands.- Installed mapping from
brew info --json=v2now uses richer display fields with fallback:- formula:
full_name(fallbackname) - cask: first
nameentry (fallbacktoken)
- formula:
HomebrewPackageReferenceremains identity-first (.formula(name:)/.cask(token:)) and still uses canonical values forpackageID; dependency-only contexts may continue showing token/name when richer metadata is not present.
- User preference: place generated PR descriptions in
.ai/scratchpad.mdby default.
- Added project skill at
.cursor/skills/pr-description-to-scratchpad/SKILL.md. - Skill contract: build PR bodies from
main...HEAD, follow.github/PULL_REQUEST_TEMPLATE.md, and append to.ai/scratchpad.md.
- Added a dedicated Homebrew analytics network boundary:
BrewAPIClientprotocol +URLSessionBrewAPIClientinBrew/Services/BrewAPIClient.swift.- Endpoint-specific APIs for Discover:
fetchFormulaInstallOnRequestAnalytics(window:)fetchCaskInstallAnalytics(window:)
- Added resilient analytics decoding model
BrewAnalyticsJSON:- tolerant top-level decode with
formulae/casksbucket fallback - count parsing supports both numeric and comma-separated string forms
- normalized
BrewAnalyticsPackageCountoutput for repository mapping
- tolerant top-level decode with
- Added
Discover包仓库+BrewDiscover包仓库returning one combinedDiscoverTop包Snapshot(topFormulae+topCasks) with limit/window parameters (defaults: top 10, 30d).
- Package-domain representations should expose
HomebrewPackageReferenceas their lookup identity (formulae map to.formula(name:), casks map to.cask(token:)). BrewPackagenow exposesreferenceagain as a computed property fromkind+name.- Discover models now carry typed references:
BrewAnalyticsPackageCount.referenceDiscoverTopPackage.reference
- Added
.cursor/rules/package-domain-reference.mdcand mirrored the rule inCONVENTIONS.mdto keep the requirement persistent for future domain models.
- Added
URLSessionProtocol(data(for:)) inBrew/Services/URLSessionProtocol.swiftwithURLSessionconformance. URLSessionBrewAPIClientnow supports dependency injection viainit(session: any URLSessionProtocol, ...), enabling deterministic unit tests with a mock session implementation instead of closure-only stubbing.
- Discover analytics decoding now fails fast for required non-optional fields rather than defaulting to empty/zero values.
BrewAnalyticsJSONrequires validcategory,total_items,total_count,start_date,end_date, and at least one analytics bucket container (formulaeorcasks).- Malformed per-entry
countvalues now throw during decode (no fallback0), and unresolved package references are treated as decode failures instead of being silently filtered out.
- Simplified
BrewAnalyticsJSONstrict decoder by removing fallback identity inference:- no fallback from bucket key
- no fallback from
categoryinferred kind
- Analytics rows now require explicit identity in payload (
formulaxorcask), which keeps failure behavior deterministic when server payloads are incomplete/ambiguous.
- Replaced
URLSessionProtocolabstraction tests with integration-style tests that use a realURLSessionconfigured withURLProtocolstubbing. URLSessionBrewAPIClientsession init now takes concreteURLSession.BrewAPIClientTestsuse host-scoped stub queues/recording inStubURLProtocolto keep tests deterministic under concurrent execution.
- Installed search still filters on canonical
BrewPackage.namerather thandisplayName. - User requested this remain unchanged for now and be revisited during discovery search work.
- Keep this as an explicit follow-up so label-search behavior can be aligned intentionally later.
- Catalogue transport decoding (
FormulaCatalogueJSON/CaskCatalogueJSON) now treatsdesc,homepage,versions.stable, andanalytics.install["30d"]as required fields. - Catalogue array decoding is now lossy per item: invalid entries are skipped, and decode failures are captured with item index + error string so callers can handle partial failures without dropping valid items.
- Split catalogue responsibilities into two boundaries:
CatalogueCacheactor owns only in-memory state, disk persistence, and ETag storage.BrewCatalogue仓库owns TTL, stale-while-revalidate policy, refresh orchestration, and in-flight task deduplication.
CatalogueCachecurrently preloads cache on async init:init(...) asyncloads formula/cask cache files immediately (missing/corrupt files are swallowed);- read/write methods operate on preloaded in-memory state (no separate warm-up method).
BrewCatalogue仓库behavior:- one refresh
Taskper catalogue kind is shared across concurrent callers; - stale cached data returns immediately while background refresh runs;
- cold start (no cache) blocks on refresh and surfaces errors;
304updates only last-refresh timestamp;- background refresh failures are swallowed.
- one refresh
Catalogue仓库now maps cached/network catalogue transport payloads to domainBrewPackagearrays before returning.- Durable rule:
Codabletransport types (*JSON, DTO/wire models) must stay behind service/repository boundaries and not appear in service/repository protocol return types. - Enforcement/docs:
- Added
CONVENTIONS.mdnote under Transport boundary. - Added
.cursor/rules/codable-boundary.mdc(always apply) and linked guidance in.cursor/rules/swift-implementation.mdc.
- Added
- Installed-only state moved out of shared package domain:
InstalledBrewPackagecomposes aBrewPackage(package) plusinstalledVersionsandoutdated.- Public fields shared with
BrewPackageare exposed as wrapper properties; identity (id,reference) derives frompackage. BrewPackageis slim/shared for catalogue/discover-prep fields only.
- Boundary rule:
- installed repositories/inventory/graph/view-model surfaces use
InstalledBrewPackage, - catalogue repository surfaces remain
[BrewPackage]and must not reintroduce installed-only fields.
- installed repositories/inventory/graph/view-model surfaces use
- Command/convenience identity behavior remains stable via
kind:nameIDs:BrewOperationID(package:)now takesInstalledBrewPackage,HomebrewPackageReferencegainedinit(installedPackage:)(delegates toinit(package:)),- package ID/reference semantics remain canonical and unchanged.
- Discover top-list domain rows now use
DiscoveryBrewPackage(package: BrewPackage+thirtyDayInstallCount) instead of reference/count-only payloads. DiscoverTop包Snapshotnow carries[DiscoveryBrewPackage]for formula/cask sections; downstream list/detail mapping should read package metadata fromDiscoveryBrewPackage.packageand ranking fromDiscoveryBrewPackage.thirtyDayInstallCount.
BrewDiscover包仓库fetches analytics viaBrewAPIClient, then resolves each ranked analytics row throughCatalogue仓库.package(for:)(per-reference lookup, not whole-catalogue loads).Catalogue仓库public API ispackage(for: HomebrewPackageReference) -> BrewPackage?; whole-catalogue fetch/map/cache refresh stays private insideBrewCatalogue仓库.- On
package(for:), if the kind’s catalogue is uncached or TTL-stale,BrewCatalogue仓库fetches the full catalogue once, updates cache, and serves lookups from an in-memory ID index (deduped in-flight refresh preserved). - Analytics rows with no catalogue match are dropped silently; the limit applies to matched rows only.
DiscoverViewModelno longer loads catalogues; it consumes enrichedDiscoveryBrewPackagerows from the discover repository.
- Selection chrome: Both
Installed包ViewandDiscover包Viewuse a plainList(notList(selection:)), row taps via.onTapGesture+viewModel.setSelection, and.listRowBackground(selected ? Color.brewBrandTint : Color.clear). AvoidList(selection:)— it applies system accent selection on top of the brand tint. - Column backgrounds: List column header, list body, and detail inherit window/split chrome; do not wrap Discover/Installed columns in
.background(Color.brewSurface)unless product asks for explicit surface fills everywhere. - Scroll: Both lists use
ScrollViewReader+scrollToSelectionon appear/selection/row-id changes (.brewFastanimation).
- Console UI is fed by a registry, not by
BrewCommandCenterdirectly.JobRegistry(@Observable @MainActor) projects center operation state for console views; it never mutates the center. Subscribes toallPhaseChanges()once at app launch viaBrewApp .task { jobRegistry.startObserving(commandCenter) }. Injection mirrorsBrewCommandCenterEnvironment—@Entry var jobRegistry: JobRegistry. CommandJobreusesBrewOperationID(do not mint a parallel UUID). Carries phase, output buffer (50k-line cap, FIFO eviction), and a derivedexitCode: Int32?.isTerminal == (exitCode != nil).- Exit code derivation from phase transitions (since
BrewOperationPhasedoesn't carry one):.running → .idle⇒ exit 0;.failed(.brewCommand(exitCode, _))⇒ that exit code; other.failed(...)cases ⇒-1sentinel. An.idle → .idle(initial replay or noop) does not back-fill an exit code. - Job materialization is lazy and ID-derived. Registry only creates a
CommandJobwhen it sees a.running(kind)phase for an unknown id; the user-facing command string andJobScopeare synthesized fromBrewOperationID.rawValue(parsed as"<kind>:<name>") plus the runningBrewOperationKind. This avoids extendingBrewMutatingCommandwith metadata for slice 1. JobScope:.package(name:)for per-package ops,.globalforbrew update/doctor/cleanup,.batch(names:)for multi-package upgrades. Used to filter jobs in detail panes vs show globally in the console.- Files live in
Brew/Features/Console/Models/(BrewCommandOutputLine,JobScope,CommandJob,JobRegistry,JobRegistryEnvironment). Future console UI will live alongside inBrew/Features/Console/Views/.
BrewCommandCenteraddsoutputChanges(for:)andallOutputChanges()mirroring the existing phase API shape exactly — per-id stream yields buffered-on-subscribe lines, all-output stream yields each new(id, line)with no replay. Both clean up viaAsyncStream.Continuation.onTermination.- Output sink plumbing uses a TaskLocal (
BrewCommandOutputContext.sink) rather than threading a closure throughBrewMutatingCommand/BrewCommandRunning.SerialBrewCommandCenter.submit(id:command:)sets the sink viawithValue(...)for the duration of the submit;BrewCommandServicereads it once at the top ofrun(...). Commands are untouched — they still callcontext.commandRunner.run(...). BrewCommandServicestreams via chunkedavailableData+ manual\nsplitting, notFileHandle.bytes.linesand notreadabilityHandler. Rationale: preserves byte-exactCommandOutput.standardOutput/standardError(the existing buffered path's contract — tests rely on it). Whensink == nilthe splitting is skipped (no overhead). Lines whose bytes are not valid UTF-8 are dropped from the stream (still kept in the verbatim byte return).- Ordering across actor boundary uses an internal
AsyncStreambuffer. The non-actor sink closure yields lines to a continuation; a per-submit drain Task pulls them in FIFO order onto the actor and broadcasts to per-id and all-output listeners. Drains untillineContinuation.finish()is called when the submit task settles. Across stdout/stderr the order is inherently non-deterministic (separate POSIX streams). NoopBrewCommandCenter/RecordingSerialBrewCommandCenterstub or forward the new methods. Output streams fromNoopBrewCommandCenterfinish immediately (no work to observe).- No 16ms batching yet — each line emits to listeners as it arrives. Sufficient for typical brew chatter; batching deferred to console-polish (Slice 6).
- Root layout:
MainWindowViewwrapsNavigationSplitViewin aVStack(spacing: 0) withDivider() + ConsolePanelat the bottom. The console spans the full window width (under sidebar + detail) — IDE convention, signals that it reflects all brew activity. @SceneStorage("consoleExpanded")stores the expand/collapse boolean per window. Survives quit-and-relaunch. Do not use@AppStorage(cross-window pollution).- Console layout tokens live in
BrewLayout:consoleCollapsedHeight(36),consoleMinExpandedHeight(120),consoleMaxExpandedHeight(600),consoleDefaultExpandedHeight(240). No new color literals at call sites — all colors flow throughBrewColors(brewTerminal,brewBrandPrimary,brewStatusSuccess,brewStatusError,brewTextSecondary,brewTextTertiary,brewCodeDefault,brewCodeError). - MVVM separation:
ConsoleStatusPresentation.from(registry:)projects the registry into a view-passive struct (DotState + Summary + isRunning); the view renders that snapshot. Mirrors the existing*BusyPresentationpattern in Discover/Installed. - Phase short labels live as
BrewOperationPhase.shortLabelextension (done/running/failed). Coarser than brew stdout markers (fetching/pouring/linking) because the center's phase enum doesn't expose those — sub-phase granularity would require stdout parsing. Single source of truth for status-bar + future inline-card text. - Status dot palette (design-system rule — BrewUI-owned components use amber for in-progress; terminal states use semantic colors): running =
brewBrandPrimary, succeeded =brewStatusSuccess, failed =brewStatusError, idle =brewTextTertiary. - Slice 3 ships the collapsed strip only. The expand toggle is wired but the expanded body comes in slice 4 — the chevron currently toggles state with no visible effect.
ConsolePanelis a state machine. Collapsed → justConsoleStatusBar. Expanded →VStackofConsoleResizeHandle+ConsoleToolbar+Divider+ConsoleBody. Animates.frame(height:)with.brewFast(0.15s easeOut) to keep theListabove from clipping (see ARCHITECTURE/CONSOLE_IMPLEMENTATION_PLAN §7).- Resize state:
@SceneStorage("consoleHeight") consoleHeight: Double = consoleDefaultExpandedHeight. Per-window, survives quit-and-relaunch. Drag is clamped betweenconsoleMinExpandedHeight(120) andconsoleMaxExpandedHeight(600). The handle's cursor usesNSCursor.resizeUpDown.push()/.pop()inonHoverso it doesn't leak when the view disappears mid-hover. - **
⌘\`` toggle usesFocusedBindingplumbing.**FocusedValues.consoleExpanded: Binding?is published byMainWindowViewvia.focusedSceneValue(.consoleExpanded, $consoleExpanded);ConsoleCommandsreads it with@FocusedBinding(.consoleExpanded)` so the menu command targets the active window's scene-storage. The menu button label flips between "Show Console" / "Hide Console" based on the current state; disabled when no focused window publishes the value. - Commands are added in two
.commandsblocks onWindowGroup— the always-onConsoleCommandsand (DEBUG-only)DebugMenuCommands. Two adjacent.commandsmodifiers merge; this avoids#if DEBUGinside a@CommandsBuilder. - Toolbar = selected-job pill + actions + collapse chevron. Pill =
ConsoleStatusDot+ monospace command on abrewBrandTintrounded rect (sm radius). Save usesNSSavePaneldefaulting to~/Downloadswith filenamebrewui-<sanitized-command>-<yyyy-MM-dd-HHmmss>.log; Copy usesNSPasteboard.general. Both consumeCommandJob.formattedOutputForExport()which[stderr]-prefixes error lines so a downstream reader can disambiguate. - Clear calls
registry.clearCompleted()directly with no confirmation dialog.clearCompletedalready preserves in-flight jobs, so there's no destructive case to confirm — deviation from CONSOLE_IMPLEMENTATION_PLAN §3.4 which suggested a dialog when running jobs exist. Revisit if user feedback wants the call-out. ConsoleBodyis aListoverselectedJob.outputwith.listStyle(.plain) + .scrollContentBackground(.hidden) + .background(Color.brewTerminal). Auto-pin-to-bottom on.onChange(of: job.output.count). User scroll-lock when scrolled up is deferred to console polish. stderr lines usebrewCodeError; stdout usesbrewCodeDefault. Text uses.textSelection(.enabled)so individual lines can be copied. Empty state =ContentUnavailableView(macOS 14+).- No ANSI / color parsing in v1 — raw monospace text. Tracked for the polish slice.
2026-05-27 — Console split into BrewCommandJobs仓库 + ConsoleViewModel (supersedes earlier registry note)
- Supersedes the 2026-05-27 "Console command-job projection layer" entry.
JobRegistryand\.jobRegistryno longer exist. The decision to call the layer a "Registry" pre-dated a full survey of the codebase's MVVM + 仓库 conventions; the conflation of translation/cache, screen-local state, and presentation projections inside one type drifted from how Discover/Installed are structured. - 新建 layer split mirrors
BrewInstalled包仓库+InstalledViewModel:BrewCommandJobs仓库(Brew/仓库/,@Observable @MainActor, conforms toCommandJobsObserving) is the single source of truth for cached command-center operation state. Subscribes tocommandCenter.allPhaseChanges()and.allOutputChanges()ininitvia@ObservationIgnoredTasks (nostartObserving(_:)call fromBrewApp— pattern matchesBrewInstalled包仓库.completionObserverTask). Ownsjobs/orderedIDs; exposesremove(id:)andclearCompleted().handlePhase/handleOutputare internal seams kept around for unit tests.ConsoleViewModel(Brew/Features/Console/ViewModels/,@Observable @MainActor) owns the per-windowselectedID(screen state — does not belong on a 仓库). Exposes derivedorderedJobs,selectedJob,activeJob,statusPresentation, andjobs(for packageName:). Intent methods (select,dismiss,clearCompleted) clean up selection before forwarding to the repository.
- Wiring:
\.commandJobs仓库env key replaces\.jobRegistry.ConsolePanelRootreads the env value and constructs a@StateConsoleViewModelper-window (mirrorsInstalledColumnsRoot).ConsolePanel/ConsoleToolbar/ConsoleBody/ConsoleStatusBarconsume the VM directly (@Bindable/let) — no environment-key reads inside console views. - AppKit isolation:
NSSavePanel/NSPasteboardcalls live in a view-layer namespaceBrew/Features/Console/Views/ConsoleOutputExport.swiftrather than in views or the VM. Rule: ViewModels do not import AppKit; AppKit bridges sit at the view layer.CommandJob.formattedOutputForExport()and.suggestedExportFilename()remain on the domain model (pure data). - File placement:
CommandJob.swiftmoved fromConsole/Models/toConsole/ViewModels/— it's@Observable @MainActorwith presentation-shaped helpers, which CONVENTIONS.md says doesn't belong underModels/.Console/Models/now holds only the pure value typesBrewCommandOutputLineandJobScope. - Tests:
BrewCommandJobs仓库Testscovers cache mechanics (phase/output materialization,remove,clearCompleted).ConsoleViewModelTestscovers selection/projection/intent forwarding.ConsoleStatusPresentationTestsconstructs a(repository, viewModel)fixture and asserts againstviewModel.statusPresentation.
BrewOperationIDis now an enum (BrewCore/Operations/BrewOperationModels.swift):.package(HomebrewPackageID)and.maintenance(token:displayCommand:). Was a package-only struct. Maintenance ops (abrew doctorfix likebrew link/brew cleanup) aren't package-scoped, so they carry their owntokenfor identity plus the user-facingdisplayCommand. Backward-compatible: the existinginit(kind:name:)/init(package:)/init(packageID:)inits still build.package, so all install/upgrade/uninstall call sites were untouched.packageIDis now an optional accessor (nilfor maintenance).BrewOperationKind.doctorFixadded.CommandJob.materializebranches on the id: package ids synthesize the command from kind+name (existing path); maintenance ids use the id's storeddisplayCommandverbatim. This is why a doctor fix shows up correctly in the bottom console with no extra wiring.DoctorFixCommand(BrewCLI) runs an arbitrarybrewargv withoperationKind = .doctorFix; vended via the newBrewMutatingCommandFactory.doctorFixCommand(arguments:)port (Live/Stub/Unimplemented impls updated).Doctor仓库is an app-scoped@Observable @MainActorport (mirrorsInstalledInventoryObserving/BrewInstalled包仓库), NOT a stateless one-shot — it holdsstate: LoadState<DoctorReport, any Error>+isRefreshingand exposesload(). Long-lived (injected once inBrewApp) so the report persists across leaving/returning to the Doctor tab.load()is stale-while-revalidate: an existing report stays on screen (isRefreshingflips on) while the re-check runs; only the first load shows.loading; a failed refresh keeps the prior report (logs). Runsbrew doctorread-only (via the command center); a non-zero exit means warnings were found, not a failure, so the exit code is ignored and both stdout+stderr are parsed. Concurrentload()s coalesce onto one in-flightTask.- 关注-up (still open): migrate install/upgrade/uninstall display strings to read from the command rather than re-synthesizing in
materialize, now that maintenance ops carrydisplayCommand.
DoctorIssueis modeled as an ordered list of typed blocks (Sources/BrewCore/Models/DoctorReport.swift):severity,blocks: [DoctorBlock](with.prose/.command/.data/.linkcontent), per-blockcaption+precededByBlankLine, andrawBodyas the verbatim fallback.- Block classification is by first member, not per-line allowlist (per the addendum). A colon-terminated un-indented line opens a pending block; its first indented member decides whether the whole block is
.command/.data/.link. The allowlist is consulted once per block (first-member command test) plus as a.prosefallback for stray commands under prose. - Run Fix is intentionally narrow.
DoctorBlock.isRunnableis true only for a single-step, non-adminbrewcommand (multi-step andsudoblocks are copy-only). CommandBlockView(Sources/BrewUIComponents/Views/CommandBlockView.swift) replacesPackageDetailCommandConsole— same single-command API plus acommands: [String]init that renders a numbered list with one "Copy all". Used by Installed (upgrade + uninstall), Discover (install), and Doctor (per fix sequence). The old name is gone.brew doctorruns throughBrewCommandCenterviaBrewOperationKind.doctorRead+ aDoctorReadCommandactor (Sources/BrewCLI/).BrewDoctor仓库now takescommandCenter: any BrewCommandCenterand submits a stable maintenance id (token "doctor",displayCommand "brew doctor") on everyload()— the bottom console picks up the pill + streams output from the existingBrewCommandJobs仓库projection, and re-submits on the same id update the existingCommandJobinstead of spawning new pills.DoctorReadCommandis an actor because it owns mutablecapturedOutputmutated byrun(in:)and read by the repo after submit returns;nonisolated let operationKindsatisfies theBrewMutatingCommandrequirement. This loosens the center's "mutating only" framing — documented at the enum case. The serial queue is shared so doctor reads queue behind active mutations; acceptable.
DoctorSeverity.infodoes not exist. The enum iscaution / danger / unsupportedonly. Tier 1 is unreachable from anyWarning:block in realbrew doctoroutput: Homebrew'ssupport_tier_message(Ruby) starts withreturn if tier.to_s == "1", so a Tier 1 configuration produces no callout text at all. "Tier 1" means "fully supported, no editorial needed" — brew doesn't print anything about it. The parser's regex is intentionally narrowed to/This is a Tier ([23]) configuration:/to make this guarantee visible at the source.- Why this is a source-level guarantee, not a fixture-coverage gap: when the
.ai/plans/brew-doctor-console.txtfixture (which is the "all possible warnings" sample) produces no Tier 1 callouts, the first instinct is "fixture is incomplete." It isn't — there is no such callout anywhere in brew's source to capture. Don't re-add.infothinking the next fixture expansion will exercise it. If anyone wants a Tier 1-style "info" affordance in the UI, it has to come from a different source (e.g. anadd_info/ohaicapture frombrew config), not the doctor warning stream. .unsupportedis still real.support_tier_messagedoes emit "This is an Unsupported configuration:" for theunsupportedslug (prerelease / outdated-macOS paths incheck_for_unsupported_macosresolve there). The fixture happens not to include such a case; the unit testunsupported configuration trumps any tierexercises the parser branch.
- The problem. A GUI process launched from Finder/Dock/launchd inherits a stripped environment: missing
PATHentries andHOMEBREW_*vars thatbrew shellenvinstalls into the user's profile files (~/.zprofile). Runningbrew config/brew doctoragainst that stripped env produced output that didn't match what the user sees in Terminal, which destroys trust in the diagnostics. - The fix. All brew invocations now route through
LoginShellBrewCommandRunner(Sources/BrewCLI/), which rewritesrun(executableURL: brew, arguments: [...])into<login-shell> -l -i -c "<brew> <quoted args>". The login shell is resolved byLoginShellResolverfromgetpwuid(getuid())->pw_shell(Directory Services) with a/bin/zshfallback.$SHELLis intentionally not consulted — it can be stale or absent in a GUI launch. - Why
-lAND-i.-lsources.zprofile/.bash_profile(Homebrew's documented install writesbrew shellenvto.zprofile).-iadditionally sources interactive rc files (.zshrc/.bashrc) so users who put their brew setup in those files also get parity. The tradeoff is real and accepted: interactive rc files may print banners or expect a TTY (we run with/dev/nullstdin, so any TTY-dependent code in rc files will surface as warnings on stderr). If we ever see that as a meaningful noise source for users, the dial is-lonly. - Single spawning route. Quoting is handled by
LoginShellBrewCommandRunner.singleQuoted(POSIX'…'with'\''for embedded apostrophes), so package names and flags survive the shell parse intact. The three productionlive()factories (BrewCommandExecutionContext.live(),BrewConfig仓库.live(),BrewInstalled包仓库.live()) all now constructLoginShellBrewCommandRunner(). Tests still useBrewCommandService()directly for raw-process plumbing tests — that's correct; the shell wrap is a live-wiring concern. - Out of scope (tracked separately).
brew.envfile parsing andBrewEnvFileLocatorassume brew sees the same world the user does, which they now do — but parser/locator changes are their own work.
BrewAccessibilityIDis the single source of truth for testable element identity, superseding the never-createdUtilities/AccessibilityIdentifiers.swiftnoted in the 2026-03-01 entry. It is a dependency-free SwiftPM target linked by both the app and theBrewUITeststarget (Homebrew.xcodeproj→packageProductDependencieson each), so an identifier string is spelled exactly once, inAXID.rawValue. Views attach identity with.axid(_:)(Sources/BrewUIComponents/Views/View+AXID.swift), neveraccessibilityIdentifierwith a literal.Tests/BrewAccessibilityIDTestspins the wire format so drift breaks a unit test rather than a UI test.- Parameterised cases carry the package token (
installedRow(token:),discoverRow(token:)) — the token is the Homebrew name/token thatHomebrewPackageID.nameyields, so rows are addressable without label or index matching. .searchablefields cannot carry a custom accessibility identifier. SwiftUI injects the field into the window toolbar; an.axidat the call site lands on the modified content and overwrites the screen's own identifier.AXID.installed搜索Field/.discover搜索Fieldtherefore exist but are unattached — queryapp.searchFieldsuntil the field is a custom view.- Two process-boundary seams, both public and both inert in production:
URLSessionBrewAPIClient.stubbed(protocolClasses:baseURL:)sets protocol classes on one ephemeral session's configuration (neverURLProtocol.registerClass, which would also capture.shared), andBrewCommandExecutionContext.uiTesting(brewURL:)uses the realBrewCommandServicewithBrewExecutableLocator(overrideURL:)and deliberately noLoginShellBrewCommandRunner— wrapping a fake brew in the user's login shell would re-introduce dotfile dependence (see 2026-06-23). BrewApp.init()branches once, onBrewUITestingLaunchConfiguration.current(), which returnsnilunless the-uiTestinglaunch argument is present. Both seams are a singleguardaway from the untouched.live()wiring; being in UI-test mode is never threaded further into the app.
- The suite mocks two process boundaries and nothing else. Every layer of our own code runs for real: the real
BrewCommandServicespawns a real subprocess and drains real pipes; the realURLSessionBrewAPIClientbuilds requests, negotiates ETag/304, decodes and caches. That is what makes error cases worth writing — a 500 becomesBrewAPIClientError.httpStatusthrough the actual client, not a stubbed error value. - One fixture tree feeds both seams.
FakeBrew.install(scenario:)writes a per-run temp directory containing<scenario>/brew(fake-brew fixtures) and<scenario>/http(response bodies), and hands the app the paths via launch environment. The HTTP responder lives in the app (BrewUITestingStubURLProtocol), not inBrewUITests: aURLProtocolregistered in the test target runs in the test process and would never see the app's traffic. PR 2's plan offered both wirings; this is the one chosen. - HTTP fixtures are addressed by request path,
/→_(/api/formula.json→api_formula.json), with optional sibling.statusand.etagfiles. A matchingIf-None-Matchgets a real 304, so the client's conditional-request branch is exercised rather than stubbed away. - fake-brew is a lookup table, not a
caseover subcommands. Files are<argv joined by _>.stdout/.stderr/.exitcode/.next-info. Adding a command to a scenario is adding a file..next-infois the one piece of state: on a successful mutating run it becomes the answer to every laterbrew info, which is what lets an uninstall actually remove the row (the repository force-refreshes off the command center's running→idle transition and must see a changed world). - PR 1 left two repositories outside the shell seam.
BrewInstalled包仓库.live()andBrewConfig仓库.live()each constructed their ownLoginShellBrewCommandRunner+BrewExecutableLocator, so the Installed list and Configuration tab ran real brew under-uiTesting. Both now take aBrewCommandExecutionContext, andBrewAppbuilds exactly one context for the whole process.live()still means the same wiring it always did. BrewCommandExecutionContext.uiTesting(brewURL:)now takes an optional.nilinstalls a locator that always throwsBrewLookupError.executableNotFound— that is how thebrewNotFoundscenario is expressed, and it also closes a hole: a-uiTestinglaunch that named no fake used to fall through to.live()and could have driven the developer's real Homebrew.- UI-test runs are state-isolated even though PR 2's plan defers "state isolation" to PR 3.
CatalogueCache/DiscoverAnalyticsCacheare given the run's scratch container and aUITesting.-prefixedUserDefaultsnamespace (cleared at launch). Without this, run N's fixture catalogue decides run N+1's behaviour and the fixtures overwrite the real app's Application Support cache on the same machine. PR 3's isolation is about real-brew/real-network runs; this is the minimum that makes "deterministic across ≥20 runs" mean anything. - The console's expand/collapse state is read from the toggle button's accessibility label ("Show console" when collapsed, "Hide console" when expanded — exactly one is mounted). The app auto-expands the panel by itself when a command starts, so a read-then-decide-then-click sequence races it;
ConsoleScreen.setExpanded(_:)waits for the state it wants before concluding it needs to click. AXIDgained the shared failure chrome (errorState/errorRetryButtononAsyncContentView's error branch,brewNotFoundStateon Configuration). It is screen-agnostic by design — every loadable surface renders the same view — so screens scope the query to their own root to say which surface failed.- Upgrades rows got their own identity (
upgradesList/upgradesRow(token:)) rather than reusing the Installed row ids. The same package is legitimately in both lists at once, and sharing ids would let an Upgrades assertion be satisfied by an Installed row.
- The failure. The obvious design — the test runner writes a fixture tree, the app reads it — needs one directory two different processes are both allowed to use, and on this machine there isn't one.
FileManager.temporaryDirectoryresolves per process, so the runner's temp dir gave the appEPERMwhen it tried to spawn the fakebrewfrom it;/private/tmpthen gave the runnerEPERMcreating directories. Neither cause was ever established (the runner hasENABLE_APP_SANDBOX = NOand Xcode'sXCTRunner.apptemplate carries no entitlements), and two rounds of guessing at the OS policy cost more than the redesign. - The fix is to delete the question.
BrewUITestContract(a dependency-free SwiftPM target linked by both the app andBrewUITests, exactly likeBrewAccessibilityID) carriesBrewUITestingFixturePayload— a bag of relative paths to bytes plus which one is the executable — JSON, raw-DEFLATE, base64, passed in one launch-environment variable.BrewUITestingFixtureInstallerwrites it into the app's own temp directory at launch, chmods the fake, andsetenvs the resulting paths so the stubURLProtocol(which runs onURLSessionthreads) and the fakebrewsubprocess (which inherits its environment) can both find them. One process creates the files, runs them, and owns them. - The payload is budgeted, not unbounded.
BrewApp.launchrejects anything over 512 KB; the tree compresses ~20× because fixture JSON is highly repetitive, so the large-inventory scenario lands well under it. If a scenario ever breaches the budget, shrink the scenario — the environment is shared with everything else the launch needs. XCUIApplication.launch()does not reliably foreground the app on macOS. The runner keeps focus, and a backgrounded app's window has an empty accessibility tree — so every element query fails with "does not exist" and the only cure is clicking the Dock icon.BrewApp.launchcallsactivate()and waits for.runningForeground. This is load-bearing, not cosmetic.- Element-not-found failures carry a diagnosis.
BrewUITestDiagnosticsreports whether the app is running, foregrounded, has a window, and which identifiers are actually in the tree. "Expected installed.screen to exist within 60s" is true and explains nothing; distinguishing "never opened a window" from "wrong identifier" is the difference between a five-minute fix and a day. - Assertions gated on a subprocess use the command timeout, not the render timeout.
DoctorReport.placeholder.isHealthyisfalse, so whilebrew doctoris in flight the Doctor screen shows the redacted issues skeleton and the healthy text does not exist yet. Same for Configuration, whose cards only exist oncebrew confighas been parsed.