Skip to content

Store the String Catalogs the way Xcode serializes them, so an IDE build leaves the tree clean - #135

Merged
kyleve merged 4 commits into
mainfrom
normalize-xcstrings-catalogs
Jul 25, 2026
Merged

Store the String Catalogs the way Xcode serializes them, so an IDE build leaves the tree clean#135
kyleve merged 4 commits into
mainfrom
normalize-xcstrings-catalogs

Conversation

@kyleve

@kyleve kyleve commented Jul 25, 2026

Copy link
Copy Markdown
Owner

The problem

Building the Where app in Xcode rewrote Where/WhereUI/Sources/Resources/Localizable.xcstrings with ~5,500 changed lines, leaving the working tree dirty after every IDE build. That makes it easy to commit thousands of lines of noise by accident, and easy to miss a real change in git status.

The diff was almost entirely serialization: the checked-in files wrote "sourceLanguage": "en", Xcode writes "sourceLanguage" : "en" — on every line of the file.

The cause

Xcode rewrites a .xcstrings in place during an IDE build whenever string extraction finds a key the catalog doesn't have yet — and when it does, it writes the whole file with its own serializer. That serializer is Foundation's pretty-printed JSON: two-space indent, a space before the colon, keys sorted by code point, no trailing newline.

Four of the seven catalogs were written by the String Catalog symbol-generation migration in #124 with a script's json.dump instead of by Xcode, so they parsed identically but differed on every line. Extraction also had four keys to add, which is what triggered the rewrite in the first place — so the one real addition arrived buried under a full reserialization.

Two things worth knowing, since neither is obvious:

  • xcodebuild never does this write-back — only the IDE does. So CI stayed green and clean while local builds churned, and "build it from the CLI and see" doesn't reproduce the bug.
  • xcstringstool sync is not the fix. It's the staleness half of the IDE's pass; run standalone it deletes every value-less auto-extracted entry, including the 26 isCommentAutoGenerated ones in WhereIntents that have been checked in and surviving IDE builds since Adopt String Catalog symbol generation across the app #124. Only the IDE's add half puts them back.

What changed

  • All seven catalogs are now byte-identical to what Xcode writes (including dropping the trailing newline — Xcode's serializer doesn't emit one). Five changed formatting only; the three that were already in Xcode's style (WhereCore, RegionKit, LifecycleKit) only lost that newline.

  • The four auto-extracted entries Xcode kept re-adding are committed, as the empty entries it writes them as. They come from literals extraction picks up:

    Entry Source
    "" Marker("", coordinate:) in RecordedPointsMap
    %lld Text("\(group.outlineCount)") in RegionMapView, Text("\(day.dayOfMonth)") in CalendarContentView
    Log today here a Button literal in an IntentSnippets #Preview
    App content a LifecycleContainer #Preview (LifecycleKit)
  • ./xcstrings normalizes catalogs in Xcode's serialization, with --lint to check. It touches formatting only — content belongs to Xcode and the translators, and it re-parses its own output and refuses to write if anything moved. Reproducing Foundation byte-for-byte needs one deviation: its .sortedKeys compares case-insensitively while Xcode sorts by code point, which shows up the moment a catalog holds an auto-extracted capitalized literal next to a lowercase key.

  • CI lints it, as a step in the existing format job (branch protection pins the two check names, so this rides along rather than adding a job).

  • Docs: the invariant and the script in AGENTS.md; what the auto-extracted entries are and how to actually get rid of one in Where/AGENTS.md.

Verification

  • No content changed anywhere. Comparing parsed JSON against HEAD: no key, value, comment, state, or extractionState differs; the only deltas are the four added entries.
  • Idempotence. After a wiped DerivedData and a cold tuist build Where (which builds the widgets, share extension, and every catalog-owning package target), git status is clean. Repeated after the full test scheme — also clean. The IDE's trigger is gone too: reading the .stringsdata that build emitted, every extracted key is already in its catalog for all seven, so the IDE has nothing to add and therefore nothing to reserialize.
  • Cross-checked against Xcode itself. The entries were derived from build .stringsdata, and the result is byte-identical to what Xcode had written into WhereUI's (4,200-line) and LifecycleKit's catalogs on its own — so this is Xcode's converged output, not an imitation of it.
  • ./swiftformat --lint clean (0/563 files). Full Stuff-iOS-Tests scheme green on iPhone 17 / iOS 27.0 — 1,380 tests, 0 failures, including WhereFormatTests.generatedSymbolsResolveToCatalogValues.

Deliberately not fixed

