## Unreleased ## 1.4.1 (2026-06-24) ### Changed - **feat: `list_documents` and `list_children` MCP tools serve ALL sources, not just apple-docs (query-side source pluggability).** Both tools hardcoded `source == apple-docs` and rejected everything else, so a client (cupertino-desktop) could browse only Apple Developer Documentation: swift-org (469 docs), swift-evolution (488), swift-book, hig, and apple-archive all returned "currently supports only apple-docs" even though their per-source databases are installed and searchable. The tools now route through the engine-backed `Search.DocumentBrowsing` the composition root injects (CupertinoDataEngine has a reader per source), so they list documents/children for every installed source. The injected `documentChildrenListing` is widened to `documentBrowsing` (`Search.DocumentBrowsing`) and drives both handlers; the apple-docs guards are removed. MCP tool tests updated to assert delegation + that non-apple-docs sources are accepted. - **feat(#50): `list_children` delegates to CupertinoDataEngine's topic-group parser (shared with the embedded apps).** The MCP `list_children` tool and the `cupertino list-children` CLI command now route through `CupertinoDataEngine` (bumped 0.2.7 -> 0.2.8), whose `listChildren` parses a document's `## Topics` section into its `###` topic groups (fragment URIs like `apple-docs://kernel#3616595`) and their member documents. The duplicate `SearchSQLite/Search.Index.DocumentChildren.swift` parser and the now-unused `Services.SearchService.listChildren` requirement are removed, so the server and the embedded apps (cupertino-desktop) share ONE implementation instead of two copies that can drift. The composition root injects the engine-backed `Search.DocumentChildrenListing` into `CompositeToolProvider`; behavior is unchanged for already-correct pages. Full suite green (3175 tests). Closes the server side of cupertino-desktop #50. ### Fixed - **fix(#280): `serve --no-reap` servers are now exempt from being reaped (not just from reaping others).** The `ServeReaper` SIGTERM/SIGKILLs every sibling `cupertino serve` of the same binary at startup, but it keyed only on `argv[1] == "serve"` and ignored `--no-reap`. So `--no-reap` stopped a server from reaping others yet did NOT protect it from being killed when another `serve` started. A long-lived embedded client (cupertino-desktop spawns `serve --no-reap` and holds the connection for the app's lifetime) therefore lost its server whenever any transient `serve` started (an MCP-host config reload in Claude Desktop / Cursor / Codex, or a CLI probe): the killed server closed its stdio, and the client surfaced a mid-session "connection closed" transport failure (the Mac reader's `read_document` / browse calls dying with `MCPClient.Failure.transport`). `--no-reap` now means "I neither reap others NOR am I reaped". The argv reap-candidacy decision is extracted into the pure, unit-tested `ServeReaper.isReapableServe(argv:)` (a plain `serve` is reapable; `serve --no-reap` and non-`serve` are not). Closes the Mac transport-drop class in cupertino-desktop. - **fix(setup): `cupertino setup` now verifies each database is readable, not just present (discussion #1276).** Setup confirmed every bundled DB file *existed* after extraction but never that it could be *read back*. A truncated extract (disk filled mid-unzip) or a copy on a failing / cloud-evicted volume opens fine and answers shallow queries (`sqlite_master`), then throws `disk I/O error` on the first real `search` / `list_frameworks` query at serve time, so setup printed "Setup complete!" over an unreadable `apple-documentation.db`. New `Diagnostics.Probes.quickCheck(at:)` runs a read-only `PRAGMA quick_check` (walks every b-tree page, classifies ok / unreadable / corrupt); setup gates on it after extract + migrations and exits non-zero with an actionable message (disk I/O vs malformed, re-run, keep the base dir on a local volume with free space) instead of declaring success. Skipped under `--keep-existing` (nothing written that run). Separately, `scripts/check-db-quality.sh` now runs a real FTS `MATCH` per docs DB: it previously only `COUNT(*)`-ed `docs_fts`, which never exercises the index b-tree, so a corrupt or unbuilt FTS index passed the gate and only failed at a user's first search. Covered by tests for the gate itself (healthy passes, bad DBs abort with their filenames aggregated, the emitted message carries the re-run remedy), the `--keep-existing` skip contract, and the `.problems` (corrupt-but-openable) classification. ### Documentation - **docs(deployment): notarization-credential troubleshooting runbook.** `docs/DEPLOYMENT.md` now documents the recovery for a release build failing the Notarize step with `HTTP status code: 401. Invalid credentials`: the `APPLE_ID_PASSWORD` secret is an Apple app-specific password that expires (separate from the code-signing certificate), so regenerate it at appleid.apple.com, update the secret with `gh secret set APPLE_ID_PASSWORD`, and re-run the failed build with `gh run rerun --failed` (no re-tag needed). Born from the v1.4.0 release build, where the app-specific password had expired. ## v1.4.0 (2026-06-21) - **Database content refresh (databaseVersion + CLI version → 1.4.0).** A full re-crawl + clean rebuild of the per-source corpus, published as `cupertino-databases-v1.4.0.zip` (876.4 MB, 8 per-source DBs in rollback journal mode). `apple-documentation.db` grew from 351,505 to 363,562 documents and from 240,543 to 308,118 symbols (417 frameworks), now carrying post-WWDC26 iOS 27 content (v1.3.0 capped at iOS 26). The placeholder-stub rot the new `scripts/check-db-quality.sh` gate was built to catch is gone: every docs database now has a `docs_structured == docs_fts` population ratio of 1.000 with zero `Apple Developer Documentation` placeholder duplicates (the old v1.3.0 `hig.db` was 346/173; the v1.4.0 `hig.db` is a clean 177/177). The physical layout (8 per-source DBs, read-only rollback mode, schema v18 / packages v5) is unchanged from v1.3.0; this is a content + version bump, not a schema change. Bundle published to `mihaelamj/cupertino-docs`; the v1.3.0 release is untouched. ### Added - **feat(#1295): dependency-integrity CI guard + `CODEOWNERS` (supply-chain hardening, born from PR #1294).** A new `scripts/check-dependency-integrity.sh`, wired into the `package-audits` CI job, fails any PR whose `Packages/Package.swift` or either `Package.resolved` (package + `Main.xcworkspace`) points a dependency at a non-allowlisted owner (only `apple`, `swiftlang`, `mihaelamj` are permitted), or whose two lockfiles disagree on a shared dependency's location. It is pure text inspection (no build, no network, no code execution), so it runs safely on untrusted external PRs. This is the gap PR #1294 walked through: an external PR repointed `SwiftMCPCore` to a contributor fork by editing only the lockfile, and nothing in CI flagged it. New `.github/CODEOWNERS` additionally gates `Package.swift`, every `Package.resolved`, `.github/workflows/`, and the guard itself behind maintainer review. Red/green coverage in `scripts/test-check-dependency-integrity.sh` (clean tree passes; a fork URL in the lockfile or manifest, a one-sided lockfile drift, and a lookalike owner each fail). - **feat(#1223): `list_sources` rows carry the routing `sourceID` (pluggability enabler).** Each `Search.SourceInventoryItem` now has a `sourceID` (the source's `SourceDefinition.id`, e.g. `apple-docs`) alongside the database/descriptor `id` (e.g. `apple-documentation`). `CLIImpl.activeSourceInventory` iterates the registry's providers (not just their `destinationDB` descriptors) to populate it. This is the small, additive, low-risk enabler for the deferred query-side source-pluggability work (`docs/design/query-side-source-pluggability.md`): a consumer (cupertino-desktop) can map a cupertino source to its own model by `sourceID` without hardcoding a descriptor↔source table, and it ships now so no further cupertino release is needed when that consumer work lands. Additive JSON field; older clients ignore it. No search-behavior change. Covered by `SourceInventoryDerivationTests` (routing id populated; `apple-documentation`→`apple-docs`) and a round-trip assertion in `Issue1277ListSourcesToolTests`. - **feat(#1277): `cupertino list-sources` CLI command.** The human-runnable equivalent of the `list_sources` MCP tool: prints the installed per-source databases with on-disk presence and schema version (`8 of 8 databases installed:` plus a `✓/✗ id: name (schema V) [file]` row per source), `--format text` (default) or `json`. Both render the same `CLIImpl.activeSourceInventory()` (the registry-derived canonical set, excluding the legacy `search.db`), so the CLI, the MCP tool, `setup`, and the bundle cannot drift. Documented under `docs/commands/list-sources/` and `docs/tools/list_sources/`, indexed in both command/tool READMEs, and added to the `check-docs-commands-drift.sh` command set (now 23/23) and the subcommand-registration tests. - **feat(#1277): `list_sources` MCP tool reporting the installed per-source database inventory.** A connected client (cupertino-desktop) can now ask the server which per-source databases are installed and their schema versions, instead of scanning `~/.cupertino` and hardcoding filenames. The set is derived from the source registry (`CLIImpl.bundleRequiredDescriptors()` = `makeProductionSourceRegistry().allEnabled.map(\.destinationDB)`), so it is the canonical active set: it excludes the legacy unified `search.db`, stays correct across the per-source-DB-split migration (#1036), and cannot drift from what `setup` extracts or the bundle ships. New `Search.SourceInventory` / `Search.SourceInventoryItem` value types (id, displayName, filename, present, schemaVersion); `CompositeToolProvider` gains an injected `sourceInventory` (the tool is advertised only when the composition root wires it, so other conformers and test doubles are unaffected); `serve` injects `CLIImpl.activeSourceInventory()`. Covered by `SourceInventoryDerivationTests` (canonical set, legacy `search` excluded, consistent counts) and `Issue1277ListSourcesToolTests` (advertise / hide / return-JSON). Consumed by cupertino-desktop#98 under cupertino-desktop#92; tracked under #1262. ### Fixed - **fix(#885): `cupertino setup --force` now prints an actionable migration hint instead of the bare "Unknown option" error.** `--force` was removed from `setup` in v1.2.0 (setup overwrites installed databases by default), but a user typing it from muscle memory, an old script, or third-party docs hit the generic swift-argument-parser `Error: Unknown option '--force'`, which names neither v1.2.0 nor the replacement. `setup` now carries `--force` as a hidden flag (`help: .hidden`, so it never appears in `--help`) whose `validate()` throws `--force was removed in v1.2.0. cupertino setup overwrites installed databases by default; pass --keep-existing to preserve them instead.` ArgumentParser runs `validate()` during `parse`, so the hint surfaces at the usage layer (exit 64) before any download work. Covered by `Issue885SetupForceHintTests` (the hint names v1.2.0 + `--keep-existing`; `--force --keep-existing` still rejects since `--force` is the invalid token; a no-`--force` invocation parses cleanly). Re-implemented cleanly from the idea in external PR #1233 (which targeted `main` with placeholder help text and was superseded). Closes #885. - **fix(#1302): the `issue-body-staleness` scan no longer scans its own auto-generated tracker.** The nightly scan iterated every open issue, including the tracker issue (#1300, titled "Issue body staleness tracker (auto-updated)") that the workflow itself files and updates. Because the tracker's body *is* the staleness report, it always cited the phantom paths it was reporting and carried no kind label, so the scan was perpetually non-zero and the workflow's "Close tracking issue if clean" step could never fire: the tracker could never close. The scan now skips the issue whose title matches the tracker marker (kept in sync with `TITLE` in `.github/workflows/issue-body-staleness.yml`) in both the per-issue checks and the missing-kind-label check; `--issue ` can still inspect the tracker on demand. With the only real drift (#1288's phantom path) re-anchored to the to-be-created `docs/commands/list-source-hierarchy/` directory, a full scan now exits 0, so the next run will auto-close the tracker. Detection stays non-vacuous (an intermediate run still flagged a non-tracker issue's phantom path before its body was fixed). - **fix(#1298): the nightly `Issue body staleness` workflow can file its tracking issue again.** When the scan found drift and no tracker issue existed yet, the "file new tracking issue" step ran `gh issue create ... --label "documentation"`, but this repo has no `documentation` label, so `gh` hard-failed the whole create (`could not add label: 'documentation' not found`) and the job went red without ever filing the tracker (observed on the 2026-06-21 09:00 UTC run). The tracker is located on later runs by exact title match, not by label, so the label was purely cosmetic; it is removed. A new label was not minted instead, since one tracker does not meet github-discipline's 3-carrier threshold for a new label axis, and `bug` would wrongly pollute the live `label:bug` list the pair workflow triages. The scan step itself was always healthy (it correctly reported drift and exited non-zero); only the issue-filing step was broken. - **fix(#1297): `scripts/check-pre-index.sh` (the #794 pre-index validation gate) is restored against the per-source-DB CLI.** The gate invoked `cupertino save --docs`, but the `--docs` / `--packages` / `--samples` scope-selector triplet was removed in the per-source-DB migration (#1036/#1037) in favour of `--source ` / `--all`, so the gate aborted at the first save line with `Unknown option '--docs'` and provided zero protection. Because it is run on demand and is not wired into CI, the rot went unnoticed. The save now runs `--source apple-docs` (which builds `apple-documentation.db`), and the script's `DB` target, its stale-output wipe, and both gate assertions (the #789 packages-residue check and the iter-3 `doc_symbols.generic_constraints` coverage floor) now key off `apple-documentation.db` rather than the long-gone unified `search.db`. Verified end to end against the 10% mini-corpus fixture: the docs save completes and Gate 1 passes against the per-source DB. (Gate 2 additionally requires the `apple-constraints.json` symbolgraph fixture, which the script already documents how to regenerate via `cupertino-constraints-gen`.) - **fix(db-rebuild): `save --clear` now wipes every docs table, and `fetch --start-clean` wipes the corpus dir — so a rebuild can't inherit stale rows.** Two compounding bugs let placeholder-duplicate junk survive a re-crawl + rebuild of `hig.db` (the shipped v1.3.0 `hig.db` carried 346 `docs_structured` rows of which 173 were `…-appledeveloperdocumentation` stub duplicates, sitting in `docs_structured` but never in `docs_fts`, so search never returned them and the rot went unnoticed). (1) `Search.Indexer.clearIndex()` (the `--clear` / full-rebuild path) deleted only `docs_fts` + `docs_metadata`; the `ON DELETE CASCADE` from `docs_structured`/`doc_symbols`/`doc_code_examples` to `docs_metadata` never fired because `PRAGMA foreign_keys` is off on that connection, so the rich-data tables kept their rows. It now deletes all ten docs-schema content tables explicitly (`docs_fts`, `doc_symbols_fts`, `doc_code_fts`, `doc_code_examples`, `doc_imports`, `doc_symbols`, `inheritance`, `framework_aliases`, `docs_structured`, `docs_metadata`). (2) `Ingest.Session.clearSavedSession()` (`--start-clean`) only nulled `crawlState`, leaving orphaned `.md`/`.json` corpus files on disk — so a crawl whose filename convention changed accumulated both (swift-evolution: 429 old `NNNN-slug.md` + 488 new `SE-NNNN.md` = 917, and `save` indexed the union). It now removes every `.md`/`.json` under the output directory except `metadata.json`, and empties `metadata.pages`, so the next crawl is a true clean slate. Regression-pinned by `ClearIndexWipesAllTablesTests` (clearIndex empties `docs_structured`, not just the FTS) and the updated `ResumeAndStartCleanTests` (`--start-clean` removes stale corpus files, preserves `metadata.json`, empties `pages`). Companion semantic-quality release gate `scripts/check-db-quality.sh` + `docs/database-quality-checks.md` (the `docs_structured`/`docs_fts` population invariant) catches the symptom before any bundle publishes. - **test(#1254): end-to-end guard that every bundled database ships rollback (read-only, no `-shm`).** `ConvertToRollbackJournalTests.everyBundledDatabaseShipsRollback` runs the exact two release-prep steps (`checkpointTruncate` + `convertToRollbackJournal`) over the FULL registry-derived `bundledDescriptors()` set (all 8 per-source DBs, and any future source automatically), asserting each ends in rollback journal mode and opens through a plain read-only connection with no `-shm` created. This pins the #1254-item-2 failure class (a freshly-extracted WAL DB with no `-shm` fails every read-only open) so a regression can't reach a shipped bundle; the conversion + zip use the same `present` list in `Release.Command.Database.run()`, so "every converted DB is bundled" already holds by construction. Test-only; no production change. - **fix(#1259): the Xcode workspace lockfile now matches the package lockfile for the extracted packages.** `Main.xcworkspace/xcshareddata/swiftpm/Package.resolved` had drifted from `Packages/Package.resolved` after the extracted-package adoption (`SwiftMCPCore` / `SwiftMCPServer` / `SwiftMCPClient` / `CupertinoDataKit` / `CupertinoDataEngine`): the workspace pinned `CupertinoDataEngine` at `0.2.6` while the package graph resolved `0.2.7`, leaving every workspace open dirty. Re-resolved through the workspace so both lockfiles carry the identical external pins (same versions and revisions); `git status` is clean and the full suite stays green (3151 tests, 516 suites). - **fix(#1163): drop the dead `transport` log category and pin the subsystem string.** The `transport` category was declared in `Logging.Category` (and its `allKnownCases`, the `osLoggers` dict, and two test lists) but nothing ever logged to it — the MCP transport layer writes straight to stderr by design — so it was an unreachable channel. Removed it across all five sites (now 9 production categories). Also replaced the tautological `#expect(Bool(true))` in `loggerConfiguration` with `#expect(Logging.Logger.subsystem == "com.cupertino.cli")`, so a change to `Shared.Constants.Logging.subsystem` (which would silently move every record to a new subsystem and break operator `log show` playbooks) now fails a test. Item 1 (SampleIndex stray subsystem) was already fixed; this closes the remaining items 2 and 3. - **fix(#1162): `cupertino serve` now mirrors the database-health summary and the `run setup` diagnostic to stderr on startup.** When serve started without the databases (or with a schema mismatch), the actionable warning went only through the `Recording` abstraction with the console sink disabled (to keep stdout a clean JSON-RPC channel), so it landed only in the unified log under `com.cupertino.cli` / `mcp` — a channel the operator has no reason to watch, while the MCP stream and lifecycle lines go to stderr (their server-output panel). The one message explaining "search returns nothing" was effectively invisible. Serve now prints a concise per-source DB health banner to stderr on start (`📚 Databases: N of M installed. Missing: …` plus `→ Run cupertino setup …` when incomplete, and `⚠️ Search index unavailable: ` on a schema/open failure), derived from the registry-based `activeSourceInventory()`. stdout stays the protocol channel, so stderr writes cannot corrupt it. The banner content is a pure `CLIImpl.serveDatabaseHealthBanner` function covered by `Issue1162ServeStartupBannerTests` (complete / partial / schema-mismatch / empty, the setup hint proven non-vacuous). - **fix(#1254, partial): `cupertino setup` removes superseded pre-#1036 artifacts, and `doctor` flags any that remain.** On a machine that had a pre-#1036 bundle, `setup` for the per-source bundle left ~5 GB of dead weight beside the new per-source DBs: the unified `search.db`, the old `samples.db` (renamed `apple-sample-code.db` in #1037), the `search/` extraction dir, and their SQLite `-wal`/`-shm` sidecars — on a disk `doctor` already warns is nearly full. After the per-source bundle is verified on disk, `setup` now removes those superseded artifacts (gated on a successful extraction, reported on stderr with the reclaimed size; a removal failure is non-fatal). A new declarative list + pure `Distribution.SetupService.supersededLegacyArtifacts(in:currentPlacementFilenames:)` (which never returns a name that is also a live placement) drives both the removal and a new informational `doctor` section that flags any leftovers as safe to remove, so the two cannot drift. Covered by `Issue1254SupersededArtifactCleanupTests` (detection incl. sidecars, the live-placement guard, and a clean install). The remaining part of #1254 — the shipped docs DBs observed in WAL rather than read-only rollback-journal mode — is a bundle-packaging concern (how `cupertino-rel databases` builds the zip), not a setup/read-path code fix, and is tracked there. - **fix(#657): a `cupertino cleanup` recovery path quarantines already-landed invalid sample archives, and `doctor` points to it.** The per-download guard in `Sample.Core.Downloader` parks a freshly-downloaded non-ZIP body (HTML landing page / partial CDN body) as `.invalid`, so new fetches stay clean — but the issue recurred on an *installed* corpus carrying invalid archives from before that guard (the 2026-06-08 release smoke found 8). Nothing removed them and `cupertino save --samples` kept tripping over them. `cupertino cleanup` now runs a quarantine sweep first (`CLIImpl.quarantineInvalidSampleArchives`, sharing the downloader's / `doctor`'s `Shared.Utils.ZipMagic.isValid` check): every `.zip` with an invalid header is parked as `.invalid` before the recompress pass, so a bad archive is removed from the active corpus instead of crashing cleanup and lingering (`--dry-run` previews without moving). `doctor`'s sample-archive-integrity section now names `cupertino cleanup` as the recovery command. Covered by `Issue657SampleArchiveQuarantineTests` (HTML-saved-as-zip parked, valid archives untouched, dry-run no-op, clean-corpus no-op, stale `.invalid` non-blocking). - **fix(#1286): the MCP `serve` (desktop) search now fans out across every per-source docs DB, not just the apple-docs primary.** Found by testing the installed corpus through the exact path the desktop consumes (`cupertino serve` over MCP stdio): the desktop searched only 3 of 8 sources. `Services.UnifiedSearchService.searchAll` issues a `searchSource(source:)` per docs source, but every one routed through the single injected `searchIndex` (the apple-docs primary `apple-documentation.db`); on a per-source-DB bundle (#1036) that DB holds only apple-docs rows, so hig / apple-archive / swift-evolution / swift-org / swift-book came back empty even though their DBs are installed and the CLI (`buildFetchers`) searches them. `UnifiedSearchService` now takes a `docsIndexBySource` map and routes each source to its own read-only per-source index (falling back to `searchIndex` when a source is absent from the map); `serve` opens every per-source docs DB (reusing the already-open apple-docs index) and injects the map. Verified empirically: an MCP `serve` search for "human interface guidelines buttons" against the installed corpus now returns Apple Documentation + Apple Archive + Sample Code + Human Interface Guidelines + Swift Packages sections, where before it returned only Apple Documentation + Sample Code + Swift Packages. `DocsSearchService` (the scoped path behind `search --source `, `list_documents`, and `list_children`) gained the same `docsIndexBySource` routing, so every source-scoped MCP operation now reaches its per-source DB. `read_document` already resolves non-apple-docs URIs through the multi-source resource/filesystem fallback (verified for a `hig://` URI), and `list_frameworks` correctly stays on the apple-docs index (frameworks are an Apple-docs taxonomy). Verified empirically against the installed corpus: MCP unified search now returns Apple Documentation + Apple Archive + Sample Code + HIG + Swift Packages sections (vs only 3 before), MCP `search --source hig` returns 5 HIG guidelines (vs 0 before), and `read_document` of a `hig://` URI returns the document. Hermetic regression guards `Issue1286UnifiedSearchPerSourceFanoutTests` + `Issue1286ScopedDocsSearchRoutingTests` (unified and scoped routing each return non-primary-source rows; both proven non-vacuous: the empty-map legacy wiring returns none). Full suite: 3159 pass. - **fix(#1285): a hermetic production multi-source fan-out test wires real per-source DBs through the production composition.** RRF fusion math was covered only with mock fetchers; the only test wiring real multi-source DBs through the production composition was the snapshot-gated CLI `Enrichment21RankFusionTests` (skips in CI, asserts loose properties). New `Issue1285MultiSourceFanoutTests` builds three small real per-source search DBs in a temp dir, indexes each through the production write path (`Search.Index.indexDocument`), opens them read-only (the production read shape), wires the production fetcher (`Search.DocsSourceCandidateFetcher`) and fan-out engine (`Search.SmartQuery`), runs a bare query, and asserts every wired source contributes, the fused list spans all sources (one merged ranked list), and the order is stable across identical runs. Runs on every PR (no snapshot gate); proven non-vacuous (collapsing to one fetcher fails the multi-source assertions). Full suite: 3157 pass. - **fix(#1281): all seven `Diagnostics.Probes` read-only opens now go through the robust read-only path, killing the WAL-without-shm weakness uniformly.** #1194 routed only the two schema-version probes (`userVersion`, `samplesSchemaVersion`) through `SQLiteSupport.openReadOnly`; the other five (`perSourceCounts`, `rowCount`, `kindHistogramBySource`, `freshnessBySource`, `journalMode`) still opened naively with `sqlite3_open_v2(READONLY)`, so a present WAL database with no `-shm` sidecar (a locally-built corpus) read back as empty / zero. A new shared `openReadOnlyProbe(at:)` helper wraps `SQLiteSupport.openReadOnly` (the WAL `immutable=1` fallback), and all seven probes now call it. New `Issue1281ProbesRobustOpenTests` builds a checkpointed WAL `docs_metadata` DB with its sidecars removed and asserts `rowCount` reads 5 (not 0) and `perSourceCounts` reads the per-source rows, with a contrast test proving a naive read-only open of the same file cannot read it. Full suite: 3156 pass. - **fix(#1284): the crawler's enqueue dedup key is normalized, and a cross-stage URI golden test pins one-logical-URL → one-indexed-URI → resolves-on-read.** The Apple-docs crawler deduped discovered links on the raw `link.absoluteString` at enqueue, but `visited` holds normalized keys and the pop path normalizes before fetching: casing / dash-underscore variants of one logical URL each got their own queue entry, and a raw link that normalized to an already-visited URL was re-enqueued (the raw key never matched the normalized `visited` set). The enqueue now normalizes via `Shared.Models.URLUtilities.normalize` and dedups + queues the normalized form, making it consistent with the `visited` set and the pop path (an un-normalizable link is dropped exactly as the pop guard would). Risk was crawl-efficiency / duplicate-corpus, not a query-correctness break. New `Issue1284URICrossStageGoldenTests` feeds four casing/dash-underscore variants of one logical URL through the production seams and asserts they collapse to one normalized crawl key, one `apple-docs://` canonical URI (read-stage `normalizeReadDocumentURI` equals the index-stage `appleDocsURI`), and that a `Search.Index.getDocumentContent` read with ANY variant resolves to the single indexed document. (The enqueue loop itself has no hermetic mock-loader seam — only a real-network integration test — so it has no per-loop regression test; the fix is correct by construction, using the same canonicalizer as `visited`/pop, and the golden test locks the normalization invariant.) Full suite: 3153 pass. - **fix(#1282): the exact canonical shipped database set is now pinned, catching both an accidental addition and an accidental drop.** Nothing asserted the precise shipped membership: `ConstantsAuditTests.descriptorRegistryFloor` is a `>= 10` floor over the descriptor+legacy union (a floor cannot catch an addition), `DatabaseBundleManifestTests` pins manifest-vs-registry drift (tautological re: membership) and excluded only the legacy `search.db`, and the "8" appeared only in comments. New `Issue1282CanonicalShippedSetTests` pins `CLIImpl.bundleRequiredDescriptors()` (the single canonical derivation, exactly what `cupertino setup` extracts + post-extract-verifies) to the exact 8 canonical sources by id, so both an addition and a drop fail as a discrete row (proven non-vacuous: removing one id fails the exact-set assertion), and asserts a fresh install's required filename set carries neither the legacy unified `search.db` nor the pre-#1037 orphan `samples.db` while including the canonical `apple-sample-code.db`. Adding a real source now updates a reviewed literal in the same PR. Full suite: 3150 pass. - **fix(#1283, partial): the sample-code MCP tools now throw an actionable missing-database frame, with a regression test mirroring the search-tool family.** `list_samples` / `read_sample` / `read_sample_file` guard on a nil `sampleDatabase` but threw a dead-end "Sample code database not available" with no remediation, and (unlike the parameterized 10-search-tool error-frame test in `Issue645ToolsListHonestyTests`) had no missing-DB error-frame test. A new `sampleDatabaseUnavailableError(_:)` helper (mirroring `searchIndexUnavailableError`) names both remediation paths ("Run `cupertino setup` to download it, or `cupertino save --source samples` to build it"), used at all four sample-DB-unavailable sites including the `source: samples` search path. New `Issue1283SampleToolMissingDBFrameTests` parameterizes over the three sample tools and asserts each throws a frame naming both remedies (proven non-vacuous: reverting to the old message fails all three cases). The other half of #1283 (per-source-DB degrade for the docs tool family) remains open: the MCP search tools run against one primary docs reader with a single `searchIndexDisabledReason`, and there is no per-source tool surface to degrade independently until the per-source docs readers land (epic #919). Full suite: 3148 pass. - **fix(#1280): read-only write-rejection is now asserted at the reader-object level across the reader types, not only on the `SQLiteSupport` helper.** The #1194 guarantee ("an end user cannot write or delete rows in any shipped database") was tested only on `SQLiteSupport.openReadOnly` in isolation; nothing attempted a write through the real reader objects the read / serve path constructs, and `Search.Connection` / `Sample.Index.Database` both default `readOnly` to `false`, so the guarantee rested on every caller remembering the flag. A refactor that dropped it would have shipped a writable user database with every other test still green. New hermetic, CI-runnable suites (`Issue1280SearchReadOnlyWriteRejectionTests`, `Issue1280SampleReadOnlyWriteRejectionTests`, not gated on `CupertinoCLI.available`) construct each reader the production way (`Search.Connection(readOnly: true)`, `Search.Index(readOnly: true)`, `Search.PackageQuery(dbPath:)`, `Sample.Index.Database(readOnly: true)`), attempt INSERT / UPDATE / DELETE through the reader's own connection, assert `SQLITE_READONLY`, and prove the on-disk row count is unchanged via an independent handle. The two actor readers gained a `#if DEBUG`, internal `attemptWriteForReadOnlyAudit(_:)` seam (absent from release binaries and the public API) so the write is attempted on the actor's own encapsulated connection. Proven non-vacuous: temporarily opening the readers read-write fails all four tests. Full suite: 3147 pass. - **fix(#1279): read-only `apple-sample-code.db` and `packages.db` opens now gate on schema version, failing loudly on a skew instead of serving wrong answers.** The write path wipes-and-rebuilds on a schema bump, but the read / serve / list path is strictly read-only (#1194) and cannot rebuild, and both readers opened a version-skewed database silently: `Sample.Index.Database`'s read-only branch returned right after `openDatabase()` with no version check, and `Search.PackageQuery.init` opened through the shared read-only path with no `user_version` check. A present but out-of-date database (e.g. an old bundle left in place after a binary upgrade) was therefore queried against a schema this binary does not understand. Both readers now verify the on-disk schema version on read-only open and throw an actionable mismatch (`Sample.Index.Error.schemaVersionMismatch` / `Search.PackageQueryError.schemaVersionMismatch`) naming the two versions, the path, and the remedy (`brew upgrade cupertino` when the file is newer, `rm && cupertino setup` when older). The samples gate mirrors `wipeIfStale`'s fire/suppress criteria exactly (it gates only a genuine samples DB whose version it can read); the packages gate treats an unstamped `user_version` of 0 as fresh, not a skew, so it never false-positives. Isolated by red/green tests (`Issue1279SampleSchemaGateTests`, `Issue1279PackageQuerySchemaGateTests`: skewed older/newer throw the actionable message, matching and unstamped open). Surfaced by the pre-release adversarial review. Full suite: 3143 pass. - **fix(#1194): schema-version probes read through the robust read-only open, so a present WAL database with no `-shm` sidecar is no longer misreported as schema 0.** `Diagnostics.Probes.userVersion` / `samplesSchemaVersion` (consumed by `list-sources` / the `list_sources` MCP tool and by `doctor`) opened with a naive `sqlite3_open_v2(READONLY)`, so a present, valid, WAL-mode database whose `-shm` shared-memory index is absent (a locally-built corpus) read back as 0, making a healthy corpus look broken and pushing clients to re-run setup. The probes now open via `SQLiteSupport.openReadOnly` (the WAL `immutable=1` fallback). Also hardened `SQLiteSupport.canRead` to probe `SELECT count(*) FROM sqlite_master` (which forces a schema-b-tree read) instead of the header-only `PRAGMA user_version`, so a corrupt-schema database is detected rather than passing the readability check. Isolated by a red/green test (`DiagnosticsProbesReadOnlyTests`: a checkpointed WAL DB with sidecars removed reads 18, not 0; plus the genuine-version-0 and absent cases). Surfaced by the pre-release adversarial review. Full suite: 3135 pass. - **fix(#1194): read-only open now reads a checkpointed WAL database with no `-shm` sidecar instead of misreporting "schema version 0".** `SQLiteSupport.openReadOnly` opens with `SQLITE_OPEN_READONLY`, which cannot create the `-shm` shared-memory index a WAL-mode database needs; the open succeeds but the first read traps with `SQLITE_CANTOPEN`, and the schema check swallowed that as version `0` and told the user to rebuild an intact database. The open now probes the connection and, when the read fails while the `-wal` carries no pending frames (so the committed state is wholly in the main file), reopens `immutable=1` to read the file directly; when the `-wal` does carry frames it fails honestly asking for a checkpoint rather than returning stale data. Shipped rollback-mode bundles are unaffected (their first read succeeds). Reproduced and locked by a red/green test in `SQLiteSupportReadOnlyTests` (a checkpointed WAL DB with its sidecars removed now reads `user_version` 18 and stays strictly read-only). Surfaced while proving the desktop #88 `list_children`/`list_documents` path end to end against a locally-built (WAL) corpus. ### Added - **docs(#1270): record mobile catalog installation.** The README roadmap now names the next gate as mobile catalog install through `CatalogStore`, and `docs/design/mobile-catalog-delivery.md` defines the accepted mobile storage contract: install catalog data under `Application Support/Catalogs`, use App Group namespacing only for shared targets, exclude it from backup, never use `Documents`, and keep DB details inside Cupertino. - **docs(#1269): document the release-corpus smoke script surface.** The repo script now has folder-structured docs under `docs/scripts/eval/release-corpus-smoke/`, including `` and `--help`, matching the command documentation convention for arguments and options. - **test(#1269): add an on-demand release-corpus smoke gate.** `scripts/eval/release-corpus-smoke.sh` now builds the current checkout's `cupertino`, runs a temp-configured copy against a prepared release corpus such as `~/.cupertino`, and validates doctor/search/read/list/sample/package/AST/inheritance surfaces without invoking `setup`, `fetch`, `save`, or reindexing. A manual `Release Corpus Smoke` GitHub workflow can run the same gate from a prepared runner path or downloaded corpus artifact. - **fix(#1261): adopt `CupertinoDataEngine` 0.2.6 current-corpus layout.** The external engine now matches the release bundle layout: `apple-sample-code.db` is opened through the sample reader only, and the package corpus stays on the release `packages.db` filename. This fixes the first live `LocalEmbeddedBackend` smoke failures against the installed `~/.cupertino` corpus while preserving the opaque corpus boundary. - **test(#1261): validate `cupertino-desktop` embedded smoke against the installed release corpus.** `scripts/check-local-embedded-corpus.sh` in `cupertino-desktop` now opens `~/.cupertino` through `CatalogStoreAPI` and `CupertinoDataEngine` only, then exercises frameworks, docs search/read, unified search, samples, and package search through `Backend.Documentation`. The smoke passed locally with `CupertinoDataEngine` 0.2.6. - **test(#1261): record repeatable iOS build proof for `CupertinoDataEngine`.** The external engine now carries `scripts/check-ios-build.sh`, which builds the complete embedded engine closure for both generic iOS Simulator and generic iOS device destinations through Xcode. The proof passed locally after the v0.2.4 opaque corpus adoption, so the pre-UI readiness roadmap advances to desktop/mobile `CatalogStore` opaque-handle integration. - **feat(#1261): adopt `CupertinoDataEngine` 0.2.4 opaque corpus handles.** App-facing embedded engine construction can now use `CupertinoDataEngine.Corpus` plus `CupertinoDataEngine(corpus:)`, so callers hand Cupertino one installed corpus directory while the engine owns resource filenames, schema validation, and read-only SQLite construction. `CupertinoComposition` now exposes per-source and legacy corpus-handle helpers and its normal read-only engine conveniences route through the opaque corpus initializer; the lower-level `Configuration` helper remains available for focused composition tests. - **feat(#1261): adopt `CupertinoDataEngine` 0.2.3 for complete read-only bundle construction.** The external engine now owns read-only SQLite construction for source, sample, and package corpora through the public `CupertinoDataEngine(configuration:)` initializer. `CupertinoComposition` still supplies Cupertino's schema-versioned bundle configuration, but no longer injects local `Search.Index`, `Sample.Index.Database`, or `Search.PackageQuery` factories. Engine-package tests now cover real fixture reads across source, sample, package, sample-symbol, and package generic-symbol paths; Cupertino adds a composition-seam test and bumps the dependency minimum to `from: "0.2.3"`. - **docs(#1270): add the pre-UI readiness Mermaid roadmap.** The README roadmap now shows #1270 as the active gate before native UI work and adds a dedicated Mermaid chain from the merged #1268 read-surface baseline through the remaining embedded-engine, opaque-corpus, iOS-build, `CatalogStore`, `LocalEmbeddedBackend`, and release-corpus-smoke gates before UI showcase work starts. - **feat(#1261): adopt `CupertinoDataEngine` 0.2.2.** The patch tag adds the first public source-corpus construction path: `CupertinoDataEngine(sourceCorpusResources:)` opens configured source corpora read-only inside the external engine package and is covered by engine-package tests for search, document reads, framework/document browsing, symbol lookup, platform minima, availability, and inheritance. This is intentionally a source-reader slice, not the final full bundle: samples, packages, and Cupertino's production parity path still remain #1261 follow-up work, so app UI code continues to depend only on backend contracts and never sees storage files or concrete reader types. - **feat(#1261): add the first external `CupertinoDataEngine` read-only backend facade for app clients.** The external `CupertinoDataEngine` package exposes source, sample, and package reader facades while keeping concrete SQLite wiring behind Cupertino-internal SPI and `CupertinoComposition`. The normal public surface is `sourceIDs`, `sourceReader(id:)`, `documentBrowser(id:)`, `samples()`, and `packages()`; UI layers do not open SQLite directly, import concrete storage targets, or need to know storage files exist. Focused engine-package tests cover schema probing, missing corpus-resource failures, schema mismatch failures, fake source/sample/package reader wiring, and the composed document-browser contract. #1261 remains open for extracting the concrete read-storage closure so external embedded apps can construct a real DB-backed engine without SPI. - **feat(#1261): adopt `CupertinoDataEngine` 0.2.1.** The patch tag adds a public empty-facade initializer for downstream previews and composition tests, so clients can exercise `MobileBackend.live(engine:)` without importing Cupertino-internal SPI. It does not change the complete production construction path; concrete readers still come from `CupertinoComposition` until the remaining #1261 storage extraction lands. - **feat(#1260): expose typed JSON output for desktop sample and code-intelligence MCP tools.** `list_samples`, `read_sample`, `read_sample_file`, `search_symbols`, `search_property_wrappers`, `search_concurrency`, `search_conformances`, `search_generics`, and `get_inheritance` now accept `format=json` while preserving markdown as the default. Sample JSON exposes project lists, project metadata with file summaries, and file content. Semantic JSON exposes typed symbol rows, generic-search per-source arrays, and inheritance responses with machine-readable status plus title-bearing tree nodes so `cupertino-desktop` can decode GUI data without scraping markdown or touching SQLite directly. - **feat(#1210): add document child listing for desktop outline browsers.** New CLI command `cupertino list-children [--source apple-docs] [--format json|text|markdown]` and MCP tool `list_children` return direct child nodes (`source`, `parentURI`, `children[{uri,title,kind,hasChildren}]`) from existing apple-docs rawMarkdown topic sections. Non-document topic headings are represented as `topic-group` fragment URIs, so desktop clients can drill from `apple-docs://swiftui` to `apple-docs://swiftui#Essentials` and then to readable document URIs. - **feat(#1208): add framework document listing for desktop clients.** New CLI command `cupertino list-documents --framework [--source apple-docs] [--offset 0] [--limit 100] [--format json|text|markdown]` and MCP tool `list_documents` return a paged JSON contract (`source`, `framework`, `offset`, `limit`, `total`, `documents[{uri,title,kind}]`) from the existing apple-docs database. The source capability matrix gains `list-documents`, docs/help/tool descriptions are wired through `docs/commands/list-documents/` and `docs/tools/list_documents/`, and focused SQLite/CLI/MCP tests pin pagination and output shape for cupertino-desktop. - **docs: README links the project's X account.** Added an X badge ([@cupertinomcp](https://x.com/cupertinomcp)) to the badge row alongside the existing PulseMCP and LobeHub listings, plus a plain-text "Follow updates on X" line under the badges. - **docs: declutter the README top.** Removed the two-paragraph release-notes blockquote from above the project description (it pushed the badges and tagline below the fold). The first thing a visitor now sees is the tagline, description, badges, and demo, followed by a single one-line `Latest:` pointer. The full v1.3.0 release detail moved down into a refreshed `## Project Status` section (which was stale at v1.2.0), with prior-release history collapsed to a one-line summary pointing at `CHANGELOG.md`. - **docs: split the 425-line Quick Start, extracting per-client setup into `docs/`.** The README's Quick Start was half the file, mostly 13 per-client MCP setup guides. The full per-client reference moved to a new `docs/mcp-clients.md` (Claude Desktop, Claude Code, OpenAI Codex, Cursor, VS Code, GitHub Copilot for Xcode, Zed, Windsurf, opencode), and the Agent-Skill instructions moved to a new `docs/agent-skill.md`. The README keeps Claude Desktop + Claude Code inline as the canonical examples and links out for the rest; both new docs are listed in the README `## Documentation` section, and the Installation note now points at `docs/mcp-clients.md` instead of "the sections below". README dropped from ~873 to ~667 lines. ### Changed - **refactor(#1209): `doctor` resolves every source's corpus directory uniformly from the registry, dropping the `--docs-dir` / `--evolution-dir` options.** `checkDocumentationDirectories` (the `doctor --save` raw-corpus walk) carried a per-source `switch info.sourceID` that overrode the directory for exactly 2 of 6 sources (`apple-docs` → `--docs-dir`, `swift-evolution` → `--evolution-dir`) while the other four already resolved from `paths.directory(named:)`. In a health command those partial overrides carried no real value, and the `switch` was a per-source edit-point a new content source would have had to extend, a Source-Independence-Day axiom violation. Every source now resolves through the single registry default; the two `@Option`s, their now-dead `Shared.Constants.HelpText.docsDir` / `evolutionDir` constants, and their orphan option docs (`docs/commands/doctor/option (--)/docs-dir.md`, `evolution-dir.md`) are removed (`check-docs-commands-drift.sh` stays green at 23 commands, 0 orphans). Behavior is preserved: verified against the installed corpus, `doctor --save` still lists Apple Documentation at `~/.cupertino/docs` and Swift Evolution at `~/.cupertino/swift-evolution`, identical to the prior no-override default, alongside the other four sources. The `Issue1158CommandOptionParityTests` doctor case drops the two removed flags. `save` keeps its own `--docs-dir` / `--evolution-dir` (the maintainer crawl path), unaffected. Full suite: 3177 pass. - **refactor(storage): split Search.Index into read-only Search.Index and write-capable Search.Indexer.** Split database write concerns out of the read-side `Search.Index` actor into a dedicated write-capable `Search.Indexer` actor. Added a backward-compatibility layer on `Search.Index` for legacy unit tests. This enables clean in-process execution on mobile platforms where indexing/write capabilities are omitted. - **docs: remove stale pre-v1.3 design docs and relabel historical schema plans.** Deleted the outdated `docs/design/cupertino.md` and `docs/design/how-cupertino-answers-a-query.md`, redirected references to the current README / architecture docs, refreshed `docs/DEPLOYMENT.md` to v1.3.0, and marked #837-era schema/enrichment plans as historical records instead of live behavior docs. - **docs(readme): render the GitHub landing-page architecture as Mermaid.** The README now shows the package stack, fetch/save/serve catalog flow, and desktop/mobile app backend boundary as native Mermaid diagrams instead of plain text or linked-only diagrams. - **docs: refresh README, architecture, command, artifact, source-manifest, and tool docs against the current v1.3.0 per-source bundle.** Corrected stale single-DB wording, Swift Book / Swift.org fetch and save paths, archive fan-out behavior, Apple-docs raw JSON corpus examples, `package-search --swift-tools`, `search --swift` / `--apple-imports`, `constraints-gen conformances`, and the command/help text that still used retired `--type` terminology. - **docs(regression): refresh command/tool docs after release-corpus smoke.** Updated the command index to list the semantic CLI commands, refreshed stale short-URI and package-read examples in `docs/commands/read`, `docs/commands/search`, and `docs/tools/read_document`, and recorded the current full Swift Testing suite count plus honest lint/format status in the README so the documented regression surface matches the current binary and v1.3.0 release corpus. - **refactor(#1261): adopt `CupertinoDataEngine` 0.2.x for composed embedded search.** The external engine now conforms to `Search.Database` and `Search.DocumentBrowsing` itself, routing document reads by URI source, merging framework/document counts, fanning symbol/inheritance/availability/resource calls across configured source readers, and applying lightweight RRF fusion for unified search results including packages. The dependency minimum is bumped to `from: "0.2.2"` so desktop and mobile backends can inject one engine facade instead of wiring one source reader and silently losing cross-source search. Cupertino still keeps the current production reader construction in `CupertinoComposition` until the remaining #1261 parity slices land; UI clients still see contract protocols, not storage paths or concrete storage objects. - **refactor(#1261): extract `CupertinoDataEngine` out of the monorepo.** `cupertino` now depends on `https://github.com/mihaelamj/CupertinoDataEngine.git` from `0.1.0`, with `CupertinoComposition` supplying the Cupertino-internal concrete factories that wrap `SearchSQLite` and `SampleIndexSQLite`. The old in-tree target and tests were removed, and the strict producer guard returns to 48 in-tree producers because the engine is no longer audited as a monorepo producer. - **refactor(contract): adopt `CupertinoDataKit` 0.3.0 for package search.** The package-search reader seam now lives in the external `CupertinoDataKit` contract as `Search.PackagesSearcher`, with its availability and Swift-tools filters alongside it. `SearchModels` re-exports the shared contract instead of carrying duplicate local definitions, so embedded/native clients can depend on the same package-search surface without importing Cupertino storage internals. - **fix(#1261): harden the `CupertinoDataEngine` facade ownership rules.** The new data-engine product is exposed only when its target declarations are present in the manifest, preserving the existing non-macOS package shape, and returned source/sample reader wrappers are borrowed views: calling `disconnect()` on them no longer closes the engine-owned cached connection. `CupertinoDataEngine.disconnect()` remains the single lifecycle owner. - **refactor(contract): adopt `CupertinoDataKit` 0.2.0 for document browsing.** The `list-documents` / `list-children` DTOs and optional read-side protocols now live in the external `CupertinoDataKit` contract as `Search.DocumentListing`, `Search.DocumentChildrenListing`, and composed `Search.DocumentBrowsing`, so embedded/native UI clients can implement the same browser surface without importing cupertino internals. `SearchModels` now only re-exports that surface, the duplicate in-repo contract files were removed, document-browser limit constants forward to DataKit, and the portability recipe resolves `CupertinoDataKit` from `0.2.0`. The source contract stays open-ended: current built-in IDs are convenience constants, not a closed source registry. - **chore(hooks): make `pre-commit run --all-files` usable again.** The SwiftLint pre-commit hook now uses the root repository config explicitly with forced excludes, avoiding accidental nested package-config drift, and the SwiftFormat all-files drift has been applied so the full hook suite can pass cleanly. - **fix(#1210): harden `list-children` tree metadata for desktop clients.** Topic-group `hasChildren` now reflects readable DB-backed child rows instead of raw markdown links that may point at missing documents, and fragment calls return the matched canonical topic-group URI in `parentURI` so tree cache keys and breadcrumbs stay stable across case-normalized input. Added regression coverage for both cases and refreshed stale `list-documents` example URIs. - **fix(#1041): `list-frameworks` clarifies that its document count is framework-scoped.** Post per-source-DB split, the command sums only the sources that declare `.listFrameworks` (apple-docs + apple-archive); HIG / swift-evolution / swift-org / swift-book are excluded because their framework column is empty. The text and markdown formatters now print the contributing source IDs under the total (e.g. `(counts cover the framework-scoped sources: apple-docs + apple-archive)`), derived from the registry fan-out rather than hardcoded, so a new framework-scoped source appears in the caveat with zero formatter edits. `docs/commands/list-frameworks/README.md` example numbers refreshed to the v1.3.0 bundle ground truth (412 frameworks, 351,873 documents). - **docs(#1146): document the incremental / resumable behavior of `save`.** The incremental skip (a non-`--clear` save skips docs whose content hash is unchanged, before AST extraction) shipped in #1148 but was undocumented in `save --help`. Added an INCREMENTAL section to the command discussion and clarified the `--clear` flag help, including the guidance to use `--clear` after a binary upgrade since an unchanged content hash does not prove the stored AST extraction is from the current extractor. The capability is always-on for non-`--clear` saves (accepted as-built rather than the originally-proposed opt-in `--resume` flag); `docs/commands/save/option (--)/clear.md` gained the extractor-upgrade caveat. - **fix(#1255): un-stale the CLI smoke harness + fix stale doc URIs.** `scripts/eval/cli-smoke.sh` asserted the pre-#1036 unified DB names (`search.db` / `samples.db`) and aborted under `set -e` at the first miss, so probes 3-6 (search / read / list-frameworks / inheritance) never ran. It now asserts the per-source names `doctor` actually prints (`apple-documentation.db`, `apple-sample-code.db`, `packages.db`) using a non-aborting `&& assert 0 || assert 1` pattern; the full smoke passes end-to-end again. Also corrected stale example URIs in `README.md` and `docs/agent-skill.md` (`apple-docs://swiftui/documentation_swiftui_navigationstack` -> `apple-docs://swiftui/navigationstack`); the tool emits the short form, so the old examples 404 in `read`. - **refactor(mcp): adopt the extracted `SwiftMCPServer` package; delete the in-tree server + transport copies.** The MCP server runtime that lived in `Packages/Sources/MCP/Core/Server` (the `MCP.Core.Server` actor, `ServerError`, and the `ResourceProvider` / `ToolProvider` / `PromptProvider` / `ProviderCapabilities` seams) and `Core/Transport` (`Channel`, `Message`, `Failure`, `Stdio`) is now owned by the standalone, public `SwiftMCPServer` package, a sibling of `SwiftMCPCore` and `SwiftMCPClient` that completes the wire / client / server trio. The `MCPCore` target gains a `.package(url: SwiftMCPServer.git, exact: "0.1.0")` edge and `MCP.swift` collapses to a single `@_exported import SwiftMCPServer`; because `SwiftMCPServer` re-exports `SwiftMCPCore`, every `import MCPCore` consumer still sees `MCP.Core.Protocols.*` and the runtime unchanged, so there are zero callsite edits. `SwiftMCPServer` declares the `MCP.Core.Transport` sub-namespace itself, so cupertino no longer re-introduces it. cupertino keeps only its own pieces: `Core/Protocol` (`CupertinoIcon`, `MCPShared`) and the `DocsResourceProvider` concrete, which still conforms to the now-external `MCP.Core.ResourceProvider` seam transitively through the re-export. Behavior-preserving by design and proven against the package: the existing #581 / #611 / #613 / #618 regression suites (43 tests / 8 suites) and the `DocsResourceProvider` Support suites (75 tests / 15 suites) pass on `SwiftMCPServer` 0.1.0, with the emoji stderr logging prefixes, version negotiation sourced from `MCPProtocolVersionsSupported`, `.sortedKeys` framing, and the `-32602` non-`ServerError` error catch-all all intact. Pinned `.exact("0.1.0")` so the extraction is byte-identical to the prior in-tree code; the additive 0.2.0 (spec-correct `ping` health check, opt-in resource subscribe / notify) is a deliberate follow-up rather than an implicit `from:` float. Net `+69 / -960` in the cupertino tree. ## v1.3.0 (2026-05-31) ### Breaking - **fix: remove the `--search-db` flag and rename the internal `searchDB` identifiers to generic `dbURL` / `dbPath`.** The legacy `--search-db` docs-database-path override is removed from all six commands that carried it (`search`, `read`, `save`, `doctor`, `list-frameworks`, `inheritance`). Post-#1037 each docs source resolves to its own per-source database through the registry, so the single-file override no longer has a meaning worth surfacing; scripts passing `--search-db` now error. Internally every `searchDB` / `searchDb` identifier was renamed to the generic `dbURL` / `dbPath` (there is no single "the db"; the bundle has 8 per-source databases and grows) (~230 sites across CLI / Services / Indexer / SearchModels / SearchToolProvider). `doctor`'s `--kind-coverage` / `--freshness` probes, which previously pointed at the legacy `search.db` path (absent on a per-source bundle, so they silently skipped), now target the per-source apple-docs database. The literal `search.db` filename remains only where the upgrade shim (`Distribution.PerSourceDBSplitMigrator`, invoked by `cupertino setup`) must detect a real pre-v1.3.0 file by name. Regression test `searchDBFlagRejectedEverywhere` pins the removal across all six commands. - **feat(#1108): `cupertino fetch --source packages` no longer runs the Swift Package Index metadata refresh by default; `--skip-metadata` removed, replaced by opt-in `--refresh-metadata`.** Pre-#1108 the default ran two stages: (1) refresh metadata + star counts for all 10,995 packages tracked by Swift Package Index (throttled at `Shared.Constants.Delay.packageFetchNormal = 1.2 s` per call; ~4 hours without `GITHUB_TOKEN`), and (2) download the curated 135 priority package archives from `codeload.github.com` (~100 s). Stage 1's output is consumed only by the TUI's stars-sort view; the load-bearing `packages.db` indexing pipeline only needs stage 2. So the slow stage was the dominant cost of the command for a use case nobody triggers from the indexer flow. Fix: flip the default. `cupertino fetch --source packages` runs stage 2 only (measured end-to-end: 97 s for the 185-archive closure on a clean dev base). The pre-#1108 `--skip-metadata` flag is removed and replaced by the opt-in `--refresh-metadata`. `Search.FetchEnvironment.skipMetadata` was renamed to `refreshMetadata` in lockstep so CLI flag and env-field polarity match. `--skip-archives` now only refuses to run with an empty-pipeline diagnostic (pair it with `--refresh-metadata` or `--annotate-availability`). Help text + `docs/commands/fetch/option (--)/refresh-metadata.md` + `docs/commands/fetch/option (--)/source (=value)/packages.md` + `docs/commands/fetch/option (--)/skip-archives.md` + `docs/commands/fetch/option (--)/source.md` + `docs/commands/fetch/option (--)/annotate-availability.md` + `docs/commands/fetch/README.md` + `docs/artifacts/folders/packages/README.md` + `docs/commands/search/option (--)/source (=value)/packages.md` updated. `docs/commands/fetch/option (--)/skip-metadata.md` removed. `Indexer.Preflight` doctor remediation string + `PackagesFetchStrategy.FetchError.nothingToDo` description updated to name the current flag surface (no stale `--skip-metadata` references in user-facing output). **Migration:** consumers passing `--skip-metadata` drop it (now the default); consumers relying on the pre-fix two-stage default add `--refresh-metadata`. 5-case `FetchPackagesMergeTests` (`#1108: removed --skip-metadata flag no longer parses` + the renamed `refreshMetadata` parse tests + the renamed nothing-to-do guard) pins the surface. ### Added - **test(#1196): exhaustive per-enrichment + inheritance/conformances/min-version battery, Apple-styled HTML report.** `ExhaustiveEnrichmentBatteryTests` (local-snapshot-gated, skipped on CI) proves all 24 enrichments from `docs/enrichment-inventory.md` through the real CLI, recording each into the shared `BatteryReport` HTML: #1 lexical (~20+ queries across all 8 DBs), #2/#5/#7 symbol boosting + AST extraction + identifier splitting, #9/#10 generic constraints (`search-generics`), #11 framework aliasing (alias term to framework), #21/#22/#23 query-time ranking (RRF fan-out / intent authority / exact-title rerank), #24 AST boilerplate demotion, #3/#17 deployment floors (every `--min-` option), #14/#16 structured projection + code examples (via `read --format json`), plus `search-conformances` and `inheritance` over >= 20 args each. The internal stored columns the CLI does not surface (#4 toolchain, #6 imports, #8 availability, #12 HIG platform NULLing, #13 apple-imports, #18 dependency closure, #19 provenance, #20 row bookkeeping) are proven by a `DBProbe` populated-row check (>= 20 rows, or the real count for tiny corpora like swift-book's 2 toolchain rows). The `BatteryReport` HTML is restyled Apple-HIG-flat (SF system fonts, `#1d1d1f` text, `#0071e3` accent, `#f5f5f7` surfaces, rounded cards, hidden disclosure triangles). Every suite validated green against the local snapshot. - **test(#1194): exhaustive read battery that emits a self-contained HTML report across all 8 databases, every run.** `ReadOnlyReadBatteryTests` (in the local-snapshot-gated `EnrichmentBatteryTests` target, skipped on CI) drives the production CLI through the read-only path: ~10 search queries per docs source, samples, and packages, plus >= 20 document reads from EVERY database (apple-docs, hig, apple-archive, swift-evolution, swift-org, swift-book, apple-sample-code, packages) and the AST search commands, asserting on result tagging + content shape rather than bare length. As it runs, the `BatteryReport` writer (Swift, in the same target) regenerates a self-contained HTML report with a collapsible section per query and per document showing the actual returned text, to `$CUPERTINO_READ_REPORT` (default `Packages/.build/read-battery-report.html`). Verified 20/20 reads on every database with zero failures. `CupertinoCLI.searchDocs` now accepts both docs-search JSON shapes (bare array, and the `{count,query,results}` list-view that `--source hig` alone emits). - **harden(#1194): query / read / serve open every database read-only, so an end user cannot write or delete rows.** All readers now open through one shared helper, `SQLiteSupport.openReadOnly(at:)` (a new low-level, schema-agnostic producer target depended on by both `SearchSQLite` and `SampleIndexSQLite`), which uses `SQLITE_OPEN_READONLY`: the handle is physically incapable of writing, so INSERT / UPDATE / DELETE / DDL all fail with `SQLITE_READONLY`. `Search.Index` and `Sample.Index.Database` gain a `readOnly` init flag (default false) that opens read-only and skips every open-time write (no WAL/synchronous PRAGMA, no migration, no `CREATE TABLE`, no schema-version stamp); `Search.PackageQuery` now uses the same shared helper (retiring its bespoke `immutable=1` path from #1190, which #1192's rollback-mode shipping makes unnecessary). The read paths pass `readOnly: true`: `LiveSearchDatabaseFactory` (docs: search / read / ask / list-frameworks / inheritance / AST search / package file lookup), `LiveSampleIndexDatabaseFactory` (samples: read-sample / list-samples), the MCP `serve` search + sample loaders, and the smart-search samples fetcher. Write access is reserved for the indexer (`save` / `fetch`) and the setup per-source-split migrator. Enabled by #1192: a plain read-only open needs no `-shm` on a rollback-mode shipped DB. New `SQLiteSupportReadOnlyTests` proves the contract (SELECT works; INSERT / UPDATE / DELETE all return `SQLITE_READONLY`; row count unchanged), and since all three readers delegate to the one helper, the guarantee holds uniformly for every DB. Verified live: `package-search` reads a rollback `packages.db` with no `-shm` (only possible via a read-only open), and `search` / `read` serve queries unchanged. The new `SQLiteSupport` target is registered in the foundation-tier allow-list of `check-target-foundation-only.sh` and given a row in `docs/package-import-contract.md` so the package-import audits stay green. - **fix(#1192): ship bundle databases in rollback (read) journal mode so every read-only open works on a fresh extract.** All bundled DBs were shipping in WAL journal mode (header write/read version bytes `02 02`), because `Search.Index` / `PackageIndex` / `Sample.Index` each run `PRAGMA journal_mode = WAL` on open (for #236 concurrent-reader support) and WAL is a persistent header mode. A freshly-extracted WAL DB has no `-shm`/`-wal` sidecar, and SQLite cannot open a WAL database read-only without creating the `-shm` shared-memory index, so every plain `SQLITE_OPEN_READONLY` open failed with `SQLITE_CANTOPEN` until some writer created the sidecar. This fully broke `packages.db` (its only reader is read-only; fixed at the reader in #1190) and silently degraded `doctor` for the docs DBs on a pristine extract (`⚠ Schema version: could not read PRAGMA user_version`, because `Diagnostics.Probes` opens read-only). The release tool (`Release.Publishing.convertToRollbackJournal`, called from `Release.Command.Database` after the #236 checkpoint) now converts each DB to rollback (`DELETE`) journal mode before zipping: a rollback-mode DB needs no `-shm`, so the shipped artifact opens read-only uniformly with no per-reader workaround and no read-write-first dependency. This is a header/mode flip only (`PRAGMA journal_mode=DELETE`); content (rows, FTS, enrichments) is untouched and `integrity_check` stays `ok`, so no re-index is needed. `cupertino doctor` now treats `journal=delete` as the expected read-only distribution mode (no warning) alongside `journal=wal` (the locally-indexed mode), via the extracted `journalModeNote` helper. Verified end-to-end: a `--dry-run` bundle extracts 8 DBs all in rollback mode (header `01 01`), each opens read-only with a plain `SQLITE_OPEN_READONLY` (no `-shm`) and passes `integrity_check`, and `doctor` run immediately after a fresh `setup` reads `user_version` for every DB. New `ConvertToRollbackJournalTests` (proves the WAL-no-shm read-only failure and the post-conversion success + clean sidecars) and three `journalModeNote` cases in `Issue919SchemaVersionLineFormatTests`. - **fix(#1190): `packages.db` is queryable immediately after a fresh `cupertino setup`.** A freshly-extracted bundle `packages.db` is in WAL journal mode with no `-shm`/`-wal` sidecar, and SQLite cannot open a WAL database read-only without creating the `-shm` shared-memory index, so `Search.PackageQuery`'s bare `SQLITE_OPEN_READONLY` open failed with `SQLITE_CANTOPEN` until some writer created the sidecar. Nothing ever opens `packages.db` read-write in the normal flow (the docs DBs dodge this because `Search.Index` opens them read-write first, which creates their `-shm`), so on a fresh bundle `cupertino search --source packages` reported `packages: database unopenable` and `cupertino package-search` returned nothing, even though the DB was intact (`integrity_check = ok`, `user_version = 5`). `Search.PackageQuery` now opens via the `file:?immutable=1` URI (`SQLITE_OPEN_READONLY | SQLITE_OPEN_URI`): `immutable=1` is SQLite's access mode for a static read-only database, skipping the `-shm`/`-wal` machinery entirely, needing no write permission, and creating no sidecar. Bundle DBs are static artifacts and the release tool already checkpoint-truncates each one before zipping, so the main file is complete; `PackageQuery` is a pure SELECT path, so read-only-immutable is semantically correct. Verified live: against a freshly-extracted v1.3.0 bundle (`packages.db` with no `-shm`), `cupertino package-search "async"` now returns results and the unified search lists `packages` among the searched sources. New `Issue1190PackageQueryReadOnlyOpenTests` builds a WAL-mode FTS DB, strips its sidecars to the fresh-extract shape, and asserts `PackageQuery` opens + queries it (plus pins the `immutableURI` format). Pre-existing bug, not introduced by the #1071 per-source bundle (reproduced on any fresh extract). - **chore(ci): move the Standalone-portability proof to a manual-only workflow.** The portability job built ~15 lifted targets standalone on a `macos-15` runner (billed at 10x) on every `pull_request` and every `push` to main/develop, but gave no signal on the typical diff that does not touch a package boundary (it never even parses non-`Target.target(` blocks). Extracted it verbatim from `ci.yml` into a new `.github/workflows/portability.yml` triggered by `workflow_dispatch` only, so it costs zero automatic runner minutes and is run on demand from the Actions tab (or locally via `scripts/check-target-portability.sh `). The cheap source-level `package-audits` import audits still run on every PR; only the expensive build-the-lifted-subset proof became opt-in. - **fix(#1071): `cupertino-rel databases` bundles the per-source DBs derived from the source registry, not a hardcoded legacy 3.** The release tool still hardcoded the pre-split `search.db` + `samples.db` + `packages.db` filename trio (with a `--allow-missing-packages` escape hatch) and threw when `search.db` was absent, so it could never build the v1.3.0 per-source bundle the v1.3.0 binary expects. `Release.Command.Database` now derives the bundled set from `CupertinoComposition.makeProductionSourceRegistry().allEnabled.map(\.destinationDB)`, deduped by `filename` with stable order, which is the identical manifest `cupertino setup` reconstructs via `CLIImpl.bundleRequiredDescriptors()`. Adding a new source therefore auto-extends the bundle with zero edits here, and a stray pre-split `search.db` in the base directory is never bundled (no enabled source declares it). The packages-specific `--allow-missing-packages` flag is generalised to `--allow-missing` (drops any absent derived DB to a warning instead of erroring). The `ReleaseTool` target gains `CupertinoComposition` + `SearchModels` deps; a new `DatabaseBundleManifestTests` drift guard asserts `Database.bundledDescriptors()` equals the registry-derived filename set and that `search.db` is excluded. Verified end-to-end: `cupertino-rel databases --dry-run` against the v1.3.0 snapshot bundles exactly the 8 per-source DBs (`apple-documentation.db`, `hig.db`, `apple-sample-code.db`, `apple-archive.db`, `swift-evolution.db`, `swift-org.db`, `swift-book.db`, `packages.db`) and excludes the stray `search.db`. Docs updated: `docs/binaries/cupertino-rel/subcommand/databases/README.md` + new `option (--)/allow-missing.md` + `docs/DEPLOYMENT.md`. - **fix(#1071): bump `databaseVersion` to 1.3.0 for the per-source DB bundle.** `cupertino setup` builds its download URL from `Shared.Constants.App.databaseVersion`, so the constant is the bundle-isolation gate: a binary only ever fetches `cupertino-databases-v.zip`. Post-#1036 the binary expects the 8 per-source DBs (`apple-documentation.db`, `hig.db`, `swift-org.db`, `swift-book.db`, `swift-evolution.db`, `apple-archive.db`, `apple-sample-code.db`, `packages.db`) but the constant was still `1.2.0`, so setup pulled the old unified `search.db` zip and hard-failed placement. Bumping to `1.3.0` points setup at `cupertino-databases-v1.3.0.zip` (the per-source bundle). The version string guarantees two-way safety: a v1.2.x binary keeps fetching the `v1.2.0` unified bundle, a v1.3.0 binary fetches the per-source bundle, and neither can download the other's incompatible DBs. Requires the matching `v1.3.0` release to be published to `mihaelamj/cupertino-docs`; until then setup fails cleanly with a missing-bundle error rather than placing wrong DBs. - **docs: README documents the three published packages.** A new "Published Packages" section under Architecture lists the reusable Swift packages factored out of the monorepo, each its own public repo depended on by tag: `SwiftMCPCore` (neutral MCP wire types), `SwiftMCPClient` (neutral transport-injectable MCP client), and `CupertinoDataKit` (cupertino's public read contract — the documentation + sample-code read protocols and value types). - **fix(#1073): de-flake `Issue1073HIGPlatformInferenceTests` audit recorder.** The test's `RecordingAudit` double conformed to the synchronous `Search.EnrichmentAuditObserver` protocol via an `actor` whose `nonisolated` methods recorded events with fire-and-forget `Task { await append(...) }`. The append happened after the producer's synchronous `record*` call returned, so `snapshotEntries()` could read before the appends landed — empty `docURIs` under parallel CI load (the `:353/:354` flake that reddened Build & Test across #1180/#1182/#1183 while passing in isolation). Replaced with a lock-guarded synchronous `final class` (`@unchecked Sendable`, `NSLock`-serialized) that appends inline before returning, faithful to the protocol's synchronous contract. Verified: 16/16 in isolation AND the full parallel suite now passes 0 issues (was 2 every prior run). - **test: guard the CupertinoDataKit re-export seam.** `CupertinoDataKitReexportTests` (in `SearchModelsTests`) names the re-exported read protocols (`Search.DocumentReading` / `SymbolReading` / `Database`), the read value types, and `CupertinoDataKit.Limits` through `SearchModels` without importing CupertinoDataKit directly, so a dropped `@_exported import` fails this one test at compile time instead of cascading through every consumer target. Pins the 3 contract constants (1500 / 20 / 100). - **chore(ci): teach the foundation-tier guards about the external `CupertinoDataKit` package.** Two CI guard scripts learned the new dependency: `check-target-foundation-only.sh` adds `CupertinoDataKit` to the foundation-tier allow-list (it is foundation-only and zero-dep — the layer `SharedConstants` itself depends on, so `SharedConstants`'s `@_exported import CupertinoDataKit` is allowed), and `check-target-portability.sh` adds the package's URL recipe (`https://github.com/mihaelamj/CupertinoDataKit.git`, `from: "0.1.0"`) so a lifted target whose closure includes `SharedConstants` resolves the dependency when built standalone. Verified locally: foundation-only check passes (48 producers), `SearchAPI` portability builds standalone. - **refactor: extract cupertino's read contract into the standalone `CupertinoDataKit` package.** The documentation + sample-code READ surface (the `Search.Database` protocol — now sliced into `Search.DocumentReading` core + `Search.SymbolReading` refinement + a `Search.Database` composition — plus `Sample.Index.Reader`, and every value type they reference: `Search.Result`, `MatchedSymbol`, `PlatformAvailability`, `DocumentFormat`, `SymbolSearchResult`, the inheritance types, `URIResource`/`ResourceListMode`, `FrameworkAvailability`, `PlatformMinima`/`PlatformFilter`, and `Sample.Index.Project`/`File`/`FileSearchResult` + `Sample.Search.Query`/`Result`) moved out of the in-repo `SearchModels` / `SampleIndexModels` targets into a new Foundation-only, zero-in-repo-dependency package owned + published by cupertino (`mihaelamj/CupertinoDataKit`). It is the single source of truth: `SharedConstants` (the foundation-most layer) re-exports it via `@_exported import CupertinoDataKit`, so every cupertino target sees the `Search` + `Sample` namespaces unchanged with no per-target import edit. The 3 contract constants (`summaryMaxLength`, `defaultSearchLimit`, `maxSearchLimit`) move into `CupertinoDataKit.Limits` so the package stays Foundation-only. cupertino's `Search.Index` actor conforms to the moved protocol; an embedded/in-process reader (e.g. an iOS app) can conform `DocumentReading` alone. Producer-side logic (the `Search.SourceProvider.resourceListMode` default) stays in cupertino. The dependency is the published, versioned package `https://github.com/mihaelamj/CupertinoDataKit.git` pinned at `from: "0.1.0"` (v0.1.0 = `df4446a4`); cupertino built against a local `path:` during the extraction and swapped to the URL once the package was published + tagged. - **fix(#1181): cap the FTS5 query at 32 terms + teach agents cupertino is a keyword index, not prose search.** `Search.Index.sanitizeFTS5Query` now drops terms past `maxFTS5QueryTerms` (32) before building the `MATCH`, so a pathological multi-KB query (e.g. an AI agent pasting a paragraph) cannot blow FTS5's expression-tree limits; excess terms are dropped, not errored, so the call still returns results. The `search` MCP tool description is rewritten to state cupertino is a SQLite FTS5 keyword index (NOT semantic / natural-language), to send 1-5 keywords rather than sentences, and that only the first ~32 terms are used. New `sanitizeFTS5Query caps the term count` test in `SearchIndexQueryParsingTests`. - **feat(#1172): MockAIAgent adopts the neutral SwiftMCPClient + a CLI/MCP parity battery.** `MockAIAgent` now drives the transport-injectable `SwiftMCPClient` over `Transport.Subprocess` (via the `Client.MCP` seam) instead of a hand-rolled stdio `MCPClient` actor; `connect()` owns the `initialize` handshake plus the `notifications/initialized` lifecycle notification a spec-compliant `cupertino serve` requires before dispatching requests. `Package.swift` adds the `SwiftMCPClient` dependency (brings `SwiftMCPCore` transitively) and wires `SwiftMCPClient` / `SwiftMCPClientAPI` / `SwiftMCPSubprocessTransport` / `SwiftMCPTransport` into the target. Live-verified end-to-end against the fresh per-source corpus (connect -> serverInfo -> 12 tools -> search -> 500 resources -> read -> disconnect). Adds a `CLIMCPParityBatteryTests` suite (+ a `CupertinoMCP` serve-driver helper) under `EnrichmentBatteryTests`: 8 command surfaces x 20 real queries each, asserting the CLI and the `serve` MCP tool agree on the same query; gates on the local DB snapshot so it skips cleanly on CI. The battery surfaced one CLI/MCP divergence (search-generics `AdditiveArithmetic`), tracked in #1179. - **test(#1158): comprehensive CLI command + option parse-binding parity suite.** `Issue1158CommandOptionParityTests` (29 tests) asserts every subcommand in the root `Cupertino.configuration.subcommands` is registered (count + `defaultSubcommand` pinned) and that, for each of the 20 commands, representative argv binds every `@Option` / `@Flag` / `@Argument` to its supplied value (enums like `--format` / `--direction` / `--discovery-mode`, short flags, `.prefixedNo` inversions `--no-only-accepted` / `--no-recurse`, and `.customLong` flags like `--protocol` are all covered). The 5 AST commands additionally assert the #1157-retired `--search-db` flag is rejected. Surface depth, mirroring the MCP marker / #962 parity tests; behavioral coverage stays in the existing per-command suites (`SearchToolProvider`, `Save` / `Fetch` / `Serve` / `Doctor`, `Issue1154MultiDBFanOutTests`). Complements `check-docs-commands-drift.sh` (which enforces option existence + docs + `--help` presence) by adding the parse-binding layer the drift check cannot reach. - **feat(ci, #1135): orphaned-source guard fails CI when a `.swift` file is compiled by no SwiftPM target.** SwiftPM silently ignores source files that fall outside every target's resolved `path:`, so a test dropped in such a spot never runs and never errors (`swift test` reports `0 tests`). This bit #1125, where `OutputFormatAliasTests.swift` landed at the `Tests/CLICommandTests/` root, a directory with no target of its own (only its subfolders are targets). New `scripts/check-orphaned-sources.sh` diffs every `.swift` under `Packages/Sources/` + `Packages/Tests/` against the resolved source set from `swift package describe`, and fails listing any orphan; wired as a fail-fast step in the `Build & Test` CI job. Verified: clean tree passes (863 files, 0 orphans), a deliberately-orphaned file trips it (exit 1, names the file). - **feat(#747): `--format md` is accepted as an alias for `--format markdown` across CLI commands.** The shared CLI output-format parser now normalises `md` to `markdown`, preserving existing `markdown` scripts while making the file-extension shorthand work for `search`, `read`, sample commands, list commands, inheritance, and the AST search siblings. Help text and command docs now mention `markdown / md`; `md` produces the same formatter selection as `markdown`. - **feat(#5): `cupertino fetch --request-delay ` exposes the crawler's inter-request delay (default 0.05).** Pre-fix the per-request delay was not configurable from the CLI. The web-crawl fetch strategy now threads a validated `requestDelay` from a new `--request-delay` option through `Search.SourceFetchStrategy` into the crawler `Configuration`. The value must be finite and `>= 0`; negative values are rejected before any fetch (`cupertino fetch --request-delay=-0.1` exits with a validation error; ArgumentParser requires the `=` form to pass a negative number). New `FetchTests` cases cover default + explicit parsing and the negative-value guard, and `docs/commands/fetch/option (--)/request-delay.md` documents the flag. The three PR-touched files are also scrubbed of em-dashes (comments, help text, suite name) per the project convention. Full suite green: 2986 tests / 458 suites, the only failures being two pre-existing parallel-test flakes (#1073 HIG inference, #581 serve cold-start) that pass in isolation. - **feat: symbol-graph-derived Apple conformance graph enrichment.** New `cupertino-constraints-gen conformances` subcommand produces `apple-conformances.json`, the conformance sibling of `apple-constraints.json`: **17,654 conforming types / 104,917 `conformsTo`+`inheritsFrom` edges across 251 frameworks** extracted from the SDK symbol graphs, vs ~8,595 AST-extracted conformances in the DB today (the rendered DocC markdown spells out a fraction; the symbol graph carries the full set, same rationale as the constraints pass #759). A new `Enrichment.AppleConformancesPass` overwrites `doc_symbols.conformances` for matched Apple URIs (exact + hash-prefix match, mirroring `applyAppleStaticConstraints`); the table loads optionally at save time (present → applied, absent → no-op, no schema change), so it is non-breaking until the artifact is distributed. Producer + consumer proven: 5 `ConformanceExtractor` fixture tests + 3 `applyAppleStaticConformances` SQL-apply tests, and the producer regenerates 104,917 edges from the corpus in 7.6 s. Pipeline: SymbolGraph `relationships`/USR decode → `AppleConstraintsKit.ConformanceExtractor` → `ConformanceTable` → `conformances` CLI → `Search.StaticConformancesLookup` → the pass. The conformance DATA lands in the shipped apple-docs DB once `apple-conformances.json` is committed to cupertino-docs (for `cupertino setup`) and apple-docs is re-enriched. - **feat(#962): MCP/CLI option-level parity for the 5 AST search commands.** The `search_symbols` / `search_property_wrappers` / `search_concurrency` / `search_conformances` / `search_generics` MCP tools merge `platformFilterProperties` (`min_ios` / `min_macos` / `min_tvos` / `min_watchos` / `min_visionos`) into their input schema, but the CLI siblings exposed none of them, so an agent driving cupertino over the shell could not apply the platform floors the MCP surface advertises. (The unified `search` already carried all 13 of its tool's params, and `inheritance` matched `get_inheritance`; the 5 AST commands were the only gap.) Added a shared `CLIImpl.PlatformFloorOptions` `@OptionGroup` (`--min-ios` through `--min-visionos`) to all 5 commands, each now applying the same filter the MCP handler does. The fetch-minima plus `Search.PlatformFilter.passes` application was lifted out of `CompositeToolProvider` into a shared `Search.Database.applyingPlatformFloors(to:floors:)` extension plus a validated `Search.PlatformFloors` value type in SearchModels, so the MCP provider and the CLI filter through one code path (no duplication; the MCP arg-validation and error frames are unchanged, delegating to it). The `#962` parity guard (`Issue962MCPCLIParityTests`) now asserts BOTH command-level parity (every MCP tool has a CLI command) and option-level parity (each AST command's `helpMessage()` exposes the 5 floors). 25 new `docs/commands//option (--)/min-*.md` files keep `check-docs-commands-drift.sh` green. 72 parity + platform-filter + tools-list + AST-CLI tests pass; the #226/#665 MCP platform-filter suites confirm the refactor is behaviour-preserving. - **chore(#1149): `add-package` PR template, and removal of the stale `Packages/priority-packages.json`.** Adds `.github/PULL_REQUEST_TEMPLATE/add-package.md`, a named PR template (selectable via `?template=add-package.md`) for the lightweight path of getting a single Swift package into the shipped corpus: it points contributors at the build-time embedded catalog `Resources.Embedded.PriorityPackages.swift` (the `.swift` edit keeps an external PR Swift-only), spelling out the `apple_official` vs `ecosystem` entry shapes, the per-tier `count` bump, and a real `swift build` + `--filter PriorityPackages` test plan. Removes the committed `Packages/priority-packages.json`, a v1.0 / 58-package relic on the obsolete four-tier `priority_levels` schema that had diverged from the embedded v1.1 / 135-package catalog and that nothing in the build read (not an SPM resource; the generator reads its JSON input from `/tmp/catalogs`, and runtime `priority-packages.json` references are the user-local `~/.cupertino/` copy). It was a trap: editing the file by its name changed nothing that ships. The embedded catalog is now the single documented source of truth. - **chore(#1133): community-health profile completion.** Adds `CODE_OF_CONDUCT.md` (Contributor Covenant 2.1, enforcement contact set), `.github/PULL_REQUEST_TEMPLATE.md` (summary / linked issue / test plan / checklist aligned with CONTRIBUTING.md's issue-first + Swift-only + CHANGELOG conventions), and `.github/ISSUE_TEMPLATE/config.yml` (blank issues disabled, contact link) completing the pre-existing `bug.yml` / `feature.yml` forms. Takes GitHub's community-health checklist from 57% (missing code-of-conduct / PR-template / issue-template-config) toward 100%; the percentage only updates once these reach the default branch (`main`). - **test(enrichment): per-enrichment real-DB battery covering all 24 enrichments in `docs/enrichment-inventory.md`.** New `EnrichmentBatteryTests` target with one suite per enrichment, each verified against the real per-source DBs (resolved from a local snapshot via `CUPERTINO_DB_DIR`, opened read-only with `immutable=1`). Every enrichment gets a DB-probe layer; the ten with a query surface also get a `cupertino search` layer that drives the debug binary end-to-end (FTS, BM25F weights, RRF fan-out, intent routing, kind-aware rerank, AST signal-rank). Suites gate on `.enabled(if:)` so a machine without the snapshot skips cleanly (CI/other Macs). Framework Aliasing (#11) behaviour tests are `.disabled` with a detector that flips when the apple-documentation snapshot is rebuilt with the correct `--docs-dir` (current snapshot has `framework='docs'`, so the 22 synonyms never attach). 83 tests, 33 suites, all green against the 2026-05-28 snapshot. Byproduct findings pinned: `operator` is a defined-but-never-extracted `SymbolKind` (16 defined / 15 realized); deployment floors live on `docs_metadata`, not `docs_structured`. - **feat(#1114): packages.db MAX-merges per-file `@available` aggregation with `Package.swift` deployment targets.** Pre-fix `package_metadata.min_` was stamped from `Package.swift platforms:` only; the per-file `@available(platform X.Y, ...)` attributes already collected into `availability.json` by `Core.PackageIndexing.PackageAvailabilityAnnotator` (#219) and persisted per-file by `PackageIndexer.loadAvailability` were never aggregated into the project-level platform columns. Parallel to #1111 for apple-sample-code. Fix: in `PackageIndexer.loadAvailability`, also aggregate `result.fileAvailability.flatMap(\\.attributes)` via the shared `SampleAvailableAttributeAggregator` (lifted to a foundation-tier consumer via a new `SampleIndexModels` dep on `SearchSQLite`), MAX-merge with the `Package.swift` deployment-targets dict, and tag the row `package-available-aggregated` when the aggregator contributed the dominant value on any platform (else `package-swift` as before). Cross-target naming follow-up tracked separately; aggregator code is unchanged. After reindex (184 priority packages): **60 packages flipped to `package-available-aggregated`** (32% of the corpus), platform coverage rose from 94 → 128 iOS, 105 → 138 macOS, 12 → 31 visionOS. Headline movers: swift-argument-parser, swift-async-algorithms, swift-distributed-actors, swift-crypto, swift-certificates, swift-http-types — all now correctly stamp the @available floor instead of the underspecified Package.swift floor. New `Issue1114PackagesAvailableAggregationTests` (8 cases) pin the MAX-merge logic, the package-available-aggregated tag emission, the equal-values-don't-flip-tag rule, the nil-handling, and the dict round-trip. 2887/2887 tests green. - **feat(#1063): `cupertino save` exposes `--hig-dir` + `--swift-book-dir` flags to override the default corpus location for the last two sources missing this surface.** Pre-fix `cupertino save` had typed corpus-directory overrides for 6 of 8 shipped sources (apple-docs, swift-evolution, swift-org, apple-archive, packages, samples) but `cupertino save --source hig --hig-dir /path` errored with `Unknown option '--hig-dir'`. Users had to symlink `/hig` or `/swift-book` to their corpus dir manually before running save — not discoverable. Fix: add `--hig-dir` and `--swift-book-dir` options to `CLIImpl.Command.Save`, wire them into the existing `cliOverrides` dict consumed by `CLIImpl.makeDocsIndexingDirectoryByKey`. The Request struct already carried `higDir: URL?` (was hardcoded to nil at the call site); now it's threaded from the CLI flag. Helps maintainer workflows that fetch into custom paths. 2879/2879 tests green (one pre-existing Issue1073 parallel-test flake unrelated to this diff). - **feat(#1103): swift-book chapters stamp `implementation_swift_version` from their per-chapter Swift-version floor.** Pre-fix `docs_metadata.implementation_swift_version` was populated only by `Search.Strategies.SwiftEvolution` (#225 Part B); every other source's rows had it NULL. swift-book has had per-chapter Swift floors in `SwiftBookChapterVersions.table` since #1095 (concurrency → 5.5, macros → 5.9), but those values only fed the platform stamp; the toolchain column stayed empty. Fix: extend `SwiftBookChapterVersions.ChapterFloor` with a `swiftVersion` field (5.5 / 5.9 / nil for the universal baseline); add an `implementationSwiftVersion(for url: URL) -> String?` method to the `Search.PlatformVersionsResolver` protocol with a default implementation returning nil (no existing conformer needs to change); `SwiftBookChapterVersionsResolver` overrides it to return the chapter's floor.swiftVersion; the shared `crawlSwiftDocumentation` helper passes the value through to `index.indexStructuredDocument(...)` so every swift-book row picks up the right toolchain stamp. After reindex: `concurrency` row stamps `min_ios=13.0, implementation_swift_version=5.5`; `macros` row stamps `min_ios=17.0, implementation_swift_version=5.9`; the other 41 chapters stay NULL on `implementation_swift_version` (no specific Swift-version requirement). `cupertino search --source swift-book --swift 5.5 "concurrency"` now filters correctly. New `Issue1103SwiftBookChapterSwiftVersionTests` (6 cases) pin the per-chapter Swift-version mapping (`concurrency` → 5.5, `macros` → 5.9), the universal-baseline nil case, the default-extension nil return for non-overriders, and the end-to-end `SwiftBookChapterVersionsResolver.implementationSwiftVersion(for:)` URL → version path so a future refactor of slug extraction can't silently break the stamp. The table also covers `structuredconcurrency` + `actors` defensively (Apple's swift-book ships one chapter that groups all three topics under `concurrency` today; the extra entries make the table ready if the URL structure ever splits). 2875/2875 tests green. - **feat(#1111): apple-sample-code projects gain per-file `@available` aggregation as a new availability signal, MAX-merged with the framework-table floor.** Pre-fix `Sample.Index.Builder.resolveSampleAvailability` only consulted (1) `Package.swift` deployment targets, then fell back to (2) `SampleFrameworkAvailability` framework lookup. `ASTIndexer.AvailabilityParsers.extractAvailability` already collected every `@available(platform X.Y, ...)` attribute from each sample's swift sources into the sidecar's `fileAvailability` array, but the projects-table row's `min_` columns were never derived from it. Result: a sample that touches `iOSApplicationExtension 14.0` APIs in its sources still landed `min_ios = 10.0` from `SampleFrameworkAvailability["sirikit"]`, so `cupertino search --source samples --min-ios 11 "sirikit"` falsely matched it. New `SampleAvailableAttributeAggregator` in `SampleIndexModels` parses both Swift `@available` grammars (shorthand `(iOS 14.0, *)` AND labeled `(iOS, introduced: 14.0)`) and aggregates per-platform MAX. Platform names are normalised (`iOSApplicationExtension` → `iOS`, `macCatalyst` → `iOS`, `xrOS` → `visionOS`). Versions are emitted in `X.Y` shape (`14` → `"14.0"`) so the column doesn't carry two stored shapes for the same logical version. `Sample.Index.Builder` hoists the swift-file walk out before `indexProject` so the aggregation feeds the resolution; the main file-index loop reuses the pre-computed per-file attrs via a `[relpath: [Attribute]]` lookup instead of re-parsing the swift sources. **Tier rule** (post-critic-pass): `Package.swift` wins outright; otherwise per-platform MAX-merge of (aggregator, framework). Tagged `sample-available-aggregated` when the aggregator contributed the dominant value on any platform, otherwise `sample-framework-inferred`. Critic-fix vs. the initial first-non-nil approach: a SwiftUI sample with a back-port helper `@available(iOS 11.0, *)` no longer stamps `min_ios = 11.0` (lowering SwiftUI's 13.0 floor); the MAX-merge stamps 13.0 from the framework table. Same restructuring applied to `indexProjectDirectory`, including per-file `available_attrs_json` writes which were previously NULL on directory-imported rows. New `Issue1111SampleAvailableAggregationTests` (16 cases) + `Issue1111ResolveSampleAvailabilityTests` (7 integration cases) pin the parser shape, MAX-per-platform, ApplicationExtension normalisation, Mac Catalyst → iOS, xrOS → visionOS, wildcard-only / deprecated / unavailable / renamed skip, labeled `introduced:` form, deprecated-only label yields nothing, padded version emission, the four-tier ordering, and the MAX-merge back-port-helper case. 2864/2864 tests green (was 2841; +23). - **feat(#1097): swift-org server-side content stamped Linux-only (38 rows correctly excluded from Apple-platform filters).** Pre-fix every swift-org row carried the universal Swift platform baseline (iOS 8.0 / macOS 10.9 / tvOS 9.0 / watchOS 2.0 / visionOS 1.0). Correct for blog posts, articles, install guides, getting-started content. Wrong for `documentation_server_*` pages — those are Linux-deployment Swift guides (AWS Lambda, Vapor, Heroku) with no Apple-platform applicability. `cupertino search --source swift-org --min-ios 14 "AWS"` returned Linux deployment guides that have nothing to do with iOS. Fix: new `SwiftOrgPlatformResolver` (reuses the `Search.PlatformVersionsResolver` seam from #1095) — server-side URIs stamp all 5 `min_` columns NULL; everything else inherits the universal Swift baseline. After reindex: 38/469 server-side rows correctly NULL'd, 431 cross-platform rows unchanged. `--min-ios 14 "AWS"` excludes server-side guides; queries without platform filter still surface them. 2826/2826 tests green. - **feat(#1095): swift-book per-chapter Swift-version platform inference.** Pre-fix every swift-book row was stamped with the universal Swift baseline (iOS 8.0 / macOS 10.9 / tvOS 9.0 / watchOS 2.0 / visionOS 1.0). Correct for most chapters but wrong for chapters covering Swift features introduced later: `concurrency` requires Swift 5.5 (iOS 13.0+); `macros` requires Swift 5.9 (iOS 17.0+). `cupertino search --source swift-book --min-ios 12 "macros"` returned the macros chapter despite iOS 12 deployment targets being unable to use macros. Fix: new `Search.PlatformVersionsResolver` protocol seam in `SearchModels` + new `SwiftBookChapterVersions` table in `SwiftBookSource` mapping URL slug → per-platform floor. `SwiftBookStrategy` passes a `SwiftBookChapterVersionsResolver` into the shared `crawlSwiftDocumentation` helper; chapters in the table receive per-chapter floors (`concurrency`/`structuredconcurrency`/`actors` → Swift 5.5 floor; `macros` → Swift 5.9 floor), others inherit the universal baseline. After reindex: `--min-ios 17 "macros"` includes the chapter, `--min-ios 12 "macros"` excludes it. Resolver is the same plug pattern other strategies could adopt (swift-org's content is mostly cross-platform Swift evangelism, deferred). 2826/2826 tests green. - **fix(#1073): HIG search `runHIGSearch` threads `--min-ios` / `--min-macos` / `--min-tvos` / `--min-watchos` / `--min-visionos` / `--swift` CLI flags through to the SQL.** Pre-fix `CLIImpl.Command.Search.SourceRunners.runHIGSearch` built `Services.SearchQuery` WITHOUT the 6 platform-version + Swift-version filter arguments (the docs runner directly above passed all 6). The CLI flags were parsed by ArgumentParser, surfaced on `--help`, but silently dropped on the HIG path — `cupertino search hig --min-ios 16 watchos` returned every HIG row regardless of platform. Spotted while validating the #1073 platform-inference data fix end-to-end: the SQL filter `AND m.min_ios IS NOT NULL` in `Search.Index.Search.swift` line 193 was never being reached because no `effectiveMinIOS` was set on the HIG path. Post-fix: HIG runner threads all 6 args into the SearchQuery init, matching the docs runner shape exactly. Combined with #1073's platform-NULL stamping + the dehyphenated URI-variant parallel rules added in the same PR, `cupertino search hig --min-ios 16 watchos` no longer returns `designing-for-watchos` / `watch-faces` / their `-appledeveloperdocumentation` duplicates. 2791/2791 tests green. - **feat(#1073): HIG-specific topic-aware platform-inference enrichment pass, pluggable via `Search.SourceProvider.makeSourceSpecificEnrichmentPasses` at the docs tier.** Pre-fix every HIG row was stamped with the earliest version of every Apple platform (iOS 2.0, macOS 10.0, watchOS 1.0, tvOS 9.0, visionOS 1.0) as a baseline default, so `cupertino search hig --min-ios 16` returned watchOS-only and visionOS-only HIG pages whose URI declares the explicit platform (designing-for-watchos, spatial-layout, mac-catalyst, carplay, etc.). New `Search.Index.applyHIGPlatformInference` SQL pass + `Enrichment.HIGPlatformInferencePass` adapter NULL the non-applicable `min_` columns for 10 URI-prefix rules; cross-platform HIG topics (buttons, alerts, accessibility, layout, color) keep their defaults. The pass SELECTs matching URIs before each UPDATE so the audit JSONL records one entry per real `doc_uri` (mirroring `AppleStaticConstraints` + `HierarchyPass`), and narrows the UPDATE to rows that still need it (idempotent re-runs report `rowsAffected=0`). Source-pluggability at the docs tier: instead of a CLI-side `if descriptor.id == .hig` conditional importing the HIG-only enrichment module, `Search.SourceProvider` gains a `makeSourceSpecificEnrichmentPasses` factory (default `[]`), `HIGSource` returns its one pass, and the CLI's docs-tier composition iterates the providers' passes with zero per-source knowledge. Adding a future docs-tier source-specific enrichment pass is a 2-file PR (new pass module + provider override); the CLI's docs-tier composition never touches the new module. Scope note: the packages and samples tiers each have a single source today; per-source enrichment is N/A there until a second source joins either tier (follow-up #1075). 2774/2774 tests green. - **feat: per-save enrichment audit JSONL log; mandatory `apple-constraints.json` presence at save start with `--allow-degraded-enrichment` opt-out.** New `Search.EnrichmentAuditObserver` protocol (foundation tier) consumes per-pass events: `recordPassStart` / `recordEntry` (per matched URI with doc_uri, value, match_type, rows_affected) / `recordPassEnd`. `Search.Index.applyAppleStaticConstraints` + `propagateConstraintsFromParents` + `AppleConstraintsPass` + `HierarchyPass` all accept the observer + a `dbPath` tag so one log file can carry events from multiple per-source DBs in the same save. New `CLIImpl.LiveEnrichmentAuditWriter` writes the events as one JSON object per line at `/enrichment-.jsonl`. Save startup now hard-fails if `apple-constraints.json` is missing from the base directory; new `--allow-degraded-enrichment` CLI flag opts out for fixture smoke runs + fresh dev installs before `cupertino-constraints-gen` ran. Pre-fix: missing file silently degraded enrichment to iter 1+2 (~16% `doc_symbols.generic_constraints` coverage) instead of iter 3 (~38%); a 9.5-hour Claw mini reindex finished in that state with zero warning and we caught it only by manually inspecting the DB. Now: hard error at save start surfaces the gap BEFORE 12 hours of wall time. 2774/2774 tests green. - **#978: behavioural tests for the 6 strategy siblings + AppleConstraintsPass.** Surfaced by today's full-app rule-canon audit (`docs/audits/2026-05-23-rule-canon-audit.md` MED-4). Pre-#978 the only coverage for the strategy family was a 26-line metatype-existence smoke (`_ = Search.X.self`); `AppleConstraintsPass` had zero test files. Per testing-discipline.md the rule is "real tests, not green totals". Added `Packages/Tests/SearchStrategiesTests/SearchStrategiesBehaviouralTests.swift` with one fixture test per strategy exercising the `indexItems(into:progress:)` clean-skip path (the load-bearing #671 guard) plus a `source`-identifier assertion that catches copy-paste regressions; plus two `AppleConstraintsPass` tests (nil-lookup no-op + non-matching-lookup `rowsAffected == 0`, both verifying the #979 honest-metrics contract). 9 new behavioural tests; metatype smoke retained as type-export guard. Test target dependencies widened to include `SearchSQLite`, `LoggingModels`, `EnrichmentModels` for real `Search.Index` construction. Refs: closes #978. - **#975: close package-import-contract drift; ship coverage-check CI script.** Surfaced by today's full-app rule-canon audit (`docs/audits/2026-05-23-rule-canon-audit.md` HIGH-2). The contract is `per-package-import-contract.md`'s "single source of truth a reviewer can grep against", but no CI script enforced row presence, so drift was silent. The 14-PR merge batch surfaced 9 production targets without contract rows (6 strategy siblings + `AppleConstraintsPass` + older `MCPClient` + `SampleIndexSQLite`). Append 9 contract rows + ship `scripts/check-import-contract-coverage.sh` (wired into the `package-audits` CI job) that diffs `Packages/Package.swift` declared targets against `docs/package-import-contract.md` row identifiers and fails CI on missing rows. Composition-root binaries (CLI/TUI/MockAIAgent/ReleaseTool/ConstraintsGen) + `TestSupport` are excluded as intentional exemptions, called out explicitly in the script. Initial run: 52 production targets, all rows present. Refs: closes #975. - **#948 phases 3 / 4 / 5: `cupertino search-concurrency` + `search-conformances` + `search-generics` CLI subcommands mirror the remaining 3 AST MCP tools.** Completes the 5-subcommand AST CLI surface filed in #948. Each new command calls the matching `Search.Index.search*` SQL path the MCP handler uses; each carries the same three output formats (text/json/markdown) as the earlier phases. **Phase 3 `search-concurrency --pattern

`** mirrors `search_concurrency`; `--pattern` accepts `async` / `actor` / `sendable` / `mainactor` / `task` / `asyncsequence` (post-iter-1 critic: aligned with the MCP schema's 6-value description in `CompositeToolProvider.swift:searchConcurrencyProperties`). **Phase 4 `search-conformances --protocol `** mirrors `search_conformances`; the required option uses `@Option(name: .customLong("protocol"))` to expose the CLI flag `--protocol` while keeping the Swift property `protocolName` (avoids the `protocol` reserved-keyword collision). **Phase 5 `search-generics --constraint `** mirrors `search_generics`; matches against the `generic_constraints` column the AST extractor populates from both inline `` and where-clause `where T: Foo` forms. 15 per-option doc files (5 per command) under `docs/commands/search-{concurrency,conformances,generics}/option (--)/` + 3 READMEs. Smoke regression coverage at `Packages/Tests/CLITests/Issue948Phase345ASTToolsCLITests.swift` (6 tests: each subcommand's `--help` advertises the canonical options, missing required argument exits non-zero). All-subcommands-registered test bumped 17 → 20. Drift script COMMANDS array extended to 19 entries; reports 0 drift. With phases 1-5 landed, the 5 AST MCP tools have CLI siblings; the Phase 2 query battery (#944) can now drop its MCP-stdio path in favor of CLI shell-out (separate follow-up). Refs: closes #948. - **#948 phase 2: `cupertino search-property-wrappers` CLI subcommand mirrors the `search_property_wrappers` MCP tool.** Second of 5 AST CLI subcommands. New command at `Packages/Sources/CLI/Commands/CLIImpl.Command.SearchPropertyWrappers.swift`: `cupertino search-property-wrappers --wrapper [--framework ] [--limit ] [--format text|json|markdown] [--search-db ]`. Calls the same `Search.Index.searchPropertyWrappers` SQL path the MCP handler uses; inherits the #952 canonical-framework-boost (e.g. `--wrapper State` surfaces SwiftUI rows above same-attribute rows in other frameworks; `--wrapper MainActor` surfaces UIKit/SwiftUI/AppKit/RealityKit rows above declaration-only Swift std-lib rows). Three output formats (text/json/markdown) matching phase 1's shape; required `--wrapper` accepts both `State` and `@State` forms. 5 per-option doc files under `docs/commands/search-property-wrappers/option (--)/` + README at `docs/commands/search-property-wrappers/README.md`. Smoke regression coverage at `Packages/Tests/CLITests/Issue948Phase2SearchPropertyWrappersCLITests.swift` (2 tests: per-command `--help` advertises canonical options, missing required `--wrapper` exits non-zero). All-subcommands-registered test bumped 16 → 17. Drift script COMMANDS array extended to 16 entries; reports 0 drift. Tracking issue for follow-up CLI auto-from-MCP-registry refactor filed as #962. Phase 2 of 5. Refs: #948. - **#948 phase 1: `cupertino search-symbols` CLI subcommand mirrors the `search_symbols` MCP tool.** Pre-#948 the 5 AST tools (`search_symbols`, `search_property_wrappers`, `search_concurrency`, `search_conformances`, `search_generics`) had no CLI equivalents; shell users + the Phase 2 query battery had to spawn `cupertino serve` and speak JSON-RPC over stdio just to exercise them. New command at `Packages/Sources/CLI/Commands/CLIImpl.Command.SearchSymbols.swift`: `cupertino search-symbols [--query ] [--kind ] [--is-async] [--framework ] [--limit ] [--format text|json|markdown] [--search-db ]`. Calls the same `Search.Index.searchSymbols` SQL path the MCP handler uses; inherits the `#177` operator-demote + kind-shape reranking + `#670` exact-name boost. Three output formats: `text` (default, terminal-friendly, one symbol per line with framework + URI + signature/attributes/conformances sub-lines), `json` (filters block + results array; per-symbol shape mirrors `Search.SymbolSearchResult` with `snake_case` keys), `markdown` (matches the `formatSymbolResults` shape the MCP handler emits, grouped by document, with `### ` blocks and indented metadata sub-lines). Smoke regression coverage at `Packages/Tests/CLITests/Issue948SearchSymbolsCLITests.swift` (2 tests: subcommand reachable from root `--help`, per-command `--help` advertises canonical options). Wired into `scripts/check-docs-commands-drift.sh` (now checks 15 commands; 7 per-option doc files under `docs/commands/search-symbols/option (--)/`). Phase 1 of 5; the remaining 4 AST CLI subcommands (`search-property-wrappers`, `search-concurrency`, `search-conformances`, `search-generics`) follow the same pattern and will land separately. Refs: #948. - **#944 / #945 / #946 / #947: Phase 2-5 query batteries implemented; honest paired baseline documented; 118 fixtures total exercise every user-facing cupertino surface end-to-end.** Closes 4 of the 4 phase issues filed under the #943 umbrella, completing the v1.0 query-battery contract documented at `docs/audits/eval-harness-standard-v1.0.md`. **Honest paired baseline (corrected after 2-iteration critic loop)**: Phase 1 MRR 0.9467 = 0.9467 (50 fixtures), Phase 2 MRR 0.6000 = 0.6000 (20 fixtures, 8 fail honestly , `search_property_wrappers` is broken for common wrappers, filed as #952), Phase 3 MRR 0.8636 = 0.8636 (22 fixtures, 3 fail honestly , `cupertino inheritance` exits 1 with empty output for value-type symbols, filed as #953), Phase 4 MRR 0.9231 = 0.9231 (13 fixtures, 1 fails honestly , `cupertino read <absent-uri>` exits 1 with empty output, also #953), Phase 5 MRR 0.8462 = 0.8462 (13 fixtures, 2 fail honestly: `package-search alamofire` + `package-search kingfisher` return non-canonical packages at rank-1 because the iter-3-tightened owner/repo matcher correctly rejects the prior body-substring tautology; the canonical alamofire/Alamofire and onevcat/Kingfisher repos are absent from the v1.2.x top-10, filed as #954). **ΔMRR = +0.0000 in every phase** across all 118 fixtures , the v1.2.1 refactor introduced zero regression in any user-facing surface. McNemar p = 1.0, Wilcoxon p = 1.0 every phase. **Critic loop addressed 11 findings inline before merge**: Phase 2 `expect_any` false-positive matches against response-header echoes of queried args (rewrote scoring to parse per-result `### <name>` blocks only); Phase 2 `expect_nonempty` matches on "_No symbols found_" body (rewrote to require non-empty result-block list); Phase 3 Class C `code != 0` + empty-stdout fallthrough (removed both, requires explicit semantic marker now); Phase 4 `"error" in all_text` too broad (replaced with explicit not-found marker list); Phase 5 `_run_cmd` dispatch for `list-samples` + `doctor` ignored per-arm DB (fixed); `cupertino serve --no-reap` flag added per #280 so the harness doesn't kill the user's Claude Desktop MCP host; `--strict` flag added to `lib_harness.run_main` so smoke jobs fail non-zero on score failures rather than green-lighting silent regressions; `lstrip("→ ")` replaced with `removeprefix` (was character-set strip, dead-code today but semantically wrong); Phase 5 networking/json substrings tightened to owner/repo matching (avoids tautology of matching ubiquitous README terms like 'http'); Phase 3 NSObject fixture note rewritten to document the actual #669 fallback path it exercises. **Product gaps revealed**: 3 new issues filed (#952, #953, #954) for genuine bugs the corrected harness exposed. **Phase 2** (#944, 20 fixtures): MCP-stdio harness covering all 5 AST tools (`search_symbols`, `search_property_wrappers`, `search_concurrency`, `search_conformances`, `search_generics`). Spawns `cupertino serve` per query and speaks JSON-RPC over stdio (per-query cost: ~0.5s startup + tool dispatch). Until #948 lands CLI subcommands for these tools, MCP stdio is the only addressable surface. **Phase 3** (#945, 22 fixtures): `cupertino inheritance` (= MCP `get_inheritance`) covering up-walks (UIViewController, UIControl, UIButton, UIScrollView, UITableView, NSView, NSWindow, NSImageView), down-walks (UIControl, UIScrollView, NSControl, NSObject), both-direction walks, depth-bounded probes (UIButton depth=1/2/10), and the documented negative-path semantic markers (value types Int/String/Result; root NSObject up walk; absent symbol). **Phase 4** (#946, 13 fixtures): `cupertino read` for 5 indexed sources × 2 formats (json + markdown), `read-sample` happy + negative paths. **Phase 5** (#947, 13 fixtures): `list-frameworks` invariant (420 frameworks pinned via `must_contain: ["420 total", ...]` + 5 known framework tokens), `list-samples` invariant (619 projects pinned via `must_contain: ["Total: 619", ...]`), 4 `doctor` invocations, 6 `package-search` queries (`alamofire`, `swift-collections`, `swift-algorithms`, `kingfisher`, `json`, `networking`), plus the empty-query negative path. **Paired-comparison v1.2.0 release vs v1.2.1 brew**: zero regression in every phase. ΔMRR = +0.0000 across all 118 fixtures (per-phase results listed in the "Honest paired baseline" section above). McNemar two-sided p = 1.0 in every phase. Wilcoxon (B > A) one-sided p = 1.0 in every phase. The v1.2.1 refactor is empirically clean across the entire user-facing surface. **Baseline docs**: `docs/audits/search-quality-phase{2,3,4,5}-versiondiff-v1.2.0-to-v1.2.1.md` (one per phase, mirror Phase 1's audit shape). **CI smoke**: the existing `query-batteries-smoke` job (added by #949) now loops over all 5 phase scripts; total per-PR overhead ~30 seconds after the bundle setup. **Honest scope reduction:** Phase 2 ships 20 of the 48-fixture design (4 per tool covering each tool's primary use case); the remaining 28 (platform-filter combos, framework-filter combos) are queued as fixture-curation follow-ups and don't block the empirical regression claim. Refs: #944, #945, #946, #947 (closes); #943 (umbrella); #949 (standard); #948 (CLI/MCP discrepancy unblocks Phase 2 to drop MCP-stdio in favor of CLI shell-out when it lands). - **#949: eval-harness standard + shared library landed; Phase 1 refactored to consume it; CI smoke-mode job added.** Cross-cutting infrastructure for the #943 query-batteries umbrella. New shared library `scripts/eval/lib_harness.py` (314 LOC) carries the boilerplate every phase needs identically: `QueryOutcome` dataclass, single-arm aggregate metrics (P@1 / P@5 / MRR / NDCG@10), McNemar exact-binomial paired test, Wilcoxon signed-rank paired test, paired-comparison bucketing (added / removed / fixed / degraded / unchanged / both-suboptimal), JSON output writer, db-stat probe, generic argparse builder + main runner, smoke-mode flag. Phase 1 harness (`scripts/eval/search-quality-phase1.py`) refactored from 547 LOC to 328 LOC: dropped the duplicated stats helpers, replaced `main()` with a 4-line call into `lib_harness.run_main(corpus=..., score_fn=..., md_writer=...)`. **Byte-identical output verified**: the paired v1.2.0-release-vs-v1.2.1-brew JSON dump (2414 lines) is unchanged after the refactor (`diff` returns 0 lines). MRR 0.9467 = 0.9467, McNemar p = 1.0, Wilcoxon p = 1.0; same answers, less code. New CI job `query-batteries-smoke` on macos-15 runs every phase harness with `--smoke` (1 fixture per phase, ~5 sec wall-time) on every PR + push. Full Phase 1-5 runs reserved for tagged releases. Standard documented at `docs/audits/eval-harness-standard-v1.0.md` (CLI contract, JSON output schema, baseline-document shape, how to add a new phase). Adding Phase 2-5 (#944, #945, #946, #947) now means ~200-400 LOC each: corpus + `score_fn` + MD writer; library handles everything else. Refs: #949 (infra); #943 (umbrella); #944, #945, #946, #947 (per-phase implementations queued). ### Changed - **fix: `URL.expandingTildeInPath` now expands a `~` that `URL(fileURLWithPath:)` already cwd-mangled.** `URL(fileURLWithPath: "~/foo.db")` resolves the relative path against the current working directory at construction, so `.path` becomes `<cwd>/~/foo.db` (no leading `~`). The old guard `if path.hasPrefix("~")` therefore never matched and returned the cwd-mangled path verbatim. It only looked correct when cwd happened to be home; under `swift test` / CI (different cwd) it produced `<cwd>/~/foo.db`, failing `CLIAppleDocsDBURLTests` "Override expands the tilde". Fixed: detect `~` anywhere in the path and rebuild from `FileManager.homeDirectoryForCurrentUser`. Verified the exact CI-mangled input `/Users/runner/.../Packages/~/cup-test.db` resolves to `<home>/cup-test.db` with no residual `~`. - **chore: add a project `PreToolUse` hook that blocks parallel tool batches.** New committed `.claude/settings.json` denies a tool call when another fired less than 1.5s before it, enforcing one-command-at-a-time execution (await each result). The matcher covers `Bash`, `Edit`, `Write`, `MultiEdit`, and `NotebookEdit`, so a rapid burst of file mutations cannot form a half-applied tree (the first call of a burst runs; siblings are denied). `.gitignore` gains a `!.claude/settings.json` negation so the project hook is tracked while the rest of `.claude/` stays ignored, mirroring the existing `!.claude/skills/` exception. - **chore: re-resolve `SwiftMCPCore` to the fresh `v0.1.0` after upstream history rewrite.** The neutral `SwiftMCPCore` + `SwiftMCPClient` repos were reset to a single clean `v0.1.0` (squashed history, force-pushed `main`, prior `v0.1.1`/`v0.1.2` tags + releases deleted) carrying the agreed additive work (`Method.notificationsInitialized` in the core; the client's `connect()` lifecycle fix + `listTools`/`listResources`/`serverInfo` surface). The `v0.1.0` tag now points at a new commit, so `Package.resolved` is re-resolved from `2fe1d78` to `12e02eb7` (manifest `from: "0.1.0"` unchanged). Verified: `MCPTests` + `MCPSupportTests` 67/67 green against the fresh core (44s from-source build). Also fixes `scripts/check-import-contract-coverage.sh`: its `-A2` window over each `Target.target(...)` was misreading the `dependencies:` line's `.product(name: ...)` entries as target declarations, flagging the external `SwiftMCPCore` (read off a `.product(name:)` dependency line) as a production target missing a contract row, which turned the "Package import audits" CI job red once #1170 added the external dependency. The extraction now drops `.product(` lines before reading target names. Separately documents the `CupertinoComposition` library composition root with its real wired import set in `docs/package-import-contract.md` (it is a `Target.target`, not a binary, so it was the lone composition root the audit still required a row for; the row records that it imports concretes by design and is not a producer). - **chore(#1171): repoint the MCP wire-core dependency to the renamed `SwiftMCPCore` repo.** The wire-core package was renamed `swift-mcp-core` -> `SwiftMCPCore` (dash-free, repo `mihaelamj/SwiftMCPCore`); the product/module name `SwiftMCPCore`, tag `v0.1.0 @ 2fe1d78`, and the `MCP.Core.Protocols.*` namespace are unchanged, so cupertino's imports + the `@_exported import` in `MCP.swift` are untouched. Repointed the dependency URL + the `.product(package:)` identity string + `Package.resolved` to the new canonical URL (not relying on the GitHub redirect; pin stays `v0.1.0`), and cleaned two stale spike-era comments in `Package.swift`. The new `v0.1.1` tag is intentionally not adopted here (tracked in #1173). Verified: `swift package resolve` resolves `SwiftMCPCore` at `0.1.0 @ 2fe1d78` from the new URL; `MCPTests` + `MCPSupportTests` 67/67 green. - **refactor(#1167): cupertino's MCP wire-protocol core is now the external `swift-mcp-core` package (`SwiftMCPCore`, pinned `from: "0.1.0"`).** The `MCP.Core.Protocols.*` wire types (JSON-RPC envelopes, `Tool`, `ContentBlock`, `Implementation`, the Prompts family, capabilities) are no longer defined in-repo: the `MCPCore` target deletes its 8 wire-type files + the `MCP` / `Core` / `Protocols` namespace anchor and `@_exported import`s them from the neutral public `swift-mcp-core` package, re-exporting under the IDENTICAL `MCP.Core.Protocols.*` namespace so the ~6 consumer targets need no call-site change. cupertino keeps its Server + Transport layers (extending the package's anchor; re-adds `MCP.Core.Transport`); `CupertinoIcon` stays cupertino-local. This is cupertino's first non-Apple external dependency; the wire core is now the single source of truth shared across cupertino + CupertinoMCPClientKit + cupertino-desktop. Verified: `swift build` + MCPTests (43) + MCPSupportTests (24) green against `swift-mcp-core@0.1.0`, plus a real end-to-end `cupertino serve` session. See `docs/handoff/2026-05-30-v1.3.0-mcp-core-adoption.md`. - **fix(#1154, #1155): the 5 AST search commands fan out across every per-source DB, not just `apple-documentation.db`.** Post per-source-DB-split, `doc_symbols` is populated by every docs-tier indexer that runs the AST extractor, so symbols live across several DBs; but `search-symbols` / `search-property-wrappers` / `search-concurrency` / `search-conformances` / `search-generics` each opened only `apple-documentation.db` via `resolveAppleDocsDBURL`, making symbols indexed into any other per-source DB invisible. Each command now resolves the participating DBs declaratively (`CLIImpl.resolveSymbolSearchDBURLs`): a source participates when its `Search.Capabilities.searchers` advertises the matching searcher, the DBs are siblings in `--base-dir` (the folder `save` / `setup` operate on, defaulting to the configured base directory), and `--source <id>` scopes to one source. The stale `--search-db` flag is replaced by `--base-dir` + `--source` on all 5 commands. Results merge across DBs (`CLIImpl.fanOutSymbolSearch`, each opened read-only and disconnected in turn) capped to `--limit`; the per-command platform-floor application is preserved. Docs updated: each command drops its `search-db.md` option doc and gains `base-dir.md` + `source.md`, keeping `check-docs-commands-drift.sh` green. `SwiftBookSource` now advertises `.symbols` + `.generics` (its DB carries `doc_symbols` via `SwiftBookIndexer`'s AST extractor), so swift-book joins the symbol + generic-constraint fan-out alongside apple-docs and swift-org; sample-code and packages keep their symbols in their own schemas (`file_symbols` / `package_files_fts`) and are correctly out of the `doc_symbols` fan-out. Covered by `Issue1154MultiDBFanOutTests` (7 tests): the registry-capability DB resolution (`.symbols` set, `--source` scoping, missing-DB skip, bad-source throw), a real two-DB `fanOutSymbolSearch` merge against seeded fixtures, the `--limit` cap, and per-command parse-surface checks that `--base-dir` / `--source` bind and the retired `--search-db` is rejected. The fan-out round-robin interleaves the per-DB result lists (rather than concat-then-cap) so a swift-org / swift-book match is not buried when apple-docs alone fills `--limit`, and a DB that fails to open or query is logged and skipped instead of aborting the whole fan-out; the `docs/sources/swift-book/manifest.yaml` searcher mirror and the 5 command-README synopses were updated to match. A second review pass corrected the per-command `--source` doc values (only apple-docs advertises the concurrency / conformances / property-wrappers searchers, so those three READMEs list `apple-docs` alone, while search-symbols / search-generics list all three symbol-bearing sources) and refreshed the `SwiftBookSource` capability doc comment that still claimed "no generics-search". - **refactor(#536): producers stop importing peer producers (foundation-only standalone-portability).** The import-audit (`scripts/check-target-foundation-only.sh`) had been silently disabled by stale `STRICT_PRODUCERS` entries (`Crawler`, `Ingest`, both absorbed into per-source modules), which made the script exit before the audit ran; re-enabling it exposed 8 producer->peer/pass imports. Lifting each behind a foundation/`*Models` seam so every producer imports only Foundation + `*Models` + external primitives (the precondition for a Linux build; design doc `docs/design/536-standalone-portability-and-linux-port.md`). Lift 1: `HIGPlatformRules` moved from the `HIGPlatformInferencePass` producer into the `SearchModels` seam (it is Foundation-only), so `SearchSQLite` + `HIGSource` read the platform-inference rules from the seam instead of importing the peer pass. Lift 2: the `HIGPlatformInferencePass` producer target is dissolved into `HIGSource` (which already owns the pass via `makeSourceSpecificEnrichmentPasses`, #1073), removing the last peer import; a stale `SearchSQLite` -> `HIGPlatformInferencePass` manifest dep was dropped too. Lift 3: the `SampleCodeFetchStrategy` in the `SampleCodeSource` producer stopped importing the `CoreSampleCode` concrete; the GitHub-fetch concrete is now reached through a `Sample.Core.GitHubFetcherFactory` Abstract-Factory seam (plus the `Sample.Core.GitHubFetching` Strategy seam and the `FetchStatistics`/`FetchAction` value types, all moved into the foundation-only `CoreSampleCodeModels` target), and the composition root injects the producer's `Sample.Core.LiveGitHubFetcherFactory` via `SampleCodeSource(fetcherFactory:)`. Lift 4: the shared web-crawl engine (`WebCrawlFetchStrategy` + `Crawler.AppleDocs` + `Ingest`) was extracted out of `AppleDocsSource` into a neutral `Crawler` producer; apple-docs, swift-org, and swift-book now consume it through a `Search.WebCrawlStrategyFactory` Abstract-Factory seam (in `SearchModels`), injected at the composition root, so swift-org / swift-book stop importing `AppleDocsSource` and all three source providers stay foundation-only (and Linux-buildable: the macOS-only crawl concrete is wired only at the root). This also replaces the stale `Crawler` / `Ingest` `STRICT_PRODUCERS` entries (they pointed at deleted directories and short-circuited the import audit before it ran), re-enabling the audit. Lift 5: `PackagesFetchStrategy` (the whole 3-stage Swift Package Index pipeline: SPI metadata refresh, GitHub archive download, availability annotation) moved out of the `PackagesSource` producer into the `CorePackageIndexing` producer where its machinery (`PackageFetcher`, `PackageDependencyResolver`, `PackageArchiveExtractor`, `PackageAvailabilityAnnotator`) already lives, reached through a new `Search.PackageFetchStrategyFactory` Abstract-Factory seam (in `SearchModels`) whose `LivePackageFetchStrategyFactory` is injected at the composition root via `PackagesSource(packageFetchStrategyFactory:)`, so `PackagesSource` no longer imports `CorePackageIndexing` and the foundation-only audit now reports 0 violations. - **refactor(#919): a per-source enrichment-input preflight replaces the two hardcoded missing-input guards (Source Independence Axiom).** Each source now DECLARES the input files it needs via `Search.SourceDefinition.requiredEnrichmentInputs` (apple-docs / samples / packages declare `apple-constraints.json`; packages also declares per-package `availability.json`), and one generic composition-root preflight (`Search.EnrichmentInputPreflight`, foundation-only in SearchModels) enforces them before any indexing, refusing partial data unless `--allow-degraded-enrichment`. This deletes the literal-filename `apple-constraints.json` guard (#1072) and `assertPackageAvailabilityComplete` from `CLIImpl.Command.Save`; both were per-source edit-points in central save logic, i.e. axiom violations. The only dispatch left is over `EnrichmentInput.Scope` (a closed set of location kinds every source reuses, never extended per source), so adding a source's requirement is one literal on its definition and the preflight never changes. Bonus correctness: the old apple-constraints guard was docs-global (a hig-only save wrongly required the file) and never covered samples (a samples-only save could silently degrade); the declarative model checks each input exactly when a consuming source is selected. New `Issue919EnrichmentInputPreflightTests` (8 cases) cover the baseDir-file and per-corpus-item scopes, the skip-when-corpus-absent rule, the operator-facing message, and the real per-source declarations. - **refactor(#1144): Apple constraint + conformance enrichment is best-effort, read at the enrichment phase, with a live progress badge.** `apple-constraints.json` / `apple-conformances.json` feed the constraint + conformance passes on three DBs (`apple-documentation.db`, `swift-packages.db`, `apple-sample-code.db`). The save no longer treats a missing input as a reason to block (or, for conformances, as a silent drop of the ~108k-edge SDK graph): it warns, names the file plus the `cupertino-constraints-gen` command that produces it, and indexes anyway (the DB is built un-enriched, which is valid on its own). Two changes make "produce the input WHILE the save runs" actually work: (1) inputs are read LAZILY at the enrichment phase (after all indexing), not eagerly at save start, so a file produced during a long index is still picked up and applied with no re-index (`LazyConstraintsLookup` / `LazyConformancesLookup`, read once and cached); (2) `cupertino-constraints-gen generate` / `conformances` now write atomically (temp + rename), so a save reading the file at enrichment time sees the complete old or complete new table, never a half-written one. The apple-docs progress lines carry a live enrichment badge that re-checks input presence on each tick (`🧬` will enrich, `🚫 no-enrich`), flipping the moment a missing file lands. `apple-conformances.json` is declared alongside `apple-constraints.json` in `requiredEnrichmentInputs` on all three sources, now driving the warning + badge rather than a hard-fail. New tests: `Issue1144EnrichmentBadgeTests` (5, incl. the live `🚫` to `🧬` flip) and `Issue1144LazyEnrichmentLookupTests` (4, incl. the deferred read of a file produced after the lookup is built). - **docs: `docs/symbolgraph-corpus.md` documents the `apple-constraints.json` input pipeline end-to-end.** How the Apple SDK symbol-graph corpus is generated (`cupertino-symbolgraphs-gen` against the active Xcode, or the published GitHub Release zip), how `cupertino-constraints-gen` turns it into `apple-constraints.json`, where it is consumed (`cupertino save` preflight + constraints passes; `cupertino setup` download), and where every artifact must live (corpus gitignored + released; derived table committed to cupertino-docs). Linked from the database handbook, the enrichment inventory (#9/#10), and the constraints-gen command doc. Closes the long-standing gap where the symbol-graph corpus had no consumer-side documentation. - **docs: the `inheritance` command is now documented under `docs/commands/`, and the drift checker covers it.** While auditing CLI/MCP parity docs (#962), `cupertino inheritance` (the `get_inheritance` MCP tool's CLI sibling) turned out to have no `docs/commands/inheritance/` folder at all, and it was absent from `scripts/check-docs-commands-drift.sh`'s hardcoded `COMMANDS` array, so the checker never flagged the gap (it only validates option docs within folders it is told to check). Added `docs/commands/inheritance/README.md` plus `option (--)/{direction,depth,framework,format,search-db}.md`, and added `inheritance` to the checker's `COMMANDS` array. The checker now covers 20 commands (was 19) and stays green. No behaviour change; closes a blind spot where a real command could ship undocumented without tripping the drift gate. - **docs(#1066): `cupertino fetch --help` now lists `swift-book` as a first-class source.** #1093 made swift-book independent of swift-org's crawl but the help-text SOURCES block still only mentioned swift-org. Fetch itself worked (`cupertino fetch --source swift-book` succeeds end-to-end); discovery via `--help` did not. Added a `swift-book` row to SOURCES, a swift-book example to EXAMPLES, and `swift-book` to the `--source` option's long-help comma-list. No behaviour change. - **refactor(post-#1057 mechanical hunt): `Search.Classify.kind` 5-arm dead-on-prod switch culled; `CLIImpl.Command.Serve` URI-directory dispatch collapsed to a single generic lookup.** Mechanical re-hunt after #1057 ("we always have things popping up") surfaced 2 real per-source edit-points: (a) `SearchSQLite/DocKind.swift` had a 5-arm fallback switch (swift-evolution / swift-book / swift-org / hig / apple-archive) returning per-source DocKind values when the registry dict was empty — but every production caller passes `sourceLookup.docKindRawValuesByID`, and each of those 5 sources declares `defaultDocKindRawValue` on its SourceDefinition (Gap 3 wiring from #1045), so the dict-first path always wins. The 5 arms were dead-on-prod (audit Finding 14.5 pattern). Cull: switch collapses to the 1 live apple-docs arm + `default → .unknown`. (b) `CLIImpl.Command.Serve.registerProviders`'s URI-strategy directory loop had a 3-arm switch on source-id (apple-docs / swift-evolution / apple-archive) picking `paths.docsDirectory` / `evolutionURL` / `archiveURL`. Post-Cluster-13 those typed accessors all delegate to `paths.directory(named:)`, so the switch was redundant — every provider now resolves via the single generic lookup keyed by `fetchInfo.defaultOutputDirKey.rawValue ?? definition.id`. Tests updated: `DocKindTests` + `DocKindIntegrationTests` build a canonical SourceLookup with the 5 docKind declarations so the classifier behaviour stays pinned post-cull. 2771/2771 tests green. - **refactor(post-#1056 follow-up): `Save.Indexers.resolveSourceDirectory` provider-driven; hardcoded `swift-book` + `samples` sentinel switch dropped.** Pre-fix the resolver's fallthrough had a hardcoded 2-arm switch on source-id (`swift-book` + `samples`) returning a `/dev/null` placeholder so the downstream `compactMap` wouldn't drop their strategy from the dispatch list — their strategies need to RUN but don't actually READ the directory parameter (swift-book is a view-source over swift-org's crawl, samples consumes `env.sampleCatalogProvider`). Adding a new view-source / alternate-input source meant editing the resolver. Post-fix: new `var requiresCorpusDirectory: Bool { get }` on `Search.SourceProvider` (default `true`); `SwiftBookSource` + `SampleCodeSource` override `false`. The resolver checks the flag and supplies the placeholder URL with zero per-source knowledge in the central code. New contract assertion `requiresCorpusDirectoryIsRegistryDriven` pins the shape. 2770/2770 tests green. - **refactor(post-#1056 follow-up): `Logging.Logger` static-per-source loggers collapse to a dict-driven lookup; redundant inner `Logging.Unified.Category` enum + `LiveRecording.categoryMap` deleted.** Pre-fix the Logging package had 3 parallel closed sets enumerating the 10 production categories: 10 `public static let <X>` os.Logger instances on `Logging.Logger`, a closed `Logging.Unified.Category: String` enum with a 10-arm `osLogger` switch, and a 10-entry `LiveRecording.categoryMap` mirroring the rawValues a third time. Adding a new source-tier category meant editing 4 sites. Post-fix the source of truth is `Logging.Logger.osLoggers: [String: os.Logger]` keyed by category rawValue; `Logging.Logger.osLogger(for:)` is the public lookup with `.cli` fall-through for unknown categories. `Logging.Unified.Category` becomes a `typealias` for the foundation-tier `LoggingModels.Logging.Category` open struct (post-#1042 Cluster 10), so the two duplicate Category types collapse to one. `LiveRecording.categoryMap` + `mapCategory()` helper deleted (no longer needed). The `"packages"` → `"package-downloader"` rawValue renaming (historical: production os.Logger channel kept its original `package-downloader` name) is localised to a one-line conditional in `Logging.Unified.logToOSLog`. 2770/2770 tests green. - **fix(audit Cluster 12 follow-up): MCP `DocsResourceProvider` dispatches via per-source `Search.URIResourceStrategy`; 3 hardcoded if/elseif arms + parse helpers + `listArchiveResources` lifted into source targets.** Pre-fix the MCP layer's `readResource` had a 3-arm `if uri.hasPrefix(...)` dispatch (apple-docs / swift-evolution / apple-archive), each carrying 30-50 LOC of bespoke URI parsing + filesystem probing + (apple-docs only) JSON-vs-md decode logic. `listResources` mirrored the shape with 3 source-specific blocks plus `listArchiveResources` + `isFrameworkRootPage` + `extractTitle` + 3 `parseXxxURI` helpers (~220 LOC of MCP-layer per-source logic total). Adding a new docs-tier source whose pages reach the client via `resources/{list,read}` required editing both dispatchers AND adding a new parse helper. Post-fix: new `Search.URIResourceStrategy` protocol + `Search.URIResourceEnvironment` + `Search.URIResource` in SearchModels foundation tier; per-source `<X>URIResourceStrategy` concretes in `AppleDocsSource`, `SwiftEvolutionSource`, `AppleArchiveSource` (each owning its scheme + parser + probe sequence + decoder). `Search.SourceProvider` gains `func makeURIResourceStrategy() -> (any Search.URIResourceStrategy)?` as a protocol REQUIREMENT (not default-extension; the regression pin from #1055 layer-2 stays load-bearing — protocol-body declaration is what makes dynamic dispatch resolve per-source overrides). `DocsResourceProvider` now consumes `resourceStrategies: [any URIResourceStrategy]` + `directoriesByScheme: [String: URL]` at init; iterates the list for both `listResources` (fan-out + merge) and `readResource` (first-match by scheme prefix). `knownURISchemes` is derived from `resourceStrategies.map(\.scheme)` so it stays in sync automatically. The CLI Serve composition root builds the strategy list from `registry.allEnabled.compactMap(\.makeURIResourceStrategy)` and the scheme→directory map from each provider's `fetchInfo.defaultOutputDirKey` (honouring `--docs-dir` + `--evolution-dir` CLI overrides). Adding a new MCP-resource source today: one `makeURIResourceStrategy()` override + one strategy concrete in the per-source target. New contract assertion `makeURIResourceStrategyIsRegistryDriven` pins the protocol-requirement shape. 2770/2770 tests green (+1 from the new contract). - **fix(audit follow-up): `Doctor.checkDocumentationDirectories` registry-driven; broken `--source` guidance corrected.** Pre-fix the raw-corpus inventory list in `cupertino doctor --save` was a hardcoded 5-element `[CorpusEntry]` literal naming `(label, url, suffix, fetchType)` inline; adding a new web-crawlable source meant editing Doctor. AND a latent bug: 4 of the 5 `fetchType` strings (`"docs"` / `"evolution"` / `"swift"` / `"archive"`) were not valid `--source` values post-#1007 registry-driven dispatch, so the inline "→ Run: cupertino fetch --source X" guidance pointed at commands that would error out with "Unknown --source value 'docs'. Valid sources: …". Post-fix the list is derived from the production registry — every FTS-tier source (`provider.isSearchTier && provider.fetchInfo != nil`) contributes one entry pulling its label from `fetchInfo.displayName`, its URL from `Shared.Paths.directory(named: fetchInfo.defaultOutputDirKey.rawValue)` (with `apple-docs` + `swift-evolution` still honouring the CLI `--docs-dir` / `--evolution-dir` overrides), its file-suffix noun from a new `fetchInfo.corpusFileSuffix` field (default `"files"`; `swift-evolution` → `"proposals"`, `swift-org` + `hig` → `"pages"`, `apple-archive` → `"guides"`), and its `fetchType` from `fetchInfo.sourceID` (the canonical `--source` flag value). 2769/2769 tests green. - **docs(audit #1055 close-out): HOW-TO-ADD-A-SOURCE.md rewritten against post-layer-2 reality; 3 contract assertions added.** The HOW-TO doc was significantly out of date — still named #1045 production-wiring gaps that all closed, called the Cluster 8 dispatch rewire "queued" (it landed weeks ago), and pointed at the hardcoded `[.appleSampleCode, .packages]` SmartReport exclusion that the part-3 `isSearchTier` fix dissolved. New doc claims a genuine 2-file PR for any source within the FTS family and lists every consumer surface that picks up the new source automatically. `Issue1042PluggabilityContractTests` gained 3 layer-2 deepening assertions: `makeReadStrategy` is pinned as a PROTOCOL REQUIREMENT (regression pin for the static-dispatch bug discovered during close-out); `Search.SearchRoute` is pinned as a RawRepresentable struct; `isSearchTier` is pinned as a protocol requirement with the correct per-source classification. Audit doc's action items updated to mark every finding closed (9.7+11.1, the 3 layer-2 parts). 2769/2769 tests green (+3 from the new contract assertions). - **fix(audit #1055 layer-2 part 3): `SmartReport.docsSources()` filters via `Search.SourceProvider.isSearchTier`; hardcoded `[.appleSampleCode, .packages]` descriptor set dropped.** Pre-fix `CLIImpl.Command.Search.SmartReport.docsSources()` filtered the production source registry by a hardcoded `excluded: Set<DatabaseDescriptor> = [.appleSampleCode, .packages]`. Any new source with a non-FTS backend (its own bespoke index) had to be appended to that set in the same PR — a 2nd parallel edit-point that violated the 2-file-PR pluggability claim. Lifted to a `var isSearchTier: Bool { get }` protocol requirement on `Search.SourceProvider` with `true` as the default extension; `SampleCodeSource` and `PackagesSource` declare `isSearchTier = false`. `SmartReport.docsSources()` becomes `.filter(\.isSearchTier)`; the hardcoded descriptor exclusion is gone. Adding a non-search-tier source is now self-contained on the provider — `SmartReport.swift` is no longer a per-source edit-point. 2766/2766 tests green. - **fix(audit #1055 layer-2 part 2): `Search.SearchRoute` is now an open `RawRepresentable` struct; CLI Search + MCP dispatchers use `==` equality chains instead of exhaustive switches.** Pre-fix `SearchRoute` was a closed enum with 5 cases (`.docs / .hig / .samples / .packages / .unified`). Both `CLIImpl.Command.Search.run` and `SearchToolProvider.CompositeToolProvider.handleSearch` had `switch route` arms — adding a new route meant a new enum case AND matching arms in BOTH dispatchers. Post-fix mirrors the `Search.FetchInfo.DefaultOutputDirKey` Cluster-9-sub-1 shape: a `static let` declaration per route, RawRepresentable struct, dispatchers use `if route == .X` and fall through to `.unified` on any unrecognised route. The 3 source-id-shaped routes (`.hig` / `.samples` / `.packages`) read from `Shared.Constants.SourcePrefix.*`; the 2 bucket-tier routes (`docs` / `unified`) get canonical names in a new `Shared.Constants.SearchRouteName` namespace co-located with `SourcePrefix`. Adding a novel route is one `static let` declaration plus one `else if` arm per dispatcher (no breakage on a missing case). 2766/2766 tests green. - **fix(audit #1055 — CLOSE): `Services.ReadService` dispatches via per-source `Search.SourceReadStrategy`; bucket-arm `readFrom` deleted.** Layer-2 follow-up to Finding 14.3. Pre-fix `Services.ReadService.readFrom` had a hardcoded 3-arm `if source == .docs/.samples/.packages` dispatch. Post-fix each provider's `makeReadStrategy()` returns its own concrete (shared `Search.DocsReadStrategy` for 6 docs-tier sources + bespoke `SamplesReadStrategy` / `PackagesReadStrategy`); `Services.ReadService.read` iterates the registry, narrows to the URI-scheme-matching provider or the explicit-source provider or the auto-source try-order, and runs the first strategy that returns non-nil. The 3 lookup protocols (`Search.DocsLookupStrategy` / `SampleLookupStrategy` / `PackageFileLookupStrategy`) live in SearchModels (foundation tier) so per-source targets reference them without depending on Services / SearchSQLite. CLI threads `providers: registry.allEnabled` into the read call. Root cause of the 2 test failures during WIP: `makeReadStrategy` was declared only as a default extension, not as a protocol requirement — Swift's static dispatch resolved through the protocol-extension default (returning nil) instead of the per-source overrides; fix: hoist `makeReadStrategy()` into the `Search.SourceProvider` protocol body. 2766/2766 tests green. - **wip(audit #1055): `Services.ReadService` dispatches via provider strategies; bucket-arm `readFrom` deleted.** The 3-arm `if source == .docs/.samples/.packages` dispatch in `Services.ReadService.readFrom` is gone — replaced by `runProviderStrategy(provider:, env:)` that asks each `Search.SourceProvider.makeReadStrategy()` for its strategy and runs it. CLI's `Read` command now passes `providers: registry.allEnabled`. `Services.LiveDocsLookupStrategy` + `Services.LiveSampleLookupStrategy` added in the Services target; CLI's `LivePackageFileLookupStrategy` updated to conform to the canonical `Search.PackageFileLookupStrategy` protocol (the Services-side protocol typealiased for back-compat). Build green. 2 test regressions tracked under #1055 — the new dispatch's auto-source flow swallows errors slightly differently than the legacy `readFromX → throws → catch → fallthrough` chain; runtime behaviour for explicit-source reads + URI reads is correct, the 2 failing tests are integration roundtrips that index a row and read it back. Debugging next. - **wip(audit #1055): per-source `Search.SourceReadStrategy` protocol + scaffolding.** Layer-2 follow-up to Finding 14.3. Pre-fix `Services.ReadService.readFrom` had a hardcoded 3-arm `if source == .docs/.samples/.packages` dispatch. Added `Search.SourceReadStrategy` protocol + `Search.ReadEnvironment` (carries the 3 DB URLs + 3 lookup-strategy seams) + `Search.ReadResult` + 3 lookup protocols (`DocsLookupStrategy` / `SampleLookupStrategy` / `PackageFileLookupStrategy`) in SearchModels foundation tier. Added shared `Search.DocsReadStrategy` (used by all 6 docs-tier sources; parameterized on source-id) and per-source `SamplesReadStrategy` (in `SampleCodeSource`) + `PackagesReadStrategy` (in `PackagesSource`). Wired all 8 providers' `makeReadStrategy()`. `Services.ReadService.read` refactor + `Live*LookupStrategy` concretes + CLI `Read` command rewire still pending; 9 of 9 layer-1 + this layer-2 scaffolding shipped today. - **fix(audit 9.7 + 11.1, phase 2 — CLOSE): every per-source fetch handler lifted; `Crawler` + `Ingest` producer targets dissolved.** Continued from phase 1. Lifted the remaining 3 complex handlers (`runSamplesFetch` → `SampleCodeSource.SampleCodeFetchStrategy`; `runPackageFetch` 3-stage pipeline → `PackagesSource.PackagesFetchStrategy`; the apple-sample-code legacy alias canonicalizes to `samples` at dispatch). Physically MOVED the per-source `Crawler.<X>` concretes (HIG / Evolution / AppleArchive / AppleDocs + state files + TechnologiesIndex + ArchiveGuideCatalog) into their owning source targets via `git mv`; the now-empty `Crawler` and `Ingest` SPM targets + their test targets + the `.singleTargetLibrary("Ingest")` product all deleted. `Ingest.Session` + `Ingest.swift` (session resume / requeue / baseline / urls-file helpers) lifted into `AppleDocsSource` (only post-lift consumer). The `Fetch.swift` switch collapsed to 2 special-token arms + `default → runRegistryFetchStrategy`; the 22 dead methods + 8 dead progress observer structs (~1500 LOC) deleted. Adding a new source = ONE `Search.SourceFetchStrategy` concrete in its target + `.register(<X>Source())` in `Cupertino.CompositionRoot.swift`. Zero edits to `Fetch.swift`, `Crawler` (the producer target is gone), or any cross-cutting CLI plumbing. 2766/2766 tests green (-5 deleted with the dead Crawler+Ingest test files; coverage of the moved code is now in per-source target tests). - **fix(audit 9.7 + 11.1, phase 1): per-source `Search.SourceFetchStrategy` protocol + 6 of 9 fetch handlers lifted into source targets.** Pre-fix `CLIImpl.Command.Fetch.run` had a 10-arm `switch source` dispatching to bespoke `run<X>Crawl/Fetch` methods (200-500 LOC each, heavy CLI-flag state coupling). Post-fix: new `Search.SourceFetchStrategy` protocol + `Search.FetchEnvironment` value type in SearchModels. Each `<X>Source` target supplies a strategy via `makeFetchStrategy()`. CLI's dispatch becomes `registry.entry(for: source)?.provider.makeFetchStrategy()?.run(env:)`. Lifted today: `HIGSource.HIGFetchStrategy`, `AppleArchiveSource.AppleArchiveFetchStrategy`, `SwiftEvolutionSource.SwiftEvolutionFetchStrategy`, `AppleDocsSource.WebCrawlFetchStrategy` (shared concrete used by apple-docs + swift-org + swift-book — each source configures it with its own seed URL + allowedPrefixes). Remaining 3 complex sources (packages + apple-sample-code + samples) keep their legacy switch arms with a TODO; the protocol seam is in place for follow-up lifts. Adding a NEW source today: declare a `Search.SourceFetchStrategy` in the source target + return it from `makeFetchStrategy()` + add `.register(<X>Source())` in `Cupertino.CompositionRoot.swift`. Zero edits to `Fetch.swift` for new sources. 2771/2771 tests green. - **fix(audit 6.0 + 9.2): formatter `availableSources` is non-optional + dead-on-prod statics culled.** Every formatter that previously took `availableSources: [String]?` (Footer.Search, Unified.Input, Unified.Markdown, Unified.Text, HIG.{Text, Markdown}, Frameworks.{Text, Markdown}, Sample.Format.{Text, Markdown}.Search) now takes `availableSources: [String]` non-optional. The `Shared.Constants.Search.availableSources` static literal + its `otherSources(excluding:)` helper + `tipSearchCapabilities` + `tipOtherSources(excluding:)` all deleted along with the back-compat `Services.UnifiedSearcher` extension overloads. Every production caller threads the registry-derived list from `CupertinoComposition.makeProductionSourceRegistry().allEnabled.map(\.definition.id)`; test fixtures construct an explicit list inline. The `Shared.Constants.DisplayName.allSourceInfos` static (dead-on-prod since #1042) deleted. `Shared.Constants.SourcePrefix.allPrefixes` + `DatabaseDescriptor.allKnown` kept as documentation lists, now pinned by drift-detector tests that compare against the production registry — adding a new source without updating these statics fails CI. 2771/2771 tests green. - **fix(audit 14.2 + 14.4): CLI Search + MCP CompositeToolProvider dispatch via `Search.SourceProvider.searchRoute`.** Pre-fix both dispatchers switched on source-id literals — adding a new source required editing both files. Now both consume a registry-supplied source-id → SearchRoute map at the composition root (CLI builds it inline; MCP wires it via `CompositeToolProvider.init(searchToolRoutesByID:)` from Serve). Production behaviour preserved for the 8 shipped sources; new sources flow through automatically by declaring their `searchRoute` on the provider. Tests in `SearchToolProviderTests` now pull the canonical route map from the new `CupertinoComposition` target instead of hardcoding the 8-entry list. - **architecture: extracted `CupertinoComposition` SPM target.** Single canonical declaration of the production source set lives in `Sources/CupertinoComposition/Cupertino.CompositionRoot.swift`. Both CLI's `CLIImpl.makeProductionSourceRegistry()` and the `SearchToolProviderTests` route-map fixture delegate to `CupertinoComposition.makeProductionSourceRegistry()`. Adding a new source = one `.register(<X>Source())` line in CompositionRoot.swift; CLI, MCP, Doctor, SaveSiblingGate, ReadService, formatter footers, MCP tool source enum, fetch dispatch, schema-version printer, and the SearchToolProvider test fixture all pick up the new source through the same registry — zero parallel hardcoded lists. - **fix(audit 7.1 + 7.2): Doctor's `healthChecks` + `printSchemaVersions` are now registry-derived, covering every per-source destinationDB.** Pre-fix the `healthChecks` list was a hardcoded 3-conformer literal (packages / samples / search) and `printSchemaVersions` had a 9-tuple literal. Post-#1036 the 5–6 per-source FTS DBs (apple-documentation.db, hig.db, apple-archive.db, swift-evolution.db, swift-documentation.db) were SILENTLY un-probed — a corrupt per-source DB would let `cupertino doctor` report "healthy" while MCP returned partial results. Fix: `SearchHealthCheck` parameterised on `descriptor:` + `isRequired:` so the composition root can stamp one instance per docs-tier descriptor (warning-only) plus the transitional `.search` legacy probe (hard-required). Doctor's `printSchemaVersions` follows the same registry-iteration pattern with 2 special-case path resolvers (`.packages` → `paths.packagesDatabase`; `.appleSampleCode` → `Sample.Index.databasePath`). New coverage-invariance test `Issue7_1DoctorCoverageInvariantTests` (3 assertions) registers a fake source + asserts the new descriptor shows up in both surfaces — if a future PR drops the registry iteration, the test fails. 2771/2771 tests green (+3 from the new coverage test). - **fix(audit 14.1): `SaveSiblingGate.classifyPostSplitSourceID` is `destinationDB`-driven instead of a hardcoded 9-arm source-id switch.** Pre-fix the docs bucket enumerated 6 source-ids (`apple-docs`, `swift-evolution`, `hig`, `apple-archive`, `swift-org`, `swift-book`); adding a new source required editing this file. Post-fix the classifier resolves the source-id via `registry.entry(for:)?.destinationDB` and switches on the destinationDB (3 stable arms: `.packages` / `.appleSampleCode` / default → docs). 30/30 SaveSiblingGate tests green; `apple-sample-code` alias still flows to the samples bucket (one-DB-two-tracks design preserved). - **fix(audit 14.3): `Services.ReadService.resolveSource` is registry-driven via a `destinationsByID: [String: DatabaseDescriptor]` dict.** Same hardcoded-switch anti-pattern as 14.1; same fix shape. New `CLIImpl.makeDestinationsByID(registry:)` helper supplies the dict at the composition root in `CLIImpl.Command.Read.swift`. The 3 stable bucket arms in `resolveSource` (`.packages` / `.appleSampleCode` / default → docs) align with the 3 backend handlers; adding a new source within an existing DB family flows through automatically, only a brand-new DB family would need a new bucket case. - **fix(audit 14.5): cull 5 dead arms in `Save.Indexers.resolveSourceDirectory` + fix latent `--docs-dir` override-loss bug.** Post-Gap-4 (#1045) the 5 docs-tier switch arms (appleDocs / swiftEvolution / swiftOrg / appleArchive / hig) were dead code — the dict path always wins for them. The 2 sentinel arms (swiftBook + samples) are the only live ones. Cull collapses the 8-arm switch to a 2-case sentinel + default-nil. **Latent regression caught while culling**: `makeDocsIndexingDirectoryByKey` built the dict from registry defaults only; CLI flags like `--docs-dir /custom` propagated through the typed `docsDirectory` field but `resolveSourceDirectory`'s dict-first lookup ALWAYS won, silently dropping the override. Fix: helper now takes `overrides: [String: URL?]`; Save command supplies its 4 typed CLI flag values (`docsDir` / `evolutionDir` / `swiftOrgDir` / `archiveDir`) so the dict reflects user intent. New regression test `gap4_cliOverrideWinsOverRegistryDefault` pins the override precedence. 2768/2768 tests green (+1 from the new test). - **docs(audits): 2026-05-26 pluggability deep audit lands at `docs/audits/2026-05-26-pluggability-deep-audit.md`.** 15-layer top-down review of Source Independence Day status after #1045 closed. Surfaces 13 cumulative findings, of which 8 are real pluggability holes (Doctor `healthChecks` / `printSchemaVersions`, `cupertino fetch` dispatch switch, per-source crawler concretes living outside `<X>Source/`, `SaveSiblingGate` source-id switch, three more `searchRoute`-shaped switches in `CLIImpl.Command.Search` / `Services.ReadService` / MCP `CompositeToolProvider`, the `HOW-TO-ADD-A-SOURCE.md` 4-line claim vs 13+ actual edits). Real edit count today for a new web-crawlable source: 13+ files touched across 9+ different production sites — not the 4 the HOW-TO advertises. Carves up cleanly into 5 follow-up issues (Doctor pluggability, `searchRoute` dispatch rewire, per-source fetch strategy, strict `availableSources` non-optional, dead-arm cull) + 1 doc rewrite once those land. Bottom line: foundation 70% landed, remaining 30% is stale dispatch switches that pre-date the `searchRoute` seam. - **fix(#1047 follow-up): `printDocsSummary` mirrors the docs runner's `excluding: [.packages]` filter so `--all` no longer over-reports `packages.db`.** Surfaced by the post-#1046 layer-by-layer audit. For `--all`, `selectedSourceIDs` contains all 8 source ids including `"packages"`; the inline filter `registry.allEnabled.filter { selectedSourceIDs.contains($0.definition.id) }.map(\.destinationDB.filename)` produced 7 filenames including `packages.db` — but the docs runner explicitly skips `.packages` (line ~323: `groupedByDestinationDB(excluding: [.packages])`), so the file is written by `runPackagesIndexer` afterward, not by the docs pipeline. The summary therefore claimed authorship of a file it did not write. Fix: chain a second `.filter { $0.destinationDB != .packages }` after the source-id filter so the summary scope matches the indexer's actual scope. `--source <id>` paths unaffected (packages isn't routed through docs pipeline at all because `buildDocs` short-circuits via `isDocsBucketSource`). 304/304 Save+CLI tests green. - **fix(#1046 extended audit): symlink resolution at every composition-root corpus entry — Gap-4 dict, MCP read path, Availability + Preflight scans.** User pushed back on the initial #1046 fix ("are you sure?"); audit found 5 more sites where unresolved symlink URLs reach `FileManager.contentsOfDirectory`/`enumerator(at:)`. Most consequential: **`CLIImpl.makeDocsIndexingDirectoryByKey` (#1045 Gap 4) built the directory dict from raw `Shared.Paths.directory(named:)` URLs, BYPASSING the #779 `optionalDir` symlink-resolution fix at `Indexer.DocsService.run`.** The strategy received raw symlink URLs via the dict (`directoryByKey` wins in `resolveSourceDirectory`), reproducing the 2026-05-18 11h15m reindex-crash failure mode. Now `makeDocsIndexingDirectoryByKey` calls `.resolvingSymlinksInPath()` at construction — consistent with #779's `optionalDir` contract. Strategy-side resolution (initial reflexive fix to `SwiftEvolutionStrategy.getProposalFiles`) reverted as redundant — composition root is the right layer; the existing `Issue779OptionalDirSymlinkTests` negative sentinel pins that contract. Also fixed: `MCP.Support.DocsResourceProvider` (3 sites: listResources for evolution + archive, readResource for evolution); `Availability.Fetcher.{discoverFrameworks, collectJSONFiles}`; `Indexer.Preflight.{samplesURL, docsDir scan, packagesURL scan}`. The `findDocFiles` + `findMarkdownFiles` resolution from the initial #1046 fix stays as belt-and-suspenders + so the regression test can call them standalone with a symlink URL. 2767/2767 tests green; smoke save against symlinked HIG corpus still produces 173 rows in hig.db. - **fix(#1046): `Search.StrategyHelpers.find{Doc,Markdown}Files` resolve symlinks before enumerating.** `FileManager.default.enumerator(at:)` silently yields zero children when the input URL is a symlink to a directory (no error, no warning) — common dev setup `~/.cupertino-dev/<source>` → `~/.cupertino/<source>` would silently produce empty indexes. Surfaced during the post-#1045 smoke save: `cupertino save --source hig --yes` against a symlinked HIG corpus reported "✅ Search index built: 0 documents" with exit 0, while the symlink target had 173 `.md` files on disk. Fix: `directory.resolvingSymlinksInPath()` before `FileManager.default.enumerator(at:)` in both helpers. Post-fix re-run indexed 173 files into `hig.db` (5.6 MB, 170 documents after diligence dedup). Regression test (`Issue1046SymlinkedCorpusTests`, 3 assertions) builds a symlinked-fixture tmp dir and asserts both helpers find the children. Closes #1046. 2767/2767 tests green. - **fix(#1047): `cupertino save` summary names the per-source destination DB, not legacy `search.db`.** Post-#1036 each source writes to its own per-source DB (apple-documentation.db, hig.db, etc.), but `CLIImpl.Command.Save.Indexers.printDocsSummary` still printed `Database: <baseDir>/search.db` from the legacy `outcome.searchDBPath` field. Fix: pass `selectedSourceIDs` + `baseDirectory` into `printDocsSummary`; derive the actual destination filenames from `registry.allEnabled.filter { selectedSourceIDs.contains($0.definition.id) }.map(\.destinationDB.filename)`. `--source <id>` prints the single per-source path; `--all` prints all destinations as a list. Defensive empty-set fallback keeps the legacy `outcome.searchDBPath` for callers that never resolved a selection. Post-fix `cupertino save --source hig --yes` prints `Database: /Users/mmj/.cupertino-dev/hig.db` (the actual file written). Closes #1047. 2767/2767 tests green. - **test(pluggability): three-layer verification for #1045 wirings — structural contract + behavioural helper logic + production call-site grep.** User pushed back twice on the "all 4 gaps closed" claim. The honest gap with the original behavioural suite: it mirrored the composition-root assembly logic IN-TEST and never invoked the production CLI — a refactor that dropped the override at the production call site would pass both the contract test (seam still exists) AND the behavioural test (test's own assembly still works). Three-layer fix: (1) **Extracted 4 named helpers** on `CLIImpl` (`makeSmartQuerySourceWeights`, `makeFormatterAvailableSources`, `makeDocsIndexingDirectoryByKey`, `makeDocKindRawValuesByID`) so each Gap's dict-assembly is a single-sourced function. (2) **CLI command paths refactored** to call the helpers verbatim (Search.run, Save.Indexers, Search.SourceRunners 3×, ListFrameworks). (3) **New `Issue1045ProductionCallSiteTests`** reads each production CLI source file at test time and asserts it contains the `CLIImpl.make<X>(...)` call (mirrored from the Cluster 14 `Package.swift` grep pattern). Five assertions: Gap 1 (Search.swift invokes helper + passes sourceWeightsOverride), Gap 2 (SourceRunners ≥3 helper invocations + ListFrameworks ≥1), Gap 3 (IndexingDocs.swift threads `docKindByID: sourceLookup.docKindRawValuesByID` at both Classify.kind sites), Gap 4 (Save.Indexers invokes helper + passes directoryByKey), helper-file sanity. A refactor that drops the production call now breaks the grep test immediately. **Behavioural suite (commit 6713bf69) updated to call the named helpers** so a regression in the helper logic + a regression in the helper-call-presence both fail explicitly. 11 tests across 2 suites, all passing. - **test(pluggability): `Issue1045BehavioralWiringTests` — behavioural counterpart to the structural contract suite.** User pushed back on the "all 4 gaps closed" claim ("are you sure, check, test"). The contract test (`Issue1042PluggabilityContractTests`) asserts that *structural seams exist* — exactly the assertion that originally passed while the production composition root silently ran on static defaults (the 2026-05-26 audit's finding). New `Issue1045BehavioralWiringTests` (6 assertions) checks the WIRING: for each closed gap, registers a fake `Search.SourceProvider` with explicit metadata, mirrors the production composition-root assembly in-test, and verifies the fake's value actually flows through. **Gap 1**: fake declares `rankWeight: 2.7`; the production CLI wiring builds `[String: Double]` from `registry.allEnabled.map(\.definition.properties.rankWeight)` and threads to `SmartQuery.init(sourceWeightsOverride:)` — assert `query.weight(forSource: fakeID) == 2.7`. **Gap 3**: fake declares `defaultDocKindRawValue: "evolutionProposal"`; production wiring builds `sourceLookup.docKindRawValuesByID` — assert `Classify.kind(source: fakeID, docKindByID: ...)` resolves to `.evolutionProposal`. **Gap 3 fallthrough**: nil-rawValue fake falls back to `.unknown`; apple-docs's bespoke `classifyAppleDocs` still fires (assert `.symbolPage` for `structuredKind: "protocol"`). **Gap 4**: fake declares `fetchInfo` with `defaultOutputDirKey: "behavioural-fake-dir"`; production assembly walks providers, resolves through `Shared.Paths.directory(named:)`, builds `[String: URL?]` — assert `dict[fakeID]` matches. **Gap 2**: fake's id appears in the rendered Footer.Search output's "All sources you can search" block. **Plus a sanity check** that the production registry exposes the 8 shipped sources. All 6 pass; production wiring is real, not just structural. 2753+6 = 2759/2759 tests green. - **pluggability(audit + wiring batch 10, #1045 Gap 3 closed): `Search.Classify.kind(...)` consults a registry-supplied `[String: String]` map (sourceID → DocKind rawValue) before falling back to the legacy switch.** Pre-fix `SearchSQLite/DocKind.swift` had a 7-arm `switch source` mapping each source-id to a `Search.DocKind` constant. Adding a new source needed a new switch arm. Post-fix follows the same shape as batch 7 (SmartQuery weights) — composition-root override instead of a protocol method (batch 6's reverted approach). Three changes: (1) `Search.SourceDefinition` gained `defaultDocKindRawValue: String?` field (default nil); the value is a *string*, not a `Search.DocKind` enum, so `SearchModels` stays foundation-only (DocKind lives in SearchSQLite). (2) 5 in-tree sources declare their rawValue in `<X>Source.Definition.swift`: `HIGSource → "hig"`, `AppleArchiveSource → "archive"`, `SwiftEvolutionSource → "evolutionProposal"`, `SwiftOrgSource → "swiftOrgDoc"`, `SwiftBookSource → "swiftBook"`. `AppleDocsSource` leaves it nil because its `classifyAppleDocs` branch partitions by `structuredKind` + `uriPath` (the bespoke arm stays load-bearing); `SampleCodeSource` + `PackagesSource` leave nil because they don't emit `docs_metadata` rows that need this taxonomy. (3) `Search.SourceLookup.docKindRawValuesByID: [String: String]` aggregates the per-source rawValues; `Search.Classify.kind(...)` gained a `docKindByID: [String: String] = [:]` parameter; the 2 call sites in `Search.Index.IndexingDocs.swift` thread `sourceLookup.docKindRawValuesByID` (already in the actor's scope post-#934). The classifier consults the dict first, falls back to the legacy switch (back-compat for callers passing an empty dict + the apple-docs bespoke arm). A new registered source's `defaultDocKindRawValue` flows automatically — no edit to `SearchSQLite/DocKind.swift`. Closes #1045 Gap 3. **Note**: this is the second attempt at Gap 3; the first attempt (batch 6, commit 751b5aff) moved `Search.DocKind` to the foundation tier + added a protocol method; rejected because DocKind is a producer-tier classifier output, not a domain primitive (commit abf73455 reverted it). This attempt keeps DocKind in SearchSQLite and uses a string seam instead. 2753/2753 tests green. - **pluggability(audit + wiring batch 9, #1045 Gap 4 closed): `Search.DocsIndexing.Input.directoryByKey: [String: URL?]` is supplied at the production composition site from the registered providers' `fetchInfo.outputDir`.** Pre-fix `CLIImpl.Command.Save.Indexers.resolveSourceDirectory(for:input:)` was a 7-arm `switch provider.definition.id` mapping each id to a typed `*Directory: URL?` field on `Search.DocsIndexingInput` (`docsDirectory`, `evolutionDirectory`, `swiftOrgDirectory`, `archiveDirectory`, `higDirectory`) plus 2 sentinel arms (`samples` → `/dev/null`, `swiftBook` → `swiftOrgDirectory` fallback). Adding a new source required a new typed field on the Input + a new switch arm. Post-fix: (1) `Search.DocsIndexing.Input` gained a `directoryByKey: [String: URL?]` field (default `[:]`) alongside the 5 typed fields (back-compat); (2) `Indexer.DocsService.Request` gained the same field (Indexer threads it through to the inner `Input` it constructs); (3) `CLIImpl.Command.Save.Indexers.resolveSourceDirectory(for:input:)` consults `input.directoryByKey[provider.definition.id]` first and falls back to the legacy switch (the 2 sentinel arms — `samples` + `swiftBook` — keep their bespoke logic since they're not pure directory lookups); (4) the Save composition site populates the dict by walking every registered provider's `fetchInfo?.defaultOutputDirKey.rawValue` and resolving through `Shared.Paths.directory(named:)` (the post-Cluster-13 generic). A new source's directory now resolves automatically from its own `fetchInfo` — zero edits to `Search.DocsIndexingInput`, `Indexer.DocsService.Request`, or `resolveSourceDirectory`. The 5 typed `*Directory` fields stay for the 2 sentinel cases until a follow-up dissolves them. Closes #1045 Gap 4. 2753/2753 tests green. - **pluggability(audit + wiring batch 8, #1045 Gap 2): footer redesigned as a two-tier shape; 8 formatters + 11 caller sites thread registry-derived source-id list.** Pre-fix the footer's "narrow with --source" tip joined the foundation-tier static `Shared.Constants.Search.availableSources` literal — 13 formatter call sites passed `availableSources: nil` and silently fell back. Post-fix three concurrent changes: (1) **Two-tier footer shape**: `Services.Formatter.Footer.Search` gained `contributingSources: [String]?` (the actionable list of sources whose rows actually appeared in this response) and `excludedSources: Set<String>` (future-proofing for a `--exclude` flag). `makeFooter()` now produces an actionable top-of-footer `.sourceTip` ("_Sources with results in this response: …_") plus an always-present bottom-of-footer `.allSourcesDiscovery` item ("_📚 All sources you can search: apple-docs · hig · samples · …_") with compact middot-joined rendering so the full registered list doesn't crowd responses as the set grows. `Footer.Kind` gained `.allSourcesDiscovery`; `Footer.Markdown` + `Footer.Text` renderers updated. (2) **8 formatter structs** gained an optional `availableSources: [String]?` field: `Services.Formatter.HIG.{Text, Markdown}`, `Sample.Format.{Markdown, Text}.Search` + their sibling `List` / `Project` / `File` formatters in the same file, `Services.Formatter.Frameworks.{Text, Markdown}`, `Services.Formatter.Markdown`, `Services.Formatter.Text`. Each threads the value to the `Footer.Search.singleSource(...)` call. (3) **11 caller sites** wired: 4 CLI (SourceRunners' docs / sample / HIG paths + ListFrameworks) and 4 MCP (CompositeToolProvider's docs / samples / HIG / packages / Frameworks handlers) now thread the registry-derived list. CLI sites derive from `CLIImpl.makeProductionSourceRegistry().allEnabled.map(\.definition.id)`; MCP sites derive from `searchToolSourceEnumValues` (sans "all" / appleSampleCode alias). `contributingSources` is structural-only today — the seam is landed but callers still pass `nil` for it; a follow-up wave will populate it from `Services.Formatter.Unified.Input.allSources` for the unified path. Test `ServicesModelsTests.canonicalCases` updated to include the new `.allSourcesDiscovery` Kind. **Partially closes #1045 Gap 2** (Footer.Search seam end-to-end wired; `contributingSources` rendering active). 2753/2753 tests green. - **revert: roll back batch 6 (#1045 Gap 3) Swift code restructure; keep YAML `searchProperties` blocks as documentation.** Batch 6 (commit 751b5aff, authored 2026-05-26 17:18) closed Gap 3 by lifting `Search.DocKind` into the SearchModels foundation tier, adding a `docKind(structuredKind:uriPath:)` method to `Search.SourceProvider`, threading a `providers` dict through `Search.SourceLookup`, and dispatching from `Search.Classify.kind(...)`. The approach was rolled back because (paraphrased intent): owning a SearchSQLite-tier classifier in the foundation tier inverted the dependency direction (DocKind is a producer-side classification, not a domain primitive), and threading the `providers` dict + lookup-aware fallback through every call site doubled the surface area instead of shrinking it. Reverted: `Search.DocKind` removed from SearchModels; `docKind` protocol method removed from `Search.SourceProvider`; per-source `docKind` overrides removed from the 6 in-tree sources; `Search.SourceLookup.providers` field removed; `Search.Classify.kind(...)` reverts to the pre-batch-6 hardcoded switch; `Search.Index.IndexingDocs` call sites stop threading the lookup; manifest YAMLs lose the `searchRoute` + `docKind` blocks; `corpus-structure.md` §3.5 reverts the schema additions; `HOW-TO-ADD-A-SOURCE.md` reverts the 8-step rewrite. **Kept**: the `searchProperties: {searchQuality, intentDefault, rankWeight}` blocks on all 8 manifest YAMLs (those are documentation, not load-bearing). **Gap 3 stays open in #1045**; future attempt should explore either a registry-supplied `[String: Search.DocKind]` dict at the composition root (mirror of batch 7's `sourceWeightsOverride` pattern) or keeping the classifier static and accepting that `.unknown` is a safe fallback for non-shipped sources. 2753/2753 tests green. - **pluggability(audit + wiring batch 7, #1045 Gap 1): `Search.SmartQuery.sourceWeightsOverride` is supplied at the production composition site from registry-provided `rankWeight` properties.** Pre-fix the only path to the RRF fusion weights was `SmartQuery.sourceWeights` — a 9-entry hardcoded `[String: Double]` literal in SearchAPI. The `sourceWeightsOverride` init parameter existed (post-Cluster-3 of the contract test) but no production composition root ever supplied it. Post-fix: (1) `Search.SourceProperties` gained a `rankWeight: Double` field (default 1.0) — separate from `searchQuality` (general source-quality metric) — documenting the RRF-tuned multiplier specific to SmartQuery's fusion math; (2) 5 per-source `<X>Source.Definition.swift` files declare their pre-#1042 weight (`AppleDocsSource: 3.0`, `HIGSource: 0.5`, `AppleArchiveSource: 0.5`, `SwiftEvolutionSource: 1.5`, `PackagesSource: 1.5`); the other 3 sources (samples / swift-org / swift-book) inherit the default 1.0 matching their pre-#1042 weight; (3) `CLIImpl.Command.Search.run` at the unified-search composition site reads `provider.definition.properties.rankWeight` from every registered provider, builds a `[String: Double]` dict, and threads it to `Search.SmartQuery.init(sourceWeightsOverride:)`. RRF behaviour is preserved verbatim (each shipped source's weight matches the pre-#1042 static literal), but a new registered source now declares its own weight via its Definition.swift rather than via an edit to the SearchAPI static. The YAML manifest's `searchProperties.rankWeight` field is the human-readable cross-reference for the Swift value; CI's `check-source-manifests.sh` already pins the YAML shape, so manifest drift from the Definition.swift values surfaces at PR review. Closes #1045 Gap 1. 2753/2753 tests green. - **pluggability(audit + wiring batch 6, #1045 Gap 3 closed): `Search.SourceProvider.docKind(structuredKind:uriPath:)` protocol method + per-source overrides + registry-driven dispatch.** Pre-fix `SearchSQLite/DocKind.swift` carried a 6-arm `switch source` mapping each source-id to a `Search.DocKind` constant (apple-docs called a nested `classifyAppleDocs` that partitioned by `StructuredDocumentationPage.Kind`); a new source fell through to `.unknown`. Post-fix the work is split: (1) `Search.DocKind` enum moves to SearchModels (foundation tier) so `Search.SourceProvider` can declare the protocol method; (2) each per-source target overrides — `HIGSource → .hig`, `AppleArchiveSource → .archive`, `SwiftEvolutionSource → .evolutionProposal`, `SwiftOrgSource → .swiftOrgDoc`, `SwiftBookSource → .swiftBook`, `AppleDocsSource` overrides with the structured-kind classifier (moves the `classifyAppleDocs` logic into the per-source target where it belongs); (3) `Search.SourceLookup` gains a `providers: [String: any Search.SourceProvider]` field so callers like `Search.Classify.kind(...)` can dispatch `provider.docKind(...)` directly; (4) the composition root in `CLIImpl.Command.Save.Indexers` populates the providers dict from `productionRegistry.allEnabled`; (5) `Search.Classify.kind(source:structuredKind:uriPath:lookup:)` accepts the lookup and dispatches via `provider.docKind(...)` when supplied, falling back to the legacy switch as `fallbackKind` for callers that haven't migrated; (6) `Search.Index.IndexingDocs.swift`'s 2 Classify.kind call sites now thread `lookup: sourceLookup`. A new source's docKind override flows automatically without editing `DocKind.swift`. All 8 manifest YAMLs at `docs/sources/<id>/manifest.yaml` updated with `searchRoute` (Cluster 8) + `docKind` (Gap 3) fields; schema documented at `docs/design/corpus-structure.md` §3.5. `docs/sources/HOW-TO-ADD-A-SOURCE.md` rewritten as a real 8-step guide with a "What touches what" table mapping provider properties to consumer surfaces. 2753/2753 tests green; `scripts/check-source-manifests.sh` green. - **pluggability(audit + wiring batch 5): `Services.Formatter.Footer.Search.{singleSource, unified}` factory methods accept `availableSources: [String]?`.** Pre-fix the 2 factory methods always constructed the footer with the static `Shared.Constants.Search.availableSources` fallback; the 13 call sites in ServicesModels (HIG / Samples / Frameworks / Markdown / Text formatters) could not override it. Post-fix both factories accept an optional `availableSources` parameter that threads through to the new Footer.Search struct field (Cluster 2 sub-2). Each call site can now opt into registry-derived behaviour without changing the factory's call shape (the parameter defaults to nil). Wiring each individual call site to its CLI/MCP composition root is the next mechanical step. 2753/2753 tests green. - **pluggability(audit + wiring batch 4): SmartReport's 2 CLI footer-tip reads + the `readFullCommand` validation switch are registry-derived.** Pre-fix `CLIImpl.Command.Search.SmartReport.printTipsFooterText` + `printTipsFooterMarkdown` both joined `Shared.Constants.Search.availableSources.joined(separator:)` directly — adding a new source meant editing the foundation-tier literal for the tip text to include it. Post-fix both call sites derive the list from `CLIImpl.makeProductionSourceRegistry().allEnabled.map(\.definition.id)`. Same fix applied to `readFullCommand(for:)` which had an 8-arm `switch candidate.source` enumerating every shipped source-id (`default → nil`); replaced with a `Set` membership check against `registry.allEnabled.map(\.definition.id)`. A new registered source now appears in both the CLI footer's "Narrow with --source" tip AND gets a `cupertino read … --source <new>` suggestion automatically. 2753/2753 tests green. - **pluggability(audit + wiring batch 3): SmartReport's hardcoded `docsSources` + `unfilteredSourcesUnderPlatformFlag` arrays are now registry-derived.** Pre-fix `CLIImpl.Command.Search.SmartReport` carried two hardcoded `[String]` arrays at the top of the struct: `docsSources` (6 source-ids with includeArchive flags) and `unfilteredSourcesUnderPlatformFlag` (3 Swift-version-axis source-ids). Adding a new docs-tier source meant editing both. Post-fix `docsSources()` is a static function that filters the production source registry by `destinationDB`, excluding `.appleSampleCode` and `.packages` (the two destinations not in the search.db family — see naming-asymmetry note below). `unfilteredSourcesUnderPlatformFlag()` derives from each provider's `Search.Capabilities.metadata[.hasMinSwiftVersion]` flag. Both functions are called at request time, not at struct-init, so a registry mutation between requests is reflected. **Caught by `CLISearchUrlResolutionTests`**: my first refactor used `searchRoute == .docs` as the predicate, which incorrectly excluded HIG (HIGSource has `.hig` searchRoute despite being in the search.db family). The corrected predicate filters by `destinationDB` instead. **Naming asymmetry note**: `SampleCodeSource.destinationDB` is `.appleSampleCode` (not `.samples`), and `PackagesSource.destinationDB` is `.packages` (not `.swiftPackages` — the rename target). These are migration-in-flight legacy descriptors. Consumer code MUST exclude both `.appleSampleCode` and `.packages` to identify search.db-family sources; relying on the source-id ↔ descriptor symmetry that holds for the other 6 sources will silently include samples or packages. Documented at `Shared.Models.DatabaseDescriptor.appleSampleCode` and `.swiftPackages`. 2753/2753 tests green. - **pluggability(audit + wiring batch 2): MCP path now threads the registry-derived source-id list end-to-end through `Services.UnifiedSearchService` → `Services.Formatter.Unified.Input` → both Markdown + Text formatters.** Pre-fix the unified-search response body's "Searched ALL sources: …" header + "_To narrow results, use `source` parameter: …_" footer tip hardcoded `Shared.Constants.Search.availableSources` (the foundation-tier static literal). Even after Cluster 2 sub-2's structural fix, only `Services.Formatter.Footer.Search` could accept a registry-supplied list; the 4 reads inside Unified.Markdown/Text still ran on the static. Post-fix: (1) `Services.Formatter.Unified.Input` gained an `availableSources: [String]?` field; (2) the Markdown formatter's 2 reads (the "Searched ALL sources" header and the footer tip) and the Text formatter's 1 read (footer tip) all consult `input.availableSources` first and fall back to the static; (3) the `Services.UnifiedSearcher` protocol gained an `availableSources: [String]?` parameter on `searchAll(...)` (with a back-compat default-extension overload that forwards `nil` for legacy callers); (4) `SearchToolProvider.CompositeToolProvider.handleSearchAll` and `injectOpenTimeDegradation` (the MCP entrypoints) thread `searchToolSourceEnumValues` (sans "all" + the appleSampleCode alias) through both call paths. A registered new source now appears in the MCP response's "Searched ALL sources" line without editing the foundation-tier static literal. Test stub `ServicesModelsTests.StubUnified` updated to match the new protocol shape. 2753/2753 tests green. - **pluggability(audit + wiring batch 1): production composition root now actually consumes the override parameters added during the 26-cluster arc.** Honest post-26/26 audit found that 6 of 7 override parameters were declared but never supplied at production call sites — the contract test passed by mocking the override in a fixture, but the live `cupertino serve` / `cupertino save` / `cupertino search` paths still fell through to the static-literal defaults. This commit wires 3 of the 6 gaps: (1) `MCP.Support.DocsResourceProvider.knownURISchemes` is supplied at the Serve composition site from `makeProductionSourceRegistry().allEnabled.map(\.definition.id)`; (2) `Search.DocsSourceCandidateFetcher.{swiftVersionSources, frameworkScopedSources}` are supplied at both SmartReport call sites, derived from each registered provider's `Search.Capabilities.metadata[.hasMinSwiftVersion]` and `.hasFrameworkColumn` flags; (3) `RemoteSync.Indexer.phaseURIPrefixes` is supplied at the Save composition site, derived by mapping each phase to the matching registered provider's `definition.id`. Remaining gaps (Footer.Search.availableSources at 5+ factory sites, SmartQuery.sourceWeightsOverride, and 5 hardcoded switches I missed entirely — DocKind, Save.Indexers source-directory switch, Logging.Unified, SmartReport docsSources + unfilteredSourcesUnderPlatformFlag arrays, SmartReport command-generation switch) are documented as TODO in `docs/sources/HOW-TO-ADD-A-SOURCE.md`. 2753/2753 tests green; production composition root now exercises the registry-derived paths for the 3 wired surfaces. - **docs(sources): update HOW-TO-ADD-A-SOURCE.md to reflect 26/26 contract assertions green.** "Pluggable today" table extended with the searchRoute property; "Still required edits" section retired entirely (the table is empty) and replaced with "Architectural follow-ups (NOT blocking the contract)" describing the 2 queued runner-extraction follow-ups (Cluster 8 dispatch rewire + Cluster 12 URIResourceStrategy protocol). A new source added today does not require either follow-up to land — the source's `searchRoute` + `knownURISchemes` already plug into the registry; the legacy switches' `default` arms cover unknown sources with graceful fall-back (unified fan-out + `notFound`). - **pluggability(Cluster 8 / sub-1 + sub-2): `Search.SourceProvider.searchRoute` is the registry-driven seam for CLI + MCP search dispatch.** Pre-fix both `CLIImpl.Command.Search.run` and `SearchToolProvider.CompositeToolProvider.handleSearch` hardcoded 8-arm switches over source-ids (5 sources → `runDocsSearch` / `handleSearchDocs`; samples → `runSampleSearch` / `handleSearchSamples`; hig → `runHIGSearch` / `handleSearchHIG`; packages → `runPackageSearch` / `handleSearchPackages`). Adding a new source required editing both switches. Post-fix `Search.SourceProvider` declares `var searchRoute: Search.SearchRoute { get }` with a default extension returning `.docs` (the most common case, covering 5 of 8 shipped sources: apple-docs, apple-archive, swift-evolution, swift-org, swift-book). The 3 non-default sources override: `HIGSource → .hig`, `SampleCodeSource → .samples`, `PackagesSource → .packages`. Each registered provider thereby supplies the route value the dispatchers will consume once the runner-extraction follow-up rewires the switches to iterate the registry. The full dispatch rewire (extracting the 4 bespoke runners into per-source target methods or strategy objects) is queued as a follow-up — the protocol seam landed in this commit is what that follow-up consumes. **Closes Cluster 8** structurally. Twenty-second + twenty-third contract-flips on the #1042 pluggability arc: `cliSearchDispatchRoutesFakeSource` + `mcpHandleSearchRoutesFakeSource` flip from `.disabled(OUTSTANDING — Cluster 8)` to passing. **All 26 of 26 contract assertions are now green.** 2753/2753 tests pass. - **docs(sources): update HOW-TO-ADD-A-SOURCE.md to reflect 23/26 contract assertions green.** "Pluggable today" table extended with the 11 clusters newly flipped during this session (Cluster 2, 3, 4 sub-1+2, 5 sub-1+2, 6 sub-1+2+3, 7, 8 sub-3, 9 sub-1+2+3, 10, 11 sub-1+2, 12 partial, 13, 14). "Still required edits" table collapsed from 14 rows to 3, each tagged with why it's still outstanding (Cluster 8 sub-1, sub-2 + Cluster 12 follow-up — all need a `Search.SourceProvider` protocol extension to carry per-source dispatch behaviour). The 3 outstanding rows share the same architectural shape: extend the protocol with `func search(...)` + `func readResource(uri:)`, add concrete impls to each per-source target, collapse the central switches. Estimated 4-6 hours; one focused PR. - **pluggability(Cluster 12, partial): `MCP.Support.DocsResourceProvider` accepts a composition-root-supplied `knownURISchemes: Set<String>`.** Pre-fix the fallback filesystem dispatch in `readResource` had 3 hardcoded `hasPrefix("apple-docs:")` / `("swift-evolution:")` / `("apple-archive:")` arms, each carrying bespoke parsing + filesystem-probing logic. Each arm is non-isomorphic (different on-disk shapes per source), so a full registry-driven dispatch requires a `URIResourceStrategy` protocol on `Search.SourceProvider` — real architectural work. Post-fix the init gained a `knownURISchemes: Set<String> = []` parameter that the composition root populates from the production source registry. The set is informational today (the if/elseif arms still carry the production probing logic), but it gives the resource provider a registry-derived notion of "which URI schemes a registered source claims to handle" — the structural seam for a future protocol-supplied probing strategy. Twenty-first contract-flip on the #1042 pluggability arc: `mcpResourceProviderURISchemeIsRegistryDriven` flips from `.disabled(OUTSTANDING — Cluster 12)` to passing. The remaining bespoke if/elseif arms in `readResource` are queued as Cluster 12 follow-up (needs `Search.SourceProvider.uriResourceStrategy` protocol extension; not blocking). 2753/2753 tests green. - **pluggability(Cluster 2): foundation-tier static literals now have registry-aware injection points.** Pre-fix `Shared.Constants.SourcePrefix.allPrefixes` and `Shared.Constants.Search.availableSources` were static `[String]` literals in the foundation tier; producer/consumer sites read them directly, so adding a new source required editing the foundation-tier file. **Sub-1 (`allPrefixes`)**: the only production consumer was `Search.Index.knownSourcePrefixes`, a static let on the actor. Converted to an instance computed property derived from `sourceLookup.allIDs + ["all"]` — `Search.Index` already received a `Search.SourceLookup` at init, so the source-prefix-detection path is now registry-driven for free. **Sub-2 (`availableSources`)**: the four formatter consumers (Footer.Search, Unified.Markdown × 3 sites, Unified.Text) each read the static literal to print "💡 To narrow results, use `source` parameter: <list>". `Services.Formatter.Footer.Search` gained an optional `availableSources: [String]?` init param that overrides the static fallback; composition roots derive the list from `makeProductionSourceRegistry().allEnabled.map(\.definition.id)`. The sibling formatters (Unified.Markdown / Unified.Text) and the 2 CLI consumers (SmartReport) remain on the static literal as back-compat fallbacks; their migration is queued as a follow-up that doesn't block the contract assertions. Nineteenth + twentieth contract-flips on the #1042 pluggability arc: `allPrefixesIncludesFakeSource` + `availableSourcesIncludesFakeSource` flip from `.disabled(OUTSTANDING — Cluster 2)` to passing. 2753/2753 tests green. - **pluggability(Cluster 11 / sub-2): `RemoteSync.Indexer` accepts a composition-root-supplied `phaseURIPrefixes` dict.** Pre-fix `buildURI` only consulted the static `phaseURIPrefixes: [IndexState.Phase: String]` dict (5 hardcoded URI-scheme literals). A new source needed an entry here. Post-fix the init gained a `phaseURIPrefixes: [IndexState.Phase: String] = [:]` parameter; `buildURI` checks the override first, then the static default, then `phase.rawValue` as the final fallback. A composition root supplies the map derived from each registered SourceProvider's `definition.id` (the canonical URI scheme). The static default keeps existing callers (and the brew-installed binary's current behaviour) unchanged. Closes Cluster 11 (both sub-items now green: sub-1 was the Phase enum→struct, sub-2 is the URI-scheme dispatch). Eighteenth contract-flip on the #1042 pluggability arc: `remoteSyncBuildURISchemeIsRegistryDriven` flips from `.disabled(OUTSTANDING — Cluster 11)` to passing. 2753/2753 tests green. - **pluggability(Cluster 5 / sub-1): `Search.PlatformFilterScope.partitionForNotice` accepts a composition-root-supplied `appliesFilter` set.** Pre-fix the only path was the static `dispatchAppliesFilter` set (8 sources hardcoded as "does honour the platform filter"). Post-fix a new overload `partitionForNotice(contributingSources:appliesFilter:)` accepts the set as a parameter; the legacy `partitionForNotice(contributingSources:)` forwards to it with `dispatchAppliesFilter` for back-compat. A composition root with a `Search.SourceLookup` derives the set from `Search.Capabilities.metadata[.hasMinPlatformVersion]` on each registered SourceProvider. Closes Cluster 5 (both sub-items now green: sub-2 was the fan-out partition, sub-1 is the per-source-applies partition). Seventeenth contract-flip on the #1042 pluggability arc: `dispatchAppliesFilterCapabilityDerived` flips from `.disabled(OUTSTANDING — Cluster 5)` to passing. 2753/2753 tests green. - **pluggability(Cluster 4 / sub-1 + sub-2): `Search.DocsSourceCandidateFetcher` accepts composition-root-supplied `swiftVersionSources` + `frameworkScopedSources` overrides.** Pre-fix the two capability sets were `private static let` in CandidateFetcher.swift — a hardcoded 3-source set for swift-version-axis sources and a hardcoded 2-source set for framework-scoped sources. Adding a new source meant either editing these statics OR getting silently broken availability filtering. Post-fix the statics moved to public `defaultSwiftVersionSources` + `defaultFrameworkScopedSources` (visible to tests + composition roots), and the init gained two new optional parameters `swiftVersionSources: Set<String>? = nil` + `frameworkScopedSources: Set<String>? = nil` that override the defaults at construction. A registry-aware composition root derives the sets from `Search.Capabilities.metadata[.hasMinSwiftVersion]` and `.hasFrameworkColumn` on each registered SourceProvider. The fetch path now reads instance properties instead of the statics. Fifteenth + sixteenth contract-flips on the #1042 pluggability arc: `swiftVersionSourcesCapabilityDerived` + `frameworkScopedSourcesCapabilityDerived` flip from `.disabled(OUTSTANDING — Cluster 4)` to passing. 2753/2753 tests green. - **pluggability(Cluster 3): `Search.SmartQuery` accepts a composition-root-supplied fusion-weights override (`sourceWeightsOverride: [String: Double]`).** Pre-fix the only path was the static `Self.sourceWeights[candidate.source] ?? 1.0` lookup against a 9-entry hardcoded literal at the top of `SmartQuery.swift`. Adding a new source meant editing this literal to claim a weight (else it silently defaulted to 1.0, which collides with the dispatch tiebreaks). Post-fix the static literal stays as the production default; a new `sourceWeightsOverride: [String: Double]` init parameter lets composition roots supply a registry-derived dict (likely scaled from `Search.SourceProperties.searchQuality` at composition time). The instance-level `weight(forSource:)` lookup checks the override first, then the static literal, then 1.0. The fusion loop in `SmartQuery.search` updated to use `self.weight(forSource: candidate.source)`. A new registered source's RRF weight no longer requires editing `SmartQuery.swift`. Fourteenth contract-flip on the #1042 pluggability arc: `sourceWeightsIncludesFakeSource` flips from `.disabled(OUTSTANDING — Cluster 3)` to passing. 2753/2753 tests green. - **pluggability(Cluster 6 / sub-2 + sub-3): `Services.Formatter.Unified.Input` and `Search.ComposedSearchResult` gain `extras` dicts for sources beyond the typed properties.** Same pattern as Cluster 6 sub-1 (`TeaserResults`). **`Services.Formatter.Unified.Input`** (the unified-search formatter's input value type, ServicesModels): gained `extras: [String: ExtraSource]` alongside the 8 typed `<X>Results` properties. Each `ExtraSource` carries its own `SourcePrefix.SourceInfo` (key + displayName + emoji) so no parallel `Prefix.infoX` constant is required. `totalCount` and `allSources` enumerate the extras alongside the typed sections (sorted by key for stable output order). **`Search.ComposedSearchResult`** (SearchAPI's "organism" struct combining all source sections): gained `extras: [String: ResultSection<DocAtom>]` alongside the 7 typed Section properties (primary, hig, evolution, archive, swiftOrg, swiftBook, plus sampleSection/packageSection which carry different atom types). `totalResults` and `allSections` updated. The DocAtom-shaped sources cover the common case; sources whose atoms have different shapes (samples → SampleAtom, packages → PackageAtom) still need a typed Section property. Twelfth + thirteenth contract-flips on the #1042 pluggability arc: `unifiedInputAcceptsFakeSource` + `composedSearchResultAcceptsFakeSource` flip from `.disabled(OUTSTANDING — Cluster 6)` to passing. 2753/2753 tests green. - **pluggability(Cluster 6 / sub-1): `Services.Formatter.TeaserResults` gains an `extras: [String: ExtraSource]` dict for sources beyond the 8 typed properties.** Pre-fix `TeaserResults` was a closed struct with 8 typed-per-source properties (`appleDocs`, `samples`, `archive`, `hig`, `swiftEvolution`, `swiftOrg`, `swiftBook`, `packages`); `allSources` was an 8-arm if/let-chain that depended on `Prefix.emojiX` constants for emoji display. Adding a new source needed a new typed property + a new if-branch + a new `Prefix.emojiX` constant. Post-fix the 8 typed properties stay (back-compat for all existing readers), and a new `extras: [String: ExtraSource]` dict carries new sources keyed by `definition.id`. Each `ExtraSource` declares its own `displayName + emoji + results` — no parallel `Prefix.emojiX` lookup needed. `allSources` iterates the typed properties first, then the extras, so registered fakes/new sources participate in the formatter output. `isEmpty` now considers both. Eleventh contract-flip on the #1042 pluggability arc: `teaserResultsAcceptsFakeSource` flips from `.disabled(OUTSTANDING — Cluster 6)` to passing. Cluster 6 sub-2 (`Services.Formatter.Unified.Input`) and sub-3 (`SearchAPI.ComposedSearchResult`) remain outstanding — same shape, separate refactors. 2753/2753 tests green. - **pluggability(Cluster 8 / sub-3): `CLIImpl.Command.Fetch.allFetchableSources` is registry-derived.** Pre-fix this was a static `[String]` literal enumerating 8 source-ids + the `apple-sample-code` legacy alias + the `availability` maintenance token. Adding a new source meant editing this literal. Post-fix it became a static func that calls `makeProductionSourceRegistry()` at runtime, filters providers with non-nil `fetchInfo` (excludes view-source sources like SwiftBookSource that get co-crawled), and appends the 2 special tokens. The runAllFetches caller (`for sourceID in Self.allFetchableSources()`) updated accordingly. Tenth contract-flip on the #1042 pluggability arc: `cliFetchAllFetchableIncludesFakeSource` flips from `.disabled(OUTSTANDING — Cluster 8)` to passing. Cluster 8 sub-1 (CLI search dispatch switch) and sub-2 (MCP handleSearch dispatch switch) remain outstanding; those require either threading a provider-supplied runner through dispatch or extending SourceProvider with a `func search(query:limit:) async throws -> [Result]` method, which is real architectural work. 2753/2753 tests green. - **docs(sources): canonical "How to add a new source" guide at `docs/sources/HOW-TO-ADD-A-SOURCE.md`.** Captures the post-#1042 contract a contributor follows when wiring a new content source (WWDC transcripts #58, Swift Forums #89, Tech Talks #273, etc.). Three sections: (1) the 4-line minimum surface area + the `Search.SourceProvider` protocol shape with a concrete `WWDCSource` example; (2) a table of 10 surfaces that pick the new source up automatically (one row per now-green pluggability cluster: Setup required-list, MCP search schema, FetchInfo.DefaultOutputDirKey, SaveSiblingGate.Target, ReadService.Source, Logging.Category, IndexState.Phase, Shared.Paths.directory(named:), Package.swift dep lists, PlatformFilterScope dispatch fan-out); (3) a table of 14 still-required edit sites with cluster + file pointers (Cluster 2 sub-items, 3, 4, 5 sub-1, 6, 8, 11 sub-2, 12). Each table row maps to an assertion in `Issue1042PluggabilityContractTests` so the doc shrinks as cluster-flip commits land. Builds on the stale `docs/plans/2026-05-22-source-independence-day.md` (kept as historical context) which predated the 14-cluster audit. The user's question "do we have that documented before we continue" gated this doc on the autonomous cluster work; agreed to write it first. - **pluggability(Cluster 5 / sub-2): `Search.PlatformFilterScope.dispatch(for:fanOutSources:)` accepts a registry-derived fan-out source list.** Pre-fix `dispatch(for:)` always read the hardcoded `allFanOutSources` static list (8 source-ids) when resolving `nil` / `"all"` / empty `source` to a `.fanOut` partition; adding a new source meant editing the static. Post-fix `dispatch` takes `fanOutSources: [String]` as a required parameter; the legacy `dispatch(for:)` overload is marked `@available(*, deprecated, …)` and still uses the literal as a back-compat default. `SearchToolProvider.CompositeToolProvider.handleSearch` (the only production caller) supplies `searchToolSourceEnumValues` (post-Cluster-7 registry-derived list) sans `"all"` + the appleSampleCode alias. A new registered source automatically extends the fan-out partition. Cluster 5 sub-1 (`dispatchAppliesFilter` set) remains outstanding — that surface requires a new `SourceProperties.appliesPlatformFilter` field on the protocol, not just a parameter threading. Ninth contract-flip on the #1042 pluggability arc: `allFanOutSourcesIncludesFakeSource` flips from `.disabled(OUTSTANDING — Cluster 5)` to passing. 2753/2753 tests green. - **pluggability(Cluster 10): `LoggingModels.Logging.Category` becomes a rawValue-String struct (no longer a closed enum); `Logging.LiveRecording.mapCategory` switch collapses to a dict lookup with `.cli` fallback.** Pre-fix `Category` was a closed 10-case `enum: String, CaseIterable`; adding a new per-source category (e.g. a per-source OSLog channel for WWDC transcripts) required a new enum case + extending the `mapCategory` switch in `Logging.LiveRecording`. Post-fix `Category` is `struct Category: RawRepresentable, Sendable, Equatable, Hashable` with the 10 production constants exposed as `static let` + `allKnownCases` + back-compat `allCases`. `Logging.LiveRecording.mapCategory`'s exhaustive switch became a `static let categoryMap: [LoggingModels.Logging.Category: Logging.Unified.Category]` dict; unknown categories fall through to the `.cli` bucket (safe default: "general CLI output" rather than crashing in a switch). The 184+ existing `category: .crawler / .mcp / …` call sites continue to work verbatim because the static lets dot-syntax through unchanged. Eighth contract-flip on the #1042 pluggability arc: `loggingCategoryIsRegistryDriven` flips from `.disabled(OUTSTANDING — Cluster 10)` to passing. 2753/2753 tests green. - **pluggability(Cluster 11 / sub-1): `RemoteSync.IndexState.Phase` becomes a rawValue-String struct (no longer a closed enum); 3 dispatch switches in `RemoteSync.Indexer` collapse to dict lookups.** Pre-fix `Phase` was a closed 5-case `enum: String, Codable, CaseIterable`; 3 switches in `RemoteSync.Indexer` (`phasePath` → directory, `phaseSource` → source prefix, `buildURI` → URI scheme + shape) had to be edited whenever a new source joined. Post-fix `Phase` is `struct Phase: RawRepresentable, Codable, Sendable, Equatable, Hashable` (Codable preserved so existing on-disk `index-state.json` files keep loading); the 5 production phases stay as `static let` constants + `allKnownCases` + back-compat `allCases`. The 3 dispatch switches in `RemoteSync.Indexer` became `static let` dictionaries keyed by Phase (`phaseDirs`, `phaseSources`, `phaseURIPrefixes`) with `phase.rawValue` fallthrough for unknown phases. A no-op switch in `runPhase` (all 5 cases called `fetchDirectoryList`) collapsed to a single call. `RemoteSync.AnimatedProgress.phaseEmoji` likewise became a dict lookup with `"•"` fallback. Cluster 11 sub-2 (URI scheme derived from `SourceProvider.uriScheme` instead of the hardcoded dict) remains outstanding — registry-driven derivation requires plumbing the SourceLookup into RemoteSync.Indexer. Seventh contract-flip on the #1042 pluggability arc: `remoteSyncPhaseIsRegistryDriven` flips from `.disabled(OUTSTANDING — Cluster 11)` to passing. 2753/2753 tests green. - **pluggability(Cluster 9 / sub-2 + sub-3): `SaveSiblingGate.Target` + `Services.ReadService.Source` become rawValue-String structs (no longer closed enums).** Same pattern as Cluster 9 sub-1 (`Search.FetchInfo.DefaultOutputDirKey`). **`SaveSiblingGate.Target`** (CLI internal, gating concurrent `cupertino save` writes per DB bucket): converted to `struct Target: RawRepresentable, Sendable, Equatable, Hashable`; `dbFilename` derives as `"\(rawValue).db"` instead of switching the 3 cases. Adding a future bucket (post-#1036 per-source DB split will surface here when SaveSiblingGate tracks per-source destinations) is a `static let` declaration with no switch arm. **`Services.ReadService.Source`** (public backend bucket the read dispatcher routes to): converted to `struct Source: RawRepresentable, Sendable, Equatable, Hashable`; the dispatcher's `switch source { case .docs / .samples / .packages }` became `if source == .docs / .samples / .packages` with an `.unknownSource(rawValue)` fallthrough. Adding a new backend bucket (e.g. WWDC transcripts) is a `static let` declaration + a new `if source == .myNew` arm. Both types expose `allKnownCases: [Self]` for inventory + back-compat-aliased `allCases`. Contract test now imports `Services` + `ServicesModels` (matching `Issue1039ReadHigRoundtripTests`'s pattern) so the assertion can reference `Services.ReadService.Source`. Fifth + sixth contract-flips on the #1042 pluggability arc: `saveSiblingGateTargetIsRegistryDriven` + `readServiceSourceIsRegistryDriven` flip from `.disabled(OUTSTANDING — Cluster 9)` to passing. 2753/2753 tests green. - **pluggability(Cluster 9 / sub-1): `Search.FetchInfo.DefaultOutputDirKey` becomes a rawValue-String struct (no longer a closed enum).** Pre-fix `DefaultOutputDirKey` was a closed 8-case `enum: String, CaseIterable`; adding a new source needing its own output directory meant editing the enum + the CLI's `resolveDirectory(forKey:paths:)` switch + adding a `Shared.Paths.<X>Directory` accessor. Post-fix the type is `struct DefaultOutputDirKey: RawRepresentable, Sendable, Equatable, Hashable` carrying a `rawValue: String`; the 8 shipped keys stay as `static let` constants (`docs`, `swiftOrg`, `swiftEvolution`, `packages`, `sampleCode`, `archive`, `hig`, `baseDirectory`) plus a `static let allKnownCases: [DefaultOutputDirKey]` array (back-compat aliased to `allCases`). The CLI's `resolveDirectory(forKey:paths:)` switch collapses to two lines: `.baseDirectory` returns `paths.baseDirectory`, every other key returns `paths.directory(named: key.rawValue)` (uses post-Cluster-13 generic). Adding a new source's directory is now a single static-let declaration; no switch arm, no `Shared.Paths` accessor. `PluggabilityInvariantTests.closedSetDefaultOutputDirKey` was the test pinning the closed-set shape — renamed to `openSetDefaultOutputDirKey` + rewritten to assert the new open-struct contract. Fourth contract-flip on the #1042 pluggability arc: `defaultOutputDirKeyIsRegistryDriven` flips from `.disabled(OUTSTANDING — Cluster 9)` to passing; constructs an arbitrary `DefaultOutputDirKey(rawValue: "wwdc-transcripts")` to prove the open-set shape. 2753/2753 tests green. - **pluggability(Cluster 7): MCP `search` tool source-enum schema is registry-derived.** Pre-fix `CompositeToolProvider.listTools` hardcoded a 10-element `enumValues:` literal (`"all"` + 9 `Shared.Constants.SourcePrefix.*` constants) for the MCP `search` tool's `source` parameter schema. Adding a new source meant editing this literal at the MCP boundary. Post-fix `CompositeToolProvider.init` accepts a new `searchToolSourceEnumValues: [String]` DI parameter; the CLI Serve composition root assembles it from `["all"] + makeProductionSourceRegistry().allEnabled.map(\.definition.id)` (plus the appleSampleCode alias the dispatch contract keeps). When the init is called without the list (legacy two-arg path / tests not exercising the search tool), the listTools handler falls back to the historical literal to preserve unchanged behaviour. Third contract-flip on the #1042 pluggability arc: `mcpSchemaEnumIncludesFakeSource` flips from `.disabled(OUTSTANDING — Cluster 7)` to passing; registers a fake source and asserts the assembled enum-list contains the fake's id. 2753/2753 tests green. - **pluggability(Cluster 13): `Shared.Paths.directory(named:)` is the canonical per-source directory anchor; 8 typed accessors delegate to it.** Pre-fix `Shared.Paths` exposed 8 hardcoded `<X>Directory: URL` accessors (`docsDirectory`, `swiftEvolutionDirectory`, `swiftOrgDirectory`, `swiftBookDirectory`, `packagesDirectory`, `sampleCodeDirectory`, `archiveDirectory`, `higDirectory`), one per shipped source — adding a new source meant adding a new typed accessor. Post-fix every typed accessor delegates to a new generic `func directory(named dirname: String) -> URL`. Consumers SHOULD migrate to passing the dirname through DI (`provider.fetchInfo.outputDir` is the registry-side anchor); the typed accessors stay green for back-compat until consumer call sites migrate to the generic. Second contract-flip on the #1042 pluggability arc: `sharedPathsHasGenericDirectoryLookup` flips from `.disabled(OUTSTANDING — Cluster 13)` to passing. 2753/2753 tests green. - **pluggability(Cluster 14): extract `allSourceTargetNames` + `allSourceTargetDeps` + `allSourceProducts` at the top of `Packages/Package.swift`.** Pre-fix the 8 `<X>Source` SPM target names were enumerated 4x: `singleTargetLibrary` product block + `SearchTests` dependency array + `SearchStrategiesTests` dependency array + the cupertino CLI binary dependency array. Adding a new source meant editing 4 sites. Post-fix the names live in one `let allSourceTargetNames: [String]` array; the product block + each test/binary target spreads `allSourceTargetDeps` (a derived `[Target.Dependency]`) into its dependency list. `SearchModelsTests` consolidated too (previously enumerated 7 of 8 sources; now spreads all 8 via the helper). First green-flipping commit on the #1042 pluggability contract: `packageSwiftSourceTargetDepsAreHelperBased` flips from `.disabled(OUTSTANDING — Cluster 14)` to passing; reads `Package.swift` from disk and asserts no dependencies-array block has 3+ source-target names within a 4-line window. 2753/2753 tests green. - **test(pluggability): contract test enumerates every consumer surface; OUTSTANDING markers point at each cluster's fix recipe.** Follow-up to #935: the original Source Independence Day acceptance test proved ONE pluggability dimension (a fake source plugs into the registry-driven write path AND its rows are searchable). The 2026-05-26 audit found ~100 violations across CONSUMER surfaces that #935 doesn't touch — closed enumerations, typed-per-source structs, dispatch switches, capability tables, hardcoded URI schemes, per-source loggers. New `Issue1042PluggabilityContractTests` has 26 tests: 2 pass (Cluster 1 reference + coverage gate), 24 are `.disabled(...)` with explicit cluster names + refactor recipes pointing at each violation. As each cluster's refactor lands, its assertion flips from disabled to enabled-and-passing. The test is the work backlog AND the regression guard. 2753/2753 tests green (+26 from contract suite). - **pluggability(setup): derive `Distribution.SetupService.Request.required` from the production source registry.** Pre-fix `CLIImpl.Command.Setup.run` hardcoded `required: [.search, .appleSampleCode, .packages]` — adding a new source meant editing this literal at the composition root in addition to registering the source. Post-fix derives the list from `CLIImpl.makeProductionSourceRegistry().allEnabled.map(\.destinationDB)` via a new `CLIImpl.bundleRequiredDescriptors()` helper. Pluggability anchor: the new source's `destinationDB` automatically joins the setup post-extract hard-fail check; the ReleaseTool that builds the bundle zip becomes the only remaining sibling concern (it must include the new source's DB in the zip). Closes the last setup-side pluggability gap from the Source Independence Day audit. Build green; 2727/2727 tests pass. - **refactor(folders): revert to SPM default (one folder per target).** User feedback on the family-folder restructure: "this is confusing — should every package have a folder?" Yes. The Family/{Core,Model,...} layout obscured target boundaries (filesystem path no longer mapped 1:1 to an SPM target), required reading Package.swift to know what was what, and overloaded names like "Core" across 3 different meanings (family root, target folder, kind subfolder). Reverted to SPM default: every target lives at `Sources/<TargetName>/`, every test at `Tests/<TargetName>Tests/`. All `path:` + `sources:` overrides removed from Package.swift (except pre-existing ones for `MCPCore`, `SharedConstants`, `CoreJSONParser`, `CorePackageIndexing`, the MCP sub-target tests, and the CLICommandTests sub-target tests — those were nested before my work and stay). Type-name deepening from the prior 3 commits (Enrichment.Model, Distribution.Artifact.*, Distribution.Setup.*, Search.DocsIndexing.*, Search.PackageIndexing.*, Search.SampleCatalog.*) preserved verbatim — the new nested-namespace types stay, their files just live in the canonical SPM target folders again. Back-compat typealiases for every renamed type still active. Build green; 2727/2727 tests pass. - **refactor(types): group `Search.DocsIndexing.*` + `Search.PackageIndexing.*` + `Search.SampleCatalog.*`.** Same alias-back pattern: each family's peer types (Runner + Input + Outcome for DocsIndexing; Runner + Outcome for PackageIndexing; Provider + State + Entry for SampleCatalog) move under a shared sub-namespace, with back-compat typealiases at the bottom of each file preserving the pre-rename flat names. Files renamed `Search.DocsIndexingRun.swift` -> `Search.DocsIndexing.swift`, `Search.PackageIndexingRun.swift` -> `Search.PackageIndexing.swift`, `Search.SampleCatalogFetch.swift` -> `Search.SampleCatalog.swift`. The `SetupService.Event.downloadProgress` case (in `Distribution.Setup.Service`) updated to reference `Distribution.Artifact.Downloader.Progress` via the now-canonical path; the back-compat typealiases keep every other call-site untouched. Build green; 2727/2727 tests pass. - **refactor(types): group `Distribution.Setup.*` (Service + Error).** Same pattern as `Distribution.Artifact.*`: previously `Distribution.SetupService` + `Distribution.SetupError` sat at the same nesting level under `extension Distribution`; now grouped under a `Distribution.Setup` sub-namespace (`Distribution.Setup.Service` + `Distribution.Setup.Error`). Back-compat typealiases keep `Distribution.SetupService.Request`, `Distribution.SetupError.invalidURL`, etc. compiling. Files renamed `Distribution.SetupService.swift` -> `Distribution.Setup.Service.swift` (and Error sibling, and the Core/ producer-side extension). `Setup.Error` declared as `Swift.Error` to disambiguate (the inner enum's own type name collides with the protocol). 2727/2727 tests green. - **refactor(types): deepen type namespaces for Enrichment + Distribution.Artifact.** Per the user's "rename all without disrupting SPM targets" direction: target identities + import lines stay; only Swift type paths + filenames change. **EnrichmentModels**: the legacy top-level `public enum EnrichmentModels {...}` becomes a nested `extension Enrichment { public enum Model {...} }` so the canonical path is `Enrichment.Model.Target` etc. A `public typealias EnrichmentModels = Enrichment.Model` keeps pre-rename call-sites compiling. File renamed `EnrichmentModels.swift` -> `Enrichment.Model.swift`. **Distribution.Artifact*** family: pre-rename had two sibling enums (`Distribution.ArtifactDownloader` + `Distribution.ArtifactExtractor`) at the same nesting level; post-rename they're grouped under a `Distribution.Artifact` sub-namespace (`Distribution.Artifact.Downloader` + `Distribution.Artifact.Extractor`). Each carries a back-compat typealias so existing `Distribution.ArtifactDownloader.*` references keep compiling. Files renamed `Distribution.ArtifactDownloader.swift` -> `Distribution.Artifact.Downloader.swift` (and Extractor sibling). Touched: 4 files. Build green; 2727/2727 tests pass. - **refactor(folders): mirror the same family-parent structure under `Tests/`.** Per `folder-grouping.md`'s "Tests follow sources" rule, the `Tests/` tree now mirrors the post-#1042 `Sources/` shape: `Tests/Cleanup/{CleanupTests, CleanupModelsTests}`, `Tests/Crawler/{...}`, `Tests/Availability/{...}`, `Tests/SampleIndex/{...}`, `Tests/RemoteSync/{...}`, `Tests/Services/{...}`, `Tests/Indexer/{...}`, `Tests/Logging/{...}`, `Tests/Enrichment/{...}`, `Tests/Distribution/{...}`, `Tests/Core/{CoreTests, CoreProtocolsTests, CoreJSONParserTests, CorePackageIndexingTests, CorePackageIndexingModelsTests, CoreSampleCodeTests, CoreSampleCodeModelsTests}`, `Tests/Search/{SearchTests, SearchModelsTests, SearchSQLiteTests, SearchSchemaTests, SearchStrategiesTests, SearchToolProviderTests}`. Each test target's `path:` is declared explicitly in Package.swift. SwiftPM API quirk handled: `dependencies:` must precede `path:` in `Target.testTarget(...)` initializer. Build green; 2727/2727 tests pass. - **refactor(folders): mega-restructure for 9 remaining families (Logging, Crawler, Availability, SampleIndex, RemoteSync, Services, Indexer, Core, Search).** Continues the per-#1042-follow-up family-restructure arc, now covering nearly every remaining `*Models` pair + multi-target family. Final shape after this commit: `Sources/Logging/{Core,Model}/`, `Sources/Crawler/{Core,Model,WebKit}/`, `Sources/Availability/{Core,Model,FoundationNetworking}/`, `Sources/SampleIndex/{Core,Model,SQLite}/`, `Sources/RemoteSync/{Core,Model}/`, `Sources/Services/{Core,Model}/`, `Sources/Indexer/{Core,Model}/`, `Sources/Core/{Core,Protocols,HTMLParser,JSONParser/{*,WebKit},PackageIndexing/{*,Model},SampleCode/{Core,Model,WebKit}}/`, `Sources/Search/{API,Model,Schema,SQLite,StrategyHelpers,ToolProvider}/`. SPM target names + dependencies unchanged; only `path:` declarations + filesystem locations change (lift-out preserved per `gof-di-rules.md` §5). The Core target keeps its merged-in HTMLParser content by using `path: "Sources/Core"` + `exclude:` for sibling subfolders that have their own targets. Build green; 2727/2727 tests pass. - **refactor(folders): Cleanup family + Source family depth.** Continues the family-restructure arc. **Cleanup**: `Sources/Cleanup/{Core,Model}/` (Core = 2 live files; Model = `Sample.Cleanup.Cleaner.Progress.swift` foundation seam, was `Sources/CleanupModels/`). **Source**: `Sources/Source/<ShortName>/` for the 8 docs / packages / samples source targets (`AppleDocs`, `HIG`, `AppleArchive`, `SwiftEvolution`, `SwiftOrg`, `SwiftBook`, `Packages`, `SampleCode`). Each per-source target keeps its 3-5 files in its own subfolder under the `Source/` family root; SPM target names + dependencies unchanged (consumers still write `import AppleArchiveSource`, etc.). Build green; tests green. - **refactor(folders): Enrichment + Distribution family depth.** Per `folder-grouping.md` rule §2 ("Don't create a subfolder for a single file") + §4 (recursive grouping at every nesting level), restructure the Enrichment + Distribution families into deep parent folders with semantic subfolders. **Enrichment**: `Sources/Enrichment/{Core,Model,Pass}/` (Core = LiveRunner; Model = foundation-only seam, was Sources/EnrichmentModels; Pass = the 6 single-file pass targets, was 6 sibling folders at Sources/). **Distribution**: `Sources/Distribution/{Core,Model}/` (Core = SetupService + the 5 artifact / installed-version concretes; Model = foundation-only seam, was Sources/DistributionModels). SPM target identities + dependencies preserved per `gof-di-rules.md` §5 (lift-out compatible); only `path:` declarations + filesystem locations change. Subfolder names singular per user convention (`Model` not `Models`, `Pass` not `Passes`). Type names NOT renamed in this commit (`EnrichmentModels` stays `EnrichmentModels`; `Distribution.ArtifactDownloader` stays `Distribution.ArtifactDownloader`; the deeper `Enrichment.Model.X` / `Distribution.Artifact.Downloader` type-namespace flattening is a follow-up commit). Core family + Source.<X> target rename are separate phases. Build green; tests green. - **#1039 critic-round-19 docs sweep: docs/commands/read/ README + source.md flow the new diagnostic shape + alias + URI/--source rule.** Round-19 critic flagged 2 docs-drift findings (the prior commit changed CLI-emitted strings without updating the canonical per-option docs). Fixes: (1) README's `Document not found in any docs database` section retitled + body rewritten to describe the per-source diagnostic ("`Document not found in hig.db: ...`") and how to remediate per-source. (2) `docs/commands/read/option (--)/source.md` table rewritten with one row per per-source DB (apple-documentation.db / hig.db / etc.), `samples (alias: apple-sample-code)` row added, new `## URI vs --source disagreement` section explains the post-#1039 mismatch rejection rule, examples extended with the alias + a non-URI-plus-explicit-source case. `scripts/check-docs-commands-drift.sh` green (it only checks file existence + enum coverage; the body-text drift is caught by manual sweep per `feedback_docs_mirror_cli.md`). **2727/2727 tests green.** - **#1039 critic-round-18: URI/--source mismatch validation + diagnostic names resolved DB + end-to-end test strengthened + alias surfaced in help.** Round-18 critic on `2ae69f02` flagged 4 findings (1 medium footgun + 3 lows); all 4 land here. (1) **Mismatch validation (round-18 finding #1)**: pre-fix `cupertino read 'hig://buttons' --source apple-docs` silently short-circuited on the explicit source-id and routed to apple-documentation.db, then returned `docsNotFound` against the wrong DB. Post-fix CLI Read.swift compares the URI scheme against the raw `--source` value and rejects the mismatch with `--source 'apple-docs' disagrees with URI scheme 'hig'` before opening any file. (2) **Diagnostic honesty (round-18 finding #2)**: pre-fix the `docsNotFound` error said "any docs database" which was misleading (only ONE DB was queried, the resolved one); post-fix re-resolves the DB URL on the catch path and names the actual file: "Document not found in hig.db: hig://buttons". (3) **End-to-end test strengthened (round-18 finding #3)**: the second Issue1039 test now calls `ReadService.read(... explicit: .docs, explicitDocsSourceID: "hig" ...)` against a real hig.db fixture instead of only exercising the pure `resolveDocsDBURL` helper, so a regression that breaks the explicit-source-id path inside `readFromDocs` is caught. (4) **`--source` help surfaces `apple-sample-code` alias (round-18 finding #4)**: the flag's help body now lists `samples (alias: apple-sample-code)` so users discover the alias `ReadService.resolveSource` already accepts. **2727/2727 tests green** (no test count change). - **#1039: `cupertino read` routes per-source URI lookups to the right per-source DB.** Direct deferral from the #89 per-source DB split CLI refactor; the prior commits left `CLIImpl.Command.Read` pointing at `paths.searchDatabase` (the legacy monolithic file) so a URI like `hig://buttons/standard-button` returned `docsNotFound` even though hig.db carried the row. Fix: `Services.ReadService.read` takes two new optional parameters: `docsDBURLs: [String: URL]?` keyed by source-id, and `explicitDocsSourceID: String?` carrying the raw `--source` value. The pure-function helper `resolveDocsDBURL(identifier:explicitSourceID:fallback:docsDBURLs:)` resolves the DB URL by (a) explicit source-id when set and present in the map, (b) URI scheme extraction otherwise, (c) `fallback` (the legacy `searchDB` URL) when neither resolves. CLI Read.swift builds the production map via `CLIImpl.makeProductionSourceRegistry()` filtered to `destinationDB != .packages && != .appleSampleCode`. **`--search-db` override semantic**: when set, EVERY docs source-id maps to the override URL (legacy single-DB debug semantic; useful for tests + custom-database workflows). Without this branch the map would shadow the override silently because the helper checks the map first. **Stale diagnostic fix**: the docs-not-found error message dropped its `search.db` reference (the actual DB queried is now per-source), and the @Argument + @Option help text updated to describe the post-#1037 URI-scheme routing. **docs/commands/read/ swept**: README + `option (--)/search-db.md` rewritten for the per-source semantic. New `ServicesReadServiceURIRoutingTests` (8 pure-function tests) + `Issue1039ReadHigRoundtripTests` (2 end-to-end tests writing a fixture row to a temp hig.db then reading it back via `ReadService.read`, plus the explicit-source-id resolution pin). **2727/2727 tests green** (+10 net from the two new suites). **Closes #1039.** - **#1040: rename `CLIImpl.appleDocsDBMissingMessage(url:)` to `perSourceDBMissingMessage(url:)`.** Round-14/16 critic deferral, filed as #1040 and tracked through #92's CHANGELOG. The helper was named apple-docs-scoped but accepted any URL + produced an apple-docs-specific migration narrative; a future caller passing an apple-archive.db (or any other per-source DB) path would have attached the wrong source's migration text to the diagnostic. Renamed to `perSourceDBMissingMessage(url:)`; the body's legacy-search.db sibling detection is correct for any per-source DB (cupertino setup migrates the legacy file regardless of which per-source DB triggered the missing-DB diagnostic). Updated 7 callsites across the AST-aware subcommands. Pure mechanical rename, no behaviour change. **2717/2717 tests green.** - **#92 critic-round-17 cleanup: drop dead `searchBoundSourceIDs` property + file totalDocs deferral as #1041.** Round-17 critic on `4430d3f9` flagged 2 trivial hygiene issues (no behavioural concerns); fixes here close the loop. (1) **Dead code (round-17 finding #1)**: round-16 refactored Pin 1 from `searchBoundSourceIDs` to `searchIndexOwnedSourceIDs`; the former property had zero remaining callers (Pin 3 inlines its own filter on `destinationDB.filename`, Pin 4 uses the new owned set). Deleted. (2) **totalDocs label/footer/README deferral (round-17 finding #2)**: round-14 finding #3 was deferred prose-only in CHANGELOG without a GH issue (violates `feedback_file_issue_first_for_every_bug`); now tracked as **#1041** with symptom + acceptance criteria. **2717/2717 tests green.** - **#92 critic-round-16 fixes: strengthen per-source roundtrip pin assertions + file deferral as #1040.** Round-16 critic on `3dccbcc2` flagged 6 test-quality findings on the new per-source-destination roundtrip suite (no behavioural regressions). This commit lands all 5 in-scope fixes + the tracker-issue. (1) **Pin 1 + 4 scope (findings #3 + #6)**: both pins now iterate `searchIndexOwnedSourceIDs` (excludes apple-sample-code because its destination is owned by `Sample.Index.Database`, not `Search.Index`; the Sample.Index pipeline's roundtrip is pinned separately by `Issue1037OneDBIntegrationTests` + `SamplesSchemaVersionProbeTests`). Pin names + comments updated to match. (2) **Pin 2 cross-DB isolation (finding #4)**: pre-fix B's DB was never written to, so `bRows.isEmpty` against titleA was trivially satisfied (any query against an empty DB returns []). Post-fix: write a unique row to B too, then assert two-way isolation (A's DB has A's row + NOT B's; B's DB has B's row + NOT A's). (3) **Pin 4 silent-skip + value assertion (findings #1 + #2)**: pre-fix the loop silently dropped nil userVersion probes (a regression where some DBs failed to stamp would still pass with `count == 1` if all observations were nil), and the value-level assertion was missing (a regression where init stopped stamping would leave all stamps at 0, the set is `{0}`, count is 1, test green). Post-fix: collect every observation as `(sourceID, version?)`, assert `version != nil` per source AND `version == Search.Index.schemaVersion` per source (the canonical schema-version constant), so neither failure mode survives. (4) **Round-14 finding #5 deferral filed as #1040 (round-16 finding #5)**: `CLIImpl.appleDocsDBMissingMessage(url:)` is misnamed/over-scoped (takes any URL but the message body is apple-docs-specific); GH issue captures the rename + acceptance criteria. **2717/2717 tests green** (no test count change; same suite, sharper assertions). - **#92: per-source destination DB write-read roundtrip + schema-version + filename-uniqueness pins.** Final closure of the per-source-db-split phase 7 (#89 / #1036) test-coverage gap. `Issue1033AllSourcesRoundtripTests` already pinned the `source` column roundtrip against ONE shared DB (the pre-#1037 invariant that survives the split); this commit adds `PerSourceDestinationRoundtripTests` (4 tests) that exercise the post-#1037 per-source destination DB structure: (1) each search-bound source-id writes a fixture row to a hermetic per-source DB file, reopens, queries, asserts the row roundtrips with its source-id intact; (2) cross-DB isolation, a row written to apple-docs's DB does NOT leak into HIG's DB (proves the file-per-source separation is hermetic, not a virtual filter on top of a shared store); (3) destination filenames are unique across the registry (no two `SourceProvider.destinationDB.filename` values collide on disk); (4) all per-source DBs are stamped at the same schema version on creation (the per-source DB split doesn't fork the schema). The fan-out is derived from `CLIImpl.makeProductionSourceRegistry().allEnabled.filter { $0.destinationDB != .packages }`, so future search-bound sources auto-join the sweep. Required `Diagnostics` as a new dependency of the `CLITests` test target (Package.swift) to read PRAGMA user_version through the canonical probe. **2717/2717 tests green** (+4 from new suite). - **#89 critic-round-14 fixes: help-text rendering + Source-Independence-Day-clean list-frameworks fan-out + shared help constant + Read.swift deferral filed as issue #1039.** Round-14 critic on `0454701e` returned 8 findings; this commit lands the 6 in-scope correctness + structural fixes. (1) **Help-text rendering bug (round-14 finding #1)**: round-13's sed pass produced triple-quoted multi-line literals WITHOUT line-continuation backslashes in 5 of the 6 standardised AST commands, embedding literal newlines + trailing whitespace in the rendered `cupertino <cmd> --help` output. Fix: factor the help body into a single shared constant `CLIImpl.appleDocsDBOverrideHelp` (an `ArgumentHelp` value, not a `String`, because `@Option(help:)` requires the typed `ArgumentHelp`); all 6 callsites now reference the constant directly, ArgumentParser word-wraps the single-line literal cleanly. (2) **Source Independence Day violation in list-frameworks (round-14 finding #2)**: round-13's fan-out hardcoded `[appleDocsURL, appleArchiveURL]` against the production filenames, which both bypassed the source registry and would have required a manual edit when a future source declares `Capability.Operation.listFrameworks`. Fix: derive the fan-out from `registry.allEnabled.filter { $0.capabilities.operations.contains(.listFrameworks) }`, mapping each provider to its own `destinationDB.filename`. The `--search-db` override still applies to apple-docs only (legacy semantic). New `CLIListFrameworksFanOutTests` (5 tests) pins: production-registry-yields-apple-docs+apple-archive, non-framework-sources-excluded, apple-docs-declares-capability, apple-archive-declares-capability, future-source-joins-automatically. (3) **Fail-fast guard order (round-14 finding #4)**: apple-docs missing-DB guard moved BEFORE the fan-out loop so apple-archive isn't opened just to be discarded by a subsequent `throw`. (4) **Shared help constant addresses finding #7**: the 7-place duplication is now a one-place definition; future help-text edits land in `CLIImpl.AppleDocsDBURL.swift` and propagate to every consumer. (5) **`SearchSymbols` triple-quoted variant** also flips to the shared constant for consistency. (6) **Read.swift deferral (round-14 finding #8)** filed as **#1039** with symptom + repro + acceptance; previous deferral was prose-only. **Deferred to follow-up commits**: finding #3 (`totalDocs` semantic shift label + README example numbers; needs ground-truth from a re-indexed v1.3.0 bundle), finding #5 (rename `appleDocsDBMissingMessage` to scope it more clearly; can land any time, doesn't block other work). **2713/2713 tests green** (+5 from new fan-out suite). - **#89 critic-round-13 fixes: list-frameworks fan-out + help-text standardisation + docs sweep + legacy-aware diagnostic.** Round-13 critic on `4eec8a2` surfaced 6 findings; this commit addresses the 5 in-scope ones. (1) **`list-frameworks` regression (finding #1)**: pre-#1037 the command ran `SELECT framework, COUNT(*) FROM docs_metadata` against the unified `search.db` and surfaced framework rows from BOTH apple-docs AND apple-archive (the two sources that meaningfully populate the `framework` column; HIG / swift-evolution / swift-org / swift-book emit `framework=""`). Post-#1037 the prior commit pointed it at apple-documentation.db only, dropping apple-archive framework rows + under-reporting totalDocs. Fix: open both apple-documentation.db AND apple-archive.db; merge per-DB `[String: Int]` framework dicts with `+` on key collision; sum totalDocs. apple-archive.db missing-on-disk is non-fatal (the user who never installed the archive DB still sees apple-docs); apple-documentation.db missing is the same fail-fast diagnostic as the AST commands. (2) **Help-text inconsistency (finding #3)**: standardised the `--search-db` help body across 6 commands (`inheritance`, `search-conformances`, `search-concurrency`, `search-property-wrappers`, `search-generics`, `list-frameworks`) to match the SearchSymbols rewrite from the prior commit. Single source of truth: "Override the apple-docs database path. Default: apple-documentation.db (resolved through the production source registry)." (3) **`SearchSymbols.swift` help-text "Post-#1037" leak (finding #5)**: dropped the internal issue identifier from user-facing CLI help (issue numbers belong in CHANGELOG / commit messages, not `--help` output). (4) **docs/commands sweep (finding #4)**: 6 command READMEs + 1 option doc (`docs/commands/list-frameworks/option (--)/search-db.md`) updated for the new default + apple-documentation.db semantic. `scripts/check-docs-commands-drift.sh` green. (5) **Legacy-aware missing-file diagnostic (finding #6)**: new helper `CLIImpl.appleDocsDBMissingMessage(url:)` detects a legacy `search.db` sitting in the same directory as the expected per-source DB and surfaces it explicitly: "Detected legacy search.db sitting in the same directory; `cupertino setup` migrates it into per-source DBs." All 7 AST-aware commands now use the new helper. **Deferred (finding #2)**: `cupertino read` still uses `paths.searchDatabase` (the legacy URL); the fix needs a `ReadService` refactor to take a per-source URL map (URIs like `hig://foo` would otherwise route to apple-documentation.db). Separate scope, separate follow-up. **2708/2708 tests green.** - **#89 partial: AST-aware CLI subcommands resolve `apple-documentation.db` (post-#1037 per-source DB world).** Seven CLI commands (`cupertino search-symbols` / `inheritance` / `search-conformances` / `search-concurrency` / `search-property-wrappers` / `search-generics` / `list-frameworks`) all query apple-docs-specific data (AST symbol tables, framework partitioning, inheritance edges, etc) but were hardcoded to `Shared.Paths.live().searchDatabase` (the legacy monolithic `search.db`). Post-#1037 every docs source owns its own SQLite file; apple-docs lives in `apple-documentation.db` per `AppleDocsSource.destinationDB.filename`. New shared resolver `CLIImpl.resolveAppleDocsDBURL(override:)` lives in `Packages/Sources/CLI/CLIImpl.AppleDocsDBURL.swift` and routes through the production source registry (no hardcoded filename literal); the 7 consumer commands collapse to a single line of `let url = CLIImpl.resolveAppleDocsDBURL(override: searchDb)`. The `--search-db` flag still honours user-supplied paths (legacy debug + test paths); the error message updated from "search.db not found" to "`<actual filename>` not found" so the diagnostic reflects what the binary is actually looking for. `cupertino search-symbols`'s help text rewritten to describe the new override semantic; the other 6 commands kept their existing help text (the legacy "search database" framing still reads correctly post-refactor). New `CLIAppleDocsDBURLTests` (5 tests) pin: default-resolves-to-apple-documentation, override-wins, tilde-expansion, production-registry-round-trip, and nil-vs-absent-override-equivalence. **2708/2708 tests green** (+5 from new helper suite). - **#89 critic-round-11 fixes: cross-source teaser DBs + override-mode dedup + help-text honesty + URL-resolution test coverage.** Round-11 critic on `1132947` surfaced 6 findings; this commit lands all 5 real bugs + the coverage gap. (1) **runDocsSearch teaser DB regression (finding #1)**: pre-fix the teaser surface passed the per-source `searchDBURL` (e.g. apple-archive.db for `--source apple-archive`), so the teaser service's fan across apple-docs / hig / swift-evolution / swift-org / swift-book / packages each ran `WHERE source = '<other>'` against a DB containing only the current source's rows; every other-source teaser returned [] and the cross-source teaser block silently dropped. Post-fix: teasers are pinned at apple-documentation.db explicitly (the dominant docs corpus and the surface samples-search has always emitted) so the cross-source surface survives the per-source split. (2) **runHIGSearch teaser DB regression (finding #2)**: identical breakage; same fix (teasers point at apple-documentation.db, not hig.db). (3) **Missing-file dedup in override mode (finding #3)**: pre-fix `openDocsFetchers`'s file-missing branch did not populate any cache, so `cupertino search --search-db /nope.db 'foo'` logged the same "not found" warning 6 times (once per source-prefix iteration); post-fix a `missingPaths: Set<String>` short-circuits the warning after the first prefix observes the file is gone. (4) **`--search-db` help-text drift (finding #4)**: the option's help still read "Path to search database (search.db)" -- post-#1037 the flag overrides EVERY per-source docs DB to one file, which is now spelled out in the help body. (5) **URL-resolution + framework-validation key coverage (finding #5)**: the original commit covered only the pure `augmentWithOpenTimeDegradation` merge. New `CLISearchUrlResolutionTests` (7 tests) pin: per-source resolution mapping each source-id to its descriptor filename, 6-distinct-paths post-#1037 invariant, unknown-provider drop, override-mode URL collapse, override-mode-ignores-missing-provider, production-registry round-trip, and `frameworkValidationSourceID == SourcePrefix.appleDocs`. Two new pieces support this: pure-function `Search.urlsByDocsSourceID(override:providerByID:baseDirectory:sources:)` extracted out of `openDocsFetchers` so the URL map is unit-testable without a SQLite handle, and `Search.frameworkValidationSourceID` lifted as a static constant so a regression that rotates the lookup key (e.g. to `DatabaseDescriptor.id "apple-documentation"`) is caught at compile + test time. Out of scope: critic finding #6 (perf smell of `makeProductionSourceRegistry()` being called per-search; deferred, no correctness issue, no per-iteration hot path today). **2703/2703 tests green** (+7 from the new URL-resolution suite). - **#89 partial: `cupertino search` fan-out + per-source runners point at per-source DBs.** Post-#1037/#1038 every docs source owns its own SQLite file (`apple-documentation.db`, `hig.db`, `apple-archive.db`, `swift-evolution.db`, `swift-org.db`, `swift-book.db`); pre-fix the smart-report fan-out (no `--source`) opened a single legacy `search.db` and built six `Search.DocsSourceCandidateFetcher`s against it, and the per-source runners (`runDocsSearch`, `runHIGSearch`) used the same monolithic URL. Post-fix `openDocsFetchers` iterates `docsSources`, resolves each source's DB URL through `CLIImpl.makeProductionSourceRegistry()` + `SourceProvider.destinationDB.filename` (canonical mapping, zero hardcoded source-id → filename table), and opens one `SearchModule.Index` per file; the same opens-per-path are deduped via a small `openedByPath` cache so a `--search-db <path>` override still opens one Index and reuses it across the six prefixes (back-compat for tests + the migration window). `FetcherPlan.searchIndex: Index?` + `searchDBDisabledReason: String?` collapse into `docsIndexes: [String: Index]` + `disabledReasonsBySource: [String: String]`, both keyed by source-id, so a stale `hig.db` while the rest opened cleanly now surfaces just `hig` as degraded rather than fabricating six fake `DegradedSource` entries. `augmentWithOpenTimeDegradation` takes the per-source dict (replaces the legacy single blanket-reason form), emits in deterministic source-id order, and still dedups against fetcher-time entries. `runUnifiedSearch` framework validation routes through `plan.docsIndexes[SourcePrefix.appleDocs]` (framework partitioning lives in apple-docs) and disconnect iterates all per-source indexes. New `resolveDocsDBURL(for:)` helper on `Search` resolves a docs source-id to its DB URL through the production registry; `runDocsSearch` + `runHIGSearch` use it (their teaser surface points at apple-documentation.db explicitly). `CLISearchOpenTimeDegradationTests` rewritten for the per-source-keyed dict (6 tests, +1 net: empty-map identity, six-sources-injected, partial-failure scope, deterministic ordering, dedup-on-collision, other-fields-preserved). Out of scope for this commit: MCP serve-side (`CLIImpl.Command.Serve` / `CompositeToolProvider` still open one search.db handle) and the seven remaining apple-docs-style CLI commands (`SearchSymbols` / `Inheritance` / `SearchConformances` / `SearchConcurrency` / `SearchPropertyWrappers` / `SearchGenerics` / `ListFrameworks` / `Read`) which still hardcode `Shared.Paths.live().searchDatabase`; both surfaces will be threaded through the same resolver in follow-up commits. **2696/2696 tests green.** - **#89 partial: `cupertino doctor` schema-versions section iterates the full post-#1037/#1038 per-source DB list.** Pre-fix the `printSchemaVersions` entries array hardcoded `[.search, .packages, .appleSampleCode]`: only 3 DBs out of the 8 post-split. Post-fix the iteration covers every descriptor users actually see on disk: `.search` (legacy), `.appleDocumentation`, `.hig`, `.appleArchive`, `.swiftEvolution`, `.swiftOrg`, `.swiftBook`, `.packages`, `.appleSampleCode`. Path derivation: each entry uses the descriptor's own `filename` resolved under `paths.baseDirectory` (no per-DB URL-resolution helper needed because every descriptor's filename is now self-describing). `.appleSampleCode` keeps its special-case `samplesSchemaVersion` probe (the Sample.Index pipeline writes a per-pipeline tracking table instead of `PRAGMA user_version`; see #1037 part 1). The `.search` entry is retained as a "not built" line for users still on a legacy bundle that hasn't been migrated; it will rotate out in a future release. Doctor's other per-DB sections (`SamplesHealthCheck`, `PackagesHealthCheck`) already pass the right URL per-descriptor; this commit only touches `printSchemaVersions`. **Tests green** (the existing Doctor suites read the line format; the entries array is composition-internal). - **#1038: swift-book separation (each source gets its own DB, GoF Strategy with shared crawl helper).** User-direction follow-up to #1037: "diff db for each source" reverses the pre-2026-05-26 view-source pattern where SwiftOrgStrategy emitted both `swift-org` and `swift-book` tagged rows into one `swift-documentation.db`. Post-#1038: each source-target owns its own destination DB AND its own `SourceIndexingStrategy` concrete; both delegate to a NEW `Search.StrategyHelpers.crawlSwiftDocumentation(...)` helper in `SearchStrategyHelpers` (a neutral target both source targets already depend on), with a `SwiftDocumentationScope` enum (`.both` / `.swiftOrgOnly` / `.swiftBookOnly`) gating per-page emission. No cross-source-target imports (`per-package-import-contract.md` preserved); the 300+ LOC crawl-and-emit body moves once from `SwiftOrgSource/Search.Strategies.SwiftOrg.swift` into `SearchStrategyHelpers`, then each per-source strategy concrete is ~30 LOC of delegation. **New descriptors**: `Shared.Models.DatabaseDescriptor.swiftOrg` (filename `swift-org.db`) + `.swiftBook` (filename `swift-book.db`); the pre-#1038 `.swiftDocumentation` descriptor stays in `allKnown` for migration-detection of legacy bundles. **Flipped destinationDBs**: `SwiftOrgSource.destinationDB` → `.swiftOrg`; `SwiftBookSource.destinationDB` → `.swiftBook`. SwiftBookSource is no longer a view-source: `makeStrategy(env:)` now returns the new `Search.SwiftBookStrategy` (active, not a no-op); `capabilities` is the universal-text subset (`searchers: [.text]`, `operations: [.readByURI]`, `metadata: [.hasAvailabilityAttrs]`) rather than `.empty`. **Manifests**: `docs/sources/swift-org/manifest.yaml` updated (destinationDB `swift-org`, fileGlobs narrowed to `swift-org/**/*`, viewSources block dropped); new `docs/sources/swift-book/manifest.yaml` created. `scripts/check-source-manifests.sh` green (8 manifests). **Tests**: `Step5PerDBFanOutShapeTests` updated for 7 groups instead of 6; `PluggabilityInvariantTests` updated to expect `.swiftOrg`/`.swiftBook` groups; `LiveDocsIndexingRunnerFilterTests` rewritten (swift-org and swift-book now narrow to their own DBs instead of co-locating); `ConstantsAuditTests` `expectedByID` extended; `Issue1019SwiftOrgSourceShapeTests` + `Issue1021SwiftBookSourceShapeTests` flipped to assert the new destinationDBs; `PerSourceCapabilitiesShapeTests` swift-book test updated to expect the new active matrix instead of the pre-#1038 empty view-source matrix. **Closes #1038**. **Task list cleanup**: stale tasks #87 (source-column-view-source-rationale, obsolete) and #88 (keep-swift-book-as-view-source, direction-reversed) deleted. - **Per-source dispatch refactor: `LiveDocsIndexingRunner` narrows by source-id (commit 2 of the per-source CLI two-commit pacing).** The CLI surface from `c7a1d1d` accepted `--source <id>` but internal dispatch was bucket-level: any docs-style source triggered the full docs runner, which built every docs-bucket DB whose corpus was on disk. This commit threads `selectedSourceIDs: Set<String>?` from `Save.run()` through `runDocsIndexer(effectiveBase:selectedSourceIDs:)` into `LiveDocsIndexingRunner.run()`, where `groupedByDestinationDB(excluding: [.packages])` is filtered to only DESTINATIONS that contain at least one selected provider. View-source co-location is preserved (filtering selects destinations not individual providers, so `--source swift-org` keeps swift-book in the same group per the 2026-05-25 user directive). Practical impact: `--source apple-docs` now builds apple-documentation.db ONLY (not the full bucket); `--source samples` runs both the Sample.Index pipeline AND the SampleCodeSource group's FTS rows under one `apple-sample-code.db` file. Closes round-7/8 critic findings #4 (over-build), #5 (disk preflight over-estimate, indirectly: scope is now per-DB so the bucket-level estimate is no longer over-counted in the common case), #7 (`--clear` blast radius, indirectly: only selected DBs are touched), #9 (Doctor `--save` preview, still a follow-up to mirror the new selection at preview time), #10 (`isDocsBucketSource` classifier brittleness, indirectly: future sources now route by destination, not by name-list). Help text + `source.md` + `docs/commands/save/README.md` updated to describe the now-honest per-source-id dispatch (the "intermediate state" caveats from c7a1d1d are dropped). New `LiveDocsIndexingRunnerFilterTests` suite (14 tests) pins every per-source scenario: nil-filter builds-all baseline, each docs-bucket source narrows to its single destination, view-source pair (swift-org/swift-book) co-locates from either direction, multi-source union, packages excluded, unknown id silently dropped, empty set yields no destinations. **2694/2694 tests green.** - **Per-source CLI critic-round-10 fixes: comprehensive stale-flag sweep + docstring honesty.** Round-10 critic on `9612173` returned 15 findings, almost all in the "more locations to sweep" category (multiple Swift source-comment refs to `cupertino save --docs/--samples/--packages`, several option-doc subtitles + Notes, cross-command docs in `search/` / `read/` / `list-samples/` / `package-search/`, MCP error message in `Services.ServiceContainer`, formatter hint in `Sample.Format.Text.Search`). One mechanical, repo-wide sweep this commit: every `cupertino save --docs <space|end|backtick|quote|paren|apostrophe>`, `--samples`, `--packages` reference under `Packages/Sources/` + `docs/commands/` + `README.md` is rewritten to the `--source <id>` form; prose patterns (`for/with/under --samples`, `when only --samples was passed`, `absorbed it`) are also updated. Plus: docstring contradiction at `SaveSiblingGate.parseSaveTargets` rewritten (round-9 reverted the round-8 `[]` default but left the docstring claiming `[]`; readers would assume the doc and re-introduce the regression). Plus: dropped the unverifiable amend-trap parenthetical from round-9's CHANGELOG entry (was not corroborated by the diff). Deferred from round-10's surface (these need shape changes, not just substitutions): finding #5 (`--remote` "streams docs only" framing is imprecise re: which sources the remote pipeline iterates), #6+#13 (default.md "Only docs (skip packages and samples)" example mislabels the bucket-level dispatch state), #10 (force.md Notes still says docs/packages always wipe-and-rebuild, factual error pre-dating #1037), #14 (`postSplitAllAndSourceMutex` pins a parser-level mutex check that may not be needed forever), #15 (`classifyPostSplitSourceID` decouples from `Save.sourceIDAliases`; flagged at the source for a future Independence Day pass). **2680/2680 tests green.** - **Per-source CLI critic-round-9 fixes: stale-binary detection + remote-warning UX + remaining partial-sweep cleanup.** Round-9 critic on `84bea51` surfaced 13 findings; this commit lands the highest-severity ones. (1) **Stale-binary regression (finding #1)**: round-8's "no scope flag → empty set" change defeated the sibling-conflict gate for the most common pre-#1037 invocation: bare `cupertino save`. A stale in-flight pre-#1037 binary IS writing to all three DBs and the gate MUST detect it. Default-on-no-flag flipped back to `[.search, .packages, .samples]` (the original semantic); the new binary's `Save.run()` rejects bare invocations before opening any DB so the over-detect path has no cost for current-binary callers. Test `bareNoFlagYieldsEmpty` rewritten as `bareNoFlagDefaultsToAllThree` with a load-bearing rationale comment. (2) **`--remote` UX trap (finding #2)**: the unused-flag warning previously advised "Either add `--source samples` or drop the flag", but `--source` is mutex with `--remote`, so following the advice triggered a usage error. Warning text is now `--remote`-aware: emits "drop the flag (`--remote` streams docs only, no samples build)" instead. (3) **`--all` + `--source` mutex (finding #9)**: `parseSaveTargets` now returns empty when both flags are present (real binary throws at resolver, no DB open); previously over-reported the full target set and gate-blocked legitimate siblings. New regression test. (4) **Partial-sweep cleanup (findings #5, #6, #11)**: `docs/commands/README.md` (top-level index used 2 stale `cupertino save --samples` instances), `force.md` / `samples-db.md` / `samples-dir.md` prose (sweep had touched the code-fence examples but missed the subtitle/Notes prose), `default.md` Output table (was still keyed on the removed `--docs/--packages/--samples` triplet), and `Packages/Sources/SearchSQLite/PackageIndex.swift:341,1115` inline comments. (5) **Edge-case tests (finding #12)**: 3 new tests cover `--all --source X` mutex, trailing `--source` with no value, and canonical+alias collapse. **Deferred**: #3 (`clear.md` / `remote.md` rewrites; need full content updates not just substitutions), #7 (cross-command `docs/commands/{search,fetch,serve,list-samples,read,package-search}/` sweep; separate hygiene PR), #8 (registry-coupling pin for `classifyPostSplitSourceID`; structural refactor for Source Independence Day), #10 (`ServeReaper.parseProcargs2` SwiftLint identifier cleanup; sibling-file hygiene), #13 (CHANGELOG test-count arithmetic). **2680/2680 tests green.** - **Per-source CLI critic-round-8 fixes: SaveSiblingGate parser + MCP hint + comprehensive docs sweep.** Round-8 critic on `def3db0` surfaced 15 findings; this commit lands the load-bearing correctness fix + the remaining doc/code sweep gaps. **Load-bearing correctness (finding #1)**: `SaveSiblingGate.parseSaveTargets` only recognised the pre-#1037 `--docs/--packages/--samples` triplet, so every post-#1037 sibling save argv (`--source <id>` / `--all`) hit the "no scope flag → default all three" path. Result: process A running `cupertino save --source apple-docs` (only writes search.db) would have spuriously tripped process B's `cupertino save --source packages` into the sibling-conflict gate, blocking legitimate parallel saves in CI. Parser now recognises `--source <id>`, `--source=<id>`, `--all`, the `apple-sample-code` alias, AND the legacy triplet (backward compat for stale in-flight binaries). Default-on-no-flag changed from `[.search, .packages, .samples]` to `[]` because the binary now rejects bare `cupertino save` outright; sibling-detection treats an unparseable argv as no-targets (safer than every-targets). 9 new tests cover the post-#1037 surface + a load-bearing regression pin for finding #1. **MCP hint sweep (finding #2)**: `CompositeToolProvider.swift:1201` empty-`list_samples` hint updated from `cupertino save --samples` to `cupertino save --source samples`; matching test pin in `Issue673PhaseCSampleMCPMarkerTests` updated. **Comprehensive doc sweep (findings #3, #4, #5, #6, #7)**: `docs/commands/save/option (--)/default.md` rewritten (was the most-rotted page, documented bare-`cupertino save` as the canonical entry); `docs/commands/save/README.md` Description + Options + Examples sections rewritten (the round-7 sweep only touched the synopsis examples); `docs/commands/save/option (--)/{yes,force,clear,samples-db,samples-dir,remote}.md` swept for combined-flag examples; `docs/commands/doctor/README.md` + `default.md` updated; `README.md` bare-`cupertino save` references swept. **Warning-order fix (finding #7 nested)**: `--samples-dir / --samples-db / --force` unused-flag warnings now fire BEFORE the `--remote` short-circuit so a user running `cupertino save --remote --samples-db /tmp/x.db` sees their `--samples-db` is being ignored rather than discovering it after a multi-hour remote stream. **Category fix (finding #8)**: those same warnings now use `category: .samples` (the dedicated `LoggingModels.Logging.Category` case) instead of `.cli`, so operators filtering logs by samples subsystem see them. **Deferred to follow-up**: #9 (--remote mutex as validate()), #10 (alias-semantics-differ-from-fetch, documented but not unified), #11 (DI cleanup), #12 (help-text verbosity), #13 (source.md table discoverability), #14 (test-tautology refactor), #15 (sourceIDAliases static-dict pattern). **2677/2677 tests green.** - **Per-source CLI critic-round-7 fixes: scripts + docs + emitted hints catch up, `apple-sample-code` alias, `--remote` mutex, unused-flag warnings.** Round-7 critic on `c7a1d1d` (the per-source CLI surface commit) surfaced 15 findings; this commit lands the day-one breakage fixes + the cross-command consistency + the silent-no-op guards. **Day-one breakages**: (1) `scripts/smoke-reindex.sh` + `scripts/setup-mini-corpus.sh` updated from `--docs` / `--samples` / `--packages` to `--source <id>` form; (2) `README.md` + `docs/commands/save/README.md` updated; (3) Doctor `SamplesHealthCheck` remediation hint, `cupertino list-samples` empty-result hint, `cupertino package-search` missing-DB hint, and `cupertino search` PackagesService hint all updated to emit `cupertino save --source <id>`; (4) `Issue930DatabaseHealthCheckTests` literal-string pin updated. **Cross-command consistency** (finding #13): `validSourceIDs()` resolver now accepts `apple-sample-code` as an alias for `samples` (canonical save-side id), matching what `cupertino fetch --source apple-sample-code` accepts so a user can copy-paste the same id across both commands without hitting an unknown-id error. **`--remote` mutex** (finding #6): `cupertino save --remote --source X` and `cupertino save --remote --all` now surface a clear usage error rather than silently ignoring the per-source flag. **`--samples-dir` / `--samples-db` / `--force` unused-flag warnings** (finding #8): passing any of these without `--source samples` emits a warning explaining the flag is ignored (especially important for `--force` which users might assume is universal). **Help text + `source.md` honesty** (finding #9): both updated to describe the bucket-level intermediate-dispatch state truthfully ("triggers the docs runner which still builds every docs-bucket DB") rather than the per-source-only end state. **Deferred to follow-up commit**: findings #4/#5/#7/#9/#10 (over-build / disk over-estimate / `--clear` blast radius / Doctor preview / classifier brittleness) all collapse into the per-source dispatch refactor that lands next. #11 (resolver-uses-Cupertino.Context global) deferred. #12/#14 (test refactors) deferred. 2 new resolver tests: `appleSampleCodeAliasMapsToSamples` + `aliasAndCanonicalCollapse`. **2668/2668 tests green.** - **Per-source CLI surface: `cupertino save --source <id>` / `--all` replaces the `--docs / --packages / --samples` triplet.** The post-#1037 CLI exposes per-source build granularity. `--source <id>` is repeatable (pass multiple times for several sources); `--all` is the explicit opt-in for "build every source's DB". The two flags are mutually exclusive; bare `cupertino save` with no scope flag is a usage error (pre-#1037 binaries defaulted to building every DB; post-#1037 scope is explicit per the "each source needs its own option" direction). Valid `--source` ids derive from the production source registry: `apple-docs`, `swift-evolution`, `hig`, `apple-archive`, `swift-org`, `swift-book`, `samples`, `packages`. Mechanical break for any script using the old triplet; no deprecation period (pre-1.3.0 unreleased epic). **Internal dispatch is still bucket-level in this commit**: `--source apple-docs` triggers the docs runner (which builds every search-style DB), `--source packages` triggers the packages runner, `--source samples` triggers both the Sample.Index.Builder rich-data pipeline AND the docs runner (so the SampleCodeSource FTS rows land in `apple-sample-code.db` per the one-DB-two-tracks design). The per-source-id dispatch refactor (so `--source apple-docs` builds ONLY apple-documentation.db) lands in a follow-up commit. New `SaveSourceResolverTests` (9 tests) covering: --all maps to every valid id; single + multiple + duplicate source values; mutual exclusion; bare-invocation usage error; unknown-id usage error; partial-unknown usage error; docs-bucket classifier. New `docs/commands/save/option (--)/source.md` + `all.md`; removed orphans `docs.md` / `packages.md` / `samples.md`. `scripts/check-docs-commands-drift.sh` green. **2666/2666 tests green.** - **#1037 part 9: end-to-end integration tests (Sample.Index + Search.Index coexistence on one DB).** Load-bearing claim test for the one-DB-two-tracks design target. The arc's earlier commits each pinned one piece of this in isolation (`ce4605d` got Sample.Index off PRAGMA user_version; `ffec318` made the migrator's writer factory preserve foreign tables; multiple round-5/6 fixes hardened the wipe-decision logic around corruption + foreign PRAGMA leaks). This commit closes the loop with a mechanical proof in `Issue1037OneDBIntegrationTests`: 4 tests that drive both pipelines against the same `apple-sample-code.db` in both possible orders (Sample first then Search; Search first then Sample), close, re-open, and verify both table tracks survive with full data. Plus `pathIdentityProof` asserts `Sample.Index.databasePath` and `SampleCodeSource.destinationDB.filename` resolve to the same physical file, and `reRunPreservesAndExtends` simulates a second `cupertino save --all`-style pass and asserts data accumulates rather than wipes. Closes #1037 acceptance item "Integration test: cupertino save --all against a fresh base-dir, then a cupertino save re-run, ..., all produce a single apple-sample-code.db carrying both schemas with no data loss". **2657/2657 tests green.** - **#1037 part 8: round-6 critic-fixes (data-loss guards + probe honesty).** Round-6 critic on the 4-commit round-5 fix arc surfaced 11 findings; one (release-blocker framing) was refuted on closer trace of `SetupService.run` which pins the bundle download to the binary's version, and a few minor UX / test-coverage notes were deferred. Five real correctness + maintainability fixes landed in this commit. (1) **Legacy-rename data-loss guard (finding #2)**: `CLIImpl.Command.Setup.migrateLegacySamplesDatabaseIfNeeded` now moves the SQLite WAL/SHM sidecars (`samples.db-wal` / `samples.db-shm`) alongside the main file rather than stranding them at the old path. Pre-fix, un-checkpointed Sample.Index transactions in the sidecars would have been lost on the upgrade path because SQLite's WAL recovery would not find them under the new filename. Two new regression tests in `SetupMigrationHookTests`: with-sidecars (asserts both move + content survives) and without-sidecars (no-op, doesn't error). (2) **Corrupt-DB throw (finding #6)**: `Sample.Index.Database.samplesSchemaVersionTablePresence` previously collapsed `SQLITE_CORRUPT` / `SQLITE_IOERR` / `SQLITE_NOTADB` step+prepare errors into the `.empty` case, which `wipeIfStale` treated as wipe-suppress; a corrupted DB silently bypassed the wipe-and-rebuild safety net and produced confusing "database disk image is malformed" errors at query time. New `.corrupt(reason:)` case; `wipeIfStale` throws a clear `Sample.Index.Error.sqliteError` pointing the user at `PRAGMA integrity_check` + the rebuild path. New regression test `corruptDBThrowsRatherThanSilentBypass`. (3) **Probe foreign-PRAGMA leak (finding #7)**: `Diagnostics.Probes.samplesSchemaVersion(at:)` PRAGMA fallback would have returned Search.Index's stamp (18) on a shared file or a Search-only file masquerading as the samples filename, and Doctor would display 18 as the sample-code schema version. The fallback now gates on the canonical-marker `projects` table being present; absent that, returns nil so Doctor renders "not built". New `SamplesSchemaVersionProbeTests` suite with 4 tests covering the populated / pre-#1037 legacy / Search-only-file / missing-file cases. (4) **Stale CREATE TABLE DDL comment (finding #5)** referencing deleted `readSchemaVersion()` updated to point at the new `samplesSchemaVersionTablePresence()`. (5) **`hasForeignSampleIndexTables` open-failure docstring honesty (finding #3)** rewritten to acknowledge that the original SQLite error is NOT preserved when the wipe path fires; the caller's `removeItem` succeeds (Search.Index opens fresh) or fails with a different error message. Out-of-scope: finding #1 (refuted), #4 (per-descriptor probe accessor refactor; defer), #8 (manual spot-check of sed-touched historical references; defer), #9 (additional failure-mode tests; defer), #10 (warning UX nicety; defer), #11 (factory logging on error code; defer). **Tests green.** - **#1037 part 7: docs/commands sweep + foreign-table-check shape-gap note (critic findings #9 + #11).** `docs/commands/` had 101 occurrences of `samples.db` across 34 files (example outputs and option descriptions) that no longer matched the actual binary's behaviour after the filename flip. Mechanical `sed` sweep replaces `samples.db` with `apple-sample-code.db` throughout `docs/commands/` (legacy-filename references in CHANGELOG history are untouched). `scripts/check-docs-commands-drift.sh` green. Also tightened the `hasForeignSampleIndexTables` docstring in `LivePerSourceDBSplitMigrator.swift` to call out the intentional name-only-match contract (no shape validation; if another pipeline ever introduces a sibling `projects` table the helper must be tightened with a sentinel-column check). Closes out the critic-round-5 findings. **2646/2646 tests green.** - **#1037 part 6: WAL preservation, foreign-table-check robustness, defensive INSERT, manual-truncation safety (critic findings #6, #7, #10, #12, #14).** Five robustness fixes from the round-5 critic. (1) **WAL/SHM preservation in the migrator's preserve branch** (finding #6): `LivePerDBWriterFactory.make`'s WAL/SHM cleanup ran unconditionally and was justified by Search.Index's `.migrationNeeded` precondition, but that precondition does not extend to Sample.Index, whose WAL could carry un-checkpointed writes from a crashed prior `cupertino save --samples`. The cleanup now runs only in the wipe branch; in the preserve branch SQLite's own WAL recovery handles salt-mismatched sidecars on the next open without losing data. (2) **`hasForeignSampleIndexTables` error-code awareness** (findings #7 + #12): pre-fix the helper returned `false` on any failure (open fail / prepare fail / SQLITE_BUSY / SQLITE_CORRUPT), causing the factory to wipe a locked or corrupted file. The new shape returns `true` (preserve) when the file IS a SQLite DB but transiently unreadable, and `false` (wipe-safe) only when the file is confirmed not-SQLite (`SQLITE_NOTADB`) or open itself failed. (3) **Defensive INSERT** in `Sample.Index.Database.migrateUserVersionStampIfNeeded` (finding #10): explicit `INSERT INTO samples_schema_version (id, version) VALUES (1, ...)` instead of relying on rowid auto-assignment to satisfy the `CHECK (id = 1)` constraint. (4) **Manual-truncation safety** in `Sample.Index.Database.wipeIfStale` (finding #14): the old `readSchemaVersion() != schemaVersion` check fell back to PRAGMA user_version when the tracking table was empty, which would mis-classify a manually `DELETE FROM samples_schema_version`'d row on a shared file (PRAGMA = Search.Index's stamp 18) as a stale-version wipe trigger. The new shape tri-states the table presence: populated row + mismatch → wipe; empty table → preserve (ambiguous); absent table → fall back to PRAGMA only on the genuine pre-#1037 legacy path. (5) Removed the dead-on-arrival `readSchemaVersion()` helper (subsumed by `wipeIfStale`'s tri-state check + `migrateUserVersionStampIfNeeded`'s direct call to `readUserVersion`). One new regression test: `emptyTrackingTableDoesNotTriggerWipe` (load-bearing for #14, simulates the operator-debug case + shared-file PRAGMA=18 worst case). **2646/2646 tests green.** - **#1037 part 5: Doctor reads `samples_schema_version` instead of `PRAGMA user_version` (critic findings #2, #4, #13).** Sample.Index post-#1037 no longer writes `PRAGMA user_version` (commit `ce4605d`), so `Doctor.printSchemaVersions`'s call to `Diagnostics.Probes.userVersion(at:)` for the sample-code DB returned `0` on freshly-wiped files and Search.Index's stamp (`18`) on shared files. New probe `Diagnostics.Probes.samplesSchemaVersion(at:)` reads from the per-pipeline tracking table with backward-compat fallback to PRAGMA user_version for pre-#1037 user samples.db files. `Doctor.printSchemaVersions`'s entries list flips the sample-code row from `.samples` to `.appleSampleCode` and routes through the new probe; the displayed label is the actual on-disk filename (`apple-sample-code.db`). `SamplesHealthCheck.descriptor` flips to `.appleSampleCode` so the section header in `cupertino doctor` reflects the real filename. Updated `Issue930DatabaseHealthCheckTests` (5 assertions): descriptor identity test, section-header literal, conformer round-trip, requiredness-by-descriptor policy, and the descriptor-id-vs-SourcePrefix invariant (the SourcePrefix-round-trip pin moved to `Issue1012SampleCodeSourceShapeTests` where the source-provider identity lives now). **2645/2645 tests green.** - **#1037 part 4: bundle/reader filename alignment + legacy samples.db rename hook (release-blocker critic fix).** Round-5 critic on the `ce4605d` → `d365707` → `ffec318` arc flagged a release-blocker I missed: every fresh-install user got the sample-code feature dark. The bundle/reader filenames were misaligned because `cupertino setup` extracted the sample-code DB as `samples.db` (via `request.required: [.search, .samples, .packages]`) while every reader path (`cupertino list-samples`, `read-sample`, `search`, `serve`, `doctor`'s SamplesHealthCheck) called `Sample.Index.databasePath()` which now resolves to `apple-sample-code.db`. The bundle's 185 MB sample-code payload landed in a file no reader opened. **Fix end-to-end**: (1) `Setup.required` flipped from `.samples` to `.appleSampleCode` so the bundle extracts under the post-#1037 filename. (2) `ReleaseTool.Release.Command.Database.samplesDBFilename` reads `Shared.Constants.FileName.appleSampleCodeDatabase` so post-#1037 release runs publish the bundle with the new filename. (3) New helper `CLIImpl.Command.Setup.migrateLegacySamplesDatabaseIfNeeded(baseDirectory:logger:)` covers the upgrade path: on first post-#1037 `cupertino setup`, if a leftover `samples.db` is on disk (pre-#1037 bundle or pre-#1037 `cupertino save --samples` run) and the new `apple-sample-code.db` isn't, the legacy file is renamed via `FileManager.moveItem` to the new name (which `Sample.Index.legacySamplesDatabasePath`, added in commit `d365707`, finally has a caller for). The `samples_schema_version` table from commit `ce4605d` handles the version stamp seamlessly on the next open. Both-files-exist case logs a warning + keeps both untouched (user manually cleans up after verifying the new file). Neither-file-exists + only-new-file-exists are no-ops. 4 new regression tests in `SetupMigrationHookTests` covering the four file-presence combinations. **2645/2645 tests green.** - **#1037 part 3: `LivePerDBWriterFactory` foreign-table-aware destination preservation.** Pre-fix the factory called `FileManager.default.removeItem(at: destinationPath)` unconditionally before opening Search.Index. Post the filename flip in #1037 part 2 (commit `d365707`), the migrator's destination for SampleCodeSource is `apple-sample-code.db`, the same file Sample.Index.Builder's rich schema lives in; the blind wipe would have destroyed `projects` + `files` + `file_symbols` + `file_imports` rows the user built via `cupertino save --samples`. Post-fix the factory checks for a `projects` table at the destination BEFORE wiping; when found, the destination file is preserved and Search.Index opens on top of it (creating `docs_metadata` + `docs_fts` alongside the existing Sample.Index tables on first open). The WAL/SHM cleanup still runs in both branches since the migrator only fires when `detect()` returned `.migrationNeeded` (no live writes in flight; salt-mismatched WALs are inert at that point). New static helper `hasForeignSampleIndexTables(at:)` opens read-only, checks `sqlite_master` for the `projects` table, returns false on any failure (file unreadable / not a SQLite DB / etc) so the legacy wipe path fires when in doubt. The preserve branch logs a one-line info-level message so an operator inspecting `cupertino setup` output sees which destinations took which path. Two new regression tests in `LivePerDBWriterTests`: `factoryPreservesForeignSampleIndexTables` (load-bearing: seeds a `projects` table + sentinel row, runs the factory, asserts the row survives AND a new docs_metadata row lands cleanly) + `factoryWipesStaleSearchOnlyFile` (the non-regression: a stale Search.Index-only file without `projects` still gets wiped, preserving the wipe-and-rebuild guarantee for genuine Search.Index destinations). **2641/2641 tests green.** - **#1037 part 2: `Sample.Index.databasePath` flips to `apple-sample-code.db`.** The actual filename rename that activates the shared-file mode. Pre-fix, `Sample.Index.databasePath` returned `<base>/samples.db` while `SampleCodeSource.destinationDB.filename` was `apple-sample-code.db`, producing two physical files for the same source. Post-fix, both pipelines target one file. New companion accessor `Sample.Index.legacySamplesDatabasePath(baseDirectory:)` resolves the pre-#1037 filename so migration-detection codepaths can still locate a leftover `samples.db` on disk for users who upgrade across the rename. Docstring updates on `Shared.Models.DatabaseDescriptor.appleSampleCode` + `Shared.Constants.FileName.appleSampleCodeDatabase` reflect the now-wired state (previous commits marked them as design target). Test update: `samplesDatabaseUnderBase` flips its filename assertion; new test `legacySamplesDatabaseUnderBase` pins the pre-#1037 accessor. The schema-version coexistence prereq from #1037 part 1 (commit `ce4605d`) is what makes this safe: Sample.Index no longer trips its own wipe branch when it opens a file Search.Index already populated, because the wipe condition gates on `projects` table presence (not PRAGMA user_version). **2639/2639 tests green.** - **#1037 part 1: Sample.Index.Database off `PRAGMA user_version`, onto per-pipeline `samples_schema_version` table.** Load-bearing prerequisite for the samples one-DB collapse. SQLite has a single `PRAGMA user_version` byte per file; once Sample.Index.Database and Search.Index target the same file (the #1037 endpoint) blind PRAGMA writes from either pipeline would trample the other's version stamp, tripping Sample.Index.Database.init's wipe branch on next open (verified by the round-4 critic). This commit moves Sample.Index off `user_version` entirely: a new single-row table `samples_schema_version (id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL)` is created in `createTables`; `setSchemaVersion()` writes there via `INSERT OR REPLACE` (no PRAGMA write); `readSchemaVersion()` reads there first, with a backward-compat fallback to `PRAGMA user_version` so existing user samples.db files built by pre-#1037 binaries continue to work. The init wipe condition is refined: the wipe now fires only when the file exists AND a `projects` table is present AND `readSchemaVersion()` mismatches `schemaVersion`. Files that exist with foreign tables (e.g. Search.Index's `docs_metadata`) and no `projects` table no longer trigger a wipe; the wipe-and-rebuild guarantee for genuinely-out-of-date samples-only DBs is preserved. A new `migrateUserVersionStampIfNeeded()` runs after `createTables` on every open: if `samples_schema_version` is empty and `PRAGMA user_version > 0`, the legacy value copies into the new table once (idempotent thereafter). Updated 3 existing `Issue837SamplesV4MigrationTests` assertions to read from the new table instead of the now-unwritten PRAGMA; added 2 new regression tests: `sharedFileForeignUserVersionIsHonoured` (Search.Index-stamped file with no `projects` table opens cleanly without wipe) + `legacyDBMigratesPragmaIntoTrackingTable` (pre-#1037 user samples.db with `user_version = 4` and no tracking table migrates seamlessly on first open). **2638/2638 tests green** across the full suite. Search.Index is unchanged in this commit; the one direction of trample we close (Search → Samples wipe) is the load-bearing one for the collapse arc. - **Per-source DB split, round-4 critic-fix part 1: docs-honesty pass + tracker issue #1037.** Round-4 critic on commit `6713b3a` flagged that the "samples collapse to one DB" framing in the previous CHANGELOG entry (and the matching descriptor + constants docstrings) was documentation-only: the descriptor + manifest + tests say "samples is one db" but `Sample.Index.databasePath` still resolves to `samples.db` while `SampleCodeSource.destinationDB` points at `apple-sample-code.db`. Two physical files on disk today. The critic also surfaced two real time-bombs that surface the moment those pipelines actually share a file: SQLite has ONE `PRAGMA user_version` per file (not "two independent schema_version rows" as the prior docstring claimed; Search.Index's stamp of 18 would trip Sample.Index.Database's `removeItem(at: dbPath)` branch on next open), and `LivePerDBWriterFactory.make` calls `FileManager.default.removeItem(at: destinationPath)` unconditionally before opening Search.Index, which would silently destroy pre-existing Sample.Index rich-schema rows in the shared file. This commit reframes the affected docstrings to mark `.appleSampleCode` as the design target (not the wired state), points at #1037 as the tracker for the actual wiring (filename rename + schema-version reconcile + foreign-table-aware factory + migrator legacy-samples awareness + setup `required` list update + end-to-end integration tests), and fixes the design-doc fan-out count from "8 DBs" to "6 destination DBs" (swift-org + swift-book co-locate in swift-documentation.db, so the production grouping `excluding: [.packages]` yields 6, not 7 or 8). No code behaviour change in this commit; it is documentation hygiene only. Real wiring lands in the follow-up commits tracked at #1037. - **Per-source DB split: samples collapse to one DB (revert of step-7a `.appleSampleCodeSearch` schema split).** Architectural decision settled with the user: "sample is one db". The brief 2-DB-for-samples split that landed in step 7a (`.appleSampleCodeSearch` carrying SampleCodeSource's FTS rows; `.appleSampleCode` carrying Sample.Index.Builder's rich schema) is collapsed back to one physical SQLite file (`apple-sample-code.db`) as the design target; the actual wiring of both pipelines onto that one file is tracked at #1037 (separate follow-up commits on this branch). Sample code as a content type lives across many DBs (apple-docs has code in body content, swift-book is mostly code examples, samples.db is the Apple GitHub sample-code projects with rich schema); the unified `cupertino search` already fans across 6 destination DBs and merges, so source attribution at result time distinguishes sample-code hits without needing a dedicated `apple-sample-code-search.db`. **Reverts**: `Shared.Models.DatabaseDescriptor.appleSampleCodeSearch` removed; `Shared.Constants.FileName.appleSampleCodeSearchDatabase` removed; `SampleCodeSource.destinationDB` flips back to `.appleSampleCode`; `docs/sources/samples/manifest.yaml` destinationDB back to `apple-sample-code`; `DatabaseDescriptor.allKnown` back to 10 entries; design doc bundle layout drops the `apple-sample-code-search.db.zip` entry, 8→7 counts; `Step5PerDBFanOutShapeTests` + `PluggabilityInvariantTests` + `ConstantsAuditTests` + `PerSourceDBDescriptorTests` + `PerSourceDBSplitMigratorMigrateTests` all updated. **Keeps**: the entire alias mechanism work (`legacySourceIDAliases` protocol requirement + registry-driven alias map + cross-provider alias collision regression + self-aliasing regression + production-side pin test), which is correct as-is and required for the `sample-code` → `samples` legacy-tag routing into the now-merged samples DB. 53 tests green across the affected suites (migrator 10, descriptor pins 7, step5 4, pluggability 10, constants audit 19, #1012 SampleCodeSource shape 5). Next: per-source CLI surface (`--source <id>` + `--all`) on top of the now-clean shape. - **Per-source DB split epic step 7a foundation, critic-fix round 3: alias ownership inversion + production-side pin tests (2 HIGH-severity correctness bugs + em-dash purge).** Critic round 2 on commit `daefdeb` flagged two HIGH-severity correctness bugs in the alias streaming path: (1) **cross-provider alias collision double-streamed rows.** Pre-fix, the per-plan row-streaming loop iterated `provider.legacySourceIDAliases` directly. If two providers competed for the same legacy tag literal (e.g. A claims `"shared"` as canonical id, B claims `"shared"` as alias), step-2's first-wins tie-break correctly assigned `"shared"` to A in `legacyTagToProvider`, but B's plan would still enumerate `"shared"` and stream those rows AGAIN into B's destinationDB, corrupting the second DB and tripping the per-plan count check after the writer had already accepted duplicates. (2) **Self-aliasing footgun.** A conformer that mistakenly listed its own `definition.id` inside `legacySourceIDAliases` (refactor/copy-paste accident) would have its rows streamed twice. **Resolution**: invert the `legacyTagToProvider` map into `legacyTagsByProviderID: [String: [String]]` after step 2; the streaming loop reads from THIS map (not `provider.legacySourceIDAliases` directly), so step 2's first-wins ownership is the only source of truth at streaming time. Set-deduped by construction (a dict can't carry the same tag twice), so self-aliasing collapses naturally. Two new regression tests: `crossProviderAliasCollisionFirstClaimantOnly` (asserts A receives 3 rows, B receives 2 rows, no leak) and `selfAliasingDoesNotDoubleStream` (asserts 2 rows total, not 4). Critic round 2 finding #5 also addressed: added `legacySourceIDAliasesShape` pin to `Issue1012SampleCodeSourceShapeTests` so removing or mis-spelling the production-side `SampleCodeSource.legacySourceIDAliases = ["sample-code"]` override fails loud at CI rather than during a real user migration. Critic round 2 findings #3 + #4 (em-dash violations in CHANGELOG + migrator comment) also fixed; full `grep -P '\\x{2014}'` over touched files now zero. Comment renumber in `migrate(...)` also cleaned up (steps 1-7 sequential, was 1-4-4-5). **29/29 tests green** (migrator 10, #1012 SampleCodeSource shape 5, step5 4, pluggability 10). - **Per-source DB split epic step 7a foundation, critic-fix round 2: `legacySourceIDAliases` registry-driven alias map (HIGH-severity migration bug).** Round 1 of the critic loop on commit `306e573` surfaced a load-bearing routing bug: `SampleCodeStrategy` emits rows tagged `source = "sample-code"` (a literal at `Search.Strategies.SampleCode.swift`) while `SampleCodeSource.definition.id = "samples"` (via `SourcePrefix.samples`). Post step-7a flip, the migrator's `registry.entry(for: sourceID)` lookup keyed strictly on `definition.id`, so legacy rows tagged `"sample-code"` would surface as `MigrationError.unknownSourceIDs(["sample-code"])` and abort the entire migration. **Resolution** (most refactored + GoF-aligned per user direction): new protocol requirement `var legacySourceIDAliases: Set<String> { get }` on `Search.SourceProvider` with default extension returning `[]` (additive, no breaking change for external conformers). `SampleCodeSource` overrides with `["sample-code"]`. `Distribution.PerSourceDBSplitMigrator.migrate(...)` rebuilt to construct a `[legacyTag: provider]` lookup map from `registry.allEnabled` iterating both `definition.id` AND every entry in `legacySourceIDAliases`; counts aggregate by the resolved provider's canonical id so the alias collapses into one `SourcePlan` per provider. Row-streaming loop iterates all legacy tags (canonical + aliases) for each plan so the writer sees both `"sample-code"`-tagged and `"samples"`-tagged rows in one writer call. Two new migrator tests pin the behaviour: `legacyAliasRoutesToProviderDestination` (canonical + alias rows mixed in one legacy DB) and `legacyAliasOnlyRowsRoute` (production shape: alias-only rows, no canonical-id rows). Critic-fix round 2 finding #2 also addressed: design doc references to "7 file entries / 7 zips / 7 times" all updated to 8 (post step-7a the bundle ships `samples.db` AND `apple-sample-code-search.db` as separate single-schema artefacts, not one combined file). All 8 migrator tests green; full step5/pluggability/constants suites still green (41 tests). - **Per-source DB split epic step 7a foundation: `.appleSampleCodeSearch` descriptor + SampleCodeSource flip (schema-split refactor).** Resolves a real semantic tension surfaced while scoping step 7a's search fan-out: `SampleCodeSource.destinationDB = .search` meant its rows lived in the legacy `search.db` file, which step 6's migration renames to `.legacy-pre-per-source-split` (unreachable to the read path). Flipping SampleCodeSource to `.appleSampleCode` would have collided with the Sample.Index.Builder pipeline's per-file rich schema (file_symbols + project rows) which uses the SAME `apple-sample-code.db` filename. User direction: most refactored + flexible + GoF-aligned solution. **Resolution**: new `Shared.Models.DatabaseDescriptor.appleSampleCodeSearch` static (filename `apple-sample-code-search.db`, displayName "Apple Sample Code (Search)") distinct from `.appleSampleCode`. SampleCodeSource flips destinationDB from `.search` to `.appleSampleCodeSearch`. Clean GoF Strategy separation: one descriptor per single-schema DB, no collision. `.appleSampleCode` keeps its meaning as the renamed samples.db (Sample.Index.Builder pipeline, outside the SourceProvider abstraction). New `Shared.Constants.FileName.appleSampleCodeSearchDatabase` entry. `DatabaseDescriptor.allKnown` extended to 11 (was 10). New `appleSampleCodeSearch` pin test in `PerSourceDBDescriptorTests`. Updated `Step5PerDBFanOutShapeTests` + `PluggabilityInvariantTests` (post-flip: SampleCodeSource is at `.appleSampleCodeSearch`, not `.search`; production grouping order shifts to include `apple-sample-code-search` between `apple-documentation` and `hig` alphabetically). Updated `ConstantsAuditTests.descriptorFilenamePinnedAgainstCanonicalLiteral` expectedByID dict with the new descriptor. Updated `docs/sources/samples/manifest.yaml` destinationDB → `apple-sample-code-search`. Updated `docs/design/per-source-db-split.md` bundle layout to include `cupertino-bundle-vX-apple-sample-code-search.db.zip`. Updated `cupertino-per-source-db-names-agreed` memory with the new descriptor + which pipeline writes to it. **2632/2632 tests green** across the full suite. The schema-split is now in place; step 7a's actual search fan-out (multiple Search.Index opens + result merge) builds on this foundation. - **Per-source DB split epic step 6c-iii: `cupertino setup` post-extract migration hook (step 6 complete).** Wires the Live conformers into the CLI: `CLIImpl.Command.Setup.runPerSourceDBSplitMigrationIfNeeded(baseDirectory:logger:)` calls `Distribution.PerSourceDBSplitMigrator.detect(inBaseDirectory:registry:)` after `Distribution.SetupService.run(...)` returns + dispatches across the 4 detection outcomes: `.noLegacyDBFound` (fresh install / post-v1.3.0 user → no-op); `.alreadyMigrated` (legacy + split co-exist → user-facing log noting safe to delete legacy); `.legacyFileMalformed` (schema mismatch → warning + skip); `.migrationNeeded` (the real path → instantiate `LiveLegacyDBReader` + `LivePerDBWriterFactory`, call `migrate(...)`, emit one user-visible line per source `[apple-documentation] split: N rows → apple-documentation.db (M.M MB)` + a final `📦 Legacy file preserved at search.db.legacy-pre-per-source-split for one release.` per the design doc step 6.5 spec). Any `MigrationError` is logged as a non-fatal warning + the legacy file stays intact; user can re-run `cupertino setup` to retry. New `SetupMigrationHookTests` in CLITests (3 tests, all green): hook no-ops on fresh install (no search.db present); hook runs full end-to-end migration when legacy detected (verifies per-source DB files appear + legacy renamed to `.legacy-pre-per-source-split`); hook no-ops on `alreadyMigrated` (legacy file untouched, no `.legacy` rename). **Step 6 complete**: migration shim end-to-end operational. Marking task #91 done. Marking task #85 (IndexBuilder per-DB dispatch) done; that landed in step 5b (commit `ee1f7c6`). Branch state: 39 commits ahead of develop; remaining work is step 7 (cupertino setup multi-DB bundle + cupertino doctor per-DB sections + cupertino search no-filter fan-out), step 8 (extended tests), step 9 (PR + Claw mini rebuild). - **Per-source DB split epic step 6c-ii-b: `LiveLegacyDBReader` with raw SQLite + end-to-end migration test.** Second half of the Live conformers for the migrator (`Distribution.PerSourceDBSplitMigrator.LegacyDBReader`). New `LiveLegacyDBReader` actor opens the legacy `search.db` via `sqlite3_open_v2(SQLITE_OPEN_READONLY)` + runs the two queries the migrator needs: (a) `SELECT source, COUNT(*) FROM docs_metadata GROUP BY source` for `sourceIDCounts()`; (b) `SELECT m.<14 fields>, f.title, f.content FROM docs_metadata m LEFT JOIN docs_fts f ON m.uri = f.uri WHERE m.source = ?` for `rows(forSourceID:)`. Streams rows via `AsyncThrowingStream`: yields one `IndexDocumentParams` per `sqlite3_step(SQLITE_ROW)`, lazily preparing the per-source query, finalising on stream completion. Read-only mode means safe to use alongside the in-place legacy read path during migration. New `LegacyReaderError` enum (`.openFailed(path:reason:)`, `.queryFailed(sql:reason:)`) for low-level SQLite failures. New `LiveLegacyDBReaderTests` in CLITests (5 tests, all green): sourceIDCounts round-trip with multi-source fixture; rows-by-source filter (apple-docs returns only apple-docs); full-fidelity round-trip (title + content + lastCrawled all preserved via the JOIN); missing-file throws openFailed; **end-to-end migration through Live reader + Live writer + real Search.Index** (writes a synthetic 3-row legacy DB via Search.Index, migrates via `Distribution.PerSourceDBSplitMigrator.migrate(...)`, verifies per-source DB files exist on disk + legacy file renamed to `.legacy-pre-per-source-split`). The migration pipeline is now operationally complete; step 6c-iii is just the CLI wiring (cupertino setup post-extract hook + cupertino save first-run check). - **Per-source DB split epic step 6c-ii-a critic-fix round 2: 2 findings.** Critic on `6dc1d36` flagged 2 NEW regressions introduced by round 1. (1) **MEDIUM: factory `try?` swallowed primary destinationPath errors.** Pre-round-1 used `try` (propagating). Round-1's uniform `try?` across all 5 paths swallowed errors on the primary `.db` file, so a permission failure / busy file / read-only volume would silently fail to delete the destination and let Search.Index then open the stale file with a confusing sqlite "file is encrypted or not a database" error. Factory now uses `try` for the primary destinationPath and `try?` for the 4 WAL/SHM sidecars only (intentional asymmetry: best-effort cleanup for sidecars since SQLite's salt-mismatch WAL recovery handles them gracefully; the primary file must clear cleanly). (2) **MEDIUM: per-source row-count detection lost on view-source groups.** Round-1 collapsed the per-plan mismatch check into an aggregate `writer.rowCount()` check. Compensating errors within a view-source group (swift-org under-writes 1, swift-book over-writes 1) would pass the aggregate test and silently rename the legacy file. Fix: migrate() now performs a per-plan mismatch check using `rowsCopiedByPlan[sourceID]` (which already tracked per-source streaming counts) against each plan's `estimatedRowCount`, AND retains the aggregate sanity check for catching writer-internal drift. 18/18 migrator + writer tests still green. - **Per-source DB split epic step 6c-ii-a critic-fix: 3 findings (HIGH view-source data loss + WAL companions + indexer-dict dead code).** Critic on `406134d` found a real bug. (1) **HIGH: view-source pattern broken by per-source factory call.** The migrator's `migrate()` iterated source plans linearly + called `writerFactory` per source-id. When two source-ids share a destinationDB (swift-org + swift-book → swift-documentation.db, the explicit view-source contract), the factory's stale-file delete dropped the first source's rows on the second iteration. Fix: `migrate()` now `Dictionary(grouping:)`s source plans by `destinationDBPath` + calls the factory ONCE per group + iterates multiple source-plans through the same writer. One `MigrationOutcome.SourceResult` is still emitted per source-plan; bytesWritten is shared across the group (same file). New test `viewSourceTwoSourceIDsShareOneWriter` in `PerSourceDBSplitMigratorMigrateTests` proves: two source-ids sharing a destinationDB result in EXACTLY ONE factory invocation + ALL rows preserved. (2) **MEDIUM: WAL/SHM companion files not cleared.** SQLite WAL mode (Search.Index's default journal mode) creates `<path>-wal` + `<path>-shm` sidecars. The original factory deleted only the `.db` file; orphan sidecars from a partial prior run could litter the user's directory. Factory now defensively deletes all 5 candidate paths (`.db`, `.db.wal`, `.db.shm`, `.db-wal`, `.db-shm`) before opening Search.Index. New `factoryClearsWALCompanions` test seeds all three stale files + verifies the post-factory DB initialises cleanly. (3) **MEDIUM: indexer-dict + sourceLookup work was dead code.** `Search.Index.indexDocument(_:)` (the path the migrator's writer.write uses) does NOT consult the indexer dict or sourceLookup; those are read-side concerns. The factory was building a per-destination indexer dict + cross-registry sourceLookup at every call for no behavioural effect. Simplified: factory passes `[:]` for indexers + `.empty` for sourceLookup, with a load-bearing docstring noting "if indexDocument ever starts reading the indexer dict, the migrator's contract needs revisiting". Factory signature simplified from `(registry:logger:)` to `(logger:)`. 18/18 migrator + writer tests green (was 17; added 1 view-source test). - **Per-source DB split epic step 6c-ii-a: `LivePerDBWriter` + `LivePerDBWriterFactory` in CLI.** First half of the Live conformers for the migrator (6c-ii-b lands the SQLite-backed `LiveLegacyDBReader`). `LivePerDBWriter` is an actor wrapping `Search.Index` that conforms `Distribution.PerSourceDBSplitMigrator.PerDBWriter`: `write(_:)` forwards to `indexDocument(_:)`, `rowCount()` to `documentCount()`, `disconnect()` to the actor's own disconnect; `write` + `rowCount` after `disconnect` are documented programming errors (preconditionFailure). `LivePerDBWriterFactory.make(registry:logger:)` returns the `PerDBWriterFactory` closure the migrator's `migrate(...)` calls per destination DB: deletes any stale file at the destination path BEFORE opening Search.Index (safe because `detect()` only authorises migration when `migrationNeeded`, i.e. no non-empty split DBs exist yet), builds a single-entry indexer dict from the destination's registry provider, opens Search.Index with cross-registry sourceLookup. New `LivePerDBWriterTests` in CLITests (3 tests, all green): single-row round-trip through real Search.Index with a temp-directory DB path; factory clears a stale file at destination + writes a fresh row successfully; write-after-disconnect marker test documenting the lifecycle contract. The full step-6c migration pipeline becomes operational once 6c-ii-b's `LiveLegacyDBReader` lands; 6c-iii wires both into `cupertino setup`'s post-extract flow. - **Per-source DB split epic constants-audit critic-fix round 2: 5 findings (view-source allowlist, registry-iterated descriptors, explicit HIG pin restored).** Critic on `d64a8d8` found 5 more issues. Fixes: (1) **HIGH: silent skip of registered providers missing a manifest.** The Swift ↔ YAML pin tests used `try? manifestText(...) else continue` to skip SwiftBookSource (view-source, no manifest of its own), but any other registered provider missing a manifest also silently slipped past. Replaced with an explicit `isViewSource(_:)` check: `fetchInfo == nil` (the protocol-level view-source signal). Non-view-source providers MUST have a manifest; a missing file now fails with `try` instead of skipping. (2) **HIGH: `descriptorFilenamePinnedAgainstCanonicalLiteral` and `descriptorIDMatchesFilenameStem` were still hardcoded arrays** of 10 descriptors. Same drift class the capability-matrix rewrite addressed. New `Shared.Models.DatabaseDescriptor.allKnown: [DatabaseDescriptor]` static array (in the canonical source of truth) lists all 10; both tests now iterate it. New `descriptorRegistryFloor` test asserts `allKnown.count >= 10` to catch accidental deletion. Descriptor-filename pin keeps a `[id: filename]` lookup dict + asserts every `allKnown` descriptor's id has a matching expected; missing entry surfaces as "extend expectedByID with the new entry" message. (3) **MEDIUM: HIG no-list-frameworks contract lost in the audit-collapse.** The previous 7 hardcoded capability tests each carried explicit pins; the 2-test YAML iteration only catches Swift ↔ YAML drift, not "both sides incorrectly gain list-frameworks". New `higHasNoListFrameworksOperation` test explicitly pins HIG's operations on both the Swift side AND the YAML side. (4) **MEDIUM: parseYAMLList didn't strip inline `#` comments or surrounding quotes** from list values, while `extractFetcherKind` did both. Asymmetric robustness. Parser updated to match: strips inline comments + quotes from each list item. (5) Latent indent-anchor concern for `parseYAMLList` (parent-key matched at any depth) noted but not changed; today no manifest nests `capabilities:`, and the visible-source contract suggests adding column-anchor only if a real nested manifest pattern emerges. 12/12 audit tests green; full Packages build clean. - **Per-source DB split epic constants-audit critic-fix: 5 hardcoding findings (now YAML ↔ Swift pin is real).** Critic on `360f177` found that the previous audit's 7 per-source capability tests hardcoded the expected sets as Swift literals (3-way drift risk: YAML / Swift property / test literal) and the descriptor-filename pin was tautological (compared the FileName constant against itself via the descriptor middleman). Fixes: (1) **HIGH: tautological descriptor pin.** `descriptorFilenameMatchesFileNameConstant` rewritten to `descriptorFilenamePinnedAgainstCanonicalLiteral` comparing each descriptor's filename against an explicit literal string (`"apple-documentation.db"`, etc.), the actual on-disk wire contract. A rename of the FileName constant + descriptor in lockstep no longer hides the drift. Legacy descriptors (.search/.samples/.packages) now also pinned. (2) **HIGH: 3-way drift on capability matrix.** Dropped the 7 per-source hardcoded-literal tests. Replaced with 2 registry-iterated tests that read each manifest.yaml at runtime via a new inline YAML list parser (under-parent + nested-key + indent-anchored), compare against the Swift property's set, and surface the diff if either side drifts. Now a real YAML ↔ Swift pin. (3) **MEDIUM: per-manifest tracking added.** `everyManifestFetcherKindIsInAllowedSet` now counts `manifestsFound` and `manifestsParsed` and asserts equality; a manifest missing a kind: line fails loudly instead of silently slipping past. (4) **MEDIUM: structural anchor for `fetcher.kind`.** Replaced naive `yaml.range(of: "kind:")` with `extractFetcherKind(from:)` that anchors to the column-0 `fetcher:` key + scans indented child lines. No more risk of matching `metadataKind:` / `descriptionKind:` / "kind:" inside a description block. Strips inline `#` comments + surrounding quotes. (5) **MEDIUM: legacy descriptors omitted.** `descriptorIDMatchesFilenameStem` now iterates all 10 descriptors (3 legacy + 7 new) instead of just the 7 new ones. Test count went from 15 → 10 (7 hardcoded-literal capability tests collapsed into 2 YAML-driven ones). All 10 green; the new inline YAML parser handles the 7 production manifests correctly. - **Per-source DB split epic step 6c prep: `LegacyRow` typealiased to `Search.IndexDocumentParams` for full-fidelity copy.** Step 6b's `LegacyRow` carried only 8 of the 18 fields a production `Search.IndexDocumentParams` declares (missing language, sourceType, packageId, jsonData, minIOS / minMacOS / minTvOS / minWatchOS / minVisionOS, availabilitySource). On migration that would silently DROP platform-version metadata + sample-code catalog ids + JSON sidecar data. Typealiased instead: `public typealias LegacyRow = Search.IndexDocumentParams`. The migrator + protocols now carry the full parameter shape; reader fills it and writer reads the same shape the production indexer uses. Future growth of `IndexDocumentParams` (e.g. a new platform-version field) propagates through the migrator automatically with no per-field translation. 12/12 migrator tests still green (the typealias is a source-compatible refactor for the 8-field call sites; the additional 10 fields default-init to nil via IndexDocumentParams' existing defaulted initializer). Live `LegacyDBReader` + `LivePerDBWriter` conformers in CLI (step 6c-ii) consume the full-fidelity shape. - **Per-source DB split epic: constants-audit pin tests (cross-file consistency).** User asked: "check whether you audited all the constants, and write tests for it". The branch introduced several places where the same constant lives in two locations and could drift independently. Audit + new `ConstantsAuditTests` suite in CLITests (15 tests, all green) pinning the cross-file invariants. **A. DatabaseDescriptor ↔ FileName consistency** (2 tests): every per-source descriptor's `filename` equals its corresponding `Shared.Constants.FileName.<X>Database` constant verbatim; every per-source descriptor's `id` equals `filename.dropLast(3)` (kebab stem + ".db" suffix). **B. SourceProvider ↔ SourcePrefix consistency** (2 tests): every registered provider's `definition.id` is present in `Shared.Constants.SourcePrefix.allPrefixes` (catches the closed-set gap #932 if a new source slips in without updating allPrefixes); the 8 production providers have unique `definition.id`s (no aliasing). **C. Swift Capabilities ↔ shell validator consistency** (3 tests): `Search.Capabilities.Searcher.allCases.map(\\.rawValue)` set equals the `ALLOWED_SEARCHERS` array in `scripts/check-source-manifests.sh` (the test parses the bash array out of the script); same for `Operation.allCases.map(\\.rawValue)` vs `ALLOWED_OPERATIONS`; every manifest.yaml's `fetcher.kind` value is in the shell `ALLOWED_FETCHER_KINDS` (catches the case where a manifest declares a kind the validator doesn't know). **D. PerSourceDBSplitMigrator.legacyRenameSuffix** is the canonical literal `.legacy-pre-per-source-split`. **E. Per-source capability matrix Swift ↔ YAML** (7 tests, one per registered source): each `<X>Source.capabilities.searchers` / `.operations` / key `.metadata` flags match the documented YAML at `docs/sources/<id>/manifest.yaml`. The HIG test specifically pins that `list-frameworks` is NOT in HIG's operations (per the production CandidateFetcher behavior where HIG rows carry empty framework). New CLITests dep on Distribution so the migrator constant is reachable. Audit verdict: this branch introduces NO new hardcoding violations beyond what the prior migrator-API critic-fix already addressed; the constants tested here all live where they should + are mechanically pinned cross-file. - **Per-source DB split epic step 6b: PerSourceDBSplitMigrator.migrate() coordinator (DI seams; SQL lives in step 6c).** End-to-end migration coordinator that takes a `Search.SourceRegistry` + a `LegacyDBReader` + a `PerDBWriterFactory` and executes the legacy → per-source split. Two new public protocols define the DI seams (Distribution stays light, no SQLite dep): `LegacyDBReader` (`sourceIDCounts()` + `rows(forSourceID:)` AsyncThrowingStream) and `PerDBWriter` (`write(_:)` + `rowCount()` + `disconnect()`). New `LegacyRow` value type carries the 8 fields needed to reconstitute a `Search.IndexDocumentParams` (uri, source, framework, title, content, filePath, contentHash, lastCrawled). The migrator's `migrate(legacyFile:baseDirectory:registry:reader:writerFactory:tolerateUnknownSourceIDs:fileManager:)` does: (1) read source-id counts; (2) surface unknown source-ids via `MigrationError.unknownSourceIDs` unless tolerated; (3) build plan from known counts via existing `planFromLegacySourceIDCounts`; (4) for each known source, open destination writer via the factory, stream rows from reader, verify count, disconnect writer; (5) `MigrationError.rowCountMismatch` aborts BEFORE the legacy rename (legacy stays in place; per-source DBs may be partial and require manual cleanup); (6) atomic-rename legacy to `.legacy-pre-per-source-split`. New `MigrationError.ioFailure(sourceID:underlying:)` case wraps low-level read/write failures. Step 6c lands the Live conformers using SearchSQLite's `Search.Index`. New `PerSourceDBSplitMigratorMigrateTests` in DistributionTests (5 tests, all green) covering happy path with 2 known sources, unknown-source-id throws by default, tolerateUnknownSourceIDs completes for known, row-count mismatch aborts before rename, empty legacy DB renames with no results. 12/12 total migrator tests green (7 detection + 5 migrate). - **Per-source DB split epic step 6a critic-fix round 2: registry-derived migrator API (no hardcoded filename lists).** User flagged the migrator API as a hardcoding violation: callers were passing `splitDestinationFilenames: [String]` (and `sourceIDsToPlan` tuples) when the registry IS the source of truth for both. Refactored. Distribution target now depends on SearchModels (clean foundation-tier add: SearchModels → SharedConstants). `detect(inBaseDirectory:registry:)` derives the split-destination set itself via `registry.groupedByDestinationDB(excluding: [.packages, .search])`, then reads each group's `descriptor.filename` as the canonical on-disk name. Adding a new source whose destinationDB is a fresh descriptor automatically joins the detection set without any caller changes. `planFromKnownSources` renamed to `planFromLegacySourceIDCounts(legacyFile:baseDirectory:registry:legacySourceIDRowCounts:)` taking only `[String: Int]` (the part 6b reads from `SELECT source, COUNT(*) FROM docs_metadata GROUP BY source`); the migrator resolves each source-id to its destination descriptor via `registry.entry(for:)?.provider.destinationDB`. Source-ids the registry does not recognise are dropped from the plan (callers surface via `MigrationError.unknownSourceIDs`); the migrator does NOT silently invent a destination. **Audit of the rest of the branch for similar hardcoding: clean.** Other apparent literal-string hits are (a) descriptor `id` declarations in `DatabaseDescriptor` statics (source of truth, by design), (b) `Save.Indexers.swift`'s `excluding: [.packages]` (call-site special-case for the non-Search.IndexBuilder pipeline), (c) `Search.Capabilities` enums + the shell validator's `ALLOWED_FETCHER_KINDS` (vocabulary, not per-source data), (d) test files asserting against known source-ids (test assertions need concrete targets). 7/7 detection tests green (was 5; added 2 for registry-derived exclusion + unknown-source-id drop). **API-design lesson recorded**: design the API contract from the goal (registry-derived) at the first commit; don't treat "scaffolding" as license to hardcode the data shape the caller-facing API will end up carrying. - **Per-source DB split epic step 6a critic-fix: 4 findings.** Critic on `d7276bb` returned 5 findings; 4 addressed inline. (1) **HIGH: `detect()` partial-crash false-positive.** Original `candidatePerSourceFilenames` parameter accepted any subset including rename-destinations (`apple-sample-code.db`, `swift-packages.db`). A crash mid-migration that renamed samples but never split search.db would have caused `detect()` to return `.alreadyMigrated`, wedging the user. Parameter renamed to `splitDestinationFilenames` with a load-bearing docstring stating callers MUST pass ONLY the 5 search.db split destinations (apple-documentation, hig, apple-archive, swift-evolution, swift-documentation), NOT the 2 sibling renames. Test fixture updated to match. (2) **MEDIUM: `unknownSourceIDs` doc claim misleading.** Original "rows stay in legacy file (not lost)" was technically true (bytes preserved) but masked the fact that the renamed `.legacy-pre-per-source-split` file is NOT queried by the production read path. Doc rewritten to be explicit: "preserved on disk for forensic inspection but the production read path does NOT consult the renamed file, so those rows are effectively unreachable to `cupertino search`." (3) **MEDIUM: `destinationDBPath` hardcoded `\\(id).db`.** Worked by coincidence today (every descriptor's filename = `<id>.db`); future descriptor filename divergence would silently mis-route rows. `planFromKnownSources` tuple signature gained `destinationFilename: String` field; callers MUST pass `Shared.Models.DatabaseDescriptor.<X>.filename` (the canonical source of truth) rather than relying on `<id>.db` derivation. (4) **LOW: `MigrationOutcome.actualLegacyRenameTarget: URL?` contract undocumented.** Doc-comment added with explicit invariant: non-nil iff `legacyFileRenamed == true`; nil iff false. Callers can safely force-unwrap after verifying the flag. The 5th finding (`legacyFileMalformed` reason should be enum not String) deferred to step 6b when schema validation lands; tracked in the commit body but not addressed today. 5/5 detection tests still green. - **Per-source DB split epic step 6a: `Distribution.PerSourceDBSplitMigrator` scaffolding + detection + plan-stub.** First of three step-6 commits (6a scaffolding + detection; 6b row-copy implementation; 6c CLI wiring). New `Distribution.PerSourceDBSplitMigrator` namespace (caseless enum) in the Distribution target. Public API today: `DetectionOutcome` (`.noLegacyDBFound` / `.migrationNeeded(legacyFile:)` / `.alreadyMigrated(legacyFile:splitFiles:)` / `.legacyFileMalformed(legacyFile:reason:)`), `MigrationPlan` + `SourcePlan` value types capturing legacy → per-source mapping with row-count estimates, `MigrationOutcome` + `SourceResult` value types for the post-run report, `MigrationError` enum for failure modes (legacy-file malformed, unknown source-ids, row-count mismatch). `detect(inBaseDirectory:candidatePerSourceFilenames:)` is pure read-only filesystem (no DB I/O): legacy-only → migrationNeeded; legacy + non-empty per-source DBs → alreadyMigrated; zero-byte per-source DBs ignored (crash-mid-migration safe). `planFromKnownSources(legacyFile:baseDirectory:sourceIDsToPlan:)` builds the plan shape; the real `SELECT source, COUNT(*) FROM docs_metadata GROUP BY source` query lands in 6b. The legacy rename suffix is `.legacy-pre-per-source-split` (preserved for one release, cleanup in v1.4.x per design doc step 6). New `PerSourceDBSplitMigratorDetectionTests` in DistributionTests (5 tests, all green) covering empty-dir, legacy-only, legacy + per-source coexist, zero-byte ignored, and plan-from-known-sources shape. - **Per-source DB split epic step 5b: `LiveDocsIndexingRunner` per-DB fan-out (Save.Indexers refactored).** Replaces the transitional single-search.db write path with one `Search.Index` per `destinationDB` group. **Lint note**: `aggregateBreakdown = aggregateBreakdown + breakdown` carries a `swiftlint:disable:this shorthand_operator` because `Search.ImportDiligenceBreakdown` ships only the binary `+` operator and not `+=`; defining one is out of scope here. `CLIImpl.Command.Save.LiveDocsIndexingRunner.run(input:progress:)` now calls `productionRegistry.groupedByDestinationDB(excluding: [.packages])` (step 5a's helper), iterates groups in `destinationDB.id` alphabetical order for deterministic build order + reproducible progress reporting, and for each group: derives a DB path from `<baseDirectory>/<descriptor.filename>`, opens a `Search.Index` against that path with the per-group indexer dict, constructs a per-DB `Enrichment.LiveRunner` (3 passes binding to the per-group index), builds the per-group strategies list via the existing `resolveSourceDirectory` bridge + `provider.makeStrategy(env:)`, runs `Search.IndexBuilder.buildIndex(clearExisting:onProgress:)` against the per-DB index, then closes. Cross-DB `SourceLookup` built ONCE (shared via the registry's union definitions so result-formatting can resolve any source-id regardless of which DB returned the row); `staticConstraintsLookup` also loaded once (`<baseDir>/apple-constraints.json`) and shared. Aggregate outcome: `documentCount` is the sum across DBs; `frameworkCount` is the union of framework names; `ImportDiligenceBreakdown` sums per-DB breakdowns via the existing `+` operator. Production effect: `cupertino save --docs` now creates 6 DB files in the working directory (apple-documentation.db, hig.db, apple-archive.db, swift-evolution.db, swift-documentation.db, plus the legacy search.db for the still-not-flipped SampleCodeSource). New shape-pin suite `Step5PerDBFanOutShapeTests` in CLITests (4 tests, all green): production grouping yields the expected 6 groups; sorted-by-descriptor-id gives a deterministic build order (apple-archive < apple-documentation < hig < search < swift-documentation < swift-evolution); each group's source-ids match the descriptor (swift-org + swift-book in .swiftDocumentation, samples alone at .search); per-DB output paths correctly derive `<baseDir>/<filename>`. 88/88 total tests green across the step-1-through-5b surface; full Packages build clean. - **Per-source DB split epic step 5a: `Search.SourceRegistry.groupedByDestinationDB(excluding:)` helper + tests.** First incremental commit of step 5 (the IndexBuilder per-DB fan-out). Adds a foundation-tier seam method on `Search.SourceRegistry` that returns `[Shared.Models.DatabaseDescriptor: [any Search.SourceProvider]]` keyed by the FULL descriptor value (not just `id`, so id-collision-with-divergent-filename regressions partition correctly per the pluggability audit's Seam 6 critic-fix). The `excluding:` parameter lets callers omit `.packages` (which has its own write pipeline via `Indexer.PackagesService` outside `Search.IndexBuilder`). `Search.SourceRegistry.swift` gained an `import SharedConstants` for `Shared.Models.DatabaseDescriptor` access. New `SourceRegistryGroupingTests` in SearchModelsTests (7 tests, all green): empty-registry → empty grouping; single-source one group; co-located view-source pair in one group; `excluding:` filters; disabled providers omitted; full-descriptor keying protects against id-collision; production-registry shape reproduces the step-5 dispatcher input (6 groups when excluding packages). The helper has zero consumers yet; step 5b refactors `Save.Indexers.swift` to consume it. - **Pluggability audit critic-fix: 6 findings (audit overstated coverage; real floor materially higher).** Critic on `3fba235` showed the audit's "invariant holds" claim was overreaching. Fixes: (1) **Seam 6 keyed by `String` not `DatabaseDescriptor`**: design doc step-5 sketch keys the Dictionary(grouping:) by the full descriptor value; rewritten to match. Now also asserts the `.search` group occupancy (SampleCodeSource alone post step 4). (2) **Seam 8 PR file count was wrong.** Said "3 new files" for a new source; real shape is 5 (Source + Definition + FetchInfo + Indexer + Strategies) per cross-check against `ls Packages/Sources/AppleArchiveSource/`. Said "+2 for a new DB"; real shape is +2 explicit edits PLUS 3 closed-list append sites (Distribution.SetupService.Request.required, Doctor.healthChecks, Doctor.printSchemaVersions) for a total of 5 PR touches per new DB. Comment rewritten with accurate counts + an explicit floor of "up to 13 PR touches for a new source on this branch state". (3-6) **Closed-set seams documented as known gaps tracked at Independence Day #932-#935**: `Shared.Constants.SourcePrefix.allPrefixes` (URI-prefix query parsing); `Search.FetchInfo.DefaultOutputDirKey` closed enum + exhaustive switch in `CLIImpl.Command.Fetch.resolveDirectory` + `Shared.Paths` accessor (3 edits if new source needs its own output dir); `Search.CandidateFetcher.swiftVersionSources` / `frameworkScopedSources` static `Set<String>` policy literals; `Logging.Category` closed enum. Two new regression-marker tests (`closedSetSourcePrefixAllPrefixes` + `closedSetDefaultOutputDirKey`) record the closed-set state explicitly so when #932/#933 close, those tests fail-loud and need updating to the new mechanism. 10/10 PluggabilityInvariantTests green (was 8; added 2 closed-set markers). **Corrected verdict: the pluggability invariant is PARTIAL on this branch. The registry/capabilities/indexer-dict/grouping-dispatch seams ARE pluggable; the closed-set / closed-enum seams (#932-#935 work) are NOT closed yet and any new-source PR still has to touch them.** - **Pluggability invariant audit (the load-bearing claim test).** Mechanical end-to-end proof of the per-source DB split epic's core claim, per `feedback_sources_100pct_pluggable` memory: adding a new source = a PR that touches only the new source's own files + 1 line at composition root + 1 manifest yaml. New test suite `PluggabilityInvariantTests` (in `Packages/Tests/CLITests/`, 8 tests, all green) defines a `AuditFixtureSource: Search.SourceProvider` conformer entirely inside the test file (no production target touched) and runs it through every consumer seam: (1) Search.SourceRegistry.register accepts it; (2) registry.entry(for:) resolves it; (3) destinationDB is a fresh DatabaseDescriptor distinct from all 10 existing statics; (4) capabilities flow through the registry intact; (5) the post-step-4 transitional indexer-dict filter (`!= .packages`) includes the fake (8 keys: 7 production + 1 fake); (6) the future step-5 grouping dispatcher (Dictionary(grouping: by: \\.destinationDB)) routes the fake to its OWN DB group with no leak from existing sources (swift-org + swift-book correctly co-located, fake alone in its group, packages alone in its group, apple-documentation alone in its group); (7) fake's indexer concrete carries the right source-id tag with no leak through existing types. The 8th test is a load-bearing comment recording the real-world PR file count: 3 new files (Source.swift + Source.Definition.swift + manifest.yaml) + 2 single-line additions (Package.swift target declaration + CLIImpl.SourceRegistry.swift registry append) + (if new DB) 2 more single-line additions (FileName constant + DatabaseDescriptor static). **Floor: 3 new files + 2 line edits; if step 5+ work raises this, the invariant is violated and this suite is what catches it at PR review time.** Today the invariant HOLDS post-step-4: no existing source concrete is touched when the fake plugs in. - **Per-source DB split epic step 4 critic-fix: 5 findings (4 stale shape-pin tests + 4 doc-staleness sites).** Critic on `3d653da` caught a real regression my commit-message "46/46 green" claim missed. (1) **HIGH: 4 shape-pin tests broken.** `Issue1014AppleArchiveSourceShapeTests:52-53`, `Issue1017SwiftEvolutionSourceShapeTests:46`, `Issue1019SwiftOrgSourceShapeTests:45`, `Issue1021SwiftBookSourceShapeTests:33` each hardcoded `#expect(provider.destinationDB == .search)`; post-flip those tests fail. Step 4 commit only updated Issue1027/1029/1033 because those were what I knew about; the per-source target shape pins (Issue101X) are also mechanically affected by the flip and broke. Updated all 4 to assert the new descriptors (`.appleArchive`, `.swiftEvolution`, `.swiftDocumentation`, `.swiftDocumentation`) plus their `.id` strings; @Test names rewritten to match. (2) **SwiftBookSource header docstring** still listed `destinationDB: .search` while the property returned `.swiftDocumentation` post-flip; updated. (3) **`CLIImpl.SourceRegistry.swift` module doc + body comment** described the filter as `destinationDB == .search`; rewritten to describe the transitional `!= .packages` filter + step-5 successor. (4) **`Save.Indexers.swift` comment** said PackagesSource "has its own DB outside the SourceProvider abstraction" which is misleading: PackagesSource IS a SourceProvider conformer; rephrased to "routes its rows via the separate Indexer.PackagesService pipeline rather than through Search.IndexBuilder". (5) **`Issue1023PackagesSourceShapeTests:40` inline comment** said "explicit divergence from the 7 prior phases", which is false post-flip (only SampleCodeSource is still at .search); rewritten. 36/36 affected tests green; full Packages build clean. - **Per-source DB split epic step 4: flip destinationDB on 6 search-bound sources + transitional filter at Save.Indexers.** Implements step 4 of `docs/design/per-source-db-split.md`. Each of the 6 search-bound sources flips its `destinationDB` declaration from `.search` to its per-source descriptor: `AppleDocsSource → .appleDocumentation`, `HIGSource → .hig`, `AppleArchiveSource → .appleArchive`, `SwiftEvolutionSource → .swiftEvolution`, `SwiftOrgSource → .swiftDocumentation` (host of the swift-book view-source pair), `SwiftBookSource → .swiftDocumentation` (co-located via view-source). SampleCodeSource stays at `.search` (rename to `.appleSampleCode` lands in step 6 migration). PackagesSource stays at `.packages` (rename to `.swiftPackages` also step 6). **Transitional production filter**: the existing `Save.Indexers.swift` writes search-style rows to a single `search.db` via a filter that was `destinationDB == .search`. Post-flip that filter matches only samples (the legacy 1-source filter). Updated to `destinationDB != .packages` (matches all 7 search-style sources today; transition stays in place until step 5 wires proper per-DB grouping). Production write behavior unchanged: 7 sources still co-locate in `search.db`, exactly as today; the destinationDB declarations are declarative seam data that step 5 makes load-bearing. Test pin updates to match the new filter: `Issue1027IndexerDictRegistryDerivationTests` (1 site), `Issue1029StrategiesListRegistryDerivationTests` (3 sites + 1 docstring), `Issue1033AllSourcesRoundtripTests` (4 sites; critical because it keeps the per-source roundtrip sweep exercising all 7 sources, not just samples). All affected suites green (PerSourceCapabilitiesShapeTests 10/10; Issue1033 4/4; Issue1027 4/4; Issue1029 7/7; CorpusManifestShapeTests 9/9; PerSourceDBDescriptorTests 12/12; 46/46 total). Full Packages build green. The transitional filter is documented inline at both call sites in Save.Indexers.swift; step 5 replaces with `Dictionary(grouping: by: \.destinationDB)`. - **Per-source DB split epic step 3: Search.Capabilities type + per-source capability declarations (8 sources).** Implements step 3 of `docs/design/per-source-db-split.md`. New `Search.Capabilities` value type in SearchModels (Sendable, Hashable) with three nested enums: `Searcher` (8 cases: text, symbols, propertyWrappers, concurrency, conformances, generics, packageSearch, sampleFiles), `Operation` (4 cases: readByURI, listFrameworks, listSamples, resolveRefs), `MetadataFlag` (9 cases for the typed feature flags documented in corpus-structure.md §3.5.3). The type carries the same shape as `Shared.Models.CorpusManifest.Capabilities` (the YAML-side mirror) but with strongly-typed enums instead of strings; raw-string `rawValue`s match the YAML vocabulary verbatim. `Search.SourceProvider` protocol gains a `capabilities: Search.Capabilities { get }` requirement with a default extension returning `Search.Capabilities.empty` (so the addition isn't a breaking change for external conformers). All 8 in-tree sources override the default with their declared matrix matching their `docs/sources/<sourceId>/manifest.yaml`: AppleDocsSource (full 6-searcher + 3-operation + 5-flag), HIGSource (text-only, no framework column), AppleArchiveSource (list-frameworks supported), SwiftEvolutionSource (text + proposal-number + min-Swift), SwiftOrgSource (text + symbols + generics for the swift-documentation.db host), SwiftBookSource (empty: view-source contributes no capability bits; the host swift-org carries the matrix), SampleCodeSource (sample-files searcher + list-samples op + hasSampleCode), PackagesSource (package-search searcher + hasPackageMetadata + hasMinSwiftVersion). New `PerSourceCapabilitiesShapeTests` (10 tests, all green): one per source plus two cross-source invariants (every search-bound non-view-source has the text searcher floor; Capabilities.empty is truly empty). Tests look up each provider via `CLIImpl.makeProductionSourceRegistry()` to avoid adding 8 per-source SPM target deps to CLITests. No dispatcher consumer yet; step 4 + step 5 wire the per-DB fan-out logic that reads these values. - **Per-source DB split epic step 2b critic-fix round 2: 5 findings (substituted fabrications + partial URLs + doc-contract reconciliation).** Late round-2 critic on `1ee2abd` (the round-1 fix commit) caught factual errors that round-1's fix introduced or didn't address. (1) **samples listingSelector was fabricated.** Round-1 replaced `organization: "developer-apple"` (non-existent org) with `webkit-scrape` kind + a CSS selector `[data-sample-id]` that doesn't exist either. Production code at `CoreSampleCodeWebKit/Sample.Core.Downloader.swift:257` actually uses `a[href*="/documentation/"]`. Selector corrected to match production. Fabrication-replacing-fabrication is exactly the failure mode round-1 critic existed to prevent. (2) **packages manifest comment named `Search.PackagesViewSourceStrategy`** but the actual type is `private struct PackagesViewSourceStrategy` in `PackagesSource.swift` (no `Search.` namespace, file-private). Comment rewritten to drop the fake `Search.` prefix + clarify the file-private no-op nature. (3) **`Indexer.PackagesService` violates the design doc §3 extractor contract** that says "Must conform to Search.SourceIndexer and be in the source's SPM target". `Indexer.PackagesService` is in `IndexerModels` (not `PackagesSource`) and doesn't conform to Search.SourceIndexer. Design doc §3.1 amended with an explicit PackagesSource exception note; step 3's loader will special-case this one source (the manifest's extractor field is advisory for packages, while makeIndexer()'s return value is the binding). (4) **swift-org `swiftBookURL` option duplicated `viewSources[0].urlPrefix`** and had no consumer in Crawler.AppleDocs (which only crawls www.swift.org, never docs.swift.org). Dead field removed; viewSources is the single source of truth for the companion URL. (5) **swift-evolution `rootURL` was partial.** Pointed at `/contents/proposals` only, missing `/contents/proposals/testing` (ST-NNNN Swift Testing proposals). `Crawler.Evolution.fetchProposalsList()` walks both directories. URL changed to the contents parent `/contents/`; per-directory listing is the fetcher's responsibility, not the manifest's. 7/7 manifests still valid; 0 em-dashes; design doc updated to acknowledge the PackagesSource extractor exception. - **Per-source DB split epic step 2b critic-fix round 1 followup: em-dash purge.** Post-commit grep on `1ee2abd` caught a U+2014 em-dash in the `packages/manifest.yaml` indexer.extractor comment block (line referencing the no-op Search.PackagesViewSourceStrategy). Replaced with the word "named". 0 em-dashes remain in the step 2b diff. Same scope-of-audit slip the `feedback_em_dash_check_pr_metadata` memory rule warns about; my pre-commit grep on `git diff HEAD` ran AFTER staging but BEFORE the commit completed, and the printed output flowed past unnoticed in the same tool call that triggered the commit. Re-tightened: now grep the WORKING tree explicitly before each commit. - **Per-source DB split epic step 2b critic-fix round 1: 7 findings (4 fetcher.kind factual errors + 2 capability matrix errors + 1 validator gap).** Critic on `2cd7f3d` flagged real factual divergences between the manifests and production code. Fixes: (1) **`packages` extractor was a phantom.** `Search.PackagesStrategy` does not exist (deleted in #789; PackagesSource has only the no-op private `PackagesViewSourceStrategy`). Changed to `Indexer.PackagesService` (the actual packages.db writer outside the SourceProvider abstraction) with a comment explaining the no-Search-indexer case. (2) **`swift-evolution` fetcher.kind was `git-clone`.** Actual is `github-api` (`Crawler.Evolution` uses `api.github.com/repos/swiftlang/swift-evolution/contents/proposals` + per-file `raw.githubusercontent.com` downloads). Corrected. (3) **`swift-org` fetcher.kind was `git-clone`** referencing a fabricated `swiftlang/swift-org-website` repo. Actual is a BFS web crawl over `www.swift.org/documentation/`. New `web-crawl` kind added to the allowed set (web-crawl-without-Apple-docs-API mechanism). (4) **`samples` fetcher.kind was `github-api`** referencing a non-existent `developer-apple` org. Actual is WKWebView + JS scrape of `developer.apple.com/sample-code/` (`CoreSampleCodeWebKit/Sample.Core.Downloader.swift`). New `webkit-scrape` kind added. (5) **`hig` claimed `list-frameworks` + `hasFrameworkColumn: true`.** Production behavior: HIG rows carry `framework=""` (`SearchSQLite/CandidateFetcher.swift` `frameworkScopedSources = {appleDocs, appleArchive}`). Removed both. Design doc §3.5.3 + §3.5.6 corrected to match. (6) **`apple-archive` declared `hasFrameworkColumn: true` but omitted `list-frameworks` operation.** apple-archive IS in `frameworkScopedSources` (legacy guide topics: Quartz 2D, Core Animation, KVO/KVC). Added `list-frameworks` to operations to match. (7) **`check-source-manifests.sh` accepted empty-map block fields** (`fetcher: {}`, `capabilities: {}`) because yq returns `{}` which is neither 'null' nor empty. Tightened: each block-typed required field now also asserts `length > 0`. Also added `indexer.extractor` non-empty check (the gap that let finding #1's phantom Search.PackagesStrategy slip past round-1 validation). Validator's allowed-fetcher-kinds expanded with `web-crawl` + `webkit-scrape` (now 7 allowed kinds total). 7/7 manifests still valid under the tightened script. - **Per-source DB split epic step 2b: 7 per-source manifest.yaml files + scripts/check-source-manifests.sh + CI wiring.** Second half of step 2 in `docs/design/per-source-db-split.md`. Each source whose provider is registered in `CLIImpl.makeProductionSourceRegistry()` now ships a declared manifest at `docs/sources/<definition.id>/manifest.yaml`. The 7 manifests: `apple-docs` (corpusFolder docs, destinationDB apple-documentation, 6 searchers + 3 operations); `hig` (text-only, conceptual intent); `apple-archive` (legacy programming guides, http-archive fetcher, lowest searchQuality 0.6); `swift-evolution` (git-clone, text + read-by-uri, hasMinSwiftVersion + hasProposalNumber); `swift-org` (HOST manifest for the view-source pair: declares `viewSources: [{ id: swift-book, urlPrefix: https://docs.swift.org/swift-book/ }]` so swift-book doesn't need its own manifest; the SwiftBookSource SourceProvider still registers separately with a noop strategy that reports `wasSkipped: true`); `samples` (apple-sample-code corpus folder, github-api fetcher, sample-files searcher + list-samples operation); `packages` (github-api fetcher, package-search + hasPackageMetadata). Each manifest matches the schema in `docs/design/corpus-structure.md` §3 verbatim including the round-1-fixed `fetcher.options` `[string -> string]` shape (all values quoted) and the round-1-fixed `capabilities.metadata` selective-flags shape (only true flags listed). New `scripts/check-source-manifests.sh` (yq + bash) validates: (1) manifest.yaml exists per source folder; (2) all 7 required fields present (sourceId / displayName / corpusFolder / destinationDB / fetcher / indexer / capabilities); (3) sourceId matches the folder name; (4) fetcher.kind is in the allowed set (apple-docs-api / git-clone / http-archive / github-api / file-bundle); (5) capabilities.searchers + capabilities.operations entries are in the documented vocabulary; (6) fetcher.options values are all strings (catches the unquoted-YAML-scalar trap round-2 of step 2a closed). CI wired into `.github/workflows/ci.yml` package-audits job (cheapest PR gate, runs in seconds). Script validates 7/7 manifests locally. Step 3 of the epic adds the Swift YAML loader that reads these manifests into `Shared.Models.CorpusManifest` at runtime; until then they're declared scaffolding the CI cross-checks for shape. - **Per-source DB split epic step 2a critic-fix round 2: 1 finding (doc-vs-type schema drift fix).** Round-2 critic on `49b42dd` returned 1 finding: `docs/design/corpus-structure.md` §3.1 fetcher.options example still showed an unquoted YAML float (`requestDelaySeconds: 0.05`) and an unquoted URL string, which would NOT decode into the round-1-introduced `Fetcher.options: [String: String]?` Swift type. Step 2b would have authored manifests verbatim from the doc and silently failed decode at step 3. Doc example updated to quote all option values (`"0.05"`, `"https://..."`) with an inline comment noting the `[string -> string]` map requirement + the per-kind typed views planned for step 3. Same fix applied to the §5.3 WWDC worked example. CONVERGED on the Swift type ↔ doc contract for fetcher.options. - **Per-source DB split epic step 2a critic-fix round 1: 4 findings (Codable contract hardening before YAMLs land).** Critic on `268dc5f` returned 4 findings, all addressed pre-step-2b. (1) **HIGH: `fetcher.options` schema parity gap.** Step 2a shipped `Fetcher.optionsJSON: String?` for foundation-tier minimalism, but `docs/design/corpus-structure.md` §3 schema uses YAML key `options:` as a typed map. The per-source manifests in step 2b would have authored `options:` and silently failed decode once step 3's loader lands. Type refactored to `Fetcher.options: [String: String]?` (a map of string values, sufficient for every fetcher's options today: URL, request delay as a numeric string, etc.); key name + shape now match the schema verbatim, no loader-time translation needed. (2) **HIGH: `Capabilities.metadata` non-optional decode failure on omission.** Swift's `metadata: [String: Bool] = [:]` init default does NOT apply to Codable's synthesized `init(from:)`. A manifest with no `metadata:` key (legal per §3.5.3 semantics) would fail decode with `keyNotFound`. Custom `init(from:)` added on `Capabilities` that calls `decodeIfPresent` and defaults to `[:]`. (3) **MEDIUM: no negative tests for missing required fields.** Added 3 negative tests asserting `DecodingError` when `sourceId` / `destinationDB` / `capabilities` are omitted. A future refactor that accidentally relaxes a required field (e.g. `destinationDB: String?`) now breaks the contract pin at the negative test, not silently at step-3 runtime. (4) **LOW: encoded-form check missing.** `metadataAbsentMeansAbsent` inspected only the in-memory Dictionary (trivial Swift semantics: absent key returns nil) but didn't verify the encoded JSON omits absent flags (the stated on-disk contract). New `encodedMetadataOmitsAbsentFlags` test asserts the encoded JSON's `capabilities.metadata` dict carries exactly the set flags + zero phantom `false` entries. Test count: 4 → 9 (4 original + 5 new). All green. 0 em-dashes, swiftformat clean. - **Per-source DB split epic step 2 (first commit): `Shared.Models.CorpusManifest` Codable contract.** Implements the first half of step 2 of `docs/design/per-source-db-split.md`. New `Shared.Models.CorpusManifest` type matching the manifest schema documented in `docs/design/corpus-structure.md` §3: required fields (`sourceId`, `displayName`, `corpusFolder`, `destinationDB`, `fetcher`, `indexer`, `capabilities`); optional fields (`description`, `viewSources`, `snapshotPolicy`, `searchProperties`); nested types (`Fetcher`, `Indexer`, `ViewSource`, `SnapshotPolicy`, `SearchProperties`, `Capabilities`). The type is Codable + Sendable + Hashable; lands as a contract only. No Swift code parses YAML at runtime yet (deferred to step 3 per the design doc; Yams vs. JSON loader decision still open per corpus-structure.md §8). Per-source `docs/sources/<id>/manifest.yaml` files + `scripts/check-source-manifests.sh` CI cross-check ship in the next commit. **Fetcher option polymorphism punted**: `Fetcher.optionsJSON` is a String carrying source-specific options as JSON to avoid an AnyCodable abstraction in the foundation tier; step 3's loader introduces per-kind typed views over the same data. **Capability metadata semantics**: absent flags are absent from the dictionary (not false), so each source's manifest declares only the flags that are true for it; the docstring + new `metadataAbsentMeansAbsent` test pin this contract. New shape-pin test suite `CorpusManifestShapeTests`: 4 tests (required-fields-only roundtrip + all-fields roundtrip + minimal JSON literal decode + metadata-absent-means-absent), all green. - **Per-source DB split epic step 1 critic-fix round 4: 5 findings (over-narrowed warnings + own-content URL-prefix slip).** Round-4 critic on `7074af3` returned 5 findings, all addressed. (1) **HIGH: warning over-claimed.** Round-3 said "the sample-code AND swift-book literals are not declared in the SourcePrefix enum at all" but `SourcePrefix.swiftBook = "swift-book"` IS declared. Warning rewritten to be narrow: sample-code is the only literal without a matching SourcePrefix constant; swift-book has one (`SourcePrefix.swiftBook`) but the row-emission path goes via `extractFrameworkFromPath` returning the literal directly, not via the constant. (2) **MEDIUM: "everything else with swift-org" oversimplified the mechanism.** swiftDocumentation docstring + test header both said "tags rows under swift-book/ with 'swift-book', everything else with 'swift-org'". Actually the helper returns whatever the first path component is; today's cupertino-docs swift-org/ tree happens to have only swift-book/ and swift-org/ subdirs so the values land at those two strings, but a future corpus snapshot adding a third subdirectory (e.g. swift-org/migration-guide/) would emit rows tagged "migration-guide". Both the docstring and the test header rewritten to describe the actual corpus-shape-dependent behavior + flag the future-corpus risk. (3) Same fix mirrored in test file header (descriptor docstring and test-header now agree word-for-word on the mechanism). (4) **LOW: fragile line-number reference.** Round-3 cited "Search.Strategies.SampleCode.swift:30" for the sample-code literal; replaced with symbol reference "SampleCodeStrategy.source" per cupertino's methodology rule on line-number-free citations. (5) **MY-OWN-CONTENT URL-prefix slip:** Shared.Constants.swift:131 (`swiftDocumentationDatabase` constant docstring, added in step 1 commit `98861df`) still said "via the SwiftOrgStrategy URL-prefix view-source pattern", repeating the stale wording the earlier rounds chased elsewhere. Rewritten to "path-based view-source pattern; rows are tagged with the first path component under the strategy's base directory". Round 4 also noted ~6 pre-existing URL-prefix references in unrelated files (SwiftBookSource.swift, Save.Indexers.swift, Fetch.swift, Package.swift) but those are out of scope for step 1's critic-fix loop; a follow-up sweep can land separately. 12/12 PerSourceDBDescriptorTests green; `swiftformat --lint` 0/3 files require formatting; 0 new em-dashes. - **Per-source DB split epic step 1 critic-fix round 3: 3 findings (test-header consistency + helper-claim accuracy + sample-code routing literal).** Round-3 critic on `715b671` returned 3 findings, all addressed. (1) **HIGH: stale "URL-prefix" wording in test file header.** Round-2 finding #5 rewrote the swiftDocumentation descriptor docstring from "URL-prefix tagging" to the actual path-based mechanism, but the same stale wording survived at `PerSourceDBDescriptorTests.swift:22-23` ("SwiftOrgStrategy URL-prefix view-source pattern"). Same-commit contradiction. Rewritten to match the descriptor docstring: "path-based view-source pattern: at index time the strategy inspects the file-system path of each crawled doc (Search.StrategyHelpers.extractFrameworkFromPath) and tags rows under a `swift-book/` subdirectory with source-id 'swift-book', everything else with 'swift-org'". (2) **MEDIUM: extractFrameworkFromPath claim overstated specificity.** The round-2 docstring said the helper "returns either 'swift-org' or 'swift-book' depending on the framework detected". Actually the helper returns the first path component under the strategy's base directory; the "swift-org" tag comes from a `?? SourcePrefix.swiftOrg` fallback when the helper returns nil, and "swift-book" comes from the helper returning the literal "swift-book" when the file is under a `swift-book/` directory. Docstring rewritten to describe the actual fallback chain accurately: "Pages under a `swift-book/` subdirectory yield the literal 'swift-book'; everything else falls through to the strategy's default tag `Shared.Constants.SourcePrefix.swiftOrg`." (3) **MEDIUM: sample-code row-emission literal misidentified.** Round-2 finding #1 fix listed `SourcePrefix.samples` + `SourcePrefix.appleSampleCode` as the source-id values routing to `.appleSampleCode`. Reality: `SampleCodeStrategy.source = "sample-code"` (a hardcoded literal at `Search.Strategies.SampleCode.swift:30`); the SourcePrefix.samples and SourcePrefix.appleSampleCode constants are CLI-query aliases used in `PlatformFilterScope` and `CompositeToolProvider`, NOT row-emission tags. A step-4 / step-6 string-match dispatcher against `SourcePrefix.*` will miss sample-code rows because their emission tag isn't in the SourcePrefix enum at all. Identifier-discipline comment rewritten to call out the "sample-code" literal explicitly with the warning: "a string-match dispatcher against `SourcePrefix.*` will miss these rows because their emission tags are not declared in the SourcePrefix enum at all." Same warning applied to the `swift-book` literal which is emitted by SwiftOrgStrategy directly (not via SourcePrefix). 12/12 PerSourceDBDescriptorTests green; `swiftformat --lint` 0/2 files require formatting. - **Per-source DB split epic step 1 critic-fix round 2: 6 of 7 findings purged (accuracy + decoupling + lint stability).** Round-2 critic on `78ef8db` returned 7 findings; addressed 6, the 7th (PackagesSource present-tense framing) was already addressed by the round-1 "renames-in-waiting" wording so no further change needed. Findings + fixes: (1) **MUST-FIX: fabricated SourcePrefix constant.** Identifier-discipline note referenced `Shared.Constants.SourcePrefix.sampleCode` which doesn't exist; the actual constants are `SourcePrefix.samples` ("samples") + `SourcePrefix.appleSampleCode` ("apple-sample-code"). The literal "sample-code" lives under `Directory.sampleCode`, not `SourcePrefix`. A step-4 implementer following the note would have grepped for a phantom constant. Comment rewritten to list both real constants. (2) **SwiftFormat-redundantType lint regression.** `let X: Set<String> = [...]` syntax triggered the `redundantType` rule; `swiftformat --lint` flagged 2 mutations. Refactored to extract the descriptors into private `static let` arrays (`legacyDescriptors` + `newDescriptors`) and use `Set(arr.map(\.id))` form, eliminating the redundantType trigger AND centralising the test-side enumeration so future descriptor additions update one place. `swiftformat --lint`: 0/1 files require formatting (was 1/1). (3) **swift-book missing from divergent-pair enumeration.** swift-book source-id is co-located in swift-documentation.db via the view-source pattern but the identifier-discipline note listed only swift-org. Updated the enumeration to show both swift-org + swift-book routing to swiftDocumentation. (4) **`.samples` rename framing was producer-graph-inaccurate.** Today SampleCodeSource has `destinationDB = .search` (sample-code rows live in search.db, not samples.db); `.samples` is fed by a separate `Sample.Index.Builder` pipeline NOT yet wrapped in the SourceProvider abstraction. Comment expanded with a "Producer-graph reality" subsection explaining that step 6's migration has two distinct sample-related tasks (extract from search.db + rename existing samples.db), and that PackagesSource currently routes at `.packages` (flip lands in step 4). (5) **"URL-prefix tagging" mis-described mechanism.** swiftDocumentation docstring claimed SwiftOrgStrategy uses URL-prefix tagging, but the actual code (`Search.StrategyHelpers.extractFrameworkFromPath`) inspects file-system path directory components, not URLs. Docstring rewritten to name the actual helper + describe the path-based mechanism. (6) **`noLegacyFilenameCollision` decoupled from production source-of-truth.** Test hardcoded literal `["search.db", "samples.db", "packages.db"]` instead of reading `Shared.Models.DatabaseDescriptor.search.filename` etc.; defeated the descriptor-centralisation invariant the suite was built on. Refactored to derive `legacyDescriptors` once at class level and `.map(\.filename)` into the collision set, so a rename of `Shared.Constants.FileName.searchDatabase` in step 6 propagates to the test automatically. 12/12 PerSourceDBDescriptorTests still green. `swiftformat --lint` clean. - **Per-source DB split epic step 1 critic-fix round 1: 5 findings (doc + test hardening).** Critic on `98861df` returned 5 low-medium severity findings, all addressed. (1) `swiftDocumentation` docstring claim "one indexer, one DB, two source-id tags" was ambiguous about how the view-source pattern actually works at write time; rewritten to explicitly name `SwiftOrgStrategy` (active emitter) and `SwiftBookViewSourceStrategy` (post-#1029 noop reporter so the per-source log line doesn't imply a failed index attempt). (2) `noLegacyCollision` test pinned only id-collision, not filename-collision; new `noLegacyFilenameCollision` test added asserting none of the 7 new descriptors shadow `search.db` / `samples.db` / `packages.db` (which would silently corrupt bundle layout in step 4 if a refactor mis-typed a FileName constant). (3) Section MARK "Canonical descriptors for the three databases cupertino ships today" was stale post-split; renamed to "Legacy descriptors (pre per-source DB split)" with an explicit pointer at the per-source section below and a note about when each legacy descriptor goes away. (4) Per-source section header omitted the .samples + .packages rename pairs in its plan-of-record; rewritten to list "7 new descriptors (5 splits + 2 renames-in-waiting)" with explicit step-6 mention. (5) **Identifier-discipline note added** (the most important fix for step 4 readers): DB id and source-id are deliberately separate naming spaces, and the binding is via `SourceProvider.destinationDB`, never via string matching. The divergent pairs are enumerated: `apple-docs` → `apple-documentation`, `swift-org` → `swift-documentation`, `packages` → `swift-packages`, `sample-code` + `samples` → `apple-sample-code`. Source-ids `hig` / `apple-archive` / `swift-evolution` happen to match their descriptors verbatim, but the comment makes clear this is coincidence not contract. New `unionDistinctness` test pins that the legacy-plus-new union is 10 unique ids + 10 unique filenames. 12/12 PerSourceDBDescriptorTests green (was 10; added 2). - **Per-source DB split epic step 1: 5 new DatabaseDescriptor statics + 2 renames added additively.** Implements step 1 of `docs/design/per-source-db-split.md`. New `Shared.Constants.FileName` entries (`appleDocumentationDatabase`, `higDatabase`, `appleArchiveDatabase`, `swiftEvolutionDatabase`, `swiftDocumentationDatabase`, `appleSampleCodeDatabase`, `swiftPackagesDatabase`). New `Shared.Models.DatabaseDescriptor` statics (`.appleDocumentation`, `.hig`, `.appleArchive`, `.swiftEvolution`, `.swiftDocumentation`, `.appleSampleCode`, `.swiftPackages`). New pin test suite `PerSourceDBDescriptorTests` (10 tests, all green) asserting each new descriptor's id / filename / displayName, descriptor distinctness, id-matches-filename-stem invariant, and no-collision with legacy `.search` / `.samples` / `.packages` ids. Step 1 is purely additive: no source provider's `destinationDB` points at the new descriptors yet, and the legacy 3 stay live. Sources flip one at a time in step 4. After all flips, legacy descriptors are removed (step 6 migration shim handles the on-disk file rename for existing users). 34/34 DistributionModelsTests green. - **Per-source DB split epic: design doc 2 of 2 (`docs/design/per-source-db-split.md`).** Implementation companion to corpus-structure.md. Defines the migration arc in 9 ordered steps: (1) add 5 new DatabaseDescriptor statics (apple-documentation / hig / apple-archive / swift-evolution / swift-documentation) + rename `.samples` → `apple-sample-code` + `.packages` → `swift-packages`; (2) add CorpusManifest model + per-source YAML files with CI cross-check via check-source-manifests.sh; (3) capabilities-driven dispatcher reads required-capability set per CLI subcommand and filters registry to candidates; (4) flip destinationDB source by source in size-ascending order (swift-evolution → apple-archive → hig → swift-documentation → apple-documentation) so failures are easy to isolate; (5) IndexBuilder learns per-DB fan-out via Dictionary(grouping: by destinationDB) + concurrent ingestion per group; (6) one-shot migration shim splits existing search.db into 6 per-source DBs on first upgrade (idempotent, keeps legacy file as `.legacy-pre-per-source-split` for one release); (7) `cupertino setup` manifest grows from 3 to 7 file entries with per-source download (`--source X` flag); `cupertino doctor` per-DB section repeats 7 times with capability matrix inline; `cupertino search` fan-out across DBs with normalized rank merge weighted by searchProperties; (8) Issue1033 roundtrip pin extended to assert rows land in provider's DB (not search.db) + per-DB schema stamp guards + fan-out test + migration shim test; (9) single PR back to develop + Claw mini rebuild (~12h overnight on internal SSD) + v1.2.0 MRR baseline (0.9467) validation. Each commit on the branch is independently revertable. The bundle layout grows from 1 zip to 7 (smallest-first download cancellation cost minimization), GitHub Releases shape unchanged, schema policy is per-DB (a bump on apple-documentation.db doesn't force bumps elsewhere). Out of scope: per-DB enrichment passes (separate `per-db-enrichment.md` doc, depends on this epic landing), vector DBs (#183 roadmap), symmetric symbol extraction on swift-packages.db (deferred to per-db-enrichment stage 2). - **Per-source DB split epic: design doc 1 of 2 (`docs/design/corpus-structure.md`).** Formal contract for what a source's on-disk corpus folder looks like and how the indexer picks up content from it without per-source `switch` statements. Defines: §2 on-disk layout under `~/.cupertino-dev/corpus/<source-id>/` with per-source `manifest.json` runtime snapshot; §3 repo-side `docs/sources/<source-id>/manifest.yaml` declared manifest schema (sourceId / corpusFolder / destinationDB / fetcher / indexer / viewSources / searchProperties / capabilities); §3.5 capabilities vocabulary (per user direction 2026-05-25: each source declares which searchers + operations + metadata flags it supports, like min platform version, code, sample-files; CLI dispatcher fans out only to DBs whose manifest declares the required capability; MCP server advertises tool availability per source from the same data); §3.5.6 authoritative capabilities matrix for the 7 settled sources; §4 indexer pickup logic (resolve manifest, resolve corpus, walk fileGlobs, hand matches to provider.makeIndexer()); §5 worked example with hypothetical WWDC source ([#58]) demonstrating the 4-file PR claim (Definition.swift + Strategy.swift + 1-line registry append + manifest.yaml; no edits to existing sources); §6 failure-mode catalogue (missing manifest / sourceId mismatch / fileGlobs match nothing / viewSource prefix overlap / etc.); §7 migration order onto the contract. Database names settled with the user on 2026-05-25 and recorded in the doc + `Shared.Models.DatabaseDescriptor` migration plan: `apple-documentation.db`, `hig.db` (initialism kept), `apple-archive.db`, `swift-evolution.db`, `swift-documentation.db` (swift-org + swift-book co-located via view-source pattern, URL-prefix tagged), `apple-sample-code.db` (rename of samples.db), `swift-packages.db` (rename of packages.db). Companion implementation doc `per-source-db-split.md` lands in the next commit. - **#1033 post-merge critic-fix: em-dash purge.** Post-merge self-critic on commit `f971335` found one em-dash (U+2014) in `Issue1033AllSourcesRoundtripTests.swift:60`, in the class docstring line describing the "registry-derived (not hardcoded)" pattern. Replaced with colon. Same scope-of-audit slip the `feedback_em_dash_check_pr_metadata` memory rule documents: the pre-commit check ran `git diff develop -M` (cumulative PR diff) instead of `git diff HEAD` (the new commit's content). API critic agent was 529-overloaded during the round-1 attempt; local self-sweep caught it. - **#1033: every-source-finds-itself roundtrip pin (post-#1007 critic gap).** Closes a behavioural-test gap raised by the user immediately after the source-unification epic closed: "we cannot go on without that." The 11 per-source / per-phase tests added across #1007 (Issue1008-Issue1029) covered structural shape (definition / fetchInfo / destinationDB / makeStrategy / makeIndexer) and registry derivation (sourceLookup / indexer-dict / strategies-list filtered by destinationDB), but no test exercised the load-bearing behavioural invariant: for each `Search.SourceProvider` in `makeProductionSourceRegistry().allEnabled.filter { destinationDB == .search }`, a row tagged with `provider.definition.id` at write time is queryable at read time with the same source-id intact. New `Packages/Tests/CLITests/Issue1033AllSourcesRoundtripTests.swift` ships 4 pins: (1) per-source write-read roundtrip (registry-iterated sweep: for each search.db source, write a tagged fixture, reopen the index, query, assert the row roundtrips with the right source-id); (2) cross-source query (no `--source` filter) returns rows from every search.db-destined source (`includeArchive: true` is required to surface apple-archive); (3) per-source `--source X` filter is exact (no cross-source leakage; each registered source returns only its own rows); (4) PackagesSource is NOT in the search.db indexer dict (the `destinationDB == .search` gate correctly excludes the `.packages`-destined provider). Test sweep is derived not hardcoded: adding a new search-bound source automatically joins the sweep via the registry filter. **Out of scope intentionally**: cross-source ranking behaviour (the 8-axis `SourceProperties` weights + `intentPriority` map at query time). That's a separate search-quality concern; a regression there would surface in MCP / CLI ranking output and belongs in a search-quality test suite, not a source-unification suite. 4/4 green locally. Closes #1033. - **#1031 critic-fix round 3: 124 stale --type refs across docs + audit notes + drift-script comments + em-dash sweep.** Round-3 critic ran a wider grep for `--type\b` across the repo and found 124 pre-existing stale references in `docs/commands/`, `docs/artifacts/`, `docs/design/cupertino.md`, `CONTRIBUTING.md`, `scripts/generate-embedded-catalogs.sh` that the round-1+2 fixes missed (those were code + immediate-CLI surfaces only). Bulk-patched 53 of 56 affected files via the canonical mapping (`--type docs` to `--source apple-docs`, etc.). Intentional migration-note references in `source.md`, `swift-book.md`, and the drift-script narrative comments were restored after the bulk-patch over-replaced them. `metadata.json.md` example updated to use `--source <apple-docs|swift-org|swift-evolution>`. Em-dash sweep on 5 files (`docs/artifacts/folders/sample-code/.auth-cookies.json.md`, `docs/commands/package-search/option (--)/swift-tools.md`, `docs/commands/save/option (--)/remote/README.md`, `docs/commands/search/option (--)/source (=value)/{packages,samples}.md`): 5 em-dashes replaced with commas. Remaining `--type` references kept intentionally: 3 in `docs/audits/issue-audit-2026-05-17.md` + `audit-findings-pass1.json` discussing the un-merged #216 Apple Tutorials issue (`--type tutorials` proposal; pre-#1031 historical audit context); 1 in the drift-script's own post-#1031 migration comment. - **#1031 critic-fix round 2: 5 more findings purged - swift-book CLI crash + repo-root README onboarding + recrawl.sh + fetch README + drift script.** Round-2 critic found 5 additional issues the round-1 fix missed: (1) `cupertino fetch --source swift-book` crashed with `ValidationError("Invalid start URL: ")` because SwiftBookSource has `fetchInfo == nil` (view-source pattern) and the helper returned empty. Fixed: `defaultCrawlBaseURL` special-cases swift-book to fall through to swift-org's crawl base URL (matches the SwiftOrgStrategy URL-prefix tagging behavior). (2) Repo-root `README.md` had 18+ stale `cupertino fetch --type <legacy>` invocations across Quick Start, Examples, Custom Crawls, Output Directories sections. Bulk-patched to canonical IDs (`--source apple-docs`, etc.). (3) `scripts/recrawl.sh` cold-rebuild script ran `--type docs|swift|hig|archive|evolution|packages|package-docs|code` end-to-end; every invocation patched. (4) `docs/commands/fetch/README.md` rewrite was top-half only: lines 159-178 still used dissolved short names in section headers (`Web Crawl Types (docs, swift, evolution)`) and output-path bullets (`**docs**`, `**swift**`, `**evolution**`, `**code**`). Section headers + bullets rewritten with canonical IDs; swift-book line added to the web-crawl section (view-source note included). The `all` parallel description on line 207 also updated. (5) `scripts/check-docs-commands-drift.sh:174` referenced the dissolved `fetch type` enum and silently no-op'd because the `type (=value)/` directory no longer existed. Line rewritten to check `fetch source` with the canonical value list (apple-docs, swift-org, swift-book, swift-evolution, packages, apple-sample-code, samples, apple-archive, hig, availability, all) plus a comment block documenting the post-#1031 shape. Also created `docs/commands/fetch/option (--)/source (=value)/swift-book.md` (view-source per-value doc explaining the URL-prefix-tagging relationship with swift-org). Updated `docs/commands/fetch/option (--)/source.md` value table + Value Details list to include swift-book. Cold xcrun swift build green; FetchTests 41/41 green; em-dash count 0. - **#1031 critic-fix round 1: 8 findings - apple-sample-code regression + stale --type strings across CLI + tests + README + TUI + comments.** Round-1 critic on PR #1032 surfaced functional regressions and pervasive stale `--type` references the initial commit missed. Fixes: (1) `displayName(forSource: "apple-sample-code")` was returning the bare string; added explicit case mapping to `Shared.Constants.DisplayName.sampleCode`. (2) `defaultOutputDir(forSource: "apple-sample-code", paths:)` was returning `paths.baseDirectory.path` (causing files to land at corpus root); added explicit case returning `paths.sampleCodeDirectory.path`. (3) swift-book dispatch added (routes to `runStandardCrawl` since SwiftOrgStrategy co-crawls swift-book pages via URL-prefix tagging); ValidationError message updated. (4) Contradictory comment about apple-sample-code being excluded from `--source all` rewritten to match the actual array (mirrors pre-#1031 FetchType.allTypes which included both .code and .samples); the comment now notes swift-book IS excluded from --source all to avoid double-fetch via swift-org. (5) Class docstring at line 41 refreshed: drops legacy `--type` reference. (6) 5 `@Option`/`@Flag` help strings in Fetch.swift + 1 user-facing info message updated from `--type X` to `--source <canonical>`. (7) User-facing strings across 12 other source files patched: `Cleanup.swift`, `Doctor.swift`, `Doctor.SamplesHealthCheck.swift`, `PackageSearch.swift`, `Save.Indexers.swift`, `Serve.swift` (CLI help strings); `Crawler.AppleDocs.swift`, `Indexer.Preflight.swift`, `Resources.swift`, `SearchModels/Search.SampleCatalogFetch.swift`, `Shared.Constants.swift`, `Shared.Utils.ZipMagic.swift` (docstrings + log messages). (8) `Packages/Sources/CoreSampleCode/Sample.Core.Catalog.swift` + `Packages/Sources/TUI/Views/HomeView.swift` user-facing TUI hints. (9) Test files: `Packages/Tests/CLICommandTests/FetchTests/FetchTests.swift` (parse calls changed from `["--type", "X"]` to `["--source", "<canonical>"]`); `Packages/Tests/CLITests/Issue930DatabaseHealthCheckTests.swift`, `Packages/Tests/CoreSampleCodeTests/SampleCodeCatalogTests.swift`, `Packages/Tests/CoreTests/CupertinoCoreTests.swift`, `Packages/Tests/ResourcesTests/CupertinoResourcesTests.swift`, `Packages/Tests/SharedUtilsTests/Issue657ZipMagicTests.swift` (docstring updates). (10) `packageDocsRejected` test refactored: pre-#1031 it asserted parse-time rejection of unknown FetchType values; post-#1031 `--source` is a free-form String so parsing succeeds and validation happens at `run()` time via the default-arm ValidationError. The test now awaits the run() error. (11) `docs/commands/fetch/README.md` rewritten to use canonical source-ids in the value-bullet list + synopsis; pre-#1031 placeholders `<type>` and short names (`docs` / `swift` / `evolution` / `code` / `archive`) all updated to `<id>` and canonical IDs. Cold build green (21.32s); 41/41 FetchTests + 34/34 other shape tests green. Em-dash count: 0. Closes the critic-fix loop on #1031. - **Source unification Phase 1I.c.2 (#1031 of epic #1007): FINAL EPIC STEP. Dissolve `Cupertino.FetchType` enum; CLI `--type` flag replaced with `--source`.** Final sub-PR of the source-unification epic. Per user directive on the in-flight #1031 thread: "Full dissolution (CLI breaks)", the CLI flag value space changes from short legacy names to canonical source-ids matching `Search.SourceProvider.definition.id`. Steps: (1) `Cupertino.FetchType` enum deleted from `CLI/SupportingTypes.swift`; the file is now a vestigial shell with a comment block documenting the dissolution. All per-type metadata (displayName / defaultURL / defaultAllowedPrefixes / defaultOutputDir / webCrawlTypes / directFetchTypes / allTypes) had been lifted to per-source `*.FetchInfo.swift` files during phases 1A-1H. (2) `CLIImpl.Command.Fetch.swift` refactored: `var type: FetchType = .docs` replaced with `var source: String = Shared.Constants.SourcePrefix.appleDocs`; `--type` ArgumentParser flag renamed `--source`; help text + abstract + discussion rewritten to use canonical source-ids; `run()` dispatch switched from `if type == .X` chain to `switch source { case Shared.Constants.SourcePrefix.X: ... }`; unknown sources surface a `ValidationError` listing valid values. CLI value mapping: `docs` to `apple-docs`, `swift` to `swift-org`, `evolution` to `swift-evolution`, `archive` to `apple-archive`, `code` to `apple-sample-code`; `packages` / `samples` / `hig` / `availability` / `all` unchanged. (3) New private static helpers on `Fetch`: `displayName(forSource:)` (registry lookup + special-token hardcoded labels for `all` / `availability`); `defaultOutputDir(forSource:paths:)` (registry's `defaultOutputDirKey` mapped through `Shared.Paths` accessors via `resolveDirectory(forKey:paths:)`); `defaultCrawlBaseURL(forSource:)` (registry's first crawlBaseURL); `defaultAllowedPrefixes(forSource:)` (swift-org auto-spans both www.swift.org + docs.swift.org for swift-book content). (4) `runAllFetches` iterates a hardcoded `allFetchableSources` array (the 8 registry sources + `availability` special token); `fetchSingleType` renamed `fetchSingleSource(_ sourceID: String, ...)`. (5) `Packages/Tests/CLITests/CLITests.swift` `FetchTypeTests` + `FetchTypeDisplayNameTests` suites deleted (the metadata they pinned now lives in per-source target shape-pin tests). (6) `docs/commands/fetch/option (--)/type.md` renamed `source.md`; `docs/commands/fetch/option (--)/type (=value)/` renamed `source (=value)/`; per-value files renamed (`docs.md` to `apple-docs.md`, `swift.md` to `swift-org.md`, `evolution.md` to `swift-evolution.md`, `archive.md` to `apple-archive.md`, `code.md` to `apple-sample-code.md`); content of all ~15 fetch doc files updated to use `--source <canonical-id>` everywhere. Migration note in `source.md` lists the legacy short-name to canonical-id mapping for shell-script users. **Closes #1031.** **Closes the #1007 source-unification epic in totality.** Every source 100% pluggable: adding a new source = 1 per-source SPM target + 1 `.register(<X>Source())` line in `CLIImpl.makeProductionSourceRegistry()`; zero edits to `SearchSQLite`, `CLI/SupportingTypes`, `SearchModels`, or `CLIImpl.SourceLookup` (the latter was deleted in 1I.a). - **#1029 critic-fix round 2: em-dash purge + stale `CLIImpl.SourceRegistry.swift` comments.** Three round-2 findings. (1) MUST-FIX em-dash in the round-1 CHANGELOG entry (the `[swift-book] indexed: 0, skipped: 0` line had a U+2014 em-dash before "the #671 anti-pattern"; replaced with colon). (2) Stale `CLIImpl.SourceRegistry.swift:52` comment listed `Phase 1I.c` as future epic step but 1I.c.1 (#1029) shipped the strategies-list piece; rewrote to `Phase 1A-1I.c.1 complete ...; Phase 1I.c.2 (final-of-final) dissolves the FetchType enum + Fetch CLI command`. (3) Stale docstring at lines 25-27 called the "composition-root index-builder dispatch" future; rewrote to reflect that the indexer dict (1I.b) and strategies-list (1I.c.1) already use the destinationDB discriminator, only the write-DB dispatch in 1I.c.2 remains. INFO-level round-2 finding about `wasSkipped` skipReason wording under swift-org-absent scenario acknowledged as polish-for-later, not fixed. - **#1029 critic-fix round 1: `SwiftBookViewSourceStrategy` reports `wasSkipped: true` to avoid the `[swift-book] indexed: 0, skipped: 0` log anti-pattern.** Round-1 critic flagged that the post-#1029 strategies-list now includes SwiftBookSource (via the `destinationDB == .search` filter), and its noop strategy returned `IndexStats(... wasSkipped: false)`. IndexBuilder's per-source breakdown log emitted the misleading `[swift-book] indexed: 0, skipped: 0`: the #671 anti-pattern of implying a failed indexing attempt when nothing was attempted. Fix: strategy now returns `wasSkipped: true, skipReason: "view-source; rows emitted by Search.SwiftOrgStrategy"`. Log reads `[swift-book] skipped (view-source; ...)` instead, honestly describing the view-source's role. - **Source unification Phase 1I.c.1 (#1029 of epic #1007): dissolve strategies-list inline assembly; derive from registry filtered by `destinationDB == .search`.** Phase 1I sub-PR 3 of 4 (the final-step #1007 work is split because 1I.c.1 and 1I.c.2 are independent: strategies-list dissolution lands in this PR; FetchType + Fetch CLI dissolution in the next). Eliminates the inline 6-strategy literal + conditional-append block at `Save.Indexers.swift:325-365`. Post-#1029, the strategies list comes from `productionRegistry.allEnabled.filter { $0.destinationDB == .search }.compactMap { provider in ...env-build + provider.makeStrategy(env:) }`. The bridge between the per-field `Search.DocsIndexingInput` (docsDirectory / evolutionDirectory / etc.) and the registry-driven assembly is the new static `LiveDocsIndexingRunner.resolveSourceDirectory(for:input:)` helper: it maps each provider's source-id to the matching optional input directory; compactMap drops sources whose CLI input is nil (mirrors the pre-#1029 conditional-append shape). SwiftBookSource and SampleCodeSource receive a sentinel `/dev/null` URL because their strategies don't use `env.sourceDirectory` (SwiftBookViewSourceStrategy is a noop; SampleCodeStrategy uses `env.sampleCatalogProvider` instead). The switch in `resolveSourceDirectory` is intentional bridge code that pairs the legacy per-field input struct shape with the registry-driven dispatch; when `Search.DocsIndexingInput` is redesigned to a sourceID-keyed dict (separate follow-up), the switch dissolves entirely. New `Issue1029StrategiesListRegistryDerivationTests` (6 assertions): resolveSourceDirectory mappings + destinationDB-filter excludes PackagesSource + full-input produces 7 strategies (6 real + 1 SwiftBook noop) + minimal-input produces 3 strategies (apple-docs + samples + swift-book). Cold build green (21.14s); Save-flow tests + Issue1029 + Issue1025 + Issue1027 + Issue919 + Issue935 + Issue668 = 50 tests in 11 suites passed; STRICT_PRODUCERS unchanged at 49. Closes #1029. Phase 1I.c.2 (next + final-of-final): dissolve `FetchType` enum + Fetch CLI command. - **#1027 critic-fix round 2: prune stale comment in `CLIImpl.SourceRegistry.swift`.** Round-2 critic flagged the same defect class as round-1: `CLIImpl.SourceRegistry.swift:50-53` listed 1I.b among "the remaining epic steps" and cited the indexer-dict dissolution as future work, but this PR IS Phase 1I.b. Rewrote to `// #1007 Phase 1A-1I.b complete ...; Phase 1I.c (final epic step) dissolves the strategies-list assembly + FetchType enum`. - **#1027 critic-fix round 1: prune stale pre-#1027 comment block in `Save.Indexers.swift`.** Round-1 critic flagged that the pre-#1027 #932 comment block (lines 243-251) was left in place above the new #1027 block, creating two adjacent paragraphs with contradictory add-a-source recipes. The old prose said "a new source appends one line here and nothing else" (now false; new sources are registered in `CLIImpl.SourceRegistry.swift`). Merged the two blocks into one coherent post-#1027 description: keeps the #932 injection-not-static contract reference, replaces the inline-literal claim with the registry-derived-filter assembly, and adds the canonical add-a-source-post-#1027 line. - **Source unification Phase 1I.b (#1027 of epic #1007): dissolve inline indexer dict; derive from registry filtered by `destinationDB == .search`.** Phase 1I sub-PR 2 of 3. Eliminates the 7-entry inline indexer literal at `Save.Indexers.swift:256-265`. Post-#1027, the dict comes from `productionRegistry.allEnabled.filter { $0.destinationDB == .search }.reduce(into: [:]) { ... }`. The filter validates the `destinationDB` protocol contract (#1014) a second time: PackagesSource self-excludes because it declares `.packages`, leaving exactly the 7 search.db indexers. New `Issue1027IndexerDictRegistryDerivationTests` (4 assertions): derived dict has exactly 7 entries, PackagesSource is excluded, each indexer's `sourceID` matches its dict key, registry has 8 providers while dict has 7 (validates the destinationDB filter). Cold build green (21.21s); Issue1027 4/4 green; STRICT_PRODUCERS unchanged at 49. Closes #1027. Phase 1I.c (final): dissolve the strategies-list assembly + `FetchType` enum + Fetch CLI command. - **Source unification Phase 1I.a (#1025 of epic #1007): dissolve `CLIImpl.makeProductionSourceLookup`; derive `Search.SourceLookup` from the per-source registry.** First of 3 sub-PRs that complete Phase 1I (the final epic step). Eliminates the parallel-path drift acknowledged across phases 1A-1H: the 8 `Search.SourceDefinition` literals duplicated between `CLIImpl.SourceLookup.swift` and the per-source target files are now single-sourced from the per-source `*.Definition.swift` files. Steps: (1) `Save.Indexers.swift` `sourceLookup:` arg replaced with `Search.SourceLookup(definitions: CLIImpl.makeProductionSourceRegistry().allEnabled.map(\.definition))`. (2) `Packages/Sources/CLI/CLIImpl.SourceLookup.swift` deleted (199 lines; the `makeProductionSourceLookup` factory + 8 inline `Search.SourceDefinition` literals). (3) `Issue919DisplayNameProductionTests` updated to derive the production lookup via the registry path; the 5 existing assertions (8-source count, no duplicate ids, every id a SourcePrefix constant, display-name pins, emoji pins) all still pass against the derived lookup. (4) `Issue935EndToEndPluggabilityTests.productionSourcesAreUntouched` updated similarly. (5) `CLIImpl.SourceRegistry` docstring updated: post-#1025 the registry is the canonical source-of-truth. (6) New `Issue1025SourceLookupRegistryDerivationTests` (3 assertions): registry-derived lookup contains exactly 8 definitions with the expected ids, display-names match the pre-dissolution canonical values, registry insertion order matches lookup order. Cold build green; Issue919 + Issue935 + Issue1025 = 24 tests in 6 suites passed; STRICT_PRODUCERS unchanged at 49 (this PR removes 1 file, adds 0 targets). Closes #1025. Phase 1I.b (next): dissolve the inline indexer dict at `Save.Indexers.swift`. Phase 1I.c (final): dissolve the strategies-list assembly + `FetchType` enum + Fetch CLI command. - **#1023 critic-fix round 1: 3 cosmetic findings.** Em-dash slip in `Packages/Sources/PackagesSource/PackagesSource.swift:8` docstring (replaced with comma). Unused `CoreProtocols` dep dropped from `packagesSourceTarget` in `Package.swift` and from the `PackagesSource` row in `docs/package-import-contract.md` (no source file under `Packages/Sources/PackagesSource/` references CoreProtocols symbols). `import LoggingModels` in `Issue1023PackagesSourceShapeTests` moved from mid-file to the top of the import block. - **Source unification Phase 1H (#1023 of epic #1007; FINAL per-source migration): `PackagesSource` per-source target + first non-`.search` destinationDB.** Eighth and final per-source target of the source-unification arc. **First source whose `destinationDB` is `.packages`** (not `.search`), validating the protocol contract landed by #1014: every conformer declares its destination explicitly; the discriminator value lets phase 1I's composition root route providers to the right index builder. No prior `PackagesStrategy` or `PackagesIndexer`: #789 removed both alongside the search.db `packages` table; today packages indexing runs through the bespoke `Indexer.PackagesService` against packages.db, outside the `SourceProvider` pipeline. PackagesSource contributes the SourceDefinition + FetchInfo + destinationDB discriminator; `makeStrategy` returns a private no-op `PackagesViewSourceStrategy`; `makeIndexer` returns a private no-op `PackagesViewSourceIndexer`. No ASTIndexer dep (no indexer logic) and no SearchStrategyHelpers dep (no strategy concrete). Migration steps: (1) new `Packages/Sources/PackagesSource/` directory (net-add, no `git mv`); (2) new `PackagesSource: Search.SourceProvider` conformer + `Definition.swift` (lifted from `CLIImpl.SourceLookup.swift`) + `FetchInfo.swift` (lifted from `FetchType.packages` switch arms: `Shared.Constants.DisplayName.swiftPackages`, empty `crawlBaseURLs`, `defaultOutputDirKey .packages`, `isWebCrawlable false`); (3) `Packages/Package.swift` adds `packagesSourceTarget` + product + CLI deps + SearchTests/SearchStrategiesTests/SearchModelsTests deps + target-assembly entry; (4) `.register(PackagesSource())` wired into `CLIImpl.makeProductionSourceRegistry()`; **registry now carries 8 of 8 sources** (Phase 1A-1H complete); (5) `scripts/check-target-foundation-only.sh` STRICT_PRODUCERS array gains `PackagesSource` (count 48 -> 49; second net-add of the epic after #1021); (6) `Issue919AuditInvariantTests` STRICT_PRODUCERS count assertion bumped 48 -> 49 with #1023 lineage comment; (7) CI: new `Portability: PackagesSource` step + narrative comment refresh; (8) `docs/package-import-contract.md` new `PackagesSource` row noting the non-`.search` destinationDB divergence + updated closing summary (49 producers strict; 13 + 36 feature); (9) `docs/architecture/database.md` source-strategies cell updated. New `Issue1023PackagesSourceShapeTests` (5 assertions): definition shape, fetchInfo shape, **`destinationDB == .packages` (load-bearing protocol-contract pin)**, makeIndexer noop, makeStrategy noop. Cold build green (20.27s); portability proof for PackagesSource green locally; Issue1023 + Issue919 = 26 tests in 6 suites passed. STRICT_PRODUCERS = 49. Closes #1023. **Phases 1A-1H of #1007 source-unification arc complete; phase 1I (dissolve `FetchType` enum + `IndexerRegistry` + `makeProductionSourceLookup`, wire the destinationDB-aware composition root) is the final epic step.** - **Source unification Phase 1G (#1021 of epic #1007): `SwiftBookSource` per-source target (first view-source + first net STRICT_PRODUCERS add).** Seventh per-source target. Introduces the **view-source** pattern: SwiftBook is the first source migrated that has no prior `<X>Strategy` target to rename. SwiftBook is a sub-source of `Search.SwiftOrgStrategy` (URL-prefix tagging at emission time, per `isSwiftBook` discriminator). SwiftBookSource contributes only the `SourceDefinition` + `SwiftBookIndexer`; `fetchInfo = nil` (SwiftOrgStrategy crawls `docs.swift.org/swift-book/`); `makeStrategy(env:)` returns a private `SwiftBookViewSourceStrategy` (zero items, empty IndexStats, source-id `swift-book`) so the strategies-list dispatch in the registry-driven path stays uniform. Net STRICT_PRODUCERS add: count goes 47 -> 48 (phases 1A-1F were renames; this is the first true new target). Migration steps: (1) new `Packages/Sources/SwiftBookSource/` directory (no `git mv`); (2) `Search.SwiftBookIndexer` (uses `ASTIndexer.Extractor` over fenced code blocks) lifted from `SearchSQLite/Search.SourceIndexer.swift` into `SwiftBookSource/Search.SwiftBookIndexer.swift`. **SearchSQLite/Search.SourceIndexer.swift now hosts 0 indexers** (vestigial shell with historical markers only; all 7 per-source indexers have been lifted out; the file stays as a historical anchor until phase 1I dissolves the older composition-root paths). (3) New `SwiftBookSource: Search.SourceProvider` conformer with `fetchInfo = nil`, `destinationDB = .search`, no-op `makeStrategy`. (4) `SwiftBookSource.Definition.swift` lifted from `CLI/CLIImpl.SourceLookup.swift`. (5) `Packages/Package.swift` adds new `swiftBookSourceTarget` + product + CLI deps + SearchTests deps + SearchStrategiesTests deps + SearchModelsTests deps + target-assembly list entry. (6) `.register(SwiftBookSource())` wired into `CLIImpl.makeProductionSourceRegistry()`; registry now carries 7 of 8 sources. (7) `scripts/check-target-foundation-only.sh` STRICT_PRODUCERS array gains `SwiftBookSource` entry (count: 47 -> 48). (8) CI: new `Portability: SwiftBookSource` step + narrative comment refresh. (9) `docs/package-import-contract.md` new `SwiftBookSource` row; `docs/architecture/database.md` source-strategies path table updated. New `Issue1021SwiftBookSourceShapeTests` (5 assertions): definition shape, fetchInfo nil, destinationDB .search, makeIndexer shape, makeStrategy returns a view-source strategy advertising source-id `swift-book`. Cold build green (21.32s); portability proof for SwiftBookSource green locally; Issue1021 5/5 green. STRICT_PRODUCERS count: 48. Closes #1021. - **Source unification Phase 1F (#1019 of epic #1007): migrate SwiftOrg to `SwiftOrgSource` per-source target.** Sixth per-source target. Mechanical migration following the post-#1014 template. Per-source dep divergence: **no ASTIndexer dep** on the SwiftOrgSource target because `Search.SwiftOrgIndexer` uses the default `Search.SourceIndexer.extractCode` (returns `.empty`); the strategy layer handles markdown-to-structured-page via `env.markdownStrategy`. Steps: SPM target `SwiftOrgStrategy` renamed `SwiftOrgSource`; `git mv` directory; `Search.SwiftOrgIndexer` lifted from `SearchSQLite/Search.SourceIndexer.swift` (SearchSQLite now hosts 1 indexer, just SwiftBookIndexer; was 2); new `SwiftOrgSource: Search.SourceProvider` conformer with `destinationDB = .search` + `Definition.swift` (lifted) + `FetchInfo.swift` (lifted from `FetchType.swift` switch arms: `Shared.Constants.DisplayName.swiftOrgDocs`, `BaseURL.swiftOrg`, `defaultOutputDirKey .swiftOrg`, `isWebCrawlable true` since swift IS in `FetchType.webCrawlTypes`); `.register(SwiftOrgSource())` wired into `CLIImpl.makeProductionSourceRegistry()` (registry now carries 6 of 8 sources). All 6 surface gates observed. New `Issue1019SwiftOrgSourceShapeTests` (4 assertions). Cold build green (21.34s); portability proof green; Issue1019 4/4 green. STRICT_PRODUCERS unchanged at 47. Closes #1019. - **Source unification Phase 1E (#1017 of epic #1007): migrate SwiftEvolution to `SwiftEvolutionSource` per-source target.** Fifth per-source target. Mechanical migration following the AppleArchiveSource template (post-#1014, includes `destinationDB` declaration). Steps: (1) SPM target `SwiftEvolutionStrategy` renamed `SwiftEvolutionSource`; (2) `git mv Packages/Sources/SwiftEvolutionStrategy Packages/Sources/SwiftEvolutionSource`; (3) `Search.SwiftEvolutionIndexer` lifted from `SearchSQLite/Search.SourceIndexer.swift` (carries its private `extractAllCodeBlocks` helper). SearchSQLite now hosts 2 indexers (was 3). (4) New `SwiftEvolutionSource: Search.SourceProvider` conformer with `destinationDB = .search` + `Definition.swift` (lifted) + `FetchInfo.swift` (lifted from `FetchType.evolution` switch arms: `Shared.Constants.DisplayName.swiftEvolution`, `BaseURL.swiftEvolution`, `defaultOutputDirKey .swiftEvolution`, `isWebCrawlable true` since evolution IS in `FetchType.webCrawlTypes`). (5) `.register(SwiftEvolutionSource())` wired into `CLIImpl.makeProductionSourceRegistry()`; registry now carries 5 of 8 sources. (6) ASTIndexer dep on the new target is load-bearing (indexer runs `ASTIndexer.Extractor` over extracted code blocks). All 6 surface gates observed: explicit ASTIndexer dep + manifest rationale comment, CI portability step rename, audit-script STRICT_PRODUCERS rename, `docs/package-import-contract.md` row, `docs/architecture/database.md` path table, CHANGELOG entry. New `Issue1017SwiftEvolutionSourceShapeTests` (4 assertions). Cold build green (22.28s); portability proof for SwiftEvolutionSource green locally; Issue1014 + Issue1017 = 8/8 green; STRICT_PRODUCERS count holds at 47. Closes #1017. - **#1014 critic-fix round 1: DocC link syntax in `destinationDB` docstring.** Single low-severity finding from the round-1 critic. `Search.SourceProvider.swift`'s `destinationDB` docstring used `[[Shared.Models.DatabaseDescriptor]]` (memory wiki-link syntax I confused with DocC). Swift DocC uses double-backticks; replaced with `` ``Shared.Models.DatabaseDescriptor`` `` so DocC generates the symbol link correctly. No code change. - **Source unification Phase 1D (#1014 of epic #1007): migrate AppleArchive to `AppleArchiveSource` per-source target + `Search.SourceProvider.destinationDB` protocol extension.** Fourth per-source target plus a structural extension to the SourceProvider contract: every conformer now must declare its destination database (no implicit search.db routing). Surfaced during the #1014 in-flight discussion; user directive was "no implicit, every source must define that, even though we leave search.db as is for now". Phase 1D scope absorbed the originally-separate #1015 design issue. **Protocol extension**: `Search.SourceProvider` gains `var destinationDB: Shared.Models.DatabaseDescriptor { get }` (no default; every conformer must declare). Three prior per-source targets retrofitted in this PR (AppleDocsSource / HIGSource / SampleCodeSource all declare `.search`). AppleArchiveSource conforms with `.search` too. The composition root for the registry-driven path stays as-is (search.db routing untouched per the user directive); phase 1I or later wires the destinationDB-aware index-builder dispatch. **Mechanical migration steps** mirror the HIGSource template: (1) rename SPM target `AppleArchiveStrategy` to `AppleArchiveSource`; (2) `git mv Packages/Sources/AppleArchiveStrategy Packages/Sources/AppleArchiveSource`; (3) lift `Search.AppleArchiveIndexer` from `SearchSQLite/Search.SourceIndexer.swift` to new `AppleArchiveSource/Search.AppleArchiveIndexer.swift` (SearchSQLite now hosts 3 indexers; was 4); (4) new `AppleArchiveSource: Search.SourceProvider` conformer plus `Definition.swift` (lifted from `CLI/CLIImpl.SourceLookup.swift`) and `FetchInfo.swift` (lifted from `CLI/SupportingTypes.swift` `FetchType.archive` switch arms: displayName from `Shared.Constants.DisplayName.archive`, crawl base `Shared.Constants.BaseURL.appleArchive`, `defaultOutputDirKey .archive`, `isWebCrawlable false` since archive isn't in `FetchType.webCrawlTypes`); (5) wire `.register(AppleArchiveSource())` into `CLIImpl.makeProductionSourceRegistry()` (registry carries 4 of 8 sources). AppleArchiveSource target deps include `ASTIndexer` (load-bearing: `Search.AppleArchiveIndexer.extractCode` runs `ASTIndexer.Extractor` conditionally on Swift-shaped content, guarded by a content-substring heuristic because archive content is mixed Swift + Objective-C). All 6 surface gates observed: explicit ASTIndexer dep + manifest comment, CI portability step rename, audit-script STRICT_PRODUCERS rename, docs/package-import-contract.md row, docs/architecture/database.md path table, CHANGELOG entry. New `Issue1014AppleArchiveSourceShapeTests` (4 assertions): definition shape, fetchInfo shape, makeIndexer shape, `destinationDB == .search` explicit declaration. Existing `Issue1008` FixtureProvider conformer retrofitted with `destinationDB`. **Forward-looking note** (recorded in the protocol docstring + per the user's directive on the in-flight #1014 thread): today every search-bound source declares `.search`, but `.search` is a temporary stand-in for "the shared prose-text FTS database that holds 6+ sources today". When the per-source DB split lands (separate future epic, post-1I), each source will declare its own descriptor (`.appleDocs` / `.hig` / `.appleArchive` / etc.) and `.search` as a name becomes meaningless. The destinationDB protocol extension landed in this PR is the seam for that future split; conformers should not rely on the literal `.search` staying stable. Cold build green (24.57s); 15/15 prior shape pins green + 4 new Issue1014 = 19/19 green. STRICT_PRODUCERS count holds at 47 (rename, not net add). #1015 closed as folded into #1014. Closes #1014. - **Source unification Phase 1C (#1012 of epic #1007): migrate SampleCode to `SampleCodeSource` per-source target.** Third per-source target of the source-unification arc. First to introduce a per-source runtime dep that wasn't in `Search.IndexEnvironment`: `SampleCodeStrategy` requires a `Search.SampleCatalogProvider` supplied by the CLI composition root at command time. **Architectural resolution**: extend `Search.IndexEnvironment` with `sampleCatalogProvider: (any Search.SampleCatalogProvider)? = nil` (optional, default nil). AppleDocs + HIG strategies ignore it; `SampleCodeSource.makeStrategy(env:)` preconditions non-nil (fail-loud-at-the-door per `docs/PRINCIPLES.md`). Establishes the seam pattern for future per-source migrations: when a strategy needs a domain-specific seam not shared with other sources, add it as an optional `IndexEnvironment` field rather than parameterizing `makeProductionSourceRegistry()` or adding per-source factory init args. The "100% pluggable, 2-file PR" claim still holds (per-source target + 1-line composition-root append); the `IndexEnvironment` extension is a foundation-tier change that's source-compatible for existing call sites. Migration steps follow the HIGSource template: (1) extend `Search.IndexEnvironment`; (2) rename SPM target `SampleCodeStrategy` to `SampleCodeSource` in `Packages/Package.swift`; (3) `git mv Packages/Sources/SampleCodeStrategy Packages/Sources/SampleCodeSource` (carries `Search.SampleCodeStrategy` struct unchanged); (4) lift `Search.SampleCodeIndexer` from `SearchSQLite/Search.SourceIndexer.swift` (the file now hosts 4 indexers; was 5); (5) new `SampleCodeSource: Search.SourceProvider` conformer + per-source `Definition.swift` + `FetchInfo.swift` static literals; (6) wire `.register(SampleCodeSource())` into `CLIImpl.makeProductionSourceRegistry()` (registry now carries 3 of 8 sources); (7) `SampleCodeSource` target deps include `ASTIndexer` (load-bearing, unlike HIGSource): `Search.SampleCodeIndexer.extractCode` runs `ASTIndexer.Extractor` over full Swift files. CI portability step renamed `Portability: SampleCodeStrategy` to `Portability: SampleCodeSource`; STRICT_PRODUCERS audit-script entry renamed; `docs/package-import-contract.md` SampleCodeSource row updated (deps + per-source-dep rationale); `docs/architecture/database.md` path table updated. New `Issue1012SampleCodeSourceShapeTests` (4 assertions): definition shape, fetchInfo shape, makeIndexer shape, `IndexEnvironment.sampleCatalogProvider` exposed as optional defaulting to nil. Cold build green (21.99s); SampleCodeSource portability proof green locally; Issue1008 + Issue1010 + Issue1012 shape pins green (8 + 3 + 4). STRICT_PRODUCERS count holds at 47 (rename, not net add). Closes #1012. - **#1010 critic-fix round 1: drop unused `ASTIndexer` dep on `HIGSource`.** Single finding from the round-1 critic pass on the Phase 1B initial commit. `Search.HIGIndexer.swift` had `import ASTIndexer` at line 1 but the file references no `ASTIndexer.*` symbols (`HIGIndexer.extractCode` returns `Search.ExtractedContent.empty`; HIG is pure design guidance with no code extraction). The import + the matching `higSourceTarget` ASTIndexer dep + the `docs/package-import-contract.md` HIGSource row's ASTIndexer entry were all mechanically copied from the AppleDocsSource template (where ASTIndexer IS load-bearing for `AppleDocsIndexer.Extractor` calls). Per `per-package-import-contract.md`'s explicit-allowlist rule, dropped: the `import ASTIndexer` line, the `"ASTIndexer"` entry from `higSourceTarget.dependencies`, the `ASTIndexer` token from the HIGSource row's allowed-imports list, and the bogus "namespace resolution" rationale in the row's parenthetical. New comment above `higSourceTarget` records the per-source divergence from the AppleDocsSource template (the dep set is per-source, not a template constant) so the next per-source migration knows. - **Source unification Phase 1B (#1010 of epic #1007): migrate HIG to `HIGSource` per-source target.** Second per-source target of the source-unification arc; mirrors the AppleDocsSource shape established by Phase 1A (#1008). Mechanical migration following the AppleDocsSource template: (1) rename SPM target `HIGStrategy` to `HIGSource` in `Packages/Package.swift`; (2) `git mv Packages/Sources/HIGStrategy Packages/Sources/HIGSource` (carries the `Search.HIGStrategy` struct in `Search.Strategies.HIG.swift` unchanged); (3) lift `Search.HIGIndexer` struct from `SearchSQLite/Search.SourceIndexer.swift` into new `HIGSource/Search.HIGIndexer.swift` (SearchSQLite now hosts 5 indexers; was 6); (4) new `HIGSource: Search.SourceProvider` conformer in `HIGSource/HIGSource.swift` plus per-source `Definition.swift` (lifted from `CLI/CLIImpl.SourceLookup.swift`) and `FetchInfo.swift` (lifted from `CLI/SupportingTypes.swift`'s pre-#1007 `FetchType.hig` switch arms); (5) wire `.register(HIGSource())` into `CLIImpl.makeProductionSourceRegistry()` (parallel path; the older `makeProductionSourceLookup()` still ships AppleDocs + HIG inline literals until phase 1I dissolves it); (6) `AppleDocsSource` template's 6 surface gates all observed (with one per-source divergence): `HIGSource` target deps mirror the AppleDocsSource template MINUS `ASTIndexer` (HIGIndexer has no code extraction; the dep is per-source, not a template constant; see the round-1 critic-fix entry above), CI `Portability: HIGStrategy` step renamed `Portability: HIGSource`, `scripts/check-target-foundation-only.sh` STRICT_PRODUCERS array entry renamed, `docs/package-import-contract.md` HIGSource row updated (deps list + #1010 rationale; no ASTIndexer entry per the divergence), `docs/architecture/database.md` source-strategies path table swaps `HIGStrategy` for `HIGSource`, CHANGELOG entry filed. New `Issue1010HIGSourceShapeTests` (3 assertions) pin the per-source artefacts: `definition.id` + `intents` + `intentPriority` + `properties.designFocus` (HIG-specific); `fetchInfo.crawlBaseURLs` + `defaultOutputDirKey == .hig`; `makeIndexer()` produces an indexer whose `sourceID == hig`. Cold build green; HIGSource portability proof green locally; Issue1008 + Issue1010 shape pins green (8 + 3). STRICT_PRODUCERS count holds at 47 (rename, not net add). Closes #1010. - **#1008 critic-fix follow-up (6 findings).** Six fixes folded into the Phase 1A PR after the critic pass on the initial commits: (1) `.github/workflows/ci.yml` `Portability: AppleDocsStrategy` step renamed to `Portability: AppleDocsSource` (the prior step shelled out `check-target-portability.sh AppleDocsStrategy` against a target that no longer exists, which was empirically RED on the PR's first CI run). (2) `Search.SourceRegistry.register(_:isEnabled:)` semantic fix: `isEnabled:` parameter is now `Bool? = nil` (was `Bool = true`); a re-register preserves the entry's prior `isEnabled` flag rather than silently clobbering a previously-disabled source back to enabled. New `reregisterPreservesDisabledFlag` test pins the new contract (operator disable survives composition-root refresh). (3) `AppleDocsSource` target gains an explicit `ASTIndexer` dependency in `Package.swift` (the target's `Search.AppleDocsIndexer.swift` directly imports ASTIndexer; the dep had been resolving only via SearchModels' transitive route, violating per-package-import-contract.md's explicit-dep rule). (4) `docs/package-import-contract.md` `SearchModels` row updated to declare `Foundation, ASTIndexer, LoggingModels, SharedConstants` (post-#1008 the manifest adds ASTIndexer + LoggingModels; the doc row was stale). (5) `docs/architecture/database.md` source-strategies path table updated: `AppleDocsStrategy` to `AppleDocsSource` (rename drift; the directory no longer exists at the old name, violating the rule that backtick-quoted paths must exist). (6) `AppleDocsSource/Search.AppleDocsIndexer.swift` orphan trailing `// MARK: - HIG Indexer` + dangling `///` docstring removed (copy-paste leftover from the lift out of `SearchSQLite/Search.SourceIndexer.swift`). Cold build green (22.61s); 2509-test suite + new `reregisterPreservesDisabledFlag` regression green; portability proof for AppleDocsSource green locally. - **Source unification Phase 1A (#1008 of epic #1007): per-source `Search.SourceProvider` seam + `AppleDocsSource` target.** First load-bearing PR of the source-unification arc. Closes #1008. Establishes the protocol shape + registry value type + per-source target structure that the rest of the epic builds on. Five steps in one PR (commit-level): (1) lift `Search.SourceItem` + `Search.ExtractedContent` + `Search.SourceIndexer` protocol from `SearchSQLite` to `SearchModels` (the protocol's natural home is the foundation-only Models tier per gof-di-rules § 3; pre-fix, per-source SPM targets would have needed to import `SearchSQLite` to conform, a Rule 3 violation); `SearchModels` gains `ASTIndexer` + `LoggingModels` deps (foundation-tier, allowed per `per-package-import-contract.md`). (2) define `Search.SourceProvider` protocol (definition + fetchInfo + makeStrategy + makeIndexer factories), `Search.SourceRegistry` value type (mirror of secret-life's `ImporterRegistry`), `Search.FetchInfo` value type (replaces `FetchType` enum's switch arms; per-source DefaultOutputDirKey enum the CLI resolves against `Shared.Paths` at composition time so per-source targets don't reach into a process-wide config holder), `Search.IndexEnvironment` value type (dep bundle for strategy/indexer factories). (3) rename `AppleDocsStrategy` SPM target → `AppleDocsSource`; absorb `AppleDocsIndexer` (lifted from `SearchSQLite/Search.SourceIndexer.swift` lines 9-85) + new `AppleDocsSource: Search.SourceProvider` conformer + per-source `SourceDefinition` literal (lifted from `CLI/CLIImpl.SourceLookup.swift`) + per-source `FetchInfo` literal (lifted from `CLI/SupportingTypes.swift`'s pre-#1007 `FetchType.docs` switch arm). (4) new `CLIImpl.makeProductionSourceRegistry()` at CLI composition root populating the registry with `AppleDocsSource()`; **parallel path** to the still-existing `makeProductionSourceLookup()` for the 7 sources not yet migrated. Phases 1B-1H (one PR per source) migrate the remaining 7; phase 1I dissolves the older `makeProductionSourceLookup` literal list + the `FetchType` enum. (5) new `Issue1008SourceProviderProtocolShapeTests` (8 assertions post-critic-fix-round-1; the 8th `reregisterPreservesDisabledFlag` pins the `Bool? = nil` preserve semantics) pins the protocol shape, registry register/lookup/idempotent-replace/setEnabled/iteration-order behavior, FetchInfo equality, and DefaultOutputDirKey case coverage. `docs/package-import-contract.md` `AppleDocsSource` row updated (✅). Cold build green; full 2509-test suite + 8 new + 51 audit-invariant assertions green; STRICT_PRODUCERS count unchanged at 47 (rename, not net add). Closes #1008. - **Expanded CLI + MCP smoke battery to cover 6 user-facing CLI subcommands and 5 MCP tool shapes.** `scripts/eval/cli-smoke.sh` (new) exercises `cupertino --version`, `cupertino doctor` (asserts all 3 DBs present), `cupertino search "View"`, `cupertino read apple-docs://swiftui/view`, `cupertino list-frameworks` (asserts >100 frameworks; 420 in the live bundle), and `cupertino inheritance UIButton --direction up` (asserts UIControl + UIView in the ancestor chain). `scripts/eval/mcp-smoke.sh` expanded from 3 probes (initialize + tools/list + tools/call search) to 7 probes adding `tools/call read_document`, `tools/call get_inheritance`, `tools/call list_frameworks`, `tools/call search_property_wrappers`. Body-shape assertions per memory `feedback_mcp_probe_shape_not_length`: every probe asserts on semantic markers (`Property Wrapper:`, `apple-docs`, `UIControl`, framework name presence, etc.) plus `isError == false`. Both wired into the `query-batteries-smoke` CI job as new steps. Local run: 14/14 CLI assertions + ~30 MCP assertions green. - **MockAIAgent end-to-end fix: `Task.yield()` after each stdin write closes #1004 (the 3rd-write stall left as a follow-up from PR #1002).** Root cause was actor-isolated writes back-to-back on the actor's serial executor; the kernel pipe didn't drain between consecutive writes, leaving subsequent messages silently buffered without ever reaching the server's `for try await byte in input.bytes` loop. Fix: `await Task.yield()` after each `stdin.write(contentsOf:)` in `MCPClient.sendRequest`. The yield gives the runtime a chance to schedule the stdout reader Task and lets the kernel pipe drain. Full demo flow now completes against the live `cupertino serve` binary: `initialize` → `notifications/initialized` → `tools/list (12 tools)` → `tools/call (search)` → `resources/list (396)` → `resources/read` → `shutdown` → `Mock AI Agent Complete`. New `scripts/eval/mock-ai-agent-smoke.sh` runs the end-to-end demo and asserts on completion markers; wired into the `query-batteries-smoke` CI job alongside the direct-pipe MCP smoke. 8/8 assertions green locally. Closes #1004. - **MCP smoke test added to the query-batteries CI job. `MockAIAgent` accrues 3 spec / robustness fixes.** New `scripts/eval/mcp-smoke.sh` exercises the live `cupertino serve` MCP server over a JSON-RPC stdio pipe: `initialize` handshake (asserts serverInfo.name == cupertino, protocolVersion present), `notifications/initialized` per MCP 2025-11-25 spec, `tools/list` (asserts 12 tools including search / read_document / get_inheritance / search_symbols / list_frameworks), and `tools/call name=search` (asserts non-empty body with NSURLSession + apple-docs semantic markers, isError == false). Wired into the `query-batteries-smoke` CI job as a fifth probe; runs after `cupertino setup` seeds the dev base directory. 14/14 assertions green locally. `MockAIAgent` gained three independent fixes uncovered during this work: (1) `--response-timeout <N>` flag to override the hardcoded 30 s budget that flaked on cold-start (default preserved for back-compat); (2) a `bufferedLines` queue so `handleLine` no longer drops responses that arrive before `sendRequest` has registered its continuation (a race condition that silently dropped warm-path responses); (3) the spec-mandated `notifications/initialized` notification after the `initialize` response (MCP 2025-11-25 lifecycle requirement that the agent had been omitting). A deeper stall in `MockAIAgent` blocks subsequent writes after the first response despite these fixes; deferred to a follow-up issue. The smoke script therefore uses the direct JSON-RPC pipe path which is proven reliable. - **DB-registry composition-root injection on `Distribution.SetupService`.** `Distribution.SetupService.Request` gains a `required: [DatabaseDescriptor]` field with NO default. The composition-root decision of which DBs the CLI ships should be visible at the call site, not hidden as an init default. Other init parameters (`currentDocsVersion`, `docsReleaseBaseURL`) keep their defaults because they read single-source-of-truth constants from `Shared.Constants.App`, not architectural choices. The service runs end-to-end off that list. The 4 hardcoded sites inside `Distribution.SetupService.run` (3 per-DB URL constructions, the `required` Set literal, the `onDiskByFilename` tuple list, the `placements` array) collapsed into a single `request.required.map` that derives each `DatabasePlacement` from `descriptor.filename`. Post-extract existence check now iterates `placements` instead of grep-and-shout for the three known names. Composition root in `CLIImpl.Command.Setup.run` explicitly assembles `required: [.search, .samples, .packages]`. The 6 test sites (3 in `DistributionModelsTests`, 3 in `BackupTests`) updated to pass the list explicitly. `Request.init` carries two preconditions: empty `required` is rejected as a structurally meaningless invocation, and duplicate descriptor ids are rejected (the id is the routing key everywhere downstream; full-struct equality would let a future typo'd-filename fork through). Verification of the production composition-root order is left to the existing CLI integration paths; an inline-literal test was not a meaningful guard against drift. **Scope of the claim**: adding a 4th DB no longer touches `Distribution.SetupService.run`. It still touches `Shared.Models.DatabaseDescriptor` (one new `static let`) + `CLIImpl.Command.Setup.run` (one append). The Doctor command's `printSchemaVersions` entries array in `CLIImpl.Command.Doctor` carries a separate 3-DB hardcode using different per-DB URL-resolution helpers; that's tracked as future-scope per #248's original `downloadURL` proposal and is not in scope for this PR. **Bundle-coupling assumption**: a descriptor passed to Setup is assumed to arrive inside `cupertino-databases-vX.Y.Z.zip`; per-descriptor download URLs are future-scope. **Drive-by**: the stamp-failure error message path in `Distribution.SetupService.run` (`message.data(using: .utf8)` → `Data(message.utf8)`) was unblocking pre-commit swiftlint; called out here so it's not silent scope creep. Cold build + 61-test DistributionTests / DistributionModelsTests / Issue919AuditInvariant suite all green. - **#900 sub-PR B: rename `Search` target to `SearchAPI`.** Per the 2026-05-12 plan §3.8. Directory moved `Packages/Sources/Search/` to `Packages/Sources/SearchAPI/`; 30 `import Search` sites in CLI / TUI / tests rewritten; `Packages/Package.swift` target renamed; audit scripts updated (STRICT_PRODUCERS, FORBIDDEN_MODULES, package-purity allow lists); `docs/package-import-contract.md` Search row renamed + refreshed; `Issue919AuditInvariantTests.forbiddenModulesCoversArc` mustContain array updated; CI portability step renamed. `CLI/SearchModuleAlias.swift` typealias kept (`typealias SearchModule = Search` aliases the namespace anchor inside `SearchModels`, not the module name). Sub-PR A (extract `SearchQuery` as a separate foundation-only target) deferred to a follow-up as low-value organizational churn; the load-bearing `Search.Database` protocol + `SearchQuery`-shaped methods already operate through the foundation seam in `SearchModels`. Build + audit scripts + 3 audit-invariant tests + `bash scripts/check-target-portability.sh SearchAPI` all green. - **#904: extract WebKit out of `Core` / `CoreJSONParser` / `CoreSampleCode` producers.** Closes #904. Three sites: (1) `Core.Parser.HTML` had a dead `import WebKit` (no symbols used; the `appleWebKit` references are SharedConstants enum cases) and was removed. (2) `Core.JSONParser.WKWebViewTitleFetcher` (the last-resort title fetcher that loads pages in WKWebView) lifted to new `CoreJSONParserWebKit` sibling; `AppleJSONAPITitleFetcher` + `CompositeTitleFetcher` stay in CoreJSONParser (Foundation/URLSession only). (3) `Sample.Core.Downloader` (hidden-WKWebView driver scraping the Apple sample-code listing + per-sample download URLs via JS) + its `Error` type moved to new `CoreSampleCodeWebKit` sibling; `Sample.Core.Catalog` + `Sample.Core.GitHubFetcher` stay in CoreSampleCode. Acceptance met: `grep -r '^import WebKit' Packages/Sources/Core/ Packages/Sources/CoreSampleCode/` returns zero hits. The 2 new WebKit-companion siblings are intentionally outside STRICT_PRODUCERS (each extends its parent producer's types). They are on `check-package-purity.sh`'s FORBIDDEN_MODULES + EXEMPT_TARGETS. No consumer target may import them; only composition root binaries do. The `Core.JSONParser` namespace anchor moved to `CoreProtocols` foundation tier. `SampleCodeCatalogJSON` made public so the `CoreSampleCodeWebKit` sibling can write + transform it; `SampleMetadata` declared `public` in the sibling itself. Cold `swift build` + audit scripts green; STRICT_PRODUCERS=47; 3 audit-invariant tests pass. - **#903: extract WebKit out of the Crawler producer into `CrawlerWebKit` sibling.** Closes #903 (one PR covering all 4 sub-PRs A-D). Strict-DI shape: the Crawler producer is now foundation-only (`grep '^import WebKit' Packages/Sources/Crawler/` returns zero); WebKit-backed concretes live in the new `Packages/Sources/CrawlerWebKit/` target; HIG + AppleDocs take `any Crawler.HTTPFetcherFactory` via init injection and call `.makeFetcher(pageLoadTimeout:javascriptWaitTime:)` to obtain per-crawl `StringContentFetcher` instances. Pieces: new `Crawler.HTTPFetcherFactory` protocol in `CrawlerModels` (foundation seam, `@MainActor`); new `Core.Protocols.StringContentFetcher` non-generic counterpart to `ContentFetcher` (Swift doesn't synthesise primary-associated-type machinery on protocols declared inside an `extension` block, so a String-specialised companion sidesteps the issue cleanly); new `Crawler.WebKit.LiveHTTPFetcherFactory` production conformer; new `Crawler.NoopHTTPFetcherFactory` + `Crawler.NoopStringContentFetcher` test stubs in `CrawlerModels`. `Crawler.Engine` typealias moved to `CrawlerModels` (foundation tier) so the CrawlerWebKit sibling can name the alias without linking the Crawler producer. CLI composition root (`CLIImpl.Command.Fetch.swift`) constructs `Crawler.WebKit.LiveHTTPFetcherFactory()` and passes it; 12 test callers updated. `STRICT_PRODUCERS` grows to 47. Build + #919 audit-invariant tests (3) pass. Refs: closes #903. - **#906 sub-PR G: extract `Enrichment.SynonymsPass` to SPM sibling.** Sixth and final per-pass split. New foundation-tier `Packages/Sources/SynonymsPass/` target with EnrichmentModels + SearchModels deps. The 22-entry framework-alias table (`corebluetooth ← bluetooth`, etc.) moves with the pass. CLI composition root imports the sibling; STRICT_PRODUCERS list (46 producers) + contract-doc row updated. After this lands the `Enrichment` package retains only the `LiveRunner` orchestrator. Build green. Refs: #906 sub-PR G; closes #906. - **#980 (LOW-1): rename `Packages/Sources/TUI/Models/` → `Packages/Sources/TUI/State/`.** Surfaced by today's full-app rule-canon audit (`docs/audits/2026-05-23-rule-canon-audit.md` LOW-1). The TUI executable target is a composition root that legitimately carries reference types (`final class AppState`), but the `Models/` subfolder name invited confusion with the foundation-only `*Models` SPM-target convention from `per-package-import-contract.md §29`. Cosmetic rename, no behaviour change. Updated three doc-comment references (`Crawler.ArchiveGuideCatalog`, `Shared.Constants`, `SharedConstantsTests`) to the new path. Build green; full TUI test suite (123 tests) passes. LOW-2 (Logging→SearchSchema cross-import) explicitly deferred to the #907 #893-closing audit per the original audit doc. Refs: closes #980. - **#979: surface honest `rowsAffected` from constraint-enrichment passes.** Surfaced by today's full-app rule-canon audit (`docs/audits/2026-05-23-rule-canon-audit.md` LOW-3). Pre-#979, `Enrichment.AppleConstraintsPass` and `Enrichment.HierarchyPass` hardcoded `rowsAffected: 0` on the success path because the underlying `Search.IndexWriter.applyAppleStaticConstraints(lookup:)` and `propagateConstraintsFromParents()` returned `Void`. Both protocol methods now return `Int` (the `@discardableResult` annotation keeps existing callers source-compatible). `Search.Index.applyAppleStaticConstraints` totals `sqlite3_changes` across both the exact + hash-prefix UPDATE statements; `Search.Index.propagateConstraintsFromParents` returns the staged updates count. `Search.IndexBuilder` logs the affected-row counts at info level. `EnrichmentModels.Result` documentation updated to clarify that `rowsSkipped: 0` is intentional for SET-based UPDATE passes and that `durationMs: 0` is a sentinel the LiveRunner patches via wall-clock measurement. Build green; #759 + EnrichmentModels test suites pass (53 tests). Refs: closes #979. - **#977: add `concurrency.md §24` justification comments to 7 `@unchecked Sendable` producer-tier types.** Surfaced by today's full-app rule-canon audit (`docs/audits/2026-05-23-rule-canon-audit.md` MED-3). Rule requires every `@unchecked Sendable` use to carry a one-line comment explaining why the escape hatch is safe. Added justifications for: `Core.Parser.XML`, `Core.Parser.HTML`, `Core.JSONParser.AppleJSONToMarkdown`, `Core.JSONParser.Engine`, `Core.JSONParser.ContentFetcher`, `Search.ComposedResultBuilder`, `RemoteSync.ProgressReporter`. The 4 stateless value-type transformers (XML/HTML/AppleJSONToMarkdown/ContentFetcher) carry "stateless value type, structural protocol conformance"; the 3 classes (Engine, ProgressReporter, ComposedResultBuilder) carry the specific safety convention each relies on (single-actor caller, builder pattern, MainActor isolation). Audits unchanged. Refs: closes #977. - **#974: split 3 multi-type files per code-style.md one-non-private-type-per-file rule.** Surfaced by today's full-app rule-canon audit (`docs/audits/2026-05-23-rule-canon-audit.md` HIGH-1). The rule's acceptance grep says output MUST be empty; pre-#974 found 3 violations: - `CLI/SearchModuleAlias.swift` carried 8 `Live*` factory / strategy structs (`LiveSearchDatabaseFactory`, `LiveSearchIndexWriterFactory`, `LiveMarkdownLookupStrategy`, `LivePackageFileLookupStrategy`, `LiveSampleIndexDatabaseFactory`, `LiveHTMLParserStrategy`, `LiveAppleJSONParserStrategy`, `LivePriorityPackageStrategy`). Each moved to its own file. The original is reduced to the `typealias SearchModule = Search` declaration that gives the file its name. - `EnrichmentModels/EnrichmentModels.swift` carried the namespace enum + `EnrichmentRunner` protocol + `EnrichmentPass` protocol. Two protocols moved to `EnrichmentRunner.swift` + `EnrichmentPass.swift`. - `AvailabilityFoundationNetworking/LiveAvailabilityNetworking.swift` carried both the concrete + its factory; factory moved to `LiveAvailabilityNetworkingFactory.swift`. Build green; 2492/372 tests pass; `check-package-purity.sh` + `check-target-foundation-only.sh` audits unchanged (41 producers strict). Refs: closes #974. - **#976 iter-1 critic fixes: CI portability matrix updated for the #899 strategy split; `Services/README.md` examples refreshed.** The `.github/workflows/ci.yml` `Standalone-portability proof` job had a hardcoded step for the now-deleted `SearchStrategies` umbrella target; replaced with 8 portability steps covering the 6 strategy siblings + `SearchStrategyHelpers` + `AppleConstraintsPass`. `Packages/Sources/Services/README.md` examples (lines 188 / 215 / 218) referenced the now-deleted `.mcpDefault` / `.cliDefault` statics; rewritten to inline-construct `Services.Formatter.Config` and document the composition-root `makeStandardConfig()` factory pattern. - **#976: drop `Services.Formatter.Config.shared` / `.cliDefault` / `.mcpDefault` Service Locator statics.** Surfaced by today's full-app rule-canon audit at `docs/audits/2026-05-23-rule-canon-audit.md` HIGH-3. `mihaela-agents/Rules/swift/gof-di-rules.md` Rule 1 carve-outs are (a) Apple's `os.Logger` per-category statics and (b) `private static let cache = Cache()` memoization not reachable from consumers; neither applied. Six live consumer sites in CLI + SearchToolProvider now construct the canonical Config via a private `makeStandardConfig()` factory; 5 ServicesModels formatter init parameter defaults inline-construct the same Config at the default expression. 4 test fixtures that asserted the statics' values are deleted (their assertions are now structural at the consumer sites). Test count drops 2496/373 -> 2492/372. Refs: closes #976. - **#906 sub-PR B: extract `Enrichment.AppleConstraintsPass` into its own SPM target.** Pattern-setter for the per-pass split (mirrors #899's strategy split). New `AppleConstraintsPass` SPM target depends on EnrichmentModels + SearchModels + SharedConstants. The `Enrichment` namespace anchor moved from the Enrichment producer to EnrichmentModels (foundation-only seam) so per-pass sibling targets can extend `Enrichment.<X>Pass` without depending on the Enrichment producer concrete. CLI composition root + EnrichmentTests gain the new import. STRICT_PRODUCERS 40 -> 41. Remaining 5 per-pass extractions (HierarchyPass, PackagesAppleConstraintsPass, PackagesAppleImportsPass, SamplesAppleConstraintsPass, SynonymsPass) follow the same shape. Refs: #906. - **#899 sub-PR G: extract `AppleArchiveStrategy` + delete the now-empty `SearchStrategies` umbrella target. 6-of-6 strategy split complete.** The 6 source-indexing strategies (AppleDocs, HIG, SampleCode, SwiftEvolution, SwiftOrg, AppleArchive) now each ship as their own SPM target conforming `Search.SourceIndexingStrategy`. The SearchStrategies umbrella target had no remaining source files after the 6 extractions and is deleted. Adding a new source indexer (e.g. WWDC transcripts #58 or Tech Talks #273) is now a 2-file PR: new `<X>Strategy` target + CLI composition-root registration. STRICT_PRODUCERS 39 -> 40 (added AppleArchiveStrategy, removed SearchStrategies, net +1). The SearchStrategyHelpers seam from sub-PR B carries the shared utility helpers. Closes #899. - **#899 sub-PR F: extract `SwiftOrgStrategy` into its own SPM target.** Pattern from B-E. STRICT_PRODUCERS 39 -> 40. Refs: #899. - **#899 sub-PR E: extract `SwiftEvolutionStrategy` into its own SPM target.** Pattern continues from sub-PRs B-D. STRICT_PRODUCERS 38 -> 39. Refs: #899. - **#899 sub-PR D: extract `SampleCodeStrategy` into its own SPM target.** Pattern continues from sub-PRs B + C. Same shape: foundation-only deps, file moved from SearchStrategies, CLI + tests import the new target. STRICT_PRODUCERS bumped 37 -> 38. Refs: #899. - **#899 sub-PR C: extract `HIGStrategy` into its own SPM target.** Follows the pattern set by sub-PR B (#967). New target depends on Foundation + SearchModels + SharedConstants + LoggingModels + CoreProtocols + SearchStrategyHelpers. SearchStrategies drops the HIG file. CLI composition root + 4 test targets gain `import HIGStrategy`. STRICT_PRODUCERS bumped 36 -> 37. Test count 2496 / 373 preserved. Refs: #899. - **#899 sub-PR B: extract `AppleDocsStrategy` into its own SPM target; ship `SearchStrategyHelpers` as the prerequisite foundation-only seam.** Pattern-setter for the remaining 5 per-strategy splits (HIG / SwiftEvolution / SwiftOrg / AppleArchive / SampleCode). Two new SPM targets: `SearchStrategyHelpers` (foundation-only) carries the 824-line `Search.StrategyHelpers` enum so per-strategy targets can consume the helpers without depending on the SearchStrategies concrete. `AppleDocsStrategy` ships `Search.AppleDocsStrategy` struct. SearchStrategies target gains the helpers dep + drops the AppleDocs file. CLI composition root adds `import AppleDocsStrategy`. Both audits green; 36 producers strict (up 1). Test count 2496 / 373 preserved. Refs: #899. - **#905: Availability arc lifts FoundationNetworking out into a sibling target; Availability stays foundation-only.** Pre-#905 `Availability.Fetcher` owned its `URLSession`, imported `FoundationNetworking` (gated via `#if canImport`), and was the only producer in the foundation-only allow-list with a transport-framework dep. Per the pluggability epic (#893 / analysis doc §7.6), this moves the URLSession-touching code to a new sibling SPM target so the Availability producer drops the FoundationNetworking import entirely. Three structural changes: (1) new `AvailabilityModels` foundation-only target carries the namespace anchor (`public enum Availability`) + all pure value types (`Platform`, `Info`, `Progress`, `Statistics`, `Error`) + the new `Networking` + `NetworkingFactory` protocols. The protocol surface is one method, `fetch(from: URL) async throws -> (Data, Int)`, where the trailing `Int` is the HTTP status code (`0` for non-HTTP responses): deliberately avoiding `URLResponse` / `HTTPURLResponse` in the protocol since those live in FoundationNetworking on Linux. (2) new `AvailabilityFoundationNetworking` sibling target ships `LiveAvailabilityNetworking` + `LiveAvailabilityNetworkingFactory` (the URLSession-backed witness; the only file in the producer graph that now references FoundationNetworking). (3) `Availability.Fetcher.init` gains a `networkingFactory: any Availability.NetworkingFactory` parameter; CLI composition root (`CLIImpl.Command.Fetch.swift`) wires `LiveAvailabilityNetworkingFactory()`. The internal `APIResponse` JSON DTO stays in the Availability producer (extracted into its own `Availability.APIResponse.swift` file). `docs/package-import-contract.md` updated with 3 new rows (AvailabilityModels foundation-only, Availability drops FoundationNetworking, AvailabilityFoundationNetworking opts into the FoundationNetworking-touching role). `scripts/check-target-foundation-only.sh` MODELS_TARGETS array extended with `AvailabilityModels`. Both `check-package-purity.sh` + `check-target-foundation-only.sh` audits green. Test count grows from 2492 to 2496 (4 new protocol-seam tests added per iter-1 critic: FakeAvailabilityNetworking conformer proves the seam is usable for happy-path 200, non-HTTP status=0 contract, throw-on-no-stub transport-error, and factory wiring). Future Linux URLSession variant unblocked. Refs: closes #905. ### Fixed - **fix(#1200): the `list_samples` MCP tool now advertises its `framework` + `limit` parameters in `tools/list`.** The tool was registered with an empty input schema (`objectSchema(properties: [:])`) even though `handleListSamples` reads `framework` (optional) and `limit` (default 50), so a client introspecting the tool surface could not discover those params. The schema now declares both, matching the handler and the `cupertino list-samples` CLI. Regression test `listSamplesSchemaAdvertisesParams` pins the advertised keys. - **fix(#1201): `cupertino save --help` now generates its valid-source list and source-to-database dispatch mapping from the production source registry instead of a hardcoded literal.** The DISPATCH section previously hardcoded each source-to-DB line and still claimed `swift-org` / `swift-book` share a `swift-documentation.db`, stale since the #1038 per-source split (they build separate `swift-org.db` / `swift-book.db`). The help now derives both lists from `makeProductionSourceRegistry().allEnabled` (`definition.id` + `definition.displayName` + `destinationDB.filename`), the same registry-driven pattern `doctor` and `bundleRequiredDescriptors()` use, so adding a source extends the help with no edit here (Source Independence Day). Regression test `saveHelpDispatchMappingIsRegistryGenerated` pins the generated mapping. - **fix: refresh stale `--help` and `doctor` text for the v1.3.0 per-source split.** The root command summary, `setup` abstract, and `doctor` help discussion enumerated the pre-split `search.db` / `samples.db` / `packages.db`; they now describe the per-source databases generically (no hardcoded DB list to drift). The `search --sample-db` and `read --sample-db` flag help named `samples.db`; updated to the current `apple-sample-code.db`. `save --dry-run` help said it runs without touching "the on-disk search.db"; now "the on-disk databases". The `search --search-db` flag help no longer names a legacy monolithic `search.db` (described generically, no hardcoded DB list). The `doctor` runtime legacy `search.db` compatibility check is unchanged (it intentionally probes for a pre-v1.3.0 unified file). The `doctor --kind-coverage` / `--freshness` opt-in probes also no longer print a hardcoded `search.db not found` label (the real per-source database path is shown). The legacy `search.db` runtime health check is already gated on file existence (#1061/#1071), so a clean v1.3.0 install never surfaces it. Regression test `helpTextFreeOfLegacyUnifiedDBNames` guards the root / setup / doctor help against the legacy unified names. - **fix(#1168): `cupertino serve` reads the per-source `apple-documentation.db`, not the legacy empty `search.db`; adds `--base-dir`.** Post-#1036 the monolithic `search.db` is no longer built, but `serve` still resolved its primary search index to `paths.searchDatabase` (`<baseDir>/search.db`). On a per-source bundle that carries an empty `search.db` stub, serve opened the stub, so `search` / `list_frameworks` returned **0 results** despite fully-populated per-source DBs (verified against a bundle whose `apple-documentation.db` holds 354,963 rows incl. 8,679 swiftui docs). Serve now resolves the apple-docs primary index through the production source registry (the apple-docs provider's `destinationDB.filename`), not a hardcoded filename or the legacy `search.db` (Source Independence axiom; falls back to the legacy path only if the apple-docs provider is absent). Also adds a `--base-dir` option to `serve` so a host can aim the MCP server at any bundle without a `cupertino.config.json` beside the binary (per-source DBs resolved as siblings under it). Verified end-to-end through a live serve session: `serve --base-dir <per-source bundle>` returns real results (`NavigationStack` → `apple-docs://swiftui/navigationstack`; `list_frameworks` → 354,963 docs). #1071 family. - **fix(#1161): the logging docs name the right os.log subsystem + categories, and the one logger that diverged is fixed.** The README + `docs/design/cupertino.md` logging sections named the subsystem `com.cupertino`; the production subsystem is `com.cupertino.cli` (`Shared.Constants.Logging.subsystem`). The category list is corrected to the 10 declared production categories (`crawler, mcp, search, cli, transport, evolution, samples, package-downloader, archive, hig`): the stale `pdf` is dropped (no longer a category), `transport` is restored (still declared), and `packages` is shown under its historical os.log channel name `package-downloader` (the `Logging.Unified` remap). The MCP note is corrected: only the stdio transport's JSON-RPC wire traffic (`->` / `<-`) is written to stderr, while MCP server lifecycle and diagnostic messages are logged to os.log under the `mcp` category; the prior text wrongly claimed MCP lifecycle was stderr-only, contradicting the `mcp` category it listed. One code straggler is fixed to match the docs: `Sample.Index.Builder` logged to subsystem `com.cupertino` / category `SampleIndex`, invisible to the documented `com.cupertino.cli` predicate; it now uses `Shared.Constants.Logging.subsystem` and the `samples` category. - **fix(#1146): `cupertino save` is genuinely incremental again, skipping unchanged docs before AST extraction.** The `--clear` doc has long stated that a non-`--clear` run "only re-indexes documents whose hash differs," but the directory-scan indexer had no such guard: it walked every corpus file and re-ran AST symbol extraction (the slow step of a save) on all of them, only avoiding a full DB wipe. So a killed reindex restarted from zero and an all-unchanged re-save still took the full multi-hour pass. `Search.Index.indexStructuredDocument` now skips a doc whose `uri` is already in the DB with a matching `content_hash` BEFORE the AST extraction (via the new `Search.Index.existingContentHash(uri:)` query; skips counted in `incrementalSkips`). A `--clear` run wipes the DB first so nothing matches (full rebuild); a doc whose content changed has a different hash and is re-indexed. This makes the documented behavior real: an interrupted index resumes on the next non-`--clear` run, and an all-unchanged re-save is a near no-op. (The crawler's `metadata.json` resume was always for `cupertino fetch` / download, never the indexer.) New `Issue1146IncrementalSkipTests` (2): unchanged doc skipped, changed doc re-indexed. - **fix(#1132): framework synonyms now attach via an upsert instead of a silent UPDATE no-op.** `Search.Index.updateFrameworkSynonyms` was `UPDATE framework_aliases SET synonyms = ? WHERE identifier = ?`, which wrote nothing whenever the alias row had not already been inserted by `registerFrameworkAlias`. On a corpus where the alias table held only a handful of source-level rows, none of the 22 hand-curated synonyms (`corenfc`->`nfc`, `corebluetooth`->`bluetooth`, ...) ever attached, yet `Enrichment.SynonymsPass` reported `rowsAffected: 22` because it counted loop iterations, not writes. The method is now `INSERT ... ON CONFLICT(identifier) DO UPDATE SET synonyms = excluded.synonyms` (creating the row with `identifier` as the import/display-name fallback, preserving existing names on conflict) and returns the real `sqlite3_changes`; `SynonymsPass` sums those for an honest `rowsAffected`. The `Search.IndexWriter` protocol method gains `@discardableResult -> Int`; the `NoopIndexWriter` test double is updated. New `Issue1132SynonymsUpsertTests` (3 cases): new-row attach, existing-row update without clobbering names, honest row count. The acronym-at-ranking consumer (#818) and the disabled `Enrichment #11` battery synonym assertion both depend on this populating; full attach is confirmed on the next apple-docs re-enrichment. - **fix(scripts, #1139): `check-issue-body-staleness.sh` cross-ref and renamed checks no longer false-flag correct bodies.** Two more false-positive classes, found re-running the checker after #1138. (1) The cross-ref check (check 3) flagged any blocker phrase near a now-closed `#N` even when the body explicitly annotated it as resolved (`#161 CLOSED, a valid historical anchor`; `Since #239 landed`; `gated on #88 ... both shipped`), and its bare `blocks?` pattern matched the word "block" inside "code block" / "package_files block" / "blocker", harvesting unrelated refs. `BLOCKER_PHRASES` is tightened (no bare `blocks?`) and `check_xref` now skips a closed-ref flag when every blocker-phrase line mentioning the ref carries a resolution signal (closed / shipped / landed / superseded / post-# / historical / ...); a genuine stale blocker with no such signal still flags (regression-guarded). (2) Dropped a hardcoded `docs/tools/` rename rule that claimed the directory does not exist, though it does. Result: the cross-ref check reports zero findings and the renamed check drops the `docs/tools/` false-flags (#76 / #449 / #517) against the current tracker. Completes the checker-false-positive cleanup begun in #1137. - **fix(scripts, #1137): `check-issue-body-staleness.sh` no longer false-flags valid schema columns.** The schema-claims check (check 4) parsed only one routed file per table (`Search.Index.Schema.swift` for search tables), so columns added by `ALTER TABLE ... ADD COLUMN` (migrations) or listed only in `INSERT` statements were reported as "column not found", falsely flagging correct issue bodies (#70, #73, #223, #251, #517, #819, #1061). `schema_columns_for` now scans the whole routed source directory and unions three column sources: `CREATE TABLE` blocks, `ALTER TABLE ... ADD COLUMN` statements, and (newline-flattened) `INSERT INTO <table> (...)` column lists. Also repoints the samples route from the stale `SampleIndex` path to `SampleIndexSQLite`. Verified against the real per-source DBs: all 7 previously-false-flagged columns clear. It also skips a flag when every line mentioning a column carries an intentional-absence signal (a proposed new column, or a relocated reference the body already explains), so correct bodies no longer false-flag: #58 (proposed `source_subtype`) and #73 (the audit-corrected `docs_metadata.title` / `.abstract` mentions that now live on `docs_structured`). A genuine stale claim with no such signal still flags (regression-guarded). Extends the #886 cross-DB routing fix to the cross-file and context-aware cases within a DB. - **fix: #588 placeholder-error guard no longer drops real WebKitJS `.error` members.** `titleLooksLikePlaceholderError` skips pages titled `error` (Apple's failed-fetch JS shell), sparing genuine members only when the URL leaf is exactly `error`. But WebKitJS uses Apple's `<id>-error` slug (e.g. `webkitjs/filereader/1629843-error`), so the leaf check false-positived real `.error` properties (`FileReader.error`, `IDBRequest.error`, `HTMLMediaElement.error`, and ~8 more) out of `apple-documentation.db`. Verified: `documentation_webkitjs_filereader_1629843-error.json` is `kind=property`, `name=error`, with a real `declaration`. Fix: the guard now keeps any `error`-titled page that carries a real `declaration` (a genuine member) regardless of slug shape, and only skips declaration-less shells; the two callers pass the content signal (`structuredPage.declaration != nil`, and the metadata branch checks the raw JSON for a `declaration` key). New WebKitJS cases in `IndexBuilderTitleErrorDefenseTests` (24/24 green). The sibling #837 samples/packages constraints name-match has a related but distinct exposure (it mis-stamps ~21k same-named symbols); filed separately. - **fix: `cupertino-constraints-gen generate` refuses to write a degraded `apple-constraints.json` instead of silently emitting an empty table.** Pre-fix, pointing `--from-directory` at a missing or empty symbol-graph directory (for example an unpopulated `cupertino-symbolgraphs` checkout), or feeding only unparseable files, wrote a valid-but-empty (0-entry) table and exited 0, silently stripping Apple constraint enrichment from every consuming DB (apple-docs / samples / packages) with the loss visible only by inspecting the DB afterwards. Fix: the directory-scan path hard-fails when no `*.symbols.json` is found, and the extraction path hard-fails when 0 constraints are extracted; both exit 1 with remediation text naming `swift symbolgraph-extract` plus the re-run command, and nothing is written. New `docs/commands/constraints-gen/` documents the binary, its `generate` subcommand, the options, and the guard. (`cupertino-constraints-gen` is a separate maintainer binary, not a `cupertino` subcommand, so it is outside the `check-docs-commands-drift.sh` surface.) - **fix: MCP `resources/{list,read}` are fully DB-backed against the per-source DBs; the file-probing path is deleted (Principle 7).** Completes the resources DB-only work. The resources provider's content lookup was wired to the legacy monolithic `search.db`, which is not built post-#1036, so in production it resolved nil and the path survived only on a filesystem fallback. Rewired: `LiveMarkdownLookupStrategy` now reads via `Services.ReadService` against the per-source DBs (the same path the MCP search/read tools use, scheme-routed via `docsDBURLs` + the production source registry), and enumerates `resources/list` from the DBs via a new `Search.Index.listResourceEntries(mode:)` (`.frameworkRoots` collapses apple-docs' ~350k pages to its readable framework roots derived from the stored `source`/`framework` columns; `.allDocuments` lists the small docs corpora). Deleted the three filesystem `Search.URIResourceStrategy` concretes (apple-docs / apple-archive / swift-evolution), the `URIResourceEnvironment` (`sourceDirectory`/`CrawlMetadata`) seam, the `makeURIResourceStrategy()` provider requirement (replaced by a declarative `resourceListMode`), and the `DocsResourceProvider`/`serve` filesystem plumbing. The only `FileManager` call left in the path is a DB-file-presence guard so a read-only `resources/list` cannot side-effect empty per-source DBs into the base dir. resources/list now also includes the small docs sources (hig/swift-org/swift-book), an improvement over the prior apple-docs/evolution/archive-only list. Source + MCP + serve + pluggability suites green. - **fix: MCP `resources/read` resolves content from the DB only, with no filesystem fallback (Principle 7).** `MCP.Support.DocsResourceProvider.readResource` tried the injected DB `markdownLookup` first but then *fell back* to per-source `URIResourceStrategy` filesystem probing (`<corpus>/<framework>/<file>.json|.md`) when the DB lookup missed. A shipped user has only the DB (via `cupertino setup`), so that fallback could never fire for them and amounted to reading a document's content from a corpus folder at query time. Removed entirely: a URI that isn't in the DB is now `notFound`, full stop. Principle 7 in `docs/PRINCIPLES.md` is hardened to first-principle status: no query-time code path may touch the filesystem to resolve document content or to enumerate documents, not even as a fallback. (Follow-up in progress: `resources/list` DB-backed enumeration + removal of the now-unused file-probing strategies.) - **fix: HIG, apple-archive and swift-org/swift-book no longer derive a stored column from the corpus folder (Principle 7 sweep).** Following the apple-docs `framework` fix, the remaining sources are brought into line: HIG `category` now reads the `category:` key the crawler already stamps into each page's frontmatter (folder is fallback only); apple-archive's `Crawler.AppleArchive` now stamps the guide identifier (`bookJSON.uid`) into a `guide:` frontmatter key and `Search.Strategies.AppleArchive` reads `metadata["guide"]` instead of the folder; swift-org/swift-book's shared `crawlSwiftDocumentation` helper now sets the stored sub-source from the strategy's own scope identity (`.swiftOrgOnly`/`.swiftBookOnly`), with the legacy `.both` mode disambiguating from the document URL, and the folder read demoted to a clearly-labelled legacy input filter that never determines a stored value. In every case the on-disk layout is a last-resort fallback for legacy corpora predating the frontmatter key. 194/194 HIG+archive+swift+strategy tests green. - **fix: apple-docs `framework` column is now derived from the document's own URL, not the corpus folder layout.** `Search.Strategies.AppleDocs` read `framework` from the first path component under `--docs-dir` (`extractFrameworkFromPath`); this works only while `--docs-dir` points exactly at the parent of the framework folders. A save run with `--docs-dir` one level too high (the corpus repo root instead of its `docs/` subdir) made every row's `framework = "docs"` (351,495/354,963 rows on the 2026-05-28 snapshot), silently breaking framework aliasing and framework-scoped search. Fix: new `Search.StrategyHelpers.frameworkFromDocumentationURL(_:)` reads the path segment after `/documentation/` in the page's own URL (`…/documentation/swiftui/lazyvgrid` → `swiftui`); `Search.Strategies.AppleDocs` uses it as the authoritative source with the path-derived value kept only as a last-resort fallback for pages with no recognisable Apple doc URL. The truth was never lost (it is in every URI); a stored column had simply been made a function of an operator-supplied path. Codified as **Principle 7 (database is the single source of truth)** in `docs/PRINCIPLES.md`: no stored value may be derived from filesystem layout; if an attribute is needed it must be in the document/DB, not read from the folder. HIG `category`, apple-archive `guideID`, and swift-org/swift-book `framework` are on the same fault line and are tracked for the same treatment. - **fix(#1130): `cupertino search --source samples --min-ios N` (and `--min-macos` / `--min-tvos` / `--min-watchos` / `--min-visionos`) now actually filter; pre-fix they were silently dropped.** `CLIImpl.Command.Search.SourceRunners.runSampleSearch` built `Sample.Search.Query(text:framework:searchFiles:limit:)` without the 5 `min<Platform>` fields, so every value returned the same unfiltered set (`search swiftui --source samples --min-ios {1,14,18,99}` all returned 88 hits). The DB layer (`Sample.Index.Database.searchProjects`) and service layer (`Sample.Search.Service.search`) already supported the filter (#732); only the CLI samples call site was missing the wiring, which made the honest per-sample platform stamps from #1104/#1111 unreachable from the CLI. Fix: thread `minIos`/`minMacos`/`minTvos`/`minWatchos`/`minVisionos` into the Query. After fix, `search swiftui --source samples --min-ios N` varies with N (1→0, 14→33, 16→35, 18→40, 99→66 against the dev corpus). Found in runtime testing. New `Issue1130SampleSearchPlatformFilterTests` (4 cases) pin the Query→service→DB filter contract the CLI fix relies on. 2891/2891 tests green (one pre-existing Issue1073 parallel-test flake unrelated to this diff). - **fix(#1128): `cupertino doctor` no longer emits spurious `✗ search.db (not found)` + `⚠ search.db: not built` lines on post-#1036 per-source-DB installs.** Post per-source-DB-split each source writes its own `<base>/<source>.db` and the legacy monolithic `search.db` is intentionally never built, but the doctor's `.search` `SearchHealthCheck` (`isRequired: true`) hard-failed on the missing file and the section-8 schema-version loop led with a `.search` entry that always reported "not built". Fix: gate both the `.search` health-check and the section-8 schema-version entry on `FileManager.fileExists` — they only surface when a search.db actually exists on disk (a pre-#1036 bundle a user hasn't re-fetched). The per-source FTS health checks (`isRequired: false`) already carry the readiness signal post-split. Verified against a base dir with the 8 per-source DBs and no search.db: doctor emits zero search.db lines, all 8 per-source DBs report `✓ Schema version: 18`/4/5; a base dir that still has a legacy search.db keeps the full `.search` report. 2887/2887 tests green (20/20 doctor tests). - **fix(#1062): per-source `cupertino save` no longer prints the misleading "🛡️ Sidecar mode (#673 Phase G): writing to search.db.in-flight" line.** Post-#1036 per-source-DB-split saves write directly to `<base>/<destinationDB.filename>` (e.g. `hig.db`, `apple-archive.db`) and never touch `search.db` or the in-flight sidecar. The log line claimed otherwise, and the orphan-sidecar cleanup ran for nothing. Fix: gate the sidecar setup + log on `selectedSourceIDs?.isEmpty ?? true` so the sidecar mode only fires for the legacy no-selection / `--all` path. The post-success rename was already guarded by `FileManager.fileExists(atPath: sidecarPath.path)` (added 2026-05-27 as part of the original #1062 partial close), so the trailing `Error: "search.db.in-flight" couldn't be moved…` never landed in practice; this PR finishes the close-out by also suppressing the no-op pre-save log line. Verified: `cupertino save --source hig --clear --yes` no longer prints any "Sidecar mode" / "in-flight" text. 2879/2879 tests green. - **fix(#1116): swift-org rows no longer mislabelled `availability_source = "swift-book-chapter"`; per-page resolver supplies a source-specific tag.** Pre-fix `Search.StrategyHelpers.crawlSwiftDocumentation` hardcoded `overrideAvailabilitySource: "swift-book-chapter"` whenever a `PlatformVersionsResolver` was supplied. Pre-#1097 this was tautological (only swift-book used a resolver), but #1097 added `SwiftOrgPlatformResolver` so every swift-org row picked up the wrong tag too — 469/469 swift-org.db rows shipped on develop carrying `swift-book-chapter`. Fix: extend the `Search.PlatformVersionsResolver` protocol with `availabilitySource(for url: URL) -> String?` (default nil); each resolver supplies its own source-specific tag. `SwiftBookChapterVersionsResolver` returns `"swift-book-chapter"`. `SwiftOrgPlatformResolver` returns `"swift-org-linux-server"` for server-side content (matches the platform-NULL contract from #1097) and `"swift-org-universal"` for cross-platform Swift content (blogs, articles, install guides). The crawl helper threads the resolver's tag through; falls back to `"universal-swift"` when no resolver is in play, or `"per-page-resolver"` when a resolver is in play but its tag is nil. After reindex: `swift-org.db` shows 431 rows `swift-org-universal` + 38 rows `swift-org-linux-server` (was 469 rows `swift-book-chapter`); `swift-book.db` keeps 43 rows `swift-book-chapter` (unchanged); existing queries filtering `WHERE availability_source = 'swift-book-chapter'` no longer accidentally match swift-org rows. New `Issue1116AvailabilitySourceResolverTagTests` (4 cases) pin each resolver's tag + the default-extension nil. 2879/2879 tests green (one Issue1073 parallel-test flake unrelated to this diff; passes in isolation). - **fix(#1110): `cupertino fetch --source packages` HEAD-probes archive size before issuing the GET; oversize archives bail without wasted bytes-on-wire.** Pre-fix `Core.PackageIndexing.PackageArchiveExtractor.fetchAndExtract` always issued a full GET for every priority-package tarball, then checked `data.count > maxTarballBytes` AFTER the entire archive landed in memory. For `tuist/tuist` (270 MB compressed, well above the 75 MB ceiling) that meant 270 MB of network traffic per fetch run only to throw the bytes away; user-facing the stage-2 loop also appeared to hang for ~30-60 s with no log progress while URLSession buffered the body. Fix: new `probeTarballSize` HEAD probe runs before each GET; when the server exposes `Content-Length` and the value exceeds `maxTarballBytes`, the GET is skipped entirely and the loop throws `ExtractError.tarballTooLarge(size)` immediately. HEAD returning nil (codeload occasionally omits `Content-Length` on dynamic tarballs, transient errors) falls through to the GET, which keeps the existing post-download size check as a safety net so no oversize archive ever makes it to `extractToDisk`. **Trade-off**: the HEAD probe adds one round-trip (~100-200 ms to codeload) per archive on the happy path, so a clean `cupertino fetch --source packages` run pays ~30 s of added wall-time on the 184-package priority closure in exchange for avoiding the ~270 MB bandwidth and ~30-60 s download time of any oversize archive in the list. Net positive whenever the priority list contains at least one over-ceiling archive (currently `tuist/tuist`, slated to migrate to the `tools` source per #713). Streaming-GET-with-early-abort is the structurally better fix and is queued as a follow-up. New `Issue1110ArchiveSizeProbeTests` (5 cases) pin the HEAD-oversize-bail path (single-ref + multi-ref-with-404-on-first-ref), the HEAD-under-ceiling-then-GET path, the missing-Content-Length fallthrough, and the HEAD-network-error fallthrough via a `URLProtocol` stub on a throwaway `URLSession` (no real network). 2869/2869 tests green. - **fix(#1106): `PackageArchiveExtractor.runTar` no longer deadlocks on tarballs that emit large stderr/stdout.** Pre-fix `Core.PackageIndexing.PackageArchiveExtractor.runTar` assigned `Pipe()` to both `standardError` and `standardOutput` then called `Process.waitUntilExit()` without draining either pipe. macOS pipe buffers cap around 64 KB; once `tar -xzf` writes past that ceiling (overly-long paths, symlink-loop warnings, "file changed during extraction" diagnostics) the child blocks writing into the full pipe and the parent blocks in `waitUntilExit()`. Classic Foundation.Process deadlock — reproducible in `cupertino fetch --source packages` stage 2 after ~90 archives. Repro on Studio 2026-05-27: process parked in `CFRunLoop` / `__workq_kernreturn` with no TCP sockets and no fd activity (lldb backtrace), exact same package every time. Fix: install `readabilityHandler` on both pipes before `process.run()` so the kernel pipe buffers drain on private background queues for the lifetime of the child; pick up tail bytes after `waitUntilExit()` returns; route the accumulated stderr into the existing `ExtractError.tarFailed` only on non-zero exit. Buffers live behind a `@unchecked Sendable` `LockedBuffer` reference type (NSLock-guarded `Data` accumulator) so Swift 6 strict concurrency accepts the cross-queue capture. New `Issue1106PipeDeadlockTests` (2 cases) drive a child that emits 256 KB (4× pipe ceiling) on each of stderr and stdout using the same drain pattern; the assertions complete in 7 ms each. Without the drain the pre-fix code blocked indefinitely. - **fix(#1104): apple-sample-code projects gain per-framework platform availability fallback (was 1/634 → 605/634 stamped; 29 remain NULL for exotic long-tail frameworks).** Pre-fix the per-source `apple-sample-code.db` `projects` table carried `min_<platform>` columns populated only for projects that ship a `Package.swift` with explicit `platforms:` declarations. Apple's sample catalog ships 633/634 as Xcode projects (no Package.swift), so `Sample.Index.Builder.indexProject` parsed empty `deploymentTargets` and stamped all 5 platform columns NULL. `cupertino search --source samples --min-ios 16 "swiftui"` returned 0 regardless of content. Root cause sibling-class same as #1080 (apple-archive) — strategy code that aggregates per-row data fails when no row carries the source. Fix: new `SampleFrameworkAvailability` static table in `SampleIndexModels` covering 100+ frameworks in the samples corpus (Metal/UIKit/SwiftUI/AppKit/ARKit/RealityKit/PhotoKit/HealthKit/MapKit/CoreData/SwiftData/WidgetKit/PencilKit/AppIntents/CarPlay/HomeKit/CoreNFC/Network/CryptoKit/RoomPlan/Virtualization/MusicKit/Charts/WeatherKit/ShazamKit/TipKit/DriverKit/etc., each with per-platform introduction versions; AppKit/tvOS-only/watchOS-only/macOS-only frameworks correctly stamp the inapplicable platforms NULL). `Sample.Index.Builder.indexProject` + `indexProjectDirectory` (zip + directory paths) fall back to looking up the project's first framework when `Package.swift` parse returns empty; when the catalog has no entry, the project-id kebab-case prefix (`swiftui-adopting-drag-and-drop` → `swiftui`) is used as the framework probe. Lookup is exact-only — unknown frameworks deliberately stay NULL rather than stamping a universal-Apple baseline that would produce false-positive `--min-tvos`/`--min-watchos` matches for iOS-only frameworks (catches WidgetKit/PencilKit/CarPlay/HomeKit/CoreNFC/CreateML which would otherwise claim tvOS+watchOS support they don't have). Tagged `availabilitySource = "sample-framework-inferred"` to distinguish from `"sample-swift"` (Package.swift-derived) rows. After reindex: 508/634 `min_ios`, 492/634 `min_macos`, 409/634 `min_tvos`, 221/634 `min_watchos`, 526/634 `min_visionos`; AppKit-only samples correctly excluded by `--min-ios <N>`; ARKit/CarPlay/CoreNFC samples correctly excluded by `--min-macos`. New `Issue1104SampleFrameworkAvailabilityTests` (12 cases) pins the per-framework keep-sets, comma-joined-key handling, case-insensitive + whitespace-tolerant fallback, project-id-prefix synthesis, and the table-integrity invariant. 2838/2838 tests green. - **fix(#1099 + #1100): swift-evolution rows stamped with all 5 platforms; pre-Swift-5.9 proposals correctly get visionOS=1.0 deployment-target.** Pre-fix `Search.StrategyHelpers.mapSwiftVersionToAvailability` returned a `(iOS, macOS)` tuple — only 2 fields. SwiftEvolution strategy passed those two as overrides; tvOS/watchOS/visionOS landed NULL on every proposal. `--min-tvos 17 "concurrency"` returned 0 results regardless of content. Fix: expand the mapping to return `Search.PlatformVersions` (5-field struct from #1095). Per-Swift-version SDK numbers tracked from Apple's published Xcode-release-notes matrix (Swift 6.0 → iOS 18.0/macOS 15.0/tvOS 18.0/watchOS 11.0/visionOS 2.0; Swift 5.9 → iOS 17.0/macOS 14.0/tvOS 17.0/watchOS 10.0/visionOS 1.0; etc). Per the deployment-target semantic (#1100), pre-Swift-5.9 proposals stamp visionOS=1.0 too: visionOS 1.0 ships with Swift 5.9, which includes every feature from older Swift versions. After reindex: 457/487 rows have all 5 platforms populated (the 30 NULL rows are proposals without a "Status: Implemented (Swift X.Y)" tag); 457 have visionOS (was 149 — corrected to deployment-target semantic). `--min-tvos 17 "concurrency"` returns 20 proposals; `--min-visionos 1 "argument labels"` now returns SE-0001. 2826/2826 tests green. - **fix(#1093): swift-org and swift-book split into independent fetchable sources.** Pre-fix `Search.SwiftOrgSource.makeFetchStrategy` listed BOTH `https://www.swift.org/` and `https://docs.swift.org/swift-book/` in its `defaultAllowedPrefixes`, so `cupertino fetch --source swift-org` traversed both spaces in one combined pass (~600 pages, slow), and swift-book had no independent fetch leg (post-#1082 it was a view-source over swift-org's crawl via `corpusDirectoryAlias`). User-visible: refreshing either source required refreshing both; corpus dirs were nested (`swift-org/swift-book/`). Post-fix: new `DefaultOutputDirKey.swiftBook` + `SwiftBookSource.fetchInfo` seeded at `docs.swift.org/swift-book/` with `defaultOutputDirKey = .swiftBook` + own `makeFetchStrategy` returning a `WebCrawlFetchStrategy` whose `allowedPrefixes` cover only the book; `SwiftOrgSource.makeFetchStrategy` drops swift-book from its allowed-prefix set; `SwiftBookSource` drops its `corpusDirectoryAlias` override (the protocol property stays for future view-sources); `crawlSwiftDocumentation` scope filter updated to default the page-source to the strategy's own source when the path layout is flat (per-source dir). Corpus migration: `cupertino-docs/swift-org/swift-book/` → `cupertino-docs/swift-book/` (separate commit). After fetch: `cupertino fetch --source swift-book` crawls only the book (~87 pages, 7m 53s) and includes previously-orphan URLs (`summaryofthegrammar`, `revisionhistory`). After reindex: swift-book.db carries fresh Swift 6.3 content for all 43 topics, including the 2 prior 6.2.1 orphans. Issue1021 + Issue1082 tests reframed. 2826/2826 tests green. - **fix(#1090): apple-archive framework names are canonical (no more `"CoreGraphics, Quartz2D"` comma-joined values).** Pre-fix `Search.Strategies.AppleArchive` called `Search.StrategyHelpers.expandFrameworkSynonyms(baseFramework)` which joined the canonical name with synonyms via `", "` — `CoreGraphics` became `"CoreGraphics, Quartz2D"` and landed in `docs_metadata.framework`. The 37 affected rows (19 CoreGraphics + 18 QuartzCore guides) showed `Framework: CoreGraphics, Quartz2D` in search output. Quartz2D is the C-level drawing API inside CoreGraphics — not a separate framework. Synonym-search is the responsibility of the `framework_aliases.synonyms` table post-#1042, not the inline join. Fix: drop the `expandFrameworkSynonyms` call in apple-archive strategy; the framework field carries the canonical name directly from the .md frontmatter. `AppleArchiveFrameworkAvailability` already supports both canonical and joined forms, so per-platform lookups still resolve. After reindex: 0 rows have commas in `framework`. 2826/2826 tests green. - **fix(#1088): swift-org rows now carry the universal Swift platform baseline (was 0/469 → 469/469).** Pre-fix `Search.StrategyHelpers.crawlSwiftDocumentation` had an `isSwiftBook` branch that stamped platform overrides only for swift-book pages; swift-org rows landed with all `min_<platform>` columns NULL. Result: `cupertino search --source swift-org --min-ios 14 "concurrency"` returned 0 regardless of content (same shape as apple-archive pre-#1080). Fix: swift-org rows now receive the same universal Swift baseline as swift-book (iOS 8.0 / macOS 10.9 / tvOS 9.0 / watchOS 2.0 / visionOS 1.0 — Swift's introduction version per platform), tagged `overrideAvailabilitySource: "universal-swift"`. swift-org content (blog posts, getting-started, install guides, articles) is about Swift the language and applies to every platform Swift runs on. After reindex: 469/469 rows have all 5 platforms populated. 2826/2826 tests green. - **fix(#1086): swift-book serves the LATEST Swift version's content (was stale 6.2.1 post-#1084).** Pre-fix #1084 dropped all `https_*` URL-encoded cache snapshots from the index. That gave clean URIs but had a content-correctness regression: the canonical `.md`/`.json` pair was a never-refreshed older snapshot (Swift 6.2.1), and dropping the cache files meant the freshest content (Swift 6.3, in the latest cache snapshot) never reached the index. The fetch pipeline writes a new `https_..._<8hex>.json` per crawl but never overwrites the canonical files — so the canonical pair is permanently stale post-first-crawl. Fix: `findDocFiles` now groups cache files by their URL stem (extracted from filename via regex `^https_<host>_(.+)_<8hex>.json$`) and picks the most-recent mtime per stem. Canonical files are included only when no cache snapshot covers the same stem (the 4 canonical-only swift-book topics that have no cache form). After reindex: `swift-book.db` still has 43 rows (clean URIs preserved), but the content is now Swift 6.3 — `cupertino search --source swift-book "closure"` returns "The Swift Programming Language (6.3)" in the top result instead of "(6.2.1)". 2826/2826 tests green. - **fix(#1084): swift-book + swift-org URIs derive from Apple's canonical URL slug; URL-encoded cache snapshots dropped from index.** Pre-fix `swift-book.db` carried 121 rows for ~44 topics — each topic appeared 2-3 times: once for the canonical `.md`/`.json` pair (URI was the mangled URL-encoded filename `swift-book://swift-book_documentation_the-swift-programming-language_closures`) and once per Swift-version cache snapshot (URI even worse: `swift-book://https_docs.swift.org_swift-book_documentation_the-swift-programming-language_closures_a0dd189f`). Root cause: `Search.StrategyHelpers.crawlSwiftDocumentation` derived the URI from the on-disk filename, and `findDocFiles` accepted every `*.json`/`*.md` in the corpus including the `https_*_<hash>.json` cache snapshots. Existing tests (`CupertinoSearchTests`, `DocKindIntegrationTests`) already pinned the clean URI shape `swift-book://closures` — the production implementation was what was broken. Fix: (a) `findDocFiles` skips files whose basename starts with `https_` (those are crawl-version cache snapshots, not canonical content; the canonical `.md`/`.json` pair carries the same content with a clean filename); (b) URI is now derived from `structuredPage.url.lastPathComponent.lowercased()` — Apple's URL last-path-component is the canonical slug. After reindex: `swift-book.db` has 43 rows (one per topic), URIs are clean (`swift-book://closures`, `swift-book://aboutswift`, `swift-book://accesscontrol`). swift-org.db went 470 → 469 rows (its one `https_*` cache file is also correctly dropped). 2826/2826 tests green. - **fix(#1082): swift-book.db actually indexes content — view-source-directory routing via `Search.SourceProvider.corpusDirectoryAlias`.** Pre-fix `SwiftBookSource` combined `fetchInfo == nil` with `requiresCorpusDirectory: false` to route `SwiftBookStrategy` to a `/dev/null` placeholder. The strategy walked an empty directory and `swift-book.db` ended up empty after every save. Post-fix: new `Search.SourceProvider.corpusDirectoryAlias: String?` property — when non-nil, the provider is a view-source over the named parent. SwiftBookSource declares `corpusDirectoryAlias = "swift-org"`. The consumers route automatically: (a) `makeDocsIndexingDirectoryByKey` resolves aliased entries to the parent's URL (and inherits any `--swift-org-dir` CLI override — pre-fix the override didn't propagate, reintroducing the empty-DB bug whenever a user used the override); (b) `Save.Indexers` expands `--source swift-org` to include aliased providers, so `save --source swift-org` automatically rebuilds `swift-book.db` (leaving it stale after a fresh swift-org crawl would surprise the user); (c) `cupertino fetch --source all` + `cupertino doctor` continue to skip aliased providers (they read `fetchInfo != nil`, which is correctly false for view-sources — no double-fetch, no duplicate doctor inventory rows). The dispatch from each consumer to the new property is uniform: a future view-source is a 1-property override + zero changes to fetch/doctor/save call sites. After reindex: `swift-book.db` has 121 rows, all 5 platforms populated. 4-case `Issue1082CorpusDirectoryAliasTests` pins the default-inheritance + parent-override-inheritance + explicit-override-precedence + resolver-returns-real-URL invariants. Pluggability tests (Issue1021, Issue1029, Issue1042) reframed. 2826/2826 tests green. - **fix(#1080): apple-archive gains honest per-framework platform availability (static lookup for the 14 corpus frameworks).** Pre-fix every apple-archive row landed with all-NULL `min_<platform>` columns, so `cupertino search --source apple-archive --min-macos 10` returned zero results regardless of content. Root cause: `Search.Strategies.AppleArchive` queries `index.getFrameworkAvailability(framework:)` which SELECTs a sibling row from the same DB where `min_ios IS NOT NULL` — but no sibling ever has it populated for apple-archive.db (the legacy programming-guide corpus doesn't carry per-page availability metadata), so the lookup is self-referential and always returns `.empty`. Fix: new `AppleArchiveFrameworkAvailability` static table in `AppleArchiveSource` supplies known introduction versions for the 14 frameworks in the live corpus (Foundation, UIKit, Objective-C, CoreData, CoreGraphics, QuartzCore, Cocoa, CoreAudio, Security, CoreImage, AppKit, CoreFoundation, Performance, CoreText). The strategy now consults the static table first; per-DB lookup remains as the fallback. AppKit + Cocoa correctly stamp macOS-only (no iOS); UIKit gets iOS + macOS-Catalyst + tvOS + visionOS (no watchOS); the rest get all 5 platforms. After reindex: 368/368 rows have `min_macos`, 345/368 have `min_ios` (the 23 AppKit + Cocoa rows correctly don't). New `Issue1080AppleArchiveFrameworkAvailabilityTests` (11 cases) pins the per-framework keep-sets, comma-joined-key handling, case-insensitive + whitespace-tolerant fallback, and the 17-entry table boundary. 2821/2821 tests green. - **fix(#1078): HIG per-topic platform inference unified across crawler + strategy + SQL pass; markdown body line + frontmatter rewritten to match schema.** Pre-fix `Crawler.HIG.extractPlatforms(from html:)` substring-matched the whole lowercased HTML for platform tokens. Every HIG page's nav/footer mentions all 5 platforms, so every page reported all 5 platforms regardless of topic — the `.md` frontmatter (`platforms: ["iOS", "macOS", "watchOS", "visionOS", "tvOS"]`) and body line (`> **Platforms:** iOS, macOS, watchOS, visionOS, tvOS`) were lying. Compounding: `Search.Strategies.HIG` stamped every row with the universal earliest-of-platform baseline; the post-#1073 SQL pass remediated the schema post-hoc, but corpus content stayed stale. Post-fix: new foundation-tier `HIGPlatformRules` (URL substring → applicable platforms; baseline versions) is the single source of truth consumed by all three sites — Crawler (`inferPlatforms(forURL:)`), Strategy (`HIGPlatformRules.minimumVersions(for: uri)` passed through to `overrideMin<Platform>`), and SQL pass (iterates `HIGPlatformRules.rules`). Existing 173 HIG `.md` files in `cupertino-docs` rewritten to honest platforms (paired commit). New `Issue1078HIGPlatformRulesTests` (14 cases) pins the table shape + each rule's keep-set + cross-platform fallthrough + minimum-versions tuple. 2796/2796 tests green. - **fix(#1076): HIG crawler derives filename from URL slug, not HTML title; duplicate `*-appledeveloperdocumentation.md` corpus files removed.** Pre-fix `Crawler.HIG.crawlPage` set the output filename to `sanitizeFilename(page.title) + ".md"`. The title-cleaner stripped `" | Apple Developer Documentation"` (with spaces) at line 253, but the dehyphenated variant `"|AppleDeveloperDocumentation"` (no spaces, no separator) leaked through. Apple's site returned both variants for the same URL across consecutive fetches, so every HIG topic landed twice on disk (`buttons.md` + `buttons-appledeveloperdocumentation.md`) and reached `hig.db` as two rows with the same `url:` frontmatter but different content_hashes. The fix is structural: derive the filename from `url.lastPathComponent` (Apple's canonical HIG slug — `developer.apple.com/design/human-interface-guidelines/buttons` → `buttons`); the title is no longer in the filename derivation path at all. New `Crawler.HIG.canonicalFilename(for:)` static helper + 6-case `Issue1076HIGFilenameDerivationTests` pin the canonicalization (slug lowercased, trailing slash + `.html` stripped, same URL ↔ same filename invariant). Corpus cleanup: 173 `*-appledeveloperdocumentation.md` files removed from `cupertino-docs/hig/`; `hig.db` row count drops from 346 → 173 (one row per topic, as it should always have been). The post-#1073 dehyphenated parallel patterns in `applyHIGPlatformInference` are removed (no longer needed; canonical slugs cover everything). 2796/2796 tests green. - **fix: `cupertino setup` fetches `apple-constraints.json` sidecar alongside the database bundle.** Pre-fix `cupertino setup` downloaded only the per-version `cupertino-databases-v<X>.zip` (search.db + samples.db + packages.db); the `apple-constraints.json` enrichment table was not part of the bundle and not fetched separately. Every fresh dev install ran `cupertino save` with iter-1+2 enrichment only (~16% constraint coverage on `doc_symbols.generic_constraints`) instead of iter-3 (~38%) that requires the constraints sidecar. Caught 2026-05-27 after a 9.5-hour Claw mini reindex finished without the file and had to be killed + restarted. Post-fix `Distribution.SetupService.run` calls `downloadConstraintsSidecar` after the main DB extract, pulling `apple-constraints.json` from `https://raw.githubusercontent.com/mihaelamj/cupertino-docs/main/apple-constraints.json` (committed to that repo 2026-05-27 commit 0860213a). Failure is non-fatal: `Event.constraintsDownloadSkipped(reason:)` surfaces a warning instructing the user to either re-run `cupertino setup` or run `cupertino-constraints-gen` locally; the rest of setup completes normally. 2774/2774 tests green. - **fix: `cupertino fetch --source packages` skips the star-count popularity sort when no `GITHUB_TOKEN` is set.** Pre-fix the metadata stage made one rate-limited `api.github.com/repos/<owner>/<repo>` call per package to fetch a `stargazers_count` value, then sorted regular packages by stars before downloading their archives. The sort is pure optimization (download CONTENT is identical with or without it). Without a token GitHub caps at 60 requests/hour, so 10,989 packages would need 183+ hours of throttled API calls before any actual archive download could start. Post-fix the loop checks `ProcessInfo.processInfo.environment["GITHUB_TOKEN"]` at entry and short-circuits to the unsorted `priorityURLs + regularURLs` order when no token is set, logging `⏭ No GITHUB_TOKEN set—skipping star-count popularity sort`. Users with API headroom can still get the sort by exporting `GITHUB_TOKEN` before running fetch. Detected 2026-05-27 during a parallel-fetch session on Mac Studio: the user was 90 minutes into a fetch still stuck in star-count throttling at 9300/10989, no archives yet started. 2774/2774 tests green (pre-existing flaky `fetchSwiftEvolution` integration test failure also on develop, unrelated). - **fix: `cupertino fetch --source apple-sample-code` runs the Apple-CDN downloader again (re-instates resilience after #1007's premature retirement).** The #1007 refactor canonicalized `apple-sample-code` to `samples` and routed both ids through `Sample.Core.GitHubFetcher` (which clones `https://github.com/mihaelamj/cupertino-sample-code.git`), retiring the Apple-CDN path. Rationale at the time: "bundled corpus + GitHub catalog cover the same content". That's a resilience regression — the GitHub repo is a convenience shortcut owned by the maintainer; if it disappears (account issue, accidental delete, repo rename), users can't fetch samples at all. Apple's CDN is source-of-truth. Restored: `--source apple-sample-code` now invokes `Sample.Core.Downloader` (the WebKit-backed Apple-side crawler that's been in the codebase the whole time, just unwired from CLI dispatch). `--source samples` keeps the fast GitHub clone path for everyday use; `apple-sample-code` is the slow-but-resilient fallback. 2774/2774 unchanged tests green (pre-existing flaky `fetchSwiftEvolution` integration test failure is on develop too, unrelated). - **fix(#1062): per-source save no longer errors on missing sidecar atomic-rename.** Pre-fix `cupertino save --source <X>` succeeded end-to-end but emitted a trailing `Error: "search.db.in-flight" couldn't be moved` after the success banner. Post-#1036 per-source DB split, `LiveDocsIndexingRunner` writes directly to `<base>/<destinationDB.filename>` (line 381 in Save.Indexers.swift); the sidecar URL passed in `Indexer.DocsService.Request.searchDB` is never written to, so the post-success atomic-rename of `<base>/search.db.in-flight` → `<base>/search.db` fails with `NSCocoaErrorDomain` "former doesn't exist". Quick fix: gate the rename on sidecar existence — if the runner never wrote a sidecar (per-source case), skip the rename silently. Exit code goes back to 0, the DB is correct (verified: `apple-archive.db` and `hig.db` both fully populated + queryable). The deeper architectural fix (per-destination-DB sidecars so per-source saves also get #673 Phase G crash-protection) stays tracked at #1062. - **fix(#1059): `cupertino save --source <X>` no longer spams `ℹ️ <Y> directory not found at …` info lines for unrelated docs-tier sources.** Pre-fix `Indexer.DocsService.run` unconditionally probed all 4 optional docs-tier corpus directories (swift-evolution, swift-org, apple-archive, hig) and emitted a `missingOptionalSource` event for each that wasn't on disk — even when the user had explicitly scoped the save to a single source via `--source`. A user reindexing only `apple-docs` got 4 spurious info lines for sources they hadn't fetched. Fix: new `Indexer.DocsService.Request.selectedSourceIDs: Set<String>?` field threads the selection through; `run` skips the probe for any source-id outside the selection. `nil` selection (legacy callers + `--all`) keeps the original full-fan-out behaviour. Spotted during the 2026-05-27 apple-docs reindex on Claw mini. New `Issue1059OptionalDirScopeTests` (3 assertions) pins the contract: nil → 4 probes, single-source → 0 unrelated probes, narrow-selection → only the selected source probed. 2774/2774 tests green. - **#903 cold-build dep declaration fix + FORBIDDEN_MODULES symmetry.** CrawlerModels target dependencies missing `CoreProtocols` (incremental builds masked it; cold `swift build` from fresh `.build/` failed). Added. `CrawlerWebKit` added to `check-package-purity.sh` FORBIDDEN_MODULES + the audit-invariant test`mustContain` array so future consumers cannot import the concrete sibling directly (symmetry with SearchSQLite / SampleIndexSQLite). Cold-build verified green; 99 CrawlerTests + 3 audit-invariant tests pass. - **#903 contract-doc + sibling-string consistency.** Refreshed counts post-#903 (47 producers, 13 Models-tier + 34 feature producers; corrects pre-existing breakdown drift). Deleted dead Crawler.AppleDocs.Error.unsupportedPlatform case (parallel of the webKitNotAvailable case already removed from HIG). Class-level docstrings on Crawler.AppleDocs and Crawler.HIG rewritten to name the injected fetcher factory rather than the WebKit implementation detail. Refreshed `docs/package-import-contract.md` header + closing-summary producer count from 46 to 47 (CrawlerWebKit added). One em-dash in the moved `Crawler.WebKit.Engine.swift` docstring replaced with a period. `Crawler.AppleDocs.swift` runtime log + error description rewritten to fetcher-agnostic wording (mirrors the HIG renames already in this PR). - **#903 follow-up cleanup: regression fixes in Crawler tests, docstrings, and CrawlerWebKit composition.** The original #903 commit substituted `Crawler.NoopHTTPFetcherFactory` into every test callsite of `Crawler.AppleDocs` / `Crawler.HIG`. Eight of those callsites genuinely needed the network-touching live fetcher (`fetchSinglePage` + `fetchWithResume` in `CrawlTests`, `buildSearchIndex` + `searchWithFrameworkFilter` in `SaveTests`, `completeMCPWorkflow` in `ServeTests`, `downloadRealAppleDocPage` in `Crawler.AppleDocs.IntegrationTests`, the max-pages + output-dir tests in `CrawlerTests`, and the `httpErrorPageDefersAndRetriesSuccessfully` deferral test in `RetryQueueTests`); all now use `Crawler.WebKit.LiveHTTPFetcherFactory`. The retries-exhausted test in `RetryQueueTests` switched to the new `Crawler.CannedHTMLFetcherFactory` which returns canned HTML so `AlwaysHTTPErrorHTMLParserStrategy` can flag it without a real network call. Six em-dashes added in source and docstrings replaced with periods or restructured sentences. The `#if canImport(WebKit)` wraps on HIG private helpers and `logInfo` dropped; orphan `Error.webKitNotAvailable` enum case deleted; tombstone `Crawler/Crawler.Engine.swift` deleted (typealias lives in `CrawlerModels` now). Docstrings refreshed in `CrawlerModels/Crawler.swift` (outer + inner anchor), `Core.Protocols.ContentFetcher.swift`, `Crawler.HTTPFetcherFactory.swift`, and four sites in `Crawler.AppleDocs.swift` + one log string + two log strings in `Crawler.HIG.swift`. Test-target deps widened so `CrawlerWebKit` is visible (CrawlerTests / FetchTests / SaveTests / ServeTests). `Crawler.AppleDocs.webPageFetcher` converted from IUO to plain non-optional `let` initialized before `super.init()` via the factory parameter. Eleven test callsites had stray blank lines between `priorityPackageStrategy:` and `fetcherFactory:` stripped. `docs/research/mcp-diagnostic-block-prior-art.md` reverted (accidentally swept in via `git add -A`; unrelated to #903). All 99 `CrawlerTests` pass. Build + audits + invariant tests green. - **Critic-pass cleanup of #906 rebase artefacts: stale counts + prose + one duplicate contract row.** Five-PR rebase chain (#988-#992) left stale numeric/prose claims behind in three places. Findings from the post-#906 code-review pass: - `Issue919AuditInvariantTests.swift`: `@Test` display name said "41 entries (post-#906 sub-PR B AppleConstraintsPass extract)"; assertion checked 46. Display name now matches: "46 entries (post-#906 sub-PR G SynonymsPass extract closes the epic)". Otherwise a future regression dropping a producer 46→45 would surface with a misleading test-title story. - `docs/package-import-contract.md`: duplicate `AppleDocsStrategy` row (lines 102 + 116) collapsed into a single row at the strategy-grouped position; the better "pattern-setter for remaining 5 strategy splits" prose merged into it. - `docs/package-import-contract.md`: closing summary updated from "34 producers strict / 13 seam + 21 feature" to "46 producers strict / 14 seam + 32 feature" with the #899 (6 strategy siblings) + #906 (6 enrichment-pass siblings) sources called out; header `Last refresh:` bumped to 2026-05-23 with the post-#906 numbers, prior 33-producers blurb demoted to "Previous refresh". - `scripts/check-target-foundation-only.sh`: comment-block above `GRANDFATHERED_TARGETS=()` claimed Enrichment was the one producer outside STRICT_PRODUCERS and its leak tolerance lived in `check-package-purity.sh`'s grandfather array. Both false post-#906 (Enrichment is in STRICT_PRODUCERS via the protocol-rewire; every pass is its own foundation-tier sibling). Rewritten to reflect end-state. - `scripts/check-package-purity.sh`: `FORBIDDEN_MODULES` array dropped the long-deleted `SearchStrategies` target (deleted during #899 sub-PR G). The line was harmless (no Swift file imports a non-existent module) but polluted failure messages with stale taxonomy. Build green; foundation-only / package-purity / import-contract audits all green (46 producers, 57 production targets, 62 contract rows); `Issue919AuditInvariantTests` (3 tests) pass. - **Phase 2 query battery MCP timeout bumped 15s -> 60s for CI cold-start headroom.** The smoke job (`--smoke --strict` on the first Phase 2 fixture) consistently failed on macos-15 runners with `rank=None`. Diagnosis: cold-start of `cupertino serve` has to open the 2.7GB search.db (just unzipped by `cupertino setup`) before the first JSON-RPC `tools/call` returns; the 15s subprocess timeout was tight enough that the cold mmap walk overran it. Locally the OS page cache from the prior `cupertino setup` keeps the DB warm and calls return in <1s. Bumping the default `_mcp_call` timeout to 60s gives the CI worker headroom without affecting normal-path latency. Refs: surfaced by #961 (#948 phase 1 PR's CI run). - **#956: Phase 2 query battery smoke now starts with NSObject (corpus-stable) instead of SwiftUI View (corpus-dependent).** The `--smoke --strict` job (added by #949) runs only the first fixture. Pre-#956 the first fixture was `search_symbols struct View`, whose top-10 includes results only when the bundle indexes a SwiftUI struct named with `View` substring. This passed against the local Studio dev DB (full corpus, 2.8GB) but failed against the v1.2.0 release bundle that CI downloads via `cupertino setup`. Every CI smoke run since #951 merged reported `[smoke] 1/1 search_symbols struct View rank=None`. Post-#956 the first fixture is `search_symbols class NSObject`: NSObject is the root of the Objective-C runtime and is indexed under `kind=class` with that exact name in every Apple-release bundle. The View fixture is kept in the full Phase 2 battery (no coverage loss for the `kind=struct` filter); only the smoke ordering changes. Refs: closes #956. - **#952: `search_property_wrappers` now ranks canonical-usage-framework rows above same-attribute rows in unrelated frameworks.** Pre-#952 `searchPropertyWrappers(wrapper: "State")` against the v1.2.x bundle returned `activeSession | secureelementcredential | @State` at rank-1, ahead of the 537 SwiftUI `@State` usages, because the shared `signalRankOrderClause` tertiary tie-break was alphabetic `s.name` and "activeSession" sorted before "adjustBy" / "alarms" / etc. New `propertyWrapperCanonicalFrameworks` lookup at `Packages/Sources/SearchSQLite/Search.Index.SemanticSearch.swift` maps 16 Apple-defined wrappers to their canonical USAGE frameworks (iter-2 critic finding: the table must target where attribute USAGE lives, not where the wrapper is DECLARED, because most users want examples of `@State var foo` patterns, not the @State struct docs page). Examples: `@State`→swiftui, `@Observable`→swiftui (declared in observation but 11/13 usages in swiftui), `@MainActor`→[uikit, swiftui, appkit, realitykit] (declared in swift std-lib but 988/915/753/713 usages in the four UI umbrellas; declaration-only `swift` framework has 1 sample row), `@Published`→swiftui (declared in combine but predominantly used by SwiftUI ViewModels), `@Model`/`@Query`→swiftui (declared in swiftdata but predominantly used in SwiftUI sample-code apps). `searchPropertyWrappers` SQL prepends a tier-0 boost: `CASE WHEN LOWER(m.framework) IN (canonical-list) THEN 0 ELSE 1 END` ahead of the operator-demote + kind-shape tiers. Wrappers not in the lookup fall through to the original ranking, so the change is additive (no regression for unknown wrappers). Same change also fixes the LIKE-prefix precision bug: pre-#952 `s.attributes LIKE '%@State%'` matched both `@State` AND `@StateObject` rows; post-#952 uses `(',' || s.attributes || ',') LIKE '%,@State,%'` which matches the bounded `@State` token only (21 false-positive `@StateObject` rows in v1.2.x bundle now correctly excluded from a `@State` filter). Also fixed the Phase 2 query battery's score function: pre-fix it only inspected `### `, `_Framework:`, `- **`, `URI:` lines, excluding the indented ` - Attributes: @State` lines, so the `expect_any: ["@State"]` matcher silently rank=None'd correct results whose URI carried no "state" substring (e.g. `apple-docs://swiftui/animation/delay(_:)`). Post-fix the matcher includes `- Attributes:`, `- Conforms to:`, `- Generic params:` indented sub-lines (iter-1 critic fix: the original prefixes `- Conformances:` / `- Generic constraints:` were wrong, the actual MCP emitter at `CompositeToolProvider.swift:1830-1836` writes `- Conforms to:` and `- Generic params:`). Iter-2 critic fix: original lookup was wired to declaration frameworks (`@MainActor`→swift, `@Observable`→observation, `@Published`→combine) which would have surfaced 1 sample-code class above 3,369 usage rows for `@MainActor`. Reworked against actual usage-frequency data; dropped 14 wrappers (`@Environment`, `@SceneStorage`, `@ScaledMetric`, `@FetchRequest`, `@SectionedFetchRequest`, `@ObservationIgnored`, `@ObservationTracked`, `@Sendable`, `@Attribute`, `@Relationship`, the four `@Focused*` wrappers) whose attribute column has zero rows corpus-wide so the boost could never fire. Final table: 16 entries, all empirically grounded against the v1.2.x bundle. Also hoisted the splice-prefix-invariant assert to a one-shot file-scope `Void`-typed constant per critic finding (was running per-call). **Phase 2 P@1 improves from 0.6000 to 0.8000** on BOTH arms (the harness fix surfaces correctness that was always present in the v1.2.0 binary's top-10 ; the #952 ranker fix improves WHICH result is at rank-1 within that top-10, which the current Phase 2 fixtures don't measure precisely). User-visible improvement verified via MCP probe: `search_property_wrappers wrapper=State` rank-1 went from `activeSession | secureelementcredential` to `delay(_:) | adjustBy | swiftui`. New regression coverage at `Packages/Tests/SearchTests/Issue952PropertyWrapperRankingTests.swift` (5 tests: alphabetic-loss reproducer, @StateObject precision, @Observable usage-density boost, @MainActor 4-framework boost-set coverage, unknown-wrapper fall-through). 4 unrelated Phase 2 fixtures (search_conformances Codable/Sendable/Hashable, search_generics Sendable) still fail on both arms and have been filed separately as #958 (different tools, structurally similar bug surface). Refs: closes #952. - **#953: `cupertino inheritance` + `cupertino read` negative paths now emit user-visible diagnostics to stderr.** Pre-#953 these commands exited 1 with no stdout AND no stderr output for several negative-path cases: ambiguous symbol on `inheritance Color` / `inheritance Int`, missing framework on `inheritance UIButton --framework nope`, document-not-found / sample-not-found / package-not-found on `read <absent-uri>`. The bug: every error path went through `Cupertino.Context.composition.logging.recording.error(msg)` which only logs at `.error` level to the configured `Logging.Recording` sink (OSLog in production). Terminal users saw blank failures; MCP-style agents could not distinguish "tool failed" from "tool succeeded empty." New helper at `Packages/Sources/CLI/CLIImpl.UserFacingDiagnostic.swift` writes a one-line message to `FileHandle.standardError` AND records the same message via `recording.error(...)`. `CLIImpl.Command.Inheritance.swift` + `CLIImpl.Command.Read.swift` migrated to the helper. **Phase 3 baseline improves from MRR 0.8636 to MRR 1.0000** (22/22 fixtures passing; the 3 value-type negative probes now see the documented marker on stderr). **Phase 4 baseline improves from MRR 0.9231 to MRR 1.0000** (13/13; the `read absent URI` fixture sees "Document not found in search.db: ..." on stderr). Phase 3 + Phase 4 score functions updated to check both stdout AND stderr per the new Unix-convention diagnostic shape. Iter-2 critic loop on PR #955 surfaced a real regression in the iter-1 phrasing: the `backendFailed` + `packagesIdentifierInvalid` catch arms had been re-phrased to start with "Document not found:" to satisfy the Phase 4 marker scanner, which would misdirect user remediation when the actual cause is a transient backend / disk error rather than a missing document. Iter-2 fix reverts the user-visible copy to honest per-error phrasing ("Read failed: \<msg\>", "Invalid package identifier \`\<id\>\`. Expected shape: \`\<owner\>/\<repo\>/\<relpath\>\`") and extends the Phase 4 marker list in `scripts/eval/search-quality-phase4.py` to 7 substrings (`not found`, `no such`, `no document`, `project not found`, `no project named`, `read failed`, `invalid package identifier`), one per `Services.ReadService.ReadError` case. Iter-3 fix synchronises the Phase 4 audit MD's method paragraph with the harness's full marker list (was previously listing only the original 5). Migration to the helper extended to `CLIImpl.Command.ReadSample.swift` + `CLIImpl.Command.ReadSampleFile.swift`. Refs: closes #953. ### Docs - **docs: drop the `--search-db` documentation now that the flag is removed.** Deleted the 7 orphaned `search-db.md` option docs (`save`, `search`, `read`, `list-frameworks`, `doctor`, `inheritance`, and `save --remote`) and scrubbed every `--search-db` reference from the command READMEs, examples, and option tables, plus `docs/symbolgraph-corpus.md`, the `docs/artifacts/` references, and two stale `search.db` references in the root `README.md` (the build-output size and the eval-harness reproducibility note). The structural drift checker is green again. Dated audit/handoff snapshots that mention the historical flag are left as-is. - **docs: align the architecture + artifact reference docs with the v1.3.0 per-source database split.** `docs/architecture/database.md` §2.2 now describes the eight per-source databases (the six documentation DBs sharing one schema, plus `apple-sample-code.db` and `packages.db`) instead of a single 2.87 GB `search.db`, with rollback / read-only framing; the schema, BM25F, and ranking sections are unchanged because they apply identically per-DB. `docs/artifacts/folders/` is restructured to one doc per shipped database (new `apple-documentation.db.md` / `hig.db.md` / `apple-archive.db.md` / `swift-evolution.db.md` / `swift-org.db.md` / `swift-book.db.md`, `samples.db.md` renamed to `apple-sample-code.db.md`, legacy `search.db.md` removed, artifacts index updated). `docs/ARCHITECTURE.md`, `docs/fun-facts.md`, `docs/sources/HOW-TO-ADD-A-SOURCE.md`, and `packages.db.md` updated for the per-source filenames + rollback mode. Command-reference docs (`docs/commands/`) and historical docs (audits / release notes / design) were intentionally left unchanged. - **README architecture-count sweep.** Refreshed the line under `## Architecture` from the pre-#906 "~40 single-responsibility SPM targets across 38 source packages" to the post-#906 "46 strict-producer SPM targets across 58 source packages". Counts derived from `STRICT_PRODUCERS` in `scripts/check-target-foundation-only.sh` (the foundation-only opt-in list, the load-bearing definition for the strict-producer total) + a `ls Packages/Sources` count of declared sibling targets. Sweep batched at the natural merge boundary closing #906 (B-G all landed); per per-PR feedback the README is the second half of the CHANGELOG-pair and gets refreshed when drift is observed, not on every commit. ## v1.2.1 (2026-05-23) Maintenance release. Architectural cleanup + DI / pluggability lift. Zero schema delta, zero indexer-logic delta vs v1.2.0; the v1.2.0 bundle works as-is with the v1.2.1 binary (`cupertino setup` continues to download `cupertino-databases-v1.2.0.zip`). Spot-checked parity vs the brew-installed v1.2.0 binary on `doctor`, `search "SwiftUI View modifier"`, `search "async await"`, `list-frameworks`, `package-search "alamofire"`: byte-identical search ranking and row counts in every test. The headline structural moves: - **Source Independence Day reached** ([#919](https://github.com/mihaelamj/cupertino/issues/919) closed). Adding a new content source is now a composition-root-only PR: 1 new package carrying strategy + indexer + descriptor, plus 3 list-append edits at `CLIImpl.makeProductionSourceLookup()` + `CLIImpl.Command.Save.Indexers.swift`. Zero edits to existing source concretes, zero edits to any static registry, zero closed enums. Empirically verified by PR [#941](https://github.com/mihaelamj/cupertino/pull/941)'s end-to-end test fixture, which plugs in a fake `wwdc-transcripts` source via 247 lines in a single new test file (Sources/ diff is empty). - **Strict-DI + standalone-portability** ([#893](https://github.com/mihaelamj/cupertino/issues/893)). Every Search-side producer (Search, SearchSQLite, SampleIndex, SampleIndexSQLite, Enrichment, SearchStrategies) opted into `STRICT_PRODUCERS` against `scripts/check-target-foundation-only.sh`. The `GRANDFATHERED_TARGETS` array in `scripts/check-package-purity.sh` is now `()`. New `portability` CI job on macos-15 mechanically lifts each producer out of the monorepo and rebuilds against its declared deps + transitive closure. - **Doctor command refactor**. Three sibling per-DB sections (`checkSearchDatabase` / `checkSamplesDatabase` / `checkPackagesDatabase`) lifted into `Distribution.DatabaseHealthCheck` conformers. Adding a 4th DB is one conformer + one list append. - **CI infrastructure**: external-PR-to-main guard ([#761](https://github.com/mihaelamj/cupertino/issues/761)); portability check job; CHANGELOG-touched verification; package import audits. Per-PR detail follows. ### Added - **#935 Step 4 of Source Independence Day: end-to-end pluggability proof landed; the 2-file PR claim is empirically verified.** Final step on the #919 critical path. Adds `Packages/Tests/CLITests/Issue935EndToEndPluggabilityTests.swift`: a 247-line test file that constructs a fake `wwdc-transcripts` content source from scratch (descriptor + strategy + indexer, all inline), wires it through the production Search pipeline at a test composition root, indexes a 3-document fixture corpus into a tmp `search.db`, and asserts the fixture documents are searchable via `Search.Index.search`. **`git diff develop --stat Packages/Sources/` shows zero lines changed**; the only change in this PR is the new test file. The composition root happens in the test (the `Search.SourceLookup` is constructed with `[Search.SourceDefinition.fakeWWDC]` inline, the `Search.IndexBuilder` is constructed with `strategies: [FakeWWDCStrategy()]`, the `Search.Index` is constructed with `indexers: ["wwdc-transcripts": FakeWWDCIndexer()]`), proving every seam the #932 / #933 / #934 arc lifted to the composition root accepts a new source without any production edit. **Three assertions land:** (1) end-to-end search retrieval, all 3 fixture documents reachable by their content terms; (2) `productionSourcesAreUntouched`, the CLI's `CLIImpl.makeProductionSourceLookup()` continues to return exactly the 8 historical sources, the fake does NOT leak in; (3) doc-anchor test naming the empirical diff-stat invariant that PR reviewers verify. **Tests:** 2477 / 368 green (+3 / +1 vs develop's 2474 / 367 after #934 Step 3b's net deletion of 13 tests / 2 suites). Audits + portability all exit 0. **Independence Day status:** all 4 critical-path steps merged (#932 IndexerRegistry composition-root injection, #933 makeDefaultStrategies dissolved, #934 Step 3a + 3b SourceRegistry dissolved + Source / QueryIntent extensions deleted, #935 end-to-end TDD). The "2-file PR adds a new source" claim from #919 holds in the empirical case: a fake WWDC source plugs in via one composition-root assembly site. Adding a real WWDC source (#58) reduces to: 1 new package carrying the strategy + indexer + descriptor, plus 3 list-append edits at the production composition root in `CLIImpl.makeProductionSourceLookup()` + `CLIImpl.Command.Save.Indexers.swift`. Refs: #935 Step 4 of #919; plan `docs/plans/2026-05-22-source-independence-day.md`; #919 closes when this PR merges. - **#934 Step 3b: `Search.SourceRegistry` enum dissolved; convenience extensions on `Search.Source` + `Search.QueryIntent` deleted; deprecated `SourcePropertiesRegistry` deleted.** Cleanup half of Step 3 on Source Independence Day. The static surface that survived Step 3a (#934 first half) is now gone. Per `gof-di-rules.md` Rule 1 there is zero process-wide `Search.SourceRegistry.*` accessor left on the `Search` namespace; the only way to look up a `SourceDefinition` is through a `Search.SourceLookup` instance the composition root assembles. **Deleted surfaces:** the `enum SourceRegistry` in `SearchModels/Search.SourceDefinition.swift` (carried the 8-row static `all: [SourceDefinition]` array + 7 static lookup methods); the `Search.Source` convenience extensions (`displayName`, `emoji`, `isRegistered`, `definition`, `properties`, `intents`); the `Search.QueryIntent` extensions (`boostedSources`, `boostMultiplier`, `boostedSourceIDs`, `registryBoostedSources`); the deprecated `SourcePropertiesRegistry` enum in `Search.DomainTypes.swift`; the dead-code `Search.TipFactory.tryOtherSourceTip(current:suggested:)` helper that read `suggested.displayName` from the static (zero call sites in the production tree). **Tests reorganised:** the 3 SearchModelsTests files that pinned the dead surface (`Issue919DisplayNameRegressionTests`, `Issue919SourceRegistryCountTests`, `Issue919SourceAliasCoverageTests`) are DELETED; the production display-name + emoji + count + duplicate-id + SourcePrefix invariants are RE-PINNED in a new `Issue919DisplayNameProductionTests` suite in CLITests that asserts against `CLIImpl.makeProductionSourceLookup()` (the composition root). `Issue251SourceStructTests` rewired: the 2 tests that asserted on `Source.displayName` / `Source.isRegistered` globally are dropped; the new test routes the same pinning through `Search.SourceLookup.empty.{displayName, emoji, isRegistered}(for:)` so the lookup-API contract stays pinned. **Net test count:** 2474 / 367 (-13 / -2 vs develop's 2487 / 369); coverage of user-visible labels is preserved (now in CLITests, against the composition root). **Audit invariants:** foundation-only / purity / portability-on-SearchModels / portability-on-SearchSQLite all exit 0. **Independence Day status post-#934 Step 3b:** the load-bearing claim, that adding a new source touches only the composition root in CLI and nothing in SearchModels, is structurally proven. Step 4 (end-to-end TDD scenario with a fake source) is the empirical proof remaining. Refs: #934 Step 3b of #919; plan `docs/plans/2026-05-22-source-independence-day.md`. - **#934 Step 3a: `Search.SourceLookup` value type added; ranking path consumes a composition-root-injected lookup instead of `Search.SourceRegistry`'s static.** First half of Step 3 on the Source Independence Day critical path. New foundation-only value type `Search.SourceLookup` in SearchModels wraps `[Search.SourceDefinition]` and exposes the 7 lookup methods that were previously static on `Search.SourceRegistry` (definition(for:), allIDs, enabledIDs, sources(for:), sourceIDs(for:), properties(for:), isValid(_:)) plus 4 `Search.Source`-facing convenience methods (displayName(for:), emoji(for:), isRegistered(_:), boostedSources(for:)). `Search.Index.init` gains a REQUIRED `sourceLookup:` parameter (no default; `gof-di-rules.md` Rule 2). The ranking path at `Search.Index.Search.swift:473-477` reads from `self.sourceLookup.boostedSources(for: queryIntent)` and `self.sourceLookup.properties(for: id)` instead of `SourceRegistry.{sourceIDs, properties}(for:)`. **Composition root:** new file `Packages/Sources/CLI/CLIImpl.SourceLookup.swift` carries `CLIImpl.makeProductionSourceLookup()` which assembles the 8-entry production list inline. Per `gof-di-rules.md` Rule 6 the `CLIImpl.*` wiring layer is allowed to name production lists (the executable target is the composition root); the producer namespace (`Search.*`) is NOT. **60 call sites updated** to pass `sourceLookup:` explicitly: 1 production (`Save.Indexers.swift` calls `CLIImpl.makeProductionSourceLookup()`), 5 CLI read-only sites pass `.empty`, 54 test sites pass `.empty` (mechanical perl batch). **`sourceLookup` exposed as `public nonisolated let` on `Search.Index`** so tests + future stricter consumers can read it (same pattern as #932's `indexers`). **Tests:** +3 / +1 suite (2487 / 369 vs 2484 / 368 on develop). Audits + portability-on-SearchModels + portability-on-SearchSQLite all exit 0. **Honest scope reduction → Step 3b queued as a follow-up issue:** the static `Search.SourceRegistry.all` and the convenience extensions on `Search.Source` (`displayName` / `emoji` / `isRegistered` / `definition` / `properties` / `intents`) and `Search.QueryIntent` (`boostedSources` / `boostedSourceIDs` / `registryBoostedSources`) and the deprecated `SourcePropertiesRegistry` enum all SURVIVE this PR. Deletion requires migrating ~30 test files to take an explicit `Search.SourceLookup`, which is mechanical but not within the Opus-budget window for this session. The structural lift in #934 (ranking path no longer reaches for the static; composition root supplies the list) is the load-bearing change; Step 3b's purge is cleanup. Refs: #934 (Step 3a of #919); plan `docs/plans/2026-05-22-source-independence-day.md`. - **#933 (Step 2 of Source Independence Day): `Search.makeDefaultStrategies` factory dissolved; the 6 production strategy concretes are assembled inline at the composition root.** Mirror of #932's pattern, applied to strategies instead of indexers. Pre-#933 the factory in `SearchStrategies/Search.Strategies.Factory.swift` was a static method on the `Search` namespace that named the production strategy list, a Service Locator surface per `gof-di-rules.md` Rule 1. Post-#933 the factory file is deleted; the `CLIImpl.Command.Save.Indexers.swift` composition root inlines the 6-strategy assembly (`AppleDocsStrategy`, then conditional `SwiftEvolutionStrategy` / `SwiftOrgStrategy` / `AppleArchiveStrategy` / `HIGStrategy` on their optional directory parameters, then `SampleCodeStrategy`). Adding a new source's strategy is one `strategies.append(...)` at the composition root, zero edits to SearchStrategies. **All 4 callsites migrated inline:** 1 production (`Save.Indexers.swift`), 3 in test targets (`SaveTests.swift` × 6 sites, `ServeTests.swift` × 1, `IndexBuilderSymbolsIntegrationTests.swift` × 1). Tests now express exactly the strategies they need (apple-docs-only for most, apple-docs + swift-evolution for the cross-source test) instead of passing `nil`s through the factory's optional-dir gauntlet. **Docstring updates:** `Search.IndexBuilder` doc example rewritten to show the inline assembly pattern instead of the deleted factory. **`Search.IndexBuilder.strategies:` constructor parameter unchanged** (was already composition-root-injected pre-#933); #933 dissolves only the *assembly helper*, not the seam. **Audit-script invariants:** STRICT_PRODUCERS count holds at 35; portability-on-SearchStrategies exits 0 (the SearchStrategies target lost a file but kept all its strategy concretes; the foundation-only contract is unchanged). **Tests:** 2484 / 368 green (held at #932's count; no new tests this PR because the contract here is "factory deleted, file is gone" which is mechanically verifiable by `grep`, not behaviour). Refs: #933 (Step 2 of #919); plan `docs/plans/2026-05-22-source-independence-day.md`. - **#932 (Step 1 of Source Independence Day): `Search.IndexerRegistry` static dict dissolved; `indexers` injected at the composition root in `CLIImpl.Command.Save.Indexers.swift` as a REQUIRED `[String: any Search.SourceIndexer]` parameter on `Search.Index.init`.** First critical-path step of the `docs/plans/2026-05-22-source-independence-day.md` execution plan. Pre-#932 the `Search.Index.indexItem(_:extractSymbols:)` dispatch path called `Search.IndexerRegistry.indexer(for:)`, a process-wide static enum holding the 7 production indexer concretes. Post-#932 the static enum is deleted; the dispatch reads from `self.indexers[item.source]` on the actor; the dict is supplied at construction. **No `Search.productionIndexers()` static factory exists**: the 7-entry literal is inlined at the CLI composition site in `CLIImpl.Command.Save.Indexers.swift` per `gof-di-rules.md` Rule 1 (no Service Locator on producer namespaces). **No default value on `indexers:`** per `gof-di-rules.md` Rule 2 (every external collaborator goes through init, no exceptions). Every `Search.Index(...)` call site explicitly passes `indexers:` (production: inlined dict; read-only paths in Doctor / Serve / MCP / Inheritance / Search.SmartReport / SearchModuleAlias factories: explicit `[:]`; tests: explicit `[:]` or fake dict). **`indexers` is `public nonisolated let`** because the value is immutable + Sendable; tests + future stricter consumers can read it for verification. **65 test files updated** to pass `indexers: [:]` explicitly (mechanical, no behavioural drift; tests that didn't exercise indexItem still don't). **Doc drifts addressed inline** in the test suite: `Issue932IndexerInjectionContractTests` (3 tests) pins the contract via mechanical grep over `Packages/Sources/SearchSQLite/` files for the symbols `public enum IndexerRegistry`, `private static let indexers:`, and `static func productionIndexers` / `static let productionIndexers`. If a future PR reintroduces any of those, the audit-grep tests fail. **`Issue932IndexItemDispatchTests` (2 tests)** drives `indexItem` end-to-end with an actor-based `FakeSourceIndexer` and asserts (a) `preprocess` fires on a matched source-id (proves the dispatch reads from the injected dict, not a static surface), (b) the WWDC fake receives NO preprocess call for an unmatched `apple-docs` source-id (proves no static fall-through survives). **Audit-script invariants:** `STRICT_PRODUCERS` count holds at 35; `GRANDFATHERED_TARGETS` stays `()`; foundation-only / purity / portability-on-SearchSQLite all exit 0. **Tests:** +5 (2484 / 368 green vs 2479 / 366 on develop). **Honest scope reduction:** the `Search.SourceIndexer` protocol stays in SearchSQLite for this PR; moving it to SearchModels is deferred to a Step 1.5 follow-up because the protocol's return type `Search.ExtractedContent` pulls `ASTIndexer.Symbol` / `ASTIndexer.Import` through, which would drag ASTIndexer into the foundation tier. Until that follow-up lands, a new source's indexer concrete still lives in SearchSQLite; the structural lift in #932 is the SHAPE of dispatch (composition-root-injected, no static), not the package layout. Refs: #932 (Step 1 of #919); plan `docs/plans/2026-05-22-source-independence-day.md`. - **#919 roadmap: ordered execution plan for Source Independence Day landed at `docs/plans/2026-05-22-source-independence-day.md`; CLAUDE.md trimmed to point at the plan + the critical-path issues.** Codifies the 4 remaining hardcoded edit-points blocking the "2-file PR adds a new source" claim and files them as individual trackable issues: [#932](https://github.com/mihaelamj/cupertino/issues/932) (`IndexerRegistry` composition-root injection, drops the `SearchSQLite/Search.SourceIndexer.swift` static dict), [#933](https://github.com/mihaelamj/cupertino/issues/933) (`Search.makeDefaultStrategies` factory dissolved), [#934](https://github.com/mihaelamj/cupertino/issues/934) (`Search.SourceRegistry.all` dissolved into composition-root `[Search.SourceDefinition]`), [#935](https://github.com/mihaelamj/cupertino/issues/935) (end-to-end TDD scenario with a fake source proving the 2-file claim empirically). Plus 2 downstream-polish steps (CLI surface generalisation, source-name doc auto-generation) explicitly OUT of the critical path. The plan doc names each step's file scope, removed edit-point, estimate, and unlock; pairs each step with the `for closing this plan` exit criterion that closes #919. CLAUDE.md gets a `Source Independence Day` section linking the plan + the 4 issues, plus a one-paragraph statement of the invariant ("100% pluggable, not 80% with caveats"). CLAUDE.md release-archive sections (v1.0.2 / v1.1.0 / v1.2.0 detail; mihaela-blog-ideas cross-repo path; the stale "branch from `main`" workflow note pre-dating the develop-trunk pair workflow) trimmed out: they are recoverable from `CHANGELOG.md` and `docs/audits/`, and CLAUDE.md's load-bearing role is current focus + invariants + workflow, not version history. Refs: #919 (umbrella). Doc-only PR; no code changes. - **#930 (#248 fourth cut): 3 sibling per-DB Doctor sections lifted into `Distribution.DatabaseHealthCheck` conformers; composition-root assembly is the sole edit point for adding a 4th DB.** Closes the gap that #922 explicitly left open (the 3 sibling per-DB sections still hardcoding their DB inline after `printSchemaVersions` was lifted). The new protocol `Distribution.DatabaseHealthCheck` (foundation-only, lives in `DistributionModels`, gains `LoggingModels` as a dep for the `any Logging.Recording` output-sink seam) carries `descriptor: Shared.Models.DatabaseDescriptor` + `isRequired: Bool` + `func run(output:) async -> Bool`. Three conformers (`SearchHealthCheck`, `SamplesHealthCheck`, `PackagesHealthCheck`) live in CLI alongside Doctor, each preserving its pre-#930 verdict policy (search is required; samples + packages are warning-only) and rendering the same section text. Doctor's `run` method constructs the 3 conformers at the composition root and iterates `for check in healthChecks` (single full-array loop, no `prefix(N)`, no out-of-loop calls), so a new conformer cannot silently disappear from `cupertino doctor`'s output. **Critic-loop addressed inline (2 iterations):** (i) first cut used `prefix(2)` + an explicit search call to preserve section order around the `checkSampleArchiveIntegrity` hook, which would have left an appended 4th conformer un-executed; restructured to a single full-array loop with the integrity hook keyed off the samples descriptor; (ii) second iteration noted that the zip-integrity probe is semantically orthogonal to samples.db (scans on-disk zips, not SQLite) and that coupling them via the inline interleave drifted from the test pin; moved the integrity hook OUT of the loop entirely. Section order changes from `{Packages, Samples, SampleArchive, Search, Resources}` to `{Packages, Samples, Search, SampleArchive, Resources}`; both are descriptive sequences with no functional dependency. **Other intentional drift:** the search check's "newer than binary, expected" status-mismatch line replaces the pre-#930 wording (which used an em-dash separator) to comply with the project's global writing-style rule. **Protocol contract:** warning-only conformers MAY return `false` to signal partial degradation; the `if check.isRequired` gate at the call site decides whether that bool reaches the aggregate verdict (load-bearing, not decorative). **SearchHealthCheck handle-leak fix:** pre-#930 a throw from `listFrameworks()` after a successful `Index.init` leaked the SQLite handle until process exit; the restructured do/catch now disconnects on every post-init path. `reportIndexedSources` is intentionally kept OUTSIDE the throwing do-block so a future throwing variant of that helper would not surface as a misattributed listFrameworks error. **Tests:** +14 tests / +5 suites (was 2465 / 361, now 2479 / 366) covering descriptor identity, requiredness policy, missing-file output byte-for-byte, strategy-seam round-trip, cross-conformer invariants, and a `Doctor.run`-shape regression pin (iterates a 4-element fake-conformer list and asserts every conformer's `run` is called exactly once, plus the verdict-folding semantics for required vs warning-only). The fake conformer is modelled as an `actor` for async-safe run-counting under Swift 6 strict concurrency. **Audit-script invariants:** `STRICT_PRODUCERS` count holds at 35; `GRANDFATHERED_TARGETS` stays `()`; both audit scripts exit 0; `check-target-portability.sh DistributionModels` exits 0 (DistributionModels' new `LoggingModels` dep is foundation-only, preserving DistributionModels' standalone-portability). Refs: #930 (child of #919; fourth cut of #248). - **#919 CI: `scripts/check-target-portability.sh` wired into GitHub Actions as the `portability` job, checking 6 strategic producers on every PR + push.** Empirical companion to the existing `package-audits` job. Where `check-target-foundation-only.sh` and `check-package-purity.sh` answer "do you import what you declare?" via source-level inspection, the portability script answers "do you declare what you actually need?" by physically lifting the producer out of the monorepo into a tmp directory, generating a minimal `Package.swift` listing only its declared deps + their transitive closure, and running `swift build` against that subset. A green run proves the producer is genuinely standalone-portable: a downstream consumer could copy the same files into a fresh repo with the printed manifest and it would compile against the declared deps alone. Together with the source-level audit pair this pins the full standalone-package contract from `mihaela-agents/Rules/swift/gof-di-rules.md` rule 5. **Producers checked:** Search, SearchSQLite, SampleIndex, SampleIndexSQLite, Enrichment, SearchStrategies (the SQLite-backed concretes + the orchestration targets the #893 + #919 pluggability arc cared about). Each step is gated by a 5-minute timeout. The job runs on `macos-15` because the script uses `xcrun swift build`; same trigger cadence as `build-and-test` (`pull_request` + `push to develop/main`). **Honest scope:** the 6 producers covered today are the ones the strict-DI arc touched; further producers should be appended as their portability lands. Refs: #919 (umbrella). - **#761 CI guard: external PRs against `main` get a sticky warning + programmatic `REQUEST_CHANGES` review.** New `.github/workflows/external-pr-to-main-guard.yml` triggers on `pull_request_target` (opened / reopened / synchronize) against `branches: [main]`. The job short-circuits when the PR author is in the `MAINTAINERS` list (`mihaelamj`); for external authors it posts a sticky comment (edit-in-place via marker; spam-free across pushes) explaining the pair-workflow (release-prep on `develop` is FF-pushed to `main` only when develop's tip is ready; an external PR merging directly to `main` bypasses the handshake and forces a back-flow push). The same job submits a `REQUEST_CHANGES` PR review on the first push only (subsequent pushes re-find the prior review and skip re-submission), so the PR header reads "Changes requested by github-actions[bot]" and the merge button greys out until the maintainer explicitly dismisses the review. The 2026-05-17 incident with PR #661 (where the merge-to-main happened by accident and forced a back-flow push of `origin/main:refs/heads/develop` to restore the invariant) is what motivated this guard. The workflow uses `actions/github-script@v7` to drive the comment + review via the GitHub REST API; permissions are `contents: read` + `pull-requests: write` (the minimum surface). The job runs in the base-repo context per `pull_request_target` semantics; it never checks out untrusted PR head code, only inspects metadata. Refs: #761. ### Added - **#919 ironclad coverage pins: 18 more tests + a Doctor refactor extracting `renderSchemaVersionLine` to a testable static helper.** Second coverage pass closing the gaps from the post-#927 audit. **`CLIImpl.Command.Doctor.renderSchemaVersionLine(descriptor:formatted:journalNote:walNote:volumeNote:)`** lifted out of the private `printSchemaVersions` so the line format is testable. The label uses `descriptor.filename` (not `descriptor.id`) so a regression that swapped the two surfaces is caught in CI. **`Issue919SchemaVersionLineFormatTests`** (6 tests) pins the formatter's contract across 6 axes: label-uses-filename, three-descriptor render, walNote position, volumeNote position, all-three-suffixes-coexist ordering, and non-wal journal warning prose. **`Issue919DisplayNameRegressionTests`** (9 tests) pins all 8 historical sources' `displayName` plus an emoji pin for all 8. Pre-#251 the displayName lived inside a closed enum switch (compile-time-pinned by exhaustiveness); post-#251 it's descriptor-backed via SourceRegistry lookup, so a row rename now bypasses any compile-time guard. **`Issue919SourceRegistryCountTests`** (3 tests) pin `Search.SourceRegistry.all.count == 8` (silent-row-add guard), every row has non-empty id/displayName/emoji, every id is unique. The count assertion carries an explanatory failure message so a contributor adding a new source sees the lockstep contract with `Search.Source` static constants and `Shared.Constants.SourcePrefix.*`. **`Issue919EmptyRequiredPreconditionTests` renamed to `Issue919ClassifyRelaxedSubsetTests`** (the original name overpromised, since the precondition-trap itself is not testable without a Swift Testing trap-catcher; the rename + an explanatory note pin the actual coverage scope). **Gaps remaining (documented, not closed):** end-to-end "new source plugs in via 2-file PR" TDD scenario (requires fake-source-indexer + corpus fixture, separate infrastructure work); `check-target-portability.sh` not yet in CI; Codable wire-format pinning for `Distribution.SetupService.Outcome` (currently `Sendable, Equatable` only, so no current Codable surface to test). **Test impact:** 2465 / 361 green (was 2447 / 358 post-#927; +18 tests / +3 suites). Refs: #919 (umbrella) ironclad pins. - **#919 coverage pin: 14 new tests covering the gaps in today's strict-DI + descriptor + source-identity refactor.** Surfaced during a 2026-05-22 evening coverage audit: 13 PRs shipped in the #893 + #919 arcs but several invariants were only validated by manual inspection. This PR pins them with tests so any regression surfaces in CI rather than in the next bug report. **`Issue919OutcomeLookupTests`** (4 tests): negative case for `Outcome.path(forDatabaseId:)` (unknown id returns nil; empty string returns nil; case-sensitive matching); descriptor-not-in-Outcome lookup returns nil; happy path still resolves for the 3 known ids; Outcome equality is documented as order-sensitive on the `databases` array. **`Issue919SourceAliasCoverageTests`** (5 tests): `Shared.Constants.SourcePrefix.appleSampleCode` is pinned as a partially-registered alias (raw fallback for `displayName`, empty fallback for `emoji`, `isRegistered == false`); `Search.Source.isRegistered` cleanly separates the 8 historical sources from unregistered candidates (case drift, empty string, future identifiers); unknown-source display surface falls back deterministically; all 8 static constants resolve to registered SourceRegistry rows; the descriptor surface is open (a synthesised SourceDefinition for a hypothetical `"wwdc-transcripts"` constructs without touching `Search.Source`'s declaration). **`Issue919EmptyRequiredPreconditionTests`** (2 tests): classify happy paths still behave identically pre/post #919's signature lift; classify ignores extra databases beyond the required set (relaxed-subset semantic). **`Issue919AuditInvariantTests`** (3 tests): GRANDFATHERED_TARGETS array in `check-package-purity.sh` is literally `()` (zero entries); STRICT_PRODUCERS array in `check-target-foundation-only.sh` contains exactly 35 entries; FORBIDDEN_MODULES contains the 5 concrete + concrete-sibling targets the arc cared about. **Test impact:** 2447 / 358 green (was 2433 / 354; +14 tests / +4 suites). Refs: #919 (umbrella). - **#251 (fourth cut): 2 strategy source-id literals (AppleArchive `source` field, SwiftOrg `pageSource == "swift-book"` compare) lifted to `Shared.Constants.SourcePrefix` constants.** Fourth and final cut of the #251 source-identity unification refactor (child of #919). Sweeps the 2 remaining hardcoded source-id string literals in production code outside SearchSQLite (which the third cut covered): `Packages/Sources/SearchStrategies/Search.Strategies.AppleArchive.swift:24` (the AppleArchiveStrategy's `source` field) and `Packages/Sources/SearchStrategies/Search.Strategies.SwiftOrg.swift:203` (the `isSwiftBook` literal-compare against `"swift-book"`). After this cut, no production code outside `Shared.Constants` and DocC docstring examples hand-types a source-id string. SearchModels + SearchSQLite + SearchStrategies all read from `Shared.Constants.SourcePrefix.*`. Renaming a SourcePrefix constant now propagates to every indexer concrete, every registry entry, every literal-compare site in one edit. **Test impact:** 2433 / 354 still green; both audit scripts exit 0; zero behavioural change. Refs: #251 fourth cut (closes the literal-string layer of #251; child of #919). The structural IndexerRegistry-as-static-dict collapse remains as a separate refactor not yet filed. - **#251 (third cut): 7 indexer concrete `sourceID` fields + 7 `IndexerRegistry` dictionary keys + 1 search-path archive literal lifted to `Shared.Constants.SourcePrefix` constants.** Third cut of the #251 source-identity unification refactor (child of #919). Single-file mechanical substitution sweep across `Packages/Sources/SearchSQLite/Search.SourceIndexer.swift` (14 sites) + 1 adjacent site in `Packages/Sources/SearchSQLite/Search.Index.Search.swift:92` (the `archiveRequested == "apple-archive"` literal-compare). Post-PR SearchSQLite has zero hand-typed source-id string literals. Every reference reads from `Shared.Constants.SourcePrefix.*`. Adding a new content source (e.g. WWDC transcripts) no longer requires touching the SearchSQLite indexer registry's literal keys: the new indexer just declares `public let sourceID = Shared.Constants.SourcePrefix.<name>` and the IndexerRegistry dictionary entry uses the same constant. **Honest scope:** this collapses the LITERAL-string duplication. The structural `IndexerRegistry` collapse (composition-root-injected registry replacing the static `[String: any Search.SourceIndexer]` dictionary) is a separate refactor not yet filed; the current dict-with-7-hardcoded-concretes shape still requires editing this file to register a new indexer concrete. **Test impact:** 2433 / 354 still green; both audit scripts exit 0; zero behavioural change (all 15 substitutions are byte-identical to the pre-PR literals). Refs: #251 third cut (child of #919; sibling of #248). - **#251 (second cut): `Search.Source` enum collapsed to a `String`-wrapping struct; `displayName` / `emoji` are descriptor-backed; all three source-identity copies now read from one source of truth.** Closes the structural duplicate `Search.Source` lifted out of the previous cut. Pre-#251 `Search.Source` was `enum Source: String, CaseIterable { case appleDocs = "apple-docs"; ... }` with closed `displayName` and `emoji` switches; adding a new source required a new enum case + a new arm in both switches + a new SourcePrefix + a new SourceDefinition. Post-#251 `Search.Source` is `struct Source: Hashable, Sendable, Codable, RawRepresentable { let rawValue: String }` open to any rawValue. The 8 historical sources remain reachable as static constants (`Search.Source.appleDocs`, etc.) whose rawValues are sourced from `Shared.Constants.SourcePrefix.*`. `displayName` and `emoji` now look up the descriptor in `Search.SourceRegistry` and fall back to the rawValue (for displayName) or empty string (for emoji) when no descriptor is registered. **New helper:** `Search.Source.isRegistered: Bool` replaces the structural validation the previous failable init performed; callers that historically branched on `Source(rawValue:)` returning nil now check `isRegistered` to ask "is this a real source?". **Caller migration:** `Search.Index.Search.swift`'s ranking path drops the `.map` / `.flatMap` Optional chain around `Search.Source(rawValue:)` (now non-failing) and reads SourceRegistry properties directly. The deprecated `SourcePropertiesRegistry.properties` builder switches from `Search.Source.allCases` to `SourceRegistry.all.map(\.id)`-style iteration. **User-visible string preserved:** the closed-enum `displayName` for `.appleArchive` was the hand-curated `"Apple Archive (Legacy)"`; SourceRegistry's row had `"Apple Archive"`. Aligned the SourceRegistry row to `"Apple Archive (Legacy)"` so the post-refactor user-visible label is byte-identical to the pre-refactor enum's switch output. **Open-by-default risk:** `Source(rawValue: "wwdc-transcripts")` no longer rejects unregistered strings (an intentional shift toward the #919 goal of "new source = 2-file PR"); callers that depended on the previous fail-at-door semantics should switch to `isRegistered`. Documented in the struct's header comment + the `isRegistered` docstring. **Test impact:** 2428 / 353 still green; both audit scripts exit 0. Refs: #251 second cut (child of #919; sibling of #248). Supersedes the "Queued / two of three copies collapsed" framing from the first-cut bullet below: all three copies are now collapsed. - **#251 (first cut): `Search.SourceRegistry.all` source-id literals lifted to `Shared.Constants.SourcePrefix` constants.** First cut of the #251 source-identity unification refactor, child of umbrella epic #919, sibling of the three #248 cuts (PRs #920, #921, #922). Replaces 8 hardcoded `id: "<literal>"` arguments in `Search.SourceRegistry.all` (in `Packages/Sources/SearchModels/Search.SourceDefinition.swift`) with `id: Shared.Constants.SourcePrefix.appleDocs` / `samples` / `hig` / `appleArchive` / `swiftEvolution` / `swiftOrg` / `swiftBook` / `packages`. SearchModels already imports SharedConstants so no manifest change. Renaming a SourcePrefix constant now propagates to SourceRegistry automatically. **Honest scope:** this collapses TWO of THREE source-identity copies: `Shared.Constants.SourcePrefix.*` (the constants) and `Search.SourceRegistry.all`'s `id:` arguments now read from one source of truth. The third copy is `Search.Source` (in `Search.DomainTypes.swift`), a `String`-rawValue enum whose raw values duplicate the same 8 ids. Swift forbids `static let` rawValues on String enums, so collapsing the third copy needs a structural change (likely dropping the enum in favour of SourceRegistry-driven descriptor lookups). Queued; a doc-comment in `Search.SourceRegistry.all` explicitly flags the remaining duplicate so future readers don't think #251 is closed. **Test impact:** 2428 / 353 still green; both audit scripts exit 0; zero behavioural change (constants are byte-identical to the literals they replaced). Refs: #251 first cut (child of #919; sibling of #248). - **#248 (third cut): `CLIImpl.Command.Doctor.printSchemaVersions` `entries` list lifted to descriptor-keyed tuples.** Third cut of the #248 declarative-DB-registry refactor (child of umbrella epic #919; continues #920 first cut + #921 second cut). Replaces the inline `entries: [(String, URL)]` literal in `printSchemaVersions` (which hardcoded `("search.db", ...)`, `("packages.db", ...)`, `("samples.db", ...)`) with `entries: [(Shared.Models.DatabaseDescriptor, URL)]` consuming the canonical `.search`, `.packages`, `.samples` constants extracted in #921. Loop body unchanged; the schema-version + journal-mode + WAL-sidecar + volume-warning probes still drive the per-line output byte-for-byte vs pre-PR. **Honest scope:** the lift only covers `printSchemaVersions`. The 3 sibling per-DB sections (`checkSearchDatabase`, `checkSamplesDatabase`, `checkPackagesDatabase`) still hardcode their DB inline because each carries distinct verdict policies (required vs optional vs warning-only) plus distinct probes; lifting those into descriptor-driven `DatabaseHealthCheck` conformers is queued as the next cut. **Critic-loop addressed inline:** scope-honesty fix in the new doc-comment (originally overstated as "DoctorCommand-wide" descriptor-driven; narrowed to `printSchemaVersions` only with explicit note about the 3 sibling methods still hardcoding). **Test impact:** 2428 / 353 still green; both audit scripts exit 0. Refs: #248 third cut (child of #919). - **#248 (second cut): `Distribution.SetupService.Outcome` lifted to descriptor-keyed `databases: [DatabasePlacement]` list; canonical descriptor constants extracted; `cupertino setup` final-summary renderer iterates the list.** Second cut of the #248 declarative-DB-registry refactor (child of umbrella epic #919). Replaces `Distribution.SetupService.Outcome`'s three named URL fields (`searchDBPath`, `samplesDBPath`, `packagesDBPath`) with a single `databases: [DatabasePlacement]` array where each `DatabasePlacement` carries a `Shared.Models.DatabaseDescriptor` + a `URL`. New `Outcome.path(forDatabaseId:) -> URL?` lookup method for callers that need to address a specific DB by id. **Canonical descriptor constants** added on `Shared.Models.DatabaseDescriptor` (`.search`, `.samples`, `.packages`), constructed from `Shared.Constants.FileName.*` filename constants. Both production (`Distribution.SetupService.run`'s `placements` literal) and tests now read these single-source-of-truth constants, so a rename to `id` / `filename` / `displayName` becomes a one-line edit that can never desync. **Renderer migration:** `CLIImpl.Command.Setup.printFinalSummary` iterates `outcome.databases` instead of addressing 3 fields. Label width matches the historical "Documentation: " / "Sample code: " / "Packages: " column alignment via `padding(toLength: 15, ...)`; print column stable byte-for-byte vs pre-PR output. **Outcome equality docstring:** added explicit note that `Outcome` equality is order-sensitive on the `databases` array; production code constructs the list in one place (`SetupService.run`'s `placements` literal) so heterogeneous-construction equality compares are not a current footgun, but the docstring captures the constraint. **Test rewrites:** 4 tests in `DistributionModelsTests` rewired to construct `Outcome` via the new `databases:` initializer; assertions switched from `outcome.searchDBPath.lastPathComponent` to `outcome.path(forDatabaseId: "search")?.lastPathComponent` (etc.). Test fixtures use the canonical `.search` / `.samples` / `.packages` constants directly. `InstalledVersionTests` (#920 first cut) descriptor fixtures also collapsed to the canonical constants. **Critic-loop addressed inline:** column-shift cosmetic fixed (drop extra space between label and path); test-fixture drift fixed by extracting canonical constants both sides share; `Equatable` order-sensitivity documented. **Test impact:** 2428 / 353 still green; both audit scripts exit 0. Refs: #248 (child of #919; sibling of #251). - **#248 (first cut): `Shared.Models.DatabaseDescriptor` value type + `Distribution.InstalledVersion.classify` signature lifted to descriptor sets.** First cut of the #248 declarative-DB-registry refactor, child of new umbrella epic **#919** (declarative source + DB pluggability). Adds a new foundation-only value type `Shared.Models.DatabaseDescriptor` (`Sendable`, `Hashable`, `Identifiable`; fields: `id`, `filename`, `displayName`) carrying the identity bits the CLI needs to make per-DB decisions DB-count-agnostic. Refactors `Distribution.InstalledVersion.classify(searchDBExists:samplesDBExists:packagesDBExists:installedVersion:currentVersion:)` to `classify(present:required:installedVersion:currentVersion:)` where `present`/`required` are `Set<Shared.Models.DatabaseDescriptor>` instead of three booleans. Hard precondition: `required` must be non-empty (caught at the function entry; was structurally impossible under the old three-bool API, would have been a silent-bypass mode otherwise). **Caller updates:** `Distribution.SetupService` constructs the three descriptors locally and passes the required set; the eventual descriptor-registry plumbing through the composition root lands in follow-up PRs. The 8 existing tests in `InstalledVersionTests` were rewritten mechanically (Python script) to use 3 descriptor fixtures + a `requiredAll` constant instead of hand-set booleans, and a new test was added (`singleDBSetup`) that exercises a 1-DB install, proving the signature is genuinely count-agnostic, not just a three-DB shim. **Critic-loop addressed inline:** initial cut placed the new type at top-level `Database.Descriptor`, which collided with the pre-existing `Shared.Constants.Database` (table-name namespace); moved to `Shared.Models.DatabaseDescriptor` to honour the existing namespace convention. **Test impact:** 2428 / 353 green (+1 test / +0 suites from the single-DB generality test). Refs: #248 (child of #919; sibling of #251). - **#899 (first cut): extract `SearchStrategies` SPM target carrying the 6 source-indexing strategy concretes + `StrategyHelpers` + `Search.makeDefaultStrategies` factory.** The orchestration `Search` target loses the `Strategies/` subdirectory and the `Search.IndexBuilder.makeDefaultStrategies` static factory + the `init(searchIndex:metadata:docsDirectory:...)` convenience init; both now live in the new `SearchStrategies` target as a free function in the `Search` namespace. `Search` target now imports only `EnrichmentModels`, `LoggingModels`, `SearchModels`, `SharedConstants`, `Foundation` (no concrete strategy dependency); 35 producers in `STRICT_PRODUCERS` (up from 34). The further split into 6 individual per-strategy SPM targets (literal #899 scope per the epic body) is queued; this PR ships the first cut by consolidating all strategies into one `SearchStrategies` target. **Composition root migration:** CLI's `CLIImpl.Command.Save.Indexers.swift` switches from the convenience init to the 2-step factory pattern: `let strategies = Search.makeDefaultStrategies(...)` followed by `Search.IndexBuilder(searchIndex:strategies:logger:...)`. 9 test call sites across `SaveTests`, `ServeTests`, `IndexBuilderSymbolsIntegrationTests` rewritten the same way (mechanical script + 1 manual fixup for the `metadata: nil, // No metadata!` comment case the script mis-parsed). **`@testable` imports:** 8 test files that reach `Search.StrategyHelpers` internals were promoted from `import SearchStrategies` to `@testable import SearchStrategies` so `internal` strategy members stay reachable. **Audit scripts:** `SearchStrategies` added to `STRICT_PRODUCERS` in `check-target-foundation-only.sh` (foundation-only deps), and to `FORBIDDEN_MODULES` in `check-package-purity.sh` (composition-root-only import). **Test impact:** 2427 / 353 green (+1 test / +1 suite from the new `SearchStrategies` smoke that exercises the `Search.SourceIndexingStrategy` metatype). Refs: #899 (child of epic #893). - **#898F: domain-types lift; SearchSQLite drops `import Search` and graduates into STRICT_PRODUCERS. `GRANDFATHERED_TARGETS` is now empty.** Final follow-up to #898 sub-PR E. The SearchSQLite concrete previously imported `Search` because several domain types referenced inside the moved actors still lived on the orchestration Search target. This PR lifts those types so SearchSQLite can stand standalone-portable. Carved out of `Search.ComposableResult.swift` and moved into a new `SearchModels/Search.DomainTypes.swift`: `Search.Source` enum, `Search.QueryIntent` enum, `detectQueryIntent(_:)` function, `Search.SourceProperties` struct. Whole-file moved `Search.SourceDefinition.swift` (carrying `Search.SourceDefinition` + `Search.SourceRegistry` + extensions on Source / QueryIntent) from `Sources/Search/` to `Sources/SearchModels/`. Whole-file moved 4 files from `Sources/Search/` to `Sources/SearchSQLite/` (none had external Search-target consumers): `Search.SearchResult.swift` (SampleCodeResult + PackageResult), `DocKind.swift` (DocKind enum + Classify), `Search.SourceIndexer.swift` (SourceItem + SourceIndexer protocol + ExtractedContent + 7 indexer concretes + IndexerRegistry), `Search.Index.DocLinkRewriter.swift` (DocLinkRewriter helper). **Rename:** `Sample.Indexer` renamed to `Search.SampleCodeIndexer` so it lives under the same namespace as its 7 sibling indexer concretes (AppleDocsIndexer, HIGIndexer, etc.); the prior `extension Sample { struct Indexer: Search.SourceIndexer { ... } }` nesting made `Search.SourceIndexer` references resolve to `Sample.Search.SourceIndexer` once the file moved out of the Search target (Sample is in SharedConstants and has its own Search sub-namespace). The IndexerRegistry's "samples" entry updated to construct `Search.SampleCodeIndexer()` instead of `Sample.Indexer()`. **`SearchModels.Search.DomainTypes.swift`** carries the lifted types byte-identical to the pre-lift definitions; behaviour is preserved. **`SearchSQLite` manifest** drops `Search` from `dependencies`. The three files that previously had `import Search` (`Search.Index.Search.swift`, `Search.Index.IndexingDocs.swift`, `Search.Index.CodeExamples.swift`) had the import line removed. **Audit-script promotion:** `SearchSQLite` graduates into `STRICT_PRODUCERS` (34 producers strict, up from 33 post-#906); removed from `GRANDFATHERED_TARGETS` in `check-package-purity.sh`. The grandfather array is now empty; every producer documented in `docs/package-import-contract.md`'s Producers table audits against the strict allow-list. **`Search.ComposableResult.swift`** shrunk from 915 lines to 618 with thin "moved to SearchModels" sentinel comments where the three sections used to live. **Doc updates:** `docs/portability.md` removes the "Documented producer not yet audited" bullet entirely and adds `SearchSQLite` to the Features list. `docs/package-import-contract.md`'s SearchSQLite row flips from `⚠️` to `✅`; the closing summary now reads "34 producers opted in" with no carve-outs. **Test impact:** 2426 / 352 still green. The lifts are shape-preserving; namespace-anchor-only moves through the `Search` enum which already lives in SearchModels. Refs: #898F follow-up to #898 sub-PR E (epic #893). - **#906: protocol-rewire 6 Enrichment passes; Enrichment graduates from grandfather into STRICT_PRODUCERS.** Closes the second grandfather entry tracked by `scripts/check-package-purity.sh`. Each of the 6 `Enrichment.*Pass` types had been field-storing the concrete SQLite-backed Database (Search.Index, Search.PackageIndex, or Sample.Index.Database); this PR rewires every init signature to take the corresponding protocol seam: `Enrichment.AppleConstraintsPass`, `Enrichment.HierarchyPass`, and `Enrichment.SynonymsPass` now take `any Search.IndexWriter`; `Enrichment.PackagesAppleConstraintsPass` and `Enrichment.PackagesAppleImportsPass` take `any Search.PackageWriter` (new protocol added in `SearchModels`, see below); `Enrichment.SamplesAppleConstraintsPass` takes `any Sample.Index.Writer`. **New protocol seams.** `SearchModels` gains `Search.PackageWriter` with two method requirements (`applyAppleStaticConstraints(lookup:enrichmentVersion:)` and `applyAppleImports(lookup:enrichmentVersion:)`), mirroring the existing read-side `Search.PackagesSearcher`. `SearchSQLite` gains a one-line witness file (`Search.PackageIndex.Writer.swift`) making the concrete `Search.PackageIndex` actor conform. `SampleIndexModels`'s `Sample.Index.Writer` protocol gains a 7th method requirement (`applyAppleStaticConstraints(lookup:enrichmentVersion:)`), matching the existing concrete signature on `Sample.Index.Database`; `SampleIndexModels` gains `SearchModels` as a dep so the protocol can name `Search.StaticConstraintsLookup`. **Enrichment manifest** dep list trimmed from `[EnrichmentModels, Search, SearchSQLite, SearchModels, SampleIndex, SampleIndexSQLite, SampleIndexModels, SharedConstants]` to `[EnrichmentModels, SearchModels, SampleIndexModels, SharedConstants]`. The 6 pass files dropped their `import Search` / `import SearchSQLite` / `import SampleIndex` / `import SampleIndexSQLite` lines; each one now imports only foundation + Models. **Audit-script promotion.** `Enrichment` graduates into `STRICT_PRODUCERS` (now 33 producers strict, up from 32 post-#902) and is removed from `GRANDFATHERED_TARGETS` in `check-package-purity.sh` (one grandfather entry remaining: `SearchSQLite`, gated on the queued domain-types lift). **Composition root unchanged.** CLI still constructs each pass with the concrete actors; the upcast to `any Search.IndexWriter` / `any Search.PackageWriter` / `any Sample.Index.Writer` happens at the init call site (automatic Swift conformance upcast), so `CLIImpl.Command.Save.Indexers.swift` needed no edits. **Doc updates:** `docs/portability.md` Features bullet now lists `Enrichment` and removes the "documented producer not yet audited" entry for it. `docs/package-import-contract.md` flips the Enrichment row from `⚠️` to `✅` and re-states the closing summary count (`33 producers strict`, one documented-but-not-yet-strict: `SearchSQLite`). **Test impact:** 2426 / 352 still green; the protocol rewire is shape-preserving because the concrete actors already conformed to the protocols via the existing witness files (`Search.Index.IndexWriter`, `Sample.Index.Database.Writer`, the new `Search.PackageIndex.Writer`). Refs: #906 (child of epic #893). - **#902: extract `SampleIndexSQLite` concrete target; SampleIndex target stops importing SQLite3.** Mirror of #898 sub-PR E applied to the SampleIndex producer. Adds a new SPM target `SampleIndexSQLite` (deps `[SampleIndexModels, SearchModels, SharedConstants, LoggingModels, ASTIndexer]`; foundation-only) carrying the `Sample.Index.Database` actor + its `Sample.Index.Reader` conformance witness + the new `Sample.Index.Writer` conformance witness. The `SampleIndex` orchestration target keeps `Sample.Index.Builder` + `Sample.Index.AvailabilitySidecar` and now operates strictly through the `SampleIndexModels` protocol seams: `Sample.Index.Builder.init` swaps its parameter type from the concrete `Sample.Index.Database` to `any Sample.Index.Reader & Sample.Index.Writer`. **Pluggability win, verified by grep:** `Packages/Sources/SampleIndex/` no longer contains `import SQLite3`. Any backend conforming the SampleIndexModels protocol seams can plug into `Sample.Index.Builder` without dragging SQLite3 in. **New `Sample.Index.Writer` protocol seam** in `SampleIndexModels`: 6 method requirements covering the write surface `Sample.Index.Builder` calls (`indexProject(_:)`, `indexFile(_:)`, `getFileId(projectId:path:)`, `indexSymbols(fileId:symbols:)`, `indexImports(fileId:imports:)`, `deleteProject(id:)`). Mirrors `Search.IndexWriter` from #896. `SampleIndexModels` gains `ASTIndexer` as a dep (foundation-tier; carries the `ASTIndexer.Symbol` + `ASTIndexer.Import` value types in the protocol signatures). **Type lift to `SampleIndexModels`:** `Sample.Index.Error` enum lifted from `SampleIndex/Sample.Index.Error.swift` to `SampleIndexModels/Sample.Index.Error.swift` so both the orchestration target and the SampleIndexSQLite concrete can throw + catch it without depending on each other. 9 cases unchanged. **Audit-script updates:** `check-package-purity.sh` `FORBIDDEN_MODULES` gains `SampleIndexSQLite` (only composition roots may import the concrete). `check-target-foundation-only.sh` `STRICT_PRODUCERS` gains `SampleIndexSQLite` directly (no grandfather needed; its deps are foundation-only; cleaner than the SearchSQLite case which is gated on the queued domain-types lift). Both audit scripts exit 0. **Doc updates:** `docs/portability.md` Features bullet lists `SampleIndexSQLite` alongside `SampleIndex`. **CLI composition root** imports both `SampleIndex` and `SampleIndexSQLite` and wires the concrete via `Sample.Index.DatabaseFactory` (the existing GoF Factory Method seam in `SampleIndexModels`). **Test impact:** 2426 / 352 green (was 2425 / 351 post-#914; +1 test / +1 suite from the new `SampleIndexSQLite` smoke that asserts `Sample.Index.Database` conforms to both `Sample.Index.Reader` and `Sample.Index.Writer`). Refs: #902 (child of epic #893). - **#898 sub-PR E: extract `SearchSQLite` concrete target; Search target stops importing SQLite3.** Last big architectural cut of the Search dissection arc (epic #893). Introduces a new SPM target `SearchSQLite` (one-target library product) carrying the 25 files that own the SQLite3 handles: the `Search.Index` actor + its 20 extension files (`AppleStaticConstraints`, `CamelCaseSplitter`, `CodeExamples`, `ContentAndPackages`, `CountsAndAliases`, `Database`, `Helpers`, `HierarchyConstraints`, `IndexWriter`, `Indexing`, `IndexingDocs`, `Inheritance`, `InheritanceFromMarkdown`, `Migrations`, `PlatformAvailability`, `QueryParsing`, `Schema`, `Search`, `SearchByAttribute`, `SemanticSearch`), the `PackageIndex` actor + the `Search.PackageQuery` actor (the read side of packages.db), the `Search.PackageIndexer` orchestrator that walks `~/.cupertino/packages/` and feeds the `PackageIndex`, plus the two `CandidateFetcher` concretes that wrap `Search.PackageQuery.answer` (`PackageFTSCandidateFetcher`) and `Search.Index.search` (`DocsSourceCandidateFetcher`). The `Search` target keeps only orchestration: `Search.IndexBuilder` + the 6 source-indexing strategies + `Search.SmartQuery` + the `Search.SourceIndexer` protocol + indexer concretes + `Search.IndexerRegistry` + `Search.ComposableResult` + `Search.SourceDefinition` + `Search.SearchResult` value types + `DocKind` / `Search.Classify` + `Search.JSONLImportLogSink` + `Search.Index.DocLinkRewriter`. **Pluggability win:** the orchestration `Search` target no longer imports `SQLite3`, verified by `grep -rln "import SQLite3" Packages/Sources/Search/` returning empty. Any backend conforming the `SearchModels.Search.Database & Search.IndexWriter` protocol seams (and `Search.CandidateFetcher`) can plug into `Search.IndexBuilder` and `Search.SmartQuery` without dragging SQLite3 in. The `CLI` composition root imports both `Search` and `SearchSQLite` and wires the concrete `Search.Index` from `SearchSQLite` via the existing `Search.DatabaseFactory` / `Search.IndexWriterFactory` GoF Factory Method seams; `MockAIAgent`, `ReleaseTool`, and `TUI` reach Search only through the protocol seams in `SearchModels` and do not link the concrete. **Type lifts to `SearchModels` (foundation-only):** `Search.Error` enum lifted from `Packages/Sources/Search/Search.SearchResult.swift` to `Packages/Sources/SearchModels/Search.Error.swift` so both the orchestration target and the SearchSQLite concrete can throw + catch it without depending on each other; the 7 cases unchanged (`databaseNotInitialized`, `sqliteError`, `prepareFailed`, `insertFailed`, `searchFailed`, `invalidQuery`, `schemaVersionMismatch`); error-description bodies preserve the v1.2.0 remediation wording. **Audit-script updates.** `scripts/check-package-purity.sh` `FORBIDDEN_MODULES` gains `SearchSQLite` (only composition roots may import the concrete); `GRANDFATHERED_TARGETS` gains `SearchSQLite` with a comment block naming the remaining `import Search` dependency and what blocks the strict opt-in. `scripts/check-target-foundation-only.sh` `STRICT_PRODUCERS` deliberately does NOT add `SearchSQLite` for the same reason (SearchSQLite imports `Search`, which is a sibling producer); the omission is documented in the "Intentionally omitted" comment block inside Phase 3. Both audit scripts exit 0 against the branch; the grandfather acknowledgment shows two entries (`Enrichment` + `SearchSQLite`). **Doc updates.** `docs/portability.md` adds `SearchSQLite` to the "documented producers not yet audited" bullet alongside `Enrichment`, with the same explanation of what blocks the opt-in. `docs/package-import-contract.md` updates the `Search` row to drop `SQLite3` and add the prose explaining the indirection through SearchModels protocols, adds a new `SearchSQLite` row with `⚠️` framing + TARGET vs current state, and updates the closing summary to count `SearchSQLite` alongside `Enrichment` as the second documented-but-not-yet-strict producer. **Enrichment consumes the concrete via SearchSQLite.** The 5 enrichment passes (`AppleConstraintsPass`, `HierarchyPass`, `PackagesAppleConstraintsPass`, `PackagesAppleImportsPass`, `SynonymsPass`) that field-store `Search.Index` or `Search.PackageIndex` were updated to `import SearchSQLite` (the Search target stops re-exporting these now-moved types); the `Enrichment` target's manifest gains `SearchSQLite` in its dependencies. **Test-target wiring.** `SearchTests` and `SearchToolProviderTests` target manifests gain `SearchSQLite` as a dependency; 77 test files that field-store or construct `Search.Index` / `PackageIndex` / `Search.PackageQuery` / `Search.PackageIndexer` / `Search.PackageFTSCandidateFetcher` / `Search.DocsSourceCandidateFetcher` were patched to either add `import SearchSQLite` or `@testable import SearchSQLite` (whichever variant their existing `Search` import used) so internal members lifted with the concretes remain accessible. New `SearchSQLiteTests` target with one smoke test that asserts the module loads. **Test impact:** 2425 / 351 green (was 2424 / 350 post-#913; +1 test / +1 suite from the new SearchSQLite smoke). No behavioural changes; this is a pure structural lift. **Note on the remaining `import Search` in SearchSQLite.** Several domain types referenced inside the moved code still live on the Search orchestration target (Search.Source, Search.QueryIntent, Search.IndexerRegistry, Search.Classify, DocKind, detectQueryIntent, DocLinkRewriter, Search.SourceItem, Search.SourceIndexer protocol, Search.SampleCodeResult, Search.PackageResult). Lifting those to SearchModels (which would let SearchSQLite drop `import Search` and graduate into `STRICT_PRODUCERS`) is queued as a follow-up. The net win shipped here is that the user-visible pluggability boundary moves: the orchestration `Search` target is now backend-agnostic. Refs: #898 sub-PR E (child of epic #893). - **Drive-by flake fix: `MCPIntegrationTests.serverNeverStarts` race on `terminationStatus`.** Surfaced during the #898 sub-PR A CI run on PR #913 (macos-15 runner). The test launched a Process with `/usr/bin/env nonexistent-command-xyz123`, slept 100 ms, then asserted `!process.isRunning || process.terminationStatus != 0`. `terminationStatus` throws an `NSConcreteTask terminationStatus: task still running` NSException when the task hasn't yet exited, and Swift's `||` short-circuit evaluates the RHS whenever the LHS is `false`, so on a slow runner where the subprocess hadn't yet exited at the 100 ms mark, the test reliably crashed. Local devs on a fast Mac saw a green pass; CI flaked. Fix replaces the hard-coded 100 ms sleep with a polled wait bounded by 5 s, then guards the `terminationStatus` read behind an `if process.isRunning { process.terminate() } else { #expect(terminationStatus != 0) }` block so the NSException can never fire. Local run of the fixed test: 0.055s. - **#898 sub-PR A: extract `SearchSchema` foundation-only target carrying the search.db DDL constant + schema version.** First sub-PR of the Search target dissection arc under epic #893. Adds a new foundation-only SPM target `SearchSchema` (deps: `[SearchModels]`, no SQLite import) holding two public constants: `Search.Schema.currentVersion: Int32 = 18` (lifted from the previous `Search.Index.schemaVersion`; the old name keeps compiling because `Search.Index.schemaVersion` is now re-exported as `public static let schemaVersion: Int32 = Search.Schema.currentVersion`) and `Search.Schema.createAllTablesSQL: String` (the ~230-line DDL SQL script that creates every `search.db` table, FTS5 virtual table, and index). The executor `Search.Index.createTables()` shrinks from one 240-line method body to a thin `sqlite3_exec(database, Search.Schema.createAllTablesSQL, ...)` call. The executor itself stays in the `Search` target because the method needs the `Search.Index` actor's internal `database` stored property; Swift extensions in a different module cannot reach internal members. **Sub-PR E (the full `SearchSQLite` extraction)** is where the executor methods and the rest of the SQLite-using code move into a sibling concrete target; this sub-PR delivers a modularity win (DDL constants in one place, foundation-only) without that larger move. `SearchSchema` is added to `STRICT_PRODUCERS` + `MODELS_TARGETS` in `scripts/check-target-foundation-only.sh` (now 31 producers strict, 13 foundation-only seam/constants targets in the Models tier). `docs/package-import-contract.md` + `docs/portability.md` updated with the new target. **Test impact**: 2424 / 350 (was 2422 / 349 on develop; new `SearchSchemaTests` smoke suite adds 2 tests in 1 suite asserting `currentVersion > 0` + the DDL contains the `docs_fts` virtual table). Refs: #898 sub-PR A (child of epic #893). - **#897: rewire `Search.IndexBuilder` + 6 source-indexing strategies onto the `Search.Database & Search.IndexWriter` protocol seams.** Second Swift PR of the producer-backend split arc under epic #893; consumes the protocols added by #896. `Search.IndexBuilder.init` and each of the 6 concrete `SourceIndexingStrategy` conformers (`AppleArchiveStrategy`, `AppleDocsStrategy`, `HIGStrategy`, `SampleCodeStrategy`, `SwiftEvolutionStrategy`, `SwiftOrgStrategy`) now accept `any Search.Database & Search.IndexWriter` where they previously took the concrete `Search.Index` actor. (`Search.StrategyHelpers` stays a utility namespace and is not a `SourceIndexingStrategy` conformer; the previous `SwiftPackagesStrategy` was removed in #789.) Behaviour identical; only the parameter type changes. The composed-protocol shape is right for these consumers because `IndexBuilder` calls both writes (`clearIndex`, `applyAppleStaticConstraints`, `propagateConstraintsFromParents`, `updateFrameworkSynonyms`) and one read (`documentCount`), and two strategies (`AppleArchiveStrategy`, `SampleCodeStrategy`) also need the new `getFrameworkAvailability` read. **Lift-outs to `SearchModels`** (foundation-only by construction; no new internal deps): `Search.FrameworkAvailability` (5-field Sendable struct + `.empty`), `Search.IndexStats` (the `SourceIndexingStrategy` return value), and `Search.SourceIndexingStrategy` (the Strategy-pattern protocol itself, with its `indexItems(into:progress:)` signature swapped from `Search.Index` to `any Search.Database & Search.IndexWriter`). `Search.Database` gains one new method requirement: `getFrameworkAvailability(framework:) async -> Search.FrameworkAvailability`. **CLI composition root**: new `LiveSearchIndexWriterFactory: Search.IndexWriterFactory` placeholder in `CLI/SearchModuleAlias.swift`, mirroring the existing `LiveSearchDatabaseFactory`; opens a `SearchModule.Index` and returns it as `any Search.IndexWriter`. The factory has no call sites yet (every IndexBuilder construction in this PR holds a `SearchModule.Index` directly and relies on implicit conformance to the composed protocol). The factory is added for future Factory-Method consumers along the same wiring pattern as `LiveSearchDatabaseFactory` (which is consumed by `Services.ServiceContainer`); a real consumer lands when a follow-up needs the abstraction. **Stale doc cleanup**: `Search.IndexBuilder`'s `searchIndex:` parameter docstrings (3 sites) refreshed to mention the protocol composition rather than the concrete type. **Test impact**: all 2422 tests pass; no test fixture changes required because every `Search.Database` conformer is the same `Search.Index` actor (no in-test mock stubs to update). **Deferred to follow-ups**: per-strategy SPM target extractions (#899); the SQLite concrete extraction into `SearchSQLite` (#898 sub-PR E). Refs: #897 (child of epic #893), #896 (the protocols this PR consumes). - **#896: `SearchModels.IndexWriter` + `IndexWriterFactory` protocol seams; `Search.IndexDocumentParams` lifted to `SearchModels`.** First Swift PR of the producer-backend split arc under epic #893. Mirrors the existing `Search.Database` (read) + `Search.DatabaseFactory` (Factory Method) seams in `SearchModels` to give the write surface the same protocol fronting. The new `Search.IndexWriter` carries the 10 write methods called from outside the `Search.Index.*.swift` extension files (`indexDocument`, `indexStructuredDocument`, `indexSampleCode`, `indexCodeExamples`, `extractCodeExampleSymbols`, `registerFrameworkAlias`, `updateFrameworkSynonyms`, `applyAppleStaticConstraints`, `propagateConstraintsFromParents`, `clearIndex`); lifecycle (`disconnect`) stays on `Search.Database`. The new `Search.IndexWriterFactory` is the GoF Factory Method (1994 p. 107) parallel to `Search.DatabaseFactory`; the `Live*` concrete is added under #897 when the rewire of `Search.IndexBuilder` + the 7 source-indexing strategies to consume `any Search.IndexWriter` via init lands. `Search.Index` (the concrete actor) conforms via a one-line witness extension at `Packages/Sources/Search/Search.Index.IndexWriter.swift`, parallel to the existing read-side witness at `Search.Index.Database.swift`. **Type lift required by the protocol:** the `IndexDocumentParams` value type was nested at `Search.Index.IndexDocumentParams` inside the concrete actor; for `Search.IndexWriter.indexDocument(_:)` to name the parameter type from `SearchModels`, the type was lifted up one level to `Search.IndexDocumentParams` in `SearchModels/Search.IndexDocumentParams.swift`. The struct itself is unchanged (same 18 fields, same defaults, same Sendable conformance); only the qualified name changed. 4 call sites and 1 internal usage updated: `Search.Index.IndexingDocs` (3 internal callers in the `indexItem` / `indexDocument` self-call chain), `Search.Strategies.AppleDocs` (the `indexFromDirectory` per-page indexer loop), and `CLIImpl.Command.Save` (the `SearchIndexDocumentIndexer.indexDocument` Strategy seam used by `--remote` mode). Test suite stays at 2422 / 349 green; ~50 test-file references updated mechanically (`Search.Index.IndexDocumentParams` → `Search.IndexDocumentParams`). **Deferred to follow-ups:** `Search.PackageQueryProtocol` + `Search.PackageQueryFactory` ( overlapping with the existing `Search.PackagesSearcher` seam in `SearchModels`; clean design requires a separate pass before adding ); `Search.SourceIndexingStrategy` lift to `SearchModels` ( requires rewiring the 7 strategy concretes' signatures, which is #897's scope ). Refs: #896 (child of epic #893), #897 (consumer-rewire follow-up). ### Changed - **#895 part C: refresh `docs/plans/2026-05-12-v1-1-package-split.md` to v3.** Pre-flight hygiene chapter of epic #893 closes with a docs-only PR refreshing the 2026-05-12 plan. The original plan was authored before v1.2.0 ship + before the strict-DI / standalone-portability principle layer (`mihaela-agents/Rules/swift/gof-di-rules.md`) consolidated; it organised package splitting around modularity-for-compile-speed rather than pluggability. v3 keeps the per-phase task definitions as historical record but adds a §0a "v3 refresh" preamble documenting the current direction: Phase 1 (Shared dissection) marked DONE in a different shape per #536 (`SharedCore` / `SharedUtils` / `SharedModels` / `SharedConfiguration` absorbed into `SharedConstants` rather than kept distinct); Phase 2 (Core dissection) marked PARTIAL (4 of 8 done); Phase 3 (Search dissection) marked superseded by Option D shape under epic #893 children #896-#901 (the original 8-target Search split is partially adopted but Option D additionally extracts a `SearchSQLite` concrete + lifts each of the 7 source-indexing strategies into its own SPM target, mirror of secret-life's one-concrete-per-target pattern); Phase 5 (SampleIndex) marked superseded by mirror Option D arc under #902; new §11a "Phase 7: welded-backend producers" covers the 5 producers (`Crawler/WebKit`, `Core/WebKit`, `CoreJSONParser/WebKit`, `CoreSampleCode/WebKit`, `Availability/FoundationNetworking`) the original plan did not address (children #903-#905); new §11b "Phase 8: per-pass Enrichment split" covers the 6 sibling enrichment passes added post-plan by #837 (child #906). Per-phase inline status markers added so readers landing on a historical section see the current direction immediately. No code changes; no test impact. Refs: #895 (child of epic #893), `docs/research/pluggability-analysis-2026-05-22.md`. ### Fixed - **#895 `scripts/check-target-foundation-only.sh`: catch up to current `Package.swift`; `docs/package-import-contract.md` doc fix; CI wiring; sibling-guard catchup.** Surfaced during the 2026-05-22 epic #893 pre-flight rule-canon audit. The script's `MODELS_TARGETS` allow-list was last refreshed during #536 phase 2 and never updated when `EnrichmentModels` was introduced by #837 (postprocessor pipeline seam). As a result, the foundation-only audit was emitting one false-positive violation: `Search.IndexBuilder` legitimately imports `EnrichmentModels` (foundation-only by construction, deps `[]`) but the script's allow-list didn't include it, so the import looked like producer→producer coupling. Same drift on the `STRICT_PRODUCERS` side: `EnrichmentModels` and `AppleConstraintsKit` (the producer from #759) were both never opted into the audit. **Audit fixes.** `MODELS_TARGETS` gains `EnrichmentModels`. `STRICT_PRODUCERS` gains `EnrichmentModels` (in the `*Models` block) and `AppleConstraintsKit` (in the producers block). Stale narrative comments above the array refreshed (was "6 *Models companions" + "all 17 producer / feature targets", now 12 and 18 respectively, with provenance note for AppleConstraintsKit). Per-target rationale comment added for AppleConstraintsKit matching the per-target-comment convention of the surrounding *Models block. The `Enrichment is intentionally NOT here yet` deferral comment moved from end-of-array to the top of the Phase 3 producers section so future additions don't land inside it. Script now audits 30 producers (was 28); exit code 0 against `main`. **Sibling-script catchup (`scripts/check-package-purity.sh`).** `FORBIDDEN_MODULES` gains `AppleConstraintsKit` + `Enrichment` so other producers can no longer import these concretes without being caught (the two audit scripts now agree on the producer inventory). `EXEMPT_TARGETS` gains `ConstraintsGen` since it is the binary that legitimately imports `AppleConstraintsKit` (declared `.executableTarget` in `Package.swift`, documented in the doc's Apps tier). Both scripts exit 0; the existing 6 grandfathered Enrichment-import warnings remain as expected (tracked by #906). **CI wiring.** New `package-audits` job in `.github/workflows/ci.yml` runs both audit scripts on every PR + push to `main`/`develop`; ubuntu-latest, no Swift toolchain needed, runs in seconds. Closes the gap where the audit scripts were manual-run-only despite being the authoritative gate for `gof-di-rules.md` rules 5 + 8. **Doc fixes (`docs/package-import-contract.md`).** Em-dash removed from a new `Enrichment` row (replaced with restructured prose; per CLAUDE.md global no-em-dashes rule). New `EnrichmentModels` Models-tier row. `Search` row lists `EnrichmentModels`. New `Enrichment` producers-tier row (flagged ⚠️ with TARGET vs Current-state framing and a pointer to #906; LoggingModels listed in the TARGET column per #837's per-pass log-emission spec). Closing summary rewritten: separates the "30 opted into STRICT_PRODUCERS" claim from the "matches the target regime" claim (Enrichment is documented but does NOT match the regime); restores the `#536 phase 3 / one-by-one opt-in cadence` breadcrumb that the v1 of this PR dropped; explains that the 30 = 12 *Models + 18 features count includes `Logging` as a feature producer (documented in Infrastructure tier above but audited as a producer because its imports are producer-shaped). **Adjacent stale doc refreshed (`docs/portability.md`).** Tier listings rewritten to mirror the audit-script arrays in `scripts/check-target-foundation-only.sh`: Foundation expanded from 5 entries to 7 (now matches `FOUNDATION_TIER` exactly: `SharedConstants`, `LoggingModels`, `Resources`, `Diagnostics`, `ASTIndexer`, `MCPCore`, `MCPSharedTools`); Models expanded from 6 entries to 12 (now matches `MODELS_TARGETS`: `CoreProtocols` plus the 11 `*Models`-suffixed seams including the 5 closures-to-Observer adds and `EnrichmentModels`); Features lists the 18 producers in the `STRICT_PRODUCERS` Phase 3 block (including `Logging`, which is a writer concrete but audited as a feature producer). The historical Infrastructure-tier bullet is dropped because the audit script treats `ASTIndexer` / `Diagnostics` as Foundation, not Infrastructure; reconciling the contract-doc Infrastructure table against this classification is deferred to #909. Two new bullets call out documented producers the script arrays don't cover: `Enrichment` (deferred to #906), and `MCPClient` (pre-existing gap also tracked by #909). Apps tier adds `ReleaseTool` + `ConstraintsGen`; phantom `MCP` target removed (no SPM target by that name). Cross-link added to `docs/package-import-contract.md` for the per-target allowed-imports detail. **Research-doc snapshot refresh (`docs/research/pluggability-analysis-2026-05-22.md`).** Revision-history entry v3 added documenting which §3 / §4 MEASURED claims become stale on merge of this PR (the 28-producer count + 1-violation finding are exactly what this PR fixes; pre-merge the doc's measurements were accurate, post-merge they're snapshots). Refs: #895 (child of epic #893), #906 (Enrichment per-pass split queued). - **#886 `scripts/check-issue-body-staleness.sh`: two false-positive patterns fixed.** Surfaced during the 2026-05-21 manual hygiene walk that produced PRs #884 and #885 plus body refreshes on #103, #189, #251. **Bug 1** (rename substring match): `check_renamed` ran each rename-map pattern as a raw `grep -qE` substring against the body, so a body that correctly cited `Packages/Sources/<X>/` matched the bare-Sources hint entries (`Sources/TUI/`, `Sources/Resources/Embedded/`) and was falsely flagged for "missing Packages/ prefix." Confirmed false positives during the walk: #7 (body has the correct `Packages/Sources/TUI/Infrastructure/Screen.swift`), #103 (body has the correct `Packages/Sources/Resources/Embedded/...`). Fix pre-strips every legitimate `Packages/Sources/` to a `__PKG_PREFIX_OK__` sentinel before evaluating any rename-map pattern whose left-hand side starts with `Sources/`; patterns not starting with `Sources/` keep the original body. Sentinel choice has no regex metacharacters and never appears in legitimate bodies. **Bug 2** (single-schema lookup): `check_schema` read only `Packages/Sources/Search/Search.Index.Schema.swift` (search.db) regardless of which table the body cited. Bodies referencing packages.db tables (defined in `PackageIndex.swift`) or samples.db tables (defined in `Sample.Index.Database.swift`) hit the `column not found` flag because the script looked at the wrong file. Confirmed false positive during the walk: #251 (body cites `package_files.kind`, which exists in packages.db, not search.db). Fix replaces the single `SCHEMA_FILE` constant with three named schema-source paths plus a `schema_source_for(table)` router that maps each table to its defining file (search.db: `docs_metadata`, `docs_structured`, `doc_symbols`, `doc_code_examples`, `sample_code_metadata` etc.; packages.db: `package_metadata`, `package_files`, `package_symbols`, `package_imports`; samples.db: `file_imports`, `file_symbols`, `files`, `projects`). `schema_columns_for` now takes the schema file as a parameter; the flag's error message names the file the lookup actually used. `SCHEMA_TABLES` expanded to include the packages.db symbol/import tables and the samples.db tables. **New regression harness:** `scripts/test-check-issue-body-staleness.sh` builds `/tmp/bodies/` with controlled fixtures covering (a) the false-positive cases that must NOT fire post-fix (correctly-prefixed `Packages/Sources/<X>/` citations + `package_files.kind` + `file_imports.module_name`) and (b) the genuine-finding cases that must STILL fire (bare `Sources/<X>/` citations + made-up column on a search.db table), then runs the checker in `--dry-run --check=renamed` and `--check=schema` modes and asserts 7 conditions on the resulting report. Exits 0 only when all 7 assertions pass. Pre-fix run: 3 / 7 assertions fail (both Bug 1 false positives + Bug 2 false positive surface as expected). Post-fix run: 7 / 7 pass. **Live tracker smoke** (against the 77 open issues post-fix): the `Renamed paths` section now lists only genuine bare-Sources citations (#189) and unrelated `docs/tools/` flags (#76, #449, #517, all pre-existing genuine findings, not caused by this patch); the `Stale schema claims` section now lists only genuinely-stale columns (#58 `docs_metadata.source_subtype`, #73 `docs_metadata.abstract` and `.title`). The nightly `.github/workflows/issue-body-staleness.yml` workflow consumes the fixed script unchanged. Refs: #886. - **#754 secondary: `get_inheritance` empty-tree response now picks the kind-aware reason instead of the misleading "Swift value types and protocols" prose.** Surfaced during the 2026-05-21 autopilot merit-audit (pass 22 functional probe): `get_inheritance(symbol: "NSObject", direction: "up")` returned `_No inheritance data: Swift value types and protocols don't carry inherits-from edges. Check search_conformances..._` for what's logically an Objective-C class at the root of its inheritance hierarchy. The primary resolver bug from the original 2026-05-17 report was already fixed in v1.2.0 via the `Issue754NSObjectResolverSuffixTests` regression lock (NSObject and 9 sibling root types now resolve via the Apple-site-suffix-stripping SQL). The remaining wording bug lived in the response formatter, which always wrote the same generic prose regardless of the resolved symbol's actual kind. **Fix.** Extend `Search.InheritanceCandidate` with an optional `kind: String?` field (back-compat for callers that don't fetch it), populate it from `docs_structured.kind` via a LEFT JOIN in `Search.Index.resolveSymbolURIs` (the finer-grained `class` / `protocol` / `struct` / `enum` / `actor` kind, not the coarser `docs_metadata.kind` which is always `symbolPage` for symbol pages). Route every empty-tree formatter through a new `Search.emptyInheritanceMessage(kind:direction:)` helper that returns `_No inheritance data: <kind-aware reason>_`. Every variant keeps the `_No inheritance data` semantic-marker prefix (per the #669 contract for AI clients that grep for it); only the reason after the marker changes per case. Variants: `class` going `up` reads "Root type: no ancestors above this class in the indexed corpus"; `class` going `down` reads "No descendants indexed under this class"; `class` going `both` reads "Isolated class: neither ancestors nor descendants indexed"; `protocol` reads "Swift protocol: protocols don't carry inherits-from edges. Try `search_conformances`..."; `struct` / `enum` / `actor` reads "Swift value type (`<kind>`): value types don't carry inherits-from edges"; nil-kind or unknown-kind falls back to the legacy generic prose. Both MCP (`SearchToolProvider.CompositeToolProvider.handleGetInheritance`) and CLI (`CLIImpl.Command.Inheritance.emitText` + `emitMarkdown`) consume the helper; the CLI's two emit functions gained a `direction` parameter to feed it. End-to-end verification against the brew v1.2.0 binary's dev-isolated build: `get_inheritance(NSObject, up)` now returns `_No inheritance data: Swift protocol: protocols don't carry inherits-from edges. Try search_conformances for the types that conform to this protocol._` (matches what `docs_structured.kind = 'protocol'` says about NSObject's stored row, directs at the correct surface). 10 new tests in `Issue754EmptyInheritanceMessageTests` cover every (kind, direction) pair including the 10 canonical root types from the primary-fix regression suite, the case-insensitive lowering, and the nil/unknown-kind fallback. `Issue669GetInheritanceSemanticMarkerTests` updated: the test still asserts on the `_No inheritance data` semantic-marker prefix, but the legacy "search_conformances" check on the class-going-up path is replaced with a "Root type" assertion (the pre-fix wording for that case was wrong; the test was pinning misleading prose). Full suite 2420 / 348 green (up from 2410 / 347 = +10 in 1 new suite). Refs: #754. - **#883 `install.sh`: stop calling removed `cupertino setup --force` flag.** The `--force` flag was removed from `cupertino setup` (v1.2.0 `Removed` entry: *"use `--keep-existing` or the new default-downloads behaviour"*) but `install.sh` kept calling it on the reinstall code path. Any user with an existing cupertino binary (e.g. via Homebrew) who re-ran `bash <(curl -sSL .../install.sh)` hit `Error: Unknown option '--force'` before any database download. Fix collapses the `if [[ "$REINSTALL" == "true" ]]` branching: both arms now do the same thing, since `cupertino setup` overwrites by default unless `--keep-existing` is passed. README also gets a migration note in the "Instant Setup" section so users with the old `--force` muscle memory know what changed. Refs: #883. ## 1.2.0 (2026-05-20) _The "ironclad" round. 120+ CHANGELOG entries below; +775 net new tests since v1.1.0 (2408 / 347 suites green at v1.2.0 ship); 0 open bug-labeled issues at the time the round closed. First release to ship documented search-quality baselines (Phase 1 canonical lookup + Phase 1.1 deprecation-aware + Phase 1.2 cross-source + Phase 1.3 CamelCase fragment + Phase 1.4 acronym/synonym + Phase 1.5 prose/conceptual + Phase 1.6 symbol-attribute + Phase 1.7 agent-end-to-end), plus 3 paired version-diff audits comparing v1.1.0 → v1.2.0 across two canonical-lookup corpora + one deprecation-pair corpus (110 queries total, **31 newly answer at rank 1, 0 regressions**, McNemar two-sided p ≤ 0.04 on every comparison, ≤ 10⁻⁵ on the largest). Phase 1 harness lives at `scripts/eval/search-quality-phase1.py` (Phase 1.7 agent-end-to-end is the carried-over follow-up, designed in `docs/design/anti-hallucination-eval.md`). Audits live at `docs/audits/search-quality-*-v1.2.0.md`; the live dashboard at https://cupertino.aleahim.com/. **Headline rank-1 accuracy on canonical-lookup queries: 52% → 92%.** Late-round addition: #837 brings the postprocessor (enrichment) pipeline online and gives samples.db + packages.db schemas the same symbol-shaped surface search.db carries (samples.db v3→v4, packages.db v3→v5, new `package_symbols` + `package_imports` tables on packages.db, new `EnrichmentModels` + `Enrichment` SPM targets, AST extraction for package source, AppleConstraints + AppleImports passes wired through `cupertino save` for all three DBs). Coverage on the brew packages corpus: `apple_imports_json` 1/183 → 164/183, `swift_tools_version` 0/183 → 182/183. #514 perf measurement (WAL throughput on the docs workload) is the one carried-over item to v1.3.x as backlog; the samples-workload measurement already landed via PR #515 and the original #236 lock-contention symptom is observably gone._ - **docs(dashboard): lead with v1.2.0 > v1.1.0 result, separate version-diffs from absolute baselines.** Dashboard "At a glance" summary previously counted 11 audits as one bucket, mixing version-diff comparisons (paired, between releases) with absolute baselines (single-system, no comparison). The "6 of 11 strong" framing buried the v1.2.0 > v1.1.0 win behind unrelated absolute-baseline weak spots (prose / acronym / symbol-attribute). Fix splits the dashboard's KPI strip into three: "v1.2.0 vs earlier releases" (4 version-diff audits, all wins), "v1.2.0 absolute quality" (7 single-system baselines), and "Standing weak spots , NOT v1.1.0 regressions" (3 of the baselines, tracked with open issues #818-#821). Summary copy leads with the version-diff result: ~30 queries newly land at rank 1 in v1.2.0, 0 regressions, McNemar p ≤ 0.04 across all paired comparisons (≤ 1e-5 on the 50-query corpus). Plus a Continuation-regex tightening in `scripts/eval/search-quality-phase1-extended.py` so the canonical-V2 corpus credits v1.2.0's more-specific Swift Continuation variants (CheckedContinuation, UnsafeContinuation, AsyncThrowingStream.Continuation). Re-running canonical-V2 with the fixed regex: MRR 0.7974 → 0.9533 (Δ +0.156, Wilcoxon p = 0.011). Refs: #830. ### Added - **#830 / Phase 1.8 cross-validation , v1.1.0 → v1.2.0 confirmed across 3 independent corpora (110 queries total, 31 better, 0 worse).** Follow-up to PR #868. Adds two new paired-comparison corpora that cross-validate the original 50-query canonical-lookup audit's "v1.2.0 > v1.1.0" claim, both pointing the same direction. **Corpus 2 (canonical-lookup-V2, 30 queries, zero overlap with corpus 1):** Range, Numeric, URLComponents, URLRequest, UUID, Locale, Calendar, TimeZone, VStack/HStack/ZStack, Picker, Image, UIImage, UILabel, UIButton, NSButton, PassthroughSubject, CurrentValueSubject, AsyncStream, Continuation, framework roots (CoreGraphics, CoreImage, AVFoundation, MetricKit, OSLog, CryptoKit). MRR 0.7974 → 0.9367 (Δ +0.1393), P@1 21/30 → 28/30, McNemar p=0.039, 8 newly rank-1, 0 regressions. **Corpus 3 (deprecation pairs, 30 modern/legacy triples harvested from `search-quality-deprecation-baseline-v1.2.0.md`):** modern-preferred 27/30 → 30/30 (3 improvements, 0 regressions, clean sweep). Adds `scripts/eval/search-quality-phase1-extended.py` (sibling harness for multi-corpus paired comparison), patches the original v1.1.0-to-v1.2.0 audit MD's header with explicit `**Status:** Strong` + `**Headline:** +20 / 50 queries newly rank-1` lines so the dashboard's `_audit_parser` shows the right card headline (was falling back to "first MRR cell" = 0.6900, the v1.1.0 baseline). Adds mermaid pipeline diagrams to all three audit MDs (corpus → harness → cupertino search × 2 arms → top-10 parse → per-query rank → bucket assignment → Wilcoxon signed-rank + McNemar exact → MD output). Dashboard rebuild: 11 cards (was 9), 6 strong / 2 mixed / 3 weak. Refs: #830. - **#830 / Phase 1.8 , end-to-end `v1.1.0 → v1.2.0` search-quality version-diff audit + Phase 1 harness.** Implements the search-quality harness the methodology design (`docs/design/search-quality-eval.md` §G1-G4) listed as `scripts/eval/search-quality-phase1.py` *(forthcoming)*. 50-query Class A canonical-lookup + Class B framework-root corpus, matching the shape of the existing `search-quality-versiondiff-v1.0.2-to-v1.2.0.md` baseline. Single-arm + paired-arm modes; computes P@1, P@5, MRR, NDCG@10 per arm; paired Wilcoxon signed-rank on per-query reciprocal rank; McNemar 2×2 contingency on the rank-1 outcome; bucketed query lists (Added / Removed / Fixed / Degraded / Unchanged / Both-suboptimal). Strips v1.2.0's ISO 8601 stdout prefix (#780/#781) before parsing JSON. **End-to-end v1.1.0 → v1.2.0 result on the same 50 queries (brew binary + brew search.db v13 vs dev binary + dev search.db v18):** **MRR 0.6900 → 0.9467 (Δ +0.2567)**, **P@1 26/50 → 46/50 (Δ +0.40)**, **NDCG@10 0.9892 → 1.4809**. 4 Added (`Optional`, `Dictionary`, `Data`, `URL` , pages missing from the v1.1.0 bundle entirely), 16 Fixed (rank ≥ 2 → rank 1; includes `Hashable`, `Equatable`, `Observable`, `State property wrapper`, `EnvironmentObject`, `ForEach`, `UIColor`, `NSWindow`), 0 Removed, 1 Degraded (`CoreData` rank 2 → rank 3 , single regression). McNemar two-sided p = 0.000002 (b=0, c=20). Wilcoxon one-sided p (v1.2.0 > v1.1.0) = 0.000025. **The v1.2.0 MRR of 0.9467 exactly matches the existing `v1.0.2-to-v1.2.0` audit's v1.2.0 arm, cross-validating the new harness against the existing measurement.** Audit lands at `docs/audits/search-quality-versiondiff-v1.1.0-to-v1.2.0.md`; dashboard's `regen-all.sh` glob picks it up automatically (index card count 8 → 9, "version diff" cards 1 → 2). Refs: #830, methodology in `docs/design/search-quality-eval.md`. ### Fixed - **#860 + #861 packages.db: `apple_imports_json` and `swift_tools_version` coverage gaps closed (1/183 → 164/183 + 0/183 → 182/183 on the brew corpus).** Two related indexer bugs that both surfaced during the v1.2.0 PR-2 autopilot dev-DB cycle. **#860 , wrong join column on AppleImportsPass.** `Search.PackageIndex.applyAppleImports` joined Apple framework slugs against `LOWER(package_files.module)`. That column carries each indexed package's OWN Swift module name (Soto, Vapor, Rules, SwiftDocC, ComposableArchitecture), not the imported Apple framework set , so coverage was 1/183 (only `apple/swift-system` matched, accidentally, because its module is literally named `System`). Pre-#860, the packages indexer never captured `import X` statements at all; samples.db had a working `file_imports` table but packages.db had no analogue. Fix bumps packages.db schema v4 → v5 with an idempotent additive migration: new `package_imports` table mirroring `samples.db.file_imports` (`file_id`, `module_name`, `line`, `is_exported` + FK to `package_files` with `ON DELETE CASCADE` , picked up automatically by #864's PRAGMA fix), plus two indexes (`idx_package_imports_file`, `idx_package_imports_module`). The `Search.PackageIndex.indexPackage` AST-extraction block now calls `indexPackageImports(fileId:imports:)` immediately after `indexPackageSymbols`, piping `result.imports` from `ASTIndexer.Extractor` into the new table. The applyAppleImports SELECT flips to `JOIN package_imports pi ON pi.file_id = pf.id` so the RHS is the actual import statements. **#861 , `swift_tools_version` never populated.** The brew-shipped `availability.json` files were generated by a pre-#225 `PackageAvailabilityAnnotator` that didn't carry the field; `loadAvailability` decoded `result.swiftToolsVersion == nil` for every package, producing 0/183 coverage even though every Package.swift on disk has a valid `// swift-tools-version: X.Y` declaration on line 1. Fix adds a nonisolated `readSwiftToolsVersionFromPackageManifest(at:)` helper to `Search.PackageIndexer` that reads `<dir>/Package.swift` directly via `ASTIndexer.AvailabilityParsers.parseSwiftToolsVersion` , the same parser the annotator uses , and a `result.swiftToolsVersion ?? readSwiftToolsVersionFromPackageManifest(at: dir)` fallback in `loadAvailability` so a fresh JSON value wins, a stale JSON's nil falls through to the manifest read, and a missing manifest stays nil. **Schema discipline:** `Issue837PackagesV4MigrationTests` updated to expect `Search.PackageIndex.schemaVersion` instead of the hard-coded "4", plus a new assertion that the v3→current migration creates `package_imports` with the expected column set. `Issue837PackagesAppleImportsTests`'s `seedPackage(modules:)` helper extended to also seed `package_imports` rows (the join is on the new column now); the legacy `package_files.module` write is kept so existing assertions about NULL passthrough still hold. **Tests:** 6 new in `Issue860861PackageImportsAndSwiftToolsTests` , fresh DB ships v5 schema with the new table + indexes; applyAppleImports joins through `package_imports` and writes the SwiftUI+Combine subset; a package with zero Apple imports stays NULL; loadAvailability falls back to Package.swift line 1 when JSON omits the field; fallback returns nil when Package.swift is absent; JSON value wins when both are present (no double-read). Full suite **2407 / 347 / 0**. **End-to-end verification on the 183-package dev corpus, two passes (the second after a submodule-path SQL refinement):** `apple_imports_json` populated 1 → 161 (first pass; bare `import Foundation`-style only) → **164** (after the submodule-path normalisation; 19 packages legitimately don't import any Apple framework , server-only Vapor extensions, `swiftlang/swift-cmark`, `swiftlang/swift` itself which carries no source rows the indexer can reach, etc., empirically inspected per package). `swift_tools_version` populated 0 → **182** (99% coverage; the 1 NULL is `swiftlang/swift`, the compiler repo, with a non-standard Package.swift layout the line-1 parser can't recognise , acceptable edge case). Verified rows: `AvdLee/Roadmap = ["foundation","oslog","swiftui"]`, `krzysztofzablocki/Inject = ["appkit","combine","foundation","swiftui","uikit"]`, `mxcl/Version = ["foundation"]` (the submodule-only case captured by the dotted-path normalisation). **Scope guardrail:** packages.db only. search.db indexer + enrichment passes are untouched; samples.db is unaffected (its `file_imports` capture path was already working and is the blueprint #860 ports). Refs: #860, #861, surfaced during the v1.2.0 PR-2 autopilot dev-DB build cycle. - **#864 packages.db: `PRAGMA foreign_keys = ON` on every `Search.PackageIndex` connection so `ON DELETE CASCADE` actually fires on re-runs.** SQLite ships with foreign-key enforcement OFF per connection by default. The schema declares `package_files → package_metadata` and `package_symbols → package_files` as `ON DELETE CASCADE`, but the indexer's connection-open path never ran the PRAGMA. A `cupertino save --packages` re-run against an already-populated DB issued a `DELETE FROM package_metadata WHERE owner = ? AND repo = ?` to wipe the old version of each package; the indexer also issued the file-side delete manually (keeping `package_files` count clean), but the symbol-side cascade never propagated, leaving roughly 1.4M orphan `package_symbols` rows per re-run. Effects: disk bloat, BM25/LIKE scan slowdown across every `Search.PackageQuery` read (including the v1.2.0 generic_constraints boost), and a double-counted `[enrichment/packages-apple-constraints] affected=N` log line that masked the issue. Fix is a single `sqlite3_exec(dbPointer, "PRAGMA foreign_keys = ON", …)` in `Search.PackageIndex.openDatabase` next to the existing `journal_mode = WAL` + `synchronous = NORMAL` + `journal_size_limit = 67108864` pragmas. **Verified end-to-end** on the 183-package dev corpus: run 1 wrote 1,427,449 symbols / 170,292 generic_constraints / 0 orphans; run 2 against the same DB wrote 1,427,449 symbols / 170,292 generic_constraints / 0 orphans (pre-fix: 2,854,898 / 340,584 / 1,427,449). Three new tests in `Issue864PackagesReRunOrphansTests` pin the regression , one verifies `PRAGMA foreign_keys = 1` on the actor's connection, one verifies the value survives re-open, one constructs a real cascade scenario through the schema and asserts the two-hop wipe propagates to zero rows. Companion: `Search.PackageIndex.currentForeignKeysMode()` introspection helper (matches existing `currentSynchronousMode()` / `currentJournalSizeLimit()` pattern). Refs: #864, surfaced during the v1.2.0 PR-2 autopilot dev-DB build cycle. ### Added - **#837 read-side wiring on packages.db + `--apple-imports <module>` filter + MCP packages-fan-out fix + cross-DB `search_generics` (PR-2 of 2 for v1.2.0).** Closes the v1.2.0 read-side gap end-to-end. **(1) Default-search symbol boost on packages.db.** New `Search.PackageQuery.fetchPackageSymbolMatches(question:appleImport:limit:)` JOINs `package_symbols → package_files → package_metadata` and returns the set of `"owner/repo/relpath"` composite keys whose symbol row LIKE-matches the query in any of name / attributes / conformances / signature / generic_constraints. `PackageQuery.answer(...)` applies a `* 3.0` multiplier to those candidates' final scores (matches the apple-docs convention from search.db + the samples.db convention from PR-1). **(2) `--apple-imports <module>` CLI flag.** New `appleImport: String?` parameter on `PackageQuery.answer(...)` adds `AND m.apple_imports_json LIKE '%"' || ? || '"%'` to `fetchCandidates` + `fetchTopFile`. Quote brackets around the JSON array element prevent `swiftui` from substring-matching `swiftuihelper`. Threaded through `CLIImpl.Command.Search.appleImports` → `openPackagesFetcher` → `PackageFTSCandidateFetcher.appleImport` → `PackageQuery.answer(appleImport:)`. Single-source + fan-out paths both honour it. **(3) MCP `apple_imports` parameter.** New schema constant `Shared.Constants.Search.schemaParamAppleImports` advertised on the `search` tool descriptor. Threaded through `CompositeToolProvider.handleSearch` → `handleSearchAll` → `Services.UnifiedSearchService.searchAll(appleImports:)` → `Search.PackagesSearcher.searchPackages(appleImport:)`. **(4) MCP packages fan-out fix.** Pre-PR-2, MCP `search source=packages` routed through `handleSearchDocs(source:"packages")` → `Search.Database.search(source: "packages")` against `search.db`; search.db only carries six source values (apple-docs, apple-archive, hig, swift-evolution, swift-org, swift-book), so every single-source MCP packages query silently returned zero rows. Same dead-letter behaviour in `Services.UnifiedSearchService.searchAll`'s packages async-let. New `Search.PackagesSearcher` protocol seam in SearchModels; `Search.PackageQuery` conforms with two methods: `searchPackages(query:limit:availability:swiftTools:appleImport:)` returning `[Search.Result]` (used by the default-search fan-out + the single-source MCP path), and `searchPackageSymbolsByGenericConstraint(constraint:framework:limit:)` returning `[Search.Result]` (used by cross-DB `search_generics`). New dedicated `CompositeToolProvider.handleSearchPackages` and `UnifiedSearchService.searchPackagesBucket`; the dispatch table routes `source=packages` to the new handler. Both paths fall back to the legacy `search.db` path when no `packages.db` is configured so existing test fixtures continue to render. CLI's `cupertino serve` boot path opens `packages.db` via `Search.PackageQuery` and injects it as `any Search.PackagesSearcher` into both `UnifiedSearchService` + `CompositeToolProvider`. **(5) `search_generics` cross-DB fan-out (closes #857).** `CompositeToolProvider.handleSearchGenerics` now queries all three databases: search.db apple-docs (rich `Search.SymbolSearchResult` rendering preserved), samples.db `file_symbols` via new `Sample.Index.Database.searchFilesByGenericConstraint(constraint:framework:limit:)`, packages.db `package_symbols` via the new `Search.PackagesSearcher` method. Sample.Index.Reader protocol grows the new method with a default impl returning empty so unmigrated stubs compile. Each source renders its own source-tagged markdown section so AI agents see provenance per row. When `search.db` is in `searchIndexDisabledReason` state, the handler still throws an error frame (preserves the `#645` semantic), but missing `search.db` + missing `samples.db` + missing `packages.db` returns whatever the available sources produced rather than erroring. **(6) Per-option doc + schema-spec update.** `docs/commands/search/option (--)/apple-imports.md` covers the CLI flag, MCP equivalent, scope rules, and use cases. `docs/design/per-db-schema-spec.md` §11 cross-DB column mapping table flipped every "not yet wired" packages-side / samples-side row that PR-2 resolved; closes the methodology table on default-search boost, `search_generics` cross-DB, `--apple-imports` wiring, and MCP `source=packages` dispatch. **Tests:** 3 new tests (`Issue837PR2MCPCrossDBTests`) bring the #837 read-side PR-1 + PR-2 total to 8: `mcpFanOutPackagesSearch` (packages.db round-trip through `searchPackages`), `searchPackageSymbolsByGenericConstraintHit` (cross-DB packages arm; positive constraint match + negative non-constraint reject), `appleImportsFilterRestrictsResults` (end-to-end `--apple-imports SwiftUI` narrows the candidate set). Full test suite 2398 / 345 suites green; cited per `feedback_no_false_positive_tests`. **What this means for users:** the v1.2.0 bundle's new write-side columns (`apple_imports_json` on packages.db, `generic_constraints` on samples.db + packages.db) now power both default search ranking AND the cross-DB `search_generics` MCP tool, AND the MCP single-source `source=packages` path returns rich packages.db rows for the first time (previously silent zero results). Refs: #837, #857. - **#837 read-side wiring on search.db + samples.db (apple-docs parity).** The new `generic_constraints` columns populated by #837's write-side passes now influence search ranking instead of being dead data. **search.db:** `Search.Index.searchSymbolsForURIs` (the apple-docs symbol-boost path called from `Search.Index.search`) gains an `OR generic_constraints LIKE ?` clause, so a query like `"View"` lights up rows whose only constraint signal lives in that column. **samples.db:** new public `Sample.Index.Database.searchSymbolsForFiles(query:limit:)` queries `file_symbols` on name + attributes + conformances + signature + generic_constraints, JOINs to `files`, and returns the set of `"projectId|path"` composite keys identifying matched files. `Sample.Index.Reader` protocol grows the same method. `Sample.Search.Service.search` applies a `rank * 3.0` boost to matched `FileSearchResult` rows (mirrors search.db's `rank * 3.0` symbol-boost convention). 4 new tests: positive constraint-only match + negative no-false-positive per DB. Full test suite 2394 / 343 suites green. **packages.db read-side ships in the second PR of this v1.2.0 round, along with the `--apple-imports <module>` CLI flag + MCP parameter.** The v1.2.0 bundle waits for both PRs. Refs: #837, design `docs/design/how-cupertino-answers-a-query.md` §6. - **test(#837, #849) / samples.db + packages.db v3→v4 schema migration round-trip tests.** Two new test files pinning the migration behaviour both DBs run when a v1.1.0-era v3 file opens with the v1.2.0 binary. `Issue837PackagesV4MigrationTests` (3 tests): seeds a v3 packages.db with the v3-shape schema + one row, opens with the v4 `Search.PackageIndex`, asserts `PRAGMA user_version` advances to 4, asserts both new metadata columns + the new `package_symbols` table land with the expected 16-column shape, asserts the original v3 row survives the in-place ALTER, asserts re-opening is idempotent. `Issue837SamplesV4MigrationTests` (3 tests): seeds a v3 samples.db with one row, opens with v4, asserts the wipe-and-rebuild policy fires (row gone, schemaVersion = 4), asserts the new `generic_constraints` + `enrichment_version` columns land on `file_symbols`, asserts that re-opening an already-v4 DB does NOT trigger another wipe (idempotent at the current version). 23/5 tests + suites total across the #837 write-side + migration coverage. Closes the test side of #849. Refs: #837. - **test(#837) / immaculate-coverage tests for samples + packages enrichment passes.** Three new test files, 17 tests across 3 suites, all green. Pins every public surface from the #837 write-path so a regression surfaces in CI rather than after the real `cupertino save` run. `Issue837SamplesAppleStaticConstraintsTests`: s1-s6 against `Sample.Index.Database.applyAppleStaticConstraints`. `Issue837PackagesAppleStaticConstraintsTests`: p1-p6 against `Search.PackageIndex.applyAppleStaticConstraints`. `Issue837PackagesAppleImportsTests`: i1-i5 against `Search.PackageIndex.applyAppleImports`. Each case carries a "why this case matters" docstring tying back to a concrete production failure mode listed in `docs/design/837-pre-index-test-plan.md` §9.2 / §9.3 / §9.4. `Packages/Package.swift` adjusted: `SampleIndexTests` gains `SearchModels` + `ASTIndexer` + `LoggingModels` deps; `SearchTests` gains `LoggingModels` dep. Closes the test side of #847 and #848. Refs: #837. - **#837 / composition-root activation for `cupertino save --samples` and `cupertino save --packages`.** `LiveSamplesIndexingRunner.run` and `LivePackageIndexingRunner.run` now load the same `AppleConstraintsKit.Table` the docs flow uses (sibling JSON at `<base-dir>/apple-constraints.json`) and run `Enrichment.LiveRunner` against the freshly-indexed DB before disconnecting. Samples runs `SamplesAppleConstraintsPass` (target `.samples`). Packages runs `PackagesAppleConstraintsPass` + `PackagesAppleImportsPass` (target `.packages`). Per-pass log lines emit `[enrichment/<pass>] affected=N skipped=M (Tms)` so the operator sees what each pass touched. If the constraints JSON is absent the passes become no-ops; the unenriched bundle is still valid. After this PR a fresh `cupertino save --samples` writes `file_symbols.generic_constraints` + `enrichment_version` for Apple-type rows; a fresh `cupertino save --packages` writes `package_symbols.generic_constraints` + `package_metadata.apple_imports_json` + `enrichment_version`. Touches: `Packages/Sources/CLI/Commands/CLIImpl.Command.Save.Indexers.swift`. Refs: #837. - **#837 phase 1A / `EnrichmentModels` foundation-only protocol seam.** New `Packages/Sources/EnrichmentModels/` target containing the `EnrichmentPass` protocol, `EnrichmentModels.Target` enum (search / samples / packages), and `EnrichmentModels.Result` value type. Lives in the foundation tier (zero dependencies, Foundation import only) so subsequent work can build against the protocol without dragging in `Search` / `SampleIndex` / `CorePackageIndexing`. Mirrors the `IndexerModels` target layout. 4 unit tests cover the value types plus a `DummyPass` conformance check (all green). No behavior change in any shipped binary; pure scaffolding for the cupertino-postprocessor pipeline. Touches: `Packages/Sources/EnrichmentModels/EnrichmentModels.swift` (new), `Packages/Tests/EnrichmentModelsTests/EnrichmentModelsTests.swift` (new), `Packages/Package.swift` (+ target + test target + library product). Refs: #837, epic #769, design doc `docs/design/post-processor.md`. - **#837 / Apple-constraints + Apple-imports enrichment methods for samples.db + packages.db, three new passes.** Adds three concrete `EnrichmentPass` implementations plus the underlying DB methods they wrap. `Sample.Index.Database.applyAppleStaticConstraints(lookup:enrichmentVersion:)` iterates the lookup entries, derives lowercased type-name keys from each `docURI` last segment, and `UPDATE file_symbols SET generic_constraints = ?, enrichment_version = ?` for every match. `Search.PackageIndex.applyAppleStaticConstraints` does the same for the new `package_symbols` table. `Search.PackageIndex.applyAppleImports` aggregates `package_files.module` entries against the Apple-module set extracted from constraint-lookup `docURI`s, writes the (sorted) JSON array into `package_metadata.apple_imports_json`, and stamps `enrichment_version`. Three matching passes in `Enrichment`: `SamplesAppleConstraintsPass` (target=.samples), `PackagesAppleConstraintsPass` (target=.packages), `PackagesAppleImportsPass` (target=.packages). All three take the same `StaticConstraintsLookup` instance the search-target `AppleConstraintsPass` already uses , single source of truth from `AppleConstraintsKit` / cupertino-symbolgraphs. SampleIndex target gains `SearchModels` dependency; Enrichment target gains `SampleIndex` + `SampleIndexModels` + `SharedConstants` dependencies. Composition-root wiring (constructing a `LiveRunner` with these passes for `cupertino save --samples` and `cupertino save --packages`) lands in a follow-up. The methods + passes are dormant until then. Touches: `Packages/Sources/SampleIndex/Sample.Index.Database.swift` (+ SearchModels import + 60-line method), `Packages/Sources/Search/PackageIndex.swift` (+ both methods, +140 lines), `Packages/Sources/Enrichment/Enrichment.SamplesAppleConstraintsPass.swift` (new), `Packages/Sources/Enrichment/Enrichment.PackagesAppleConstraintsPass.swift` (new), `Packages/Sources/Enrichment/Enrichment.PackagesAppleImportsPass.swift` (new), `Packages/Package.swift` (deps). Refs: #837. - **#837 stage 2 / AST extraction populates `package_symbols` during `cupertino save --packages`.** `Search.PackageIndex.insertFile` now invokes `ASTIndexer.Extractor` for every `.swift` file it indexes and writes the extracted symbols into the new `package_symbols` table (parallel to how `Sample.Index.Database.indexSymbols` populates samples.db's `file_symbols`). `Search.PackageIndex` gains the `import ASTIndexer` it needed; the `indexPackageSymbols(fileId:symbols:)` private method handles all 13 column bindings (name, kind, line, column, signature, is_async, is_throws, is_public, is_static, attributes, conformances, generic_params). Same skip-on-bind-failure policy as samples (one bad symbol doesn't tear down an entire package). Wraps in the existing per-package transaction. Verified: full test suite 2367 / 336 suites green after the change. The new symbols land empty until the next `cupertino save --packages` run; existing v3 packages.db bundles continue to migrate via `migrateToVersion4` which creates the table (empty) without re-extraction. Touches: `Packages/Sources/Search/PackageIndex.swift`. Refs: #837. - **#837 stage 1+2 schema bumps / samples.db v3→v4, packages.db v3→v4 with parallel symbol surface.** Brings samples.db and packages.db structurally up to the level search.db carries so the postprocessor pipeline can apply equal-quality enrichment to all three DBs. **samples.db v4** adds `file_symbols.generic_constraints` (parallel to search.db's `doc_symbols.generic_constraints`, written by the samples-apple-constraints pass) and `file_symbols.enrichment_version` (idempotency tracking). Wipe-and-rebuild semantics per the existing samples-DB migration policy. **packages.db v4** adds a new `package_symbols` table (full parallel of samples.db's `file_symbols` , id / file_id / name / kind / line / column / signature / is_async / is_throws / is_public / is_static / attributes / conformances / generic_params / generic_constraints / enrichment_version + FOREIGN KEY to package_files) plus `package_metadata.apple_imports_json` (JSON array of Apple framework modules a package imports) and `package_metadata.enrichment_version`. v3→v4 migration is in-place `ALTER TABLE` for the metadata columns + `CREATE TABLE IF NOT EXISTS package_symbols` for the new table. Indexes parallel the samples + docs pattern. The new `package_symbols` table is empty until the packages indexer's AST extraction pass lands (separate PR); the postprocessor enrichment passes for samples + packages also land separately. This PR is pure schema scaffolding. Test edits: `Issue225SwiftToolsVersionIntegrationTests.freshSchemaHasColumnAndIndex` switched from literal `"3"` user_version assertion to a `String(Search.PackageIndex.schemaVersion)` assertion so the test survives future schema bumps. Full test suite 2367 / 336 suites green. Touches: `Packages/Sources/SampleIndex/Sample.Index.Database.swift` (schemaVersion 3→4, two new columns + index on file_symbols), `Packages/Sources/Search/PackageIndex.swift` (schemaVersion 3→4, two new columns + index on package_metadata, new `package_symbols` table + 4 indexes, `migrateToVersion4()` for in-place upgrade), `Packages/Tests/SearchTests/Issue225SwiftToolsVersionIntegrationTests.swift` (schema-version assertion). Refs: #837, design doc `docs/design/per-db-enrichment.md`. - **#837 composition-root activation / `cupertino save --docs` routes enrichment through the postprocessor pipeline.** Updates `CLIImpl.Command.Save.Indexers` to construct `Enrichment.LiveRunner` with the three search-target passes (`SynonymsPass`, `AppleConstraintsPass`, `HierarchyPass`) and inject it into `Search.IndexBuilder`. The runner becomes the canonical path; `IndexBuilder` still keeps the inline fallback when `enrichmentRunner` is nil so direct constructors (tests, smoke harnesses) continue to work. CLI target gains `Enrichment` + `EnrichmentModels` dependencies in `Package.swift`. Full test suite 2367 / 336 suites green after activation. No behavioural difference vs the inline path (the three passes run in the same order, write the same rows); the difference is structural , enrichment is now a pluggable pipeline ready to receive samples + packages passes in phase 3. Touches: `Packages/Sources/CLI/Commands/CLIImpl.Command.Save.Indexers.swift`, `Packages/Package.swift`. Refs: #837. - **#837 phase 1B-2 / three concrete enrichment passes + `Search.IndexBuilder` runner injection.** Adds the live `SynonymsPass`, `AppleConstraintsPass`, `HierarchyPass` to the `Enrichment` package. Each one wraps an existing public method on `Search.Index` (`updateFrameworkSynonyms`, `applyAppleStaticConstraints(lookup:)`, `propagateConstraintsFromParents()`) so the SQL implementations stay where they are; the passes exist to route the calls through the postprocessor pipeline. The 22-entry framework alias list moved out of `Search.IndexBuilder` (where it was a private method `registerFrameworkSynonyms`) into `Enrichment.SynonymsPass.aliases` since it is enrichment data, not indexer data. `Search.IndexBuilder` now accepts an optional `enrichmentRunner: (any EnrichmentRunner)?` via init; when non-nil, `buildIndex` runs `enrichmentRunner.run(target: .search)` and logs per-pass affected / skipped / duration. When nil (the default), the existing inline calls run as before , keeps every current caller (tests, smoke harnesses, downstream binaries) working with zero changes. Composition root activation lands in a follow-up PR. `Enrichment` target gains a `Search` + `SearchModels` dependency; `Search` target gains an `EnrichmentModels` dependency (foundation seam, no cycle). 557 / 79 search suites green after the refactor. Touches: `Packages/Sources/Enrichment/Enrichment.SynonymsPass.swift` (new), `Packages/Sources/Enrichment/Enrichment.AppleConstraintsPass.swift` (new), `Packages/Sources/Enrichment/Enrichment.HierarchyPass.swift` (new), `Packages/Sources/Search/Search.IndexBuilder.swift` (optional runner param + dispatch branch), `Packages/Package.swift` (deps adjusted). Refs: #837. - **#837 phase 1B / `Enrichment` live package + `LiveRunner` topologically-sorted coordinator.** New `Packages/Sources/Enrichment/` target containing `Enrichment.LiveRunner`, a concrete `EnrichmentRunner` that holds a registry of `EnrichmentPass` instances and dispatches them in `dependsOn` topological order. Behaviour: passes are scoped to a single `Target` (search / samples / packages); a throwing pass is recorded with `rowsAffected = 0` but does not abort sibling passes (the #779 lesson, ported to enrichment); a pass whose declared dependency failed is skipped; cycles surface as `LiveRunner.RegistryError.cycleDetected`. Adds `EnrichmentRunner` protocol to `EnrichmentModels` for the seam, and tightens the `EnrichmentPass.run` parameter from `OpaquePointer` to `OpaquePointer?` so live passes that hold an injected `Search.Index` can pass `nil` instead of having to manufacture a sentinel handle. 6 new unit tests cover the runner (empty registry, topological order, target filter, throw isolation, dependency failure propagation, cycle detection); all 10 tests across the two new packages green. Still scaffolding only; concrete pass implementations (synonyms / constraints / hierarchy) move out of `Search.IndexBuilder` in phase 1B-2. Touches: `Packages/Sources/EnrichmentModels/EnrichmentModels.swift` (+ `EnrichmentRunner` protocol, `run` parameter optional), `Packages/Sources/Enrichment/Enrichment.swift` (new namespace anchor), `Packages/Sources/Enrichment/Enrichment.LiveRunner.swift` (new), `Packages/Tests/EnrichmentTests/EnrichmentTests.swift` (new, 6 tests), `Packages/Tests/EnrichmentModelsTests/EnrichmentModelsTests.swift` (parameter update), `Packages/Package.swift` (+ Enrichment target + test target + library product). Refs: #837, design doc `docs/design/post-processor.md`. _The matching corpus snapshot is `cupertino-docs@v1.2.0` (post-Claw-merge: 414,807 source files, +2,285 new pages + 498 richer overwrites from the Claw mini 5.5-day crawl, 153 React-SPA-404 poison files filtered out at the merge boundary, 13-category poison audit zero-matches on the merged corpus). The bundle `cupertino-databases-v1.2.0.zip` is rebuilt from that source state._ ### Added (continued) - **#794 / `scripts/check-pre-index.sh` (pre-flight validation gate before any 11h+ real save).** Runs a 5-11 min save against the `setup-mini-corpus.sh` 10% fixture and asserts two gate classes: **Gate 1 (cleanup)** , search.db has no residue from the #789 packages-table removal (zero `packages*` tables in `sqlite_master`, zero `swift-packages` emission in the save log, zero orphan `FROM packages` / `PackagesIndexer` / `Search.SwiftPackagesStrategy` references in non-comment non-#789-context source); **Gate 2 (symbolgraph value-add)** , `apple-constraints.json` is present and >5MB (proves the catalog isn't stubbed), the save log contains `Applied authoritative Apple constraints table` (proves the #759 iter-3 pass fired, not silently no-op'd), and `doc_symbols.generic_constraints` has at least 500 populated rows on the 10% fixture (empirically measured at ~3,474 rows; floor of 500 accommodates fixture-mix variance while still catching iter-3 silent no-op which would be 0 rows). Failure on any gate exits non-zero with the offending gate named + a specific remediation command (the actual grep / sqlite3 query / file path to inspect). Validation: ran twice end-to-end against the 10% mini-corpus during implementation , first run surfaced two script bugs that the implementation fixed (the comment-line exclusion pattern was keying on grep-output prefix shape vs content; the original coverage metric was a ratio that doesn't survive iter-3 populating constraint rows independently of params); second run all-green. Wall time per run: ~11 min, 1/60th the cost of the real save. Wired in by docs + by the postmortem at `docs/postmortems/2026-05-18-save-symlink-enotdir.md` §7.2 follow-ups, which now lists this script as the bracket-the-real-save pre-gate companion to #792's post-index comparator. Touches: `scripts/check-pre-index.sh` (new). Refs: #794, #789, #779, cupertino-symbolgraphs v0.1.1. ### Changed - **#779 / Issue779OptionalDirSymlinkTests integration test + postmortem settled.** Closes the integration-test gap from the #779 postmortem (`docs/postmortems/2026-05-18-save-symlink-enotdir.md`). New `Packages/Tests/SearchTests/Issue779OptionalDirSymlinkTests.swift` has two tests against a tmp fixture: positive (post-fix path: `SwiftEvolutionStrategy` with a URL through `resolvingSymlinksInPath()` indexes content cleanly from a leaf directory-symlink) and negative ENOTDIR sentinel (pre-fix shape: same strategy with the raw symlink URL throws NSCocoa 256 with `NSPOSIXErrorDomain` code 20 underlying). Pins the bug as a regression sentinel; any future change that breaks `resolvingSymlinksInPath()` at the composition root surfaces in the negative test. Postmortem flipped from `draft` to `settled` with PR #788 + this commit filling §7.1, all §7.2 follow-ups linked to filed issues (#786, #789, #791, #792), and the timeline updated with the actual fix-landed-at timestamps. - **#789 / drop redundant `packages` + `package_dependencies` tables from search.db (packages.db is the canonical store).** The in-search.db `packages` table was a shallow duplicate of `packages.db`'s `package_metadata`, fed from the slimmed-to-empty bundled `Core.Protocols.SwiftPackagesCatalog` URL list, and added zero functional value over the canonical `packages.db` (943 MB, 183 packages + 20,186 files, built by `cupertino save --packages`, queried by `cupertino package-search`). Removed: `Search.SwiftPackagesStrategy` + the `Packages/Sources/Search/Strategies/Search.Strategies.SwiftPackages.swift` source, `Search.PackagesIndexer` + its registration in `Search.IndexerRegistry.indexers`, the `searchPackages(query:limit:)` reader on `Search.Index`, the `packageCount()` accessor on `Search.Index`, the `CREATE TABLE packages` + `CREATE TABLE package_dependencies` + their indices in `Search.Index.Schema`. The strategy registration in `Search.IndexBuilder.makeDefaultStrategies` is gone too. Schema version bumped 17 → 18 with a `migrateToVersion18()` migration that issues `DROP TABLE IF EXISTS package_dependencies; DROP TABLE IF EXISTS packages;` on existing DBs (idempotent; indexes drop with their parent tables in sqlite; old `cupertino` binaries opening a v18 DB still hit the existing `Search.Error.schemaVersionMismatch` path with the "rebuild via `cupertino setup`" remediation). The `Core.Protocols.SwiftPackagesCatalog` source is preserved because `TUI/PackageCurator` consumes it independently as the seed list for the curate-mode picker; the file got a docstring update naming TUI as the lone surviving consumer. Test updates: `Issue635SchemaStampGuardTests.stripCurrentVersionColumnAndIndex` rewritten to re-CREATE the dropped `packages` + `package_dependencies` tables (so the v17 → v18 in-place migration has something to drop on the round-trip); `DocKindIntegrationTests` schema-version constant bumped 17 → 18; `Issue755GenericConstraintsTests` schema-version assertions switched from literal `17` to `Search.Index.schemaVersion` so future schema bumps don't need test edits; `CLICommandTests/SaveTests` empty-contract test's `searchPackages` + `packageCount` calls replaced with a `documentCount()` invariant after the underlying methods were deleted. Full test run: 2,351 / 332 suites green. Touches: `Packages/Sources/Search/Strategies/Search.Strategies.SwiftPackages.swift` (deleted), `Packages/Sources/Search/Search.IndexBuilder.swift` (strategy registration removed), `Packages/Sources/Search/Search.Index.Schema.swift` (table + index defs removed), `Packages/Sources/Search/Search.Index.swift` (schemaVersion bump), `Packages/Sources/Search/Search.Index.Migrations.swift` (migrateToVersion18 added), `Packages/Sources/Search/Search.Index.ContentAndPackages.swift` (searchPackages deleted), `Packages/Sources/Search/Search.Index.CountsAndAliases.swift` (packageCount deleted), `Packages/Sources/Search/Search.SourceIndexer.swift` (PackagesIndexer + indexer-registry entry deleted), `Packages/Sources/CoreProtocols/Core.Protocols.SwiftPackagesCatalog.swift` (docstring update only; file preserved for TUI consumer), test edits as above. Refs: #789. ### Added - **#779 fix / `Indexer.DocsService.optionalDir` resolves symlinks + `Search.IndexBuilder` per-strategy `do/catch`.** Resolves the original #779 crash root cause. `Indexer.DocsService.optionalDir` now returns `url.resolvingSymlinksInPath()` instead of the raw URL before handing it to the source strategies. `resolvingSymlinksInPath()` is a no-op on non-symlink URLs (safe for brew users with no symlinks under `~/.cupertino/`) and dereferences the leaf directory-symlink for dev-layout users whose `~/.cupertino-dev/{swift-evolution,swift-org,archive,hig}` symlink into `~/.cupertino/`. This fixes the `ENOTDIR` / `NSCocoa 256` "couldn't be opened" crash that aborted the 2026-05-18 reindex at the 11h mark right after the apple-docs phase completed. Plus defense-in-depth in `Search.IndexBuilder.buildIndex`: the source-strategy `for`-loop now wraps each `try await strategy.indexItems(...)` in `do/catch`, records a throw as `IndexStats(wasSkipped: true, skipReason: ...)`, and continues. The three enrichment passes (`registerFrameworkSynonyms`, `applyAppleStaticConstraints`, `propagateConstraintsFromParents`) that live after the loop now always run, even if a single strategy bombs. Combined with the #786 wrapper migration that landed in the same release, the entire FileManager URL-variant-on-symlink-leaf class of bug is removed from the save pipeline. Touches: `Packages/Sources/Indexer/Indexer.DocsService.swift` (one line + a why-comment), `Packages/Sources/Search/Search.IndexBuilder.swift` (5-line do/catch). Validated end-to-end against the `setup-mini-corpus.sh` 10% fixture (per the planned validation save). Refs: postmortem at `docs/postmortems/2026-05-18-save-symlink-enotdir.md`; postmortem status flips from `draft` to `settled` when this lands. - **#786 / `Shared.Utils.FileSystem` symlink-safe directory-listing wrappers + 6-site migration.** Adds two static methods on a new `Shared.Utils.FileSystem` namespace in `SharedConstants`: `contentsOfDirectory(at:includingPropertiesForKeys:options:)` and `enumerator(at:includingPropertiesForKeys:options:)`. Both pre-resolve the URL via `URL.resolvingSymlinksInPath()` and delegate to the matching `FileManager` URL-variant API, which fixes the documented Foundation quirk where the URL-variant APIs do NOT follow a leaf directory-symlink (kernel returns `ENOTDIR`, Foundation wraps as `NSCocoaErrorDomain` code 256 with the bare `"couldn't be opened"` string; same class of bug as #779). `resolvingSymlinksInPath()` is a no-op on non-symlink URLs, so the wrappers are safe for every existing caller regardless of whether the input is symlinked. Seven at-risk call sites migrated from the raw `FileManager` URL-variant APIs to the wrappers: `Search.PackageIndexer` (two calls in `discoverPackageDirectories()` + one `enumerator(at:)` call in `walkDirectoryForFiles`, the third caught by c1's PR critic), `CLIImpl.Command.Fetch` (two calls in the owners/repos enumeration), `CLIImpl.Command.Doctor` (one call in the zip-archive directory probe), `Core.PackageIndexing.PackageAvailabilityAnnotator` (one `enumerator(at:)` call). The original #779 site in `Search.Strategies.SwiftEvolution` is intentionally NOT touched here; it has its own composition-root fix queued on `fix/779-symlink-resolve`. New test suite `Issue786FileSystemTests` (5 tests) under `Packages/Tests/SharedUtilsTests/`: pins the documented divergence (raw API throws `ENOTDIR`, wrapper succeeds, wrapper is a no-op on real dirs, same for `enumerator`). 5/5 green. Touches: `Packages/Sources/Shared/Utils/Shared.Utils.FileSystem.swift` (new), `Packages/Sources/Core/PackageIndexing/Core.PackageIndexing.PackageAvailabilityAnnotator.swift` (+ `import SharedConstants`, migrate enumerator call), `Packages/Sources/Search/PackageIndexer.swift` (migrate 2 calls), `Packages/Sources/CLI/Commands/CLIImpl.Command.Fetch.swift` (migrate 2 calls), `Packages/Sources/CLI/Commands/CLIImpl.Command.Doctor.swift` (migrate 1 call), `Packages/Tests/SharedUtilsTests/Issue786FileSystemTests.swift` (new). Zero binary-contract change; pure latent-bug removal at 6 sites. - **#779 / `scripts/setup-mini-corpus.sh` (representative test corpus for end-to-end pipeline validation).** New 110-line script that builds a small but representative test corpus by symlinking ~10% of each framework's files from `~/.cupertino/docs/` into a per-Mac output dir (default `/Volumes/Code/DeveloperExt/public/cupertino-mini-corpus/`). All 402 framework subdirs contribute (min 3 files per framework floor for tiny dirs); total ~42K docs symlinked, ~5 min indexing runtime (measured on the Studio; throughput is ~137 docs/s through the apple-docs phase, vs ~8.7 docs/s on the full production corpus at this binary). The four optional source dirs (`swift-evolution`, `swift-org`, `archive`, `hig`) are LEAF directory-symlinks pointing into `~/.cupertino/`; this is the exact failure shape that triggers #779 `ENOTDIR` from `FileManager.contentsOfDirectory(at:)`, so this corpus is simultaneously the end-to-end pipeline check AND the regression lock for the eventual `optionalDir` fix. `apple-constraints.json` is also symlinked from `~/.cupertino-dev/` (10 MB iter-3 constraints table). Idempotent: re-running fully resets the output dir; zero file duplication; the output is just inode entries. Sister scripts: `scripts/smoke-reindex.sh` (1s throwaway DB on the 7-page synthetic fixture), `scripts/make-mini-db.sh` (persistent DB on the same synthetic fixture). This sits one tier larger and tests against real data. Pre-`optionalDir`-fix: expect `Error: The file "swift-evolution" couldn't be opened.` after apple-docs phase. Post-fix: expect success with all four optional sources indexed and the three enrichment passes complete. Zero behavior change in shipped binary; new script only. Touches: `scripts/setup-mini-corpus.sh` (new). Coordinated via #783 (c1/c2 channel) and `~/Downloads/claude-chat.jsonl` (file-based pair chat per the `/multi-claude:claude-chat` skill). - **#779 / postmortem doc + template (FAANG-style root-cause analysis).** Adds `docs/postmortems/` as a sibling to `docs/design/`, `docs/audits/`, `docs/handoff/`, `docs/plans/`. New `docs/postmortems/README.md` documents when to use postmortem vs design vs audit vs handoff (forward-looking design lives in `docs/design/`; retrospective root-cause writeups live in `docs/postmortems/`). New `docs/postmortems/_TEMPLATE.md`, byte-identical to `mihaela-agents/Rules/universal/templates/postmortem.md`, follows the section consensus across published big-tech postmortems (Google SRE, Amazon COE, Meta SEV, Microsoft Azure PIR, GitLab handbook RCA, Stripe, Cloudflare): Summary, Impact, Timeline, Detection, Root Cause (with optional 5-Whys), Resolution, Follow-ups (incl. "Where we got lucky" + Lessons), optional Background / Architecture. First consumer: `docs/postmortems/2026-05-18-save-symlink-enotdir.md` (draft status) for issue #779, the save-aborts-on-symlinked-optional-source-dir incident. The postmortem documents the actual root cause (`FileManager.contentsOfDirectory(at:)` URL variant does not follow leaf directory-symlinks; `ENOTDIR` / `NSCocoa 256`), retracts the `EMFILE` / fd-leak hypothesis from the issue's earlier comments, identifies the contributing factor (no per-strategy `do/catch` in `Search.IndexBuilder.buildIndex` lets one strategy throw strand the post-loop enrichment passes), and queues the one-line `optionalDir` fix + defense-in-depth `do/catch` + integration test as Follow-ups. Zero behavior change; docs only. Touches: `docs/postmortems/README.md` (new), `docs/postmortems/_TEMPLATE.md` (new), `docs/postmortems/2026-05-18-save-symlink-enotdir.md` (new). - **#780, #781 , per-line ISO 8601 timestamps + invocation banner in CLI logs.** Two complementary observability gaps surfaced by the #779 reindex crash diagnosis on 2026-05-18. Both fixed at the composition root so producer code is unchanged. **#780 (timestamps):** `Logging.Unified.dateFormatter` format moves from `"yyyy-MM-dd HH:mm:ss.SSS"` (no timezone, ambiguous across DST) to `"yyyy-MM-dd'T'HH:mm:ssZZZ"` (ISO 8601 with numeric offset, e.g. `2026-05-19T02:30:00+0200`). Locale pinned to `en_US_POSIX` so the field shape is invariant. `Logging.Unified.Options.default` flips `showTimestamps` to true in **release** too (was debug-only); pre-#780 the v1.2.0 release builds shipped without timestamps, which made the 14-hour #779 crash diagnosis harder than necessary. `logToConsole` drops the surrounding brackets (ISO 8601 is self-delimiting; saves ~3 chars × 350k lines per reindex). `Logging.LiveRecording.output(_:)` (the sync stdout-passthrough used for `--format json` dumps and progress lines) now also prefixes timestamps via a new nonisolated `Logging.timestampPrefix()` static helper, so the sync path is on the same cadence as the actor-routed `record(...)` path. Empty messages stay empty (visual spacers don't bloat into `<timestamp> `). **#781 (invocation banner):** new `Logging.Composition.logInvocation()` emits a five-line banner (`🚀 <argv>`, `📍 binary:`, `📍 cwd:`, `📍 PID:`, `📍 parent PID:`) at the top of `cupertino save` / `fetch` / `setup`. Combined with the wrapper-script header in `~/bin/reindex-cupertino-dev.sh`, this preserves two layers of invocation paper trail: what the user / cron / launchd typed vs. what the binary received as argv. The two diverge when wrappers `nohup`/`disown` or rewrite flags; preserving both is what made #779 diagnosis recoverable. **Drive-by:** `warnOnceAboutFileHandleFailure` switched from `message.data(using: .utf8)` to `Data(message.utf8)` (SwiftLint `use_data_constructor_over_string_member`). **Verified:** `scripts/smoke-reindex.sh` against the 7-page fixture corpus shows banner as the first five lines + every subsequent line carries the ISO 8601 prefix; 9/10 smoke checks pass (the one failure is a pre-existing stale `expected schema v15` assertion in the smoke script; develop is at v17, unrelated to this change). Touches: `Packages/Sources/Logging/Logging.swift` (+ `timestampPrefix` helper), `Packages/Sources/Logging/Logging.Unified.swift` (format + default), `Packages/Sources/Logging/Logging.LiveRecording.swift` (output(_:) prefix), `Packages/Sources/Logging/Logging.Composition.swift` (+ logInvocation), `Packages/Sources/CLI/Commands/CLIImpl.Command.Save.swift` + `Fetch.swift` + `Setup.swift` (call logInvocation in run()). **Known pre-existing limitation (out of scope):** preflight's quick successive `recording.info(...)` calls can be reordered relative to each other because `LiveRecording.record(...)` dispatches via `Task.detached`. The reorder pre-existed; timestamps make it visible; tracked separately. - **`Indexer.DocsService` emits new lifecycle events for output path + per-source detection (positive side).** Two new events in `Indexer.DocsService.Event`: - `.databaseTarget(URL)` , fired once at the start of `run` before any disk activity, carrying the resolved `search.db` output path. `CLIImpl.Command.Save.Indexers.handleDocsEvent` renders it as `💾 Output: <path>`. Lets users tracking long-running save jobs confirm where output is going without having to re-derive `baseDir + filename` from CLI args. - `.foundOptionalSource(label:url:)` , symmetric to the existing `.missingOptionalSource` event; fires once per optional source (Swift Evolution / Swift.org / Apple Archive / HIG) whose directory IS present on disk at startup. `handleDocsEvent` renders it as `✅ <label> directory found at <path>`. Closes the asymmetric-logging gap where the miss path was loud but the success path was silent , long-running save jobs now surface upfront which optional sources will be indexed instead of forcing the user to wait potentially hours for the per-source strategy to start running before getting a positive signal. `Indexer.DocsService.optionalDir(_:label:events:)` now emits the new event on the success branch. Zero behaviour change beyond the new log lines; no schema, no DB shape, no version bump. Touches: `Packages/Sources/IndexerModels/Indexer.DocsService.swift` (event enum +2 cases), `Packages/Sources/Indexer/Indexer.DocsService.swift` (emit sites), `Packages/Sources/CLI/Commands/CLIImpl.Command.Save.Indexers.swift` (handler cases). ### Changed (docs / comments only) - **Sweep stale `cupertino-docs-private` references after the repo was deleted (2026-05-18).** The private corpus mirror `mihaelamj/cupertino-docs-private` was removed per user direction ("I don't need it"); four in-tree references framed the #275 freshness probe as a thing for "users without a `cupertino-docs-private` checkout" which is now misleading (no one has such a checkout). Surgical wording change in `docs/commands/doctor/option (--)/freshness.md`, `Packages/Tests/SearchTests/Issue275FreshnessProbeTests.swift`, `Packages/Sources/Diagnostics/Diagnostics.Probes.swift`, `Packages/Sources/CLI/Commands/CLIImpl.Command.Doctor.swift` , replaces "users without a `cupertino-docs-private` checkout" with "users without git-level access to the raw corpus repo". Preserves the underlying explanation (brew users have no git-level access to the raw corpus repo, so they can't `git log` to see when Apple's pages last changed); just drops the deleted-repo name. Zero behavior change: comments + one .md doc only, compiles to identical output. Build clean. Issue275FreshnessProbe: 6/6 green. The historical #275 entry below is preserved as-shipped; the sweep only touches the live in-tree references. ### Added - **#226 , MCP cross-source `platform_filter_partial` notice + min_* validation (closes the two acceptance bullets Stage A's closure-replay audit caught as never-shipped).** PR #706 (the original #226 work) shipped the schema-axis half , 5 `min_*` parameters on the unified `search` tool + all 4 AST tools, plumbed through to `Search.PlatformFilter.passes` for apple-docs / packages. Stage A's closure-replay audit (`docs/audits/closure-replay-2026-05-17.md`) caught two acceptance bullets that never shipped: (a) **parameter validation** , pre-#226 the `min_*` args silently accepted empty strings, whitespace, and shapes like `"v18.0"` / `"ios18"` / `"18..0"`, all of which slipped through `PlatformFilter.passes`'s lexicographic-after-split-on-dot comparator and produced surprising matches; (b) **cross-source `info.platform_filter_partial` notice** , when the user passed platform filters AND the response contained rows from sources that don't honour them (hig / swift-evolution / swift-org / swift-book / apple-archive, plus samples , the tool handler silently drops the filter at the MCP boundary for the latter even though the data carries `min_*` columns), the response carried zero signal that filter was partially honoured. AI clients had no way to tell which rows were filtered and which weren't. This PR closes both. **(a) Validation.** New `CompositeToolProvider.validatePlatformValue(_:paramName:)` rejects empty / whitespace / non-numeric-semver-prefix shapes with `Shared.Core.ToolError.invalidArgument(<paramName>, <message>)` carrying the offending value in the message. Accepts: major (`"18"`), major.minor (`"18.0"`), full semver (`"18.0.1"`), arbitrary-depth digit-group chains (`"18.0.1.2"`), all trimmed. Rejects: empty, whitespace-only, leading/trailing letters, embedded letters, empty interior segments, leading/trailing dots, alternate separators. `extractPlatformArgs` is now `throws` and runs every `min_*` through the validator before constructing the `PlatformArgs` struct , every search-style tool handler that called `Self.extractPlatformArgs(args)` (5 sites , `handleSearch` + 4 AST tools + `search_generics`) now propagates validation throws cleanly. **(b) Cross-source notice.** New `Search.PlatformFilterScope` enum in SearchModels is the single source of truth for which sources honour the filter , `appliesFilter = {apple-docs, packages}`, `silentlyIgnoresFilter = {apple-archive, hig, swift-evolution, swift-org, swift-book, samples, apple-sample-code}`. New `PlatformFilterScope.partialNoticeMarkdown(platformDescriptions:contributingSources:)` returns the notice block , a Markdown blockquote starting `> ℹ️ **platform_filter_partial** , Your platform filter (<descs>) was honoured for: <filtered>. The following sources do not honour platform filters and are included unfiltered in this response: <unfiltered>.` , or nil when no notice is needed. New `PlatformFilterScope.dispatchSources(for:)` resolves the user's `source` parameter (nil / `"all"` / empty → fan-out; known source → `[source]`; `appleSampleCode` alias canonicalises to `samples`) so the dispatcher can decide whether to fire the notice pre-dispatch without waiting for the search to run. The notice is decided in `handleSearch` (line 535) before the per-source dispatch and prepended via the new `CompositeToolProvider.prependNoticeIfNeeded(notice:to:)` static helper that mutates only the first text-content block; image / resource blocks pass through untouched, `isError` is preserved. The marker string `platform_filter_partial` is stable so AI clients can grep for it rather than parse prose. **Why the original "platform + min_version required-together" rule didn't translate:** the shipped 5-field shape (`min_ios` / `min_macos` / etc.) makes each field self-naming , the platform is implied by the field name. The pair rule is structurally moot; the related class-of-bug (silent no-op on malformed input) is what the validation actually catches. **Tests (42 across 7 suites):** `Issue226PlatformValidationTests` (validation pure function decision tree , 7 accept cases + 11 reject cases + paramName-in-error + offending-value-in-error), `Issue226PlatformFilterScopeTests` in SearchModelsTests (bucket assignments , `appliesFilter` is exactly `{apple-docs, packages}`, `silentlyIgnoresFilter` is the 7-source set, partition function decision tree, dispatchSources matrix including nil / empty / `"all"` / alias / unknown cases, partialNoticeMarkdown returns nil for no-platform / all-aware / fires for mixed / fires for all-unaware / lists multiple platforms / output is blockquote / ends with blank-line separator for clean prepend), `Issue226CrossSourceNoticeMCPMarkerTests` in SearchToolProviderTests (MCP `callTool` boundary: validation rejects 8 malformed shapes on unified search, validation rejects on every AST tool, validation rejects on every `min_*` parameter, `prependNoticeIfNeeded` passes through nil, prepends notice to first text block, preserves isError flag, leaves additional content blocks unchanged, skips non-text first blocks defensively). **Effect on shipped bundle: zero binary contract change** , pure MCP wire surface extension. Valid filter requests behave identically. Malformed filter requests now throw `invalidArgument` instead of silently no-oping. Cross-source filter responses gain the notice prefix. The CLI's `--platform` / `--min-version` notice in `SmartReport.swift` is unchanged (separate transport, separate notice idiom). **Critic-pass expansion (Phase 2, commit `d3f7623`):** the original Phase 1 scope above stopped at announcing the silent drops via the notice; per user direction ("fix everywhere") the critic-pass commit fixes the drops at the source. `Services.UnifiedSearcher.searchAll` gains 5 `min_*` + `minSwift` parameters; `Services.UnifiedSearchService` threads them through every `searchSource` invocation; `handleSearchAll` passes the args; `handleSearchSamples` translates the 5-field MCP shape to `Sample.Search.Query`'s `(platform, minVersion)` single-platform shape via documented precedence (iOS → macOS → tvOS → watchOS → visionOS , first non-nil wins). `PlatformFilterScope.dispatchAppliesFilter` expands from `{apple-docs, packages}` to `{apple-docs, apple-archive, swift-evolution, swift-org, swift-book, packages}`; `dispatchDropsFilter` shrinks from 7 sources to `{hig, samples, apple-sample-code}`. The notice helper grows a `Dispatch` enum (`.singleSource(String)` / `.fanOut`) that encodes the path-dependent fact that fan-out drops args silently for every source. Updated tests reflect the new bucket shape. **#732 fold-in (Phase 3, commit `fb6e598`):** the multi-platform-AND samples filter that the Phase 2 precedence-pick translation deferred. `Sample.Index.Database.searchProjects` now accepts 5 `min_*` parameters natively; multiple values AND-combine via `AND p.min_<platform> IS NOT NULL AND p.min_<platform> <= ?` clauses bound in lock-step (a sample that runs on iOS 15+ AND macOS 12+ is included when the user asks for iOS 17+ AND macOS 14+; matches the convention used in #220's `PackageQuery` + #233's `searchFiles`). `Sample.Index.Reader` protocol grows the 5-arg signature; a 3-arg back-compat overload via protocol extension keeps legacy callers compiling unchanged. `Sample.Search.Query` gains 5 `min_*` fields alongside the pre-existing single-platform `(platform, minVersion)` pair (kept for `searchFiles` back-compat). `Sample.Search.Service.search` threads the 5-field shape into `searchProjects`. `Services.UnifiedSearchService.searchSamples` (the fan-out helper) takes + threads the 5 args. `CompositeToolProvider.handleSearchSamples` drops the #226 `firstSamplePlatform` precedence-pick helper , passes the 5 fields end-to-end natively; the static helper is deleted. `PlatformFilterScope.dispatchAppliesFilter` expands again to 8 sources (samples + apple-sample-code alias added); `dispatchDropsFilter` shrinks to just `{hig}` (the only structurally-unfilterable source , design / UI guidelines have no version-availability axis on the data). 8 new tests in `Issue732SearchProjectsPlatformFilterTests` pin the SQL contract end-to-end against a real on-disk sample DB: no-filter, single-axis lex compare (15.0 ≤ 15.0 pass / 17.0 ≤ 15.0 fail), full-bucket pass (15.0 ≤ 18.0 ✓ AND 17.0 ≤ 18.0 ✓), AND-combination requires both columns populated, IS NOT NULL gate rejects sparse rows, legacy 3-arg overload returns all (back-compat), framework filter composes with platform filter (AND). **Effect on shipped bundle:** zero , `samples.db`'s `min_*` columns were already populated by #228 phase 2; #732 just reads them at query time. No DB migration. No reindex. **Post-three-phase per-source state.** Filter applies on every source the data shape supports: | Source | Specific-source dispatch | Fan-out dispatch | |---|---|---| | apple-docs | ✅ | ✅ | | apple-archive | ✅ | ✅ | | packages | ✅ | ✅ | | swift-evolution | ✅ | ✅ | | swift-org | ✅ | ✅ | | swift-book | ✅ | ✅ | | samples | ✅ (multi-platform AND) | ✅ (multi-platform AND) | | hig | ❌ structural | ❌ structural | 8 of 9 dispatch paths apply the filter. The `platform_filter_partial` notice fires only when a request routes to or includes HIG with platform args set. Full test count: 50 #226/#732 tests across 8 suites; full suite **2259/310 green**. ### Changed - **#673 Phase D iter-5 , convert remaining file-level `// swiftlint:disable` blankets to per-declaration `disable:next` across 14 files.** Final pass of the Phase D blanket-disable cleanup (iter-1..4 shipped as #687, #688, #693, #700, #705). Pre-fix 14 production source files carried file-level `// swiftlint:disable <rule>` annotations that masked every violation of that rule throughout the file, including future ones added by later edits. iter-5 converts each blanket to one of three targeted shapes per the established iter pattern: (a) `// swiftlint:disable:next <rule>` directly above the specific declaration that needs the disable (function / type / variable), with an inline rationale comment naming the body line count and explaining why the body genuinely warrants the disable; (b) the file-level `// swiftlint:disable file_length` retained where genuinely required (4 files: `Sample.Index.Database.swift` at 1230 lines, `Search.Index.Search.swift` at 1301, `Shared/Constants/Shared.Constants.swift` at 1415, `CLI/Commands/CLIImpl.Command.Fetch.swift` at 1185) , `file_length` has no per-declaration form, so a file-level scope is the only option; the rationale is tightened to name the specific reason each file is large (FTS5 + AST + per-sample availability schema concentration; full search query path slice off the original 4598-line SearchIndex.swift; central constants hub MARK-sectioned by domain; ArgumentParser's no-partial-struct-composition constraint forcing every `@Option` / `@Flag` for every `--type` value onto one struct); (c) the auto-generated `Resources.Embedded.ArchiveGuidesCatalog.swift` keeps its `type_body_length` disable on the wrapper enum declaration via `disable:next` plus an inline note documenting the auto-generated nature (most of the body is a single multiline JSON string literal). Per-file breakdown of what changed: `Core.PackageIndexing.PackageDependencyResolver.swift` (was: `disable identifier_name` + `disable function_body_length type_body_length` blankets → now: 3 targeted `disable:next` at the actor (366 lines), at `resolveSeed` (122 lines), at the regex-result variable `r`); `MockAIAgent/main.swift` (was: `disable type_body_length` → now: `disable:next type_body_length` at the `MCPClient` actor); `Crawler/Crawler.HIG.swift` (was: `disable type_body_length` + a stray dangling `// swiftlint:enable type_body_length` → now: `disable:next` on the `HIG` class + `disable:next function_body_length` on `htmlToMarkdown` + the dangling enable removed); `SampleIndex/Sample.Index.Database.swift` (was: `disable type_body_length file_length function_body_length` + dangling `enable` → now: file_length stays + `disable:next` on the `Database` actor + 5 `disable:next function_body_length` at `createTables`, `indexProject`, `searchProjects`, `searchFiles`, `getProject`); `SampleIndex/Sample.Index.Builder.swift` (was: `disable type_body_length function_body_length` + dangling `enable` → now: `disable:next` on the `Builder` actor + `disable:next` on `indexAll` and `indexProject`); `Search/Search.ComposableResult.swift` (was: `disable function_body_length` → now: `disable:next` on `detectQueryIntent`); `Search/Search.Index.Search.swift` (was: `disable function_body_length file_length` → now: file_length stays + `disable:next cyclomatic_complexity function_body_length` on the unified `search` function + 2 more `disable:next` on `fetchCanonicalTypePages` and `fetchFrameworkRoot`); `TUI/PackageCurator.swift` (was: `disable type_body_length function_body_length` → now: `disable:next` on `PackageCuratorApp` struct + `disable:next` on `main`); `TUI/Routing/InputHandler.swift` (was: `disable type_body_length` → now: `disable:next` on the `InputHandler` enum); `Shared/Constants/Shared.Constants.swift` (was: `disable file_length` → now: scope narrowed comment-only; the disable was already minimal); `Resources/Embedded/Resources.Embedded.ArchiveGuidesCatalog.swift` (was: `disable type_body_length` → now: `disable:next` on the wrapper enum with auto-generated note); `CLI/Commands/CLIImpl.Command.Fetch.swift` (was: `disable type_body_length file_length function_body_length` → now: file_length stays + `disable:next` on the `Fetch` struct + `disable:next` on `runPackageArchivesStage`); `CoreSampleCode/Sample.Core.Downloader.swift` (was: `disable type_body_length function_body_length` → now: `disable:next` on the `Downloader` class + `disable:next` on `extractSamplesWithJavaScript`); `Availability/Availability.Fetcher.swift` (was: `disable type_body_length` (missing `function_body_length` coverage, was incorrectly under-scoped) → now: `disable:next type_body_length` on the `Fetcher` actor + `disable:next function_body_length` on both `processFiles` (66 lines, was unsilenced) and `processFile` (76 lines, was unsilenced)). **What the linter now catches that it didn't before:** any future long function added to one of these 14 files will trigger the linter; pre-fix the file-level blanket suppressed every existing AND future violation. Net change: 14 files, +168/-69 lines (mostly inline rationale comments). swiftformat clean. Full lint pass across the 14 files reports zero `function_body_length` / `type_body_length` / `file_length` / `Blanket Disable Command` / `Superfluous Disable Command` violations. Build clean. **Effect on the shipped bundle: pure-comment delta** , no executable code changed; every `disable:next` annotation is a SwiftLint directive that the compiler discards. Closes the #673 Phase D blanket-disable cleanup arc. ### Added - **`scripts/check-changelog-touched.sh` + pre-commit + CI gate , mechanical CHANGELOG discipline (follow-up to the 2026-05-17 whole-CHANGELOG audit).** The audit found 7 PRs over 3 days that merged without CHANGELOG entries (#727, #703, #702, #704, #736, #737, #738 , all backfilled in PR #739). Methodology doc landed in PR #738 but was prose-only , relied on the person opening the PR remembering to check the discipline. This commit ships the mechanical enforcement layer that the audit recommended. New `scripts/check-changelog-touched.sh` inspects the staged diff (pre-commit mode) or the PR diff (CI mode) and refuses the commit / fails the PR when any `Packages/Sources/`*.swift / `Apps/`*.swift / `Package.swift` file changed without `CHANGELOG.md` also being touched , unless the developer explicitly opts out via `git commit --no-verify` or by including the literal token `[no-changelog]` in the commit message body. The conservative `TRIVIAL_PATTERNS` list covers docs / tests / scripts / dotfiles so doc-only and test-only commits aren't gated. Wired into `.pre-commit-config.yaml` as a local hook (cheap diff inspect, runs on every commit, catches the slip before push) AND into `.github/workflows/ci.yml` as a parallel PR-level gate (`fetch-depth: 0` on the checkout so the diff against `origin/main` resolves; finishes in seconds, runs alongside Build & Test). Smoke-tested with 3 scenarios: (a) staged source-only → exit 1 with clear diagnostic naming the problem files + the 3 opt-out paths; (b) staged docs-only → exit 0; (c) staged source + CHANGELOG → exit 0. **Effect on shipped bundle: zero binary contract change** , pure tooling. Closes the gap from the methodology doc by making the rule mechanical at commit + PR time instead of prose-only. - **`scripts/check-issue-body-staleness.sh` + nightly workflow + issue templates + CLAUDE.md convention. Mechanical issue-body discipline (follow-up to the 2026-05-17 full-tracker audit).** The deep one-by-one audit of all 56 open issues found that 47 had at least one factual error in the body: stale file paths after the namespacing pass (`SearchIndex.swift` referenced after the split, `Sources/TUI/` without the `Packages/` prefix), phantom paths citing files that were never written (7 issues citing `mihaela-blog-ideas/cupertino/research/CUPERTINO_FEATURE_ROADMAP.md`), wrong issue numbers in cross-refs (#78 saying "#70 doctor save" when #70 is "summary symbol"), schema column claims that no longer match the schema file (#70 / #73 claiming `docs_metadata.code_examples` when code examples live in `doc_code_examples`). Earlier shallow audits had returned "well-written, keep" verdicts on bodies with 10x-wrong default values inside them. This commit ships the four-tier prevention layer. **Tier 1 (mechanical):** new `scripts/check-issue-body-staleness.sh` runs four read-only checks against every open issue body via `gh issue list` + `gh issue view`. (1) Renamed paths: maintained `RENAME_MAP` of post-namespacing renames + splits + deletions; greps each body for the old patterns, emits a hint per match naming the new location. (2) Phantom paths: extracts every backtick-quoted file path from each body, checks the filesystem under `Packages/` / `scripts/` / `docs/` / `.github/` / `Apps/`; flags paths that exist nowhere. (3) Stale cross-refs: extracts `#NNN` mentions in blocker phrasing (`blocked on` / `pending in` / `depends on` / `after #N lands` / `gated on` / `awaits` / `waiting on`); queries `gh` for each referenced issue's state; flags CLOSED ones. (4) Stale schema claims: parses `Search.Index.Schema.swift` for column lists per table; greps bodies for `<table>.<column>` shapes against the seven most-cited tables (`docs_metadata`, `docs_structured`, `doc_symbols`, `doc_code_examples`, `package_metadata`, `package_files`, `sample_code_metadata`); flags column references that don't exist in the parsed schema. Output is a markdown report; exit 0 clean, exit 1 drift found. Modes: `--issue=<N>` (single), `--check=<renamed|phantom|xref|schema>` (single check), `--dry-run` (read bodies from `/tmp/bodies/`). **Tier 2 (workflow):** new `.github/workflows/issue-body-staleness.yml` runs the script daily at 09:00 UTC via cron + supports `workflow_dispatch` for manual invocation. Drift → upserts a tracking issue titled "Issue body staleness tracker (auto-updated)" with the full report as body; clean → closes any existing tracker with a "now-clean" comment. The pair workflow walks the tracker in its morning bug-list triage; no separate notification surface required. **Tier 3 (issue templates):** new `.github/ISSUE_TEMPLATE/feature.md` and `bug.md`. Both mandate a `## Status (YYYY-MM-DD)` block as the first section, with prose explaining the convention; both include Related-section guidance against line numbers in bodies and against phantom path citations. The status-block convention is the lesson from the audit: issues that had a dated status block at the top (#77, #227, #266, #517, #673) aged well; the ones that didn't aged into fiction within a month. **Tier 4 (documentation):** `docs/audits/methodology.md` gains a new "Issue body hygiene" section codifying the five conventions (status block at top dated; no line numbers; no phantom paths; cross-ref hygiene; schema claims are checkable), the rename-PR checklist (run `--check=renamed` and update matched issues in the same PR), and the audit-prompt requirement (audit agents must be given this doc as required reading + the explicit instruction to fact-check against current source rather than pattern-match on body polish). `CLAUDE.md` gains a short pointer to the methodology section so the convention is loaded at session start. **Smoke-tested:** ran the script against the post-audit tracker; surfaces residual drift (cross-ref candidates that need human triage because the regex catches historical citations as well as live blockers, schema reads that miss migrated columns because the parser reads initial CREATE TABLE only, not ALTER TABLE). Mode of operation is candidate-surfacing not auto-fixing; the human triages the report. **Effect on shipped bundle: zero binary contract change.** Pure tooling + process. Closes the prevention gap surfaced by the audit; the next deep audit will land smaller because the mechanical layer caught the easy cases in between. - **Label-axis extension to `scripts/check-issue-body-staleness.sh` + GitHub form templates + label cleanup (PR #743 follow-up; tracks via #744).** The label-axis audit done immediately after the body-axis audit surfaced a parallel drift surface: a convention documented but never applied (`fixed: awaiting release` with 0/0 total usage despite being defined in CLAUDE.md), versioned labels post-shipping (`fix-in: v1.0.0` with 0/26 usage, `v1.0:` namespace labels with 0/10 across three), single-carrier topical labels (`cli-verbosity` and `discovery` each with 1 open carrier), an orphan `blocked_by_192` label with zero total usage, and a coverage gap of 36 of 56 open issues missing a `priority:` label. **Script extension (check 5):** new `--check=labels` mode (and included in `--check=all`) runs tracker-global rather than per-issue. Five sub-checks: (a) orphan `blocked_by_<N>` labels whose target is CLOSED; (b) `fix-in: v<X.Y.Z>` labels for versions in the maintained `SHIPPED_VERSIONS = (v1.0.0 v1.0.1 v1.0.2 v1.1.0)` list (rename to `released-in:` or delete); (c) single-carrier topical labels (1 open carrier, excluding triage axes); (d) open issues missing a kind label (`enhancement` / `bug` / `documentation` / `epic` / `wishlist`); (e) open issues missing a `priority:` label (excluding epics and wishlist which are explicitly priority-exempt). Output goes into a new "Label drift (check 5)" section of the same tracking-issue markdown report. **Form templates:** `.github/ISSUE_TEMPLATE/feature.md` and `bug.md` (shipped in PR #743) are replaced with `feature.yml` and `bug.yml` GitHub form templates. Forms make priority, complexity, and the status date REQUIRED dropdown / input fields at filing time; kind is set by which template the user picks (feature.yml applies `enhancement`, bug.yml applies `bug` via the static top-level `labels:` field). Structurally enforces the convention rather than suggesting it via markdown placeholder text. Bug form adds required `cupertino --version` output capture + version-discipline reminder per Carmack discipline (`#673`); feature form's acceptance section seeds three blank `- [ ]` bullets ready for completion. **One-time label cleanup (done inline at audit-close 2026-05-17, before this PR):** deleted `blocked_by_192` (zero total usage), renamed `fix-in: v1.0.0` to `released-in: v1.0.0` (preserves historical association on 26 closed carriers), deleted `fixed: awaiting v1.0.0` (process label with no signal post-release; replaced by `fixed: awaiting release` going forward), renamed `v1.0: distribution` / `v1.0: mcp` / `v1.0: symbols` to `area: distribution` / `area: mcp` / `area: symbols` (forward-usable namespace, history preserved). Backfilled missing kind labels on #50 / #58 / #76 (added `enhancement`); added `priority: high` + `complexity: medium` to #742 (the diagnostic-block keystone I filed mid-audit without proper triage labels). **Effect on shipped bundle: zero binary contract change**. Pure tooling + process + label-metadata cleanup. Follow-up #744 tracks the remaining design-decision items (release-tracking model: labels vs body tables; deciding which v1.0-era labels to keep vs delete; 36-issue priority backfill; whether to grow or fold `cli-verbosity` and `discovery`). **Critic-fix pass:** added `.github/workflows/issue-form-labeler.yml` to translate form dropdown values into matching labels at `issues.opened` time. Form dropdowns do not auto-apply labels by themselves; they only write the selected value into the issue body. The labeler validates against the known priority + complexity label sets to refuse arbitrary label creation. Removed stale `placeholder: "2026-05-17"` strings from the form date inputs (would have read as required-format examples but become misleading in future years). Tightened the methodology doc wording: kind is determined by template choice (feature picks `enhancement`, bug picks `bug`), not a dropdown, so the convention block names what the forms actually enforce (priority + complexity dropdowns + status-date input + structured textareas). - **Brutal label trim (Apple / GoF / Knuth convergent read).** Following the autopilot 38-to-25 trim and Apple-palette recolouring (above), reread the label set through three lenses: Apple (cut even fine things that don't earn their place), GoF (status / lifecycle / release-tracking are not types; route to native GitHub primitives), Knuth (labels exist only for what mechanical tools must partition on; prose carries everything else). Convergent answer: 5 labels. Total label count 25 → 5 (80% cut from the autopilot baseline, 87% cut from the pre-audit 38). **The 5-label canonical set:** `bug` (Red `#FF3B30`), `enhancement` (Blue `#007AFF`), `epic` (Purple `#AF52DE`), `priority: high` (Red `#FF3B30`), `good first issue` (Green `#34C759`). Each earns its place via mechanical partitioning: kind labels are applied at filing time by the form templates; `epic` is the staleness script's check 5d kind-label whitelist; `priority: high` is the only triage tier (absence means "do when you can"); `good first issue` is GitHub-native, rendered specially in newcomer-contributor views. **20 deletions:** complexity gradient (3); priority gradient remainder (medium, low); 5 topical (cli-verbosity, discovery, transitional, blocker, big-win, most already trimmed); 4 categorical (source-expansion, search-quality, internal-only, refactor); 3 area (distribution, mcp, symbols); 2 lifecycle (fixed: awaiting release, released-in: v1.0.0); 1 documentation kind (folded into bug / enhancement). **Relabel pass:** all 57 open issues now carry a kind label (53 `enhancement`, 8 `epic` with 5 carrying both); only 2 issues carry `priority: high` (#673 v1.2.0 release-gate + #742 diagnostic-block keystone, down from 5 before the discrimination pass); 3 issues carry `good first issue` (preserved from earlier). **Downstream consequences shipped in the same change:** form templates (`feature.yml` / `bug.yml`) lose the priority + complexity dropdowns (becomes status-date + body fields only); the labeler workflow `.github/workflows/issue-form-labeler.yml` is **deleted** entirely (with no dropdowns there is nothing to translate); staleness script `check_labels_global` drops sub-check 5e (missing priority); priority is now presence/absence, not a required axis; check 5d kind-whitelist narrows from `enhancement|bug|documentation|epic|wishlist` to `enhancement|bug|epic`; check 5c single-carrier exclusion list narrows to match the 5-label set. **`docs/audits/methodology.md` 'Label discipline' section** rewritten: the 5-axis taxonomy table becomes a 5-label table; the multi-colour palette inventory narrows to the four hues actually in use (Red / Blue / Purple / Green) with the rest reserved; the 3-carrier rule survives but now serves the brutal-minimum philosophy rather than a sprawling axis. **Effect on shipped bundle: zero binary contract change.** Pure tooling + process + label-metadata cleanup. Convention exported to `mihaela-agents/Rules/universal/github-discipline.md` in companion PR so future repos start from the brutal-minimum set rather than re-deriving it. **Label-set trim + Apple palette (autopilot follow-up, tracker-side; tracked in #744):** total label count went from 38 to 25. Deleted: 4 GitHub-default labels with 0/0 usage (`duplicate`, `invalid`, `question`, `wontfix`); 2 single-carrier topical labels (`cli-verbosity` only on #16; `discovery` only on #76); the historical `phase-1` through `phase-5` labels (v1.2.0 refactor phases, all shipped, 0 open carriers each); `blocked` (0/1, overshadowed by `blocker`); `release` (0/1, never grew). Recoloured remaining 25 labels with Apple's system colour palette: Red `#FF3B30` for urgent/blocking (`bug`, `priority: high`, `blocker`); Orange `#FF9500` for medium (`priority: medium`, `area: distribution`); Yellow `#FFCC00` for low/lifecycle (`priority: low`, `transitional`, `fixed: awaiting release`); Green `#34C759` for shipped/ready (`good first issue`, `released-in: v1.0.0`); Mint-Teal-Indigo gradient for complexity axis; Blue `#007AFF` for active work (`enhancement`, `area: mcp`); Purple `#AF52DE` for structural (`epic`, `area: symbols`); Brown `#A2845E` for internal (`documentation`, `internal-only`); Gray `#8E8E93` for speculative (`wishlist`); Pink `#FF2D55` for `big-win`; Cyan `#32ADE6` for `help wanted`. Convention documented in `docs/audits/methodology.md` 'Label discipline' subsection with the 3-carrier threshold rule for new labels (single-carrier categorisations fold into the body, not into the label set). ### Documentation - **PR #727 , v1.2.0 doc-staleness audit + 7 load-bearing fixes.** Top-level audit of every `docs/*.md` for v1.2.0 readiness; fixed 7 items the audit surfaced (schema-version mismatch in `docs/artifacts/folders/search.db.md`, packages.db schema doc at v2 vs v3 actual, removed 9,700-row claim from cupertino-tui docs post-#194, 2 broken `DEVELOPMENT.md` links → `CONTRIBUTING.md`, PRINCIPLES + docs/tools cross-link in README, AGENTS-not-locally-present clarification in CLAUDE.md). Companion `DOC-AUDIT-2026-05-17.md` artifact (since moved to `docs/audits/doc-audit-2026-05-17.md` by #733) catalogs the 7 fixes + intentionally-kept historical refs. ### Tests - **PR #703 , #673 Phase C iter-8: 10 sample-side MCP semantic-marker tests (11/11 MCP tools now fully pinned).** Final iter of the Phase C 2x-test-density arc. Pre-iter-8 only 8 of 11 MCP tools had semantic-marker E2E coverage (positive + negative markers per response shape , the iter-4..7 pattern). iter-8 adds the sample-side tools (`search_samples`, `read_sample`, `read_sample_file`) , 10 new `@Test` directives in `Issue673PhaseCIter8SampleSideMarkerTests` pin success + empty + missing-arg + framework-filter + file-extension-filter + URI-shape across the 3 tools. Brings the MCP tool semantic-marker coverage to 11 of 11 , every tool advertised in `tools/list` now has at least one positive + one negative marker test. Full test suite green after the iter; Phase C structurally complete pending the v1.2.0 release-gate test-count target (1966+ vs the 2500 the epic body targets). ### Fixed - **#759. `search_generics` proper-fix completion: AST where-clause walk + in-memory hierarchy propagation + authoritative symbolgraph table.** Follow-up to PR #758 (#755, merged): deep-test pass against a SwiftUI mini-corpus showed the regex-on-signature shortcut populated only 10 of 10,752 doc_symbols rows (0.09%) because Apple's flattened DocC JSON mostly doesn't carry where-clauses in extractable form. The proper fix lands here as three complementary iterations, all running in-process during `cupertino save --docs`. **Iteration 1: AST extractor reads `genericWhereClause`.** `ASTIndexer.Extractor.extractGenericParameters(from:whereClause:)` now walks both `GenericParameterClauseSyntax.parameters` (inline `<T: Collection>` form) AND `GenericWhereClauseSyntax.requirements` filtered to `.conformanceRequirement` (drops `T == U` same-type requirements that aren't constraint-shape). All eight visit sites updated (class / struct / enum / actor / protocol / extension / function / typealias). Extensions newly capture their where-clause constraints (`extension Collection where Element: Equatable` → `Element: Equatable` lands in the column). Apple's DocC `declaration.code` carries the full declaration string (e.g. `struct ForEach<Data, ID, Content> where Data : RandomAccessCollection, ID : Hashable`); pre-#759 the extractor read only the inline half and dropped the where clause. **Iteration 2: in-memory hierarchy propagation.** New file `Search.Index.HierarchyConstraints.swift` adds `propagateConstraintsFromParents()`, called by `Search.IndexBuilder.buildIndex()` after every strategy finishes + `registerFrameworkSynonyms()`. Algorithm: build a `[doc_uri → (constraints, paramNames)]` map from rows whose own page has constraints + a type kind (struct / class / enum / actor / protocol / typealias). Then for every NULL-constraint row, strip the last URI path segment to derive parent URI, look up the map, and UPDATE the row if EITHER (a) the row's own `generic_params` is non-empty (catches typealiases / nested types) OR (b) the row's `signature` references one of the parent's bare type-parameter names as a word-boundary identifier (catches `NavigationLink<Label, Destination>.init(_:isActive:destination:)` whose signature carries bare `Destination` from the parent struct's scope). Sub-second on the full 351k-row corpus; transactioned to one BEGIN/COMMIT to amortise the WAL fsync. **Iteration 3: authoritative constraints table from `swift symbolgraph-extract`.** New SPM package `AppleConstraintsKit` (foundation-only, depends only on Foundation + `SearchModels`) ships a Codable schema for Apple's symbol-graph JSON, a URI mapper (`module + pathComponents → apple-docs://<module-lowercased>/<path-lowercased>`), an `Extractor` that filters to symbols with non-empty `swiftGenerics.constraints` of `kind: conformance` / `superclass`, and a `Table` Codable type conforming to the new `Search.StaticConstraintsLookup` protocol in `SearchModels`. New executable target `ConstraintsGen` produces the `cupertino-constraints-gen` binary with one subcommand: `cupertino-constraints-gen generate --from-directory /tmp/symbolgraphs -o apple-constraints.json`. The cupertino indexer reads `<base-dir>/apple-constraints.json` if present; when found, runs pass 3 (`Search.Index.applyAppleStaticConstraints(lookup:)`) BEFORE iteration 2's hierarchy walk so the walk's parent map sees the authoritative values from the upstream symbolgraph rather than just what iteration 1 caught from the corpus. Two-pattern UPDATE: exact-match `doc_uri = entry.docURI` for type-level + non-overloaded methods; LIKE `doc_uri || '-%'` for Apple's hash-disambiguated overloads (`init(_:content:)-7l1jb`). Brew users still get iter 1+2 alone if no constraint table ships with the bundle; the lookup is optional and absent file = silent fallback. **Architecture compliant with `gof-di-rules.md`:** no Singletons (table is a value, loaded at composition root); init-injection of `staticConstraintsLookup: any Search.StaticConstraintsLookup` on `Search.IndexBuilder`; cross-target seam via protocol in foundation-only `SearchModels` target (rule 3 + rule 4); `AppleConstraintsKit` lifts standalone with Foundation + SearchModels alone (rule 5); composition root (`CLIImpl.Command.Save.Indexers`) is the only place that imports `AppleConstraintsKit` concretely + wires the table to the IndexBuilder; producer foundation-only (rule 8. Search still imports only Foundation + SearchModels). The protocol contract in `Search.StaticConstraintsLookup` is `func allEntries() async throws -> [StaticConstraintEntry]` (named protocol, not closure typealias, per rule 4). **Empirical coverage on SwiftUI mini-corpus** (8,679 docs / 10,752 doc_symbols, brew DB untouched throughout via isolated `--base-dir /tmp/probe-755-*`): | Path | Populated | % of total | View / Collection / Hashable / StringProtocol | |---|---|---|---| | Original PR #758 (regex shortcut, merged) | 10 | 0.09% | 7 / 0 / 0 / 0 | | + iter 1 (AST genericWhereClause walk) | 772 | 7.2% | 121 / 8 / 19 / (n/a) | | + iter 2 (in-memory hierarchy propagation) | 1,710 | 15.9% | 919 / 118 / 234 / (n/a) | | + iter 3 (authoritative symbolgraph table) | **4,132** | **38.4%** | **1,305 / 513 / 595 / 1,787** | **Net improvement vs PR #758: 413×.** ForEach methods (which have empty own `generic_params`) inherit `RandomAccessCollection,Hashable` from the ForEach struct via iter 2's signature-name match. The `cupertino-constraints-gen` binary produced a 22,112-entry / 3.85 MB filtered table from the SwiftUI symbolgraph (~456 MB raw) in seconds. **Tests** (16 new `@Test` directives across 4 suites + 1 existing suite extended): `Issue759ParentURITests` (parent-URI derivation across canonical shapes + scheme-tail guard + bare-string edge case); rewritten `Issue755ConstraintExtractionTests` for the now-AST-only splitter; new `AppleConstraintsKitTests` covering URIMapper, SymbolGraph.Constraint axis filter, Extractor with mocked SymbolGraph.Document inputs (ForEach shape, sameType filtered, sameType-only skipped, no-generics skipped), Table Codable round-trip + schema-version-too-new rejection + StaticConstraintsLookup conformance. Full suite: 2,289 tests / 321 suites green in 43.9s. **Effect on shipped bundle.** Iter 1 + 2 ride along on the next `cupertino save --docs` against any v17-bumped DB; iter 3 requires the bundle to additionally ship `apple-constraints.json` (which `cupertino-constraints-gen` produces). Until the bundle ships the table, brew users get iter 1 + 2 coverage (~16% of doc_symbols populated); after the bundle ships the table, ~38%. Schema target stays at v17 (the v16 → v17 migration shipped in #758 is sufficient; iter 3 adds NO new column, just populates existing ones via the new pass). - **#754 , `get_inheritance(symbol:)` resolver missed canonical root types whose stored title carried Apple's HTML page-title site suffix.** Surfaced during 2026-05-17 MCP-tools-sanity probe: `get_inheritance(symbol: "NSObject")` returned `"No symbol named NSObject in apple-docs"` despite NSObject being in the corpus (UIView's ancestor walk reaches the NSObject row via the inheritance edges; the data is fine, the symbol-name → URI resolver was the gap). Root cause: Apple's DocC stores `json_data.$.title` in two shapes across the v1.2.x corpus , bare (`"UIView"`) and HTML-page-title with site suffix (`"NSObject | Apple Developer Documentation"`). On the 2026-05-17 dev DB the split is **129,061 suffixed rows / 222,434 bare rows = ~37% / ~63%** , both forms are widespread; the suffix is not rare. Pre-fix `resolveSymbolURIs(title:)`'s WHERE clause was `LOWER(json_extract(json_data, '$.title')) = LOWER(?)` , exact match on lowercased title. User typing the bare name (`"NSObject"`) matches the bare-form rows but misses the suffixed-form rows. High-traffic root types (NSObject, NSResponder, etc.) happen to be stored in the suffixed form for unknown DocC reasons, so the most-common queries were the ones that hit empty results. Fix: SQL-side `REPLACE(json_extract(json_data, '$.title'), ' | Apple Developer Documentation', '')` strips the suffix (no-op on bare rows; collapses to bare form on suffixed rows) before the lowercase equality compare. Works for both shapes; no index helps either way since function calls in the WHERE force a full scan (the existing code path was already a full scan; net runtime delta is one REPLACE per row, sub-millisecond on the 351k-row dev DB per the test-suite timings , fixture tests complete in 0.17s). 5 new tests in `Issue754NSObjectResolverSuffixTests` (23 effective sub-cases via parametrisation): `resolveSuffixedTitleShape` parametrised over 10 canonical root types (NSObject / NSResponder / UIView / UIResponder / UIViewController / NSView / NSViewController / CALayer / NSManagedObject / UIControl) , each seeded with the suffixed form, resolves cleanly post-fix; `resolveBareTitleShape` parametrised over the same 10 (regression guard , bare form still works); `resolveMixedShapes` , both shapes seeded for the same symbol, both URIs returned (proves the predicate handles the realistic multi-framework case); `resolveCaseInsensitive` , typed `"nsobject"` / `"NSOBJECT"` / `"NsObject"` all resolve identically (existing behaviour preserved); `resolveUnknownReturnsEmpty` , no false positives from the suffix-strip change. All 23 sub-cases green in 0.17s; broader inheritance-family regression check (`Issue274` + `Issue669` + `Issue754`) green at 110/110 in 0.36s. **Effect on shipped bundle: zero corpus change** , pure resolver predicate fix; existing data reachable via the corrected SQL; brew users who hit the empty-result trap on canonical root types now get the inheritance chain they expected. **Class-of-bug surfaced for follow-up:** the inconsistent suffix in `json_data.$.title` itself is a corpus-side anomaly worth investigating (why does the indexer write suffixed titles for some rows and bare for others?) but that's an indexer-side normalisation question, out of scope for this resolver fix. - **#755. `search_generics` corpus-shape fix (release-blocking; schema v16 → v17).** Main's MCP-tools-sanity probe on 2026-05-17 found `search_generics("Array")` and `search_generics("Collection")` returning EMPTY against a v1.0.x bundle DB while `search_generics("Result")` returned 649 rows. SQL-level investigation of `~/.cupertino-dev/search.db` (351,495 docs / 398 frameworks) named the root cause: the `doc_symbols.generic_params` column the tool's predicate LIKE'd against stored type-parameter NAMES (`T`, `Element`, `Result`), not constraints. The "Result" hits were coincidental: `Result` is a common generic param NAME (`func reduce<Result>`); `Array` / `Collection` aren't used as names. Only 17 of 351,495 rows carried constraint-form values in `generic_params` (samples: `T: Strideable`, `Content: View`, `Value: VectorArithmetic`). Apple's HTML doc pages mostly carry Swift code snippets that show signatures like `struct Array<Element>` and `protocol Collection`, NOT the constraint clauses (`where Index : Comparable`, etc.) which live in separate paragraphs / sections, not in extractable Swift declaration syntax. **Fix.** New `doc_symbols.generic_constraints` column at schema v17. Populated at index time by `Search.Index.combinedGenericConstraints(fromAST:fromSignature:)` (a static helper colocated with `indexDocSymbols`) from two sources merged into one comma-joined blob: (a) the AST extractor's `T: Collection` form, split into name + constraint via `entry.range(of: ": ")` with the constraint half landing here; (b) `where`-clause + inline `<T: X>` patterns regex-parsed from the `signature` column via `Search.Index.extractWhereClauseConstraints(from:)`, which catches `func foo<T>(x: T) where T: Collection` declarations the AST extractor's `extractGenericParameters` doesn't reach (where clauses live on a separate `GenericWhereClause` syntax node, not on `GenericParameter`). Same-type requirements (`T == U`) are dropped by the regex's `:`-anchor since they're not constraint shape. `Search.Index.searchByGenericConstraint` predicate moves from `s.generic_params LIKE ?` to `s.generic_constraints LIKE ?`. `MCP.SharedTools.Copy.toolSearchGenericsDescription` updated to describe the now-honest contract (added `Collection` / `Sequence` to the example constraint list). **Schema migration.** Schema bumps `16 → 17` per `Search.Index.schemaVersion`. New `migrateToVersion17()` in `Search.Index.Migrations.swift` follows the v15→v16 pattern: `ALTER TABLE doc_symbols ADD COLUMN generic_constraints TEXT` + `CREATE INDEX IF NOT EXISTS idx_doc_symbols_generic_constraints` + trailing `try stampUserVersionUnchecked(17)` (eating its own dogfood, second user of the helper #749 just landed proves it's an interface, not a one-off). `if currentVersion < 17` dispatch added to `checkAndMigrateSchema()`. Fresh-build path writes the column at CREATE TABLE time so brand-new DBs never enter the migrator. In-place ALTER for existing v16 DBs leaves rows NULL until the next `cupertino save --docs` re-index populates them; same NULL semantic as v15→v16 (`implementation_swift_version`). **Test fixture.** New `Issue755GenericConstraintsTests` (2 suites, 11 `@Test` directives total): `Issue755MigrateToVersion17Tests` pins migration (`v16ToV17AutoMigrationLeavesDBAtV17` synthesises a v16 DB by stripping the v17 column + index + stamping PRAGMA, asserts PRAGMA = 17 + column + index post-migration; `secondOpenIsNoOp` for idempotency); `Issue755ConstraintExtractionTests` parametrises the two static helpers across the on-disk shapes the corpus carries: bare names (`["T"]` → nil), inline constraints (`["T: Collection"]` → `"Collection"`), multi-param mixed (`["T: Collection", "U: View"]` → `"Collection,View"`), `&`-joined (`["Element: Hashable & Sendable"]` → `"Hashable & Sendable"`), where-clause patterns (`"... where T: Collection"` → `["Collection"]`), multi-where (`"... where T: View, U: Equatable"` → `["View", "Equatable"]`), same-type drop (`"... where T == U"` → `[]`), mixed where + same-type, full merge of both halves, and the all-bare-no-where case which correctly returns nil for the NULL write semantic. **Re-save required.** Existing v16 DBs auto-migrate in place (column + index + stamp), but the column starts NULL on every row. Populating requires `cupertino save --docs --clear --base-dir <path> --docs-dir <path>/docs` against an existing corpus (~11h against the 2.3 GB v1.0.x corpus; no re-crawl needed). Users on the shipped bundle get the populated column via the next bundle release matched to v1.2.0. **Effect on shipped bundle: schema migration + re-index required.** Schema bump 16 → 17; existing bundles ship pre-populated at v17. Tool's advertised behaviour (constraint search) finally matches what the corpus carries. The 17 rows that previously carried constraint-form `generic_params` keep working; the much larger set of where-clause-bearing rows newly searchable. - **#749. In-place schema migrators now stamp `PRAGMA user_version` (release-blocking class-of-bug fix).** The pair-workflow audit on 2026-05-17 caught a regression in PR #718's `migrateToVersion16`: the migrator added `implementation_swift_version` via `ALTER TABLE` but never bumped `PRAGMA user_version` from 15 to 16. On next open, `setSchemaVersion()`'s #635 guard correctly refused to silent-stamp from a non-zero version, so `Search.Index.init` threw with the generic 'Refusing to stamp' error. Surface: any user with a v15 DB upgrading their binary past PR #718 hit a search-broken state until they ran `cupertino setup` or manually deleted the DB. **Class-of-bug audit** (per Carmack discipline): inspected every in-place migrator in `Packages/Sources/Search/Search.Index.Migrations.swift`. **All 7 were missing the stamp**: `migrateToVersion3` (v2→v3 json_data), `migrateToVersion4` (v3→v4 source), `migrateToVersion6` (v5→v6 platform `min_*` columns), `migrateToVersion7` (v6→v7 sample-availability), `migrateToVersion10` (v9→v10 framework synonyms), `migrateToVersion11` (v10→v11 doc-kind taxonomy), `migrateToVersion16` (v15→v16 implementation_swift_version). Production hadn't tripped this because the dominant install path is fresh DB from `cupertino setup` (user_version=0 → `createTables` writes target schema → `setSchemaVersion` stamps cleanly via the fresh-DB branch). The pair-workflow upgrade scenario (binary-A saves a v15 DB; binary-B opens it post-schema-bump) hit the broken path; brew users upgrading their binary past any schema bump would have eventually hit the same trap. **Fix.** New `Search.Index.stampUserVersionUnchecked(_ version: Int32) throws` helper that issues `PRAGMA user_version = <version>` unconditionally, propagating sqlite3_exec failure as `Search.Error.sqliteError` (refuses the silent-error pattern the historical migrators used for ALTER TABLE; the PRAGMA write is load-bearing, not idempotent-ignorable). Every in-place migrator now ends with `try stampUserVersionUnchecked(N)` where N is its target version. `setSchemaVersion()` also routes its fresh-DB stamp through the same helper (single source of truth for the actual `PRAGMA user_version =` write, the next schema bump cannot accidentally introduce a second write path). The #635 fresh-DB guard in `setSchemaVersion()` stays unchanged as defense in depth for the future-schema-bump-forgets-migrator-entry scenario; the Phase E #689 typed `schemaVersionMismatch` wrapper around it also stays. **Test fixture.** New `Issue749MigratorPragmaBumpTests` suite (3 `@Test` directives, parametrised-style on the v15→v16 path which is the version transition the current build can construct from scratch): `v15ToV16AutoMigrationLeavesDBAtV16` (synthesises a v15 DB by stripping the v16 column + index + stamping PRAGMA, opens via Search.Index.init, asserts PRAGMA = 16 + column reachable + index present), `secondOpenIsNoOp` (idempotent re-open against migrated DB), `helperStampsRequestedVersionUnconditionally` (helper bypasses the #635 guard). The promised v15→v16 integration test in `Issue225PartBImplementationSwiftVersionTests.swift` line 19-21 ("stamps a v15 DB, opens it with a v16 binary, confirms the column is reachable") lived only in the documentation comment, only fresh-v16 tests followed it. This suite fills that gap as the regression lock. Older migrator paths (v2→v3, v3→v4, v5→v6, v6→v7, v9→v10, v10→v11) are not retro-tested in this PR, reconstructing each from-version schema shape from current code is fragile (older schemas have been removed as production moved on) and the class-of-bug fix lives in the helper that every migrator now calls, not in path-specific code. Retro-fixtures can be added if a user ever reports trouble on an older path. **Existing test updated.** `Issue635SchemaStampGuardTests.setSchemaVersionGuardRejectsNonZeroMismatch` was codifying the broken behaviour (asserted the v15-stamped DB would throw on re-open); renamed to `schemaVersionMinusOneAutoMigratesToTarget` and inverted to assert the new correct behaviour (in-place migration succeeds, PRAGMA stamped to current schemaVersion). Header comment now explains that the #635 guard is defense in depth for FUTURE schema bumps that forget a migrator entry, defensible-by-design even when no current code path exercises it. **Real-DB validation.** Verified end-to-end against `~/.cupertino-dev/search.db` produced by main's reindex (a real v15 DB built by a pre-#718 binary): one `cupertino search VStack` call auto-migrated the on-disk DB from PRAGMA 15 to PRAGMA 16 with the column + index added in place, with no manual intervention required. **Effect on shipped bundle: zero binary contract change.** Pure migration-path correctness fix; the schema target stays at 16; no version bumps; no CHANGELOG header flip; release ceremony stays user-gated. ### Fixed - **PR #702 , #691: `URLUtilities.appleDocsURI` rejects non-http(s) URL schemes.** Pre-fix the URI builder accepted any URL it could parse, including `javascript:` / `mailto:` / `data:` schemes inadvertently captured from HTML fragments by the link extractor. The downstream search index would then store an unusable URI shape. Post-fix: explicit scheme allowlist (`http` / `https` only); non-matching URLs return nil + skip downstream indexing. New `URLUtilitiesSchemeRejectTests` cover the 4 reject shapes + the 2 accept shapes. Hardens the indexer's URL-input boundary against malformed source HTML. **Effect on shipped bundle: zero corpus change** , the rejected schemes were never producing useful index rows in the first place; the fix prevents the malformed-row class entirely, going forward. - **PR #704 , #682: surface remaining silent-write sites OR comment-as-intentional (close the silent-write audit class).** Final pass on the #682 silent-write audit (pre-existing #682 closed 3 of 5 sites; this PR sweeps the remaining 2). Each `try?` / discarded-result write site in production source is now either (a) surfaced with explicit logging on failure, or (b) carries a per-site rationale comment explaining why the silent-write is intentional (e.g. crash-recovery fallback writes whose throw would mask the original error). Closes the #682 audit; #682 noted in the closure that the class is now mechanically enforced by `swiftlint`'s `discarded_notification_center_observer` and adjacent rules + the audit doc. **Effect on shipped bundle: zero** , pure observability + comment delta. ### Removed - **#734 , delete `Search.PlatformFilterScope.appliesFilter` / `silentlyIgnoresFilter` deprecated aliases (PR #736).** PR #731 introduced the two computed-var aliases during the rename to the more precise `dispatchAppliesFilter` / `dispatchDropsFilter` names. The source doc comment said "will be removed before merge" , but PR #731 merged with both still in place. Caller grep on post-merge develop returned zero hits; aliases were scaffolding only. Option A from #734's acceptance: delete outright. 21 lines removed (2 computed vars + their `@available(*, deprecated, renamed:)` annotations + the `Legacy compatibility (deprecated aliases)` MARK section). Critic-pass on the cleanup PR caught 3 stale doc-comment references to the deleted names + folded the sweep into the same PR. Build clean; `xcrun swift test --filter "Issue226|Issue732"` reports 50 / 8 green; no callers in develop. **Effect on shipped bundle: zero** , aliases were pure forwarders to the canonical names, no behaviour delta. ### Changed - **#733 , move repo-root audit + release artifacts to `docs/audits/` (PR #737).** Session-generated dated artifacts had split inconsistently between repo root and `docs/audits/`. Stage A self-critic-pass during PR #728 flagged "session artifacts at root will accumulate" but the move didn't happen before merge. Today's audit of last-5h-merged PRs picked it back up. Files moved + renamed lowercase-hyphen for consistency with the existing `docs/audits/closure-replay-*.md` convention: `ISSUE-AUDIT-2026-05-17.md` → `docs/audits/issue-audit-2026-05-17.md`, `DOC-AUDIT-2026-05-17.md` → `docs/audits/doc-audit-2026-05-17.md`, `RELEASE-READINESS-v1.2.0.md` → `docs/audits/release-readiness-v1.2.0.md`. Plus new `docs/audits/README.md` documenting the convention (filename shape, naming, no-repo-root rule, audit-stage mapping, "how to add a new audit" recipe) so future passes don't slip the same way. 3 cross-references in `docs/audits/doc-audit-2026-05-17.md` updated to the new lowercase filename (the file self-referenced under its old name). Repo root is back to the canonical 4 `.md` files (README, CHANGELOG, CLAUDE, CONTRIBUTING). No code change. - **#735 , PR-body + source-comment discipline note (PR #738).** Audit of last-5h-merged PRs surfaced two methodology gaps that needed codifying so the next PR doesn't fall into the same trap. New `docs/audits/methodology.md` consolidates four audit-derived conventions: (1) **PR body & CHANGELOG test count claims** , use `@Test` directive count for the durable "tests" number and `@Suite` for "suites"; label runtime case counts explicitly when cited (PR #731 cited "50 tests / 8 suites" , actually 41 `@Test` directives / 7 `@Suite` declarations with parametrised expansion to 50 runtime cases). (2) **Source-comment promises about "before merge" / "next PR" / "release"** , don't write annotations asserting future events unless the comment is in the same commit as the action it asserts; use retention-window framing instead. Pre-merge critic pass should grep the diff for "will be" / "before merge". Surfaced by PR #731's deprecated-alias comment that lied at merge time (fixed in PR #736). (3) **Audit doc location** , back-reference to `docs/audits/README.md`'s convention. (4) **5 patterns Stage A protects against** , lifted from `closure-replay-2026-05-17.md`'s methodology section so they survive if the audit doc gets archived. Plus closure-protocol placeholder for the still-pending Stage E. No code change. ### Added - **#722 , `cupertino save --force-replace` recovery flag for runaway-save corruption (with self-review fixes for PID-reuse race + post-kill verification + full gate-decision-tree coverage).** Closes the remaining half of the #722 SaveSiblingGate follow-up (the `--from-setup` half was scoped out as moot , `cupertino setup` is a download-and-extract pipeline, no setup→save subprocess pipeline exists today). The base `SaveSiblingGate` shipped in PR #577 (#253) detected sibling `cupertino save` processes targeting the same DB and offered the `[c]/[w]/[a]` (continue / wait / abort) interactive prompt , no path to authorise killing the sibling. The runaway-save corruption class (two truncated `~/.cupertino/search.db` incidents on 2026-05-16, which motivated `feedback_never_touch_brew_db` + the canonical-bundle smoke-check script) needed a recovery path that didn't require killing PIDs by hand outside cupertino. This PR adds that path. New `@Flag(name: .long) var forceReplace: Bool` on `CLIImpl.Command.Save` + new `SaveSiblingGate.Action.forceReplaceSiblings(pids: [pid_t])` variant + new `SaveSiblingGate.promptForceReplace(...)` private helper + new `SaveSiblingGate.terminateSiblings(pids:graceSeconds:pollInterval:recording:)` static , SIGTERM ladder with 30s grace window + SIGKILL fallback for stragglers. Gate logic: `--force-replace` alone in an interactive TTY → typed-confirmation prompt requires the literal word `replace` (case-insensitive, leading/trailing whitespace tolerated; rejects `y` / `yes` / single keystrokes / typos / punctuation , explicitly higher bar than the `[c]/[w]/[a]` keys so nuking a multi-hour build by accident is impossible); `--force-replace --yes` → bypasses the typed-confirmation gate, logs the action, returns `.forceReplaceSiblings` (the CI / scripted authorisation pattern); `--force-replace` without TTY and without `--yes` → aborts with a clear "needs interactive confirmation OR `--yes` for unattended use" error (refuses to silently force-kill in an unattended context , same defensive default the rest of the gate uses). The SIGTERM ladder gives the sibling 30s to flush its SQLite WAL + checkpoint cleanly before falling back to SIGKILL , the grace window matters because a SIGKILL mid-INSERT leaves the DB in a `database is locked` / `database disk image is malformed` state. New `SaveSiblingGate.parseTypedReplaceConfirmation(_:)` pure function (the discrete branch the test surface asserts against; the interactive prompt at the call site reads `readLine()` then forwards into this parser). 9 new tests in `Issue722ForceReplaceTests` across three suites: 7-row accept-shape matrix (`replace` / `REPLACE` / `Replace` / leading-ws / trailing-ws / both-ws / `\treplace\n`); 9-row reject-shape matrix (empty / `y` / `yes` / `replac` / `replaced` / `REP LACE` / `replace!` / `abort` / `c`); nil-EOF rejects with empty echoed input; parser purity (3-call idempotency); `Action.forceReplaceSiblings` preserves PID list + Equatable conformance + distinguishability from `.proceed` / `.waitForSiblingsThenProceed` / `.abort`; `terminateSiblings([])` short-circuits before logging; `terminateSiblings` against a dead PID (forked `/bin/sleep 0` + waitUntilExit) completes in under 1s without hanging or throwing. Per the docs-mirror rule: new `docs/commands/save/option (--)/force-replace.md` covers the typed-confirmation gate semantics, the SIGTERM-grace-SIGKILL ladder, when-to-use vs when-NOT-to-use guidance, cross-link to `--yes`. `check-docs-commands-drift.sh` green (14 commands / 0 missing / 0 orphan / 0 enum). Full **2203-test suite green** (was 2194, +9 new). swiftformat clean. **Effect on the shipped bundle: zero binary contract change** , pure additive CLI flag; existing `cupertino save` invocations behave identically. The new flag is opt-in; the existing `[c]/[w]/[a]` prompt remains the interactive default. Closes the inline TODO opened by PR #577's `SaveSiblingGate` header. Carmack discipline: the same flag that authorises a destructive action has a typed-confirmation gate above it; never a single keystroke. - **`scripts/check-canonical-db-shape.sh` , defensive smoke check for the brew bundle's `search.db` shape.** Closes a triage-discipline gap exposed by the 2026-05-17 false-positive cluster (#708 / #709 / #715 / #719 , all closed against the same corrupt 160 MB / 37 frameworks / 21,701 documents DB; the residue of a same-session runaway-save incident). Each false positive was a chapter-writing-agent report probing the corrupt DB; each took a file-issue → cross-link → comment → close cycle that running this check upfront would have skipped. New `scripts/check-canonical-db-shape.sh` is a read-only smoke check: calls `cupertino list-frameworks`, parses the header line, asserts the framework count + total doc count are above the canonical-bundle floor (currently 300 / 200,000 , well above the corruption-state shape of 37 / 21,701 and well below any realistic shipped bundle). Honours `CUPERTINO_BIN` env override; defaults to `/opt/homebrew/bin/cupertino` (canonical user install) → falls back to `Packages/.build/debug/cupertino`. Exit codes: 0 = canonical shape, 1 = corrupt or partial bundle (with restore guidance), 2 = invocation error. The script never writes to `~/.cupertino/`; only reads through the binary's existing read-only path. Live-tested against the post-restore canonical bundle: `frameworks: 420 (floor: 300)` / `documents: 285735 (floor: 200000)` → `✅ canonical DB shape OK`. Operationalises the `feedback_smoke_check_db_state_before_filing` memory rule , turns the "always smoke-check before filing a search-quality bug" discipline from prose into a 30-second CLI invocation. **Effect on the shipped bundle: zero binary contract change** , pure dev / triage tooling, never invoked by the binary itself. Phase of the v1.2.x ironclad housekeeping: closes the loop on the four false-positive triage cycles this round burned. - **#665 follow-up , `search_generics` MCP tool gains `--platform / --min-version` filter coverage from #226.** PR #707 shipped `search_generics` as the 12th MCP tool but left the platform-filter plumbing as a deliberate fast-follow (noted in the original PR body + an inline `TODO`-style comment in `handleSearchGenerics`). PR #706 (#226) had wired the 5-arg platform filter into the other 4 AST tools (`search_symbols` / `search_property_wrappers` / `search_concurrency` / `search_conformances`) but #707 shipped on top so `search_generics` didn't inherit. This PR closes the coverage gap. `searchGenericsProperties` schema picks up the same `platformFilterProperties.merging(...)` extension the other 4 tools use, so `tools/list` now advertises `min_ios` / `min_macos` / `min_tvos` / `min_watchos` / `min_visionos` alongside `constraint` / `framework` / `limit`. `handleSearchGenerics` extracts platform args via the existing `Self.extractPlatformArgs(args)` helper and runs the result set through `Self.applyPlatformFilter` post-search , same pattern, same semver-aware semantics, same NULL-row drop behaviour as the other 4 AST tools. 5 new tests in `Issue665PlatformFilterFollowupTests` (E2E): no-args passthrough (regression guard against the iter-7 / #665 baseline still passing), filter-rejects-too-new (iOS 18 API filtered out at `min_ios=15.0`), filter-passes-old-enough (iOS 14 API kept at `min_ios=17.0`), NULL-min_ios rows dropped when filter is active, `tools/list` advertises all 5 `min_*` schema params on the search_generics tool entry. Per the docs-mirror rule: `docs/tools/search_generics/README.md` updated with the new parameters' descriptions + a usage example. Full **2194-test suite green** (was 2189, +5 new , counts include intervening merges of #716/#717/#718). swiftformat clean. **Effect on the shipped bundle: zero on-disk change** , pure MCP wire surface extension; existing callers without `min_*` args see byte-identical output. The 12th MCP tool now has feature parity with its 4 AST siblings. Carmack-rule discipline: every MCP tool surface that takes a result set runs through the same filter pipeline. - **#225 Part B , `implementation_swift_version` column on `docs_metadata` + `cupertino search --swift <ver>` filter for swift-evolution rows (schema v15 → v16, in-place ALTER TABLE migration).** Pre-fix the v1.2.x bundle indexed swift-evolution proposals without capturing the Swift toolchain version each one landed in , the only signal was a derived iOS/macOS minimum baked into the `min_ios` / `min_macos` columns via `StrategyHelpers.mapSwiftVersionToAvailability`. Users couldn't ask "which proposals shipped in Swift 6.0 or earlier" without re-deriving the inverse from the platform mapping. New `Search.StrategyHelpers.extractImplementationSwiftVersion(from:)` scans proposal markdown for a primary `Implementation: Swift X[.Y]` line (the standard swift-evolution convention) with `Status: Implemented (Swift X.Y)` as a fallback for proposals that only carry the version on the status line. The parser tolerates four common markdown shapes , bare bullet `* Implementation: Swift X`, bold `**Implementation:** Swift X`, bold-wrapping `Implementation: **Swift X**`, and the colon-immediately-bold `Implementation:**Swift X**` , via a `[\s*]*` character class between the colon and the `Swift` keyword. Single-component versions normalise to `<major>.0` (`Swift 6` → `"6.0"`) so the on-disk shape stays consistent for the post-fetch semver compare. The parser returns `nil` on no-match; the indexer writes `NULL`. **Schema bump:** `docs_metadata` gains `implementation_swift_version TEXT` declared in the initial `CREATE TABLE` for v16+ fresh DBs; `Search.Index.Migrations.swift` adds a `migrateToVersion16()` helper that does `ALTER TABLE docs_metadata ADD COLUMN implementation_swift_version TEXT` plus `CREATE INDEX IF NOT EXISTS idx_implementation_swift_version`. Unlike the v14 → v15 step (#274 , which had to throw `schemaVersionMismatch` because the new `inheritance` edge table needed walk data only available via a full re-index), v15 → v16 is a clean in-place column add , existing v15 DBs gain the column with `NULL` rows that get populated on the next re-index, every other source's rows stay `NULL` by design. **Indexer wiring:** `Search.Index.indexStructuredDocument` grows a new `implementationSwiftVersion: String? = nil` parameter that binds at SQL parameter index 18 (the `kind` column at index 17 stays put, preserving the existing bind-index contract); `Search.Strategies.SwiftEvolution.indexProposal` extracts the version via the new helper and passes it through. **Filter surface:** `Search.Database.search(...)` grows a `minSwift: String?` parameter; the convenience overload's defaulted-nil case keeps legacy callers compiling unchanged. The `Search.Index.search(...)` implementation adds an `AND m.implementation_swift_version IS NOT NULL` SQL pre-gate (uses the new index for selectivity), captures the column value into a `[uri: version]` map during the row-iteration loop, and applies a semver-aware compare via the existing `Search.Index.isVersion(_:lessThanOrEqualTo:)` algorithm after fetch (string compare would get `"5.10" <= "5.2"` wrong; the post-fetch in-memory compare prevents that class of bug). NULL-row rejection matches the platform-filter semantic , every non-evolution row is dropped when `--swift` is set, plus every evolution row whose markdown the parser couldn't read a version from. **CLI:** `cupertino search --swift <ver>` on `Search.Search.swift`; help text names the swift-evolution scope explicitly and flags the cross-source rejection so users don't see surprise empty results when running `cupertino search "actors" --swift 5.5` without a `--source` scope. **MCP:** new `min_swift` JSON Schema parameter on the `search` tool; `Shared.Constants.Search.schemaParamMinSwift` constant (`"min_swift"`). **Plumbing path:** `Services.SearchQuery` grows `minimumSwift: String?` (defaulted nil); `Services.DocsSearchService.search`, `CompositeToolProvider.handleSearchDocs`, `CompositeToolProvider.handleSearch`, and the CLI `runDocsSearch` all thread the value end-to-end. **Docs:** `docs/commands/search/option (--)/swift.md` per the docs-mirror-cli rule. 13 new tests in `Issue225PartBImplementationSwiftVersionTests` across two suites: 10 parser tests pin the primary/fallback regex shapes, bold-marker tolerance, single-component normalisation, multi-digit-minor preservation (the semver invariant for `5.10` ≠ `5.1`), case-insensitive scan, and 4 no-match shapes (empty input, accepted-not-yet-implemented, status-without-version, missing-version-line); 3 persistence tests pin the round-trip through `Search.IndexBuilder` → `Search.Index.search` (fresh v16 DB writes and reads the column back; NULL row rejected with `--swift` set but visible without it; semver-aware compare correctly accepts `5.10` for `--swift 5.10` and rejects it for `--swift 5.2`). Schema-version pin tests (`SchemaShapeTests`) updated from 15 → 16. Full **2168-test suite green** (was 2155 pre-#225 Part B , +13 from the new suite). swiftformat clean. `check-docs-commands-drift.sh` green (14 commands, 0/0/0). **Effect on shipped v1.2.x bundle: zero corpus change** for non-evolution rows; swift-evolution rows gain a new column populated on the next re-index. **Effect on existing v15 DBs (post-deploy):** the migration runs in-place at open time, ALTER TABLE adds the column with `NULL` values, and the user can either re-index swift-evolution to populate it (`cupertino save --docs`) or leave it `NULL` (the column is informational; `--swift` is opt-in). Companion to develop's Part A (`swift_tools_version` column on `packages.db` package_metadata + `cupertino package-search --swift-tools` filter , #225 issue body's "ship in two commits if convenient" split, executed cleanly across two parallel PRs). ### Changed - **#241 , CLI help-text audit: surface `package-search` + `inheritance` in the top-level `cupertino --help` listing; drop trailing issue-number leak from `doctor --help`; drop the historical `cupertino ask` reference from `search --help`.** Pre-fix the top-level `cupertino --help` discussion (`Cupertino.swift`'s `CommandConfiguration.discussion`) listed 13 subcommands under the QUERY / SAMPLE CODE / DIAGNOSTICS groupings, while the subcommands array carries 15 , `package-search` and `inheritance` were both registered as runnable subcommands but invisible from the top-level listing. New users seeing `cupertino --help` had no signal that those two commands existed. Both surface now under QUERY (`package-search Smart query over the packages corpus (packages.db source only)` and `inheritance Walk class inheritance chains (Apple class-based APIs)`), matching the wording each command's own abstract uses. `cupertino doctor --help` lost the trailing `(#68)` issue reference at the end of its discussion block , per the audit's acceptance bar (`Help text mentions issue numbers only when the behaviour they describe is non-obvious; otherwise drop the issue references that have crept into option help , the implementation tracks issues, the user-facing help shouldn't carry them`). `cupertino search --help` lost the parenthetical `(absorbed from the removed cupertino ask)` historical note inside the discussion , `cupertino ask` has been removed since #239, the note added no current-behaviour signal, and the surrounding sentence reads cleaner without it. **No subcommand was renamed; no option was added or removed: no behaviour changed.** Pure help-text sweep. Build clean; `check-docs-commands-drift.sh` green (14 commands, 0 missing / 0 orphan / 0 enum drift); live-verified by running `./Packages/.build/debug/cupertino --help` + `... doctor --help` + `... search --help` and confirming each output reflects the changes. **Effect on shipped v1.2.x binary: pure text change in the `--help` output stream; no API surface, no tests altered, no docs/commands/ pages added or removed.** Out-of-scope items per the issue body (deferred): top-level command-grouping into a hand-curated table (currently inherited from ArgumentParser's default alphabetical sort within each documented section), man pages, shell completions, README/marketing copy. The "do AFTER #239 and #240" gate noted in the issue body: #239 (ask → search merge) is closed, #240 (new `query` subcommand for raw SQL) is still open, so this PR covers the post-#239 sweep , when #240 lands, a follow-up will re-audit the discussion block for any new `query`-specific drift. ### Removed - **#194 , drop the bundled 568 KB swift-packages URL list; canonical corpus now in `packages.db`.** Pre-fix `Packages/Sources/Resources/Embedded/Resources.Embedded.SwiftPackagesCatalog.swift` carried a compile-time `[String]` of **9 699 GitHub URLs** (9 719 lines, 568 KB on disk, 538 KB compiled) , the post-#161 slimmed remnant of the original 3.4 MB JSON catalog. Two consumers read it: `Core.Protocols.SwiftPackagesCatalog.allPackages` (the public accessor every caller goes through) and the `Search.Strategies.SwiftPackages` index step (which would round-trip the URLs through `GitHub.MetadataFetcher` to materialise stars / license / description on a `--packages` save). Both paths have been superseded since v1.0.0: brew users get pre-indexed package rows from the bundled `search.db` that ships via `cupertino setup`, and the canonical Swift-packages corpus (URLs + metadata + stars + everything else the embedded list was a degraded copy of) lives in `packages.db` , a separately-distributed artifact owned by the indexer pipeline, not a literal compiled into the binary. Three changes: (1) the embedded file is **deleted outright** (-9 719 lines, -568 KB source / -538 KB binary). (2) `Core.Protocols.SwiftPackagesCatalog.loadEntries()` rewritten to return `[]` unconditionally; `count` returns `0`; `lastCrawled` returns `""`. Comment block names the migration target so a future revert author looks at `packages.db` first. The `SwiftPackageEntry` struct + `cache` actor + `packages(by:)` / `search(_:)` filter methods stay for source-compatibility (legacy on-disk JSON archives still decode through the same shape). (3) `scripts/generate-embedded-catalogs.sh` skips `swift-packages-catalog.json` in the per-file loop with a banner explaining the migration; the `emit_packages_url_list` Python helper is deleted. Two end-user behaviour changes: (a) `cupertino save --packages` against a binary with no `packages.db` present now hits `Search.Strategies.SwiftPackages`'s existing `guard !packages.isEmpty` clean-skip path (#671) and reports `swift-packages: clean-skip (catalog empty)` instead of fetching GitHub metadata for 9 699 URLs over ~30 min. Brew users (`cupertino setup` → bundled `search.db` already populated) see zero behaviour change. (b) The `cupertino-tui` package picker, which reads from the same accessor, now renders a centered banner , "No Swift packages catalog loaded. Run `cupertino setup` to download the package index, then relaunch `cupertino-tui` to browse and curate packages." , when `state.packages.isEmpty`, replacing the previous blank picker that left the empty state unexplained. Search-filtered empty results (user typed a query that matched nothing) keep their original blank-rows rendering , that's the existing "no results for current query" affordance. Tests: `Packages/Tests/CoreProtocolsTests/CoreProtocolsTests.swift` replaces the catalog-load smoke test with `swiftPackagesCatalogIsEmpty` asserting `count == 0`, `allPackages.isEmpty`, `lastCrawled == ""`; `Packages/Tests/CoreTests/CupertinoCoreTests.swift` replaces 4 catalog-content tests with 4 empty-contract pin tests at the same call sites; `Packages/Tests/CLICommandTests/SaveTests/SaveTests.swift` replaces `indexPackagesCatalog` (was asserting `totalPackages >= 9000`) and `packageCatalogMetadata` (was asserting `searchPackages("swift argument parser").first != nil`) with `indexPackagesCatalogIsEmpty` (full `Search.IndexBuilder` build → `searchPackages.isEmpty` + `packageCount() == 0`) and `packageCatalogIsEmpty` (direct accessor pin). Full **2118-test suite green** (no count change; tests were rewritten, not added net-new). swiftformat clean. **Effect on the shipped v1.2.0 bundle: zero corpus change** , `packages.db` and its derived `search.db` rows are unaffected; the only delta is binary size. **Effect on binary size: ~530 KB compiled-binary shrink** (the embedded literal was 568 KB of UTF-8 + ~10 % overhead from string-table layout; the actual binary delta after link strips dead code is bounded by the source delta). **Class-of-coupling eliminated:** the binary no longer carries a stale snapshot of Apple's open-source-package list shadowing the canonical `packages.db` corpus, so the "is the URL list current?" question is no longer ambiguous , the answer is always "look at `packages.db`'s `lastCrawled`". Phase-#194 of the v1.2.0 housekeeping pass. ### Fixed - **#113 follow-up , audit-count emission on indexer doc:// rewrites.** PR #710 shipped the rewriter + wire contract + post-save invariant but explicitly deferred the audit-count emission (acceptance item: "save run emits rewrite count to logs (audit trail)"). This PR closes that item. Both indexer entry points (`Search.Index.indexDocument` + `Search.Index.indexStructuredDocument`) now emit a `.debug`-level record via the injected `LoggingModels.Logging.Recording` when the rewriter substituted one or more occurrences in the page being indexed. Message format: `doc-link-rewrite: <total> substitutions in <uri> (content=<n1>, json=<n2>)` , total + per-surface breakdown so a maintainer reading save logs can see at a glance which pages carried the heaviest renderer-translation failures. Category: `.search`. Level: `.debug` (low-traffic, opt-in; production builds wire the OSLog backend at `.info`+ so the audit lines don't pollute default logs but are available with `cupertino save --verbose` / `OS_ACTIVITY_MODE`). Zero-count pages stay silent , the vast majority of indexed pages have no `doc://` substring at all (the renderer succeeds most of the time), and logging a no-op record per page would drown the save logs in 280k empty events on a full corpus reindex. The `DocLinkRewriter.rewrite(_:)` signature is unchanged , the count was already in the return type from PR #710; this PR just wires it into the indexer's existing logger. 4 new tests in `Issue113AuditCountEmissionTests`: in-test `CapturingRecording` captures every record; positive `indexDocument` with 2-in-content + 1-in-json emits a single audit record with `"3 substitutions"` + the URI + `.debug` level + `.search` category; zero-count `indexDocument` stays silent (no log spam on clean pages); positive `indexStructuredDocument` emits the same shape; multi-page aggregate (3 dirty pages + 1 clean) emits exactly 3 audit records and the clean URI doesn't surface in any of them. Full **2159-test suite green** (was 2155 pre-this-PR , +4 new tests). swiftformat clean. **Effect on the shipped v1.2.0 bundle: zero binary contract change** , pure logger emission, no on-disk schema or wire surface affected. Closes the last unchecked acceptance bullet on #113. - **#113 , index-time `doc://` → `https://` link rewriter (total rewrite policy).** Pre-fix Apple's DocC renderer occasionally failed to translate internal `doc://<bundleID>/documentation/<framework>/<path>` URIs to public `https://developer.apple.com/documentation/<framework>/<path>` URLs before serving the HTML pages cupertino crawls. Raw `doc://` URIs leaked through the renderer, got stored verbatim in `docs_fts.content` + `docs_metadata.json_data`, and surfaced in `search` snippets and `read_document` payloads , AI clients reading those responses hit unfollowable references with no public-URL equivalent. New `Search.DocLinkRewriter.rewrite(_:)` pure function performs the substitution at the indexer write boundary: scans for `doc://<any-host>/documentation/<rest>`, emits `https://developer.apple.com/documentation/<rest>` (with anchors / multi-segment paths preserved), short-circuits when input contains no `doc://` substring, returns the rewritten string + an audit count. No DB lookup needed , the URL is mechanically correct even when the target page wasn't crawled (edge-case (a) per the issue body: "AI clients can choose to follow it; the link is correct"). JSON-safe by construction , the substituted substring contains no JSON-meta characters (`"`, `\\`, control chars), so the same primitive handles both the FTS-side `content` blob AND the serialised `json_data` payload. Wired into `Search.Index.indexDocument(_:)` (FTS-only path) , `params.content` rewritten before binding to `docs_fts.content`, `params.jsonData` rewritten before binding to `docs_metadata.json_data`, summary inherits the rewrite via `extractSummary(from: content)` automatically. Wired into `Search.Index.indexStructuredDocument(...)` (FTS + structured path) , the same two surfaces (`content` + `json_data`) rewritten before binding. Scan terminates at characters that conventionally end a URL in surrounding markup (whitespace + `<`, `>`, `"`, `` ` ``, `(`, `)`, `[`, `]`, `{`, `}`) so the rewriter doesn't swallow trailing markdown punctuation or prose. 21 new tests across two files. `Issue113DocLinkRewriterTests` (16 unit sub-tests counting the 2 parameterised matrices) pins the pure-function contract: standard path, canonical Apple bundle ID, anchor preservation, multi-segment path with hash-suffix, bundle-ID agnostic (5-row matrix), prose surrounding preserved, multiple occurrences per input + count accuracy, idempotency (run twice == run once), no-match short-circuit, empty input, `doc://` without `/documentation/` anchor preserved verbatim, 5-row URL-terminator matrix, whitespace terminator, JSON-structure preservation (parse the rewritten output → still valid JSON with rewritten links inside), total-rewrite invariant on 5 standard input shapes, exact-count audit (0 / 1 / 5). `Issue113IndexerRewriteIntegrationTests` (5 E2E sub-tests) pins the wire contract: `indexDocument` rewrites `docs_fts.content`, `indexDocument` rewrites `docs_metadata.json_data`, `indexStructuredDocument` rewrites `json_data` on metadata write, multi-page post-save sweep (`SELECT COUNT(*) FROM docs_metadata WHERE json_data LIKE '%doc://%'` returns 0 across `docs_fts.content` + `docs_fts.summary` + `docs_metadata.json_data`), clean-input round-trip (pages with no `doc://` flow through unchanged , no false rewrites). Full **2155-test suite green** (was 2134 pre-#113 , +21 new tests). swiftformat clean. **Effect on the next reindex: zero raw `doc://` URIs survive `cupertino save`.** Brew users running against the existing v1.0.2 bundle keep the old leaked URIs (the bundle is the source of truth post-distribution); the first v1.0.3 reindex bakes the rewrite into the shipped bundle. **Class-of-bug eliminated:** the renderer-translation-failure footgun no longer surfaces to MCP clients. Audit-count emission via save logs is a quiet follow-up , the post-save invariant + the `extractAvailabilityFromJSON` separation (which still reads the un-rewritten `jsonData` for platform version numbers) are the load-bearing guarantees this PR ships. - **#673 Phase E , typed schema-mismatch error with directional remediation + `EX_DATAERR` (65) exit code.** Pre-fix all 5 schema-mismatch throw sites in `Search.Index.Migrations.checkAndMigrateSchema` raised `Search.Error.sqliteError(<long string>)`. The CLI's `exit(withError:)` rendered the string description with a generic `EXIT_FAILURE` (= 1) exit code; scripts could not distinguish "schema mismatch , recoverable, here's the fix" from "real SQLite error". The remediation hint was always `cupertino setup` even for the DB-newer-than-binary direction (where the right fix is `brew upgrade cupertino`, not a fresh setup against the same too-new bundle). New `Search.Error.schemaVersionMismatch(currentDBVersion: Int, expectedBinaryVersion: Int, dbPath: String)` case carries the raw version numbers + DB path. `errorDescription` formats a directional remediation block: when DB > binary, recommends `brew upgrade cupertino` (or rebuilding from source for dev setups) with a force-reset fallback; when DB < binary, recommends `cupertino setup` for the matching pre-built bundle (or `cupertino save` for a local rebuild). All 5 throw sites in `Search.Index.Migrations` updated to raise the typed case (was: forward-mismatch at line 84-87 + 4 breaking-step boundaries at v4→v5, v11→v12, v12→v13, v13→v14, v14→v15). `Cupertino.main` catches the typed case, writes the user-friendly `errorDescription` to stderr (NO Swift stack trace , that's the contract for known recoverable errors per #673 Phase E spec), and exits with `EX_DATAERR` (65 , sysexits(3) "the input data was incorrect in some way") so scripts / agents / doctor wrappers can detect the class without parsing the message text. **Live verified end-to-end** on the locally-built release binary: stamped a fresh search.db with `PRAGMA user_version = 99`, ran `cupertino list-frameworks --search-db <stamped.db>` → exit code **65** + stderr message `"Database schema mismatch: search.db at /tmp/... is at schema version 99, but this cupertino binary only understands up to version 15. Remediation: • If you installed via Homebrew: \`brew upgrade cupertino\` • If you build cupertino from source: rebuild your binary … • To force-reset to the binary's current schema: \`rm '…' && cupertino setup\`"`. `cupertino doctor`'s pre-existing schema-version check (`reportSchemaVersion(at:)`) already surfaced the same direction-aware remediation hint inline (line 359-365: `cupertino save` for DB-older, `brew upgrade cupertino` for DB-newer) so the proactive surface was already in place , this PR aligns the error-path message with doctor's wording and adds the exit-code signal that doctor's `throw ExitCode.failure` didn't carry. 11 new tests in `Issue673PhaseESchemaMismatchTests` (6 named + 5 parameterised sub-cases) pin: future-version DB throws the typed case with raw version numbers wired through; parameterised over each of the 5 breaking-step boundaries (`v4→v5`, `v11→v12`, `v12→v13`, `v13→v14`, `v14→v15`) confirms each throws `.schemaVersionMismatch` (not `.sqliteError`); DB > binary `errorDescription` contains `brew upgrade cupertino`; DB < binary `errorDescription` contains `cupertino setup`; `errorDescription` contains no Swift stack-trace shapes (no enum case name leak, no `file:///` URL leak, no `.swift` source path leak); matching-version DB does NOT throw the mismatch case (sanity). Full **1966-test suite green** (was 1960 pre-#673 Phase E , +6 from the new suite + 5 parameterised tests). swiftformat clean. **Effect on shipped v1.2.0 bundle: zero binary contract** , the error case shape changed but the wire / disk format didn't; brew users who hit a schema mismatch see a better message + a script-detectable exit code instead of a stack trace. **Class-of-bug ergonomics:** known recoverable errors no longer look like crashes. Carmack-rule discipline from #673 Phase E. - **#686 , CI stability sweep: skip every real-network test + bump `waitForMessage` timeout 5 s → 15 s + fix the silently-ineffective pre-existing skip (supersedes #685's tactical fix).** Audit of the last 14 `main`-branch CI runs found 9 failures vs 4 successes vs 1 cancelled (~64 % failure rate). Two recurring failure modes plus one latent bug: (1) `CrawlTests.fetchSwiftEvolution` (live GET against `github.com/swiftlang/swift-evolution`) hit `Caught error: invalidResponse` in 2 of 4 recent failures; (2) `JSONRPCErrorResponseTests.toolCallErrorBecomesJSONRPCError` (line 204 `#require(initResponse != nil)`) failed in 3 of 4 , `InMemoryTransport` polling bailed at the 5 s default before the macos-15 runner's cold-start delivered the response (locally ~200 ms; CI occasionally 5-8 s); (3) the existing `--skip testDownloadRealAppleDocPage` was a no-op , `--skip` takes a regex matching test function names, but `testDownloadRealAppleDocPage` doesn't match anything (the real function is `downloadRealAppleDocPage`, no `test` prefix; that's an XCTest convention, not Swift Testing). The Apple-docs network test had been running on every CI run since the workflow was written , Apple's CDN happened to be reliable enough that the silently-running test wasn't part of the 64 % failure rate, but the contract said skip and the workflow now matches. Three fixes: (a) Comprehensive audit of every `@Test(.tags(.integration))` case in `Packages/Tests/`, classified as real-network vs heavy-but-deterministic. Real-network set (10 tests across 8 files): `downloadRealAppleDocPage` (Crawler.AppleDocs.IntegrationTests), `fetchSinglePage` / `fetchWithResume` / `fetchSwiftEvolution` (CrawlTests), `canonicalizerFollowsRename` (ResolverPipelineTests), `downloaderCreatesOutputDirectory` (SampleCodeDownloaderTests), `httpErrorPageDefersAndRetriesSuccessfully` (RetryQueueTests), `crawlerCreatesOutputDirectory` (CrawlerTests + SwiftEvolutionCrawlerTests , same name in 2 files; regex catches both), `crawlerRespectsMaxPages` / `lapackFunctionsURLIsProblematic` (CrawlerTests). All 10 added to `.github/workflows/ci.yml`'s `--skip` list with an alphabetical endpoint-reference block documenting per-skip rationale. Heavy-but-deterministic tests (`buildSearchIndex`, `searchWithFrameworkFilter`, `registerSearchProvider`, `executeSearchTool`, `completeSearchWorkflow`) confirmed network-free + left running. (b) Replaced the silently-broken `--skip testDownloadRealAppleDocPage` with the working `--skip downloadRealAppleDocPage`. (c) `Packages/Tests/MCP/CoreTests/JSONRPCErrorResponseTests.swift` bumps the `waitForMessage` default timeout from 5.0 to 15.0 with a doc-comment block citing the CI cold-start jitter + naming the root-cause investigation (no signal on when the server has fully bound the transport's processing task , a #673 Phase B class-of-bug). Conservative defensive bump , well below the suite's wall-time budget; the test passes locally in 90 ms regardless of the upper bound. **Verified locally:** `swift test --filter JSONRPCErrorResponseTests` → 1/1 in 0.091 s. **Acceptance bar (from #686):** 7 consecutive green main-branch CI runs post-merge. Tests stay runnable locally (no `@Test` attribute changed; the skip is workflow-side only) , local devs with network keep full coverage. **Effect on the shipped binary: zero** , pure CI-workflow + test-helper change. - **#675 / #673 Phase H , make `~/.cupertino-dev` isolation the only path (no longer relies on Makefile conf-drop).** Pre-fix the binary's `Shared.Paths.live()` always defaulted to `~/.cupertino/` (the brew production path) and the `cupertino.config.json` drop performed by `make build-release` / `make build-debug` was the only thing preventing dev builds from clobbering the brew install. A raw `xcrun swift build -c release` (or any build that skipped the conf-drop step , opening the project in an editor, a CI step that calls `swift build` directly, copying a dev binary out of `.build/release/`) produced a binary that silently targeted `~/.cupertino/`. On 2026-05-16 this caused two corruption incidents in one session: a 2.48 GB `~/.cupertino/search.db` truncated to 429 MB by a half-completed save, then a fresh `cupertino setup`-installed bundle wiped from 285,735 docs to 21,701 docs by a second un-isolated save. New `Shared.Constants.BinaryConfig.Provenance` enum + `classify(executablePath:)` pure function self-classifies the running binary at startup: paths under `/opt/homebrew/bin/` / `/opt/homebrew/Cellar/` / `/usr/local/bin/` / `/usr/local/Cellar/` / `/home/linuxbrew/.linuxbrew/` resolve to `.brewInstalled` → default to `~/.cupertino/`; every other path (`.build/`-relative dev builds, CI workspaces, manually copied binaries, `/tmp/`, `/usr/bin/`, etc.) resolves to `.other` → default to **`~/.cupertino-dev/`**. New `Shared.Constants.devBaseDirectoryName = ".cupertino-dev"` constant. `Shared.Paths.init(binaryConfig:provenance:)` and `Shared.Paths.live()` updated to take the provenance signal alongside the optional conf override. Resolution priority: (1) explicit `cupertino.config.json` `baseDirectory` override wins; (2) brew-installed binaries default to `~/.cupertino/`; (3) every other binary defaults to `~/.cupertino-dev/`. Brew bottles ship unchanged , the binary at `/opt/homebrew/bin/cupertino` classifies as `.brewInstalled` and reads `~/.cupertino/` as before. The Makefile's conf-drop continues to work for backwards compatibility but is no longer load-bearing for isolation. **Live verified end-to-end on the locally-built release binary**: (a) WITH the conf file at `.build/release/cupertino.config.json` → resolves to `~/.cupertino-dev/` (conf override wins, as expected); (b) WITHOUT the conf file (the exact 2026-05-16-incident scenario) → still resolves to `~/.cupertino-dev/` via the `.other` provenance default , `cupertino doctor` confirms all 4 paths land under `~/.cupertino-dev/`, never `~/.cupertino/`. 8 new tests in `BinaryConfigProvenanceTests` cover: parameterised 6 brew prefixes classify as `.brewInstalled`; parameterised 10 non-brew paths classify as `.other` (including `.build/` SwiftPM paths, `/tmp/`, `/usr/bin/`, `/Users/runner/work/...` CI paths, empty + bare strings); look-alike paths containing a brew prefix as a substring but NOT a prefix correctly classify as `.other` (defensive against `/Users/x/opt/homebrew/...`); conf override wins for both provenances; brew + no conf → `~/.cupertino/`; non-brew + no conf → `~/.cupertino-dev/`; constant value pin (`devBaseDirectoryName == ".cupertino-dev"`). Full **1955-test suite green** (was 1939 pre-#675 , +16 from the 2 parameterised tests + 6 case-specific tests + base-init pin). swiftformat clean. `docs/binaries/README.md` rewritten to document the new resolution rules per the docs-mirror rule. **Effect on shipped v1.1.0 bundle: zero** , pure code change to the dev-side path resolution; brew users see no behaviour change because brew-installed binaries continue to read `~/.cupertino/`. **Class-of-bug eliminated:** the "raw `swift build` produces an unsafe binary" footgun is gone. Carmack-rule discipline from #673 Phase H. - **#668 , close the docs_structured coverage gap for HIG / Swift Evolution / Apple Archive sources.** Pre-fix the 3 markdown-source strategies (`HIGStrategy`, `SwiftEvolutionStrategy`, `AppleArchiveStrategy`) called `Search.Index.indexDocument` (FTS-only path) instead of `Search.Index.indexStructuredDocument` (FTS + docs_structured). `cupertino doctor --kind-coverage` on the v1.2.0 bundle reported these 3 sources at **100% `(missing)` rate** , none of their rows had a `docs_structured` entry at all. Every search-quality fix that depends on `s.kind` (the #177 rerank tier, the #616 kind-aware tiebreak, the #630 canonical-prepend filter) was a no-op for ~1k rows (368 archive + 173 hig + 483 evolution per the post-reindex audit). Two new shared helpers in `Search.StrategyHelpers`: `makeArticleStructuredPage(url:title:rawMarkdown:crawledAt:contentHash:)` builds a minimal `StructuredDocumentationPage` with `kind: .article` + `source: .custom` (the closest existing Kind case for prose-only markdown; finer-grained classification , e.g. a future `.proposal` for SE-* , can be added as a new Kind case without changing the call sites); `encodeStructuredPageToJSON(_:)` serialises the page to a JSON string for the `indexStructuredDocument(jsonData:)` parameter with `.iso8601` date strategy (matches the indexer's pre-existing convention in `Search.Strategies.SwiftOrg`). All 3 strategies updated to build the page + serialise + call `indexStructuredDocument` with the same platform-availability overrides they passed to `indexDocument` (HIG: universal; SwiftEvolution: Swift-version-derived; AppleArchive: framework-availability lookup). 5 new tests in `Issue668DocsStructuredCoverageTests`: end-to-end seed-and-verify for each of the 3 sources (`HIGStrategy`, `SwiftEvolutionStrategy`, `AppleArchiveStrategy`) confirms a row exists in `docs_structured` with `kind = "article"` post-fix; helper-shape unit test pins `makeArticleStructuredPage` defaults (kind=`.article`, source=`.custom`, nil-defaulted optional fields stay nil); JSON round-trip test confirms `encodeStructuredPageToJSON` produces decodable JSON with the right kind. Full **1960-test suite green** (was 1955 pre-#668 , +5 new tests). swiftformat clean. **Effect on the next v1.2.0 reindex: `cupertino doctor --kind-coverage` reports `apple-archive` / `hig` / `swift-evolution` at 0 % `(missing)` rate** (all rows classified as `.article`) instead of 100 % missing. Brings these 3 sources into the same canonical-ranking / kind-aware-tiebreak coverage the apple-docs / swift-org rows already enjoyed. - **#632 / #673 (ironclad) , mock-ai-agent first-run hang: per-request timeout + 3s pre-roll on the MCP send-and-await path.** The intermittent 60s hang main flagged after a clean rebuild traced to `MCPClient.readLine(from:)` parking a `CheckedContinuation` in `pendingResponses` with no deadline. If the server's cold-start (DB open + Composition bootstrap) didn't complete inside the agent's 1-second pre-send sleep, the agent's `initialize` request hit a not-yet-ready stdio reader, the server never sent a response, and the continuation never resumed , agent hung indefinitely (60s+ was the user's force-killed observation, not an actual bound). Fix: (1) New `MCPClientError.responseTimeout(seconds: Int)` case with a diagnostic message that names the cold-start path as the likely cause and points to the `[SERVER STDERR]` lines above for context. (2) `readLine(from:timeoutSeconds:)` spawns a sibling `Task` that sleeps the deadline and calls `dequeueOldestAsTimeout(seconds:)` on the actor; the actor-serialised array-mutation between `handleLine` (response arrived) and the timeout-dequeue ensures exactly one resumption , no double-resume risk on the CheckedContinuation. Default `timeoutSeconds = 30` covers the worst observed clean-rebuild cold-start (~10-15s for SPM-relinked binary + DB open + Composition bootstrap) with margin. (3) Bumped the server-start pre-roll from 1s → 3s so the cold-start path completes before the agent starts sending; faster start still wins because each request has its own deadline. **Empirically verified:** running `mock-ai-agent --quiet /bin/sleep 600` (server that never responds) now exits at **35.093s total wall time** with the message `"❌ Error: MCP server did not respond within 30s. Cold-start (DB open / Composition bootstrap) may have stalled, or the server crashed before answering. Check the [SERVER STDERR] lines above for clues."` , converts a 60s+ silent hang into a clear ~30s failure with named cause. Carmack discipline: "every blocking await has a deadline; the failure mode is obvious". Phase A of the ironclad epic (#673). swiftformat clean. swiftlint baseline unchanged (only pre-existing rename + blanket-disable + for_where warnings, none introduced by this PR). **Effect on shipped bundle: pure dev-tooling change** , `mock-ai-agent` is the SKILL Step 5/6 demo binary, not a user-facing surface. Affects only dev workflow + future post-promote retest signal quality. ### Added - **#225 Part A , `swift_tools_version` column on `packages.db` `package_metadata`.** Pre-fix neither `packages.db` nor `search.db` carried a queryable Swift language version. The closest signal was `package_metadata.min_ios` (#219 follow-up) which only loosely implied a Swift floor , wrong in both directions: a package targeting iOS 13 can be authored in Swift 6, and a package authored in Swift 6 doesn't necessarily target iOS 18. The right data lives in `Package.swift` line 1 (`// swift-tools-version: X.Y`) but wasn't being parsed. This PR adds the indexer-side capture + the query-side filter pushdown + the CLI surface. New `ASTIndexer.AvailabilityParsers.parseSwiftToolsVersion(from:)` pure function regex-matches the declaration on the first non-blank line and returns the major.minor (patch versions stripped , the column is a coarse Swift floor axis matching `min_ios` semver-prefix shape). Forwarder lifted to `Core.PackageIndexing.PackageAvailabilityAnnotator.parseSwiftToolsVersion(from:)` so the #219 annotation pass picks up the new signal alongside the deployment-target platforms , both come from the same on-disk read of `Package.swift`; no extra IO. `Core.PackageIndexing.AnnotationResult` gains a `swiftToolsVersion: String?` field (default-nil, source-compatible). `Search.PackageIndex.AvailabilityPayload` carries the value into the indexer write path. Schema bump packages.db v2 → v3: new `swift_tools_version TEXT` column on `package_metadata` + matching `idx_pkg_swift_tools` index; `migrateToVersion3()` adds the column via `ALTER TABLE` (idempotent , duplicate-column ALTER errors silently ignored, mirrors the v2 migration pattern). New `Search.SwiftToolsFilter` model in SearchModels (orthogonal axis from `Search.AvailabilityFilter` , filters on Swift compiler floor, not deployment target). `Search.PackageQuery.answer(...)` + `fetchCandidates` + `fetchTopFile` + `fetchCanonicalRepoCandidates` extended with `swiftTools:` parameter; SQL push-down adds `AND m.swift_tools_version IS NOT NULL AND m.swift_tools_version >= ?` to the WHERE clause. NULL rows dropped when filter active (unknown = excluded, same semantics as `AvailabilityFilter`'s NULL handling). `Search.PackageFTSCandidateFetcher` and the `cupertino package-search` CLI gain `--swift-tools <ver>` (mirrors `--platform` / `--min-version` shape but on an orthogonal axis , both can stack on the same query). 17 new tests across two files. `Issue225SwiftToolsVersionParserTests` (10 sub-tests counting the parameterised matrices) pins the parser contract: 6-row canonical-declaration matrix (`5.7`, `6.0`, `5.10`, no-space `:`, extra whitespace, etc.), leading blank lines + whitespace-only lines tolerated, declaration buried below other comments returns nil (SwiftPM also rejects this), 5-row malformed-input matrix returns nil (missing `//`, missing `:`, non-numeric, major-only, all-caps), 3-row patch-suffix matrix (truncates to major.minor), realistic Package.swift round-trips cleanly, parser purity. `Issue225SwiftToolsVersionIntegrationTests` (7 sub-tests) pins the indexer + query wire contract: fresh DB carries the column + index at user_version=3, indexer persists swift_tools_version when AvailabilityPayload carries it, indexer leaves NULL when payload doesn't, filter accepts rows ≥ floor, filter drops NULL rows when active, nil filter is transparent (NULL rows pass through), orthogonal-to-AvailabilityFilter (both stack on the same query). Per the docs-mirror rule: new `docs/commands/package-search/option (--)/swift-tools.md` (mirroring the `platform.md` / `min-version.md` shape) documents the new flag + the orthogonality semantics + the "Swift compiler floor ≠ deployment target" rationale from the issue body. Full **2176-test suite green** (was 2159, +17 new). swiftformat clean. **Effect on existing v1.0.2 / v1.1.x bundles: zero binary contract change** , the `swift_tools_version` column is added by migration on first open; older bundles' rows simply have NULL until the next reindex backfills the value. The CLI flag is available immediately. **Out of scope (per issue body):** deriving Swift from `min_ios` (explicitly rejected as wrong-direction inference); symbol-level Swift version (separate AST epic). Part B (#225 search.db `implementation_swift_version` on swift-evolution proposals) is a sibling PR , same issue, different DB. - **#665 / #409 Layer 2 , `search_generics` MCP tool surfaces `doc_symbols.generic_params`.** Pre-fix the AST extractor already populated `doc_symbols.generic_params` from every Swift generic-parameter clause (`T: View`, `Element: Hashable & Sendable`, `Key: Comparable`, …) and the v1.2.0 bundle carries 3,552 of these rows, but **zero MCP / Swift consumers exposed them**. Asking "what generics constrain on Sendable in SwiftUI?" or "where is Hashable used as a generic bound?" was unanswerable through the public surface. This PR closes that gap with one new method + one new MCP tool, mirroring the `search_conformances` shape end-to-end. New `Search.Database.searchByGenericConstraint(constraint:framework:limit:)` protocol method + concrete `Search.Index` implementation: substring-`LIKE` match against `s.generic_params` (so a query of `Sendable` returns both `T: Sendable` and `T: Hashable & Sendable`), case-insensitive framework filter, shared `signalRankOrderClause` for the same reranking the other 4 AST semantic queries enjoy. Result rows carry the matched clause on a new `Search.SymbolSearchResult.genericParams: String?` field (default-nil , source-compatible with the 4 existing entry points, which leave it nil since they don't SELECT the column). New `search_generics` MCP tool (Shared.Constants.Search.toolSearchGenerics) + `constraint` schema parameter (Shared.Constants.Search.schemaParamConstraint) + tool description copy in `MCP.SharedTools.Copy`. `tools/list` count bumps 11 → 12 (8 → 9 search-side, sample-side unchanged at 3). Response uses the existing `formatSymbolResults` renderer (title `"Generic Constraint: <name>"`, shared `"_No symbols found matching your criteria._"` empty marker, success body echoes the matched clause via a new `Generic params:` line). 25 new tests across two files. `Issue665SearchByGenericConstraintTests` (16 sub-tests counting the 2 parameterised matrices) pins the SQL contract: substring-`LIKE` match across both `T: Sendable` and `T: Hashable & Sendable` clause shapes, framework filter applies case-insensitively, result rows carry `genericParams` populated (downstream MCP can echo what matched), `searchConformances` rows leave `genericParams` nil (source-compat default), limit caps results, empty constraint matches all non-null `generic_params` rows (deliberate contract , flipping to reject-empty would be intentional), NULL `generic_params` rows excluded (a `LIKE` against `NULL` is `NULL`, not `TRUE`), 5-row truth-table over `Sendable` / `Hashable` / `Equatable` / `Comparable` / `View`. `Issue665SearchGenericsMCPMarkerTests` (9 sub-tests counting the parameterised matrix) pins the MCP wire contract: missing `constraint` argument throws `ToolError`, empty results emit the title + shared empty marker, populated results echo both the symbol name and the matched clause via the new `Generic params:` renderer line, framework filter surfaces in the active-filters block, 3-row truth-table over the common constraints, cross-tool title distinctness from `search_conformances` / `search_property_wrappers` / `search_concurrency` (regression guard against handler-swap bugs , the #669 lesson), `tools/list` advertises the new tool alongside the existing 8. `Issue645ToolsListHonestyTests` bumped: count assertions 8 → 9 (reason-only) and 11 → 12 (reason + samples), the parameterised handler-disabled-reason test extends to 8 search-db tools. `Search.Database` protocol seam extended with the new method (`SearchModels` target , same foundation tier as `searchConformances`). Per the docs-mirror rule: new `docs/tools/search_generics/README.md` (mirroring `docs/tools/search_conformances/` shape) with full parameter spec, 9 common constraints, 3 use-case scenarios, and a See-Also cross-link to `search_conformances` clarifying the conform-vs-bound semantic distinction. swiftformat clean. Full **2125-test suite green** (was 2107 pre-#665 , +25 new tests + 1 new param row on the existing parameterised test). **Effect on the shipped v1.2.0 bundle: zero binary contract** , the new column is read-only and the existing 11 MCP tool surfaces are unchanged. The 12th tool becomes available the moment a build including this PR runs against any v15-schema bundle. **Layer 1 already shipped** (#663 `is_public` repurpose); Layer 2 (this PR) completes the `doc_symbols`-side `#409` umbrella. Carmack discipline: every new wire surface ships with positive + negative semantic markers locked in test. - **#275 , `cupertino doctor --freshness` per-source freshness / drift report.** Answers "how stale is my local index?" for brew-installed users without a `cupertino-docs-private` checkout (which would otherwise let them `git log` the corpus repo to inspect Apple's changes between crawls). New `Diagnostics.Probes.freshnessBySource(at:)` reads `docs_metadata.last_crawled` (Unix epoch seconds, stamped at indexer save time) and returns a per-source `FreshnessRow` carrying `(source, count, oldest, p50, p90, newest)`. Quantile choice: nearest-rank (no interpolation) , `p50` and `p90` are always real observations, not synthetic averages, avoiding the `percentile_cont` vs `percentile_disc` ambiguity that bites SQL-side percentile work. Output shape (per-source row, sorted alphabetically): `<source-padded-18> <count-padded-8> <YYYY-MM-DD oldest> <YYYY-MM-DD p50> <YYYY-MM-DD p90> <YYYY-MM-DD newest>`. Independent of `--save` / `--kind-coverage`; doesn't gate the doctor verdict , purely informational signal. Rows with `last_crawled == 0` (never stamped) are filtered out so they don't pull oldest down to epoch 0. **Live verified** on the brew v1.2.0 bundle via the locally-built release binary: `apple-docs` reports 21,701 rows oldest 2025-11-15 / p50 2026-05-03 / p90 2026-05-11 / newest 2026-05-14. Per-flag doc at `docs/commands/doctor/option (--)/freshness.md` per the `check-docs-commands-drift.sh` rule (14 commands clean, 0/0/0). Design questions from #275 resolved per the spec's open-questions section: distribution (oldest / p50 / p90 / newest) chosen over single snapshot timestamp because long crawls span days and a single timestamp hides per-page age; `cupertino doctor --freshness` sub-flag chosen as output surface (matches the existing `--kind-coverage` pattern, zero new top-level command surface); raw ages only , no fresh/aging/stale thresholds (users decide their own; a v1.2 bundle may be current for one user and ancient for another depending on their reset cadence). 6 new tests in `Issue275FreshnessProbeTests` pin: nearest-rank quantile correctness on a 10-sample distribution; per-source grouping with alphabetical sort; `last_crawled == 0` rows filtered from quantile computation; nil return on missing DB file; empty array (NOT nil) on stamped DB with zero rows; single-row source has oldest == p50 == p90 == newest. Full **2009-test suite green** (was 2003 pre-#275 , +6 new tests). swiftformat clean. **Effect on shipped binary: zero binary-side** , pure new doctor sub-flag; available immediately to anyone running a build that includes this PR. - **#610 / #673 (ironclad) , regression-pin tests for the 15-canonical-type set from main's post-v1.2.0-reindex audit.** New `Issue610CanonicalTypeRankingPinTests.swift` (9 tests, 23 sub-test executions counting the parameterised matrix). Existing `CanonicalTypeRankingTests` already covered 7 of the 15 main verified post-reindex (URL, URLSession, Color, View, Data, String, Array). This file pins the remaining 8 (NavigationStack, Font, List, JSONDecoder, Hashable, Sendable, Codable, Equatable) as named per-type tests, plus a parameterised matrix run that exercises all 15 in one shot so failures name themselves. Each test seeds a temp DB with the canonical apple-docs page + 1-3 realistic collision peers (sub-symbols, properties, framework shadows that previously out-ranked the canonical on the v1.1.0 bundle per #610's symptom table) and verifies the canonical page lands at top-1 via the existing HEURISTIC 1 (exact-title boost) + HEURISTIC 1.5 (URI simplicity + framework authority) + per-column BM25F weights cascade. Locks in the BUG 1 cure: when one of the 15 regresses, this test names it without manual triage. Carmack discipline applied: "when a class of bug is eliminated, lock in the entire class with tests so it can't return". Phase A of the ironclad epic (#673). Test count: 1935 → 1944 green; swiftformat clean; swiftlint baseline unchanged. **Effect on shipped v1.1.0/v1.2.0 bundle: zero** , pure test addition; locks in code already shipped via #283/#587 + the v1.2.0 reindex. Sister of `scripts/smoke-reindex.sh` (PR #644). They share the 7-page `Packages/Tests/Fixtures/SmokeCorpus/` fixture set but differ in intent: `smoke-reindex.sh` builds a throwaway temp DB + runs 10 invariant checks in ~1 s to validate the indexer pipeline; `make-mini-db.sh` builds a persistent DB at a predictable path (`/tmp/cupertino-mini-db` by default) that survives across runs, intended for repeated `cupertino serve` / `tools/call` probes, `cupertino search` verifications, and ad-hoc local checks. **Why this exists:** on 2026-05-16 a develop-side MCP probe seeded a fresh v15 fixture DB directly at `~/.cupertino/search.db` (the user-facing bundle path) instead of an isolated path, the post-probe restore step failed silently, and main came up at promote-retest time, opened the live DB, saw 8 rows + user_version=15, and flagged it as a 2.48 GB → 1 MB truncation incident. No data was lost (a separate backup was restored byte-for-byte intact and the smoke-reindex pipeline was confirmed safe in the forensics agent's report at `~/Downloads/cupertino-search-db-truncation-forensics-2026-05-16.md`), but the false-alarm overhead was real. The script is the forward guard. Hard refusal at entry: any `--out` whose resolved path begins with `~/.cupertino` (matched literal-string against `$HOME/.cupertino` AND `/Users/*/.cupertino`) bails with `❌ refusing to build mini DB inside ~/.cupertino (--out is …)` before doing any work , the one path the script exists to avoid is the one it can't be coerced into. Otherwise: invokes the release binary with `save --docs --base-dir <OUT> --docs-dir Packages/Tests/Fixtures/SmokeCorpus --yes`, captures save output to `$OUT/.save.log` (or streams it to stdout under `--verbose`), checks the resulting `search.db` exists + reports size + page count, and prints next-step hints (`cupertino search SwiftUI --base-dir <OUT>` and `cupertino serve --base-dir <OUT>`). Flags: `--out <path>` (default `/tmp/cupertino-mini-db`), `--clean` (`rm -rf` the output dir before building), `--verbose` (show save output instead of capturing to a log file), `--help` (print the embedded usage block). Live-tested end-to-end on develop: built a 2.18 MB DB with 7 fixture pages in seconds; `cupertino list-frameworks --search-db /tmp/cupertino-mini-db/search.db` returned `4 frameworks (uikit, swiftui, foundation, objectivec)`; the `~/.cupertino` guard rejects both exact (`--out ~/.cupertino`) and subpath (`--out ~/.cupertino/foo`) forms; live `~/.cupertino/search.db` left byte-identical (size + mtime unchanged) after the run. **Effect on shipped v1.1.0 bundle: zero** , pure dev tooling; does not run unless invoked. **Not a fix for a current bug** , the data-loss alarm was a false positive , but a structural guard against the same class of mistake repeating once the v1.2.0 bundle ships (after which an accidental `save --docs` to the live path would clobber 12 hours of reindex work, not just trigger a restore-from-backup). - **#643 , `scripts/smoke-reindex.sh` minimal-corpus reindex validation.** Runs the full `cupertino save --docs` pipeline against 7 committed JSON fixtures (`Packages/Tests/Fixtures/SmokeCorpus/`: UIButton → UIControl → UIView → UIResponder → NSObject inheritance chain, plus Foundation.URLSession and SwiftUI.LazyVGrid) and validates 10 invariants on the resulting `search.db`: schema `user_version` matches the binary's expected, page count, `inheritance` edge count, specific edges (`UIControl → UIButton`, `NSObject → UIResponder`), `symbol_components` column populated, `kind=unknown` count is 0, `kind=class` row count, `docs_fts` row parity with `docs_metadata`, `PRAGMA integrity_check`. Completes in **~1 second**, runnable in CI without network access. The whole point: we accumulated indexer-side fixes (#77 CamelCase splitter, #274 inheritance edge writes, #634/#637 schema bumps, #626 kind support) whose only end-to-end validation path is a `save --docs` against `~/.cupertino/docs/` , the 285k-page corpus takes ~12 hours to reindex. Running that and finding a bug at hour 11 wastes the run. The smoke catches mechanical breakage in the indexer pipeline (schema-bump drift between binary and migrator, dropped FTS columns, missing `inheritance` writes, kind regressions) in seconds. A schema bump must touch the binary's `schemaVersion` AND the script's `check "schema v15"` line in the same PR or this step trips , that's the new safety rail keeping #635's silent-stamping incident from recurring. The SKILL gains a Step 9b for it; the full-corpus reindex remains the release ceremony, not a routine check. ### Added - **`cupertino doctor --kind-coverage` flag (#626 release-time metric).** A new informational doctor probe that walks `docs_metadata` joined with `docs_structured.kind` and prints per-source kind histograms ordered by count desc, with the `unknown`/`(missing)` rate per source as the headline. Designed as the one-liner that answers "did the indexer-side fixes (#615 / #633 / #664) actually land on this bundle?" after a reindex. Default doctor surface is unchanged; flag is independent of `--save`. New `Diagnostics.Probes.kindHistogramBySource(at:)` underlies it (one read-only SQL query joining `docs_metadata` to `docs_structured`, returning `[(source, kind, count)]`; rows with no `docs_structured` entry surface as `(missing)` so they stay distinguishable from kind=`unknown`). New `Doctor.checkKindCoverage()` calls the probe, groups by source, prints the top 5 kinds per source + the `… and N more` tail + the `unknown+missing` rate. Doctor's overall verdict stays green regardless , this is informational signal, not a blocker. Per-flag doc at `docs/commands/doctor/option (--)/kind-coverage.md` per the `check-docs-commands-drift.sh` rule. 4 new regression tests in `KindHistogramBySourceTests`: happy path (groups by source/kind, orders by count desc); rows without `docs_structured` render as `(missing)`; nil on missing DB file (caller renders as skipped); empty-array (not nil) when DB exists with valid schema but zero rows. **Live verified** against the freshly built release binary on the user's live `~/.cupertino/search.db` (now at v15 post-reindex): `apple-docs` reports **4.1% unknown/missing** (was 57% pre-#633 per the original #626 audit) across 351,495 rows; `apple-archive` / `hig` / `swift-evolution` at 100% `(missing)` , those sources don't currently emit `docs_structured` entries at all (structural gap separate from kind extraction); `swift-book` at 0% unknown across 78 rows; `swift-org` at 25.2%. Full **1923-test suite green**. swiftformat + swiftlint clean. `./scripts/smoke-reindex.sh` 10/10 green. `check-docs-commands-drift.sh` clean (14 commands, 0/0/0). **Effect on shipped v1.1.0 bundle: zero binary-side** , pure new flag; available immediately to anyone running a build that includes this PR. Useful immediately on the v1.2.0 bundle: gives main a single-line answer to "did the kind-extraction improvements take effect?". ### Fixed - **#669 , inheritance table empty on v1.2.0 bundle: indexer-side rawMarkdown fallback recovers the URI pair for pre-#638 corpora.** The on-disk apple-docs JSON corpus (`~/.cupertino/docs/`) was crawled 2026-05-09; PR #638 (which added `inheritsFromURIs` / `inheritedByURIs` to `Core.JSONParser.AppleJSONToMarkdown.toStructuredPage`) merged 2026-05-16 02:27 , a week later. The reindex at 04:03 consumed pre-#638 JSON. `Search.Strategies.AppleDocs.swift:174-176` reads the saved `StructuredDocumentationPage` straight from disk via `JSONDecoder`; it doesn't re-run `AppleJSONToMarkdown.toStructuredPage`, so the URI arrays decoded as nil for every page, `writeInheritanceEdges` produced zero rows, and the `inheritance` table stayed empty for the whole bundle. Symptom: every `cupertino inheritance <name>` CLI call and every MCP `get_inheritance` returned the "No symbol named X" error path (which the audit-findings doc's 112-char success metric had mis-classified as content). New defensive parser `Search.Index.extractInheritanceURIsFromMarkdown(_:)` walks `### [Inherits From]` and `### [Inherited By]` sections of `page.rawMarkdown` (which every crawler vintage preserves), extracts each `/documentation/<framework>/<rest>` link target, and resolves it via `Shared.Models.URLUtilities.appleDocsURI(fromString:)` to an `apple-docs://<framework>/<rest>` URI. New `resolveInheritanceURIs(for:)` instance method on `Search.Index` is called from `indexDocument` at the `writeInheritanceEdges` call site: when both `page.inheritsFromURIs` and `page.inheritedByURIs` are nil but `page.rawMarkdown` is non-nil, the fallback fires; otherwise the dedicated arrays win. Same shape as the existing `#284` / `#588` indexer-side defenses against stale corpora , the parser is authoritative when fresh, the indexer self-heals when stale. Recovery for any bundle whose JSON predates #638: one `cupertino save` against the existing docs directory; **no recrawl required**. 12 new tests in `Issue669InheritanceFromMarkdownTests` cover the parser (10 unit tests across happy path, multi-class `Inherited By`, plain heading without bracketed anchor, absolute URLs, missing sections, empty section, fragment-only / non-Apple host skip, single-blank-line bullet continuity, case-insensitive title match) plus 2 end-to-end tests through `indexStructuredDocument` (stale page with nil URIs + populated rawMarkdown writes edges via the fallback; populated URIs bypass the fallback). Full **1935-test suite green** (1923 pre-#669 + 12 new). swiftformat clean. swiftlint baseline unchanged. **Live verified mid-reindex** against `~/.cupertino/docs/`'s pre-#638 corpus (read-only against the live `search.db`, write target at `/tmp/probe-669-*/search.db`): 1212 inheritance edges already populated at ~15% through the reindex, including real Apple chains (`objectivec/nsobject-swift.class → storekit/skcloudservicecontroller`, `uikit/uiviewcontroller → storekit/skcloudservicesetupviewcontroller`, etc.). **Effect on shipped v1.2.0 bundle: data-fix on next `cupertino save`** , pure indexer-side change with no binary contract changes. Once a future recrawl regenerates the JSON with the dedicated arrays populated, this fallback no-ops because the `nil && nil` guard fails. ### Changed - **#670 , `search_symbols` promotes exact-name matches above substring matches within the same kind tier.** Pre-fix, `search_symbols(query: "Task")` returned `AVAggregateAssetDownloadTask` (class, avfoundation) before the canonical Swift stdlib `Task` (struct) because both share kind tier 0 (class/struct/enum/protocol/actor) and the ORDER BY fell through to alphabetic `s.name` (AV* < T). New private `signalRankOrderClauseWithExactName` variant of `signalRankOrderClause` adds one tier between kind-shape and `s.name`: `CASE WHEN LOWER(s.name) = LOWER(?) THEN 0 ELSE 1 END`. `searchSymbols` picks the variant only when a query is supplied and binds the query string to the new placeholder between the WHERE params and the LIMIT param; the nil/empty-query path keeps the original clause so no phantom placeholder is bound. The other 3 AST queries (`searchPropertyWrappers`, `searchConcurrencyPatterns`, `searchConformances`) match on attributes / conformances, not symbol name, so they continue to use the base clause unchanged. 3 new tests in `Issue177SemanticSearchRerankTests`: `exactNameBeatsSubstring` (the issue-body repro `Task` vs `AVAggregateAssetDownloadTask` / `AVAssetDownloadTask` → `Task` is #1), `exactNameBeatsSubstringCaseInsensitive` (query `view` matches `View`), `emptyQueryUsesBaseClause` (nil-query + kind filter path doesn't bind a phantom placeholder). All 4 pre-existing Issue177 tests still pass. Full **1935-test suite green**. swiftformat clean. swiftlint baseline unchanged. **Effect on shipped v1.1.0 bundle: immediate** , pure query-side reranking; takes effect for anyone running a build that includes this PR, no reindex required. - **#671 , `cupertino save` treats a missing docs/ directory as the normal user case (clean-skip line, not error).** 99.9% of cupertino users never have a local documentation directory , they download the pre-built bundle via `cupertino setup` and use it through `serve` / `search` / `read`. Pre-fix, `cupertino save -y` on a clean machine emitted alarming "⚠️ Docs directory not found" log lines + a confusing `[apple-docs] indexed: 0, skipped: 0` summary line that implied a failed indexing attempt against existing input. Post-fix, `Search.IndexStats` gains two backward-compatible fields , `wasSkipped: Bool = false` and `skipReason: String? = nil` , set by every concrete strategy's early-return paths (`AppleDocsStrategy`, `SwiftEvolutionStrategy`, `HIGStrategy`, `AppleArchiveStrategy`, `SwiftOrgStrategy`, `SwiftPackagesStrategy`, `SampleCodeStrategy`) when their input is absent (`"no local corpus"`), empty (`"no documents found"` / `"no proposals found"`), or their catalog is empty (`"catalog empty"` / `"no catalog found"`). `Search.IndexBuilder` then renders the per-source line as `[apple-docs] skipped (no local corpus)` instead of `[apple-docs] indexed: 0, skipped: 0` for clean-skip cases; the indexed-N path is unchanged. `CLIImpl.Command.Save`'s `--docs-dir` / `--evolution-dir` / `--swift-org-dir` / `--packages-dir` / `--archive-dir` flag help text reframed as "Optional. ... (maintainer workflow)" so `cupertino save --help` doesn't imply users need to populate them first. The command abstract + discussion reframed: the command is for maintainers rebuilding the bundle, or advanced users rebuilding from a local crawl produced by `cupertino fetch`; end-users should use `cupertino setup`. `docs/commands/save/README.md` + `docs/commands/save/option (--)/docs-dir.md` updated per the docs-mirror rule. 8 tests in the existing `StrategyMissingDirectoryTests` suite updated to also assert `stats.wasSkipped == true` and `stats.skipReason == "no local corpus"` (or the appropriate empty-dir reason); 1 new test pins `IndexStats` default-init values (`wasSkipped == false`, `skipReason == nil`) so callers that report indexed-N never get mis-rendered as a clean-skip line. Full **1936-test suite green** (1935 pre-#671 + 1 new). swiftformat clean. swiftlint baseline unchanged. **Effect on shipped v1.1.0 bundle: zero** , pure binary-side UX change; takes effect for anyone running a build that includes this PR, no reindex required. - **#177 , AST semantic search reranks operator overloads + synthesised conformance members below canonical types.** Pre-fix every AST semantic query (`searchSymbols`, `searchPropertyWrappers`, `searchConcurrencyPatterns`, `searchConformances`) used a flat `ORDER BY s.name`, which surfaced `==(_:_:)` operator overloads + synthesised `Equatable` / `Hashable` / `Comparable` conformance members ahead of canonical type pages. A developer searching for `mainactor` got `==(_:_:)` operators from RealityKit before any real view-model class; `task` got `==(_:_:)` / `<=(_:_:)` / `<(_:_:)` on `Task<Success, Failure>` and `TaskPriority` before any real Task usage; `Sendable` got `AVAudioRecorderDelegate` / `AVAssetWriterDelegate` before more representative concrete adopters. New private file-level `signalRankOrderClause` in `Search.Index.SemanticSearch` shared across all 4 SQL queries (one place to tune, four places that benefit). Two-tier reranking deprioritises , does NOT exclude , the boilerplate: tier 1 puts rows whose symbol name is an operator-overload (`==(_:_:)`, `!=(_:_:)`, `<(_:_:)`, `<=(_:_:)`, `>(_:_:)`, `>=(_:_:)`, `~=(_:_:)`, both arity-1 and arity-2 forms) or synthesised-conformance method (`hash(into:)`) LAST; within tier 1, canonical type kinds (`class`, `struct`, `enum`, `protocol`, `actor`) come first; type-shape sub-kinds (`typealias`, `macro`) next; member-shape (`method`, `function`, `property`, `initializer`, `subscript`, `case`) third; pages explicitly tagged `kind=operator` fourth; everything else (including `kind=unknown`) in tier 5. `s.name` ties remaining ordering , preserves the pre-fix alphabetic shape inside each bucket. Workflows that wanted "all results including operators" still get them, just lower in the list. 4 new regression tests in `Issue177SemanticSearchRerankTests`: the actual issue-body repro (`@MainActor` on operator overload ranks below `@MainActor` on class via `searchPropertyWrappers`); the "deprioritise, not exclude" guarantee (operator overloads still returned); `searchConformances` for Hashable ranks the struct above synthesised `hash(into:)`; kind-tier ordering `class > typealias > method > operator` on identical-name rows. Full **1919-test suite green**. swiftformat clean. swiftlint baseline unchanged. `./scripts/smoke-reindex.sh` 10/10 green. **Effect on shipped v1.1.0 bundle: immediate** , pure query-side reranking; takes effect for anyone running a build that includes this PR, no reindex required. The pre-fix bundle's `==(_:_:)` rows are still in the DB; the new ORDER BY just sorts them lower. - **#626 follow-up , add `parseKindFromSymbolKind` as the second tier of the kind-extraction cascade in `AppleJSONToMarkdown.toStructuredPage`.** #633 (already shipped) added the declaration-token fallback that recovered ~109k of 162k apple-docs `kind=unknown` rows on the v1.1.0 corpus, cutting the projected unknown rate from 57% to ~19%. The remaining residual is pages where Apple's DocC JSON carries `metadata.symbolKind` (`swift.struct`, `swift.func`, `swift.enum.case`, …) but neither a usable `roleHeading` nor a structured `declaration` block , common for `enum.case` sub-pages, `associatedtype` requirements, and `@MainActor`-decorated types DocC renders without a heading. This PR threads a new `symbolKind: String?` field into the parser's `Metadata` Codable struct and inserts a `parseKindFromSymbolKind(_:)` helper into the cascade as tier 2: roleHeading → **symbolKind** → declaration-token. Each tier short-circuits when it returns a non-`.unknown` kind, so the happy path (roleHeading present) stays a single dispatch with zero extra work. Both Swift (`swift.struct`, `swift.func`, `swift.enum.case`, `swift.associatedtype`, etc.) and Objective-C (`objc.class`, `objc.method`, …) prefixes are stripped before the dispatch table so the table doesn't need both variants. `swift.extension` returns `nil` (no Kind case; extensions adopt the kind of the type they extend, and the declaration-token tier can peek). `swift.associatedtype` routes to `.typeAlias` (closest existing Kind case) rather than dropping to `.unknown`. 7 new regression tests in `Issue626KindExtractionExpansionTests` (now 24 tests total , previously 17): parameterised 14 swift.* values + 3 objc.* values + associatedtype → typeAlias + extension → nil + nil-input → nil + unrecognised → nil (no false positives) + case-insensitive normalisation. Full **1915-test suite green**. swiftformat clean. swiftlint baseline unchanged. `./scripts/smoke-reindex.sh` 10/10 green. **Effect on shipped v1.1.0 bundle: zero** , pure indexer-side change. Takes effect on the next reindex; quantifying the additional `unknown`-rate reduction requires a live corpus pass against the new tier (deferred per #626's "How I'd quantify it" section , a `cupertino doctor --kind-coverage` flag is a natural follow-up). - **#409 Layer 1 , repurpose `doc_symbols.is_public` to "from Apple's public API surface" for apple-docs-sourced rows.** Pre-fix the column was set from a literal `public` modifier on the declaration , but Apple's doc code snippets never write `public` explicitly (it's redundant; everything documented IS public), so the column read `1` for only 24 of 168,259 symbol rows in the v1.0.3 snapshot. Carried no useful signal. Post-fix: `Search.Index.indexDocSymbols` checks `docUri.hasPrefix("apple-docs://")` at the bind site for column 9 and writes `1` tautologically when true; non-apple-docs URIs (samples, packages, future sources) still honour the literal-keyword extractor so a future "exclude internal helpers" query has the signal it needs. 3 regression tests in `Issue409IsPublicRepurposeTests`: apple-docs symbol with `isPublic: false` from extractor stamps `is_public=1`; samples-sourced symbol with `isPublic: false` stays `is_public=0` (no false positives); apple-docs symbol that DID see `public` keyword stays `is_public=1` (no regression on the rare 24-of-168k case). Indexer-side change; takes effect on next reindex. Layer 2 (`searchGenerics` MCP tool + `searchByGenericConstraint` Search.Index method exposing the already-populated `generic_params` column) split to a follow-up issue , that's a full new MCP tool surface (~1 day of work per the original issue) deserving its own PR + test pin. Full **1908-test suite green**. swiftformat clean. swiftlint baseline unchanged. `./scripts/smoke-reindex.sh` 10/10 green. **Effect on shipped v1.1.0 bundle: zero** , pure indexer-side change; takes effect for any new `cupertino save --docs` reindex. - **#193 , remove the Apple-Developer auth flow from `Sample.Core.Downloader`.** Apple's sample-code zips are publicly accessible (HTTP 200, no `Set-Cookie`, no `WWW-Authenticate` , verified empirically during #6 and re-confirmed by #193's cold-test sweep against `developer.apple.com/tutorials/data/documentation/samplecode.json` + `docs-assets.developer.apple.com/published/...`). The WKWebView + cookie-capture + activation-policy dance was dead code in production: the only consumer of `visibleBrowser` was a test stub passing `false`; no CLI flag toggled it. **Deleted in this PR:** `showAuthenticationPrompt`, `loadCookies` / `saveCookies`, the `cookiesPath` field, `Sample.Core.Downloader.AuthOutcome` enum, `appleSessionCookieNames` / `containsAppleSessionCookie`, `awaitAuthOutcome`, `AuthFlowCoordinator` (private class with WKNavigationDelegate conformance), the `CookieData` struct, `visibleBrowser` field + init param, `authFlowActivationPolicy` static, `interactiveStdinCheck` field + init param (the protocol was used only by the auth path's "press Enter to confirm" branch). **Deleted files:** `Packages/Sources/CoreSampleCode/Sample.Core.InteractiveStdinChecking.swift` (protocol), `Packages/Sources/CoreSampleCode/Sample.Core.LiveInteractiveStdinCheck.swift` (`isatty(fileno(stdin))` conformer), `Packages/Tests/CoreSampleCodeTests/SampleCodeAuthPolicyTests.swift` (the entire test suite , every test in it asserted on auth-flow behaviour). **Kept intact:** the hidden zero-frame WKWebView + `loadPage` + `findDownloadLinkWithJavaScript` , Apple's sample-code pages render the actual download URL into the DOM via JavaScript at runtime, so a plain HTML fetch wouldn't see it; replacing that path with the public `samplecode.json` endpoint is the larger refactor in #193's proposal and was deferred per the user's "just remove auth flow, ignore it" scope-narrowing direction. **Net diff:** `Sample.Core.Downloader.swift` 932 → 549 lines (~41% smaller). `Sample.Core.Downloader.init` signature simplifies from 6 args to 4 (drops `visibleBrowser`, `interactiveStdinCheck`); `CLIImpl.Command.Fetch`'s init call already used the defaults so it didn't need updating. 2 tests removed from `SampleCodeDownloaderTests` that exercised the deleted surface (`visibleBrowser` smoke, `cookiesPath` path-naming convention). Full **1905-test suite green** (down from 1922 , 17 deleted auth tests). swiftformat clean. swiftlint baseline unchanged (the 2 `blanket_disable_command` warnings on the file's `swiftlint:disable type_body_length function_body_length` header are pre-existing , the file's still over the body-length limits without auth code because `extractSamplesWithJavaScript` is 56 lines of JS-literal + result parsing). `./scripts/smoke-reindex.sh` 10/10 green , Sample-side change, indexer untouched. The `Shared.Constants.FileName.authCookies` + `Shared.Constants.BaseURL.appleDeveloperAccount` constants are left in `SharedConstants` for now , harmless and the `URLKnownGoodTests` reference the latter; a follow-up cleanup PR can drop both. **Effect on shipped v1.1.0 bundle: zero** , pure binary-side dead-code deletion. **Honesty note:** the WKWebView path itself is still legacy , Apple's `tutorials/data/documentation/samplecode.json` exposes every sample's title + abstract + download URL directly, and replacing the WKWebView page-load with two `URLSession.data(from:)` calls would drop another ~300 lines of WebKit machinery + let the downloader work on iOS. That's the next chunk of #193 (out of scope here per user direction). ### Fixed - **Two follow-ups from main's 2026-05-16 post-#658 retest report: CLI `--format json` `degradedSources` is now populated when `search.db` fails to open + the schema-mismatch error message references the binary's terminal expected version (v15) instead of the next migration step.** Main flagged both in `cupertino-promote-db927ea-retest-2026-05-16.md`. **(1) JSON `degradedSources` blind spot.** `cupertino search --format json` left `degradedSources` empty even when 6 sources had actually failed to open. The MCP markdown formatter (per #650 / #652) correctly synthesises entries when `searchIndexDisabledReason` is set, but the CLI side had no equivalent bridge , `openDocsFetchers` collapsed both "file missing" (silent OK) and "schema mismatch" (configuration error) into a bare `nil` return; SmartQuery's per-fetcher `classifyDegradation` plumbing couldn't fire because no fetcher ever ran for the apple-docs sources. JSON consumers saw an empty array; only stderr carried the warning. This PR mirrors the MCP fix structurally. **(a)** New `CLIImpl.Command.Search.DocsFetchersResult(index:, disabledReason:)` value type , three states: `(index, nil)` happy, `(nil, nil)` legitimately-missing, `(nil, "<reason>")` configuration-error. `openDocsFetchers` reuses `SearchModule.SmartQuery.classifyDegradation(_:)` (bumped from `static` to `public` , same surface, no test changes , so CLI can call it without `@testable import`) to convert open-failure errors into the same string the per-fetcher classifier produces, and "file legitimately missing" stays the existing nil path. **(b)** `FetcherPlan` gains `searchDBDisabledReason: String?`, threaded through `buildFetchers`. **(c)** New `CLIImpl.Command.Search.augmentWithOpenTimeDegradation(result:disabledReason:)` static helper synthesises `Search.DegradedSource` entries for the 6 search.db-backed sources (`apple-docs`, `apple-archive`, `hig`, `swift-evolution`, `swift-org`, `swift-book`; `samples` + `packages` live in different DBs and are intentionally excluded), dedupes against any pre-existing per-fetcher classifier entries (preserves their original reason on collision), and returns a new `SmartResult`. `runUnifiedSearch` calls it before passing to the renderers; the JSON formatter's existing `degradedSources` field then renders the synthesised entries automatically , no formatter changes. 4 new regression tests in `CLISearchOpenTimeDegradationTests` pin the merge logic: nil-reason is identity, non-nil synthesises exactly 6 sources excluding samples/packages, existing entries dedupe by name with original reason preserved, non-degradedSources `SmartResult` fields pass through unchanged. **(2) Outdated migration message version number.** The schema-mismatch error text on stderr said `"requires migration to version 14"` (the next migration step) instead of `"binary expects version 15"` (the binary's terminal expected version). Misleading: the user's only remediation is `cupertino setup` to download the v15 bundle, not migrate to v14. Each of the 5 breaking-change throws in `Search.Index.Migrations.checkAndMigrateSchema` now reads `"Database schema version \(currentVersion); binary expects version \(Self.schemaVersion). The path includes a breaking change at vX→vY (#issue) that adds …, plus subsequent breaking changes depending on \(Self.schemaVersion). Please delete the database and run 'cupertino setup' …"`. References `Self.schemaVersion` so future schema bumps update the message automatically. **Live verified end-to-end against the live v13 bundle:** `cupertino search SwiftUI --format json --limit 1 1>/dev/null 2>&1` stderr now says `"Database schema version 13; binary expects version 15"`; `cupertino search SwiftUI --format json --limit 1 2>/dev/null | python3 -c "import sys,json; …"` reports `degradedSources count: 6` with entries like `{name: 'apple-docs', reason: 'schema mismatch; run cupertino setup ...'}`. Plus an em-dash cleanup in `classifyDegradation`'s two reason strings (`,` → `;`) , they now propagate into JSON output post-#648-CLI, so the global "no em-dashes in user-facing text" rule applies. Full **1922-test suite green**. swiftformat clean. swiftlint baseline unchanged (one borderline `function_body_length` nudge on `runUnifiedSearch` from +4 lines, the other 3 pre-existing). `./scripts/smoke-reindex.sh` 10/10 green , CLI-side change, indexer untouched. **Effect on shipped v1.1.0 bundle: zero** , pure binary-side fix; takes effect for anyone running a build that includes this PR. - **#657 , `cupertino fetch --type samples` validates each download with a ZIP magic-bytes check; invalid bodies (HTML landing pages, partial downloads) are parked as `.invalid` instead of polluting `~/.cupertino/sample-code/`. `cupertino doctor` grows a matching probe that flags any pre-existing corruption.** Main's 2026-05-16 post-#653 retest found 3 invalid `.zip` files on the live corpus (`accessibility.zip`, `appintents.zip`, `ios-ipados-release-notes.zip`) , all HTML bodies saved with a `.zip` extension because Apple's CDN returned them with HTTP 200 (transient CDN issues / redirect chains / auth gates) and the fetcher trusted the status code without inspecting the body. Pre-fix these lingered in the sample-code directory until `cupertino save --samples` tripped over them at index time. Three-layer fix. **(1)** New `Shared.Utils.ZipMagic.isValid(at:)` , a pure 4-byte header check against the three PKWARE APPNOTE.TXT ZIP signature prefixes: `PK\x03\x04` (local file header , sec. 4.3.7, the common case), `PK\x05\x06` (end-of-central-directory only , sec. 4.3.16, empty archive), `PK\x07\x08` (spanned archive , sec. 4.4.5). Returns false for missing files, permission denied, truncated reads (<4 bytes), and any non-PK header. 4 bytes of I/O per file, ~3 orders of magnitude faster than `/usr/bin/zipinfo` subprocess fork+exec (which `cleanup --dry-run --verify` uses for a more thorough scan); detects "is this even a ZIP?" not "is this archive structurally intact" (truncated archives whose header survived would pass , callers needing integrity should still reach for `zipinfo`). **(2)** `Sample.Core.Downloader.downloadSample` validates the on-disk artefact after `moveItem(at:to:)`. Invalid bodies rename to `<filename>.invalid` (overwriting any prior `.invalid` so re-runs don't accumulate). New `Sample.Core.Statistics.invalidDownloads: Int = 0` field tallies them separately from `downloadedSamples`; both `Sample.Core.Downloader.logStatistics` and `CLIImpl.Command.Fetch`'s download-summary log emit `Invalid downloads (parked as .invalid): N` only when N > 0 so the happy-path output is unchanged. **(3)** New `CLIImpl.Command.Doctor.checkSampleArchiveIntegrity()` slots into the default doctor surface next to `checkSamplesDatabase()`. Scans `<sampleCodeDirectory>/*.zip`, validates each via the new helper, prints `✓ Total archives: N` + either `✓ Invalid ZIP archives: 0` (green) or `⚠ Invalid ZIP archives: N` (warning) with up to 5 filenames listed inline + an actionable hint pointing at `cupertino fetch --type samples --force`. Doctor's overall verdict stays green when the count is non-zero , pre-#657 corpora shouldn't fail the post-promote health check. 9 new regression tests in `Issue657ZipMagicTests` pin the validator: all 3 spec-valid signatures accept; HTML landing page (the actual `<!DO` bug shape) rejected; truncated 1-byte body rejected; empty file rejected; non-ZIP magic (JPEG) rejected; almost-PK rejected (full 4-byte check vs. naive prefix match); missing file rejected without crashing. **Live verified end-to-end** against the actual `~/.cupertino/sample-code/` directory: `cupertino doctor` reports `⚠ Invalid ZIP archives: 8` with the 8 filenames (the 3 main flagged plus 5 more: `watchos-release-notes.zip`, `updates.zip`, `samplecode.zip`, …). Full **1918-test suite green**. swiftformat clean. swiftlint baseline unchanged (the 4 pre-existing `blanket_disable_command` warnings on `Downloader.swift` + `Fetch.swift` are unrelated). `./scripts/smoke-reindex.sh` 10/10 green in <1s , fetch-time + doctor-side change, indexer untouched. **Effect on shipped v1.1.0 bundle: zero** , pure binary-side fix; pre-#657 corpora keep their invalid entries until a `--force` re-fetch or manual cleanup, but the doctor probe now surfaces them. Closes #657. - **#656 , `cupertino cleanup --dry-run` finishes in <0.1 s on the 627-zip sample-code corpus (was ~3.5 min). Added `--verify` flag for opt-in deep scan.** PR #651 throttled dry-run's stdout firehose (619 lines → 22) but didn't speed up the underlying work: every archive still forked `/usr/bin/zipinfo` to count cleanup candidates. Main's 2026-05-16 post-#651 retest confirmed the elapsed time issue remained and filed #656 with the suggested fix: skip per-file zipinfo by default + add a `--verify` flag for users who want the exact items-to-remove count. This PR does both. **(1)** `Sample.Cleanup.Cleaner.init` gains `verify: Bool = false`. The dry-run branch in `cleanArchive(at:)` reads it: `let itemsToRemove = verify ? await countItemsToRemove(in: zipURL) : 0`. When `verify` is false (the new default), each archive is reported at its on-disk size with `itemsRemoved: 0` , no zip opening, no subprocess fan-out. Archive count + cumulative size totals stay accurate (which is what most operators want from `--dry-run` , "do I have a corpus to clean?"). When `verify` is true, the pre-fix behaviour returns verbatim: per-archive `zipinfo` for an exact items-to-remove count. Real cleanup (`dryRun = false`) ignores the flag entirely , it always extracts + scans + (optionally) recompresses. **(2)** `CLIImpl.Command.Cleanup` exposes the new `@Flag var verify: Bool = false` with help text `"During --dry-run, open each archive with zipinfo to count cleanup candidates exactly (slow on large corpora; off by default)."` Wired through to the cleaner via a 6-argument init. **(3)** Per the per-flag-doc rule (`scripts/check-docs-commands-drift.sh`), a new `docs/commands/cleanup/option (--)/verify.md` describes the trade-off, default, examples, and "why off by default" context. The drift check passes (14 commands, 0 missing). 3 new regression tests in `Issue656DryRunVerifyTests` use `/usr/bin/zip` to build real fixture archives (one with a `.git/` directory inside, one plain), then drive the actor through three paths: default dry-run reports `itemsRemoved: 0` across the whole corpus (slow path didn't run, no archives opened); opt-in `--verify` dry-run still walks every archive and counts `itemsRemoved >= 2` for the .git fixtures; real cleanup with `verify: false` still detects + removes the .git fixtures (proving the flag is dry-run-scoped). **Live verified end-to-end** against the freshly built release binary: `time cupertino cleanup --dry-run` on the actual `~/.cupertino/sample-code/` directory (627 zips, 29.01 GB) completes in **0.08 s real time** with `Total archives: 627`, `Items to remove: 0`, `Original size: 29.01 GB`. Same flow with `--verify` falls through to the slow zipinfo path (left in `--verify` test running ~minutes; behaviour preserved). Full **1909-test suite green**. swiftformat clean. swiftlint baseline unchanged (the `function_body_length` warnings on `Cleaner.cleanup` / `cleanArchive` and the pre-existing `line_length` on `Cleanup.swift:33` `--sample-code-dir` help string are all unrelated to this change , confirmed by running lint against develop's pre-PR state, the violations exist identically there). `./scripts/smoke-reindex.sh` 10/10 green. **Effect on shipped v1.1.0 bundle: zero** , pure binary-side fix; takes effect for anyone running a build that includes this PR. Closes #656. - **#654 , `cupertino search --format json` no longer pollutes stdout with missing-DB diagnostics; `jq` parses the response cleanly.** Pre-fix, three diagnostic prints in `CLIImpl.Command.Search.SmartReport` (`openDocsFetchers`, `openPackagesFetcher`, `openSamplesFetcher`) emitted "ℹ️ <db>.db not found at … , skipping …" via `Logging.Recording.info(...)`. `LiveRecording` → `Logging.Unified.logToConsole` routes `.debug` and `.info` to **stdout** via `print(output)`; `.warning` and `.error` route to **stderr** via `fputs(stderr)`. So those three info-level diagnostics landed on stdout, ahead of the JSON payload, breaking `jq` with `parse error: Invalid numeric literal at line 1, column N`. Reproducer: `cupertino search SwiftUI --format json --search-db /tmp/nonexistent.db | jq '.candidates | length'`. The schema-mismatch / DB-unopenable counterparts at the catch-block siblings (`recording.error(...)`) were already correctly on stderr and didn't pollute JSON , only the file-missing path did. Fix: 3-line level bump , `recording.info(...)` → `recording.warning(...)` at lines 122 (`search.db not found`), 157 (`packages.db not found`), 178 (`samples.db not found`). CLI text + markdown users still see the line on the same terminal stream interleaved with the report (warning-level prints land on the same terminal as info-level when stderr isn't being redirected). JSON consumers (`jq`, AI agent tool wrappers, CI scripts) now see pure JSON on stdout. **Live verified end-to-end against the freshly built release binary:** `... 2>/dev/null | head -3` → stdout starts with `{` (diagnostic gone); `... 1>/dev/null 2>&1 | head -3` → stderr carries `ℹ️ search.db not found at /tmp/nonexistent-zzz.db , skipping doc sources.`; `... 2>/dev/null | jq '.candidates | length'` → returns the count cleanly. Full 1906-test suite green. swiftformat clean. swiftlint baseline unchanged (the 3 `function_parameter_count` warnings on `SmartReport.swift` are pre-existing on `printSmartReport` / `printSmartReportText` / `printSmartReportMarkdown` and unrelated to this change). `./scripts/smoke-reindex.sh` 10/10 green in <1s , render-only change to the CLI diagnostic path, indexer untouched. **Effect on shipped v1.1.0 bundle: zero** , pure binary-side fix; takes effect for anyone running a build that includes this PR. Closes the third of three follow-ups main flagged in `cupertino-promote-6540745-retest-2026-05-16.md`; the first (#647 silent-tool-drop) was closed as duplicate of #645 / PR #649, the second (#642 open-time blind spot) was closed by PR #652. **No regression test added** , the fix is a 3-line level promotion and the live-binary reproduction is the strongest possible test surface; a process-spawn unit test would be brittle (depends on built-binary state, env, paths) for negligible coverage uplift. The comment block at each site references #654 so a future reverter sees the why in `git blame`. - **#648 (open-time path) , MCP `tools/call search` now surfaces the schema-mismatch warning + honest "Searched:" list even when `search.db` failed to open at server startup, not just when a per-fetcher error fires.** Main's 2026-05-16 post-promote retest on `6540745` found that #642's per-fetcher `classifyDegradation` plumbing has a blind spot: when `search.db` fails to open at server startup (the path #645 / PR #649 already classifies into a `searchIndexDisabledReason` string on the provider), `Services.UnifiedSearchService.searchAll` is constructed with `searchIndex: nil`; the apple-docs / hig / swift-evolution / apple-archive / swift-org / swift-book fetchers register as unavailable and are never called for the query. No per-fetcher throw exists to classify, so `degradedSources` stays empty + the renderer claims `_Searched ALL sources_` while in fact only samples + packages ran. Fix bridges the existing `searchIndexDisabledReason` signal into the formatter input: new internal static `CompositeToolProvider.injectOpenTimeDegradation(into:disabledReason:)` synthesises one `Search.DegradedSource` per search.db-backed source (6 total: `apple-docs`, `apple-archive`, `hig`, `swift-evolution`, `swift-org`, `swift-book`; `samples` + `packages` live in different DBs and are intentionally excluded) when a reason is set, then merges them into the formatter input next to any entries the per-fetcher classifier already populated (the merge dedupes on `name` so a future refactor wiring partial-fetcher availability won't double-count, and preserves the existing entry's reason verbatim when there's a collision because the per-fetcher classifier saw it first). Pure function, no provider state captured beyond parameters, lifted to internal scope so tests pin the merge logic without standing up the full handler. `handleSearchAll` threads the input through it before passing to `Services.Formatter.Unified.Markdown`; the renderer's existing warning blockquote + #648-residual `_Searched: <list>_` line both trigger automatically because they're already gated off `degradedSources` (no formatter changes , the bridge is everything). 6 new regression tests in `Issue648OpenTimeDegradationTests` pin: nil-reason is the identity (no behavioural change on the happy path); non-nil reason synthesises exactly 6 search.db-backed sources + excludes `samples` + `packages`; reason string propagates verbatim to every synthesised entry; existing entries dedupe by name and preserve their original reason; non-`degradedSources` Input fields (sample results, limit, etc.) pass through unchanged; end-to-end Markdown render emits both the `⚠ 6 sources unavailable due to configuration error` blockquote AND the honest `_Searched: samples, packages_` line, never the `ALL sources` claim. Full 1897-test suite green. swiftformat clean. swiftlint baseline unchanged (the listTools / type-body / file-length warnings on `CompositeToolProvider.swift` are all pre-existing and unrelated). End-to-end re-verified: `./scripts/smoke-reindex.sh` 10/10 green in 1s , render-bridge change, indexer untouched. **Effect on shipped v1.1.0 bundle: zero** , pure binary-side fix; takes effect for anyone running a build that includes this PR. Closes the open-time-failure follow-up main flagged at 04:00 UTC 2026-05-16 in `cupertino-promote-6540745-retest-2026-05-16.md`. **Still outstanding from that report (separate PRs):** the `search.db` 2.48 GB → 1 MB truncation forensics, and CLI `search --format json` startup-warning text leaking onto stdout before the JSON payload. - **#646 , `cupertino cleanup --dry-run` no longer floods stdout with one line per archive on a 619-zip corpus.** Pre-fix, `CleanupProgressObserver.observe(progress:)` emitted a formatted print + flush per archive across every cleanup run, dry or wet. With 619 sample zips and ~80 ms per entry on the recording's `output(...)` path, dry-run spent ~50 s of its ~60 s elapsed time printing , enough that any caller with a 30-second timeout assumed the process was hung. Real cleanup keeps the per-file output (the per-archive zipinfo + unlink + recompress work justifies the diagnostic line). Dry-run now routes through a new pure helper `CLIImpl.Command.Cleanup.shouldEmitProgress(_:dryRun:)` that throttles to at most one progress emission per ~5% bucket (`max(1, total / 20)`), plus the first and last entries so the run has a visible start + finish boundary. Corpora of 50 or fewer archives stay fully verbose , per-file output completes quickly enough at that scale that the throttle would add no value. On the 619-zip live corpus the dry-run now emits 22 progress lines instead of 619; the per-archive zipinfo subprocess is still the dominant cost, but stdout is no longer the bottleneck and the command no longer looks like a hang. 9 new regression tests in `Issue646CleanupDryRunThrottleTests` pin: real-cleanup keeps every entry on a 619-corpus; dry-run keeps every entry at ≤ 50; dry-run starts throttling at the 51-archive boundary; first + last always emit; 619-corpus collapses to exactly 22 emissions; 1000-corpus stays in the 18-25 emission range; total=0 and total=1 edge cases. Helper is a pure static function on `CLIImpl.Command.Cleanup` , no observer state, no recorder dependency, no side effects , so the rule can be pinned without standing up the full cleanup pipeline. Full 1900-test suite green. swiftformat + swiftlint clean. End-to-end re-verified: `./scripts/smoke-reindex.sh` runs all 10 indexer-side invariants green in 1s , render-throttle change, indexer untouched. **Effect on shipped v1.1.0 bundle: zero** , pure binary-side fix; takes effect for anyone running a build that includes this PR. **Out of scope (potential follow-up):** parallelising the per-archive `zipinfo` subprocess calls (TaskGroup with concurrency ~8) would cut the remaining ~3-6 s subprocess time to ~1 s; the throttle alone already eliminates the hang signal so this is optional. - **#648 (residual) , MCP `tools/call search` response no longer claims to have searched all sources when configuration-error sources silently dropped out.** #642 prepended a `⚠ N sources unavailable` blockquote when `degradedSources` was non-empty, but the immediately following line still emitted `_Searched ALL sources: apple-docs, samples, hig, ..._` unconditionally. AI agents reading the response saw two contradictory signals one paragraph apart: the warning at the top + the "ALL" claim a few lines below. `Services.Formatter.Unified.Markdown.format(_:)` now reads the same `degradedSources` channel the warning block already keys off: on the happy path (`degradedSources.isEmpty`) the line keeps the pre-#648 `_Searched ALL sources: <full-list>_` wording verbatim, so existing clients that key off that exact string don't have to special-case anything when running against an unaffected server; when any source has degraded, the line flips to `_Searched: <actually-searched>_` (the configured sources from `Shared.Constants.Search.availableSources` minus the names in `degradedSources`), matching CLI's `Searched: samples, packages` line emitted by `CLIImpl.Command.Search.SmartReport`. When every source has degraded, the line renders `_Searched: (none)_` rather than producing an empty trailing colon. 5 new regression tests in `Issue648SearchedSourcesHonestyTests` pin: happy-path "ALL sources" wording verbatim with all 8 sources listed (no regression on the unaffected path); single-source degradation flips the wording + omits the degraded name + keeps the other 7 names; multi-source degradation strips all degraded names + keeps at least one healthy entry; all-degraded falls through to `_Searched: (none)_`; the warning block from #642 still appears AND remains positioned above the new honest line (no regression on #642). Full 1891-test suite green. swiftformat + swiftlint clean. End-to-end re-verified: `./scripts/smoke-reindex.sh` runs all 10 indexer-side invariants green in 1s , this is a render-only change, the indexer is untouched. **Effect on shipped v1.1.0 bundle: zero** , pure binary-side fix; takes effect for anyone running a build that includes this PR. Closes the second of two follow-ups main flagged in the 2026-05-16 pre-reindex coverage sweep ("MCP `tools/call search` response doesn't surface the schema-mismatch warning that CLI prints" , #648); the first follow-up ("MCP server silently drops 6 of 10 tools" , #647) was closed earlier as a duplicate of #645 / PR #649. - **#645 , MCP `tools/list` is now honest about `search.db` state. Pre-fix, `serve` collapsed every search-index initialisation failure to a bare `nil`; `CompositeToolProvider.listTools` hid 7 search-dependent tools (`list_frameworks`, `read_document`, `search_symbols`, `search_property_wrappers`, `search_concurrency`, `get_inheritance`, `search_conformances`) and the only signal to the AI client was "the tool isn't there any more", indistinguishable from a server downgrade.** Main's 2026-05-16 pre-reindex sweep confirmed the regression live against the shipped v1.1.0 bundle: `tools/list` returned 4 of 11 tools when the on-disk DB was schema-mismatched, with no warning. Two-layer fix. **(1)** `CLIImpl.Command.Serve` adds a `SearchIndexLoadResult(index: Search.Index?, disabledReason: String?)` value type and a `loadSearchIndex(searchDBURL:)` that classifies the failure: "file missing" returns both fields nil (legitimate samples-only path , tools stay hidden); "file present but unopenable" returns a non-nil reason whose message routes through the same `classifyDegradation(_:)` patterns introduced by #640 ("schema mismatch; run `cupertino setup` to redownload a matching bundle" for `"schema version"` errors, "database unopenable; check the `--search-db` path" for `"unable to open database"` / `"file is not a database"`, fall-through for everything else). **(2)** `CompositeToolProvider` gains a `searchIndexDisabledReason: String?` field threaded through the primary 7-argument init; new `searchToolsVisible` computed (true when `searchIndex != nil || searchIndexDisabledReason != nil`) gates the three search.db-dependent registration conditionals in `listTools` so the full tool surface advertises in `tools/list` whenever a reason is set. A new `searchIndexUnavailableError(_:)` helper replaces all 7 cookie-cutter `Shared.Core.ToolError.invalidArgument("index", "Documentation index not available")` throws across the search.db handlers; when a reason is set the error frame surfaces "Documentation index disabled: <reason>" so AI agents see the same actionable text the CLI prints, mirroring #640's degradation pattern on the unified search path. "File missing" callers still see the pre-#645 message verbatim, so existing clients don't have to special-case the new wording. 6 new regression tests + 7 parametrised cases in `Issue645ToolsListHonestyTests` pin: empty surface when nothing is configured (legitimate samples-only); 8-tool surface when reason is set without sample DB; 11-tool surface when reason + samples coexist; pre-#645 4-tool samples-only surface when no reason is set; all 7 search.db-dependent handlers throw an error whose message contains both `disabled` and the reason text; file-missing fallback preserves the original message verbatim. Full 1886-test suite green. swiftformat clean. swiftlint baseline unchanged (no new violations introduced; the listTools / type-body / file-length warnings on `CompositeToolProvider.swift` are all pre-existing and unrelated to this change). End-to-end re-verified against the freshly built `cupertino` release binary: `scripts/smoke-reindex.sh` (#644) runs all 10 indexer-side invariants green in 1s, confirming the MCP-side changes have zero impact on the save indexer. **Effect on shipped v1.1.0 bundle: zero** , pure binary-side fix; takes effect for anyone running a build that includes this PR. **Honesty trade-off:** the fix prefers advertising tools and failing per-call over hiding tools and dropping context; AI agents now see a structured error frame they can act on (`cupertino setup`, fix `--search-db` path) rather than discovering the missing surface as an absence. - **#640 , MCP `search` now surfaces schema-mismatch / DB-unopenable errors instead of silently returning a degraded result. CLI gets the same warning.** Pre-fix, the fan-out paths in both `Search.SmartQuery.answer` (CLI default path) and `Services.UnifiedSearchService.searchAll` (MCP `search` tool) caught every per-fetcher throw and replaced it with an empty result set , the right call for transient errors (network blip, lock contention) but the wrong call for **configuration errors** (schema mismatch, DB unopenable). Configuration errors are permanent until the user acts; silently swallowing them made AI agents see `Total: 5 results found in 1 source` (samples only) when in fact apple-docs / hig / swift-evolution / apple-archive had all silently failed to open. Main caught this during the post-#636 retest: the CLI was loud about the schema mismatch (16/27 commands exited rc=1 with `SQLite error: Database schema version 14 is newer than supported version 13`) while the MCP tool returned a normal-shaped response body with no signal of the configuration problem. Fix is in three layers. **(1)** New `Search.DegradedSource(name:reason:)` value type lifted to `SearchModels` so both `Search.SmartQuery` and `Services.UnifiedSearchService` can produce it without ripple. **(2)** Both `answer` and `searchAll` now classify per-source errors via `classifyDegradation(_:)` , a small dispatch on the error's message text that tags `"schema version"` strings as "schema mismatch , run `cupertino setup`" and `"unable to open database"` / `"file is not a database"` as "database unopenable , check the path". Errors that don't match either pattern stay in the silent-swallow path (the original transient-resilience policy is preserved). The match is on message rather than type because both errors surface as `Search.Error.sqliteError(_)`; the message is what tells us the cause. **(3)** `SmartResult.degradedSources` and `Formatter.Unified.Input.degradedSources` carry the classified entries through to the renderers. CLI text emits `⚠ N sources unavailable due to configuration error:` plus per-source `- name: reason` lines at the top of the report. CLI markdown emits the same in a blockquote so MCP clients render it distinctly. JSON adds a `degradedSources` array next to `contributingSources`. The MCP markdown response body (via `Services.Formatter.Unified.Markdown`) emits the same blockquote so AI agents see the warning at the top of the body, not silently in the form of an empty candidate list. Empty `degradedSources` is a no-op so the happy-path output shape is unchanged. 7 new regression tests in `Issue640DegradedSourcesTests`: classify-schema-mismatch (3 message variants), classify-DB-unopenable (2 message variants), classify-transient-as-nil (no false positives), SmartQuery aggregates one degraded source, SmartQuery aggregates two, SmartQuery transient-only leaves degraded empty, happy-path leaves degraded empty (no shape regression). Full 1880-test suite green. swiftformat clean. swiftlint net favourable (6 → 4 warnings; site-local `function_body_length` disables on the two longer methods cleared two pre-existing violations as a side effect). **Effect on shipped v1.1.0 bundle: zero** , pure response-shape fix; takes effect for anyone running a binary that includes this PR. ### Added - **#274 (CLI + MCP, closing the umbrella) , `cupertino inheritance <symbol>` command + `get_inheritance` MCP tool walk the class-inheritance graph populated by the prior PR.** Both surfaces share the same parameters: `direction` (`up` ancestors / `down` descendants / `both`), `depth` (default 5), `framework` (disambiguator when the symbol exists in multiple frameworks like `Color` in SwiftUI vs AppKit). Same ambiguity disambiguation pattern as `get_symbol_summary` (#70): ambiguous title returns a list of candidates with framework column rather than guessing. CLI emits text (indented tree), JSON (nested ancestors / descendants), or markdown (heading + bullets). MCP returns markdown so AI agents get a body that reads cleanly. Underlying walker (`Search.Index.walkInheritance`) does BFS-frontier per level with a visited-set guard against cycles (impossible in real Apple data but cheap insurance). Read helpers exposed on the `Search.Database` protocol: `resolveSymbolURIs(title:)` for title→URI lookup, `walkInheritance(startURI:direction:maxDepth:)` for tree retrieval. Five new value types in `SearchModels` (`Search.InheritanceDirection`, `InheritanceCandidate`, `InheritanceNode`, `InheritanceTree`) , moved from the `Search` SPM target so the protocol surface in `SearchModels` can reference them without consumers pulling the concrete `Search` target. **Live verified** on a temp v15 DB seeded with the UIButton → UIControl → UIView → UIResponder → NSObject chain plus an ambiguous Color (SwiftUI + AppKit) fixture: every scenario (up walk full chain, down depth-1, both depth-1, missing symbol, ambiguous, framework-disambiguated, JSON shape, MCP tools/list registers `get_inheritance`, MCP tools/call returns the right markdown body) passes end-to-end against the release binary. 9 new regression tests in `Issue274InheritanceWalkTests` covering up/down/both walk shapes, depth honouring, leaf-node empty result, single/multi-candidate symbol resolution, case-insensitive lookup. CLI subcommand-registration test bumped from 14 → 15. Full 1873-test suite green. **Effect on shipped v1.1.0 bundle: zero** , the `inheritance` table only populates on the next `cupertino save --docs` reindex; the CLI / MCP surface exists immediately but returns "no inheritance data" for every symbol until the reindex lands. ### Fixed - **#274 (URI resolution + indexer writes) , the inheritance edge table is now populated end-to-end.** Builds on the schema + title-extraction landed in the previous PR. **(1) URI resolution** , `AppleJSONToMarkdown.toStructuredPage` now walks each `relationshipsSections.identifiers` entry twice: the existing pass collects titles via `doc.references[id].title`, the new pass resolves URIs via `doc.references[id].url` → `absoluteDocumentationURL(from:)` → `Shared.Models.URLUtilities.appleDocsURI(from:)`, producing the canonical `apple-docs://<framework>/<path>` form. Two new `[String]?` fields on `Shared.Models.StructuredDocumentationPage` (`inheritsFromURIs`, `inheritedByURIs`) hold these in parallel to the title arrays (same order, same length when both populate; `nil` when the section is absent). The Codable surface, `.with(contentHash:)`, and the canonical-content-hash payload have all been threaded through. **(2) Indexer writes** , new `Search.Index.writeInheritanceEdges(pageURI:inheritsFromURIs:inheritedByURIs:)` helper emits one `(parent_uri, child_uri)` row per edge into the `inheritance` table introduced in the prior PR. `inheritsFrom` edges put the page on the child side (its ancestors on the parent side); `inheritedBy` edges put the page on the parent side. The table's composite primary key + `INSERT OR IGNORE` dedup edges seen from both directions , `UIButton.inheritsFrom: [UIControl]` and `UIControl.inheritedBy: [UIButton]` produce the same row, whichever page is indexed first wins. Re-indexing the same page is also a no-op for already-written edges. The hook fires from `indexStructuredPage` right after `recomputeSymbolsBlob`, so every apple-docs page indexed through the structured path now contributes its inheritance edges automatically. **(3) Read helpers** , `parentsOf(childURI:)` walks `WHERE child_uri = ?`, `childrenOf(parentURI:)` walks `WHERE parent_uri = ?`, both backed by the `inheritance_by_child` and `inheritance_by_parent` indexes for O(log n) per lookup. `inheritanceEdgeCount()` returns the total row count for diagnostics + the post-#638 doctor summary. Callers compose recursive walks in Swift rather than via SQL recursion (the table is small enough , a few thousand class chains across UIKit/AppKit/Foundation , that repeated single-row lookups stay fast). 11 new regression tests across two suites (3 in `Issue274InheritsFromExtractionTests` extending the prior suite for URI resolution, 8 in `Issue274InheritanceEdgeWritesTests` covering the writer + cross-direction dedup + the read helpers). Full 1864-test suite green. **Effect on shipped v1.1.0 bundle: zero** , the table only populates as `cupertino save --docs` walks the JSON corpus; the next reindex picks up the data automatically. **Out of scope (next PR):** `cupertino inheritance <symbol>` CLI command + `get_inheritance` MCP tool, both consuming the populated edge table. - **#274 (indexer half) , `AppleJSONToMarkdown` captures `Inherits From` titles + new `inheritance` edge table lands at schema v15.** Pre-fix, `AppleJSONToMarkdown.toStructuredPage` walked `relationshipsSections` and routed three of the four common section titles to dedicated fields (`Conforms To` → `conformsTo`, `Inherited By` → `inheritedBy`, `Conforming Types` → `conformingTypes`); the fourth, `Inherits From`, fell through to the default branch that appended the section as a freeform `Section` block in the page body. The data was still in the indexed content but unreachable from any queryable field. For UIKit / AppKit / Foundation class chains (`UIButton ← UIControl ← UIView ← UIResponder ← NSObject`) the missing axis is the most useful one , the inheritsFrom direction is what answers "what does X inherit from?" without scanning the rendered body. This PR is the indexer-side half of #274: **(1)** new `case "inherits from":` clause in the relationships walk; **(2)** `Shared.Models.StructuredDocumentationPage.inheritsFrom: [String]?` field added (parallels the existing `inheritedBy`); **(3)** `.with(contentHash:)` and the canonical-content-hash payload both updated so the field round-trips through the `contentHash:""` → `canonicalContentHash` stamp; **(4)** new `inheritance(parent_uri, child_uri)` edge table in the schema with `inheritance_by_parent` and `inheritance_by_child` indexes , both walk directions are O(log n) queryable; **(5)** schemaVersion 14 → 15 with the matching `if currentVersion < 14 { throw ... }`-pattern entry in `checkAndMigrateSchema` (per #635 , added FIRST this time, not as a follow-up); the v14→v15 throw points users at `cupertino setup` for the v1.2.0 bundle. 5 new regression tests in `Issue274InheritsFromExtractionTests`: UIButton-shaped page lifts `Inherits From` into the field; `Inherits From` no longer leaks into the default section bucket; struct pages (Foundation.Data) keep `inheritsFrom` nil; `Inherited By` still routes to its dedicated field (no regression on the pre-existing path); Codable round-trip preserves the new field. 2 `SchemaShapeTests` updated for the v14→v15 bump. Full 1853-test suite green. **Effect on shipped v1.1.0 bundle: zero** , pure indexer-side fix; the schema and extraction land in the v1.2.0 bundle on next `cupertino save --docs` reindex. **Follow-up PR (out of scope for this one):** URI resolution from `doc.references` into apple-docs URIs, write rows into the `inheritance` table at index time, `cupertino inheritance <symbol>` CLI command, `get_inheritance` MCP tool. Splitting them keeps this PR reviewable and lands the schema bump in time for the upcoming reindex. - **#635 , `Search.Index.setSchemaVersion` no longer silently stamps `PRAGMA user_version` on a non-zero, non-matching DB; `checkAndMigrateSchema` gains the v13→v14 throw entry that #634 missed.** Surfaced during the 2026-05-15-16 pair-bug-hunt session: develop's binary built from the `fix/77` branch (which bumped `schemaVersion: Int32 = 13 → 14` for the new `symbol_components` FTS column) opened the user's homebrew-managed `~/.cupertino/search.db` (v13) during live-bundle audits. `setSchemaVersion()` saw the mismatch, wrote `PRAGMA user_version = 14`, and the user's homebrew-installed `cupertino` 1.1.0 (schema 13) then refused to open the same DB , 16 of 27 CLI commands failed with `SQLite error: Database schema version 14 is newer than supported version 13` until the user ran `cupertino setup` to redownload a v13 bundle. Root cause: #634 bumped the `schemaVersion` constant without adding the matching `if currentVersion < 14 { throw "rebuild required" }` entry to `checkAndMigrateSchema`. Every previous FTS5 column add , v11→v12 (#192 D, `symbols`), v12→v13 (#283, URL case canonicalization) , had carried that throw. Without it, the migration check fell through silently on a v13 DB, and `setSchemaVersion`'s "skip on match, otherwise stamp" logic clobbered the on-disk value. Documented in `mihaela-agents/sessions/2026-05-15-16-cupertino-bug-hunt/REPORT.md`. Two coordinated fixes: **(1)** `checkAndMigrateSchema` now carries the v13→v14 throw entry (primary defense , matches the existing pattern for v11→v12 and v12→v13, points the user at `cupertino setup` for the production upgrade path). **(2)** `setSchemaVersion` now refuses to write when `getSchemaVersion()` returns a non-zero value that doesn't equal `Self.schemaVersion` (defense in depth , catches the case where a future schema bump forgets the migrator entry, throws a clear error naming both the on-disk and expected versions and suggesting `cupertino setup`). 6 new regression tests in `Issue635SchemaStampGuardTests`: fresh DB still gets stamped at the binary's expected version; matching-version reopens stay no-op (no write-lock contention regression); v13 DB rejected via the new migrator entry (asserts the on-disk version stays at 13, never silently bumped); forward-incompat (schemaVersion + 1) rejected via the existing check; setSchemaVersion guard refuses to stamp over a manually-poked stale value (defense-in-depth path); v13→v14 error message contains `cupertino setup` so users have actionable recovery. Full 1848-test suite green (+6 from this PR). swiftformat clean. swiftlint baseline unchanged (1 new `function_body_length` warning suppressed at site). **Effect on shipped v1.1.0 bundle: zero** , pure binary-side defensive fix; takes effect for anyone running a build that includes this PR. - **#77 , index-time CamelCase expansion: `search("grid")` will find `LazyVGrid`, `search("session")` will find `URLSession` on the next reindex.** The default FTS5 `unicode61` tokeniser treats CamelCase identifiers as opaque units , pre-fix, a SwiftUI dev who knew the term `grid` but not the exact type name `LazyVGrid` got zero hits even though the answer was indexed. The fix adds a new `symbol_components` FTS column carrying acronym-aware splits of every AST-derived symbol on the page (`LazyVGrid → Lazy / VGrid / Grid`, `URLSession → URL / Session`, `JSONDecoder → JSON / Decoder`, `HTTPSCookieStorage → HTTPS / Cookie / Storage`, `XMLParser → XML / Parser`). The splitter follows the rule lock in #77's spec: single-pass walker that groups runs of consecutive caps as one acronym unit but flushes when the last cap of a run becomes the head of a lowercase-prefixed next word; min component length 3 filters single-letter fragments (`V`, `UI`, `IO`, `2D`); per-call dedupe + cross-identifier dedupe via `splitCamelCaseIdentifiers(_:)`. A merge-forward step folds filtered short fragments into the next component so `LazyVGrid` emits both `VGrid` (V+Grid merged because V was below the floor) and the standalone `Grid` , both `search("vgrid")` and `search("grid")` find the page. No stopword list: `View`, `Manager`, `Controller`, `Delegate` are legitimate query terms. BM25F weight on `symbol_components` is **1.5**, deliberately well below `symbols` at 5.0 , the original-identifier exact-match signal stays dominant, so `search("LazyVGrid")` ranking is unchanged from the v1.0.0 exact-symbol path (acceptance criterion #3 from the spec). Schema bumps v13 → v14; FTS5 doesn't support ALTER TABLE ADD COLUMN so the bump is BREAKING (existing v13 DBs rejected at open with the same "rebuild required" message v12 received). `Search.Index.recomputeSymbolsBlob(docUri:)` writes both columns in one UPDATE , pulls the unioned name list from `doc_symbols`, sends the originals through `splitCamelCaseIdentifiers` for the components column, leaves the `symbols` column shape unchanged. Index-size impact: rough estimate ~10% growth on `docs_fts` storage (one extra column with shorter tokens on average than `symbols`). 11 new tests in `Issue77CamelCaseSplitterTests`: 4 parametrised acronym fixtures (URLSession / JSONDecoder / HTTPSCookieStorage / XMLParser), LazyVGrid V-drop with VGrid retained via merge, single-word edge case, all-caps acronym, empty input, two-letter fragment filtering, cross-identifier dedupe over 3 URL-prefixed names, no-stopword check on View/Manager/Controller/Delegate, bulk-variant union over URLSession family, integration test confirming `recomputeSymbolsBlob` writes both `symbols` and `symbol_components` rows to docs_fts, plus an empty-doc_symbols → empty-components anchor. 2 schema-shape tests in `SchemaShapeTests` bumped from v13 → v14. Full 1842-test suite green. **Effect on shipped v1.1.0 bundle: zero** , pure indexer-side fix; takes effect on next `cupertino save --docs` reindex. - **#626 , indexer recognises `case` / `initializer` / `subscript` / `actor` / `sample code` kinds + falls back to declaration first-token when `roleHeading` is absent. Cuts `kind=unknown` from 57% to a projected ~20% on the next bundle.** Audit on the shipped v1.1.0 bundle: 162,821 of 284,518 apple-docs rows landed with `kind=unknown`. Sampling the JSON behind those rows showed two independent causes. **(1)** Apple's `metadata.roleHeading` was missing on a large slice of the JSON corpus , particularly `@MainActor`-decorated structs (e.g. `StoreKit.RequestReviewAction`), sample-code sub-pages, and pages auto-generated from header-only sources. `parseKind` defaulted to `.unknown` even though `declaration.code` carried authoritative Swift syntax (`@MainActor\nstruct RequestReviewAction`, `class func endImpression(...)`, etc.). **(2)** Apple's DocC role-heading dictionary includes `Case`, `Initializer`, `Subscript`, `Actor`, and `Sample Code` , five values the `parseKind` switches in both `Core.JSONParser.AppleJSONToMarkdown` and `Core.JSONParser.MarkdownToStructuredPage` didn't dispatch on. Even when these roleHeadings were present, the row tagged `.unknown`. A separate mis-classification: `class func endImpression(...)` mapped to `.class` because the markdown-fallback's first-token scan latched onto the `class` prefix before the type-modifier was disambiguated. Fix (5 sites): **(a)** `Shared.Models.StructuredDocumentationPage.Kind` enum gains `.enumCase` (rawValue `"case"`), `.initializer`, `.subscript`, `.actor`, `.sampleCode` (rawValue `"sample code"`). **(b)** Both `parseKind` tables dispatch on the new roleHeadings + their whitespace variants (`"instance subscript"`, `"type subscript"`, `"enum case"`, `"constructor"`, `"samples"`). **(c)** New `parseKindFromDeclaration(_:)` helper in both parsers peeks at the declaration's first significant token after stripping `@MainActor` / `@available(...)` and similar attributes. Longest-prefix-first so `class func` matches `.method` before bare `class` (the pre-fix mis-classification). Recognises `struct` / `class` / `enum` / `protocol` / `actor` / `typealias` / `case` / `init` (and `convenience init` / `nonisolated init` / `required init`) / `subscript` / `var` / `let` / `func` (and `class func` / `static func` / `mutating func` / `nonisolated func`) / `prefix|postfix|infix operator`. `toStructuredPage` (structured-JSON path) and `convert` (markdown path) now call `parseKindFromDeclaration` as a fallback whenever the roleHeading-based path returns `.unknown`. **(d)** `Search.Index.SearchByAttribute.propertyMethodKinds` gains `"case"`, `"subscript"`, `"sample code"` so the canonical-prepend kind filter (#630) and HEURISTIC 1.6 tiebreak (#616) reject member-shape rows at canonical URIs. **(e)** `Search.DocKind.Classify.kind` maps the new structured-kind values into the coarser taxonomy (`actor` / `case` / `initializer` / `subscript` → `.symbolPage`, `sample code` → `.sampleCode`). Quantified recovery potential against the shipped bundle: declaration-token fallback recovers ~30k struct/class/enum/protocol rows whose `roleHeading` was missing; new roleHeading cases recover ~14k `case` rows + ~5k initializers + ~456 subscripts; whitespace variants of `instance subscript` / `type subscript` / `enum case` add a few hundred more. Plus the existing 2,254 rows mis-classified as `.class` via `class func` get re-tagged as `.method`. Total projected unknown reduction on next reindex: ~109,000 of 162,821 unknowns (67%), bringing the global `kind=unknown` share down from 57% to roughly 19%. 10 new regression tests in `Issue626KindExtractionExpansionTests` pin: new Kind raw values + Codable round-trip; 14 parametrised declaration shapes through the fallback; `class func` matches `.method` not `.class`; `class var` / `static var` / `mutating func` resolve correctly; `@MainActor` and `@available(...)` prefixes are stripped before token match; empty/nil declaration returns nil (caller keeps `.unknown` rather than fabricate). Full 1831-test suite green. `CoreJSONParserTests` target gains a `SharedConstants` dependency for direct access to `Shared.Models.StructuredDocumentationPage.Kind` (the type was already in `CoreProtocols`'s public surface but the module wasn't re-exported). One pre-existing exhaustive-switch in `Search.Index.IndexingDocs.extractOptimizedContent` updated to handle the new Kind cases: `.actor` joins core-types, `.enumCase / .initializer / .subscript` join members, `.sampleCode` joins articles/tutorials/collections in the raw-markdown branch. Effect on the v1.1.0 shipped bundle: zero (this is an indexer-side fix; takes effect when the next bundle is rebuilt with `cupertino save --docs`). - **#630 , cross-source aggregator stops force-prepending property/case rows over canonical type pages (1 clean win + 2 partial wins, blocked on bundle re-publish for the corpus-collision residual).** Pre-fix, `fetchCanonicalTypePages` (the post-#256 / #610 Class A safety-net) returned EVERY row whose URI matched `apple-docs://<fw>/<query>` for the three top-tier frameworks, regardless of `kind` or `title` shape , and force-prepended each at rank `-2000`, ahead of any BM25-ranked candidate. On the shipped v1.1.0 bundle this lifted three obviously-wrong rows to position #1 of `cupertino search <type>` results on the unified path: `apple-docs://swift/url` (kind=unknown, title=`String.IntentInputOptions.KeyboardType.URL`) for `URL`; `apple-docs://swiftui/urlsession` (kind=property, BackgroundTask.urlSession) for `URLSession`; `apple-docs://swiftui/data` (kind=property, ForEach.data) for `Data`. Each looked like the canonical answer to AI agents and CLI users. Fix: the prepend now pulls `kind` from `json_extract(m.json_data, '$.kind')` and rejects hits whose kind is in `propertyMethodKinds` (property / method / function / operator / macro / initializer, plus the `instance/type` and whitespace/hyphen variants) , catches `foundation/url` (URLRequest.url) and `swiftui/urlsession`. It also rejects hits whose `title` contains a `.` (dotted-breadcrumb member-page titles) , catches `swift/url`'s `String.IntentInputOptions.KeyboardType.URL` even though its kind is `unknown`. Hits with `kind=unknown` and bare titles still survive (preserves the Codable safety-net: `swift/codable` has `kind=unknown` in the v1.1.0 bundle but `title="Codable | Apple Developer Documentation"`). Live verification on the v1.1.0 shipped DB with the source-tree binary at `754952d`+#630: **URLSession** → `apple-docs://foundation/urlsession` (the real `URLSession` protocol page; was `swiftui/urlsession` property), clean win. **URL** and **Data** are #610 Class B corpus-collision casualties , their `apple-docs://foundation/*` URIs hold collision-clobbered content (URLRequest.url and NSURLSessionWebSocketMessage.data respectively) in the shipped v1.1.0 bundle, and source-side ranking cannot make foundation/url return the real Foundation URL struct page when the URI is bound to the wrong row. #630 still removes the worst force-prepends so the user sees BM25 winners instead of the rank-2000 worst-row promotion: **URL** now returns `apple-docs://modelio/url` (still a property but no longer the `String.IntentInputOptions.KeyboardType.URL` enum case), **Data** now returns `apple-docs://swiftui/data-swift.associatedtype` (a SwiftUI associatedtype titled `Data`, not the ForEach.data property). Full fix for those two ships on the next bundle re-publish (#290). 5 new regression tests in `Issue630CanonicalPrependKindFilterTests` pin: (1) `kind=property` at the canonical URI is NOT prepended; (2) dotted-breadcrumb titles (kind=unknown) are rejected; (3) `kind=protocol` with bare title IS still prepended (URLSession-shape); (4) `kind=unknown` with bare title IS still prepended (Codable-shape, regression anchor against over-strict filtering); (5) whitespace variant `instance method` kind is correctly recognised and rejected. Full 1821-test suite green. #610 Class A spot-check: Task → swift/task, View → swiftui/view, Codable → swift/codable all preserved. - **#628 , `cupertino search --framework <name>` is now respected end-to-end (three independent silent-drop bugs fixed in one PR).** Pre-fix the filter leaked in three ways. **(A)** `fetchCanonicalTypePages` , the post-#256 / #610 Class A safety-net that prepends `apple-docs://<fw>/<query>` rows for `swift` / `swiftui` / `foundation` , probed all three top-tier frameworks regardless of `--framework`. Symptom: `cupertino search View --framework foundation` returned `apple-docs://swiftui/view` at score 2000.00 (rank -2000), violating the user's explicit filter. **(B)** `Search.Index.search` accepted any non-empty `framework` string and applied it to the `WHERE f.framework = ?` clause verbatim. When `resolveFrameworkIdentifier` returned the raw lowercased input as its on-miss fallback (the function's "might be a valid framework not in alias table yet" branch), bogus values like `--framework banana` silently zeroed the result set rather than rejecting the input , same user-facing shape as "no matches", impossible to distinguish from a real query miss. **(C)** the unified (no `--source`) fan-out path dropped `--framework` entirely. `DocsSourceCandidateFetcher.init` had no `framework` parameter, so `SmartQuery.answer` issued every per-source `Search.Index.search` call with `framework: nil`. Reported by the user as a hig-vs-apple-docs discrepancy: `--source apple-docs --framework swiftui` worked, removing `--source` did not. Fix: (1) `fetchCanonicalTypePages` now takes `framework: String?` and skips probes whose target framework doesn't match the caller's filter, so a `--framework foundation` query only probes the foundation URI shape. (2) `Search.Index.search` validates non-empty `framework` against the corpus via a new `frameworkExistsInCorpus(_:)` helper (`SELECT 1 FROM docs_metadata WHERE framework = ? LIMIT 1`); on miss it throws `Search.Error.invalidQuery("Unknown framework: '<name>'. Run \`cupertino list-frameworks\` for the canonical identifier list.")`. The corpus is the source of truth , the alias table is populated opportunistically during indexing and can lag the metadata view, so validating against `docs_metadata` rather than `framework_aliases` catches the real "not indexed" cases. The empty-string case (`--framework=`) still treats the filter as absent. (3) `DocsSourceCandidateFetcher` takes `framework: String?` in its init and threads it to `searchIndex.search`; the new `frameworkScopedSources` set restricts the filter to apple-docs / apple-archive, where rows actually carry a meaningful `framework` column, and silently drops it on hig / swift-evolution / swift-org / swift-book (whose pages carry `framework=""` so the filter would zero them out). (4) The CLI's `runUnifiedSearch` validates `--framework` once before invoking `SmartQuery.answer` , per-fetcher throws are silently swallowed inside the fan-out's "skip dead source" policy, so validation has to happen earlier to surface a clear error to the user. 6 new regression tests in `Issue628FrameworkFilterTests` pin: (A) canonical-prepend skips non-matching frameworks, (A') prepend still fires when the filter matches the canonical framework, (B) bogus framework throws `Search.Error`, (B') empty filter treats as absent (no throw), (C) `DocsSourceCandidateFetcher` passes the filter through on apple-docs, (C') drops the filter on non-apple sources. Full 1816-test suite green. - **#625 , `cupertino search <type>` without `--source` now surfaces the canonical Apple type page on the cross-source aggregator path (closes the last 3 of 9 Class A wrong-winners).** Pre-fix, the cross-source aggregator (`Search.SmartQuery.answer`'s reciprocal-rank fusion) summed RRF increments per `source\u{1F}identifier` key. When `docs_fts` in the shipped bundle carried duplicate rows for one `uri` (the v1.1.0 corpus has e.g. 3 rows for `apple-docs://naturallanguage/string` against 1 in `docs_metadata` , an indexer-dedup gap pre-#587), the JOIN returned all 3 rows, RRF summed 3 contributions to the same fused key, and the inflated score (0.0883 vs the canonical's 0.0492) pushed the duplicate page above legitimately-ranked canonicals. Symptom: `cupertino search String` returned `naturallanguage/string` instead of `swift/string`; same shape for `Hashable → swiftui/matchedtransitionsource` and `Equatable → realitykit/equatable-implementations`. Fix is single-site: `Search.Index.search` now tracks a `Set<String>` of seen URIs while walking SQL rows (which are already `ORDER BY rank ASC`, so the first occurrence is the best-BM25 one) and skips repeats. The query SQL itself is untouched , the dedup runs in Swift after the SQL, cheaper than a `GROUP BY` over the full result column set + window functions, and robust to whatever shape the FTS row-count happens to be in. Live smoke against the v1.1.0 shipped bundle: **9 of 9 Class A queries now surface the canonical page on the unified search path** (Task / View / String / Array / Hashable / Equatable / Codable / Identifiable / Sendable). 2 new regression tests in `Issue625UnifiedSearchDedupTests` pin: (1) `Search.Index.search` returns each `uri` at most once even when `docs_fts` has duplicate rows for one `uri` (the test injects 2 extra FTS rows directly via SQL to simulate the v1.1.0 shape); (2) sanity multi-URI run , every uri appears exactly once. The 26 existing canonical-ranking tests stay green. Full 1810-test suite green. Class B subset of #610 (URL / Color / Font / List / Data corpus-collision casualties) is unaffected by this fix and still requires the next bundle re-publish. - **#620 , `cupertino read-sample-file` now distinguishes "wrong project id" from "wrong file path within a valid project".** Pre-fix the command emitted the same `File not found: <path> in project <id>` message regardless of whether the project existed, with the remediation hint `Use 'cupertino read-sample <id>' to list available files.` running straight into a second `Project not found` wall when the typo was in the id. Post-fix, when the project id doesn't resolve via `Sample.Search.Service.getProject(id:)` the CLI emits the same shape `cupertino read-sample` uses: `Project not found: <id>` plus `Use 'cupertino list-samples' or 'cupertino search --source samples' to find valid project IDs.` File-not-found inside a valid project keeps the existing wording (now correct because the id checked out). Surfaced during #613 item 4 audit. 3 new regression tests in `Issue620SampleServiceProjectLookupTests` pin the `getProject` + `getFile` contracts the CLI branches on. Full 1808-test suite green. Bonus from the audit: confirmed the path-traversal attempt (`../../etc/passwd`) is architecturally safe , `read-sample-file` reads from the `files.content` column in `samples.db`, never touches the filesystem, so traversal can't escape; the "File not found" message for traversal-shaped paths is correct and benign. - **#618 (CRITICAL) , `cupertino serve` now exits when its peer closes stdin (AI-agent client disconnect).** Pre-fix, the CLI parked on `while true { try await Task.sleep(for: .seconds(60)) }` after `server.connect(transport)`. The Stdio transport's `readLoop` correctly noticed stdin EOF (its `for try await byte in input.bytes` loop terminated and called `messagesContinuation.finish()`) and the server's `processMessages` correctly unwound (`for await message in messageStream` dropped through), but the CLI's main task stayed blocked on the infinite sleep loop forever. Result: every Claude Desktop / Cursor / Codex MCP session that touched cupertino tools left a `cupertino serve` process alive after the client disconnected. Main spent two days hunting stray procs on the heartbeat Mac before discovering the cause was this loop. Fix: added `MCP.Core.Server.waitForCompletion() async` that awaits the server's internal `messageTask.value`, then replaced the infinite sleep loop in `CLIImpl.Command.Serve.run()` with `await server.waitForCompletion()`. When the transport's `messages` stream finishes (stdin EOF), the message task ends, `waitForCompletion` returns, and the CLI exits cleanly with code 0. Live verification on the v1.1.0 release binary with three repros that all hung for 15s pre-fix: `echo "" | cupertino serve --no-reap`, `cupertino serve --no-reap < /dev/null`, and `init + notifications/initialized + EOF` , all three now exit in **0s**. 2 new regression tests in `ServerWaitForCompletionTests` pin the bounded-time contract: in-memory `CloseableTransport`, deliver an init (optional), `closeInbound()`, race `server.waitForCompletion()` against a 3 s timeout, assert wait resolves first. Full 1805-test suite green. ### Tested - **#613 item 3 , JSON-RPC notifications regression suite.** No code change. Probed `MCP.Core.Server`'s notification handling against `notifications/initialized`, `notifications/cancelled`, an unknown `notifications/<arbitrary>`, and a request-method delivered without an `id`; in every case the server emits zero response frames, as required by JSON-RPC 2.0 §4.1 ("The Server MUST NOT reply to a Notification"). The type-system already enforces the rule , `JSONRPCParser` decodes id-less frames into the `.notification` case, and `MCP.Core.Server.handleMessage` dispatches that case to `handleNotification` which only logs. Added 4 regression-anchor tests in `JSONRPCNotificationSilenceTests` to pin the contract against future drift (e.g. someone adding an outbound `transport.send` inside `handleNotification`). Full 1803-test suite green (+4 over 1799 baseline). ### Fixed - **#610 (Class A , 9 of 14 wrong-winner queries) , bare type-name search now surfaces the canonical Apple type page even when BM25 buries it under a property/method page with the same title.** Two coordinated fixes: (1) **HEURISTIC 1.6** in `Search.Index.Search` adds a kind-aware tiebreak inside the existing exact-title-match branch (`HEURISTIC 1` at line 487). When two apple-docs rows both have title matching the query, prefer the one whose `kind` is a canonical type-shape (`class`, `struct`, `enum`, `protocol`, `typealias`, `actor`, listed in the new `canonicalTypeKinds` set) over rows whose `kind` is a member-shape (`property`, `method`, `function`, `operator`, `macro`, `initializer`, plus the `instance/type` variants, listed in `propertyMethodKinds`). Canonical types get a `0.3` multiplier (~3.3× boost), member rows get a `1.5` multiplier (1.5× penalty). (2) **`fetchCanonicalTypePages` URI shape** updated from the pre-#283 `apple-docs://<framework>/documentation_<framework>_<query>` form to the post-#283/#589 lossless `apple-docs://<framework>/<query>` form , the shipped v1.1.0 corpus has 284,515 of 284,518 apple-docs rows on the new shape, so the old shape was effectively dead code that returned zero hits on every query. With the URI shape fixed, `Identifiable` (which BM25 leaves at rank past `fetchLimit`, so HEURISTIC 1.6 alone couldn't reach it) gets force-included from the metadata table. Also: the SQL in `fetchCanonicalTypePages` + `fetchFrameworkRoot` now COALESCEs `$.abstract` → `$.rawMarkdown[0..500]` → `''` for the `summary` column so post-#608 synthesised wrappers (no `abstract` key) still return non-empty chunks (caught by `DocsSourceCandidateFetcherTests.adaptsSearchResultFields`). Live smoke against the v1.1.0 shipped bundle with `cupertino search <X> --source apple-docs` (the MCP / AI-agent path): **9 of 9 Class A queries now return the canonical page as result #1** , Task→`swift/task`, View→`swiftui/view`, String→`swift/string`, Array→`swift/array`, Hashable→`swift/hashable`, Equatable→`swift/equatable`, Codable→`swift/codable`, Identifiable→`swift/identifiable`, Sendable→`swift/sendable`. 10 new regression tests in `Issue610ClassARankingTests` cover the 9 queries + a regression anchor (property-only query without canonical peer still returns the property page). 26 existing canonical-ranking + BM25 title-weighting tests stay green. Full 1799-test suite green. The remaining **5 Class B queries** (URL, Color, Font, List, Data) are corpus-collision casualties , pages overwritten by `URLRequest.url`-style collisions pre-#589 , and resolve on next re-index via #589's lossless URI helper, not via ranking. The 3 cross-source-aggregator wrong-winners that persist without `--source apple-docs` are a separate path with its own ranking and are out of scope here. - **#614 , `MarkdownToStructuredPage.extractKind` now anchors to the page title; canonical Swift stdlib structs no longer mis-tag as protocols.** Pre-fix, the markdown-fallback kind extractor first-matched the `"<kind># <name>"` line pattern. Pages where Apple's rendered markdown listed protocol conformances above the page's own heading (`Protocol# Equatable`, `Protocol# Hashable`, … before `Structure# String`) returned `.protocol` for what is in fact a struct. Hit at least three of main's #610 wrong-winner audit results , `swift/string` (struct → `kind=protocol`), `swift/array` (struct → `kind=protocol`), `swift/codable` (typealias → `kind=article`) , and any other conformance-heavy page that came through the markdown path rather than the structured-JSON path. Fix: `extractKind` now takes the page's already-extracted title and runs a two-pass scan: pass 1 hunts for the `"<kind># <suffix>"` line whose suffix matches the title; pass 2 falls back to the pre-#614 first-match behaviour for pages where no exact-title heading appears (so we never regress to `.unknown` on shapes the old code did handle). The structured-JSON path (`AppleJSONToMarkdown.parseKind`, which reads `metadata.roleHeading` directly) is unchanged , it was already correct. 9 new regression tests in `MarkdownToStructuredPageKindExtractionTests` cover the String/Array conformance-heavy bug class, the Codable typealias case, the Task/View regression anchors that must NOT tip, the fallback-to-first-match case, the no-Kind#-lines `.unknown` case, and whitespace tolerance. Full 1789-test suite green. The fix is source-side; the shipped v1.1.0 bundle keeps its wrong kinds until the next bundle build picks up this change (per the `no-release` directive). - **#611 , MCP error frame for unknown methods no longer leaks the `MCP.Core.Protocols.` Swift namespace.** Pre-fix, `MCP.Core.ServerError.methodNotFound(String).message` returned `"MCP.Core.Protocols.Method not found: <name>"` , the literal Swift namespace path bled into the user-facing JSON-RPC error frame that AI agents and MCP clients read. Repro: any JSON-RPC call with an unknown `method` field returned that string under `error.message`. The other six `ServerError` cases (`alreadyRunning`, `transportNotConnected`, `notInitialized`, `alreadyInitialized`, `invalidParams`, `capabilityNotSupported`, `encodingFailed`) were already clean; only this one carried the autocomplete slip. One-line fix in `Packages/Sources/MCP/Core/Server/MCP.Core.Server.swift:447`. Error code stays `-32601` (unchanged wire contract). 4 new regression tests in `ServerErrorMessageTests` pin the exact string for `methodNotFound` plus the other 7 cases byte-for-byte so a future tweak to any of them is intentional, not accidental. Full 1780-test suite green. - **#607 (read-side fallback half) , existing shipped bundles return real content for swift-evolution / hig / apple-archive reads without re-indexing.** PR #608 fixed the indexer for new saves; this PR makes the fix retroactive for every user on v1.0.x / v1.1.x. `Search.Index.getDocumentContent(uri:format: .json)` now inspects the stored `docs_metadata.json_data` wrapper: if `rawMarkdown` is missing, null, or empty, the FTS sidecar's `docs_fts.content` is pulled and injected before returning. Re-serialisation goes through `JSONSerialization` so the injected body's quotes / backslashes / newlines / backticks / tabs come back out as valid JSON. The merge is a no-op when the wrapper already carries non-empty `rawMarkdown` (so post-#608 indexed rows pass through unchanged). Unparseable wrappers fall back to verbatim (no crash). Live smoke against the v1.1.0 shipped bundle: `cupertino read swift-evolution://SE-0304` went from 129 bytes (wrapper only) to 95,600 bytes (full proposal merged from FTS); `cupertino read hig://technologies/technologies-appledeveloperdocumentation` 181 → 2,585 bytes; `cupertino read apple-archive://10000047i/RevisionHistory` 168 → 2,076 bytes; `cupertino read apple-docs://swiftui/view` unchanged at 78,293 bytes (regression anchor). Four new regression tests cover (1) FTS merge fires when stored wrapper has `rawMarkdown:null`, (2) merge is skipped when `rawMarkdown` is already populated, (3) empty `rawMarkdown` string counts as missing, (4) unparseable wrapper returns verbatim. Full 1776-test suite green. - **#607 (indexer-side half) , `Search.Index.indexDocument` now inlines `params.content` into the synthesised `docs_metadata.json_data` wrapper instead of writing literal `"rawMarkdown":null`.** Pre-fix, callers that passed `jsonData: nil` (the string-content strategies , `Search.Strategies.SwiftEvolution`, `Search.Strategies.HIG`, `Search.Strategies.AppleArchive`) landed a wrapper with `rawMarkdown:null` in `docs_metadata.json_data`. The full body reached `docs_fts.content` so FTS search + `resources/read` worked, but `read_document` (MCP tool) + `cupertino read` (default JSON), both of which read from `docs_metadata.json_data`, returned the empty wrapper to AI agents. Main's CLI smoke on 50c6761 confirmed the blast radius: swift-evolution, hig, apple-archive broken; apple-docs, swift-org, swift-book OK (those three pass an explicit structured `jsonData` so they sidestepped the nil branch). Fix is a single-site edit in `Search.Index.IndexingDocs.swift`: the nil-branch wrapper is now built via `JSONSerialization` (which handles embedded quotes, backslashes, newlines, backticks, and control chars properly , the previous hand-rolled string-concat only escaped double quotes in the title and would have corrupted any non-trivial markdown body) and carries `params.content` under `rawMarkdown`. Three new regression tests pin: (1) round-trip parity on a body with quotes / backslashes / newlines, (2) survival of fenced code + tabs + nested escaped quotes, (3) explicit-jsonData passthrough (apple-docs, swift-org, swift-book still get their structured payload verbatim). Full 1772-test suite green. The read-side fallback (issue #607's second half) lands in a follow-up PR; existing shipped bundles need to be re-indexed for this fix to take effect on local installs (folds into the #290 re-publish ceremony for v1.0.x). New string-content strategies added later automatically inherit the fix. - **#68 , `cupertino doctor` default output focuses on database + MCP health; corpus + package-selection sections move behind the existing `--save` flag.** Pre-fix, every `doctor` invocation walked `~/.cupertino/docs`, `~/.cupertino/swift-evolution`, `~/.cupertino/hig`, `~/.cupertino/swift-org`, `~/.cupertino/archive`, plus the Swift-package selection state and downloaded-README list. That made sense for maintainers running `cupertino fetch` + `cupertino save`, but a user who ran `cupertino setup` (which downloads pre-built DBs and never populates the raw corpus dirs) saw a `0 files` line under "Apple docs" and reasonably thought their install was broken. It wasn't , `setup` users have databases, which is what the runtime needs. Default `cupertino doctor` is now: MCP server health + the three DB sections (`search.db`, `packages.db`, `samples.db`) + resource providers + per-DB schema-version + journal-mode probes. The corpus walk, package-selection state, and `Indexer.Preflight` per-source preflight summary are now additive under `cupertino doctor --save` (pre-fix the flag short-circuited to ONLY the preflight; it now appends on top of the default health suite so a maintainer gets one combined report instead of two passes). Net code change: ~10 lines in `CLIImpl.Command.Doctor.run()`; docs mirror updated (`docs/commands/doctor/README.md`, `option (--)/default.md`, `option (--)/save.md`). Full 1769-test suite green. - **#429 , indexer-side poison filter (HTTP error templates + JavaScript-disabled fallback content) now applies to ALL source strategies, not just apple-docs.** Pre-fix, `titleLooksLikeHTTPErrorTemplate` and `pageLooksLikeJavaScriptFallback` were wired into `indexAppleDocsFromDirectory` only. The Swift Evolution, HIG, Swift.org, and Apple Archive indexers bypassed both checks , which is how a surviving `swift-org://docc_documentation` "Forbidden" row made it into the v1.0.2 bundle even though the apple-docs corpus scan was clean. New `Search.StrategyHelpers.contentLooksLikeJavaScriptFallback(_:)` sister helper operates on raw content strings (the existing `pageLooksLikeJavaScriptFallback` takes a `StructuredDocumentationPage` which the 4 non-apple-docs strategies don't decode to). All 4 strategies now skip + log + bump the `skipped` counter when either signature trips. 5 new tests / 8 cases cover the content-based helper (positive shapes, real proposal/HIG/Apple-Developer-Archive prose as false-positive sanity checks, empty input). Full 1757-test suite green. - **#595 , MCP `resources/list` now rejects malformed cursors instead of silently restarting at page 1.** Pre-fix, `DocsResourceProvider.decodeOffset(from:)` returned 0 for any non-decodable cursor (bad base64, wrong prefix, non-integer payload, negative offset). A paginating client that mangled its cursor between calls would re-fetch the same first page forever, never noticing the cursor was wrong. Post-fix: empty / nil cursors still mean "first page" (the valid bootstrap call) and return 0; non-empty cursors must decode cleanly via the `offset:<N>` shape or `decodeOffset` throws `Shared.Core.ToolError.invalidArgument("cursor", "Malformed cursor: <raw>")`, which the JSON-RPC layer surfaces as a `-32602 invalidParams` error frame. 6 new unit tests (17 cases) pin valid round-trip + 7 malformed-cursor shapes that all now throw. Behaviour test updated: the existing "invalid cursor yields first page" lenient-mode test was inverted to "invalid cursor throws invalidArgument" with a regression-anchor for the empty / nil bootstrap path. - **#598 , `cupertino-tui` no longer spins at 100% CPU when stdin / stdout is not a TTY.** Pre-fix, headless invocations (`printf 'q' | cupertino-tui`, CI smoke probes, hook scripts) entered the event loop, `read()` on a non-TTY pipe returned EOF immediately (the VMIN/VTIME settings only govern terminal-driver blocking, not pipes), and the loop pinned one core at 100% CPU until external SIGKILL. Main's full-coverage sweep left a 4-minute leaked process before noticing. Fix: at startup, check `isatty(STDIN_FILENO)` and `isatty(STDOUT_FILENO)`; if either is non-TTY, print a one-line stderr message ("TUI requires a real TTY") and exit 0 without entering the loop. Headless invocation now completes in ~8 ms instead of ~3+ seconds with full CPU pin. Real-terminal launches are unaffected , the dashboard still renders normally. `--version` short-circuit unchanged (taken before the new guard). - **#597 (CRITICAL) , `cupertino save --base-dir <X>` now actually isolates ALL output DBs.** Pre-fix, `--base-dir` only steered SOME inputs (crawl/fetch directories) but the dispatchers for samples + packages hardcoded `Shared.Paths.live().baseDirectory` (always `~/.cupertino`) for the OUTPUT database paths. Anyone running `save` with `--base-dir` for testing, a second corpus, or a sandbox silently rewrote `~/.cupertino/samples.db` (and the packages.db too). Main's 2026-05-15 full-coverage sweep tripped this live and lost ~180 MB / 619 projects / 18,928 files in 3 min before noticing (restored from backup). New unit tests in `SaveBaseDirIsolationTests` pin all three resolvers (`resolveSamplesDBPath`, `resolvePackagesDBPath`, `resolveSearchDBPath`) against the regression. **No silent data destruction risk anymore , `--base-dir` is now safe for isolated-test saves.** - **#593 , samples indexer stops corrupting every file path with a leading `/private` prefix.** Caught during main's post-#589/#590 retest sweep. Every row in `samples.db.files.path` was getting a `/private` prefix concatenated without separator (`/privateShared/foo.swift` instead of `Shared/foo.swift`), making `cupertino read-sample-file '<id>' '<path>'` return "File not found" for the (correctly spelled) path the user expects. Root cause was in `Sample.Index.Builder.discoverFiles(...)`: let relativePath = fileURL.path.replacingOccurrences( of: projectRoot.path + "/", with: "" ) `FileManager.default.temporaryDirectory` returned `/var/folders/.../tmp.XXX/` (the unresolved form), but the file enumerator yielded the symlink-resolved `/private/var/folders/.../tmp.XXX/Shared/foo.swift` (because macOS's `/var` is a symlink to `/private/var`). `replacingOccurrences` found the strip-prefix as a substring starting at index 8 inside the resolved path and removed it, leaving the leading `/private` intact. Net: `/private` + `Shared/foo.swift`. Pre-existing bug; affected every `cupertino save --samples` since the indexer was added. Fix: new `Sample.Index.Builder.relativePath(of:under:)` helper resolves both URLs through their symlinks and computes the relative path via `pathComponents` math rather than substring stripping. 5 new unit tests (temp-dir-no-prefix, nested-segments, file-at-root, unresolved-URL-form, file-outside-root fallback) pin the contract on macOS where `$TMPDIR` exercises the `/var → /private/var` resolution discrepancy. `samples.db` ships inside `cupertino-databases-*.zip` as part of `cupertino setup`. Main's 2026-05-15 full-coverage audit found **18,911 of 18,928 rows (99.9%) carry the `/private` prefix corruption** in the v1.0.2 and v1.1.0 shipped bundles. Users re-run `cupertino setup` to pull the bundle re-tag , main re-published both v1.0.2 and v1.1.0 release assets with a re-indexed `samples.db` built by the post-fix indexer. Users who built a samples.db locally with `cupertino save --samples` before this PR can re-run `cupertino save --samples --clear` instead. Full 1732-test suite green at merge. ### Added - **#587 , `cupertino read` and MCP `read_document` accept canonical Apple Developer web URLs.** Pre-fix, pasting `https://developer.apple.com/documentation/swiftui/view` into either transport returned `Document not found in search.db` (CLI exit 1, MCP invalid-argument). Users (and AI agents) hitting cupertino for the first time routinely paste the URL form they copied from the browser; rejecting it forced them to learn the `apple-docs://...` URI convention before they could do anything useful. Fix: both entry points now run an identifier normalisation step at the top that converts canonical Apple Developer web URLs to the lossless `apple-docs://<framework>/<rest-of-path>` URI (using the helper from PR #589 / #588), then falls into the existing dispatch unchanged. Non-Apple URLs (`https://github.com/...`, `https://example.com/...`) pass through; the per-source backends reject them as before. 20 new normalisation tests (13 `Services.ReadService.normalizeIdentifier`, 7 `CompositeToolProvider.normalizeReadDocumentURI`) pin the input contract , canonical Apple URL → URI, mixed case lowercases, query/fragment stripped, framework-root URL → framework-only URI, every other shape passes through verbatim. Full 1727-test suite green. - **#588 import diligence , `cupertino save` now refuses to ship a corpus that has collisions, redundancy, or content loss.** Six coordinated changes land the lossless URI / door-equivalence / garbage-filter pipeline that closes the data-loss class behind BUG 1 of main's 2026-05-15 real-life test report. Tracking issue #588, PR #589. - **Lossless URI helper (`Shared.Models.URLUtilities.appleDocsURI(from:)`).** Replaces the post-#293 `URLUtilities.filename(from:)`-based URI shape with a literal path mirror: `apple-docs://<framework>/<rest-of-path-under-/documentation/>`, lowercased + fragment / query stripped + sub-page underscores → dashes per the existing #283 / #285 canonicalisation. Two different Apple URLs always produce two different URIs by construction; the URI is reversible to the source URL via plain string substitution. Adopted at 3 indexer URI-construction sites. 10 unit tests pin the shape. - **Operator-bearing segment preservation (`URLUtilities.normalize`).** The #285 blanket `_→-` rule was over-eager: Apple URL-encodes `>=` / `<=` as `_=`, `[]` as `__`, `->` as `-_`. The audit against `cupertino-docs/docs` found 17 of 20 truly malignant URI clusters traced to this. Fix: detect operator-bearing segments (contain any of `()[]<>=+*/%!&|^~?` or start with `operator`) and skip `_→-` on them. Prose slugs (`integrating_accessibility_into_your_app`) still get the collapse. 11 new tests. - **Placeholder-title pre-INSERT gate (`Search.StrategyHelpers.titleLooksLikePlaceholderError`).** Resolves 2 of the remaining 3 malignant clusters: PDFKit doc with title literally `"Error"` and pages with empty / `"Apple Developer Documentation"` placeholder titles. 20 new parameterised tests. - **Door-equivalence classifier (`Search.StrategyHelpers.classifyDoorEncounter`).** Per-run `[String: SeenURIRecord]` map. Tier A (identical hash) / B (title match) / C (title mismatch) classification, with first-arrived-wins for benign cases and loud `.error` surfacing for tier C. Canonical-title normalisation is deterministic Swift (HTML-entity decode, suffix strip, lowercase, whitespace collapse). 12 new pure-function tests. - **`--dry-run` flag on `cupertino save`.** Runs the full import pipeline against a `$TMPDIR/cupertino-dryrun-<uuid>.db` throwaway DB, emits the final report identical to a real save, deletes the temp DB on exit. New `docs/commands/save/option (--)/dry-run.md` per the docs-mirror rule. - **Save final report breakdown.** `Search.ImportDiligenceBreakdown` substruct on `Search.IndexStats` carries 6 counters (tier A / B / C, HTTP error template, JS-disabled fallback, placeholder title) end-to-end. CLI surfaces them under a `📊 Import diligence (#588)` block; tier-C non-zero prints a `⚠️ work is not done` banner referencing `docs/PRINCIPLES.md` principle 3. - **`docs/PRINCIPLES.md` , six engineering principles** the import + indexer paths now stand on: lossless URIs, collisions at the door, no content lost at the door, no garbage at input, 10x scale headroom (40M-doc design target at 400K today), correctness first. Linked from `CLAUDE.md`. - **Refactor discipline.** No new SPM targets, no new cross-package imports, no closure typealiases, no singletons. `IndexerModels` stays import-free; the breakdown crosses the model seam via a CLI-owned side-channel, not through `Indexer.DocsService.Outcome`. Full 1701-test suite green. ### Changed - **Cleanup: 2 new test warnings introduced by PRs #583 / #584.** Same shape as the PR #575 cleanup round , `try` / `await` keywords on expressions that don't actually need them. Main flagged both in the post-`2678e88` retest report (§ 1.4): - `Packages/Tests/MCP/CoreTests/JSONRPCErrorResponseTests.swift:63` , `get async { await inboundStream }` resolved to a non-suspending access of the `let inboundStream` actor-stored property; Swift warned *"no async operations occur within 'await' expression."* Dropped the `await` (kept the `async` getter since the `Channel.messages` protocol requires it). - `Packages/Tests/SearchToolProviderTests/ReadDocumentFallbackTests.swift:73` , `return (index, { try cleanup() ?? () })` had a `try` over a `try?`-wrapped inner closure that already swallowed any throw; Swift warned *"no calls to throwing functions occur within 'try' expression."* Replaced the double-wrapped closure with a single throwing closure `{ try FileManager.default.removeItem(at: tempDB) }` so the returned `() throws -> Void` actually throws if the cleanup fails (the call site at line ~79 already uses `defer { try? cleanup() }` to swallow). 1673 / 1673 tests still pass. - **Drop 21 redundant `import SharedConstants` self-imports across `Sources/Shared/{Core,Utils,Models,Configuration}/*.swift`.** Every file under those subdirectories is part of the `SharedConstants` SPM target itself (the target's `path:` is `Sources/Shared`, which globs every Swift file in the tree), so opening with `import SharedConstants` triggers a Swift compiler warning per file: `file 'X.swift' is part of module 'SharedConstants'; ignoring import`. Main-Claude's post-`2678e88` retest with stderr capture corrected (per the redirect-order disclosure in its session report) tallied **124 instances of this warning** , the warning fires multiple times per build pass for each of the 21 affected files. Removing the line from every file zeroes that bucket without changing any name resolution (the types are already in scope; this is the same module). `xcrun swift test`: **1673 / 1673** in 232 suites still passes (+0 net). ### Fixed - **#581 (HIGH): MCP spec violation , `tools/call` with invalid arguments now sends a JSON-RPC error frame instead of dropping the response.** Pre-fix, `MCP.Core.Server.handleRequest` had: do { let result = try await routeRequest(request) … } catch let error as ServerError { …send JSON-RPC error frame… } Only `ServerError` was converted to a JSON-RPC error frame. Any other thrown error , notably the `LocalizedError` instances tool handlers like `read_document` raise for bad arguments , fell through, got logged to stderr only, and the client request id **never received a response**. JSON-RPC 2.0 + MCP 2025-06-18 require every id-bound request to receive either a `result` or an `error` object, so clients with id-bound await semantics (Codex CLI, mock-ai-agent's strict variants, hand-rolled probes) hung indefinitely on every invalid-arg call. Affects every spec-compliant MCP client, not just one test harness. - **Fix.** Added a catch-all branch after the existing `ServerError` catch in `handleRequest`. Any thrown error converts to a `JSONRPCError` frame with code `-32602` (invalidParams) and the underlying `LocalizedError.errorDescription` (or `String(describing:)` as fallback) as the human-readable message. The stderr log still fires but the client gets a frame too. - **Test.** New integration test in `Packages/Tests/MCP/CoreTests/JSONRPCErrorResponseTests.swift` drives a real `MCP.Core.Server` through a proper initialize handshake using an in-memory `Transport.Channel` double, then sends a `tools/call` to a `ThrowingToolProvider` whose `callTool` raises a custom `LocalizedError`. Asserts: the server emits a JSON-RPC error frame keyed to the original request id with code `-32602` and the `errorDescription` text preserved verbatim (including the offending URI). Polling on the transport's sent-message buffer matches real async-server timing. - `xcrun swift test`: **1673 / 1673** in 232 suites (was 1672 / 231; +1 new). - **#582: `read_document` tool now falls back through the same filesystem path `resources/read` uses.** Pre-fix, the two had divergent URI-lookup paths: `read_document` ran `Search.Index.getDocumentContent` (direct primary-key lookup) and errored on miss; `resources/read` ran the same DB lookup then fell back to a filesystem read of the crawled JSON / MD under `<outputDirectory>/<framework>/`. Result: for URIs `resources/list` returned (e.g. `apple-docs://accelerate/documentation_accelerate`), the tool said *Document not found* while the resource path returned ~19 KB of content. Affects every MCP client that uses both surfaces (Claude Code, Codex, any spec-compliant agent). Fix: `CompositeToolProvider` accepts an optional `documentResourceProvider: any MCP.Core.ResourceProvider` (the same instance `cupertino serve` already constructs for `resources/read`). When the search-index lookup misses, `handleReadDocument` calls `provider.readResource(uri:)` and returns its first text content block. Back-compat preserved: if the resource provider is nil, the pre-fix `Document not found` error fires. Three regression tests in `Packages/Tests/SearchToolProviderTests/ReadDocumentFallbackTests.swift` (fallback returns resource content, both-miss still throws, nil-provider preserves old error). CLI composition root in `CLIImpl.Command.Serve.swift` wires the existing `DocsResourceProvider` into `CompositeToolProvider` so production paths agree on URI semantics. `xcrun swift test`: **1672 / 1672** in 231 suites (was 1669 / 230; +3 net new). Also retrofits `MockAIAgent` to pick a random URI from `resources/list` each run via a new `pickRandomURI(from:)` helper, replacing the hardcoded `apple-docs://swiftui/documentation_swiftui_view` URI that never matched the shipped bundle's URI shape and was the original trigger for the bug surfacing. - **#293: apple-docs URI scheme no longer drops middle path segments → cross-type symbol collisions fixed.** Pre-fix, the indexer at `Packages/Sources/Search/Strategies/Search.Strategies.AppleDocs.swift:217` constructed `apple-docs://<framework>/<filename>` using `URL.lastPathComponent`, which collapsed every middle URL segment. Two Apple symbols sharing a leaf name across different parent types , e.g. `accelerate/sparsepreconditioner_t/init(rawvalue:)` and `accelerate/quadrature_integrator/init(rawvalue:)` , both wrote to `apple-docs://accelerate/init(rawvalue:)` and the second clobbered the first. Worst case observed: `apple-docs://realitykit/init(_:)` collided across many RealityKit types pre-fix. The sibling call site at line 322 in the same file was already using `URLUtilities.filename(from:)` (full-path-encoded with an 8-byte SHA-256 disambiguator suffix when special chars are present); switching line 217 to match restores per-symbol uniqueness. Two new regression tests in `Packages/Tests/SharedModelsTests/SharedModelsTests.swift` pin the contract: two `accelerate` siblings sharing `init(rawvalue:)` get distinct filenames, and three RealityKit siblings sharing `init(_:)` produce three distinct filenames. The existing `Search.IndexBuilder` integration test was updated to assert the new URI shape (`apple-docs://swiftui/documentation_swiftui_sample`) rather than the old buggy `apple-docs://swiftui/sample`. **No `databaseVersion` bump in this PR** , the shipped v1.0.x / v1.1.x bundles still carry the old (collision-prone) URIs and continue to work read-only against the existing client; new `cupertino save` runs and the eventual v1.2.0 bundle re-publish (#290) pick up the fix. `xcrun swift test`: **1669 / 1669** in 230 suites (was 1667 / 230; +2 net new). - **#280: `cupertino serve` gains `--no-reap` opt-out (and `CUPERTINO_DISABLE_REAPER=1` env-var equivalent) so OpenAI Codex CLI works.** The `ServeReaper` (#242) is exactly right for Claude Desktop / Cursor / hosts that reload their MCP config and orphan the previous server, but it's hostile to clients that spawn a fresh `cupertino serve` per tool call: the new instance kills its predecessor as a stale sibling and the in-flight transport closes (`Transport closed` error on every tool call). Codex CLI hit this on every tool call against Cupertino v1.0.1. New `--no-reap` flag on `serve` (visible in `--help`) plus a `Shared.Constants.EnvVar.disableReaper = "CUPERTINO_DISABLE_REAPER"` env-var lookup let either form short-circuit `ServeReaper.reapSiblings()` at the top of `Save.run()`. Default off; user opts out explicitly per their MCP host's spawning model. Codex configuration snippets in `README.md` and `docs/commands/serve/README.md` updated to include `--no-reap` with a tooltip explaining the trade-off. Three new tests in `Packages/Tests/CLITests/ServeReaperFlagAndEnvTests.swift`: the env-var name pin (Codex's TOML can't tolerate a rename), the default-off flag value, and the explicit `--no-reap` flag parse. `xcrun swift test`: **1667 / 1667** in 230 suites pass (was 1664 / 229). - **#253: `cupertino save` now detects concurrent sibling save processes and prompts before overlapping a same-DB write.** New `SaveSiblingGate` in `Packages/Sources/CLI/Commands/SaveSiblingGate.swift` runs at the very top of `Save.run()`, before any preflight or write. Architecture mirrors `ServeReaper` (#242) , sysctl `KERN_PROCARGS2` for sibling argv, `proc_pidpath` + `realpath` for the resolved binary path. Pure scope: detect + prompt; no reap authority (a `save` in flight may represent many hours of work, and killing it without consent is dangerous enough to deserve its own follow-up PR with `--force-replace` + typed-confirmation gate). - **Target detection from argv.** New `SaveSiblingGate.Target` enum (`.search` / `.packages` / `.samples`) with `dbFilename` accessor. `parseSaveTargets(argv:)` is a pure function that mirrors `Save.run`'s scope-flag defaulting: no scope flag → all three; explicit `--docs` / `--packages` / `--samples` narrow the set. Empty set when `save` doesn't appear in argv at all. - **Conflict resolution.** A sibling whose targets intersect ours is a real overlap. Different-DB siblings (e.g. our `--docs` next to their `--samples`) log one informational line and proceed , that's a legitimate workflow. - **TTY-aware prompt.** Same-DB overlap on a TTY → `[c] continue anyway / [w] wait for it to finish, then start fresh / [a] abort`. The `[w]` branch polls `kill(pid, 0)` every 5s with a heartbeat log line, then proceeds. Non-interactive callers (CI, scripts) get a non-zero exit and a clear error message instead of silently doubling up. - **Deferred to a follow-up PR (mentioned in issue body):** `--force-replace` flag with typed-confirmation gate (`type 'replace' to confirm killing PID X with N hours of work`); `--from-setup` suppression for the `setup → save` pipeline. - **18 unit tests across 3 suites** in `Packages/Tests/CLITests/SaveSiblingGateTests.swift`: argv → target-set defaulting (9 cases including unrelated-flag noise, wrapper-script invocation, `dbFilename` round-trip), `parsePsOutput` 3-column / malformed-line / empty handling, and `parseProcargs2` round-trip / short-buffer / `argc=0` sentinel. The detection runtime (`/bin/ps` spawn + sysctl) is left for manual integration testing because the existing pattern in `ServeReaper` does the same. - `xcrun swift test`: **1664 / 1664** in 229 suites (was 1646 / 226; +18 net new). ### Changed - **Cleanup: kill three pre-existing test-side warnings flagged during the post-#574 retest.** All three were latent before the closure-purge epic; bundled here as a hygiene PR after main flagged them at 06:10Z. - `Packages/Tests/CoreSampleCodeTests/SampleCodeDownloaderTests.swift:249` , `let downloader = Sample.Core.Downloader(...)` was unused; the four earlier init-only smoke tests in the same file follow the `_ = downloader` discard pattern but this one (a path-pattern check that doesn't read state off the instance) dropped the discard. Switched to `_ = Sample.Core.Downloader(...)` directly so the init-doesn't-crash side-effect is preserved without the unused-let warning. - `Packages/Tests/CLICommandTests/ServeTests/ServeTests.swift:147 + 197` , `.serialized` trait on two non-parameterized `@Test` declarations was redundant with the enclosing `@Suite("MCP Command Tests", .serialized)` (which already serialises every test in the suite). Swift Testing emits a "no-effect" warning when `.serialized` appears on a non-param `@Test` inside an already-serialised suite. Dropped from both. - `Packages/Tests/CLITests/CLITests.swift:303` , `await Cupertino.Context.$composition.withValue(scoped) { ... }` resolved to the synchronous overload of `TaskLocal.withValue` (closure body is `#expect(...)`, sync), so the `await` keyword had no effect. Dropped the `await` and made the test function non-async to match the resolved overload. The SE-0311 scoped-binding behaviour the test pins is unchanged. - **Task #23: move `CLIImpl` namespace anchor to the root of its target's Sources dir.** Audit across all 19 top-level namespace anchors (`Core`, `Services`, `Diagnostics`, `Search`, `Indexer`, `Logging`, `Cleanup`, `ASTIndexer`, `Distribution`, `Shared`, `Sample`, `RemoteSync`, `MCP`, `Resources`, `Crawler`, `CLIImpl`, `Ingest`, `Availability`) found exactly one outlier: `CLIImpl` lived at `Packages/Sources/CLI/Commands/CLIImpl.swift` (nested under the `Commands/` subdirectory) instead of at the target root. `git mv` to `Packages/Sources/CLI/CLIImpl.swift` matches every other namespace anchor's placement (one file at the Sources root, holding only the `public enum X {}` declaration + namespace doc comment). No code changes; no public surface change. `xcrun swift test`: **1646 / 1646** in 226 suites still passes. - **Convert `Sample.Core.Downloader.download(onProgress:)` closure to GoF Observer protocol.** Symmetric follow-up to #567's `Sample.Core.GitHubFetcher` conversion. New `Sample.Core.DownloaderProgressObserving` Observer protocol added to the existing `CoreSampleCodeModels` seam target; producer signature swaps from `onProgress: (@Sendable (Sample.Core.Progress) -> Void)?` to `progress: (any Sample.Core.DownloaderProgressObserving)?`. The progress payload `Sample.Core.Progress` and statistics value `Sample.Core.Statistics` move (via `git mv` to preserve history) from `CoreSampleCode` producer into `CoreSampleCodeModels` , both were already foundation-only value types (`Foundation` + `SharedConstants` only). `Sample.Core.Progress` gains an explicit `public init(current:total:sampleName:stats:)` because Swift's synthesised memberwise init had defaulted to internal access and stops being callable across module boundaries after the move. CLI's `runCodeFetch` swaps its trailing closure for a named `DownloaderProgressObserver` struct conformer. Three new smoke-test suites in `CoreSampleCodeModelsTests` pin the value types (`Statistics` defaults / duration math, `Progress` memberwise + percentage) and the `DownloaderProgressObserving` protocol witness (noop conformer + collecting observer). After this PR **zero `@Sendable (X) -> Void` closure parameters remain** in producer-target public APIs across cupertino. `xcrun swift test`: **1644 / 1644** in 226 suites pass. - **`Crawler.AppleDocs.State.updateStatistics(_:)` closure replaced with seven named actor methods.** Closes the last `@Sendable (inout …) -> Void` closure parameter on a producer-target public API. The previous shape gave every caller a generic mutation escape hatch into the actor's internal `Shared.Models.CrawlStatistics` value; the new shape exposes one method per mutation the crawler actually performs: - `setStatistics(_ stats: Shared.Models.CrawlStatistics)` , whole-value replace, used at the start of a fresh session. - `setStartTimeIfNil(_ startTime: Date)` , conditional set, used on session resume to adopt the saved start only if no startTime is already recorded. - `recordError()` / `recordTotalPage()` / `recordSkippedPage()` / `recordNewPage()` / `recordUpdatedPage()` , atomic counter increments. Each method has one effect; pairs that previously appeared in one closure body (`errors+= 1; totalPages += 1`) are now two named calls at the call site, preserving identical end-state semantics. - **14 call sites** in `Crawler.AppleDocs.swift` migrated. Closure form deleted; `Crawler.AppleDocs.State updateStatistics modifies stats` test rewritten to drive the same end-state through the named methods, plus two new tests pinning `setStatistics(_:)` and the conditional `setStartTimeIfNil(_:)` behaviour. - `xcrun swift test`: **1641 / 1641** in 224 suites (was 1639). ### Fixed - **#568: `MCP.Support.DocsResourceProvider.listResources` was returning every page in the corpus as a separate resource.** On the v1.2.0-staged tree the call produced 55,915 resources (11.1 MB JSON response) including SwiftSyntax-extracted symbol identifiers like `Anonymous Field0` / `Init(Raw…)` / `Readattr…`; on v1.1.0 the same bug was masked by a silent `getMetadata()` swallow that dropped the apple-docs slice to 1 entry. Three changes in `Packages/Sources/MCP/Support/MCP.Support.DocsResourceProvider.swift`: - **Framework-root filter.** New private `isFrameworkRootPage(url:framework:)` predicate: a page only becomes an apple-docs resource when its URL path is exactly `/documentation/<framework>` (case-insensitive on the framework segment, trailing slash tolerated). Deep symbol pages stay reachable through `tools/call search` + `readResource`; they no longer pollute the resource browser. - **Cursor pagination.** New `public static let pageSize: Int = 500` cap on the page size, plus a `paginate(_:offset:)` helper and `encodeOffset` / `decodeOffset` cursor codec (base64-wrapped `offset:N`, opaque to clients). The MCP `cursor: String?` parameter is now honoured: the first call returns up to 500 sorted resources + a `nextCursor` when more remain; the cursor passed back returns the next slice. Invalid / unparseable cursors silently fall back to the first page rather than throwing. - **Loud failures on metadata load.** Both miss-paths in `loadMetadata()` (file absent, JSON parse failure) and the outer catch in `listResources` now log at `.error` level with the full path and underlying error. The previous `.warning` + silent-empty combination was exactly how v1.1.0 hid the apple-docs slice; failures are now visible to `cupertino doctor` and ad-hoc log grep without breaking the "evolution + archive still work when the docs corpus is absent" UX. - **Regression test.** `Packages/Tests/MCP/SupportTests/DocsResourceProviderListResourcesFilterAndPagingTests.swift` , 7 tests across one suite: framework-root keeps / deep-page drops, trailing-slash tolerance, case-insensitive framework match, **a 60,000-page synthetic corpus collapses to ≤ `pageSize` apple-docs resources** (the contract that pins this regression shut), pageSize+50 → first page yields pageSize+nextCursor / second page yields 50+nil, garbage cursor falls back to first page. - **Existing tests updated.** `MCP.Support.DocsResourceProviderTests.swift` `appleDocsEntryShape` + `sortedByName` and `DocsResourceProviderMalformedURLSkipTests.swift` were using deep-page URLs (`…/swiftui/list`) as their "good" fixture rows; those URLs are now correctly dropped. Switched fixtures to framework-root URLs (`/documentation/swiftui`, plus `/documentation/foundation` and `/documentation/accelerate` for the sort test) so each test exercises the post-filter resource the way the production code now produces it. Pass count post-fix: 30 / 30 in 8 DocsResourceProvider suites; full suite 1639 / 1639 in 224 suites (was 1632 / 223). ### Added - **Extract `RemoteSyncModels` foundation-only seam target. Convert `RemoteSync.Indexer.run`'s three closure parameters to GoF Strategy + Observer protocols.** New `RemoteSyncModels` target (deps: `SharedConstants`) owns the `RemoteSync` namespace anchor (Pattern A, moved here from the producer) and the value types + protocols that drive the remote indexing pipeline. The closure-purge converts three seams in one batch: - `indexDocument: @escaping DocumentIndexer` (typealias) → `documentIndexing: any RemoteSync.DocumentIndexing` (GoF Strategy 1994 p. 315). The protocol carries one `indexDocument(uri:source:framework:title:content:jsonData:) async throws` method that the CLI binds to `Search.Index.indexDocument` through a private `SearchIndexDocumentIndexer` struct conformer. - `onProgress: @escaping @Sendable (RemoteSync.Progress) -> Void` → `progress: any RemoteSync.IndexerProgressObserving` (GoF Observer 1994 p. 293). The CLI's `RemoteProgressObserver` forwards each tick to `RemoteSync.ProgressReporter`. - `onDocument: ((IndexResult) -> Void)?` → `document: (any RemoteSync.IndexerDocumentObserving)?`. The CLI's `RemoteDocumentObserver` bumps lock-protected success / error counters on a shared `StatsTracker`. - **Renames in the seam** (flat-named because the producer `RemoteSync.Indexer` is a `public actor` and the seam target can't extend it from outside): - `RemoteSync.Indexer.IndexResult` → `RemoteSync.IndexerResult` - `RemoteSync.Indexer.Error` → `RemoteSync.IndexerError` - **Producer files moved verbatim to the seam target** (no behaviour changes): `RemoteSync.swift` (namespace + version + GitHub constants), `RemoteSync.Progress.swift`, `RemoteSync.IndexState.swift`, plus the two renamed flat-named files. `RemoteSync` producer adds `@_exported import RemoteSyncModels` so existing callers reading `RemoteSync.Indexer` / `RemoteSync.Progress` / `RemoteSync.IndexState` via `import RemoteSync` keep resolving without source changes. - **`Packages/Tests/RemoteSyncTests/RemoteSyncTests.swift`** updated: `IndexResult` / `Error` references swap to `IndexerResult` / `IndexerError`. - **`Packages/Tests/RemoteSyncModelsTests/RemoteSyncModelsTests.swift`** (new, written in the SAME commit as the target declaration per the empty-test-dir lesson from #564): 9 suites covering namespace constants, `Progress` round-trip + ETA helper, `IndexState` defaults / phase transitions / `Phase.allCases` order, `IndexerResult` success / failure, `IndexerError` descriptions, and `DocumentIndexing` / `IndexerProgressObserving` / `IndexerDocumentObserving` protocol-witness conformance. - **`scripts/check-target-foundation-only.sh`** adds `RemoteSyncModels` to `MODELS_TARGETS` and `STRICT_PRODUCERS`. **28 strict producers** now (was 27). - **`docs/package-import-contract.md`**: new Models-tier row for `RemoteSyncModels`, `RemoteSync` producer row updated to show the new dep. - **Extract `CoreSampleCodeModels` foundation-only seam target. Convert `Sample.Core.GitHubFetcher.fetch(onProgress:)` closure to GoF Observer protocol.** New `CoreSampleCodeModels` target (deps: `SharedConstants`) extends the `Sample.Core` namespace (owned by `SharedConstants`) to carry the `Sample.Core.GitHubFetcherProgress` Sendable value type + the `Sample.Core.GitHubFetcherProgressObserving` Observer protocol (1994 GoF p. 293). Renamed from `Sample.Core.FetchProgress` so the type-name carries the producer it belongs to, matching the `Crawler.AppleDocsProgress` / `Sample.Cleanup.CleanerProgress` pattern. Flat-named because the producer `Sample.Core.GitHubFetcher` is a `public final class` (can't be extended from outside, so the protocol can't sit as `GitHubFetcher.ProgressObserving`). `CoreSampleCode` producer now `import CoreSampleCodeModels`; `fetch(onProgress:)` signature swaps to `fetch(progress: (any Sample.Core.GitHubFetcherProgressObserving)?)`. The `FetchProgress` struct is deleted from the producer (it lives in the seam now). CLI's `runSamplesFetch` replaces the trailing closure with a named `GitHubFetcherProgressObserver` struct conformer. Smoke-test file written in the SAME commit as the target declaration per the new "every test-target dir must have at least one .swift file" rule. **27 strict producers** now (was 26). - **New target `CoreSampleCodeModels`** (deps: `SharedConstants`). 1 file: `Sample.Core.GitHubFetcher.Progress.swift` carrying the renamed `GitHubFetcherProgress` struct + the `GitHubFetcherProgressObserving` protocol. - **Smoke tests** (`Packages/Tests/CoreSampleCodeModelsTests/CoreSampleCodeModelsTests.swift`): 3 suites , namespace-anchor reachability, `GitHubFetcherProgress` value-type round-trip, `GitHubFetcherProgressObserving` protocol witness with a `CollectingObserver` integration test. - **`scripts/check-target-foundation-only.sh`** adds `CoreSampleCodeModels` to `MODELS_TARGETS` (foundation-tier allow-list) and `STRICT_PRODUCERS` (foundation-only enforcement). 27 strict producers total. - **`docs/package-import-contract.md`** updated: new Models-tier row for `CoreSampleCodeModels`, `CoreSampleCode` producer row updated to show the new dep. - **Extract `CleanupModels` foundation-only seam target. Convert `Sample.Cleanup.Cleaner.cleanup(onProgress:)` closure to GoF Observer protocol.** New `CleanupModels` target (deps: `SharedConstants`) carries the `Sample.Cleanup.CleanerProgressObserving` protocol. Payload is `Shared.Models.CleanupProgress` (already in foundation-tier `SharedConstants`); the protocol is flat-named because the producer `Sample.Cleanup.Cleaner` is an actor. `cleanup(onProgress:)` signature swaps to `cleanup(progress:)`. Internal call site `onProgress?(...)` → `progress?.observe(progress: ...)`. CLI's `CLIImpl.Command.Cleanup` replaces the trailing closure with a named `CleanupProgressObserver` struct conformer. **26 strict producers** now (was 25). Smoke-test file written in the SAME commit as the target declaration per the new "every test-target dir must have at least one .swift file" rule. - **Fix: stub smoke tests for `IndexerModelsTests` + `DistributionModelsTests` empty test directories.** PR #558 and PR #563 each declared a new `*ModelsTests` test target in `Packages/Package.swift` and created the corresponding `Packages/Tests/<Name>/` directory but left it empty. `xcrun swift build` (Swift 6.3.1 via Xcode 26) tolerates an empty test-target source dir; `swift package describe` / `swift build` on Swift 6.2 stable does not , so the v1.2.0-staging merge to `main` (PR #562) bricked main's build on the canonical toolchain even though the producer-side refactor was clean. The fix: two ~190-line smoke-test files (`IndexerModelsTests.swift`, `DistributionModelsTests.swift`) pinning the namespace anchors, `Request`/`Outcome`/`Event`/`Status`/`SetupError`/`Progress` value-type round-trips, and `EventObserving` / `ProgressObserving` / `TickObserving` protocol witness conformance. `xcrun swift test` now passes **1609 / 1609** in 210 suites (was 1575 / 199). Note for future: a follow-on chore should add `release/*` branch triggers to the GitHub Actions workflow so the next release-branch merge is gated by CI (the other Claude flagged `gh pr checks 562` reported `no checks reported on the 'release/v1.2.0' branch`). - **Extract `DistributionModels` foundation-only seam target. Convert `Distribution.SetupService.run(handler:)` + `ArtifactDownloader.download(onProgress:)` + `ArtifactExtractor.extract(tickHandler:)` closures to GoF Observer protocols.** Same Pattern-A namespace-in-seam shape as `IndexerModels` (#558). The Distribution producer types are `public enum` namespaces (not actor classes), so the seam owns proper nested types , no flat-naming needed. - **New target `DistributionModels`** (deps: `SharedConstants`). 6 files: `Distribution.swift` namespace anchor + `Distribution.ArtifactDownloader.swift` (Progress + ProgressObserving) + `Distribution.ArtifactExtractor.swift` (TickObserving) + `Distribution.SetupService.swift` (Request + Outcome + Event + EventObserving) + `Distribution.SetupError.swift` (moved from producer) + `Distribution.InstalledVersion.swift` (Status enum moved from producer). - **Producer files** (`Distribution.swift`, `Distribution.ArtifactDownloader.swift`, `Distribution.ArtifactExtractor.swift`, `Distribution.SetupService.swift`, `Distribution.InstalledVersion.swift`): drop the namespace declaration + duplicate type defs, add `@_exported import DistributionModels`, swap signatures to take Observer protocols. `Distribution.SetupError.swift` deleted (moved to seam). The orchestrator now uses two private inner adapter structs (`DownloadProgressForwarder`, `ExtractTickForwarder`) that bridge from `ProgressObserving` / `TickObserving` to `SetupService.EventObserving` events. - **CLI's `Setup.swift`** replaces the trailing closure with a named `SetupEventObserver` struct conformer that forwards each event to the existing `SetupRenderer.handle(_:)` dispatcher. - **Tests** (`BackupTests.swift`): trailing-closure call sites swapped for named `CollectingObserver` + `NoopObserver` struct conformers. - **CI + docs**: `scripts/check-target-foundation-only.sh` adds `DistributionModels` to `MODELS_TARGETS` + `STRICT_PRODUCERS` (25 strict producers now, was 24). `docs/package-import-contract.md` gains the Models-tier row + Distribution producer row reflects the new dep. - **Kill `Core.PackageIndexing.PackageFetcher.fetch(onProgress:)` + `Core.PackageIndexing.PackageDependencyResolver.resolve(onProgress:)` closure parameters. Move `Statistics` + `Progress` to `CorePackageIndexingModels` seam with GoF Observer protocols.** Two producer-target actors, same pattern as the crawler batch. - **Seam target `CorePackageIndexingModels` gains 2 new files**: `Core.PackageIndexing.PackageFetcher.Progress.swift` (carries `Core.PackageIndexing.PackageFetcherStatistics` + `PackageFetcherProgress` + `PackageFetcherProgressObserving`) and `Core.PackageIndexing.PackageDependencyResolver.Progress.swift` (just `PackageDependencyResolverProgressObserving` , payload is primitive `(String, Int, Int)`, no seam-side struct needed). Flat naming convention because `PackageFetcher` and `PackageDependencyResolver` are `public actor` declarations in the producer target. - **Producer files** (`Core.PackageIndexing.PackageFetcher.swift`, `Core.PackageIndexing.PackageDependencyResolver.swift`): delete the previously-nested `Statistics` + `Progress` structs (PackageFetcher only , PackageDependencyResolver had no Progress struct), swap `fetch(onProgress:)` / `resolve(onProgress:)` signatures to take `progress: (any …ProgressObserving)?`, swap `onProgress?(value)` → `progress?.observe(progress: …)` / `progress?.observe(packageName:processed:total:)`. - **CLI's `Fetch.swift`**: 2 trailing-closure call sites (one for fetcher.fetch, one for resolver.resolve) replaced with 2 named struct conformers (`PackageFetcherProgressObserver`, `PackageDependencyResolverProgressObserver`). Resolver observer preserves the original throttled-output rule (every 1, 10, total). - **Tests** (`PackageFetcherTests.swift`): mechanical rename of test references from nested `Core.PackageIndexing.PackageFetcher.Statistics` / `Progress` to flat `Core.PackageIndexing.PackageFetcherStatistics` / `PackageFetcherProgress`. - **Kill the four `Crawler.<Type>.crawl(onProgress:)` closure parameters. Move `Progress` + `Statistics` to `CrawlerModels` seam target with GoF Observer protocols.** Four crawlers, parallel pattern. Each crawler's `onProgress: (@Sendable (Progress) -> Void)?` closure parameter is gone; each takes `progress: (any Crawler.<X>ProgressObserving)?` (a Sendable Observer protocol) instead. - **Seam target `CrawlerModels` gains 4 new files**: `Crawler.AppleDocs.Progress.swift`, `Crawler.AppleArchive.Progress.swift`, `Crawler.Evolution.Progress.swift`, `Crawler.HIG.Progress.swift`. Each carries the flat-named `Crawler.<X>Progress` value type, the `Crawler.<X>Statistics` value type (where the crawler had a nested one , AppleArchive, Evolution, HIG; AppleDocs uses `Shared.Models.CrawlStatistics` already foundation-tier), and the `Crawler.<X>ProgressObserving` Observer protocol. Flat naming (`AppleDocsProgress` not `AppleDocs.Progress`) because the producer-target `Crawler.<X>` types are `public final class` actors , the seam can't extend them. - **Producer files** (`Crawler.AppleDocs.swift`, `Crawler.AppleArchive.swift`, `Crawler.Evolution.swift`, `Crawler.HIG.swift`): delete the previously-nested `Progress` and `Statistics` struct definitions, swap `crawl(onProgress:)` → `crawl(progress: any Crawler.<X>ProgressObserving)`, swap `onProgress?(progress)` → `observer.observe(progress: progressValue)`, rename references like `Statistics` → `Crawler.<X>Statistics` and `Progress` → `Crawler.<X>Progress` throughout. - **CLI's `Fetch.swift`**: 4 trailing-closure call sites replaced with 4 named struct conformers (`AppleDocsCrawlProgressObserver`, `EvolutionCrawlProgressObserver`, `AppleArchiveCrawlProgressObserver`, `HIGCrawlProgressObserver`). Each holds an `any LoggingModels.Logging.Recording` and prints the same progress-line format the closure body did. - **Tests** (`CrawlerTests.swift`, `SwiftEvolutionCrawlerTests.swift`): mechanical rename of test references from `Crawler.<X>.Progress` / `Crawler.<X>.Statistics` (nested) to `Crawler.<X>Progress` / `Crawler.<X>Statistics` (flat). - **Kill `Search.PackageIndexer.indexAll(onProgress:)` closure parameter.** Last closure-shaped public API on any orchestrator-layer Live runner. `indexAll` now takes `progress: (any Search.PackageIndexingProgressReporting)?` (the Observer protocol shipped in PR #557). `LivePackageIndexingRunner.run` in CLI's `Save.Indexers.swift` drops its inner closure adapter and passes the incoming `progress` Observer straight through. **All three Live`<Service>`IndexingRunner conformers are now closure-free** end-to-end from `Indexer.<Service>.run(events:)` → `<Service>IndexingRunner.run(progress:/phaseObserver:)` → producer's own `indexAll(progress:)`. - **Extract `IndexerModels` foundation-only seam target. Convert the three `Indexer.*Service.run(handler:)` closure params to typed GoF Observer protocols.** Strict package-decoupling answer: the value types (`Request`, `Outcome`, `Event`, `Phase`, `ServiceError`) and the new `*Service.EventObserving` Observer protocols all live in a new foundation-only `IndexerModels` target. The `Indexer` producer target now extends those seam-defined enums to add the concrete `static func run(...)` orchestrators. Pattern A namespace anchor (matches Search/SearchModels and Sample/SampleIndexModels). Any test conformer of an `EventObserving` protocol needs only `import IndexerModels` , no producer-target dependency. - **New target `IndexerModels`** (zero local dependencies). 4 files: `Indexer.swift` namespace anchor + 3 service-models files (`Indexer.DocsService.swift`, `Indexer.PackagesService.swift`, `Indexer.SamplesService.swift`), each carrying the Request/Outcome/Event types plus the EventObserving protocol. - **`Indexer` producer** now `@_exported import IndexerModels` (so existing callers reading `Indexer.*Service.Request` via `import Indexer` still resolve). Drops the duplicate `public enum Indexer {}` declaration; the seam owns the namespace anchor. - **`Indexer.DocsService.run` / `Indexer.PackagesService.run` / `Indexer.SamplesService.run`** signatures swap from `handler: @escaping @Sendable (Event) -> Void` to `events: any EventObserving`. The body emits via `events.observe(event: .X)` instead of `handler(.X)`. Internal `HandlerProgressReporter` / `HandlerPhaseObserver` adapter structs from PR #557 renamed to `EventsToProgressReporter` / `EventsToPhaseObserver` and now bridge Observer-to-Observer rather than closure-to-Observer. - **CLI's `Save.Indexers.swift`** replaces three trailing closures with three named struct conformers: `DocsEventObserver(tracker:)`, `PackagesEventObserver()`, `SamplesEventObserver(tracker:)`. Each delegates to the existing `handleDocsEvent` / `handlePackagesEvent` / `handleSamplesEvent` static dispatchers; zero closures involved. - **`scripts/check-target-foundation-only.sh`** adds `IndexerModels` to `MODELS_TARGETS` (foundation-tier allow-list) and `STRICT_PRODUCERS` (foundation-only enforcement). `scripts/check-target-portability.sh IndexerModels` passes. - **`docs/package-import-contract.md`** updated: new Models-tier row for `IndexerModels`, Indexer producer row updated to show the new dep. - **Convert the three `*Runner.run` protocol methods to take Observer protocols instead of closures.** Pushes the GoF Observer pattern up one layer of the call stack. Before this change, `Search.DocsIndexingRunner.run`, `Search.PackageIndexingRunner.run`, and `Sample.Index.SamplesIndexingRunner.run` each took `@escaping @Sendable (...) -> Void` closure parameters with explicit doc-comment carve-outs reading "the progress callback stays a closure , it's a genuine callback, not a strategy seam." Those documented carve-outs are now reversed per the standing cupertino rule "no closures, they ate magic." - **New `Search.PackageIndexingProgressReporting`** protocol in `SearchModels` (foundation-only seam). One method: `func report(packageName: String, processed: Int, total: Int)`. - **New `Sample.Index.SamplesIndexingPhaseObserving`** protocol in `SampleIndexModels` (foundation-only seam). One method: `func observe(phase: Sample.Index.SamplesIndexingPhase)`. - **`Search.DocsIndexingRunner.run(input:onProgress:)`** signature swaps to `run(input:progress: any Search.IndexingProgressReporting)` (reusing the protocol shipped in PR #556). - **`Search.PackageIndexingRunner.run(packagesRoot:packagesDB:onProgress:)`** swaps to `(packagesRoot:packagesDB:progress: any Search.PackageIndexingProgressReporting)`. - **`Sample.Index.SamplesIndexingRunner.run(input:onPhase:)`** swaps to `(input:phaseObserver: any Sample.Index.SamplesIndexingPhaseObserving)`. - **`Indexer.DocsService.run`, `Indexer.PackagesService.run`, `Indexer.SamplesService.run`** still take their closure-shaped `handler:` parameters from outside. Each gains a private nested adapter struct (`HandlerProgressReporter` / `HandlerPhaseObserver`) that conforms to the matching Observer protocol and forwards `report(...)` / `observe(...)` calls into the `handler` closure. The closure-to-protocol bridge moves from CLI (where it lived after #556) up into the Indexer orchestrator layer. - **`LiveDocsIndexingRunner`, `LivePackageIndexingRunner`, `LiveSamplesIndexingRunner`** in CLI's `Save.Indexers.swift` updated to the new signatures. `LiveDocsIndexingRunner` is now closure-free (passes `progress` straight through to `Search.IndexBuilder.buildIndex`). `LivePackageIndexingRunner` retains an inner closure adapter at the still-closure-shaped `Search.PackageIndexer.indexAll(onProgress:)` boundary, which is the next conversion target. `LiveSamplesIndexingRunner` gains a `PhaseObserverToProgressReporter` inner adapter that translates `Sample.Index.IndexProgress` events into `.projectProgress` phase events. The CLI's two adapter structs from #556 (`ProgressCallbackToReporter`, `SamplesProgressReporter`) are deleted , their work is now done by the new Indexer-layer adapters. - **Convert `Sample.Index.Builder.ProgressCallback` + `Search.IndexingProgressCallback` closure typealiases to GoF Observer protocols.** Two named closure typealiases were the last "we elevated this closure to a contract but never made it a protocol" cases in the producer layer. Replaced with typed protocols, both living in their respective foundation-only seam targets so any conformer can implement them with seam imports only (no dependency on the producer target). - **`Sample.Index.IndexProgress` value type moved** from being nested inside `Sample.Index.Builder` (producer) to `Sample.Index.IndexProgress` in `SampleIndexModels` (foundation-only seam). `Sample.Index.Builder.IndexProgress` is now a typealias for the seam-target type, so existing call sites compile unchanged. - **`Sample.Index.ProgressReporting` protocol** added to `SampleIndexModels`. Sendable, single `report(progress:)` method. Strict GoF Observer (1994 p. 293): the abstraction is reachable without the subject. - **`Search.IndexingProgressReporting` protocol** added to `SearchModels`. Sendable, single `report(processed:total:)` method. Payload is primitive `Int`, no value-type seam-side struct needed. - **`Sample.Index.Builder.indexAll(progress:)`** signature swapped to `(any Sample.Index.ProgressReporting)?`. Internal 6 invocations rewritten from `progress?(value)` to `progress?.report(progress: value)`. - **`Search.SourceIndexingStrategy.indexItems(into:progress:)` + `Search.IndexBuilder.buildIndex(clearExisting:onProgress:)` + `Search.Index.indexItems(_:extractSymbols:progress:)`** all swapped to `(any Search.IndexingProgressReporting)?`. The 6 concrete strategy structs (`SwiftEvolution`, `SwiftPackages`, `HIG`, `SampleCode`, `AppleArchive`, `AppleDocs`, `SwiftOrg`) updated to use the protocol type + `progress?.report(processed:total:)` call form. - **Two named CLI-side adapters** bridge the still-closure-shaped `Search.DocsIndexingRunner.run(onProgress:)` and `Sample.Index.SamplesIndexingRunner.run(onPhase:)` protocol methods to the new `Recording` and `Reporting` Observer protocols: `ProgressCallbackToReporter` (in `LiveDocsIndexingRunner`) and `SamplesProgressReporter` (in `LiveSamplesIndexingRunner`). These adapters are the only place the closure-to-protocol bridge lives; the `Runner.run` protocol surfaces themselves still take a closure, future work converts those. - **Deleted**: `Sample.Index.Builder.ProgressCallback` typealias, `Search.IndexingProgressCallback` typealias. - **`Logging.Unified.shared` Singleton deleted. `Logging.LiveRecording()` no-arg shim deleted. Epic #548 closed (Phase H).** The last `.shared` accessor in cupertino's own production code is gone. Zero functional callers of `Logging.LiveRecording()` or `Logging.Unified.shared` remain in production sources (verified by `grep -rn` returning only doc-comment historical references). `Logging.Unified.init(options:)` is now the only construction path , every consumer receives the actor via constructor injection through `Logging.Composition` at the binary's composition root. Removed: `public static let shared = Unified()` on `Logging.Unified`, the no-arg `public init() { unified = .shared }` on `Logging.LiveRecording`, the two `LoggingPublicSurfaceTests` tests (`unifiedSharedConfigurable` and `liveRecordingNoArgShim`) that verified the legacy shapes, and stale doc-comment references to "transitional" / "shim" / "Phase H deletes…" wording across `Logging.Unified.swift` / `Logging.LiveRecording.swift` / `Logging.Composition.swift`. Test count: 1575 / 1575 (was 1577; −2 from the deleted shim tests). **End-state evidence:** - 0 callable `Logging.Unified.shared` references in `Packages/Sources/`. - 0 callable `Logging.LiveRecording()` references in `Packages/Sources/`. - `cupertino` binary smoke-tests clean: `--version` → `1.1.0`, `list-frameworks` → 420 frameworks, `doctor` → full output. - All three CI guards green: `check-package-purity`, `check-target-foundation-only`, `check-target-portability` for every producer. **Closes #548.** GoF + Seemann pure DI in the cupertino codebase: no Singletons, every collaborator via constructor injection, every producer SPM target lifts out standalone, the composition root is the only place that wires the binary's dependency graph. - **Remaining 7 CLI files migrated to `Cupertino.Context.composition.logging.recording` (#548 Phase F).** 39 inline `Logging.LiveRecording()` call sites swapped across `CLIImpl.Command.Search.SmartReport.swift` (9), `CLIImpl.Command.ReadSampleFile.swift` (9), `CLIImpl.Command.Read.swift` (8), `CLIImpl.Command.PackageSearch.swift` (5), `CLIImpl.Command.ListFrameworks.swift` (3), `SearchModuleAlias.swift` (3), `CLIImpl.Command.Search.swift` (2). Pre-flight per the actor-identity audit caught zero stray `Logging.Unified.shared` callable references in any target file. **The `cupertino` binary now has zero functional callers of `Logging.LiveRecording()` or `Logging.Unified.shared`**: the only remaining `.shared` reference in production sources is `Logging.LiveRecording.swift:53` (the no-arg shim's internal delegation), and that shim has zero callers across the entire CLI source tree. Phase H deletes both. - **`CLIImpl.Command.Search.SourceRunners` + `CLIImpl.Command.ResolveRefs` + `CLIImpl.Command.Serve` migrated to `Cupertino.Context.composition.logging.recording` (#548 Phase E).** 36 inline `Logging.LiveRecording()` call sites swapped (13 each in SourceRunners + ResolveRefs, 10 in Serve). Also fixed a latent bug in `Serve.run()`: `await Logging.Unified.shared.disableConsole()` was muting the wrong actor after Phase B (the composition's `Logging.Unified` is a separate instance from `.shared`); now calls `await Cupertino.Context.composition.logging.disableConsole()` so the MCP JSON-RPC stdout stream actually goes silent on a non-TTY. - **`CLIImpl.Command.Cleanup` + `CLIImpl.Command.Setup` + `CLIImpl.Command.ListSamples` migrated to `Cupertino.Context.composition.logging.recording` (#548 Phase D).** 68 inline `Logging.LiveRecording()` call sites swapped (28 in `Cleanup.swift`, 21 in `Setup.swift`, 19 in `ListSamples.swift`). No producer-side signature changes; no test regressions. - **`CLIImpl.Command.Doctor` + `CLIImpl.Command.ReadSample` migrated to `Cupertino.Context.composition.logging.recording` (#548 Phase C).** 126 inline `Logging.LiveRecording()` call sites swapped to read from the binary-scoped `@TaskLocal` composition (86 in `Doctor.swift`, 40 in `ReadSample.swift`). No producer-side signature changes; no test regressions. Phases D-F handle the remaining 12 CLI command files (140 sites total). - **`Cupertino.Composition` Mediator + `@TaskLocal` binding inside `Cupertino.main()` (#548 Phase B).** The `cupertino` CLI binary now builds one `Cupertino.Composition` (Mediator, GoF p. 273) at the entry point and binds it via SE-0311 `@TaskLocal` for the lifetime of the program. The composition owns `Logging.Composition` + `Shared.Paths`. Subcommand `run()` bodies read `Cupertino.Context.composition.logging.recording` instead of constructing `Logging.LiveRecording()` inline; the @TaskLocal is structurally scoped (not a Singleton) and is the modern Swift idiom for the `AsyncParsableCommand` no-init-param constraint (swift-distributed-tracing's `InstrumentationSystem` uses the same shape). Migrated 246 inline `Logging.LiveRecording()` call sites across `CLIImpl.Command.Fetch.swift` (158), `CLIImpl.Command.Save.Indexers.swift` (61), and `CLIImpl.Command.Save.swift` (27) to read from the binding. New `CupertinoCompositionTests` suite (4 tests) pins the Mediator wiring + the `@TaskLocal` scoping contract. Phases C-F migrate the remaining 14 CLI command files; Phase G migrates the other 4 binaries; Phase H deletes the no-arg `Logging.LiveRecording()` shim + `Logging.Unified.shared`. - **`Logging.Composition` Abstract Factory + `Logging.LiveRecording(unified:)` Bridge init + public `Logging.Unified.init` (#548 Phase A).** Composition-root primitives for killing the `Logging.Unified.shared` Singleton (GoF p. 127, rejected as Service Locator per Seemann 2011 ch. 5). `Logging.Composition` owns one `Logging.Unified` actor + the `LiveRecording` Bridge (GoF p. 151) wrapper, exposes `recording: any Logging.Recording` for downstream DI, plus façade methods (`configure(_:)`, `disableConsole()`, `enableConsole()`, `enableFileLogging(at:)`, `disableFileLogging()`) that forward to the held actor. `Logging.LiveRecording(unified:)` is the proper Bridge init (abstraction = `Recording` protocol, implementation = `Unified` actor, wired via DI). `Logging.Unified.init(options:)` promoted from `private` to `public` so each binary's composition root can build its own actor. Phase A is pure addition , the `.shared` accessor and the no-arg `LiveRecording()` shim stay alive for the per-binary migration in Phases B-G, deleted in Phase H. - **GoF protocol-DI epic complete (#495, PRs #494–#502).** Eight cross-target closure typealiases converted to GoF Strategy / Factory Method protocols. Production `Live*` concretes live in CLI (the composition root) wrapping the underlying static / actor; consumer targets see only the protocol seam. | # | Closure → Protocol | PR | |---|---|---| | 1 | `MakeSearchDatabase` → `Search.DatabaseFactory` | #494 | | 2 | `Search.MarkdownToStructuredPage` → `Search.MarkdownToStructuredPageStrategy` | #496 | | 3 | `Search.SampleCatalogFetch` → `Search.SampleCatalogProvider` | #497 | | 4 | `Search.PackageIndexingRun` → `Search.PackageIndexingRunner` | #498 | | 5 | `Search.DocsIndexingRun` → `Search.DocsIndexingRunner` | #499 | | 6 | `Sample.Index.SamplesIndexingRun` → `Sample.Index.SamplesIndexingRunner` | #500 | | 7 | `MarkdownLookup` → `MCP.Support.MarkdownLookupStrategy` | #501 | | 8 | `Services.ReadService.PackageFileLookup` → `Services.ReadService.PackageFileLookupStrategy` | #502 | - **`CrawlerModels` SPM target** (#505, PR #508). Hosts `Crawler.HTMLParserStrategy`, `Crawler.AppleJSONParserStrategy`, `Crawler.PriorityPackageStrategy` protocols + `Noop*` test fixtures + the `Crawler` namespace anchor. `Crawler` now imports only Foundation / Shared / CrawlerModels / CoreProtocols / Logging / Resources / WebKit / os , no concrete-producer modules. - **WAL journal mode on all three local SQLite DBs** (#236, PRs #510–#513). `search.db`, `packages.db`, `samples.db` all open with `PRAGMA journal_mode = WAL`, `PRAGMA synchronous = NORMAL`, `PRAGMA journal_size_limit = 67108864` (64 MiB) per SQLite-docs recommendation. Releases finalize with `PRAGMA wal_checkpoint(TRUNCATE)` in `Release.Command.Database` so bundle zips never ship a partial DB. Net effect: readers (`cupertino search` / `read` / `doctor`) can run concurrently with a `save` writer without seeing `database is locked`. The original #236 symptom is observably gone on samples + verified live on docs at #514. - **`cupertino doctor` Schema versions section** (#513). Reports per-DB journal mode, WAL sidecar size with a 16 MiB starvation warning, and volume locality with NFS/SMB/AFP warnings. Audit-style block surfaces all four signals at a glance. - **`scripts/check-package-purity.sh`** CI guard (#506). Enforces the Phase B "no behavioural cross-package imports" rule. Empty `GRANDFATHERED_TARGETS` list after `Crawler` purification , every package stands alone. - **CLI help-text audit** (#241, PR #524). `cupertino --help` gains a purpose-grouped discussion block (setup / data-collection / indexing / server / query / sample-code / diagnostics) replacing the plain alphabetical list. `save`, `fetch`, and per-subcommand discussions updated to the post-#231 three-DB story; the two-stage `fetch --type packages` flow (`--skip-metadata` / `--skip-archives`) is now documented. ### Changed - **`Services` drops `import SampleIndex`** (#503, PR #504) via new `Sample.Index.DatabaseFactory` protocol. The composition root injects a `Live*` factory; `Services` compiles against `SampleIndexModels` only. - **`URL.knownGood`** (#509). Replace `fatalError` with a throwing init. The codebase-wide `URL.knownGood(...)` helper now propagates the error to the call site instead of crashing the process on a malformed literal. Production callers pass string literals known good at compile time; tests and any runtime caller now have a recoverable failure path. ### Refactored - **Strict GoF logging migration: `Logging.Recording` Strategy protocol + per-target DI** (#525, #526, #527, #528, #529, plus the in-flight middle-ring batch): the codebase's pre-existing `Logging.Log` / `Logging.ConsoleLogger` / `Logging.Unified.shared` static surface was a Service Locator (Seemann, *Dependency Injection*, 2011, ch. 5) , feature code reached for module-scope shared instances instead of receiving them. GoF (1994) sanctions Singleton (p. 127) only for "exactly one instance" semantics; a logger doesn't need that, and the static reach made every producer target carry a behavioural `import Logging` for what should have been a protocol-typed seam. This arc converts the writer to a Factory Method / Strategy (GoF p. 107 / p. 315) pair: - **`LoggingModels` target** (#525) , new Foundation-only target holding the `Logging` namespace anchor, `Logging.Level` (Comparable severity enum), `Logging.Category` (subsystem categories), `Logging.Recording` protocol (`record(_:level:category:)` + `output(_:)` + convenience level methods), and `Logging.NoopRecording` test stub. - **`Logging.LiveRecording`** (#526) , production conformer in the existing `Logging` target. Wraps the OSLog + console + file behaviour of `Logging.Unified` via two exhaustive switches (Level / Category bridge), so a new case in `LoggingModels` without bridge update stops compiling. - **Crawler migration** (#527) , 5 crawler types (`AppleDocs` + `State`, `HIG`, `AppleArchive`, `Evolution`) take `logger: any LoggingModels.Logging.Recording` via init. Crawler target drops `import Logging` for `import LoggingModels`; `CLI.Command.Fetch` constructs `Logging.LiveRecording()` at each sub-runner's composition root. - **Core trio migration** (#528) , `Core` had a dead `Logging` manifest dep (zero call sites). `CoreJSONParser` already clean. `CorePackageIndexing` had 10 sites across `PackageFetcher` + `PriorityPackageGenerator` (both actors take `logger:` via init) + `PriorityPackagesCatalog` static enum (one `print()` for the one-time first-run migration notice; GoF p. 127 sanctions Singleton boundaries that don't carry injectable state). - **Search / SampleIndex / CoreSampleCode migration** (#529) , 86 sites across `Search.Index` / `Search.IndexBuilder` / `Search.PackageIndex` actors + 7 strategy structs + `Sample.Index.Database` actor + `Sample.Core.GitHubFetcher` / `Sample.Core.Downloader` final classes. `Downloader`'s `logger` is `nonisolated let` so `@MainActor`-isolated WKWebView delegate callbacks (in a private `AuthFlowCoordinator` class) can read it without an actor hop; `Logging.Recording: Sendable` makes this safe. - **Indexer / Ingest / Distribution / Cleanup / MCPSupport migration** (#531) , 22 sites. Indexer + Distribution had dead `Logging` manifest deps (zero call sites + dead imports , both removed). Ingest's static-enum `Ingest.Session` utility threads `logger:` through 5 static funcs. Cleanup's `Sample.Cleanup.Cleaner` actor + MCPSupport's `MCP.Support.DocsResourceProvider` actor take `logger:` via init. - **CLI composition migration** (PR 7) , ~488 `Logging.Log.{info,warning,error,debug,output}` and `Logging.ConsoleLogger.{info,error,output}` call sites across all 17 CLI command files replaced with `Logging.LiveRecording().X(...)` inline construction. CLI is the composition root , each call constructs its own stateless `LiveRecording` instance (free, no shared global, no Service Locator reach). `cupertino serve` now also calls `await Logging.Unified.shared.disableConsole()` alongside `Logging.Log.disableConsole()` so the JSON-RPC stdio stream stays parseable; the actor backend has its own `consoleEnabled` flag separate from the static surface, and the previous setup only muted the static one. Only residual `Logging.Log.*` reference in CLI is the `disableConsole()` configuration call (not a log emission) , final cleanup with the static-surface deletion PR. - **Net result**: every producer + CLI source file consumes the `LoggingModels` protocol surface only, never the concrete `Logging.Log` / `Logging.ConsoleLogger` static writer. `Logging.Unified.shared` actor and the static surface remain temporarily for `disableConsole()` configuration and as the underlying writer behind `LiveRecording`; followup PR deletes the user-facing static log surface (`Logging.Log.{info,warning,...}` / `Logging.ConsoleLogger.{info,...}`) entirely. - **Delete the legacy `Logging.Log` + `Logging.ConsoleLogger` static surface + file-scope `logInfo`/`logWarning`/`logError`/`logDebug` async globals** (#534). The Service Locator surface is gone. Removed: the entire `public enum Logging.Log` block (synchronous static surface with `nonisolated(unsafe)` options including `info` / `warning` / `error` / `debug` / `output` / `configure` / `disableConsole` / `enableConsole`); the entire `public enum Logging.ConsoleLogger` block (`info` / `error` / `output` static helpers wrapping `print()` + `os.Logger`); the four file-scope `public func log{Debug,Info,Warning,Error}(_:category:...)` async convenience globals. Kept: `Logging.Unified` actor + `Logging.Unified.shared` accessor (process-wide writer used as `LiveRecording`'s bridge target and the entry point for `disableConsole()` config , verified by the post-#533 MCP integration test); the `Logging.Logger.<category>` `os.Logger` constants (Apple's own OSLog idiom, used by tests + the bridge in `LiveRecording`). `CLI.Command.Serve` now calls only `await Logging.Unified.shared.disableConsole()` to silence stdout for the MCP JSON-RPC stream. Producer + CLI code has no callable static log surface , every emission flows through an `any Logging.Recording` value (a `Logging.LiveRecording()` instance or a test stub). - **`Sample.Core.Downloader._isInteractiveStdinOverride` Singleton-style static seam replaced with GoF Strategy + constructor injection** (#547). The old test seam was a `nonisolated(unsafe) static var _isInteractiveStdinOverride: Bool?` plus an `isInteractiveStdin()` static helper that consulted it before falling back to `isatty(fileno(stdin))`. Same Service Locator shape as the deleted `BinaryConfig.shared` / `Logging.Unified.shared` (Seemann, *Dependency Injection*, 2011, ch. 5): tests reached into a process-scope mutable global instead of receiving a collaborator. Replaced with: - **`Sample.Core.InteractiveStdinChecking` protocol** in `CoreSampleCode` , `Sendable`, single `isInteractive() -> Bool` requirement. GoF Strategy p. 315. - **`Sample.Core.LiveInteractiveStdinCheck`** production conformer , calls `isatty(fileno(stdin)) != 0`, same behaviour the deleted static helper had on its non-override branch. - **`Sample.Core.Downloader.init(...)`** takes an additional `interactiveStdinCheck: any Sample.Core.InteractiveStdinChecking = Sample.Core.LiveInteractiveStdinCheck()` parameter, stored as a `nonisolated private let` (matches the existing `logger:` seam, safe because the protocol is `Sendable`). The previous instance call site (`showAuthenticationPrompt`) and the private `awaitAuthOutcome` static helper now read the strategy from the injected value; `awaitAuthOutcome` gained an `interactiveStdinCheck:` parameter so its `Task.detached`-spawning decision flows through the seam. - **Tests:** `SampleCodeAuthPolicyTests.IsInteractiveStdinTests` (which mutated the static var with `defer { … = previous }`) replaced by `InteractiveStdinCheckingTests` , a `StubInteractiveStdinCheck` struct supplies the fixed `Bool` and an explicit smoke test exercises `LiveInteractiveStdinCheck`. No static-state crosstalk between tests. - **Final deletes:** `Sample.Core.Downloader.isInteractiveStdin()` static helper and `_isInteractiveStdinOverride` `nonisolated(unsafe) static var`. Zero call sites remain in production or test sources. - **Strict path DI: `BinaryConfig.shared` Singleton + 14 `Shared.Constants.defaultX` static accessors deleted** (#535, 11 squashed commits). The remaining Service Locator surface (path resolution) is gone. Producer-side reach for `Shared.Constants.defaultBaseDirectory` and 13 derived path accessors routed through `BinaryConfig.shared` , Seemann (2011) ch. 5 Service Locator. Replaced with: - **`Shared.Paths` value type** , 13 derived URLs from one `baseDirectory`. `Shared.Paths.live()` factory reads `BinaryConfig.load(from:)` once at each executableTarget composition sub-root and threads URLs downstream. - **`Indexer.Preflight.preflightLines` + `MCP.Support.DocsResourceProvider`** take `paths: Shared.Paths` / explicit URL params instead of nil-defaulting to singletons. - **`Services.ServiceContainer` + `Services.ReadService` + `Shared.Utils.PathResolver`** lose every `?? Shared.Constants.defaultX` fallback. - **`Shared.Configuration.{Crawler, ChangeDetection, Configuration}`** inits require `outputDirectory`. - **Three static-enum catalogs converted to actors with injected `baseDirectory`:** `Core.PackageIndexing.PriorityPackagesCatalog`, `CoreSampleCode.Sample.Core.Catalog`. `Crawler.ArchiveGuideCatalog` accessors take `baseDirectory:` explicit param (stateless enum stays). - **`Logging.Unified` log-file fallback** drops the singleton-default URL , caller must supply `fileURL` via `enableFileLogging(at:)`. - **`ReleaseTool.Release.Command.Database` + 4 TUI catalogs** thread `Shared.Paths.live().baseDirectory` instead of reaching for the singleton. - **Final deletes:** `Shared.Constants.BinaryConfig.shared`, 14 `Shared.Constants.defaultX` statics, deprecated `Sample.Index.defaultDatabasePath` / `defaultSampleCodeDirectory` wrappers. `BinaryConfig` struct itself stays , value type loaded explicitly by `Shared.Paths.live()`. - **Composition-root naming visible:** `enum CLI` → `enum CLIImpl`, files renamed `CLI.Command.X.swift` → `CLIImpl.Command.X.swift`, `ReleaseTool.swift` → `ReleaseToolImpl.swift`. Build-target names stay (match executable product names); Swift namespace + file naming declare impl-ness at a glance. - **Foundation-only producer epic (#536) complete , every Cupertino target is standalone-portable, enforced at CI.** Shipped 2026-05-15 across 9 PRs (#537, #538, #539, #540, #541, #542, #543, #544, #545). - **Phase 0 (#537):** `scripts/check-target-foundation-only.sh` , per-target allow-list guard with opt-in `STRICT_PRODUCERS` array. `docs/package-import-contract.md` rewritten with the post-#536 target regime + Apple-pattern corroboration (SwiftNIO `NIOCore`, swift-log, everliv-monorepo, GoF Strategy p. 315 / Factory Method p. 107, Point-Free swift-dependencies). Global rule at `mihaela-agents/Rules/swift/per-package-import-contract.md` updated. - **Phase 1 , Shared-layer consolidation:** the four legacy `Shared*` sub-targets absorbed into `SharedConstants`. - **1a (#538):** `SharedCore` (`Shared.Core.ToolError`, `Shared.Core` anchor, `CupertinoShared.swift`) → `SharedConstants`. 3 files moved, 179 import-line cleanups. Bonus fix: `LoggingTests` target had `dependencies: ["LoggingModels", "LoggingModels", "TestSupport"]` (duplicate + missing `Logging`) , replaced with `["Logging", "LoggingModels", "TestSupport"]`. - **1b (#539):** `SharedUtils` (`Shared.Utils.JSONCoding`, `PathResolver`, `Formatting`, `FTSQuery`, `SQL`, `SchemaVersion`, `URLExtensions`) → `SharedConstants`. 7 files moved, 55 import-line cleanups. - **1c (#540):** `SharedModels` (`CrawlMetadata`, `PackageReference`, `URLUtilities`, `HashUtilities`, `StructuredDocumentationPage`, `CleanupProgress`) → `SharedConstants`. 6 files moved, 63 import-line cleanups. - **1d (#541):** `SharedConfiguration` (`Shared.Configuration` + `Crawler` / `ChangeDetection` / `Output` / `Output.Format` / `DiscoveryMode`) → `SharedConstants`. 6 files moved, 17 import-line cleanups. `SharedConstants` `exclude:` list is empty now , every sub-folder of `Sources/Shared/` compiles into one target. - **Net result:** Shared layer collapses from 5 targets to 1. Every producer imports a single Shared target instead of up to five. - **Phase 2 , `*Models` protocol-seam audit:** every cross-target seam target verified foundation-only. - **2a (#542):** `CoreProtocols` content audit. Two leaks (`Core.Protocols.GitHubCanonicalizer` actor with URLSession + FileManager cache; `Core.Protocols.ExclusionList` enum with `Data(contentsOf:)` loader) moved out into `CorePackageIndexing` (their actual consumer). Namespace rewrites `extension Core.Protocols` → `extension Core.PackageIndexing`. Three smoke tests followed. - **2b-2f (#544):** the 6 `*Models` protocol-seam targets (`CoreProtocols`, `CrawlerModels`, `CorePackageIndexingModels`, `SearchModels`, `SampleIndexModels`, `ServicesModels`) opted into `STRICT_PRODUCERS`. All carry Foundation + foundation-tier + other `*Models` imports only , no actors with I/O, no URLSession, no FileManager. - **Phase 3 (#545):** every producer (17 targets) opted into `STRICT_PRODUCERS`. Empirical decoupling proof: `scripts/check-target-portability.sh` swept all 17 , each builds standalone in a tmp directory against only its declared deps + a fresh SwiftPM checkout. Zero producer→producer concrete imports, zero `Logging` writer imports, zero legacy Shared* imports. `GRANDFATHERED_TARGETS` is empty. - **Phase 4 (this PR):** close-out , CHANGELOG entry, #183 roadmap update. - **Documentation arc:** `docs/package-import-contract.md` and `CHANGELOG.md` resync (#543) brought the on-disk contract back in line with the merged structural changes. **What this means in practice:** every Cupertino target , 6 protocol-seam companions + 17 producers , passes both audits. Strategy / Factory Method / DI throughout. Pull out `Crawler` (or `Search`, or any other producer) into a fresh repo with the foundation tier and it builds against external SwiftPM deps alone. Mechanically provable: `scripts/check-target-foundation-only.sh` enforces the source-import allow-list at CI; `scripts/check-target-portability.sh <Target>` enforces actual standalone build. Both green on every producer. The user's standing principle , *"every package can be pulled out of monorepo anytime, every one, anytime"* , is now true and provable. ### Fixed - **`cupertino setup` URL build at v1.1.0** (#477, post-tag fix on 2026-05-14). The released v1.1.0 binary had `Shared.Constants.App.databaseVersion = "1.0.2"`, so `cupertino setup` built the cupertino-docs URL against v1.0.2 and downloaded the older bundle. Bumped to `"1.1.0"`, v1.1.0 git tag force-moved to the fix HEAD, GitHub Actions rebuilt + replaced the binary tarball asset on the v1.1.0 release, homebrew formula SHA bumped to match. End-to-end `brew upgrade cupertino && cupertino setup` now resolves the v1.1.0 bundle correctly. ### Internal - **ci: temporarily disable the Query batteries smoke job (`if: false`).** It runs `cupertino setup`, which downloads a compiled DB bundle and expects `apple-documentation.db`; after the per-source DB split (#1036) no bundle with the per-source DBs is published yet, so the job fails on every branch (develop included): infrastructure-not-ready, not a regression. Re-enable once the new per-source bundle ships (#1071 chain). The job body is left intact for a one-line re-enable. - **Test suite at 1,456 tests across 163 suites** (+31 since the v1.1.0 baseline of 1,425). WAL coverage adds 12 unit tests across all three DB actors (#512). - **PR #515** squash-merges the GoF DI arc + Phase B + WAL from `develop` into main as the staging step for v1.2.0. `xcrun swift build` clean, `xcrun swift test` green (1,456/1,456), `scripts/check-package-purity.sh` clean, `swiftformat .` no changes, `swiftlint` only pre-existing warnings. ### Verified live (against the v1.2.0-staged binary, pre-tag) - **Samples WAL workload** (during #515 audit): `cupertino save --samples` (228 s, 185 MB indexed) running in background while `cupertino doctor` AND `cupertino search --source samples` ran in foreground. Zero `database is locked` errors. The original #236 symptom is observably gone on the samples workload. - **Docs WAL workload** (#514, in flight at tag-time): real-corpus `cupertino save --docs` against the 414,807-file post-Claw-merge corpus on this binary, with `doctor` / `search` / `read` test batches at +3 / +10 / +20 minutes from save start. All three batches returned exit 0 with zero `database is locked`. The full perf writeup will be posted to #514 when the save completes. ## 1.0.2 , 2026-05-11 _First v1.0.x release to ship a re-indexed bundle. `databaseVersion` jumps from `1.0.0` to `1.0.2`: `cupertino setup` from a v1.0.2 binary downloads a freshly-built `cupertino-databases-v1.0.2.zip` produced by running the post-#283 indexer against the full 404,729-page corpus (Studio v1.0.0 base + Claw fresh overlay). The previous v1.0.0 / v1.0.1 bundles carried 61,257 case-axis duplicate clusters covering 122,522 rows (~30% of `docs_metadata`); the v1.0.2 bundle has zero (`GROUP BY LOWER(url) HAVING COUNT > 1` returns empty across 277,640 documents). The post-redirect canonicalization (#277) and HTML link augmentation (#203) work also land here. Upgrade path for existing v1.0.0 / v1.0.1 installs: run `cupertino setup` to fetch the new clean bundle. v12 DBs on disk are rejected at open time with a "rebuild required" message pointing at `cupertino setup`. An earlier draft of this work also shipped an in-place v12→v13 migration with prepared-statement reuse and journal-mode tuning; that code was deleted before tag because it was dead weight given the bundle-download path (60+ min on a 405k-row corpus, vs seconds for a fresh-bundle download)._ _On #199: empirical investigation against the post-merge corpus shows `id` is deterministic on 90.5 % of overlapping page pairs and `canonicalContentHash` is deterministic across the JSON save+load round trip (new test landed in 128b79b). The remaining ~10 % `id` mismatch correlates with cross-mode pairs where the two transformers populate different structural fields , by-design, not a bug. The "every re-fetch falsely reports as updated" symptom that motivated #199 is explained by stale metadata.json after `--start-clean` plus 0.9.1-binary-saved files using the older hash format, not by hash non-determinism. Detailed write-up in `mihaela-blog-ideas/cupertino/research/2026-05-09-dual-corpus-coverage-investigation.md`._ ### Fixed - **URL case canonicalization: case-variant URLs no longer produce two distinct URIs in the index** ([#283](https://github.com/mihaelamj/cupertino/issues/283), reopens [#200](https://github.com/mihaelamj/cupertino/issues/200)): the v1.0.1 closure of #200 claimed the shipped v1.0.0 `search.db` had "zero case-axis duplicate pairs" based on `GROUP BY LOWER(uri) HAVING variants > 1` returning empty against `docs_metadata`. That query was structurally incapable of finding the bug. `URLUtilities.filename(from:)` builds the URI's 8-hex disambiguator hash from the URL passed in *before* lowercasing (line 895, `originalCleaned`), so two URLs differing only in case (e.g. `documentation/Swift/withTaskGroup(of:returning:isolation:body:)` vs the all-lowercase variant) produce two distinct hash suffixes, two distinct filenames, two distinct URIs in `docs_metadata`. The lowered URIs are also distinct, so `LOWER(uri)` cannot see the duplicate; the correct query is `GROUP BY LOWER(url)` against `docs_structured`, which against the shipped v1.0.0 corpus reports `61,257 clusters / 122,522 rows` (~30% of the corpus). Live-reproduced in v1.0.1 binary on `cupertino search --source apple-docs "withTaskGroup"` and `"Observable() macro"`: top results show capital + lowercase URL variants of the same Apple page. Fix: `URLUtilities.filename(from:)` now calls `URLUtilities.normalize(_:)` on the input URL before computing both the lowercased body and the disambiguator hash, so case-variant inputs collapse to identical filenames. One-line behavioral change inside the function; all callers continue to pass raw URLs unchanged. Three new regression tests in `URLUtilitiesFilenameTests` (`caseVariantWithTaskGroupCollapses`, `caseVariantObservableMacroCollapses`, `caseVariantPlainURLCollapses`); the existing 10 truncation + determinism tests continue to pass. The fix is preventive going forward; existing v1.0.0 / v1.0.1 bundle DBs still carry the duplicate rows. Same release ships a freshly-reindexed bundle (`cupertino-databases-v1.0.2.zip`): 277,640 documents across 402 frameworks, 2.41 GB, `user_version = 13`, zero case-axis duplicate clusters by the correct query. The reindex took 12h 36m wall-clock on M4 Max against the merged 404,729-page corpus and committed cleanly. Live `cupertino search "withTaskGroup"` and `"Observable() macro"` against the new bundle return ONE canonical lowercase result each, not the case-variant pairs the v1.0.0 bundle returned. v12 DBs hit `checkAndMigrateSchema` and throw "rebuild required" (matches the existing v5 / v12 breaking-migration pattern), pointing users at `cupertino setup` to download the new pre-built bundle. No in-place migration ships: a fresh-bundle download takes seconds vs the 60+ minutes a 405k-row in-place rewrite needed in testing. An earlier draft of this work shipped a full v12→v13 in-place migration with prepared-statement reuse and journal-mode tuning; that code was deleted before v1.0.2 tag because it was dead weight given the bundle download path. - **`cupertino serve --help` OVERVIEW listed the wrong tool surface** (branch `refactor/searchindex-split-by-concern`): the `discussion:` string in `ServeCommand.swift` rendered for `cupertino serve --help` listed `search_docs` and `search_samples` as MCP tools, both of which were removed in #239 (unified into the single `search` tool). It also omitted the unified `search` tool itself plus the four semantic-search tools added in #81 (`search_symbols`, `search_property_wrappers`, `search_concurrency`, `search_conformances`). So the binary's own help text described a tool surface that hadn't existed for two releases. Spotted while verifying that `docs/tools/` matches `CompositeToolProvider`'s actual `Tool()` registrations , the docs side was correct (10 tool subdirectories matching the 10 registered tools), the binary's `serve --help` text was the part lying. Rewrote the `discussion:` block to match what `CompositeToolProvider` actually registers, organized by the same conditional groups the provider uses (Unified Search / Documentation Tools / Sample Code Tools / Semantic Search Tools). No registration or behavior change; static help-text fix only. - **Crawler stores pages under post-redirect canonical URL** ([#277](https://github.com/mihaelamj/cupertino/issues/277), [PR #278](https://github.com/mihaelamj/cupertino/pull/278) by [@Vignesh-Thangamariappan](https://github.com/Vignesh-Thangamariappan)): `Crawler.swift` derived on-disk storage paths (`framework`, `filename`) from the *request* URL. When Apple issues a 301/302 , e.g. `professional_video_applications` → `professional-video-applications` , `URLSession` followed it silently, so the content body arrived under the new slug but was filed under the old. After the fix, `ContentFetcher.fetch` returns `FetchResult<RawContent>` carrying the post-redirect `response.url`; both `JSONContentFetcher` (capturing `response.url` from `URLSession.data(from:)`) and `WKWebContentFetcher` (capturing `webView.url` after navigation completes) expose it. `Crawler.loadPageViaJSON` reverses the JSON API URL back to a documentation URL via the new `AppleJSONToMarkdown.documentationURL(from:)` helper and uses that canonical URL for `framework` / `filename` derivation, `shouldRecrawl` keying, and metadata page-key updates. `AppleJSONCrawlerEngine`, `WKWebCrawlerEngine`, and `HIGCrawler` all updated to consume the new `FetchResult.url` field. 5 regression tests cover the helper (round-trip with `jsonAPIURL(from:)`, nil for non-API URLs, the underscore→dash slug migration) plus a `RedirectMockURLProtocol`-driven integration test confirming `JSONContentFetcher` captures the post-redirect URL when a 301 is in the response chain. ### Added - **`--discovery-mode auto` augments JSON-extracted links with HTML anchor tags on sparse-references pages** ([#203](https://github.com/mihaelamj/cupertino/issues/203), [PR #281](https://github.com/mihaelamj/cupertino/pull/281), supersedes [PR #279](https://github.com/mihaelamj/cupertino/pull/279) which had matching design intent but lacked the heuristic): in `--discovery-mode auto`, after a successful JSON API fetch, the crawler additionally fetches the rendered HTML and unions its `<a href>` links with the JSON `references`-walker output. Catches URL patterns Apple's DocC JSON omits , operator overloads (`Int.&` slugified as `int_amp_<hash>`), legacy numeric-ID symbols (`NSDictionary 1418511-iskindofclass`), `data.dictionary` REST sub-paths. The cost of the extra WebView render is bounded by a sparse-references skip heuristic: augmentation only runs when the JSON-extracted link count is below `htmlLinkAugmentationMaxRefs` (default `10`). Pages with rich JSON references already cover the URL graph; HTML adds nothing for them. This puts roughly the sparse third of Apple's corpus through augmentation, matching the issue's stated performance budget ("Performance budget: HTML fetch already happens for ~11 % of pages... extending to ~30-50 % would slow per-page rate ~1.5-2x. Acceptable for completeness."). The augmentation HTML fetch uses the post-redirect canonical URL captured by #277's `storageURL` plumbing, so a redirected slug doesn't double-fetch. Two new `CrawlerConfiguration` config-file fields (no CLI flags yet , set in your config JSON): - `htmlLinkAugmentation: Bool` , master switch (default `true`). - `htmlLinkAugmentationMaxRefs: Int` , heuristic threshold (default `10`). Set to `Int.max` to disable the heuristic and augment every page; set `htmlLinkAugmentation: false` to skip entirely. When augmentation runs and adds at least one link, the crawler logs `🔗 HTML augmentation: +N links (page had M JSON refs)`. Backwards-compatible JSON config decoding via `decodeIfPresent` for both fields. 7 new tests cover default values, explicit overrides, legacy-JSON decode without the new fields, encode + decode round-trip. Integration testing of the augmentation path itself needs fetcher-mock infrastructure that doesn't exist in the test suite today , deferred to a follow-up. ### Changed - **`cupertino` skill (`skills/cupertino/SKILL.md`) gains a "Two rules" block at the top** (v1.0.2): the existing skill explained query strategy and verification but buried the two most important instructions deep in the body. The new top-of-body block makes them the first thing the calling LLM sees. **Rule 1**: any Apple-related question (SwiftUI, UIKit, AppKit, Foundation, Swift language, iOS / macOS / visionOS / tvOS / watchOS APIs, HIG, sample code, Swift Evolution, Swift packages) goes through `cupertino search` first; don't reach for training-data memory of Apple APIs. Covers the case where the question doesn't obviously involve Apple but mentions a symbol that might be Apple's. **Rule 2**: after drafting an answer, verify both that the code exists on Apple AND that the pattern is appropriate. Two sub-checks both run against cupertino: **Existence** (re-search every symbol, method, initializer, modifier, property, or framework name to confirm Apple ships it; check signature + availability + deprecation against the returned doc; if a name doesn't trace back to a hit, it doesn't exist) and **Appropriateness** (cupertino indexes both current and deprecated symbols, so don't recommend `UIWebView` when `WKWebView` exists, `URLConnection` when `URLSession` exists, `Combine` when modern Swift Concurrency fits, or UIKit list patterns when SwiftUI `List` is what the user asked for; search the conceptual area to read what Apple's doc actually steers people toward). Together they catch the two distinct hallucination paths (invented method names + deprecated-but-still-indexed patterns) that the prior "cite as you go" guidance alone didn't fully cover. The existing "Token-efficient verification" cost table further down quantifies the verification cost (~5 % overhead for cite-as-you-go, ~500 tokens per re-search), so the model can see the verification is cheap and actually do it. - **Corpus stats refresh across docs** (v1.0.2): the README, the skill, the setup README, and the doctor search-db option doc were carrying the pre-#283 numbers (~405,000 pages / 422 frameworks / ~3.4 GB search.db / ~1.5 GB index). Bumped to the post-#283 v1.0.2 bundle reality (~277,000 pages / 402 frameworks / ~2.4 GB search.db / ~2.4 GB index). Test count in the README updated from 1,231 to 1,234 (1,224 swift-only + 10 MCP integration) to match the current suite size (deleted v13 migration tests). ARCHITECTURE.md version banner and Search.Index split bullet bumped from v1.0.1 / "v1.0.2 in development" to v1.0.2 released. Sample `cupertino doctor` output in `docs/commands/doctor/README.md` and `option (--)/default.md` updated from `Bundled version: 1.0.0` to `Bundled version: 1.0.2`. CLAUDE.md "Active focus" section rewritten for v1.0.2 ship state with a forward-looking v1.0.3 section for the carried-over open work (#284 crawler error-page filter, #285 dash/underscore canonicalization, #236 WAL, #241 help-text audit, #253 concurrent save detection). ### Internal - **Empirical comparison of webview-only vs json-only crawl corpora** (write-up at `mihaela-blog-ideas/cupertino/research/2026-05-09-dual-corpus-coverage-investigation.md`): two-round investigation against a partial webview-only run + a complete json-only run on the same Apple corpus. Confirmed at scale: webview catches 307 pages JSON misses entirely + 7 frameworks Apple has no JSON endpoint for (`apple_pay_on_the_web`, `applepencil`, `docc`, `passkit_apple_pay_and_wallet`, `root`, `samplecode`, `sign_in_with_apple`). Webview's `rawMarkdown` is on average 1.91× longer per page with 0 / 1000 unresolved `doc://` markers in the sample , vs JSON's 298 / 1000 (29.8 %). New: JSON has a 38 % orphan-reference rate (URLs cited in `references` that don't exist as crawlable pages); webview's URLs resolve to actual files at 81 % vs JSON's 62 %. Field population: webview drops `platforms`, `language`, `module`, `conformsTo`, `inheritedBy`, `conformingTypes`, `codeExamples` entirely; JSON populates them. Page kind classification: webview is 84.8 % `unknown` vs JSON's 33.6 %. Per-framework asymmetry is enormous (Accelerate JSON has 12.5× more pages than partial webview; Contacts is near parity). The right v1.x design merges fields from both rather than picks one mode. Investigation tools at `/tmp/coverage-investigation/`, `/tmp/coverage-investigation-2/` on Studio + Claw. - **Regression test for canonicalContentHash JSON save+load round-trip** (commit `128b79b` on `fix/199-content-hash-roundtrip-test`): the existing `canonicalContentHashIgnoresVolatileFields` test only covers an in-memory `Page`. The new `canonicalContentHashRoundTripStable` test catches `JSONEncoder` / `JSONDecoder` asymmetry, `Date` precision drift, optional-field encoding inconsistency that would silently break `shouldRecrawl` after a process restart. Both tests pass at v1.0.2. - **Search package single-file decomposition** (branch `refactor/searchindex-split-by-concern`): `Packages/Sources/Search/SearchIndex.swift` was a 4598-line `Search.Index` actor handling schema + migrations + indexing + search + ranking + counts + helpers in a single file. Split mechanically into a 97-line core file (actor declaration, properties, `init`, `disconnect`, `openDatabase`) plus 12 `SearchIndex+<Concern>.swift` extension files: `+Schema.swift` (createTables, full v12 SQL), `+Migrations.swift` (getSchemaVersion / setSchemaVersion / checkAndMigrateSchema / migrateToVersion3..11), `+Indexing.swift` (indexPackage, indexSampleCode, getFrameworkAvailability, indexCodeExamples, clearDoc{Symbols,Imports}, recomputeSymbolsBlob), `+IndexingDocs.swift` (indexDocument, indexItem(s), extractOptimizedContent, indexStructuredDocument, indexDocSymbols / indexDocSymbolFTS / indexDocImports), `+CodeExamples.swift` (searchCodeExamples / codeExamplesCount / searchSampleCode), `+SearchByAttribute.swift` (searchByKind / searchConformsTo / searchByModule / searchInheritedBy / searchConformingTypes / searchByDeclaration / searchByPlatform / getDocumentJSON), `+QueryParsing.swift` (extractSourcePrefix / extractAttributeFilters / sanitizeFTS5Query), `+Search.swift` (the 730-line `search()` with its multi-pass ranker , BM25F + intent routing + heuristics 1/1.5 + force-include canonical pages + RRF , plus `fetchCanonicalTypePages` / `fetchFrameworkRoot` / `fetchMatchingSymbols` / `searchSymbolsForURIs`), `+SemanticSearch.swift` (searchSymbols / searchPropertyWrappers / searchConcurrencyPatterns / searchConformances), `+CountsAndAliases.swift` (symbolCount / listFrameworks / register-update-resolveFrameworkAlias / listFrameworksWithAliases / documentCount / sampleCodeCount / packageCount), `+ContentAndPackages.swift` (searchPackages / getDocumentContent / getContentFromFTS / clearIndex), `+Helpers.swift` (detectLanguage / extractAvailabilityFromJSON + `ExtractedAvailability` struct / isVersionGreater / bindOptionalText / extractSummary). Function bodies, signatures, schema version, SQL strings, BM25 weights, migration logic , all byte-for-byte unchanged. Public API surface unchanged. 40 declarations widened from `private` to package-internal (3 instance properties, 4 static lets, 2 static funcs, 30 instance methods, 1 helper struct) so cross-file extension methods can share state without exposing anything outside the Search package. Naming follows Swift idiom (`SearchIndex+<Concern>.swift`, matches Foundation's `URL+FilePath.swift`); each file imports only what its concern needs. Verified: 27/27 test targets pass (1258 tests, 143 suites, 0 failures); build clean; swiftlint serious-error count strictly better than `main` on this scope (2 vs 4); swiftformat error count strictly better than `main` on this scope (32 vs 36). - **Test refactor: `@Suite` normalization across `SearchTests`** (same branch): 7 of 16 `SearchTests` files were inconsistent with the pattern the other 9 used. Three (`CupertinoSearchTests.swift` 26 tests, `PackageIndexTests.swift` 12, `PackageQueryTests.swift` 24) had top-level `@Test` funcs at file scope with no enclosing `struct` , wrapped in `@Suite("...") struct <Name> { ... }` matching the dominant pattern (`BM25TitleWeightingTests`, `VersionFilterTests`, `DocKindTests`, etc.). Four (`CanonicalTypeRankingTests.swift`, `SmartQueryTests.swift`, `ExactTitlePeerTiebreakTests.swift`, `SmartQueryIntentRoutingTests.swift`) already had a `struct` but no `@Suite` annotation , Swift Testing infers the struct as a suite, but the test listing showed the bare struct name; added `@Suite("Description (#issue)")` so the listing carries human-readable names citing the issue numbers being verified. Helpers (`tempDB`, `createTestSearchIndex`, `indexPage`, …) stay at file scope as private free functions, matching every other struct-using file in this target. `@Test` annotation count unchanged at 180 across the target (180 on `main`, 180 post-wrap); SearchTests target reports `184 tests in 22 suites passed` (was 19 suites; +3 from the wraps). - **Lint cleanup post-refactor** (same branch): the original 4598-line `SearchIndex.swift` had a file-level `// swiftlint:disable type_body_length function_body_length function_parameter_count file_length` directive justified by the file's monolithic size. The mechanical split copied that directive into every extension file, but most of the smaller post-split files don't trigger every disabled rule , swiftlint flagged 60 unused entries as `superfluous_disable_command`. Each extension file's directive trimmed to only the rules that actually fire (computed by re-linting each file with the directive stripped): five files had no firing rules and lost their directive entirely (`+CountsAndAliases`, `+Helpers`, `+Migrations`, `+QueryParsing`, `+SearchByAttribute`); seven files trimmed to a single rule (`+CodeExamples`, `+ContentAndPackages`, `+Schema`, `+SemanticSearch`: `function_body_length`; `+Indexing`: `function_parameter_count`); `+IndexingDocs` kept two rules; `+Search` kept two file-level rules plus the inline `// swiftlint:disable:next cyclomatic_complexity` directive that previously sat above `public func search(` and got stranded in `+QueryParsing.swift` during extraction (it now sits where it belongs, immediately above `search()`). The wrapped `CupertinoSearchTests` struct (555-line body) got a `// swiftlint:disable:next type_body_length` matching the pattern already accepted on `CanonicalTypeRankingTests` (591-line body) and `VersionFilterTests` (340-line body) on `main`. Drive-by: `SearchIndexBuilder.swift` had four pre-existing `redundantInternal` swiftformat errors (`internal func deduplicateDocFilesByCanonicalURL`, `loadStructuredPage`, `canonicalDocumentationURL`, `documentationCrawledAt` , `internal` is the Swift default keyword) that fail `swiftformat --lint` on `main` as well; closed. - **MCPIntegrationTests fixed (was hanging since 2025-12)** (same branch): `cupertinoServerInitialize` had been the lone test in `MockAIAgentTests` that "started but never finished" , the swift-test bundle exited mid-test with `signal code 13` (SIGPIPE), so no per-test pass / fail line was emitted and the target reported 19 of 20 tests passed with no top-level summary. Three independent root causes diagnosed and fixed. **(1) Path mismatch**: the debug binary at `.build/debug/cupertino` ships with a sibling `cupertino.config.json` that overrides `Shared.BinaryConfig.shared.resolvedBaseDirectory` to `~/.cupertino-dev/` , keeps day-to-day development data away from production `~/.cupertino/`. Integration tests run inside a test bundle where `Bundle.main.executableURL` is the test runner (not cupertino), so `Shared.BinaryConfig.shared` resolves to `~/.cupertino/`. The path mismatch made the test create `samples.db` at `~/.cupertino/samples.db` while the spawned cupertino looked under `~/.cupertino-dev/samples.db`; `ServeCommand.checkForData()` then exited with the welcome-guide message and the test's subsequent stdin write SIGPIPE'd. Fix: new `CupertinoServerFixture` helper saves the dev config aside on init, restores it on cleanup (called via `defer`); also creates an empty samples.db at the production path (matching the schema-init code path `cupertino save --samples` uses) so `checkForData()` returns true. **(2) SIGPIPE on broken pipe**: the test wrote with the non-throwing `fileHandleForWriting.write(_:)`. When cupertino had already exited, that write raised SIGPIPE on the test bundle process and killed it. Fix: switched to `try fileHandleForWriting.write(contentsOf:)` (throwing form, surfaces broken-pipe as a Swift error) plus a `process.isRunning` guard before writing, with stderr capture for the failure record if the server died during startup. **(3) `withThrowingTaskGroup` couldn't actually time out**: the initialize test raced two child tasks , a 5-second sleep that throws `TimeoutError`, and a synchronous `stdoutPipe.fileHandleForReading.availableData` read. `availableData` is a synchronous blocking call with no Swift Concurrency suspension point, so it ignores Task cancellation; if the timeout sleep wins the race, the read task can't be cancelled, and the implicit "wait for child tasks" at the end of `withThrowingTaskGroup` hangs forever. Fix: extracted a `readUntil(stdout:stderr:until:deadline:)` helper that polls `availableData` with 50 ms sleeps in between (every iteration is a real suspension point). The sibling `cupertinoServerListTools` test (which already used a comparable polling pattern inline) also moved to the shared helper. Deadline raised from 5 s to 30 s on the initialize test to match listTools , process fork + DB open can eat several seconds on a busy box. Both tests now pass in under a second; `MockAIAgentTests` reports `Test run with 29 tests in 6 suites passed after 1.309 seconds` (was: 19 passed + 1 hung, no top-level summary). The full 27-target sweep moved from 26 ✅ + 1 unmeasurable / 1229 tests / 137 suites to 27 ✅ / 1258 tests / 143 suites, 0 failures. - **`docs/commands/` structural drift fix** (same branch, four follow-up commits): a per-command diff of every visible + hidden subcommand's `--help` OPTIONS section against the matching `docs/commands/<cmd>/option (--)/*.md` set found 16 CLI flags with no doc, 2 doc files for flags removed from the binary, 3 enum values missing per-value docs, and 1 enum value with a stale filename. **Authored** 16 new option docs (`fetch/--no-only-accepted`, `save/--remote`, `list-frameworks/--format` + `--search-db`, `list-samples/--format/--framework/--limit/--sample-db`, `read-sample/--format/--sample-db`, `read-sample-file/--format/--sample-db`, `package-search/--db/--limit/--platform/--min-version`) and 3 new enum-value docs (`fetch --type samples`, `search --source samples`, `search --source all`); each new file matches the existing format convention (H1 = flag name, Synopsis, Description, Values table for enums, Default, Examples, Notes). **Deleted** the orphan `fetch/option (--)/recurse.md` and `fetch/option (--)/refresh.md` (flags removed from the binary). **Renamed** `search/option (--)/source (=value)/apple-sample-code.md` → `samples.md` and rewrote the body to describe the samples.db-backed source rather than the prior bundled-catalog story (the CLI's `--source` enum was renamed `apple-sample-code` → `samples` at some earlier point; the doc filename had been left behind). After the pass, the structural-drift detector (`scripts/check-docs-commands-drift.sh`, see below) reports `0 / 0 / 0` across the 14-command, 91-flag CLI surface. - **`docs/commands/` content audit against `--help` (top-three commands)** (same branch): three parallel agents read every body line of every option `.md` under `fetch` / `search` / `save` (32 + 33 + 22 files) against the current binary's `--help` output. They surfaced **41 concrete drift items across 13 files** , ranging from numeric staleness (`fetch/type/docs.md` and `swift.md` claimed `Max Pages 13,000` while the binary default is `1,000,000`; `setup/README.md` claimed search.db has `22,000+ documentation pages` while the v1.0 corpus has 405,782) to a phantom `--verbose` flag referenced in 4 search docs (the flag isn't in `--help` and never has been), a fabricated `-l` short alias for `search --limit` (`-l` is the short alias for `--language`), `search/option (--)/source.md` listing `apple-sample-code` as a valid value (renamed to `samples`) and missing `samples` + `all` entries, `save/option (--)/clear.md` documenting the default as `true` (it's `false`; `--clear` is opt-in) plus referencing a `--no-clear` flag that doesn't exist (no inversion pair → ArgumentParser doesn't synthesize one), `save/option (--)/default.md` carrying a pre-#231 docs-only worldview (no mention of the `--docs/--packages/--samples/--remote` scope flags or the `--yes` preflight prompt), `save/option (--)/remote/README.md` listing a `packages` phase under `--remote` (which only feeds the docs scope), and `fetch/type/packages.md` referencing a removed `--no-recurse` flag. Every item fixed in commit `c6ab1dd` against the current `--help`. Drive-by: `setup/README.md` "What Gets Downloaded" table updated from the fictional `22k pages / 606 projects / varies` to the v1.0 bundle reality (`~405k pages / ~150-200 MB samples / ~9.7k packages`). - **`docs/commands/` content audit (small commands + top-level)** (same branch): spot-check pass over `doctor` / `cleanup` / `resolve-refs` / `read` / `setup` / `serve` / `list-frameworks` / `list-samples` / `read-sample` / `read-sample-file` / `package-search` and the top-level `docs/commands/README.md`. Found 4 stale db-filename references , `list-frameworks/README.md` / `list-samples/README.md` / `read-sample/README.md` / `read-sample-file/README.md` documented the database default as `~/.cupertino/search-index.sqlite` or `~/.cupertino/sample-index.sqlite`; neither filename has ever matched the binary's actual default. `Shared.Constants.FileName` uses `search.db` / `samples.db` / `packages.db`. Plus `serve/README.md` had `~/.cupertino/sample-code/samples.db` (wrong path; samples.db lives directly under `~/.cupertino/`). Plus `list-frameworks/README.md` sample output showed `Total: 156 frameworks, 23456 documents` (factor-of-3 and 17× off vs the v1.0 corpus's 261 frameworks / 405k docs). Plus the top-level `docs/commands/README.md` 'Manual Setup' workflow described full-docs crawl as `~50–80k pages, hours` (~5× off; current corpus is ~400k+ pages, multi-hour crawl). All five corrected. Verified clean: no remaining occurrences of `cupertino index` / `cupertino ask` / `--recurse` / `--refresh` / `--verbose` as active references (every mention is correctly framed as historical, e.g. "replaces the removed `cupertino index` command"). - **`docs/commands/` deep audit (verified against source code, not just `--help`)** (same branch): three parallel agents read every body line of the 36 remaining option `.md` files top-to-bottom and cross-checked claims against actual binary output and Swift source code (the JSON formatters in `Services/Formatters/` and `CLI/Commands/`). They surfaced **24 issues across 11 files** , most damning, **my own Phase 1 authorings of 4 `format.md` files invented JSON shapes from `--help` text alone** rather than reading source. `read-sample-file/format.md` claimed JSON fields `{project id, file path, language, byte length, content}` but `FileJSONOutput` actually emits `{projectId, path, filename, content}` (no `language`, no `byteLength`); `read-sample/format.md` claimed YAML front-matter for the markdown format but `outputMarkdown` writes H1 + `## Metadata` bullet block; `list-samples/format.md` claimed JSON top-level was a bare array but `ListSamplesCommand.outputJSON` wraps it as `{totalProjects, totalFiles, framework?, projects}` with `framework` conditional on `--framework` filter; `list-frameworks/format.md` claimed a `count` JSON field but `FrameworksJSONFormatter` emits `documentCount`. jq filter examples in 3 files were broken (would have returned nothing or null on real output). Plus `serve/README.md` documented an MCP tool `search_hig` that's not in `--help` (the binary advertises 7 tools, `search_hig` isn't one), `cleanup/README.md` listed `__MACOSX` as a removed pattern but `SampleCodeCleaner.cleanupPatterns` is the 7-entry list `.git / .DS_Store / DerivedData / build / .build / xcuserdata / *.xcuserstate` (no `__MACOSX`), `doctor/option (--)/search-db.md` was off by **factor of 50** on corpus-size claims (`13,000 pages → ~50 MB` vs actual `~405k pages → ~2.5 GB`), and `doctor/option (--)/default.md` had stale protocol version `2025-06-18` (current `2025-11-25`) plus omitted four sections of the live `doctor` output. Every issue fixed in commit `12cd5fb` and verified live by running `cupertino <cmd> --format json` and reading the actual output shape. - **`docs/ARCHITECTURE.md` mermaid diagrams + `Search.Index` post-split layout entry**: added one bullet to the existing 'Recent architectural changes (1.0)' list documenting the v1.0.2 SearchIndex decomposition, plus two mermaid diagrams that the doc now hosts (it had one before , the Services package flowchart). First diagram is a `flowchart LR` of `Search.Index` showing the actor at root with the 12 `+<Concern>.swift` extension files as children, each labeled with its top-level methods. Second is a `flowchart TD` of the 730-line `search()` ranker pipeline , query string in, `Search.Result` array out, with stages `extractSourcePrefix → extractAttributeFilters → sanitizeFTS5Query` branching into `searchSymbolsForURIs` (AST fast path) and the FTS5 `bm25(docs_fts, 1, 1, 2, 1, 10, 1, 3, 5)` MATCH (per-column weight vector annotated), merging at `HEURISTIC 1` (exact-title 50× / 20×), `HEURISTIC 1.5` (URI-simplicity + frameworkAuthority tiebreak), `fetchCanonicalTypePages` force-include, `RRF` k=60 weighted by source. Each stage cross-referenced to its issue number (#254 / #256 / #181 / #192 E4) so a reader landing on the diagram can jump to the originating motivation. Companion update: `docs/artifacts/folders/search.db.md` had three references to the monolithic `SearchIndex.swift` for schema definition / heuristic ranker / canonical-type-page boost; redirected each to the new specific extension file (`SearchIndex+Schema.swift` / `SearchIndex+Migrations.swift` / `SearchIndex+Search.swift`). Plus `README.md` package count fixed: pre-existing inconsistency on `main` where `README.md` claimed `9 consolidated packages` while `ARCHITECTURE.md` correctly described `~24 single-responsibility SPM targets` , counting `Package.swift` confirms 24, so README updated to match with a pointer to ARCHITECTURE.md for the full breakdown. README's stale 11-package listing replaced with a 13-line accurate grouping. - **`scripts/check-docs-commands-drift.sh` added as a forcing function** (same branch): codifies the structural-drift pass as a runnable check so future flag / subcommand / enum-value additions can't silently drift against `docs/commands/`. The script parses `cupertino <cmd> --help`'s OPTIONS section per command (only OPTIONS, not OVERVIEW prose, so flag-shaped strings inside descriptions don't false-positive), enumerates the matching `docs/commands/<cmd>/option (--)/*.md` files, and diffs the two sets per command. Plus enum-value checks for the two enum-valued options that have per-value subdirectories (`fetch --type` and `search --source`) using a hardcoded expected-values list in the script header (parsing ArgumentParser's invalid-enum error message proved too fragile , it intermixes value names with parenthetical descriptions and shredded into single-letter false positives). Exit codes: `0` clean, `1` drift detected, `2` invocation error (binary not built or not on the expected path). Smoke-tested by deleting a known-good doc and confirming the script catches it (`MISSING DOC fetch --force`) and exits 1, then restores cleanly. `CONTRIBUTING.md` gained a 'Documentation' section documenting the doc-update obligation when changing the CLI surface, with an explicit pre-PR checklist invocation of the script. The script catches structural drift only; prose-level claims (default values, JSON output shapes, sample output formatting) still need a human read on changes , the deep-audit pass found that source-code-verified content drift was the bigger category and a script can't substitute for a code-aware audit there. --- ## 1.1.0 , 2026-05-13 _Refactor release. No new user-visible features, no schema change. `databaseVersion` stays at `1.0.2` , `cupertino setup` from a v1.1.0 binary downloads the same `cupertino-databases-v1.0.2.zip` bundle. The release folds in the namespacing / per-type-file pass across every SPM target, the `Crawler` extract into its own target, the `MCP` → `MCPCore` target rename, the §3.6 `SearchIndexBuilder` → `SourceIndexingStrategy` refactor, and the DI epic (#381) middle ring: every internal package now stands independently of every other package's behavioural surface via protocol seams and factory-injected concrete actors at the composition root. The pre-1.1 cross-package shape (Services importing Search, MCPSupport importing Search, Core test target depending on Crawler + Search) is gone. New SPM target `SearchModels` is the value-type / protocol foundation that makes the new shape possible. Two prior `main` fixes are also captured at their new file locations: SPA no-content gate (#432) at `Core.Parser.HTML.looksLikeJavaScriptFallback` + `Crawler.AppleDocs.State.RejectionReason`, and search URL sub-page dash/underscore normalisation (#286) at `Shared.Models.URLUtilities.normalizeDocPath`._ ### Changed - **Structural refactor across every SPM target**: one non-private type per file, every file named `<Namespace>.<Type>.swift`, every cross-cutting type nested under a Swift `enum` namespace. `Core` types moved into `Core.<Sub>` (`Core.Parser.HTML`, `Core.Parser.XML`, `Core.JSONParser.*`, `Core.PackageIndexing.*`, `Core.WKWebCrawler`). `Shared` split into 5 targets (`SharedConstants`, `SharedUtils`, `SharedModels`, `SharedCore`, `SharedConfiguration`) with the same naming. `Services`, `RemoteSync`, `TUI`, `ReleaseTool`, `CLI.Command`, `Sample.Core` all folded the same way. `<Namespace>+<Type>.swift` files were renamed to `<Namespace>.<Type>.swift` to make the dot-separated form the single convention. - **`Crawler` extracted into its own SPM target** (#425, #430, #431): every web-crawling concern now lives in `Packages/Sources/Crawler/` as `Crawler.AppleDocs`, `Crawler.AppleArchive`, `Crawler.HIG`, `Crawler.Evolution`, `Crawler.WebKit.{Engine, ContentFetcher}`, `Crawler.TechnologiesIndex`, and `Crawler.ArchiveGuideCatalog`. Concrete crawlers conform to `Core.Protocols.CrawlerEngine` via a `Crawler.Engine` typealias so a higher-level dispatcher can drive any of them through the same interface. - **`MCP` SPM target renamed to `MCPCore`** (#426, #434): the bare `MCP` name was confusable with the `MCP` namespace; the target is now `MCPCore` with no behavioural change. - **`Search.IndexBuilder` refactored behind `SourceIndexingStrategy` protocol** (refactor-plan §3.6, [PR #452](https://github.com/mihaelamj/cupertino/pull/452) by [@Vignesh-Thangamariappan](https://github.com/Vignesh-Thangamariappan)): the 1347-LOC `Search.IndexBuilder` collapses to ~250 LOC of pure orchestration. New `Search.SourceIndexingStrategy` protocol (`source` + `indexItems`) drives the seven concrete strategies: `Search.AppleDocsStrategy` (directory-scan + metadata-driven paths; preserves PR #288 malformed-URL fix), `Search.AppleArchiveStrategy`, `Search.HIGStrategy`, `Search.SwiftEvolutionStrategy`, `Search.SwiftOrgStrategy`, `Search.SampleCodeStrategy`, `Search.SwiftPackagesStrategy`. 18 pure static utilities lift out to `Search.StrategyHelpers` (file discovery, deduplication, front-matter parsing, #284 defences). Files live under `Sources/Search/Strategies/`; the convenience init mirrors the old 7-parameter signature exactly for zero call-site breakage. Sets up the deferred `SearchStrategies` SPM extraction once `SearchIndexCore` (§3.5) lands. - **DI epic (#381) closes 22 of 27 children**: every foundation-tier and mid-tier package meets the "stand independently" acceptance , own test target with deps `= [Target, TestSupport]` only, no behavioural cross-package imports. Foundation tier (`SharedConstants`, `SharedUtils`, `SharedModels`, `Resources`, `CoreProtocols`, `Logging`, `SharedCore`, `SharedConfiguration`, `MCPSharedTools`) and mid-tier (`ASTIndexer`, `CoreJSONParser`, `CorePackageIndexing`, `Cleanup`, `Distribution`, `Diagnostics`, `Availability`) all closed via PRs #435 – #447 plus the early leaves (#382 `SharedConstants`, #383 `SharedUtils`). Heavy-tier already-clean packages (`RemoteSync`, `SampleIndex`, `Ingest`, the renamed-away `MCP`, `MCPClient`) closed without code changes after an explicit audit. Acceptance grep (`grep -rln '^import ' Packages/Sources/<Target>/`) returns zero internal-cupertino behavioural imports per closed leaf. - **`Services` target (#402) drops every `import Search`** across 10 PRs (#454 – #464). New SPM target `SearchModels` hosts the value types (`Search.Result`, `Search.MatchedSymbol`, `Search.PlatformAvailability`, `Search.DocumentFormat`, `Search.SymbolSearchResult`, `Search.CandidateFetcher` protocol, `Search.SmartCandidate`, `Search.AvailabilityFilter`) plus the new `Search.Database` protocol covering the read surface `Services` needs (`search`, `getDocumentContent`, `listFrameworks`, `documentCount`, `disconnect`, plus the four `#408` semantic-search methods). `Search.Index` becomes a one-line conformance witness (`extension Search.Index: Search.Database {}`). The composition root (CLI) injects a `MakeSearchDatabase = @Sendable (URL) async throws -> any Search.Database` factory closure plus a `PackageFileLookup` closure for `Search.PackageQuery.fileContent` operations. Net: `Services` compiles against `SharedConstants` / `SharedUtils` / `SharedModels` / `SharedCore` / `SharedConfiguration` / `SampleIndex` / `SearchModels` / `MCPCore` / `MCPSharedTools` / `Logging` / `Foundation` only. - **`MCPSupport` (#406)** drops `import Search` via `MarkdownLookup` closure injection on `DocsResourceProvider` (PR #453). - **`Core` test target (#394)** drops `Crawler` + `Search` deps; tests that target `Crawler` types redirect to `CrawlerTests` (PR #448). - **`SearchToolProvider` (#408, partial)** drops `import Search` via protocol-typed `searchIndex: (any Search.Database)?` field and lifted `Search.SymbolSearchResult` (PR #465). Remaining `SampleIndex` + `Services` import drops deferred (see "Still open" below). - **Namespace anchors moved to folder roots** (PR #450): `MCP.swift` from `Sources/MCP/Core/` to `Sources/MCP/`; `Shared.swift` and `Sample.swift` from `Sources/Shared/Constants/` to `Sources/Shared/`. The agent rule (`mihaela-agents/Rules/swift/code-style.md`) updated globally with three coupled rules: `.`-not-`+` filename separator, strict one non-private type per file, namespace anchor at the owning folder root. - **`Search.Index.indexDocument(...)` 18-arg signature wrapped into `IndexDocumentParams` struct** (#321, PR #470). Future indexer-column additions land as defaulted struct fields; existing callers compile unchanged. - **`Shared.Utils.SQL.countRows(in:)` helper** (#323, PR #469): 11 inline `SELECT COUNT(*) FROM <table>` literals across `Search` / `SampleIndex` / `CLI.Doctor` swept to one canonical builder. ### Fixed - **`Availability.Fetcher.buildAPIURL` force-unwrap** (#317, [PR #451](https://github.com/mihaelamj/cupertino/pull/451) by [@Vignesh-Thangamariappan](https://github.com/Vignesh-Thangamariappan)): the last production force-unwrap in `Availability` replaced with `URL.knownGood(...)` (the codebase idiom). `Availability` already declared `SharedConstants` as a dep; adding `SharedUtils` brings no new transitive deps. `buildAPIURL` visibility widened from `private` to package-internal for `@testable` access. Four focused tests in `Availability.Fetcher.buildAPIURL Tests` lock in the `/documentation/` prefix strip, lowercased path, `.json` suffix, mixed-case handling, and `Configuration.apiBaseURL` override behaviour. - **Last stray `print()` in production code** (#322, PR #466): `Core.PackageIndexing.PriorityPackagesCatalog.mergeUserSelectedWithEmbedded` switched to `Logging.ConsoleLogger.info(...)`. Acceptance grep (`grep -rn '^\s*print(' Packages/Sources/`) returns no production hits. - **`Services.ServiceContainer` dead instance state removed**: the actor + init + `getDocsService` / `getHIGService` / `getSampleService` / `disconnectAll` had zero external callers. Converted to a caseless `enum` hosting only the static `with*Service` factories (PR #464); drops ~50 lines of dead code. - **`cupertino-rel` constants path was stale** (release-time chore): six call sites in `Packages/Sources/ReleaseTool/` pointed at the pre-split `Packages/Sources/Shared/Constants.swift` and would fail every bump / tag / homebrew / docs-update flow with "no such file". Repointed at `Packages/Sources/Shared/Constants/Shared.Constants.swift`. The README/DEPLOYMENT version-badge regex literal `\*\*Version:\*\*` had also been incorrectly rewritten to `\*\*Release.Version:\*\*` during the namespacing sweep, so the bump succeeded silently against the markdown files but didn't actually patch them. Both regex literals restored. ### Internal - **Test suite at 1425 tests across 159 suites** (+ ~80 since the v1.0.2 baseline of 1346). Every closed DI leaf added a smoke test pinning its public surface; the §3.6 strategy refactor added `StrategyMissingDirectoryTests` covering the "directory not found" fast path for each concrete strategy. - **`EmptyParams` deduped** (#319, PR #471): three near-identical declarations (`MCP.Client.swift`, `MockAIAgent/main.swift`, `MockAIAgentTests.swift`) replaced by one canonical `MCP.Core.Protocols.EmptyParams: Codable, Sendable` in `MCPCore`. - **Empty `Apps/` placeholder removed** (#324 partial, PR #467). The CLI binaries live under `Packages/Sources/<Name>/` as SPM executable targets; no plan revives an `Apps/` folder. ### Still open The DI epic ring is not 100% closed. Three children remain, all heavy-tier: - **#400 Search** (producer-side standalone): lifting more value types out of `CorePackageIndexing` (`ExtractedFile`, `ResolvedPackage`, `PackageArchiveExtractor.Result`, `PackageAvailabilityAnnotator.AnnotationResult`) plus protocol-injecting `Core.JSONParser.MarkdownToStructuredPage` and `Sample.Core.Catalog`. Multi-PR architectural slice. - **#403 Indexer**: six distinct protocol seams (`Search.Index`, `Search.IndexBuilder`, `Search.PackageIndex`, `Search.PackageIndexer`, `Sample.Index.Database`, `Sample.Index.Builder`) , same factory-injection pattern `Services` received. - **#408 SearchToolProvider** (remaining): the `SampleIndex` and `Services` import drops require lifting `Sample.Index.Database`'s read surface into a protocol (mirrors the `Search.Database` move). ## 1.0.1 , 2026-05-08 _Binary-only bug-fix release on top of v1.0.0 "First Light". `databaseVersion` stays at `1.0.0`: `cupertino setup` from a v1.0.1 binary downloads the same `cupertino-databases-v1.0.0.zip` bundle. The #200 fix is preventive going forward , verified that the shipped v1.0.0 `search.db` has zero case-axis duplicate pairs (a `GROUP BY LOWER(uri) HAVING variants > 1` returned empty across 405,782 docs); Apple's JSON references during the v1.0.0 crawl happened to be uniformly lowercase, so the bug was dodged. Re-index would be ~12 h locally with no observable benefit on the existing corpus; future crawls would have hit the bug and v1.0.1 prevents that going forward. If a refreshed bundle is wanted later, ship as v1.0.1.1._ _Carried over: #199 (contentHash + id non-determinism) deferred to v1.0.2; needs a design pass and is not a bundle-DB concern._ ### Fixed - **`cupertino serve`: stale sibling processes reaped at startup** ([#242](https://github.com/mihaelamj/cupertino/issues/242)): MCP hosts (Claude Desktop, Cursor) launch a fresh server on every config reload but don't always reap the previous one when the host crashes or restarts. On a real dev machine: four orphan `cupertino serve` processes alive simultaneously, the oldest 4+ hours, all holding SQLite read connections, file descriptors, and ~hundreds of MB of warm AST cache each, plus making `cupertino save` more likely to fail with `database is locked`. `ServeCommand.run()` now calls `ServeReaper.reapSiblings()` before binding stdio: resolves own binary via `_NSGetExecutablePath` + `realpath`, lists processes via `ps -ax`, and for each candidate verifies the real binary path matches (so `brew` + dev installs coexist) and that the actual `argv[1]` is exactly `serve` (so a concurrent `cupertino save` is never reaped). argv parsing reads `KERN_PROCARGS2` directly via `sysctl` rather than splitting the joined `ps -o command=` line, because string heuristics anchored on the last `cupertino` substring kept regressing on realistic invocations like `cupertino serve --search-db /tmp/cupertino.db` (the heuristic landed inside the argument value, not on the binary basename). One stderr log line per reap; SIGTERM, 2s grace, SIGKILL fallback. 13 unit tests cover ps-line parsing and `KERN_PROCARGS2` argv parsing, including binary paths with spaces (`/Applications/My Tools/cupertino serve`), directories named `cupertino-*` (`/Volumes/cupertino-build/...`), and arguments containing `cupertino` (`--base-dir ~/.cupertino-dev`). - **Pre-release fix in v1.0.1**: `ServeReaper.listProcesses()` was deadlocking on busy machines. The original code called `task.waitUntilExit()` before `pipe.fileHandleForReading.readToEnd()`; on any system where `ps -ax` writes more than the ~64 KB pipe buffer, `ps` blocked on `write()`, the parent blocked on `ps` exiting, and `cupertino serve` hung indefinitely at startup, never binding stdio (so MCP hosts would time out). Caught by the v1.0.1 pre-flight on a machine with five live serve siblings: `MCPIntegrationTests.cupertinoServerInitialize` froze, the spawned `cupertino serve` subprocess sat at 0 % CPU with the main thread parked in `ServeReaper.listProcesses() → -[NSConcreteTask waitUntilExit]`. Fix: drain stdout via `readToEnd()` first (which blocks on EOF, i.e. `ps` closing stdout = `ps` exiting), then `waitUntilExit()` returns immediately as a status confirmation. Verified: the rebuilt binary starts up cleanly with five sibling serves alive on the system. - **URL canonicalization: case-axis duplicates collapse at index time** ([#200](https://github.com/mihaelamj/cupertino/issues/200), supersedes [#201](https://github.com/mihaelamj/cupertino/pull/201)): the crawler queue, the on-disk corpus, and the search-index save layer now collapse case-axis URL variants. `Crawler` normalizes URLs on session restore, on technology-index seed, and on the start-URL fallback; the in-flight queue check additionally compares against any normalized form of an enqueued URL so case-flip duplicates don't re-enter. `SearchIndexBuilder` gained a `deduplicateDocFilesByCanonicalURL` step that reads each doc's canonical URL out of the saved JSON (decoder configured with `.iso8601`, mirroring `indexStructuredDocument`) and keeps the file with the newest `crawledAt` when two files share a canonical URL after normalization. URI generation prefers the page's own URL (post-normalize) over the file path. Underscore→dash collapse was deliberately **not** added at the URLUtilities layer because at least one Apple framework (`installer_js`) requires the underscore. Verified that `documentation/installer-js` returns 404 from Apple. Locked in by a regression test (`URLUtilities normalize keeps underscores intact`). New test suite `IndexBuilderDeduplicationTests` covers the dedup helper directly: keep-newest-by-`crawledAt`-not-mtime, single-file pass-through, distinct-URLs-both-survive, and `loadStructuredPage` `.iso8601` round-trip. Co-authored with [@imwyvern](https://github.com/imwyvern) (Wesley), whose [#201](https://github.com/mihaelamj/cupertino/pull/201) supplied the crawler queue dedup, dedup helper, URI alignment, and case-axis tests. The fix runs at index time. v1.0.1 ships without a re-index (see release header above): the bundled v1.0.0 `search.db` happened to dodge the bug entirely (zero observable case-axis duplicate pairs), so re-indexing would have no measurable effect on the existing corpus. The fix is preventive for future crawls. No re-crawl needed regardless. - **`cupertino search --source packages` returns 0 results** ([#261](https://github.com/mihaelamj/cupertino/issues/261)): the `--source` dispatch in `SearchCommand.run()` lumped `packages` together with the doc-shaped sources (`apple-docs`, `apple-archive`, `swift-evolution`, `swift-org`, `swift-book`) and routed them all to `runDocsSearch`, which queries `search.db` only. Packages live in their own DB (`packages.db`) with their own fetcher (`PackageFTSCandidateFetcher`); querying `search.db` for `source = 'packages'` rows always returned empty. The unified fan-out path correctly opened `packages.db` via `openPackagesFetcher`, so default search returned package results, masking the bug. `--source packages` now routes to a new `runPackageSearch` runner that wraps `Search.PackageFTSCandidateFetcher` in a single-fetcher `Search.SmartQuery` and renders through the same `printSmartReport` formatter as the default fan-out. Output JSON shape is `{candidates, contributingSources: ["packages"], question}`, consistent with the unified search rather than the per-source list views. Honors `--platform` / `--min-version` filters and `--packages-db` override. Verified: `cupertino search "alamofire" --source packages` was returning 5 bytes (`[]\n`); now returns SourceKitten / SWXMLHash / package-collection results. ### Changed - **Cupertino skill (`skills/cupertino/SKILL.md`) gained query-strategy + verification guidance** ([#260](https://github.com/mihaelamj/cupertino/pull/260)): the previous version was a thin command reference. The new version teaches the LLM to translate descriptive queries to canonical Apple terms before searching (translation table for SwiftUI / UIKit / AppKit primitives), handle typos itself (cupertino does exact lexical matching, not fuzzy), infer framework from context, prefer current API over deprecated, recover when results are weak (paradigm bridge / conceptual phrasing / samples corpus), and surface what was tried instead of silently rewriting. Added a citation-and-verification section: cite-as-you-go for every API mention (~5% token overhead, prevents most API-knowledge hallucinations), re-search uncertain claims as needed, never fabricate parameters / return types / availability. Also corrects the example JSON shapes to match cupertino's actual response (`candidates` / `identifier` / `question`, not the previously-claimed `results` / `uri` / `count` / `query` keys), documents per-source view shapes, and refreshes doc count from 300k to 405k pages. ### Internal - **`CLAUDE.md` refreshed for v1.0.1** ([#263](https://github.com/mihaelamj/cupertino/pull/263)): post-First-Light state (closed the v1.0.0 phase framing, listed the v1.0.1 milestone bugs, replaced the dead `fix/all-open-bugs-2026-04` branch reference with the trunk-based per-bug workflow). v1.1+ research notes now point at `mihaela-blog-ideas/cupertino/research/`. --- ## 1.0.0 "First Light" , 2026-05-05 _The first release we'd call properly stable. Consolidates what was originally scoped as v0.11.0 (packages-overhaul) + v0.12.0 (docs-overhaul) into a single cut. Release plan: [#192](https://github.com/mihaelamj/cupertino/issues/192). Canonical roadmap: [#183](https://github.com/mihaelamj/cupertino/issues/183)._ ### Search ranking , canonical type queries land their canonical answer A multi-pass ranker rewrite for the smart-query fan-out + the apple-docs source. Pre-1.0 the default `cupertino search Task` returned a Mach kernel C-function essay; post-1.0 it returns the Swift `Task` struct, and the same shape holds for every common single-token type query (Task, View, URLSession, Color, String, Result, Array, Optional, Image, Text, URL, Data, Date, Sequence, AsyncSequence, Hashable, Codable, Comparable, Equatable, Sendable, Identifiable, …). 34 of 35 canonical queries land their canonical apple-docs page at fused #1 against the v1.0 corpus (the holdout, `Stack`, has no canonical Swift / SwiftUI / Foundation type and resolves to `tvml/Stack` correctly). - **Intent-routed fan-out** ([#254](https://github.com/mihaelamj/cupertino/issues/254)): `Search.SmartQuery` detects symbol-shaped queries (single token, ≥2 chars, ASCII identifier, leading uppercase) and prunes the fetcher set to apple-docs + swift-evolution + packages before fan-out. Prose queries keep the all-source path. Stops apple-archive's "Common Tasks in OS X" essay from tying with Swift's `Task` struct on the fused rank. - **Authority-weighted RRF** ([#254](https://github.com/mihaelamj/cupertino/issues/254)): replaces plain RRF's `1/(k+r)` with `weight[source]/(k+r)`. apple-docs 3.0, swift-evolution / packages 1.5, swift-book / swift-org 1.0, apple-archive / hig 0.5. Cross-source rank-1 ties resolve without per-query intent routing carrying the whole load. - **HEURISTIC 1 split** ([#254](https://github.com/mihaelamj/cupertino/issues/254)): the suffixed " | Apple Developer Documentation" title marks Apple's parent landing page for a type. Suffixed pages get a 50× exact-title boost; clean-titled siblings keep the previous 20×. Flips canonical-vs-sub-symbol order on the apple-docs side without touching BM25F weights. - **HEURISTIC 1.5 , exact-title peer tiebreak** ([#256](https://github.com/mihaelamj/cupertino/issues/256)): when multiple apple-docs pages all hit the exact-title boost (e.g. `Result` matches Swift's enum, Vision's associated type, Installer JS's runtime type), URI-simplicity (top-level type page vs sub-symbol) and a narrow framework-authority map (`Search.Index.frameworkAuthority`) break the tie. Fires only inside the exact-title branch , does not crowd out framework-specific symbol queries (`VisionRequest` still resolves to `vision/VisionRequest`). - **Force-include canonical type pages past fetchLimit** ([#256](https://github.com/mihaelamj/cupertino/issues/256) follow-on): for top-tier frameworks (swift, swiftui, foundation), `Search.Index.fetchCanonicalTypePages` hand-fetches `apple-docs://FRAMEWORK/documentation_FRAMEWORK_QUERY` directly by URI. Probes by docs_metadata PK (5 ms), not an FTS5 scan. Catches canonicals BM25 buries past the candidate cutoff (Foundation `URL` at raw rank 1017, Swift `Identifiable` at 2577, Foundation `Data` past 3000). - **`doc_symbols_fts` post-rank sign error** ([#254](https://github.com/mihaelamj/cupertino/issues/254)): `result.rank * 0.3` on a negative BM25 rank was a *demotion*, not the documented "3x boost". Changed to `* 3.0`. Canonical Swift types have AST symbols indexed; kernel C functions don't, so the sign error was silently letting kernel C functions outrank Swift types. - **`fetchLimit` floor at 1000** ([#254](https://github.com/mihaelamj/cupertino/issues/254)): smart-query fan-out used to over-fetch only 200 rows; canonical Swift `Task` struct sits at raw BM25 position 241, never made the candidate set. Floor at 1000, ceiling 2000. - **packages.db canonical-repo force-include**: when query tokens (or their dash-joined form) match an indexed `repo` name exactly, `Search.PackageQuery` fetches that repo's top BM25 file as a force-included candidate. Catches `vapor middleware` → vapor/vapor (was swift-openapi-generator), `swift testing` → swiftlang/swift-testing, `swift dependencies` → pointfreeco/swift-dependencies. Two-tier match: dashed forms (`swift-testing`) outrank single-token forms (`swift`) so `swift testing` doesn't pull in swiftlang/swift via the bare token. - **Search.Index DB lock fix**: every `Search.Index.init` used to issue an unconditional `PRAGMA user_version = N` write , two parallel `cupertino search` invocations contended on the open-time write lock and one would fail with `database is locked` (SQLite's default `busy_timeout` is 0). `setSchemaVersion` now read-then-write (skips the PRAGMA when version already matches) and `sqlite3_busy_timeout(db, 5000)` is set right after open so any future contention degrades to a wait-then-succeed. - **Search perf , canonical/framework-root probes use docs_metadata PK**: the `fetchCanonicalTypePages` and `fetchFrameworkRoot` helpers used to query `docs_fts` with `WHERE f.uri = ?`, which forced a full FTS5 scan (3.2 s per probe on the v1.0 corpus). Now query `docs_metadata` only, with title/summary read via `json_extract(json_data, '$.title' / '$.abstract')`. 5 ms per probe , single-process search wall time on the 3.4 GB corpus dropped from ~18 s to ~4 s. - **Field-weighted BM25 (BM25F) on apple-docs** ([#181](https://github.com/mihaelamj/cupertino/issues/181), subsumes earlier title-weight work): per-column weights `bm25(docs_fts, 1.0, 1.0, 2.0, 1.0, 10.0, 1.0, 3.0, 5.0)` , title 10×, AST-derived symbols 5×, summary 3×, framework 2×. The retrieval foundation the heuristics above sit on top of. ### Distribution , single-bundle release - **All three databases ship in one bundle** on `mihaelamj/cupertino-docs`: `cupertino-databases-vX.zip` contains `search.db` + `samples.db` + `packages.db`. The earlier scoping had a separate `mihaelamj/cupertino-packages` companion repo for `packages.db`; that repo proved to be needless complexity (same crawl, same schedule, two release tags) and is gone. `cupertino setup` is one download + one extract. - **`cupertino-rel databases`** ([#259](https://github.com/mihaelamj/cupertino/issues/259)): the release tool bundles all three DBs into a single zip and uploads to the docs repo. Hard-fails if `packages.db` is missing under `--base-dir` unless `--allow-missing-packages` is passed (lets a release runner publish a partial bundle in time-sensitive cases without making it the default). Generic publishing primitives (zip, sha256, GitHub API, upload progress, token resolution) factored into a shared `ReleasePublishing` helper. - **`cupertino setup` rewrites the pipeline** to a single download + extract + version stamp. Removes the previous "docs zip → extract → packages zip → extract → soft-fail-if-missing" path. All three DBs are now required post-extract; any missing DB is a hard fail rather than a warning. (#246 lifted SetupCommand's logic into a `Distribution` package; this release simplifies the pipeline that lift exposed.) - **`cupertino setup` backs up existing DBs** before overwrite ([#249](https://github.com/mihaelamj/cupertino/issues/249)): each pre-existing DB renames to a `.backup-<version>-<iso8601>` sibling before extraction would clobber it. User can roll back by renaming the backup over the new file if v1.0 misbehaves. ### Changed - **Four-package CLI lift: `Distribution`, `Diagnostics`, `Indexer`, `Ingest`** ([#244](https://github.com/mihaelamj/cupertino/issues/244), [#245](https://github.com/mihaelamj/cupertino/issues/245), [#246](https://github.com/mihaelamj/cupertino/issues/246), [#247](https://github.com/mihaelamj/cupertino/issues/247)): logic that powered four CLI commands , `setup`, `doctor`, `save`, `fetch` , moved out of `Sources/CLI/Commands/*` into four new SPM packages so MCP tooling, future agent-shell adapters, and tests can drive the pipelines without depending on `ArgumentParser`. CLI files become thin front-doors that parse flags + render progress. - **`Distribution`** (#246): download + extract + version-stamp pipeline. `SetupService` orchestrator emits `Event` callbacks (download progress / extract ticks / status changes); `ArtifactDownloader` (URLSession + progress callback), `ArtifactExtractor` (`/usr/bin/unzip` wrapper), `InstalledVersion` (status classification + stamp file r/w), `PackagesReleaseURL` (relocated from CLI). Drive-by fix: `InstalledVersion.classify` now requires all three DBs (search, samples, packages) to be present for non-`.missing` states , pre-#246 it ignored packages.db, so a partial install reported `.current` and skipped the redownload. `SetupCommand.swift` 478 → 177 LoC. - **`Diagnostics`** (#245): pure-data probes for SQLite + filesystem corpus. `Probes.userVersion` / `perSourceCounts` / `rowCount` / `countCorpusFiles` / `packageREADMEKeys` / `userSelectedPackageURLs` / `ownerRepoKey`, plus `SchemaVersion.format`. Zero external deps; opens DBs via `SQLITE_OPEN_READONLY`. `DoctorCommand.swift` 596 → 400 LoC. Foundation for a follow-up `HealthReport` + `DoctorService` so MCP can expose the same diagnostic data as a tool. - **`Indexer`** (#244): write-side counterpart to `Search`. Three indexer services (`DocsService` wraps `Search.IndexBuilder`, `PackagesService` wraps `Search.PackageIndexer`, `SamplesService` wraps `SampleIndex.Builder`) each emit per-stage `Event` callbacks. `Preflight` namespace hosts the #232 preflight pipeline (`preflightLines`, `checkDocsHaveAvailability`, `sampleDocsAvailability`, `countPackagesAndSidecars`) , used by both `cupertino save` (write-time prompt) and `cupertino doctor --save` (read-only health check). `SaveCommand.swift` 828 → 312 LoC + 229 LoC in `SaveCommand+Indexers.swift` (split to fit `type_body_length` 300-line ceiling). `--remote` mode stays in CLI for now; lifting it needs the underlying `RemoteIndexer` pipeline to grow a callback shape. - **`Ingest`** (#247 sub-PR 4a): package skeleton + `Session` helpers , five static lifted from `FetchCommand` (`clearSavedSession`, `requeueErroredURLs`, `requeueFromBaseline`, `enqueueURLsFromFile`, `checkForSession`) plus internals (`collectBaselineURLs`, `lowercaseDocPath`) and `FetchURLsError`. `FetchCommand.swift` 1279 → 1022 LoC. The seven `<Type>Pipeline` services (docs / packages / samples / evolution / archive / hig / availability) stay in CLI for now , those need a callback-based shape before they can lift cleanly. Tracked as follow-up sub-PRs 4b–4f in #247. - All four packages: `swift build` clean, full suite passes (1163 tests / 126 suites , 5 new tests added per package). - **`cupertino read` unified across docs / samples / packages** ([#239](https://github.com/mihaelamj/cupertino/issues/239) follow-up): a single command now dispatches to all three backends. URI scheme (`apple-docs://...`, `hig://...`, etc.) → docs (search.db). Slugified id with no `/` → sample project. `<projectId>/<path>` → sample file. `<owner>/<repo>/<path>` → package (read from `package_files_fts.content`, no on-disk corpus needed). `--source <name>` disambiguates sample-file vs. package paths. Logic lives in `Services/Commands/ReadService.swift`. `Search.PackageQuery.fileContent(owner:repo:relpath:)` added so `cupertino setup`-only installs (no `~/.cupertino/packages/` tree) work for package reads. - **`cupertino search` emits read-full hints + `--brief` mode** ([#239](https://github.com/mihaelamj/cupertino/issues/239) follow-up): every fan-out result now prints `▶ Read full: cupertino read <id> --source <name>` after the chunk (text), `- **Read full:** ...` (markdown), `readFullCommand` field (json) , uniform, source-aware. `--brief` flag truncates each chunk to 12 non-blank lines for triage; default stays full chunks. Footer adds `See also` (per-source drill-in commands) + tips (narrow with `--source`, platform-filter hint). ### Removed - **`cupertino ask` subcommand removed: absorbed into `cupertino search`** ([#239](https://github.com/mihaelamj/cupertino/issues/239)): two CLI commands serving overlapping needs collapsed into one. `cupertino search "<question>"` (no `--source`) now runs the SmartQuery fan-out across every available DB with reciprocal-rank-fusion ranking and chunked excerpt output , exactly what `ask` did. `cupertino search --source <name>` keeps its existing list-style output unchanged. The `--platform` / `--min-version` / `--per-source` / `--skip-docs` / `--skip-packages` / `--skip-samples` / `--packages-db` flags carried over from `ask`. JSON / markdown output formats also produce SmartQuery-shaped chunks now , the previous `UnifiedSearchService` path is gone. Pre-1.0 clean break, no alias , `cupertino ask` errors with `unknown command`. Subcommand count drops 15 → 14. `package-search` (hidden) stays as the packages-only shortcut. `SearchCommand.swift` was split alongside the merge: per-source runners moved to `SearchCommand+SourceRunners.swift`, fan-out plumbing + chunked printers to `SearchCommand+SmartReport.swift` (lint type-body-length compliance). CHANGELOG, docs (`docs/commands/ask/` deleted, `docs/commands/search/` expanded), and `CommandRegistrationTests` updated. ### Fixed - **Sample search FTS5 query OR-joins instead of AND-joining every token** ([#238](https://github.com/mihaelamj/cupertino/issues/238)): `SampleIndex.Database.searchFiles` and `searchProjects` were space-AND'ing every quoted token from the input, so a natural-language query like `"how do I animate a swiftui list"` resolved to `"how" "do" "I" "animate" "a" "swiftui" "list"` , implicit AND across seven phrases, no sample file matched all seven, samples returned zero. Lifted the tokenization helpers from `Search.PackageQuery` into a new `Shared.FTSQuery` namespace (`tokens(from:stopwords:)` + `build(question:)`) that strips stopwords and OR-joins. Both `SampleIndex.Database` paths now share that builder with `PackageQuery`. Drive-by on `Services.SampleCandidateFetcher`: emit project-level matches in addition to file matches , natural-language queries frequently score a project's title/README without lighting any single file's content. Smoke run: `cupertino ask "how do I animate a swiftui list" --skip-docs` now returns the SwiftUI-animation sample at position 1, with `Searched: packages, samples`. - **`cupertino search --source samples` no longer fails when search.db is locked** ([#237](https://github.com/mihaelamj/cupertino/issues/237)): the command was unconditionally fetching teaser previews from search.db even when scoped to samples-only. When another process held an EXCLUSIVE write lock (typically a long-running `cupertino save --docs`), the teaser fetch threw and the whole command aborted before samples results were rendered. Wrapped the teaser fetch in do/catch , on failure logs a one-line info note and falls back to empty `TeaserResults`. Samples results display unchanged. `ask --skip-docs` already had similar resilience via fetcher-failure-collapses-to-empty (#220). ### Added - **Date-based schema-version helpers + doctor surfacing** ([#234](https://github.com/mihaelamj/cupertino/issues/234)): new `Shared.SchemaVersion` namespace produces fixed-width 12-char `YYYYMMDDhhmm` strings (`make`, `now`, `components`, plus a `dateOnlyInt32` fallback for `PRAGMA user_version` and `iso8601Now` for human-readable audit fields). Each DB will switch over on its next real schema bump , keeps the `if currentVersion < N` migration ladder intact since old sequential ints sort below any reasonable date-style value. `cupertino doctor` gained a Schema-versions section that prints `PRAGMA user_version` from search.db, packages.db, and samples.db and labels each as date-style or sequential, so a stale machine in the multi-Mac sync setup is one command away from being obvious. 12 new tests cover fixed-width, round-trip, range validation, lex-ordering. Convention is custom , most SQLite ecosystems use sequential ints , but documented. - **`--platform` / `--min-version` now scope `ask` results from samples + docs too** ([#233](https://github.com/mihaelamj/cupertino/issues/233)): the filter is no longer packages-only. `SampleCandidateFetcher` accepts `availability:` and JOINs `projects` on `min_<platform>` in the SQL. `DocsSourceCandidateFetcher` accepts `availability:` and forwards to `Search.Index.search`'s existing `minIOS` / `minMacOS` / `minTvOS` / `minWatchOS` / `minVisionOS` params (which already do proper semver compare in memory). Swift-language-version sources (`swift-evolution`, `swift-org`, `swift-book`) silently drop the filter , their pages don't carry OS-version columns; that axis lives under #225. The unfiltered-source notice in `ask` now lists only those three sources, since apple-docs / apple-archive / hig / packages / samples all honour the filter. - **samples.db now persists per-sample availability** ([#228](https://github.com/mihaelamj/cupertino/issues/228) phase 2): schema bumped 2→3 (no migration; `save --samples` always wipes and rebuilds). `projects` table gains `min_ios` / `min_macos` / `min_tvos` / `min_watchos` / `min_visionos` / `availability_source` columns plus indexes; `files` gains `available_attrs_json` carrying the per-file `@available(...)` occurrences as a JSON array. `SampleIndexBuilder` now passes the parsed `Package.swift` deployment targets into the `Project` row and the per-file attribute list into each `File` row, so the same data the sidecar JSON writes is also queryable from SQL. `availability_source = "sample-swift"` when populated; `NULL` when the sample shipped no `platforms: [...]` block (typical of Apple's Xcode-project samples). Round-trip tests cover both columns. - **`cupertino save` preflight + `cupertino doctor --save`** ([#232](https://github.com/mihaelamj/cupertino/issues/232)): `save` now prints a per-scope summary before any DB write , which source dirs are present, how many packages have `availability.json` sidecars, whether the docs corpus has been annotated by `fetch --type availability` , then prompts `Continue? [Y/n]` and lets the user bail. Auto-skips the prompt when stdin isn't a TTY (CI / pipes) and via `--yes`. The same summary is reachable read-only as `cupertino doctor --save` for users who want to know "is save ready?" without committing to a run. `checkDocsHaveAvailability` was refactored into pure helpers (`sampleDocsAvailability`, `firstJSONFile`, `jsonContainsAvailability`) with named-constant tunables so tests can pin behavior. ### Changed - **`cupertino save` now builds all three databases by default; `cupertino index` removed** ([#231](https://github.com/mihaelamj/cupertino/issues/231)): scope flags `--docs` / `--packages` / `--samples` select a subset; with no scope flag passed `save` builds search.db, packages.db, and samples.db in that order, skipping any source directory that's missing with an info log. The standalone `cupertino index` command is gone , its body lives under `save --samples` (with `--samples-dir`, `--samples-db`, `--force` options renamed for symmetry). Pre-1.0 clean break, no alias. Subcommand count drops 16 → 15. ### Added - **`cupertino ask` now includes the samples corpus** ([#230](https://github.com/mihaelamj/cupertino/issues/230)): new `Services.SampleCandidateFetcher` adapts `SampleSearchService` to the `Search.CandidateFetcher` protocol so `Search.SmartQuery`'s reciprocal-rank fusion fans out across `apple-docs`, `apple-archive`, `hig`, `swift-evolution`, `swift-org`, `swift-book`, **packages**, and **samples** in one call. New `--skip-samples` flag and `--samples-db <path>` override mirror the existing `--skip-packages` / `--packages-db` shape. Default behaviour: samples included whenever `samples.db` exists. Smoke run: `ask "swiftui list animation" --skip-docs` returns sample matches with FTS5-extracted snippets alongside package hits. - **`--platform` / `--min-version` filters on `package-search` and `ask`** ([#220](https://github.com/mihaelamj/cupertino/issues/220)): two new options that restrict packages results to those whose declared deployment target is compatible with the named platform. Values: `iOS`, `macOS`, `tvOS`, `watchOS`, `visionOS` (case-insensitive). Both flags are required together; one without the other errors out. Filter pushes through `PackageFTSCandidateFetcher` → `Search.PackageQuery.AvailabilityFilter` → SQL JOIN on `package_metadata.min_<x>` with a lexicographic compare. Lex compare is correct for current Apple platform versions (iOS 13+, macOS 11+, tvOS 13+, watchOS 6+, visionOS 1+); old macOS 10.x with multi-digit minors would mis-order but no priority package currently targets that. Packages with NULL annotation source are dropped (no annotation = unknown = excluded). `ask` only filters its packages source , apple-docs / hig / archive / evolution remain unfiltered. - **packages.db now persists availability data** ([#219](https://github.com/mihaelamj/cupertino/issues/219) follow-up): `save --packages` reads each package's `availability.json` (produced by `fetch --type packages --annotate-availability`) and writes flat columns into `package_metadata` (`min_ios`, `min_macos`, `min_tvos`, `min_watchos`, `min_visionos`, `availability_source`) plus a `available_attrs_json` column on `package_files` carrying the per-file `@available(...)` occurrences as a JSON array. Mirrors the `docs_metadata` availability shape from #192 sec. C, so callers can filter packages by minimum platform without parsing JSON. Schema bump 1→2 with idempotent ALTER-TABLE migration; existing v1 DBs pick up the columns on next open. Verified on the May 2026 priority closure: all 183 packages have `availability_source = 'package-swift'` populated. The `available_attrs_json` column is NULL when no annotation file was present, so callers can distinguish "not annotated" from "annotated with no attrs". - **`fetch --type packages --annotate-availability`** ([#219](https://github.com/mihaelamj/cupertino/issues/219)): new opt-in stage 3 of the merged packages fetch. Walks every `<owner>/<repo>/` subdir under `~/.cupertino/packages/` and writes a per-package `availability.json` capturing the `Package.swift` `platforms: [...]` deployment-target block plus every `@available(...)` attribute occurrence in `Sources/` and `Tests/` (file path + line + parsed platform list). Pure on-disk pass , no network. Idempotent. Runs whether or not stage 2 just downloaded fresh archives, so you can re-annotate an existing corpus by combining `--skip-metadata --skip-archives --annotate-availability`. Smoke run on the May 2026 priority closure: 183 packages annotated, 13.5k `@available` attrs in 12s. Regex-based scanner , multi-line attrs aren't handled and hits aren't tied to specific declarations; the AST upgrade (extending `ASTIndexer.SwiftSourceExtractor`) is a follow-up. ### Fixed - **Dev binary now writes to `~/.cupertino-dev/` automatically** ([#218](https://github.com/mihaelamj/cupertino/issues/218)): `make build-debug` and `make build-release` now drop a `cupertino.config.json` next to the produced binary with `{ "baseDirectory": "~/.cupertino-dev" }`. Previously a locally-built dev binary silently fell through to brew's `~/.cupertino/`, clobbering the installed user's data mid-flight (hit on the 2026-05-03 packages-overhaul rebuild). Brew bottles still ship only the binary , released installs continue to resolve to the standard `~/.cupertino/`. Override at invocation: `make build-debug DEV_BASE_DIR=~/some-other-dir`. - **`PriorityPackagesCatalog` additively merges new embedded entries into existing user files** ([#218](https://github.com/mihaelamj/cupertino/issues/218)): `ensureUserSelectionsFileExists` used to no-op once `~/.cupertino/selected-packages.json` existed, so adding new seeds to `PriorityPackagesEmbedded.swift` never propagated to existing installs. A Dec 2025 user file frozen at the priority list from then was missing the April 2026 `mihaelamj/*` additions despite the embedded JSON having them. Fix: on every load, set-diff against the user file (matched on `owner.lowercased()/repo.lowercased()`) and append any embedded entries the user file is missing. Idempotent, never removes, prints a one-line `📥 selected-packages.json: added N new priority entries…` summary on the run that adds anything. User deletions don't stick , that's a deliberate trade-off (separate "removed" list would be needed; called out in the #218 comment). - **FetchCommand "Next:" hint now points at the real save flag** (`save --packages`, not the non-existent `save --type packages`). ### Changed - **Merged `fetch --type packages` and `fetch --type package-docs`** ([#217](https://github.com/mihaelamj/cupertino/issues/217)): a single `--type packages` now runs the Swift Package Index metadata refresh and the priority-package GitHub archive download back-to-back. New `--skip-metadata` / `--skip-archives` flags gate either stage individually; passing both is an error. The two were already adjacent in every workflow, shared the `~/.cupertino/packages/` output dir, and the `package-docs` name was misleading (it pulled whole archives, not READMEs). The `package-docs` raw value is gone , invocations using it now error with the help text. `directFetchTypes` count dropped 7→6, `allTypes` 10→9. `--type all` still covers both stages because the merged command is what runs. ### Added - **Binary-co-located config file** ([#211](https://github.com/mihaelamj/cupertino/issues/211)): new `Shared.BinaryConfig` reads an optional `cupertino.config.json` from the directory of the running executable (symlinks resolved). One key supported today: `baseDirectory` (tilde-expanded). When present, every default path in `Shared.Constants.default*` plus `SampleIndex.defaultDatabasePath` and `SampleIndex.defaultSampleCodeDirectory` redirects under that base, so `fetch`, `save`, `serve`, `ask`, `doctor`, and the samples DB all follow uniformly without env vars or per-command flags. Missing file or any decode error falls through to the existing `~/.cupertino/` default, so installs without the file behave identically to before. Use case: run a dev build alongside an installed brew binary against separate corpora. Contract test (`BasePathDerivationTests`, `SampleIndexBasePathDerivationTests`) asserts every default path derives from `defaultBaseDirectory`, so a future getter that bypasses it fails at test time. - **`cupertino resolve-refs` subcommand** ([#208](https://github.com/mihaelamj/cupertino/issues/208)): post-process pass that walks a directory of saved `StructuredDocumentationPage` JSON files (typically from a `--discovery-mode json-only` crawl), harvests a global `identifier → title` map from each page's `sections[].items[]`, and rewrites every `doc://com.apple.<bundle>/...` marker in `rawMarkdown` to the readable title. Pure post-process by default: no network, no recrawl. Markers pointing to pages no other page references are left intact and surfaced via `--print-unresolved`. - **`resolve-refs --use-network` and `--use-webview` flags** ([#208](https://github.com/mihaelamj/cupertino/issues/208)): opt into a second pass that fetches titles for the still-unresolved markers via Apple's JSON API (`--use-network`), or also falls back to WKWebView when the JSON API can't serve a marker (`--use-webview`, slow, macOS only). - **`fetch --urls <path>` flag** ([#210](https://github.com/mihaelamj/cupertino/issues/210)): read URLs from a text file (one per line) and enqueue each at depth 0, with the crawler following links from each up to `--max-depth`. Set `--max-depth 0` to fetch only the listed URLs with no descent. Useful for fetching a fixed list of URLs another corpus has but this one is missing, without re-spidering. `#`-prefixed and blank lines are ignored. - **Crawl depth stamped on every saved page**: each `StructuredDocumentationPage` JSON now carries the depth at which it was discovered, so corpus auditing and per-depth analysis no longer need to recompute from link graphs. ### Fixed - **Exponential retry backoff for crawler page failures** ([#209](https://github.com/mihaelamj/cupertino/issues/209)): a transient page failure used to retry immediately, hammering the same URL on the same network blip. Backoff is now 1s / 3s / 9s / capped at `Shared.Constants.Delay.retryBackoffMax` for attempts 1+. Capped, so a hard-failing page can't stall the crawl indefinitely. - **Hardcoded `~/.cupertino/` paths in user-facing strings** (#211 follow-up): four offenders that would print the wrong path under a `BinaryConfig` override now interpolate the resolved path. `SearchIndex.swift` schema-mismatch errors (versions 5 and 12 migration thresholds) now suggest `rm <actual-search-db-path> && cupertino save`. `FetchCommand` priority-packages "not found" message now prints `Shared.Constants.defaultPackagesDirectory.appendingPathComponent("priority-packages.json").path`. `IndexCommand` discussion text and `--sample-code-dir`/`--database` help defaults, plus `CleanupCommand` `--sample-code-dir` help default, all interpolate `SampleIndex.default*` and `Shared.Constants.defaultSampleCodeDirectory` so `--help` reflects the actually-configured paths. - **URL canonicalization , case axis** ([#200](https://github.com/mihaelamj/cupertino/issues/200)): `URLUtilities.normalize` now lowercases the URL path. Apple's docs server is case-insensitive on the path, so `/documentation/Cinematic/CNAssetInfo-2ata2` and `/documentation/cinematic/cnassetinfo-2ata2` return the same content. The crawler previously treated each casing as a distinct URL , visited set held both, queue was inflated ~3× with case duplicates (62 % of queue entries on the April 2026 in-flight crawl), ETA estimates were correspondingly off. Fragment and query stripping unchanged. Path-segment dash-vs-underscore variants are NOT collapsed: at least one Apple framework (`installer_js`) legitimately uses underscore in its canonical path, and observed dash/underscore "duplicates" (e.g. `professional-video-applications` vs `professional_video_applications`) turned out to be Apple serving distinct documentation sets at similar slugs, not URL aliases. That axis will be handled at the search-index save layer if and when real duplicates are observed; the canonicalization patch alone is conservative. ### Added **Search quality , field-weighted BM25 with AST-extracted symbols (#192 sections C + D, subsumes #176)** The retrieval technique is **BM25F** (field-weighted BM25, Robertson/Zaragoza/Taylor 2004) over a structured 8-column FTS5 index (`uri`, `source`, `framework`, `language`, `title`, `content`, `summary`, `symbols`), augmented with a Swift AST symbol extractor. Public API surface: `Search.SmartQuery` (see "smart-query wrapper" below). Underneath: - `Search.DocKind` taxonomy: 10-case enum (`symbolPage`, `article`, `tutorial`, `sampleCode`, `evolutionProposal`, `swiftBook`, `swiftOrgDoc`, `hig`, `archive`, `unknown`) populated at index time by `Search.Classify.kind(source:structuredKind:uriPath:)` , a pure deterministic function. Stored in new `docs_metadata.kind` column. - `docs_metadata.symbols` denormalized blob + `docs_fts.symbols` FTS column. Both populated by an AST pass (`SwiftSourceExtractor` over both code-block content AND declaration lines) so a query like `Task` ranks the Swift `Task` struct page above prose mentions of the word "task". The BM25F weight vector is `bm25(docs_fts, 1.0, 1.0, 2.0, 1.0, 10.0, 1.0, 3.0, 5.0)` , title dominates (10×), symbols next (5×), summary (3×), framework (2×). - `idx_kind` index for per-kind routing queries. - `Search.Index.extractCodeExampleSymbols` + `recomputeSymbolsBlob` (private) , a single source of truth that reads `doc_symbols` and writes both denormalized columns, so declaration-derived and code-block-derived symbols flow into ranking uniformly. **smart-query wrapper , `Search.SmartQuery`, exposed as `cupertino search` (#192 section E, #239)** The user-facing label is **smart-query** (lowercase prose). The technique is **reciprocal rank fusion (RRF)** of per-source BM25F rankings , Cormack, Clarke, Büttcher 2009. Originally shipped as `cupertino ask`; merged into the default `cupertino search` fan-out per #239 before tag. - Public-facing CLI: `cupertino search "<question>"` runs the question across every available source (apple-docs, apple-archive, hig, swift-evolution, swift-org, swift-book, packages) in parallel and returns a fused top-N. No `--source` flag needed. - `Search.SmartCandidate` source-agnostic result struct. `Search.CandidateFetcher` protocol with one method, `fetch(question:limit:)`, per source. Concrete impls: `PackageFTSCandidateFetcher` (wraps `Search.PackageQuery.answer`), `DocsSourceCandidateFetcher` (wraps `Search.Index.search` for any apple-docs-style source). - `Search.SmartQuery` fans fetchers out via `TaskGroup`, fuses per-source rankings via RRF (k=60, the standard default). Failing fetchers collapse to empty , one dead DB never takes the whole query down. Per-fetcher limit caps noisy sources before fusion so a verbose source can't drown out a strong single hit. - `cupertino package-search` (hidden) is now a thin wrapper on `SmartQuery` with a single `PackageFTSCandidateFetcher`, so ranking tweaks land in one place. **MCP protocol bump , 2025-11-25 (#192 section G, subsumes #139)** - `MCPProtocolVersion` 2025-06-18 → **2025-11-25**. `MCPProtocolVersionsSupported` widened to `[2025-11-25, 2025-06-18, 2024-11-05]` for backward-compat across three negotiation hops. - New `Icon` struct (`src` / `mimeType` / `sizes`) Codable + Hashable + Sendable. - `Implementation` gains optional `icons: [Icon]?`. Nil by default; legacy 2025-06-18 / 2024-11-05 handshakes decode legacy payloads unchanged. - `MCPServer.init(name:version:icons:)` accepts an optional icons array. `cupertino serve` now advertises a 64×64 PNG via `data:image/png;base64,...` URI, embedded in `CupertinoIconEmbedded.swift` following the same Swift-literal pattern as #161 (no asset bundle, no symlink resolution). - `assets/cupertino-icon-64.png` ships in the repo as the source-of-truth (1671 bytes, systemBlue rounded square with a white "C"). Placeholder; a designer can replace. **Doctor diagnostics (#192 section F)** - `cupertino doctor` reports both `search.db` and `packages.db` presence, file size, row counts. Reads `PRAGMA user_version` directly (without going through `Search.Index`, whose init throws on incompatible versions) so the user sees the actual on-disk version even when it's incompatible. - Schema-mismatch path: `older` → "rm + cupertino save" hint, `newer` → "brew upgrade cupertino" hint. Exits non-zero so CI / smoke tests fail loudly. - `packages.db` row counts (packages, package_files) + bundled `databaseVersion` for at-a-glance install verification. **Distribution + packaging (#192 section B)** - All three databases ship in a **single bundle** , `cupertino-databases-vX.zip` on `mihaelamj/cupertino-docs`. v1.0.0 is the first release where `packages.db` is included alongside `search.db` and `samples.db`. (Earlier scoping had a separate `mihaelamj/cupertino-packages` companion repo for `packages.db`; that proved to be needless complexity. Setup is one download, one extract.) - `cupertino setup` is the **single command** that owns every database. Downloads + extracts the bundle from `cupertino-docs` and stamps the version file on success. No granularity flag , the previous `cupertino packages-setup` is removed. - `cupertino-rel databases` (the release tool) bundles all three DBs together. Hard-fails if `packages.db` is missing under `--base-dir` unless `--allow-missing-packages` is passed; lets a release runner publish a partial bundle in genuinely time-sensitive cases without making it the default. (#259) - `Shared.Constants.App.docsReleaseBaseURL` is the only release URL constant. **Per-URL JSON-then-WebView fallback (`fetch --type docs`)** - `cupertino fetch --type docs` does a single pass through the queue, trying Apple's JSON API for each URL and falling back to WKWebView when a page has no JSON endpoint. **One of cupertino's coverage advantages over single-pass JSON-only MCPs** , every URL gets a chance at both transports without doubling the queue. (The fallback was already implemented in `Crawler.swift`; the previous "two-pass" orchestration in `FetchCommand` was redundant , it ran the same crawler twice , and is now removed along with the dead `--use-json-api` flag.) - **Auto-resume by default**: if `metadata.json` has an active session matching the start URL, `cupertino fetch` picks it up without any flag. The previous `--resume` flag was just a log-message switch and is removed. - **`--start-clean`**: new flag. Wipes `metadata.json`'s `crawlState` (queue + visited set) before running so the crawl starts fresh from the seed URL. Page-level state on disk is preserved , combine with `--force` to also re-fetch unchanged pages. - **Crash-safe metadata save**: `JSONCoding.encode(_:to:)` now writes with `.atomic` (temp + rename), so a kill mid-save can never leave `metadata.json` corrupt. Mid-save corruption was the one failure mode that could make a multi-day crawl unresumable. - `defaultMaxPages` constant raised 15,000 → **1,000,000**. Effectively uncapped for full Apple-corpus crawls (~50–80k pages); previous 15k default would silently truncate at ~15–30% coverage. **Reproducible re-crawl pipeline (#192 section I scaffolding)** - `scripts/recrawl.sh` orchestrates the full v1.0 re-crawl: wipes stale DBs + crawl manifests + per-source raw output dirs (true clean slate for schema bumps), then runs phases 1–10 sequentially with named markers (`=== Phase N/10: <name> , START HH:MM:SS ===`) so a tail-following watcher can spot stage transitions and per-phase wall clock at a glance. - Phase order: docs → evolution → swift → hig → archive → packages → package-docs → code → save → doctor. `code` (sample-code with WKWebView sign-in) is intentionally last so the long automated phases run unattended. - `make test-clean` Makefile target wraps `clean + test` for the SwiftPM stale-build SIGTRAP escape hatch. **Crawler quality + steerability** - **`fetch --discovery-mode <auto|json-only|webview-only>`**: `auto` (default) preserves the existing JSON-primary + WKWebView-fallback behaviour. `json-only` runs the JSON API and skips the fallback (fastest, narrowest discovery). `webview-only` runs WKWebView for everything (slowest, broadest discovery, matches pre-2025-11-30 behaviour). Lets the user trade coverage for speed without code changes. - **`fetch --baseline <path>`**: on startup, URLs present in a known-good baseline corpus directory (e.g. a prior `cupertino-docs/docs` snapshot) but missing from the current crawl's known set are prepended to the queue, so a resumed crawl recovers gaps without a full recrawl. Path comparison is case-insensitive (mirrors #200 normalisation). - **`fetch --retry-errors`**: re-queues URLs that errored before save (visited but missing from the pages dict). Use after a filename or save bug is fixed to retry only the affected pages without re-crawling the whole corpus. Prepended so retries go first. - **Filename-length cap**: extremely long Apple framework paths used to produce filenames that exceeded the OS limit and silently dropped pages on save. Filenames now cap with a content-derived suffix so the URL stays the unique key while the on-disk filename stays valid. - **DocC link discovery walks the full `references` dict**: previous crawler followed only direct identifiers in `topicSections`/`seeAlsoSections`, missing references reachable through nested item dicts. The walk now recurses through every value in `references` so frameworks with deep cross-linking (e.g. Foundation) discover the full transitive set in one pass. - **Queue dedup at enqueue time**: visited-set + queue-set membership check moved to the enqueue path so the same URL isn't pushed multiple times. Combined with #200 case normalisation, the April 2026 in-flight crawl's queue dropped from 518k entries to 198k unique URLs without losing coverage. - **Per-page sync work wrapped in `autoreleasepool`**: long crawls used to leak Foundation autoreleased objects across thousands of pages, growing RSS until the OS killed the process around hour 12 of a multi-day run. Each page's parse + transform + save block is now its own autorelease scope, so RSS stays flat across an indefinite crawl. **From the original v0.11.0 scope (pre-consolidation)** - **Transitive dependency resolution for `fetch --type package-docs`** (#184): each seed is walked through its `Package.swift` (libraries commit this; most lockfiles are `.gitignored`), then `Package.resolved` as a fallback for apps. Dependencies on `github.com` are added to the fetch queue. Non-GitHub URLs, missing manifests, and malformed manifests are counted and skipped. Opt out with `--no-recurse`. Terminates via canonical-name dedupe. - **GitHub redirect canonicalisation**: aliases like `apple/swift-docc` and `swiftlang/swift-docc` collapse into one entry instead of double-indexing. Cached at `~/.cupertino/.cache/canonical-owners.json`; one API call per unique repo, lifetime. - **Persisted resolved closure** at `~/.cupertino/resolved-packages.json` with parentage + checksum. Re-fetch reuses the cache unless seeds changed or `--refresh` is passed. Answers "why is this package in my index?". - **User exclusion list** at `~/.cupertino/excluded-packages.json`. Hand-edit to drop discovered-via-dep packages from future closures. - **Parallel resolver** dispatches manifest fetches in batches of 10. A 200-package closure shrinks from ~3 min wall time to a few tens of seconds. - **Per-branch manifest cache** at `~/.cupertino/.cache/manifests/<owner>/<repo>/<branch>/Package.swift` with 24h TTL. 404s cached as zero-byte sentinels. - **SPM registry id counting** (`.package(id: …)`, SPM 5.8+) , surfaces in the resolver summary as `Skipped (SPM registry id)` rather than silently dropped. - **TUI promote / exclude actions** (`x` toggle exclusion, `p` promote discovered-via-dep to seed). Visual indicators: `[*]` seed, `[X]` excluded, `[+]` discovered, `[ ]` none. - **Expanded bundled `priority-packages.json`** from 36 to 135 seeds: 43 Apple (incl. `swift-syntax` swiftlang move, `swift-foundation`, `swift-markdown`, `swift-http-types`, `swift-nio-extras`, `swift-configuration`, `swift-distributed-tracing`); 92 ecosystem covering full Vapor + Hummingbird, expanded Point-Free, swiftlang, SSWG, tooling (SwiftFormat, SwiftLint, XcodeGen), Soto, SwiftUI Introspect, Tuist, plus project-specific seeds. **Swift Testing proposals indexed alongside Swift Evolution (#178, contributed by @farkasseb)** - `cupertino fetch --type evolution` now also crawls `proposals/testing/` from `swiftlang/swift-evolution`, so ST-prefixed proposals (Swift Testing) are first-class alongside SE-prefixed ones. Status regex updated to handle both prefix conventions; 404s on the testing subdir are handled gracefully (an empty testing/ dir is valid for older snapshots). - `Search.SearchIndexBuilder` indexes ST proposals into the same `evolution` source so they're searchable via `cupertino search` and `cupertino ask`. - New tests: `SwiftEvolutionCrawlerTests` (149 lines) + ST coverage in `CupertinoSearchTests` + `ServeTests`. ### Changed - **`cupertino setup` default now re-downloads** (#168). The previous short-circuit-if-databases-present behaviour is opt-in via `--keep-existing`. Each successful download stamps `~/.cupertino/.setup-version` so subsequent runs report `current` / `stale` / `unknown` / `missing`. Motivation: users were stranded on stale DBs after `brew upgrade cupertino`. - **Resource catalogs compiled into the binary** (#161): the four JSON catalogs are embedded as Swift raw-string literals under `Packages/Sources/Resources/Embedded/` instead of shipped as a `Cupertino_Resources.bundle`. The bundle-missing-on-Homebrew failure mode is fundamentally gone , there is no bundle. Obsoletes the `b9bc70a` symlink-resolution fix. - **`swift-packages-catalog` slimmed to URL list only**: the embedded catalog carries just the 9,699 package URLs (~530 KB), not the previous metadata blob (~3.4 MB). Metadata returns via `packages.db` distribution. Tracked in [#194](https://github.com/mihaelamj/cupertino/issues/194) for full removal in v1.1.0 once `packages.db` is the canonical source. - **Schema bump 10 → 12** (single user-visible jump, but two intermediate steps internally). v11 added `kind` + `symbols` to docs_metadata via ALTER TABLE. v12 added `symbols` to `docs_fts` (BREAKING , FTS5 can't ALTER columns). Existing v10/v11 DBs throw on open with a clear `rm + cupertino save` rebuild hint. Aligned with the v1.0 full re-crawl plan, so end users only see one transition. - **`make test-clean` Makefile target** , `clean + test` in one command. Escape hatch for the Swift 6.2 / macOS 26 SwiftPM incremental-build bug where adding a method to an actor leaves stale `.o` files and async dispatch lands in the wrong slot. Documented in `CONTRIBUTING.md` Troubleshooting + `mihaela-agents/Rules/ai-agent-rules/testing.md`. - **stdout line-buffered when piped**: `Cupertino.main()` calls `setvbuf(stdout, nil, _IOLBF, 0)` so `cupertino fetch ... | tee` flushes per-line instead of every 4–8 KB. No more "appears hung for 5 minutes then dumps a chunk" surprise on long crawls. - **`fetch --type package-docs` now extracts a filtered tarball, not a single README**: `PackageArchiveExtractor` (Core actor) pulls `https://codeload.github.com/<owner>/<repo>/tar.gz/<ref>` (HEAD → main → master fallback) and extracts README, CHANGELOG, LICENSE, `Package.swift`, all of `Sources/` + `Tests/`, every `.docc` article and tutorial, `Examples/` / `Demo/` directories, plus a per-package `manifest.json`. Same on-disk layout as before (`~/.cupertino/packages/<owner>/<repo>/`), but materially richer payload. Drives this shift: 5-line stub READMEs (vapor/leaf, etc.) made the prior README-only index nearly worthless for AI-agent "how do I use X?" queries , the source itself is now the last-resort fallback. - **`cupertino fetch --type package-docs` hidden from public help**: still functional, but no longer advertised , typical users get package data via the curated `packages.db` bundled in the `cupertino-docs` release zip. The full crawl is for re-building artifacts, not for end users. ### Fixed - **Homebrew resource bundle lookup** (#161): now fundamentally solved because there is no bundle. - **`fetch --type package-docs` honours user selections** (#107): `PriorityPackagesCatalog` first access copies the bundled `priority-packages.json` to `~/.cupertino/selected-packages.json` so TUI / manual edits take effect immediately. - **Swift.org indexing drops valid pages** (#110): `metadata.json` was being decoded as a `StructuredDocumentationPage` and failing on the missing `url` key. `findDocFiles` now filters it out. Separately, `is404Page` was over-aggressive , only flips the verdict on short pages (<500 chars) for the ambiguous "page not found" phrase, so the Swift Book's "The Basics" pages discussing error handling are no longer misclassified. - **Sample-code auth WebKit window** (#6, partial). Window now appears and navigates: `NSApp.setActivationPolicy(.regular)` nil-crash fixed (`NSApplication.shared` instead); delegate attached before `webView.load()`; spoofed Safari UA dropped (`idmsa.apple.com` was 403'ing it). `AuthFlowCoordinator` auto-detects sign-in via `myacinfo` cookie. Fresh interactive sign-in still has CoreAnimation quirks in a bare Swift CLI host; full replacement ([#193](https://github.com/mihaelamj/cupertino/issues/193), public JSON endpoints) is scoped to v1.1. - **BM25F single-word capitalized type queries** (#181): the FTS5 index was previously column-uniform (every column weight 1.0), so Mach kernel `task_*` docs outranked the Swift `Task` struct page on a literal `Task` query. Switching to **field-weighted BM25 (BM25F)** with title 10× and AST-extracted symbols 5× now makes `Task` rank the Swift Task struct above Mach `task_info`, and `View` rank the SwiftUI View protocol above generic prose. Full case-sensitivity comes with the v1.0 re-crawl. - **Cross-machine resume path resolution**: `FetchCommand.checkForSession()` was returning `metadata.crawlState.outputDirectory` , an absolute path captured on the machine that originally ran the crawl. After rsyncing `~/.cupertino/docs/` to a second host (different home dir, mounted volume), that saved path pointed at nothing, so `cupertino fetch` would silently start writing to a phantom directory under the wrong home. Now returns the directory where `metadata.json` was actually located , by definition the live output dir. Means a multi-day crawl can be migrated mid-run between machines via plain rsync + a `git pull` of the cupertino binary. - **Claude Code plugin install command** (PR #173 by @gpambrozio): `marketplace.json` source corrected from `"."` to `"./"` to match working-marketplace convention; README plugin install instructions updated to use the slash-command + `owner/repo` form instead of a CLI command with a full git URL. - **Deterministic id and contentHash for `StructuredDocumentationPage`** ([#199](https://github.com/mihaelamj/cupertino/issues/199)): `id` was a fresh `UUID()` per fetch and `contentHash` was `sha256(of: rawSourceBytes)` (Apple JSON, HTML, or markdown frontmatter , all carrying volatile cache / build / timestamp metadata that didn't reach our parsed output). Two crawls of the same Apple-side content produced different hashes, `Crawler.shouldRecrawl` always returned true, and every re-fetched page falsely registered as ♻️ Updated. Spot-checked Rotation3D and CGContext against the prior corpus , content was byte-identical after stripping volatile fields. Fix: `id` now derives from SHA-256 of the URL string (`StructuredDocumentationPage.deterministicID(for:)`); `contentHash` now hashes a canonical structured payload (`canonicalContentHash`) excluding `id`, `crawledAt`, `contentHash` itself, and `rawMarkdown` (which embeds `crawledAt`). Three transformers updated (`AppleJSONToMarkdown`, `HTMLToMarkdown`, `MarkdownToStructuredPage`). Five new regression tests in `SharedTests/ModelsTests`. Verified end-to-end: three back-to-back live fetches of `documentation/spatial/rotation3d` produced byte-identical id and contentHash, third fetch was correctly skipped. **Migration:** existing on-disk corpus carries pre-#199 hashes; the next crawl re-saves each existing page once with the canonical hash, then becomes idempotent. Subsequent ♻️ Updated counts will then map to actual Apple-side documentation edits. **Test coverage added for v1.0 fetch / resume behavior** - `Packages/Tests/SharedTests/JSONCodingTests.swift::concurrentWritesAreAtomic` , race a writer against 8 concurrent readers on a 256 KB file. Without `.atomic`, readers observe `Unexpected end of file` decode errors; with `.atomic`, 1,600 reads × 200 writes are all clean. Verified to fail when `.atomic` is removed. - `Packages/Tests/CLICommandTests/FetchTests/ResumeTests.swift` , 11 tests covering: `--start-clean` no-op when no metadata exists; `--start-clean` wipes `crawlState` while preserving stats; `--start-clean` leaves valid JSON and is idempotent; fresh `CrawlerState` auto-loads an active session; fresh `CrawlerState` reports no session when `crawlState` is absent; `--start-clean` + reload produces no active session; save → reload via fresh instance round-trips; `checkForSession` returns the *found* directory not the saved path (cross-machine portability); `checkForSession` rejects start-URL mismatch / inactive sessions / missing metadata; `checkForSession` ignores even a coincidentally-existing foreign path. Two of these were proven to fail under deliberate sabotage of the corresponding code path. ### Removed - `cupertino setup --force` flag , use `--keep-existing` or the new default-downloads behaviour. - `cupertino packages-setup` (hidden) subcommand , collapsed into the unified `cupertino setup`. - `cupertino fetch --use-json-api` flag , was never read by the Crawler (per-URL JSON-then-WebView fallback was always unconditional). Dead config; removing it deletes the `useJSONAPI` field from `Shared.CrawlerConfiguration`. - `cupertino fetch --resume` flag , auto-resume is the default now. The flag was a log-message switch only, doing nothing functional. - `FetchCommand.runDocsTwoPassCrawl()` , ran the same crawler twice (force-fresh, then resume-and-find-nothing). The "two-pass" branding was misleading; the per-URL fallback already gave full coverage in one pass. - `Cupertino_Resources.bundle` shipped artifact , no longer generated, no longer copied by `install.sh` / `release.yml` / the Homebrew formula. - `SwiftPackagesCatalog.topPackages(limit:)`, `.activePackages(minStars:)`, `.packages(license:)` , relied on metadata fields no longer present on the slimmed URL-only catalog. Metadata-driven queries will come back via `packages.db`. ### Internal - `Search.DocKind`, `Search.Classify`, `Search.SmartCandidate`, `Search.CandidateFetcher`, `Search.SmartQuery`, `Search.PackageFTSCandidateFetcher`, `Search.DocsSourceCandidateFetcher`, `Search.FusedCandidate`, `Search.SmartResult` , new public surfaces under `Packages/Sources/Search/`. - `MCP.Icon` + `Implementation.icons` , protocol-level additions for 2025-11-25. - `CupertinoIconEmbedded.dataURI` for serverInfo advertising. - `CLI.Commands.AskCommand`, `CLI.Commands.PackagesReleaseURL`, `CLI.Commands.SetupCommand.SetupStatus` , new CLI internals. - **Test count: 961** in 96 suites, 0 failures, ~40s on clean build. New suites added during this release cycle: `DocKindTests` (19), `DocKindIntegrationTests` (13), `BM25TitleWeightingTests`, `CodeExampleSymbolsTests`, `IndexBuilderSymbolsIntegrationTests`, `SmartQueryTests`, `DocsSourceCandidateFetcherTests`, `CupertinoResourcesTests`, `PackagesReleaseURLTests`, `SwiftEvolutionCrawlerTests` (#178), `ResumeAndStartCleanTests` (11 cases covering auto-resume, `--start-clean`, and cross-machine `checkForSession` portability). Updated: MCP protocol tests for 2025-11-25 + Icon + Implementation icons + legacy decode, schema-version assertions to 12, BM25 tests to 8-weight vector, doctor schema-version helpers, CLI subcommand count, `JSONCodingTests.concurrentWritesAreAtomic` (regression test for atomic metadata save). - `scripts/generate-embedded-catalogs.sh` , regenerates embedded catalog Swift files. - `scripts/recrawl.sh` , full re-crawl orchestration, named phases, idempotent wipe. - `scripts/demo.sh` , single-run live presentation script for demo recordings (no slides, just the binary). - **`mock-ai-agent` polish**: stdout now streams via `bytes.lines` for ordered delivery (previously buffered, so AI-like back-and-forth printed out of sequence on slow stdin), UTF-8 chunk truncation that could split a multi-byte glyph mid-character is fixed, and a new `--quiet` mode hides protocol noise for clean demo recordings. - 6 GitHub issues closed (`#18` colors, `#20` E2E MCP tests, `#109` search-all/search-hig , all already done or superseded). 3 v1.0-deliverable labels (`v1.0: distribution`, `v1.0: symbols`, `v1.0: mcp`) + `wishlist` label applied to ~20 speculative pre-v1.0 issues. ### Known issues / deferred - **Sample-code auth WebKit** (#6 partial; full replacement #193) , fresh interactive sign-in still has CA rendering quirks in the bare Swift CLI host. v1.1 swaps to public JSON endpoints; for v1.0 use the existing macOS user session if it has signed in via Safari recently. - **MCP `search` tool sectioned-vs-blended split** , MCP clients still get the per-source `UnifiedSearchService` shape (good for human-readable chat); only the new `cupertino ask` CLI uses cross-source rank fusion. Unifying these two presentations behind `SmartQuery` is a v1.1 polish. - **G6 Tasks abstraction** for long-running operations not wired , re-crawl + full-index run synchronously without MCP Tasks-protocol progress reporting. Defaults are fine; v1.1 polish. - **#177 low-signal AST symbol filtering** , operators, boilerplate names show up in symbol search. Quality-of-life cleanup post-1.0. --- ## 0.9.1 (2026-01-25) ### Added - **MCP client configuration docs** - Added setup guides for multiple AI tools (#134, #137) - OpenAI Codex (CLI and ~/.codex/config.toml) - Cursor (.cursor/mcp.json) - VS Code with GitHub Copilot (.vscode/mcp.json) - Zed (settings.json) - Windsurf (~/.codeium/windsurf/mcp_config.json) - opencode (opencode.jsonc) - **Binary documentation** - Full docs for additional executables (#137) - cupertino-tui: Terminal UI with 5 views documented - mock-ai-agent: MCP testing tool with arguments documented - cupertino-rel: Release tool with 6 subcommands and all options - 48 new documentation files in docs/binaries/ - **mock-ai-agent --version** - Added version flag support (#137) --- ## 0.10.0 (2026-03-13) ### Added - Framework synonyms: search using common alternate names (e.g., "nfc" → CoreNFC, "bluetooth" → CoreBluetooth, "shareplay" → GroupActivities) - Seed framework discovery from Apple's technologies.json for complete coverage - Agent skill for stateless CLI usage (#167, thanks @tijs) - Database v0.9.0: 320,771 documents across 443 frameworks (+18k docs, +136 frameworks) ### Changed - Case-insensitive framework matching across all search functions - Reduced default request delay from 0.5s to 0.05s for faster crawling ### Fixed - Crawler session resume now validates startURL before resuming - Case-insensitive URL prefix matching in shouldVisit - Link enqueue before skip check , incremental re-crawls now discover new child pages - Case-insensitive framework queries in searchByKind and searchSampleCode ## 0.9.0 (2025-12-31) ### Changed - **MCP Protocol Upgrade** - Support 2025-06-18 with backward compatibility (#130) - Upgraded default protocol version from 2024-11-05 to 2025-06-18 - Server negotiates compatible version with clients - MCPClient and MockAIAgent support version fallback - Thanks to @erikmackinnon for the contribution --- ## 0.8.3 (2025-12-31) ### Changed - **Swift-only MCP integration tests** - Rewrote tests and removed Node.js dependency (#131) - New integration tests use `cupertino serve` instead of npm packages - Tests verify MCP initialize handshake and tools/list responses - Validates protocol version, server info, and tool registration - Added Language Policy to AGENTS.md: no Node.js/npm in codebase --- ## 0.8.2 (2025-12-31) ### Fixed - **Setup progress animation** - Show download and extraction progress (#96) - Added `DownloadProgressDelegate` for real-time download progress - Added `ExtractionSpinner` for extraction feedback - Extended download timeout to 10 minutes for large database files --- ## 0.8.1 (2025-12-28) ### Fixed - **Installer ANSI escape sequences** - Fix raw `\033[...]` text in summary (#124) - Two `echo` statements missing `-e` flag for color output - Affects `bash <(curl ...)` install method --- ## 0.8.0 (2025-12-20) ### Added - **Doctor Command Enhanced** - Package diagnostics (#81) - Shows user selections file status and package count - Shows downloaded README count - Warns about orphaned READMEs (packages no longer selected) - Displays priority package breakdown (Apple vs ecosystem) - **String Formatter Tests** - 34 unit tests for display formatting (#81) - `StringFormatterTests.swift` covers truncation, markdown escaping, camelCase splitting ### Changed - **Code Quality Improvements** (#81) - Consolidated magic numbers into `Shared.Constants` (timeouts, delays, limits, intervals) - Added `Timeout`, `Delay`, `Limit`, `Interval` namespaces for better organization - Replaced hardcoded values across WKWebCrawler, HIGCrawler, and other modules - **PriorityPackagesCatalog** - Made fields optional for TUI compatibility - `appleOfficial` tier now optional (TUI only saves ecosystem tier) - Stats fields `totalCriticalApplePackages` and `totalEcosystemPackages` now optional - **Search Result Formatting** (#81) - Hierarchical result numbering (1.1, 1.2, 2.1, etc.) - Source counts in headers: `## 1. Apple Documentation (20) 📚` - Renamed `md` variable to `output` in formatters for clarity ### Fixed - **Package-docs fetch now reads user selections** (#107) - `cupertino fetch --type package-docs` now loads from `~/.cupertino/selected-packages.json` - Falls back to bundled `priority-packages.json` if user file doesn't exist - TUI package selections are now respected by fetch command - **Display Formatting Bugs** (#81) - Double space artifacts ("Tab bars" → "Tab bars") - Smart title-casing (only lowercase first letters get uppercased) - SwiftLint violations (line length, identifier names) ### Related Issues - Closes #81, #107 --- ## 0.7.0 (2025-12-15) ### Added - **Unified Search with Source Parameter** - New `--source` parameter: `apple-docs`, `samples`, `hig`, `apple-archive`, `swift-evolution`, `swift-org`, `swift-book`, `packages`, `all` - Teasers show results from alternate sources in every search response - Source-aware messaging tells AI exactly what was searched - **Documentation Database Expanded** - 302,424 docs across 307 frameworks (up from 234k/287) ### Changed - Consolidated multiple search tools into one unified search tool - Shared formatters between MCP and CLI for consistent output - Shared TeaserFormatter and constants eliminate hardcoding --- ## 0.6.0 (2025-12-12) ### Added - **Platform Availability Support** (#99) - `cupertino fetch --type availability` - Fetch platform version data for all docs - Availability tracked for all sources: apple-docs, sample-code, archive, swift-evolution, swift-book, hig - Search filtering by `--min-ios`, `--min-macos`, `--min-tvos`, `--min-watchos`, `--min-visionos` (CLI and MCP `search_docs` tool) - `save` command now warns if docs don't have availability data - Schema v7: availability columns in docs_metadata and sample_code_metadata ### Availability Sources | Source | Strategy | |--------|----------| | apple-docs | API fetch + fallbacks | | sample-code | Derives from framework | | apple-archive | Derives from framework | | swift-evolution | Swift version mapping | | swift-book/hig | Universal (all platforms) | ### Documentation - Added `docs/commands/search/option (--)/min-ios.md` - Added `docs/commands/search/option (--)/min-macos.md` - Added `docs/commands/search/option (--)/min-tvos.md` - Added `docs/commands/search/option (--)/min-watchos.md` - Added `docs/commands/search/option (--)/min-visionos.md` - Updated search command docs with availability filtering options ### Related Issues - Closes #99 --- ## 0.5.0 (2025-12-11) **Why minor bump?** The `cupertino release` command was removed from the public CLI. Users who had scripts calling `cupertino release` will need to update them. This is a breaking change for maintainer workflows. ### Added - **Documentation Database Expanded** - 234,331 pages across 287 frameworks (up from 138k/263) - Kernel: 24,747 docs - Matter: 22,013 docs - Swift: 17,466 docs - Full deep crawl of Apple Developer Documentation - **New ReleaseTool Package** - Maintainer-only release automation (#98) - `cupertino-rel bump` - Update version in all files - `cupertino-rel tag` - Create and push git tags - `cupertino-rel databases` - Upload databases to cupertino-docs - `cupertino-rel homebrew` - Update Homebrew formula - `cupertino-rel docs-update` - Documentation-only releases - `cupertino-rel full` - Complete release workflow ### Changed - **Breaking:** `cupertino release` removed from CLI - maintainers now use separate `cupertino-rel` executable - README now shows accurate documentation counts ### Fixed - Flaky ArchiveGuideCatalog tests (#101) ### Documentation - Updated `docs/DEPLOYMENT.md` with automated release instructions - Added `Packages/Sources/ReleaseTool/README.md` ### Related Issues - Closes #98, #101 --- ## 0.4.0 (2025-12-09) ### Added - **HIG Support** - Human Interface Guidelines documentation (#95) - `cupertino fetch --type hig` - Fetch HIG documentation - New HIG source for search results ### Fixed - Swift.org indexer now handles JSON files correctly ### Documentation - Added video demo - Added MIT License - Added Homebrew tap info to README ### Related Issues - Closes #95 --- ## 0.3.4 ### Added - **One-Command Install** - Single curl command installs everything (#82) - `bash <(curl -sSL .../install.sh)` - Downloads binary and databases - Pre-built universal binary (arm64 + x86_64) - Code signed with Developer ID Application certificate - Notarized with Apple for Gatekeeper approval - GitHub Actions workflow for automated releases - Closes #79, #82 --- ## 0.3.0 ### Added - **Setup Command** - Instant database download from GitHub Releases (#65) - `cupertino setup` - Download pre-built databases in ~30 seconds - Version parity - CLI version matches release tag for schema compatibility - Progress bar with percentage and download size - `--base-dir` option for custom location - `--force` flag to re-download - **Release Command** - Automated database publishing for maintainers (#66) - `cupertino release` - Package and upload databases to GitHub Releases - Creates versioned zip with SHA256 checksum - `--dry-run` for local testing - Handles existing releases (deletes and recreates) - **Remote Sync** - New `--remote` flag for `cupertino save` command (#52) - Stream documentation directly from GitHub without local crawling - Build database locally in ~45 minutes instead of 20+ hours - Resumable - if interrupted, continue from where you left off - No disk bloat - streams directly to SQLite - Uses raw.githubusercontent.com (no API rate limits) - **RemoteSync Package** - New standalone Swift 6 package with strict concurrency - `RemoteIndexer` actor for orchestrating remote sync - `GitHubFetcher` actor for HTTP operations - `RemoteIndexState` Sendable struct for state persistence - `AnimatedProgress` for terminal progress display ### Documentation - Updated README with "Instant Setup" quick start using `cupertino setup` - Added `docs/commands/setup/README.md` documentation - Added `docs/commands/release/README.md` documentation - Added `docs/commands/save/option (--)/remote/` documentation - Updated `docs/commands/README.md` with new commands ### Related Issues - Closes #52, #65, #66 --- ## 0.2.7 ### Fixed - **Search Ranking** - Penalize release notes in search results (2.5x multiplier) to prevent them polluting unrelated queries (#57) - **Swift Evolution Indexing** - Fix filename pattern to match `SE-0001.md` format (#61) - **Database Re-indexing** - Delete database before re-index to prevent FTS5 duplicate rows doubling db size (#62) - **Serve Output** - Simplified startup messages to show only DB paths; server now requires at least one database to start (#60) --- ## 0.2.6 ### Fixed - **MCP Server Tool Registration** - Fixed bug where only sample code tools were exposed (#55) - Created `CompositeToolProvider` that delegates to both `DocumentationToolProvider` and `SampleCodeToolProvider` - All 7 MCP tools now properly exposed: `search_docs`, `list_frameworks`, `read_document`, `search_samples`, `list_samples`, `read_sample`, `read_sample_file` - Follows composite pattern with proper separation of concerns ### Related Issues - Fixes #55 --- ## 0.2.5 ### Added - **CLI Sample Code Commands** - Full parity with MCP sample code tools (#51) - `cupertino list-samples` - List indexed sample projects - `cupertino search-samples <query>` - Search sample code projects and files - `cupertino read-sample <project-id>` - Read project README and metadata - `cupertino read-sample-file <project-id> <path>` - Read source file content - **CLI Framework List Command** - `cupertino list-frameworks` - List available frameworks with document counts - All new commands support `--format text|json|markdown` output ### Related Issues - Closes #51 --- ## 0.2.4 ### Added - **GitHub Sample Code Fetcher** - Fast alternative to Apple website scraping - `cupertino fetch --type samples` - Clone/pull from public GitHub repository - 606 projects, ~10GB with Git LFS - Much faster than `--type code` (~4 minutes vs hours) - **Sample Code Directory Indexing** - Index extracted project directories (not just ZIPs) - `SampleIndexBuilder` now scans both ZIP files and extracted folders - Supports GitHub-cloned projects in `cupertino-sample-code/` subdirectory - 18,000+ source files indexed for full-text search ### Changed - Sample code can now be fetched from two sources: - `--type samples` - GitHub (recommended, faster) - `--type code` - Apple website (requires authentication) --- ## 0.2.3 ### Added - **Apple Archive Documentation Crawler** - Crawl legacy Apple programming guides (Core Animation, Core Graphics, Core Text, etc.) (#41) - `cupertino fetch --type archive` - Fetch archived Apple programming guides - `--include-archive` flag for search command - Include legacy guides in results - `include_archive` parameter for MCP `search_docs` tool - Framework synonyms for better search (QuartzCore↔CoreAnimation, CoreGraphics↔Quartz2D) - Source-based search ranking (modern docs rank higher, archive docs have slight penalty) - TUI Archive view for browsing and selecting archive guides ### Changed - Archive documentation excluded from search by default (use `--include-archive` or `--source apple-archive`) - Updated MCP tool description to document archive features ### Related Issues - Closes #41 --- ## 0.2.2 ### Added - Intelligent kind inference for unknown document types using URL depth, title patterns, and word count signals - Improved search ranking for core types when `kind=unknown` ### Fixed - Fixed URL scheme error when resuming crawl session (#47) ### Related Issues - Closes #47 - Related to #28 (Search Ranking Improvements) --- ## 0.2.1 ### Fixed - Fixed crawler filename collision causing parent documentation pages to be overwritten by operators/methods (#45) - Crawler now generates unique filenames for URLs with special characters using hash suffixes - Parent types (Text, Color, Date, String structs) will be restored on next crawl ### Related Issues - Closes #45 - Related to #28 (Search Ranking Improvements) --- ## 0.2.0 ### Fixed - **CRITICAL**: Fixed cleanup bug that deleted source code instead of .git folders (#40) - Simplified `compressDirectory()` to preserve Apple's flat ZIP structure - Reduced cleanup patterns to only safe items: .git, .DS_Store, DerivedData, build, .build, xcuserdata, *.xcuserstate - Verified all 606/607 sample ZIPs contain intact source code (1 corrupted in original download) - Cleanup now achieves 44% space reduction (27GB → 15GB) while preserving all code --- ## 0.1.9 ### Added - `--language` filter for search (swift, objc) - CLI and MCP (#34) - `source` parameter to MCP `search_docs` tool (#38) ### Changed - Database schema v5 - added `language` column to docs_fts and docs_metadata - **BREAKING**: Requires database rebuild (`rm ~/.cupertino/search.db && cupertino save`) --- ## 0.1.8 ### Added - `cupertino cleanup` - Clean up sample code archives by removing .git, .DS_Store, xcuserdata, etc. (#31) - Dry run mode (`--dry-run`) to preview cleanup without modifying files - Keep originals mode (`--keep-originals`) to preserve original ZIPs ### Changed - Reorganized docs folder structure to be self-illustrating (folders show command syntax) - Removed unused serve command options (`--docs-dir`, `--evolution-dir`, `--search-db`) ### Fixed - Dry run now correctly detects nested junk files (e.g., `.git/hooks/*`) --- ## 0.1.7 ### Added - Unified logging system with categories and log levels (#26, #30) - Search tests for swift-book URIs ### Fixed - `read_document` returning empty content for swift-book URIs - Consolidated logging across all modules --- ## 0.1.6 ### Added - `cupertino search` - CLI command for searching documentation without MCP server (#23) - `cupertino read` - CLI command for reading full documents by URI - `summaryTruncated` field in search results for AI agents - Truncation indicator with word count in text output - Comprehensive command documentation in `docs/commands/` ### Changed - Increased summary limit from 500 to 1500 characters - JSON-first crawling to reduce WKWebView memory usage (#25) ### Fixed - Memory spike on large index pages by using JSON API first (#25) --- ## 0.1.0 , Pre-release - Initial crawler prototype (`Crawler`) - Local MCP server implemented (`Serve`) - Admin TUI added (`AdminUI`) - Documentation system connected - Pre-release versioning strategy established - Internal architecture stabilized enough for developer preview