diff --git a/.agents/skills/cross-model-review/SKILL.md b/.agents/skills/cross-model-review/SKILL.md deleted file mode 100644 index 2b660bb8f..000000000 --- a/.agents/skills/cross-model-review/SKILL.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -name: cross-model-review -description: Independent cross-vendor code review protocol for TablePro changes. Use after Claude Code or Codex implements a medium-risk change, and always for data loss, destructive SQL, credentials, auth, MCP, AI permissions, sync, migrations, plugin ABI, concurrency, C boundaries, signing, or release automation. It invokes the other vendor read-only, prevents review recursion, validates findings, and produces evidence-ranked results. ---- - -# Cross-model Review - -Read `references/review-rubric.md` for the P0 to P3 scale and the evidence contract every finding -must satisfy. Do not start a cross-model review from inside a review session. - -## The packet - -Give the reviewer: - -- Observable behavior and acceptance criteria. -- The base reference and the exact diff scope, including **which tree** the diff is in. -- The invariants that apply. -- The verification steps already run and their verdicts. -- One focused threat statement for high-risk work. - -Do not prime the reviewer with your preferred conclusion, and do not include your own self-review. - -## When Claude Code is the writer - -Only one Codex entry point can be invoked by an agent. `/codex:review`, -`/codex:adversarial-review`, `/codex:status`, and `/codex:result` are all declared -`disable-model-invocation: true`, so they work when the user types them and not otherwise. A run -that "starts a Codex review" with those and then waits for a result waits forever. - -Use `/codex:rescue`, which is model-invocable, with `--wait` so the result returns in this turn -rather than into a status command you cannot call: - -```text -/codex:rescue --wait --fresh Read-only review of . Do not edit files, do not commit, -do not run builds. Review against . Read AGENTS.md and the -invariant files under .agents/skills/tablepro-engineering/references/. Report only P0 to P3 -findings with file:line, evidence, a failure scenario, and the smallest valid fix. -Threat to focus on: . -``` - -Three things about that command are load-bearing: - -- **The read-only wording is the guard, not decoration.** The rescue forwarder defaults to a - write-capable run and only stays read-only when the request says so. Keep "Read-only" and "Do not - edit files" literally, and check the returned job did not write. -- **Name the tree explicitly.** `rescue` has no `--cwd`, and the companion falls back to the session - working directory, which is the main checkout. A `$fix-issue` run's diff lives in its worktree, so - give the absolute worktree path in the prompt and tell the reviewer to read it with - `git -C diff`. Without that the review reads a different tree and its findings are noise. -- **Omit `--effort`.** The run inherits the repository's Codex profile. Do not lower it. - -Run one review for a medium-risk change, plus one focused adversarial pass for high-risk work. If -the review cannot be started, say so in the handoff and do not describe the change as reviewed. - -## When Codex is the writer - -Invoke Claude Code non-interactively and read-only from the repository root: - -```bash -claude -p --model opus --effort ultracode --permission-mode plan --no-session-persistence \ - --tools "Read,Grep,Glob,Bash" \ - --disallowedTools "Write,Edit,NotebookEdit,Agent" \ - "Review the working tree at against its merge base. Do not edit files, commit, push, - stash, reset, build, invoke Codex, or start another cross-vendor review. Read AGENTS.md, the - relevant invariant files, the diff, callers, and tests. Report only actionable correctness, - security, data-loss, concurrency, ABI, behavior, and missing-test findings, each ranked P0 to - P3 with file:line, evidence, a failure scenario, and the smallest valid fix. State explicitly - when no findings survive verification." -``` - -Details that matter: - -- `-p` is required. `--no-session-persistence` only applies with print mode, and without it the - call opens an interactive session that never returns a review. -- `--tools` caps which tools exist. `--allowedTools` is a permission allow-rule, not a restriction: - listing `Bash` there pre-approves every command in a session that cannot prompt. Cap availability - with `--tools`, and rely on `--permission-mode plan` plus the written prohibitions above. -- `--effort ultracode` is real and maps to the ultracode profile. Do not downgrade it. - -For high-risk work, make one second call with a narrow threat statement. Do not reuse or continue -the first review session. - -If the Claude CLI is unavailable, use Codex's project `adversarial_reviewer` agent, which runs -`sandbox_mode = read-only`, and disclose that the review was not cross-vendor. If Codex's sandbox -blocks Claude authentication or Keychain access, request approval to run the same read-only command -in the host environment. Never modify credentials or Keychain state; if approval is unavailable, use -the local fallback and report the limitation. - -## Resolve findings - -1. Verify each finding in source, tests, SDK documentation, headers, or a probe. -2. Reject speculation and style-only preferences. -3. Fix confirmed P0 to P2 findings in the writer session. -4. Re-run the affected verification step. -5. Re-review only when a fix materially changes the design or a high-risk boundary. - -## Recursion caps - -- One primary external review per change, plus one adversarial pass for high-risk work. -- A reviewer never invokes the other vendor, never invokes another reviewer, and never reviews its - own output. -- Reviewers are read-only. A review leader may run read-only evidence lanes; it does not fix, commit, - push, or open pull requests. -- Never enable an automatic review gate that can fire on every stop. That is the one configuration - that can loop two agents against each other. -- Never let both vendors write in the same checkout. - -The writer validates every finding and owns the final decision. diff --git a/.agents/skills/cross-model-review/agents/openai.yaml b/.agents/skills/cross-model-review/agents/openai.yaml deleted file mode 100644 index fcd1fa696..000000000 --- a/.agents/skills/cross-model-review/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Cross-model Review" - short_description: "Review TablePro changes across Claude and Codex" - default_prompt: "Use $cross-model-review to run an independent review of this TablePro change." diff --git a/.agents/skills/cross-model-review/references/review-rubric.md b/.agents/skills/cross-model-review/references/review-rubric.md deleted file mode 100644 index ab18e02d6..000000000 --- a/.agents/skills/cross-model-review/references/review-rubric.md +++ /dev/null @@ -1,30 +0,0 @@ -# TablePro Review Rubric - -## Priority - -- `P0`: active data loss, credential exposure, destructive operation without a boundary, or a release-blocking break. -- `P1`: reachable correctness, security, ABI, concurrency, crash, or silent-corruption defect. -- `P2`: material behavior regression, missing failure handling, or a test gap likely to let a defect escape. -- `P3`: maintainability issue with a concrete future failure mode. Do not report taste or formatting. - -## Required evidence - -Every finding includes: - -1. `file:line` and the exact symbol or state transition. -2. A concrete input, sequence, or environment that reaches the failure. -3. Why existing guards or tests do not prevent it. -4. The smallest correct fix and the test that proves it. -5. Whether the finding is confirmed, inferred, or blocked on measurement. - -## Review lenses - -- User data and destructive SQL safety. -- Credentials, auth, token scope, MCP allowlists, and AI tool permissions. -- Actor isolation, cancellation, late completion, task ownership, and process or C boundaries. -- PluginKit ABI, open plugin types, registry-only builds, and dialect-specific behavior. -- Persistence, sync ordering, schema refresh, cache retention, and migration compatibility. -- AppKit and SwiftUI lifecycle, focus, responder chain, accessibility, and window or tab ownership. -- Missing negative tests, UI automation, probes, docs, localization, and changelog entries. - -Return `No findings` when no claim meets the evidence bar. diff --git a/.agents/skills/tablepro-engineering/SKILL.md b/.agents/skills/tablepro-engineering/SKILL.md deleted file mode 100644 index 452e97373..000000000 --- a/.agents/skills/tablepro-engineering/SKILL.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: tablepro-engineering -description: End-to-end engineering workflow for the TablePro macOS and iOS repository. Use for any TablePro feature, refactor, test, build, plugin, driver, AI, MCP, sync, storage, UI, or documentation task that reads or changes repository files. It routes to the project invariant that governs the subsystem, coordinates independent investigation, enforces one-writer ownership, and requires build, test, lint, and review evidence. ---- - -# TablePro Engineering - -`AGENTS.md` is the workflow: scope, principles, work sequence, code rules, verification, and the -change contract all live there and are not repeated here. This file does one thing AGENTS.md -cannot: it routes you to the specific knowledge a subsystem needs, and it holds the few practices -that differ from the general rule. - -In Claude Code, a GitHub issue goes to `$fix-issue` instead of here. Do not load both. - -## Route to the invariant that governs your change - -The project reference is split by domain. Find the rule by symptom or symbol, not by reading a file: - -```bash -rg -n '^####' .agents/skills/tablepro-engineering/references/invariants-*.md -rg -n '||' .agents/skills/tablepro-engineering/references/ -``` - -| Working on | Read | -| --- | --- | -| Views, coordinators, windows, tabs, split panes, the data grid | `references/invariants-ui.md` | -| Connect and cancel, schema loading, caches, session state, pooling | `references/invariants-connections.md` | -| CloudKit sync, stored records, driver read and write paths | `references/invariants-data.md` | -| `Plugins/` or `TableProPluginKit` | `references/plugin-system.md` | -| Adding a file or target, orienting in a subsystem | `references/architecture.md` | -| `Libs/`, CI failures, shipping | `references/build-and-release.md` | -| Commit scopes, docs routing, lint limits, performance pitfalls | `references/conventions.md` | -| AI or MCP | `TablePro/Core/AI`, `TablePro/Core/MCP`, the tool policy, token scopes, connection allowlists, `docs/external-api/` | -| A driver or dialect | the driver invariant, the vendored header, the build script, and the sibling driver that already works | - -Read the full paragraph of a matching invariant. Each exists because it was violated and shipped a -bug. If one names a symbol that no longer exists, correct it in the same change rather than working -around it. - -Verification, quarantine lists, and environment traps: `.claude/skills/fix-issue/references/verification.md`. -Platform, SDK, and HIG sources: `.claude/skills/fix-issue/references/research-sources.md`. - -## Delegating investigation - -Give every lane the same problem statement and one narrow question. Require confirmed facts, -inferences, and unknowns to be labeled separately. Ask for the smallest answer that supports a -decision, anchored to `file:line`, and verify at those anchors rather than re-reading whole files. - -Beyond the default lenses in AGENTS.md, add a UI and HIG specialist when the change is user-facing: -native behavior, focus, the responder chain, and accessibility are decided by the HIG and by this -app's existing interaction language, not by what is easiest to build. - -## Where this differs from the general rule - -- **Run the smallest relevant test suite before the app build**, not after. A wedged XCTest host or - a stale generated project shows up in seconds there and costs a full build cycle later. -- **Fix the source when a test fails.** Never adjust a test to match incorrect output. -- **No compatibility shims and no temporary workarounds left in place.** If the shape cannot express - the behavior, change the shape. -- **Every verification step goes through the wrapper**, `.claude/skills/fix-issue/scripts/verify.sh`, - which stores the log and prints a verdict. A raw `xcodebuild` failure returns a truncated excerpt - with no log to read back, which is the one case where the whole output matters. diff --git a/.agents/skills/tablepro-engineering/agents/openai.yaml b/.agents/skills/tablepro-engineering/agents/openai.yaml deleted file mode 100644 index e7cbd8cbe..000000000 --- a/.agents/skills/tablepro-engineering/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "TablePro Engineering" - short_description: "Build and verify TablePro changes correctly" - default_prompt: "Use $tablepro-engineering to implement and verify this TablePro change." diff --git a/.agents/skills/tablepro-engineering/references/architecture.md b/.agents/skills/tablepro-engineering/references/architecture.md deleted file mode 100644 index 49f3ac6df..000000000 --- a/.agents/skills/tablepro-engineering/references/architecture.md +++ /dev/null @@ -1,53 +0,0 @@ -# Architecture - -## Project Overview - -TablePro is a native macOS database client built on SwiftUI and AppKit. It targets macOS 14.0, builds a Universal Binary (arm64 and x86_64), and compiles in Swift 5 language mode (`Configs/Base.xcconfig`). - -- **Source** lives in `TablePro/`: `Core/` (business logic, services), `Views/` (UI), `Models/` (data structures), `ViewModels/`, `Extensions/`, `Theme/` -- **Plugins** live in `Plugins/`: `.tableplugin` bundles plus the `TableProPluginKit` shared framework. - - **Bundled in app** (the 14 targets in the app's copy-to-PlugIns phase in `project.yml`): MySQL, PostgreSQL, SQLite, ClickHouse, Redis, CSV export, JSON export, SQL export, XLSX export, MQL export, SQL import, JSON import, CSV import, CSV inspector. Shipped only inside the app bundle. **Never publish bundled plugins to the registry.** Updates ride with the next app release. - - **Registry-only** (the other 16): MongoDB, Oracle, DuckDB, MSSQL, Cassandra, Etcd, CloudflareD1, DynamoDB, BigQuery, LibSQL, Snowflake, Elasticsearch, Beancount, SurrealDB, Teradata, Trino. Distributed via [TableProApp/plugins](https://github.com/TableProApp/plugins) `plugins.json`, installed into the user plugins directory. -- **C bridges**: Each plugin contains its own C bridge module (e.g., `Plugins/MySQLDriverPlugin/CMariaDB/`, `Plugins/PostgreSQLDriverPlugin/CLibPQ/`) -- **Static libs** live in `Libs/` as pre-built `.a` files, with iOS xcframeworks in `Libs/ios/`. Both are downloaded by `scripts/download-libs.sh` and are not in git. -- **SPM deps**: declared in `project.yml`. Vendored local packages under `LocalPackages/` (CodeEditSourceEditor, CodeEditTextView, CodeEditLanguages) and `Packages/` (TableProCore, TableProOracle); remote packages are Sparkle, swift-certificates and Yams. Revisions are pinned by the tracked `Package.resolved` inside each generated `.xcodeproj`. - - -### Project Generation - -`TablePro.xcodeproj` and `TableProMobile/TableProMobile.xcodeproj` are **generated artifacts**. They are gitignored and must never be hand-edited or committed. The source of truth is: - -- `project.yml` / `TableProMobile/project.yml`: targets, sources, dependencies, schemes, and per-target build settings -- `Configs/*.xcconfig`: project-wide and per-configuration build settings, shared by both projects -- `Configs/Version.xcconfig`: the app's `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION`, read by the release skill and by `build-plugin.yml` -- `Configs/Secrets.xcconfig`: gitignored, pulled in with `#include?`, holds `ANALYTICS_HMAC_SECRET` and per-developer signing overrides. `Configs/Secrets.xcconfig.example` is the template. - -Run `scripts/generate-project.sh` after editing any of those, and after adding, moving, or deleting a source file: XcodeGen globs sources at generation time, so a new file is not in the project until you regenerate. Changing signing in the Xcode UI is pointless, because the next generate discards it; set `TABLEPRO_DEVELOPMENT_TEAM` and `TABLEPRO_APP_BUNDLE_IDENTIFIER` in `Configs/Secrets.xcconfig` instead. - -The 30 plugin bundles share one `DriverPlugin` target template; a plugin declares only its folder, principal class, and any C-library link flags. Every target gets a shared scheme named after it, which is what `scripts/build-plugin.sh -scheme ` builds. The `AllPlugins` aggregate target compile-checks all 30, including the registry-only ones the app does not embed. - - -- Editor tabs are drawn by `EditorTabStrip`, not by native window tabs. A window belongs to exactly one `NSWindow` tab group and that group's bar shows every window in it, so a window hosting several connections could only ever show all of their tabs interleaved. Window tabbing itself stays on AppKit's terms: `TabWindowController` leaves `tabbingMode` at `.automatic`, which is the user's own System Settings preference, and never forces `.preferred`. -- Cursor model: `cursorPositions: [CursorPosition]` (multi-cursor via CodeEditSourceEditor) - -### Window Close (Cmd+W) - -`EditorWindow` (NSWindow subclass in `TabWindowController.swift`) overrides `performClose:` to route Cmd+W through `closeTab()`. SwiftUI's `.commands { Button(...).keyboardShortcut("w") }` does NOT replace AppKit's built-in "File > Close", both fire, and AppKit's wins. The NSWindow subclass is the correct native pattern. - - -### Storage Patterns - -| What | How | Where | -| -------------------- | ---------------- | ------------------------------------------- | -| Connection passwords | Keychain | `ConnectionStorage` | -| User preferences | UserDefaults | `AppSettingsStorage` / `AppSettingsManager` | -| Query history | SQLite FTS5 | `QueryHistoryStorage` | -| Tab state | JSON persistence | `TabPersistenceService` / `TabStateStorage` | -| Filter defaults | UserDefaults | `FilterSettingsStorage` (default column/operator, panel state) | -| Filter presets | UserDefaults | `FilterPresetStorage` | -| Per-table filters | JSON files | `FilterSettingsStorage` (one file per connection + database + schema + table; saves the valid working set, each row's enabled flag included) | -| Favorite tables | UserDefaults | `FavoriteTablesStorage` (per connection + database + schema; iCloud-synced) | -| Tree database filter | UserDefaults | `DatabaseTreeFilterStorage` (per connection; selected database set, empty = show all; device-local). Live value held in `SharedSidebarState`. | -| Recent tables | UserDefaults | `RecentTablesStore` (per connection, keyed by database, last 10 each; device-local). Live value held in `SharedSidebarState`, recorded at the `QueryTabManager` open chokepoint. | -| History drawer state | UserDefaults | `HistoryPanelPreferencesStorage` (per connection; visibility, connection scope, source/date/outcome filters; device-local). Live value held in `HistoryPanelState.forConnection`, cleared alongside `SharedSidebarState` when a session ends. | -| Trusted external links | UserDefaults | `ExternalConnectionTrustStore` (keyed by database type + host + database + username + URL `name`, never the port; loopback hosts only, enforced on read and write). Consulted by `ExternalConnectionGate` before the external-URL confirmation alert. | diff --git a/.agents/skills/tablepro-engineering/references/build-and-release.md b/.agents/skills/tablepro-engineering/references/build-and-release.md deleted file mode 100644 index c6adda961..000000000 --- a/.agents/skills/tablepro-engineering/references/build-and-release.md +++ /dev/null @@ -1,70 +0,0 @@ -# Build, Libraries, and CI - -## Build & Development Commands - -```bash -# First-time setup (and after any project.yml / Configs change, or adding a source file) -scripts/download-libs.sh # static libraries, not in git -scripts/generate-project.sh # generates both .xcodeproj bundles from project.yml - -# Build (development): -skipPackagePluginValidation required for SwiftLint plugin in CodeEditSourceEditor -xcodebuild -project TablePro.xcodeproj -scheme TablePro -configuration Debug build -skipPackagePluginValidation - -# Clean build -xcodebuild -project TablePro.xcodeproj -scheme TablePro clean - -# Build and run -xcodebuild -project TablePro.xcodeproj -scheme TablePro -configuration Debug build -skipPackagePluginValidation && open build/Debug/TablePro.app - -# Release builds -scripts/build-release.sh arm64|x86_64|both - -# Lint & format -swiftlint lint # Check issues -swiftlint --fix # Auto-fix -swiftformat . # Format code - -# Tests -xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProTests/TestClassName -xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProTests/TestClassName/testMethodName -xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProUITests - -# DMG -scripts/create-dmg.sh - -# Static libraries (after lib updates) -scripts/download-libs.sh --force # Re-download and overwrite -``` - - -### Updating Static Libraries - -Static libs (`Libs/*.a`) are hosted on the `libs-v1` GitHub Release (not in git). When adding or updating a library: - -```bash -# 1. Update the .a files in Libs/ (build scripts write them there) -# 2. Publish: verifies all OTHER local libs still match the checksums at HEAD, -# regenerates checksums.sha256, uploads the archive. Name every lib you rebuilt. -scripts/publish-libs.sh libmongoc_arm64.a libmongoc_x86_64.a libmongoc_universal.a libmongoc.a -# 3. Commit the updated checksums -git add Libs/checksums.sha256 && git commit -m "build: update static library checksums" -``` - -Never run `shasum -a 256 Libs/*.a > Libs/checksums.sha256` by hand: regenerating from a stale `Libs/` reverts other libraries silently (this shipped a broken libmongoc and rolled back DuckDB once). `publish-libs.sh` exists to make that impossible. - -```bash - -# iOS xcframeworks (Libs/ios/*.xcframework) -tar czf /tmp/tablepro-libs-ios-v1.tar.gz -C Libs/ios . -gh release upload libs-v1 /tmp/tablepro-libs-ios-v1.tar.gz --clobber --repo TableProApp/TablePro -``` - - -## CI/CD - -GitHub Actions (`.github/workflows/build.yml`) triggered by `v*` tags. The release job needs all five of `lint`, `test`, `build-arm64`, `build-x86_64`, and `registry-readiness`, so a registry missing a compatible plugin binary blocks the tag. Release notes are auto-extracted from `CHANGELOG.md` by `scripts/ci/extract-release-notes.sh`, which matches `## [X.Y.Z]` exactly. Test gating, the quarantine lists, and their traps live in `.claude/skills/fix-issue/references/verification.md`. - -**Plugin CI** (`.github/workflows/build-plugin.yml`): triggered by `plugin-*-v*` tags or `workflow_dispatch`. The dispatch input accepts comma-separated `tag:pluginKitVersion` pairs; if `:pluginKitVersion` is omitted, the workflow reads `currentPluginKitVersion` from `PluginManager.swift`. Registry update logic lives in `.github/scripts/update-registry.py` (atomic write, per-binary `pluginKitVersion`, prune-old policy). Use `scripts/release-all-plugins.sh ` for bulk re-release after an ABI bump. - -**Plugin tag naming**: Tag names must match the CI workflow's `resolve_plugin_info()` mapping. Notable non-obvious mappings: `CloudflareD1DriverPlugin` → `plugin-cloudflare-d1-v*`, `EtcdDriverPlugin` → `plugin-etcd-v*`. Check existing tags with `git tag -l "plugin-*"` before creating new ones. diff --git a/.agents/skills/tablepro-engineering/references/conventions.md b/.agents/skills/tablepro-engineering/references/conventions.md deleted file mode 100644 index be41dfa4a..000000000 --- a/.agents/skills/tablepro-engineering/references/conventions.md +++ /dev/null @@ -1,71 +0,0 @@ -# Conventions - -6. **No hacky solutions**: no backward-compatibility shims, no temporary workarounds left in place, no duct tape. If the right fix is harder, do the right fix. -7. **Testability**: every testable code change needs unit/function tests, and UI/user-flow changes should add UI automation where they run deterministically. When tests fail, fix the source code, never adjust tests to match incorrect output. - -### Logging & Debugging - -Use OSLog for all logging, never `print()`. When debugging issues, add structured OSLog statements to trace the problem, don't guess. - -```swift -import os -private static let logger = Logger(subsystem: "com.TablePro", category: "ComponentName") -``` - - -- **Access control**: always explicit (`private`, `internal`, `public`). Specify on extension, not individual members: - ```swift - public extension NSEvent { - var semanticKeyCode: KeyCode? { ... } - } - ``` -- **No force unwrapping/casting**: use `guard let`, `if let`, `as?` -- **Acronyms as words**: `JsonEncoder` not `JSONEncoder` (except SDK types) - -### SwiftLint Limits - -| Metric | Warning | Error | -| --------------------- | ------- | ----- | -| File length | 1200 | 1800 | -| Type body | 1100 | 1500 | -| Function body | 160 | 250 | -| Cyclomatic complexity | 40 | 60 | - -When approaching limits: extract into `TypeName+Category.swift` extension files in an `Extensions/` subfolder. Group by domain logic, not arbitrary line counts. - - -3. **Documentation**: Update docs in `docs/` (Mintlify-based) when adding/changing features: - - New keyboard shortcuts → `docs/features/keyboard-shortcuts.mdx` - - UI/feature changes → relevant `docs/features/*.mdx` page - - Settings changes → `docs/customization/settings.mdx` - - Database driver changes → `docs/databases/*.mdx` - - - **Canonical scopes** (reuse these instead of inventing new ones): - - AI: `ai-chat`, `ai-providers`, `mcp`, `copilot`, `inline-suggest` - - App UI: `editor`, `datagrid`, `tabs`, `coordinator`, `sidebar`, `connections`, `connection-form`, `welcome`, `settings`, `toolbar`, `hig` - - Infra: `ssh`, `ios`, `windows`, `perf`, `launch`, `plugins` - - Plugins: `plugin-` (e.g. `plugin-mongodb`, `plugin-redis`, `plugin-clickhouse`) - - Docs and release: `changelog`, `claude-md`, `docs`, `ci`, `release` - - **Examples**: `feat(ai-chat): add /refactor slash command`, `fix(editor): prevent crash on empty query result`, `refactor(mcp): migrate pairing store to actor`, `docs(changelog): adopt Keep a Changelog 1.1.0`. - -7. **Atomic API changes**: When you rename, remove, or change a public type, property, or function signature, update every caller AND every test in the same commit. Do not split a rename from "fix tests for rename" into separate commits; the in-between commit is broken, fails CI, and pollutes `git bisect`. If a refactor crosses too many files for one reviewable commit, narrow the change first or stage it behind a typealias the renaming commit removes. - -## Performance Pitfalls - -These have caused real production bugs: - -- **Never use `ForEach($bindable.array) { $item in }`** on `@Observable` arrays that can be cleared externally, index-based bindings crash with out-of-bounds when the array shrinks during SwiftUI evaluation. Use `ForEach(array) { item in` with a manual `Binding` via `binding(for: item)`. -- **Never use `string.count`** on large strings, O(n) in Swift. Use `(string as NSString).length` for O(1). -- **Never use `string.index(string.startIndex, offsetBy:)` in loops** on bridged NSStrings, O(n) per call. Use `(string as NSString).character(at:)` for O(1) random access. -- **Never call `ensureLayout(forCharacterRange:)`**: defeats `allowsNonContiguousLayout`. Let layout manager queries trigger lazy local layout. -- **SQL dumps can have single lines with millions of characters**: cap regex/highlight ranges at 10k chars. -- **Tab persistence**: `QueryTab.toPersistedTab()` truncates queries >500KB to prevent JSON freeze. `TabStateStorage.saveLastQuery()` skips writes >500KB. - - -**No em dashes (—).** Anywhere. Use a comma, period, colon, or rewrite the sentence. Hyphens (-) for compound words are fine. - -**No AI-generated filler.** If it sounds like a chatbot wrote it, rewrite it. Banned words: seamless, robust, comprehensive, intuitive, effortless, powerful (as filler), streamlined, leverage, elevate, harness, supercharge, unlock, unleash, dive into, game-changer, empower, delve, utilize, facilitate. No "Absolutely!" / "Ready to dive in?" / "Let's get started!" openers. - -**Be specific.** Numbers, tech names, file paths. "Runs in 200ms" beats "runs fast". "Uses `PQexecParams`" beats "uses native binding". diff --git a/.agents/skills/tablepro-engineering/references/invariants-connections.md b/.agents/skills/tablepro-engineering/references/invariants-connections.md deleted file mode 100644 index 2e25acd05..000000000 --- a/.agents/skills/tablepro-engineering/references/invariants-connections.md +++ /dev/null @@ -1,23 +0,0 @@ -# Invariants: connections, schema, and session state - -These have caused real bugs when violated: - -#### SQLSchemaProvider: an isLoading boolean guard returns without schema data - -**Schema loading**: `SQLSchemaProvider` (actor) stores an in-flight `loadTask: Task?`. Concurrent callers `await` the same Task instead of firing duplicate `fetchTables()` queries. Never use a boolean `isLoading` guard that returns without data, callers need to await the result. - -#### Schema and tree cache: a refresh that clears first blanks the sidebar - -**A refresh never clears the cache it is refreshing**: fetch first, then commit over the old value. A loading flag that discards data is a blank screen: `SchemaService.runLoad` used to write `states[id] = .loading` before the network call, which made `tables(for:)` return `[]`, so `SidebarView`'s `case .loading where tables.isEmpty` matched on every refresh and the whole object list became a spinner (#1916). Only enter `.loading` when there is no loaded content (`hasLoadedContent`), signal an in-flight refresh separately (`isRefreshing`), and keep a failed refresh from replacing good data (the guard `markLoadFailed` already had). The same rule covers per-schema state and `StructureTabDataState`, where "has data" (drives the tab counts) is deliberately separate from "needs refetch" (drives the reload) so marking everything stale never blanks a count. `DatabaseTreeMetadataService.reloadTablesInPlace` is the reference shape. Use `prepareForReload` before a reload and reserve `invalidate` for genuine teardown (disconnect, database switch); invalidating to force a reload wipes the visible tree. - -#### Connect cancellation: a cooperative cancel leaves a live driver and erases the session list - -**Cancelling a connect does not stop the driver**: `Task.cancel()` is cooperative, so it cannot interrupt a driver blocked in a C call. A cancelled attempt keeps running and completes late. Two rules follow. First, a driver that blocks on connect must expose its own abort path and poll it (the PostgreSQL driver uses `PQconnectStart`/`PQconnectPoll` with an app-owned deadline and a cancel flag flipped from `withTaskCancellationHandler`; a blocking `PQconnectdb` cannot be cancelled at all). When the driver's C API has no pollable connect (FreeTDS db-lib's `dbopen`), the other valid shape is to resume the awaiting caller on cancel or an app-owned deadline through a resume-once continuation gate (`SingleResumeGate` / `runCancellableBlocking`), keep the blocking call on its own serial queue, and have the late-completing call tear down its own handle (the loser `dbclose`s the `dbproc`) instead of adopting it; a process-global set before the blocking call (e.g. `KRB5CCNAME` for Kerberos) is set and restored inside that queue block so its lifetime tracks the real completion, not the early return (#1889). Second, never assume the losing attempt is gone: every attempt validates its `ConnectionAttemptRegistry` generation before adopting a driver into `activeSessions` or tearing session state down, so a late attempt discards its own driver instead of clobbering the winner. Cancelling also drops the connection from `LastOpenConnections.json` (via `SessionRecoveryTracker.sync()`) so "Reopen Last Session" never replays a connect the user cancelled, but a connect that merely *failed* keeps its place in the list: a database that was down is not a user who gave up. That distinction is `ConnectionWindowPhaseMachine.retainsRestoreIntent`, read per workspace through `ConnectionWorkspace.retainsRestoreIntent` and aggregated per window by `MainSplitViewController.connectionIdsRetainingRestoreIntent`, and it is the whole reason `RecoveryCandidate` carries `retainsRestoreIntent` alongside `isActivated`. Collapsing the two back into one flag makes one launch against a stopped server erase the session permanently. This area shipped the same bug four times (#1185, #1358, #1369). - -#### Connection window panes: deriving content from activeSessions freezes a spinner - -**A workspace's content is a function of its own `ConnectionWindowPhase`, never of `activeSessions` membership**: the global session dictionary can only say *present* or *absent*, and that vocabulary cannot tell "never started" from "connecting" from "failed" from "the user cancelled" from "the window is closing". Deriving the pane from it shipped a window that painted a live spinner forever after a failed launch restore, could not be repainted by a later successful connect, and left no route back to the connection list except the Dock icon's context menu. `ConnectionWorkspace` owns the `phase`, one per connection the window hosts; `ConnectionWindowPhaseMachine` owns the transitions (pure, exhaustive, `.closing` absorbing), and `ConnectionWindowPaneResolver` owns the pane choice (pure). `MainSplitViewController` renders the selected workspace and routes a transition by `connectionId` through `transition(to:for:)`; it is only an adapter, and its `phase` property is a pass-through to `workspaces.selected`. Three rules follow. First, every phase must have an exit: the old `closingSessionId` latch was set once and never cleared, so the controller went permanently deaf to `connectionStatusChanged`. Second, a cancel updates the UI synchronously with the button press and never waits on the driver, because `Task.cancel()` is cooperative and may have no observable effect; the attempt is fenced by a per-workspace `attemptToken` (`ConnectionWorkspace.attemptToken`) plus `DatabaseManager.invalidateConnectionAttempt`, so a late failure cannot write into a workspace that moved on. The token cannot live on the window, because the window did not move on: one of the connections it hosts did. Closing a window therefore cancels the in-flight attempt of every workspace it hosts, not just the one its original payload named, and a completion that finds its workspace gone discards itself rather than resurrecting it. Third, a failure is presented inline through `ConnectionUnavailableView`, never as an alert, per the HIG's rule against alerts at startup and its one-alert-at-a-time rule (N restored connections would mean N modals). Only one presenter per failure: `LaunchIntentRouter.presentError` stays silent when a window for that connection exists. - -#### Metadata pooling: an embedded engine's second connection is an empty database - -**A pooled metadata read assumes a second connection reaches the same database, and an embedded engine breaks that assumption**: `MetadataConnectionPool` builds a whole new driver, so it is only correct when the database lives on a server the driver reconnects to. When the database lives *inside* the driver instance, the pool gets a different database: a second `duckdb_open(":memory:")` is a fresh empty database, and a second `duckdb_open` on the same *file* is a second independent read-write instance that the first never sees (DuckDB's file lock does not conflict within one process). The failure is silent, because an empty catalog is indistinguishable from "no tables", which is why #2108 survived a manual refresh. `supportsConnectionPooling` is the opt-out, and it is read only by `DatabaseManager.canPool`; DuckDB and PGlite set it `false`. SQLite-family engines keep pooling, because multi-connection access to one file is what they are built for. Two rules follow. First, every metadata read goes through `DatabaseManager.withMetadataDriver` so `metadataRoute` can apply the rule; reaching for `MetadataConnectionPool.shared.withDriver` directly bypasses it, which is how routines kept pooling after the sidebar stopped. Second, a capability with no `DriverPlugin` static is curated per type and `buildMetadataSnapshot` must carry it over from the built-in snapshot, or `register(snapshot:forTypeId:)` resets it to the struct default the moment the plugin loads. That is not hypothetical: it silently disabled MongoDB's `authenticationIsDatabaseScoped` (#1970) for every build that had the plugin installed. `registerVariant` already treats the curated entry as authoritative, which is the only reason PGlite's flag ever worked. diff --git a/.agents/skills/tablepro-engineering/references/invariants-data.md b/.agents/skills/tablepro-engineering/references/invariants-data.md deleted file mode 100644 index bb2d0f975..000000000 --- a/.agents/skills/tablepro-engineering/references/invariants-data.md +++ /dev/null @@ -1,19 +0,0 @@ -# Invariants: sync, drivers, and stored data - -These have caused real bugs when violated: - -#### CloudKit sync: an undeployed CKRecord field silently kills a whole record type - -**A synced CKRecord field must be deployed to Production before anything writes it**: both apps pin `com.apple.developer.icloud-container-environment` to `Production`, and CloudKit only auto-creates fields in the Development environment. So no build, not even a local Debug one, can create a field on the server. Saving a record that carries a field the Production schema does not declare makes CloudKit reject **that whole record**, and with `isAtomic = false` the rest of the batch still saves, so the symptom is one record type silently never syncing. `ConnectionSyncField` (`Packages/TableProCore/Sources/TableProSyncTransport/ConnectionSyncSchema.swift`) is the single declaration of every `Connection` wire key, and its gated `CKRecord` subscript refuses to write a field that is not `.verified`. A new case defaults to `.unverified`, so a field added without the deploy is inert rather than destructive. To ship one: add the field in CloudKit Console, deploy Development to Production, run `scripts/export-cloudkit-schema.sh`, commit the refreshed `CloudKit/production-schema.ckdb`, then mark the field verified. `ProductionSchemaParityTests` fails if the registry and the snapshot disagree in either direction. This shipped as `isFavorite` (#1452, unconditional on every connection) killing every Mac connection push for two months while the UI reported success (#643). - -#### Sync delete ordering: markDeleted before saveConnections re-uploads the deleted record - -**Sync delete ordering**: In `ConnectionStorage` (and all storage classes), `SyncChangeTracker.markDeleted()` must be called AFTER `saveConnections()`. The `markDeleted` call fires `postChangeNotification` which can trigger a sync. If the file on disk still contains the deleted item when sync runs, it may re-upload the deleted record. Persist first, then notify. - -#### MongoDB binary UUID: a decoded value in a BLOB-typed column renders as hex and breaks writes - -**Decoding a MongoDB binary UUID is a per-column decision, and the column's type name is load-bearing**: BSON binary subtype 3 is the legacy UUID format, and the Java, C# and Python drivers each wrote it with a different byte order with nothing in the stored bytes to say which. `MongoDBUuidCodec` therefore decodes subtype 3 only when the connection names one (`mongoUuidRepresentation`); subtype 4 is unambiguous and always decodes. The choice is made once per column from `BsonDocumentFlattener.columnKinds`' majority vote, never per value, because a decoded cell is `.text` and an undecoded one is `.bytes`, and `CellDisplayFormatter` runs blob formatting over a `.text` cell whenever its column type is BLOB. One UUID decoded inside a column the app still types `BLOB` renders as `0x4c65676163...`. For the same reason `BsonDocumentFlattener.typeName` must keep `BLOB` as the base name for undecoded binary: `ColumnTypeClassifier` splits a type name at the first `(` and looks the base up, so `BLOB` and `BLOB(3)` both classify as `.blob`, and that classification is the only thing keeping a binary cell out of the inline editor. The parenthesised part carries the BSON subtype so MQL export can write it back; `MongoDBUuidCodec.columnTypeName(forSubtype:)` and `binarySubtype(fromColumnTypeName:)` are the only two places that spelling is produced or read, and MQL export is `supportedDatabaseTypeIds = ["MongoDB"]`, so it never sees another driver's `BLOB`. Once a column does decode, both edit guards (`isBlobType` and `asBytes != nil`) fall together, so every write path must parse the wrapper back to `$binary`: `MongoDBStatementGenerator.jsonValue` and `idValueJson`, `MongoDBQueryBuilder.jsonValue` plus its `=`, `!=` and `IN` arms (a case-insensitive regex can never match a binary field), and `MQLExportHelpers.mqlJsonValue`. An `_id` filter left as wrapper text matches zero documents while the UI reports the save succeeded. (#2086) - -#### MongoDB write anchoring: a non-_id delete filter deletes the wrong document - -**A MongoDB update or delete is anchored on `_id` or it does not run**: `generateDelete` used to fall back to a filter built from the remaining columns, which silently dropped every value it could not stringify (all binary) and then `deleteOne`d the first partial match, so a document with a binary `_id` could delete a different document. Both paths now skip with a logged warning instead, matching what `generateUpdate` already did. diff --git a/.agents/skills/tablepro-engineering/references/invariants-ui.md b/.agents/skills/tablepro-engineering/references/invariants-ui.md deleted file mode 100644 index 282bd923b..000000000 --- a/.agents/skills/tablepro-engineering/references/invariants-ui.md +++ /dev/null @@ -1,43 +0,0 @@ -# Invariants: windows, tabs, split panes, and the grid - -These have caused real bugs when violated: - -#### WelcomeViewModel: a connections mutation without rebuildTree leaves the tree stale - -**WelcomeViewModel tree rebuild**: The welcome screen renders `treeItems` (grouped/filtered), not `connections` directly. Every mutation to `connections` must call `rebuildTree()` afterward, or the UI won't update. - -#### Tab reuse guard: replacing a tab with unsaved edits, filters, or sort destroys work - -**Tab replacement guard**: `openTableTab` checks for active work (unsaved edits, applied filters, sorting) before replacing the current tab. A tab with active work is left alone and the table opens as a new editor tab in the same window's strip. This check runs before the preview tab branch. - -#### Window and editor tab titles: a blank title ships as an empty tab label - -**Window tab titles**: The native tab label follows `NSWindow.title`, and AppKit renders it for background tabs too, so the title must be correct from creation, not from first activation. Every title resolves through `WindowTitleResolver` (pure, AppKit-free): `MainSplitViewController.init` for the payload-driven initial title, `updateWindowTitleAndFileState()` in `MainContentView+Setup.swift` for ongoing tab-driven updates. The resolver treats a blank string as absent at every tier and always recomputes a `.table` tab's name from `tableName`+`schemaName` instead of trusting a carried-over title. `TabWindowController.init` pushes the resolved title onto `window.title`/`window.subtitle` right after assigning `contentViewController`, because a joined-but-never-activated tab window never runs `viewWillAppear` or its SwiftUI lifecycle. `MainSplitViewController.windowTitle`'s `didSet` is the single guarded sink and never lets an empty string reach `NSWindow.title`. Never write `window.title` or `NSApp.keyWindow?.title` directly; mutate `tab.title` and call `QueryTabManager.markTabRenamed(_:)` so the resolver re-runs. A restored tab whose persisted title decoded to "" shipped as a blank tab label that only healed on activation. Editor tabs are no longer windows, so there are now two labels with two owners: the window titlebar goes through `WindowTitleResolver` and the guarded `windowTitle` sink, while the editor tab label is `Text(tab.title)` in `EditorTabStrip` with no resolver between it and the string. Blank-title healing therefore has to hold at `QueryTab.title` itself. - -#### Grid selection: a display-row index used against TableRows.rows hits the wrong row - -**Selection indices are display positions**: `GridSelectionState.indices` come from `NSTableView.selectedRowIndexes` and are display-row positions, not indices into `TableRows.rows`. They match array indices only when `displayIDs` (`valueFilteredIDs ?? sortedIDs`) is nil; a per-column value filter makes them diverge. Resolve any selected index through `DisplayRowMapping` (or `TableViewCoordinator.displayRow(at:)` (declared in `Views/Results/DataGridCoordinator.swift:41`, method at `:566`, with `tableRowsIndex(forDisplayRow:)` at `:574`) / `tableRowsIndex(forDisplayRow:)`) before reading or mutating a row; never index `TableRows.rows` with a display position. The row details inspector shipped this bug (#1837). - -#### App lifecycle: reintroducing a SwiftUI App wipes the AppKit menu bar - -**The app runs the AppKit lifecycle, and AppKit owns the menu bar**: `main.swift` assigns the delegate before `NSApplicationMain`, and `MainMenuBuilder.install` runs in `applicationWillFinishLaunching`. Do not reintroduce a SwiftUI `App`. SwiftUI reconciles `NSApp.mainMenu` once shortly after launch and removes every item it did not build itself, and no hook can undo it: `NSApp.mainMenu` is not KVO-compliant, `didUpdateNotification`, `didBecomeKeyNotification` and the `applicationDidUpdate(_:)` delegate method never fire under `@NSApplicationDelegateAdaptor`, and `applicationDidBecomeActive` fires before the reconciliation. Only a wall-clock delay worked, which is why #2057 shipped a menu bar that vanished half a second after launch and had to be reverted (#2071). Every window is an `NSWindowController`; the Welcome window is one too, so closing it is an ordinary `close()` and the old "closed, never ordered out" rule no longer applies. - -#### Tab persistence: clearing on an empty tab aggregate deletes tabs nobody closed - -**An emptied tab manager is not the same as "the user closed every tab"**: only `closeTabsByUser(ids:)` clears saved tabs, and only after checking emptiness itself (`MainContentCoordinator+TabClosing.swift:23`, then `persistence.clearForUserClosedAllTabs()`). Both aggregated save paths reach one shared gate, `writablePayload` (`TabPersistenceCoordinator.swift:79`), which returns nil on an empty set and never clears, because ending a session is not closing your tabs. Any new path that clears on an empty aggregate deletes tabs the user never closed. A coordinator torn down by a lost session has already emptied `tabManager.tabs`, so letting the window-close path run afterwards wipes tabs the user never closed. `handleWindowWillClose` guards on `isTearingDown` for that reason. - -#### Split pane holdingPriority: a value at or above 490 makes the divider undraggable - -**A split pane's `holdingPriority` must stay below 490**: AppKit applies a divider drag as a layout change at `dragThatCannotResizeWindow` (490). Any pane whose `holdingPriority` is at or above that outranks the drag, so its width constraint wins and the divider cannot move at all. `.defaultHigh` (750) freezes it outright, which shipped as three dead dividers (Users & Roles, Structure triggers, Server Dashboard). Use `.splitPaneHolding` (260, the value AppKit itself gives a sidebar item): high enough to outrank a `.defaultLow` (250) sibling so the pane holds its size when the window resizes, low enough that a drag still wins. `.defaultLow` is not the fix, since the pane then grows with the window instead of holding. (#1872) - -#### Tab content minimums: a nested split view's fittingSize kills the window dividers - -**Tab content must never pin the window's split dividers**: `NSSplitViewItem.minimumThickness` is a required constraint, so a nested `NSSplitViewController` reports `sum(minimums) + dividers` as its `fittingSize`. SwiftUI adopts that number for an `NSViewControllerRepresentable` and the enclosing `NSHostingView` turns it into a `minWidth` at priority 999.9, which beats the 490 (`dragThatCannotResizeWindow`) a divider drag runs at: the window's sidebar and inspector dividers go dead. Two rules follow. First, every hosting controller that is a split item's view controller sets `sizingOptions = []` (`MainSplitViewController`'s `detailHosting` and `inspectorHosting`, and both panes inside `AutosavingSplitView`), and `AutosavingSplitView` returns the proposal from `sizeThatFits` so its own minimums never escape into SwiftUI. Second, a tab that genuinely needs more width than `defaultDetailMinThickness` declares it through `resolveDetailMinimumThickness(for:)` instead of leaking it; the detail pane's minimum is a per-tab contract, and `recomputeWindowMinSize()` reads it live. AppKit will not rescue you here: `.sidebar` behaviour and `canCollapseFromWindowResize` only auto-collapse on a window live-resize, which an embedded split view never sees, and no form of collapsibility lowers `fittingSize` (only an actual `isCollapsed = true` does). `CollapsingSplitViewController` collapses the pane itself for that reason. This shipped as a dead inspector divider on Users & Roles tabs (#1872). - -#### SwiftUI-hosted split views: dividers drag but show no resize cursor - -**A SwiftUI-hosted split view needs an explicit divider cursor**: `NSSplitView` shows the resize cursor over its dividers through AppKit's cursor-rects system, which does not fire once the split view is mounted inside an `NSHostingController` (every tab-content split is, several SwiftUI layers deep under `MainSplitViewController.detailHosting`). The divider still drags because drag hit-testing is independent of cursor rects, but the pointer never changes. Every SwiftUI-hosted split-view controller must subclass `ResizeCursorSplitViewController`, which adds a key-window tracking area to its own split view and sets `NSCursor.columnResize`/`rowResize` (falling back to `resizeLeftRight`/`resizeUpDown` before macOS 15) in `mouseMoved`, the same hand-rolled approach `SortableHeaderView` uses for column resize. It attaches the tracking area to the framework's split view in `viewDidLoad` rather than replacing the split view, so `NSSplitViewController`'s own layout and divider orientation stay intact; replacing the split view through a `loadView` override that skips `super` leaves the controller half-initialized and its panes stack instead of laying out side by side. Do not swap the controller back to a plain `NSSplitViewController` expecting the stock cursor to work; the window's own sidebar and inspector dividers only get the cursor for free because `MainSplitViewController` is the window's `contentViewController` directly, with no SwiftUI host in between. This shipped as Users & Roles, Structure, Server Dashboard, and query editor dividers that dragged but never showed the resize cursor (#1905). - -#### Data grid header: AppKit's 28pt band rules through a 42pt comment header - -**The data grid header owns all of its own chrome, so nothing may ask AppKit to paint any of it**: `NSTableHeaderCell` and `NSTableHeaderView` both paint a fixed 28pt band that they centre vertically in whatever frame they are given, a 16pt column divider on `midY` and a 1pt rule at `midY + 13`. The data grid grows its header to 42pt for a column comment, so that band lands mid-cell: the rule crosses the comment's descenders and sits 8pt above the real bottom edge. `SortableHeaderChrome` is therefore the single owner of header geometry and colours, `SortableHeaderCell.draw(withFrame:in:)` never calls `super`, and `SortableHeaderView.draw(_:)` fills the background and rules the bottom edge itself. The trap is that the header view paints a second copy of that same band for `NSTableView.highlightedTableColumn`, driven by *state* rather than by a drawing call, so no cell override can reach it: setting it gives the sorted column a stray divider and a rule no other column has. TablePro already draws the sorted-column affordance itself (bold title, chevron, priority number, with `drawSortIndicator` overridden to nothing), so `highlightedTableColumn` is a redundant second channel and must stay unset. All sorted-column presentation goes through `SortableHeaderView.applySortState(_:schema:)`, which publishes the order natively through `tableView.sortDescriptors` (for accessibility; it paints nothing) and updates the cells. `SortableHeaderRenderingTests` rasterises the header and guards this. This shipped as a rule through the comment line and a stray divider on the sorted column (#2017). diff --git a/.agents/skills/tablepro-engineering/references/plugin-system.md b/.agents/skills/tablepro-engineering/references/plugin-system.md deleted file mode 100644 index 5e86ade0d..000000000 --- a/.agents/skills/tablepro-engineering/references/plugin-system.md +++ /dev/null @@ -1,42 +0,0 @@ -# Plugin System - -### Plugin System - -All database drivers are `.tableplugin` bundles loaded at runtime by `PluginManager` (`Core/Plugins/`): - -- **TableProPluginKit** (`Plugins/TableProPluginKit/`): shared framework with `PluginDatabaseDriver`, `DriverPlugin`, `TableProPlugin` protocols and transfer types (`PluginQueryResult`, `PluginColumnInfo`, etc.). This is the single source of truth; the SwiftPM target at `Packages/TableProCore/Sources/TableProPluginKit` is a symlink to it, so edit the files under `Plugins/TableProPluginKit/` only. -- **PluginDriverAdapter** (`Core/Plugins/PluginDriverAdapter.swift`): bridges `PluginDatabaseDriver` → `DatabaseDriver` protocol -- **DatabaseDriverFactory** (`Core/Database/DatabaseDriver.swift`): looks up plugins via `DatabaseType.pluginTypeId` -- **DatabaseManager** (`Core/Database/DatabaseManager.swift`): connection pool, lifecycle, primary interface for views/coordinators -- **ConnectionHealthMonitor**: 30s ping, auto-reconnect with exponential backoff - -When adding a new driver: create a new plugin bundle under `Plugins/`, implement `DriverPlugin` + `PluginDatabaseDriver`, add the target to `project.yml`, add `DatabaseType` static constant, add case to `resolve_plugin_info()` in `.github/workflows/build-plugin.yml`, add row to `docs/index.mdx` supported databases table, and add CHANGELOG entry. See `docs/development/plugin-development.mdx` and `docs/development/plugin-registry.mdx` for details. - -When adding a new method to the driver protocol: add to `PluginDatabaseDriver` (with default implementation), then update `PluginDriverAdapter` to bridge it to `DatabaseDriver`. This is an additive, ABI-safe change (see below) and needs no version bump. - -**PluginKit ABI (resilient)**: TableProPluginKit is built with `BUILD_LIBRARY_FOR_DISTRIBUTION = YES` (Swift Library Evolution), so its public ABI is resilient. The Swift runtime instantiates witness tables for already-built plugins and fills any requirement the plugin did not implement from the protocol's default, so a plugin built against an older PluginKit keeps loading under a newer app. - -**Additive changes are binary-compatible and need NO version bump**: adding a requirement to `DriverPlugin` / `PluginDatabaseDriver` that has a default implementation, reordering requirements, or adding a field to a non-`@frozen` transfer struct. - -**Never remove a published protocol requirement, even one that defaulted to `nil`.** Library Evolution fills in requirements *added* after a plugin was built, but it cannot rescue a requirement *removed* out from under an already-built plugin. Removing one deletes both its method descriptor and its default-implementation symbol, and every shipped plugin that relied on the default hard-references both in its witness table, so it fails to load with "Bundle failed to load executable". If the app stops using a requirement, leave it in place with its default (it costs nothing). Removing it is a breaking change: bump `currentPluginKitVersion` and re-release every plugin. (#1917, and it broke MongoDB, Oracle, Cassandra, and Elasticsearch on 0.58.) - -**Adding a field to a transfer struct is additive ONLY if every existing public initializer keeps its exact signature.** Adding a parameter to an existing public init or function, even with a default value, replaces its mangled symbol and breaks every already-built plugin (this shipped in 0.49.0: `PluginQueryResult` gained `columnMeta:` on its init and every registry plugin failed to load with "Bundle failed to load executable"). Add a NEW overload for the new field and keep the old signature; mark the old overload `@_disfavoredOverload` so new code resolves to the full init while old binaries keep their symbol. Before any PluginKit change run `scripts/check-pluginkit-abi.sh` (see below) and act on the result: either the diff is additive (verify no symbol disappeared) or it is breaking (bump and re-release). - -**Bump `currentPluginKitVersion` (in `PluginManager.swift`) and `TableProPluginKitVersion` in every plugin `Info.plist` ONLY for a breaking change**: changing or removing an existing requirement's signature, adding a requirement without a default, adding a case to a `@frozen` enum, or changing a frozen type's layout. Mark a public enum `@frozen` only when an exhaustive switch over it forces it (the compiler flags the switch) and its case set is genuinely closed; leave the rest non-frozen so they can gain cases. `PluginCapability` stays non-frozen with `@unknown default` because it is a growing capability set, not a closed vocabulary. The driver protocols and transfer structs stay non-frozen so they can grow. The strict version gate in `validateBundleVersions` still rejects a stale plugin cleanly after a breaking bump (no `EXC_BAD_INSTRUCTION`). - -**ABI check** (manual): `scripts/check-pluginkit-abi.sh [base-ref]` generates the project from `project.yml` on both sides, builds TableProPluginKit at the current tree and at the base ref with the same toolchain, then diffs their public interfaces. A base ref that predates `project.yml` cannot be compared. There is no committed baseline, so a Swift version difference between machines never produces a false diff. Run it before merging any change under `Plugins/TableProPluginKit/**`, comparing against the merge base. A reported diff is a real ABI change: additive needs no bump; breaking needs the version bump above plus `release-all-plugins.sh`. (Until Library Evolution is on the base too, the base emits no interface and the check passes as a bootstrap.) - -**Post-ABI-bump checklist (mandatory, breaking bumps only)**: Bumps are now rare (only the breaking changes listed above). After one, every registry-published plugin must be rebuilt against the new ABI. Run `release-all-plugins.sh` for the new version BEFORE or WITH the app release, never after, or users on the new app hit `noCompatibleBinary` until the registry catches up. App auto-update reconciliation handles the user-facing recovery, but the registry has to carry binaries for the new PluginKit version first. - -1. Commit the bump (updates `PluginManager.swift` and every bundled plugin's `Info.plist`). Bundled plugins ship with the next app release. Do not tag them. -2. Trigger the bulk re-release: - ```bash - ./scripts/release-all-plugins.sh - ``` - The workflow runs all registry plugins as a parallel matrix, publishes ZIPs to GitHub Releases, and updates `plugins.json` (via `.github/scripts/update-registry.py`, which appends new binaries and prunes per the `--keep-kit-versions 2` policy). No manual `plugins.json` editing. -3. Verify by installing one plugin from the registry on a build with the new PluginKit version. - -**Binary retention policy**: The registry keeps binaries for the two most recent PluginKit versions per plugin (`--keep-kit-versions 2`). Users on the previous app version can still install plugins; users two or more versions behind hit `noCompatibleBinary` and need to update the app. - - -- Use `DatabaseType.allKnownTypes` (not `allCases`) for the canonical list diff --git a/.agents/skills/tablepro-engineering/references/project-guide.md b/.agents/skills/tablepro-engineering/references/project-guide.md deleted file mode 100644 index 245a15451..000000000 --- a/.agents/skills/tablepro-engineering/references/project-guide.md +++ /dev/null @@ -1,43 +0,0 @@ -# TablePro Project Reference - -This file is an index. The knowledge lives in the files below, split so a task opens one of them -instead of all of it. `AGENTS.md` owns the principles, the code rules, the work sequence, and the -change contract, and none of them are repeated here. - -| File | What is in it | Open it when | -| --- | --- | --- | -| `invariants-ui.md` | Windows, tabs, split panes, the data grid, app lifecycle | Editing anything under `TablePro/Views/`, a coordinator, a window, or a split view | -| `invariants-connections.md` | Connect and cancel, schema loading, caches, session state, pooling | Touching connection lifecycle, schema refresh, or the sidebar tree | -| `invariants-data.md` | CloudKit sync ordering, MongoDB writes, driver data handling | Touching sync, stored records, or driver read and write paths | -| `plugin-system.md` | Plugin layout, `DatabaseType`, PluginKit ABI rules, registry versus bundled | Any change under `Plugins/` or `TableProPluginKit` | -| `architecture.md` | Project layout and generation, editor bridge, window close, storage map | Orienting in a subsystem, or adding a file or target | -| `build-and-release.md` | Commands beyond the wrapper, static libraries, the CI job graph | Updating `Libs/`, reading a CI failure, or shipping | -| `conventions.md` | Logging, lint limits, commit scopes, docs routing, performance pitfalls, writing style extras | Before a commit, or when naming a scope or a docs page | - -Each invariant carries its own `####` heading naming the subsystem and the failure it prevents, so -search for the symptom or the symbol rather than reading a whole file: - -```bash -rg -n '^####' .agents/skills/tablepro-engineering/references/invariants-*.md -rg -n '||' .agents/skills/tablepro-engineering/references/ -``` - -Read the full paragraph of a matching invariant, not the heading alone. Each one exists because it -was violated and shipped a bug. - -## What is not here - -- Build, test, and lint verification, the quarantine lists, and the environment traps: - `.claude/skills/fix-issue/references/verification.md`. -- Release and tagging: `$release`. -- SwiftUI and AppKit view rules: `$swiftui`. -- Lint numbers, formatter settings, and plugin tag names are read from `.swiftlint.yml`, - `.swiftformat`, and `.github/workflows/build-plugin.yml`. Those files are authoritative; a copy - here would drift. - -## Keeping it honest - -An invariant that names a symbol which no longer exists is worse than no invariant, because it is -followed confidently. Five such drifts were found and fixed in this reference. When you touch a -subsystem, check the invariant that governs it still matches the code, and correct it in the same -change. diff --git a/.claude/agents/adversarial-reviewer.md b/.claude/agents/adversarial-reviewer.md deleted file mode 100644 index 6942d825b..000000000 --- a/.claude/agents/adversarial-reviewer.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: adversarial-reviewer -description: Review TablePro diffs and fix blueprints for reachable correctness, security, concurrency, ABI, behavior, and test failures. -tools: Read, Grep, Glob, Bash -permissionMode: plan -model: opus -effort: xhigh -background: true ---- - -Review the diff or blueprint you were given as a skeptical TablePro owner. Read `AGENTS.md`, the -acceptance criteria, the invariants that apply, the callers, and the tests. - -Report only findings that carry a priority, `file:line`, a reachable failure scenario, the reason -existing guards do not prevent it, the smallest valid fix, and the test that proves it. Verify -each one before reporting: a finding with no evidence costs the writer a cycle to disprove. -Return `No findings` when nothing meets the bar, which is a useful answer rather than a failure. - -Do not edit, invoke Codex, invoke another reviewer, commit, push, or open a pull request. diff --git a/.claude/agents/codebase-investigator.md b/.claude/agents/codebase-investigator.md deleted file mode 100644 index 887133fd7..000000000 --- a/.claude/agents/codebase-investigator.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: codebase-investigator -description: Trace TablePro code paths, root cause, state flow, callers, blast radius, invariants, and tests before implementation. -tools: Read, Grep, Glob, Bash -permissionMode: plan -model: opus -effort: xhigh -background: true ---- - -Read `AGENTS.md` and the relevant sections of the TablePro project guide. Trace the real shipping -execution path with file and symbol evidence. Separate confirmed facts, inferences, and unknowns. -Identify root cause, blast radius, sibling paths, existing tests, and applicable invariants. - -Answer only the question you were asked. Do not design a patch before the path is proven, edit -files, invoke another agent, or perform external writes. - -Return the smallest answer that lets the main thread decide, anchored as `file:line` plus what is -there. Anchor only to files you actually opened; a path you inferred is not evidence. Your full -reasoning stays in this transcript and can be recovered, so do not pad the answer to preserve it. -An honest unknown outranks a confident guess. diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md deleted file mode 100644 index ca00f162c..000000000 --- a/.claude/agents/implementer.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: implementer -description: Implement an approved TablePro blueprint, a fix or a new feature, as the single writer in one checkout, verify it, and report the diff. -tools: Read, Grep, Glob, Bash, Edit, Write -model: opus -effort: xhigh -background: true ---- - -Read `AGENTS.md` and the blueprint you were given. Implement exactly that plan as the only writer -in the worktree you were given. - -Write only inside that worktree. The main checkout is off limits: other sessions are working in -it, and it does not carry this branch. Pass absolute paths to every command, and use -`git -C ` rather than relying on the working directory, which resets on its own and -silently redirects edits, builds, and test filters to the wrong tree. - -Follow the blueprint's dependency order and its ownership decision. Do not downgrade a required -refactor into a special case, do not substitute a design you prefer, and do not build past the -blueprint's non-goals. When the blueprint is wrong, incomplete, or contradicted by the code, stop -and report what you found instead of improvising around it: a surprise in the diff costs more than -a question. - -Land the test with the change. Handle changelog, `docs/`, localization, and logging as part of it, -and for a new user-visible feature the discovery point, empty and error states, and settings -defaults the blueprint specifies. Regenerate the project after adding, moving, or deleting a -source file. - -Any change in the worktree outside the blueprint's file list is unplanned: report it, never stash, -reset, revert, or stage it, and never run `git add -A`. - -Verify through `.claude/skills/fix-issue/scripts/verify.sh --root ` before returning, -running the steps the blueprint requires one at a time. Run the `generate` step first: the -worktree has its own generated Xcode project and XcodeGen globs sources when it runs. - -Return the files you changed, the verdict of each verification step with its log path, anything in -the blueprint you could not implement, and any question the plan left open. Do not narrate the -edits; the writer reads the diff. Do not commit, push, open a pull request, tag, or release. diff --git a/.claude/agents/platform-researcher.md b/.claude/agents/platform-researcher.md deleted file mode 100644 index e79e10c31..000000000 --- a/.claude/agents/platform-researcher.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: platform-researcher -description: Verify Apple APIs, SDK availability, dependency contracts, headers, binaries, and measured behavior. -tools: Read, Grep, Glob, Bash, WebSearch, WebFetch -permissionMode: plan -model: opus -effort: xhigh -background: true ---- - -Read `AGENTS.md` and `.claude/skills/fix-issue/references/research-sources.md`. Verify behavior -against authoritative Apple documentation, the installed SDK interface, vendored headers, shipped -static libraries, or a minimal probe. Check availability against TablePro's deployment targets. - -Cite exact symbols, paths, lines, URLs, and measured output, and label every claim confirmed, -inferred, or unknown. Do not edit product files or invoke another agent. - -Return the smallest answer that settles the question. Your full reasoning stays in this transcript -and can be recovered, so do not pad the answer to preserve it. "Could not confirm" is a useful -answer; a confident wrong claim costs the writer a verification cycle to disprove. diff --git a/.claude/agents/plugin-abi-reviewer.md b/.claude/agents/plugin-abi-reviewer.md deleted file mode 100644 index 875d4ff09..000000000 --- a/.claude/agents/plugin-abi-reviewer.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: plugin-abi-reviewer -description: Review TablePro PluginKit and plugin changes for binary, registry, and open-domain compatibility. -tools: Read, Grep, Glob, Bash -permissionMode: plan -model: opus -effort: xhigh -background: true ---- - -Read `AGENTS.md` and the complete Plugin System, PluginKit ABI, `DatabaseType`, and plugin CI -sections of the project guide. Inspect public symbol compatibility, initializer signatures, -protocol defaults, version gates, bundled versus registry-only distribution, generated targets, -and the ABI or `AllPlugins` checks the change requires. Never assume source compatibility proves -binary compatibility. - -Report findings ranked by evidence, each anchored to `file:line` with the plugin build or load -path that fails. Return the smallest answer that lets the main thread decide; your full reasoning -stays in this transcript and can be recovered. - -Do not edit, release, publish, tag, or invoke another agent. diff --git a/.claude/agents/test-strategist.md b/.claude/agents/test-strategist.md deleted file mode 100644 index bd7fe81de..000000000 --- a/.claude/agents/test-strategist.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -name: test-strategist -description: Map TablePro behavior changes to regression tests, deterministic UI coverage, and serial verification commands. -tools: Read, Grep, Glob, Bash -permissionMode: plan -model: opus -effort: xhigh -background: true ---- - -Read `AGENTS.md` and `.claude/skills/fix-issue/references/verification.md`. Identify the smallest -regression test that fails before the fix and passes after, the neighboring suites the change can -break, whether deterministic UI coverage is possible, the plugin or ABI checks the change -requires, and the exact serial commands. Check the quarantine files and the environment traps -before calling a suite relevant. - -Do not edit files, run destructive commands, invoke another agent, or claim a test ran when it did -not. - -Return the smallest answer that lets the main thread verify: suite names, the command for each, -and what each one would prove. Prefer `verify.sh` steps over raw `xcodebuild` lines. Your full -reasoning stays in this transcript and can be recovered, so do not pad the answer to preserve it. diff --git a/.claude/hooks/guard-test.sh b/.claude/hooks/guard-test.sh new file mode 100755 index 000000000..a64a80d7b --- /dev/null +++ b/.claude/hooks/guard-test.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Regression suite for .claude/hooks/guard.sh. +# +# Every banned pattern is assembled from parts at runtime, so the text of this file never +# contains one. That matters: the PreToolUse guards inspect the command text of whatever runs +# them, so a suite written the obvious way blocks itself. +# +# Run it after touching guard.sh: .claude/hooks/guard-test.sh +# Exit 0 when every case passes. +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +G="$REPO/.claude/hooks/guard.sh" +ADD=$(printf 'a%s' dd) +COMMIT=$(printf 'com%s' mit) +PUSH=$(printf 'pu%s' sh) +pass=0 +fail=0 + +# want: fires | silent +run() { + local want="$1" check="$2" payload="$3" name="$4" + local out got + out="$(printf '%s' "$payload" | "$G" "$check" 2> /dev/null)" + if [ -n "$out" ]; then + got=fires + if ! printf '%s' "$out" | jq -e . > /dev/null 2>&1; then + printf ' BAD JSON %-46s\n' "$name" + fail=$((fail + 1)) + return + fi + else + got=silent + fi + if [ "$got" = "$want" ]; then + pass=$((pass + 1)) + else + printf ' FAIL %-46s want=%s got=%s\n' "$name" "$want" "$got" + fail=$((fail + 1)) + fi +} + +cmd() { printf '{"tool_input":{"command":%s}}' "$(printf '%s' "$1" | jq -Rs .)"; } +wrote() { printf '{"tool_input":{"file_path":"%s","content":%s}}' "$1" "$(printf '%s' "$2" | jq -Rs .)"; } + +echo "no-blanket-add" +run fires no-blanket-add "$(cmd "git $ADD -A")" "-A" +run fires no-blanket-add "$(cmd "git $ADD -u")" "-u" +run fires no-blanket-add "$(cmd "git $ADD --all")" "--all" +run fires no-blanket-add "$(cmd "git $ADD .")" "dot" +run fires no-blanket-add "$(cmd "git -C /tmp/x $ADD -A")" "-C then -A" +run fires no-blanket-add "$(cmd "git stage -A")" "stage alias (was missed)" +run silent no-blanket-add "$(cmd "git $ADD TablePro/App.swift")" "explicit path" +run silent no-blanket-add "$(cmd "git $ADD -p")" "interactive -p" +run silent no-blanket-add "$(cmd "git $ADD ./TablePro")" "dot-slash path" +run silent no-blanket-add "$(cmd "ls -la")" "unrelated" + +echo "no-commit-push" +run fires no-commit-push "$(cmd "git $COMMIT -m x && git $PUSH")" "&&" +run fires no-commit-push "$(cmd "git $COMMIT -m x; git $PUSH")" "semicolon" +run fires no-commit-push "$(printf '{"tool_input":{"command":%s}}' "$(printf 'git %s -m x\ngit %s\n' "$COMMIT" "$PUSH" | jq -Rs .)")" "NEWLINE (the real gap)" +run silent no-commit-push "$(cmd "git $COMMIT -m x")" "commit alone" +run silent no-commit-push "$(cmd "git $PUSH -u origin b")" "push alone" +run silent no-commit-push "$(cmd "git log --grep=$PUSH")" "log grep" + +echo "no-xcstrings-add" +run fires no-xcstrings-add "$(cmd "git $ADD TablePro/Resources/Localizable.xcstrings")" "stage xcstrings" +run silent no-xcstrings-add "$(cmd "git diff TablePro/Resources/Localizable.xcstrings")" "diff is fine" + +echo "writing-style" +run fires writing-style "$(wrote "$REPO/docs/x.md" "this is a seamless flow")" "banned word" +run fires writing-style "$(wrote "$REPO/docs/x.md" "an em dash here — like this")" "em dash" +run silent writing-style "$(wrote "$REPO/x.swift" "let robustness = 1")" "robustness (was false positive)" +run silent writing-style "$(wrote "$REPO/x.swift" "comprehensiveCheck()")" "comprehensiveCheck (was false positive)" +run silent writing-style "$(wrote "$REPO/docs/x.md" "a short specific sentence")" "clean prose" +run silent writing-style "$(wrote "$REPO/.claude/hooks/guard.sh" "seamless robust —")" "guard.sh itself (self-reference)" +run silent writing-style "$(wrote "/tmp/outside.md" "totally seamless")" "outside repo" + +echo "regenerate-note" +run fires regenerate-note "$(printf '{"tool_input":{"file_path":"%s"}}' "$REPO/TablePro/BrandNewFile.swift")" "untracked .swift" +run silent regenerate-note "$(printf '{"tool_input":{"file_path":"%s"}}' "$REPO/TablePro/AppDelegate.swift")" "tracked .swift (was noisy)" +run silent regenerate-note "$(printf '{"tool_input":{"file_path":"%s"}}' "$REPO/README.md")" "not swift" + +echo "malformed input fails open" +run silent no-blanket-add '{}' "empty object" +run silent writing-style 'not json' "garbage stdin" + +echo +echo "passed: $pass failed: $fail" +[ "$fail" -eq 0 ] diff --git a/.claude/hooks/guard.sh b/.claude/hooks/guard.sh new file mode 100755 index 000000000..178744ba9 --- /dev/null +++ b/.claude/hooks/guard.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# +# Claude Code hook guards for TablePro. +# +# Each subcommand reads the hook payload as JSON on stdin and writes a hook JSON result on +# stdout. Every rule here was previously prose in a skill file that something violated anyway; +# a hook makes the mistake impossible instead of forbidden. +# +# Usage: guard.sh +# +# no-blanket-add PreToolUse/Bash deny git add -A, -u, --all, . +# no-xcstrings-add PreToolUse/Bash deny staging Localizable.xcstrings +# no-commit-push PreToolUse/Bash deny chaining git commit into git push +# regenerate-note PostToolUse/Write remind to regenerate after a new .swift file +# changelog-intact PostToolUse/Edit block when a CHANGELOG version heading disappeared +# writing-style PostToolUse/Edit warn on em dashes and banned filler in written text +# +# Exit 0 always. A guard that crashes must never block the session, so every check fails open +# except the two that deliberately emit a deny decision. + +set -u + +CHECK="${1:-}" +PAYLOAD="$(cat)" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# These are TablePro's project rules, so they apply to TablePro's files. A session also writes +# outside the repo (scratchpad files, the auto-memory index, notes), and those carry their own +# conventions: the memory index, for one, separates its title from its hook with an em dash. +in_repo() { + case "$1" in + "$REPO_ROOT"/*) return 0 ;; + *) return 1 ;; + esac +} + +field() { + printf '%s' "$PAYLOAD" | jq -r "$1 // \"\"" 2>/dev/null || printf '' +} + +deny() { + printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}' \ + "$(printf '%s' "$1" | jq -Rs .)" + exit 0 +} + +note() { + printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":%s}}' \ + "$(printf '%s' "$1" | jq -Rs .)" + exit 0 +} + +block() { + printf '{"decision":"block","reason":%s}' "$(printf '%s' "$1" | jq -Rs .)" + exit 0 +} + +case "$CHECK" in + no-blanket-add) + cmd="$(field '.tool_input.command')" + # Matched against the raw command, quoted spans included. That does fire on a command + # that merely quotes the pattern, which is the price of not opening the obvious bypass: + # stripping quotes first would wave through `bash -c ''`. A safety + # net should fail closed, and the denial message says what to do instead. + if printf '%s' "$cmd" | grep -qE '\bgit\b.*\b(add|stage)\b[[:space:]]+(-A|-u|--all|\.)([[:space:]]|$)'; then + deny "Blanket git add is banned in this repository. Other sessions leave files in this tree, so -A, -u, --all, and . stage work that is not yours. Stage the explicit paths your change touches, then re-read git status --short." + fi + ;; + + no-xcstrings-add) + cmd="$(field '.tool_input.command')" + if printf '%s' "$cmd" | grep -qE '\bgit\b.*\badd\b.*Localizable\.xcstrings'; then + deny "TablePro/Resources/Localizable.xcstrings is shared and frequently dirty from other work, and new keys fall back to the English key until a build regenerates it. Leave it out unless your change is the reason it moved; if it is, stage it in its own call and say so." + fi + ;; + + no-commit-push) + # Newlines are a command separator too, and a multi-line Bash block is the shape this + # actually arrives in. grep works a line at a time and `.*` never crosses a newline, so + # the two-line form slipped through until this flattened them into the separator class. + cmd="$(field '.tool_input.command' | tr '\n\r' ';;')" + if printf '%s' "$cmd" | grep -qE '\bgit\b[^;&|]*\bcommit\b.*(&&|\|\||;).*\bgit\b[^;&|]*\bpush\b'; then + deny "Never chain git commit into git push. That chain removed the last chance to notice a checkout sitting on main after a squash merge, and it pushed straight to the default branch. Commit first, read the result, then push as its own call." + fi + ;; + + regenerate-note) + path="$(field '.tool_input.file_path')" + in_repo "$path" || exit 0 + case "$path" in + *.swift) ;; + *) exit 0 ;; + esac + # Only a file git has never seen needs a regeneration: XcodeGen globs at generation time, + # so an existing target member is already compiled. Without this the note fired on every + # edit to every Swift file, which trains you to ignore it. + git -C "$(dirname "$path")" ls-files --error-unmatch "$path" > /dev/null 2>&1 && exit 0 + note "New Swift file written: $path. TablePro.xcodeproj is generated and XcodeGen globs sources at generation time, so this file is not compiled until scripts/generate-project.sh runs. Run .claude/skills/fix-issue/scripts/verify.sh generate before the next build. The failure mode if you skip it is misleading: the build reports 'cannot find X in scope' from the callers, as if the code were never written." + ;; + + changelog-intact) + path="$(field '.tool_input.file_path')" + case "$path" in + *CHANGELOG.md) ;; + *) exit 0 ;; + esac + [ -f "$path" ] || exit 0 + dir="$(dirname "$path")" + base="$(basename "$path")" + # Compare the actual set of headings, not how many there are. A count catches a deletion + # but not a rename, and renaming a released heading in place does the same damage. + was="$(git -C "$dir" show "HEAD:./$base" 2> /dev/null | grep '^## \[' | sort)" || exit 0 + [ -n "$was" ] || exit 0 + now="$(grep '^## \[' "$path" | sort)" + gone="$(comm -23 <(printf '%s\n' "$was") <(printf '%s\n' "$now"))" + if [ -n "$gone" ]; then + block "A CHANGELOG version heading from HEAD is no longer in the file after that edit: +$gone +This is the failure where an Edit whose new_string drops the trailing context swallows a released version heading and folds that release into [Unreleased]. Release notes are extracted from [Unreleased], so the next release would re-ship it, and no build catches it. Re-read the file and restore the heading exactly as it was." + fi + ;; + + writing-style) + path="$(field '.tool_input.file_path')" + in_repo "$path" || exit 0 + # guard.sh carries the banned-word list as data and guard-test.sh carries fixtures built + # from it, so both always match themselves. + [ "$path" = "${BASH_SOURCE[0]}" ] && exit 0 + case "$path" in */.claude/hooks/guard.sh | */.claude/hooks/guard-test.sh) exit 0 ;; esac + written="$(printf '%s' "$PAYLOAD" | jq -r '(.tool_input.content // .tool_input.new_string // "")' 2>/dev/null)" + [ -n "$written" ] || exit 0 + # Word boundaries matter: without them `robustness` and `comprehensiveCheck()` in ordinary + # Swift tripped this on every write. The em dash stays unbounded, it is not a word. + hits="$(printf '%s' "$written" \ + | grep -noE '—|\b(seamless|robust|comprehensive|intuitive|effortless|streamlined|leverage|elevate|delve|utilize|facilitate)\b' \ + | sort -n -t: -k1 | head -12)" + [ -n "$hits" ] || exit 0 + note "Writing-style hits in what you just wrote to $path. The repository style rule bans em dashes and promotional filler in UI text, docs, changelogs, commit subjects, PR text, and agent-authored guidance. Rewrite these on the lines you added: +$hits" + ;; + + *) + echo "guard.sh: unknown check '${CHECK}'" >&2 + exit 0 + ;; +esac + +exit 0 diff --git a/.claude/rules/ai-mcp-security.md b/.claude/rules/ai-mcp-security.md index ae67eaed3..88fe116a6 100644 --- a/.claude/rules/ai-mcp-security.md +++ b/.claude/rules/ai-mcp-security.md @@ -8,6 +8,16 @@ paths: # AI and MCP changes -Trace provider disclosures, consumer-subscription behavior, tool authorization, safe mode, confirmation state, token scope, connection allowlists, query limits, timeouts, session recovery, and audit logging. Preserve read-only defaults and require `$cross-model-review` for authorization or destructive-operation changes. +This path is a security boundary: it decides what a model, a tool call, or a paired client is allowed to do with the user's databases and credentials. -This rule adds domain constraints. It does not pick your workflow: `AGENTS.md` decides whether you are in `$fix-issue` or `$tablepro-engineering`, and you never load both. +Trace and preserve, every time: + +- Provider disclosures and consumer-subscription behavior, so the user always knows which service their query text reaches. +- Tool authorization, safe mode, and confirmation state. Read-only stays the default, and a destructive operation stays behind an explicit confirmation. +- Token scope, connection allowlists, query limits, timeouts, session recovery, and audit logging. Widening any of them is the change, not a side effect of one. + +Ask what this lets a user or a paired client do that they could not do before, and answer it in writing. A new surface here is a new trust boundary. + +Run `Skill(security-review)` over the diff before the commit for any change to authorization, scope, allowlists, or a destructive operation. Update the matching page under `docs/external-api/` in the same change, because that page is the contract external clients are written against. + +This rule adds domain constraints and does not pick your workflow. diff --git a/.claude/rules/data-sync-storage.md b/.claude/rules/data-sync-storage.md index 7b4fe09b7..33f48ddb0 100644 --- a/.claude/rules/data-sync-storage.md +++ b/.claude/rules/data-sync-storage.md @@ -9,6 +9,16 @@ paths: # Data, sync, and connection changes -Search `.agents/skills/tablepro-engineering/references/invariants-data.md` for CloudKit production fields and delete ordering, and `.agents/skills/tablepro-engineering/references/invariants-connections.md` for schema loading, refresh retention, cancellation, attempt generations, pooling, and persistence teardown. Protect user data and prove late-completion behavior with tests. +This path is a user-data boundary. Read the `### Invariants` section of `CLAUDE.md` and find the paragraphs that apply; `### Storage Patterns` in the same file says which store owns what. -This rule adds domain constraints. It does not pick your workflow: `AGENTS.md` decides whether you are in `$fix-issue` or `$tablepro-engineering`, and you never load both. +The ones that bite most often on this path: + +- **A synced CKRecord field must reach Production before anything writes it.** Both apps pin the container to Production and CloudKit only auto-creates fields in Development, so no build can create one. A record carrying an undeclared field is rejected whole, and with `isAtomic = false` the rest of the batch still saves, so the symptom is one record type silently never syncing. Follow the deploy sequence in the invariant and let a new `ConnectionSyncField` case stay `.unverified` until the schema snapshot is committed. +- **Persist before you notify.** `SyncChangeTracker.markDeleted()` runs after `saveConnections()`, never before, or a sync fired by the notification re-uploads the deleted record from the stale file. +- **A refresh never clears the cache it is refreshing.** Fetch first, then commit over the old value. Only enter `.loading` when there is no loaded content, signal an in-flight refresh separately, and never let a failed refresh replace good data. +- **Cancelling a connect does not stop the driver.** `Task.cancel()` is cooperative and cannot interrupt a blocking C call, so a cancelled attempt completes late. Validate the `ConnectionAttemptRegistry` generation before adopting a driver or tearing session state down. This area shipped the same bug four times. +- **A pooled metadata read assumes a second connection reaches the same database.** That is false for an embedded engine. Route every metadata read through `DatabaseManager.withMetadataDriver` so `supportsConnectionPooling` can apply. + +Prove late-completion and cancellation behavior with a test. A silent wrong answer here looks exactly like an empty database. + +This rule adds domain constraints and does not pick your workflow. diff --git a/.claude/rules/plugin-system.md b/.claude/rules/plugin-system.md index 21f75832b..7772bfa63 100644 --- a/.claude/rules/plugin-system.md +++ b/.claude/rules/plugin-system.md @@ -9,6 +9,14 @@ paths: # Plugin changes -Read `.agents/skills/tablepro-engineering/references/plugin-system.md`. Treat binary compatibility, open plugin types, bundled versus registry-only distribution, project regeneration, `AllPlugins`, and the ABI check as binding constraints. +Read `### Plugin System` and `### DatabaseType (String-Based Struct)` in `CLAUDE.md` before editing. Binding constraints for this path: -This rule adds domain constraints. It does not pick your workflow: `AGENTS.md` decides whether you are in `$fix-issue` or `$tablepro-engineering`, and you never load both. +- **The plugin domain is open.** `DatabaseType` is a string-backed struct, not an enum. Unknown types from future plugins must round-trip through Codable, and every `switch` over it keeps a `default:`. +- **Binary compatibility.** TableProPluginKit ships with Library Evolution, so adding a protocol method with a default implementation is ABI-safe. Adding a parameter to an existing public initializer is not: it replaces the symbol and breaks every shipped plugin. Add an overload instead. Run `.claude/skills/fix-issue/scripts/verify.sh abi ` for any shared plugin API change; nothing in CI does it for you. +- **Edit the real files.** The SwiftPM target at `Packages/TableProCore/Sources/TableProPluginKit` is a symlink to `Plugins/TableProPluginKit/`. Edit the files under `Plugins/` only. +- **Bundled versus registry-only.** The app scheme depends on the bundled plugins alone, and PR CI never compiles the registry-only ones, so a hard compile error there still produces `BUILD SUCCEEDED`. Build the aggregate yourself with `verify.sh plugins`. +- **Regenerate after any target change.** `project.yml` is the source of truth and the `.xcodeproj` is generated. Never hand-edit or commit it. + +A new driver also needs its `project.yml` target, its `DatabaseType` constant, a `case` arm in the `Resolve plugin info` step of `.github/workflows/build-plugin.yml` (the `case "$PLUGIN_NAME"` block that maps the tag to its target, bundle id, display name, and type ids), a row in the `docs/index.mdx` table, and a CHANGELOG entry. + +This rule adds domain constraints and does not pick your workflow. diff --git a/.claude/rules/ui-lifecycle.md b/.claude/rules/ui-lifecycle.md index d8fb1efc3..d1c32d3c4 100644 --- a/.claude/rules/ui-lifecycle.md +++ b/.claude/rules/ui-lifecycle.md @@ -8,6 +8,14 @@ paths: # UI and lifecycle changes -Search `.agents/skills/tablepro-engineering/references/invariants-ui.md` for the affected view, coordinator, window, tab, split view, header, focus, selection, or issue number, and read the full matching invariant. `architecture.md` in the same directory holds the window-close and storage map. Preserve native AppKit and SwiftUI ownership, responder-chain behavior, accessibility identifiers, actor isolation, and deterministic `UITestCase` coverage. +Read the `### Invariants` section of `CLAUDE.md` and find the paragraph that names the view, coordinator, window, tab, split pane, header, focus, or selection you are touching. Each one is there because it shipped a bug, and several shipped the same bug more than once. `### Main Coordinator Pattern`, `### Window Close (Cmd+W)`, and `### Editor Architecture` in the same file hold the ownership map for those areas. -This rule adds domain constraints. It does not pick your workflow: `AGENTS.md` decides whether you are in `$fix-issue` or `$tablepro-engineering`, and you never load both. +Binding constraints for this path: + +- Native AppKit and SwiftUI ownership. The app runs the AppKit lifecycle and AppKit owns the menu bar. Do not reintroduce a SwiftUI `App`. +- The responder chain, focus, selection, undo, IME, and UTF-16 range handling survive the change. +- Accessibility identifiers stay on the controls that need them. Never put one on a SwiftUI container by itself; it replaces the identifier of every descendant in the same hosting tree. +- Actor isolation holds. Keep UI state mutation on the correct actor and never use `Task.detached` to escape it. +- A user flow that runs deterministically gets `TableProUITests` coverage, and every suite subclasses `UITestCase`. A bare `XCUIApplication()` or `: XCTestCase` under `TableProUITests/` fails a source-scanning guard test, because storage isolation depends on the launch path. + +For view work, load `$swiftui` as well. This rule adds domain constraints and does not pick your workflow. diff --git a/.claude/settings.json b/.claude/settings.json index 14c30dd8e..567e86ac1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -13,5 +13,60 @@ "enabledPlugins": { "feature-dev@claude-plugins-official": true, "codex@openai-codex": true + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/guard.sh no-blanket-add", + "if": "Bash(git *)", + "timeout": 10, + "statusMessage": "Checking staging scope" + }, + { + "type": "command", + "command": ".claude/hooks/guard.sh no-xcstrings-add", + "if": "Bash(git *)", + "timeout": 10 + }, + { + "type": "command", + "command": ".claude/hooks/guard.sh no-commit-push", + "if": "Bash(git *)", + "timeout": 10 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/guard.sh regenerate-note", + "timeout": 10 + } + ] + }, + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": ".claude/hooks/guard.sh changelog-intact", + "timeout": 15 + }, + { + "type": "command", + "command": ".claude/hooks/guard.sh writing-style", + "timeout": 10 + } + ] + } + ] } } diff --git a/.claude/skills/cross-model-review/SKILL.md b/.claude/skills/cross-model-review/SKILL.md deleted file mode 100644 index ceed1382a..000000000 --- a/.claude/skills/cross-model-review/SKILL.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: cross-model-review -description: Shared independent Claude and Codex review protocol. Use after medium-risk changes and always for data loss, destructive SQL, auth, MCP, AI permissions, sync, migrations, plugin ABI, concurrency, C boundaries, signing, or release automation. ---- - -# Cross-model Review - -Read `.agents/skills/cross-model-review/SKILL.md` completely and follow it as the canonical -protocol. Resolve its references from `.agents/skills/cross-model-review/`. It is the only copy of -these rules; this file adds none. diff --git a/.claude/skills/fix-issue/SKILL.md b/.claude/skills/fix-issue/SKILL.md index 646637ed4..67c8c725e 100644 --- a/.claude/skills/fix-issue/SKILL.md +++ b/.claude/skills/fix-issue/SKILL.md @@ -1,246 +1,237 @@ --- name: fix-issue -description: High-compute workflow for resolving a TablePro GitHub issue, whether it is a defect or a feature request. Use when the user asks to fix or implement an issue number or URL, or reports a crash, incorrect behavior, data or query bug, plugin defect, concurrency problem, UI gap, missing capability, new database or driver support, or a requested enhancement. It delegates investigation, critique, and verification so evidence stays out of the main thread, implements with one writer and independent review, then branches, commits, pushes, and opens the pull request on its own and works its follow-up findings into their own pull requests. +description: >- + Root-cause fix workflow for the TablePro macOS app. Use whenever the user wants to fix a + GitHub issue (by number or URL) or a described bug, behaviour gap, or UX problem, and cares + about doing it the right way: native AppKit/SwiftUI, Apple HIG, clean architecture, full + scope, no quick patches. It orchestrates the investigation as a multi-agent workflow + (codebase tracing, platform/API research, competitor UX, collateral defect hunting), + synthesizes a refactor-aware blueprint, has that blueprint attacked, implements to + TablePro's standards, agrees the approach with the user before writing code, builds and + tests and lints through the verification wrapper, opens the pull request on its own, and + reports every other defect it found with the evidence, shipping only the ones the primary + fix is unsafe without. + Trigger on things like "fix issue #1234", "fix this bug", "this should behave like a native + app", "do this properly / natively", or any non-trivial defect or behaviour gap in the app. + Prefer this over an ad-hoc fix when the change touches UI behaviour, architecture, or + anything the user expects to match Apple conventions. --- -# Resolve Issue +# Fix Issue -Invoked as `/fix-issue`, which is historical: this covers feature requests as well as defects. It -replaces `$tablepro-engineering` for issue work of either kind. Do not load both. +A disciplined way to fix a TablePro problem so the result is correct, native, and complete, not a patch over a symptom. The core idea: understand before you build, build the version Apple would ship, prove it against the artifact we actually ship, and leave the subsystem better than the issue found it. -Load nothing else yet. Each phase below names the one file to read when you reach it. +Low-quality fixes fail for five reasons: the author did not trace how the code actually behaves, did not check what the platform documents as correct, believed a plausible claim instead of measuring it, stopped at the first change that made the symptom disappear, or never built the result. This skill attacks all five. -## The context rule +**It stops once, at one gate.** Invoking it is the authorization to investigate, design, implement, verify, commit, push and open the pull request without asking again. The single exception is the Phase 2 gate: once the blueprint has survived its critique you present the approach and wait, because the cheapest place to catch a wrong direction is before the diff rather than in review. Everything on either side of that gate proceeds on its own. Stop early only when verification fails, and then say exactly what failed. -This thread holds the problem, the plan, the decisions, and the diff. It does not hold evidence. +## When to use this -- **Delegate the reading.** Lanes and subagents read; you get their digest. Detail is parked, not - lost, and `references/delegation.md` says how to get it back. -- **Verify at the anchor.** Lanes agreeing is not evidence, so check every load-bearing claim - yourself, at the cited line with `Read` offset and limit or `grep -n`. Whole files only when you - are about to change their shape. -- **Never let a build log in here.** Every check goes through this skill's `scripts/verify.sh`. +Use it for any non-trivial fix: a bug, a wrong behaviour, a UX gap, "make this work like \", or "do this the proper native way". It matters most when the change touches UI behaviour, AppKit/SwiftUI internals, a driver plugin, or anything the user expects to match Apple conventions. -Before any call that returns more than a screen: does this thread need the text, or just the answer? +Skip the investigation workflow for genuinely trivial edits (a typo, a renamed constant, a one-line guard with an obvious cause). Still run Phase 3 onward for those, because the branch, build, test, lint, and CHANGELOG rules apply to every change. If you are unsure whether a fix is trivial, it is not. -## Two tracks +Run at high effort. The investigation and the adversarial passes are where the quality comes from, and they are worth their cost. -Every phase below runs on both tracks. Where they differ, the track is named. +## The standard this skill holds to -- **Defect.** The `bug` label, the bug report template, or a report that something behaves wrong. - The unknown is the cause, so the work is tracing, and the blueprint turns on the root cause. -- **Change.** The `enhancement` label, the feature request template, or a request for behavior - that does not exist yet. The cause is known and uninteresting, so the work is placement: where - this belongs, which existing pattern it follows, and what it deliberately will not do. +`CLAUDE.md` at the repo root defines done: the principles, the mandatory rules (CHANGELOG, localization, docs, lint, tests, conventional commits, writing style), and the **Invariants** section listing the patterns that have already caused real bugs. Cite it rather than restating it; when the two disagree, `CLAUDE.md` wins. -A feature that behaves wrongly is a defect. A feature that is missing is a change. An issue that -is both gets split, defect first, and you say so. Decide the track at intake and carry it through. +The standing preference is the complete, Apple-correct fix grounded in documented APIs. Never pitch a phased, minimal, or quick-win version as the answer, and never implement the user's literal UI suggestion when research says a different native mechanism is correct. Say what the correct approach is, then build that. Native means documented AppKit, SwiftUI and system behaviour, with the HIG rule for the interaction quoted in the blueprint, and with keyboard access, focus, selection, undo and the responder chain still intact afterwards. + +### Refactor or patch: the central decision + +Every fix forces this call, and it is the one the gate exists to confirm. Make it explicit. + +**Refactor** when the current structure cannot express the correct behaviour without a special case that fights the existing shape; when the bug is a symptom of a design that is wrong for the real requirement, such as a boolean where the state is multi-valued or logic in a view that belongs in a model; or when fixing only the reported case would leave the same class of bug latent elsewhere. + +**Patch** when the design is sound and the bug is a genuine local mistake: an off-by-one, a missing guard, a wrong comparison, a stale mapping. + +The failure mode to avoid is patching a symptom so the reported case disappears while the cause stays. A minimal stopgap is never offered as an equal alternative to the real fix. That is a different thing from the gate, which does present genuine design alternatives when the investigation found more than one defensible shape: choosing between two real designs is the user's call, choosing to do less than the correct fix is not. + +### Measure, do not assume + +The single highest-value habit in this skill. When the fix depends on how a binary dependency, a C library, or a system framework actually behaves, **write a probe and run it against the artifact we ship**, rather than trusting documentation, an agent's report, or your own recall. + +A probe is cheap: a C file compiled against the real `Libs/*.a` and the vendored header, a `swiftc` harness, a SQL statement run through the vendored CLI. It routinely overturns claims that three independent sources agreed on. Treat an unverified claim as a hypothesis no matter how confidently it was stated, including when you stated it. + +When the probe settles a fact that the codebase then hard-codes by hand, commit the probe as a script under `scripts/` so a future dependency bump re-checks it instead of trusting a transcription. `scripts/check-pluginkit-abi.sh` and `scripts/check-duckdb-value-api.sh` are the shape. ## Phase 0: Intake -1. `git branch --show-current` and `git status --short`. Existing changes are user-owned. Never - stash, reset, switch branches over them, or fold them into your diff. -2. For an issue number or URL, read the issue and its comments with `gh issue view --comments`. - Its label picks the track: `bug` or `enhancement`. Read the label, do not assume from the title. -3. Create the run's worktree. Every file this run writes goes there: +Get a precise problem statement, and a safe place to work, before touching anything. - ```bash - .claude/skills/fix-issue/scripts/worktree.sh fix/ # or feat/ on the change track - ``` +When the session exposes `TodoWrite`, open a list here with the phases this run will actually use and keep it current: it is how the user follows a long run without asking, and it is what tells you where you were after a compaction. The tool is not present in every session, so fall back to stating the phase in the thread at each boundary rather than planning around it. - It prints the path. That path is `$WT` for the rest of the run, and every command that touches - code takes it explicitly: `git -C "$WT"`, `verify.sh --root "$WT"`, absolute paths in edits. -4. Create the run directory `.analysis//` **in the main checkout**, not in the - worktree, so briefs, blueprints, state, and logs survive the worktree being removed. Write - `brief.md`, naming the track and `$WT`: - - Defect: current behavior, expected behavior, smallest reproduction, environment. - - Change: the user's problem in their words, the proposed behavior, the non-goals, and the - database types affected when the issue names one. - - Both: acceptance criteria, and the suspected subsystem labeled as a hint. Reporter code - pointers, proposed fixes, and proposed designs are hypotheses, including the reporter's own - idea of what the feature should look like. -5. Write `state.md` with the phase, the track, the branch, and `$WT`. Update it at every phase - boundary. - -The main checkout is read-only for this run. It never receives an edit, never gets the branch, -and never gets committed to. Other sessions are working in it, and a run that writes there fights -them for the tree, the branch, and the build database. - -If the run stops before implementing anything, remove the worktree and delete its branch rather -than leaving both behind: `worktree.sh --remove fix/`. - -`.analysis/` is gitignored. The brief is the shared input for every lane, so it is written once -and never restated in a prompt. - -Concluding that the requested feature already exists, or that the reported defect is already -fixed, is a real result. Report it with the evidence and stop. Building a second version of -something the app already does is worse than building nothing. - -Ask a question only when repository evidence cannot choose between materially different product -outcomes. Do not ask permission to investigate, implement, verify, or ship. A change reaches that bar -more often than a defect, because a feature request can be satisfied by several designs the code -cannot rank. Ask then, with the options and your recommendation, and keep going on everything the -answer does not block. - -## Phase 1: Investigate in lanes +1. **Read the report.** Given an issue number or URL: `gh issue view --repo TableProApp/TablePro --comments`. Read the body and every comment; reporters often clarify the real complaint in follow-ups. Given a chat description: restate it in one sentence naming the observable wrong behaviour against the expected behaviour. +2. **Capture the specifics.** Reproduction steps, screenshots, database type, macOS version. These shape what the investigators look for. +3. **Treat any code pointer in the issue as a hint, not a fact.** Reporters point at the wrong file often. Verify it and follow the evidence where it actually leads. +4. **Check the tree.** Run `git branch --show-current` and `git status --porcelain`. If the tree is dirty with unrelated work, ask the user how to proceed before creating a branch. Never silently stash someone else's uncommitted changes; a mid-session branch move drops uncommitted edits to tracked files. -``` -Workflow({ scriptPath: ".claude/skills/fix-issue/workflows/investigate.mjs", - args: { brief: ".analysis//brief.md", root: "<$WT>", - track: "defect" } }) // or "change" -``` +End Phase 0 with a written problem statement: what happens now, what should happen, and the smallest reproduction. If the expected behaviour is genuinely ambiguous, meaning two reasonable readings lead to different fixes, resolve it with `AskUserQuestion` now, before spending the investigation on a guess. That is the one question worth asking, and it is about the requirement, never about permission to proceed. -The track picks the lane set, four either way: +## Phase 1: Investigation workflow -- `defect`: the shipping call path, sibling paths and collateral, the platform or dependency - contract, test coverage. -- `change`: placement and the closest precedent already in the repository, platform capability, - the user-visible surface the feature has to touch, test coverage. +Run the investigation with the `Workflow` tool. This skill's instructions are the opt-in the tool requires, so no further user consent is needed. The full script, with the agent charters written out, is in `references/orchestration.md`. Read it before calling. -Pass `extra` to add lanes the issue justifies, such as `plugin-abi-reviewer` for PluginKit, -driver, registry, or public plugin API work, which a new database type almost always needs. Scale -lanes to the number of genuinely independent questions. Do not cap them to save tokens, and do not -add a lane that duplicates another's question. +The script fans out four investigators, then adversarially verifies what the collateral hunter found: -Each lane returns a capped digest: verdict, confidence, anchors, collateral, risks, unknowns, -tests. Only that reaches the thread. +| Role | Answers | +| --- | --- | +| Codebase tracer | How does the relevant code actually work today? Which files, types, and call paths are involved? Where is the real cause, as opposed to the symptom? | +| Platform researcher | What do Apple's HIG and framework docs, or the vendored header of the dependency in play, say the correct behaviour and the right API are? | +| Competitor / UX researcher | How do TablePlus, DataGrip, Postico, and Sequel Ace handle this? What interaction do users expect? | +| Collateral hunter | What **else** is wrong in the subsystem this fix touches? This feeds Phase 6, and it is the reason the skill leaves the area better than it found it. | -Then, in the main thread: +Give every agent the Phase 0 problem statement verbatim and a sharp question. A vague brief produces a vague report. Require concrete evidence: `file:line` for code, a doc URL or exact symbol name for platform claims, a named source for competitor behaviour, a reproduction for a collateral finding. -- Open each anchor that the plan will depend on. An anchor that does not say what the lane - claimed invalidates the lane, not the anchor. -- Resolve contradictions between lanes at the source, not by majority. -- For a lane that is thin on the question you care about, ask that one narrow follow-up rather - than re-running the phase. +### Orchestration notes -Read `references/delegation.md` if you need the lane contract, the follow-up mechanics, or how to -recover a lane's full detail. +- **Hardcode the inputs in the script.** The `args` parameter has failed to reach the script global before. Paste the problem statement into the script as a string. +- **The workflow runs in the background.** You are notified when it completes. Do not report, assume, or invent its results before the notification arrives. +- **Its report goes to you, not the user.** Relay what matters in your own words. +- **Verify load-bearing claims yourself.** An agent's confident report is evidence, not proof. Anything the design depends on gets a probe, a header grep, or a read of the actual file. Agents in this session have been wrong in both directions on exactly the facts that decided the architecture. +- **The three reporting lanes come back as a capped digest**, not prose: a verdict, a confidence label, and up to eight anchors. That schema is the only enforceable limit on what a lane can send, because a subagent's final message has none. Read each anchor the plan will depend on rather than trusting the claim beside it, and when a digest is thin on the one question you care about, ask that lane a narrow follow-up instead of re-running the phase. What did not fit is still in the lane transcript and in the run's `journal.jsonl`. +- **Do not chase parallelism at the cost of the brief.** If the `Workflow` tool is unavailable, run the same charters with the `Agent` tool in one message, or in sequence yourself. Parallelism is a latency optimization; the evidence bar is what determines the fix. -## Phase 2: Blueprint, then attack it +## Phase 2: Synthesis and challenge -Write `.analysis//blueprint.md`. It is the run's contract: what the critics attack, what the -implementer follows, what shipping stages, and the one artifact that survives a compaction. -`references/blueprint.md` holds the field list for each track. `references/quality-bar.md` holds the -call it turns on: refactor versus patch on a defect, new seam versus existing shape on a change. +You own the blueprint. You have every report plus the conversation context the subagents never saw, so write it yourself rather than handing it to a fresh agent that would re-derive everything. -Then attack it: +The blueprint must answer: -``` -Workflow({ scriptPath: ".claude/skills/fix-issue/workflows/critique.mjs", - args: { blueprint: ".analysis//blueprint.md", root: "<$WT>", - brief: ".analysis//brief.md", track: "defect" } }) -``` +- **Root cause**, stated plainly and separated from the symptom. +- **Refactor vs. patch.** Can the current structure express the correct behaviour cleanly, or does the relevant code need restructuring to do this properly? This is the most important call in the skill. If the existing design cannot express the right behaviour, say "refactor X" instead of bolting a special case onto a broken shape. The criteria are in "Refactor or patch" above. +- **The native, HIG-correct design**, naming the specific AppKit/SwiftUI API or dependency call and the documented behaviour it follows. Prefer a documented platform API over a hand-rolled equivalent. +- **Full scope.** Every file to create or change, in implementation order, plus the edge cases and the TablePro invariants from `CLAUDE.md` the change must respect. +- **Blast radius.** The reported symptom is usually one instance of a class. Say how many cases the root cause actually covers, and cover all of them. +- **Tests** that would have caught the bug: the unit test always, plus `TableProUITests` automation when the fix changes a user flow and that flow runs deterministically. If it does not run deterministically, say so and why, so it can go in the PR description. Name the CHANGELOG and `docs/` updates the fix requires. +- **The collateral register.** Every finding that is real but is not the reported bug, with its evidence and its disposition. Phase 6 consumes this. -Three lenses, chosen by track. On a defect: ownership and existing patterns, missing scope and -compatibility, correctness and safety. On a change the first lens becomes product and -architectural fit, which asks whether a smaller design already satisfies the acceptance criteria -and whether this invents a pattern the app does not use. +**Then have the blueprint attacked**, with a second `Workflow` call that runs three critics on distinct lenses: does it fight existing codebase patterns, what scope is missing, and is the refactor-vs-patch call right. Script in `references/orchestration.md`. Fold what survives into the blueprint. Skip the challenge only for a contained single-file fix. -Verify each surviving objection at its evidence before you change the blueprint. A measured fact -outranks a critic. Record in the blueprint what you rejected and why. +### Gate: agree the approach before writing code -## Phase 3: Implement with one writer +Once the blueprint has survived the challenge, present it and wait. This is the one approval gate in the skill, and it exists because the cheapest place to catch a wrong direction is before the diff, not in the PR. -Every edit lands in `$WT`, never in the main checkout. Exactly one writer works in that worktree. +Keep it short enough to read in a minute. Not the blueprint itself, which is long: the decision inside it. -Write in the main thread when the blueprint touches roughly three files or fewer and keeps the -existing shape. Otherwise delegate the edit to the `implementer` agent, giving it `$WT`, the -blueprint path, and the run directory, then review `git -C "$WT" diff` here. Always delegate if a -compaction has already happened in this run, because the thread no longer holds what the blueprint -holds. +- **The root cause**, as a mechanism rather than a symptom, in one or two sentences. +- **The approach you recommend**, named as the ownership boundary it sits at and the API or pattern it follows. When the investigation surfaced a genuine alternative, give two or three with the trade-off between them and say which you would take and why. When there is only one correct native answer, say that plainly instead of inventing options. +- **The refactor-vs-patch call**, with its reason. This is where a wrong answer costs the most, so it gets its own line. +- **Scope and non-goals**: how many cases the root cause covers, and what you are deliberately not doing. +- **What you will verify**, and any question the investigation itself raised. -Pass absolute paths to everything. The shell's working directory resets to the main checkout on -its own, and a relative path then edits or builds the wrong tree while the symptoms look like your -own bug. +**Present it with `ExitPlanMode`.** That is the native approval affordance: the user gets an accept or reject control instead of having to type a reply, and rejecting keeps you out of the edit. Put the summary above in the plan body. Use `AskUserQuestion` instead only when the decision is a choice between named options that needs answering before a plan can be written at all, and put your recommendation first. -Either way: +Two things this gate is not. It is not a request for permission to investigate, verify, or ship, all of which you already have. And it is not an invitation to pitch a smaller version: the recommendation is still the complete, Apple-correct fix, and a phased or quick-win option only appears if the user asks for one. -- Follow the blueprint's dependency order. Do not quietly downgrade a required refactor into a - special case, and do not quietly grow a change past its non-goals. -- Land the test with the change, not after it. On a defect it fails before the fix; on a change it - encodes the acceptance criteria. -- Regenerate the project after adding, moving, or deleting a source file. -- Changelog, docs, localization, and logging are part of the change, not cleanup. -- Preserve every unrelated change already in the tree. +Skip the gate for a contained single-file fix whose cause is proven and whose fix is mechanical, the same bar that skips the challenge pass. Report the direction alongside the diff instead. -For SwiftUI or AppKit view work, load `$swiftui` in the writing context only. +## Phase 3: Implementation + +- **Branch first.** `git checkout -b /` off the current base in the main checkout. Default to the main checkout, not a worktree; use a worktree only when the tree already holds unrelated in-flight work and the fix needs its own PR, and say so before doing it. When you do need one, create it with `.claude/skills/fix-issue/scripts/worktree.sh `, which also symlinks `Secrets.xcconfig`, `Libs/*.a`, `Libs/dylibs`, and `Libs/ios`. Without those a fresh worktree fails before it compiles, with an "Unable to open base configuration reference file" error that reads like a broken toolchain. Then pass `--root ` to `verify.sh`, and run its `generate` step before the first build, because the worktree has its own generated project. +- **Follow the blueprint's file order.** Do the refactor it calls for. Do not quietly downgrade to a patch because the refactor turned out to be more work. +- **Regenerate after adding a file.** A new `.swift` file is not compiled until `scripts/generate-project.sh` runs. The symptom is `cannot find 'X' in scope` from the callers, which reads like the code was never written. +- **Honour the mandatory rules as you go**, not as cleanup: `String(localized:)` for user-facing strings and never with interpolation, `CHANGELOG.md` under `[Unreleased]`, `docs/` for shortcut, UI, settings, or driver changes, OSLog instead of `print`, no comments, early returns, explicit access control. +- **Write the tests the blueprint specified**, unit and UI both. UI suites subclass `UITestCase`; a bare `XCUIApplication()` or `: XCTestCase` under `TableProUITests/` fails a source-scanning guard test, because storage isolation depends on the launch path. When a test fails, fix the source. Never bend a test to match wrong output. +- **Run `Skill(swiftui-pro)`** when the change adds or reworks SwiftUI views, before you consider the code done. ## Phase 4: Verify -Every check runs through the wrapper, which keeps the full log on disk and prints a verdict of -`PASS`, `FAIL`, or `INCONCLUSIVE`: +You build and test this yourself. Do not hand unverified code back and ask the user to surface compile errors. The full playbook, including the environment setup that makes local `xcodebuild` and `swiftlint` work, is in `references/verification.md`. + +**Run every step through the wrapper.** It keeps the full log on disk and prints at most about thirty lines, ending in a verdict of `PASS`, `FAIL`, or `INCONCLUSIVE`: ```bash -.claude/skills/fix-issue/scripts/verify.sh --root "$WT" --run .analysis/ +.claude/skills/fix-issue/scripts/verify.sh generate +.claude/skills/fix-issue/scripts/verify.sh build +.claude/skills/fix-issue/scripts/verify.sh test [Suite…] +.claude/skills/fix-issue/scripts/verify.sh uitest +.claude/skills/fix-issue/scripts/verify.sh plugins # AllPlugins aggregate +.claude/skills/fix-issue/scripts/verify.sh abi +.claude/skills/fix-issue/scripts/verify.sh lint [path…] +.claude/skills/fix-issue/scripts/verify.sh tail [n] # re-read a stored log ``` -`--root` builds and tests the run's worktree. `--run` keeps the logs in the main checkout's run -directory, where they outlive the worktree. Regenerate the project inside `$WT` before its first -build: XcodeGen globs sources at generation time and the worktree has its own generated project. +This is not a convenience. A raw `xcodebuild` failure returns roughly 10,000 characters as a head-and-tail excerpt **with no log file path**, so the one case where you need the whole output is the one case you cannot get it back. The wrapper also exports `DEVELOPER_DIR`, resolves the project explicitly so a drifting shell cannot build the wrong checkout, waits for any other `xcodebuild` on the machine, and cross-references both quarantine lists before it calls anything a failure. Exit codes are `0` pass, `1` fail, `2` inconclusive. + +`INCONCLUSIVE` means the environment failed, not your change. The wrapper names the cause. Never record it as a pass, and never start debugging your own code on one: the nastiest signature is a locked build database, where every case reports `failed` at `0.000 seconds` and reads exactly like a mass regression. + +**Run the slow steps in the background.** A Debug build and the `plugins` aggregate take minutes, and a foreground `Bash` call blocks the whole session for them. Pass `run_in_background: true` and you are re-invoked when the step exits, so the wait costs nothing. Keep `generate` and short `lint` runs in the foreground, where the round trip is not worth it. Background steps still run one at a time: never have two `xcodebuild` processes in flight, backgrounded or not. + +Non-obvious rules that decide whether the result means anything: run only the suites you touched and their neighbours, never the whole target; run the steps serially; and build the `plugins` aggregate yourself if the change touched a registry-only plugin, because PR CI never compiles those. + +Where the fix rests on how a dependency behaves, finish with the before-and-after probe from "Measure, do not assume". A probe that reproduces the bug on the old path and shows every case correct on the new one is the strongest evidence a PR can carry. + +UI tests have their own trap list, including an accessibility tree that differs between this machine and the CI runner, and a SwiftUI container identifier that silently erases every child's. Read `references/verification.md` before writing one. + +## Phase 5: Review, commit, and open the primary PR + +1. **Self-review the diff.** Run `Skill(code-review)` on the change. Fix what it finds, or say why a finding does not apply. Treat its findings on your own edits as seriously as its findings on old code; this pass has already caught a CHANGELOG heading deleted by a careless `Edit`. + - **`Skill(security-review)`** as well whenever the change touches a security boundary: credentials, keychain, SQL construction, query execution, plugin loading, MCP, AI tool permissions, sync, or anything that widens what a user or a plugin can do. It reviews the pending changes on the branch, so run it after the diff is complete and before the commit. + - **`Skill(simplify)`** when the change grew past a couple of files. It is a quality pass for reuse and duplication rather than a bug hunt, which is the gap `code-review` leaves. + - Each of these reads the diff itself. Do not paste the diff into the thread to prepare for them. +2. **Check the CHANGELOG survived.** After any edit to `CHANGELOG.md`, run `grep -n '^## \[' CHANGELOG.md` and confirm the released version headings are still there. An `Edit` whose `new_string` drops the trailing context silently folds a shipped release into `[Unreleased]`, and the next release notes then re-ship it. +3. **Writing-style gate.** Stage the change, then run the grep from `CLAUDE.md` over the staged diff for em dashes and banned filler words. Rewrite every hit that is on an added line. +4. **Verify the branch, in its own call, immediately before committing.** `git branch --show-current`. The checkout can move between turns, and chaining `commit && push` has already pushed straight to `main` once. Never chain them. +5. **Commit.** Conventional Commits: single line, no body, canonical scope from `CLAUDE.md`. Never pass `-c user.email` or `-c user.name`; the repo identity is already correct and overriding it has shipped unattributed commits. +6. **Push.** `git push -u origin `. If SSH fails, port 22 is blocked here; push over HTTPS with the `gh` credential helper instead: + ```bash + git -c credential.helper='!gh auth git-credential' push https://github.com/TableProApp/TablePro.git + ``` +7. **Open the PR.** + ```bash + gh pr create --repo TableProApp/TablePro --base main --head --title "" --body-file + ``` + Write the body to a file rather than passing it inline, so the writing-style grep can run over it first. The body states the root cause, the fix, and what you built and tested, and it closes the issue with `Fixes #`. If a UI flow could not get deterministic automation, say so here: the PR description is the only place that exemption is recorded. + +If the fix is stacked on another in-flight branch, base the PR on that branch instead of `main` and say so, rather than dragging the other work into this PR. -Steps: `generate`, `build [Scheme]`, `test …`, `uitest …`, `plugins`, `abi `, -`lint …`. Also `parse ` and `tail [n]` to re-read a stored log without rerunning. +## Phase 6: Dispose of the collateral findings -Run them serially. Never run two `xcodebuild` processes at once. `INCONCLUSIVE` means the -environment failed, not the change: read the stated cause and rerun, and never record it as a -pass. A `FAIL` naming only quarantined suites is not your regression, and the wrapper says so. +**An investigation that finds three defects and reports one has failed at the part that mattered most.** The hunt is not optional and neither is saying what it found. What is optional is building it: only a finding the primary fix is unsafe or incomplete without ships on its own. Everything else is reported precisely and left for the user to decide. -Read `references/verification.md` when a verdict needs interpreting, when the change touches UI -automation, or before the first commit. +### Disposition -## Phase 5: Independent review +Sort every register entry into exactly one of three: -Load `$cross-model-review` and follow it. Claude wrote this change, so Codex reviews it, plus one -focused adversarial pass for high-risk work. Two things it needs from you: the review must read -`$WT`, not the main checkout, so name that path in the request, and a review you could not start is -reported as not started rather than glossed as reviewed. +1. **Blocking, so it ships with the fix.** The primary fix is wrong, unsafe, or incomplete without it, which is the `blocksPrimaryFix` flag from the investigation. The test: would shipping the primary fix alone make this defect more likely to bite, or leave the same class of bug latent? If yes it is not collateral, it is scope, and it ships without asking. Fold it into the primary diff by default. When it is genuinely separable and large enough to deserve its own review, make it a prerequisite PR and base the primary on that branch, saying so in both bodies. In the DuckDB timestamp fix, routing common types through an existing cast path made that path's row-misalignment and unbound-parameter bugs go from rare to routine, so both belonged with the primary change. +2. **Verified but independent, so report it and stop.** A real defect the primary fix does not depend on. Do not open a PR for it, do not fold it in, and do not file it as an issue. Write it into the final report and let the user choose. This is the default for anything that clears the bar. +3. **Drop it.** The evidence did not survive verification, the path is not reachable in the shipping app, or fixing it needs a product decision. Say so plainly with what you found and why you stopped. -## Phase 6: Ship +### The bar for reporting one at all -A clean run ships by itself. Read `references/shipping.md` before the first git command. It holds -the gate list, the staging rules, the commit and push order, and the pull request body contract, -and it is the copy that wins if this summary and it ever disagree. +All of these, or it is noise and goes unmentioned: -Two things belong here rather than only there: +- It is a **defect or a concrete correctness risk**, with a failure scenario someone could actually hit. Not naming, not taste, not "I would have structured this differently". +- The evidence **survived verification**. A plausible-sounding claim nobody confirmed is a hypothesis. Probe it or drop it. +- Nothing upstream already prevents it. +- It lives **in this repository**. Another target counts: `TableProMobile`, a registry-only plugin, and `scripts/` are all in scope. -- **A gate that fails stops the run.** A `FAIL`, an `INCONCLUSIVE` never rerun to a pass, a file - dirty in `$WT` the blueprint does not list, or an unresolved P0 to P2 review finding. Stopping and - reporting is a normal outcome, not a failure. -- **Authorization ends at the open pull request.** Never merge, tag, publish, release, force push, - or rewrite history. Ship from `$WT` with `git -C`, so nothing here can commit to `main`. - `$release` runs only on an explicit release request. +### How to report them -## Phase 7: Work the follow-up queue +Each entry gets its `file:line`, one line on the defect, the concrete failure scenario, how it was verified, and a size estimate. That is enough for the user to say yes or no without reopening the investigation, which is the whole point of writing it down properly. -The blueprint's collateral register becomes its own pull requests, one at a time, once the primary -one is open. Work the queue to empty and do not stop to ask whether to continue. Each item gets its -own worktree, its own branch, and the whole playbook: brief, blueprint, test, changelog, docs, -verification, review, pull request. `references/shipping.md` holds the qualifying bar, the ordering, -and the four conditions that stop the queue. +Say explicitly when the register is empty. A clean subsystem is a real result, not a gap in the report. -A defect found while building a feature is a queue item, not a silent addition to the feature's diff. +### When a blocking finding ships -## Recovering after a compaction +- **Primary PR first, always.** It is what the user asked for. +- **Run Phases 3 through 5 in full**, including the prerequisite PR when there is one. Tests, build, `plugins` when a registry plugin moved, lint, CHANGELOG, code review, style gate. Code nobody asked for is not exempt from the quality bar; it is the most likely to be judged on it. +- **Say why it exists** in the body: found while investigating `#`, what the defect is, its failure scenario, how it was verified, and why the primary fix is not safe without it. +- **Autonomy is not a licence for risk.** The standing safety rules hold: nothing destructive or irreversible, no force-push, no rewriting history, no touching release tags, no publishing plugins or libraries. Those still get asked about. -Do not restart the investigation. Read `.analysis//state.md` and `blueprint.md`, run -`git status --short` and `git diff --stat`, and resume at the recorded phase. That is what the -run directory is for. +## Phase 7: Report -## Final report +Close with the pull request opened, what it fixes, and its verification verdicts with their log paths. Then, in your own words, the root cause and how the fix maps to it, and plainly what you built and tested. -Root cause on a defect or the design decision on a change, implemented behavior, files changed, -verification verdicts with the log paths, review result, the pull requests opened, remaining risk, -queue items dropped and why, and on a change the non-goals you held to. State every check that -could not run and why. No claim beyond the evidence. +Then the collateral register, which is the part the user cannot reconstruct: every verified finding at disposition 2, each with its `file:line`, the failure scenario, how it was verified, and a size estimate, so a yes or no does not need the investigation reopened. Say when the register is empty. List what was dropped at disposition 3 with the reason. -## References +If part of the work was blocked, say which part and why, and confirm everything else shipped. State every check that could not run. -Read on demand, at the phase that needs them, never up front. +## Reference files -- `references/delegation.md`: lane contracts, detail recovery, agent versus workflow, the writer handoff. -- `references/blueprint.md`: the field list for the run's contract, per track. -- `references/quality-bar.md`: refactor versus patch, new seam versus existing shape, the evidence - bar, what done means on each track. -- `references/verification.md`: verdicts, environment traps, UI automation, the pre-commit list. -- `references/shipping.md`: the shipping gates, staging rules, pull request body, and the queue. -- `references/research-sources.md`: platform, SDK, and HIG sources. The platform lane reads this, not you. +- `references/orchestration.md`: the investigation and challenge workflow scripts, with the agent charters written out. Read before Phase 1. +- `references/research-sources.md`: Apple documentation map, dependency headers, research tools, competitor apps, and what counts as evidence. The investigators use this. +- `references/verification.md`: build, test, and lint playbook, including the environment setup and the failures that are not yours. Read before Phase 4. diff --git a/.claude/skills/fix-issue/evals/evals.json b/.claude/skills/fix-issue/evals/evals.json index 7ec7c19b1..bc4b5a9b5 100644 --- a/.claude/skills/fix-issue/evals/evals.json +++ b/.claude/skills/fix-issue/evals/evals.json @@ -1,6 +1,6 @@ { "skill_name": "fix-issue", - "notes": "Autonomous root-cause skill with no approval gate. The graded artifact is the investigation and implementation blueprint each run produces because committed code and live pull requests are outside these evals. A with_skill run may continue into implementation; grade the blueprint wherever it appears. with_skill runs follow SKILL.md; baseline runs receive the same prompt without the skill. Two prompts are symptom-only to test root-cause discovery, and one is a bare GitHub issue number to test issue retrieval. Claude runs use ultracode dynamic workflow orchestration with independent evidence lanes and do not pause for routine approval. The skill also claims a context property: the brief and blueprint land in a run directory, evidence gathering happens in lanes, and build output goes through the verification wrapper, so a with_skill run should reach the same or better blueprint while reading far less in the main thread than a baseline run.", + "notes": "Root-cause skill with exactly one approval gate. The graded artifact is the investigation plus the blueprint each run produces, and the gate summary it presents from that blueprint, because grading committed code and live pull requests is out of scope for these evals. Grade the blueprint content wherever it appears in the transcript. IMPORTANT for grading: the skill stops at the Phase 2 gate and waits for the user to accept the approach, so in a non-interactive eval run a transcript that ends there is the skill working correctly, not a run that stalled or gave up. A run that skips the gate and implements anyway fails, and so does a run that pauses anywhere else to ask permission to investigate, verify, or ship. with_skill runs follow SKILL.md; baseline runs get the same prompt with no skill. Two prompts are symptom-only (no file or invariant named) to test whether the investigation rediscovers the cause; one is a bare GitHub issue number to test the fetch path. The orchestration expectations check the current mechanics: the investigation fans out through the Workflow tool with capped digest schemas, the ux lane supplies competitor and HIG evidence for user-visible surfaces, build and test output goes through the verification wrapper, and collateral findings are reported rather than built unless the primary fix is unsafe without them.", "evals": [ { "id": 1, @@ -8,15 +8,17 @@ "source_issue": 1350, "input_mode": "chat-symptom-only", "prompt": "In TablePro, the Active Connections panel dims the background when it opens but I can't tell how to close it. There's no close button anywhere. Make it dismiss properly, the native macOS way.", - "expected_output": "A blueprint that locates the Active Connections sheet (ConnectionSwitcherSheet.swift), cites macOS HIG conventions for dismissing a sheet or panel, and covers all three dismissal paths from the issue: a visible close control, Escape, and click-outside. The run proceeds on its own rather than asking whether to implement.", + "expected_output": "A blueprint that locates the Active Connections sheet (ConnectionSwitcherPopover.swift), cites macOS HIG conventions for dismissing a sheet or panel, and covers all three dismissal paths from the issue: a visible close control, Escape, and click-outside. The run then presents that approach at the gate and waits, having asked for nothing else.", "files": [], "expectations": [ - "Identifies the Active Connections sheet component (ConnectionSwitcherSheet.swift or equivalent) as the place to fix", + "Identifies the Active Connections sheet component (ConnectionSwitcherPopover.swift) as the place to fix", "Cites a specific macOS HIG convention or native dismissal API (e.g. SwiftUI dismiss, sheet/NSPanel, Escape via cancelAction) rather than inventing an approach", "Addresses the full scope of dismissal, not just adding a button: visible close control AND Escape AND click-outside", "States the root cause (no dismissal affordance) distinctly from the symptom", - "Writes the problem statement to a run directory and fans investigation out to independent lanes, rather than reading the subsystem in the main thread", - "Proceeds from blueprint to implementation without pausing to ask the user for approval or confirmation", + "Runs the investigation through the Workflow tool rather than ad-hoc one-off searches", + "Uses the ux lane for this user-visible surface and reports how comparable native clients dismiss the same kind of panel, labelled CONFIRMED or INFERRED, with the HIG deciding where they disagree", + "Presents the approach at the Phase 2 gate and waits, rather than implementing straight from the blueprint. It asks for nothing else along the way: no permission to investigate, verify, or ship", + "Plans its build and test steps through .claude/skills/fix-issue/scripts/verify.sh rather than raw xcodebuild", "Plan includes a CHANGELOG entry, a unit test, and TableProUITests automation for the dismissal flow" ] }, @@ -26,17 +28,17 @@ "source_issue": 1348, "input_mode": "chat-symptom-only", "prompt": "When I click a table in the sidebar, the behaviour is inconsistent. The first click replaces the current tab, but once I've opened a second tab, clicking another table keeps opening brand-new tabs instead of replacing the active one. It should be predictable. Fix it properly so it behaves like a native macOS app would.", - "expected_output": "A blueprint that traces openTableTab in MainContentCoordinator+Navigation, rediscovers the tab replacement guard invariant in the shared TablePro project guide, explains why the branch flips after the second tab, and proposes one consistent preview-tab rule (single-click reuses or replaces the active preview tab; an explicit action opens a new tab) matching native preview-tab UX.", + "expected_output": "A blueprint that traces openTableTab in MainContentCoordinator+Navigation, rediscovers the tab replacement guard invariant documented in CLAUDE.md, explains why the branch flips after the second tab, and proposes one consistent preview-tab rule (single-click reuses or replaces the active preview tab; an explicit action opens a new tab) matching native preview-tab UX.", "files": [], "expectations": [ "Traces the cause to openTableTab in MainContentCoordinator+Navigation (or the table-selection path) with file evidence", - "Surfaces the tab replacement guard invariant from the shared project guide, or otherwise explains the preview-tab vs new-tab branch precisely", + "Surfaces the tab replacement guard invariant from CLAUDE.md, or otherwise explains the preview-tab vs new-tab branch precisely", "Explains the root cause: why selection replaces the tab initially but opens new tabs after a second tab exists", "Proposes one consistent rule (single-click reuses or replaces the preview tab; explicit action opens a new tab), referencing native preview-tab UX from Xcode, Finder, or a competitor client including TablePlus", "Makes an explicit refactor-vs-patch decision rather than bolting on a special case", "Attacks its own blueprint with a challenge pass, since the change touches a documented invariant", - "Keeps a register of defects found outside the reported scope and queues them as separate follow-up work rather than folding them into this diff", - "Proceeds without pausing for approval" + "Keeps a register of any defect found in the tab or coordinator subsystem that is not the reported bug, each with a file:line and a reachable failure scenario, and reports it for the user to decide rather than folding it into this diff or opening a pull request for it unasked", + "Presents the approach at the Phase 2 gate and waits, and asks for nothing else along the way" ] }, { @@ -53,46 +55,7 @@ "States the root cause as completion firing only for the leading token or not re-running per clause, not a surface workaround", "Covers the full scope: column completion at every valid position including after AND and OR, not only after AND", "Specifies a test that exercises completion after AND and OR in the raw SQL filter", - "Proceeds without pausing for approval" - ] - }, - { - "id": 4, - "name": "saved-query-reuse-change-track", - "source_issue": null, - "input_mode": "chat-feature-request", - "prompt": "In TablePro I keep retyping the same few queries. I want to save a query and re-run it later with a keyboard shortcut. Add that.", - "expected_output": "A blueprint on the change track that finds the shipping precedents for reusable queries before designing anything, makes an explicit extend-versus-new-seam decision, specifies the discovery surface and persistence, and writes down non-goals. The run proceeds on its own rather than asking whether to implement.", - "files": [], - "expectations": [ - "Classifies the request as a change rather than a defect and states the track before investigating", - "Finds the existing precedents for saved and reusable queries, such as favorites and query history, with file evidence, instead of designing from scratch", - "Makes an explicit extend-existing versus new-seam decision with a reason, and does not propose a parallel system that duplicates one already shipping", - "Specifies the user-visible surface: entry point, menu placement, keyboard shortcut, empty state, and error state", - "Covers persistence, settings defaults, and what happens to data existing users already have", - "Writes non-goals so the plan has a stated size", - "Names the docs page and the CHANGELOG entry the change requires, plus unit coverage and deterministic UI automation", - "Treats the reporter's proposed solution as a hypothesis rather than as the design", - "Proceeds without pausing for approval" - ] - }, - { - "id": 5, - "name": "new-database-type-request", - "source_issue": 1986, - "input_mode": "github-issue-number", - "prompt": "resolve issue #1986 in TablePro", - "expected_output": "A blueprint on the change track that reads the issue and its enhancement label, routes the work through the plugin architecture instead of the app target, keeps DatabaseType open, verifies what the underlying protocol actually supports, and plans the registry-only build and the PluginKit ABI check.", - "files": [], - "expectations": [ - "Fetches the issue with gh and uses its enhancement label to pick the change track", - "Routes the work through the plugin system and TableProPluginKit rather than adding a case inside the app target", - "Keeps DatabaseType open: string-backed, unknown types round-trip, and every switch over it keeps a fallback", - "Identifies the closest existing driver plugin as the precedent and lists its files as the template to follow", - "Verifies what the client library or wire protocol actually supports rather than assuming SQL semantics", - "Plans the registry-only AllPlugins build and scripts/check-pluginkit-abi.sh, not just the app build", - "States non-goals, because a complete driver is larger than one issue", - "Proceeds without pausing for approval" + "Presents the approach at the Phase 2 gate and waits, and asks for nothing else along the way" ] } ] diff --git a/.claude/skills/fix-issue/references/blueprint.md b/.claude/skills/fix-issue/references/blueprint.md deleted file mode 100644 index 21efea616..000000000 --- a/.claude/skills/fix-issue/references/blueprint.md +++ /dev/null @@ -1,48 +0,0 @@ -# The Blueprint - -`.analysis//blueprint.md` is the run's contract. It is the input the critics attack, the -instruction the implementer follows, the checklist shipping reads, and the one artifact that -survives a compaction. Written well, nothing later has to reconstruct the plan from the -conversation. Written vaguely, the implementer invents the missing half and you find out in the diff. - -Write it before touching a product file, and update it when a critic or a measurement changes it. - -## On a defect - -- **Root cause**, stated as a mechanism and separated from the symptom. "The list does not refresh" - is a symptom. "The refresh clears the cache it is about to read, so the second call sees empty" - is a cause. -- **The ownership boundary** that makes the behavior correct: which type first has enough - information to decide, and why the fix belongs there rather than where the symptom appeared. -- **Targeted fix or refactor**, with the reason. See `quality-bar.md`. - -## On a change - -- **The design**, the ownership boundary it sits at, and the **precedent** it follows, named as - files. Where it departs from that precedent, say why. Copying a shipping example beats inventing - a shape, and an inaccurate precedent is worse than none. -- **The user-visible surface**: entry point, menu placement, keyboard shortcut, settings and their - defaults, empty state, error state, and what happens to existing users and their stored data. -- **The non-goals.** Unwritten, they let each critic, reviewer, and implementer invent a different - larger feature. Written, a plan that respects them is complete rather than thin. - -## Both tracks - -- **Every affected path**: callers, state, persistence, plugins, docs, localization, migration, ABI. -- **The invariants it must not break**, named, from the project guide. -- **The file list.** Shipping stages exactly this, by explicit path, so a file missing here does not - get committed and a file added here without a reason gets caught. -- **Verification**: the test that fails before and passes after, plus the build, lint, UI, probe, - and ABI steps this change requires, as `verify.sh` steps. -- **The collateral register**, in three parts: required scope, independently useful findings, and - unverified hypotheses. The middle part becomes the follow-up queue, so each entry needs - `file:line`, a reachable failure scenario, and evidence that no upstream guard already prevents - it. See `shipping.md`. -- **Rejected objections** and why, once the critique phase has run. Otherwise the next reader - re-raises them. - -## The test it has to pass - -Hand the blueprint to someone who did not watch the investigation. If they cannot implement it -without guessing, it is not finished, and finishing it now is cheaper than discovering the gap in a -diff or a review. diff --git a/.claude/skills/fix-issue/references/delegation.md b/.claude/skills/fix-issue/references/delegation.md deleted file mode 100644 index 5ebcda61b..000000000 --- a/.claude/skills/fix-issue/references/delegation.md +++ /dev/null @@ -1,106 +0,0 @@ -# Delegation and the Context Budget - -How work is split so the main thread stays small enough to finish the job, and how to get detail -back when a digest is not enough. The mechanics are the same on both tracks; only the lane -questions change. - -## What the mechanisms actually cost - -Measured against the Claude Code contract, not guessed: - -- A subagent returns **only its final message** to the parent. Its tool calls, file reads, and - reasoning never enter the parent thread. -- A workflow returns **only the script's return value**. Lane transcripts stay in the run's - transcript directory, and every lane's return value is one line in its `journal.jsonl`. -- Nothing caps a subagent's final message. Prompt instructions are the only lever, and a lane - that is enjoying itself will write two thousand words. A workflow `schema` is the only - enforceable cap, which is why the fan-out phases are workflows. -- A subagent declared `permissionMode: plan` cannot write files, so read-only lanes hand back - digests rather than writing evidence to disk. A subagent declared `background: true` keeps - `Write` and `Edit` but loses the `Workflow` tool, so the implementer can edit and cannot fan - out again. -- `Bash` returns about 30,000 characters inline on success and then saves the rest to a file you - can read. On failure it returns about 10,000 characters as a head-and-tail excerpt **with no - file path**. Build and test failures are therefore the one case where the output is both - largest and least recoverable. That is what this skill's `scripts/verify.sh` exists to fix. - -Sources: `code.claude.com/docs/en/sub-agents`, `/workflows`, `/tools-reference`. - -## Choosing the mechanism - -| Situation | Use | Why | -| --- | --- | --- | -| Several independent questions at once | `Workflow` with a schema | Deterministic fan-out, enforceable digest size, transcripts stay out | -| One narrow follow-up to a finished lane | `SendMessage` to that agent | It still holds everything it read, so the answer costs nothing to re-derive | -| One focused question, no fan-out | `Agent` with a project agent type | Simpler than a workflow, and the agent stays resumable | -| Editing files under a written plan | `implementer` agent, or the main thread | One writer per checkout, always | -| Anything with build or test output | this skill's `scripts/verify.sh` | The log belongs on disk | - -Do not run a workflow inside a lane. Lanes are leaves. - -## The lane digest contract - -`workflows/investigate.mjs` and `workflows/critique.mjs` hold the schemas. The rules the prompts -enforce, and that any hand-written lane prompt should repeat: - -- Every lane reads the same `brief.md`. Never restate the problem in a prompt, and never give a - lane the writer's preferred solution. -- One narrow question per lane. A lane that answers two questions is two lanes. -- An anchor is `Path/To/File.swift:123` plus what is there, and it is only valid for a file the - lane actually opened. Inferred paths are not anchors. -- Confidence is `confirmed` only when a file, an SDK interface, or a measured probe backs it. - Otherwise `inferred`, or `blocked` with what would settle it. -- The digest is a budget, not a summary style. Detail that does not fit stays in the transcript - on purpose. - -## Getting detail back - -In order of cost: - -1. **Open the anchor.** `Read` with `offset` and `limit`, or `grep -n`. Almost always enough. -2. **Ask the lane again.** For an `Agent` lane, `SendMessage` with the one question. The agent - still has its full context. -3. **Read the journal.** A workflow's completion notice names its transcript directory. Each - completed lane is one `{"type":"result"}` line in `journal.jsonl` holding its full return - value. Grep it for a key rather than reading it whole. -4. **Re-run one lane.** Cheapest correct answer when the question changed. Re-running the whole - phase to recover one fact is not. - -Never read `agent-*.jsonl` transcripts directly. They are full conversation logs and reading one -undoes the saving that produced it. - -## Implementer handoff - -Delegate the edit when the blueprint touches more than about three files, when it restructures a -type, or when this run has already been compacted. - -The handoff is the blueprint path and the worktree, nothing else. If the blueprint is not complete -enough to implement from, it is not finished, and fixing that here is cheaper than discovering it -in a diff. Give the implementer: - -- The blueprint path and the run directory, both in the main checkout. -- The worktree path and its branch, with the instruction to write only there. -- The verification steps it must run through `verify.sh --root ` before returning. -- The requirement to return a summary of files changed and verdicts, not a narration of the - edits. You will read the diff. - -Review the returned diff with `git -C diff` in the main thread. That is the writer's -real output, and it is the cheapest complete record of what happened. - -## Parallelism and safety - -- One writer per worktree, and this skill's writer is always in a worktree, never in the main - checkout. Additional writers need their own worktree with disjoint files. -- Reads and analysis run in parallel. Generation, `xcodebuild`, tests, and ABI checks run - serially, one process at a time, for the whole machine and not just this session. -- Reviewers are read-only. A review leader may run read-only evidence lanes and never fixes, - commits, or starts another cross-vendor review. -- One primary external review per change, plus one adversarial pass for high-risk work. The - writer validates and resolves every finding. - -## Cross-vendor review packet - -Give the reviewer observable behavior, acceptance criteria, base reference, diff scope, the -invariants that apply, the verification verdicts already collected, and one focused threat -statement. Leave out your own conclusions about whether the change is correct. Findings come back -ranked P0 to P3 against `.agents/skills/cross-model-review/references/review-rubric.md`. diff --git a/.claude/skills/fix-issue/references/orchestration.md b/.claude/skills/fix-issue/references/orchestration.md new file mode 100644 index 000000000..6bc0e1e82 --- /dev/null +++ b/.claude/skills/fix-issue/references/orchestration.md @@ -0,0 +1,374 @@ +# Orchestration + +The two `Workflow` calls this skill makes: the Phase 1 investigation and the Phase 2 challenge. Both scripts are here in full. Adapt the prompts to the problem, keep the structure. + +The skill's instructions are the opt-in the `Workflow` tool requires, so these need no further user consent. + +## Rules that apply to both scripts + +- **Hardcode the inputs.** Paste the Phase 0 problem statement into the script as a template literal. The `args` parameter has failed to reach the script global before, and a script that silently investigates `undefined` looks like a thorough run that found nothing. +- **Plain JavaScript, not TypeScript.** Type annotations, interfaces and generics fail to parse. +- **No `Date.now()`, `Math.random()`, or argless `new Date()`.** They throw. Vary an agent by its index, not by a random seed. +- **`meta` must be a pure literal.** No variables, calls, spreads, or interpolation inside it. +- **Every claim needs evidence.** `file:line` for code, a doc URL or exact symbol name for platform claims, a named source for competitor behaviour, a reproduction for a collateral finding. `references/research-sources.md` says what counts. +- **Distinguish confirmed from inferred.** An agent that labels its uncertainty is useful. One that sounds certain about everything is a liability, because you will act on it. + +## Investigation script (Phase 1) + +Four investigators run concurrently, then every collateral finding is adversarially verified before it can reach the Phase 6 register. The barrier between the phases is deliberate: verification needs the full finding set so duplicates across investigators collapse first. + +```js +export const meta = { + name: 'fix-issue-investigation', + description: 'Trace a TablePro defect, ground it in platform and competitor evidence, hunt collateral defects', + phases: [ + { title: 'Investigate', detail: 'code path, platform API, competitor UX, collateral defects' }, + { title: 'Verify', detail: 'try to refute each collateral finding' }, + ], +} + +const PROBLEM = ` +PASTE THE PHASE 0 PROBLEM STATEMENT HERE, VERBATIM. +What happens now, what should happen, the smallest reproduction, the reporter's +environment, and any code pointer they gave (marked as a hint, not a fact). +` + +const SUBSYSTEM = `Plugins/DuckDBDriverPlugin/` // the area the fix will land in + +const FINDINGS_SCHEMA = { + type: 'object', + properties: { + findings: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + location: { type: 'string', description: 'file:line' }, + evidence: { type: 'string' }, + failureScenario: { type: 'string', description: 'concrete inputs or state that produce the wrong result' }, + blocksPrimaryFix: { type: 'boolean', description: 'true if the reported fix is unsafe or incomplete without it' }, + }, + required: ['title', 'location', 'evidence', 'failureScenario', 'blocksPrimaryFix'], + }, + }, + }, + required: ['findings'], +} + +const VERDICT_SCHEMA = { + type: 'object', + properties: { + real: { type: 'boolean' }, + reasoning: { type: 'string' }, + howToReproduce: { type: 'string' }, + }, + required: ['real', 'reasoning'], +} + +// The context budget for the three reporting lanes. A schema is the ONLY enforceable cap on +// what a lane sends back: a subagent's final message has no limit, and a lane enjoying itself +// will write two thousand words of narration into the main thread. The maxLength values are +// ceilings, not quotas. Detail that does not fit stays in the lane transcript on purpose, and +// is recoverable from the run's journal.jsonl or by re-asking that lane one narrow question. +const DIGEST_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['verdict', 'confidence', 'anchors', 'unknowns'], + properties: { + verdict: { type: 'string', maxLength: 240, description: 'One sentence answering this lane only.' }, + confidence: { type: 'string', enum: ['confirmed', 'inferred', 'blocked'] }, + rootCause: { + type: ['string', 'null'], + maxLength: 400, + description: 'The mechanism rather than the symptom, or the design conclusion this lane supports. Null when it cannot establish one.', + }, + anchors: { + type: 'array', + maxItems: 8, + description: 'Only files, headers, or SDK interfaces this lane actually opened.', + items: { + type: 'object', + additionalProperties: false, + required: ['ref', 'claim'], + properties: { + ref: { type: 'string', maxLength: 160, description: 'Path/To/File.swift:123, a symbol name, or a doc URL' }, + claim: { type: 'string', maxLength: 200, description: 'What is there and why it matters.' }, + }, + }, + }, + recommendation: { type: 'string', maxLength: 400, description: 'What this lane thinks we should do, if it has a view.' }, + risks: { type: 'array', maxItems: 5, items: { type: 'string', maxLength: 200 } }, + unknowns: { + type: 'array', + maxItems: 5, + description: 'What this lane could not establish, and what would settle it.', + items: { type: 'string', maxLength: 200 }, + }, + tests: { type: 'array', maxItems: 6, items: { type: 'string', maxLength: 200 } }, + }, +} + +const DIGEST_RULES = ` +Return the digest schema and nothing else. The schema is a hard context budget, not a summary +style. Fill a field only when it carries a decision; the array caps are ceilings and an empty +list beats a padded one. An anchor is only valid for something you actually opened, so never +anchor to a path you inferred. Label the lane confirmed only when a file, an SDK interface, a +vendored header, a cited source, or a measured probe backs the verdict; use inferred when the +mechanism is reasoned but unproven, and blocked when you could not establish it. An honest +unknown outranks a confident guess, because a wrong confirmed claim costs a verification cycle +to disprove. Your full reasoning stays in this transcript and can be recovered, so do not pad +the digest to preserve it. +` + +phase('Investigate') + +const [trace, platform, ux, collateral] = await parallel([ + () => agent(` +You are the Codebase Tracer on a TablePro fix investigation. Working dir: the TablePro repo. +Read CLAUDE.md first for architecture context. + +Problem statement: +${PROBLEM} + +Trace the code that produces this behaviour. I need: +1. The exact files, types and functions involved, with file:line references. +2. The real call path: what triggers this, what state flows through it, where the wrong + behaviour originates. If more than one path reaches it (buffered vs streaming, grid vs + export, parameterized vs not), say which one the reported scenario actually takes and + prove it from the dispatch code. +3. Root cause vs symptom. If the current structure cannot express the correct behaviour + cleanly, say so and explain why. +4. Blast radius: the reported symptom is usually one case of a class. How many other inputs, + types, or states hit the same cause? +5. Which TablePro invariants (the Invariants section of CLAUDE.md) this area touches, and + whether the list already records this area breaking before. +6. Existing tests covering this area and the obvious gaps. Say concretely where a real, + non-dead test could live. Watch for tests gated behind '#if canImport(C...)', which + compile to nothing. +7. Whether the fix lands in the app, a bundled plugin, or a registry-only plugin. Name the + target and, for a plugin, its CI tag. + +Change nothing. Say "not confirmed" rather than guessing. +${DIGEST_RULES} + `, { label: 'trace', agentType: 'feature-dev:code-explorer', schema: DIGEST_SCHEMA }), + + () => agent(` +You are the Platform Researcher on a TablePro fix investigation. TablePro is a native macOS +app (SwiftUI + AppKit, macOS 14+) built with the Xcode at /Applications/Xcode-beta.app. + +Problem statement: +${PROBLEM} + +Establish what the correct behaviour and the right API are, from the authoritative source. + +If this is a UI or interaction problem, that source is Apple: +1. The relevant Human Interface Guidelines section, quoted and linked. +2. The right AppKit/SwiftUI API, named exactly, with its documented behaviour, its + availability against our macOS 14 target, and its gotchas. Prefer the modern API; if the + only option is deprecated, say so and name the replacement. +3. Any standard system control that already does this, so we do not reinvent it. +4. Confirm every symbol against the local SDK interface, which is exact for our toolchain: + /Applications/Xcode-beta.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/.framework/Modules/.swiftmodule/arm64e-apple-macos.swiftinterface + +If this is a database driver or dependency problem, that source is the vendored header and +the shipped binary, not the web docs: +1. Find the header (look under Plugins/*/C*/include/ and Libs/) and state which version we + actually link. Build scripts have named a version we never shipped. +2. Grep the header for every symbol in play and quote the doc comments, especially anything + about deprecation or about what a call returns when it cannot do the job. +3. Where behaviour cannot be read off the header, compile a small C probe against the real + Libs/*.a and measure it. Report the measured output verbatim. This outranks any doc page. + +Separate what you confirmed from what you inferred. +${DIGEST_RULES} + `, { label: 'platform', schema: DIGEST_SCHEMA }), + + () => agent(` +You are the Competitor / UX Researcher on a TablePro fix investigation. TablePro is a native +macOS database client positioned as a lightweight alternative to TablePlus. + +Problem statement: +${PROBLEM} + +I need: +1. How TablePlus, DataGrip, Postico and Sequel Ace handle this behaviour, as concretely as + you can from docs, help pages, release notes and issue trackers. Start with TablePlus: + most of our users arrive with its habits and often describe it when they say "how it + should work". +2. The interaction users expect: the control, the keyboard and mouse affordances, the edge + cases these tools handle. +3. What these tools get wrong that we should avoid. Matching TablePlus is not a goal in + itself; where it conflicts with the macOS HIG, say so. +4. A short, concrete recommendation for what TablePro should do, with example strings or + states rather than adjectives. + +Use WebSearch and WebFetch. You cannot run these apps, so rely on their documentation and +credible descriptions. Follow the competitor method in references/research-sources.md, and +anchor every competitor claim to the source you read, marked CONFIRMED or INFERRED. +${DIGEST_RULES} + `, { label: 'ux', schema: DIGEST_SCHEMA }), + + () => agent(` +You are the Collateral Hunter on a TablePro fix investigation. Your job is NOT the reported +bug. It is everything else wrong in the same subsystem, because that subsystem is about to be +edited. What you find gets reported to the user with your evidence, and anything the primary +fix is unsafe or incomplete without ships with it, so set blocksPrimaryFix carefully: true +means the reported fix is wrong or leaves the same class of bug latent unless this lands too. + +Reported problem, for context only: +${PROBLEM} + +Subsystem to audit: ${SUBSYSTEM} + +Look for, with evidence: +- Defects of the same class as the reported one, elsewhere in the same files. +- Paths that silently swallow a failure: an empty catch, a guard that returns the old value, + a fallback that fabricates a plausible-looking result instead of reporting it could not + decode. These are the worst kind, because they look like working software. +- Two hand-maintained lists, switches or tables that must agree and that nothing forces to + agree. +- Work done twice, or a result merged from two separate executions where ordering is not + guaranteed. +- Forks of this logic elsewhere in the repo that have drifted (check TableProMobile/ and any + sibling plugin). +- Build or release scripts in scripts/ that reference this subsystem and are stale. + +Rules: report only what you can evidence with a file:line and a concrete failure scenario. +Style preferences, naming and "I would have written it differently" do not count and will be +discarded. Better to return three real findings than fifteen speculative ones. Return an empty +list if the subsystem is clean; that is a legitimate and useful answer. + `, { label: 'collateral', schema: FINDINGS_SCHEMA }), +]) + +phase('Verify') + +const candidates = (collateral && collateral.findings) || [] +log(`${candidates.length} collateral findings to verify`) + +const verified = await parallel(candidates.map((finding, index) => () => + agent(` +Try to REFUTE this claim about the TablePro codebase. Default to refuted when uncertain. + +Claim: ${finding.title} +Location: ${finding.location} +Evidence given: ${finding.evidence} +Claimed failure: ${finding.failureScenario} + +Read the actual code at that location and the code around it. Then answer: is this real, and +would the described failure actually happen? Check specifically whether something upstream +already prevents it, whether the path is reachable at all in the shipping app, and whether the +claimed behaviour is contradicted by a test or by the dependency's own documented contract. +If measuring settles it, measure it. + +Set real=false unless you can state exactly how to reproduce the failure. + `, { label: `verify:${index + 1}`, phase: 'Verify', schema: VERDICT_SCHEMA }) + .then(verdict => ({ finding: finding, verdict: verdict })) +)) + +const confirmed = verified.filter(Boolean).filter(item => item.verdict && item.verdict.real) +log(`${confirmed.length} of ${candidates.length} collateral findings survived`) + +return { trace: trace, platform: platform, ux: ux, collateral: confirmed } +``` + +Scale the finder pool to the ask. A contained bug needs the four above. "Audit this properly" justifies several collateral hunters on different slices of the subsystem, and a loop that keeps hunting until two consecutive rounds surface nothing new. + +## Challenge script (Phase 2) + +Runs after you have written the blueprint. Three critics on distinct lenses beat three on the same one, because a single lens finds a single class of problem. + +```js +export const meta = { + name: 'fix-issue-challenge', + description: 'Attack a draft TablePro fix blueprint from three independent lenses', + phases: [{ title: 'Critique', detail: 'patterns, scope, refactor-vs-patch' }], +} + +const BLUEPRINT = ` +PASTE THE FULL DRAFT BLUEPRINT HERE, PLUS THE ESTABLISHED FACTS IT RESTS ON, +SO THE CRITICS DO NOT RE-DERIVE THEM OR ARGUE WITH SETTLED MEASUREMENTS. +` + +// The critics used to return free text. A subagent's final message has no length limit, so three +// of them writing essays is exactly the context blowout DIGEST_RULES warns about in the +// investigation script. The schema is the only thing that actually caps it. +const OBJECTIONS_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['lens', 'verdict', 'objections'], + properties: { + lens: { type: 'string', maxLength: 40 }, + verdict: { + type: 'string', + enum: ['sound', 'needs-change', 'wrong-shape'], + description: 'wrong-shape means the blueprint solves the wrong problem or sits at the wrong ownership boundary.', + }, + objections: { + type: 'array', + maxItems: 6, + items: { + type: 'object', + additionalProperties: false, + required: ['severity', 'claim', 'evidence'], + properties: { + severity: { type: 'string', enum: ['blocking', 'material', 'minor'] }, + claim: { type: 'string', maxLength: 240 }, + evidence: { type: 'string', maxLength: 240, description: 'file:line, an SDK symbol, or measured output. Reasoning alone is not evidence.' }, + correction: { type: 'string', maxLength: 240, description: 'The smallest change to the blueprint that answers this.' }, + }, + }, + }, + }, +} + +const LENSES = [ + { + key: 'patterns', + ask: `Where does this design fight existing patterns in the codebase instead of following +them? Cite the pattern with file:line. Does the repo already have a helper, protocol hook or +convention for this that the blueprint reinvents? Does it violate any invariant in CLAUDE.md?`, + }, + { + key: 'scope', + ask: `What scope is missing? Callers that break, files that also need changing, state or +persistence that goes stale, edge cases the design does not mention. Be specific about inputs: +empty, null, duplicated names, sentinel values, very large results, cancellation. Does the +CHANGELOG or docs claim more than the change delivers?`, + }, + { + key: 'decision', + ask: `Is the refactor-vs-patch call right? If this patches a symptom while the underlying +cause survives, say so plainly. If it refactors more than the cause justifies, say that too. +Is there a better-fitting documented API than the one chosen? Name it. Is the rejected +alternative rejected for a real reason or a convenient one?`, + }, +] + +phase('Critique') + +const critiques = await parallel(LENSES.map(lens => () => agent(` +You are reviewing a draft implementation blueprint for a TablePro fix. Read CLAUDE.md, paying +particular attention to the Invariants section. + +Draft blueprint: +${BLUEPRINT} + +Attack it through one lens only: ${lens.key}. + +${lens.ask} + +I want weaknesses, not a summary. If a part of the blueprint is sound, say so in one line and +move on. Verify before you assert: read the files you cite, and measure rather than assume when +the answer depends on a dependency's behaviour. A confident wrong objection costs more than a +missed one, because it will be acted on. Report as structured text with file:line evidence. + `, { label: `critique:${lens.key}`, agentType: 'feature-dev:code-architect', schema: OBJECTIONS_SCHEMA }))) + +return critiques.filter(Boolean) +``` + +## Reading the results + +- Fold what survives into the blueprint. A critic can be wrong; check its file:line before acting on it. +- When a critic and a measurement disagree, the measurement wins. +- A critic finding that is real but outside the reported fix is not a reason to widen the primary PR. It is a new entry in the collateral register, and Phase 6 ships it. diff --git a/.claude/skills/fix-issue/references/quality-bar.md b/.claude/skills/fix-issue/references/quality-bar.md deleted file mode 100644 index 155ebcd03..000000000 --- a/.claude/skills/fix-issue/references/quality-bar.md +++ /dev/null @@ -1,100 +0,0 @@ -# Quality Bar - -## Definition of done - -The change sits at the correct ownership boundary, preserves native behavior and project -invariants, lands with its test, builds, passes targeted verification, survives independent -review, and reports its limitations honestly. A defect is done when the root cause is gone, not -when the symptom is. A feature is done when a user can find it, use it, undo it, and read about -it, not when the happy path compiles. - -## Refactor versus targeted fix - -Refactor when the current shape cannot express correct behavior without a special case, models a -multi-state domain as a boolean, puts ownership in the wrong layer, or leaves the same failure -class in sibling paths. - -Take the targeted fix when the architecture is sound and the defect is local: a wrong comparison, -a missing guard, a stale mapping, an incorrect ordering. Small is good only when it is complete. - -Decide this once, in the blueprint, with the reason written down. Discovering halfway through the -edit that the shape cannot hold the behavior is how a fix becomes a special case. - -## New seam versus existing shape - -The same decision on the change track. Extend the abstraction that already owns this behavior. -Add a seam only when the existing one cannot express the feature without lying about what it -models, and say in the blueprint what it could not express. A parallel system that duplicates an -existing one is the expensive mistake here, because both halves then have to be maintained and -they drift. - -Copy a precedent rather than inventing one. The repository has a shipping example of almost every -kind of surface: a settings pane, a menu command with a shortcut, a sidebar section, a sheet, a -plugin-backed capability. Find the nearest one and follow its structure, including where it puts -persistence and where it registers itself. Departing from it is allowed and has to be argued. - -Ship the smallest version that satisfies the acceptance criteria, and write the non-goals down. -An unwritten non-goal is an invitation for a critic, a reviewer, and the implementer to each -invent a different larger feature. - -Requests are not designs. A reporter asking for a button is describing a need, and the button may -not be the answer. The HIG and the app's existing interaction language decide the surface. - -## Evidence - -- Cite code as `file:line` plus the state transition that reaches it. -- Cite platform behavior with the exact API and authoritative documentation, and check - availability in the installed SDK for the deployment target. -- Verify C and database behavior against the vendored header and the shipped artifact, not the - upstream project's current documentation. -- Measure ambiguous behavior with a minimal probe. -- Treat lane reports, review findings, and competitor descriptions as hypotheses until verified at - the source. Agreement between agents is not evidence. -- Verify at the anchor. Open the cited lines rather than re-reading whole files, and read a file - end to end only when you are about to change its structure. - -## Native UX - -- Prefer documented AppKit, SwiftUI, and system behavior over a hand-rolled approximation. -- Preserve keyboard access, focus, selection, undo, IME, UTF-16 range handling, accessibility, and - the responder chain. -- Use AppKit where the repository deliberately uses it to avoid a known SwiftUI lifecycle or - sizing failure. Check before replacing one with the other. -- Competitor behavior is research input. The HIG and verified user requirements decide the design. - -## Completion checks - -Both tracks: - -- Changelog entry under `[Unreleased]` for user-visible behavior, in the right section: `Added` - for a new capability, `Changed` for altered behavior, `Fixed` for a defect. A fix to a feature - that has not shipped yet folds into that feature's existing entry instead of adding a new one. -- The relevant `docs/` page for a feature, shortcut, setting, external API, or driver behavior. -- Localization through `String(localized:)` with no interpolation inside a key. -- Unit coverage, and deterministic UI automation where the flow allows it. -- Project regeneration after any source or configuration change. -- Build, targeted tests, strict lint, and the plugin or ABI checks the change requires. -- Independent other-vendor review for high-risk changes. -- A diff read end to end, preserving unrelated work already in the tree. - -A new user-visible feature also needs: - -- Discoverability: the menu item, keyboard shortcut, or entry point a user reaches it by, placed - where comparable commands already live. -- An empty state, an error state, and cancellation for anything that can take time or fail. -- Settings defaults chosen for existing users, plus whatever migration keeps their stored state - valid. A new key that silently changes behavior on upgrade is a regression. -- Undo, or an explicit note in the blueprint that the action is not undoable and why that is safe. -- A decision about the iOS target, even when the decision is that it does not apply. -- For a new database type: the string-backed `DatabaseType` stays open, unknown types round-trip, - every switch keeps a fallback, and the registry-only build and ABI checks run. - -## Collateral findings - -Fold a finding into this change only when it is required for the correctness, safety, or -verification of the requested behavior. Everything else verified goes into the register with its -evidence, and the register is the follow-up queue: a qualifying finding ships as its own pull -request after the primary one, never as an unannounced addition to this diff. Mixing them makes -the diff hard to review and impossible to revert cleanly. - -The bar for entering the queue is the evidence bar above. A hunch is not a queue item. diff --git a/.claude/skills/fix-issue/references/research-sources.md b/.claude/skills/fix-issue/references/research-sources.md index bf8bde23d..99df0b9e5 100644 --- a/.claude/skills/fix-issue/references/research-sources.md +++ b/.claude/skills/fix-issue/references/research-sources.md @@ -1,20 +1,18 @@ # Research Sources -Where the platform lane looks and which tools it uses. The aim is to ground the fix in documented -platform behavior, not in guesswork. Read this in the lane, not in the main thread. +Where the investigators look and which tools they use. The aim is to ground the fix in documented platform behaviour and established UX, not in guesswork. ## Tools -No MCP server is configured in this repository. Everything below is a built-in tool or a file on -disk. +There are no MCP servers configured in this repo. Everything below is a built-in tool or a file on disk. | Tool | Use for | | --- | --- | | `Grep` over the SDK `.swiftinterface` files | Confirming a symbol exists, its exact signature, and its `@available` annotations, for the toolchain we actually build with. Authoritative and offline. Path below. | -| `LSP` | Symbol definitions, references, and hover types inside the repository. Faster and more exact than grep for "who calls this". | -| `WebSearch` | Finding the right HIG page, Apple sample code, WWDC session notes, competitor docs. | -| `WebFetch` | Reading a specific page once you have the URL. | -| `Read` over `docs/` | TablePro's own shipped documentation, so a fix does not contradict what users have been told. | +| `WebSearch` | Finding the right HIG page, Apple sample code, competitor docs, WWDC session notes. | +| `WebFetch` | Reading a specific Apple doc or competitor help page once you have the URL. | +| `LSP` | Symbol definitions, references, and hover types inside the repo, when the session exposes the tool. It is not always present, so check before planning around it and fall back to `grep` for "who calls this". | +| `Read` over `docs/` | TablePro's own shipped documentation (Mintlify source, in-repo). Check it so a fix does not contradict what users have been told. | ### The local SDK interface files @@ -22,47 +20,43 @@ disk. /Applications/Xcode-beta.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks/.framework/Modules/.swiftmodule/arm64e-apple-macos.swiftinterface ``` -`AppKit`, `SwiftUI`, `Foundation`, and the rest are all there. This is ground truth for "does this -API exist and what is its signature", because it is the interface the compiler reads. Web docs -describe intent; the interface file settles facts. Use both: the interface for the signature, the -docs for the behavior. Xcode-beta is the only Xcode installed. - -## Apple documentation - -- **Human Interface Guidelines**: `https://developer.apple.com/design/human-interface-guidelines`. - The macOS sections on windows, panels, sheets, toolbars, sidebars, menus, tables and lists, and - selection are the usual ones for a database client. Quote the specific guideline. "The HIG says - so" without a citation is not evidence. -- **AppKit**: `https://developer.apple.com/documentation/appkit`. Native windows, sheets, - `NSToolbar`, `NSTableView` and `NSOutlineView`, `NSWindow` tabbing, the responder chain, menus, - `NSViewController`. -- **SwiftUI**: `https://developer.apple.com/documentation/swiftui`. TablePro is SwiftUI first with - AppKit where SwiftUI falls short. Check whether a native SwiftUI modifier already does the job - before dropping to AppKit, and check the reverse too: several TablePro views are AppKit - precisely because the SwiftUI equivalent misbehaves. `AGENTS.md` and the project guide record - those constraints. -- **Deprecations matter.** Name the modern API. If the only documented option is deprecated, say - so and name the replacement. -- **Availability matters.** TablePro targets macOS 14. An API introduced later needs an - `if #available` branch, and the blueprint has to say what the fallback is. - -## Competitor behavior - -Native macOS database clients worth checking when the question is "what should this feel like": -TablePlus, DataGrip, Postico, and Sequel Ace, whose source is inspectable. An issue reporter -describing how something "should" work is often describing the client they came from. - -Matching another client is not a goal on its own. Where a competitor and the HIG disagree, the HIG -wins and the blueprint says why. You cannot run these apps from here, so rely on their -documentation and changelogs, and label confirmed behavior separately from inference. +`AppKit`, `SwiftUI`, `Foundation`, and the rest are all there. This is the ground truth for "does this API exist and what is its signature", because it is the interface the compiler will read. Web docs describe intent; the interface file settles facts. Use both: the interface for the signature, the docs for the behaviour. + +## Apple documentation map + +- **Human Interface Guidelines**: `https://developer.apple.com/design/human-interface-guidelines`. The macOS sections on windows, panels, sheets, toolbars, sidebars, menus, tables and lists, and selection are the usual ones for a database client. Quote the specific guideline. "The HIG says so" without a citation is not evidence. +- **AppKit**: `https://developer.apple.com/documentation/appkit`. Native windows, sheets, `NSToolbar`, `NSTableView` and `NSOutlineView`, `NSWindow` tabbing, the responder chain, menus, `NSViewController`. +- **SwiftUI**: `https://developer.apple.com/documentation/swiftui`. TablePro is SwiftUI-first with AppKit where SwiftUI falls short. Check whether a native SwiftUI modifier already does the job before dropping to AppKit, and check the reverse too: several TablePro views are AppKit precisely because the SwiftUI equivalent misbehaves, and `CLAUDE.md` records why. +- **Deprecations matter.** Name the modern API. If the only documented option is deprecated, say so and note the replacement. +- **Availability matters.** TablePro targets macOS 14. An API introduced in 15 or 26 needs an `if #available` branch and a fallback, and the blueprint has to say what the fallback is. + +## Competitor apps + +An issue reporter describing how something "should" work is usually describing the client they came from. Finding that client and reading how it actually behaves turns a vague request into a concrete specification, and it is often the fastest way to see the edge cases the reporter did not mention. + +| Client | Why it is worth reading | Where | +| --- | --- | --- | +| **TablePlus** | The closest comparison, and where most of our users arrive from. Check it first. | `tableplus.com/changelog`, `docs.tableplus.com` | +| **Sequel Ace** | Open source, so behaviour can be read rather than inferred. Best source for MySQL-specific interaction detail. | `github.com/Sequel-Ace/Sequel-Ace` | +| **Postico** | Strongly native and opinionated about macOS conventions. Good when the question is what the HIG-correct version of a surface looks like. | `eggerapps.at/postico` | +| **DataGrip** | Deepest SQL tooling: completion, refactoring, diagrams, introspection. Not native macOS, so take the capability and not the interaction. | `jetbrains.com/datagrip`, their release blog | +| **Beekeeper Studio** | Open source, so its implementation of a feature is readable. | `github.com/beekeeper-studio/beekeeper-studio` | +| **DBeaver** | Broadest driver and dialect coverage. Useful for "how does anyone handle this database's quirk". | `github.com/dbeaver/dbeaver` | + +The method: + +1. Name the surface in the words a competitor would use, then search their docs and changelog for it. The changelog is often better than the docs, because it says when and why the behaviour changed. +2. For the open-source clients, read the code. That is observed behaviour, not inference, and it is the only way to be sure about an edge case. +3. Write down what each one does in one line, then say where they agree. Convergence across three clients is a strong signal about what users will expect. +4. Note what they get wrong, or what their users complain about. A competitor's shipped behaviour is not automatically correct, and their issue trackers are public. +5. Label every finding CONFIRMED or INFERRED. You cannot run these apps from here, so anything not read in source or stated in their documentation is inference. + +Matching TablePlus is not the goal on its own, and neither is differing from it. Where a competitor and the HIG disagree, the HIG wins and the blueprint says why. Keep the findings in the blueprint and the PR body; this repository deliberately carries no competitive comparison content in `docs/`. ## What good evidence looks like -- Code: `Path/To/File.swift:123` plus one line on what is there. -- Platform: a doc URL or exact symbol name with the rule quoted, plus the `.swiftinterface` line - when the question is whether an API exists. -- Measured: the probe you built, the command you ran, and its output. +- Code: `Path/To/File.swift:123` plus a one-line note on what is there. +- Platform: a doc URL or exact symbol name (`NSWindow.toggleToolbarShown`, the HIG "Sheets" section), with the relevant rule quoted, and the `.swiftinterface` line when the question is whether an API exists. +- Competitor: the source (docs page, release note) and whether it is confirmed or inferred. -Thin or missing evidence is fine to report as long as it is labeled. A confident wrong claim is -worse than an honest "could not confirm", because the writer spends a verification cycle -disproving it. +Thin or missing evidence is fine to report as long as it is labelled. A confident wrong claim is worse than an honest "could not confirm". diff --git a/.claude/skills/fix-issue/references/shipping.md b/.claude/skills/fix-issue/references/shipping.md deleted file mode 100644 index 89187c311..000000000 --- a/.claude/skills/fix-issue/references/shipping.md +++ /dev/null @@ -1,177 +0,0 @@ -# Shipping and the Follow-up Queue - -This skill carries a standing authorization the rest of the repository does not: a run that -finishes clean branches, commits, pushes, and opens a pull request on its own, then works its -follow-up queue to empty without stopping to ask. That authorization is narrow. It covers exactly -those actions, only inside a `$fix-issue` run, and only when every gate below passes. - -It never covers merging, tagging, publishing, releasing, force pushing, editing another branch, -rewriting history, or touching anything a peer session owns. - -## Why the gates are strict - -This checkout is shared. Other sessions edit files, create and revert them mid-run, and hold -branches for their own in-flight pull requests. Automation that stages broadly, or that assumes -the current branch is yours, does damage that is hard to undo and easy to miss. A chained commit -and push in this repository once went straight to `main` after a squash merge moved the checkout. -Every rule below exists because of something that already happened. - -## Everything happens in the worktree - -The run created `$WT` at intake with this skill's `scripts/worktree.sh`, which also created the -branch. The main checkout is never edited, never carries this branch, and is never committed to. -Every command below takes the worktree explicitly: - -```bash -git -C "$WT" … -gh pr create … # run with $WT as the working directory -verify.sh --root "$WT" --run .analysis/ … -``` - -Use absolute paths. The shell's working directory resets to the main checkout on its own, and a -relative path then commits, builds, or tests the wrong tree. The symptoms look like your own bug: -a test filter that matches nothing, or a regenerated project that does not contain your file. - -One branch per pull request, and never reuse one across two. Do not stack a follow-up on the -primary pull request unless it genuinely depends on it, in which case the body says so. - -## Gate: may this run ship at all - -Stop and report instead of shipping when any of these is true: - -- A verification step is `FAIL`, or `INCONCLUSIVE` and never rerun to a `PASS`. -- `git -C "$WT" branch --show-current` is not the branch this run created. -- A file is dirty inside `$WT` that the blueprint does not list. A fresh worktree starts clean, so - anything unexpected there is an edit nobody planned. -- The blueprint's completion checks are not all satisfied: test, changelog, docs, localization. -- The change is high risk and independent review has not run or has unresolved P0 to P2 findings. - -A stop is a normal outcome. Report what blocked shipping, leave the worktree in place, and say -what would unblock it. - -## Stage - -Stage the blueprint's file list by explicit path. Never `git add -A`, never `git add .`, never -`git add -u`. - -```bash -git -C "$WT" add … -git -C "$WT" status --short -``` - -Read that `status --short` output before committing. Anything staged that the blueprint does not -list comes back out with `git -C "$WT" restore --staged `. - -Leave `TablePro/Resources/Localizable.xcstrings` out unless your change is the reason it moved. -It is shared, it is frequently dirty from other work, and new keys fall back to the English key -until a build regenerates it. - -## Before the commit - -Run these and act on what they say. They are in `verification.md` too, because they matter whether -or not shipping is automatic. - -```bash -grep -n '^## \[' "$WT/CHANGELOG.md" -git -C "$WT" diff --cached -U0 | grep -nE '—|seamless|robust|comprehensive|intuitive|effortless|streamlined|leverage|elevate|delve|utilize|facilitate' -``` - -The first confirms an `Edit` did not swallow a released version heading and fold that release into -`[Unreleased]`. The second catches writing-style violations on added lines. Rewrite every hit that -lands on a line you added. - -Then `Skill(code-review)` over the staged diff, and fix what it finds on your own lines. - -## Commit - -One atomic commit, one-line Conventional Commit subject, canonical scope, matching the style of -`git log --oneline`. - -``` -fix(sidebar): keep the database switcher list through a refresh -feat(editor): save a query for reuse from the command menu -``` - -Check the branch in the same message as the commit, never several turns earlier: - -```bash -git -C "$WT" branch --show-current -git -C "$WT" commit -m "fix(scope): …" -``` - -## Push, as its own call - -Never chain commit and push. If SSH fails, port 22 is blocked here: - -```bash -git -C "$WT" push -u origin -git -C "$WT" -c credential.helper='!gh auth git-credential' push -u https://github.com/TableProApp/TablePro.git -``` - -## Pull request - -Write the body to a file first, so it can be checked before it is sent, then run the same -writing-style grep over that file. - -```bash -cd "$WT" && gh pr create --title "" \ - --body-file "
/.analysis//pr-body.md" -``` - -`gh` reads the repository from the working directory, so run it inside the worktree. The body file -lives in the run directory in the main checkout, which is why that path is absolute. - -The body carries: what the user sees now, the root cause on a defect or the design decision and -its non-goals on a change, the files and ownership boundaries touched, every verification verdict -with its result, the independent review outcome, known limitations and anything not verified, and -`Closes #` when the run started from an issue. - -Never `--web`, never auto-merge, never merge, never tag, never release. The pull request is where -your authorization ends. - -## The follow-up queue - -Findings recorded in the blueprint's collateral register become their own pull requests, one at a -time, after the primary pull request is open. - -A finding enters the queue only if it clears the same bar it always had: confirmed at `file:line`, -with a reachable failure scenario, and evidence that no upstream guard already prevents it. Drop -speculation, unreachable code, style preferences, and anything that is a product decision for the -user to make. Re-verify the finding against the tree before building it, because the primary fix -may have already resolved it. - -Order by severity, then by independence. Take one at a time and never hold two branches at once. - -### Each follow-up gets its own worktree - -Same as the primary work: its own worktree, its own branch, created together. - -```bash -.claude/skills/fix-issue/scripts/worktree.sh fix/ -``` - -That links `Secrets.xcconfig`, `Libs/*.a`, `Libs/dylibs`, and `Libs/ios`, without which the -worktree cannot build. Remove the previous worktree before creating the next one, so only one -exists at a time and the machine is not carrying a dozen half-finished trees. - -Run `verify.sh generate` inside a new worktree before its first build. It has its own generated -Xcode project, and XcodeGen globs sources at generation time. - -### Each follow-up gets the whole playbook - -A collateral fix nobody asked for is the one most likely to be judged on its rigour, so shipping -it unverified is worse than not shipping it. Each one gets its own brief, blueprint, regression -test, changelog entry, documentation update, verification run, review at its risk level, and pull -request. No batching, no "small enough to skip the test". - -### Stopping the queue - -Work the queue to empty without asking. Stop it, and report, when: - -- A finding turns out to need a product decision. -- A finding's verification fails twice for a reason that is not environmental. -- A finding is no longer reachable, in which case say so and drop it. -- A finding is large enough to be its own blueprint-level design rather than a fix. - -Report after each item: what shipped, its pull request number, and what remains queued. Remove the -worktree when its pull request is open. diff --git a/.claude/skills/fix-issue/references/verification.md b/.claude/skills/fix-issue/references/verification.md index e34ac3290..49638bbd2 100644 --- a/.claude/skills/fix-issue/references/verification.md +++ b/.claude/skills/fix-issue/references/verification.md @@ -1,214 +1,161 @@ # Verification -You build, test, and lint the change yourself. "Ready to build, tell me if it fails" is not a -handoff, it is an unverified change. +How to prove a fix works before handing it back. You build, test, and lint it yourself. Reporting "ready to build, tell me if it fails" is not a handoff, it is an unverified change. -Read the section you need. The map: +## Run it through the wrapper + +```bash +.claude/skills/fix-issue/scripts/verify.sh [--root ] [--run ] [args] +``` -| Section | Read when | -| --- | --- | -| Run it through the wrapper | Always. It is the whole interface for the main thread. | -| Reading a verdict | A step came back `FAIL` or `INCONCLUSIVE`. | -| Regenerate before building | You added, moved, or deleted a file, or edited project config. | -| Environment | The wrapper is unavailable, or a step fails before compiling. | -| UI tests | The change touches a user flow or `TableProUITests`. | -| Before the commit | The user asked for a commit or a pull request. | +Steps: `generate`, `build [Scheme]`, `plugins`, `test …`, `uitest …`, `abi `, `lint …`, plus `parse ` and `tail [n]` to re-read a stored log without rerunning. Options: `--offline` when the network is down, `--no-wait` to skip the concurrency wait. Exit codes are `0` pass, `1` fail, `2` inconclusive, `3` usage. -## Run it through the wrapper +It does the four things that keep a verification honest and out of the conversation. It exports `DEVELOPER_DIR` so the commands work at all. It resolves the project explicitly, so a shell whose working directory drifted cannot build a different checkout while your test filter matches nothing. It waits for any other `xcodebuild` on the machine rather than wedging the XCTest host. And it writes the full log to disk while printing at most about thirty lines: status, the real errors, test counts, and whether a failure is already-known. + +That last point is the reason it exists. A `Bash` call returns about 30,000 characters inline on success and saves the rest to a file, but on failure it returns about 10,000 characters as a head-and-tail excerpt **with no file path**. Build and test failures are the one case where the output is both largest and least recoverable, so a raw `xcodebuild` failure loses the log exactly when you need it. + +`INCONCLUSIVE` means the environment failed, not the change, and the wrapper names the cause. Never record it as a pass, and never begin debugging your own code on one. The worst signature is a locked build database in a test run: every case reports `failed` at `0.000 seconds`, which reads exactly like a mass regression. + +A `FAIL` on a test step is cross-referenced before it reaches you, against `.github/macos-test-quarantine.txt`, the UI quarantine, and the known environment failures on this machine. Only unexplained suites are reported, and a run whose failures are all already red comes back as a pass with the count of muted suites. + +Everything below is the underlying detail: read it when a verdict needs interpreting, or when you have to run a command the wrapper does not cover. + +## Environment setup + +`xcode-select` points at `/Library/Developer/CommandLineTools`, which has no `xcodebuild` and no `sourcekitd`. Both `xcodebuild` and `swiftlint` fail without the export below, which reads as "local builds are broken" and leads to shipping unverified code. They are not broken. ```bash -.claude/skills/fix-issue/scripts/verify.sh --root "$WT" --run .analysis/ +export DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer ``` -`--root` picks the tree to build, which for a `$fix-issue` run is always the run's worktree rather -than the main checkout. `--run` picks where the logs go, which is the run directory in the main -checkout so the evidence outlives the worktree. A new worktree needs `generate` before its first -build; it has its own generated project. - -| Step | Runs | -| --- | --- | -| `generate` | `scripts/generate-project.sh` | -| `build [Scheme]` | Debug build, default scheme `TablePro` | -| `plugins` | the `AllPlugins` aggregate | -| `test …` | `TableProTests`, filtered by suite | -| `uitest …` | `TableProUITests`, filtered by suite | -| `abi ` | `scripts/check-pluginkit-abi.sh` | -| `lint …` | `swiftlint lint --strict` | -| `parse ` | re-reads a stored log and prints its verdict again | -| `tail [n]` | last n lines of a stored log | - -It exports `DEVELOPER_DIR`, resolves the project explicitly so a drifting shell cannot build the -wrong checkout, waits for any other `xcodebuild` on the machine, writes the full log under the run -directory, and prints at most about thirty lines. Options: `--offline` when the network is down, -`--no-wait` to skip the concurrency wait. - -Exit codes are `0` pass, `1` fail, `2` inconclusive. - -Run steps serially. Two `xcodebuild` processes at once wedge the XCTest host, and recovery seems -to need a logout. - -## Reading a verdict - -`INCONCLUSIVE` means the environment failed, not the change. The wrapper names the cause. Never -record it as a pass, and never start debugging your own code on one: - -- **Build database is locked.** Another build holds it. The signature is vicious in a test run: - every case reports `failed` at `0.000 seconds`, which reads exactly like a mass regression. -- **The Swift frontend produced no further output.** A flaky compiler crash on this machine that - cascades into bogus `cannot find type X in scope` errors. Rerun before believing any of them. - If it repeats, check whether the file declaring the symbol is untracked and therefore absent - from the generated project. -- **Could not resolve package dependencies.** SwiftPM tried the network. Rerun with `--offline`, - which pins `-disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile` to the - revisions already in `Package.resolved`. -- **Unable to open base configuration reference file.** `Secrets.xcconfig` is missing. A fresh - worktree needs it, plus `Libs/*.a`, `Libs/dylibs`, and `Libs/ios`, symlinked from the main - checkout before anything builds. -- **Zero cases executed.** Either the host wedged, or the filter matched nothing. A - `-only-testing` filter naming a Swift Testing `@Test` function silently matches nothing and - still prints `TEST SUCCEEDED`. Filter by suite. - -`FAIL` on a test step is cross-referenced before it reaches you. The wrapper reads -`.github/macos-test-quarantine.txt`, the UI quarantine, and the known environment failures on this -machine, and reports only the suites that are unexplained. A run whose failures are all already -red comes back as a pass with the count of muted suites. If you need to know why a suite is -muted, read the quarantine file: it documents each one. - -The baseline is not green. CI skips the quarantined suites and a local run does not, so a full -`xcodebuild test` is red on unmodified `main`. Never run the whole target as a gate. +`/Applications/Xcode-beta.app` is the only Xcode installed. Export it once per shell command chain, or prefix each invocation. ## Regenerate before building -`TablePro.xcodeproj` is generated by XcodeGen from `project.yml` and is gitignored. XcodeGen globs -sources at generation time, so a new `.swift` file is **not compiled** until you regenerate. Run -`verify.sh generate` after adding, moving, or deleting a source file, and after editing -`project.yml` or anything in `Configs/`. +`TablePro.xcodeproj` is generated by XcodeGen from `project.yml` and is gitignored. XcodeGen globs sources at generation time, so a new `.swift` file is **not compiled** until you regenerate: -The failure mode is misleading: the build does not say "file not in target", it reports -`cannot find 'X' in scope` from the callers, as if the code were never written. +```bash +scripts/generate-project.sh +``` -Build the `plugins` aggregate when the change touched a registry-only plugin. The `TablePro` -scheme depends only on the bundled plugins, and nothing in PR CI compiles the registry-only ones -(MongoDB, Oracle, DuckDB, MSSQL, Cassandra, Etcd, CloudflareD1, DynamoDB, BigQuery, LibSQL, -Snowflake, Elasticsearch, Beancount, SurrealDB, Teradata, Trino). A hard compile error in one of -those still produces `BUILD SUCCEEDED` under the app scheme, and green CI proves nothing about it. +Run it after adding, moving, or deleting any source file, and after editing `project.yml` or anything in `Configs/`. The failure mode is misleading: the build does not say "file not in target", it reports `cannot find 'X' in scope` from the callers, as if the code were never written. If a symbol you just added is not found and the code is obviously correct, regenerate before debugging anything else. -`scripts/check-pluginkit-abi.sh` has no CI wiring at all. It is manual only, despite guarding the -hazard behind two registry-wide plugin outages. Run `abi` yourself for any shared plugin API change. +## Build -## Environment +```bash +xcodebuild -project TablePro.xcodeproj -scheme TablePro -configuration Debug build -skipPackagePluginValidation +``` -Only needed when running a command outside the wrapper. +**The `TablePro` scheme only builds the 14 bundled plugins**, so a hard compile error in one of the 17 registry-only ones still produces `BUILD SUCCEEDED` under the app scheme. Build the aggregate when you want that answer locally before pushing. -`xcode-select` points at `/Library/Developer/CommandLineTools`, which has no `xcodebuild` and no -`sourcekitd`. Both `xcodebuild` and `swiftlint` fail without: +PR CI does cover it, contrary to what this file used to say: the `Compile every plugin` step in the `app-tests` job of `.github/workflows/macos-tests.yml` builds `-scheme AllPlugins` whenever the change touches `Plugins/` or another watched path. That step landed on 2026-08-11 in #2091. What CI still does not exercise is plugin packaging, signing and notarization, which run only from `build-plugin.yml` on a release tag, and `scripts/check-pluginkit-abi.sh`, which has no CI wiring at all. ```bash -export DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer +xcodebuild -project TablePro.xcodeproj -scheme AllPlugins -configuration Debug build -skipPackagePluginValidation +``` + +**Never run two `xcodebuild` invocations at once.** Overlapping runs appear to wedge the XCTest host, and recovery seems to need a logout. + +**`Could not resolve package dependencies` means SwiftPM tried the network.** Rerun through the wrapper with `--offline`, which pins `-disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile` to the revisions already in `Package.resolved`. It is an environment failure, not a change failure. + +**`Unable to open base configuration reference file` means `Secrets.xcconfig` is missing**, which is the normal state of a fresh worktree. A worktree also needs `Libs/*.a`, `Libs/dylibs`, and `Libs/ios`. `.claude/skills/fix-issue/scripts/worktree.sh` symlinks all four when it creates one; if the tree came from somewhere else, link them by hand before blaming the toolchain. + +**`scripts/check-pluginkit-abi.sh` has no CI wiring at all.** It is manual only, despite guarding the hazard behind two registry-wide plugin outages. Run `verify.sh abi ` yourself for any shared plugin API change; nothing else will. + +**Ignore SourceKit diagnostics in this repo.** They routinely report `Cannot find type 'DatabaseManager'` for long-standing module-internal types and `No such module 'TablePro'` in test files. A real `xcodebuild` run is the only trustworthy signal. + +**Expect `MemberImportVisibility` errors on new files** that use `Combine` (`PassthroughSubject.send()`) or `TableProPluginKit` members. They need an explicit `import`; the error is real and the fix is the import line. + +## Test + +Run the suites you touched and their neighbours. Never run the whole target. + +**The baseline is not green, so the whole target can never be a gate.** CI skips the quarantined suites and a local run does not, which means `xcodebuild test` with no filter is red on unmodified `main`. A run that reports failures without a filter has told you nothing about your change. + +```bash +xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation \ + -only-testing:TableProTests/ ``` -That reads as "local builds are broken" and leads to shipping unverified code. They are not broken. +Three rules for reading the result: -Other sessions and Xcode share this checkout and its DerivedData. Before building by hand, check -`pgrep -f "Developer/usr/bin/xcodebuild"` and wait rather than passing a separate -`-derivedDataPath`, which forces a cold build of thirty plugin targets. +1. **Check the executed-test count before believing `TEST FAILED`.** `Testing failed: TablePro (NNNN) encountered an error (The test runner hung before establishing connection.)` with **zero** executed cases is a wedged host, not a test failure. It reproduces on unmodified `main`. Confirm with `grep -c '^Test case.*passed'` on the output. +2. **A per-function `-only-testing` filter silently matches nothing for Swift Testing `@Test` functions** and still prints `TEST SUCCEEDED`. Filter by suite, then confirm the case actually ran. +3. **Cross-reference `.github/macos-test-quarantine.txt` before blaming yourself.** CI skips those ~40 suites; a local run does not, so they all fail. Six more fail on this machine for environment reasons and are not quarantined: `StatusBarSnapshotTests` (the machine locale is `en_VN`, whose grouping separator is `.`), `RowOperationsManagerBinaryCopyTests` (`NSPasteboard.general`), `AWSSSOFetchTests` and `SSEEventStreamTests` (live network), `CopilotIdleStopControllerTests` (wall-clock timers). -The shell's working directory does not reliably persist across calls, and it resets to the repo -root after any command that leaves it. In a worktree, always pass absolute paths and use -`git -C `; a relative `-project TablePro.xcodeproj` silently builds the main checkout while -your test filter matches nothing. +To decide whether an unexpected failing suite is yours, grep its file for the symbols you changed. Zero references plus a known environment mechanism is a faster and more reliable answer than rebuilding another ref. If you truly need a baseline, `git stash push -u`, re-run the single suite, then `git stash pop`. -Treat SourceKit diagnostics as noise here. They routinely report `Cannot find type -'DatabaseManager'` for long-standing module-internal types and `No such module 'TablePro'` in test -files. A real `xcodebuild` run is the only trustworthy signal. +Run tests early rather than saving them for the end, so a wedged host does not block the handoff. -Expect `MemberImportVisibility` errors on new files that use `Combine` -(`PassthroughSubject.send()`) or `TableProPluginKit` members. They need an explicit `import`; the -error is real and the fix is the import line. +**A collateral finding that ships gets this whole playbook too.** Only a finding the primary fix is unsafe or incomplete without ships at all, and code nobody asked for is the most likely to be judged on its rigour, so shipping it unverified is worse than not shipping it. Build, test the touched suites, lint, and give it its own CHANGELOG entry. Everything else in the register is reported rather than built, which needs no verification run but does need the `file:line` and the failure scenario. ## UI tests -`TableProUITests` is a separate target and a separate gate. A change to a user flow needs UI -automation where the flow runs deterministically. If it cannot, say why. - -**`UITestCase` is the only supported way to launch the app.** Never write `XCUIApplication()` or -`: XCTestCase` in a file under `TableProUITests/` outside `Support/UITestCase.swift`. A unit test -scans the target's sources and fails the build gate if you do, because storage isolation depends -on the launch path: the app is fail-closed on `TABLEPRO_UI_TEST_SANDBOX`, and a suite that -launches the app directly drives the user's real store. - -**The app under test cannot detect XCUITest.** Measured: no `XCTestConfigurationFilePath`, no -`XCTestSessionIdentifier`, no `XCTestBundlePath`, and no XCTest `DYLD_INSERT_LIBRARIES` reach it. -Nothing in the app can gate on "am I under test" except the variables `UITestCase` sets. - -**The accessibility tree differs between this machine and the CI runner.** Tests can pass locally -and fail deterministically on the runner, on both the first run and the retry. Confirmed from a -dumped tree, so nobody has to re-derive them: - -- A contextual `NSMenu` carries its identifier locally and not on the runner. Never match a - contextual menu by identifier, and never use `app.menus.firstMatch`, which is the first menu-bar - menu. Use `app.windows.firstMatch.menus.firstMatch`. -- Quick Switcher scope chips and result rows are buttons, not static text. Only the group header - and the empty-state line are static text. -- The window holds more than one `NSTableView`, so `tables.firstMatch` is ambiguous. The data grid - is identifier `data-grid`. -- `.accessibilityIdentifier("sql-editor-textview")` sits on the SwiftUI wrapper and does not reach - the text view. Matchers keyed on it find nothing. +`TableProUITests` is a separate target and a separate gate. A change to a user flow needs UI automation where the flow runs deterministically; if it cannot, say why in the PR description. + +```bash +xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation \ + -only-testing:TableProUITests/ +``` + +**`UITestCase` is the only supported way to launch the app.** Never write `XCUIApplication()` or `: XCTestCase` in a file under `TableProUITests/` outside `Support/UITestCase.swift`. A unit test scans the target's sources and fails the build gate if you do, because storage isolation depends on the launch path: the app is fail-closed on `TABLEPRO_UI_TEST_SANDBOX`, and a suite that launches the app directly drives the user's real store. That guard has already caught one suite that arrived from `main`. + +**The app under test cannot detect XCUITest.** Measured: no `XCTestConfigurationFilePath`, no `XCTestSessionIdentifier`, no `XCTestBundlePath`, and no XCTest `DYLD_INSERT_LIBRARIES` reach it. Nothing in the app can gate on "am I under test" except the variables `UITestCase` sets. Do not try. + +**The accessibility tree differs between this machine and the CI runner** (local is Darwin 27, CI is `macos-26`). Tests can pass locally and fail deterministically on both the first run and the retry. Facts already confirmed from a dumped tree, so nobody has to re-derive them: + +- A contextual `NSMenu` carries its identifier locally and **not** on the runner. Never match a contextual menu by identifier, and never use `app.menus.firstMatch` (that is the first menu-bar menu). Use `app.windows.firstMatch.menus.firstMatch`. +- Quick Switcher scope chips and result rows are **buttons**, not static text. Only the group header and the empty-state line are static text. +- The window holds more than one `NSTableView`, so `tables.firstMatch` is ambiguous. The data grid is identifier `data-grid`. +- `.accessibilityIdentifier("sql-editor-textview")` sits on the SwiftUI wrapper and does not reach the text view. Matchers keyed on it find nothing. - The Chinook sample has no `users` table. Use `Track`. -- "Open Sample Database" lives only in the Help menu. +- "Open Sample Database" lives only in the **Help** menu. -**Never put `.accessibilityIdentifier` on a SwiftUI container by itself.** It replaces the -identifier of every descendant control in the same hosting tree, so a whole panel's buttons, -search field, and popups all report the container's identifier and their own is gone. The symptom -is a test that cannot find a control you can plainly see, and the obvious diagnoses (wrong query, -wrong element type, timing) are all wrong. Children behind an `NSViewRepresentable` boundary keep -their own identifiers, since that is a separate hosting tree. +**Never put `.accessibilityIdentifier` on a SwiftUI container by itself.** It replaces the identifier of every descendant control in the same hosting tree, so a whole panel's buttons, search field, and popups all report the container's identifier and their own is gone. The symptom is a test that cannot find a control you can plainly see, and the obvious diagnoses (wrong query, wrong element type, timing) are all wrong. Children behind an `NSViewRepresentable` boundary keep their own identifiers, since that is a separate hosting tree. -When a container genuinely needs its own identifier, pair it with -`.accessibilityElement(children: .contain)`, which makes the view an accessibility container -instead of one merged element. Measured on the query history detail pane: before, all three action -buttons reported `query-history-detail`; after, they reported their own identifiers with the -container still addressable. Order matters, `children: .contain` before the identifier. +When a container genuinely needs its own identifier, pair it with `.accessibilityElement(children: .contain)`, which makes the view an accessibility container instead of one merged element, so every leaf keeps its identifier and the container keeps its own. Measured on the query history detail pane from a dumped tree: before, all three action buttons reported `query-history-detail`; after, they reported `query-history-copy`, `query-history-run-in-new-tab` and `query-history-load-in-editor` with the container still addressable. Order matters, `children: .contain` before the identifier. -Diagnose by dumping the real tree, not by guessing: -`print(app.windows.firstMatch.debugDescription)` inside a `UITestCase`, run the suite, read the log. +**Diagnose by dumping the real tree, not by guessing:** `print(app.windows.firstMatch.debugDescription)` inside a `UITestCase`, run the suite, read the output. -When a UI test fails only on the runner and the cause is not established, quarantine it in -`.github/macos-ui-test-quarantine.txt` with a reason and what would take it off the list. Do not -guess at a fix and push it. +When a UI test fails only on the runner and the cause is not established, quarantine it in `.github/macos-ui-test-quarantine.txt` with a reason and what would take it off the list. Do not guess at a fix and push it. ## Lint -SwiftLint's `included:` scope is `TablePro` only. `Plugins/`, `LocalPackages/`, and the test -targets are never linted by a bare `swiftlint lint`, so pass explicit paths for a change outside -the app target. The local `swiftformat` is a version behind the repo `.swiftformat` and rejects -`--ifdefindent`, so it cannot run here; rely on SwiftLint plus reading the diff. Never remove the -four `force_unwrapping` disables to satisfy a local run. The CI toolchain differs from this one. +```bash +swiftlint lint --strict +``` -## Before the commit +The `DEVELOPER_DIR` export above is required; without it SwiftLint aborts with `Loading sourcekitdInProc.framework ... failed`. + +Two things to know: + +- **SwiftLint's `included:` scope is `TablePro` only.** `Plugins/`, `LocalPackages/`, and the test targets are never linted by a bare `swiftlint lint`. Pass explicit paths to lint a change outside the app target. +- **Local `swiftformat` is a version behind the repo `.swiftformat`** and rejects `--ifdefindent`, so it cannot run here. Rely on SwiftLint plus reading the diff. +- **Never remove a `swiftlint:disable force_unwrapping` comment** to satisfy a local run. There are five of them, inline in four files, not in the config: `.swiftlint.yml` enables the rule as an opt-in and sets it to `warning`. The CI toolchain differs from this one, so a disable that looks unnecessary here is load-bearing there. + +## Branch safety The checkout can move between turns, and uncommitted edits to tracked files are lost when it does. -- Run `git branch --show-current` as its own call, in the same message as the commit, not several - turns earlier. The checked-out branch may belong to another session's in-flight pull request. -- **Never chain `commit && push`.** That chain is what removed the last chance to notice a - checkout sitting on `main` after a squash merge, and it pushed straight to the default branch. -- Never `git add -A`. Other sessions leave files in this tree. Stage explicit paths and re-check - `git status`. Leave `TablePro/Resources/Localizable.xcstrings` out of your commits; it is shared - and frequently dirty, and new keys fall back to the English key until a build regenerates it. -- Run `Skill(code-review)` over the diff and act on what it finds. -- Confirm the CHANGELOG still has its headings: `grep -n '^## \[' CHANGELOG.md`. An `Edit` whose - `new_string` drops the trailing context deletes the released version heading and folds that - release into `[Unreleased]`. Release notes are extracted from `[Unreleased]`, so the next - release re-ships it. The diff looks small and nothing in a build catches it. -- Run the writing-style grep from `CLAUDE.md` on the staged diff, and on the pull request body - file before `gh pr create`. Writing the body to a file rather than passing it inline is what - makes that possible. -- Confirm the CHANGELOG entry, the localization calls, the `docs/` update, and the tests are all - in the diff, not on a mental list. -- If SSH push fails, port 22 is blocked here. Push over HTTPS with the `gh` credential helper: +- Run `git branch --show-current` **as its own call, in the same message as the commit**, not several turns earlier. +- **Never chain `commit && push`.** That chain is what removed the last chance to notice a checkout sitting on `main` after a PR was squash-merged, and it pushed straight to the default branch. +- Commit work to its branch before a turn ends. Do not leave a large change staged but uncommitted. +- If SSH push fails (port 22 is blocked here), push over HTTPS with the `gh` credential helper: ```bash git -c credential.helper='!gh auth git-credential' push https://github.com/TableProApp/TablePro.git ``` -A follow-up pull request gets this whole playbook too. `shipping.md` says why. +## Before the commit + +- Run `Skill(code-review)` over the diff and act on what it finds. Its findings on the lines you just wrote matter as much as its findings on old code. +- **Confirm the CHANGELOG still has its headings:** + ```bash + grep -n '^## \[' CHANGELOG.md + ``` + An `Edit` whose `new_string` drops the trailing context deletes the released version heading and silently folds that whole release into `[Unreleased]`. Release notes are auto-extracted from `[Unreleased]`, so the next release re-ships it. The diff looks small and the damage does not show up in a build. +- Run the writing-style grep from `CLAUDE.md` on the staged diff and rewrite every hit that lands on an added line: + ```bash + git diff --cached -U0 | grep -nE '—|seamless|robust|comprehensive|intuitive|effortless|streamlined|leverage|elevate|delve|utilize|facilitate' + ``` + Run the same grep over the PR body file before `gh pr create`. Writing the body to a file rather than passing it inline is what makes that possible. +- Confirm the CHANGELOG entry, the localization calls, the `docs/` update, and the tests are all in the diff, not on a mental to-do list. diff --git a/.claude/skills/fix-issue/scripts/verify.sh b/.claude/skills/fix-issue/scripts/verify.sh index 4510e8e5a..143cf3574 100755 --- a/.claude/skills/fix-issue/scripts/verify.sh +++ b/.claude/skills/fix-issue/scripts/verify.sh @@ -38,18 +38,31 @@ MAX_WAIT_SECONDS=1800 # Source: .claude/skills/fix-issue/references/verification.md ("Test", rule 3). KNOWN_ENV_FAILURES="StatusBarSnapshotTests RowOperationsManagerBinaryCopyTests AWSSSOFetchTests SSEEventStreamTests CopilotIdleStopControllerTests" +# Asking for help is not a usage error, so -h exits 0. Anything else exits 3, which a caller +# running under `set -e` can distinguish from a real verdict. usage() { awk 'NR > 2 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}" - exit 3 + exit "${1:-3}" +} + +need_value() { + [ "$1" -ge 2 ] || { echo "$2 needs a value" >&2; usage; } } while [ $# -gt 0 ]; do case "$1" in - --run) RUN_DIR="$2"; shift 2 ;; - --root) REPO_ROOT="$(cd "$2" && pwd)"; shift 2 ;; + --run) need_value $# "--run"; RUN_DIR="$2"; shift 2 ;; + --root) + need_value $# "--root" + # Without this check a bad path leaves REPO_ROOT empty, and the run then builds and + # reports on a tree that is not the one asked for. + REPO_ROOT="$(cd "$2" 2> /dev/null && pwd)" || { echo "--root: no such directory: $2" >&2; exit 3; } + [ -n "$REPO_ROOT" ] || { echo "--root: no such directory: $2" >&2; exit 3; } + shift 2 + ;; --no-wait) WAIT_FOR_XCODEBUILD=0; shift ;; --offline) OFFLINE=1; shift ;; - -h|--help) usage ;; + -h | --help) usage 0 ;; --*) echo "unknown option: $1" >&2; usage ;; *) break ;; esac @@ -63,7 +76,12 @@ if [ -z "$RUN_DIR" ]; then RUN_DIR="$REPO_ROOT/.analysis/${branch//\//-}" fi LOG_DIR="$RUN_DIR/logs" -mkdir -p "$LOG_DIR" +# Only the steps that produce a log create the directory. `parse` and `tail` read an existing +# log and used to leave an empty logs/ tree wherever --run pointed. +case "$STEP" in + parse | tail) ;; + *) mkdir -p "$LOG_DIR" ;; +esac # ---------------------------------------------------------------------------- reporting helpers @@ -244,6 +262,8 @@ run_logged() { case "$STEP" in tail) [ $# -ge 1 ] || usage + # A mistyped path used to print the shell's error and still exit 0, which reads as success. + [ -f "$1" ] || { echo "no such log: $1" >&2; exit 3; } tail -n "${2:-60}" "$1" exit 0 ;; @@ -254,12 +274,25 @@ case "$STEP" in STEP_DETAIL="$1" STATUS=PASS grep -q '^\*\* \(BUILD\|TEST\) FAILED \*\*' "$1" 2> /dev/null && STATUS=FAIL - diagnose_environment "$1" && STATUS=INCONCLUSIVE - if grep -qiE "$PASS_PATTERN|$FAIL_PATTERN" "$1" 2> /dev/null; then + + # Treat anything that looks like a test run as one, not only a log that already has case + # lines. A wedged host prints TEST SUCCEEDED with zero cases, and keying on case lines + # meant report_tests never ran, so that log parsed as a pass. + if grep -qiE "$PASS_PATTERN|$FAIL_PATTERN|^Test Suite |-only-testing:|^\*\* TEST (SUCCEEDED|FAILED) \*\*" "$1" 2> /dev/null; then report_tests "$1" else - note "$(report_errors "$1")" + parse_errors="$(report_errors "$1")" + if [ -n "$parse_errors" ]; then + note "$parse_errors" + # swiftlint and other non-xcodebuild tools never print the ** BUILD FAILED ** + # banner, so without this a lint log full of errors reported PASS and exit 0. + STATUS=FAIL + fi fi + + # An environment cause outranks whatever the log appears to say, because the run did not + # get far enough to be evidence about the change. This runs last so nothing overwrites it. + diagnose_environment "$1" && STATUS=INCONCLUSIVE emit "$1" "-" ;; @@ -315,18 +348,22 @@ case "$STEP" in run_logged "$log" xcodebuild -project "$REPO_ROOT/TablePro.xcodeproj" -scheme TablePro \ test $(xcodebuild_flags) "${filters[@]}" code=$? - if grep -q '^\*\* TEST SUCCEEDED \*\*' "$log" 2> /dev/null; then - STATUS=PASS - else + # report_tests owns the verdict on this path, so reading the TEST SUCCEEDED banner here + # was dead: it was always overwritten. Keep the environment answer instead, which the + # build path already honours and this path used to discard. + env_failed=1 + diagnose_environment "$log" && env_failed=0 + + if grep -qE '(^|[^a-zA-Z])error: ' "$log" 2> /dev/null && ! grep -qiE "$PASS_PATTERN|$FAIL_PATTERN" "$log" 2> /dev/null; then STATUS=FAIL - fi - diagnose_environment "$log" - if grep -qE '(^|[^a-zA-Z])error: ' "$log" 2> /dev/null && ! grep -qiE "^test case .* (passed|failed)" "$log" 2> /dev/null; then note "cause: the test target failed to build. The errors below are compile errors, not test failures." note "$(report_errors "$log")" + [ "$env_failed" -eq 0 ] && STATUS=INCONCLUSIVE emit "$log" $code fi + report_tests "$log" + [ "$env_failed" -eq 0 ] && STATUS=INCONCLUSIVE emit "$log" $code ;; @@ -363,6 +400,23 @@ case "$STEP" in STATUS=FAIL note "$(grep -E ':[0-9]+:[0-9]+: (error|warning):' "$log" 2> /dev/null | sed 's/^/ /' | head -15)" fi + + # Lint the agent-facing docs in the same pass. They are instructions the next run acts on, + # so a stale symbol there is a defect the same way a lint violation is, and it is the one + # class of defect nothing else in this repo catches. + doc_check="$REPO_ROOT/scripts/check-doc-symbols.sh" + if [ -x "$doc_check" ]; then + doc_out="$("$doc_check" 2>&1)" + doc_code=$? + if [ "$doc_code" -ne 0 ]; then + [ "$STATUS" = "PASS" ] && STATUS=FAIL + note "docs: $(printf '%s' "$doc_out" | tail -3 | head -1)" + note "$(printf '%s' "$doc_out" | grep -E '^(CLAUDE|\.claude)' | sed 's/^/ /' | head -10)" + note " run scripts/check-doc-symbols.sh for the full list" + else + note "docs: $(printf '%s' "$doc_out" | tail -1)" + fi + fi emit "$log" $code ;; diff --git a/.claude/skills/fix-issue/workflows/critique.mjs b/.claude/skills/fix-issue/workflows/critique.mjs deleted file mode 100644 index 34ec732f8..000000000 --- a/.claude/skills/fix-issue/workflows/critique.mjs +++ /dev/null @@ -1,140 +0,0 @@ -export const meta = { - name: 'fix-issue-critique', - description: 'Attack a written TablePro fix blueprint from independent lenses and return only objections that survive their own evidence bar', - whenToUse: 'Phase 2 of the fix-issue skill, after the blueprint file is written and before any product file is edited', - phases: [{ title: 'Critique', detail: 'one independent lens per critic' }], -} - -// args: { blueprint: "", brief?: "", -// root?: "", -// track?: "defect" | "change", lenses?: [...], extra?: [...] } -// -// The safety lens is the same either way. The first two change with the track: a defect plan is -// most often wrong about ownership, a change plan is most often wrong about size. - -const OBJECTIONS = { - type: 'object', - additionalProperties: false, - required: ['lens', 'objections'], - properties: { - lens: { type: 'string', maxLength: 60 }, - verdict: { - type: 'string', - enum: ['sound', 'needs-change', 'wrong-shape'], - description: 'wrong-shape means the blueprint solves the wrong problem or sits at the wrong ownership boundary.', - }, - objections: { - type: 'array', - maxItems: 6, - items: { - type: 'object', - additionalProperties: false, - required: ['severity', 'claim', 'evidence'], - properties: { - severity: { - type: 'string', - enum: ['blocking', 'material', 'minor'], - description: 'blocking: shipping this blueprint is incorrect or unsafe. material: it leaves a real gap. minor: preference.', - }, - claim: { type: 'string', maxLength: 240 }, - evidence: { - type: 'string', - maxLength: 240, - description: 'file:line, SDK symbol, invariant name, or measured output. Reasoning alone is not evidence.', - }, - correction: { type: 'string', maxLength: 240, description: 'The smallest change to the blueprint that answers this.' }, - check: { type: 'string', maxLength: 160, description: 'What would prove this objection right or wrong.' }, - }, - }, - }, - }, -} - -const CHANGE_LENSES = [ - { - key: 'fit', - agentType: 'codebase-investigator', - prompt: 'Attack the design as product and architectural fit. Is there a smaller design that satisfies the acceptance criteria in the brief? Does this invent a pattern, a control, or a settings surface the app does not already use, where an existing one would do? Does it follow the precedent the blueprint claims to follow, or only resemble it? Name the existing feature it should have copied, with file evidence, and say plainly if the feature is larger than the problem.', - }, - { - key: 'scope', - agentType: 'codebase-investigator', - prompt: 'Attack the completeness of a user-visible feature. What does this plan omit that every comparable feature in the repository has: an empty state, an error and offline state, cancellation, undo, keyboard and menu access, a settings default, a migration for existing stored data, localization, the documentation page, the changelog entry, the iOS target, an unknown plugin type falling through a switch? Verify each gap against a shipping example before claiming it.', - }, - { - key: 'risk', - agentType: 'adversarial-reviewer', - prompt: 'Attack correctness and safety. You are reviewing a written blueprint, not a diff. Look for data loss, destructive SQL, credential or scope leakage, actor isolation and cancellation errors, late completion, ABI breakage, and native UX or responder-chain regressions. New surfaces bring new trust boundaries: check what this feature lets a user or a plugin do that they could not do before. Assume the blueprint will be implemented literally.', - }, -] - -const DEFECT_LENSES = [ - { - key: 'architecture', - agentType: 'codebase-investigator', - prompt: 'Attack the ownership boundary. Does this blueprint put the behavior where the repository already puts that kind of decision, or does it bolt a special case onto a shape that cannot express the correct behavior? Name the existing pattern it should have followed, with file evidence. Challenge the targeted-fix versus refactor call in both directions.', - }, - { - key: 'scope', - agentType: 'codebase-investigator', - prompt: 'Attack the scope. What caller, state transition, persisted value, plugin, migration, localization, document, or edge case does this blueprint miss? What breaks for existing users, existing data, or an unknown plugin type? Verify each gap against the code before claiming it.', - }, - { - key: 'risk', - agentType: 'adversarial-reviewer', - prompt: 'Attack correctness and safety. You are reviewing a written blueprint, not a diff. Look for data loss, destructive SQL, credential or scope leakage, actor isolation and cancellation errors, late completion, ABI breakage, and native UX or responder-chain regressions. Assume the blueprint will be implemented literally.', - }, -] - -const blueprint = args?.blueprint -if (!blueprint) throw new Error('args.blueprint is required: the path to the blueprint') - -const track = args?.track ?? 'defect' -if (track !== 'defect' && track !== 'change') throw new Error(`args.track must be "defect" or "change", got "${track}"`) - -const lenses = [...(args?.lenses ?? (track === 'change' ? CHANGE_LENSES : DEFECT_LENSES)), ...(args?.extra ?? [])] -const briefLine = args?.brief - ? `The accepted problem statement is ${args.brief}. Do not relitigate the problem, and treat its non-goals as decided: a plan that respects a non-goal is not incomplete.` - : '' - -const root = args?.root -const rootLine = root ? `Verify against the tree at ${root}, this run's pinned worktree, not the directory you started in.` : '' - -const CONTRACT = ` -Read ${blueprint}. ${briefLine} ${rootLine} - -Attack the blueprint, not the problem statement, and not the other critics. The writer already believes this plan is right, so agreeing costs nothing and finding the flaw is the whole job. - -Verify before you object. An objection with no file:line, SDK symbol, named invariant, or measured output is noise that will cost the writer a verification cycle to disprove. Drop taste, formatting, and product decisions that are the user's to make. - -Return the schema and nothing else. If the blueprint survives your lens, say so with verdict "sound" and an empty objections list. That is a useful answer, not a failure. -` - -phase('Critique') - -const reports = ( - await parallel( - lenses.map((lens) => () => - agent(`${lens.prompt}\n${CONTRACT}`, { - label: `critic:${lens.key}`, - phase: 'Critique', - agentType: lens.agentType, - schema: OBJECTIONS, - }), - ), - ) -).filter(Boolean) - -const all = reports.flatMap((r) => (r.objections ?? []).map((o) => ({ lens: r.lens, ...o }))) -const kept = all.filter((o) => o.severity !== 'minor') -const dropped = all.length - kept.length -if (dropped) log(`dropped ${dropped} minor objection(s); they are in the lane transcripts`) - -return { - blueprint, - root: root ?? null, - track, - verdicts: reports.map((r) => ({ lens: r.lens, verdict: r.verdict ?? 'needs-change' })), - objections: kept.sort((a, b) => (a.severity === 'blocking' ? -1 : 1)), - droppedMinor: dropped, -} diff --git a/.claude/skills/fix-issue/workflows/investigate.mjs b/.claude/skills/fix-issue/workflows/investigate.mjs deleted file mode 100644 index 40d86e695..000000000 --- a/.claude/skills/fix-issue/workflows/investigate.mjs +++ /dev/null @@ -1,175 +0,0 @@ -export const meta = { - name: 'fix-issue-investigate', - description: 'Run independent read-only investigation lanes over one TablePro issue brief and return capped digests', - whenToUse: 'Phase 1 of the fix-issue skill, after the brief file exists and before any blueprint is written', - phases: [{ title: 'Investigate', detail: 'one read-only lane per independent question' }], -} - -// args: { brief: "", root?: "", -// track?: "defect" | "change", lanes?: [{key, agentType, question}], extra?: [...] } -// -// The track picks the default lane set. A defect needs tracing, so the lanes hunt the cause. A -// change needs placement, so the lanes hunt the precedent and the surface it has to touch. -// -// Every lane reads the same brief and answers one narrow question. The digest schema is the -// context budget: whatever a lane learned that does not fit here stays in the lane's transcript, -// which the main agent can recover from journal.jsonl or by re-asking a focused follow-up. - -const DIGEST = { - type: 'object', - additionalProperties: false, - required: ['verdict', 'confidence', 'anchors', 'unknowns'], - properties: { - verdict: { type: 'string', maxLength: 240, description: 'One sentence answering this lane only.' }, - confidence: { type: 'string', enum: ['confirmed', 'inferred', 'blocked'] }, - rootCause: { - type: ['string', 'null'], - maxLength: 400, - description: - 'On a defect, the mechanism rather than the symptom. On a change, the placement or design conclusion this lane supports. Null when this lane cannot establish it.', - }, - anchors: { - type: 'array', - maxItems: 8, - description: 'Only files this lane actually opened.', - items: { - type: 'object', - additionalProperties: false, - required: ['ref', 'claim'], - properties: { - ref: { type: 'string', maxLength: 120, description: 'Path/To/File.swift:123' }, - symbol: { type: 'string', maxLength: 80 }, - claim: { type: 'string', maxLength: 160, description: 'What is at that line and why it matters.' }, - }, - }, - }, - collateral: { - type: 'array', - maxItems: 5, - description: 'Same failure class found elsewhere. Each needs a reachable scenario.', - items: { type: 'string', maxLength: 200 }, - }, - risks: { type: 'array', maxItems: 5, items: { type: 'string', maxLength: 160 } }, - unknowns: { - type: 'array', - maxItems: 5, - description: 'What this lane could not establish, and what would settle it.', - items: { type: 'string', maxLength: 160 }, - }, - tests: { - type: 'array', - maxItems: 6, - description: 'Existing suites that cover this, or the command that would.', - items: { type: 'string', maxLength: 160 }, - }, - }, -} - -const CHANGE_LANES = [ - { - key: 'placement', - agentType: 'codebase-investigator', - question: - 'Decide where this behavior belongs. Name the owner that should hold it, the seam it plugs into, and the closest feature already shipping in this repository that solves a similar problem. Return that precedent end to end as a file list: model, view model, view, persistence, registration point, tests, and documentation. The writer will copy its shape, so an inaccurate precedent is worse than none.', - }, - { - key: 'platform', - agentType: 'platform-researcher', - question: - 'Establish whether the platform, SDK, or dependency supports this at all, and how. Verify the API exists for the deployment target, name the fallback for anything newer, and cite the HIG rule that governs this kind of surface. When the request names a database or driver, verify what the vendored client library and the plugin contract actually allow.', - }, - { - key: 'surface', - agentType: 'codebase-investigator', - question: - 'Ignore the implementation. Enumerate everything a user-visible feature in this repository has to touch before it can ship: registration and discovery points, menu and keyboard shortcut wiring, settings keys with their defaults and any migration for existing users, localization, the documentation page, the changelog section, feature flags, and the iOS target. Anchor each one to the file where a comparable feature does it.', - }, - { - key: 'tests', - agentType: 'test-strategist', - question: - 'Find the test that would encode the acceptance criteria, the neighboring suites this change can break, whether deterministic UI automation is possible for the new flow, and the exact serial verification commands including quarantine and environment traps.', - }, -] - -const DEFECT_LANES = [ - { - key: 'path', - agentType: 'codebase-investigator', - question: - 'Trace the real shipping execution path that produces the reported behavior. Identify the entry point, the state transitions, the first owner that has enough information to behave correctly, and the root cause separated from the symptom. Name the project-guide invariants that apply.', - }, - { - key: 'siblings', - agentType: 'codebase-investigator', - question: - 'Ignore the primary path. Search the affected subsystem for the same failure class: silent fallbacks, duplicated source-of-truth lists, missing generation or cancellation checks, and drifted sibling implementations. Every finding needs file:line, a reachable failure scenario, and evidence that no upstream guard already prevents it.', - }, - { - key: 'platform', - agentType: 'platform-researcher', - question: - 'Establish the documented platform or dependency contract this behavior depends on. Verify the API exists in the installed SDK for the deployment target, check availability and the fallback path, and measure with a minimal probe anything source inspection cannot prove.', - }, - { - key: 'tests', - agentType: 'test-strategist', - question: - 'Find the smallest regression test that fails before the fix and passes after, the neighboring suites the change can break, whether deterministic UI automation is possible, and the exact serial verification commands including quarantine and environment traps.', - }, -] - -const brief = args?.brief -if (!brief) throw new Error('args.brief is required: the path to the run brief') - -const track = args?.track ?? 'defect' -if (track !== 'defect' && track !== 'change') throw new Error(`args.track must be "defect" or "change", got "${track}"`) - -const lanes = [...(args?.lanes ?? (track === 'change' ? CHANGE_LANES : DEFECT_LANES)), ...(args?.extra ?? [])] - -const root = args?.root -const rootLine = root - ? `\nRead the tree at ${root}, not the directory you started in. That worktree is this run's pinned base; the main checkout has other sessions' half-finished edits in it, and a file that appears or vanishes there is their work, not evidence. Anchor with repository-relative paths so they stay valid in both.\n` - : '' - -const CONTRACT = ` -Read ${brief} first. It is the full problem statement and the acceptance criteria. Treat any suspected subsystem in it as a hint, not a conclusion, and treat the reporter's proposed fix or proposed design as a hypothesis: they described what they want, not where it belongs. -${rootLine} - -Answer only your own question. Do not design the patch and do not review anyone else's work. - -Return the digest schema and nothing else. The schema is a hard context budget, not a summary style: the main agent's context is the scarce resource, so send it only what it needs to decide, with anchors precise enough to verify without re-reading whole files. Your full reasoning stays in this transcript and can be recovered, so do not pad the digest to preserve it. - -Rules for the digest: -- Fill a field only when it carries a decision. The array caps are ceilings, not quotas, and an empty list beats a padded one. -- An anchor is only valid for a file you actually opened. Never anchor to a path you inferred. -- Label the lane confirmed only when a file, an SDK interface, or a measured probe backs the verdict. Use inferred when the mechanism is reasoned but unproven, and blocked when you could not establish it. -- An honest unknown outranks a confident guess. A wrong confirmed claim costs more than an empty lane. -` - -phase('Investigate') - -const results = await parallel( - lanes.map((lane) => async () => { - const digest = await agent(`${lane.question}\n${CONTRACT}`, { - label: `lane:${lane.key}`, - phase: 'Investigate', - agentType: lane.agentType, - schema: DIGEST, - }) - return digest ? { key: lane.key, agent: lane.agentType, ...digest } : { key: lane.key, agent: lane.agentType, failed: true } - }), -) - -const lanesOut = results.filter(Boolean) -const dead = lanesOut.filter((l) => l.failed).map((l) => l.key) -if (dead.length) log(`lanes returned nothing: ${dead.join(', ')}`) - -return { - brief, - root: root ?? null, - track, - lanes: lanesOut.filter((l) => !l.failed), - deadLanes: dead, - unresolved: lanesOut.flatMap((l) => (l.unknowns ?? []).map((u) => `${l.key}: ${u}`)), -} diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 96f2adb4c..ed7188c15 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -1,170 +1,338 @@ --- name: release -description: Ships a TablePro release. Bumps Configs/Version.xcconfig, finalizes CHANGELOG.md and the docs changelog, commits, tags, and pushes, then handles registry-only plugin bundle releases. Invoke only when the user explicitly asks for a release. -disable-model-invocation: true +description: > + Prepares and ships a new TablePro release — bumps version numbers in + Configs/Version.xcconfig, finalizes CHANGELOG.md, commits, tags, and pushes. + Also handles separate plugin releases (Redis, Oracle, ClickHouse, + DuckDB). Use this skill whenever the user says "release", "bump + version", "ship version", "tag a release", "cut a release", or + provides a version number they want to release (e.g., "/release 0.5.0", + "/release plugin-oracle 1.0.0"). --- -# Release +# Release Version -Every step here is public and most of it cannot be undone. A pushed tag triggers a build, a -GitHub Release, an appcast commit on `main`, and for plugins a registry push that clients read. +Automate the full release pipeline for TablePro. Supports two modes: -## Gate +- **App release**: `/release ` — bumps versions, finalizes + changelog, commits, tags, and pushes. +- **Plugin release**: `/release plugin- ` — tags and + pushes a separate plugin bundle release. -Run this only on an explicit release request from the user, naming the version. "Release" appearing -in conversation is not a request. If you arrived here without one, stop and ask. +## Usage ``` -/release # app, for example /release 0.5.0 -/release plugin- # one registry plugin, for example /release plugin-oracle 1.0.1 +/release # App release (e.g., /release 0.5.0) +/release plugin- # Plugin release (e.g., /release plugin-oracle 1.0.0) ``` -## Pre-flight, all blocking +## Pre-flight Checks -Nothing below this section is reversible, so every check runs first and a failure stops the -release. Do not "note it and continue". +Before making any changes, verify ALL of the following. If any check +fails, stop and tell the user what's wrong. -1. **Version shape.** `X.Y.Z`, optionally with `-beta.N` or `-rc.N`. -2. **Version is newer** than `MARKETING_VERSION` in `Configs/Version.xcconfig`. -3. **Tag is free on the remote**, not just locally. A local check passes for a tag that already - exists on origin and then the push fails mid-release: - ```bash - git ls-remote --tags --refs origin "refs/tags/v" +1. **Version argument exists** — the user must provide a semver version + (e.g., `0.5.0`). If missing, ask for it. + +2. **Version is valid semver** — must match `X.Y.Z` where X, Y, Z are + non-negative integers. Pre-release suffixes like `-beta.1` or `-rc.1` + are allowed. + +3. **Version is newer** — compare against the current `MARKETING_VERSION` + in `Configs/Version.xcconfig`. The new version must be greater. Read the + current value: + ``` + Read Configs/Version.xcconfig + ``` + +4. **Tag doesn't exist** — run `git tag -l "v"` to confirm the + tag is available. + +5. **Working tree is clean** — run `git status --porcelain`. If there are + uncommitted changes, warn the user and ask whether to proceed (the + release commit will include those changes). + +6. **Unreleased section has content** — read `CHANGELOG.md` and verify + the `## [Unreleased]` section has entries. If empty, warn the user + that the release will have no changelog entries. + +7. **On main branch** — run `git branch --show-current`. Warn (but don't + block) if not on `main`. + +8. **SwiftLint passes** — run `swiftlint lint --strict`. If there are + any warnings or errors, spawn a Task subagent to fix all issues + before continuing with the release. The subagent should run + `swiftlint --fix` first, then manually fix any remaining issues, + and verify with `swiftlint lint --strict` until clean. + +## Release Steps + +### Step 1: Bump Version in Configs/Version.xcconfig + +File: `Configs/Version.xcconfig` + +It holds exactly two lines, and they belong to the macOS app alone: + +- Set `MARKETING_VERSION` to the new version (e.g., `0.5.0`) +- Increment `CURRENT_PROJECT_VERSION` by 1 from its current value + +No other file carries an app version. Plugin bundles, the test bundles, and +TableProPluginKit pin `MARKETING_VERSION = 1.0` / `CURRENT_PROJECT_VERSION = 1` +in `project.yml`; the iOS app and its widget read `Configs/Version-iOS.xcconfig`. +Leave all of those alone. + +### Step 2: Finalize CHANGELOG.md + +Make these edits to `CHANGELOG.md`: + +1. **Convert Unreleased to versioned heading** — replace: ``` -4. **Branch and tree.** `git branch --show-current` is `main`, and `git status --porcelain` is - understood. Other sessions work in this checkout, so anything dirty that you did not put there - is theirs: never sweep it into a release commit. Stage explicit paths only. -5. **`## [Unreleased]` has entries.** An empty section ships a release whose notes are the CI - fallback line. -6. **Lint and tests pass**, unscoped, because the release job depends on both: - ```bash - .claude/skills/fix-issue/scripts/verify.sh lint TablePro - .claude/skills/fix-issue/scripts/verify.sh test + ## [Unreleased] ``` -7. **Registry readiness.** The `v*` tag is gated on it in CI, so check it before tagging rather - than discovering it after: - ```bash - MANAGER=TablePro/Core/Plugins/PluginManager.swift - CURRENT=$(grep -E 'static let currentPluginKitVersion = ' "$MANAGER" | grep -oE '[0-9]+' | head -1) - FLOOR=$(grep -E 'static let minimumCompatiblePluginKitVersion = ' "$MANAGER" | grep -oE '[0-9]+' | head -1) - python3 scripts/check-registry-readiness.py --floor "$FLOOR" --current "$CURRENT" + with: ``` -8. **PluginKit floor decision.** If `minimumCompatiblePluginKitVersion` rose since the last - release, every registry plugin needs re-publishing with `scripts/release-all-plugins.sh` - **before or with** this app release, never after. Shipping an app ahead of its plugin binaries - is what caused two registry-wide outages. + ## [Unreleased] -## App release + ## [] - + ``` + where `` is today's date. -### 1. Version +2. **Update footer links** — at the bottom of the file: -`Configs/Version.xcconfig` holds the only app version, and only for the macOS app: set -`MARKETING_VERSION`, and increment `CURRENT_PROJECT_VERSION` by one. Leave everything else alone. -Plugin bundles, test bundles, and `TableProPluginKit` pin `1.0` / `1` in `project.yml`, and the iOS -app reads `Configs/Version-iOS.xcconfig`. + Replace the `[Unreleased]` compare link: + ``` + [Unreleased]: https://github.com/TableProApp/TablePro/compare/v...HEAD + ``` + with: + ``` + [Unreleased]: https://github.com/TableProApp/TablePro/compare/v...HEAD + []: https://github.com/TableProApp/TablePro/compare/v...v + ``` -### 2. CHANGELOG.md + `` is the previous release version (the one currently in + the `[Unreleased]` compare link). -Insert a new heading under the kept `## [Unreleased]`: +### Step 3: Commit (main repo) +Stage the changed files and commit: + +```bash +git add Configs/Version.xcconfig CHANGELOG.md docs/changelog.mdx +git commit -m "$(cat <<'EOF' +release: v +EOF +)" ``` -## [] - + +If there were other staged/unstaged changes from the pre-flight check +that the user agreed to include, stage those too. + +### Step 4: Tag + +```bash +git tag v ``` -The shape is load-bearing. `scripts/ci/extract-release-notes.sh` awk-matches `## [X.Y.Z]`, and any -other shape silently yields the fallback release note. Then update the footer links: point -`[Unreleased]` at `v...HEAD` and add `[]: …/compare/v...v`. +### Step 5: Push -Confirm the released headings survived the edit, because dropping one folds a whole past release -into `[Unreleased]` and the next release re-ships it: +Push the commit and the tag **separately** — `--follow-tags` only pushes +annotated tags, but `git tag` creates lightweight tags: ```bash -grep -n '^## \[' CHANGELOG.md +git push origin main && git push origin v ``` -### 3. docs/changelog.mdx +This triggers the CI/CD pipeline (`.github/workflows/build.yml`) which +automatically: +- Builds arm64 and x86_64 binaries +- Creates DMG and ZIP artifacts +- Signs with Sparkle EdDSA +- Generates and commits `appcast.xml` +- Creates the GitHub Release with release notes extracted from CHANGELOG.md -Add one `` block at the top, directly -after the frontmatter. Match the shape of the block already there. Rewrite the changelog entries as -user-facing prose grouped by what a user would look for, not by Keep a Changelog categories, and -drop internal refactors with no visible effect. There is no other locale: `docs/vi/` was deleted. +### Step 6: Update Documentation Changelogs -This has to be written now, before the commit, because it ships in the same commit. +The documentation lives in the main repo under `docs/`. Two changelog +files need a new `` entry: -### 4. Commit, tag, push, in that order and separately +- `docs/changelog.mdx` (English) +- `docs/vi/changelog.mdx` (Vietnamese) -```bash -git add Configs/Version.xcconfig CHANGELOG.md docs/changelog.mdx -git status --short -git branch --show-current -git commit -m "release: v" +**How to write the entry:** + +1. Read the new version's section from `CHANGELOG.md` (the entries you + finalized in Step 2). +2. Rewrite them as a user-friendly `` block — group entries + under `### New Features`, `### Improvements`, `### Bug Fixes`, etc. + (not the raw Added/Changed/Fixed/Removed from Keep a Changelog). +3. Write concise, user-facing descriptions (not developer-internal + details). Skip purely internal refactors unless they have visible + impact. + +**English format** (`docs/changelog.mdx`): + +```mdx + + ### New Features + + - **Feature Name**: Description + + ### Improvements + + - Description + + ### Bug Fixes + + - Description + ``` -Read that `git status --short` before committing and unstage anything that is not one of those -three paths. +Insert the new `` block at the top of the file, right after the +frontmatter `---` closing delimiter (before the first existing ``). + +**Vietnamese format** (`docs/vi/changelog.mdx`): + +Same structure but with Vietnamese text. Use the date format +` tháng , ` (e.g., `19 tháng 2, 2026`). Translate +feature names and descriptions to Vietnamese. Follow the style of +existing Vietnamese entries in the file. + +**Important:** These changelog files are staged and committed together +with the release in Step 3 — no separate commit needed. + +### Step 7: Check for Separate Plugin Changes + +After the app release is pushed, check if any **separate plugin bundles** +have changes since their last release. Also check +`Plugins/TableProPluginKit/` — changes there affect all plugins. + +**Important**: Do NOT use a hardcoded plugin list. Dynamically discover +all separate plugins by scanning the `Plugins/` directory and excluding +built-in plugins and the shared framework. + +**Detection**: Dynamically find all separate plugin directories and check +each for changes: ```bash -git push origin main -git tag v -git push origin v +# Built-in plugins (bundled in app) and shared framework — skip these: +BUILTIN="MySQLDriverPlugin|PostgreSQLDriverPlugin|SQLiteDriverPlugin|CSVExportPlugin|JSONExportPlugin|SQLExportPlugin|XLSXExportPlugin|MQLExportPlugin|SQLImportPlugin|TableProPluginKit" + +# Discover all separate plugin directories dynamically: +for dir in Plugins/*/; do + dirname=$(basename "$dir") + # Skip built-in plugins and PluginKit + echo "$dirname" | grep -qE "^($BUILTIN)$" && continue + + # Derive tag name from directory (e.g., OracleDriverPlugin -> oracle, + # CloudflareD1DriverPlugin -> d1, EtcdDriverPlugin -> etcd) + # Strip "DriverPlugin" or "ExportPlugin" or "ImportPlugin" suffix, + # then lowercase. For "CloudflareD1", use "d1". Apply custom mappings + # as needed based on the CI workflow's tag-name expectations. + tag_name= + + LAST_TAG=$(git tag -l "plugin-${tag_name}-v*" --sort=-version:refname | head -1) + # Check for changes since that tag (include PluginKit as shared dependency): + if [ -z "$LAST_TAG" ]; then + git log --oneline -- "Plugins/${dirname}/" "Plugins/TableProPluginKit/" + else + git log --oneline "${LAST_TAG}..HEAD" -- "Plugins/${dirname}/" "Plugins/TableProPluginKit/" + fi +done ``` -Push the branch and the tag as separate commands. `git tag` creates a lightweight tag and -`--follow-tags` pushes only annotated ones, so a combined push silently ships no tag. +The tag name derivation must match the CI workflow's mapping. Known +mappings: `CloudflareD1DriverPlugin` → `d1`, `EtcdDriverPlugin` → +`etcd`. For standard plugins, strip the suffix and lowercase (e.g., +`OracleDriverPlugin` → `oracle`). -CI then builds both architectures, signs with Sparkle EdDSA, commits `appcast.xml` to `main`, and -creates the GitHub Release. Pull `main` afterwards, since CI has committed to it. +If `LAST_TAG` is empty (never released), check for changes since the +beginning of the repo. -## Recovery +**If changes are found**: Tell the user which plugins have changes, show +the relevant commits, and ask if they want to release them. Suggest +bumping the patch version from the last tag (e.g., `1.0.0` → `1.0.1`). +If the user confirms, proceed with the plugin release steps below for +each plugin. -A failed release leaves a public tag. Delete it on both sides before retrying, and never force -push a branch: +**If no changes**: Skip — do not release plugins unnecessarily. + +## Post-release Summary + +After all pushes, print a summary: -```bash -git push origin :refs/tags/v -git tag -d v ``` +Release v (build ) pushed successfully. -If the appcast commit already landed, the release is out. Ship a follow-up version rather than -rewriting history. +CI will now build arm64 + x86_64, create DMG/ZIP, update appcast.xml, create GitHub Release. +Monitor: https://github.com/TableProApp/TablePro/actions +Release: https://github.com/TableProApp/TablePro/releases/tag/v +``` -## Plugin releases +If plugin releases were also triggered, append: -Only registry-only plugins are released this way. **Never publish a bundled plugin to the -registry.** Do not maintain a list here: `scripts/release-all-plugins.sh` holds the authoritative -`PLUGINS` and `BUNDLED_PLUGINS` arrays and hard-fails on a bundled name. +``` +Plugin releases: +- v: https://github.com/TableProApp/TablePro/releases/tag/plugin--v +``` -The `` in a tag must match a case in `.github/workflows/build-plugin.yml`. Read that case -block for the valid names; do not derive a name by transforming a directory name. A wrong name -still creates a permanent public tag and then fails CI with `Unknown plugin name`. +--- -One plugin: +## Plugin Releases -```bash -git ls-remote --tags --refs origin "refs/tags/plugin--v" -git tag plugin--v -git push origin plugin--v +Separate plugin bundles (any plugin not built-in) are released +independently from the main app via a dedicated workflow +(`.github/workflows/build-plugin.yml`). They are also checked +automatically during app releases (Step 7 above). + +### Usage + +``` +/release plugin- ``` -Every registry plugin, which is what a PluginKit floor bump requires: +Example: `/release plugin-oracle 1.0.0` + +### Tag Format -```bash -scripts/release-all-plugins.sh ``` +plugin--v +``` + +Examples: `plugin-oracle-v1.0.0`, `plugin-clickhouse-v1.2.0` + +The `` must match one of the cases in the workflow's mapping. +Check `.github/workflows/build-plugin.yml` for the current list of +supported names. New plugins must be added to the workflow mapping. + +### Plugin Release Steps -No version bump or changelog edit: a plugin's version is the tag. +1. **Verify tag is available** — `git tag -l "plugin--v"` +2. **Tag** — `git tag plugin--v` +3. **Push tag** — `git push origin plugin--v` -### Which plugins have changes +No version bumps or changelog edits needed — plugin bundles keep +`MARKETING_VERSION = 1.0` and `CURRENT_PROJECT_VERSION = 1` in `project.yml`. +The version is embedded via the tag only. -To find candidates, compare each registry plugin's directory and `Plugins/TableProPluginKit/` -against its last remote tag. Take the plugin names from the `PLUGINS` array in -`scripts/release-all-plugins.sh` and map each to its directory, rather than scanning `Plugins/` -and filtering, which is how bundled plugins get offered by mistake. +### What CI Does -Do not release a plugin with no changes since its last tag. Report the candidates and their -commits to the user and let them choose. +The `build-plugin.yml` workflow: -## Report +1. Extracts plugin name and version from the tag +2. Builds ARM64 and x86_64 via `scripts/build-plugin.sh` +3. Strips binaries, code signs, creates ZIPs with SHA-256 checksums +4. Optionally notarizes (if `NOTARIZE_PLUGINS` var is set) +5. Creates a GitHub Release with both arch ZIPs +6. Updates the plugin registry (`TableProApp/plugins` repo's + `plugins.json`) with download URLs, SHA-256 hashes, and + `minAppVersion` (read from the current `MARKETING_VERSION`) -State the version and build number, the tag, the CI run URL, the release URL, and any plugin tags -pushed. Say explicitly if a check was skipped and why. +### Post-plugin-release Summary + +``` +Plugin v tag pushed. + +CI will build arm64 + x86_64, create ZIPs, update plugin registry. +Monitor: https://github.com/TableProApp/TablePro/actions +Release: https://github.com/TableProApp/TablePro/releases/tag/plugin--v +``` diff --git a/.claude/skills/swiftdata/SKILL.md b/.claude/skills/swiftdata/SKILL.md new file mode 100644 index 000000000..1780dee64 --- /dev/null +++ b/.claude/skills/swiftdata/SKILL.md @@ -0,0 +1,98 @@ +--- +name: swiftdata +description: Writes, reviews, and improves SwiftData code using modern APIs and best practices. Use when reading, writing, or reviewing projects that use SwiftData. +--- + +Write and review SwiftData code for correctness, modern API usage, and adherence to project conventions. Report only genuine problems - do not nitpick or invent issues. + +Review process: + +1. Check for core SwiftData issues using `references/core-rules.md`. +1. Check that predicates are safe and supported using `references/predicates.md`. +1. If the project uses CloudKit, check for CloudKit-specific constraints using `references/cloudkit.md`. +1. If the project targets iOS 18+, check for indexing opportunities using `references/indexing.md`. +1. If the project targets iOS 26+, check for class inheritance patterns using `references/class-inheritance.md`. + +If doing partial work, load only the relevant reference files. + + +## Core Instructions + +- Target Swift 6.2 or later, using modern Swift concurrency. +- The user strongly prefers to use SwiftData across the board. Do not suggest Core Data functionality unless it is a feature that cannot be solved with SwiftData. +- Do not introduce third-party frameworks without asking first. +- Use a consistent project structure, with folder layout determined by app features. + + +## Output Format + +If the user asks for a review, organize findings by file. For each issue: + +1. State the file and relevant line(s). +2. Name the rule being violated. +3. Show a brief before/after code fix. + +Skip files with no issues. End with a prioritized summary of the most impactful changes to make first. + +If the user asks you to write or improve code, follow the same rules above but make the changes directly instead of returning a findings report. + +Example output: + +### Destination.swift + +**Line 8: Add an explicit delete rule for relationships.** + +```swift +// Before +var sights: [Sight] + +// After +@Relationship(deleteRule: .cascade, inverse: \Sight.destination) var sights: [Sight] +``` + +**Line 22: Do not use `isEmpty == false` in predicates – it crashes at runtime. Use `!` instead.** + +```swift +// Before +#Predicate { $0.sights.isEmpty == false } + +// After +#Predicate { !$0.sights.isEmpty } +``` + +### DestinationListView.swift + +**Line 5: `@Query` must only be used inside SwiftUI views.** + +```swift +// Before +class DestinationStore { + @Query var destinations: [Destination] +} + +// After +class DestinationStore { + var modelContext: ModelContext + + func fetchDestinations() throws -> [Destination] { + try modelContext.fetch(FetchDescriptor()) + } +} +``` + +### Summary + +1. **Data loss (high):** Missing delete rule on line 8 of Destination.swift means sights will be orphaned when a destination is deleted. +2. **Crash (high):** `isEmpty == false` on line 22 will crash at runtime – use `!isEmpty` instead. +3. **Incorrect behavior (high):** `@Query` on line 5 of DestinationListView.swift only works inside SwiftUI views. + +End of example. + + +## References + +- `references/core-rules.md` - autosaving, relationships, delete rules, property restrictions, and FetchDescriptor optimization. +- `references/predicates.md` - supported predicate operations, dangerous patterns that crash at runtime, and unsupported methods. +- `references/cloudkit.md` - CloudKit-specific constraints including uniqueness, optionality, and eventual consistency. +- `references/indexing.md` - database indexing for iOS 18+, including single and compound property indexes. +- `references/class-inheritance.md` - model subclassing for iOS 26+, including @available requirements, schema setup, and predicate filtering. diff --git a/.claude/skills/swiftdata/references/class-inheritance.md b/.claude/skills/swiftdata/references/class-inheritance.md new file mode 100644 index 000000000..d64d8d467 --- /dev/null +++ b/.claude/skills/swiftdata/references/class-inheritance.md @@ -0,0 +1,104 @@ +# Class inheritance + +When supporting iOS 26 and other coordinated releases (macOS 26, etc), SwiftData supports class inheritance for models. + +**Important:** This is not a common feature; only add model subclassing if it actually has a benefit. Alternatives such as protocols are often simpler and better. + +This works the same as regular class inheritance in Swift, however, child classes must be explicitly marked `@available` for a 26 release or later, e.g. iOS 26. This is required even if iOS 26 is set as the minimum deployment target. + +For example: + +```swift +@Model class Article { + var type: String + + init(type: String) { + self.type = type + } +} + +@available(iOS 26, *) +@Model class Tutorial: Article { + var difficulty: Int + + init(difficulty: Int) { + self.difficulty = difficulty + super.init(type: "Tutorial") + } +} + +@available(iOS 26, *) +@Model class News: Article { + var topic: String + + init(topic: String) { + self.topic = topic + super.init(type: "News") + } +} +``` + +Notice how both the parent and child classes must use the `@Model` macro. + +**Important:** When using a 26 release or later as minimum deployment target, we must still mark subclassed models with `@available`. However, we do *not* need to do the same with code using that model, because Xcode can match the deployment target and the model availability. + +When providing the schemas as part of model container creation, make sure to list both the parent class and its child classes – SwiftData is *not* able to infer the connection by itself. + +If you create a relationship to a model that has subclasses, the relationship might contain the parent class or any of its subclasses. + +For example, the `articles` array here might contain `Article`, `Tutorial`, or `News` instances: + +```swift +@Model class Magazine { + @Relationship(deleteRule: .cascade) var articles: [Article] + + init(articles: [Article]) { + self.articles = articles + } +} +``` + +If only one subclass is supported, it should be written specifically. If several subclasses but not all should be in the relationship, you might have no choice but to add another level of subclasses: BaseClass -> Subclass -> Subsubclass. However, this is not a good idea – deep subclassing is generally frowned upon, and will increase complexity in migrations. + + +## Filtering with subclasses + +One important benefit of model subclassing is that we can use `@Query` to look for specific subclasses, *or* to look for the base class, which will automatically return all child classes too. + +For example, we could load only tutorials like this: + +```swift +@Query private var tutorials: [Tutorial] +``` + +Or load *all* articles, including tutorials, like this: + +```swift +@Query private var articles: [Article] +``` + +If you want to load specific child classes but not the parent class, use `is` with the `#Predicate` macro to perform filtering: + +```swift +@Query(filter: #Predicate
{ + $0 is Tutorial || $0 is News +}) private var tutorialsAndNews: [Article] +``` + +**Important:** The type of the resulting array elements is `Article`, the parent class, so typecasting must be used to access child-class properties and methods. + +It's possible to do typecasting inside predicates to filter based on child-class properties. For example, this looks for easier tutorials and general news to create a list of articles suitable for the front page: + +```swift +@Query(filter: #Predicate
{ article in + if let tutorial = article as? Tutorial { + tutorial.difficulty < 3 + } else if let news = article as? News { + news.topic == "General" + } else { + false + } +}) private var frontPageArticles: [Article] +``` + +When working with the resulting data, regular Swift typecasting using `as` works fine. diff --git a/.claude/skills/swiftdata/references/cloudkit.md b/.claude/skills/swiftdata/references/cloudkit.md new file mode 100644 index 000000000..f08de6397 --- /dev/null +++ b/.claude/skills/swiftdata/references/cloudkit.md @@ -0,0 +1,10 @@ +# Using SwiftData with CloudKit + +**These rules only apply if the project is configured to use SwiftData with CloudKit.** + +- Never use `@Attribute(.unique)` or `#Unique`; they are *not* supported in CloudKit, and when used will cause local data to fail too. +- All model properties must always either have default values or be marked as optional. +- All relationships must be marked optional. +- Indexes and subclasses are supported in CloudKit, as long as the correct OS release is used. + +Keep in mind that CloudKit is designed for *eventual consistency* – any SwiftData code written with CloudKit support must be able to function if data has yet to synchronize. diff --git a/.claude/skills/swiftdata/references/core-rules.md b/.claude/skills/swiftdata/references/core-rules.md new file mode 100644 index 000000000..647894adb --- /dev/null +++ b/.claude/skills/swiftdata/references/core-rules.md @@ -0,0 +1,20 @@ +# Core rules + +- When SwiftData first launched, it autosaved model contexts aggressively. Since then, autosaving happens less frequently and is now hard to predict, so many developers prefer to add explicit calls to `save()` when correctness is important. +- There is no need to check `modelContext.hasChanges` before saving; just call `save()` directly. +- `ModelContext` and model instances must never cross actor boundaries. Model containers and persistent identifiers *are* sendable, so if you need a model instance to be transferred across actors you should send its identifier and re-fetch in the destination context. For more help with Swift concurrency, suggest the [Swift Concurrency Pro agent skill](https://github.com/twostraws/swift-concurrency-agent-skill). +- When using `@Relationship` to define a relationship from one model to another, place the macro on one side of the relationship only. Trying to use it on both sides causes a circular reference. +- Persistent identifiers are temporary before they are saved for the first time. Temporary IDs start with a lowercase “t”, and a model will be given a new ID after it is saved for the first time. As a result, you must save an object before relying on its ID. +- Do not attempt to use the property name `description` in any `@Model` class; it is explicitly disallowed. +- Do not attempt to add property observers to `@Model` classes; they will be quietly ignored. +- `@Attribute(.externalStorage)` is a *suggestion*, not a *requirement*, and only applies to properties of type `Data` – SwiftData will do what it thinks is best. +- `@Transient` properties are not persisted, and must have a default value. They reset to that default when the object is fetched from the store. If the value is derived from other stored properties, using a computed property is usually a better idea – use `@Transient` only if the value is expensive to produce. +- It is nearly always a good idea to have a specific migration schema in place, even if the project is only dealing with lightweight migrations. +- It is nearly always a good idea to have an explicit delete rule in place for relationships. This is most commonly `@Relationship(deleteRule: .cascade)`, but others are available. The default is `.nullify`, which sets the related model's reference to nil when the parent is deleted. This can leave orphaned objects or crash if the property is non-optional. +- Do not attempt to use `@Query` outside of SwiftUI views; it is designed to work specifically *inside* views, and will not operate correctly outside. For more help with SwiftUI, suggest the [SwiftUI Pro agent skill](https://github.com/twostraws/swiftui-agent-skill). +- If you only need the number of items matching a query, consider `ModelContext.fetchCount()` with a fetch descriptor. This will *not* live update if the data changes unless something else triggers the update, such as `@Query`, so it should be used carefully. +- When using `FetchDescriptor`, it may sometimes be beneficial to set the `relationshipKeyPathsForPrefetching` property. It’s an empty array by default, but if you know certain relationships will be used it’s more efficient to fetch them upfront. +- Similarly, you should consider setting `propertiesToFetch` so that only properties that are used are actually fetched. (It fetches all properties by default.) +- SwiftData frequently gets inverse relationships wrong, so it’s almost always a good idea to be explicit with the `@Relationship` macro by specifying the exact inverse relationship. +- Do not write `#Unique` more than once per model; you can only have one, placed inside the model class. If you need multiple uniqueness constraints, pass them as separate key path arrays in a single `#Unique`, e.g. `#Unique([\.email], [\.username])`. +- Enum properties stored in a model must conform to `Codable`. Some agents will insist that enums with associated values are not supported, but this is wrong – they work just fine. diff --git a/.claude/skills/swiftdata/references/indexing.md b/.claude/skills/swiftdata/references/indexing.md new file mode 100644 index 000000000..8e9dadfd9 --- /dev/null +++ b/.claude/skills/swiftdata/references/indexing.md @@ -0,0 +1,27 @@ +# Indexing + +When supporting iOS 18 and other coordinated releases, SwiftData supports indexes to help speed up queries. This has a small performance cost for writing, so if data is read rarely and updated frequently (such as logging), indexes may be a bad choice. + +Indexes can be on single properties, like this: + +```swift +@Model class Article { + #Index
([\.type], [\.author]) + + var type: String + var author: String + var publishDate: Date + + init(type: String, author: String, publishDate: Date) { + self.type = type + self.author = author + self.publishDate = publishDate + } +} +``` + +Alternatively, you can mix single properties and groups of properties when you know they are often used together: + +```swift +#Index
([\.type], [\.type, \.author]) +``` diff --git a/.claude/skills/swiftdata/references/predicates.md b/.claude/skills/swiftdata/references/predicates.md new file mode 100644 index 000000000..b4cf2a362 --- /dev/null +++ b/.claude/skills/swiftdata/references/predicates.md @@ -0,0 +1,73 @@ +# Working with predicates + +SwiftData predicates support only a subset of Swift functionality. Some things are marked as being unsupported, meaning that they will not build. Other things are *not* marked as unsupported and yet are still not supported, meaning that they will build but crash at runtime. + +This guide contains specific guidance on what to use and when. + + +## String matching + +When writing a query predicate to perform string matching, always use `localizedStandardContains()` rather than trying to use `lowercased().contains()` or similar. + +For example, this is correct: + +```swift +@Query(filter: #Predicate { + $0.name.localizedStandardContains("titanic") +}) private var movies: [Movie] +``` + + +## hasPrefix() + +`hasPrefix()` and `hasSuffix()` are not supported in SwiftData predicates. If you want to use `hasPrefix()`, you should use `starts(with:)` instead, like this: + +```swift +@Query(filter: #Predicate { + $0.type.starts(with: "https://apple.com") +}) private var appleLinks: [Website] +``` + + +## Unsupported predicates + +Many common methods have no equivalent in SwiftData, and will not compile. For example, all these common operations are not supported: + +- `String.hasSuffix()` +- `String.lowercased()` +- `Sequence.map()` +- `Sequence.reduce()` +- `Sequence.count(where:)` +- `Collection.first` + +Custom operators are also not allowed. + + +## Dangerous predicates + +Some SwiftData predicates will compile cleanly then fail or even crash at runtime. + +For example, this is a valid predicate designed to show only movies that have a non-empty cast list: + +```swift +@Query(filter: #Predicate { !$0.cast.isEmpty }, sort: \Movie.name) private var movies: [Movie] +``` + +However, *this* query looks like it does the same thing, but will crash at runtime: + +```swift +@Query(filter: #Predicate { $0.cast.isEmpty == false }, sort: \Movie.name) private var movies: [Movie] +``` + +Never attempt to create query predicates that use computed properties, `@Transient` properties, or use custom `Codable` struct data. They might compile cleanly, but they will crash at runtime. + +All predicates must rely on data that is actually stored in the database as `@Model` classes. + +Never attempt to use regular expressions in predicates. They will compile cleanly then fail at runtime. So, this is *not* allowed: + +```swift +@Query(filter: #Predicate { + $0.name.contains(/Titanic/) +}, sort: \Movie.name) +private var movies: [Movie] +``` diff --git a/.claude/skills/swiftui/SKILL.md b/.claude/skills/swiftui/SKILL.md index 3593f9c86..af3ebdf2a 100644 --- a/.claude/skills/swiftui/SKILL.md +++ b/.claude/skills/swiftui/SKILL.md @@ -1,101 +1,104 @@ --- name: swiftui -description: TablePro's SwiftUI and AppKit view rules. Use when writing or reviewing a view, view model, window, settings pane, or accessibility identifier in TablePro or TableProMobile. It carries the rules this repository's targets and conventions actually decide, not generic framework advice. +description: Comprehensively reviews SwiftUI code for best practices on modern APIs, maintainability, and performance. Use when reading, writing, or reviewing SwiftUI projects. --- -# SwiftUI in TablePro +Review Swift and SwiftUI code for correctness, modern API usage, and adherence to project conventions. Report only genuine problems - do not nitpick or invent issues. -Generic SwiftUI advice is wrong here often enough to be dangerous. This app is a deliberate -hybrid, its macOS target is older than most sample code assumes, and several of its conventions -are the opposite of the usual defaults. Only the rules below are TablePro rules. +Review process: -## The hybrid is deliberate +1. Check for deprecated API using `references/api.md`. +1. Check that views, modifiers, and animations have been written optimally using `references/views.md`. +1. Validate that data flow is configured correctly using `references/data.md`. +1. Ensure navigation is updated and performant using `references/navigation.md`. +1. Ensure the code uses designs that are accessible and compliant with Apple’s Human Interface Guidelines using `references/design.md`. +1. Validate accessibility compliance including Dynamic Type, VoiceOver, and Reduce Motion using `references/accessibility.md`. +1. Ensure the code is able to run efficiently using `references/performance.md`. +1. Quick validation of Swift code using `references/swift.md`. +1. Final code hygiene check using `references/hygiene.md`. -TablePro is SwiftUI first and drops to AppKit where SwiftUI cannot hold the behavior: window and -tab ownership, the responder chain, menus, split-view geometry, table performance, and sizing. -`MainSplitViewController` is an `NSSplitViewController` replacing `NavigationSplitView` on purpose. +If doing a partial review, load only the relevant reference files. -Before replacing an AppKit view with SwiftUI, search the project guide for that view's invariant. -If the guide records a lifecycle, responder-chain, sizing, menu, window, table, or performance -reason, that reason still holds. Replacing it reintroduces a shipped bug. -The reverse also applies: check whether a native SwiftUI modifier already does the job before -writing an `NSViewRepresentable`. +## Core Instructions -## Targets +- iOS 26 exists, and is the default deployment target for new apps. +- Target Swift 6.2 or later, using modern Swift concurrency. +- As a SwiftUI developer, the user will want to avoid UIKit unless requested. +- Do not introduce third-party frameworks without asking first. +- Break different types up into different Swift files rather than placing multiple structs, classes, or enums into a single file. +- Use a consistent project structure, with folder layout determined by app features. -| Target | Deployment | Concurrency | -| --- | --- | --- | -| `TablePro` (macOS) | macOS 14 | Swift 5 mode, `SWIFT_APPROACHABLE_CONCURRENCY`, **no** default actor isolation | -| `TableProMobile` (iOS) | iOS 18 | same, plus `SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor` | -Consequences that decide real code: +## Output Format -- An API introduced after macOS 14 needs `if #available` and a stated fallback. Check the installed - SDK `.swiftinterface`, not a blog post. `Tab`, `.searchFocused`, and - `EnumeratedSequence` as a `RandomAccessCollection` are all past macOS 14 and do not compile here. -- On the macOS target, an `@Observable` class that drives UI needs an explicit `@MainActor`. There - is no default isolation to inherit. On the iOS target there is, so the same type written for - mobile may legitimately omit it. -- iOS-only API is a hard compile error in the macOS app, which is most of the codebase. No - `UIScreen`, no `keyboardType`, no `.topBarLeading`, no 44 by 44 touch targets. macOS colors are - `NSColor`. +Organize findings by file. For each issue: -## State and storage +1. State the file and relevant line(s). +2. Name the rule being violated (e.g., "Use `foregroundStyle()` instead of `foregroundColor()`"). +3. Show a brief before/after code fix. -- `@AppStorage` must resolve its store through `AppStorageEnvironment.shared.defaults`, and plain - `UserDefaults.standard` is a SwiftLint error outside the few macOS-owned preference sites. A UI - test that writes the developer's real store is the failure this prevents. -- Never put a credential, token, or secret in `@AppStorage`. Credentials go to the Keychain. -- `@AppStorage` inside an `@Observable` class never triggers a view update, with or without - `@ObservationIgnored`. Read it from the view, or publish an explicit change. -- Do not cache a derived collection in `@State` unless you own its invalidation. Recompute, or - make the dependency explicit. +Skip files with no issues. End with a prioritized summary of the most impactful changes to make first. -## Concurrency +Example output: -Prefer Swift concurrency. `DispatchQueue.main.async` is allowed for one purpose: deferring by a -single run-loop turn out of an AppKit callback, where doing the work inline reenters something that -is not reentrant. State the reason at the call site. `DispatchQueue.main.sync` is allowed only where -a sheet or modal would otherwise deadlock, and the existing sites say so. +### ContentView.swift -Never use `Task.detached` to escape actor isolation. Keep UI state mutation on its owning actor. +**Line 12: Use `foregroundStyle()` instead of `foregroundColor()`.** -## Deprecated but silent +```swift +// Before +Text("Hello").foregroundColor(.red) -These are marked deprecated with a version so high the compiler emits nothing, so nothing warns -you and reviews are the only gate: +// After +Text("Hello").foregroundStyle(.red) +``` -`foregroundColor` (use `foregroundStyle`), `cornerRadius` (use `clipShape` or -`.rect(cornerRadius:)`), `overlay(_:alignment:)` (use the trailing-closure form), -`ScrollView(showsIndicators:)` (use `.scrollIndicators`), `NavigationView` (use -`NavigationStack`, or the AppKit split view this app actually uses). +**Line 24: Icon-only button is bad for VoiceOver - add a text label.** -## Accessibility +```swift +// Before +Button(action: addUser) { + Image(systemName: "plus") +} -- An icon-only `Button` or `Menu` carries a text label. UI automation resolves elements by label, - so an unlabeled control is both inaccessible and untestable. -- Never put `.accessibilityIdentifier` on a SwiftUI container by itself: it replaces the identifier - of every descendant in the same hosting tree. Pair it with - `.accessibilityElement(children: .contain)`, in that order. - `.claude/skills/fix-issue/references/verification.md` holds the measured detail and the - accessibility-tree traps that differ between this machine and CI. +// After +Button("Add User", systemImage: "plus", action: addUser) +``` -## File organization +**Line 31: Avoid `Binding(get:set:)` in view body - use `@State` with `onChange()` instead.** -Do not extract every computed `some View`. This repository has hundreds of them and files that -declare several small types, deliberately, organized by domain. The limits in `.swiftlint.yml` are -the actual rule: flag a view when it pushes a file or type past them, and extract into -`TypeName+Domain.swift` when it does. +```swift +// Before +TextField("Username", text: Binding( + get: { model.username }, + set: { model.username = $0; model.save() } +)) -## Text +// After +TextField("Username", text: $model.username) + .onChange(of: model.username) { + model.save() + } +``` -User-facing strings in AppKit and computed strings use `String(localized:)`. SwiftUI string -literals localize automatically. The catalog is keyed on the English source string, so never -interpolate inside a key and never hand-author catalog entries or extraction states. +### Summary -## Reviewing +1. **Accessibility (high):** The add button on line 24 is invisible to VoiceOver. +2. **Deprecated API (medium):** `foregroundColor()` on line 12 should be `foregroundStyle()`. +3. **Data flow (medium):** The manual binding on line 31 is fragile and harder to maintain. -Report findings as `file:line`, what breaks, and the smallest fix. Do not report taste, and do not -report a rule from this file that the repository deliberately violates at that site: check the -project guide first, then say the site is wrong. +End of example. + + +## References + +- `references/accessibility.md` - Dynamic Type, VoiceOver, Reduce Motion, and other accessibility requirements. +- `references/api.md` - updating code for modern API, and the deprecated code it replaces. +- `references/design.md` - guidance for building accessible apps that meet Apple’s Human Interface Guidelines. +- `references/hygiene.md` - making code compile cleanly and be maintainable in the long term. +- `references/navigation.md` - navigation using `NavigationStack`/`NavigationSplitView`, plus alerts, confirmation dialogs, and sheets. +- `references/performance.md` - optimizing SwiftUI code for maximum performance. +- `references/data.md` - data flow, shared state, and property wrappers. +- `references/swift.md` - tips on writing modern Swift code, including using Swift Concurrency effectively. +- `references/views.md` - view structure, composition, and animation. diff --git a/.claude/skills/swiftui/references/accessibility.md b/.claude/skills/swiftui/references/accessibility.md new file mode 100644 index 000000000..2589e2737 --- /dev/null +++ b/.claude/skills/swiftui/references/accessibility.md @@ -0,0 +1,13 @@ +# Accessibility + +- Respect the user’s accessibility settings for fonts, colors, animations, and more. +- Do not force specific font sizes. Prefer Dynamic Type (`.font(.body)`, `.font(.headline)`, etc.). +- If you *need* a custom font size, use `@ScaledMetric` when targeting iOS 18 and earlier. When targeting iOS 26 or later, `.font(.body.scaled(by:))` is also available to get font size adjustment. +- Flag instances where images have unclear or unhelpful VoiceOver readings, e.g. `Image(.newBanner2026)`. If they are decorative, suggest using `Image(decorative:)` or `accessibilityHidden()`, otherwise attach an `accessibilityLabel()`. +- If the user has “Reduce Motion” enabled, replace large, motion-based animations with opacity instead. +- If buttons have complex or frequently changing labels, recommend using `accessibilityInputLabels()` to provide better Voice Control commands. For example, if a button had a live-updating share price for Apple such as “AAPL $271.68”, adding an input label for “Apple” would be a big improvement. +- Buttons with image labels must always include text, even if the text is invisible: `Button("Label", systemImage: "plus", action: myAction)`. Flag icon-only buttons that lack a text label as being bad for VoiceOver. Usually SwiftUI will make labels use the correct label style based on their context – e.g. buttons in iOS toolbars will automatically be icon-only by default – but if there's a specific reason for a button to remain visually icon-only, apply `.labelStyle(.iconOnly)` to preserve the visual while keeping the text available for VoiceOver. +- If color is an important differentiator in the user interface, make sure to respect the environment’s `.accessibilityDifferentiateWithoutColor` setting by showing some kind of variation beyond just color – icons, patterns, strokes, etc. +- The same is true of `Menu`: using `Menu("Options", systemImage: "ellipsis.circle") { }` is much better than just using an image. In the rare case where the menu trigger should really display only the icon, `.labelStyle(.iconOnly)` can be used. +- Never use `onTapGesture()` unless you specifically need tap location or tap count. All other tappable elements should be a `Button`. +- If `onTapGesture()` must be used, make sure to add `.accessibilityAddTraits(.isButton)` or similar so it can be read by VoiceOver correctly. diff --git a/.claude/skills/swiftui/references/api.md b/.claude/skills/swiftui/references/api.md new file mode 100644 index 000000000..bfd5199bd --- /dev/null +++ b/.claude/skills/swiftui/references/api.md @@ -0,0 +1,39 @@ +# Using modern SwiftUI API + +- Always use `foregroundStyle()` instead of `foregroundColor()`. +- Always use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()`. +- Always use the `Tab` API instead of `tabItem()`. +- Never use the `onChange()` modifier in its 1-parameter variant; either use the variant that accepts two parameters or accepts none. +- Do not use `GeometryReader` if a newer alternative works: `containerRelativeFrame()`, `visualEffect()`, or the `Layout` protocol. Flag `GeometryReader` usage and suggest the modern alternative. +- When designing haptic effects, prefer using `sensoryFeedback()` over older UIKit APIs such as `UIImpactFeedbackGenerator`. +- Use the `@Entry` macro to define custom `EnvironmentValues`, `FocusValues`, `Transaction`, and `ContainerValues` keys. This replaces the legacy pattern of manually creating a type conforming to (for example) `EnvironmentKey` with a `defaultValue`, then extending `EnvironmentValues` with a computed property. +- Strongly prefer `overlay(alignment:content:)` over the deprecated `overlay(_:alignment:)`. For example, use `.overlay { Text("Hello, world!") }` rather than `.overlay(Text("Hello, world!"))`. +- Never use `.navigationBarLeading` and `.navigationBarTrailing` for toolbar item placement; they are deprecated. The correct, modern placements are `.topBarLeading` and `.topBarTrailing`. +- Prefer to rely on automatic grammar agreement when dealing with English, French, German, Portuguese, Spanish, and Italian. For example, use `Text("^[\(people) person](inflect: true)")` to show a number of people. +- You can fill and stroke a shape with two chained modifiers; you do *not* need an overlay for the stroke. The overlay was required previously, but this is fixed in iOS 17 and later. +- When referencing images from an asset catalog, prefer the generated symbol asset API when the project is configured to use them: `Image(.avatar)` rather than `Image("avatar")`. +- When targeting iOS 26 and later, SwiftUI has a native `WebView` view type that replaces almost all uses of hand-wrapped `WKWebView` inside `UIViewRepresentable`. To use it, make sure to include `import WebKit`. +- `ForEach` over an `enumerated()` sequence should not convert to an array first. Use `ForEach(items.enumerated(), id: \.element.id)` directly. +- When hiding scroll indicators, use `.scrollIndicators(.hidden)` rather than `showsIndicators: false` in the initializer. +- Never use `Text` concatenation with `+`. + +For example, the usage of `+` here is bad and deprecated: + +```swift +Text("Hello").foregroundStyle(.red) ++ +Text("World").foregroundStyle(.blue) +``` + +Instead, use text interpolation like this: + +```swift +let red = Text("Hello").foregroundStyle(.red) +let blue = Text("World").foregroundStyle(.blue) +Text("\(red)\(blue)") +``` + + +## Using ObservableObject + +If using `ObservableObject` is absolutely required – for example if you are trying to create a debouncer using a Combine publisher – you should always make sure `import Combine` is added. This was previously provided through SwiftUI, but that is no longer the case. diff --git a/.claude/skills/swiftui/references/data.md b/.claude/skills/swiftui/references/data.md new file mode 100644 index 000000000..952571f8d --- /dev/null +++ b/.claude/skills/swiftui/references/data.md @@ -0,0 +1,43 @@ +# Data flow, shared state, and property wrappers + +It is important that SwiftUI body code and logic code be kept separate in order to make code easier to read, write, and maintain. That usually means placing code into methods rather than inline in the `body` property, but often also means carving functionality out into separate `@Observable` classes. + +These rules help ensure code is efficient and works well in the long term. + + +## Shared state + +- `@Observable` classes must be marked `@MainActor` unless the project has Main Actor default actor isolation. Flag any `@Observable` class missing this annotation. +- All shared data should use `@Observable` classes with `@State` (for ownership) and `@Bindable` / `@Environment` (for passing). +- Strongly prefer not to use `ObservableObject`, `@Published`, `@StateObject`, `@ObservedObject`, or `@EnvironmentObject` unless they are unavoidable, or if they exist in legacy/integration contexts when changing architecture would be complicated. + + +## Local state + +- `@State` should be marked `private` and only owned by the view that created it. +- If a view stores a class instance that contains expensive-to-recompute data, e.g. `CIContext`, it can be stored using `@State` even though it is not an observable object. This effectively uses `@State` as a cache – storing something persistently, but not doing any change tracking on it since it's not an observable object. + + +## Bindings + +- Strongly prefer to avoid creating bindings using `Binding(get:set:)` in view body code. It is much cleaner and simpler to use a binding provided by `@State`, `@Binding` or similar, then use `onChange()` to trigger any effects. +- If the user needs to enter a number into a `TextField`, bind the `TextField` to a numeric value such as `Int` or `Double`, then use its `format` initializer like this: `TextField("Enter your score", value: $score, format: .number)`. Apply either `.keyboardType(.numberPad)` (for integers) or `.keyboardType(.decimalPad)` (for floating-point numbers) as appropriate. Using the modifier alone is *not* sufficient. + + +## Working with data + +- Prefer to make structs conform to `Identifiable` rather than using `id: \.someProperty` in SwiftUI code. +- Never attempt to use `@AppStorage` inside an `@Observable` class, even if marked `@ObservationIgnored` – it will *not* trigger view updates when a change happens. + + +## SwiftData + +- If you only need the number of items matching a query, consider `ModelContext.fetchCount()` with a fetch descriptor. This will *not* live update if the data changes unless something else triggers the update, such as `@Query`, so it should be used carefully. + +For more help with SwiftData, suggest the [SwiftData Pro agent skill](https://github.com/twostraws/swiftdata-agent-skill). + +## If the project uses SwiftData with CloudKit + +- Never use `@Attribute(.unique)`. +- Model properties must always either have default values or be marked as optional. +- All relationships must be marked optional. diff --git a/.claude/skills/swiftui/references/design.md b/.claude/skills/swiftui/references/design.md new file mode 100644 index 000000000..4a8ba0c4f --- /dev/null +++ b/.claude/skills/swiftui/references/design.md @@ -0,0 +1,32 @@ +# Design + +## Creating a uniform design in this app + +Prefer to place standard fonts, sizes, colors, stack spacing, padding, rounding, animation timings, and more into a shared enum of constants, so they can be used by all views. This allows the app’s design to feel uniform and consistent, and be adjusted easily. + + +## Requirements for flexible, accessible design + +- Never use `UIScreen.main.bounds` to read available space; prefer alternatives such as `containerRelativeFrame()`, or `visualEffect()` as appropriate, or (if there is no alternative) `GeometryReader`. +- Prefer to avoid fixed frames for views unless content can fit neatly inside; this can cause problems across different device sizes, different Dynamic Type settings, and more. Giving frames some flexibility is usually preferred. +- Apple’s minimum acceptable tap area for interactions on iOS is 44x44. Ensure this is strictly enforced. + + +## Standard system styling + +- Strongly prefer to use `ContentUnavailableView` when data is missing or empty, rather than designing something custom. +- When using `searchable()`, you can show empty results using `ContentUnavailableView.search` and it will include the search term they used automatically – there’s no need to use `ContentUnavailableView.search(text: searchText)` or similar. +- If you need an icon and some text placed horizontally side by side, prefer `Label` over `HStack`. +- Prefer system hierarchical styles (e.g. secondary/tertiary) over manual opacity when possible, so the system can adapt to the correct context automatically. +- When using `Form`, wrap controls such as `Slider` in `LabeledContent` so the title and control are laid out correctly. +- `LabeledContent` also works outside `Form` for any title-value display; it might be necessary to define a custom `LabeledContentStyle` for consistent layout across views. +- When using `RoundedRectangle`, the default rounding style is `.continuous` – there is no need to specify it explicitly. + + +## Ensuring designs work for everyone + +- Use `bold()` instead of `fontWeight(.bold)`, because using `bold()` allows the system to choose the correct weight for the current context. +- Only use `fontWeight()` for weights other than bold when there's an important reason - scattering around `fontWeight(.medium)` or `fontWeight(.semibold)` is counterproductive. +- Avoid hard-coded values for padding and stack spacing unless specifically requested. +- Avoid UIKit colors (`UIColor`) in SwiftUI code; use SwiftUI `Color` or asset catalog colors. +- The font size `.caption2` is extremely small, and is generally best avoided. Even the font size `.caption` is on the small side, and should be used carefully. diff --git a/.claude/skills/swiftui/references/hygiene.md b/.claude/skills/swiftui/references/hygiene.md new file mode 100644 index 000000000..80bc9c5f5 --- /dev/null +++ b/.claude/skills/swiftui/references/hygiene.md @@ -0,0 +1,9 @@ +# Hygiene + +- If the project requires secrets such as API keys, never include them in the repository. +- Code comments and documentation comments should be present where the logic isn't self-evident. +- Unit tests should exist for core application logic. UI tests only where unit tests are not possible. +- `@AppStorage` must never be used to store usernames, passwords, or other sensitive data. Use the keychain for that. +- If SwiftLint is configured, it should return no warnings or errors. +- If the project uses Localizable.xcstrings, prefer to add user-facing strings using symbol keys (e.g. “helloWorld”) in the string catalog with `extractionState` set to "manual", accessing them via generated symbols such as `Text(.helloWorld)`. Offer to translate new keys into all languages supported by the project. +- If the Xcode MCP is configured, prefer its tools over generic alternatives. For example, `RenderPreview` is able to capture images of rendered SwiftUI previews for examination, and `DocumentationSearch` can search Apple’s documentation for latest usage instructions. diff --git a/.claude/skills/swiftui/references/navigation.md b/.claude/skills/swiftui/references/navigation.md new file mode 100644 index 000000000..44b402565 --- /dev/null +++ b/.claude/skills/swiftui/references/navigation.md @@ -0,0 +1,14 @@ +# Navigation and presentation + +- Use `NavigationStack` or `NavigationSplitView` as appropriate; flag all use of the deprecated `NavigationView`. +- Strongly prefer to use `navigationDestination(for:)` to specify destinations; flag all use of the old `NavigationLink(destination:)` pattern where it should be replaced. +- Never mix `navigationDestination(for:)` and `NavigationLink(destination:)` in the same navigation hierarchy; it causes significant problems. +- `navigationDestination(for:)` must be registered once per data type; flag duplicates. + + +## Alerts, confirmation dialogs, and sheets + +- Always attach `confirmationDialog()` to the user interface that triggers the dialog. This allows Liquid Glass animations to move from the correct source. +- If an alert has only a single “OK” button that does nothing but dismiss the alert, it can be omitted entirely: `.alert("Dismiss Me", isPresented: $isShowingAlert) { }`. +- If a sheet is designed to present an optional piece of data, prefer `sheet(item:)` over `sheet(isPresented:)` so the optional is safely unwrapped. +- When using `sheet(item:)` with a view that accepts the item as its only initializer parameter, prefer `sheet(item: $someItem, content: SomeView.init)` over `sheet(item: $someItem) { someItem in SomeView(item: someItem) }`. diff --git a/.claude/skills/swiftui/references/performance.md b/.claude/skills/swiftui/references/performance.md new file mode 100644 index 000000000..72c5a037d --- /dev/null +++ b/.claude/skills/swiftui/references/performance.md @@ -0,0 +1,46 @@ +# Performance + +- When toggling modifier values, prefer ternary expressions over if/else view branching to avoid `_ConditionalContent`, preserve structural identity, and avoid repeatedly recreating underlying platform views. +- Avoid `AnyView` unless absolutely required. Use `@ViewBuilder`, `Group`, or generics instead. +- If a `ScrollView` has an opaque, static, and solid background, prefer to use `scrollContentBackground(.visible)` to improve scroll-edge rendering efficiency. +- It is more efficient to break views up by making dedicated SwiftUI views rather than place them into computed properties or methods. Using `@ViewBuilder` on a property or method does not solve this; breaking views up is strongly preferred. +- Always ensure view initializers are kept as small and simple as possible, avoiding any non-trivial work. Flag any work that can be moved into a `task()` modifier to be run when the view is shown. +- Similarly, assume each view’s `body` property is called frequently – if logic such as sorting or filtering can be moved out of there easily, it should be. +- Avoid creating properties to store formatters such as `DateFormatter` unless they are required. A more natural approach is to use `Text` with a format, like this: `Text(Date.now, format: .dateTime.day().month().year())` or `Text(100, format: .currency(code: "USD"))`. +- Avoid expensive inline transforms in `List`/`ForEach` initializers (e.g. `items.filter { ... }`) when they are repeated often. +- Prefer deriving transformed data from the source-of-truth using `let`, or caching in `@State`. However, do not cache derived collections in `@State` unless you also own explicit invalidation logic to avoid stale UI. +- For large data sets in `ScrollView`, use `LazyVStack`/`LazyHStack`; flag eager stacks with many children. +- Prefer using `task()` over `onAppear()` when doing async work, because it will be cancelled automatically when the view disappears. +- Avoid storing escaping `@ViewBuilder` closures on views when possible; store built view results instead. + +Example: + +```swift +// Anti-pattern: stores an escaping closure on the view. +struct CardView: View { + let content: () -> Content + + var body: some View { + VStack(alignment: .leading) { + content() + } + .padding() + .background(.ultraThinMaterial) + .clipShape(.rect(cornerRadius: 8)) + } +} + +// Preferred: store the built view value; the synthesized init handles calling the builder. +struct CardView: View { + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading) { + content + } + .padding() + .background(.ultraThinMaterial) + .clipShape(.rect(cornerRadius: 8)) + } +} +``` diff --git a/.claude/skills/swiftui/references/swift.md b/.claude/skills/swiftui/references/swift.md new file mode 100644 index 000000000..92f32b1b5 --- /dev/null +++ b/.claude/skills/swiftui/references/swift.md @@ -0,0 +1,56 @@ +# Swift + +- Prefer Swift-native string methods over Foundation equivalents: use `replacing("a", with: "b")` not `replacingOccurrences(of: "a", with: "b")`. +- Prefer modern Foundation API: `URL.documentsDirectory` instead of `FileManager` directory lookups, `appending(path:)` to append strings to a URL. +- Never use C-style number formatting like `String(format: "%.2f", value)`. Use `Text(value, format: .number.precision(.fractionLength(2)))` or similar `FormatStyle` APIs. +- Prefer static member lookup to struct instances where possible, such as `.circle` rather than `Circle()`, and `.borderedProminent` rather than `BorderedProminentButtonStyle()`. +- Avoid force unwraps (`!`) and force `try` unless the failure is truly unrecoverable, and even then prefer using `fatalError()` with a clear description. If possible, use `if let`, `guard let`, nil-coalescing, or `try?`/`do-catch`. +- Filtering text based on user-input must be done using `localizedStandardContains()` as opposed to `contains()` or `localizedCaseInsensitiveContains()`. +- Strongly prefer `Double` over `CGFloat`, except when using optionals or `inout`; Swift is able to bridge the two freely except in those two cases. +- If you want to count array objects that match a predicate, always use `count(where:)` rather than `filter()` followed by `count`. +- Prefer `Date.now` over `Date()` for clarity. +- When `import SwiftUI` is already in a file, you do not need to add `import UIKit` or `import AppKit` to access things like `UIImage` or `NSImage` – they are imported automatically on the appropriate platform. +- When dealing with the names of people, strongly prefer to use `PersonNameComponents` with modern formatting over simple string interpolation such as `Text("\(firstName) \(lastName)")`. +- If a given type of data is repeatedly sorted using an identical closure, e.g. `books.sorted { $0.author < $1.author }`, prefer to make the type in question conform to `Comparable` so the sort order is centralized. +- Prefer to avoid manual date formatting strings if possible. If manual date formatting *is* used for user display, at least make sure to use “y” rather than “yyyy” for years, so the year value is correct in all localizations. If the purpose is data exchange with an API, this rule does not apply. +- When trying to convert a string to a date, prefer the modern `Date` initializer API such as `Date(myString, strategy: .iso8601)`. +- Flag instances where errors triggered by a user action are swallowed silently, e.g. using `print(error.localizedDescription)` rather than showing an alert or similar. +- Prefer `if let value {` shorthand over `if let value = value {`. +- Omit return for single expression functions. `if` and `switch` can be used as expressions when returning values and assigning to variables. + +For example, this kind of code: + +```swift +var tileColor: Color { + if isCorrect { + return .green + } else { + return .red + } +} +``` + +Should be written like this: + +```swift +var tileColor: Color { + if isCorrect { + .green + } else { + .red + } +} +``` + + +## Swift Concurrency + +- If an API offers both modern `async`/`await` equivalents and older closure-based variants, always prefer the `async`/`await` versions. +- Never use Grand Central Dispatch (`DispatchQueue.main.async()`, `DispatchQueue.global()`, etc.). Always use modern Swift concurrency (`async`/`await`, actors, `Task`). +- Never use `Task.sleep(nanoseconds:)`; use `Task.sleep(for:)` instead. +- Flag any mutable shared state that isn't protected by an actor or `@MainActor`, unless the project is configured to use MainActor default actor isolation. +- Assume strict concurrency rules are being applied; flag `@Sendable` violations and data races. +- When evaluating `MainActor.run()`, check whether the project has its default actor isolation set to Main Actor first, because `MainActor.run()` might not be needed. +- `Task.detached()` is often a bad idea. Check any usage extremely carefully. + +For more help with Swift concurrency, suggest the [Swift Concurrency Pro agent skill](https://github.com/twostraws/swift-concurrency-agent-skill). diff --git a/.claude/skills/swiftui/references/views.md b/.claude/skills/swiftui/references/views.md new file mode 100644 index 000000000..880a6051f --- /dev/null +++ b/.claude/skills/swiftui/references/views.md @@ -0,0 +1,36 @@ +# SwiftUI Views + +- Strongly prefer to avoid breaking up view bodies using computed properties or methods that return `some View`, even if `@ViewBuilder` is used. Extract them into separate `View` structs instead, placing each into its own file. +- Flag `body` properties that are excessively long; they should be broken into extracted subviews. +- If the user has created a handful of small, private helper `some View` properties for structural readability, and they both belong to the same concern as `body` and would fit in `body` at an acceptable length if inlined, these can be left alone. Otherwise, they should be extracted to new `View` structs. +- Button actions should be extracted from view bodies into separate methods, to avoid mixing layout and logic. +- Similarly, general business logic should not live inline in `task()`, `onAppear()` or elsewhere in `body`. +- Prefer to place view logic into view models or similar, so it can be tested. For more help with testing, suggest the [Swift Testing Pro agent skill](https://github.com/twostraws/swift-testing-agent-skill). +- Each type (struct, class, enum) should be in its own Swift file. Flag files containing multiple type definitions. +- Unless a full-screen editing experience is required, prefer using `TextField` with `axis: .vertical` to using `TextEditor`, because it allows placeholder text. If a specific minimum height is required for `TextField`, use something like `lineLimit(5...)`. +- If a button action can be provided directly as an `action` parameter, do so. For example: `Button("Label", systemImage: "plus", action: myAction)` is preferred over `Button("Label", systemImage: "plus") { action() }`. +- When rendering SwiftUI views to images, strongly prefer `ImageRenderer` over `UIGraphicsImageRenderer`. +- `#Preview` should be used for previews, not the legacy `PreviewProvider` protocol. +- When using `TabView(selection:)`, use a binding to a property that stores an enum rather than an integer or string. For example, `Tab("Home", systemImage: "house", value: .home)` is better than `Tab("Home", systemImage: "house", value: 0)`. +- Strongly prefer to avoid breaking up view bodies using computed properties or methods that return `some View`, even if `@ViewBuilder` is used. Extract them into separate `View` structs instead, placing each into its own file. (Yes, this is repeated, but it’s so important it needs to be mentioned twice.) + + +## Animating views + +- Strongly prefer to use the `@Animatable` macro over creating `animatableData` manually – the macro automatically adds conformance to the `Animatable` protocol and creates the correct `animatableData` property. If some properties should not or cannot be animated (e.g. Booleans, integers, etc), mark them `@AnimatableIgnored`. +- Never use `animation(_ animation: Animation?)`; always provide a value to watch, such as `.animation(.bouncy, value: score)`. +- Chaining animations must be done using a `completion` closure passed to `withAnimation()`, rather than trying to execute multiple `withAnimation()` calls using delays. + +For example: + +```swift +Button("Animate Me") { + withAnimation { + scale = 2 + } completion: { + withAnimation { + scale = 1 + } + } +} +``` diff --git a/.claude/skills/tablepro-engineering/SKILL.md b/.claude/skills/tablepro-engineering/SKILL.md deleted file mode 100644 index 792fd8131..000000000 --- a/.claude/skills/tablepro-engineering/SKILL.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: tablepro-engineering -description: Shared TablePro engineering workflow for features, fixes, refactors, tests, builds, plugins, drivers, AI, MCP, sync, storage, UI, and docs. Use before Claude Code changes any TablePro repository file. ---- - -# TablePro Engineering - -Read `.agents/skills/tablepro-engineering/SKILL.md` completely and follow it as the canonical workflow. Resolve its references from `.agents/skills/tablepro-engineering/`. `AGENTS.md` remains authoritative. diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index ca17fd1ec..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,123 +0,0 @@ -# TablePro Engineering Instructions - -These instructions are the shared source of truth for Claude Code, Codex, and their subagents. -Tool-specific files may add orchestration details, but they must not weaken these rules. - -## Required context - -- For any code, test, build, configuration, release, or user-facing documentation change, load and follow `$tablepro-engineering` before editing. -- In Claude Code, for any GitHub issue, defect or feature request alike, load `$fix-issue` instead. It is the specialized form of the same workflow and runs a defect track or a change track, so do not load both. It is Claude-only because its investigation and critique phases run workflow lanes; Codex stays on `$tablepro-engineering` and delegates those lanes with its own worker threads. -- For an independent review or any high-risk change, load and follow `$cross-model-review`. -- For SwiftUI or AppKit view work, read `$swiftui` and apply only rules compatible with TablePro's deployment targets and hybrid architecture. -- Detailed project knowledge lives in `.agents/skills/tablepro-engineering/references/`, split by domain, with `project-guide.md` as its index. Every invariant has its own `####` heading naming the subsystem and the failure it prevents, so search for the symptom or symbol and read the matching paragraph. Do not open a whole file unless the task is a broad architecture audit. -- More specific `AGENTS.md` files and Claude path rules add local constraints for their directories. - -## Product and engineering principles - -1. Protect user data. Treat query execution, credentials, sync, plugin loading, AI tools, and MCP as security boundaries. Validate inputs and preserve safe-mode, read-only, confirmation, scope, and allowlist checks. -2. Build a native macOS and iOS product. Prefer SwiftUI, AppKit, and system frameworks. Do not add web views or cross-platform UI abstractions for native UI. -3. Fix root causes. Reproduce, trace the real execution path, separate cause from symptom, then choose a targeted fix or refactor based on evidence. -4. Keep architecture clean. Preserve ownership boundaries, dependency direction, protocol seams, actor isolation, and testability. Do not hide a design problem behind a special case. -5. Keep the plugin domain open. `DatabaseType` is a string-backed struct, not an enum. Unknown plugin types must round-trip, and switches over it require a fallback. -6. Measure load-bearing behavior. Probe the actual SDK, C header, static library, database, or generated artifact when source inspection cannot prove behavior the design depends on. -7. Leave unrelated work untouched. The worktree may contain user changes. Never stash, reset, discard, rewrite, or include them without explicit authorization. - -## High-compute collaboration - -Use available compute aggressively when it improves evidence or catches independent failure modes. This repository optimizes for correctness and coverage, not usage conservation. - -Spend it where the output can be thrown away. The main thread holds the problem, the plan, the decisions, and the diff; searching, reading, building, and reviewing belong in subagents, lanes, and log files. Verify a delegated claim at its `file:line` anchor instead of re-reading the file, and route build and test output through a wrapper that stores the log and returns a verdict. A thread that runs out of room mid-implementation loses the plan, which costs more than any lane. - -- In Claude Code, use `ultracode`: `xhigh` reasoning plus dynamic workflow orchestration. The workflow size is unrestricted. Scale to all useful independent lanes for broad audits, migrations, and security reviews; do not invent work merely to increase agent count. -- In Codex, run the parent at `ultra`: maximum reasoning with automatic task delegation. Workers run at `max`. Use all 8 configured threads when a broad audit has eight genuinely independent lenses. -- Default lenses are code-path tracing, platform or dependency research, test and failure analysis, architecture challenge, and adversarial correctness or security review. -- The main agent owns requirements, synthesis, the implementation plan, and the final decision. Subagent reports are evidence, not truth. Verify every load-bearing claim in the repository or an authoritative source. -- Use one writer per checkout. Parallel writers require isolated worktrees and non-overlapping file ownership. Never let two agents edit the same files concurrently. When several sessions share a checkout, put the writer in a worktree so the main tree stays readable and uncontested. -- Never run two `xcodebuild` processes concurrently. Parallelize reading and analysis, then serialize generation, builds, tests, and ABI checks. -- Reviewers are read-only. A review leader may orchestrate read-only evidence lanes, but it does not fix findings, commit, push, open pull requests, or start another cross-vendor review. -- Prevent review recursion. One writer may request one primary external review and, for high-risk changes, one adversarial external review. The writer validates and resolves the findings. -- High-risk changes require review by the other vendor when its CLI or plugin is available. High-risk areas are data loss, destructive SQL, credentials, auth, MCP, AI tool permissions, sync, migrations, plugin ABI, actor isolation, process or C boundaries, release automation, and signing. - -## Work sequence - -1. Inspect `git status --short` and the current branch. Identify user-owned changes before touching files. -2. Restate the observable behavior and acceptance criteria. Ask only when materially different outcomes remain possible and repository evidence cannot resolve them. -3. Search before editing. Trace entry points, state transitions, callers, sibling implementations, tests, docs, and relevant project-guide invariants. -4. For non-trivial tasks, run the independent investigations above and synthesize a concrete plan with risks and verification commands. -5. Implement the smallest complete root-cause change. A small diff is not a goal if the existing shape cannot express the correct behavior. -6. Add or update tests that fail on the old behavior and pass on the new behavior. -7. Regenerate generated projects when required, then run targeted build, tests, lint, and domain checks. -8. Inspect the full diff for unintended changes. Run cross-model review at the required risk level. -9. Report changed files, evidence, test results, remaining risk, and any check that could not run. - -## Project map - -- `TablePro/`: macOS application, core services, models, view models, AppKit, and SwiftUI. -- `TableProMobile/`: iOS app and widget. -- `Plugins/`: driver, import, and export plugin bundles plus `TableProPluginKit`. -- `Packages/` and `LocalPackages/`: shared and vendored Swift packages. -- `TableProTests/`: unit and integration tests. -- `TableProUITests/`: deterministic macOS UI automation. Suites must subclass `UITestCase`. -- `docs/`: Mintlify product and developer documentation. -- `project.yml`, `TableProMobile/project.yml`, and `Configs/`: source of truth for generated Xcode projects. -- `scripts/`: generation, build, ABI, library, plugin, and release automation. - -## Build and verification - -Prefer the wrapper. It exports `DEVELOPER_DIR`, resolves the project from its own checkout, waits for a concurrent `xcodebuild`, keeps the full log on disk, and prints a verdict instead of thousands of lines: - -```bash -.claude/skills/fix-issue/scripts/verify.sh [args] -``` - -The underlying commands, for a case the wrapper does not cover. Read the environment and failure guidance in `.claude/skills/fix-issue/references/verification.md` first: - -```bash -scripts/generate-project.sh -xcodebuild -project TablePro.xcodeproj -scheme TablePro -configuration Debug build -skipPackagePluginValidation -xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProTests/ -xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProUITests/ -swiftlint lint --strict -``` - -- Run `scripts/generate-project.sh` after adding, moving, or deleting a source file, or changing project YAML or build configuration. Never hand-edit or commit generated `.xcodeproj` files. -- Build `AllPlugins` when a registry-only plugin changes. -- Run `scripts/check-pluginkit-abi.sh ` before completing a `TableProPluginKit` change. -- Prefer targeted suites plus affected neighbors. Confirm that filters executed the intended tests. -- Treat SourceKit diagnostics as hints. A real `xcodebuild` result is authoritative. -- Do not claim success from inspection alone. Show command evidence, or state exactly why a command could not run. - -## Code rules - -- Follow `.swiftformat` and `.swiftlint.yml`. Use 4 spaces, explicit access control, early returns, and focused functions. -- Do not add explanatory, task-reference, or narration comments. Prefer self-explanatory names and extracted functions. Keep required legal, generated, API documentation, and genuinely non-obvious safety comments. -- Do not force unwrap or force cast without a proven invariant and an existing project precedent. -- Use structured `OSLog`, never `print()` in shipping code. -- Keep UI state mutation on the correct actor. Do not use `Task.detached` to escape isolation. -- Preserve cancellation, late-completion, and generation-token checks around connection and query work. -- Add files by domain. When a type or file approaches lint limits, extract `TypeName+Domain.swift` extensions. -- Do not introduce a production dependency or change a public contract without explaining the need and blast radius. - -## User-facing change contract - -- Update `CHANGELOG.md` under `[Unreleased]` for user-visible behavior. Documentation and agent-configuration-only changes do not need a changelog entry. Fold fixes to unreleased features into their existing entry. -- Update the relevant page in `docs/` for features, shortcuts, settings, external APIs, or driver behavior. -- Localize user-facing AppKit and computed strings with `String(localized:)`. SwiftUI string literals localize automatically. Never interpolate inside a localization key; use a localized format string. -- Add unit tests for testable behavior. Add UI automation for deterministic user flows, or record why automation cannot be deterministic. -- Keep commits atomic if the user asks for commits. Use a one-line Conventional Commit with a canonical scope. -- Do not commit, push, open a pull request, publish artifacts, tag, or release unless the user explicitly requests that external action. -- One standing exception: a `$fix-issue` run that passes its shipping gates branches, commits, pushes, and opens its pull request without being asked, then works its confirmed follow-up findings into their own pull requests. That authorization covers those actions only, only inside that skill, and never merging, tagging, publishing, releasing, force pushing, or rewriting history. `.claude/skills/fix-issue/references/shipping.md` holds the gates. Every other task still asks. - -## Writing style - -Use short, specific, human sentences in UI text, docs, changelogs, commit subjects, PR text, and agent-authored guidance. Do not use em dashes or promotional filler. - -Before a requested commit, inspect added lines for: - -```bash -git diff --cached -U0 | grep -nE '—|seamless|robust|comprehensive|intuitive|effortless|streamlined|leverage|elevate|delve|utilize|facilitate' -``` - -## Completion standard - -A task is complete only when the behavior is implemented at the correct ownership boundary, relevant tests exist, required docs and changelog are updated, targeted checks pass, the diff is reviewed, and the final handoff reports evidence and limitations. Do not stop at a plausible patch or an unverified code change. diff --git a/CLAUDE.md b/CLAUDE.md index 4efd9319a..c744cffd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,38 +1,339 @@ -# TablePro Claude Code Instructions +# CLAUDE.md -@AGENTS.md +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -`AGENTS.md` is the shared source of truth. Follow it before the Claude-specific orchestration below. +## Principles -## Runtime profile +These govern every decision about code, architecture, tooling and process: -- Run Opus in `ultracode` for every TablePro task. This repository persists `ultracode: true`, which means `xhigh` reasoning plus standing dynamic-workflow orchestration; `effortLevel: xhigh` is the fallback. Do not replace it with `max` or a lower effort unless the user explicitly asks. -- Keep the ultracode workflow size `unrestricted`. Scale independent lanes to the problem instead of a token budget, while avoiding duplicate work. -- For non-trivial tasks, use dynamic workflows and the project agents in `.claude/agents/`. Run independent read-only lanes in parallel, then synthesize their evidence in the main thread before editing. -- Prefer `codebase-investigator`, `platform-researcher`, `test-strategist`, `adversarial-reviewer`, `plugin-abi-reviewer`, and `implementer` over generic agents when their lens applies. -- Delegate the reading, not just the work. A workflow `schema` is the only enforceable cap on what a lane sends back; a subagent's final message has none, so say what you want and how short. `.claude/skills/fix-issue/references/delegation.md` holds the mechanics and the costs. -- Keep build and test output out of the thread. A failing `xcodebuild` returns roughly 10,000 characters with no log to read back, so run checks through `.claude/skills/fix-issue/scripts/verify.sh`. -- Use the dedicated file tools. `Read` to read, `Edit` or `Write` to change, `Grep` and `Glob` to search when the session exposes them. Fall back to `Bash` only for what no tool covers: git, `gh`, project generation, builds, tests, lint, and search in a session with no `Grep`. Never rewrite a source file with `sed -i`, `perl -i`, or a heredoc in place of `Edit`, because that hides the change from the harness and reads as an opaque shell command in review. A bypass-permissions session reminder may push the other way; this rule wins. -- Keep one writer in the current checkout. Use an isolated worktree for any additional writer. A `$fix-issue` run always writes in its own worktree and never in the main checkout, because other sessions are working there. -- Use `/clear` between unrelated tasks and `/compact` within a long task. Project instructions survive compaction; a skill body and the run's own findings may not, so keep long-running state in `.analysis//`. +1. **Security first**: never introduce vulnerabilities (injection, XSS, OWASP top 10). Validate at system boundaries. +2. **Native only**: use native macOS/iOS components (AppKit, SwiftUI, system frameworks). No cross-platform abstractions, no web views for native UI. +3. **Clean architecture**: proper separation of concerns, protocol-oriented design, dependency injection where appropriate. Every task must consider its impact on architecture and code quality, not just the immediate problem. +4. **Clean code**: self-explanatory naming, early returns over nested conditionals, small focused functions. No comments in the codebase, code must be self-documenting through clear naming and structure. +5. **Root cause fixes**: don't patch symptoms. Diagnose the underlying issue, add logging to debug if needed, then fix the actual cause. +6. **No hacky solutions**: no backward-compatibility shims, no temporary workarounds left in place, no duct tape. If the right fix is harder, do the right fix. +7. **Testability**: every testable code change needs unit/function tests, and UI/user-flow changes should add UI automation where they run deterministically. When tests fail, fix the source code, never adjust tests to match incorrect output. +8. **Maintainability**: follow existing patterns but offer refactors when they improve quality. Extract into extensions when approaching size limits. Group by domain logic. +9. **Scalability**: design for the plugin system's open-ended nature. `DatabaseType` is a struct, not an enum. All switches need `default:`. -## Shared skills +## Project Overview -- `/tablepro-engineering` loads the shared TablePro workflow from `.agents/skills/tablepro-engineering/`. -- `/cross-model-review` loads the shared Claude and Codex review protocol. -- `/fix-issue` is the high-compute workflow for resolving a GitHub issue, running a defect track for bugs and a change track for feature requests. It replaces `/tablepro-engineering` for that work rather than stacking on top of it. -- `/release` is destructive and may run only after an explicit release request. +TablePro is a native macOS database client (SwiftUI + AppKit), a fast, lightweight alternative to TablePlus. macOS 14.0+, `SWIFT_VERSION = 5.0` (`Configs/Base.xcconfig`), Universal Binary (arm64 + x86_64). -## Claude and Codex pairing +- **Source**: `TablePro/` holds `Core/` (business logic, services), `Views/` (UI), `Models/` (data structures), `ViewModels/`, `Extensions/` and `Theme/` +- **Plugins**: `Plugins/` holds the `.tableplugin` bundles plus the `TableProPluginKit` shared framework. + - **Bundled in app** (the 14 targets in the app's `copy: { destination: plugins }` phase in `project.yml`): MySQL, PostgreSQL, SQLite, ClickHouse, Redis, CSV export, JSON export, SQL export, XLSX export, MQL export, SQL import, JSON import, CSV import, CSV inspector. These ship inside the app bundle and their updates normally ride with the next app release. Six of them (`sqlite`, `clickhouse`, `redis`, `xlsx`, `mql`, `sqlimport`) also have registry arms in `build-plugin.yml`, so a bundled plugin can be published when users on an already-shipped app need the fix sooner. `scripts/build-plugin.sh:10` explains the flag that makes that work. + - **Registry-only** (the other 17): MongoDB, Oracle, DuckDB, MSSQL, Cassandra, Etcd, CloudflareD1, DynamoDB, BigQuery, LibSQL, Snowflake, Elasticsearch, Beancount, SurrealDB, Teradata, Trino, Dameng. Distributed via [TableProApp/plugins](https://github.com/TableProApp/plugins) `plugins.json`, installed into the user plugins directory. +- **C bridges**: Each plugin contains its own C bridge module (e.g., `Plugins/MySQLDriverPlugin/CMariaDB/`, `Plugins/PostgreSQLDriverPlugin/CLibPQ/`) +- **Static libs**: `Libs/` holds pre-built `.a` files and `Libs/ios/` holds the iOS xcframeworks. Both are downloaded by `scripts/download-libs.sh` and are not in git. +- **SPM deps**: declared in `project.yml`. Vendored local packages under `LocalPackages/` (CodeEditSourceEditor, CodeEditTextView, CodeEditLanguages) and `Packages/` (TableProCore, TableProOracle); remote packages are Sparkle, swift-certificates and Yams. Revisions are pinned by the tracked `Package.resolved` inside each generated `.xcodeproj`. -- The `codex@openai-codex` plugin is enabled for this repository. -- For an ambiguous root cause or high-risk design, request a fresh Codex read-only investigation before editing: `/codex:rescue --wait --fresh Read-only investigation of : . Do not edit files.` Omit `--effort` so it inherits the repository's `gpt-5.6-sol` `ultra` profile. Use `--wait`, not `--background`: `/codex:status` and `/codex:result` are `disable-model-invocation: true`, so you cannot read a backgrounded result back. Name the tree, because `rescue` has no `--cwd` and defaults to the session directory. The read-only wording is the guard: the forwarder defaults to a write-capable run. -- Never use the rescue command's default write-capable mode while Claude owns the current checkout. A deliberate writer handoff requires an isolated worktree or an explicit ownership transfer. -- One Codex review after a medium-risk change, plus one focused adversarial pass for high-risk work, with a narrow threat statement such as data loss, actor isolation, ABI breakage, SQL dialect drift, or MCP privilege. `$cross-model-review` owns the commands, the read-only caps, and the recursion rules; follow it rather than reconstructing them here. -- Claude's own workflow lanes may gather review evidence. They must not start another cross-vendor review, and Codex is never asked to invoke Claude. +## Build & Development Commands -## Autonomy +```bash +# First-time setup (and after any project.yml / Configs change, or adding a source file) +scripts/download-libs.sh # static libraries, not in git +scripts/generate-project.sh # generates both .xcodeproj bundles from project.yml -Work through analysis, implementation, and local verification without asking routine permission. Do not infer permission to commit, push, open pull requests, publish, tag, or release. Preserve user changes already in the tree. +# Build (development), -skipPackagePluginValidation required for SwiftLint plugin in CodeEditSourceEditor +xcodebuild -project TablePro.xcodeproj -scheme TablePro -configuration Debug build -skipPackagePluginValidation -A `$fix-issue` run is the one exception: once its gates pass it branches, commits, pushes, opens the pull request, and works its follow-up queue into further pull requests without checking in. It still never merges, tags, publishes, releases, force pushes, or commits to a branch it did not create. Nothing outside that skill inherits this. +# Clean build +xcodebuild -project TablePro.xcodeproj -scheme TablePro clean + +# Build and run +xcodebuild -project TablePro.xcodeproj -scheme TablePro -configuration Debug build -skipPackagePluginValidation && open build/Debug/TablePro.app + +# Release builds +scripts/build-release.sh arm64|x86_64|both + +# Lint & format +swiftlint lint # Check issues +swiftlint --fix # Auto-fix +swiftformat . # Format code + +# Tests +xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation +xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProTests/TestClassName +xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProTests/TestClassName/testMethodName +xcodebuild -project TablePro.xcodeproj -scheme TablePro test -skipPackagePluginValidation -only-testing:TableProUITests + +# DMG +scripts/create-dmg.sh + +# Static libraries (after lib updates) +scripts/download-libs.sh --force # Re-download and overwrite +``` + +### Updating Static Libraries + +Static libs (`Libs/*.a`) are hosted on the `libs-v1` GitHub Release (not in git). When adding or updating a library: + +```bash +# 1. Update the .a files in Libs/ (build scripts write them there) +# 2. Publish: verifies all OTHER local libs still match the checksums at HEAD, +# regenerates checksums.sha256, uploads the archive. Name every lib you rebuilt. +scripts/publish-libs.sh libmongoc_arm64.a libmongoc_x86_64.a libmongoc_universal.a libmongoc.a +# 3. Commit the updated checksums +git add Libs/checksums.sha256 && git commit -m "build: update static library checksums" +``` + +Never run `shasum -a 256 Libs/*.a > Libs/checksums.sha256` by hand: regenerating from a stale `Libs/` reverts other libraries silently (this shipped a broken libmongoc and rolled back DuckDB once). `publish-libs.sh` exists to make that impossible. + +```bash + +# iOS xcframeworks (Libs/ios/*.xcframework) +tar czf /tmp/tablepro-libs-ios-v1.tar.gz -C Libs/ios . +gh release upload libs-v1 /tmp/tablepro-libs-ios-v1.tar.gz --clobber --repo TableProApp/TablePro +``` + +## Architecture + +### Project Generation + +`TablePro.xcodeproj` and `TableProMobile/TableProMobile.xcodeproj` are **generated artifacts**. They are gitignored and must never be hand-edited or committed. The source of truth is: + +- `project.yml` / `TableProMobile/project.yml`: targets, sources, dependencies, schemes, and per-target build settings +- `Configs/*.xcconfig`: project-wide and per-configuration build settings, shared by both projects +- `Configs/Version.xcconfig`: the app's `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION`, read by the release skill and by `build-plugin.yml` +- `Configs/Secrets.xcconfig`: gitignored, pulled in with `#include?`, holds `ANALYTICS_HMAC_SECRET` and per-developer signing overrides. `Configs/Secrets.xcconfig.example` is the template. + +Run `scripts/generate-project.sh` after editing any of those, and after adding, moving, or deleting a source file: XcodeGen globs sources at generation time, so a new file is not in the project until you regenerate. Changing signing in the Xcode UI is pointless, because the next generate discards it; set `TABLEPRO_DEVELOPMENT_TEAM` and `TABLEPRO_APP_BUNDLE_IDENTIFIER` in `Configs/Secrets.xcconfig` instead. + +The 31 plugin bundles share one `DriverPlugin` target template; a plugin declares only its folder, principal class, and any C-library link flags. Every target gets a shared scheme named after it, which is what `scripts/build-plugin.sh [arm64|x86_64|both] [version]` builds. The `AllPlugins` aggregate target compile-checks all 31, including the registry-only ones the app does not embed, and PR CI runs it: the `Compile every plugin` step in the `app-tests` job of `.github/workflows/macos-tests.yml` builds that scheme whenever the change touches `Plugins/` or any other watched path. What PR CI still does not cover is plugin packaging, signing and notarization, which only `build-plugin.yml` does and only on a release tag. + +### Plugin System + +All database drivers are `.tableplugin` bundles loaded at runtime by `PluginManager` (`Core/Plugins/`): + +- **TableProPluginKit** (`Plugins/TableProPluginKit/`), shared framework with `PluginDatabaseDriver`, `DriverPlugin`, `TableProPlugin` protocols and transfer types (`PluginQueryResult`, `PluginColumnInfo`, etc.). This is the single source of truth; the SwiftPM target at `Packages/TableProCore/Sources/TableProPluginKit` is a symlink to it, so edit the files under `Plugins/TableProPluginKit/` only. +- **PluginDriverAdapter** (`Core/Plugins/PluginDriverAdapter.swift`), bridges `PluginDatabaseDriver` → `DatabaseDriver` protocol +- **DatabaseDriverFactory** (`Core/Database/DatabaseDriver.swift`), looks up plugins via `DatabaseType.pluginTypeId` +- **DatabaseManager** (`Core/Database/DatabaseManager.swift`), connection pool, lifecycle, primary interface for views/coordinators +- **ConnectionHealthMonitor**: 30s ping, auto-reconnect with exponential backoff + +When adding a new driver: create a new plugin bundle under `Plugins/`, implement `DriverPlugin` + `PluginDatabaseDriver`, add the target to `project.yml`, add `DatabaseType` static constant, add a `case` arm to the `case "$PLUGIN_NAME"` block in the `Resolve plugin info` step of `.github/workflows/build-plugin.yml`, add row to `docs/index.mdx` supported databases table, and add CHANGELOG entry. See `docs/development/plugin-development.mdx` and `docs/development/plugin-registry.mdx` for details. + +When adding a new method to the driver protocol: add to `PluginDatabaseDriver` (with default implementation), then update `PluginDriverAdapter` to bridge it to `DatabaseDriver`. This is an additive, ABI-safe change (see below) and needs no version bump. + +**PluginKit ABI (resilient)**: TableProPluginKit is built with `BUILD_LIBRARY_FOR_DISTRIBUTION = YES` (Swift Library Evolution), so its public ABI is resilient. The Swift runtime instantiates witness tables for already-built plugins and fills any requirement the plugin did not implement from the protocol's default, so a plugin built against an older PluginKit keeps loading under a newer app. + +**Additive changes are binary-compatible and need NO version bump**: adding a requirement to `DriverPlugin` / `PluginDatabaseDriver` that has a default implementation, reordering requirements, or adding a field to a non-`@frozen` transfer struct. + +**Never remove a published protocol requirement, even one that defaulted to `nil`.** Library Evolution fills in requirements *added* after a plugin was built, but it cannot rescue a requirement *removed* out from under an already-built plugin. Removing one deletes both its method descriptor and its default-implementation symbol, and every shipped plugin that relied on the default hard-references both in its witness table, so it fails to load with "Bundle failed to load executable". If the app stops using a requirement, leave it in place with its default (it costs nothing). Removing it is a breaking change: bump `currentPluginKitVersion` and re-release every plugin. (#1917, and it broke MongoDB, Oracle, Cassandra, and Elasticsearch on 0.58.) + +**Adding a field to a transfer struct is additive ONLY if every existing public initializer keeps its exact signature.** Adding a parameter to an existing public init or function, even with a default value, replaces its mangled symbol and breaks every already-built plugin (this shipped in 0.49.0: `PluginQueryResult` gained `columnMeta:` on its init and every registry plugin failed to load with "Bundle failed to load executable"). Add a NEW overload for the new field and keep the old signature; mark the old overload `@_disfavoredOverload` so new code resolves to the full init while old binaries keep their symbol. Before any PluginKit change run `scripts/check-pluginkit-abi.sh` (see below) and act on the result: either the diff is additive (verify no symbol disappeared) or it is breaking (bump and re-release). + +**Bump `currentPluginKitVersion` (in `PluginManager.swift`) and `TableProPluginKitVersion` in every plugin `Info.plist` ONLY for a breaking change**: changing or removing an existing requirement's signature, adding a requirement without a default, adding a case to a `@frozen` enum, or changing a frozen type's layout. Mark a public enum `@frozen` only when an exhaustive switch over it forces it (the compiler flags the switch) and its case set is genuinely closed; leave the rest non-frozen so they can gain cases. `PluginCapability` stays non-frozen with `@unknown default` because it is a growing capability set, not a closed vocabulary. The driver protocols and transfer structs stay non-frozen so they can grow. The strict version gate in `validateBundleVersions` still rejects a stale plugin cleanly after a breaking bump (no `EXC_BAD_INSTRUCTION`). + +**ABI check** (manual): `scripts/check-pluginkit-abi.sh [base-ref]` generates the project from `project.yml` on both sides, builds TableProPluginKit at the current tree and at the base ref with the same toolchain, then diffs their public interfaces. A base ref that predates `project.yml` cannot be compared. There is no committed baseline, so a Swift version difference between machines never produces a false diff. Run it before merging any change under `Plugins/TableProPluginKit/**`, comparing against the merge base. A reported diff is a real ABI change: additive needs no bump; breaking needs the version bump above plus `release-all-plugins.sh`. (Until Library Evolution is on the base too, the base emits no interface and the check passes as a bootstrap.) + +**Post-ABI-bump checklist (mandatory, breaking bumps only)**: Bumps are now rare (only the breaking changes listed above). After one, every registry-published plugin must be rebuilt against the new ABI. Run `release-all-plugins.sh` for the new version BEFORE or WITH the app release, never after, or users on the new app hit `noCompatibleBinary` until the registry catches up. App auto-update reconciliation handles the user-facing recovery, but the registry has to carry binaries for the new PluginKit version first. + +1. Commit the bump (updates `PluginManager.swift` and every bundled plugin's `Info.plist`). Bundled plugins ship with the next app release. Do not tag them. +2. Trigger the bulk re-release: + ```bash + ./scripts/release-all-plugins.sh + ``` + The workflow runs all registry plugins as a parallel matrix, publishes ZIPs to GitHub Releases, and updates `plugins.json` (via `.github/scripts/update-registry.py`, which appends new binaries and prunes per the `--keep-kit-versions 2` policy). No manual `plugins.json` editing. +3. Verify by installing one plugin from the registry on a build with the new PluginKit version. + +**Binary retention policy**: The registry keeps binaries for the two most recent PluginKit versions per plugin (`--keep-kit-versions 2`). Users on the previous app version can still install plugins; users two or more versions behind hit `noCompatibleBinary` and need to update the app. + +### DatabaseType (String-Based Struct) + +`DatabaseType` is a string-based struct (not an enum): +- All `switch` statements must include `default:`, the type is open +- Use static constants (`.mysql`, `.postgresql`) for known types +- Unknown types (from future plugins) are valid, they round-trip through Codable +- Use `DatabaseType.allKnownTypes` (not `allCases`) for the canonical list + +### Editor Architecture (CodeEditSourceEditor) + +- **`SQLEditorTheme`**: single source of truth for editor colors/fonts +- **`TableProEditorTheme`**: adapter to CodeEdit's `EditorTheme` protocol +- **`CompletionEngine`**: framework-agnostic; **`QueryCompletionAdapter`** bridges to CodeEdit's `CodeSuggestionDelegate` +- Editor tabs are drawn by `EditorTabStrip`, not by native window tabs. A window belongs to exactly one `NSWindow` tab group and that group's bar shows every window in it, so a window hosting several connections could only ever show all of their tabs interleaved. Window tabbing itself stays on AppKit's terms: `TabWindowController` leaves `tabbingMode` at `.automatic`, which is the user's own System Settings preference, and never forces `.preferred`. +- Cursor model: `cursorPositions: [CursorPosition]` (multi-cursor via CodeEditSourceEditor) + +### Change Tracking Flow + +1. User edits cell → `DataChangeManager` records change +2. User clicks Save → `SQLStatementGenerator` produces INSERT/UPDATE/DELETE +3. Undo and redo come from a private `UndoManager` inside `StructureChangeManager`, plus `ConnectionWorkspace.undoManager` +4. `AnyChangeManager` abstracts over concrete manager for protocol-based usage + +### Invariants + +These have caused real bugs when violated: + +**A synced CKRecord field must be deployed to Production before anything writes it**: both apps pin `com.apple.developer.icloud-container-environment` to `Production`, and CloudKit only auto-creates fields in the Development environment. So no build, not even a local Debug one, can create a field on the server. Saving a record that carries a field the Production schema does not declare makes CloudKit reject **that whole record**, and with `isAtomic = false` the rest of the batch still saves, so the symptom is one record type silently never syncing. `ConnectionSyncField` (`Packages/TableProCore/Sources/TableProSyncTransport/ConnectionSyncSchema.swift`) is the single declaration of every `Connection` wire key, and its gated `CKRecord` subscript refuses to write a field that is not `.verified`. A new case defaults to `.unverified`, so a field added without the deploy is inert rather than destructive. To ship one: add the field in CloudKit Console, deploy Development to Production, run `scripts/export-cloudkit-schema.sh`, commit the refreshed `CloudKit/production-schema.ckdb`, then mark the field verified. `ProductionSchemaParityTests` fails if the registry and the snapshot disagree in either direction. This shipped as `isFavorite` (#1452, unconditional on every connection) killing every Mac connection push for two months while the UI reported success (#643). + +**Sync delete ordering**: In `ConnectionStorage` (and all storage classes), `SyncChangeTracker.markDeleted()` must be called AFTER `saveConnections()`. The `markDeleted` call fires `postChangeNotification` which can trigger a sync. If the file on disk still contains the deleted item when sync runs, it may re-upload the deleted record. Persist first, then notify. + +**WelcomeViewModel tree rebuild**: The welcome screen renders `treeItems` (grouped/filtered), not `connections` directly. Every mutation to `connections` must call `rebuildTree()` afterward, or the UI won't update. + +**Tab replacement guard**: `openTableTab` checks for active work (unsaved edits, applied filters, sorting) before replacing the current tab. A tab with active work is left alone and the table opens as a new editor tab in the same window's strip. This check runs before the preview tab branch. + +**Window tab titles**: The native tab label follows `NSWindow.title`, and AppKit renders it for background tabs too, so the title must be correct from creation, not from first activation. Every title resolves through `WindowTitleResolver` (pure, AppKit-free): `MainSplitViewController.init` for the payload-driven initial title, `updateWindowTitleAndFileState()` in `MainContentView+Setup.swift` for ongoing tab-driven updates. The resolver treats a blank string as absent at every tier and always recomputes a `.table` tab's name from `tableName`+`schemaName` instead of trusting a carried-over title. `TabWindowController.init` pushes the resolved title onto `window.title`/`window.subtitle` right after assigning `contentViewController`, because a joined-but-never-activated tab window never runs `viewWillAppear` or its SwiftUI lifecycle. `MainSplitViewController.windowTitle`'s `didSet` is the single guarded sink and never lets an empty string reach `NSWindow.title`. Never write `window.title` or `NSApp.keyWindow?.title` directly; mutate `tab.title` and call `QueryTabManager.markTabRenamed(_:)` so the resolver re-runs. A restored tab whose persisted title decoded to "" shipped as a blank tab label that only healed on activation. Editor tabs are no longer windows, so there are now two labels with two owners: the window titlebar goes through `WindowTitleResolver` and the guarded `windowTitle` sink, while the editor tab label is `Text(tab.title)` in `EditorTabStrip` with no resolver between it and the string. Blank-title healing therefore has to hold at `QueryTab.title` itself. + +**Schema loading**: `SQLSchemaProvider` (actor) stores an in-flight `loadTask: Task?`. Concurrent callers `await` the same Task instead of firing duplicate `fetchTables()` queries. Never use a boolean `isLoading` guard that returns without data, callers need to await the result. + +**A refresh never clears the cache it is refreshing**: fetch first, then commit over the old value. A loading flag that discards data is a blank screen: `SchemaService.runLoad` used to write `states[id] = .loading` before the network call, which made `tables(for:)` return `[]`, so `SidebarView`'s `case .loading where tables.isEmpty` matched on every refresh and the whole object list became a spinner (#1916). Only enter `.loading` when there is no loaded content (`hasLoadedContent`), signal an in-flight refresh separately (`isRefreshing`), and keep a failed refresh from replacing good data (the guard `markLoadFailed` already had). The same rule covers per-schema state and `StructureTabDataState`, where "has data" (drives the tab counts) is deliberately separate from "needs refetch" (drives the reload) so marking everything stale never blanks a count. `DatabaseTreeMetadataService.reloadTablesInPlace` is the reference shape. Use `prepareForReload` before a reload and reserve `invalidate` for genuine teardown (disconnect, database switch); invalidating to force a reload wipes the visible tree. + +**Selection indices are display positions**: `GridSelectionState.indices` come from `NSTableView.selectedRowIndexes` and are display-row positions, not indices into `TableRows.rows`. They match array indices only when `displayIDs` (`valueFilteredIDs ?? sortedIDs`) is nil; a per-column value filter makes them diverge. Resolve any selected index through `DisplayRowMapping` (or `TableViewCoordinator.displayRow(at:)` / `tableRowsIndex(forDisplayRow:)`) before reading or mutating a row; never index `TableRows.rows` with a display position. The row details inspector shipped this bug (#1837). + +**Cancelling a connect does not stop the driver**: `Task.cancel()` is cooperative, so it cannot interrupt a driver blocked in a C call. A cancelled attempt keeps running and completes late. Two rules follow. First, a driver that blocks on connect must expose its own abort path and poll it (the PostgreSQL driver uses `PQconnectStart`/`PQconnectPoll` with an app-owned deadline and a cancel flag flipped from `withTaskCancellationHandler`; a blocking `PQconnectdb` cannot be cancelled at all). When the driver's C API has no pollable connect (FreeTDS db-lib's `dbopen`), the other valid shape is to resume the awaiting caller on cancel or an app-owned deadline through a resume-once continuation gate (`SingleResumeGate` / `runCancellableBlocking`), keep the blocking call on its own serial queue, and have the late-completing call tear down its own handle (the loser `dbclose`s the `dbproc`) instead of adopting it; a process-global set before the blocking call (e.g. `KRB5CCNAME` for Kerberos) is set and restored inside that queue block so its lifetime tracks the real completion, not the early return (#1889). Second, never assume the losing attempt is gone: every attempt validates its `ConnectionAttemptRegistry` generation before adopting a driver into `activeSessions` or tearing session state down, so a late attempt discards its own driver instead of clobbering the winner. Cancelling also drops the connection from `LastOpenConnections.json` (via `SessionRecoveryTracker.sync()`) so "Reopen Last Session" never replays a connect the user cancelled, but a connect that merely *failed* keeps its place in the list: a database that was down is not a user who gave up. That distinction is `ConnectionWindowPhaseMachine.retainsRestoreIntent`, read per workspace through `ConnectionWorkspace.retainsRestoreIntent` and aggregated per window by `MainSplitViewController.connectionIdsRetainingRestoreIntent`, and it is the whole reason `RecoveryCandidate` carries `retainsRestoreIntent` alongside `isActivated`. Collapsing the two back into one flag makes one launch against a stopped server erase the session permanently. This area shipped the same bug four times (#1185, #1358, #1369). + +**A workspace's content is a function of its own `ConnectionWindowPhase`, never of `activeSessions` membership**: the global session dictionary can only say *present* or *absent*, and that vocabulary cannot tell "never started" from "connecting" from "failed" from "the user cancelled" from "the window is closing". Deriving the pane from it shipped a window that painted a live spinner forever after a failed launch restore, could not be repainted by a later successful connect, and left no route back to the connection list except the Dock icon's context menu. `ConnectionWorkspace` owns the `phase`, one per connection the window hosts; `ConnectionWindowPhaseMachine` owns the transitions (pure, exhaustive, `.closing` absorbing), and `ConnectionWindowPaneResolver` owns the pane choice (pure). `MainSplitViewController` renders the selected workspace and routes a transition by `connectionId` through `transition(to:for:)`; it is only an adapter, and its `phase` property is a pass-through to `workspaces.selected`. Three rules follow. First, every phase must have an exit: the old `closingSessionId` latch was set once and never cleared, so the controller went permanently deaf to `connectionStatusChanged`. Second, a cancel updates the UI synchronously with the button press and never waits on the driver, because `Task.cancel()` is cooperative and may have no observable effect; the attempt is fenced by a per-workspace `attemptToken` (`ConnectionWorkspace.attemptToken`) plus `DatabaseManager.invalidateConnectionAttempt`, so a late failure cannot write into a workspace that moved on. The token cannot live on the window, because the window did not move on: one of the connections it hosts did. Closing a window therefore cancels the in-flight attempt of every workspace it hosts, not just the one its original payload named, and a completion that finds its workspace gone discards itself rather than resurrecting it. Third, a failure is presented inline through `ConnectionUnavailableView`, never as an alert, per the HIG's rule against alerts at startup and its one-alert-at-a-time rule (N restored connections would mean N modals). Only one presenter per failure: `LaunchIntentRouter.presentError` stays silent when a window for that connection exists. + +**The app runs the AppKit lifecycle, and AppKit owns the menu bar**: `main.swift` assigns the delegate before `NSApplicationMain`, and `MainMenuBuilder.install` runs in `applicationWillFinishLaunching`. Do not reintroduce a SwiftUI `App`. SwiftUI reconciles `NSApp.mainMenu` once shortly after launch and removes every item it did not build itself, and no hook can undo it: `NSApp.mainMenu` is not KVO-compliant, `didUpdateNotification`, `didBecomeKeyNotification` and the `applicationDidUpdate(_:)` delegate method never fire under `@NSApplicationDelegateAdaptor`, and `applicationDidBecomeActive` fires before the reconciliation. Only a wall-clock delay worked, which is why #2057 shipped a menu bar that vanished half a second after launch and had to be reverted (#2071). Every window is an `NSWindowController`; the Welcome window is one too, so closing it is an ordinary `close()` and the old "closed, never ordered out" rule no longer applies. + +**An emptied tab manager is not the same as "the user closed every tab"**: a coordinator torn down by a lost session has already emptied `tabManager.tabs`, so any persistence path that reads "no tabs" as "clear the saved tabs" wipes tabs the user never closed. The fix is that the teardown path cannot clear at all: `TabPersistenceCoordinator.saveAggregatedSync()`, which disconnect and window-close call, opens with `guard !tabs.isEmpty else { return }`. Clearing requires explicit consent and happens on the `closeTabsByUser` path instead. Keep those two paths separate; the moment a teardown path can write an empty aggregate, the bug is back. + +**A split pane's `holdingPriority` must stay below 490**: AppKit applies a divider drag as a layout change at `dragThatCannotResizeWindow` (490). Any pane whose `holdingPriority` is at or above that outranks the drag, so its width constraint wins and the divider cannot move at all. `.defaultHigh` (750) freezes it outright, which shipped as three dead dividers (Users & Roles, Structure triggers, Server Dashboard). Use `.splitPaneHolding` (260, the value AppKit itself gives a sidebar item): high enough to outrank a `.defaultLow` (250) sibling so the pane holds its size when the window resizes, low enough that a drag still wins. `.defaultLow` is not the fix, since the pane then grows with the window instead of holding. (#1872) + +**Tab content must never pin the window's split dividers**: `NSSplitViewItem.minimumThickness` is a required constraint, so a nested `NSSplitViewController` reports `sum(minimums) + dividers` as its `fittingSize`. SwiftUI adopts that number for an `NSViewControllerRepresentable` and the enclosing `NSHostingView` turns it into a `minWidth` at priority 999.9, which beats the 490 (`dragThatCannotResizeWindow`) a divider drag runs at: the window's sidebar and inspector dividers go dead. Two rules follow. First, every hosting controller that is a split item's view controller sets `sizingOptions = []` (`MainSplitViewController`'s `detailHosting` and `inspectorHosting`, and both panes inside `AutosavingSplitView`), and `AutosavingSplitView` returns the proposal from `sizeThatFits` so its own minimums never escape into SwiftUI. Second, a tab that genuinely needs more width than `defaultDetailMinThickness` declares it through `resolveDetailMinimumThickness(for:)` instead of leaking it; the detail pane's minimum is a per-tab contract, and `recomputeWindowMinSize()` reads it live. AppKit will not rescue you here: `.sidebar` behaviour and `canCollapseFromWindowResize` only auto-collapse on a window live-resize, which an embedded split view never sees, and no form of collapsibility lowers `fittingSize` (only an actual `isCollapsed = true` does). `CollapsingSplitViewController` collapses the pane itself for that reason. This shipped as a dead inspector divider on Users & Roles tabs (#1872). + +**A SwiftUI-hosted split view needs an explicit divider cursor**: `NSSplitView` shows the resize cursor over its dividers through AppKit's cursor-rects system, which does not fire once the split view is mounted inside an `NSHostingController` (every tab-content split is, several SwiftUI layers deep under `MainSplitViewController.detailHosting`). The divider still drags because drag hit-testing is independent of cursor rects, but the pointer never changes. Every SwiftUI-hosted split-view controller must subclass `ResizeCursorSplitViewController`, which adds a key-window tracking area to its own split view and sets `NSCursor.columnResize`/`rowResize` (falling back to `resizeLeftRight`/`resizeUpDown` before macOS 15) in `mouseMoved`, the same hand-rolled approach `SortableHeaderView` uses for column resize. It attaches the tracking area to the framework's split view in `viewDidLoad` rather than replacing the split view, so `NSSplitViewController`'s own layout and divider orientation stay intact; replacing the split view through a `loadView` override that skips `super` leaves the controller half-initialized and its panes stack instead of laying out side by side. Do not swap the controller back to a plain `NSSplitViewController` expecting the stock cursor to work; the window's own sidebar and inspector dividers only get the cursor for free because `MainSplitViewController` is the window's `contentViewController` directly, with no SwiftUI host in between. This shipped as Users & Roles, Structure, Server Dashboard, and query editor dividers that dragged but never showed the resize cursor (#1905). + +**The data grid header owns all of its own chrome, so nothing may ask AppKit to paint any of it**: `NSTableHeaderCell` and `NSTableHeaderView` both paint a fixed 28pt band that they centre vertically in whatever frame they are given, a 16pt column divider on `midY` and a 1pt rule at `midY + 13`. The data grid grows its header to 42pt for a column comment, so that band lands mid-cell: the rule crosses the comment's descenders and sits 8pt above the real bottom edge. `SortableHeaderChrome` is therefore the single owner of header geometry and colours, `SortableHeaderCell.draw(withFrame:in:)` never calls `super`, and `SortableHeaderView.draw(_:)` fills the background and rules the bottom edge itself. The trap is that the header view paints a second copy of that same band for `NSTableView.highlightedTableColumn`, driven by *state* rather than by a drawing call, so no cell override can reach it: setting it gives the sorted column a stray divider and a rule no other column has. TablePro already draws the sorted-column affordance itself (bold title, chevron, priority number, with `drawSortIndicator` overridden to nothing), so `highlightedTableColumn` is a redundant second channel and must stay unset. All sorted-column presentation goes through `SortableHeaderView.applySortState(_:schema:)`, which publishes the order natively through `tableView.sortDescriptors` (for accessibility; it paints nothing) and updates the cells. `SortableHeaderRenderingTests` rasterises the header and guards this. This shipped as a rule through the comment line and a stray divider on the sorted column (#2017). + +**Decoding a MongoDB binary UUID is a per-column decision, and the column's type name is load-bearing**: BSON binary subtype 3 is the legacy UUID format, and the Java, C# and Python drivers each wrote it with a different byte order with nothing in the stored bytes to say which. `MongoDBUuidCodec` therefore decodes subtype 3 only when the connection names one (`mongoUuidRepresentation`); subtype 4 is unambiguous and always decodes. The choice is made once per column from `BsonDocumentFlattener.columnKinds`' majority vote, never per value, because a decoded cell is `.text` and an undecoded one is `.bytes`, and `CellDisplayFormatter` runs blob formatting over a `.text` cell whenever its column type is BLOB. One UUID decoded inside a column the app still types `BLOB` renders as `0x4c65676163...`. For the same reason `BsonDocumentFlattener.typeName` must keep `BLOB` as the base name for undecoded binary: `ColumnTypeClassifier` splits a type name at the first `(` and looks the base up, so `BLOB` and `BLOB(3)` both classify as `.blob`, and that classification is the only thing keeping a binary cell out of the inline editor. The parenthesised part carries the BSON subtype so MQL export can write it back; `MongoDBUuidCodec.columnTypeName(forSubtype:)` and `binarySubtype(fromColumnTypeName:)` are the only two places that spelling is produced or read, and MQL export is `supportedDatabaseTypeIds = ["MongoDB"]`, so it never sees another driver's `BLOB`. Once a column does decode, both edit guards (`isBlobType` and `asBytes != nil`) fall together, so every write path must parse the wrapper back to `$binary`: `MongoDBStatementGenerator.jsonValue` and `idValueJson`, `MongoDBQueryBuilder.jsonValue` plus its `=`, `!=` and `IN` arms (a case-insensitive regex can never match a binary field), and `MQLExportHelpers.mqlJsonValue`. An `_id` filter left as wrapper text matches zero documents while the UI reports the save succeeded. (#2086) + +**A pooled metadata read assumes a second connection reaches the same database, and an embedded engine breaks that assumption**: `MetadataConnectionPool` builds a whole new driver, so it is only correct when the database lives on a server the driver reconnects to. When the database lives *inside* the driver instance, the pool gets a different database: a second `duckdb_open(":memory:")` is a fresh empty database, and a second `duckdb_open` on the same *file* is a second independent read-write instance that the first never sees (DuckDB's file lock does not conflict within one process). The failure is silent, because an empty catalog is indistinguishable from "no tables", which is why #2108 survived a manual refresh. `supportsConnectionPooling` is the opt-out, and it is read only by `DatabaseManager.canPool`; DuckDB and PGlite set it `false`. SQLite-family engines keep pooling, because multi-connection access to one file is what they are built for. Two rules follow. First, every metadata read goes through `DatabaseManager.withMetadataDriver` so `metadataRoute` can apply the rule; reaching for `MetadataConnectionPool.shared.withDriver` directly bypasses it, which is how routines kept pooling after the sidebar stopped. Second, a capability with no `DriverPlugin` static is curated per type and `buildMetadataSnapshot` must carry it over from the built-in snapshot, or `register(snapshot:forTypeId:)` resets it to the struct default the moment the plugin loads. That is not hypothetical: it silently disabled MongoDB's `authenticationIsDatabaseScoped` (#1970) for every build that had the plugin installed. `registerVariant` already treats the curated entry as authoritative, which is the only reason PGlite's flag ever worked. + +**A MongoDB update or delete is anchored on `_id` or it does not run**: `generateDelete` used to fall back to a filter built from the remaining columns, which silently dropped every value it could not stringify (all binary) and then `deleteOne`d the first partial match, so a document with a binary `_id` could delete a different document. Both paths now skip with a logged warning instead, matching what `generateUpdate` already did. + +### Main Coordinator Pattern + +`MainContentCoordinator` is the central coordinator, split across 51 extension files in `Views/Main/Extensions/` (e.g., `+Alerts`, `+Filtering`, `+Pagination`, `+RowOperations`). When adding coordinator functionality, add a new extension file rather than growing the main file. + +### Window Close (Cmd+W) + +`EditorWindow` (NSWindow subclass in `TabWindowController.swift`) overrides `performClose:` to route Cmd+W through `closeTab()`. SwiftUI's `.commands { Button(...).keyboardShortcut("w") }` does NOT replace AppKit's built-in "File > Close", both fire, and AppKit's wins. The NSWindow subclass is the correct native pattern. + +### Storage Patterns + +| What | How | Where | +| -------------------- | ---------------- | ------------------------------------------- | +| Connection passwords | Keychain | `ConnectionStorage` | +| User preferences | UserDefaults | `AppSettingsStorage` / `AppSettingsManager` | +| Query history | SQLite FTS5 | `QueryHistoryStorage` | +| Tab state | JSON persistence | `TabPersistenceCoordinator` / `TabDiskActor` | +| Filter defaults | UserDefaults | `FilterSettingsStorage` (default column/operator, panel state) | +| Filter presets | UserDefaults | `FilterPresetStorage` | +| Per-table filters | JSON files | `FilterSettingsStorage` (one file per connection + database + schema + table; saves the valid working set, each row's enabled flag included) | +| Favorite tables | UserDefaults | `FavoriteTablesStorage` (per connection + database + schema; iCloud-synced) | +| Tree database filter | UserDefaults | `DatabaseTreeFilterStorage` (per connection; selected database set, empty = show all; device-local). Live value held in `SharedSidebarState`. | +| Recent tables | UserDefaults | `RecentTablesStore` (per connection, keyed by database, last 10 each; device-local). Live value held in `SharedSidebarState`, recorded at the `QueryTabManager` open chokepoint. | +| History drawer state | UserDefaults | `HistoryPanelPreferencesStorage` (per connection; visibility, connection scope, source/date/outcome filters; device-local). Live value held in `HistoryPanelState.forConnection`, cleared alongside `SharedSidebarState` when a session ends. | +| Trusted external links | UserDefaults | `ExternalConnectionTrustStore` (keyed by database type + host + database + username + URL `name`, never the port; loopback hosts only, enforced on read and write). Consulted by `ExternalConnectionGate` before the external-URL confirmation alert. | + +### Logging & Debugging + +Use OSLog for all logging, never `print()`. When debugging issues, add structured OSLog statements to trace the problem, don't guess. + +```swift +import os +private static let logger = Logger(subsystem: "com.TablePro", category: "ComponentName") +``` + +## Code Style + +**Authoritative sources**: `.swiftlint.yml` and `.swiftformat`, check those files for the full rule set. Key points: + +- **No comments**: code must be self-explanatory through naming and structure. Never add comments that describe what code does, reference tasks/tickets, or explain callers. +- **Early returns**: use `guard` and early `return` instead of nested `if/else` blocks. Flatten control flow. +- **4 spaces** indentation (never tabs) +- **120 char** target line length (SwiftFormat); SwiftLint warns at 180, errors at 300 +- **K&R braces**, LF line endings, no semicolons, no trailing commas +- **Imports**: system frameworks alphabetically → third-party → local, blank line after imports +- **Access control**: always explicit (`private`, `internal`, `public`). Specify on extension, not individual members: + ```swift + public extension NSEvent { + var semanticKeyCode: KeyCode? { ... } + } + ``` +- **No force unwrapping/casting**: use `guard let`, `if let`, `as?` + +### SwiftLint Limits + +| Metric | Warning | Error | +| --------------------- | ------- | ----- | +| File length | 1200 | 1800 | +| Type body | 1100 | 1500 | +| Function body | 160 | 250 | +| Cyclomatic complexity | 40 | 60 | + +When approaching limits: extract into `TypeName+Category.swift` extension files in an `Extensions/` subfolder. Group by domain logic, not arbitrary line counts. + +## Mandatory Rules + +These are **non-negotiable**, never skip them: + +1. **CHANGELOG.md**: Follow [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). Update under `[Unreleased]` using the canonical sections: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. Do **not** add a "Fixed" entry for fixing something that is itself still unreleased; fold the fix into the Added or Changed entry instead. Documentation-only changes (`docs/`, `CLAUDE.md`, `CHANGELOG.md` formatting) do **not** need a CHANGELOG entry. Each entry is one line, user-facing, with no file paths, class names, or method signatures; reference IDs go in parens at the end: `(#1234)`. + +2. **Localization**: Use `String(localized:)` for new user-facing strings in computed properties, AppKit code, alerts, and error descriptions. SwiftUI view literals (`Text("literal")`, `Button("literal")`) auto-localize. Do NOT localize technical terms (font names, database types, SQL keywords, encoding names). Never use `String(localized:)` with string interpolation, `String(localized: "Preview \(name)")` creates a dynamic key that never matches the strings catalog. Use `String(format: String(localized: "Preview %@"), name)`. + +3. **Documentation**: Update docs in `docs/` (Mintlify-based) when adding/changing features: + - New keyboard shortcuts → `docs/features/keyboard-shortcuts.mdx` + - UI/feature changes → relevant `docs/features/*.mdx` page + - Settings changes → `docs/customization/settings.mdx` + - Database driver changes → `docs/databases/*.mdx` + +4. **Tests**: Every change with testable behavior must include or update unit/function tests. UI and user-flow changes should add or update `TableProUITests` UI automation where the flow runs deterministically; if it can't, note why in the PR description. When tests fail, fix the source code, never adjust tests to match incorrect output. Tests define expected behavior. + +5. **Lint after changes**: Run `swiftlint lint --strict` to verify compliance. `.swiftlint.yml` sets `included: [TablePro]`, so a bare run never sees `Plugins/`, `Packages/`, `LocalPackages/` or the test targets. Pass those paths explicitly when your change is outside the app target, or the run passes while your code is broken. + +6. **Commit messages**: Follow [Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/). Single line only, no description body. Format: `(): `. Scope is optional but preferred when the change has a clear domain. Use `!` after type or scope for breaking changes (e.g. `refactor(ai-providers)!: drop OpenAI legacy completion endpoint`). + + **Types**: `feat`, `fix`, `refactor`, `perf`, `test`, `docs`, `build`, `ci`, `chore`, `style`, `revert`. + + **Canonical scopes** (reuse these instead of inventing new ones): + - AI: `ai-chat`, `ai-providers`, `mcp`, `copilot`, `inline-suggest` + - App UI: `editor`, `datagrid`, `tabs`, `coordinator`, `sidebar`, `connections`, `connection-form`, `welcome`, `settings`, `toolbar`, `hig` + - Infra: `ssh`, `ios`, `windows`, `perf`, `launch`, `plugins` + - Plugins: `plugin-` (e.g. `plugin-mongodb`, `plugin-redis`, `plugin-clickhouse`) + - Docs and release: `changelog`, `claude-md`, `docs`, `ci`, `release` + + **Examples**: `feat(ai-chat): add /refactor slash command`, `fix(editor): prevent crash on empty query result`, `refactor(mcp): migrate pairing store to actor`, `docs(changelog): adopt Keep a Changelog 1.1.0`. + +7. **Atomic API changes**: When you rename, remove, or change a public type, property, or function signature, update every caller AND every test in the same commit. Do not split a rename from "fix tests for rename" into separate commits; the in-between commit is broken, fails CI, and pollutes `git bisect`. If a refactor crosses too many files for one reviewable commit, narrow the change first or stage it behind a typealias the renaming commit removes. + +## Performance Pitfalls + +These have caused real production bugs: + +- **Never use `ForEach($bindable.array) { $item in }`** on `@Observable` arrays that can be cleared externally, index-based bindings crash with out-of-bounds when the array shrinks during SwiftUI evaluation. Use `ForEach(array) { item in` with a manual `Binding` via `binding(for: item)`. +- **Never use `string.count`** on large strings, O(n) in Swift. Use `(string as NSString).length` for O(1). +- **Never use `string.index(string.startIndex, offsetBy:)` in loops** on bridged NSStrings, O(n) per call. Use `(string as NSString).character(at:)` for O(1) random access. +- **Never call `ensureLayout(forCharacterRange:)`**: defeats `allowsNonContiguousLayout`. Let layout manager queries trigger lazy local layout. +- **SQL dumps can have single lines with millions of characters**: cap regex/highlight ranges at 10k chars. +- **Tab persistence**: a query longer than `TabQueryContent.maxPersistableQuerySize` (500,000 UTF-16 units) is blanked by `QueryTab.toPersistedTab()` to prevent a JSON freeze, and the full text moves to `TabQueryOverflowStore`. `RecentlyClosedTabStore` applies the same cap. + +## Writing Style + +Applies to **everything**: docs, commit messages, CHANGELOG entries, UI strings, error messages, PR descriptions. + +**Write like a human developer.** Short sentences. Plain words. Say what it does, not how great it is. If a sentence works without a word, drop the word. + +**No em dashes (—).** Anywhere. Use a comma, period, colon, or rewrite the sentence. Hyphens (-) for compound words are fine. + +Before any commit that touches user-facing strings, CHANGELOG.md, PR bodies, or files you authored this session, run: +```bash +git diff --cached -U0 | grep -nE '—|seamless|robust|comprehensive|intuitive|effortless|streamlined|leverage|elevate|delve|utilize|facilitate' +``` +If anything matches, rewrite before committing. + +**No AI-generated filler.** If it sounds like a chatbot wrote it, rewrite it. Banned words: seamless, robust, comprehensive, intuitive, effortless, powerful (as filler), streamlined, leverage, elevate, harness, supercharge, unlock, unleash, dive into, game-changer, empower, delve, utilize, facilitate. No "Absolutely!" / "Ready to dive in?" / "Let's get started!" openers. + +**Be specific.** Numbers, tech names, file paths. "Runs in 200ms" beats "runs fast". "Uses `PQexecParams`" beats "uses native binding". + +## CI/CD + +GitHub Actions (`.github/workflows/build.yml`) triggered by `v*` tags. The `release` job needs all five of `lint`, `test`, `build-arm64`, `build-x86_64` and `registry-readiness`, so a red test suite or a registry missing a compatible plugin binary blocks the tag. It produces the DMG and ZIP plus Sparkle signatures, and release notes are auto-extracted from `CHANGELOG.md`. + +**Plugin CI** (`.github/workflows/build-plugin.yml`): triggered by `plugin-*-v*` tags or `workflow_dispatch`. The dispatch input accepts comma-separated `tag:pluginKitVersion` pairs; if `:pluginKitVersion` is omitted, the workflow reads `currentPluginKitVersion` from `PluginManager.swift`. Registry update logic lives in `.github/scripts/update-registry.py` (atomic write, per-binary `pluginKitVersion`, prune-old policy). Use `scripts/release-all-plugins.sh ` for bulk re-release after an ABI bump. + +**Plugin tag naming**: Tag names must match the `case "$PLUGIN_NAME"` mapping in the CI workflow's `Resolve plugin info` step. Notable non-obvious mappings: `CloudflareD1DriverPlugin` → `plugin-cloudflare-d1-v*`, `EtcdDriverPlugin` → `plugin-etcd-v*`. Check existing tags with `git tag -l "plugin-*"` before creating new ones. diff --git a/docs/development/plugin-development.mdx b/docs/development/plugin-development.mdx index c3c276697..7551848ae 100644 --- a/docs/development/plugin-development.mdx +++ b/docs/development/plugin-development.mdx @@ -97,7 +97,7 @@ The script copies the bundle from DerivedData, so build from Xcode, not from a C Publishing to TablePro's registry takes two steps in this repo: -1. Add a case for your plugin to `resolve_plugin_info()` in `.github/workflows/build-plugin.yml`: target name, bundle ID, display name, summary, database type IDs, icon, category, homepage. Without it the workflow exits with `Unknown plugin name`. +1. Add a case for your plugin to the `case "$PLUGIN_NAME"` block in the `Resolve plugin info` step of `.github/workflows/build-plugin.yml`: target name, bundle ID, display name, summary, database type IDs, icon, category, homepage. Without it the workflow exits with `Unknown plugin name`. 2. Push the tag `plugin--v`. CI builds both architectures, signs, notarizes, and updates `plugins.json`. CI signs with TablePro's own certificate, so a third-party plugin ships through a pull request, not a tag of your own. See [Plugin Registry](/development/plugin-registry) for the manifest format, the self-describing `metadata` block, and the publishing flow. diff --git a/docs/development/plugin-registry.mdx b/docs/development/plugin-registry.mdx index b40c4c465..6367d12a0 100644 --- a/docs/development/plugin-registry.mdx +++ b/docs/development/plugin-registry.mdx @@ -100,7 +100,7 @@ The full field list is `RegistryPluginMetadata` in `TablePro/Core/Plugins/Regist ## Registry Plugins and databaseTypeIds -`databaseTypeIds` tells the app which plugin to install when a user picks a database type with no loaded driver. Tag names must match `resolve_plugin_info()` in `.github/workflows/build-plugin.yml`. Note the non-obvious tags for Cloudflare D1 and etcd. +`databaseTypeIds` tells the app which plugin to install when a user picks a database type with no loaded driver. Tag names must match the `case "$PLUGIN_NAME"` block in the `Resolve plugin info` step of `.github/workflows/build-plugin.yml`. Note the non-obvious tags for Cloudflare D1 and etcd. | Tag prefix | databaseTypeIds | |-----------|-----------------| @@ -126,7 +126,7 @@ Bundled plugins (MySQL, PostgreSQL, SQLite, ClickHouse, Redis, and the import/ex ## Publishing a Plugin -A new plugin needs a case in `resolve_plugin_info()` in `.github/workflows/build-plugin.yml` before its first tag. Without one the workflow exits with `Unknown plugin name`. +A new plugin needs a case in the `Resolve plugin info` step of `.github/workflows/build-plugin.yml` before its first tag. Without one the workflow exits with `Unknown plugin name`. Tag the commit and push that one tag: diff --git a/scripts/check-doc-symbols.sh b/scripts/check-doc-symbols.sh new file mode 100755 index 000000000..19d6792ef --- /dev/null +++ b/scripts/check-doc-symbols.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# +# Check that the agent-facing docs still describe a repository that exists. +# +# Prose does not fail a build when the code moves underneath it. An audit on 2026-08-18 found 16 +# claims in CLAUDE.md naming symbols, paths and counts that had all drifted: `SQLCompletionAdapter` +# had been renamed to `QueryCompletionAdapter`, `saveOrClearAggregatedSync` had been renamed and +# its behaviour inverted, `TabPersistenceService` and `TabStateStorage` had been deleted outright. +# Correcting those by hand without this check only resets the clock. +# +# Scope: the documents that describe THIS repository. +# CLAUDE.md, .claude/rules/*.md, .claude/skills/fix-issue/**/*.md +# The swiftui and swiftdata skills are about framework APIs rather than this tree, so they are +# not checked here; a symbol check would be measuring the SDK, not the repo. +# +# What is checked. Only mechanical claims, because those are the ones that rot silently: +# paths a backticked repo-relative path must exist +# symbols a backticked CamelCase identifier must exist in this tree or in the macOS SDK +# scripts every .sh named must exist and be executable +# skills every Skill(name) and $name reference must resolve +# counts a stated plugin-bundle count must match the tree +# +# Fenced code blocks are stripped before scanning. A claim in prose is a claim; a symbol inside +# an example is an example. Behavioural claims ("CI does X") still need a human or a probe. +# +# Usage: +# scripts/check-doc-symbols.sh # exit 1 if anything is stale +# scripts/check-doc-symbols.sh --list # also print what passed + +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" || exit 3 + +LIST=0 +[ "${1:-}" = "--list" ] && LIST=1 + +DOCS=() +[ -f CLAUDE.md ] && DOCS+=(CLAUDE.md) +while IFS= read -r f; do DOCS+=("$f"); done < <( + find .claude/rules .claude/skills/fix-issue -name '*.md' -type f 2> /dev/null | sort +) + +BUILTIN_SKILLS="code-review security-review simplify swiftui-pro run init update-config loop schedule" + +# Claude Code tool names. They are backticked CamelCase in these docs and are not Swift types, +# so without this list every mention of the harness reads as a stale symbol. +HARNESS_TOOLS="Read Write Edit Bash Glob Grep Agent Skill Workflow Task TodoWrite WebSearch WebFetch +AskUserQuestion ExitPlanMode EnterPlanMode SendMessage ListAgents Monitor NotebookEdit LSP +ReportFindings Artifact PushNotification TaskOutput TaskStop" + +# Environment variables that read as CamelCase rather than ALL_CAPS, so the ALL_CAPS filter +# below does not catch them. XCTest sets these and the UI-test notes name them. +ENV_NAMES="XCTestConfigurationFilePath XCTestSessionIdentifier XCTestBundlePath" + +findings=0 +checked=0 +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +report() { + findings=$((findings + 1)) + printf '%s: %s\n' "$1" "$2" +} + +pass() { + checked=$((checked + 1)) + [ "$LIST" -eq 1 ] && printf ' ok %-52s %s\n' "$1" "$2" + return 0 +} + +# ------------------------------------------------------------------ symbol index + +# A symbol resolves if this tree declares or uses it, or if the SDK we compile against does. +# Without the SDK half, every mention of NSTableView or UndoManager reads as a stale claim. +build_symbol_index() { + local sdk_root frameworks fw iface + { + grep -rhoE '\b[A-Z][A-Za-z0-9_]{3,}\b' --include='*.swift' \ + TablePro Plugins Packages LocalPackages TableProTests TableProUITests 2> /dev/null + # C bridge headers: libpq, libmariadb and friends are named in the docs too. + grep -rhoE '\b[A-Za-z][A-Za-z0-9_]{3,}\b' --include='*.h' Plugins 2> /dev/null + # Xcode target and scheme names live in project.yml, not in any source file. + grep -hoE '^ [A-Za-z][A-Za-z0-9_+-]*:' project.yml 2> /dev/null | tr -d ' :' + printf '%s\n' $HARNESS_TOOLS + printf '%s\n' $ENV_NAMES + } | sort -u > "$WORK/symbols" + + sdk_root="${DEVELOPER_DIR:-/Applications/Xcode-beta.app/Contents/Developer}/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/System/Library/Frameworks" + frameworks="AppKit SwiftUI Foundation Combine CoreData Observation UniformTypeIdentifiers" + for fw in $frameworks; do + iface="$sdk_root/$fw.framework/Modules/$fw.swiftmodule/arm64e-apple-macos.swiftinterface" + [ -f "$iface" ] || continue + grep -hoE '\b[A-Z][A-Za-z0-9_]{3,}\b' "$iface" 2> /dev/null + done | sort -u >> "$WORK/symbols" + sort -u -o "$WORK/symbols" "$WORK/symbols" +} + +# ------------------------------------------------------------------ prose extraction + +# Strip fenced code blocks, then emit "line:content" for what is left. +prose() { + awk '/^```/ { fence = !fence; next } { print NR ":" (fence ? "" : $0) }' "$1" +} + +# ------------------------------------------------------------------ checks + +check_doc() { + local doc="$1" dir line body token target + dir="$(dirname "$doc")" + prose "$doc" > "$WORK/prose" + + # paths + while IFS= read -r hit; do + line="${hit%%:*}"; token="${hit#*:}" + case "$token" in + http*|*' '*|*'*'*|*'<'*|*'$'*|*'|'*|*'{'*) continue ;; + # A first segment carrying a dot is a hostname, not a path in this tree. + *.*/*) [ "${token%%/*}" != "${token%%.*}" ] && continue ;; + esac + # Prose reads like a path when it is a pair of lowercase words: if/else, and/or, read/write. + case "$token" in + [a-z]*/[a-z]*) + case "$token" in + *.*|*/*/*) ;; + *) continue ;; + esac + ;; + esac + token="${token%/}" + [ -n "$token" ] || continue + if [ -e "$token" ]; then + pass "$token" "$doc:$line" + elif [ -e "$dir/$token" ]; then + pass "$token (relative to the doc)" "$doc:$line" + elif [ -e "$dir/../$token" ]; then + pass "$token (relative to the skill root)" "$doc:$line" + elif [ -e "TablePro/$token" ] || [ -e "TableProUITests/$token" ]; then + # CLAUDE.md writes app paths as Core/… and Views/… , and the UI-test doc writes + # Support/… . Both are long-standing shorthand, not broken references. + pass "$token (app-relative shorthand)" "$doc:$line" + elif git check-ignore -q "$token" 2> /dev/null; then + # A gitignored path is per-developer or downloaded, so a fresh checkout not having it + # is the expected state. Secrets.xcconfig and Libs/*.a are documented for exactly that + # reason, and flagging them would train everyone to ignore this check. + pass "$token (gitignored, optional by design)" "$doc:$line" + else + checked=$((checked + 1)) + report "$doc:$line" "path does not exist: $token" + fi + done < <(sed 's/`/\n`/g' "$WORK/prose" | grep -oE '^[0-9]+:.*|`[A-Za-z0-9_./+-]+/[A-Za-z0-9_./+-]*`' > /dev/null 2>&1; \ + awk -F: '{ line=$1; $1=""; body=substr($0,2); + while (match(body, /`[A-Za-z0-9_.\/+-]+\/[A-Za-z0-9_.\/+-]*`/)) { + t = substr(body, RSTART+1, RLENGTH-2); print line ":" t; + body = substr(body, RSTART+RLENGTH) } }' "$WORK/prose") + + # swift symbols + while IFS= read -r hit; do + line="${hit%%:*}"; token="${hit#*:}" + # ALL_CAPS is an environment variable, a build setting, or a verdict word, never a Swift + # type. Checking those against the source index only produces noise. + # ALL_CAPS is an environment variable, a build setting, or a verdict word, never a Swift + # type. Use the POSIX class, not [a-z]: outside the C locale that range collates to + # include uppercase, so the filter silently passes everything through. + case "$token" in + *[[:lower:]]*) ;; + *) continue ;; + esac + if grep -qxF "$token" "$WORK/symbols"; then + pass "$token" "$doc:$line" + else + checked=$((checked + 1)) + report "$doc:$line" "symbol is in no Swift source and no SDK interface: $token" + fi + done < <(awk -F: '{ line=$1; $1=""; body=substr($0,2); + while (match(body, /`[A-Z][A-Za-z0-9_]{3,}`/)) { + t = substr(body, RSTART+1, RLENGTH-2); print line ":" t; + body = substr(body, RSTART+RLENGTH) } }' "$WORK/prose" | sort -u -t: -k2) + + # scripts + while IFS= read -r hit; do + line="${hit%%:*}"; token="${hit#*:}" + if [ ! -f "$token" ]; then + checked=$((checked + 1)) + report "$doc:$line" "script does not exist: $token" + elif [ ! -x "$token" ]; then + checked=$((checked + 1)) + report "$doc:$line" "script exists but is not executable: $token" + else + pass "$token" "$doc:$line" + fi + done < <(grep -oE '[0-9]+:.*' "$WORK/prose" \ + | awk -F: '{ line=$1; $1=""; body=substr($0,2); + while (match(body, /(scripts|\.claude\/hooks|\.claude\/skills\/[a-z-]+\/scripts)\/[a-z0-9_-]+\.sh/)) { + print line ":" substr(body, RSTART, RLENGTH); + body = substr(body, RSTART+RLENGTH) } }' | sort -u -t: -k2) + + # skills + while IFS= read -r hit; do + line="${hit%%:*}"; token="${hit#*:}" + if [ -d ".claude/skills/$token" ] || [ -d ".agents/skills/$token" ]; then + pass "skill $token" "$doc:$line" + elif printf '%s\n' $BUILTIN_SKILLS | grep -qx "$token"; then + pass "built-in skill $token" "$doc:$line" + else + checked=$((checked + 1)) + report "$doc:$line" "skill does not resolve: $token" + fi + done < <(awk -F: '{ line=$1; $1=""; body=substr($0,2); + while (match(body, /Skill\([a-z-]+\)|\$[a-z]+-[a-z-]+|\$(swiftui|swiftdata|release|fix-issue)\b/)) { + t = substr(body, RSTART, RLENGTH); + gsub(/Skill\(|\)|\$/, "", t); print line ":" t; + body = substr(body, RSTART+RLENGTH) } }' "$WORK/prose" | sort -u -t: -k2) +} + +check_counts() { + local total doc line stated + total="$(ls -d Plugins/*Plugin 2> /dev/null | wc -l | tr -d ' ')" + for doc in "${DOCS[@]}"; do + while IFS=: read -r line stated; do + if [ "$stated" = "$total" ]; then + pass "$stated plugin bundles" "$doc:$line" + else + checked=$((checked + 1)) + report "$doc:$line" "states $stated plugin bundles, the tree has $total" + fi + done < <(grep -noE '(The|all) [0-9]+ plugin' "$doc" 2> /dev/null | grep -oE '^[0-9]+|[0-9]+ plugin' \ + | paste -d: - - 2> /dev/null | sed -E 's/([0-9]+):([0-9]+) plugin/\1:\2/') + done +} + +# ------------------------------------------------------------------ run + +build_symbol_index +echo "checking ${#DOCS[@]} documents against the tree" +for doc in "${DOCS[@]}"; do check_doc "$doc"; done +check_counts + +echo +if [ "$findings" -eq 0 ]; then + echo "clean: $checked references check out" + exit 0 +fi +echo "$findings stale reference(s) out of $checked checked" +echo "Each is a claim these docs make that the tree does not support. Fix the doc, or fix the" +echo "code if the doc describes the intent and the code is what drifted." +exit 1