From 148f3adb9e659bbe48e934fe1027cd2d9e0e0f87 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 9 Mar 2026 23:26:42 -0700 Subject: [PATCH 1/4] Fix generated code ordering bug with transitive Instantiator captures When a root @Instantiable has both an @Instantiated service and an @Instantiated Instantiator where the child transitively depends on the service via @Received(onlyIfAvailable: true), the generated code placed the Instantiator closure before the service variable declaration, causing a Swift compiler error: closure captures 'service' before it is declared. The topological sort in orderedPropertiesToGenerate now intersects unfulfilled scope keys with both received properties and their unwrapped variants, correctly detecting transitive optional dependencies. The now-redundant onlyIfAvailableUnwrappedReceivedProperties computed property has been removed. Bumps podspec to 1.5.2. Co-Authored-By: Claude Opus 4.6 --- SafeDI.podspec | 2 +- .../Generators/ScopeGenerator.swift | 32 ++------- .../SafeDIToolCodeGenerationTests.swift | 71 +++++++++++++++++++ 3 files changed, 76 insertions(+), 29 deletions(-) diff --git a/SafeDI.podspec b/SafeDI.podspec index 16e59b56..56705f57 100644 --- a/SafeDI.podspec +++ b/SafeDI.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'SafeDI' - s.version = '1.5.1' + s.version = '1.5.2' s.summary = 'Compile-time-safe dependency injection' s.homepage = 'https://github.com/dfed/SafeDI' s.license = 'MIT' diff --git a/Sources/SafeDICore/Generators/ScopeGenerator.swift b/Sources/SafeDICore/Generators/ScopeGenerator.swift index 74336ed0..8a6e4002 100644 --- a/Sources/SafeDICore/Generators/ScopeGenerator.swift +++ b/Sources/SafeDICore/Generators/ScopeGenerator.swift @@ -378,32 +378,6 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { private let propertiesToDeclare: Set private let property: Property? - nonisolated private var onlyIfAvailableUnwrappedReceivedProperties: Set { - switch scopeData { - case let .property(instantiable, _, _, _, _): - .init(instantiable.dependencies.compactMap { - switch $0.source { - case .instantiated, .forwarded: - nil - case let .received(onlyIfAvailable): - if onlyIfAvailable { - $0.property.asUnwrappedProperty - } else { - nil - } - case let .aliased(fulfillingProperty, _, onlyIfAvailable): - if onlyIfAvailable { - fulfillingProperty.asUnwrappedProperty - } else { - nil - } - } - }) - case .root, .alias: - [] - } - } - private var unavailablePropertiesToGenerateCodeTask = [Set: Task]() private var orderedPropertiesToGenerate: [ScopeGenerator] { @@ -422,8 +396,10 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { } let scopeDependencies = propertyToUnfulfilledScopeMap .keys - .intersection(scope.receivedProperties) - .union(scope.onlyIfAvailableUnwrappedReceivedProperties) + .intersection( + scope.receivedProperties + .union(Set(scope.receivedProperties.map(\.asUnwrappedProperty))) + ) .compactMap { propertyToUnfulfilledScopeMap[$0] } // Fulfill the scopes we depend upon. for dependentScope in scopeDependencies { diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index 51024cdb..0c75a6ff 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -5933,6 +5933,77 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { ) } + @Test + mutating func run_writesConvenienceExtensionOnRootOfTree_whenInstantiatorClosureTransitivelyCapturesVariableDeclaredLaterAlphabetically() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public final class Root { + public init(childVCBuilder: Instantiator, service: Service) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + @Instantiated let childVCBuilder: Instantiator + @Instantiated let service: Service + } + """, + """ + @Instantiable + public final class ChildVC { + public init(grandchildVCBuilder: Instantiator) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + @Instantiated let grandchildVCBuilder: Instantiator + } + """, + """ + @Instantiable + public final class GrandchildVC { + public init(service: Service?) { + fatalError("SafeDI doesn't inspect the initializer body") + } + + @Received(onlyIfAvailable: true) let service: Service? + } + """, + """ + @Instantiable + public final class Service { + public init() { + fatalError("SafeDI doesn't inspect the initializer body") + } + } + """, + ], + buildDependencyTreeOutput: true, + filesToDelete: &filesToDelete + ) + + #expect(try #require(output.dependencyTree) == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + extension Root { + public convenience init() { + let service = Service() + func __safeDI_childVCBuilder() -> ChildVC { + func __safeDI_grandchildVCBuilder() -> GrandchildVC { + GrandchildVC(service: service) + } + let grandchildVCBuilder = Instantiator(__safeDI_grandchildVCBuilder) + return ChildVC(grandchildVCBuilder: grandchildVCBuilder) + } + let childVCBuilder = Instantiator(__safeDI_childVCBuilder) + self.init(childVCBuilder: childVCBuilder, service: service) + } + } + """ + ) + } + // MARK: Private private var filesToDelete = [URL]() From ecec1b866a5f355b28132333b2fc5a3bf27462cf Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 9 Mar 2026 23:31:10 -0700 Subject: [PATCH 2/4] Bump version to 1.5.2 in SafeDITool and Plugins/Shared Co-Authored-By: Claude Opus 4.6 --- Plugins/Shared.swift | 2 +- Sources/SafeDITool/SafeDITool.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index a224dac9..8aad676e 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -29,7 +29,7 @@ import PackagePlugin // As of Xcode 15.0, Xcode command plugins have no way to read the package manifest, therefore we must hardcode the version number. // It is okay for this number to be behind the most current release if the inputs and outputs to SafeDITool have not changed. // Unlike SPM plugins, Xcode plugins can not determine the current version number, so we must hardcode it. - "1.5.1" + "1.5.2" } var safeDIOrigin: URL { diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index 2be304aa..d3138dbb 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -50,7 +50,7 @@ struct SafeDITool: AsyncParsableCommand, Sendable { // MARK: Internal static var currentVersion: String { - "1.5.1" + "1.5.2" } func run() async throws { From a60762877ca2de0ef3addb5f03a2ec5b819b0843 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 9 Mar 2026 23:40:01 -0700 Subject: [PATCH 3/4] Fix infinite recursion in orderedPropertiesToGenerate Mark the scope as fulfilled in the map before recursing into dependencies, preventing cycles when two sibling scopes each have optional received properties that unwrap to each other's property type. Co-Authored-By: Claude Opus 4.6 --- Sources/SafeDICore/Generators/ScopeGenerator.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SafeDICore/Generators/ScopeGenerator.swift b/Sources/SafeDICore/Generators/ScopeGenerator.swift index 8a6e4002..000b1623 100644 --- a/Sources/SafeDICore/Generators/ScopeGenerator.swift +++ b/Sources/SafeDICore/Generators/ScopeGenerator.swift @@ -394,6 +394,8 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { else { return } + // Mark as fulfilled before recursing to prevent cycles. + propertyToUnfulfilledScopeMap[property] = nil let scopeDependencies = propertyToUnfulfilledScopeMap .keys .intersection( @@ -406,9 +408,7 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { fulfill(dependentScope) } - // We can now be marked as fulfilled! orderedPropertiesToGenerate.append(scope) - propertyToUnfulfilledScopeMap[property] = nil } for scope in propertiesToGenerate { From efd2dc8cdacf7dfc386f8b02bf3d87396d0f3493 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 9 Mar 2026 23:56:18 -0700 Subject: [PATCH 4/4] Track onlyIfAvailable unwrapped properties transitively Instead of unwrapping all optional received properties (which could create false dependencies for regular @Received optional properties), propagate onlyIfAvailable unwrapped properties transitively through the scope tree, mirroring how receivedProperties propagates. Co-Authored-By: Claude Opus 4.6 --- .../Generators/ScopeGenerator.swift | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/Sources/SafeDICore/Generators/ScopeGenerator.swift b/Sources/SafeDICore/Generators/ScopeGenerator.swift index 000b1623..0843d363 100644 --- a/Sources/SafeDICore/Generators/ScopeGenerator.swift +++ b/Sources/SafeDICore/Generators/ScopeGenerator.swift @@ -79,6 +79,35 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { } } ) + onlyIfAvailableUnwrappedReceivedProperties = Set( + propertiesToGenerate.flatMap { [propertiesToDeclare, scopeData] propertyToGenerate in + propertyToGenerate.onlyIfAvailableUnwrappedReceivedProperties + .subtracting(propertiesToDeclare) + .subtracting(scopeData.forwardedProperties) + } + ) + .union( + instantiable + .dependencies + .compactMap { + switch $0.source { + case .instantiated, .forwarded: + nil + case let .received(onlyIfAvailable): + if onlyIfAvailable { + $0.property.asUnwrappedProperty + } else { + nil + } + case let .aliased(fulfillingProperty, _, onlyIfAvailable): + if onlyIfAvailable { + fulfillingProperty.asUnwrappedProperty + } else { + nil + } + } + } + ) } init( @@ -95,6 +124,11 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { onlyIfAvailable: onlyIfAvailable ) receivedProperties = [fulfillingProperty] + onlyIfAvailableUnwrappedReceivedProperties = if onlyIfAvailable { + [fulfillingProperty.asUnwrappedProperty] + } else { + [] + } description = property.asSource propertiesToGenerate = [] propertiesToDeclare = [] @@ -370,6 +404,8 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { private let scopeData: ScopeData /// Properties that we require in order to satisfy our (and our children’s) dependencies. private let receivedProperties: Set + /// Unwrapped versions of received properties from transitive `@Received(onlyIfAvailable: true)` dependencies. + private let onlyIfAvailableUnwrappedReceivedProperties: Set /// Received properties that are optional and not created by a parent. private let unavailableOptionalProperties: Set /// Properties that will be generated as `let` constants. @@ -400,7 +436,7 @@ actor ScopeGenerator: CustomStringConvertible, Sendable { .keys .intersection( scope.receivedProperties - .union(Set(scope.receivedProperties.map(\.asUnwrappedProperty))) + .union(scope.onlyIfAvailableUnwrappedReceivedProperties) ) .compactMap { propertyToUnfulfilledScopeMap[$0] } // Fulfill the scopes we depend upon.