The four source literals behind those entries. Removing an entry for good means removing the literal, and a couple are worth fixing on their own terms — the interpolated Text("\(Int)") sites skip WhereFormat's number styling, the preview Button hardcodes copy the snippet.logTodayHere symbol already owns, and the empty Marker("") label says nothing to VoiceOver. That's app-source work rather than serialization, so it's filed in Where/TODOs.md instead of done here.

kyleve added 4 commits July 24, 2026 18:23
Xcode rewrites a `.xcstrings` in place during an IDE build whenever string
extraction finds a key the catalog doesn't have yet, and it writes the *whole*
file with its own serializer. That serializer is Foundation's pretty-printed
JSON — two-space indent, `"key" : value` with a space before the colon, keys
sorted by code point, no trailing newline — so a catalog written by anything
else parses identically but differs on every line, and the next IDE build buries
the one entry it actually added under thousands of lines of whitespace churn.

`./xcstrings` rewrites catalogs in that exact serialization, and `--lint`
reports any that have drifted. It deliberately touches formatting only: which
keys exist, their values, comments, and extraction state are Xcode's and the
translators', so a normalization pass can never add, drop, or edit an entry (it
re-parses its own output and refuses to write if the content moved).

Reproducing Foundation byte-for-byte needs one deviation: its `.sortedKeys`
compares case-insensitively while Xcode sorts by code point, which shows up as
soon as a catalog holds an auto-extracted capitalized literal next to a
lowercase key. Keys are therefore sorted here and only leaf values go through
JSONSerialization, which keeps string escaping identical. Verified by
round-tripping two catalogs Xcode itself had rewritten (WhereUI's 4,200-line
one included) to byte-identical output.
Every catalog is now byte-identical to Xcode's own output, so an IDE build has
nothing to rewrite and leaves the working tree clean. Building the Where app
used to reserialize WhereUI's catalog into ~5,500 changed lines — noise that
hides real changes in `git status` and is easy to commit by accident. The four
catalogs adopted in #124 were written by that migration's script rather than by
Xcode (`"key": value`, trailing newline); the other three were already in
Xcode's style apart from the trailing newline.

Two things had to be true, not just the formatting:

- **Formatting** — all seven now round-trip through `./xcstrings` unchanged.
- **Content** — the reason Xcode rewrote anything at all is that extraction
  found four keys the catalogs lacked: `""` (`Marker("", …)` in
  RecordedPointsMap), `%lld` (`Text("\(count)")` in RegionMapView and
  CalendarContentView), `Log today here` (a `#Preview` literal in
  IntentSnippets), and `App content` (a `#Preview` literal in
  LifecycleContainer). Xcode re-adds them on every build, so they're committed
  as the empty auto-extracted entries it writes. The source literals behind them
  are left alone here; they're noted in Where/AGENTS.md as the way to remove an
  entry for good.

The entries were derived from the `.stringsdata` a clean `tuist build Where`
produced, then cross-checked: the result is byte-identical to what Xcode wrote
into WhereUI's and LifecycleKit's catalogs on its own. No key, value, comment,
state, or `extractionState` changed anywhere — verified by comparing the parsed
JSON against HEAD.

CI lints catalogs in the existing format job (branch protection pins the check
names, so this rides along rather than adding a job).
They're the reason an IDE build had a write-back to do; the entries themselves
are Xcode's and stay, so the follow-up is at the source. Filed rather than fixed
here to keep this change to serialization.
Subject/verb agreement in both summaries, and flush stdout first so the lint
count prints below the files it's counting rather than above them.
kyleve added a commit that referenced this pull request Jul 25, 2026
Xcode doesn't write a trailing newline in a String Catalog, and #135 adds
an ./xcstrings --lint CI step that enforces its exact serialization. This
branch moved the catalog from Shared/LifecycleKit to Shared/LifecycleKitUI,
so the file exists only here — merging main won't normalize it, and it
would have been the one catalog failing the new lint.

Content is byte-identical (verified by parsing both sides); only the
trailing newline changed. The rest of this branch's catalogs are still
un-normalized and will pick that up from main when #135 lands.
@kyleve
kyleve merged commit 032c113 into main Jul 25, 2026
2 checks passed
kyleve added a commit that referenced this pull request Jul 25, 2026
Two conflicts, both from files this branch had already moved or appended:

- The LifecycleKitUI catalog: git followed the LifecycleKit ->
  LifecycleKitUI rename and tried to merge #135's changes to the old
  path into the new one, splicing main's `failure.launch.retry` header
  onto this branch's gate entry. Restored this branch's version, which
  is correct and already normalized: `failure.launch.retry` died with
  retry in #121, and `App content` isn't extracted here because the
  rewritten container preview uses `Text(verbatim:)`.
