mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-18 13:37:23 +02:00
fix(ios-qa): generate app-owned bridge accessors deterministically
This commit is contained in:
Executable
+154
@@ -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 <dir> --bridge-dir <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"
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
// swift-tools-version:5.9
|
// swift-tools-version:5.9
|
||||||
//
|
//
|
||||||
// gen-accessors-tool — SwiftPM tool that reads an app's Swift source via
|
// gen-accessors-tool — SwiftPM tool that reads an app's Swift source via
|
||||||
// swift-syntax, finds @Observable classes with @Snapshotable-marked fields,
|
// swift-syntax, finds @Observable classes with `// @Snapshotable`-marked
|
||||||
// and emits StateAccessor.swift for each one.
|
// fields, and emits StateAccessor.swift for each one.
|
||||||
//
|
//
|
||||||
// First build is 2-5 min on a cold machine (swift-syntax compile chain).
|
// First build is 2-5 min on a cold machine (swift-syntax compile chain).
|
||||||
// Subsequent runs are content-hash-cached and finish in ~50ms.
|
// Subsequent runs are content-hash-cached and finish in ~50ms.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// gen-accessors entry point. Walks the input dir for *.swift files, parses
|
// gen-accessors entry point. Walks the input dir for *.swift files, parses
|
||||||
// each via SwiftParser, finds @Observable classes with @Snapshotable-marked
|
// each via SwiftParser, finds @Observable classes with `// @Snapshotable`
|
||||||
// properties, and emits StateAccessor.swift for each.
|
// 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
|
// Output goes to --output (default: same dir as input). Cache key is
|
||||||
// computed from a composite hash and stored at
|
// computed from a composite hash and stored at
|
||||||
@@ -15,6 +16,8 @@ struct AccessorSpec {
|
|||||||
let fields: [(name: String, typeText: String)]
|
let fields: [(name: String, typeText: String)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private let generatorFormatVersion = "accessor-generator-v5"
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct GenAccessors {
|
struct GenAccessors {
|
||||||
static func main() async {
|
static func main() async {
|
||||||
@@ -32,38 +35,83 @@ struct GenAccessors {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Walk + collect *.swift files
|
// 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))
|
FileHandle.standardError.write(Data("input dir not found: \(inputDir)\n".utf8))
|
||||||
exit(3)
|
exit(3)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Composite cache key — codex catch (source content alone misses
|
// Parse + validate before consulting the cache. Invalid marked fields
|
||||||
// generator-logic changes).
|
// must fail deterministically rather than being hidden by an older
|
||||||
let cacheKey = computeCacheKey(swiftFiles: swiftFiles)
|
// cached output.
|
||||||
let cacheDir = ("~/.gstack/cache/gen-accessors" as NSString).expandingTildeInPath
|
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"
|
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) {
|
if FileManager.default.fileExists(atPath: cachedOutput) {
|
||||||
// Cache hit. Copy to output dir.
|
// Cache hit. Copy to output dir.
|
||||||
try? FileManager.default.removeItem(atPath: "\(outputDir)/StateAccessor.swift")
|
let finalOutput = "\(outputDir)/StateAccessor.swift"
|
||||||
try? FileManager.default.copyItem(atPath: cachedOutput, toPath: "\(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))")
|
print("gen-accessors: cache hit (\(cacheKey))")
|
||||||
return
|
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
|
// Emit
|
||||||
let output = render(specs: specs, buildId: getEnv("APP_BUILD_ID") ?? "unknown", accessorHash: cacheKey)
|
let output = render(specs: specs, buildId: buildId, accessorHash: accessorHash)
|
||||||
try? FileManager.default.createDirectory(atPath: outputDir, withIntermediateDirectories: true)
|
do {
|
||||||
try? output.write(toFile: "\(outputDir)/StateAccessor.swift", atomically: true, encoding: .utf8)
|
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
|
// Populate cache
|
||||||
try? FileManager.default.createDirectory(atPath: "\(cacheDir)/\(cacheKey)", withIntermediateDirectories: true)
|
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")
|
print("gen-accessors: wrote \(specs.count) accessor(s) to \(outputDir)/StateAccessor.swift")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func collectSwiftFiles(at path: String) -> [String]? {
|
static func collectSwiftFiles(at path: String, excluding outputPath: String) -> [String]? {
|
||||||
guard let enumerator = FileManager.default.enumerator(atPath: path) else { return nil }
|
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] = []
|
var files: [String] = []
|
||||||
for case let f as String in enumerator {
|
for case let fileURL as URL in enumerator {
|
||||||
if f.hasSuffix(".swift") { files.append("\(path)/\(f)") }
|
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()
|
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.
|
// Codex-flagged: hash must include Swift version, tool git rev, platform.
|
||||||
let swiftVer = getEnv("SWIFT_VERSION") ?? "unknown"
|
let swiftVer = getEnv("SWIFT_VERSION") ?? "unknown"
|
||||||
let toolRev = getEnv("GEN_ACCESSORS_REV") ?? "dev"
|
let toolRev = getEnv("GEN_ACCESSORS_REV") ?? generatorFormatVersion
|
||||||
let platform = "darwin-arm64" // simplified for the test harness
|
#if arch(arm64)
|
||||||
var combined = "swift=\(swiftVer)|tool=\(toolRev)|platform=\(platform)|"
|
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 {
|
for path in swiftFiles {
|
||||||
if let data = try? Data(contentsOf: URL(fileURLWithPath: path)) {
|
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 {
|
static func render(specs: [AccessorSpec], buildId: String, accessorHash: String) -> String {
|
||||||
var out = "// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n"
|
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<Value: Decodable>: Decodable {\n"
|
||||||
|
out += " let value: Value\n"
|
||||||
|
out += " }\n\n"
|
||||||
|
out += " static func decode<Value: Decodable>(_ 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<Value>.self, from: data).value\n"
|
||||||
|
out += " } catch {\n"
|
||||||
|
out += " return nil\n"
|
||||||
|
out += " }\n"
|
||||||
|
out += " }\n"
|
||||||
|
out += "}\n\n"
|
||||||
|
}
|
||||||
for spec in specs {
|
for spec in specs {
|
||||||
out += "@MainActor\npublic enum \(spec.className)Accessor {\n"
|
// Accessors live in the app target beside its usually-internal
|
||||||
out += " public static func register(_ state: \(spec.className)) {\n"
|
// 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 += " StateServer.shared.register(\n"
|
||||||
out += " buildId: \"\(buildId)\",\n"
|
out += " buildId: {\n"
|
||||||
out += " accessorHash: \"\(accessorHash)\",\n"
|
out += " let shortVersion = Bundle.main.object(forInfoDictionaryKey: \"CFBundleShortVersionString\") as? String\n"
|
||||||
out += " atomicRestore: { _ in .ok }\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"
|
out += " )\n"
|
||||||
for (name, _) in spec.fields {
|
for (name, typeText) in spec.fields {
|
||||||
|
let wrapped = optionalWrappedType(typeText)
|
||||||
out += " StateServer.shared.registerAccessor(\n"
|
out += " StateServer.shared.registerAccessor(\n"
|
||||||
out += " key: \"\(name)\",\n"
|
out += " key: \"\(name)\",\n"
|
||||||
out += " type: \"Any\",\n"
|
out += " type: \"\(typeText)\",\n"
|
||||||
out += " read: { state.\(name) as Any? },\n"
|
if wrapped != nil {
|
||||||
out += " write: { _ in false }\n"
|
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"
|
||||||
}
|
}
|
||||||
out += " }\n}\n\n"
|
out += " }\n}\n\n"
|
||||||
@@ -123,6 +279,59 @@ struct GenAccessors {
|
|||||||
|
|
||||||
final class ObservableClassVisitor: SyntaxVisitor {
|
final class ObservableClassVisitor: SyntaxVisitor {
|
||||||
var specs: [AccessorSpec] = []
|
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 {
|
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
|
||||||
// Look for @Observable attribute
|
// Look for @Observable attribute
|
||||||
@@ -133,23 +342,88 @@ final class ObservableClassVisitor: SyntaxVisitor {
|
|||||||
guard isObservable else { return .visitChildren }
|
guard isObservable else { return .visitChildren }
|
||||||
|
|
||||||
let className = node.name.text
|
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)] = []
|
var fields: [(String, String)] = []
|
||||||
|
|
||||||
for member in node.memberBlock.members {
|
for member in node.memberBlock.members {
|
||||||
guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue }
|
guard let varDecl = member.decl.as(VariableDeclSyntax.self) else { continue }
|
||||||
// Field must be marked @Snapshotable to be included
|
// Field must opt in with the source marker (or legacy attribute).
|
||||||
let isSnapshotable = varDecl.attributes.contains(where: { attr in
|
guard hasSnapshotableMarker(varDecl) else { continue }
|
||||||
guard let attr = attr.as(AttributeSyntax.self) else { return false }
|
|
||||||
return attr.attributeName.trimmedDescription == "Snapshotable"
|
let bindingName = varDecl.bindings.first?
|
||||||
})
|
.pattern.as(IdentifierPatternSyntax.self)?.identifier.text ?? "<unknown>"
|
||||||
guard isSnapshotable else { continue }
|
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 {
|
for binding in varDecl.bindings {
|
||||||
if let pattern = binding.pattern.as(IdentifierPatternSyntax.self) {
|
guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else {
|
||||||
let name = pattern.identifier.text
|
diagnostics.append("\(context) only supports an identifier binding")
|
||||||
let typeText = binding.typeAnnotation?.type.trimmedDescription ?? "Any"
|
continue
|
||||||
fields.append((name, typeText))
|
|
||||||
}
|
}
|
||||||
|
// 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> = [
|
||||||
|
"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..<characters.count {
|
||||||
|
if characters[index] == "<" {
|
||||||
|
depth += 1
|
||||||
|
} else if characters[index] == ">" {
|
||||||
|
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[..<openIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let argumentsText = String(characters[(openIndex + 1)..<closeIndex])
|
||||||
|
let arguments = splitTopLevel(argumentsText, separator: ",")
|
||||||
|
guard !base.isEmpty, !arguments.isEmpty else { return nil }
|
||||||
|
return (base, arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func splitTopLevel(_ source: String, separator: Character) -> [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..<index]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
)
|
||||||
|
start = index + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.append(String(characters[start...]).trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
func getEnv(_ key: String) -> String? {
|
func getEnv(_ key: String) -> String? {
|
||||||
ProcessInfo.processInfo.environment[key]
|
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..<close]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return wrapped.isEmpty ? nil : wrapped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
extension Data {
|
extension Data {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
// Tests for the gen-accessors TS port. Covers:
|
// Tests for the gen-accessors TS port. Covers:
|
||||||
//
|
//
|
||||||
// - Parse: 3 regex-failure-mode fixtures from the fork (codex catch)
|
// - Parse: lexical marker isolation + invalid declaration diagnostics
|
||||||
// - Cache: same input → same key; different swift version → different key;
|
// - Cache: same input → same key; different swift version → different key;
|
||||||
// different tool rev → different key; file modified → different key
|
// different tool rev/build provenance → different key
|
||||||
|
// - Schema: stable hash depends only on ordered accessor signatures
|
||||||
|
// - Optional: NSNull read/write/restore behavior and Swift type checking
|
||||||
// - Prune: >30d entries removed, recent kept
|
// - Prune: >30d entries removed, recent kept
|
||||||
|
|
||||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
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 { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync, utimesSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
@@ -13,9 +16,11 @@ import {
|
|||||||
collectSwiftFiles,
|
collectSwiftFiles,
|
||||||
parseSwift,
|
parseSwift,
|
||||||
computeCacheKey,
|
computeCacheKey,
|
||||||
|
computeAccessorHash,
|
||||||
generate,
|
generate,
|
||||||
pruneCache,
|
pruneCache,
|
||||||
render,
|
render,
|
||||||
|
AccessorGenerationError,
|
||||||
type AccessorSpec,
|
type AccessorSpec,
|
||||||
} from './gen-accessors';
|
} from './gen-accessors';
|
||||||
|
|
||||||
@@ -30,12 +35,14 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('parseSwift — fork regex-failure-mode fixtures', () => {
|
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 = `
|
const src = `
|
||||||
@Observable
|
@Observable
|
||||||
final class AppState {
|
final class AppState {
|
||||||
@Snapshotable var isLoggedIn: Bool = false
|
// @Snapshotable
|
||||||
@Snapshotable var username: String = ""
|
var isLoggedIn: Bool = false
|
||||||
|
// @Snapshotable
|
||||||
|
var username: String = ""
|
||||||
var notSnapshotable: Int = 0
|
var notSnapshotable: Int = 0
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -46,12 +53,100 @@ final class AppState {
|
|||||||
expect(specs[0]!.fields.find(f => f.name === 'isLoggedIn')!.typeText).toBe('Bool');
|
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', () => {
|
test('handles @Snapshotable on multi-line type signatures', () => {
|
||||||
const src = `
|
const src = `
|
||||||
@Observable
|
@Observable
|
||||||
class Cart {
|
class Cart {
|
||||||
@Snapshotable var items:
|
@Snapshotable var items:
|
||||||
[CartItem<Detail>]
|
[Dictionary<String, [Int]>]
|
||||||
= []
|
= []
|
||||||
var unrelated: Int = 0
|
var unrelated: Int = 0
|
||||||
}
|
}
|
||||||
@@ -60,20 +155,67 @@ class Cart {
|
|||||||
expect(specs).toHaveLength(1);
|
expect(specs).toHaveLength(1);
|
||||||
expect(specs[0]!.fields).toHaveLength(1);
|
expect(specs[0]!.fields).toHaveLength(1);
|
||||||
expect(specs[0]!.fields[0]!.name).toBe('items');
|
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 = `
|
const src = `
|
||||||
@Observable
|
@Observable
|
||||||
class Repo {
|
class Repo {
|
||||||
@Snapshotable var pages: Dictionary<String, [Result<Item, Error>]> = [:]
|
@Snapshotable var pages: Dictionary<String, [Optional<Int>]> = [:]
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const specs = parseSwift(src);
|
const specs = parseSwift(src);
|
||||||
expect(specs).toHaveLength(1);
|
expect(specs).toHaveLength(1);
|
||||||
expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
|
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<Swift.Int> = []
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
expect(specs[0]!.fields.map((field) => field.typeText)).toEqual([
|
||||||
|
'CoreGraphics.CGFloat',
|
||||||
|
'Swift.Array<Swift.Int>',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects custom values that Foundation JSON cannot serialize', () => {
|
||||||
|
expect(() => parseSwift(`
|
||||||
|
@Observable
|
||||||
|
class Repo {
|
||||||
|
// @Snapshotable
|
||||||
|
var pages: Dictionary<String, [Result<Item, Error>]> = [:]
|
||||||
|
}
|
||||||
|
`)).toThrow("unsupported snapshot type 'Result<Item,Error>'");
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
test('ignores fields without @Snapshotable marker', () => {
|
||||||
@@ -114,10 +256,7 @@ class B {
|
|||||||
expect(specs.map(s => s.className).sort()).toEqual(['A', 'B']);
|
expect(specs.map(s => s.className).sort()).toEqual(['A', 'B']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('skips fields with computed body braces', () => {
|
test('diagnoses 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.
|
|
||||||
const src = `
|
const src = `
|
||||||
@Observable
|
@Observable
|
||||||
class M {
|
class M {
|
||||||
@@ -127,9 +266,50 @@ class M {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
const specs = parseSwift(src);
|
expect(() => parseSwift(src)).toThrow("field 'computed' must be stored and writable");
|
||||||
expect(specs).toHaveLength(1);
|
});
|
||||||
expect(specs[0]!.fields.map(f => f.name)).toEqual(['snapshotted']);
|
|
||||||
|
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);
|
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', () => {
|
describe('generate', () => {
|
||||||
@@ -283,6 +525,35 @@ class AppState {
|
|||||||
expect(r1.cacheKey).toBe(r2.cacheKey);
|
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', () => {
|
test('modifying source invalidates the cache', () => {
|
||||||
const inputDir = join(workDir, 'src');
|
const inputDir = join(workDir, 'src');
|
||||||
mkdirSync(inputDir);
|
mkdirSync(inputDir);
|
||||||
@@ -295,6 +566,62 @@ class AppState {
|
|||||||
expect(r1.cacheKey).not.toBe(r2.cacheKey);
|
expect(r1.cacheKey).not.toBe(r2.cacheKey);
|
||||||
expect(r2.cacheHit).toBe(false);
|
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', () => {
|
describe('pruneCache', () => {
|
||||||
@@ -332,14 +659,395 @@ describe('render', () => {
|
|||||||
fields: [{ name: 'a', typeText: 'Int' }, { name: 'b', typeText: 'String' }],
|
fields: [{ name: 'a', typeText: 'Int' }, { name: 'b', typeText: 'String' }],
|
||||||
}];
|
}];
|
||||||
const out = render(specs, 'build-1.2.3', 'hash-abc');
|
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: "a"');
|
||||||
expect(out).toContain('key: "b"');
|
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('accessorHash: "hash-abc"');
|
||||||
|
expect(out).toContain('import DebugBridgeCore');
|
||||||
|
expect(out).not.toContain('import DebugBridge\n');
|
||||||
expect(out).toContain('#if DEBUG');
|
expect(out).toContain('#if DEBUG');
|
||||||
expect(out).toContain('#endif');
|
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<Int>' },
|
||||||
|
],
|
||||||
|
}], '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<Int>');
|
||||||
|
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', () => {
|
describe('collectSwiftFiles', () => {
|
||||||
@@ -355,4 +1063,17 @@ describe('collectSwiftFiles', () => {
|
|||||||
const files = collectSwiftFiles(workDir);
|
const files = collectSwiftFiles(workDir);
|
||||||
expect(files.sort()).toEqual([a, b].sort());
|
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]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+659
-89
@@ -5,15 +5,16 @@
|
|||||||
// first time. Also exercised by tests so we can verify the cache + parse
|
// first time. Also exercised by tests so we can verify the cache + parse
|
||||||
// behavior without a Swift toolchain.
|
// 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
|
// - @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)
|
// - 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):
|
// Invalid marked declarations (computed/immutable/inaccessible/untyped,
|
||||||
// - Computed properties with bodies (regex can mis-parse braces)
|
// nested, duplicate-keyed, or non-JSON) fail generation instead of producing
|
||||||
// - Property wrappers other than @Snapshotable
|
// Swift that only fails later in xcodebuild or at snapshot time.
|
||||||
//
|
//
|
||||||
// Composite cache key (codex-flagged): swift_version || tool_git_rev ||
|
// Composite cache key (codex-flagged): swift_version || tool_git_rev ||
|
||||||
// platform_triple || source_content_hash. Source-only hash misses generator
|
// platform_triple || source_content_hash. Source-only hash misses generator
|
||||||
@@ -35,6 +36,16 @@ export interface AccessorSpec {
|
|||||||
fields: AccessorField[];
|
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 {
|
export interface GenInputs {
|
||||||
inputDir: string;
|
inputDir: string;
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
@@ -48,56 +59,105 @@ export interface GenInputs {
|
|||||||
export interface GenResult {
|
export interface GenResult {
|
||||||
outputPath: string;
|
outputPath: string;
|
||||||
cacheKey: string;
|
cacheKey: string;
|
||||||
|
accessorHash: string;
|
||||||
specs: AccessorSpec[];
|
specs: AccessorSpec[];
|
||||||
cacheHit: boolean;
|
cacheHit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FALLBACK_PLATFORM = process.platform === 'darwin' ? 'darwin-arm64' : `${process.platform}-${process.arch}`;
|
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 out: string[] = [];
|
||||||
const excludeGenerated = opts.excludeGenerated ?? true;
|
const excludeGenerated = opts.excludeGenerated ?? true;
|
||||||
for (const name of readdirSync(dir)) {
|
const root = resolve(dir);
|
||||||
const full = join(dir, name);
|
const outputDir = opts.outputDir ? resolve(opts.outputDir) : undefined;
|
||||||
const s = statSync(full);
|
|
||||||
if (s.isDirectory()) {
|
function walk(currentDir: string): void {
|
||||||
// Skip generated output dir (when it lives under the input dir)
|
for (const name of readdirSync(currentDir)) {
|
||||||
if (excludeGenerated && name === 'DebugBridgeGenerated') continue;
|
const full = join(currentDir, name);
|
||||||
out.push(...collectSwiftFiles(full, opts));
|
const s = statSync(full);
|
||||||
} else if (name.endsWith('.swift')) {
|
if (s.isDirectory()) {
|
||||||
// Skip the codegen output file. Otherwise the second run picks it up,
|
// The generated subtree must never participate in its own cache key.
|
||||||
// changes the cache key, and the cache never hits.
|
// Keep the well-known-name guard for direct collectSwiftFiles callers,
|
||||||
if (excludeGenerated && name === 'StateAccessor.swift') continue;
|
// and use the actual --output path when generate() supplies one.
|
||||||
out.push(full);
|
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();
|
return out.sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSwift(source: string): AccessorSpec[] {
|
export function parseSwift(source: string): AccessorSpec[] {
|
||||||
const specs: AccessorSpec[] = [];
|
const specs: AccessorSpec[] = [];
|
||||||
|
const diagnostics: string[] = [];
|
||||||
|
const masked = maskSwiftSource(source);
|
||||||
// Find `@Observable\n(public )?(final )?class <Name>` followed by a brace
|
// Find `@Observable\n(public )?(final )?class <Name>` followed by a brace
|
||||||
// block. We then scan inside that block for @Snapshotable fields.
|
// 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;
|
const classPattern = /@Observable\s*(?:(?:public|internal|fileprivate|private)\s+)?(?:final\s+)?class\s+(\w+)[^{]*\{/g;
|
||||||
|
|
||||||
let match: RegExpExecArray | null;
|
for (const match of masked.matchAll(classPattern)) {
|
||||||
while ((match = classPattern.exec(source)) !== null) {
|
|
||||||
const className = match[1]!;
|
const className = match[1]!;
|
||||||
const startIdx = classPattern.lastIndex;
|
const matchOffset = match.index!;
|
||||||
const endIdx = findMatchingBrace(source, startIdx - 1);
|
const openBraceOffset = matchOffset + match[0].lastIndexOf('{');
|
||||||
|
const startIdx = openBraceOffset + 1;
|
||||||
|
const endIdx = findMatchingBrace(masked, startIdx - 1);
|
||||||
if (endIdx === -1) continue;
|
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) {
|
if (fields.length > 0) {
|
||||||
specs.push({ className, fields });
|
specs.push({ className, fields });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (diagnostics.length > 0) throw new AccessorGenerationError(diagnostics);
|
||||||
return specs;
|
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 {
|
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;
|
let depth = 0;
|
||||||
for (let i = openIdx; i < s.length; i++) {
|
for (let i = openIdx; i < s.length; i++) {
|
||||||
const c = s[i];
|
const c = s[i];
|
||||||
@@ -105,48 +165,305 @@ function findMatchingBrace(s: string, openIdx: number): number {
|
|||||||
else if (c === '}') {
|
else if (c === '}') {
|
||||||
depth--;
|
depth--;
|
||||||
if (depth === 0) return i;
|
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;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseFields(body: string): AccessorField[] {
|
/**
|
||||||
// Look for @Snapshotable followed by var/let declarations. Allow attribute
|
* Blank comments and string literals while preserving byte offsets/newlines.
|
||||||
// ordering: `@Snapshotable var name: Type` OR `@Snapshotable\n var name: Type`.
|
* A canonical standalone `// @Snapshotable` comment is rewritten to the
|
||||||
// Multi-line types are handled by greedy non-newline match in the type, but
|
* equivalent attribute token. This is intentionally lexical rather than a
|
||||||
// we collapse adjacent whitespace first to avoid false negatives.
|
* whole-file regexp: markers inside nested block comments, normal/triple/raw
|
||||||
const normalized = body.replace(/[\t ]*\r?\n[\t ]*/g, ' ');
|
* strings, trailing comments, and prose comments must never opt a field in.
|
||||||
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[] = [];
|
function maskSwiftSource(source: string): string {
|
||||||
let m: RegExpExecArray | null;
|
const out = source.split('');
|
||||||
while ((m = fieldPattern.exec(normalized)) !== null) {
|
|
||||||
// Codex catch: skip fields that have a computed body (`{ get ... }` or
|
const blank = (start: number, end: number): void => {
|
||||||
// `{ didSet ... }` after the type). The match boundary stops before `{`,
|
for (let j = start; j < end; j++) {
|
||||||
// so we peek at what comes after the type in the original body.
|
if (out[j] !== '\n' && out[j] !== '\r') out[j] = ' ';
|
||||||
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
|
let i = 0;
|
||||||
// (initialized with `=` or just declared).
|
while (i < source.length) {
|
||||||
if (afterMatch.startsWith('{')) continue;
|
if (source[i] === '/' && source[i + 1] === '/') {
|
||||||
fields.push({ name: m[1]!, typeText: m[2]!.trim() });
|
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 ?? '<unknown>';
|
||||||
|
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: {
|
export function computeCacheKey(inputs: {
|
||||||
@@ -154,35 +471,263 @@ export function computeCacheKey(inputs: {
|
|||||||
swiftVersion: string;
|
swiftVersion: string;
|
||||||
toolGitRev: string;
|
toolGitRev: string;
|
||||||
platformTriple: string;
|
platformTriple: string;
|
||||||
|
buildId?: string;
|
||||||
}): string {
|
}): string {
|
||||||
const h = createHash('sha256');
|
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) {
|
for (const f of inputs.swiftFiles) {
|
||||||
const content = readFileSync(f);
|
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(content);
|
||||||
h.update('|');
|
h.update('|');
|
||||||
}
|
}
|
||||||
return h.digest('hex');
|
return h.digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function render(specs: AccessorSpec[], buildId: string, accessorHash: string): string {
|
/** Stable snapshot-schema fingerprint, deliberately independent of cache ABI,
|
||||||
let out = '// AUTO-GENERATED — DO NOT EDIT. Regenerate with /ios-sync.\n';
|
* source paths, app build provenance, and unmarked source. Field/class order is
|
||||||
out += '#if DEBUG\nimport Foundation\nimport DebugBridge\n\n';
|
* 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) {
|
for (const spec of specs) {
|
||||||
out += `@MainActor\npublic enum ${spec.className}Accessor {\n`;
|
signature += `C${Buffer.byteLength(spec.className)}:${spec.className}\n`;
|
||||||
out += ` public static func register(_ state: ${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*</.exec(type);
|
||||||
|
if (!optional) return undefined;
|
||||||
|
const open = type.indexOf('<', optional.index);
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = open; i < type.length; i++) {
|
||||||
|
if (type[i] === '<') depth++;
|
||||||
|
else if (type[i] === '>') {
|
||||||
|
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<string, string>();
|
||||||
|
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<T: Decodable>(_ 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 += ` StateServer.shared.register(\n`;
|
||||||
out += ` buildId: "${buildId}",\n`;
|
out += ` buildId: {\n`;
|
||||||
out += ` accessorHash: "${accessorHash}",\n`;
|
out += ` let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String\n`;
|
||||||
out += ` atomicRestore: { _ in .ok }\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`;
|
out += ` )\n`;
|
||||||
for (const field of spec.fields) {
|
for (const field of spec.fields) {
|
||||||
|
const wrapped = optionalWrappedType(field.typeText);
|
||||||
out += ` StateServer.shared.registerAccessor(\n`;
|
out += ` StateServer.shared.registerAccessor(\n`;
|
||||||
out += ` key: "${field.name}",\n`;
|
out += ` key: "${field.name}",\n`;
|
||||||
out += ` type: "${field.typeText}",\n`;
|
out += ` type: "${field.typeText}",\n`;
|
||||||
out += ` read: { state.${field.name} as Any? },\n`;
|
if (wrapped !== undefined) {
|
||||||
out += ` write: { _ in false }\n`;
|
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`;
|
||||||
}
|
}
|
||||||
out += ` }\n}\n\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 {
|
export function defaultCacheRoot(): string {
|
||||||
return process.env.GSTACK_IOS_CACHE_ROOT ?? join(homedir(), '.gstack', 'cache', 'gen-accessors');
|
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 inputDir = resolve(inputs.inputDir);
|
||||||
const outputDir = resolve(inputs.outputDir ?? inputDir);
|
const outputDir = resolve(inputs.outputDir ?? inputDir);
|
||||||
const cacheRoot = inputs.cacheRoot ?? defaultCacheRoot();
|
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({
|
const cacheKey = computeCacheKey({
|
||||||
swiftFiles,
|
swiftFiles,
|
||||||
swiftVersion: inputs.swiftVersion ?? detectSwiftVersion(),
|
swiftVersion: inputs.swiftVersion ?? detectSwiftVersion(),
|
||||||
toolGitRev: inputs.toolGitRev ?? detectToolGitRev(),
|
toolGitRev: inputs.toolGitRev ?? detectToolGitRev(),
|
||||||
platformTriple: inputs.platformTriple ?? FALLBACK_PLATFORM,
|
platformTriple: inputs.platformTriple ?? FALLBACK_PLATFORM,
|
||||||
|
buildId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cachedOutput = join(cacheRoot, cacheKey, 'StateAccessor.swift');
|
const cachedOutput = join(cacheRoot, cacheKey, 'StateAccessor.swift');
|
||||||
@@ -242,18 +808,13 @@ export function generate(inputs: GenInputs): GenResult {
|
|||||||
return {
|
return {
|
||||||
outputPath: finalOutput,
|
outputPath: finalOutput,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
specs: [], // intentionally empty on cache hit (no need to re-parse)
|
accessorHash,
|
||||||
|
specs: allSpecs,
|
||||||
cacheHit: true,
|
cacheHit: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse + render fresh
|
const rendered = render(allSpecs, buildId, accessorHash);
|
||||||
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);
|
|
||||||
writeFileSync(finalOutput, rendered);
|
writeFileSync(finalOutput, rendered);
|
||||||
|
|
||||||
// Populate cache (best-effort — cache failures don't break codegen).
|
// Populate cache (best-effort — cache failures don't break codegen).
|
||||||
@@ -267,6 +828,7 @@ export function generate(inputs: GenInputs): GenResult {
|
|||||||
return {
|
return {
|
||||||
outputPath: finalOutput,
|
outputPath: finalOutput,
|
||||||
cacheKey,
|
cacheKey,
|
||||||
|
accessorHash,
|
||||||
specs: allSpecs,
|
specs: allSpecs,
|
||||||
cacheHit: false,
|
cacheHit: false,
|
||||||
};
|
};
|
||||||
@@ -300,10 +862,18 @@ if (import.meta.main) {
|
|||||||
const inputDir = args[inputIdx + 1]!;
|
const inputDir = args[inputIdx + 1]!;
|
||||||
const outputIdx = args.indexOf('--output');
|
const outputIdx = args.indexOf('--output');
|
||||||
const outputDir = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
|
const outputDir = outputIdx !== -1 ? args[outputIdx + 1] : undefined;
|
||||||
const result = generate({ inputDir, outputDir });
|
try {
|
||||||
process.stdout.write(
|
const result = generate({ inputDir, outputDir });
|
||||||
result.cacheHit
|
process.stdout.write(
|
||||||
? `gen-accessors: cache hit (${result.cacheKey.slice(0, 12)})\n`
|
result.cacheHit
|
||||||
: `gen-accessors: wrote ${result.specs.length} accessor(s) to ${result.outputPath}\n`,
|
? `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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,16 @@ import Foundation
|
|||||||
public final class DebugBridgeManager {
|
public final class DebugBridgeManager {
|
||||||
public static let shared = DebugBridgeManager()
|
public static let shared = DebugBridgeManager()
|
||||||
|
|
||||||
public func start(appState: AppState) {
|
/// Register app-owned generated accessors, then start the server. The
|
||||||
// 1. Register the canonical AppState struct + accessor wiring.
|
/// registration closure is passed in from the consuming app because the
|
||||||
// AppStateAccessor.register(_:) is generated by gen-accessors-tool.
|
/// DebugBridgeCore package cannot import app-target types. On UIKit apps,
|
||||||
AppStateAccessor.register(appState)
|
/// call DebugBridgeUIWiring.installAll() before this method so a warm
|
||||||
|
/// daemon cannot reach uninitialized resolvers during listener startup.
|
||||||
|
public func start<State>(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()
|
StateServer.shared.start()
|
||||||
|
|
||||||
// 3. The consuming app installs DebugOverlayWindow separately. See
|
// 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
|
#endif // DEBUG
|
||||||
|
|||||||
@@ -6,22 +6,35 @@
|
|||||||
// the DebugBridge target.
|
// the DebugBridge target.
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
import DebugBridge
|
import Foundation
|
||||||
|
import DebugBridgeCore
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import DebugBridgeUI
|
||||||
|
#endif
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func startGstackDebugBridge(appState: AppState) {
|
func startGstackDebugBridge<State>(
|
||||||
|
appState: State,
|
||||||
|
register: (State) -> Void
|
||||||
|
) {
|
||||||
// Read --recording flag from launch arguments
|
// Read --recording flag from launch arguments
|
||||||
let recording = ProcessInfo.processInfo.arguments.contains("--gstack-recording")
|
let recording = ProcessInfo.processInfo.arguments.contains("--gstack-recording")
|
||||||
|
|
||||||
// Install accessibility + screenshot + mutation bridges before starting
|
// Install the UI resolvers before opening the listener. A warm daemon can
|
||||||
// the server so the first authenticated request can use them.
|
// issue its first request as soon as StateServer starts; it must never see
|
||||||
ElementsBridge.resolver = { AccessibilityScanner.snapshot() }
|
// the default empty screenshot/elements/mutation handlers.
|
||||||
ScreenshotBridge.resolver = { SnapshotCapture.capturePNG() }
|
#if canImport(UIKit)
|
||||||
MutationBridge.resolver = { op, payload in
|
DebugBridgeUIWiring.installAll()
|
||||||
MutationDispatcher.shared.run(op: op, payload: payload)
|
#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
|
#endif
|
||||||
|
|
||||||
@@ -33,7 +46,10 @@ func startGstackDebugBridge(appState: AppState) {
|
|||||||
//
|
//
|
||||||
// init() {
|
// init() {
|
||||||
// #if DEBUG
|
// #if DEBUG
|
||||||
// startGstackDebugBridge(appState: appState)
|
// startGstackDebugBridge(
|
||||||
|
// appState: appState,
|
||||||
|
// register: MyAppStateAccessor.register
|
||||||
|
// )
|
||||||
// #endif
|
// #endif
|
||||||
// }
|
// }
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
//
|
//
|
||||||
// This file is a TEMPLATE that gen-accessors-tool fills in. The placeholders
|
// 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
|
// 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
|
// {{CLASS_NAME}} — the canonical AppState struct name
|
||||||
// {{APP_BUILD_ID}} — bundle short version + git SHA at codegen time
|
// {{APP_BUILD_ID}} — bundle short version + git SHA at codegen time
|
||||||
// {{ACCESSOR_HASH}} — sha256 of accessor signatures (snapshot schema fingerprint)
|
// {{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
|
#if DEBUG
|
||||||
|
|
||||||
@@ -21,13 +21,16 @@ public enum {{CLASS_NAME}}Accessor {
|
|||||||
StateServer.shared.register(
|
StateServer.shared.register(
|
||||||
buildId: "{{APP_BUILD_ID}}",
|
buildId: "{{APP_BUILD_ID}}",
|
||||||
accessorHash: "{{ACCESSOR_HASH}}",
|
accessorHash: "{{ACCESSOR_HASH}}",
|
||||||
atomicRestore: { keys in
|
atomicRestore: { keys, apply in
|
||||||
// Validate every key + type FIRST, then apply in one struct
|
// Validate every key + type before mutating any state. Invalid
|
||||||
// assignment so SwiftUI observers see exactly one change.
|
// input therefore cannot leave a partially restored model.
|
||||||
var snapshot = state.snapshotable
|
var snapshot = state.snapshotable
|
||||||
{{VALIDATION_BLOCK}}
|
{{VALIDATION_BLOCK}}
|
||||||
// Apply atomically.
|
// StateServer validates every model first, then invokes the
|
||||||
state.snapshotable = snapshot
|
// same handlers with apply=true for a cross-model commit.
|
||||||
|
if apply {
|
||||||
|
state.snapshotable = snapshot
|
||||||
|
}
|
||||||
return .ok
|
return .ok
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -59,18 +59,20 @@ public final class StateServer {
|
|||||||
private var writeHandlers: [String: WriteHandler] = [:]
|
private var writeHandlers: [String: WriteHandler] = [:]
|
||||||
private var typeNames: [String: TypeName] = [:]
|
private var typeNames: [String: TypeName] = [:]
|
||||||
|
|
||||||
// Atomic-restore hook. Codegen wires this to the canonical AppState struct.
|
// Validated-restore hooks. Every generated model registers one two-phase
|
||||||
// Restore replaces the entire struct in one assignment so SwiftUI's Combine
|
// handler. The server validates all models before it lets any model mutate,
|
||||||
// pipeline observes exactly one change notification — true observable
|
// so invalid input can never cause a cross-model partial restore.
|
||||||
// atomicity. @MainActor alone doesn't guarantee that.
|
// Valid restores apply properties on MainActor; observers may receive one
|
||||||
public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult
|
// 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 {
|
public enum RestoreResult {
|
||||||
case ok
|
case ok
|
||||||
case missingKey(String)
|
case missingKey(String)
|
||||||
case typeMismatch(String)
|
case typeMismatch(String)
|
||||||
case schemaMismatch(expected: String, got: 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
|
// Snapshot schema hash — written by codegen, stable across builds with
|
||||||
// identical accessor signatures.
|
// identical accessor signatures.
|
||||||
@@ -109,7 +111,7 @@ public final class StateServer {
|
|||||||
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
|
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
|
||||||
self.appBuildId = buildId
|
self.appBuildId = buildId
|
||||||
self.accessorHash = accessorHash
|
self.accessorHash = accessorHash
|
||||||
self.atomicRestore = atomicRestore
|
self.atomicRestores.append(atomicRestore)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
|
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",
|
"version": "1.0.0",
|
||||||
"build": appBuildId,
|
"build": appBuildId,
|
||||||
"accessor_hash": accessorHash,
|
"accessor_hash": accessorHash,
|
||||||
|
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -476,22 +479,36 @@ public final class StateServer {
|
|||||||
send(connection: connection, status: 400, body: ["error": "missing_keys"])
|
send(connection: connection, status: 400, body: ["error": "missing_keys"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let restore = atomicRestore else {
|
guard !atomicRestores.isEmpty else {
|
||||||
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
|
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Validate-then-apply via the codegen-supplied closure. The closure does
|
// Phase one validates every registered model without assignment.
|
||||||
// a single struct-assignment so SwiftUI sees one change notification.
|
for restore in atomicRestores {
|
||||||
switch restore(keys) {
|
switch restore(keys, false) {
|
||||||
case .ok:
|
case .ok:
|
||||||
send(connection: connection, status: 200, body: ["ok": true])
|
continue
|
||||||
case .missingKey(let k):
|
case .missingKey(let k):
|
||||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
|
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
|
||||||
case .typeMismatch(let k):
|
return
|
||||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
|
case .typeMismatch(let k):
|
||||||
case .schemaMismatch(let expected, let got):
|
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
|
||||||
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
|
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)
|
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
|
||||||
@@ -522,9 +539,19 @@ public final class StateServer {
|
|||||||
// MARK: Response
|
// MARK: Response
|
||||||
|
|
||||||
private func send(connection: NWConnection, status: Int, body: JSONDict) {
|
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
|
let statusText: String
|
||||||
switch status {
|
switch responseStatus {
|
||||||
case 200: statusText = "OK"
|
case 200: statusText = "OK"
|
||||||
case 400: statusText = "Bad Request"
|
case 400: statusText = "Bad Request"
|
||||||
case 401: statusText = "Unauthorized"
|
case 401: statusText = "Unauthorized"
|
||||||
@@ -537,7 +564,7 @@ public final class StateServer {
|
|||||||
case 503: statusText = "Service Unavailable"
|
case 503: statusText = "Service Unavailable"
|
||||||
default: statusText = "Status"
|
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)
|
var packet = Data(header.utf8)
|
||||||
packet.append(json)
|
packet.append(json)
|
||||||
connection.send(content: packet, completion: .contentProcessed { _ in
|
connection.send(content: packet, completion: .contentProcessed { _ in
|
||||||
|
|||||||
+9
-20
@@ -14,12 +14,16 @@ import Foundation
|
|||||||
public final class DebugBridgeManager {
|
public final class DebugBridgeManager {
|
||||||
public static let shared = DebugBridgeManager()
|
public static let shared = DebugBridgeManager()
|
||||||
|
|
||||||
public func start(appState: AppState) {
|
/// Register app-owned generated accessors, then start the server. The
|
||||||
// 1. Register the canonical AppState struct + accessor wiring.
|
/// registration closure is passed in from the consuming app because the
|
||||||
// AppStateAccessor.register(_:) is generated by gen-accessors-tool.
|
/// DebugBridgeCore package cannot import app-target types. On UIKit apps,
|
||||||
AppStateAccessor.register(appState)
|
/// call DebugBridgeUIWiring.installAll() before this method so a warm
|
||||||
|
/// daemon cannot reach uninitialized resolvers during listener startup.
|
||||||
|
public func start<State>(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()
|
StateServer.shared.start()
|
||||||
|
|
||||||
// 3. The consuming app installs DebugOverlayWindow separately. See
|
// 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
|
#endif // DEBUG
|
||||||
|
|||||||
+49
-22
@@ -59,18 +59,20 @@ public final class StateServer {
|
|||||||
private var writeHandlers: [String: WriteHandler] = [:]
|
private var writeHandlers: [String: WriteHandler] = [:]
|
||||||
private var typeNames: [String: TypeName] = [:]
|
private var typeNames: [String: TypeName] = [:]
|
||||||
|
|
||||||
// Atomic-restore hook. Codegen wires this to the canonical AppState struct.
|
// Validated-restore hooks. Every generated model registers one two-phase
|
||||||
// Restore replaces the entire struct in one assignment so SwiftUI's Combine
|
// handler. The server validates all models before it lets any model mutate,
|
||||||
// pipeline observes exactly one change notification — true observable
|
// so invalid input can never cause a cross-model partial restore.
|
||||||
// atomicity. @MainActor alone doesn't guarantee that.
|
// Valid restores apply properties on MainActor; observers may receive one
|
||||||
public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult
|
// 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 {
|
public enum RestoreResult {
|
||||||
case ok
|
case ok
|
||||||
case missingKey(String)
|
case missingKey(String)
|
||||||
case typeMismatch(String)
|
case typeMismatch(String)
|
||||||
case schemaMismatch(expected: String, got: 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
|
// Snapshot schema hash — written by codegen, stable across builds with
|
||||||
// identical accessor signatures.
|
// identical accessor signatures.
|
||||||
@@ -109,7 +111,7 @@ public final class StateServer {
|
|||||||
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
|
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
|
||||||
self.appBuildId = buildId
|
self.appBuildId = buildId
|
||||||
self.accessorHash = accessorHash
|
self.accessorHash = accessorHash
|
||||||
self.atomicRestore = atomicRestore
|
self.atomicRestores.append(atomicRestore)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
|
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",
|
"version": "1.0.0",
|
||||||
"build": appBuildId,
|
"build": appBuildId,
|
||||||
"accessor_hash": accessorHash,
|
"accessor_hash": accessorHash,
|
||||||
|
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -476,22 +479,36 @@ public final class StateServer {
|
|||||||
send(connection: connection, status: 400, body: ["error": "missing_keys"])
|
send(connection: connection, status: 400, body: ["error": "missing_keys"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let restore = atomicRestore else {
|
guard !atomicRestores.isEmpty else {
|
||||||
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
|
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Validate-then-apply via the codegen-supplied closure. The closure does
|
// Phase one validates every registered model without assignment.
|
||||||
// a single struct-assignment so SwiftUI sees one change notification.
|
for restore in atomicRestores {
|
||||||
switch restore(keys) {
|
switch restore(keys, false) {
|
||||||
case .ok:
|
case .ok:
|
||||||
send(connection: connection, status: 200, body: ["ok": true])
|
continue
|
||||||
case .missingKey(let k):
|
case .missingKey(let k):
|
||||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
|
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
|
||||||
case .typeMismatch(let k):
|
return
|
||||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
|
case .typeMismatch(let k):
|
||||||
case .schemaMismatch(let expected, let got):
|
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
|
||||||
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
|
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)
|
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
|
||||||
@@ -522,9 +539,19 @@ public final class StateServer {
|
|||||||
// MARK: Response
|
// MARK: Response
|
||||||
|
|
||||||
private func send(connection: NWConnection, status: Int, body: JSONDict) {
|
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
|
let statusText: String
|
||||||
switch status {
|
switch responseStatus {
|
||||||
case 200: statusText = "OK"
|
case 200: statusText = "OK"
|
||||||
case 400: statusText = "Bad Request"
|
case 400: statusText = "Bad Request"
|
||||||
case 401: statusText = "Unauthorized"
|
case 401: statusText = "Unauthorized"
|
||||||
@@ -537,7 +564,7 @@ public final class StateServer {
|
|||||||
case 503: statusText = "Service Unavailable"
|
case 503: statusText = "Service Unavailable"
|
||||||
default: statusText = "Status"
|
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)
|
var packet = Data(header.utf8)
|
||||||
packet.append(json)
|
packet.append(json)
|
||||||
connection.send(content: packet, completion: .contentProcessed { _ in
|
connection.send(content: packet, completion: .contentProcessed { _ in
|
||||||
|
|||||||
@@ -1,32 +1,22 @@
|
|||||||
// Canonical app state for the fixture. Every snapshot-eligible field is
|
// Canonical observable app state for the fixture. Snapshot eligibility is a
|
||||||
// marked with the @Snapshotable property wrapper that the codegen tool
|
// generator-only source marker, not a property wrapper, so it composes with
|
||||||
// detects via attribute scan.
|
// Observation's @Observable macro.
|
||||||
//
|
|
||||||
// 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).
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
public final class FixtureAppState {
|
@Observable
|
||||||
@Snapshotable public var isLoggedIn: Bool = false
|
final class FixtureAppState {
|
||||||
@Snapshotable public var username: String = ""
|
// @Snapshotable
|
||||||
@Snapshotable public var tapCounter: Int = 0
|
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.
|
/// Not snapshotted — ephemeral cache that should never leak via /state/snapshot.
|
||||||
public var ephemeralCache: [String: String] = [:]
|
var ephemeralCache: [String: String] = [:]
|
||||||
|
|
||||||
public init() {}
|
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<Value> {
|
|
||||||
public var wrappedValue: Value
|
|
||||||
public init(wrappedValue: Value) { self.wrappedValue = wrappedValue }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user