Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
94edb58
Add String Catalog localization foundations to BrewUIComponents
jacksonfdam Sep 9, 2026
8445c1d
Localize BrewUIComponents copy and move relative time to catalogue pl…
jacksonfdam Sep 9, 2026
accfb57
Localize the Doctor surface into Brazilian Portuguese
jacksonfdam Sep 9, 2026
8963e56
Add comments and fix ordering/style in Doctor Localizable.xcstrings
jacksonfdam Sep 9, 2026
b2837d0
Reorder Doctor string catalogue to case-insensitive alphabetical order
jacksonfdam Sep 9, 2026
e65b5ee
Reformat Doctor string catalog to match Xcode pretty-print style
jacksonfdam Sep 9, 2026
d364aff
Fail the build on an untranslated string catalogue entry
jacksonfdam Sep 9, 2026
87cb984
Match the Doctor healthy state by identifier instead of copy
jacksonfdam Sep 9, 2026
f46a79c
Declare pt-BR as a known region for the app target
jacksonfdam Sep 9, 2026
0c3940b
Note unverified accessibility lookup in DoctorScreen
jacksonfdam Sep 9, 2026
4959724
Declare app-level en/pt-BR localizations via InfoPlist.strings
jacksonfdam Sep 9, 2026
4118876
Document the localization pattern and record the decisions
jacksonfdam Sep 9, 2026
0f9f49f
Localize the command block's header and copy button
jacksonfdam Sep 9, 2026
9410e7d
Give NoteCallout a resource route and a verbatim one
jacksonfdam Sep 9, 2026
a051a98
Interpolate the severity resource instead of resolving it first
jacksonfdam Sep 9, 2026
7f225fe
Say why the package kind badges stay English
jacksonfdam Sep 9, 2026
89954f5
Resolve the Doctor healthy state through BrewUIElement
jacksonfdam Sep 9, 2026
82efaa0
Check catalogue consistency instead of imposing a language
jacksonfdam Sep 9, 2026
b2fefd5
Ship the module bundle accessors as SPI, not API
jacksonfdam Sep 9, 2026
824b83a
Use the written form of "para o" in the healthy state
jacksonfdam Sep 9, 2026
10206b6
Name the word-order limit in LastUpdatedLabel
jacksonfdam Sep 9, 2026
011aa40
Record the rulings the final review settled
jacksonfdam Sep 9, 2026
83f3be4
Merge branch 'main' into feat/localization-ptbr
jacksonfdam Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .ai/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,102 @@

- **The app is unsandboxed, so `~/Library` is shared ground.** There is no container to namespace writes, and a folder named `Brew` collides with anything else of that name and reads as the `brew` CLI's. Every store resolves its own path under `sh.brew.app`.
- **Which root follows from whether the contents can be rebuilt.** The catalogue and Discover analytics caches hold ETag-validated HTTP responses, so they sit in `Caches`: purgeable, out of Time Machine, one refetch to replace. Crash reports sit in `Application Support` because a pending report cannot be regenerated. Do not merge the two back into one root.

## 2026-09-09 — Localization (String Catalogs, pt-BR first)

