From 7efb596ced7c10c4980f0726bd6d53beb94f3904 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 16:35:30 -0700 Subject: [PATCH] docs(ios-qa): document regeneration and device verification flow --- AGENTS.md | 1 + README.md | 1 + docs/howto-ios-testing-with-gstack.md | 88 +++++++++++++++++++++------ ios-clean/SKILL.md | 5 +- ios-clean/SKILL.md.tmpl | 5 +- ios-qa/SKILL.md | 54 +++++++++++----- ios-qa/SKILL.md.tmpl | 54 +++++++++++----- ios-sync/SKILL.md | 51 +++++++++------- ios-sync/SKILL.md.tmpl | 51 +++++++++------- 9 files changed, 211 insertions(+), 99 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 69651022d..4df7eec35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,7 @@ Companion CLIs (run on the Mac that's plugged into the device): |---------|-------------| | `gstack-ios-qa-daemon` | Mac-side broker. Loopback by default; `--tailnet` adds a Tailscale-facing listener with capability tiers and audit logging. | | `gstack-ios-qa-mint` | Owner-grant CLI for the tailnet allowlist (`grant`/`revoke`/`list`). | +| `gstack-ios-qa-regen` | Regenerate the canonical local DebugBridge package and typed accessors (`--app-source` / `--bridge-dir`). | End-to-end walkthrough: [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). diff --git a/README.md b/README.md index 4bb177c3a..af534d2c5 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,7 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that | `gstack-taste-update` | **Design taste learning** — writes approvals and rejections from `/design-shotgun` into a persistent per-project taste profile. Decays 5%/week. Feeds back into future variant generation so the system learns what you actually pick. | | `gstack-ios-qa-daemon` | **iOS QA daemon** — Mac-side broker between an agent and a connected iPhone over USB CoreDevice. Loopback by default; `--tailnet` opens a Tailscale-facing listener with identity-gated capability tiers. Single-instance via flock on `~/.gstack/ios-qa-daemon.pid`. See [docs/howto-ios-testing-with-gstack.md](docs/howto-ios-testing-with-gstack.md). | | `gstack-ios-qa-mint` | **iOS allowlist manager** — owner-grant CLI for the tailnet allowlist. `grant`/`revoke`/`list` against `~/.gstack/ios-qa-allowlist.json` (mode 0600). Remote agents never auto-allowlist; this is the explicit-intent path. | +| `gstack-ios-qa-regen` | **iOS bridge regenerator** — deterministically installs the canonical DebugBridge package, generates typed state accessors, and records the installed gstack version. Safe to rerun after source changes or upgrades. | ### Continuous checkpoint mode (opt-in, local by default) diff --git a/docs/howto-ios-testing-with-gstack.md b/docs/howto-ios-testing-with-gstack.md index 1187e9a85..647a77c98 100644 --- a/docs/howto-ios-testing-with-gstack.md +++ b/docs/howto-ios-testing-with-gstack.md @@ -9,7 +9,7 @@ Everything below has been verified end-to-end on a real iPhone 17 Pro Max runnin - macOS with Xcode 16.0+ installed (`xcrun devicectl --version` must succeed). Xcode 16 ships the CoreDevice tunnel `devicectl` uses to reach the device over USB. - A real iPhone running iOS 16 or later. Unlocked, paired with your Mac, with **Developer Mode** enabled in Settings → Privacy & Security. - An Apple developer team — the free personal team works fine for live-device debug deploys. You'll need the team ID (e.g. `623FYQ2M88`), not the certificate ID. Find it in Xcode → Settings → Accounts → your Apple ID → team list. The setup signs the app for your device on first deploy via `-allowProvisioningUpdates -allowProvisioningDeviceRegistration`. -- gstack installed (`./setup` complete; `bin/gstack-ios-qa-daemon` must be on disk and executable). +- gstack installed (`./setup` complete; `gstack-ios-qa-regen` and `gstack-ios-qa-daemon` must be on PATH). - Bun runtime on PATH (`bun --version`). The Mac-side daemon is a bun process. For the optional remote-agent (Tailscale) mode, you'll additionally need Tailscale installed on the Mac with `/var/run/tailscale.sock` readable. @@ -30,28 +30,49 @@ For the optional remote-agent (Tailscale) mode, you'll additionally need Tailsca The iOS `StateServer` is loopback-only **always**, even in remote mode. Identity validation happens Mac-side because the iPhone has no way to validate a Tailscale identity. -## Step 1: Add the DebugBridge templates to your iOS app +## Step 1: Generate the DebugBridge package -The templates live at `~/.claude/skills/gstack/ios-qa/templates/` after `./setup`. The fastest install is to invoke the `/ios-qa` skill in Claude Code from your app's root — it reads your Swift source, codegens typed `@Observable` state accessors, and lays down the templates with your bundle ID. Or do it by hand: +Run `/ios-qa` from the app root, or invoke the same deterministic regenerator directly: -1. Copy these into a `DebugBridge/` SPM package inside your app workspace: - - `Sources/DebugBridgeCore/StateServer.swift` (from `StateServer.swift.template`) - - `Sources/DebugBridgeCore/DebugBridgeManager.swift` (from `DebugBridgeManager.swift.template`) - - `Sources/DebugBridgeTouch/DebugBridgeTouch.m` + `Sources/DebugBridgeTouch/include/DebugBridgeTouch.h` (from the two `.template` files) - - `Sources/DebugBridgeUI/Bridges.swift` (from `Bridges.swift.template`) - - `Sources/DebugBridgeUI/DebugOverlay.swift` (from `DebugOverlay.swift.template`) - - `Package.swift` (from `Package.swift.template`) -2. Add the package as a local dependency of your app. Depend on the `DebugBridgeUI` product with `condition: .when(configuration: .debug)`. `DebugBridgeCore` and `DebugBridgeTouch` come in transitively. -3. In your `@main` App init, gate the wiring on `#if DEBUG`: +```bash +gstack-ios-qa-regen \ + --app-source "$PWD/Sources/YourApp" \ + --bridge-dir "$PWD/DebugBridge" +``` + +The command copies an explicit allowlist of canonical templates into the local +`DebugBridge/` Swift package, generates +`DebugBridgeGenerated/StateAccessor.swift`, and writes the installed version to +`DebugBridgeGenerated/.gstack-version`. It excludes generated output from its +own schema hash, so rerunning it with unchanged source is a fast, byte-stable +cache hit. It also removes the explicit legacy generated-file set from older +flat harness layouts so stale bridge sources cannot shadow the package. + +1. Add `DebugBridge/` as a local package dependency. Depend on the + `DebugBridgeUI` product only in Debug configuration; `DebugBridgeCore` and + `DebugBridgeTouch` come in transitively. +2. Add `DebugBridgeGenerated/StateAccessor.swift` to the app target. +3. In your `@main` App init, install the UIKit resolvers before starting the + server, then register the generated accessor. Replace the example + state/accessor names with the type the generator found: ```swift #if DEBUG import DebugBridgeCore - StateServer.shared.start() #if canImport(UIKit) import DebugBridgeUI + #endif + #endif + + // Inside App.init(), after appState is initialized: + #if DEBUG + #if canImport(UIKit) DebugBridgeUIWiring.installAll() #endif + DebugBridgeManager.shared.start( + appState: appState, + register: AppStateAccessor.register + ) #endif ``` @@ -109,7 +130,16 @@ GSTACK_IOS_TARGET_BUNDLE_ID=com.yourorg.yourapp GSTACK_IOS_DAEMON_PORT=9099 # loopback listener port; default 9099 ``` -If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the first paired connected device. +If `GSTACK_IOS_TARGET_UDID` is unset, the daemon picks the best paired, +available iPhone. +Automatic selection is restricted to available iPhones and prefers a wired +phone. The daemon keeps a healthy rotated tunnel, then invalidates and +rebootstraps once on an app-relaunch 401 or recoverable CoreDevice connection +failure. +If a newly started daemon reaches an already-running target whose one-use boot +token was consumed by an earlier daemon, it verifies the bundle owner, force +relaunches that target once, waits for a fresh token, verifies ownership again, +and rotates normally. ## Step 4: Drive the device @@ -123,15 +153,39 @@ Once the daemon is running, you have an HTTP surface at `http://127.0.0.1:9099` | `POST /session/release` | Release the lock. | bearer + session | | `GET /screenshot` | Capture a PNG of the active window. Returns `{png_base64: "..."}`. | bearer | | `GET /elements` | Accessibility-tree snapshot. | bearer | -| `GET /state/snapshot` | Dump every `@Snapshotable` field as JSON. | bearer | -| `POST /state/restore` | Atomically restore a full snapshot. | bearer + session, mutate tier | +| `GET /state/snapshot` | Dump every `// @Snapshotable` field as JSON. | bearer | +| `POST /state/restore` | Validate the full snapshot, then restore it on MainActor. | bearer + session, mutate tier | | `POST /tap` `{x,y}` | Synthesize a real UITouch at window coordinates. SwiftUI Buttons fire. | bearer + session, interact tier | | `POST /swipe` `{from_x,from_y,to_x,to_y}` | Scroll the nearest enclosing UIScrollView. | bearer + session, interact tier | | `POST /type` `{text}` | Set text on the current first responder. | bearer + session, interact tier | Mutating requests require both an `Authorization: Bearer ` header AND an `X-Session-Id` header. Read endpoints (`/screenshot`, `/elements`, `GET /state/*`) only need the bearer. -The state snapshot is opt-in per field via a `@Snapshotable` property wrapper on your canonical state struct. Fields you don't annotate never appear in the snapshot, which keeps tokens, PII, and auth state out of recorded fixtures by default. +The state snapshot is opt-in per field via a standalone generator marker +comment immediately above a property. It is intentionally not a property +wrapper, so it compiles cleanly with Observation: + +```swift +@Observable +final class AppState { + // @Snapshotable + var username: String = "" + + var authToken: String = "" // never exported +} +``` + +Unmarked fields never appear in the snapshot, which keeps tokens, PII, and +auth state out of recorded fixtures by default. A marked field must be a +writable instance `var` on a file-scope observable class, with an explicit type +and an internal or public setter. Supported snapshot types are JSON-native +scalars (`String`, `Bool`, signed/unsigned integer widths, `Float`, `Double`, +`CGFloat`), arrays, String-keyed dictionaries, and Optional compositions of +those types. Snapshot keys must be unique across observable classes. The +generator reports and stops on invalid declarations, custom values, implicitly +unwrapped Optionals, nested observable classes, or duplicate keys instead of +emitting broken or lossy Swift. Restore uses two phases: every model validates +the complete input first, and only then are assignments applied on MainActor. ## Step 5: Make remote agents work (optional) diff --git a/ios-clean/SKILL.md b/ios-clean/SKILL.md index 127649646..6c466896b 100644 --- a/ios-clean/SKILL.md +++ b/ios-clean/SKILL.md @@ -821,9 +821,8 @@ Each item is reverted only after AskUserQuestion confirmation: 1. The `DebugBridge` SPM target from `Package.swift`. 2. The `#if DEBUG` block in the app's `@main` entry that calls `DebugBridgeManager.shared.start()`. -3. Any `@Snapshotable` property wrappers on the canonical app state struct - (the codegen-detection markers — the wrapper file lives inside - DebugBridge so removing the SPM dep removes the wrapper too). +3. Any standalone `// @Snapshotable` generator marker comments on the + canonical app state class. 4. Generated `StateAccessor.swift` files anywhere under the app source. 5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the device (best-effort — only works if device is connected when /ios-clean diff --git a/ios-clean/SKILL.md.tmpl b/ios-clean/SKILL.md.tmpl index 3a64481a9..21a1d5495 100644 --- a/ios-clean/SKILL.md.tmpl +++ b/ios-clean/SKILL.md.tmpl @@ -50,9 +50,8 @@ Each item is reverted only after AskUserQuestion confirmation: 1. The `DebugBridge` SPM target from `Package.swift`. 2. The `#if DEBUG` block in the app's `@main` entry that calls `DebugBridgeManager.shared.start()`. -3. Any `@Snapshotable` property wrappers on the canonical app state struct - (the codegen-detection markers — the wrapper file lives inside - DebugBridge so removing the SPM dep removes the wrapper too). +3. Any standalone `// @Snapshotable` generator marker comments on the + canonical app state class. 4. Generated `StateAccessor.swift` files anywhere under the app source. 5. The `gstack-ios-qa.token` file under `NSTemporaryDirectory()` on the device (best-effort — only works if device is connected when /ios-clean diff --git a/ios-qa/SKILL.md b/ios-qa/SKILL.md index a5d4575d4..24624be5f 100644 --- a/ios-qa/SKILL.md +++ b/ios-qa/SKILL.md @@ -869,17 +869,33 @@ fi ## Phase 1: Read source, plan codegen 1. Walk the app source (passed as `--source `) and identify all `@Observable` - classes. Note any property marked with the `@Snapshotable` wrapper — those - are the snapshot-eligible fields. -2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input `. - First invocation builds the swift-syntax dependency tree (cold: 2-5 min). - Subsequent runs are content-hash-cached and finish in ~50ms. -3. Show the user the accessor list and ask whether to install the DebugBridge + classes. Note any property immediately preceded by the generator marker + comment `// @Snapshotable` — those are the snapshot-eligible fields. The + marker is a comment so it composes with the `@Observable` macro. Each + marked field must belong to a file-scope observable class and be a writable + instance `var` with an explicit type and an internal or public setter. + Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths, + `Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their + Optional compositions. Keys must be unique across observable classes. + Codegen stops with a source diagnostic instead of emitting a broken or + lossy harness when any of these constraints is violated. +2. Show the user the accessor list and ask whether to install the DebugBridge SPM dependency into their `Package.swift` (one AskUserQuestion). ## Phase 2: Bootstrap the device bridge -1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package +1. Generate the canonical local bridge package, typed accessors, and installed + version marker with one deterministic command: + ```bash + ~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ + --app-source "" \ + --bridge-dir "/DebugBridge" + ``` + The regenerator also removes the explicit obsolete flat-file set created by + older ios-sync versions, preventing a stale second harness from remaining + in the app target. +2. Add the generated `DebugBridge` local SPM dependency to the app's + `Package.swift`. The package ships three Debug-config-only library products: - `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols. - `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch @@ -889,27 +905,35 @@ fi The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)` (transitively pulls in Core + Touch). Release builds refuse to link these targets. -2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`: +3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`: ```swift #if DEBUG import DebugBridgeCore - StateServer.shared.start() #if canImport(UIKit) import DebugBridgeUI + // Install resolvers before StateServer opens its listener. DebugBridgeUIWiring.installAll() #endif + // Replace AppState/AppStateAccessor with the type discovered in Phase 1. + DebugBridgeManager.shared.start( + appState: appState, + register: AppStateAccessor.register + ) #endif ``` -3. Build + deploy to the device with `xcodebuild -scheme +4. Build + deploy to the device with `xcodebuild -scheme -destination 'platform=iOS,id=' build install`. -4. Launch via `devicectl device process launch --device --console `. +5. Launch via `devicectl device process launch --device --console `. Capture the boot token printed to `os_log` on first run. -5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon +6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another daemon is alive, the second invocation discovers its port and connects. -6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a +7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a fresh in-memory-only token. The boot token becomes useless ~5s later. Anything scraping `os_log` past this point sees a dead credential. + If a fresh daemon finds the app running after another daemon consumed that + one-use token, it verifies the bundle owner, relaunches the target once, + waits for the new token, verifies ownership again, and then rotates. ## Phase 3: Vision-driven agent loop @@ -917,7 +941,7 @@ Each iteration: 1. `GET /screenshot` (via daemon) → save PNG. 2. `GET /elements` → accessibility tree. -3. `GET /state/snapshot` (only `@Snapshotable` fields) → current state. +3. `GET /state/snapshot` (only `// @Snapshotable` fields) → current state. 4. Decide next action based on what's on the screen vs the test goal. 5. `POST /session/acquire` to grab the device lock. 6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/` write. @@ -981,7 +1005,7 @@ live. | `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed | | `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote ` on the Mac | | `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture | -| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s | +| `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists | | `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies | | `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot | diff --git a/ios-qa/SKILL.md.tmpl b/ios-qa/SKILL.md.tmpl index e93d2831a..78f9454b1 100644 --- a/ios-qa/SKILL.md.tmpl +++ b/ios-qa/SKILL.md.tmpl @@ -97,17 +97,33 @@ fi ## Phase 1: Read source, plan codegen 1. Walk the app source (passed as `--source `) and identify all `@Observable` - classes. Note any property marked with the `@Snapshotable` wrapper — those - are the snapshot-eligible fields. -2. Run `swift run --package-path $GSTACK_HOME/ios-qa/scripts/gen-accessors-tool gen-accessors --input `. - First invocation builds the swift-syntax dependency tree (cold: 2-5 min). - Subsequent runs are content-hash-cached and finish in ~50ms. -3. Show the user the accessor list and ask whether to install the DebugBridge + classes. Note any property immediately preceded by the generator marker + comment `// @Snapshotable` — those are the snapshot-eligible fields. The + marker is a comment so it composes with the `@Observable` macro. Each + marked field must belong to a file-scope observable class and be a writable + instance `var` with an explicit type and an internal or public setter. + Snapshot types are JSON-native scalars (`String`, `Bool`, integer widths, + `Float`, `Double`, `CGFloat`), arrays, String-keyed dictionaries, and their + Optional compositions. Keys must be unique across observable classes. + Codegen stops with a source diagnostic instead of emitting a broken or + lossy harness when any of these constraints is violated. +2. Show the user the accessor list and ask whether to install the DebugBridge SPM dependency into their `Package.swift` (one AskUserQuestion). ## Phase 2: Bootstrap the device bridge -1. Add the `DebugBridge` SPM dependency to the app's `Package.swift`. The package +1. Generate the canonical local bridge package, typed accessors, and installed + version marker with one deterministic command: + ```bash + ~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ + --app-source "" \ + --bridge-dir "/DebugBridge" + ``` + The regenerator also removes the explicit obsolete flat-file set created by + older ios-sync versions, preventing a stale second harness from remaining + in the app target. +2. Add the generated `DebugBridge` local SPM dependency to the app's + `Package.swift`. The package ships three Debug-config-only library products: - `DebugBridgeCore` (Swift, cross-platform) — StateServer + bridge protocols. - `DebugBridgeTouch` (Objective-C, iOS-only) — KIF-derived in-process touch @@ -117,27 +133,35 @@ fi The app target depends on `DebugBridgeUI` with `.when(configuration: .debug)` (transitively pulls in Core + Touch). Release builds refuse to link these targets. -2. Wire the bridges from the `@main` App init, gated on `#if DEBUG`: +3. Wire the bridges from the `@main` App init, gated on `#if DEBUG`: ```swift #if DEBUG import DebugBridgeCore - StateServer.shared.start() #if canImport(UIKit) import DebugBridgeUI + // Install resolvers before StateServer opens its listener. DebugBridgeUIWiring.installAll() #endif + // Replace AppState/AppStateAccessor with the type discovered in Phase 1. + DebugBridgeManager.shared.start( + appState: appState, + register: AppStateAccessor.register + ) #endif ``` -3. Build + deploy to the device with `xcodebuild -scheme +4. Build + deploy to the device with `xcodebuild -scheme -destination 'platform=iOS,id=' build install`. -4. Launch via `devicectl device process launch --device --console `. +5. Launch via `devicectl device process launch --device --console `. Capture the boot token printed to `os_log` on first run. -5. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon +6. Spawn the Mac-side daemon (on-demand) — `gstack-ios-qa-daemon`. Daemon acquires an exclusive flock on `~/.gstack/ios-qa-daemon.pid`. If another daemon is alive, the second invocation discovers its port and connects. -6. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a +7. Daemon immediately calls `POST /auth/rotate` on the iOS StateServer with a fresh in-memory-only token. The boot token becomes useless ~5s later. Anything scraping `os_log` past this point sees a dead credential. + If a fresh daemon finds the app running after another daemon consumed that + one-use token, it verifies the bundle owner, relaunches the target once, + waits for the new token, verifies ownership again, and then rotates. ## Phase 3: Vision-driven agent loop @@ -145,7 +169,7 @@ Each iteration: 1. `GET /screenshot` (via daemon) → save PNG. 2. `GET /elements` → accessibility tree. -3. `GET /state/snapshot` (only `@Snapshotable` fields) → current state. +3. `GET /state/snapshot` (only `// @Snapshotable` fields) → current state. 4. Decide next action based on what's on the screen vs the test goal. 5. `POST /session/acquire` to grab the device lock. 6. Execute `POST /tap`, `/swipe`, `/type`, or `POST /state/` write. @@ -209,7 +233,7 @@ live. | `curl: connection refused` to daemon | daemon crashed | Re-run `/ios-qa`; spawn-race lock will fail closed | | `403 identity_not_allowed` from `/auth/mint` | identity missing from allowlist | Run `gstack-ios-qa-mint --remote ` on the Mac | | `409 schema_mismatch` on `/state/restore` | snapshot from older app build | Discard the snapshot; re-capture | -| `503 device_disconnected` from proxy | USB tunnel dropped | Reconnect device; daemon auto-reconnects within 30s | +| `503 device_disconnected` from proxy | USB route dropped or app relaunched | Daemon invalidates the stale tunnel and retries one fresh bootstrap; reconnect/unlock the iPhone if it persists | | `429 rate_limited` from `/auth/mint` | >10 mints/min from one identity | Wait 60s; check audit log for anomalies | | `413 body_too_large` on `/state/restore` | snapshot >1MB | Increase `--max-body` or trim snapshot | diff --git a/ios-sync/SKILL.md b/ios-sync/SKILL.md index 2f689c4d6..95e0bca0a 100644 --- a/ios-sync/SKILL.md +++ b/ios-sync/SKILL.md @@ -806,49 +806,53 @@ After `/ios-qa` is installed in an app, the user may: 1. Add new `@Observable` classes or properties that need accessor coverage. 2. Upgrade gstack to a newer version with hardening fixes. -3. Move the `@Snapshotable` marker to a different field. +3. Move the `// @Snapshotable` generator marker comment to a different field. This skill regenerates the relevant artifacts in place. -**Templates live in upstream gstack.** This skill resolves them from -`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's -`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch -pattern is gone. +**Templates live in upstream gstack.** The installed +`gstack-ios-qa-regen` launcher resolves its own gstack root and copies only +the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch +and wildcard-copy patterns are gone. ## Phase 1: Detect installed version 1. Read `/DebugBridgeGenerated/.gstack-version` (written by /ios-qa during install). If missing, treat the install as "unknown old version". -2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the - value baked into the installed gstack binary). +2. Read upstream version from `$GSTACK_ROOT/VERSION`. 3. If versions match AND no new `@Observable` classes were added, exit early with "already up to date". ## Phase 2: Regenerate codegen output -Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly): +Run the deterministic regenerator once. `--app-source` is the directory the +accessor scanner should inspect; `--bridge-dir` is the local Swift package +that the app links in Debug builds: ```bash -swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \ - gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated" +~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ + --app-source "$APP_SOURCE_DIR" \ + --bridge-dir "$APP_SOURCE_DIR/DebugBridge" ``` +The command removes only the known obsolete generated files from the former +flat `DebugBridgeGenerated/` layout before emitting the current accessor. +Generation accepts file-scope observable classes and JSON-native scalar, +array, String-keyed dictionary, and Optional field types. It rejects custom +types, implicitly unwrapped Optionals, nested observable classes, and duplicate +snapshot keys before writing a completion marker. + The composite-hash cache key handles whether anything actually needs regenerating; if Swift version, generator git rev, lockfile, source content, and platform triple all match the cache, this is a ~50ms no-op. -## Phase 3: Update templated Swift files in place +## Phase 3: Review the generated diff -For each file that comes from `ios-qa/templates/*.swift.template`: - -1. Read the current installed file at - `/DebugBridgeGenerated/.swift`. -2. Read the upstream template at - `$GSTACK_HOME/ios-qa/templates/.swift.template`. -3. If the installed file has a `// GSTACK-EDIT-LINE` marker, fold the user's - edits forward. -4. Otherwise, replace the file outright with the new template (after - AskUserQuestion if the diff is non-trivial). +1. Review changes under `/DebugBridge/` and + `/DebugBridgeGenerated/StateAccessor.swift`. +2. Confirm the command did not modify the app's handwritten Swift files. +3. Keep app-specific wiring in the app target; canonical bridge package files + are regenerated from upstream and should not be hand-edited. ## Phase 4: Verify @@ -862,5 +866,6 @@ For each file that comes from `ios-qa/templates/*.swift.template`: | Symptom | Action | |---|---| | Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error | -| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. | -| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` | +| Codegen reports an invalid marked declaration | Use a file-scope observable class and a writable instance `var` with an explicit JSON-native type, internal/public setter, and a key unique across models; otherwise remove the `// @Snapshotable` marker. | +| Schema hash unchanged after adding new @Observable | No field has the standalone `// @Snapshotable` marker comment — codegen excludes unmarked state correctly. Add the comment immediately above each field that should be snapshotted. | +| Scanner sees generated bridge sources | Pass the narrow app source directory; the regenerator automatically excludes `DebugBridgeGenerated` and `StateAccessor.swift`. | diff --git a/ios-sync/SKILL.md.tmpl b/ios-sync/SKILL.md.tmpl index 156a33c4c..add54c3d8 100644 --- a/ios-sync/SKILL.md.tmpl +++ b/ios-sync/SKILL.md.tmpl @@ -35,49 +35,53 @@ After `/ios-qa` is installed in an app, the user may: 1. Add new `@Observable` classes or properties that need accessor coverage. 2. Upgrade gstack to a newer version with hardening fixes. -3. Move the `@Snapshotable` marker to a different field. +3. Move the `// @Snapshotable` generator marker comment to a different field. This skill regenerates the relevant artifacts in place. -**Templates live in upstream gstack.** This skill resolves them from -`~/.claude/skills/gstack/ios-qa/templates/` (or the worktree's -`ios-qa/templates/` when developing gstack itself). The fork's HTTP-fetch -pattern is gone. +**Templates live in upstream gstack.** The installed +`gstack-ios-qa-regen` launcher resolves its own gstack root and copies only +the supported bridge files from `ios-qa/templates/`. The fork's HTTP-fetch +and wildcard-copy patterns are gone. ## Phase 1: Detect installed version 1. Read `/DebugBridgeGenerated/.gstack-version` (written by /ios-qa during install). If missing, treat the install as "unknown old version". -2. Read upstream version from `$GSTACK_HOME/ios-qa/.gstack-version` (or the - value baked into the installed gstack binary). +2. Read upstream version from `$GSTACK_ROOT/VERSION`. 3. If versions match AND no new `@Observable` classes were added, exit early with "already up to date". ## Phase 2: Regenerate codegen output -Run `gstack-ios-qa-regen` (or the underlying SwiftPM tool directly): +Run the deterministic regenerator once. `--app-source` is the directory the +accessor scanner should inspect; `--bridge-dir` is the local Swift package +that the app links in Debug builds: ```bash -swift run --package-path "$GSTACK_HOME/ios-qa/scripts/gen-accessors-tool" \ - gen-accessors --input "$APP_SOURCE_DIR" --output "$APP_SOURCE_DIR/DebugBridgeGenerated" +~/.claude/skills/gstack/bin/gstack-ios-qa-regen \ + --app-source "$APP_SOURCE_DIR" \ + --bridge-dir "$APP_SOURCE_DIR/DebugBridge" ``` +The command removes only the known obsolete generated files from the former +flat `DebugBridgeGenerated/` layout before emitting the current accessor. +Generation accepts file-scope observable classes and JSON-native scalar, +array, String-keyed dictionary, and Optional field types. It rejects custom +types, implicitly unwrapped Optionals, nested observable classes, and duplicate +snapshot keys before writing a completion marker. + The composite-hash cache key handles whether anything actually needs regenerating; if Swift version, generator git rev, lockfile, source content, and platform triple all match the cache, this is a ~50ms no-op. -## Phase 3: Update templated Swift files in place +## Phase 3: Review the generated diff -For each file that comes from `ios-qa/templates/*.swift.template`: - -1. Read the current installed file at - `/DebugBridgeGenerated/.swift`. -2. Read the upstream template at - `$GSTACK_HOME/ios-qa/templates/.swift.template`. -3. If the installed file has a `// GSTACK-EDIT-LINE` marker, fold the user's - edits forward. -4. Otherwise, replace the file outright with the new template (after - AskUserQuestion if the diff is non-trivial). +1. Review changes under `/DebugBridge/` and + `/DebugBridgeGenerated/StateAccessor.swift`. +2. Confirm the command did not modify the app's handwritten Swift files. +3. Keep app-specific wiring in the app target; canonical bridge package files + are regenerated from upstream and should not be hand-edited. ## Phase 4: Verify @@ -91,5 +95,6 @@ For each file that comes from `ios-qa/templates/*.swift.template`: | Symptom | Action | |---|---| | Swift compile fails after regen | Revert via `git restore` + AskUserQuestion: surface the compile error | -| Schema hash unchanged after adding new @Observable | The new class isn't marked `@Snapshotable` — the codegen excludes it correctly. If the user wanted it snapshotted, add the wrapper. | -| `--input` source dir contains test fixtures | gen-accessors scans the input dir recursively; exclude test/ via `--exclude` | +| Codegen reports an invalid marked declaration | Use a file-scope observable class and a writable instance `var` with an explicit JSON-native type, internal/public setter, and a key unique across models; otherwise remove the `// @Snapshotable` marker. | +| Schema hash unchanged after adding new @Observable | No field has the standalone `// @Snapshotable` marker comment — codegen excludes unmarked state correctly. Add the comment immediately above each field that should be snapshotted. | +| Scanner sees generated bridge sources | Pass the narrow app source directory; the regenerator automatically excludes `DebugBridgeGenerated` and `StateAccessor.swift`. |