| @@ -1,346 +0,0 @@ |
| 1 | | # DomainDig Release Roadmap |
| 2 | | |
| 3 | | Priority lens: **new user-facing features.** The inspection engine is already |
| 4 | | deep (DNS, DNSSEC, CAA, TLS, TLSA/DANE, email security incl. BIMI/MTA-STS, RDAP, |
| 5 | | ports, geolocation, subdomains, availability). The next several releases invest |
| 6 | | in *reach and surfacing* — getting that data onto more iOS surfaces and into more |
| 7 | | workflows — rather than adding raw protocol checks. |
| 8 | | |
| 9 | | Current version: `v5.0.2`. |
| 10 | | |
| 11 | | ## v4.4.1 Patch: Release Readiness — ✅ shipped |
| 12 | | |
| 13 | | - Consolidated Audit Mode onto the single `DomainDig/DomainDig/Audit*` |
| 14 | | implementation and retired the prototype files. |
| 15 | | - Aligned `AppVersion.current`, Xcode marketing version, and build number. |
| 16 | | - Included audit sessions in backup/restore counts, summaries, and merge behavior. |
| 17 | | - Removed the retired `DomainDigCLI` target and refreshed README/architecture docs. |
| 18 | | |
| 19 | | ## v4.5.0 Minor: Home Screen & Shortcuts Reach — ✅ shipped |
| 20 | | |
| 21 | | Goal: put DomainDig data and actions where the user already is. |
| 22 | | |
| 23 | | - **App Intents / Shortcuts** — `InspectDomainIntent`, `AddToWatchlistIntent`, and |
| 24 | | `RunSweepIntent`, exposed via `DomainDigShortcuts` for Shortcuts, Spotlight, the |
| 25 | | Action button, and Siri. |
| 26 | | - **`domaindig://` deep links** — `inspect`, `watch`, `domain` (detail), and |
| 27 | | `sweep`, routed in `RootTabView`. |
| 28 | | - **WidgetKit portfolio widget** (Home Screen small/medium/large) — per-domain |
| 29 | | health, certificate countdowns, and portfolio health counts, shared from the app |
| 30 | | via an App Group; tapping a domain deep-links into its detail. |
| 31 | | |
| 32 | | Deferred to a later minor: **Lock Screen accessory widget families** and a richer |
| 33 | | per-widget "last change" indicator. |
| 34 | | |
| 35 | | ## v4.6.0 Minor: Alerts, Glances & iPad — ✅ shipped |
| 36 | | |
| 37 | | Goal: make monitoring and results feel first-class across contexts. |
| 38 | | |
| 39 | | - **Sweep Live Activity** — a batch/watchlist sweep drives a Live Activity with a |
| 40 | | progress bar, current domain, and change/warning counts on the Lock Screen and |
| 41 | | in the Dynamic Island (`SweepActivityController` around the batch pipeline). |
| 42 | | - **Share extension** (`DomainDigShareExtension`) — "Dig Domain" accepts a web URL |
| 43 | | from the system share sheet, extracts the host, and hands it to the app via the |
| 44 | | App Group inbox; the app inspects it on next activation. |
| 45 | | - **iPad-optimized layout** — `RootTabView` renders a `NavigationSplitView` |
| 46 | | (sidebar + detail) in the regular size class and the tab bar in compact. |
| 47 | | - **Actionable notifications** — per-domain `threadIdentifier` grouping, a |
| 48 | | "Re-inspect" action, and taps that route into the domain's detail. |
| 49 | | |
| 50 | | Deferred: monitoring-alert Live Activities (only the sweep activity shipped) and |
| 51 | | Lock Screen accessory widget families (carried over from v4.5.0). |
| 52 | | |
| 53 | | ## v4.7.0 Minor: Intelligence & Comparison — ✅ shipped |
| 54 | | |
| 55 | | Goal: help users interpret and organize, not just collect. |
| 56 | | |
| 57 | | - **Domain-vs-domain comparison** — `DiffService.compare(domainA:domainB:)` |
| 58 | | reuses the existing section-diff builders; `DomainCompareView` (Watchlist |
| 59 | | toolbar → "Compare Domains") picks two tracked domains and renders the result |
| 60 | | with the existing diff section UI. |
| 61 | | - **Reputation / blocklist signals** — a new pluggable data source |
| 62 | | (`ExternalDataService.reputation(domain:)`, Pro+) mirroring the existing |
| 63 | | ownership/DNS-history/pricing enrichment pattern. Ships with no bundled |
| 64 | | third-party endpoint; folds a listed status into risk score/factors and |
| 65 | | insights, so it rides the existing report and monitoring change-severity |
| 66 | | pipeline rather than needing bespoke monitoring wiring. |
| 67 | | - **Tags and saved views** for the watchlist — freeform tags per tracked |
| 68 | | domain, tag filter chips, and named saved filter/sort/tag presets |
| 69 | | (UserDefaults-backed; not yet part of backup/restore). |
| 70 | | |
| 71 | | ## v4.8.0 Minor: Reporting & Sharing — ✅ shipped |
| 72 | | |
| 73 | | Goal: turn point-in-time snapshots into shareable, scheduled deliverables. |
| 74 | | |
| 75 | | - **Markdown and PDF export formats** — `DomainExportFormat` gains `.markdown` |
| 76 | | and `.pdf` alongside text/csv/json. Markdown reuses the existing text-export |
| 77 | | content via a line-based transform (never drifts from the text export); PDF |
| 78 | | renders that Markdown via `UIGraphicsPDFRenderer`, mirroring the approach |
| 79 | | `AuditExporter` already used for audit sessions. |
| 80 | | - **Scheduled report generation** — `ScheduledReportService` / |
| 81 | | `ScheduledReportScheduler` (Settings → Scheduled Reports): a BGTaskScheduler- |
| 82 | | driven daily/weekly job that builds a markdown/PDF/JSON report bundle for all |
| 83 | | tracked domains, writes it locally, logs the run, and notifies when ready. |
| 84 | | Mirrors `DomainMonitoringService`'s headless, storage-backed design; gated |
| 85 | | behind the same Pro `.automatedMonitoring` capability. |
| 86 | | - **Stronger share affordances** — "Export Markdown"/"Export PDF" added to the |
| 87 | | single-result, batch, watchlist, and workflow export menus; generated |
| 88 | | scheduled reports are individually shareable from their log. |
| 89 | | - **Export consistency verified** — the local API already serves the canonical |
| 90 | | `DomainReport` directly (no field allowlist), so `reputation`, `domainPricing`, |
| 91 | | and every other field added since v4.7.0 already flow through automatically. |
| 92 | | No code change was needed there. |
| 93 | | |
| 94 | | Deferred/scoped out: scheduled-report settings and logs are UserDefaults-only |
| 95 | | (not part of `DomainDataPortabilityService` backup/restore), same reasoning as |
| 96 | | v4.7.0's watchlist saved views — this is local automation config, not |
| 97 | | user-authored content. |
| 98 | | |
| 99 | | ## v4.8.1 Patch: Reporting & Sharing Fixes — ✅ shipped |
| 100 | | |
| 101 | | Goal: fix what UAT of v4.8.0 turned up. |
| 102 | | |
| 103 | | - **Scheduled reports were unreachable manually** — the Overview section wrapped |
| 104 | | every control in a single `VStack` inside one `List` row, so SwiftUI collapsed |
| 105 | | them into one tap target and the Cadence `Picker` captured taps meant for |
| 106 | | "Generate Now". Each control is now its own row. |
| 107 | | - **Pro gate completed on that screen** — `.automatedMonitoring` previously |
| 108 | | disabled only the toggle, leaving both pickers and "Generate Now" interactive |
| 109 | | on Free where they silently no-opped against the service-side guard. |
| 110 | | - **Markdown/PDF reports rendered `=` underlines as bullets** — the plain-text |
| 111 | | transform only recognized `-`, so `batchText`'s title underline and its |
| 112 | | 48-character inter-report separators leaked through as literal list items. |
| 113 | | - **Duplicate DNS record values** — the report concatenated apex and wildcard |
| 114 | | records without dedup, listing every value twice on domains with wildcard DNS. |
| 115 | | - **Inspect tab keyboard behavior** — removed the "Dismiss Keyboard" toolbar |
| 116 | | button and the launch-time focus that raised the keyboard on app open. |
| 117 | | - **In-app purchases were unbuyable** — none of the four product ID constants in |
| 118 | | `PurchaseService` matched the auto-renewable subscriptions configured in App |
| 119 | | Store Connect, so `Product.products(for:)` returned nothing and `tier(for:)` |
| 120 | | resolved every purchase to `.free`. Product IDs are permanent once created, so |
| 121 | | the constants were corrected to match the store rather than the reverse. |
| 122 | | - **Local StoreKit testing** — added `DomainDig.storekit` mirroring the App Store |
| 123 | | Connect group (Pro+ at level 1, Pro at level 2) and wired it into the Run |
| 124 | | action, so the purchase and entitlement paths can be exercised without the |
| 125 | | `DOMAIN_DIG_FORCE_PRO_PLUS` launch argument that bypasses StoreKit entirely. |
| 126 | | |
| 127 | | Follow-ups filed during UAT (#8, #9, #10) were all resolved in v4.8.2. |
| 128 | | |
| 129 | | ## v4.8.2 Patch: Delivery Visibility & Build Health — ✅ shipped |
| 130 | | |
| 131 | | Goal: close the UAT follow-ups and make failures legible instead of silent. |
| 132 | | |
| 133 | | - **Disabled integrations no longer swallow events** (#8) — `enqueue(events:)` |
| 134 | | filtered to enabled targets before writing any `DeliveryRecord`, so events |
| 135 | | routed to a disabled integration vanished entirely. They now log a `.skipped` |
| 136 | | entry with a reason. `sendTest` also respects `isEnabled`, which previously |
| 137 | | delivered against targets that dropped every real event. |
| 138 | | - **"Process Queue Now" forces backed-off retries** (#9) — it only restarted the |
| 139 | | processing task, never moving `nextAttemptAt`, so an item in backoff (up to an |
| 140 | | hour) stayed undue and the button appeared inert. It now pulls queued items |
| 141 | | forward, and reports an empty queue instead of doing nothing silently. |
| 142 | | - **Unreachable domains report as unreachable** (#10) — when the snapshot |
| 143 | | fallback fired, the run compared old data against itself and claimed "No |
| 144 | | meaningful changes" for a domain it never reached. `MonitoringDomainResult` |
| 145 | | now carries `unreachableReason`, the summary says so, and a warning-severity |
| 146 | | `monitoringFailure` reaches configured integrations. |
| 147 | | - **Swift 6 concurrency warnings cleared** — `SweepActivityAttributes` is |
| 148 | | explicitly `nonisolated` (the app target sets |
| 149 | | `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` while the widget target does not), |
| 150 | | and `LocalAPIService`'s logger closures capture `self` coherently. Build is |
| 151 | | warning-free. |
| 152 | | - **StoreKit configuration corrected and synced** — the scheme's path was wrong, |
| 153 | | and the hand-authored file has been replaced by `SyncedProducts.storekit`, |
| 154 | | synced against App Store Connect. Registered in the project without target |
| 155 | | membership so it is not bundled into shipping builds. |
| 156 | | |
| 157 | | ## v4.8.3 Patch: Static Analysis Cleanup — ✅ shipped |
| 158 | | |
| 159 | | Goal: clear the SonarCloud new-code backlog without changing behavior. |
| 160 | | |
| 161 | | - **Dead confidence conditionals fixed** (4 bugs) — |
| 162 | | `DomainInspectionService`'s `confidenceFor*` helpers each returned |
| 163 | | `error == nil ? .low : .low`. The conditional was inert, so the unused `error` |
| 164 | | parameter was dropped alongside it. |
| 165 | | - **Identical switch branches merged** — 14 sites in `DomainViewModel` handled |
| 166 | | `.empty(message)` and `.error(message)` with byte-identical bodies; they now |
| 167 | | share one `case let .empty(message), let .error(message):`. |
| 168 | | - **Duplicate implementations consolidated** — `clearPresentedResults()` now |
| 169 | | delegates to `reset()`, `String.nonEmpty` was folded into `nilIfEmpty`, and |
| 170 | | `ExportFormat.id` derives from `fileExtension`. |
| 171 | | - **Nested ternaries extracted** — grade-to-tone and impact-to-color mappings |
| 172 | | became `TLSGrade.tone`, `EmailSecurityGrade.tone`, and |
| 173 | | `ChangeImpactClassification.color`, replacing `ContentView`'s private |
| 174 | | `impactColor` and the duplicate mapping in `BatchResultsView`. |
| 175 | | - **Remaining smells** — empty closures and singleton inits documented, unused |
| 176 | | protocol-conformance parameters marked `_`, `CloudSyncTrigger.import` renamed |
| 177 | | to `imported` (raw value preserved), `_serverTrust`/`_tlsMetadata` renamed, |
| 178 | | nested `if`s merged in the DER parser, and deep closure nesting flattened in |
| 179 | | `PortScanService` and `IntegrationService`. |
| 180 | | |
| 181 | | Left open deliberately: `swift:S107` (initializer parameter counts on model |
| 182 | | memberwise inits), `swift:S115` (constants mirroring DoH/ipapi JSON keys), |
| 183 | | `swift:S1075` (false positives on `https://` literals), and two `swift:S117` |
| 184 | | hits on SwiftUI `$binding` shorthand in `AuditModeView`, which cannot be |
| 185 | | renamed. These want a *Won't Fix* / *Safe* resolution in SonarCloud rather than |
| 186 | | a code change. |
| 187 | | |
| 188 | | ## v4.9.0 Minor: Accessibility, Appearance & Engineering Health — ✅ shipped |
| 189 | | |
| 190 | | Goal: make the app usable by every iOS user — full accessibility pass (#21), |
| 191 | | light mode, and the engineering scaffolding to keep both from regressing. |
| 192 | | |
| 193 | | - **Semantic colour system** — every hard-coded colour replaced with adaptive |
| 194 | | colorsets in `Shared/Colors.xcassets` (Any/Dark + High Contrast variants), |
| 195 | | shared by app, widget, and share extension via the synchronized `Shared` |
| 196 | | group. Every status colour clears WCAG AA on its page, its card, and its |
| 197 | | badge surface, in both schemes; measured, not asserted. The accent is now |
| 198 | | blue (`#0000FF` light / `#4DA3FF` dark), split into foreground |
| 199 | | (`StatusInfo`), fill (`AccentFill`), and on-fill (`AppOnAccent`) roles |
| 200 | | because one value cannot serve as both text-on-dark and fill-behind-white. |
| 201 | | `AppStatusTone` pairs each status foreground with an authored surface. |
| 202 | | - **Light mode unlocked** — the 16 scattered `.preferredColorScheme(.dark)` |
| 203 | | calls removed; appearance (System/Light/Dark) is applied once at the |
| 204 | | `WindowGroup` and exposed under Settings → Display. `.secondary` (3.29:1 on a |
| 205 | | light card) replaced with `AppTextSecondary` across 191 sites. |
| 206 | | - **Dynamic Type & reflow** — `Label`-clipped empty-state titles fixed, the |
| 207 | | 44pt tap-target floor enforced (`AppCopyButton` was 30×30; |
| 208 | | `controlMinHeight` was 42), `CardView`'s horizontal-scroll default flipped |
| 209 | | to reflow, dense rows (`WatchlistRowView`, `BatchResultRowView`, |
| 210 | | `PortfolioExpiryRow`) and the collapsible section headers rebuilt on |
| 211 | | `ViewThatFits` so badges and buttons can never letter-wrap vertically, and |
| 212 | | the widget clamped at `accessibility1` (fixed canvas, no scroll). |
| 213 | | - **VoiceOver** — labels on every icon-only control (label-in-name preserved |
| 214 | | for Voice Control), selected-state on all toggles, badges read as one word, |
| 215 | | heading-rotor navigation, dense rows collapsed to one element with the |
| 216 | | detail on the More Content rotor (`accessibilityCustomContent`), technical |
| 217 | | strings (DNS records, cipher suites) spoken with punctuation, and lookup/ |
| 218 | | sweep completion announcements. Widget rows read as a single phrase. |
| 219 | | - **Colour independence, motion, transparency** — widget status uses the badge |
| 220 | | symbol vocabulary instead of colour-only dots; `differentiateWithoutColor` |
| 221 | | adds symbols/borders on demand; all five animation sites honour |
| 222 | | `reduceMotion`; the one material honours `reduceTransparency`. |
| 223 | | - **Accessibility audit harness** — `DomainDigUITests` runs |
| 224 | | `performAccessibilityAudit()` over every primary screen at default and |
| 225 | | AccessibilityXXXL, on CI (newest runtime, clean merge-result checkout) and |
| 226 | | locally (`Scripts/audit-a11y.sh`, real floor runtime, wired to an opt-in |
| 227 | | pre-push hook). `DOMAIN_DIG_SEED_FIXTURES` seeds deterministic in-memory |
| 228 | | rows so the dense paths actually render under audit. The **enforcement |
| 229 | | ratchet is engaged**: named findings in six categories fail CI, with |
| 230 | | narrowly characterised, always-logged noise suppressions. Findings burndown |
| 231 | | 20 → 11, with every remaining item characterised as system noise. |
| 232 | | - **Phase 6 verification** — the simulator-executable half of the manual pass |
| 233 | | was run and converted into permanent tests: `AccessibilityMetadataTests` |
| 234 | | asserts the icon-only control labels, toggle selected-states, and dense-row |
| 235 | | label/value pairs; `AccessibilityScreenshotTests` captures both appearances |
| 236 | | across classic chrome and Liquid Glass. A middle-band Dynamic Type sweep was |
| 237 | | added after two real layout bugs turned up *between* the default and |
| 238 | | AccessibilityXXXL test points. The pass also caught a genuine enforced |
| 239 | | `.dynamicType` failure on iOS 27.0 against UIKit-rendered Settings section |
| 240 | | headers; the app applies no font to those, so it is carved out by exact |
| 241 | | header title, scoped to that one audit type. |
| 242 | | - **Swift 6 language mode** (#27) — all three product targets build under |
| 243 | | `SWIFT_VERSION = 6.0` with zero warnings. `SMTPChannel` became an actor |
| 244 | | (fixing a real `CheckedContinuation` double-resume hazard), |
| 245 | | `SweepActivityController` stores a Sendable activity id, and the remaining |
| 246 | | isolation issues were resolved layer by layer. The UITests target stays on |
| 247 | | Swift 5 (XCTest override isolation), recorded as a decision. |
| 248 | | - **Project hygiene** — the misleading project-level deployment target |
| 249 | | (26.2 shadowing the real 17.6) reconciled; CI selects simulators |
| 250 | | floor-aware instead of first-match. |
| 251 | | |
| 252 | | Deferred — genuinely physical-device-only, since the Simulator cannot run |
| 253 | | VoiceOver or Voice Control at all: |
| 254 | | |
| 255 | | - **VoiceOver speech**, the More Content rotor, custom-content ordering, and the |
| 256 | | spoken lookup/sweep announcements. The underlying metadata (labels, values, |
| 257 | | selected states) *is* asserted in `AccessibilityMetadataTests`; what remains |
| 258 | | unverified is how it is spoken. |
| 259 | | - **Voice Control** activation of every control by its printed label (WCAG |
| 260 | | 2.5.3). |
| 261 | | - **iPad Full Keyboard Access** focus order across the split layout. |
| 262 | | - **Smart Invert**. |
| 263 | | |
| 264 | | The Liquid Glass (iOS 26+) runtime check is **done** — it ran on simulator |
| 265 | | alongside the classic-chrome floor. |
| 266 | | |
| 267 | | ## v5.0.0 Major: Contract Stabilization & Engineering Health — ✅ shipped |
| 268 | | |
| 269 | | Goal: earn long-term compatibility promises — and pay down the debt the feature |
| 270 | | releases above accumulated. All four workstreams landed on `main` behind the new |
| 271 | | test net. |
| 272 | | |
| 273 | | - **Deterministic-core test net — done first**, as the cross-cutting note below |
| 274 | | required. Added `DomainDigTests`, the project's first XCTest unit target, |
| 275 | | hosted by the app with `@testable import`. `SnapshotFixture` builds the deep |
| 276 | | `LookupSnapshot`/`DomainReport` models through the real builder; 58 tests cover |
| 277 | | `DiffService`, `DomainReportBuilder`, `DomainReportExporter`, |
| 278 | | `DomainDataPortabilityService` (merge/replace dedup), the migration runner, and |
| 279 | | the Local API contract. Runs in CI and the pre-push hook. (#43) |
| 280 | | - **Local API `v1` contract stabilized.** `LocalAPIContract` is the single source |
| 281 | | of truth for the wire version and JSON encoder; the response envelope and every |
| 282 | | payload are promoted to a first-class, documented contract. |
| 283 | | `Docs/local-api.txt` documents each endpoint, the envelope, the encoding |
| 284 | | conventions (notably: absent optionals are omitted, not null), and a |
| 285 | | semantic-version compatibility policy. 16 golden structure tests pin the JSON |
| 286 | | shape so a renamed/removed field fails CI. (#45) |
| 287 | | - **Versioned store-migration policy** for persisted snapshots, backups, audits, |
| 288 | | workflows, and settings. `DataMigrationService` became a forward-only, |
| 289 | | idempotent, never-downgrades runner keyed by an integer store schema version, |
| 290 | | replacing the one-shot boolean marker. `Docs/data-migration.txt` documents the |
| 291 | | policy, the two independent version lines (on-device store vs. backup export), |
| 292 | | and when to use lenient decoding vs. a migration step; legacy-fixture tests |
| 293 | | cover it. (#46) |
| 294 | | - **God-files decomposed** behind that test net, one behavior-preserving slice |
| 295 | | per PR (each built clean with 58/58 tests, no logic changes): |
| 296 | | - `DomainViewModel.swift` **4864 → 4170 lines** — audit, monitoring, export, |
| 297 | | workflow, and history surfaces moved to `DomainViewModel+*.swift` |
| 298 | | extensions. (#47–#51) |
| 299 | | - `ContentView.swift` **3881 → 1625 lines** — Settings screens to |
| 300 | | `SettingsViews.swift` and the nine result section views to |
| 301 | | `ResultSectionViews.swift`. (#52–#53) |
| 302 | | |
| 303 | | Left in place deliberately: the tightly-coupled inspection core (the section |
| 304 | | runners, `performLookup`/`saveHistoryEntry`, batch orchestration) and a few |
| 305 | | remaining `ContentView` cards/primitives. Splitting the inspection core further |
| 306 | | is a *design* change — extracting a collaborator object — not a mechanical move, |
| 307 | | so it is deferred rather than forced through visibility promotions. |
| 308 | | |
| 309 | | Release cut: `MARKETING_VERSION` 4.9.0 → 5.0.0, `CURRENT_PROJECT_VERSION` |
| 310 | | 44 → 45, and `AppVersion.current` bumped in lockstep. App Store archive/submit is |
| 311 | | the only step left, and it is a manual action outside the repo. |
| 312 | | |
| 313 | | ## v5.0.1 Patch: Owner entitlement — ✅ shipped |
| 314 | | |
| 315 | | - **Owner Pro+ allowlist.** `OwnerAccess` identifies the app owner by their |
| 316 | | CloudKit user-record ID (an opaque, per-Apple-ID value scoped to the app's |
| 317 | | container). `PurchaseService` resolves it against CloudKit once per launch and, |
| 318 | | on a match, grants `.proPlus` — persisted so it applies instantly and offline |
| 319 | | thereafter. It only ever elevates the tier and defers to the existing `#if |
| 320 | | DEBUG` overrides, so real purchases and free/pro testing are unaffected. |
| 321 | | - Release cut: `MARKETING_VERSION` 5.0.0 → 5.0.1, `CURRENT_PROJECT_VERSION` |
| 322 | | 45 → 46, `AppVersion.current` in lockstep. |
| 323 | | |
| 324 | | ## v5.0.2 Patch: krazywarez migration — ✅ shipped |
| 325 | | |
| 326 | | - **Org/domain migration.** Moved all outward-facing references from |
| 327 | | `zerolabsco`/`zerolabs.sh` to `krazywarez`/`krz.sh`: source, documentation, |
| 328 | | privacy, and support links in `AppLinks`, the support email (`root@krz.sh`), |
| 329 | | the SonarCloud project badges, the `DomainDebugLog` logging subsystem, and the |
| 330 | | docs (`README`, `SECURITY`, `ACCESSIBILITY`). Stable identity — bundle IDs, App |
| 331 | | Group, iCloud/CloudKit container, and background-task identifiers — stays on |
| 332 | | `net.cleberg.DomainDig`, tied to the existing App Store listing. |
| 333 | | - Release cut: `MARKETING_VERSION` 5.0.1 → 5.0.2, `CURRENT_PROJECT_VERSION` |
| 334 | | 46 → 47, `AppVersion.current` in lockstep. |
| 335 | | |
| 336 | | ## Cross-cutting note |
| 337 | | |
| 338 | | New feature surfaces (widgets, intents, extensions) each add a target and a |
| 339 | | persistence/entitlement seam. Through v4.9.0 the project had **no XCTest unit |
| 340 | | target** — v4.5.0 through v4.7.0 all shipped without the characterization-test |
| 341 | | safety net originally recommended before v4.7.0, and that gap only grew |
| 342 | | (comparison, reputation, and tags/saved-views all touch persisted models with |
| 343 | | hand-written backward-compatible decoders). v5.0.0 closed it first: the |
| 344 | | `DomainDigTests` deterministic-core net went in before anything else, which is |
| 345 | | what made stabilizing the external contracts and decomposing the god-files safe |
| 346 | | to attempt. |