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); + }); +});