- **Mechanism:** one `Resources/Localizable.xcstrings` per target that owns user-facing copy, with
`defaultLocalization: "en"` in `Package.swift`. Not a central localization module: Xcode's key
extraction is per-target, and a shared catalogue would degrade into a hand-maintained key enum.
- **Boundary type:** strings crossing a boundary (ViewModel → View, module → module) are
`LocalizedStringResource`, not `String`. It carries key *and* bundle and resolves at display time.
`LastUpdatedLabel(lead:)` is the reason this matters — the lead phrase belongs to the *calling*
module's catalogue.
- **The bundle trap:** `String(localized:)` and `LocalizedStringResource(_:)` default to
`Bundle.main`, which in a SwiftPM module is the app, not where the catalogue was processed.
Resolution against the wrong bundle does not throw — it returns the key, so the string silently
stays English. Each localized module has an internal `init(<module>:)` that supplies the bundle;
every user-facing string in that module goes through it.
- **The two build systems disagree, and that dictates where a test can live.** `xcrun swift build`
/ `swift test` copies `Localizable.xcstrings` into the module bundle raw — SwiftPM's native
builder runs no Apple resource compiler at all (`Media.xcassets` is copied raw too, same
reasoning as the 2026-08-30 contrast audit). So under `swift test` a catalogue lookup always
returns its key, and an assertion on *resolved* Portuguese prose can never pass there. `xcodebuild`
is the one that actually compiles the catalogue, into `<lang>.lproj/Localizable.strings`. CI runs
both, and `Brew-Unit.xctestplan` contains only the `BrewTests` Xcode target — everything under
`Tests/` runs exclusively under `swift test`. Hence the split: suites under `Tests/` assert
catalogue keys and bundle bindings (e.g. `Tests/BrewUIComponentsTests/LocalizationBundleTests.swift`,
`StringCatalogueCompletenessTests`, which reads the `.xcstrings` JSON directly and so is
build-system-agnostic); `BrewTests/LocalizationResolutionTests.swift` is the only place a resolved
Portuguese string is asserted. A Portuguese assertion added under `Tests/` will pass locally under
`xcodebuild` and fail in CI's `swift test` leg.
- **The app bundle has to advertise its own languages.** Adding `pt-BR` to `knownRegions` in
`Package.swift`/the Xcode project only puts `pt-BR.lproj` inside the nested `BrewKit_*.bundle`s.
macOS decides which languages an app offers in System Settings → General → Language & Region →
Applications by reading the *app bundle's own* top-level `.lproj` folders and
`CFBundleLocalizations` — and this app has no in-app language picker by choice, so without this
the translation would ship built but unreachable. Fixed with
`Homebrew/en.lproj/InfoPlist.strings` and `Homebrew/pt-BR.lproj/InfoPlist.strings`.
`INFOPLIST_KEY_CFBundleLocalizations` does **not** work — Xcode's build setting synthesis maps
only an allowlist of `INFOPLIST_KEY_*` names into `Info.plist`, and that key isn't on it. Don't
retry that route; ship the `.lproj` files.
- **Two strings differing only in punctuation collide.** Xcode's `GenerateStringSymbols` build
phase strips punctuation when deriving an identifier, so `"Re-checking"` (a VoiceOver label) and
`"Re-checking…"` (the header subtitle) produced the same generated symbol and broke `xcodebuild`
outright — not a warning, a build failure. Fixed with `"generatesSymbol": false` on both entries,
safe because nothing references the generated symbols. Any new string that's an existing one plus
trailing punctuation needs the same flag.
- **Catalogue house style, so the two files stop fighting each other:** keys are the English source
text; entries are ordered case-insensitively; the JSON follows Xcode's own pretty-printer (a space
before every colon, a simple `stringUnit` object collapsed onto one line) because Xcode rewrites
the file in that style on save and a hand-formatted diff would just get re-diffed into noise on
the next edit made through the editor. Every entry carries a `comment` — a translator working in
Xcode's String Catalog editor sees only the string and its comment, never the surrounding view.
- **81 existing call sites are still bundle-less** — `localized:` appears 81 times across `Sources`
in `BrewFeatureInstalled` (46), `BrewFeatureDiscover` (27), `BrewFeatureConfig` (3),
`BrewServicesTestSupport` (2), `BrewCore` (2), `BrewRepositories` (1), none passing `bundle:`.
Harmless while those targets have no catalogue — the call returns its English key, which is
today's behaviour. Fixing them, plus the BrewUILint rule that would enforce the argument, is
specified in `.ai/plans/2026-09-10-localization-bundle-followup.md` — a **local working document
only**, since `.ai/plans/` is gitignored and nobody else can open it from the repository; treat
its existence as this note, not as that file. Deliberately deferred: it touches six targets, and
this pull request is scoped to two.
- **Never localized:** copy that echoes `brew` output verbatim (`DoctorCopy.warningPreamble`),
copyable command text (`CONVENTIONS.md` — Command transparency), SF Symbol names, `AXID` values.
- **No in-app language picker.** macOS already offers per-app language selection; a second source
of truth would have to be persisted and defended against the system's.
- **Plurals** go through catalogue plural variations (`%lld minutes ago`), never a
singular/plural ternary in Swift — plural rules are per-language.
- **Completeness is enforced,** not reviewed: `StringCatalogueCompletenessTests` reads every
`.xcstrings` in `Sources/` plus the app target's, and fails when a catalogue translates *some* of
its keys into a language but not all of them. Deliberately not "every string must have `pt-BR`":
that would put a standing translation obligation on maintainers who never agreed to one, which is
project policy and not this repository's to decide. A catalogue nobody has begun translating
passes; a half-translated one — the failure that is invisible at runtime — does not.
- **A component that renders caller-supplied copy takes a `LocalizedStringResource`,** and offers a
`verbatim:` initialiser for text that must not be translated (`NoteCallout`, for `brew`'s own
preamble and for a package's caveats). Migrated in this branch rather than in PRs 2..n, so later
modules plug in without changing public signatures again: `CommandBlockView.title`,
`BrewActionButton`'s title/confirmation/help, `NoteCallout`. Still `String` and deliberately
deferred: `CommandBlockView.summaryText`, `PackageDetailSectionHeading.title` and
`LoadState`'s failure payload — every one of those has a call site fed by a view-model-computed
`String`, so they move with their own module's PR.
- **The module bundle accessors are `@_spi(BrewUITesting) public`,** not `public`. They exist only so
the Xcode `BrewTests` target can reach a bundle that is otherwise internal, `.periphery.yml` sets
`retain_public: false`, and the staged plan adds one per localized module. `BrewTests` imports them
with `@_spi(BrewUITesting) import`; dropping that attribute is a hard compile error, which is the
point.
- **`BrewTests` needs `@MainActor` on any test that reaches into a UI module.**
`BrewUIComponents` and `BrewFeatureDoctor` set `.defaultIsolation(MainActor.self)` in
`Package.swift`; the Xcode `BrewTests` target sets no default isolation, so a nonisolated test
calling into either module is a hard Swift 6 compile error. Annotate the test — never loosen the
module's isolation default to work around it.
- **Status:** `BrewUIComponents` and `BrewFeatureDoctor` are translated. The `Homebrew/` app target
has an empty catalogue plus the two `InfoPlist.strings` files, so it already sits inside the
completeness guard. Every other target awaits its own pull request.
- **Known gap:** `LoadState`'s failure payload is still `String`, produced by
`OperationFailure.userFacingMessage` in `BrewCore` and consumed by every feature module. Error
copy is therefore still English everywhere. Migrating it touches all five feature modules at
once and belongs in its own PR.
- **`Logs` takes the same namespace**, so the self-upgrade transcript is `~/Library/Logs/sh.brew.app/self-upgrade.log`. It was `Logs/Homebrew` on the reasoning that the app's log belongs beside brew's own; that is the `brew` CLI's directory, and the rule above is what settles it. **The pattern is `<root>/sh.brew.app/…` for every root the app writes to** — `Caches`, `Application Support`, `Logs`, and anything added later.

## 2026-09-08 — The simulated upgrade is gone, and both outcomes share one alert
Expand Down
5 changes: 5 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ Guiding patterns:
- **Command center:** `BrewCommandCenter` (actor protocol; app default `SerialBrewCommandCenter`) — **serializes** mutating `brew` work, tracks **in-flight / failed** **operation** state (`BrewOperationID` + `BrewOperationPhase`) for UI across surfaces, and runs **small `BrewMutatingCommand` types** that call `BrewCommandRunning` + the brew locator. It does **not** own **read/parsing** of `brew list` / `brew info` output — that stays in **repositories**. Feature-scoped executors (e.g. upgrade helpers) should stay **thin** and be invoked **from** commands the center schedules, not as a second parallel pipeline.
- **Models:** Domain-only value types and relationships shared across layers. Keep UI/presentation helpers, transport decoding models, and infrastructure-state containers out of domain models.

## File organisation

- `Sources/<Target>/Resources/Localizable.xcstrings` — that target's String Catalog. Source
language English; `pt-BR` supported. See [`CONVENTIONS.md`](CONVENTIONS.md) — **Localization**.

## Command execution

Run Homebrew commands **asynchronously** via subprocess; support **cancellation**; **stream or preserve** stdout/stderr for transparency and logs. Always make the **exact command** visible to the user; treat **CLI text output as unstable** (tolerant parsing, fallbacks).
Expand Down
84 changes: 84 additions & 0 deletions BrewTests/LocalizationResolutionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//
// LocalizationResolutionTests.swift
// BrewTests
//

@_spi(BrewUITesting) import BrewFeatureDoctor
@_spi(BrewUITesting) import BrewUIComponents
import Foundation
import Testing

/// Asserts a translation actually comes back, which only `xcodebuild` can demonstrate: the SwiftPM
/// CLI copies `Localizable.xcstrings` into the module bundle raw, while `xcodebuild` compiles it to
/// `<lang>.lproj/Localizable.strings`. That is why this suite lives in the Xcode target — CI runs it
/// via test plan `Brew-Unit` — and not beside the rest of `BrewUIComponents`' tests under `Tests/`.
struct LocalizationResolutionTests {
private static func localized(_ resource: LocalizedStringResource, in identifier: String) -> String {
var resolved = resource
resolved.locale = Locale(identifier: identifier)
return String(localized: resolved)
}

@Test func `a module string resolves in both languages`() {
let retry = LocalizedStringResource("Retry", bundle: .atURL(Bundle.brewUIComponents.bundleURL))
#expect(
[Self.localized(retry, in: "en"), Self.localized(retry, in: "pt-BR")]
== ["Retry", "Tentar novamente"],
)
}

/// One representative count per unit. This is where plural variations are actually exercised:
/// the count comes from a `%lld` argument, and the catalogue picks `one` or `other` per language.
///
/// `@MainActor`: `BrewUIComponents` defaults every declaration to main-actor isolation
/// (`Package.swift`'s `.defaultIsolation(MainActor.self)`), so `RelativeTimeText.resource` is
/// main-actor-isolated too. `BrewTests` carries no such default, so the call needs an isolated
/// caller — unlike `Tests/BrewUIComponentsTests`, whose own target shares that same default.
@MainActor
@Test func `relative time resolves in English`() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let phrases = [0, 60, 5 * 60, 3600, 24 * 3600].map { secondsAgo in
Self.localized(
RelativeTimeText.resource(
for: now.addingTimeInterval(-TimeInterval(secondsAgo)),
relativeTo: now,
),
in: "en",
)
}
#expect(phrases == ["just now", "1 minute ago", "5 minutes ago", "1 hour ago", "1 day ago"])
}

