docs(ios-qa): document regeneration and device verification flow

This commit is contained in:
t
2026-07-14 16:35:30 -07:00
parent c378074ab7
commit 7efb596ced
9 changed files with 211 additions and 99 deletions
+1
View File
@@ -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).
+1
View File
@@ -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)
+71 -17
View File
@@ -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 <token>` 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)
+2 -3
View File
@@ -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
+2 -3
View File
@@ -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
+39 -15
View File
@@ -869,17 +869,33 @@ fi
## Phase 1: Read source, plan codegen
1. Walk the app source (passed as `--source <dir>`) 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 <source-dir>`.
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 "<source-dir>" \
--bridge-dir "<source-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 <SchemeName>
4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
-destination 'platform=iOS,id=<UDID>' build install`.
4. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
5. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
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/<key>` 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 <identity>` 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 |
+39 -15
View File
@@ -97,17 +97,33 @@ fi
## Phase 1: Read source, plan codegen
1. Walk the app source (passed as `--source <dir>`) 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 <source-dir>`.
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 "<source-dir>" \
--bridge-dir "<source-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 <SchemeName>
4. Build + deploy to the device with `xcodebuild -scheme <SchemeName>
-destination 'platform=iOS,id=<UDID>' build install`.
4. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
5. Launch via `devicectl device process launch --device <UDID> --console <bundle-id>`.
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/<key>` 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 <identity>` 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 |
+28 -23
View File
@@ -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 `<app>/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
`<app>/DebugBridgeGenerated/<Name>.swift`.
2. Read the upstream template at
`$GSTACK_HOME/ios-qa/templates/<Name>.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 `<app>/DebugBridge/` and
`<app>/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`. |
+28 -23
View File
@@ -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 `<app>/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
`<app>/DebugBridgeGenerated/<Name>.swift`.
2. Read the upstream template at
`$GSTACK_HOME/ios-qa/templates/<Name>.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 `<app>/DebugBridge/` and
`<app>/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`. |