- Where/TODOs.md: #135 filed a P2 about the four auto-extracted source
  literals while this branch filed two launch observations. Kept all
  three.

`./xcstrings --lint` (the new CI step) reports all 7 catalogs matching
Xcode's serialization; the other six normalized by coming across in the
merge. Full Stuff-iOS-Tests scheme green; ./swiftformat --lint clean.
kyleve pushed a commit that referenced this pull request Jul 26, 2026
Weekly read-only audit refresh. Every open July 19 finding was re-verified against current source, and the week's new surface was reviewed: the Periscope migration (#94), Settings drill-in (#111), developer HUD (#115), navigation restructure (#119), log-viewer tooling (#107), String Catalog symbols (#124), the Gregorian-calendar pass (`fe99dde`), preview coverage (`52f0136`), Bumper Bowling (#127), and catalog serialization (#135). No source changed.

## Structural changes to the audit

- **`LogKit` and `LogViewerUI` are gone** — Periscope replaced them, so their sections are removed and the inventory drops to **14 SPM library targets**. Counts refreshed to ~359 source / ~198 test files (WhereUI 84 → 113, WhereCore 70 → 87, PeriscopeTools 15 → 24, RegionKit 9 → 13).
- New section for **Bumper Bowling**, the repo-owned architecture lint that landed this week.

## Two new high findings

1. **`where.gregorian_calendar` enforces nothing.** The rule filters `MemberAccessExprSyntax` on `base == "Calendar"`, so it matches a spelled-out `Calendar.current` but not the implicit-member form (`calendar: Calendar = .current`, `in: .current`) — which, after the Gregorian call-site pass, is the only form left in the tree. CI hard-gates `bumper lint` at `severity: .error` and is green, which confirms it finds nothing while seven production sites drift. `.bumper/RULES.md` also claims three calendar violations and some preview-coverage violations are "left visible during this bootstrap"; both claims are stale.
2. **`CalendarDay.displayDate` resolves through `Calendar.current`.** `startOfDay(in:)` interprets the day's Gregorian Y-M-D as *that* calendar's components, so on a Buddhist-era device every day label flowing through the helper — relabel, logged days, resolution details, the region drill-in — renders a date ~543 years off. `DateRangeFormatting.abbreviated` and `PresenceTimeline.stints` also default to `.current`.

The three highs carried from July 19 are all still open: daily-summary staleness, the WhereUI tracking-toggle race, and the LifecycleKit terminal-phase race (now described precisely — `runSteps`' cancellation check sits at the *top* of the loop, so a cancel during the **final** step's `minVisible` hold is never observed before it returns `.completed`).

## Retired as verified fixed

The Where app `README.md`; the cold-launch reason misclassification (`.undetermined` + `completedStepIDs`); `#Preview` coverage across WhereUI/WhereWidgets; the hand-maintained string-key facades; the in-app SwiftData browser; the widget post-midnight snapshot policy (now documented as intentional degradation); and `SharedItemLoader` warning logs.

## TODOs.md

**`Where/TODOs.md`** — retires the `WhereModel` break-up (now 185 lines of process-lifetime state), the SwiftData browser, the `@_spi(Testing)` migration, and two items whose subject no longer exists (the `WhereModel` "controller" guard, hoisting `Calendar.current` onto the controller). Files this week's findings, pins the exact remaining `waitForOneRunloop` call sites, and promotes soft-deleting untracked regions now that the shipped picker reaches the hard delete.

**`Shared/Periscope/TODOs.md`** — adds a P1 (the orphan sweep closing spans whose `survivesRelaunch` policy couldn't be decoded, silently) and three P2s (`LogInspectorModel` re-querying full subtrees per commit, `SpanHistoryModel` swallowing an `SpanEnded` decode failure, and a test pinning open-span containment). Sharpens the two density items with the user-visible consequence.

**Root `AGENTS.md`** — its description of the audit cited the LogKit/LogViewerUI sections as an example of stale content surviving a refresh; this refresh removed them, so the caveat is rewritten to keep the useful half without an example that goes stale again.

## Verification

Static analysis only — the Cloud agent runs Linux, so no `tuist test`, `bumper lint`, or simulator runs. CI status on `main` was read via `gh` (green) which is what lets the "the Gregorian rule finds nothing" conclusion stand. Changes are markdown-only, so `./swiftformat --lint` and `./xcstrings --lint` are unaffected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant