diff --git a/bin/gstack-ios-qa-regen b/bin/gstack-ios-qa-regen
new file mode 100755
index 000000000..ffaec9ffb
--- /dev/null
+++ b/bin/gstack-ios-qa-regen
@@ -0,0 +1,154 @@
+#!/usr/bin/env bash
+# gstack-ios-qa-regen — deterministically regenerate the iOS DebugBridge
+# package and the app-owned typed state accessors.
+
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage: gstack-ios-qa-regen --app-source
--bridge-dir
+
+ --app-source Swift source tree to scan for @Observable state
+ --bridge-dir Destination for the generated local DebugBridge package
+EOF
+}
+
+APP_SOURCE=""
+BRIDGE_DIR=""
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --app-source)
+ [[ $# -ge 2 ]] || { echo "gstack-ios-qa-regen: --app-source requires a value" >&2; exit 2; }
+ APP_SOURCE="$2"
+ shift 2
+ ;;
+ --bridge-dir)
+ [[ $# -ge 2 ]] || { echo "gstack-ios-qa-regen: --bridge-dir requires a value" >&2; exit 2; }
+ BRIDGE_DIR="$2"
+ shift 2
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "gstack-ios-qa-regen: unknown argument: $1" >&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+if [[ -z "$APP_SOURCE" || -z "$BRIDGE_DIR" ]]; then
+ echo "gstack-ios-qa-regen: both --app-source and --bridge-dir are required" >&2
+ usage >&2
+ exit 2
+fi
+
+if [[ ! -d "$APP_SOURCE" ]]; then
+ echo "gstack-ios-qa-regen: app source directory not found: $APP_SOURCE" >&2
+ exit 1
+fi
+
+if ! command -v bun >/dev/null 2>&1; then
+ echo "gstack-ios-qa-regen: bun runtime not on PATH — install from https://bun.sh" >&2
+ exit 1
+fi
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+GSTACK_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+TEMPLATE_DIR="$GSTACK_ROOT/ios-qa/templates"
+GENERATOR="$GSTACK_ROOT/ios-qa/scripts/gen-accessors.ts"
+VERSION_FILE="$GSTACK_ROOT/VERSION"
+GENERATED_DIR="$APP_SOURCE/DebugBridgeGenerated"
+
+for required in "$GENERATOR" "$VERSION_FILE"; do
+ if [[ ! -f "$required" ]]; then
+ echo "gstack-ios-qa-regen: missing required gstack file: $required" >&2
+ exit 1
+ fi
+done
+
+TMP_FILE=""
+cleanup() {
+ if [[ -n "$TMP_FILE" ]]; then
+ rm -f "$TMP_FILE"
+ fi
+}
+trap cleanup EXIT
+
+# Copy through a sibling temporary file so interruption never leaves a
+# truncated generated source. Preserve an unchanged destination byte-for-byte
+# and metadata-for-metadata on repeated runs.
+install_file() {
+ local source="$1"
+ local destination="$2"
+
+ if [[ ! -f "$source" ]]; then
+ echo "gstack-ios-qa-regen: missing template: $source" >&2
+ exit 1
+ fi
+ if [[ -f "$destination" ]] && cmp -s "$source" "$destination"; then
+ return
+ fi
+
+ mkdir -p "$(dirname "$destination")"
+ TMP_FILE="${destination}.tmp.$$"
+ cp "$source" "$TMP_FILE"
+ mv "$TMP_FILE" "$destination"
+ TMP_FILE=""
+}
+
+# Invalidate the completion marker before changing any package source. A
+# failed or interrupted regeneration must never look current to ios-sync.
+mkdir -p "$GENERATED_DIR"
+rm -f -- "$GENERATED_DIR/.gstack-version"
+
+# This is intentionally an allowlist, not a template glob. Wiring belongs to
+# the consuming app and StateAccessor.swift is emitted by the parser below.
+install_file "$TEMPLATE_DIR/Package.swift.template" \
+ "$BRIDGE_DIR/Package.swift"
+install_file "$TEMPLATE_DIR/StateServer.swift.template" \
+ "$BRIDGE_DIR/Sources/DebugBridgeCore/StateServer.swift"
+install_file "$TEMPLATE_DIR/DebugBridgeManager.swift.template" \
+ "$BRIDGE_DIR/Sources/DebugBridgeCore/DebugBridgeManager.swift"
+install_file "$TEMPLATE_DIR/Bridges.swift.template" \
+ "$BRIDGE_DIR/Sources/DebugBridgeUI/Bridges.swift"
+install_file "$TEMPLATE_DIR/DebugOverlay.swift.template" \
+ "$BRIDGE_DIR/Sources/DebugBridgeUI/DebugOverlay.swift"
+install_file "$TEMPLATE_DIR/DebugBridgeTouch.m.template" \
+ "$BRIDGE_DIR/Sources/DebugBridgeTouch/DebugBridgeTouch.m"
+install_file "$TEMPLATE_DIR/DebugBridgeTouch.h.template" \
+ "$BRIDGE_DIR/Sources/DebugBridgeTouch/include/DebugBridgeTouch.h"
+
+# Older ios-sync versions copied the entire template set flat into the app's
+# generated-source directory. Those files can shadow the package modules or
+# make Xcode compile two harness implementations. Remove only the explicit
+# obsolete generated paths; handwritten app sources are never touched.
+for obsolete in \
+ "$BRIDGE_DIR/DebugBridgeWiring.swift" \
+ "$BRIDGE_DIR/StateAccessor.swift" \
+ "$GENERATED_DIR/Package.swift" \
+ "$GENERATED_DIR/StateServer.swift" \
+ "$GENERATED_DIR/DebugBridgeManager.swift" \
+ "$GENERATED_DIR/Bridges.swift" \
+ "$GENERATED_DIR/DebugOverlay.swift" \
+ "$GENERATED_DIR/DebugBridgeTouch.m" \
+ "$GENERATED_DIR/DebugBridgeTouch.h" \
+ "$GENERATED_DIR/DebugBridgeWiring.swift"
+do
+ if [[ -f "$obsolete" || -L "$obsolete" ]]; then
+ rm -f -- "$obsolete"
+ echo "gstack-ios-qa-regen: removed obsolete generated file $obsolete"
+ fi
+done
+
+bun run "$GENERATOR" --input "$APP_SOURCE" --output "$GENERATED_DIR"
+
+# Stamp only after successful accessor generation. ios-sync uses this marker
+# to distinguish a complete current install from an interrupted regeneration.
+install_file "$VERSION_FILE" "$GENERATED_DIR/.gstack-version"
+
+echo "gstack-ios-qa-regen: bridge package ready at $BRIDGE_DIR"
+echo "gstack-ios-qa-regen: accessors ready at $GENERATED_DIR/StateAccessor.swift"
diff --git a/ios-qa/scripts/gen-accessors-tool/Package.swift b/ios-qa/scripts/gen-accessors-tool/Package.swift
index f71c58793..477aa61a0 100644
--- a/ios-qa/scripts/gen-accessors-tool/Package.swift
+++ b/ios-qa/scripts/gen-accessors-tool/Package.swift
@@ -1,8 +1,8 @@
// swift-tools-version:5.9
//
// gen-accessors-tool — SwiftPM tool that reads an app's Swift source via
-// swift-syntax, finds @Observable classes with @Snapshotable-marked fields,
-// and emits StateAccessor.swift for each one.
+// swift-syntax, finds @Observable classes with `// @Snapshotable`-marked
+// fields, and emits StateAccessor.swift for each one.
//
// First build is 2-5 min on a cold machine (swift-syntax compile chain).
// Subsequent runs are content-hash-cached and finish in ~50ms.
diff --git a/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift b/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift
index 8287350cb..c47e3cae3 100644
--- a/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift
+++ b/ios-qa/scripts/gen-accessors-tool/Sources/GenAccessors/main.swift
@@ -1,6 +1,7 @@
// gen-accessors entry point. Walks the input dir for *.swift files, parses
-// each via SwiftParser, finds @Observable classes with @Snapshotable-marked
-// properties, and emits StateAccessor.swift for each.
+// each via SwiftParser, finds @Observable classes with `// @Snapshotable`
+// source-marker comments (plus the legacy attribute form), and emits
+// StateAccessor.swift for each.
//
// Output goes to --output (default: same dir as input). Cache key is
// computed from a composite hash and stored at
@@ -15,6 +16,8 @@ struct AccessorSpec {
let fields: [(name: String, typeText: String)]
}
+private let generatorFormatVersion = "accessor-generator-v5"
+
@main
struct GenAccessors {
static func main() async {
@@ -32,38 +35,83 @@ struct GenAccessors {
}()
// Walk + collect *.swift files
- guard let swiftFiles = collectSwiftFiles(at: inputDir) else {
+ guard let swiftFiles = collectSwiftFiles(at: inputDir, excluding: outputDir) else {
FileHandle.standardError.write(Data("input dir not found: \(inputDir)\n".utf8))
exit(3)
}
- // Composite cache key — codex catch (source content alone misses
- // generator-logic changes).
- let cacheKey = computeCacheKey(swiftFiles: swiftFiles)
- let cacheDir = ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
+ // Parse + validate before consulting the cache. Invalid marked fields
+ // must fail deterministically rather than being hidden by an older
+ // cached output.
+ var specs: [AccessorSpec] = []
+ var diagnostics: [String] = []
+ for path in swiftFiles {
+ guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
+ let tree = Parser.parse(source: source)
+ let visitor = ObservableClassVisitor(sourcePath: path, viewMode: .sourceAccurate)
+ visitor.walk(tree)
+ specs.append(contentsOf: visitor.specs)
+ diagnostics.append(contentsOf: visitor.diagnostics)
+ }
+ var snapshotKeyOwners: [String: String] = [:]
+ for spec in specs {
+ for field in spec.fields {
+ if let previous = snapshotKeyOwners[field.name] {
+ diagnostics.append(
+ "snapshot key '\(field.name)' is declared by both \(previous) and \(spec.className); "
+ + "keys must be unique across @Observable types"
+ )
+ } else {
+ snapshotKeyOwners[field.name] = spec.className
+ }
+ }
+ }
+ if !diagnostics.isEmpty {
+ let message = "gen-accessors: invalid @Snapshotable declaration(s):\n"
+ + diagnostics.map { " - \($0)" }.joined(separator: "\n") + "\n"
+ FileHandle.standardError.write(Data(message.utf8))
+ exit(4)
+ }
+
+ let buildId = detectedBuildId()
+ let accessorHash = computeAccessorHash(specs: specs)
+ // Cache identity includes build provenance and generator ABI. The
+ // separately computed accessorHash is schema-only and remains stable
+ // across checkout paths, unrelated sources, and app rebuilds.
+ let cacheKey = computeCacheKey(swiftFiles: swiftFiles, buildId: buildId)
+ let cacheDir = getEnv("GSTACK_IOS_CACHE_ROOT")
+ ?? ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
let cachedOutput = "\(cacheDir)/\(cacheKey)/StateAccessor.swift"
+ do {
+ try FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
+ } catch {
+ FileHandle.standardError.write(Data("gen-accessors: cannot create output directory: \(error)\n".utf8))
+ exit(5)
+ }
if FileManager.default.fileExists(atPath: cachedOutput) {
// Cache hit. Copy to output dir.
- try? FileManager.default.removeItem(atPath: "\(outputDir)/StateAccessor.swift")
- try? FileManager.default.copyItem(atPath: cachedOutput, toPath: "\(outputDir)/StateAccessor.swift")
+ let finalOutput = "\(outputDir)/StateAccessor.swift"
+ do {
+ if FileManager.default.fileExists(atPath: finalOutput) {
+ try FileManager.default.removeItem(atPath: finalOutput)
+ }
+ try FileManager.default.copyItem(atPath: cachedOutput, toPath: finalOutput)
+ } catch {
+ FileHandle.standardError.write(Data("gen-accessors: cannot restore cached output: \(error)\n".utf8))
+ exit(5)
+ }
print("gen-accessors: cache hit (\(cacheKey))")
return
}
- // Parse + extract specs
- var specs: [AccessorSpec] = []
- for path in swiftFiles {
- guard let source = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
- let tree = Parser.parse(source: source)
- let visitor = ObservableClassVisitor(viewMode: .sourceAccurate)
- visitor.walk(tree)
- specs.append(contentsOf: visitor.specs)
- }
-
// Emit
- let output = render(specs: specs, buildId: getEnv("APP_BUILD_ID") ?? "unknown", accessorHash: cacheKey)
- try? FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
- try? output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
+ let output = render(specs: specs, buildId: buildId, accessorHash: accessorHash)
+ do {
+ try output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
+ } catch {
+ FileHandle.standardError.write(Data("gen-accessors: cannot write output: \(error)\n".utf8))
+ exit(5)
+ }
// Populate cache
try? FileManager.default.createDirectory(atPath: "\(cacheDir)/\(cacheKey)", withIntermediateDirectories: true)
@@ -72,46 +120,154 @@ struct GenAccessors {
print("gen-accessors: wrote \(specs.count) accessor(s) to \(outputDir)/StateAccessor.swift")
}
- static func collectSwiftFiles(at path: String) -> [String]? {
- guard let enumerator = FileManager.default.enumerator(atPath: path) else { return nil }
+ static func collectSwiftFiles(at path: String, excluding outputPath: String) -> [String]? {
+ let inputURL = URL(fileURLWithPath: path).standardizedFileURL
+ let outputURL = URL(fileURLWithPath: outputPath).standardizedFileURL
+ var isDirectory: ObjCBool = false
+ guard FileManager.default.fileExists(atPath: inputURL.path, isDirectory: &isDirectory),
+ isDirectory.boolValue,
+ let enumerator = FileManager.default.enumerator(
+ at: inputURL,
+ includingPropertiesForKeys: [.isDirectoryKey],
+ options: []
+ ) else { return nil }
+
+ // When --output is the input root, scan the root but still exclude
+ // StateAccessor.swift. For a nested output, skip the entire subtree.
+ let shouldExcludeOutputSubtree = outputURL.path != inputURL.path
var files: [String] = []
- for case let f as String in enumerator {
- if f.hasSuffix(".swift") { files.append("\(path)/\(f)") }
+ for case let fileURL as URL in enumerator {
+ let normalized = fileURL.standardizedFileURL
+ let values = try? normalized.resourceValues(forKeys: [.isDirectoryKey])
+ if values?.isDirectory == true {
+ if normalized.lastPathComponent == "DebugBridgeGenerated"
+ || (shouldExcludeOutputSubtree && normalized.path == outputURL.path) {
+ enumerator.skipDescendants()
+ }
+ continue
+ }
+ guard normalized.pathExtension == "swift",
+ normalized.lastPathComponent != "StateAccessor.swift" else { continue }
+ files.append(normalized.path)
}
return files.sorted()
}
- static func computeCacheKey(swiftFiles: [String]) -> String {
+ static func computeCacheKey(swiftFiles: [String], buildId: String) -> String {
// Codex-flagged: hash must include Swift version, tool git rev, platform.
let swiftVer = getEnv("SWIFT_VERSION") ?? "unknown"
- let toolRev = getEnv("GEN_ACCESSORS_REV") ?? "dev"
- let platform = "darwin-arm64" // simplified for the test harness
- var combined = "swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|"
+ let toolRev = getEnv("GEN_ACCESSORS_REV") ?? generatorFormatVersion
+ #if arch(arm64)
+ let platform = "darwin-arm64"
+ #elseif arch(x86_64)
+ let platform = "darwin-x86_64"
+ #else
+ let platform = "darwin-unknown"
+ #endif
+ var combined = Data("\(generatorFormatVersion)|swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|build=\(buildId)|".utf8)
for path in swiftFiles {
if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) {
- combined += "\(path):\(data.count):\(data.sha256())|"
+ // Do not include absolute checkout paths in cache identity.
+ combined.append(Data("\(data.count):".utf8))
+ combined.append(data)
+ combined.append(Data("|".utf8))
}
}
- return combined.data(using: .utf8)!.sha256()
+ return combined.sha256()
+ }
+
+ static func computeAccessorHash(specs: [AccessorSpec]) -> String {
+ var signature = "snapshot-schema-v1\n"
+ for spec in specs {
+ signature += "C\(spec.className.utf8.count):\(spec.className)\n"
+ for field in spec.fields {
+ signature += "F\(field.name.utf8.count):\(field.name)"
+ signature += "T\(field.typeText.utf8.count):\(field.typeText)\n"
+ }
+ signature += "E\n"
+ }
+ return signature.sha256()
}
static func render(specs: [AccessorSpec], buildId: String, accessorHash: String) -> String {
var out = "// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n"
- out += "#if DEBUG\nimport Foundation\nimport DebugBridge\n\n"
+ out += "#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n"
+ if !specs.isEmpty {
+ // JSONSerialization produces Foundation bridge objects. Direct
+ // `as?` casts let NSNumber cross-cast between Bool and numeric
+ // Swift types. Round-tripping through JSONDecoder enforces the
+ // declared Codable shape (including every collection element)
+ // and preserves a successful Optional nil as a double Optional.
+ out += "private enum _GStackDebugBridgeSnapshotJSON {\n"
+ out += " private struct Box: Decodable {\n"
+ out += " let value: Value\n"
+ out += " }\n\n"
+ out += " static func decode(_ value: Any, as _: Value.Type) -> Value? {\n"
+ out += " let object: [String: Any] = [\"value\": value]\n"
+ out += " guard JSONSerialization.isValidJSONObject(object),\n"
+ out += " let data = try? JSONSerialization.data(withJSONObject: object) else {\n"
+ out += " return nil\n"
+ out += " }\n"
+ out += " do {\n"
+ out += " return try JSONDecoder().decode(Box.self, from: data).value\n"
+ out += " } catch {\n"
+ out += " return nil\n"
+ out += " }\n"
+ out += " }\n"
+ out += "}\n\n"
+ }
for spec in specs {
- out += "@MainActor\npublic enum \(spec.className)Accessor {\n"
- out += " public static func register(_ state: \(spec.className)) {\n"
+ // Accessors live in the app target beside its usually-internal
+ // state types. A public signature cannot expose an internal type.
+ out += "@MainActor\nenum \(spec.className)Accessor {\n"
+ out += " static func register(_ state: \(spec.className)) {\n"
out += " StateServer.shared.register(\n"
- out += " buildId: \"\(buildId)\",\n"
- out += " accessorHash: \"\(accessorHash)\",\n"
- out += " atomicRestore: { _ in .ok }\n"
+ out += " buildId: {\n"
+ out += " let shortVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleShortVersionString\") as? String\n"
+ out += " let bundleVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleVersion\") as? String\n"
+ out += " if let shortVersion, let bundleVersion { return \"\\(shortVersion) (\\(bundleVersion))\" }\n"
+ out += " return shortVersion ?? bundleVersion ?? \(String(reflecting: buildId))\n"
+ out += " }(),\n"
+ out += " accessorHash: \(String(reflecting: accessorHash)),\n"
+ out += " atomicRestore: { keys, apply in\n"
+ out += " // Validate every key and value before assignment.\n"
+ out += " // StateServer invokes every model once with apply=false,\n"
+ out += " // then applies every validated model with apply=true.\n"
+ for (index, field) in spec.fields.enumerated() {
+ let (name, typeText) = field
+ out += " guard let raw\(index) = keys[\"\(name)\"] else {\n"
+ out += " return .missingKey(\"\(name)\")\n"
+ out += " }\n"
+ out += " guard let restored\(index): \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(raw\(index), as: \(typeText).self) else {\n"
+ out += " return .typeMismatch(\"\(name)\")\n"
+ out += " }\n"
+ }
+ out += " if apply {\n"
+ for (index, field) in spec.fields.enumerated() {
+ out += " state.\(field.name) = restored\(index)\n"
+ }
+ out += " }\n"
+ out += " return .ok\n"
+ out += " }\n"
out += " )\n"
- for (name, _) in spec.fields {
+ for (name, typeText) in spec.fields {
+ let wrapped = optionalWrappedType(typeText)
out += " StateServer.shared.registerAccessor(\n"
out += " key: \"\(name)\",\n"
- out += " type: \"Any\",\n"
- out += " read: { state.\(name) as Any? },\n"
- out += " write: { _ in false }\n"
+ out += " type: \"\(typeText)\",\n"
+ if wrapped != nil {
+ out += " read: {\n"
+ out += " guard let value = state.\(name) else { return NSNull() }\n"
+ out += " return value as Any\n"
+ out += " },\n"
+ } else {
+ out += " read: { state.\(name) as Any? },\n"
+ }
+ out += " write: { value in\n"
+ out += " guard let typed: \(typeText) = _GStackDebugBridgeSnapshotJSON.decode(value, as: \(typeText).self) else { return false }\n"
+ out += " state.\(name) = typed\n"
+ out += " return true\n"
+ out += " }\n"
out += " )\n"
}
out += " }\n}\n\n"
@@ -123,6 +279,59 @@ struct GenAccessors {
final class ObservableClassVisitor: SyntaxVisitor {
var specs: [AccessorSpec] = []
+ var diagnostics: [String] = []
+ private let sourcePath: String
+
+ init(sourcePath: String, viewMode: SyntaxTreeViewMode) {
+ self.sourcePath = sourcePath
+ super.init(viewMode: viewMode)
+ }
+
+ private func hasSnapshotableMarker(_ declaration: VariableDeclSyntax) -> Bool {
+ // Preserve compatibility with source that defines a custom attribute
+ // or wrapper, even though that form conflicts with @Observable in
+ // ordinary app models.
+ if declaration.attributes.contains(where: { attribute in
+ guard let attribute = attribute.as(AttributeSyntax.self) else { return false }
+ return attribute.attributeName.trimmedDescription == "Snapshotable"
+ }) {
+ return true
+ }
+
+ // Match a standalone ordinary line-comment trivia piece exactly.
+ // Avoid declaration.description/contains: both would accept prose
+ // such as `// do not expose through @Snapshotable`.
+ return declaration.leadingTrivia.contains { piece in
+ guard case .lineComment(let text) = piece else { return false }
+ return text.dropFirst(2).trimmingCharacters(in: .whitespaces) == "@Snapshotable"
+ }
+ }
+
+ private func enclosingScopeDescription(for node: ClassDeclSyntax) -> String? {
+ var ancestor = Syntax(node).parent
+ while let current = ancestor {
+ if let declaration = current.as(ClassDeclSyntax.self) {
+ return "class \(declaration.name.text)"
+ }
+ if let declaration = current.as(StructDeclSyntax.self) {
+ return "struct \(declaration.name.text)"
+ }
+ if let declaration = current.as(EnumDeclSyntax.self) {
+ return "enum \(declaration.name.text)"
+ }
+ if let declaration = current.as(ActorDeclSyntax.self) {
+ return "actor \(declaration.name.text)"
+ }
+ if let declaration = current.as(ExtensionDeclSyntax.self) {
+ return "extension \(declaration.extendedType.trimmedDescription)"
+ }
+ if current.is(CodeBlockSyntax.self) {
+ return "a local scope"
+ }
+ ancestor = current.parent
+ }
+ return nil
+ }
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
// Look for @Observable attribute
@@ -133,23 +342,88 @@ final class ObservableClassVisitor: SyntaxVisitor {
guard isObservable else { return .visitChildren }
let className = node.name.text
+ let markedMembers = node.memberBlock.members.compactMap {
+ $0.decl.as(VariableDeclSyntax.self)
+ }.filter(hasSnapshotableMarker)
+ if !markedMembers.isEmpty, let enclosingScope = enclosingScopeDescription(for: node) {
+ diagnostics.append(
+ "\((sourcePath as NSString).lastPathComponent): nested @Observable class "
+ + "\(className) inside \(enclosingScope) is unsupported; "
+ + "move snapshot-enabled models to file scope"
+ )
+ // Continue walking so a more deeply nested marked model is also
+ // diagnosed rather than silently omitted.
+ return .visitChildren
+ }
var fields: [(String, String)] = []
for member in node.memberBlock.members {
guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue }
- // Field must be marked @Snapshotable to be included
- let isSnapshotable = varDecl.attributes.contains(where: { attr in
- guard let attr = attr.as(AttributeSyntax.self) else { return false }
- return attr.attributeName.trimmedDescription == "Snapshotable"
- })
- guard isSnapshotable else { continue }
+ // Field must opt in with the source marker (or legacy attribute).
+ guard hasSnapshotableMarker(varDecl) else { continue }
+
+ let bindingName = varDecl.bindings.first?
+ .pattern.as(IdentifierPatternSyntax.self)?.identifier.text ?? ""
+ let context = "\((sourcePath as NSString).lastPathComponent): \(className).\(bindingName)"
+ if varDecl.bindingSpecifier.text == "let" {
+ diagnostics.append("\(context) must be declared var, not let")
+ continue
+ }
+ let modifierNames = varDecl.modifiers.map { modifier -> String in
+ let detail = modifier.detail?.trimmedDescription ?? ""
+ return modifier.name.text + detail
+ }
+ if modifierNames.contains(where: {
+ $0 == "private" || $0 == "fileprivate"
+ || $0 == "private(set)" || $0 == "fileprivate(set)"
+ }) {
+ diagnostics.append("\(context) cannot be private, fileprivate, private(set), or fileprivate(set)")
+ continue
+ }
+ if modifierNames.contains("static") || modifierNames.contains("class") {
+ diagnostics.append("\(context) must be an instance property")
+ continue
+ }
+ if varDecl.bindings.count != 1 {
+ diagnostics.append("\(context) declaration must contain exactly one binding")
+ continue
+ }
for binding in varDecl.bindings {
- if let pattern = binding.pattern.as(IdentifierPatternSyntax.self) {
- let name = pattern.identifier.text
- let typeText = binding.typeAnnotation?.type.trimmedDescription ?? "Any"
- fields.append((name, typeText))
+ guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else {
+ diagnostics.append("\(context) only supports an identifier binding")
+ continue
}
+ // An initializer plus an accessor block is a stored property
+ // with observers. Without an initializer, conservatively
+ // reject the block rather than emitting a setter for a
+ // computed/read-only declaration.
+ if binding.accessorBlock != nil && binding.initializer == nil {
+ diagnostics.append("\(context) must be stored and writable")
+ continue
+ }
+ guard let annotation = binding.typeAnnotation else {
+ diagnostics.append("\(context) requires an explicit type annotation")
+ continue
+ }
+ let name = pattern.identifier.text
+ let typeText = annotation.type.trimmedDescription
+ .split(whereSeparator: { $0.isWhitespace })
+ .joined(separator: " ")
+ switch parseJSONSnapshotType(typeText) {
+ case .valid:
+ break
+ case .implicitlyUnwrappedOptional:
+ diagnostics.append("\(context) cannot use an implicitly unwrapped Optional type")
+ continue
+ case .nestedOptional:
+ diagnostics.append("\(context) cannot use a nested Optional type")
+ continue
+ case .unsupported:
+ diagnostics.append("\(context) uses unsupported non-JSON snapshot type '\(typeText)'")
+ continue
+ }
+ fields.append((name, typeText))
}
}
@@ -160,10 +434,233 @@ final class ObservableClassVisitor: SyntaxVisitor {
}
}
+private indirect enum JSONSnapshotType {
+ case scalar
+ case optional(JSONSnapshotType)
+ case array(JSONSnapshotType)
+ case dictionary(JSONSnapshotType)
+
+ var isOptional: Bool {
+ if case .optional = self { return true }
+ return false
+ }
+}
+
+private enum JSONSnapshotTypeParseResult {
+ case valid(JSONSnapshotType)
+ case implicitlyUnwrappedOptional
+ case nestedOptional
+ case unsupported
+}
+
+/// Parse the deliberately small set of Swift types that can make a lossless
+/// trip through JSONSerialization and JSONDecoder without application-defined
+/// encoding behavior. Type aliases and arbitrary Codable models are rejected:
+/// the bridge has no compiler/type-checker context in which to prove that they
+/// are JSON-native.
+private func parseJSONSnapshotType(_ source: String) -> JSONSnapshotTypeParseResult {
+ let type = source.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !type.isEmpty else { return .unsupported }
+
+ if type.hasSuffix("!") {
+ return .implicitlyUnwrappedOptional
+ }
+
+ if type.hasSuffix("?") {
+ let wrappedText = String(type.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
+ switch parseJSONSnapshotType(wrappedText) {
+ case .valid(let wrapped):
+ return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
+ case let issue:
+ return issue
+ }
+ }
+
+ if let bracketContents = outerDelimitedContents(type, open: "[", close: "]") {
+ let dictionaryParts = splitTopLevel(bracketContents, separator: ":")
+ if dictionaryParts.count == 1 {
+ switch parseJSONSnapshotType(dictionaryParts[0]) {
+ case .valid(let element): return .valid(.array(element))
+ case let issue: return issue
+ }
+ }
+ guard dictionaryParts.count == 2, isStringType(dictionaryParts[0]) else {
+ return .unsupported
+ }
+ switch parseJSONSnapshotType(dictionaryParts[1]) {
+ case .valid(let value): return .valid(.dictionary(value))
+ case let issue: return issue
+ }
+ }
+
+ if let generic = genericTypeParts(type) {
+ let base = compactTypeName(generic.base)
+ if base == "Optional" || base == "Swift.Optional" {
+ guard generic.arguments.count == 1 else { return .unsupported }
+ switch parseJSONSnapshotType(generic.arguments[0]) {
+ case .valid(let wrapped):
+ return wrapped.isOptional ? .nestedOptional : .valid(.optional(wrapped))
+ case let issue:
+ return issue
+ }
+ }
+ if base == "Array" || base == "Swift.Array" {
+ guard generic.arguments.count == 1 else { return .unsupported }
+ switch parseJSONSnapshotType(generic.arguments[0]) {
+ case .valid(let element): return .valid(.array(element))
+ case let issue: return issue
+ }
+ }
+ if base == "Dictionary" || base == "Swift.Dictionary" {
+ guard generic.arguments.count == 2, isStringType(generic.arguments[0]) else {
+ return .unsupported
+ }
+ switch parseJSONSnapshotType(generic.arguments[1]) {
+ case .valid(let value): return .valid(.dictionary(value))
+ case let issue: return issue
+ }
+ }
+ return .unsupported
+ }
+
+ let scalar = compactTypeName(type)
+ let supportedScalars: Set = [
+ "String", "Swift.String",
+ "Bool", "Swift.Bool",
+ "Int", "Swift.Int", "Int8", "Swift.Int8", "Int16", "Swift.Int16",
+ "Int32", "Swift.Int32", "Int64", "Swift.Int64",
+ "UInt", "Swift.UInt", "UInt8", "Swift.UInt8", "UInt16", "Swift.UInt16",
+ "UInt32", "Swift.UInt32", "UInt64", "Swift.UInt64",
+ "Float", "Swift.Float", "Double", "Swift.Double",
+ "CGFloat", "CoreGraphics.CGFloat",
+ ]
+ return supportedScalars.contains(scalar) ? .valid(.scalar) : .unsupported
+}
+
+private func isStringType(_ source: String) -> Bool {
+ let type = compactTypeName(source)
+ return type == "String" || type == "Swift.String"
+}
+
+private func compactTypeName(_ source: String) -> String {
+ source.filter { !$0.isWhitespace }
+}
+
+private func outerDelimitedContents(_ source: String, open: Character, close: Character) -> String? {
+ let characters = Array(source)
+ guard characters.first == open, characters.last == close else { return nil }
+ var depth = 0
+ for (index, character) in characters.enumerated() {
+ if character == open {
+ depth += 1
+ } else if character == close {
+ depth -= 1
+ guard depth >= 0 else { return nil }
+ if depth == 0, index != characters.count - 1 { return nil }
+ }
+ }
+ guard depth == 0 else { return nil }
+ return String(characters.dropFirst().dropLast())
+}
+
+private func genericTypeParts(_ source: String) -> (base: String, arguments: [String])? {
+ let characters = Array(source)
+ guard let openIndex = characters.firstIndex(of: "<") else { return nil }
+ var depth = 0
+ var closeIndex: Int?
+ for index in openIndex.." {
+ depth -= 1
+ guard depth >= 0 else { return nil }
+ if depth == 0 {
+ closeIndex = index
+ break
+ }
+ }
+ }
+ guard let closeIndex, closeIndex == characters.count - 1 else { return nil }
+ let base = String(characters[.. [String] {
+ let characters = Array(source)
+ var angleDepth = 0
+ var squareDepth = 0
+ var parenDepth = 0
+ var start = 0
+ var parts: [String] = []
+
+ for (index, character) in characters.enumerated() {
+ switch character {
+ case "<": angleDepth += 1
+ case ">": angleDepth -= 1
+ case "[": squareDepth += 1
+ case "]": squareDepth -= 1
+ case "(": parenDepth += 1
+ case ")": parenDepth -= 1
+ default: break
+ }
+ if character == separator, angleDepth == 0, squareDepth == 0, parenDepth == 0 {
+ parts.append(
+ String(characters[start.. String? {
ProcessInfo.processInfo.environment[key]
}
+func detectedBuildId() -> String {
+ if let explicit = getEnv("APP_BUILD_ID"), !explicit.isEmpty { return explicit }
+ let marketing = getEnv("MARKETING_VERSION")
+ let build = getEnv("CURRENT_PROJECT_VERSION")
+ if let marketing, let build, !marketing.isEmpty, !build.isEmpty {
+ return "\(marketing) (\(build))"
+ }
+ if let build, !build.isEmpty { return build }
+ return "unknown"
+}
+
+func optionalWrappedType(_ typeText: String) -> String? {
+ let type = typeText.trimmingCharacters(in: .whitespacesAndNewlines)
+ if type.hasSuffix("?") {
+ let wrapped = String(type.dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
+ return wrapped.isEmpty ? nil : wrapped
+ }
+
+ guard let optionalRange = type.range(of: #"^Optional\s*<"#, options: .regularExpression),
+ let open = type[optionalRange].lastIndex(of: "<") else { return nil }
+ let openIndex = type.distance(from: type.startIndex, to: open)
+ var depth = 0
+ for (offset, char) in type.enumerated() where offset >= openIndex {
+ if char == "<" { depth += 1 }
+ else if char == ">" {
+ depth -= 1
+ if depth == 0 {
+ let close = type.index(type.startIndex, offsetBy: offset)
+ guard type[type.index(after: close)...].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ return nil
+ }
+ let wrappedStart = type.index(after: open)
+ let wrapped = String(type[wrappedStart..30d entries removed, recent kept
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
+import { spawnSync } from 'child_process';
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync, utimesSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
@@ -13,9 +16,11 @@ import {
collectSwiftFiles,
parseSwift,
computeCacheKey,
+ computeAccessorHash,
generate,
pruneCache,
render,
+ AccessorGenerationError,
type AccessorSpec,
} from './gen-accessors';
@@ -30,12 +35,14 @@ afterEach(() => {
});
describe('parseSwift — fork regex-failure-mode fixtures', () => {
- test('parses @Observable class with simple @Snapshotable fields', () => {
+ test('parses @Observable class with source-marker comments', () => {
const src = `
@Observable
final class AppState {
- @Snapshotable var isLoggedIn: Bool = false
- @Snapshotable var username: String = ""
+ // @Snapshotable
+ var isLoggedIn: Bool = false
+ // @Snapshotable
+ var username: String = ""
var notSnapshotable: Int = 0
}
`;
@@ -46,12 +53,100 @@ final class AppState {
expect(specs[0]!.fields.find(f => f.name === 'isLoggedIn')!.typeText).toBe('Bool');
});
+ test('retains legacy @Snapshotable attribute parsing', () => {
+ const specs = parseSwift(`
+@Observable
+final class LegacyState {
+ @Snapshotable var counter: Int = 0
+}
+`);
+ expect(specs).toEqual([{
+ className: 'LegacyState',
+ fields: [{ name: 'counter', typeText: 'Int' }],
+ }]);
+ });
+
+ test('does not treat documentation prose as a source marker', () => {
+ const specs = parseSwift(`
+@Observable
+final class PrivateState {
+ /// Do not expose this field through @Snapshotable.
+ var token: String = "secret"
+}
+`);
+ expect(specs).toHaveLength(0);
+ });
+
+ test('does not let a trailing marker comment bleed into the next field', () => {
+ const specs = parseSwift(`
+@Observable
+final class PrivateState {
+ var oldValue: Int = 0 // @Snapshotable
+ var nextValue: Int = 1
+}
+`);
+ expect(specs).toHaveLength(0);
+ });
+
+ test('ignores exact-looking markers inside nested block comments', () => {
+ const specs = parseSwift(`
+@Observable
+final class PrivateState {
+ /*
+ /* // @Snapshotable */
+ // @Snapshotable
+ var leaked: String = "secret"
+ */
+ // @Snapshotable
+ var visible: Int = 1
+}
+`);
+ expect(specs).toEqual([{
+ className: 'PrivateState',
+ fields: [{ name: 'visible', typeText: 'Int' }],
+ }]);
+ });
+
+ test('ignores declarations and markers inside multiline and raw strings', () => {
+ const specs = parseSwift(`
+let ordinary = """
+@Observable class FakeA {
+ // @Snapshotable
+ var leaked: Int = 0
+}
+"""
+let raw = #"""
+@Observable class FakeB { @Snapshotable var leaked: String = "" }
+"""#
+@Observable
+final class RealState {
+ // @Snapshotable
+ var safe: Bool = true
+}
+`);
+ expect(specs).toEqual([{
+ className: 'RealState',
+ fields: [{ name: 'safe', typeText: 'Bool' }],
+ }]);
+ });
+
+ test('ignores marker words in standalone trailing prose', () => {
+ const specs = parseSwift(`
+@Observable
+final class PrivateState {
+ // @Snapshotable fields are intentionally disabled here.
+ var token: String = "secret"
+}
+`);
+ expect(specs).toHaveLength(0);
+ });
+
test('handles @Snapshotable on multi-line type signatures', () => {
const src = `
@Observable
class Cart {
@Snapshotable var items:
- [CartItem]
+ [Dictionary]
= []
var unrelated: Int = 0
}
@@ -60,20 +155,67 @@ class Cart {
expect(specs).toHaveLength(1);
expect(specs[0]!.fields).toHaveLength(1);
expect(specs[0]!.fields[0]!.name).toBe('items');
- expect(specs[0]!.fields[0]!.typeText).toContain('CartItem');
+ expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
});
- test('handles generic types in property signatures', () => {
+ test('handles JSON-compatible generic types in property signatures', () => {
const src = `
@Observable
class Repo {
- @Snapshotable var pages: Dictionary]> = [:]
+ @Snapshotable var pages: Dictionary]> = [:]
}
`;
const specs = parseSwift(src);
expect(specs).toHaveLength(1);
expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
- expect(specs[0]!.fields[0]!.typeText).toContain('Result');
+ expect(specs[0]!.fields[0]!.typeText).toContain('Optional');
+ });
+
+ test('accepts qualified JSON-native scalar and collection spellings', () => {
+ const specs = parseSwift(`
+@Observable
+class Metrics {
+ // @Snapshotable
+ var ratio: CoreGraphics.CGFloat = 0
+ // @Snapshotable
+ var counts: Swift.Array = []
+}
+`);
+ expect(specs[0]!.fields.map((field) => field.typeText)).toEqual([
+ 'CoreGraphics.CGFloat',
+ 'Swift.Array',
+ ]);
+ });
+
+ test('rejects custom values that Foundation JSON cannot serialize', () => {
+ expect(() => parseSwift(`
+@Observable
+class Repo {
+ // @Snapshotable
+ var pages: Dictionary]> = [:]
+}
+`)).toThrow("unsupported snapshot type 'Result- '");
+ });
+
+ test('rejects nested observable types instead of emitting an unqualified reference', () => {
+ expect(() => parseSwift(`
+enum Namespace {
+ @Observable
+ class State {
+ // @Snapshotable
+ var count: Int = 0
+ }
+}
+`)).toThrow('nested @Observable types are not supported');
+ });
+
+ test('ignores nested observable types that expose no snapshot fields', () => {
+ expect(parseSwift(`
+enum Namespace {
+ @Observable
+ class State { var transient: Int = 0 }
+}
+`)).toEqual([]);
});
test('ignores fields without @Snapshotable marker', () => {
@@ -114,10 +256,7 @@ class B {
expect(specs.map(s => s.className).sort()).toEqual(['A', 'B']);
});
- test('skips fields with computed body braces', () => {
- // Codex flagged "Properties with computed getters / didSet blocks" as a
- // failure mode of the fork's regex. We deliberately exclude them here —
- // computed properties are not snapshot-eligible.
+ test('diagnoses fields with computed body braces', () => {
const src = `
@Observable
class M {
@@ -127,9 +266,50 @@ class M {
}
}
`;
- const specs = parseSwift(src);
- expect(specs).toHaveLength(1);
- expect(specs[0]!.fields.map(f => f.name)).toEqual(['snapshotted']);
+ expect(() => parseSwift(src)).toThrow("field 'computed' must be stored and writable");
+ });
+
+ test.each([
+ ['let', '// @Snapshotable\n let immutable: Int = 1', 'must be declared var, not let'],
+ ['private', '// @Snapshotable\n private var secret: String = ""', 'cannot be private'],
+ ['fileprivate', '// @Snapshotable\n fileprivate var secret: String = ""', 'cannot be private'],
+ ['private(set)', '// @Snapshotable\n public private(set) var count: Int = 0', 'cannot be private'],
+ ['fileprivate(set)', '// @Snapshotable\n fileprivate(set) var count: Int = 0', 'cannot be private'],
+ ['inferred type', '// @Snapshotable\n var inferred = 42', 'requires an explicit type annotation'],
+ ['multiple bindings', '// @Snapshotable\n var a: Int = 1, b: Int = 2', 'exactly one binding'],
+ ['static', '// @Snapshotable\n static var shared: Int = 0', 'must be an instance property'],
+ ['nested Optional', '// @Snapshotable\n var nested: String?? = nil', 'cannot use a nested Optional'],
+ ['nested Optional in collection', '// @Snapshotable\n var nestedItems: [String??] = []', 'cannot use a nested Optional'],
+ ['implicitly unwrapped Optional', '// @Snapshotable\n var legacy: String! = nil', 'cannot use an implicitly unwrapped Optional'],
+ ['custom type', '// @Snapshotable\n var custom: CustomValue = .init()', 'uses unsupported snapshot type'],
+ ])('diagnoses invalid %s declarations', (_label, declaration, expected) => {
+ expect(() => parseSwift(`
+@Observable
+final class InvalidState {
+ ${declaration}
+}
+`)).toThrow(expected);
+ });
+
+ test('aggregates invalid declaration diagnostics with class and line context', () => {
+ try {
+ parseSwift(`
+@Observable
+final class InvalidState {
+ // @Snapshotable
+ let first: Int = 1
+ // @Snapshotable
+ private var second: String = ""
+}
+`);
+ throw new Error('expected parseSwift to fail');
+ } catch (error) {
+ expect(error).toBeInstanceOf(AccessorGenerationError);
+ const generationError = error as AccessorGenerationError;
+ expect(generationError.diagnostics).toHaveLength(2);
+ expect(generationError.message).toContain('InvalidState (line 4)');
+ expect(generationError.message).toContain('InvalidState (line 6)');
+ }
});
});
@@ -244,6 +424,68 @@ describe('computeCacheKey', () => {
});
expect(k1).not.toBe(k2);
});
+
+ test('app build provenance invalidates the cache key', () => {
+ const f = join(workDir, 'a.swift');
+ writeFileSync(f, '@Observable class A {}');
+ const shared = {
+ swiftFiles: [f],
+ swiftVersion: '6.0.0',
+ toolGitRev: 'abc',
+ platformTriple: 'darwin-arm64',
+ };
+ expect(computeCacheKey({ ...shared, buildId: '100' })).not.toBe(
+ computeCacheKey({ ...shared, buildId: '101' }),
+ );
+ });
+
+ test('equivalent source content does not depend on its absolute checkout path', () => {
+ const firstDir = join(workDir, 'checkout-a');
+ const secondDir = join(workDir, 'checkout-b');
+ mkdirSync(firstDir);
+ mkdirSync(secondDir);
+ const first = join(firstDir, 'State.swift');
+ const second = join(secondDir, 'State.swift');
+ writeFileSync(first, '@Observable class A {}');
+ writeFileSync(second, '@Observable class A {}');
+ const versioning = { swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
+ expect(computeCacheKey({ swiftFiles: [first], ...versioning })).toBe(
+ computeCacheKey({ swiftFiles: [second], ...versioning }),
+ );
+ });
+});
+
+describe('computeAccessorHash', () => {
+ const base: AccessorSpec[] = [{
+ className: 'AppState',
+ fields: [
+ { name: 'count', typeText: 'Int' },
+ { name: 'nickname', typeText: 'String?' },
+ ],
+ }];
+
+ test('is deterministic for the same ordered accessor signatures', () => {
+ expect(computeAccessorHash(base)).toBe(computeAccessorHash(structuredClone(base)));
+ });
+
+ test('changes when field order, name, or type changes', () => {
+ const reordered: AccessorSpec[] = [{
+ className: 'AppState',
+ fields: [...base[0]!.fields].reverse(),
+ }];
+ const renamed: AccessorSpec[] = [{
+ className: 'AppState',
+ fields: [{ name: 'total', typeText: 'Int' }, base[0]!.fields[1]!],
+ }];
+ const retyped: AccessorSpec[] = [{
+ className: 'AppState',
+ fields: [{ name: 'count', typeText: 'Int64' }, base[0]!.fields[1]!],
+ }];
+ const hash = computeAccessorHash(base);
+ expect(computeAccessorHash(reordered)).not.toBe(hash);
+ expect(computeAccessorHash(renamed)).not.toBe(hash);
+ expect(computeAccessorHash(retyped)).not.toBe(hash);
+ });
});
describe('generate', () => {
@@ -283,6 +525,35 @@ class AppState {
expect(r1.cacheKey).toBe(r2.cacheKey);
});
+ test('custom nested output subtree cannot poison the second-run cache key', () => {
+ const inputDir = join(workDir, 'src');
+ const outputDir = join(inputDir, 'generated', 'accessors');
+ mkdirSync(inputDir);
+ writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
+ const cacheRoot = join(workDir, 'cache');
+
+ const r1 = generate({
+ inputDir,
+ outputDir,
+ cacheRoot,
+ swiftVersion: '6',
+ toolGitRev: 't',
+ platformTriple: 'p',
+ });
+ writeFileSync(join(outputDir, 'Ignored.swift'), '@Observable class Poison { @Snapshotable var bad: Int = 1 }');
+ const r2 = generate({
+ inputDir,
+ outputDir,
+ cacheRoot,
+ swiftVersion: '6',
+ toolGitRev: 't',
+ platformTriple: 'p',
+ });
+
+ expect(r2.cacheHit).toBe(true);
+ expect(r2.cacheKey).toBe(r1.cacheKey);
+ });
+
test('modifying source invalidates the cache', () => {
const inputDir = join(workDir, 'src');
mkdirSync(inputDir);
@@ -295,6 +566,62 @@ class AppState {
expect(r1.cacheKey).not.toBe(r2.cacheKey);
expect(r2.cacheHit).toBe(false);
});
+
+ test('unmarked source churn changes cache identity but not schema identity', () => {
+ const inputDir = join(workDir, 'src');
+ mkdirSync(inputDir);
+ const state = join(inputDir, 'state.swift');
+ const unrelated = join(inputDir, 'unrelated.swift');
+ writeFileSync(state, '@Observable class A { // @Snapshotable\n var x: Int = 0\n }');
+ writeFileSync(unrelated, 'struct Unrelated { let a = 1 }');
+ const cacheRoot = join(workDir, 'cache');
+ const options = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
+ const first = generate(options);
+ writeFileSync(unrelated, 'struct Unrelated { let a = 2 }');
+ const second = generate(options);
+ expect(second.cacheKey).not.toBe(first.cacheKey);
+ expect(second.accessorHash).toBe(first.accessorHash);
+ });
+
+ test('buildId changes cannot reuse generated fallback provenance', () => {
+ const inputDir = join(workDir, 'src');
+ mkdirSync(inputDir);
+ writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
+ const cacheRoot = join(workDir, 'cache');
+ const shared = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
+ const first = generate({ ...shared, buildId: 'build-100' });
+ const second = generate({ ...shared, buildId: 'build-101' });
+ expect(second.cacheKey).not.toBe(first.cacheKey);
+ expect(second.cacheHit).toBe(false);
+ expect(readFileSync(second.outputPath, 'utf8')).toContain('?? "build-101"');
+ });
+
+ test('CLI exits 4 with actionable diagnostics and no generated output', () => {
+ const inputDir = join(workDir, 'src');
+ const outputDir = join(workDir, 'generated');
+ mkdirSync(inputDir);
+ writeFileSync(join(inputDir, 'state.swift'), `
+@Observable final class InvalidState {
+ // @Snapshotable
+ private let secret = "nope"
+}
+`);
+ const result = spawnSync('bun', [
+ join(import.meta.dir, 'gen-accessors.ts'),
+ '--input', inputDir,
+ '--output', outputDir,
+ ], {
+ encoding: 'utf8',
+ env: { ...process.env, GSTACK_IOS_CACHE_ROOT: join(workDir, 'cache') },
+ });
+ expect(result.status).toBe(4);
+ // `let` is diagnosed first; either way the class/property is named and
+ // generation never emits an accessor that will fail in xcodebuild.
+ expect(result.stderr).toContain('InvalidState');
+ expect(result.stderr).toContain("field 'secret' must be declared var, not let");
+ expect(result.stderr).not.toContain('AccessorGenerationError:');
+ expect(existsSync(join(outputDir, 'StateAccessor.swift'))).toBe(false);
+ });
});
describe('pruneCache', () => {
@@ -332,14 +659,395 @@ describe('render', () => {
fields: [{ name: 'a', typeText: 'Int' }, { name: 'b', typeText: 'String' }],
}];
const out = render(specs, 'build-1.2.3', 'hash-abc');
- expect(out).toContain('public enum AppStateAccessor');
+ expect(out).toContain('enum AppStateAccessor');
+ expect(out).not.toContain('public enum AppStateAccessor');
+ expect(out).toContain('static func register(_ state: AppState)');
+ expect(out).not.toContain('public static func register(_ state: AppState)');
expect(out).toContain('key: "a"');
expect(out).toContain('key: "b"');
- expect(out).toContain('buildId: "build-1.2.3"');
+ expect(out).toContain('return .missingKey("a")');
+ expect(out).toContain('return .typeMismatch("b")');
+ expect(out).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)');
+ expect(out).toContain('guard let restored1 = Self.decodeSnapshotValue(raw1, as: String.self)');
+ expect(out).toContain('atomicRestore: { keys, apply in');
+ expect(out).toContain('if apply {');
+ expect(out).toContain('state.a = restored0');
+ expect(out).toContain('state.b = restored1');
+ expect(out.indexOf('state.a = restored0')).toBeGreaterThan(out.indexOf('guard let restored1'));
+ expect(out).toContain('guard let typed = Self.decodeSnapshotValue(value, as: Int.self) else { return false }');
+ expect(out).toContain('state.a = typed');
+ expect(out).toContain('return true');
+ expect(out).not.toContain('atomicRestore: { _ in .ok }');
+ expect(out).not.toContain('write: { _ in false }');
+ expect(out).toContain('CFBundleShortVersionString');
+ expect(out).toContain('CFBundleVersion');
+ expect(out).toContain('return shortVersion ?? bundleVersion ?? "build-1.2.3"');
expect(out).toContain('accessorHash: "hash-abc"');
+ expect(out).toContain('import DebugBridgeCore');
+ expect(out).not.toContain('import DebugBridge\n');
expect(out).toContain('#if DEBUG');
expect(out).toContain('#endif');
});
+
+ test('emits explicit NSNull round-trip handling for Optional fields', () => {
+ const out = render([{
+ className: 'AppState',
+ fields: [
+ { name: 'nickname', typeText: 'String?' },
+ { name: 'selection', typeText: 'Optional' },
+ ],
+ }], 'build', 'schema');
+
+ expect(out).toContain('let restored0: String?');
+ expect(out).toContain('if raw0 is NSNull');
+ expect(out).toContain('else if let typed = Self.decodeSnapshotValue(raw0, as: String.self)');
+ expect(out).toContain('let restored1: Optional');
+ expect(out).toContain('else if let typed = Self.decodeSnapshotValue(raw1, as: Int.self)');
+ expect(out).toContain('guard let value = state.nickname else { return NSNull() }');
+ expect(out).toContain('if value is NSNull');
+ expect(out).toContain('state.nickname = nil');
+ expect(out).not.toContain('value as? String?');
+ });
+
+ test('rejects duplicate snapshot keys across observable models', () => {
+ expect(() => render([
+ { className: 'FirstState', fields: [{ name: 'count', typeText: 'Int' }] },
+ { className: 'SecondState', fields: [{ name: 'count', typeText: 'Int' }] },
+ ], 'build', 'schema')).toThrow("snapshot key 'count' is declared by both FirstState and SecondState");
+ });
+
+ test('typechecks beside an internal @Observable app state using a comment marker', () => {
+ if (spawnSync('swiftc', ['--version'], { encoding: 'utf8' }).status !== 0) return;
+
+ const coreSource = join(workDir, 'DebugBridgeCore.swift');
+ const coreModule = join(workDir, 'DebugBridgeCore.swiftmodule');
+ writeFileSync(coreSource, `
+public typealias JSONDict = [String: Any]
+
+@MainActor
+public final class StateServer {
+ public static let shared = StateServer()
+ public enum RestoreResult {
+ case ok
+ case missingKey(String)
+ case typeMismatch(String)
+ }
+ private init() {}
+ public func register(
+ buildId: String,
+ accessorHash: String,
+ atomicRestore: @escaping (JSONDict, Bool) -> RestoreResult
+ ) {}
+ public func registerAccessor(
+ key: String,
+ type: String,
+ read: @escaping () -> Any?,
+ write: @escaping (Any) -> Bool
+ ) {}
+}
+`);
+ const emitModule = spawnSync('swiftc', [
+ '-emit-module',
+ '-parse-as-library',
+ '-module-name', 'DebugBridgeCore',
+ coreSource,
+ '-emit-module-path', coreModule,
+ ], { encoding: 'utf8' });
+ if (emitModule.status !== 0) {
+ throw new Error(`failed to build DebugBridgeCore test stub:\n${emitModule.stderr}`);
+ }
+
+ const appSource = join(workDir, 'AppState.swift');
+ writeFileSync(appSource, `import Observation
+
+@Observable
+final class AppState {
+ // @Snapshotable
+ var counter: Int = 0
+}
+
+${render([{
+ className: 'AppState',
+ fields: [{ name: 'counter', typeText: 'Int' }],
+ }], 'build-test', 'hash-test')}`);
+ const typecheck = spawnSync('swiftc', [
+ '-typecheck',
+ '-D', 'DEBUG',
+ '-I', workDir,
+ appSource,
+ ], { encoding: 'utf8' });
+ if (typecheck.status !== 0) {
+ throw new Error(`generated accessor failed Swift type checking:\n${typecheck.stderr}`);
+ }
+ });
+
+ test('strict JSON typing and cross-model validate-before-apply restore run correctly', () => {
+ if (process.platform !== 'darwin') return;
+ if (spawnSync('swiftc', ['--version'], { encoding: 'utf8' }).status !== 0) return;
+
+ const coreSource = join(workDir, 'DebugBridgeCore.swift');
+ const coreModule = join(workDir, 'DebugBridgeCore.swiftmodule');
+ const coreLibrary = join(workDir, 'libDebugBridgeCore.dylib');
+ writeFileSync(coreSource, `
+public typealias JSONDict = [String: Any]
+
+@MainActor
+public final class StateServer {
+ public typealias Restore = (JSONDict, Bool) -> RestoreResult
+ public enum RestoreResult { case ok, missingKey(String), typeMismatch(String) }
+ public static let shared = StateServer()
+ public var restores: [Restore] = []
+ public var reads: [String: () -> Any?] = [:]
+ public var writes: [String: (Any) -> Bool] = [:]
+ private init() {}
+ public func register(buildId: String, accessorHash: String, atomicRestore: @escaping Restore) {
+ restores.append(atomicRestore)
+ }
+ public func registerAccessor(
+ key: String,
+ type: String,
+ read: @escaping () -> Any?,
+ write: @escaping (Any) -> Bool
+ ) {
+ reads[key] = read
+ writes[key] = write
+ }
+ public func restoreAll(_ keys: JSONDict) -> RestoreResult {
+ for restore in restores {
+ let result = restore(keys, false)
+ guard case .ok = result else { return result }
+ }
+ for restore in restores {
+ let result = restore(keys, true)
+ guard case .ok = result else { return result }
+ }
+ return .ok
+ }
+}
+`);
+ const emitCore = spawnSync('swiftc', [
+ '-emit-library', '-emit-module', '-parse-as-library',
+ '-module-name', 'DebugBridgeCore', coreSource,
+ '-emit-module-path', coreModule,
+ '-o', coreLibrary,
+ ], { encoding: 'utf8' });
+ if (emitCore.status !== 0) throw new Error(`failed to build runtime stub:\n${emitCore.stderr}`);
+
+ const appSource = join(workDir, 'OptionalRoundTrip.swift');
+ writeFileSync(appSource, `
+import Foundation
+import Observation
+import DebugBridgeCore
+
+@Observable
+final class AppState {
+ // @Snapshotable
+ var nickname: String? = nil
+ // @Snapshotable
+ var count: Int = 1
+}
+
+@Observable
+final class FeatureState {
+ // @Snapshotable
+ var enabled: Bool = false
+}
+
+${render([
+ {
+ className: 'AppState',
+ fields: [
+ { name: 'nickname', typeText: 'String?' },
+ { name: 'count', typeText: 'Int' },
+ ],
+ },
+ {
+ className: 'FeatureState',
+ fields: [{ name: 'enabled', typeText: 'Bool' }],
+ },
+ ], 'fallback-build', 'schema-hash')}
+
+@main
+struct Runner {
+ @MainActor static func main() {
+ let state = AppState()
+ let feature = FeatureState()
+ AppStateAccessor.register(state)
+ FeatureStateAccessor.register(feature)
+ guard StateServer.shared.reads["nickname"]?() is NSNull else { fatalError("nil read") }
+ guard StateServer.shared.writes["nickname"]?("Ada") == true, state.nickname == "Ada" else {
+ fatalError("optional write")
+ }
+ guard StateServer.shared.writes["nickname"]?(NSNull()) == true, state.nickname == nil else {
+ fatalError("null write")
+ }
+ guard StateServer.shared.writes["count"]?(true) == false, state.count == 1 else {
+ fatalError("boolean must not coerce to integer")
+ }
+ guard StateServer.shared.writes["enabled"]?(1) == false, feature.enabled == false else {
+ fatalError("integer must not coerce to boolean")
+ }
+ let valid = try! JSONSerialization.jsonObject(
+ with: Data(#"{"nickname":"Grace","count":7,"enabled":true}"#.utf8)
+ ) as! JSONDict
+ switch StateServer.shared.restoreAll(valid) {
+ case .ok: break
+ default: fatalError("valid restore")
+ }
+ guard state.nickname == "Grace", state.count == 7, feature.enabled else { fatalError("restore values") }
+ switch StateServer.shared.restoreAll(["nickname": NSNull(), "count": 8, "enabled": false]) {
+ case .ok: break
+ default: fatalError("null restore")
+ }
+ guard state.nickname == nil, state.count == 8, !feature.enabled else { fatalError("null restore values") }
+ state.nickname = "unchanged"
+ state.count = 9
+ feature.enabled = false
+ switch StateServer.shared.restoreAll(["nickname": "would-partially-apply", "count": 10, "enabled": 1]) {
+ case .typeMismatch("enabled"): break
+ default: fatalError("expected mismatch")
+ }
+ guard state.nickname == "unchanged", state.count == 9, !feature.enabled else { fatalError("cross-model partial mutation") }
+ }
+}
+`);
+ const executable = join(workDir, 'optional-round-trip');
+ const compile = spawnSync('swiftc', [
+ '-D', 'DEBUG', '-I', workDir, '-L', workDir, '-lDebugBridgeCore',
+ '-parse-as-library', appSource, '-o', executable,
+ ], { encoding: 'utf8' });
+ if (compile.status !== 0) throw new Error(`generated Optional accessor failed compilation:\n${compile.stderr}`);
+ const run = spawnSync(executable, [], {
+ encoding: 'utf8',
+ env: { ...process.env, DYLD_LIBRARY_PATH: workDir },
+ });
+ if (run.status !== 0) throw new Error(`generated Optional accessor failed at runtime:\n${run.stderr}`);
+ });
+});
+
+describe('SwiftSyntax generator parity', () => {
+ test('isolates canonical markers and rejects inaccessible fields', () => {
+ if (process.platform !== 'darwin') return;
+ if (spawnSync('swift', ['--version'], { encoding: 'utf8' }).status !== 0) return;
+
+ const packageDir = join(import.meta.dir, 'gen-accessors-tool');
+ const inputDir = join(workDir, 'swift-syntax-input');
+ const outputDir = join(workDir, 'swift-syntax-output');
+ const cacheRoot = join(workDir, 'swift-syntax-cache');
+ mkdirSync(inputDir);
+ writeFileSync(join(inputDir, 'State.swift'), `
+import Observation
+@Observable
+final class ToolState {
+ let documentation = """
+ // @Snapshotable
+ var stringLeak: String = "no"
+ """
+ /* // @Snapshotable */
+ var blockLeak: Int = 1
+ var trailingLeak: Int = 2 // @Snapshotable
+ // prose about @Snapshotable must not opt in
+ var proseLeak: Int = 3
+ // @Snapshotable
+ var nickname: String? = nil
+ // @Snapshotable
+ var names:
+ [String]
+ = []
+ // @Snapshotable
+ var count: Int = 0
+ // @Snapshotable
+ var enabled: Bool = false
+}
+`);
+ const env = {
+ ...process.env,
+ GSTACK_IOS_CACHE_ROOT: cacheRoot,
+ APP_BUILD_ID: 'syntax-test-build',
+ GEN_ACCESSORS_REV: 'syntax-test-v5',
+ };
+ const run = spawnSync('swift', [
+ 'run', '--package-path', packageDir, 'gen-accessors',
+ '--input', inputDir, '--output', outputDir,
+ ], { encoding: 'utf8', env, timeout: 180_000 });
+ if (run.status !== 0) throw new Error(`SwiftSyntax generator failed:\n${run.stderr}`);
+ const output = readFileSync(join(outputDir, 'StateAccessor.swift'), 'utf8');
+ expect(output).toContain('key: "nickname"');
+ expect(output).toContain('key: "names"');
+ expect(output).toContain('key: "count"');
+ expect(output).toContain('key: "enabled"');
+ expect(output).toContain('_GStackDebugBridgeSnapshotJSON.decode(raw0');
+ expect(output).toContain('atomicRestore: { keys, apply in');
+ expect(output).toContain('if apply {');
+ expect(output).not.toMatch(/raw\d+ as\?/);
+ expect(output).toContain('CFBundleShortVersionString');
+ expect(output).toContain(`accessorHash: "${computeAccessorHash([{
+ className: 'ToolState',
+ fields: [
+ { name: 'nickname', typeText: 'String?' },
+ { name: 'names', typeText: '[String]' },
+ { name: 'count', typeText: 'Int' },
+ { name: 'enabled', typeText: 'Bool' },
+ ],
+ }])}"`);
+ expect(output).not.toContain('key: "stringLeak"');
+ expect(output).not.toContain('key: "blockLeak"');
+ expect(output).not.toContain('key: "trailingLeak"');
+ expect(output).not.toContain('key: "proseLeak"');
+
+ const invalidInput = join(workDir, 'swift-syntax-invalid');
+ const invalidOutput = join(workDir, 'swift-syntax-invalid-output');
+ mkdirSync(invalidInput);
+ writeFileSync(join(invalidInput, 'Invalid.swift'), `
+import Observation
+@Observable
+final class InvalidState {
+ // @Snapshotable
+ public private(set) var token: String = "secret"
+ // @Snapshotable
+ let immutable: Int = 1
+ // @Snapshotable
+ var inferred = 2
+ // @Snapshotable
+ var legacy: String! = nil
+ // @Snapshotable
+ var custom: Date = .now
+}
+
+enum Namespace {
+ @Observable
+ final class NestedState {
+ // @Snapshotable
+ var nestedValue: Int = 0
+ }
+}
+
+@Observable
+final class FirstState {
+ // @Snapshotable
+ var shared: String = "first"
+}
+
+@Observable
+final class DuplicateState {
+ // @Snapshotable
+ var shared: String = "duplicate"
+}
+`);
+ const invalid = spawnSync('swift', [
+ 'run', '--package-path', packageDir, 'gen-accessors',
+ '--input', invalidInput, '--output', invalidOutput,
+ ], { encoding: 'utf8', env, timeout: 180_000 });
+ expect(invalid.status).toBe(4);
+ expect(invalid.stderr).toContain('InvalidState.token cannot be private');
+ expect(invalid.stderr).toContain('InvalidState.immutable must be declared var, not let');
+ expect(invalid.stderr).toContain('InvalidState.inferred requires an explicit type annotation');
+ expect(invalid.stderr).toContain('InvalidState.legacy cannot use an implicitly unwrapped Optional type');
+ expect(invalid.stderr).toContain("InvalidState.custom uses unsupported non-JSON snapshot type 'Date'");
+ expect(invalid.stderr).toContain('nested @Observable class NestedState');
+ expect(invalid.stderr).toContain("snapshot key 'shared' is declared by both FirstState and DuplicateState");
+ expect(existsSync(join(invalidOutput, 'StateAccessor.swift'))).toBe(false);
+ }, 180_000);
});
describe('collectSwiftFiles', () => {
@@ -355,4 +1063,17 @@ describe('collectSwiftFiles', () => {
const files = collectSwiftFiles(workDir);
expect(files.sort()).toEqual([a, b].sort());
});
+
+ test('excludes the configured output subtree and every StateAccessor.swift', () => {
+ const source = join(workDir, 'App.swift');
+ const staleAccessor = join(workDir, 'old', 'StateAccessor.swift');
+ const outputDir = join(workDir, 'custom-output');
+ mkdirSync(join(workDir, 'old'));
+ mkdirSync(outputDir);
+ writeFileSync(source, 'struct App {}');
+ writeFileSync(staleAccessor, '// stale generated output');
+ writeFileSync(join(outputDir, 'Poison.swift'), '// must not be scanned');
+
+ expect(collectSwiftFiles(workDir, { outputDir })).toEqual([source]);
+ });
});
diff --git a/ios-qa/scripts/gen-accessors.ts b/ios-qa/scripts/gen-accessors.ts
index 05475f918..738e08364 100644
--- a/ios-qa/scripts/gen-accessors.ts
+++ b/ios-qa/scripts/gen-accessors.ts
@@ -5,15 +5,16 @@
// first time. Also exercised by tests so we can verify the cache + parse
// behavior without a Swift toolchain.
//
-// The TS port uses a stricter regex than the fork's original — it understands:
+// The TS fast path uses a lightweight Swift lexical scanner — it understands:
// - @Observable class declarations
-// - @Snapshotable property markers (only marked fields are exported)
+// - `// @Snapshotable` property markers (only marked fields are exported)
+// - Legacy @Snapshotable attributes for existing integrations
// - Multi-line type signatures (collapses whitespace before matching)
-// - Generic type parameters (matched as opaque text inside the type)
+// - JSON-native generic arrays and String-keyed dictionaries
//
-// What it does NOT handle (deferred to the SwiftPM tool):
-// - Computed properties with bodies (regex can mis-parse braces)
-// - Property wrappers other than @Snapshotable
+// Invalid marked declarations (computed/immutable/inaccessible/untyped,
+// nested, duplicate-keyed, or non-JSON) fail generation instead of producing
+// Swift that only fails later in xcodebuild or at snapshot time.
//
// Composite cache key (codex-flagged): swift_version || tool_git_rev ||
// platform_triple || source_content_hash. Source-only hash misses generator
@@ -35,6 +36,16 @@ export interface AccessorSpec {
fields: AccessorField[];
}
+export class AccessorGenerationError extends Error {
+ readonly diagnostics: string[];
+
+ constructor(diagnostics: string[]) {
+ super(`gen-accessors: invalid @Snapshotable declaration(s):\n${diagnostics.map(d => ` - ${d}`).join('\n')}`);
+ this.name = 'AccessorGenerationError';
+ this.diagnostics = diagnostics;
+ }
+}
+
export interface GenInputs {
inputDir: string;
outputDir?: string;
@@ -48,56 +59,105 @@ export interface GenInputs {
export interface GenResult {
outputPath: string;
cacheKey: string;
+ accessorHash: string;
specs: AccessorSpec[];
cacheHit: boolean;
}
const FALLBACK_PLATFORM = process.platform === 'darwin' ? 'darwin-arm64' : `${process.platform}-${process.arch}`;
+const GENERATOR_FORMAT_VERSION = 'accessor-generator-v5';
-export function collectSwiftFiles(dir: string, opts: { excludeGenerated?: boolean } = {}): string[] {
+const JSON_SCALAR_TYPES = new Set([
+ 'String',
+ 'Bool',
+ 'Int', 'Int8', 'Int16', 'Int32', 'Int64',
+ 'UInt', 'UInt8', 'UInt16', 'UInt32', 'UInt64',
+ 'Float', 'Double', 'CGFloat',
+]);
+
+export function collectSwiftFiles(
+ dir: string,
+ opts: { excludeGenerated?: boolean; outputDir?: string } = {},
+): string[] {
const out: string[] = [];
const excludeGenerated = opts.excludeGenerated ?? true;
- for (const name of readdirSync(dir)) {
- const full = join(dir, name);
- const s = statSync(full);
- if (s.isDirectory()) {
- // Skip generated output dir (when it lives under the input dir)
- if (excludeGenerated && name === 'DebugBridgeGenerated') continue;
- out.push(...collectSwiftFiles(full, opts));
- } else if (name.endsWith('.swift')) {
- // Skip the codegen output file. Otherwise the second run picks it up,
- // changes the cache key, and the cache never hits.
- if (excludeGenerated && name === 'StateAccessor.swift') continue;
- out.push(full);
+ const root = resolve(dir);
+ const outputDir = opts.outputDir ? resolve(opts.outputDir) : undefined;
+
+ function walk(currentDir: string): void {
+ for (const name of readdirSync(currentDir)) {
+ const full = join(currentDir, name);
+ const s = statSync(full);
+ if (s.isDirectory()) {
+ // The generated subtree must never participate in its own cache key.
+ // Keep the well-known-name guard for direct collectSwiftFiles callers,
+ // and use the actual --output path when generate() supplies one.
+ const isOutputSubtree = outputDir !== undefined
+ && outputDir !== root
+ && resolve(full) === outputDir;
+ if (excludeGenerated && (name === 'DebugBridgeGenerated' || isOutputSubtree)) continue;
+ walk(full);
+ } else if (name.endsWith('.swift')) {
+ // Skip generated accessor files wherever they live. Otherwise moving
+ // an old copy outside the output directory poisons the next cache key.
+ if (excludeGenerated && name === 'StateAccessor.swift') continue;
+ out.push(full);
+ }
}
}
+
+ walk(root);
return out.sort();
}
export function parseSwift(source: string): AccessorSpec[] {
const specs: AccessorSpec[] = [];
+ const diagnostics: string[] = [];
+ const masked = maskSwiftSource(source);
// Find `@Observable\n(public )?(final )?class ` followed by a brace
// block. We then scan inside that block for @Snapshotable fields.
const classPattern = /@Observable\s*(?:(?:public|internal|fileprivate|private)\s+)?(?:final\s+)?class\s+(\w+)[^{]*\{/g;
- let match: RegExpExecArray | null;
- while ((match = classPattern.exec(source)) !== null) {
+ for (const match of masked.matchAll(classPattern)) {
const className = match[1]!;
- const startIdx = classPattern.lastIndex;
- const endIdx = findMatchingBrace(source, startIdx - 1);
+ const matchOffset = match.index!;
+ const openBraceOffset = matchOffset + match[0].lastIndexOf('{');
+ const startIdx = openBraceOffset + 1;
+ const endIdx = findMatchingBrace(masked, startIdx - 1);
if (endIdx === -1) continue;
- const body = source.slice(startIdx, endIdx);
+ const body = masked.slice(startIdx, endIdx);
- const fields = parseFields(body);
+ const parsed = parseFields(body, source, startIdx, className);
+ if (braceDepthAt(masked, matchOffset) !== 0) {
+ if (parsed.fields.length > 0 || parsed.diagnostics.length > 0) {
+ const line = source.slice(0, matchOffset).split(/\r?\n/).length;
+ diagnostics.push(`${className} (line ${line}): nested @Observable types are not supported; move the type to file scope`);
+ }
+ continue;
+ }
+ diagnostics.push(...parsed.diagnostics);
+ const fields = parsed.fields;
if (fields.length > 0) {
specs.push({ className, fields });
}
}
+
+ if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
return specs;
}
+function braceDepthAt(masked: string, offset: number): number {
+ let depth = 0;
+ for (let i = 0; i < offset; i++) {
+ if (masked[i] === '{') depth++;
+ else if (masked[i] === '}') depth = Math.max(0, depth - 1);
+ }
+ return depth;
+}
+
function findMatchingBrace(s: string, openIdx: number): number {
- // openIdx points at '{'. Return idx of matching '}', or -1.
+ // Strings and comments have already been blanked by maskSwiftSource, so
+ // braces here are syntax rather than prose or literal content.
let depth = 0;
for (let i = openIdx; i < s.length; i++) {
const c = s[i];
@@ -105,48 +165,305 @@ function findMatchingBrace(s: string, openIdx: number): number {
else if (c === '}') {
depth--;
if (depth === 0) return i;
- } else if (c === '"' || c === "'") {
- // skip string literal
- const quote = c;
- i++;
- while (i < s.length && s[i] !== quote) {
- if (s[i] === '\\') i++;
- i++;
- }
- } else if (c === '/' && s[i + 1] === '/') {
- // skip line comment
- while (i < s.length && s[i] !== '\n') i++;
- } else if (c === '/' && s[i + 1] === '*') {
- i += 2;
- while (i < s.length - 1 && !(s[i] === '*' && s[i + 1] === '/')) i++;
- i++;
}
}
return -1;
}
-function parseFields(body: string): AccessorField[] {
- // Look for @Snapshotable followed by var/let declarations. Allow attribute
- // ordering: `@Snapshotable var name: Type` OR `@Snapshotable\n var name: Type`.
- // Multi-line types are handled by greedy non-newline match in the type, but
- // we collapse adjacent whitespace first to avoid false negatives.
- const normalized = body.replace(/[\t ]*\r?\n[\t ]*/g, ' ');
- const fieldPattern = /@Snapshotable\s+(?:(?:public|internal|fileprivate|private)\s+)?(?:var|let)\s+(\w+)\s*:\s*([^={]+?)(?=\s*(?:=|\{|@Snapshotable|\bvar\b|\blet\b|\bfunc\b|\}|$))/g;
- const fields: AccessorField[] = [];
- let m: RegExpExecArray | null;
- while ((m = fieldPattern.exec(normalized)) !== null) {
- // Codex catch: skip fields that have a computed body (`{ get ... }` or
- // `{ didSet ... }` after the type). The match boundary stops before `{`,
- // so we peek at what comes after the type in the original body.
- const afterMatchIdx = m.index + m[0].length;
- const afterMatch = normalized.slice(afterMatchIdx, afterMatchIdx + 4).trim();
- // If the next non-space character is `{`, this is a computed property.
- // We're conservative: snapshot-eligible fields must be stored properties
- // (initialized with `=` or just declared).
- if (afterMatch.startsWith('{')) continue;
- fields.push({ name: m[1]!, typeText: m[2]!.trim() });
+/**
+ * Blank comments and string literals while preserving byte offsets/newlines.
+ * A canonical standalone `// @Snapshotable` comment is rewritten to the
+ * equivalent attribute token. This is intentionally lexical rather than a
+ * whole-file regexp: markers inside nested block comments, normal/triple/raw
+ * strings, trailing comments, and prose comments must never opt a field in.
+ */
+function maskSwiftSource(source: string): string {
+ const out = source.split('');
+
+ const blank = (start: number, end: number): void => {
+ for (let j = start; j < end; j++) {
+ if (out[j] !== '\n' && out[j] !== '\r') out[j] = ' ';
+ }
+ };
+
+ let i = 0;
+ while (i < source.length) {
+ if (source[i] === '/' && source[i + 1] === '/') {
+ let end = i + 2;
+ while (end < source.length && source[end] !== '\n' && source[end] !== '\r') end++;
+ const lineStart = source.lastIndexOf('\n', i - 1) + 1;
+ const standalone = source.slice(lineStart, i).trim().length === 0;
+ const isMarker = standalone && source.slice(i + 2, end).trim() === '@Snapshotable';
+ blank(i, end);
+ if (isMarker) {
+ const marker = '@Snapshotable';
+ for (let j = 0; j < marker.length; j++) out[i + j] = marker[j]!;
+ }
+ i = end;
+ continue;
+ }
+
+ if (source[i] === '/' && source[i + 1] === '*') {
+ const start = i;
+ i += 2;
+ let depth = 1;
+ while (i < source.length && depth > 0) {
+ if (source[i] === '/' && source[i + 1] === '*') {
+ depth++;
+ i += 2;
+ } else if (source[i] === '*' && source[i + 1] === '/') {
+ depth--;
+ i += 2;
+ } else {
+ i++;
+ }
+ }
+ blank(start, i);
+ continue;
+ }
+
+ // Swift strings may be ordinary, multiline, or raw (`#"..."#` and
+ // `#"""..."""#`). Mask the entire literal, including interpolation;
+ // declarations cannot legally originate inside a literal.
+ let hashCount = 0;
+ while (source[i + hashCount] === '#') hashCount++;
+ const quoteIdx = i + hashCount;
+ if (source[quoteIdx] === '"') {
+ const start = i;
+ const triple = source.slice(quoteIdx, quoteIdx + 3) === '"""';
+ const quoteCount = triple ? 3 : 1;
+ i = quoteIdx + quoteCount;
+ while (i < source.length) {
+ if (hashCount === 0 && source[i] === '\\') {
+ i += 2;
+ continue;
+ }
+ const quoteRun = source.slice(i, i + quoteCount) === '"'.repeat(quoteCount);
+ const hashesMatch = source.slice(i + quoteCount, i + quoteCount + hashCount) === '#'.repeat(hashCount);
+ if (quoteRun && hashesMatch) {
+ i += quoteCount + hashCount;
+ break;
+ }
+ i++;
+ }
+ blank(start, i);
+ continue;
+ }
+
+ i++;
}
- return fields;
+ return out.join('');
+}
+
+const DECL_MODIFIERS = new Set([
+ 'public', 'internal', 'package', 'open', 'private', 'fileprivate',
+ 'final', 'static', 'class', 'lazy', 'weak', 'unowned', 'override',
+ 'nonisolated', 'isolated', 'borrowing', 'consuming',
+]);
+
+function parseFields(
+ body: string,
+ fullSource: string,
+ bodyOffset: number,
+ className: string,
+): { fields: AccessorField[]; diagnostics: string[] } {
+ const fields: AccessorField[] = [];
+ const diagnostics: string[] = [];
+ let braceDepth = 0;
+
+ const lineFor = (localOffset: number): number => {
+ const absolute = bodyOffset + localOffset;
+ let line = 1;
+ for (let j = 0; j < absolute; j++) if (fullSource[j] === '\n') line++;
+ return line;
+ };
+ const fail = (offset: number, message: string): void => {
+ diagnostics.push(`${className} (line ${lineFor(offset)}): ${message}`);
+ };
+ const skipWhitespace = (start: number): number => {
+ let at = start;
+ while (at < body.length && /\s/.test(body[at]!)) at++;
+ return at;
+ };
+ const identifierAt = (start: number): { text: string; end: number } | undefined => {
+ const m = /^[A-Za-z_]\w*/.exec(body.slice(start));
+ return m ? { text: m[0], end: start + m[0].length } : undefined;
+ };
+ const skipBalancedParens = (start: number): number => {
+ if (body[start] !== '(') return start;
+ let depth = 0;
+ for (let at = start; at < body.length; at++) {
+ if (body[at] === '(') depth++;
+ else if (body[at] === ')' && --depth === 0) return at + 1;
+ }
+ return body.length;
+ };
+
+ for (let i = 0; i < body.length; i++) {
+ if (body[i] === '{') {
+ braceDepth++;
+ continue;
+ }
+ if (body[i] === '}') {
+ braceDepth--;
+ continue;
+ }
+ if (braceDepth !== 0 || !body.startsWith('@Snapshotable', i)) continue;
+ const before = i === 0 ? '' : body[i - 1]!;
+ const after = body[i + '@Snapshotable'.length] ?? '';
+ if (/\w/.test(before) || /\w/.test(after)) continue;
+
+ const markerOffset = i;
+ let at = skipWhitespace(i + '@Snapshotable'.length);
+ const modifiers: string[] = [];
+ let bindingKind: 'var' | 'let' | undefined;
+
+ // Permit other Swift attributes between the marker and declaration. They
+ // remain the compiler's responsibility; this scanner only owns the
+ // snapshot contract. Parenthesized attribute arguments are skipped.
+ while (body[at] === '@') {
+ const attribute = identifierAt(at + 1);
+ if (!attribute) break;
+ at = skipWhitespace(attribute.end);
+ if (body[at] === '(') at = skipBalancedParens(at);
+ at = skipWhitespace(at);
+ }
+
+ while (at < body.length) {
+ const token = identifierAt(at);
+ if (!token) break;
+ if (token.text === 'var' || token.text === 'let') {
+ bindingKind = token.text;
+ at = token.end;
+ break;
+ }
+ if (!DECL_MODIFIERS.has(token.text)) break;
+ let modifier = token.text;
+ at = skipWhitespace(token.end);
+ if (body[at] === '(') {
+ const end = skipBalancedParens(at);
+ modifier += body.slice(at, end).replace(/\s+/g, '');
+ at = skipWhitespace(end);
+ }
+ modifiers.push(modifier);
+ }
+
+ if (!bindingKind) {
+ fail(markerOffset, '@Snapshotable must immediately precede a stored property declaration');
+ continue;
+ }
+
+ at = skipWhitespace(at);
+ const identifier = identifierAt(at);
+ const fieldName = identifier?.text ?? '';
+ if (!identifier) {
+ fail(markerOffset, '@Snapshotable only supports a single identifier binding');
+ continue;
+ }
+ at = skipWhitespace(identifier.end);
+
+ if (bindingKind === 'let') {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' must be declared var, not let`);
+ continue;
+ }
+ if (modifiers.some(m => /^(?:private|fileprivate)(?:\(set\))?$/.test(m))) {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' cannot be private, fileprivate, private(set), or fileprivate(set)`);
+ continue;
+ }
+ if (modifiers.some(m => m === 'static' || m === 'class')) {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' must be an instance property`);
+ continue;
+ }
+ if (body[at] !== ':') {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`);
+ continue;
+ }
+
+ at = skipWhitespace(at + 1);
+ const typeStart = at;
+ let parens = 0;
+ let brackets = 0;
+ let angles = 0;
+ let typeEnd = at;
+ let delimiter = '';
+ for (; at < body.length; at++) {
+ const c = body[at]!;
+ if (c === '(') parens++;
+ else if (c === ')') parens--;
+ else if (c === '[') brackets++;
+ else if (c === ']') brackets--;
+ else if (c === '<') angles++;
+ else if (c === '>') angles = Math.max(0, angles - 1);
+ const topLevel = parens === 0 && brackets === 0 && angles === 0;
+ if (topLevel && (c === '=' || c === '{' || c === ',' || c === ';')) {
+ delimiter = c;
+ break;
+ }
+ if (topLevel && (c === '\n' || c === '\r')) {
+ const soFar = body.slice(typeStart, at).trim();
+ if (soFar.length > 0 && !/(?:->|[&.,])\s*$/.test(soFar)) break;
+ }
+ typeEnd = at + 1;
+ }
+ const typeText = body.slice(typeStart, typeEnd).replace(/\s+/g, ' ').trim();
+ if (typeText.length === 0) {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' requires an explicit type annotation`);
+ continue;
+ }
+ if (delimiter === '{') {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' must be stored and writable`);
+ continue;
+ }
+ if (delimiter === ',') {
+ fail(markerOffset, '@Snapshotable declarations must contain exactly one binding');
+ continue;
+ }
+ if (delimiter === '=') {
+ // A declaration such as `var a: Int = 1, b: Int = 2` reaches `=`
+ // before its binding comma. Scan the initializer at syntax depth zero
+ // so commas in arrays/calls/closures remain valid.
+ let initializerAt = at + 1;
+ let expressionParens = 0;
+ let expressionBrackets = 0;
+ let expressionBraces = 0;
+ let hasSecondBinding = false;
+ for (; initializerAt < body.length; initializerAt++) {
+ const c = body[initializerAt]!;
+ if (c === '(') expressionParens++;
+ else if (c === ')') expressionParens--;
+ else if (c === '[') expressionBrackets++;
+ else if (c === ']') expressionBrackets--;
+ else if (c === '{') expressionBraces++;
+ else if (c === '}') {
+ if (expressionBraces === 0) break;
+ expressionBraces--;
+ }
+ const topLevel = expressionParens === 0 && expressionBrackets === 0 && expressionBraces === 0;
+ if (topLevel && c === ',') {
+ hasSecondBinding = true;
+ break;
+ }
+ if (topLevel && (c === '\n' || c === '\r' || c === ';')) break;
+ }
+ if (hasSecondBinding) {
+ fail(markerOffset, '@Snapshotable declarations must contain exactly one binding');
+ continue;
+ }
+ }
+ const wrappedOptional = optionalWrappedType(typeText);
+ if (wrappedOptional !== undefined && optionalWrappedType(wrappedOptional) !== undefined) {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' cannot use a nested Optional type`);
+ continue;
+ }
+ const typeIssue = snapshotTypeIssue(typeText);
+ if (typeIssue !== undefined) {
+ fail(markerOffset, `@Snapshotable field '${fieldName}' ${typeIssue}`);
+ continue;
+ }
+
+ fields.push({ name: fieldName, typeText });
+ }
+ return { fields, diagnostics };
}
export function computeCacheKey(inputs: {
@@ -154,35 +471,263 @@ export function computeCacheKey(inputs: {
swiftVersion: string;
toolGitRev: string;
platformTriple: string;
+ buildId?: string;
}): string {
const h = createHash('sha256');
- h.update(`swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|`);
+ h.update(`${GENERATOR_FORMAT_VERSION}|swift=${inputs.swiftVersion}|tool=${inputs.toolGitRev}|platform=${inputs.platformTriple}|build=${inputs.buildId ?? 'unknown'}|`);
for (const f of inputs.swiftFiles) {
const content = readFileSync(f);
- h.update(`${f}:${content.length}:`);
+ // Cache identity is content-based. Absolute checkout paths must not make
+ // equivalent source trees produce different generated output.
+ h.update(`${content.length}:`);
h.update(content);
h.update('|');
}
return h.digest('hex');
}
-export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string {
- let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n';
- out += '#if DEBUG\nimport Foundation\nimport DebugBridge\n\n';
+/** Stable snapshot-schema fingerprint, deliberately independent of cache ABI,
+ * source paths, app build provenance, and unmarked source. Field/class order is
+ * source order because restore payload compatibility is an ordered contract. */
+export function computeAccessorHash(specs: AccessorSpec[]): string {
+ let signature = 'snapshot-schema-v1\n';
for (const spec of specs) {
- out += `@MainActor\npublic enum ${spec.className}Accessor {\n`;
- out += ` public static func register(_ state: ${spec.className}) {\n`;
+ signature += `C${Buffer.byteLength(spec.className)}:${spec.className}\n`;
+ for (const field of spec.fields) {
+ signature += `F${Buffer.byteLength(field.name)}:${field.name}`;
+ signature += `T${Buffer.byteLength(field.typeText)}:${field.typeText}\n`;
+ }
+ signature += 'E\n';
+ }
+ return createHash('sha256').update(signature).digest('hex');
+}
+
+/** Return the wrapped type for one top-level Optional spelling. */
+export function optionalWrappedType(typeText: string): string | undefined {
+ const type = typeText.trim();
+ if (type.endsWith('?')) {
+ const wrapped = type.slice(0, -1).trim();
+ return wrapped.length > 0 ? wrapped : undefined;
+ }
+
+ const optional = /^(?:Swift\.)?Optional\s*') {
+ depth--;
+ if (depth === 0) {
+ if (type.slice(i + 1).trim().length > 0) return undefined;
+ const wrapped = type.slice(open + 1, i).trim();
+ return wrapped.length > 0 ? wrapped : undefined;
+ }
+ }
+ }
+ return undefined;
+}
+
+function splitTopLevel(type: string, delimiter: string): string[] {
+ const parts: string[] = [];
+ let start = 0;
+ let angle = 0;
+ let square = 0;
+ let paren = 0;
+ for (let i = 0; i < type.length; i++) {
+ const char = type[i]!;
+ if (char === '<') angle++;
+ else if (char === '>') angle--;
+ else if (char === '[') square++;
+ else if (char === ']') square--;
+ else if (char === '(') paren++;
+ else if (char === ')') paren--;
+ else if (char === delimiter && angle === 0 && square === 0 && paren === 0) {
+ parts.push(type.slice(start, i));
+ start = i + 1;
+ }
+ }
+ parts.push(type.slice(start));
+ return parts;
+}
+
+function genericArgument(type: string, name: string): string | undefined {
+ const prefix = `${name}<`;
+ if (!type.startsWith(prefix) || !type.endsWith('>')) return undefined;
+ let depth = 0;
+ for (let i = name.length; i < type.length; i++) {
+ if (type[i] === '<') depth++;
+ else if (type[i] === '>') {
+ depth--;
+ if (depth === 0 && i !== type.length - 1) return undefined;
+ if (depth < 0) return undefined;
+ }
+ }
+ return depth === 0 ? type.slice(prefix.length, -1) : undefined;
+}
+
+/** Return a user-facing suffix when a Swift type cannot round-trip through
+ * Foundation JSON without custom encoding. The accepted grammar is deliberately
+ * narrow: JSON scalar values, arrays, string-keyed dictionaries, and Optional
+ * compositions of those types. */
+export function snapshotTypeIssue(typeText: string): string | undefined {
+ const type = typeText.replace(/\s+/g, '');
+ if (type.length === 0) return 'requires a JSON-compatible type';
+ if (type.endsWith('!')) {
+ return 'cannot use an implicitly unwrapped Optional; use T? instead';
+ }
+
+ if (type.endsWith('?')) {
+ const wrapped = type.slice(0, -1);
+ if (wrapped.endsWith('?')
+ || genericArgument(wrapped, 'Optional') !== undefined
+ || genericArgument(wrapped, 'Swift.Optional') !== undefined) {
+ return 'cannot use a nested Optional type';
+ }
+ return snapshotTypeIssue(wrapped);
+ }
+ const optional = genericArgument(type, 'Optional') ?? genericArgument(type, 'Swift.Optional');
+ if (optional !== undefined) {
+ if (optional.endsWith('?')
+ || genericArgument(optional, 'Optional') !== undefined
+ || genericArgument(optional, 'Swift.Optional') !== undefined) {
+ return 'cannot use a nested Optional type';
+ }
+ return snapshotTypeIssue(optional);
+ }
+
+ const scalar = type === 'CoreGraphics.CGFloat'
+ ? 'CGFloat'
+ : (type.startsWith('Swift.') ? type.slice('Swift.'.length) : type);
+ if (JSON_SCALAR_TYPES.has(scalar)) return undefined;
+
+ if (type.startsWith('[') && type.endsWith(']')) {
+ const inner = type.slice(1, -1);
+ const dictionaryParts = splitTopLevel(inner, ':');
+ if (dictionaryParts.length === 1) return snapshotTypeIssue(inner);
+ if (dictionaryParts.length === 2) {
+ const key = dictionaryParts[0]!.replace(/^Swift\./, '');
+ if (key !== 'String') return 'must use String keys for snapshot dictionaries';
+ return snapshotTypeIssue(dictionaryParts[1]!);
+ }
+ return 'requires a JSON-compatible array or dictionary type';
+ }
+
+ const array = genericArgument(type, 'Array') ?? genericArgument(type, 'Swift.Array');
+ if (array !== undefined) return snapshotTypeIssue(array);
+ const dictionary = genericArgument(type, 'Dictionary') ?? genericArgument(type, 'Swift.Dictionary');
+ if (dictionary !== undefined) {
+ const parts = splitTopLevel(dictionary, ',');
+ if (parts.length !== 2) return 'requires a JSON-compatible dictionary type';
+ const key = parts[0]!.replace(/^Swift\./, '');
+ if (key !== 'String') return 'must use String keys for snapshot dictionaries';
+ return snapshotTypeIssue(parts[1]!);
+ }
+
+ return `uses unsupported snapshot type '${typeText}'; use JSON scalar, array, or String-keyed dictionary types`;
+}
+
+export function validateAccessorSpecs(specs: AccessorSpec[]): void {
+ const owners = new Map();
+ const diagnostics: string[] = [];
+ for (const spec of specs) {
+ for (const field of spec.fields) {
+ const previous = owners.get(field.name);
+ if (previous !== undefined) {
+ diagnostics.push(`snapshot key '${field.name}' is declared by both ${previous} and ${spec.className}; keys must be unique across @Observable types`);
+ } else {
+ owners.set(field.name, spec.className);
+ }
+ }
+ }
+ if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
+}
+
+function swiftStringLiteral(value: string): string {
+ return JSON.stringify(value);
+}
+
+export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string {
+ validateAccessorSpecs(specs);
+ let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n';
+ out += '#if DEBUG\nimport Foundation\nimport DebugBridgeCore\n\n';
+ for (const spec of specs) {
+ // Accessors compile in the app target beside its usually-internal state
+ // types. Making this API public would make valid internal models fail
+ // Swift type checking (a public signature cannot expose an internal type).
+ out += `@MainActor\nenum ${spec.className}Accessor {\n`;
+ out += ` private static func decodeSnapshotValue(_ value: Any, as type: T.Type) -> T? {\n`;
+ out += ` guard JSONSerialization.isValidJSONObject(["value": value]),\n`;
+ out += ` let data = try? JSONSerialization.data(withJSONObject: ["value": value]),\n`;
+ out += ` let decoded = try? JSONDecoder().decode([String: T].self, from: data) else { return nil }\n`;
+ out += ` return decoded["value"]\n`;
+ out += ` }\n\n`;
+ out += ` static func register(_ state: ${spec.className}) {\n`;
out += ` StateServer.shared.register(\n`;
- out += ` buildId: "${buildId}",\n`;
- out += ` accessorHash: "${accessorHash}",\n`;
- out += ` atomicRestore: { _ in .ok }\n`;
+ out += ` buildId: {\n`;
+ out += ` let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String\n`;
+ out += ` let bundleVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String\n`;
+ out += ` if let shortVersion, let bundleVersion { return "\\(shortVersion) (\\(bundleVersion))" }\n`;
+ out += ` return shortVersion ?? bundleVersion ?? ${swiftStringLiteral(buildId)}\n`;
+ out += ` }(),\n`;
+ out += ` accessorHash: ${swiftStringLiteral(accessorHash)},\n`;
+ out += ` atomicRestore: { keys, apply in\n`;
+ out += ` // Validate every key and value before assignment.\n`;
+ out += ` // Successful assignments are sequential on MainActor.\n`;
+ spec.fields.forEach((field, index) => {
+ out += ` guard let raw${index} = keys["${field.name}"] else {\n`;
+ out += ` return .missingKey("${field.name}")\n`;
+ out += ` }\n`;
+ const wrapped = optionalWrappedType(field.typeText);
+ if (wrapped !== undefined) {
+ out += ` let restored${index}: ${field.typeText}\n`;
+ out += ` if raw${index} is NSNull {\n`;
+ out += ` restored${index} = nil\n`;
+ out += ` } else if let typed = Self.decodeSnapshotValue(raw${index}, as: ${wrapped}.self) {\n`;
+ out += ` restored${index} = typed\n`;
+ out += ` } else {\n`;
+ out += ` return .typeMismatch("${field.name}")\n`;
+ out += ` }\n`;
+ } else {
+ out += ` guard let restored${index} = Self.decodeSnapshotValue(raw${index}, as: ${field.typeText}.self) else {\n`;
+ out += ` return .typeMismatch("${field.name}")\n`;
+ out += ` }\n`;
+ }
+ });
+ out += ` if apply {\n`;
+ spec.fields.forEach((field, index) => {
+ out += ` state.${field.name} = restored${index}\n`;
+ });
+ out += ` }\n`;
+ out += ` return .ok\n`;
+ out += ` }\n`;
out += ` )\n`;
for (const field of spec.fields) {
+ const wrapped = optionalWrappedType(field.typeText);
out += ` StateServer.shared.registerAccessor(\n`;
out += ` key: "${field.name}",\n`;
out += ` type: "${field.typeText}",\n`;
- out += ` read: { state.${field.name} as Any? },\n`;
- out += ` write: { _ in false }\n`;
+ if (wrapped !== undefined) {
+ out += ` read: {\n`;
+ out += ` guard let value = state.${field.name} else { return NSNull() }\n`;
+ out += ` return value as Any\n`;
+ out += ` },\n`;
+ } else {
+ out += ` read: { state.${field.name} as Any? },\n`;
+ }
+ out += ` write: { value in\n`;
+ if (wrapped !== undefined) {
+ out += ` if value is NSNull {\n`;
+ out += ` state.${field.name} = nil\n`;
+ out += ` return true\n`;
+ out += ` }\n`;
+ out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${wrapped}.self) else { return false }\n`;
+ } else {
+ out += ` guard let typed = Self.decodeSnapshotValue(value, as: ${field.typeText}.self) else { return false }\n`;
+ }
+ out += ` state.${field.name} = typed\n`;
+ out += ` return true\n`;
+ out += ` }\n`;
out += ` )\n`;
}
out += ` }\n}\n\n`;
@@ -215,6 +760,15 @@ function detectToolGitRev(): string {
}
}
+function detectBuildId(): string {
+ if (process.env.APP_BUILD_ID) return process.env.APP_BUILD_ID;
+ const marketing = process.env.MARKETING_VERSION;
+ const build = process.env.CURRENT_PROJECT_VERSION;
+ if (marketing && build) return `${marketing} (${build})`;
+ if (build) return build;
+ return 'unknown';
+}
+
export function defaultCacheRoot(): string {
return process.env.GSTACK_IOS_CACHE_ROOT ?? join(homedir(), '.gstack', 'cache', 'gen-accessors');
}
@@ -223,13 +777,25 @@ export function generate(inputs: GenInputs): GenResult {
const inputDir = resolve(inputs.inputDir);
const outputDir = resolve(inputs.outputDir ?? inputDir);
const cacheRoot = inputs.cacheRoot ?? defaultCacheRoot();
- const swiftFiles = collectSwiftFiles(inputDir);
+ const swiftFiles = collectSwiftFiles(inputDir, { outputDir });
+ const buildId = inputs.buildId ?? detectBuildId();
+
+ // Parse before cache lookup. This keeps diagnostics deterministic even when
+ // an older cache entry exists, and gives us the schema-only accessor hash.
+ const allSpecs: AccessorSpec[] = [];
+ for (const f of swiftFiles) {
+ const src = readFileSync(f, 'utf-8');
+ allSpecs.push(...parseSwift(src));
+ }
+ validateAccessorSpecs(allSpecs);
+ const accessorHash = computeAccessorHash(allSpecs);
const cacheKey = computeCacheKey({
swiftFiles,
swiftVersion: inputs.swiftVersion ?? detectSwiftVersion(),
toolGitRev: inputs.toolGitRev ?? detectToolGitRev(),
platformTriple: inputs.platformTriple ?? FALLBACK_PLATFORM,
+ buildId,
});
const cachedOutput = join(cacheRoot, cacheKey, 'StateAccessor.swift');
@@ -242,18 +808,13 @@ export function generate(inputs: GenInputs): GenResult {
return {
outputPath: finalOutput,
cacheKey,
- specs: [], // intentionally empty on cache hit (no need to re-parse)
+ accessorHash,
+ specs: allSpecs,
cacheHit: true,
};
}
- // Parse + render fresh
- const allSpecs: AccessorSpec[] = [];
- for (const f of swiftFiles) {
- const src = readFileSync(f, 'utf-8');
- allSpecs.push(...parseSwift(src));
- }
- const rendered = render(allSpecs, inputs.buildId ?? 'unknown', cacheKey);
+ const rendered = render(allSpecs, buildId, accessorHash);
writeFileSync(finalOutput, rendered);
// Populate cache (best-effort — cache failures don't break codegen).
@@ -267,6 +828,7 @@ export function generate(inputs: GenInputs): GenResult {
return {
outputPath: finalOutput,
cacheKey,
+ accessorHash,
specs: allSpecs,
cacheHit: false,
};
@@ -300,10 +862,18 @@ if (import.meta.main) {
const inputDir = args[inputIdx + 1]!;
const outputIdx = args.indexOf('--output');
const outputDir = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
- const result = generate({ inputDir, outputDir });
- process.stdout.write(
- result.cacheHit
- ? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
- : `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
- );
+ try {
+ const result = generate({ inputDir, outputDir });
+ process.stdout.write(
+ result.cacheHit
+ ? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
+ : `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
+ );
+ } catch (error) {
+ if (error instanceof AccessorGenerationError) {
+ process.stderr.write(`${error.message}\n`);
+ process.exit(4);
+ }
+ throw error;
+ }
}
diff --git a/ios-qa/templates/DebugBridgeManager.swift.template b/ios-qa/templates/DebugBridgeManager.swift.template
index e18e2fb05..8dfb96d88 100644
--- a/ios-qa/templates/DebugBridgeManager.swift.template
+++ b/ios-qa/templates/DebugBridgeManager.swift.template
@@ -14,12 +14,16 @@ import Foundation
public final class DebugBridgeManager {
public static let shared = DebugBridgeManager()
- public func start(appState: AppState) {
- // 1. Register the canonical AppState struct + accessor wiring.
- // AppStateAccessor.register(_:) is generated by gen-accessors-tool.
- AppStateAccessor.register(appState)
+ /// Register app-owned generated accessors, then start the server. The
+ /// registration closure is passed in from the consuming app because the
+ /// DebugBridgeCore package cannot import app-target types. On UIKit apps,
+ /// call DebugBridgeUIWiring.installAll() before this method so a warm
+ /// daemon cannot reach uninitialized resolvers during listener startup.
+ public func start(appState: State, register: (State) -> Void) {
+ register(appState)
- // 2. Boot the StateServer.
+ // Boot only after registration so the first snapshot has a real build
+ // id, schema hash, and key set.
StateServer.shared.start()
// 3. The consuming app installs DebugOverlayWindow separately. See
@@ -31,19 +35,4 @@ public final class DebugBridgeManager {
}
}
-// Placeholder. gen-accessors-tool emits the real `AppStateAccessor` enum next
-// to the app's canonical state struct. Apps that haven't run codegen get a
-// stub that registers no accessors (snapshot is empty, restore returns
-// missing-key for every key).
-@MainActor
-public enum AppStateAccessor {
- public static var register: (Any) -> Void = { _ in }
-}
-
-// Apps declare their canonical state struct; codegen reads it and emits
-// AppStateAccessor.register. The app's struct must be `@Observable` and
-// must hold all snapshot-eligible state in `@Snapshotable`-marked fields.
-@MainActor
-public protocol AppState: AnyObject {}
-
#endif // DEBUG
diff --git a/ios-qa/templates/DebugBridgeWiring.swift.template b/ios-qa/templates/DebugBridgeWiring.swift.template
index 009a70861..bf7016d87 100644
--- a/ios-qa/templates/DebugBridgeWiring.swift.template
+++ b/ios-qa/templates/DebugBridgeWiring.swift.template
@@ -6,22 +6,35 @@
// the DebugBridge target.
#if DEBUG
-import DebugBridge
+import Foundation
+import DebugBridgeCore
+#if canImport(UIKit)
+import DebugBridgeUI
+#endif
@MainActor
-func startGstackDebugBridge(appState: AppState) {
+func startGstackDebugBridge(
+ appState: State,
+ register: (State) -> Void
+) {
// Read --recording flag from launch arguments
let recording = ProcessInfo.processInfo.arguments.contains("--gstack-recording")
- // Install accessibility + screenshot + mutation bridges before starting
- // the server so the first authenticated request can use them.
- ElementsBridge.resolver = { AccessibilityScanner.snapshot() }
- ScreenshotBridge.resolver = { SnapshotCapture.capturePNG() }
- MutationBridge.resolver = { op, payload in
- MutationDispatcher.shared.run(op: op, payload: payload)
- }
+ // Install the UI resolvers before opening the listener. A warm daemon can
+ // issue its first request as soon as StateServer starts; it must never see
+ // the default empty screenshot/elements/mutation handlers.
+ #if canImport(UIKit)
+ DebugBridgeUIWiring.installAll()
+ #endif
- DebugBridgeManager.shared.start(appState: appState, recording: recording)
+ // Generated typed accessors live in the app target, so pass their register
+ // function into the package instead of asking DebugBridgeCore to import
+ // app-owned types.
+ DebugBridgeManager.shared.start(appState: appState, register: register)
+
+ #if canImport(UIKit)
+ DebugOverlayWindow.shared.install(recording: recording)
+ #endif
}
#endif
@@ -33,7 +46,10 @@ func startGstackDebugBridge(appState: AppState) {
//
// init() {
// #if DEBUG
-// startGstackDebugBridge(appState: appState)
+// startGstackDebugBridge(
+// appState: appState,
+// register: MyAppStateAccessor.register
+// )
// #endif
// }
//
diff --git a/ios-qa/templates/StateAccessor.swift.template b/ios-qa/templates/StateAccessor.swift.template
index 07de99e19..a9a87b6f8 100644
--- a/ios-qa/templates/StateAccessor.swift.template
+++ b/ios-qa/templates/StateAccessor.swift.template
@@ -3,12 +3,12 @@
//
// This file is a TEMPLATE that gen-accessors-tool fills in. The placeholders
// are filled per-class from swift-syntax AST inspection of the app's
-// @Observable types. Only properties marked with @Snapshotable are emitted.
+// @Observable types. Only properties preceded by `// @Snapshotable` are emitted.
//
// {{CLASS_NAME}} — the canonical AppState struct name
// {{APP_BUILD_ID}} — bundle short version + git SHA at codegen time
// {{ACCESSOR_HASH}} — sha256 of accessor signatures (snapshot schema fingerprint)
-// {{ACCESSORS}} — generated register/read/write blocks per @Snapshotable field
+// {{ACCESSORS}} — generated register/read/write blocks per marked field
#if DEBUG
@@ -21,13 +21,16 @@ public enum {{CLASS_NAME}}Accessor {
StateServer.shared.register(
buildId: "{{APP_BUILD_ID}}",
accessorHash: "{{ACCESSOR_HASH}}",
- atomicRestore: { keys in
- // Validate every key + type FIRST, then apply in one struct
- // assignment so SwiftUI observers see exactly one change.
+ atomicRestore: { keys, apply in
+ // Validate every key + type before mutating any state. Invalid
+ // input therefore cannot leave a partially restored model.
var snapshot = state.snapshotable
{{VALIDATION_BLOCK}}
- // Apply atomically.
- state.snapshotable = snapshot
+ // StateServer validates every model first, then invokes the
+ // same handlers with apply=true for a cross-model commit.
+ if apply {
+ state.snapshotable = snapshot
+ }
return .ok
}
)
diff --git a/ios-qa/templates/StateServer.swift.template b/ios-qa/templates/StateServer.swift.template
index 803bedf31..b8441df16 100644
--- a/ios-qa/templates/StateServer.swift.template
+++ b/ios-qa/templates/StateServer.swift.template
@@ -59,18 +59,20 @@ public final class StateServer {
private var writeHandlers: [String: WriteHandler] = [:]
private var typeNames: [String: TypeName] = [:]
- // Atomic-restore hook. Codegen wires this to the canonical AppState struct.
- // Restore replaces the entire struct in one assignment so SwiftUI's Combine
- // pipeline observes exactly one change notification — true observable
- // atomicity. @MainActor alone doesn't guarantee that.
- public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult
+ // Validated-restore hooks. Every generated model registers one two-phase
+ // handler. The server validates all models before it lets any model mutate,
+ // so invalid input can never cause a cross-model partial restore.
+ // Valid restores apply properties on MainActor; observers may receive one
+ // change notification per property because arbitrary @Observable models do
+ // not expose a general single-assignment transaction API.
+ public typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult
public enum RestoreResult {
case ok
case missingKey(String)
case typeMismatch(String)
case schemaMismatch(expected: String, got: String)
}
- private var atomicRestore: AtomicRestoreFn?
+ private var atomicRestores: [AtomicRestoreFn] = []
// Snapshot schema hash — written by codegen, stable across builds with
// identical accessor signatures.
@@ -109,7 +111,7 @@ public final class StateServer {
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
self.appBuildId = buildId
self.accessorHash = accessorHash
- self.atomicRestore = atomicRestore
+ self.atomicRestores.append(atomicRestore)
}
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
@@ -284,6 +286,7 @@ public final class StateServer {
"version": "1.0.0",
"build": appBuildId,
"accessor_hash": accessorHash,
+ "bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
])
return
}
@@ -476,22 +479,36 @@ public final class StateServer {
send(connection: connection, status: 400, body: ["error": "missing_keys"])
return
}
- guard let restore = atomicRestore else {
+ guard !atomicRestores.isEmpty else {
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
return
}
- // Validate-then-apply via the codegen-supplied closure. The closure does
- // a single struct-assignment so SwiftUI sees one change notification.
- switch restore(keys) {
- case .ok:
- send(connection: connection, status: 200, body: ["ok": true])
- case .missingKey(let k):
- send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
- case .typeMismatch(let k):
- send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
- case .schemaMismatch(let expected, let got):
- send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
+ // Phase one validates every registered model without assignment.
+ for restore in atomicRestores {
+ switch restore(keys, false) {
+ case .ok:
+ continue
+ case .missingKey(let k):
+ send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
+ return
+ case .typeMismatch(let k):
+ send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
+ return
+ case .schemaMismatch(let expected, let got):
+ send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
+ return
+ }
}
+
+ // Phase two applies only after every model accepted the immutable input.
+ // A valid multi-field restore may notify once per property.
+ for restore in atomicRestores {
+ guard case .ok = restore(keys, true) else {
+ send(connection: connection, status: 500, body: ["error": "restore_apply_failed"])
+ return
+ }
+ }
+ send(connection: connection, status: 200, body: ["ok": true])
}
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
@@ -522,9 +539,19 @@ public final class StateServer {
// MARK: Response
private func send(connection: NWConnection, status: Int, body: JSONDict) {
- let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8)
+ let responseStatus: Int
+ let json: Data
+ if JSONSerialization.isValidJSONObject(body),
+ let encoded = try? JSONSerialization.data(withJSONObject: body) {
+ responseStatus = status
+ json = encoded
+ } else {
+ logger.error("Refusing to send a non-JSON response body")
+ responseStatus = 500
+ json = Data("{\"error\":\"response_not_json_serializable\"}".utf8)
+ }
let statusText: String
- switch status {
+ switch responseStatus {
case 200: statusText = "OK"
case 400: statusText = "Bad Request"
case 401: statusText = "Unauthorized"
@@ -537,7 +564,7 @@ public final class StateServer {
case 503: statusText = "Service Unavailable"
default: statusText = "Status"
}
- let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
+ let header = "HTTP/1.1 \(responseStatus) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
var packet = Data(header.utf8)
packet.append(json)
connection.send(content: packet, completion: .contentProcessed { _ in
diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift
index e18e2fb05..8dfb96d88 100644
--- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift
+++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/DebugBridgeManager.swift
@@ -14,12 +14,16 @@ import Foundation
public final class DebugBridgeManager {
public static let shared = DebugBridgeManager()
- public func start(appState: AppState) {
- // 1. Register the canonical AppState struct + accessor wiring.
- // AppStateAccessor.register(_:) is generated by gen-accessors-tool.
- AppStateAccessor.register(appState)
+ /// Register app-owned generated accessors, then start the server. The
+ /// registration closure is passed in from the consuming app because the
+ /// DebugBridgeCore package cannot import app-target types. On UIKit apps,
+ /// call DebugBridgeUIWiring.installAll() before this method so a warm
+ /// daemon cannot reach uninitialized resolvers during listener startup.
+ public func start(appState: State, register: (State) -> Void) {
+ register(appState)
- // 2. Boot the StateServer.
+ // Boot only after registration so the first snapshot has a real build
+ // id, schema hash, and key set.
StateServer.shared.start()
// 3. The consuming app installs DebugOverlayWindow separately. See
@@ -31,19 +35,4 @@ public final class DebugBridgeManager {
}
}
-// Placeholder. gen-accessors-tool emits the real `AppStateAccessor` enum next
-// to the app's canonical state struct. Apps that haven't run codegen get a
-// stub that registers no accessors (snapshot is empty, restore returns
-// missing-key for every key).
-@MainActor
-public enum AppStateAccessor {
- public static var register: (Any) -> Void = { _ in }
-}
-
-// Apps declare their canonical state struct; codegen reads it and emits
-// AppStateAccessor.register. The app's struct must be `@Observable` and
-// must hold all snapshot-eligible state in `@Snapshotable`-marked fields.
-@MainActor
-public protocol AppState: AnyObject {}
-
#endif // DEBUG
diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift
index 803bedf31..b8441df16 100644
--- a/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift
+++ b/test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift
@@ -59,18 +59,20 @@ public final class StateServer {
private var writeHandlers: [String: WriteHandler] = [:]
private var typeNames: [String: TypeName] = [:]
- // Atomic-restore hook. Codegen wires this to the canonical AppState struct.
- // Restore replaces the entire struct in one assignment so SwiftUI's Combine
- // pipeline observes exactly one change notification — true observable
- // atomicity. @MainActor alone doesn't guarantee that.
- public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult
+ // Validated-restore hooks. Every generated model registers one two-phase
+ // handler. The server validates all models before it lets any model mutate,
+ // so invalid input can never cause a cross-model partial restore.
+ // Valid restores apply properties on MainActor; observers may receive one
+ // change notification per property because arbitrary @Observable models do
+ // not expose a general single-assignment transaction API.
+ public typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult
public enum RestoreResult {
case ok
case missingKey(String)
case typeMismatch(String)
case schemaMismatch(expected: String, got: String)
}
- private var atomicRestore: AtomicRestoreFn?
+ private var atomicRestores: [AtomicRestoreFn] = []
// Snapshot schema hash — written by codegen, stable across builds with
// identical accessor signatures.
@@ -109,7 +111,7 @@ public final class StateServer {
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
self.appBuildId = buildId
self.accessorHash = accessorHash
- self.atomicRestore = atomicRestore
+ self.atomicRestores.append(atomicRestore)
}
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
@@ -284,6 +286,7 @@ public final class StateServer {
"version": "1.0.0",
"build": appBuildId,
"accessor_hash": accessorHash,
+ "bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
])
return
}
@@ -476,22 +479,36 @@ public final class StateServer {
send(connection: connection, status: 400, body: ["error": "missing_keys"])
return
}
- guard let restore = atomicRestore else {
+ guard !atomicRestores.isEmpty else {
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
return
}
- // Validate-then-apply via the codegen-supplied closure. The closure does
- // a single struct-assignment so SwiftUI sees one change notification.
- switch restore(keys) {
- case .ok:
- send(connection: connection, status: 200, body: ["ok": true])
- case .missingKey(let k):
- send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
- case .typeMismatch(let k):
- send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
- case .schemaMismatch(let expected, let got):
- send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
+ // Phase one validates every registered model without assignment.
+ for restore in atomicRestores {
+ switch restore(keys, false) {
+ case .ok:
+ continue
+ case .missingKey(let k):
+ send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
+ return
+ case .typeMismatch(let k):
+ send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
+ return
+ case .schemaMismatch(let expected, let got):
+ send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
+ return
+ }
}
+
+ // Phase two applies only after every model accepted the immutable input.
+ // A valid multi-field restore may notify once per property.
+ for restore in atomicRestores {
+ guard case .ok = restore(keys, true) else {
+ send(connection: connection, status: 500, body: ["error": "restore_apply_failed"])
+ return
+ }
+ }
+ send(connection: connection, status: 200, body: ["ok": true])
}
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
@@ -522,9 +539,19 @@ public final class StateServer {
// MARK: Response
private func send(connection: NWConnection, status: Int, body: JSONDict) {
- let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8)
+ let responseStatus: Int
+ let json: Data
+ if JSONSerialization.isValidJSONObject(body),
+ let encoded = try? JSONSerialization.data(withJSONObject: body) {
+ responseStatus = status
+ json = encoded
+ } else {
+ logger.error("Refusing to send a non-JSON response body")
+ responseStatus = 500
+ json = Data("{\"error\":\"response_not_json_serializable\"}".utf8)
+ }
let statusText: String
- switch status {
+ switch responseStatus {
case 200: statusText = "OK"
case 400: statusText = "Bad Request"
case 401: statusText = "Unauthorized"
@@ -537,7 +564,7 @@ public final class StateServer {
case 503: statusText = "Service Unavailable"
default: statusText = "Status"
}
- let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
+ let header = "HTTP/1.1 \(responseStatus) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
var packet = Data(header.utf8)
packet.append(json)
connection.send(content: packet, completion: .contentProcessed { _ in
diff --git a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift
index 2980cd9e7..13861ab03 100644
--- a/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift
+++ b/test/fixtures/ios-qa/FixtureApp/Sources/FixtureApp/FixtureAppState.swift
@@ -1,32 +1,22 @@
-// Canonical app state for the fixture. Every snapshot-eligible field is
-// marked with the @Snapshotable property wrapper that the codegen tool
-// detects via attribute scan.
-//
-// Note: we DON'T use @Observable here because the macro expansion converts
-// stored properties into computed ones, which the @Snapshotable wrapper
-// can't apply to. In production apps that need both observability AND
-// snapshotting, the right pattern is:
-// - Use ObservableObject + @Published (older API), or
-// - Hold all @Snapshotable state in a nested struct + replace it
-// wholesale on restore so SwiftUI sees a single change notification
-// (the canonical-state-struct atomicity strategy from the plan).
+// Canonical observable app state for the fixture. Snapshot eligibility is a
+// generator-only source marker, not a property wrapper, so it composes with
+// Observation's @Observable macro.
import Foundation
+import Observation
-public final class FixtureAppState {
- @Snapshotable public var isLoggedIn: Bool = false
- @Snapshotable public var username: String = ""
- @Snapshotable public var tapCounter: Int = 0
+@Observable
+final class FixtureAppState {
+ // @Snapshotable
+ var isLoggedIn: Bool = false
+ // @Snapshotable
+ var username: String = ""
+ // @Snapshotable
+ var tapCounter: Int = 0
+ // @Snapshotable
+ var nickname: String? = nil
/// Not snapshotted — ephemeral cache that should never leak via /state/snapshot.
- public var ephemeralCache: [String: String] = [:]
+ var ephemeralCache: [String: String] = [:]
- public init() {}
-}
-
-/// Property wrapper marker for snapshot-eligible state. The actual wrapper
-/// is a no-op at runtime; codegen-tool detection happens via attribute scan.
-@propertyWrapper
-public struct Snapshotable {
- public var wrappedValue: Value
- public init(wrappedValue: Value) { self.wrappedValue = wrappedValue }
+ init() {}
}
diff --git a/test/ios-qa-regen.test.ts b/test/ios-qa-regen.test.ts
new file mode 100644
index 000000000..0378869dd
--- /dev/null
+++ b/test/ios-qa-regen.test.ts
@@ -0,0 +1,258 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import { createHash } from 'crypto';
+import { spawnSync } from 'child_process';
+import {
+ chmodSync,
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ readdirSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from 'fs';
+import { tmpdir } from 'os';
+import { join, relative } from 'path';
+
+const ROOT = join(import.meta.dir, '..');
+const SAFE_TEMPLATE_MAP = [
+ ['Package.swift.template', 'Package.swift'],
+ ['StateServer.swift.template', 'Sources/DebugBridgeCore/StateServer.swift'],
+ ['DebugBridgeManager.swift.template', 'Sources/DebugBridgeCore/DebugBridgeManager.swift'],
+ ['Bridges.swift.template', 'Sources/DebugBridgeUI/Bridges.swift'],
+ ['DebugOverlay.swift.template', 'Sources/DebugBridgeUI/DebugOverlay.swift'],
+ ['DebugBridgeTouch.m.template', 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'],
+ ['DebugBridgeTouch.h.template', 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'],
+] as const;
+
+const workDirs: string[] = [];
+
+afterEach(() => {
+ for (const dir of workDirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+function copyIntoFakeInstall(workDir: string): { root: string; launcher: string } {
+ const root = join(workDir, 'fake gstack install');
+ const binDir = join(root, 'bin');
+ const scriptsDir = join(root, 'ios-qa', 'scripts');
+ const templatesDir = join(root, 'ios-qa', 'templates');
+ mkdirSync(binDir, { recursive: true });
+ mkdirSync(scriptsDir, { recursive: true });
+ mkdirSync(templatesDir, { recursive: true });
+
+ const launcher = join(binDir, 'gstack-ios-qa-regen');
+ copyFileSync(join(ROOT, 'bin', 'gstack-ios-qa-regen'), launcher);
+ chmodSync(launcher, 0o755);
+ copyFileSync(join(ROOT, 'ios-qa', 'scripts', 'gen-accessors.ts'), join(scriptsDir, 'gen-accessors.ts'));
+ for (const [template] of SAFE_TEMPLATE_MAP) {
+ copyFileSync(join(ROOT, 'ios-qa', 'templates', template), join(templatesDir, template));
+ }
+
+ // These files deliberately exist in the discovered template directory. If
+ // the launcher ever regresses to wildcard copying, their sentinel content
+ // will escape into the app and fail the absence assertions below.
+ writeFileSync(join(templatesDir, 'DebugBridgeWiring.swift.template'), '// FORBIDDEN-WIRING-SENTINEL\n');
+ writeFileSync(join(templatesDir, 'StateAccessor.swift.template'), '// FORBIDDEN-STATE-SENTINEL\n');
+ writeFileSync(join(root, 'VERSION'), '9.8.7.6\n');
+ return { root, launcher };
+}
+
+function treeHash(...roots: string[]): string {
+ const hash = createHash('sha256');
+ for (const root of roots) {
+ const visit = (dir: string): void => {
+ for (const name of readdirSync(dir).sort()) {
+ const path = join(dir, name);
+ const stat = statSync(path);
+ if (stat.isDirectory()) {
+ visit(path);
+ } else {
+ hash.update(relative(root, path));
+ hash.update('\0');
+ hash.update(readFileSync(path));
+ hash.update('\0');
+ }
+ }
+ };
+ visit(root);
+ }
+ return hash.digest('hex');
+}
+
+function allFileContents(root: string): string {
+ let contents = '';
+ const visit = (dir: string): void => {
+ for (const name of readdirSync(dir)) {
+ const path = join(dir, name);
+ if (statSync(path).isDirectory()) visit(path);
+ else contents += readFileSync(path, 'utf8');
+ }
+ };
+ visit(root);
+ return contents;
+}
+
+describe('gstack-ios-qa-regen', () => {
+ test('repository launcher is executable', () => {
+ expect(statSync(join(ROOT, 'bin', 'gstack-ios-qa-regen')).mode & 0o111).not.toBe(0);
+ });
+
+ test('requires the documented app-source and bridge-dir contract', () => {
+ const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
+ workDirs.push(workDir);
+ const { launcher } = copyIntoFakeInstall(workDir);
+ const result = spawnSync('bash', [launcher, '--app-source', workDir], { encoding: 'utf8' });
+
+ expect(result.status).toBe(2);
+ expect(result.stderr).toContain('both --app-source and --bridge-dir are required');
+ });
+
+ test('leaves no completion marker when accessor generation fails', () => {
+ const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
+ workDirs.push(workDir);
+ const { launcher } = copyIntoFakeInstall(workDir);
+ const appSource = join(workDir, 'app-source');
+ const bridgeDir = join(workDir, 'bridge');
+ const generatedDir = join(appSource, 'DebugBridgeGenerated');
+ const fakeBin = join(workDir, 'fake-bin');
+ mkdirSync(generatedDir, { recursive: true });
+ mkdirSync(fakeBin, { recursive: true });
+ writeFileSync(join(appSource, 'AppState.swift'), '@Observable final class AppState {}\n');
+ writeFileSync(join(generatedDir, '.gstack-version'), 'stale-complete-marker\n');
+ const fakeBun = join(fakeBin, 'bun');
+ writeFileSync(fakeBun, '#!/bin/sh\nexit 17\n');
+ chmodSync(fakeBun, 0o755);
+
+ const result = spawnSync('bash', [
+ launcher,
+ '--app-source', appSource,
+ '--bridge-dir', bridgeDir,
+ ], {
+ encoding: 'utf8',
+ env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH ?? ''}` },
+ });
+
+ expect(result.status).toBe(17);
+ expect(existsSync(join(generatedDir, '.gstack-version'))).toBe(false);
+ });
+
+ test('regenerates the allowlisted package and accessors idempotently', () => {
+ const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
+ workDirs.push(workDir);
+ const { root, launcher } = copyIntoFakeInstall(workDir);
+ const appSource = join(workDir, 'app source');
+ const bridgeDir = join(appSource, 'DebugBridge');
+ const generatedDir = join(appSource, 'DebugBridgeGenerated');
+ const cacheRoot = join(workDir, 'isolated cache');
+ mkdirSync(appSource, { recursive: true });
+ writeFileSync(join(appSource, 'AppState.swift'), `
+@Observable
+final class AppState {
+ // @Snapshotable
+ var counter: Int = 0
+}
+`);
+
+ // Seed the flat legacy layout that old ios-sync versions produced. A
+ // correct regeneration must remove these known generated artifacts rather
+ // than letting Xcode compile a second, stale harness implementation.
+ mkdirSync(generatedDir, { recursive: true });
+ for (const obsolete of [
+ join(bridgeDir, 'DebugBridgeWiring.swift'),
+ join(bridgeDir, 'StateAccessor.swift'),
+ join(generatedDir, 'Package.swift'),
+ join(generatedDir, 'StateServer.swift'),
+ join(generatedDir, 'DebugBridgeManager.swift'),
+ join(generatedDir, 'Bridges.swift'),
+ join(generatedDir, 'DebugOverlay.swift'),
+ join(generatedDir, 'DebugBridgeTouch.m'),
+ join(generatedDir, 'DebugBridgeTouch.h'),
+ join(generatedDir, 'DebugBridgeWiring.swift'),
+ ]) {
+ mkdirSync(join(obsolete, '..'), { recursive: true });
+ writeFileSync(obsolete, '// OBSOLETE-HARNESS-SENTINEL\n');
+ }
+
+ const env = {
+ ...process.env,
+ GSTACK_IOS_CACHE_ROOT: cacheRoot,
+ SWIFT_VERSION: '6.3.3',
+ GEN_ACCESSORS_REV: 'regen-test',
+ };
+ const args = [launcher, '--app-source', appSource, '--bridge-dir', bridgeDir];
+ const first = spawnSync('bash', args, { encoding: 'utf8', env });
+ expect(first.status).toBe(0);
+ expect(first.stderr).toBe('');
+
+ // Every installed package file must be byte-identical to its explicit
+ // source template: this is the durable template/output parity contract.
+ for (const [template, destination] of SAFE_TEMPLATE_MAP) {
+ expect(readFileSync(join(bridgeDir, destination))).toEqual(
+ readFileSync(join(root, 'ios-qa', 'templates', template)),
+ );
+ }
+
+ const accessorPath = join(generatedDir, 'StateAccessor.swift');
+ const accessor = readFileSync(accessorPath, 'utf8');
+ expect(accessor).toContain('import DebugBridgeCore');
+ expect(accessor).toContain('enum AppStateAccessor');
+ expect(accessor).not.toContain('public enum AppStateAccessor');
+ expect(accessor).not.toContain('import DebugBridge\n');
+ expect(accessor).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)');
+ expect(accessor).toContain('atomicRestore: { keys, apply in');
+ expect(accessor).toContain('state.counter = restored0');
+ expect(accessor).toContain('state.counter = typed');
+ expect(accessor).toContain('return true');
+ expect(accessor).not.toContain('atomicRestore: { _ in .ok }');
+ expect(accessor).not.toContain('write: { _ in false }');
+ expect(readFileSync(join(generatedDir, '.gstack-version'), 'utf8')).toBe(
+ readFileSync(join(root, 'VERSION'), 'utf8'),
+ );
+
+ expect(existsSync(join(bridgeDir, 'DebugBridgeWiring.swift'))).toBe(false);
+ expect(existsSync(join(bridgeDir, 'StateAccessor.swift'))).toBe(false);
+ for (const obsoleteName of [
+ 'Package.swift',
+ 'StateServer.swift',
+ 'DebugBridgeManager.swift',
+ 'Bridges.swift',
+ 'DebugOverlay.swift',
+ 'DebugBridgeTouch.m',
+ 'DebugBridgeTouch.h',
+ 'DebugBridgeWiring.swift',
+ ]) {
+ expect(existsSync(join(generatedDir, obsoleteName))).toBe(false);
+ }
+ const installedContents = allFileContents(bridgeDir) + allFileContents(generatedDir);
+ expect(installedContents).not.toContain('FORBIDDEN-WIRING-SENTINEL');
+ expect(installedContents).not.toContain('FORBIDDEN-STATE-SENTINEL');
+ expect(installedContents).not.toContain('OBSOLETE-HARNESS-SENTINEL');
+
+ const swiftAvailable = spawnSync('swift', ['--version'], { encoding: 'utf8' }).status === 0;
+ if (swiftAvailable) {
+ const dump = spawnSync('swift', ['package', 'dump-package', '--package-path', bridgeDir], {
+ encoding: 'utf8',
+ });
+ expect(dump.status).toBe(0);
+ const manifest = JSON.parse(dump.stdout) as { targets: Array<{ name: string }> };
+ expect(manifest.targets.map(target => target.name).sort()).toEqual([
+ 'DebugBridgeCore',
+ 'DebugBridgeTouch',
+ 'DebugBridgeUI',
+ ]);
+ }
+
+ const firstHash = treeHash(bridgeDir, generatedDir);
+ const firstAccessorHash = accessor.match(/accessorHash: "([a-f0-9]+)"/)?.[1];
+ const second = spawnSync('bash', args, { encoding: 'utf8', env });
+ expect(second.status).toBe(0);
+ expect(second.stderr).toBe('');
+ expect(second.stdout).toContain('gen-accessors: cache hit');
+ expect(treeHash(bridgeDir, generatedDir)).toBe(firstHash);
+ expect(readFileSync(accessorPath, 'utf8').match(/accessorHash: "([a-f0-9]+)"/)?.[1]).toBe(firstAccessorHash);
+ });
+});