/// Portuguese takes the singular below two, as English does here — but through catalogue plural
/// variations rather than a hand-written ternary, so a language with different rules stays right.
@MainActor
@Test func `relative time resolves in Portuguese`() {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let phrases = [0, 60, 5 * 60, 3600, 24 * 3600].map { secondsAgo in
Self.localized(
RelativeTimeText.resource(
for: now.addingTimeInterval(-TimeInterval(secondsAgo)),
relativeTo: now,
),
in: "pt-BR",
)
}
#expect(phrases == ["agora mesmo", "há 1 minuto", "há 5 minutos", "há 1 hora", "há 1 dia"])
}

/// A representative Doctor string per surface: the header subtitle and a severity label. The
/// completeness test guarantees every other key has a `pt-BR` entry; this one proves the Doctor
/// module's bundle wiring reaches it.
@Test func `doctor copy resolves in Portuguese`() {
let subtitle = LocalizedStringResource(
"Running brew doctor…",
bundle: .atURL(Bundle.brewFeatureDoctor.bundleURL),
)
let severity = LocalizedStringResource(
"Unsupported",
bundle: .atURL(Bundle.brewFeatureDoctor.bundleURL),
)
#expect([Self.localized(subtitle, in: "pt-BR"), Self.localized(severity, in: "pt-BR")]
== ["Executando brew doctor…", "Sem suporte"])
}
}
22 changes: 9 additions & 13 deletions BrewUITests/Screens/DoctorScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,25 +41,21 @@ struct DoctorScreen: Screen {
return self
}

/// `DoctorReport.placeholder` is not healthy, so this text exists only once `brew doctor` has run.
/// `DoctorReport.placeholder` is not healthy, so this element exists only once `brew doctor` has
/// run. Matched by identifier rather than copy so the assertion holds in every localization.
@discardableResult
func assertIsHealthy(
timeout: TimeInterval = BrewUITestTimeout.command,
file: StaticString = #filePath,
line: UInt = #line,
) -> Self {
let healthy = root.element.staticTexts["Your system is ready to brew"]
guard healthy.waitForExistence(timeout: timeout) else {
XCTFail(
"""
Expected Doctor to show the healthy state within \(timeout)s.
\(BrewUITestDiagnostics.report(for: app))
""",
file: file,
line: line,
)
return self
}
healthyState.waitToExist(timeout: timeout, file: file, line: line)
return self
}

/// Scoped to this screen's root, and resolved by ``BrewUIElement`` rather than a typed query, so
/// it holds whichever element type SwiftUI surfaces the healthy state's container as.
private var healthyState: BrewUIElement {
BrewUIElement(app, .doctorHealthyState, in: root.element)
}
}
Loading
Loading