diff --git a/.claude/rules/libs.md b/.claude/rules/libs.md deleted file mode 100644 index 739866a330..0000000000 --- a/.claude/rules/libs.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -paths: - - "apple/Libs/**/*" - - "apple/scripts/download-libs.sh" - - "apple/scripts/publish-libs.sh" ---- - -# Static libraries - -- **`apple/Libs/` is published only through `apple/scripts/publish-libs.sh `.** Each run creates the next immutable `libs-` release and writes `apple/Libs/release` and both `checksums.sha256` files, which are committed together. Never edit them by hand: a stale `apple/Libs/` silently reverts other libraries. -- A published `libs-` never changes; a fix is the next ``. `libs-v1` stays frozen for older commits. -- **`apple/scripts/download-libs.sh` fetches the release `apple/Libs/release` names at `HEAD` and verifies every file against the checksums at `HEAD`** on every run; `--force` re-downloads. diff --git a/.claude/rules/tests.md b/.claude/rules/tests.md index 3195864426..749bd65552 100644 --- a/.claude/rules/tests.md +++ b/.claude/rules/tests.md @@ -16,130 +16,80 @@ UI flows: `.claude/rules/ui-tests.md`. Running tests and reading verdicts: `.cla ## Where a test goes -| Code under test | Target | Folder | Host | Runs in | -| --- | --- | --- | --- | --- | -| A TableProCore module | `Tests` | `apple/Packages/TableProCore/Tests/Tests/` | none | `swift test`, iOS and Package Tests | -| A PluginKit API | `TableProPluginKitTests` | `apple/Packages/TableProCore/Tests/TableProPluginKitTests/` | none | `swift test`, iOS and Package Tests | -| Logic a plugin and the iOS app share without PluginKit | the test target of its TableProCore product (`TableProMySQLCoreTests`, `TableProMSSQLCoreTests`) | `apple/Packages/TableProCore/Tests/` | none | `swift test`, iOS and Package Tests | -| Oracle wire logic | `TableProOracleCoreTests` | `apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/` | none | `swift test`, iOS and Package Tests when the `oracle` detector fires | -| A TableProMacCore module | `Tests` | `apple/Packages/TableProMacCore/Tests/Tests/` | none | Unit plan | -| The editor or the grammars | `TableProEditorKitTests`, `TableProTextEngineTests`, `TableProGrammarsTests` | `apple/Packages/TableProEditor/Tests/`, `apple/Packages/TableProGrammars/Tests/` | none | Unit plan | -| A bundled driver's logic | `DriverTests` | `apple/Plugins/DriverTests/` | none | Unit plan | -| A bundled format plugin | `FormatPluginTests`, one bundle for all of them | `apple/Plugins/FormatPluginTests/` | none | Unit plan | -| App code that needs the app host: AppKit windows, `DatabaseManager.shared`, a coordinator | `TableProTests` | `apple/TableProTests/` | TablePro.app | Unit plan | -| The iOS app | `TableProMobileTests` | `apple/TableProMobile/TableProMobileTests/` | the iOS app on a simulator | iOS and Package Tests, `verify.sh ios` | -| A new end-to-end user flow | `TableProUITests` | `apple/TableProUITests/` | XCUITest drives TablePro.app | UI plan | +| Code under test | Target | Runs in | +| --- | --- | --- | +| A TableProCore module or a PluginKit API | `Tests`, `TableProPluginKitTests` | `swift test` | +| Logic a plugin and the iOS app share without PluginKit | its TableProCore product's target (`TableProMySQLCoreTests`) | `swift test` | +| Oracle wire logic | `TableProOracleCoreTests` (TableProOracle) | `swift test` | +| A TableProMacCore module, the editor, the grammars | the package's `Tests` | Unit plan | +| A bundled driver, a bundled format plugin | `DriverTests`, `FormatPluginTests` (one bundle for all formats) | Unit plan | +| App code that needs the app host: AppKit windows, `DatabaseManager.shared`, a coordinator | `TableProTests` | Unit plan | +| The iOS app | `TableProMobileTests` | iOS and Package Tests, `verify.sh ios` | +| A new end-to-end user flow | `TableProUITests` | UI plan | - Take the first row that can hold the test. A hostless target beats `TableProTests`. -- A registry-only driver gets no test target; `AllPlugins` only compiles it. An app-side gate around a registry engine (Safe Mode, the MCP write tier, the query classifier) is app code with a hosted test. `TableProMSSQLCoreTests` and `TableProOracleCoreTests` stay, because the iOS app links that code. -- A check that needs a live server, or a service no workflow starts, is a hand-run probe in `apple/scripts/probes/`, never a test. No workflow runs a probe. +- A registry-only driver gets no test target; `AllPlugins` only compiles it. An app-side gate around a registry engine (Safe Mode, the MCP write tier, the query classifier) is app code with a hosted test. `TableProMSSQLCoreTests` and `TableProOracleCoreTests` stay: the iOS app links that code. +- A check that needs a live server, or a service no workflow starts, is a hand-run probe in `apple/scripts/probes/`, never a test. ## Plans -- `apple/TestPlans/Unit.xctestplan` is the `TablePro` scheme's default plan. `apple/TestPlans/UI.xctestplan` holds `TableProUITests` alone. -- Unit holds `TableProTests`, the hostless plugin bundles, the Editor and Grammars package targets and the TableProMacCore package targets. -- TableProCore and TableProOracle test targets are in no plan and run under `swift test`. Some depend on the `TableProPluginKit` package, and a plan target that does makes the app and plugins compile against that module instead of the resilient framework. A package joins the Unit plan only when nothing it builds depends on that package; the top of `apple/Packages/TableProMacCore/Package.swift` says so. -- Unit plan settings: language `en`, region `US`, timeouts on with a 60 s default and a 120 s maximum per case, no code coverage, the sandbox variable (Isolation below) and `DEVELOPER_DIR` set to `$(DEVELOPER_DIR)`. UI plan settings are in `.claude/rules/ui-tests.md`. -- The host gets no shell environment, so without that entry a `/usr/bin` shim a test runs (`python3`) resolves through the Mac's selected Xcode, not the one running the tests. On `xcode-27` that is 27.0, and the shim asked to install the Command Line Tools instead. -- The Unit plan never retries. A red case is a bug, or a flake to fix. -- The Unit plan runs serially. Its Xcode targets carry `"parallelizable": false`; the package targets carry no key, so their suites run in parallel in-process. A new Xcode target entry stays serial too. -- Why serial: in-process parallelism starts every `TableProTests` suite at once and starves the main actor, so time limits kill cases and then the host. Parallel clones never split the Swift Testing suites of a hosted target: they all run in one clone. The plugin bundles in parallel save about 1% of the Unit step. -- `-parallel-testing-enabled YES` overrides the plan: it turns in-process Swift Testing parallelism on even where the `.xctestrun` says off. -- Build for testing scheme-wide, or let `verify.sh` build. `build-for-testing -testPlan UI` alone has no `TableProTests` in its graph, so it links TableProMCP statically into the app. `test-without-building -testPlan Unit` on those products then loads two copies, and the MCP cases fail with "expected error of type MCPProtocolError, but MCPProtocolError was thrown". `verify.sh` runs `xcodebuild test -testPlan` for the plan it tests, and CI builds once with no plan. +- TableProCore and TableProOracle targets run under `swift test`, in no plan: a plan target that depends on the `TableProPluginKit` package makes the app and plugins compile against that module instead of the resilient framework. A package joins the Unit plan only when nothing it builds depends on that package. +- The test host gets no shell environment: a variable goes into the plan's environment entries or an in-process `setenv`. The Unit plan passes `DEVELOPER_DIR`, or a `/usr/bin` shim a test runs (`python3`) resolves through the Mac's selected Xcode. +- **The Unit plan never retries and runs serially.** In-process parallelism starts every `TableProTests` suite at once and starves the main actor, so time limits kill cases and then the host. A new Xcode target entry carries `"parallelizable": false`; package targets carry no key. `-parallel-testing-enabled YES` turns it back on even where the `.xctestrun` says off. +- **iOS tests have no plan and run with `-parallel-testing-enabled NO`** on one simulator: on the 3-core runner a second booted simulator killed runs with "(ipc/mig) server died". `apple/TableProMobile/project.yml` says `parallelizable: true`, which every run overrides. +- Build for testing scheme-wide, or let `verify.sh` build. `build-for-testing -testPlan UI` alone links TableProMCP statically, and a Unit run on those products loads two copies: "expected error of type MCPProtocolError, but MCPProtocolError was thrown". ## Adding a test target -- Xcode test targets are defined in `apple/project.yml`. A bundled plugin's target uses the `PluginTests` template plus that plugin's linkage template, and compiles `apple/Plugins/`, `apple/Plugins/Shared` and `apple/Plugins/` against the PluginKit framework. -- Name the folder after the target: `verify.sh` finds a test's target by that path component. -- `TableProTests` compiles only `apple/TableProTests/`. Never add a plugin or package source to it. -- `TableProTests` depends only on what the app does not link: `TableProTestSupport`, `TableProPostgreSQLCore`, `TableProMCPTestSupport`. The app's packages reach it through `TEST_HOST`. -- A second link compiles a second copy of a module into the test bundle. Check with `nm -m` on `/Build/Products/Debug/TablePro.app/Contents/PlugIns/TableProTests.xctest/Contents/MacOS/TableProTests`: it must define no `Mn` descriptor of a module the app links. For TableProMCP (the number is the name's length) this prints nothing: +- Xcode test targets live in `apple/project.yml`; a bundled plugin's uses the `PluginTests` template plus the plugin's linkage template. Name the folder after the target: `verify.sh` finds a test's target by that path component. +- `TableProTests` compiles only `apple/TableProTests/` and links only what the app does not (`TableProTestSupport`, `TableProPostgreSQLCore`, `TableProMCPTestSupport`); the app's packages reach it through `TEST_HOST`. A second link puts a second copy of a module in the bundle. `nm -m` on `TablePro.app/Contents/PlugIns/TableProTests.xctest/Contents/MacOS/TableProTests` must show no `Mn` descriptor of a module the app links; for TableProMCP (11 is the name's length) this prints nothing: ```bash nm -m "$BIN" | grep -v undefined | grep -E '_\$s11TableProMCP[0-9A-Za-z_]*Mn$' ``` -- Add the target to its plan. An Xcode target goes in by its PBXNativeTarget id from the generated `apple/TablePro.xcodeproj/project.pbxproj`, never the scheme's id. A package target goes in with `containerPath: container:Packages/` and its name as identifier. -- `apple/scripts/ci/check-test-plans.py`, run by Repo Hygiene, fails on: a test target missing from its plan or in the wrong one; a plan entry with no target; a TableProCore or TableProOracle target in a plan; a wrong `containerPath`; a duplicate entry; an Xcode test target with no folder of its name; `enabled: false`, `skippedTests` or `selectedTests`. -- It reads target types from `apple/project.yml`, templates included, and test targets from every `apple/Packages/*/Package.swift`, so a new package needs no script change. +- An Xcode target joins its plan by its PBXNativeTarget id from the generated `apple/TablePro.xcodeproj/project.pbxproj`, never the scheme's id. A package target joins with `containerPath: container:Packages/` and its name as identifier. `apple/scripts/ci/check-test-plans.py` checks plan membership. ## Writing a test -- New tests are Swift Testing. Convert an XCTest file you rewrite. `TableProUITests` stays XCTest. -- No bare top-level `@Suite` in `apple/TableProTests`, `apple/Plugins/*Tests` or `apple/Packages/TableProMacCore/Tests`. Each top-level `@Suite` expansion walks every other one, so compile time grows with their square. Keep `@Suite` only to carry a trait. SwiftLint's `suite_without_trait` enforces it in those folders; the other package test folders are not covered yet, so follow it there by hand. -- Hosted tests reach app code through `@testable import TablePro`. MemberImportVisibility is on, so a file that names a PluginKit enum case also imports `TableProPluginKit`. -- A plugin bundle test compiles the plugin's sources into its own module: it imports `TableProPluginKit` and `Testing`, never `@testable import`. -- A test does not depend on the machine. Pin the locale, use `.recordingClipboard` rather than the real pasteboard, keep the network out (`OfflineRegistry` for the plugin registry), inject the clock (`ManualClock`). -- Wait for an effect no event reports with `waitUntil`, which polls every 5 ms and throws `WaitTimeout` naming the file and line. Await an event when one exists. Never a fixed `Task.sleep` or a `Task.yield` loop. -- `.serialized` orders the cases of one suite only; it is not isolation. A suite that swaps a process-wide value across a suspension nests under `GlobalStateSuites`, whose `.serialized` orders every suite inside it. Users of `.appSettings { }` and `.recordingClipboard` nest there. -- A regression test proves something only once it fails without the fix. Revert the fix and watch it fail, or name the change that turns it red. +- New tests are Swift Testing; convert an XCTest file you rewrite. `TableProUITests` stays XCTest. +- Keep `@Suite` only to carry a trait, in every test folder: each top-level `@Suite` expansion walks every other one, so compile time grows with their square. +- MemberImportVisibility is on: a hosted test that names a PluginKit enum case imports `TableProPluginKit` beside `@testable import TablePro`. A plugin bundle test compiles the plugin's sources into its own module, so it never uses `@testable`. +- Await the event when one exists, else poll with `waitUntil`. Never a fixed `Task.sleep` or a `Task.yield` loop. +- `.serialized` orders one suite's cases only. A suite that swaps a process-wide value across a suspension (`.appSettings { }`, `.recordingClipboard`) nests under `GlobalStateSuites`, whose `.serialized` orders every suite inside it. ## Isolation and the app host -- When `TABLEPRO_UI_TEST_SANDBOX` is set, `AppStorageEnvironment.shared` resolves every store, the defaults suite and the keychain inside it. `main.swift` calls `AppStorageEnvironment.bootstrap()` before any static initializer runs. The rule for production code is in `apple/AGENTS.md`, Invariants that apply everywhere. -- The Unit plan sets the sandbox to `$(TARGET_TEMP_DIR)/UnitTestStorage`, under DerivedData. The unit host deletes the sandbox's `TablePro` folder at every launch, so every run starts empty. -- The defaults suite is `com.TablePro.uitest.<16 hex digits>`, a digest of the sandbox path, so each plan, checkout and worktree gets its own. A sandbox without the `.defaults-suite` marker is new and empties its suite, since cfprefsd keeps a suite after its folder is deleted. Plugins derive the same suite through `PluginHostStorage`. -- Under isolation the keychain is `SandboxKeychainStore`, a `keychain.json` in the sandbox. -- AppKit autosave names (split views, window frames, tables) are nil in the unit host and under the sandbox, because AppKit writes those records to the real domain. The rule for a new one is in `apple/AGENTS.md`, Invariants that apply everywhere. -- Stores default to `AppStorageEnvironment.shared.defaults`. ManagedPolicy, AppleLanguages, Sparkle and AppKit stay on `.standard`. -- Sparkle reads the real domain, so under isolation the app starts neither the updater nor sync (`runPostLaunchActivationIfNeeded` in `AppDelegate`). +- With `TABLEPRO_UI_TEST_SANDBOX` set, `AppStorageEnvironment.shared` resolves every store, the defaults suite and the keychain (`SandboxKeychainStore`, a `keychain.json`) inside it. `main.swift` calls `AppStorageEnvironment.bootstrap()` before any static initializer runs. +- The unit host deletes the sandbox's `TablePro` folder at every launch. +- The defaults suite is `com.TablePro.uitest.<16 hex digits>`, a digest of the sandbox path, so each plan, checkout and worktree gets its own. A sandbox without the `.defaults-suite` marker empties its suite, since cfprefsd keeps a suite after its folder is deleted. Plugins derive the same suite through `PluginHostStorage`. +- ManagedPolicy, AppleLanguages, Sparkle and AppKit stay on `.standard`. So AppKit autosave names are nil in the unit host and under the sandbox, and under isolation the app starts neither the updater nor sync (`runPostLaunchActivationIfNeeded` in `AppDelegate`). - `TABLEPRO_UI_TESTING=1` without a sandbox makes the app log a fault and exit. The app cannot detect XCUITest by itself. -- The test host gets no shell environment. A variable goes into the plan's environment entries, or an in-process `setenv`. -- Under XCTest the app skips plugin loading and its normal startup. Keep that guard in `AppDelegate`; the `PluginManager` tests depend on it. Plugin metadata in a hosted test comes from the curated built-in table, so test plugin logic in its bundle. -- `PluginManager.waitForInitialLoad()` returns at once unless `loadPlugins()` started a load. Its 10 s timeout, on the injected `initialLoadClock`, still covers a load that hangs. -- A known write to the real domain, accepted: a test that changes the general settings runs `AppLanguage.apply()`, which writes `AppleLanguages` to `.standard`. -- Proof a change writes nothing real: `ls ~/Library/Preferences | wc -l` and `defaults read com.TablePro | wc -l` give the same numbers before and after one Unit run and one UI run. -- `apple/scripts/clean-test-residue.sh` lists what older test runs left on the Mac: test defaults domains, test keys and sandboxed autosave records in `com.TablePro`, `mcp-audit-test_` databases, test folders in the user temp directory. It never touches per-connection keys. -- Its `--apply` backs everything up to `~/TablePro-test-residue-/` before removing it, and refuses while TablePro or a TablePro test run is active. Give the user the commands (dry run, `--apply`, dry run again until every "Would remove" count is 0) rather than running `--apply` on their Mac. +- Under XCTest the app skips plugin loading and its normal startup; the `PluginManager` tests depend on that guard in `AppDelegate`. A hosted test gets plugin metadata from the curated built-in table, so test plugin logic in its bundle. +- `apple/scripts/clean-test-residue.sh` lists what older runs left on the Mac. Give the user the commands (dry run, `--apply`, dry run until every "Would remove" count is 0) rather than running `--apply` on their Mac. ## Shared helpers Use one before writing a local helper. -| Helper | Where | Use | -| --- | --- | --- | -| `waitUntil` | `TableProTestSupport` | poll for an effect no event reports | -| `AsyncGate` | `TableProTestSupport` | hold callers until the case calls `open()`; `arrived(_:)` waits for that many callers | -| `ManualClock` | `TableProTestSupport` | time moves only on `advance(by:)`; production code takes `clock: any Clock = ContinuousClock()` | -| `BoundedCall`, `HeldOpenWriter`, `BackgroundPipeWriter`, `BackgroundPipeReader` | `TableProTestSupport` | bound a call that can block; drive a pipe from another thread | -| `GlobalStateSuites` | `apple/TableProTests/Helpers/` | parent of every suite that swaps a process-wide value | -| `EphemeralDefaults` | `apple/TableProTests/Helpers/` | a defaults suite that deletes its domain and plist with its owner | -| `.appSettings { }`, `.recordingClipboard` | `apple/TableProTests/Helpers/` | traits that swap the app settings or the clipboard for one case | -| `CoordinatorFixture` | `apple/TableProTests/Helpers/` | a coordinator that is torn down with the fixture | -| `OfflineRegistry` | `apple/TableProTests/Helpers/` | a registry client that never reaches the network | -| `InMemoryColumnLayoutStore`, `InMemoryKeychain`, `PluginDriverStub`, `TestFixtures` | `apple/TableProTests/Helpers/` | stores and driver doubles | -| `AlwaysAllowGate`, `AlwaysDenyGate` | `apple/TableProTests/Helpers/StubExecutionGates.swift` | an `ExecutionGate` that never raises the real alert | -| `TableProMCPTestSupport` | `apple/Packages/TableProMacCore/Tests/TableProMCPTestSupport/` | MCP doubles: `MCPTestClock`, `MockHttpServer`, `RecordingBridgeLogger` | -| `TableProTLSTestFixtures` | `apple/Packages/TableProCore/Tests/TableProTLSTestFixtures/` | TLS fixtures, also used by the iOS tests | - -`TableProTestSupport` is the TableProCore product at `apple/Packages/TableProCore/Tests/TableProTestSupport/`. +- `TableProTestSupport` (TableProCore): `waitUntil`, `AsyncGate`, `ManualClock` (production code takes `clock: any Clock = ContinuousClock()`), `BoundedCall`, `HeldOpenWriter`, `BackgroundPipeWriter`, `BackgroundPipeReader`. +- `apple/TableProTests/Helpers/`: `GlobalStateSuites`, `EphemeralDefaults`, `.appSettings { }`, `.recordingClipboard`, `CoordinatorFixture`, `OfflineRegistry`, `InMemoryColumnLayoutStore`, `InMemoryKeychain`, `PluginDriverStub`, `TestFixtures`, `AlwaysAllowGate`, `AlwaysDenyGate`. +- `TableProMCPTestSupport` (TableProMacCore): `MCPTestClock`, `MockHttpServer`, `RecordingBridgeLogger`. `TableProTLSTestFixtures` (TableProCore), also for the iOS tests. ## Known failures and flakes -- Mark a known failure in code: `@Test(.disabled("why"), .bug(""))`, `withKnownIssue { }`, or `XCTExpectFailure` in XCTest. `.disabled(if:)` only for a real precondition. -- There are no quarantine files, and a plan never skips a test (`check-test-plans.py` refuses it). -- To reproduce an intermittent failure, build scheme-wide, then run the built plan repeatedly. This is the one place `-test-iterations` belongs: - - ```bash - xcodebuild test-without-building -destination platform=macOS \ - -xctestrun /Build/Products/TablePro_Unit_macosx-arm64.xctestrun \ - -only-testing:/ -test-iterations 10 -run-tests-until-failure - ``` - -- A UI flow that fails only on CI: `.claude/rules/ui-tests.md`, Known failures. +- Mark a known failure in code (`.disabled("why")` with `.bug("")`, `withKnownIssue { }`, `XCTExpectFailure`), never in a plan or a quarantine file. `.disabled(if:)` only for a real precondition. +- A flake is reproduced by `test-without-building` on the built `TablePro_Unit_macosx-arm64.xctestrun` with `-only-testing:/ -test-iterations 10 -run-tests-until-failure`, the one place `-test-iterations` belongs. ## Traps - One crashing test kills the host, and every later case reports failed at 0.000 s. Find the crash in `~/Library/Logs/DiagnosticReports`. - `close()` on a test-created `NSWindow` releases it twice and kills the host. Set `isReleasedWhenClosed = false`, or use `orderOut(nil)`. -- A hosted case that reaches the real `ExecutionGate` raises an `NSAlert` that nothing answers, and the run stalls. Inject a gate. +- A hosted case that reaches the real `ExecutionGate` raises an `NSAlert` nothing answers, and the run stalls. Inject `AlwaysAllowGate` or `AlwaysDenyGate`. - `NSPasteboard.general` fails headless. Use `.recordingClipboard`. -- On the main actor, a `Task.yield` loop starves what it waits for. -- Awaiting a finished Task does not suspend. -- A synchronous `@MainActor` test that pumps the run loop never runs main-actor Tasks. -- Injected `ConnectionSession`s start `.disconnected`. A shared service that treats "has a driver" as live starts real work from unrelated tests, so such a service checks for `.connected`. -- In an offscreen window a floating subview's container is offset by the header height, and `needsDisplay` cannot be observed. Assert the geometry, not the flag. +- Injected `ConnectionSession`s start `.disconnected`. A shared service that treats "has a driver" as live starts real work from unrelated tests: check `.connected`. +- In an offscreen window a floating subview's container is offset by the header height, and `needsDisplay` cannot be observed: assert the geometry. - `cacheDisplay` of an `NSTableView` runs an alternating-row pass the screen never shows and copies translucent fills as they are. Test the grid's drawing by calling it into a bitmap. - CI runner windows are 1x and a developer Mac is 2x, so centred geometry lands half a point apart. Use `1 / window.backingScaleFactor` as the tolerance. -- AppKit's private view hierarchy differs between OS builds (selection material, effect views). Gate such a test on a host probe, `.enabled("why") { await Suite.probe() }`, and pair it with a test that runs everywhere. -- Inside `#expect`, a key path passed to a `rethrows` method (`allSatisfy(\.flag)`) does not compile, nor does a key path to a main-actor property (`filter(\.isSelected)`). Write the closure. The compile error fails the whole target, so no suite runs. +- AppKit's private view hierarchy differs between OS builds. Gate such a test on a host probe, `.enabled("why") { await Suite.probe() }`, and pair it with a test that runs everywhere. +- Inside `#expect`, a key path passed to a `rethrows` method (`allSatisfy(\.flag)`) or to a main-actor property (`filter(\.isSelected)`) does not compile. Write the closure: the error fails the whole target, so no suite runs. diff --git a/.claude/rules/theme.md b/.claude/rules/theme.md index 6829ac0356..a09169dd46 100644 --- a/.claude/rules/theme.md +++ b/.claude/rules/theme.md @@ -8,73 +8,51 @@ paths: # Theme -## What a theme colors +## Content, never chrome -- **A theme colors content and the window backdrop, never chrome.** Content is every code view, the data grid, value text and wells (an editable value included, which is why it is a `ValueTextField` and not a bezeled control), value trees, diffs, record tables, diagrams, charts, and the states that stand in for any of them. Chrome is controls, Accent, Highlight, the sidebar, inspector frame, toolbar, titlebar, tab strip and bars, Settings, Welcome, the connection form, sheet and popover chrome, and SwiftUI `List`, `Form` and `Table` backgrounds. `NSApp.appearance` is the only lever on chrome. -- **Chrome never reads a slot.** It asks `ThemeBackdropState.isThemed(in:scope:)` or `isThemed(highContrast:scope:)` whether the backdrop behind it is themed, and answers with a system style: - - A bar over a pane takes `.paneBarBackground(_:ignoresSafeAreaEdges:)` with the native style it drew before (`.bar` by default, or a flat `controlBackgroundColor` or `windowBackgroundColor`). It draws that style while the backdrop is unthemed and nothing while it is themed, because a material over a themed pane reads as a system band. No edges by default, and a top bar keeps that default, so it never reaches under the toolbar. - - A bottom status bar takes `.statusBarChrome()`. `StatusBarChrome` asks `ThemeBackdropState` itself and drops its material over a themed backdrop, so a host sets nothing. - - A list or scroll view filling a glass sidebar or inspector item takes `.splitItemGlassBackground()`. From macOS 26 it hides the list's own content-background material, which never follows the window color, so the item's glass shows through and the pane matches the sidebar; before macOS 26 the list keeps its background. The inspector field list and the agent's proposed statements take it. - - The editor tab strip track draws `secondarySystemFill` over a themed backdrop on macOS 26 and later, where the track sits on the theme's window color, and its unthemed fill otherwise. Never a palette color. -- **A code field takes the editor theme wherever it appears.** Startup Commands, Pre-Connect Script, a favorite's query, Rewind, Agent SQL, the deep-link Startup SQL and the tunnel command preview wear `editor.*`; the form or sheet around them stays system. -- **The slot family follows what the surface shows.** Code, values, value trees and wells: `editor.*`, with tree tones from `editor.syntax.*`. Records, schema objects, diagram nodes, chart plots and states that stand in for a grid: `dataGrid.*`. Diff rows: `dataGrid.inserted`, `modified` and `deleted` over the editor background and text. A region behind panes: `window.background`. Identity, severity and status hues stay system. -- **Every site passes what it draws natively.** A themed call site names a slot and, as its fallback, the exact system color or style it drew before, so while the active theme leaves the slot out the pixels are an unthemed app's. A default changes only on purpose, named in the PR and in a changelog fragment. +- **A theme colors content and the window backdrop, never chrome.** Content: code, the grid, values and wells (an editable value is a `ValueTextField`, not a bezeled control), value trees, diffs, record tables, diagrams, charts, and their stand-in states. Chrome: controls, Accent, Highlight, sidebar, inspector frame, toolbar, titlebar, tab strip, bars, Settings, Welcome, the connection form, sheets, popovers, and `List`, `Form` and `Table` backgrounds. `NSApp.appearance` is the only lever on chrome. +- **Chrome never reads a slot.** It asks `ThemeBackdropState.isThemed` and picks a system style: `.paneBarBackground(_:ignoresSafeAreaEdges:)` on a pane bar (nothing while themed; no edges, so a top bar never reaches under the toolbar), `.statusBarChrome()` on a status bar, `.splitItemGlassBackground()` on a list filling a glass sidebar or inspector item. No `.tint` with a theme color. +- **The slot family follows the surface.** Code, values, trees, wells: `editor.*`, so a code field in a form (Startup Commands, Agent SQL) wears it while the form stays system. Records, schema objects, diagram nodes, chart plots, grid stand-ins: `dataGrid.*`. Diff rows: `dataGrid.inserted`, `modified`, `deleted`. Behind panes: `window.background`. Identity, severity and status hues stay system. +- **Every call site passes the exact system color it drew before as the fallback**, so an unset slot draws an unthemed app. A default changes only on purpose, named in the PR and a changelog fragment. -## The model: `TableProTheme` +## Model -- **`TableProTheme` (`apple/Packages/TableProCore/Sources/TableProTheme`) is the theme model, Foundation only**: `ThemeSlot`, `ThemeColor` (RGBA8 with compositing, mixing and WCAG contrast), `ThemeDefinition`, `ThemeFileFormat`, `ThemeLoadError`, `ThemeResolver`, `ThemeOrdering`, `DefaultSyntaxPalette` and `ThemeFileImporter`. AppKit and SwiftUI stay in `apple/TablePro/Theme`, so the iOS app can adopt the package. Its tests are `TableProThemeTests`, under `swift test`. -- **`ThemeSlot` is the one registry.** The decoder, the encoder, the theme editor and the VS Code mapping enumerate it. Each slot declares `group` (its file key), `section` (where the theme editor lists it), `label`, `accessibilityLabel` (unique, one whole localized phrase), `yieldsToIncreasedContrast`, `requiresOpaque`, `offersOpacity` and `fallback`, each an exhaustive switch, so a new slot cannot ship without every decision. A slot exists only with a reader: nothing checks for one no view reads, so removing a slot's last reader removes the slot. -- **Every slot is optional, and `fallback` decides what an unset one draws.** `ThemeDefinition.value(for:highContrast:)` returns `.themed`, `.system(slot)` or `.defaultSyntax(slot)`: - - `.system`: the slot's native color, `ThemeSlot.nativeColor`. - - `.defaultSyntax`: the same appearance's `DefaultSyntaxPalette` color (Xcode's Default, with its high-contrast variant). Syntax has no system color. - - `.slot(other)`: the other slot's theme color, else native. The operator follows `editor.text`; the bools and the header text follow `dataGrid.text`. - - `.derived(base:rule:)`: built from the base while the theme sets it, else native. The alternate row is the grid background mixed 4% toward the grid text, or toward black in a light theme and white in a dark one when the text is unset. The header background equals the grid background. - - `.paired(with:fallback:)`: `dataGrid.selectionText` draws a theme color only over a themed `dataGrid.selection`, its own color or else the grid text. Under AppKit's selection it stays native, white text included. - - A rule names only slots that fall back to the system themselves, so resolution never chains. -- **`isOverridden(_:)` means the slot draws a theme color, set or derived.** Every carrier asks it before it swaps a native drawing path. Only the theme editor asks `hasExplicitColor(_:)`, which means the file writes the slot. -- **Default Light and Default Dark set no slot.** They draw a native app plus the default syntax palette. A duplicate of one carries no overrides either, so it keeps the high-contrast syntax. -- **Opaque slots.** `requiresOpaque` holds `window.background`, `editor.background`, `dataGrid.background` and `dataGrid.headerBackground`: the codec and `value(for:highContrast:)` drop their alpha, so no fill shows the desktop through the window. `offersOpacity` marks the washes drawn over content (selections, current line and statement, invisibles, alternate row, change tints); only their wells offer opacity. No slot has both. -- **Schema policy.** Adding a slot bumps `ThemeSchema.current`. The decoder keeps reading every format from 3 up and refuses a newer one, and an unknown key stays an error: a file is refused rather than loaded without colors its author wrote. The bump also renames the sync content kind (Sync, below). -- **Import.** `ThemeFileImporter.importTheme(at:)` checks the size before it reads a byte, reads a TablePro theme (a numeric `schema`) or a VS Code color theme (`colors`, `tokenColors`, `include`, `settings` or VS Code's `$schema`), and mints a fresh id every time: a file's id says nothing about which theme on this Mac it is. A VS Code `include` never leaves the chosen file's folder. `ThemeSlot.vscodeSources` is an exhaustive switch, and a slot the fallback rules derive the way VS Code paints it stays unset. +- `TableProTheme` is Foundation only, so the iOS app can adopt it; AppKit and SwiftUI code stays in `apple/TablePro/Theme`. +- **`ThemeSlot` is the one registry.** A slot exists only while a view reads it: removing its last reader removes the slot. +- `isOverridden(_:)` means the slot draws a theme color, set or derived: ask it before swapping a native drawing path (row selection, caret, header). Only the theme editor asks `hasExplicitColor(_:)`. +- **Adding a slot bumps `ThemeSchema.current`.** The decoder reads every schema from 3 up and refuses a newer one or an unknown key, rather than load a file without colors its author wrote. The bump renames the sync content kind (Sync). +- **An imported file is untrusted.** `ThemeFileImporter` checks the size before it reads, mints a fresh id every time, and never follows a VS Code `include` out of the file's folder. -## Reading colors in the app +## Reading colors -- **`ThemeScope` is where a carrier reads.** `ThemeScope.live` forwards to `ThemeEngine.shared` and `AppEvents.shared.themeChanged`. `ThemeScope.fixed(_:)` draws one definition in both appearances on its own `ThemeSource` and never reports a change; a new definition is a new scope. Only the theme editor preview passes a fixed scope. A scoped carrier reads `activeTheme`, `palette`, `resolved` and `changes` through its scope, never through `ThemeEngine.shared`. The SwiftUI modifiers read the engine. -- **Two color tiers.** `palette[slot]` and `palette.color(_:otherwise:)` are dynamic colors over the scope's theme pair; use them wherever a view holds a color. `palette.color` returns a new object on every call, so never compare it to skip a write. Each `ThemeSource` names its colors apart, so a fixed scope's color never equals a live one. `resolved[slot]` is the active theme's color or the system color object itself, never a flattened copy; bake it into a `CGColor` only inside the drawing view's appearance (`resolved.cgColor(_:otherwise:in:)`), and check `resolved.isOverridden(_:)` before replacing a native drawing path (row selection, caret, header). -- **The carriers.** SwiftUI: `.themeBackground(_:native:)`, `.themeForeground(_:native:)`, `CodeWell`, `.valueWell(clipsContent:)`, `ValueTextField` (an editable value: a borderless field filling a `.valueWell()`, with its own field editor on `ThemeTextViewStyle`), `TextValueEditor(style: .code)`, `.gridStandInBackground(isActive:)`, `.valueStandInBackground()` (a state that takes the place of a value's tree or text, on `editor.background`) and `.geometryMapWell()` (the map pane's well). AppKit, each taking a `scope:`: `ThemeTextViewStyle` on an `NSTextView`; `ThemeTableChrome` (`.records` or `.code`) on a content `NSTableView` or `NSOutlineView`, never a source list, with `ThemeTableRowView` rows and `ThemeTableChrome.HostingCell` cells; `DataGridView(themeScope:)`. Window: `ThemeWindowBackdrop` and `DetailColumnBackdrop` (Windows, below). -- **A themed records header is drawn by the theme.** `NSTableHeaderView` paints its own band over anything beneath it, so while `dataGrid.headerBackground` or `headerText` is themed, `ThemeTableChrome` swaps in `ThemeTableHeaderView` and `ThemeTableCornerView`, and puts the table's own back once the theme stops. The data grid's header: `.claude/rules/data-grid.md`. -- **Hold every AppKit carrier strongly**, as a stored property on its owner. Its subscription and its row views keep weak references, so a carrier held only in a local stops at the first theme change. -- **A theme change has to reach the view.** `AppEvents.shared.themeChanged` fires when the active pair changes (a selection, a catalog edit or sync, a draft preview), on a light/dark flip, an Accent or Highlight change, an Increase Contrast toggle and a font change. A scoped carrier subscribes to its scope's `changes`. A SwiftUI view that reads the engine observes it (`@ObservedObject private var themeEngine = ThemeEngine.shared`), because SwiftUI caches `Color(nsColor:)` by object identity; an AppKit view that bakes a color re-bakes on it. Never call `withAlphaComponent` on a system color outside an `NSColor(name:dynamicProvider:)`: it freezes the color. -- **No fixed color on selectable-row text.** A theme color does not turn white on the emphasized selection the way `.primary` does, so `.themeForeground` never goes on a container of selectable rows. Row text keeps a hierarchical style unless the theme also sets that table's selection; `ThemeTableChrome.recordsTextSlot(onThemedSelection:onEmphasizedSelection:highContrast:scope:)` picks a records cell's slot. -- **The window backdrop yields to Increase Contrast** and draws native there, since the format has no increased-contrast variant. Content slots keep the theme. +- **A scoped carrier reads through its `ThemeScope`, never `ThemeEngine.shared`.** Only the theme editor preview uses `.fixed(_:)`, and a new definition is a new scope. +- `palette.color(_:otherwise:)` returns a new object on every call: never compare it to skip a write. Bake `resolved` into a `CGColor` only inside the drawing view's appearance (`resolved.cgColor(_:otherwise:in:)`). +- **Hold every AppKit carrier** (`ThemeTextViewStyle`, `ThemeTableChrome`, never on a source list) **as a stored property.** Its subscription and row views hold it weakly, so one held in a local stops at the first theme change. +- `NSTableHeaderView` paints over anything beneath it, so `ThemeTableChrome` swaps in `ThemeTableHeaderView` while the header is themed. +- **A theme change has to reach the view.** SwiftUI caches `Color(nsColor:)` by object identity, so a view reading the engine holds `@ObservedObject private var themeEngine = ThemeEngine.shared`; an AppKit view that bakes a color re-bakes on `AppEvents.shared.themeChanged`. `withAlphaComponent` on a system color outside `NSColor(name:dynamicProvider:)` freezes it. +- **No theme color on selectable-row text**: it does not turn white on the emphasized selection. Never put `.themeForeground` on a container of selectable rows; `ThemeTableChrome.recordsTextSlot(...)` picks a records cell's slot. -## Windows and the backdrop +## Windows -- **The four content windows are `ContentWindow`s**: main (`TabWindowController`), Data File, Compare & Sync and the value viewers. Each holds a `ThemeWindowBackdrop`, which on macOS 26 and later sets `NSWindow.backgroundColor` to `window.background` only while the theme sets it and puts the window's own color back when it stops; the sidebar, inspector and toolbar glass take their tone from it. Before macOS 26 the window keeps its own color. `ThemeWindowBackdrop.canReachChrome` is that gate; the titlebar policy, `DetailPaneBackdrop.extendsUnderToolbar` and `EditorTabStripPalette.followsWindowBackdrop` read it rather than an `#available` of their own. -- **One titlebar policy, in `ContentWindow`.** On macOS 26 and later the titlebar is transparent, so the backdrop reaches the toolbar; in full screen with the toolbar hidden it keeps its background, since it then slides down over the content. Before macOS 26 it stays opaque: a transparent titlebar there swaps its material for a flat fill, which fills chrome. A new content window subclasses `ContentWindow`. Toolbar separators stay each window's own. -- **The detail column sits on `window.background` on every macOS version.** `MainSplitViewController` installs `DetailColumnBackdrop` under the detail panes through `WorkspacePaneHost.installBackdrop(_:)`, so Connecting, Unavailable, the agent and every other state get it. Before macOS 26 it stops below the toolbar, which keeps the system color. The backdrop passes clicks through. `DetailPaneBackdropFill` goes only there; a pane that draws nothing shows it. The Compare & Sync and Data File roots fill with `.themeBackground(.windowBackdrop, native:)`. -- **Never fill chrome.** No fill on the sidebar, inspector frame, toolbar or titlebar, and no `.tint` with a theme color. AppKit keeps its own selection unless the theme sets the selection slot. SwiftLint's `pane_hidden_background` bans `.scrollContentBackground(.hidden)` and `.listRowBackground(` in app code outside `SplitItemGlassBackground`, and `window_background_outside_backdrop` bans a window color write outside `ThemeWindowBackdrop`. +- **A content window subclasses `ContentWindow`** and holds a `ThemeWindowBackdrop`. On macOS 26 and later the backdrop sets `window.background` only while the theme sets it; earlier the window keeps its own color. Code asks `ThemeWindowBackdrop.canReachChrome`, never an `#available` of its own. +- `ContentWindow` owns the titlebar. Before macOS 26 it stays opaque: a transparent one there swaps its material for a flat fill. +- The detail column sits on `DetailColumnBackdrop`, so a pane that draws nothing shows `window.background`. `DetailPaneBackdropFill` goes only there. ## Engine and catalog -- **`ThemeEngine` owns the selection**: `mode`, `lightThemeId` and `darkThemeId`, set through `apply(mode:)`, `select(lightThemeId:darkThemeId:)` and `apply(mode:lightThemeId:darkThemeId:)`. It never writes settings. It resolves with `ThemeResolver` over the catalog's themes in `ThemeOrdering` order and follows `ThemeCatalog.$themes` itself, so a save, trash, import, sync or file changed on disk repaints with no view involved; a view never asks it to reapply. A burst of catalog or draft changes costs one resolve per run loop turn, in the common modes, because a color well drag runs the event-tracking mode. -- **`ThemeResolver` is pure.** A selected theme that is missing, rejected or of the other appearance draws that slot's default, and the saved id stays, so the theme comes back when its file does. -- **Draft overlay.** `beginPreview(_:)` stands a draft in for the theme with its id until `endPreview(id:)`, so the theme editor repaints the real windows on every change with no file write. A draft no pair slot selects draws nothing. -- **`ThemeCatalog` owns the files.** Built-ins ship as JSON in `apple/TablePro/Resources/Themes` (ids `tablepro.*`, read-only), besides Default Light and Default Dark from the package. User themes are JSON files in Application Support's `TablePro/Themes` folder, resolved through `AppStorageEnvironment`, with ids from `ThemeIdentifier.generated()`. A user theme is its JSON `id`, in whatever file it loaded from. The catalog reloads whenever TablePro becomes active. -- **A file changed on disk wins.** A save, rename or trash of a theme whose file changed or vanished since it was read throws `changedOnDisk` or `deletedOnDisk` and reloads; it never overwrites the file or recreates it under another name. `trash(id:)` moves the file to the Trash, or into the storage environment's own folder when that is isolated, and returns a `ThemeTrashReceipt` that `restore(_:)` takes back. `rename(id:to:)` refuses a taken name (`nameTaken`, case-insensitive); duplicate and import number theirs. Two files with one name show as "Name 2" in the list only, never in the file, so a display fix never syncs. -- **A built-in theme carries its source's license**: an entry in `apple/TablePro/Resources/ThirdPartyLicenses/licenses.yml` and its text in `apple/TablePro/Resources/ThirdPartyLicenses/texts`, checked by `apple/scripts/ci/check-license-inventory.py`. +- **`ThemeEngine` never writes settings and follows `ThemeCatalog.$themes` itself**: a view never asks it to reapply. A burst resolves once per run loop turn, scheduled in the common modes, since a color well drag runs the event-tracking mode. +- **`ThemeResolver` is pure.** A missing, rejected or wrong-appearance theme draws the default and the saved id stays, so the theme returns with its file. +- A user theme is its JSON `id`, whatever its file name. +- **A file changed on disk wins.** Save, rename or trash of a theme whose file changed or vanished since it was read throws `changedOnDisk` or `deletedOnDisk` and reloads; it never overwrites or recreates the file. Two themes with one name show as "Name 2" in the list only, never in the file, so a display fix never syncs. -## The theme editor +## Theme editor -- **Settings > Appearance is chrome.** It holds the mode popup on `AppSettingsManager.appearanceMode` ("Applies to this Mac."), the light and dark theme popups, and the themes list: `ThemeLibrarySection` over `ThemeLibraryModel`, which owns every list action and returns typed results. -- **One Theme Editor window per theme.** `ThemeEditorWindows` keeps them, and opening a theme again focuses its window. `ThemeEditorWindowController` is chrome; only its preview wears the theme. `ThemeEditorModel` owns the draft: undo on the window's undo manager with one step per settled change, Revert, a save about 750 ms after a change settles and on close, and the catalog's conflict errors as alerts. A built-in opens read-only, with Duplicate. -- **The preview is the real views.** A read-only `SourceEditor` themed through `EditorThemeFactory.make(resolved:highlightsCurrentLine:highlightsCurrentStatement:)` and a `DataGridView` with `themeScope:`, both on the draft's `ThemeScope.fixed(_:)`, drawn in the draft's appearance. It never takes focus: its editor and grid answer Undo with histories of their own. -- **Set the window's delegate before its content.** A window whose undo manager was read while it had no delegate keeps its own manager and never asks `windowWillReturnUndoManager(_:)`. -- **A slot row** is a `ColorPicker` with `supportsOpacity: slot.offersOpacity` and the slot's `accessibilityLabel`. Its reset shows only while `hasExplicitColor(_:)` is true. +- The preview is a real `SourceEditor` and `DataGridView` on `ThemeScope.fixed(_:)`. It never takes focus: its editor and grid answer Undo with their own histories. +- **Set the window's delegate before its content.** A window whose undo manager was read with no delegate keeps its own and never asks `windowWillReturnUndoManager(_:)`. ## Sync -- **Each user theme syncs as an `AppSettings` record, category `theme.`**, its payload the canonical bytes from `ThemeFileFormat.encode(_:)`. The catalog marks it dirty on save, rename, duplicate, import and restore, and marks it deleted once the trash has landed (persist, then notify). A load or migration marks nothing. Built-ins never sync. -- **A synced theme is untrusted input.** `ThemeCatalog.applyRemote(category:data:)` checks, in order, the category id (never a built-in id), a 64 KB cap, the nesting depth, a full decode (a newer schema fails here), and the file's id against the category's, and never writes over bytes it has not read. A validation failure is a logged skip. Only a disk failure returns `.failed`, so the coordinator withholds the change token and the pull replays. A remote deletion moves the local file to the Trash through `removeWithoutSync(ids:)`. -- **The theme ids sync; the appearance mode does not.** `AppearanceSettings` (category `appearance`) holds only the light and dark theme ids. `AppSettingsManager.appearanceMode` is device-local (`AppSettingsCategory.deviceLocal`, its own defaults key), and a received `appearance` record never changes it. Fonts are device-local too (`.claude/rules/editor.md`). -- **`SyncCoordinator.themeContentKind` names `ThemeSchema.current`**, so a Mac that upgrades past a schema bump fetches the zone once and picks up the theme records its older build skipped. After the first full pull, only local themes the server lacks are marked dirty, once. +- **Each user theme syncs as the `AppSettings` record `theme.`** holding `ThemeFileFormat.encode(_:)` bytes. A user edit marks it dirty, a trash marks it deleted once the trash lands, and a load or migration marks nothing. Built-ins never sync. +- **A synced theme is untrusted.** `ThemeCatalog.applyRemote(category:data:)` checks the category id (never a built-in), a 64 KB cap, nesting depth, a full decode and the file id, and never writes over bytes it has not read. A validation failure is a logged skip; only a disk failure returns `.failed`, which withholds the change token so the pull replays. A remote delete trashes the file through `removeWithoutSync(ids:)`. +- The light and dark theme ids sync. The appearance mode and fonts are device-local, and a received `appearance` record never changes them. +- `SyncCoordinator.themeContentKind` names `ThemeSchema.current`, so a Mac upgraded past a bump refetches the zone once. After the first full pull, only local themes the server lacks are marked dirty, once. diff --git a/.claude/rules/ui-lifecycle.md b/.claude/rules/ui-lifecycle.md index bee3f72690..8b1a3b8df1 100644 --- a/.claude/rules/ui-lifecycle.md +++ b/.claude/rules/ui-lifecycle.md @@ -15,5 +15,5 @@ paths: - A coupled edit is one whole-value write through a model method (`HighlightRule.selectingOperator(_:)` is the shape), never two field writes through a `Binding`: the second resolves against the same stale value and the first is lost. - Never put `.accessibilityIdentifier` on a SwiftUI container alone: it replaces every descendant's identifier in that hosting tree. Pair it with `.accessibilityElement(children: .contain)` first. How a UI test queries a leaf control is in `.claude/rules/ui-tests.md`. - Keep focus, selection, undo, the responder chain and IME working after the change, and keep UI state on the main actor (no `Task.detached` to escape it). -- SwiftUI targets macOS 13. An API from a later release needs `if #available` and a fallback. +- An API newer than the deployment target (`apple/AGENTS.md`) needs `if #available` and a fallback. - A view that is AppKit on purpose says why in its type comment (`FavoritesOutlineView`: a SwiftUI `List` cannot draw an emphasized selection under a bare `NSHostingController`). Read it before converting. diff --git a/apple/AGENTS.md b/apple/AGENTS.md index 13aa87b0fe..df662a2663 100644 --- a/apple/AGENTS.md +++ b/apple/AGENTS.md @@ -45,7 +45,7 @@ apple/scripts/generate-project.sh [both|macos|ios] # after a project.yml or Con - `verify.sh --help` lists every step. When each runs: `.claude/skills/fix-issue/references/platform-apple.md`, Verify. Verdicts, test names and red CI checks: `.claude/skills/fix-issue/references/verification-apple.md`. - A raw `xcodebuild` runs in `apple/` with `-skipPackagePluginValidation`, plus `-testPlan Unit` or `UI` for macOS tests (`CONTRIBUTING.md`, Tests). -- Never add `-parallel-testing-enabled`, `-test-iterations` or `-retry-tests-on-failure` to a macOS run, except to reproduce a flake: the plans own those. iOS tests pass `-parallel-testing-enabled NO`. +- Never add `-parallel-testing-enabled`, `-test-iterations` or `-retry-tests-on-failure` to a macOS run, except to reproduce a flake: the plans own those. iOS test flags: `.claude/rules/tests.md`, Plans. - Never run `swiftlint --fix`. Formatting SwiftLint leaves open: `CONTRIBUTING.md`, Code style. - Releases: `RELEASING.md` and the `release` skill. diff --git a/apple/CONTRIBUTING.md b/apple/CONTRIBUTING.md index f810b012ea..088f974c2e 100644 --- a/apple/CONTRIBUTING.md +++ b/apple/CONTRIBUTING.md @@ -6,7 +6,7 @@ This file covers the macOS and iOS apps in `apple/`, and every command in it run | Software | Version | Notes | |---|---|---| -| Xcode | 27.1 | What CI selects in `.github/actions/setup-xcode`. Code that needs the macOS 27 SDK sits behind `SDK_MACOS_27` (`Configs/Base.xcconfig`) so Xcode 26 can still build, though nothing on CI builds with 26. Newer SDK symbols sit behind `#available`; the deployment target is macOS 13.0 | +| Xcode | 27.1 | What CI selects in `.github/actions/setup-xcode`. Code that needs the macOS 27 SDK sits behind `SDK_MACOS_27` (`Configs/Base.xcconfig`) so Xcode 26 can still build, though nothing on CI builds with 26. Symbols newer than the deployment target ([`AGENTS.md`](AGENTS.md)) sit behind `#available` | | XcodeGen | 2.46.0 exactly | The generated project differs between versions, so `scripts/generate-project.sh` warns on any other | | SwiftLint | 0.65.1 | The version CI runs | | GitHub CLI | any | Optional: `scripts/download-libs.sh` falls back to `curl` | diff --git a/apple/RELEASING.md b/apple/RELEASING.md index d3a453a351..789bc4e2e5 100644 --- a/apple/RELEASING.md +++ b/apple/RELEASING.md @@ -152,7 +152,7 @@ gh workflow run withdraw.yml --ref v -f version=X.Y.Z ## Static libraries -The `.a` files under `Libs/` and the xcframeworks under `Libs/ios/` live on the immutable `libs-` GitHub release that `Libs/release` names, not in git; `scripts/download-libs.sh` fetches it and verifies every file against the checksums at `HEAD`. `scripts/publish-libs.sh` takes every library you rebuilt, added or removed, verifies the others against `HEAD`, creates the next `libs-` and rewrites `Libs/release` and both checksum files (`--dry-run` only prints). A published `libs-` never changes: a fix is the next ``. Each library is built by a `build-*.sh` in `scripts/` or `scripts/ios/`. +The `.a` files under `Libs/` and the xcframeworks under `Libs/ios/` live on the immutable `libs-` GitHub release that `Libs/release` names, not in git; `scripts/download-libs.sh` fetches it and verifies every file against the checksums at `HEAD`. `scripts/publish-libs.sh` takes every library you rebuilt, added or removed, verifies the others against `HEAD`, creates the next `libs-` and rewrites `Libs/release` and both checksum files, which nothing else edits (`--dry-run` only prints). A published `libs-` never changes: a fix is the next ``, and `libs-v1` stays for older commits. Each library is built by a `build-*.sh` in `scripts/` or `scripts/ios/`. ```bash apple/scripts/publish-libs.sh libmongoc_arm64.a libmongoc_x86_64.a libmongoc_universal.a libmongoc.a