diff --git a/.swift-version b/.swift-version index d346e2a..95ee81a 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -5.3 +5.9 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..12c4fa0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,98 @@ +# AGENTS.md + +`DependencyInjection` is a zero-dependency Swift package providing a type-keyed dependency injection +container for Apple platforms. This file covers using it in app code. [README.md](README.md) has +installation and every API in full; [CONTRIBUTING.md](CONTRIBUTING.md) covers changing the package. + +**Prefer `AsyncContainer` in new code.** It is an `actor`, so it is data-race safe under the Swift 6 +language mode, with compiler-enforced `Sendable` bounds. `Container` is `@unchecked Sendable` over +unguarded dictionaries — that silences the concurrency checker rather than proving anything. Use +`Container` only for `@Injected` / `@LazyInjected` or `autoregister`, which the async side lacks, and +confine it to one isolation domain. Examples below are `Container` unless marked otherwise. + +## Composition root + +One `ModuleRegistration` per feature module, all called once at launch before anything resolves. + +```swift +enum NetworkingModule: ModuleRegistration { + static func registerDependencies(in container: Container) { + container.register(type: APIClient.self, dependency: URLSessionAPIClient()) + container.autoregister(in: .shared, initializer: TokenStore.init) + } +} + +enum AppDependencies { + static func configure(in container: Container = .shared) { + NetworkingModule.registerDependencies(in: container) + } +} +``` + +Call `configure()` from `App.init()` or `application(_:didFinishLaunchingWithOptions:)`. Registering +into `Container.shared` is what lets the property wrappers work without `from:`. New registrations go +in a module's `registerDependencies(in:)`, not scattered through the app. + +## Register and resolve + +```swift +container.register(type: APIClient.self, in: .shared) { resolver in + URLSessionAPIClient(tokenStore: resolver.resolve(type: TokenStore.self)) +} +container.register(type: AppConfiguration.self, dependency: .production) // always shared +container.autoregister(in: .shared, initializer: ProfileRepository.init) // parameters auto-resolved +let repository: ProfileRepository = container.resolve() +let checked = try container.tryResolve(type: ProfileRepository.self) +``` + +`.shared` caches the first instance forever; `.new` runs the factory on every resolve. **Always pass +`in:`** — `register { ... }` without a scope binds the variadic-argument overload, which hard-codes +`.new`. `resolve` is `try! tryResolve`, so an unregistered type **crashes the process**; use +`tryResolve` wherever the composition root does not guarantee the registration. + +`@Injected` resolves at owner init, `@LazyInjected` at first access. Both default to +`Container.shared` and take `from:` to target another container. + +## AsyncContainer + +Same model with `await` throughout and `Sendable` required on every dependency and argument. The +`await` sits on the inner `resolve`, not on the factory closure. + +```swift +enum NetworkingModule: AsyncModuleRegistration { + static func registerDependencies(in container: AsyncContainer) async { + await container.register(in: .shared) { resolver in + URLSessionAPIClient(tokenStore: await resolver.resolve(type: TokenStore.self)) + } + } +} +let client: URLSessionAPIClient = await AsyncContainer.shared.resolve() +``` + +There is no `autoregister` and no property wrappers on this side. + +## Runtime arguments + +`container.register { resolver, userID in ... }`, resolved with `container.resolve(arguments: "id")`. + +- 1–3 arguments, always `.new` — there is no scope parameter. +- A fourth throws `ResolutionError.tooManyArguments` **at resolve time, not registration**, so an + over-wide registration compiles and fails at runtime. +- Matching uses **compile-time** types: registering `any APIClient` and resolving a concrete + `URLSessionAPIClient` throws `ResolutionError.unmatchingArgumentType`. + +## Testing app code + +Register into a local `Container()` per test rather than `Container.shared`, re-running the module's +`registerDependencies(in:)` and then overriding what the test needs. A test that does touch +`Container.shared` — the bare property wrappers do — must `clean()` before it returns, or the +registration leaks into every other test. + +## Traps + +- `Container` is **not thread-safe**; its storage is unguarded despite `@unchecked Sendable`. +- Re-registering a type discards its cached `.shared` instance. Anything holding the old one keeps it, + so two objects can both believe they are the singleton. Register once at startup. +- `@LazyInjected` is a `final class`; struct copies share the wrapper and the resolved instance. +- `AsyncContainer` dedupes in-flight `.shared` resolution — N concurrent resolves run the factory once, + and a throw clears the entry so the next resolve retries. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4247092..8f3d8c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,22 @@ __Sections__ - `Removed` for deprecated features removed in this release. - `Fixed` for any bug fixes. +## [Unreleased] + +### Added + +- **Agent-facing documentation** — `AGENTS.md` documents the package from the outside, for agents and developers building an app with it: the `ModuleRegistration` composition root, scope rules, resolution, runtime arguments, testing app code, and the runtime traps. Installation and requirements stay in `README.md`, which it links to. `CONTRIBUTING.md` carries the contributor workflow, build and test commands, public API compatibility, and the container invariants that are easy to break; `Sources/AGENTS.md` and `Tests/AGENTS.md` carry the rules scoped to those trees. +- **Guidance to prefer `AsyncContainer`** — `AGENTS.md` and `README.md` now recommend `AsyncContainer` for new code, especially under the Swift 6 language mode: it is an actor with compiler-enforced `Sendable` bounds, whereas `Container` is `@unchecked Sendable` over dictionaries it mutates without a lock, so the concurrency checker cannot catch a race. The trade-off — no `autoregister` and no property wrappers on the async side — is documented alongside it. +- **Documented `ModuleRegistration`** — `ModuleRegistration` and `AsyncModuleRegistration` are public API but were documented nowhere outside their own DocC comments. They are now documented as the composition-root pattern in `AGENTS.md`. + +### Fixed + +- **README examples now compile** — the resolution examples used the `argument:` and `argument1:`/`argument2:` labels removed in 2.0.0 and now use the parameter-pack `arguments:` parameter, and the argument-matching example annotated a closure parameter without the parentheses Swift requires. +- **Documented registration scope** — the README claimed `shared` was the default scope. Omitting `in:` selects the overload for registrations with arguments, which always registers with the `new` scope; the examples and the surrounding text now say so and pass the scope explicitly. +- **Stale requirements** — the README advertised Swift 5.3 and Xcode 11; the package requires Swift 5.9 and Xcode 15 because the container API is built on parameter packs. `.swift-version` was pinned to 5.3 for the same reason and now matches the manifest. +- **Installation snippet excluded every 2.x release** — the README pinned `.upToNextMajor(from: "1.0.0")`, which resolves to `>= 1.0.0, < 2.0.0` and therefore never reaches the current version. It now pins `2.0.0`. +- **Broken source link** — the `Factory` link in the README pointed at a path that moved when the sync and async protocols were split. + ## [2.0.1] ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..45d58cc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,143 @@ +# Contributing + +Development guide for `DependencyInjection`, and the contributor entry point for this repository. +[AGENTS.md](AGENTS.md) documents the package from the outside, for agents and developers building an +app with it; [README.md](README.md) covers the same ground in prose. Rules scoped to the code itself +live in [Sources/AGENTS.md](Sources/AGENTS.md) and [Tests/AGENTS.md](Tests/AGENTS.md) — read whichever +covers the files you are about to edit. + +## Every change + +- `swift test` is the authoritative check. Run it before reporting a change as working. +- A pull request changing more than 50 lines must also update `CHANGELOG.md`, or CI fails the build. +- A public API change updates `AGENTS.md` and `README.md` in the same pull request. + +## Toolchain + +The package has no dependencies and builds with SwiftPM alone. It declares +`swift-tools-version:5.9` and uses parameter packs, so Swift 5.9 / Xcode 15 is the floor. + +`.mise.toml` pins the Ruby toolchain CI needs for Danger: + +```sh +mise install # Ruby 3.3 + danger 8.2 +``` + +SwiftLint and SwiftFormat are configured (`.swiftlint.yml`, `.swiftformat`) but are **not installed +or run by CI** — they are local-only conveniences. + +## Commands + +```sh +swift build # compile the library +swift test # authoritative check — run this before claiming success +swift test --enable-code-coverage # exactly what CI runs +danger --fail-on-errors=true # CI only; needs GITHUB_TOKEN and an open pull request +swiftformat --lint . # report formatting drift without rewriting files +swiftlint --quiet # report lint findings +``` + +Two things that look like they should work but do not: + +- **`xcodebuild test` has no test target to run.** `DependencyInjection.xcodeproj` exposes a single + framework target and no tests. SwiftPM is the only path that runs the suite. +- **`swift package generate-documentation` is unavailable.** swift-docc-plugin is deliberately not a + dependency. Build DocC from Xcode instead: Product → Build Documentation. + +Do not run bare `swiftformat .`. It rewrites every file it touches, including ones unrelated to your +change. + +## CI + +`.github/workflows/integrations.yml` runs on every pull request, on a self-hosted macOS runner. It +installs Mise, runs Danger, then runs `swift test --enable-code-coverage`. That is the whole gate — +no linting, no formatting check, no coverage threshold. + +`Dangerfile` fails the build when: + +- a PR changes more than 50 lines without touching `CHANGELOG.md` (override by putting `#trivial` in + the PR title or body, or `[WIP]` in the title), or +- the PR description is shorter than 5 characters. + +## Public API compatibility + +Everything under `Sources/` ships as public API. The package is past 1.0 — see `CHANGELOG.md` for the +current version — so changing an existing signature, argument label, or default value is a breaking +change: it needs a deliberate decision and a `CHANGELOG.md` entry under a new version heading. Adding +new overloads is fine; silently changing what an existing call resolves to is not. + +## Container invariants + +These are the behaviours that are easy to break, or to rely on incorrectly. Each is verified against +the current implementation. + +**`Container` is not thread-safe.** It is declared `@unchecked Sendable`, but its `registrations` and +`sharedInstances` dictionaries are mutated without any lock. Concurrent `register`/`resolve` calls +from different threads are a data race and will corrupt the dictionaries or crash. Either register +everything from a single thread during startup, or use `AsyncContainer`, whose actor isolation makes +this safe. + +**`resolve` traps; `tryResolve` throws.** `resolve` is `try! tryResolve` (see +`Sources/Protocols/Resolution/Sync/DependencyResolving.swift`). Resolving an unregistered type kills +the process. `@Injected` calls `resolve` during property-wrapper initialisation, so a missing +registration crashes when the enclosing object is created; `@LazyInjected` defers the same crash to +the first access of the property. Use `tryResolve` anywhere the registration is not guaranteed. + +**Omitting `in:` registers `.new`, not `.shared`.** `register { ... }` with no scope binds the +variadic-argument overload with an empty parameter pack, which hard-codes `scope: .new` +(`Sources/Container/Sync/Container.swift`). Only `register(in: .shared)` and the autoclosure +`register(dependency:)` produce a cached instance. Always pass the scope explicitly. + +**Re-registering a type discards its cached shared instance.** `register` sets +`sharedInstances[identifier] = nil`, because the new factory probably returns something different. +Anything already holding the previous instance keeps it, so two objects that both believe they are +the singleton can be alive at once. Register once at startup rather than re-registering later. + +**The three-argument limit is enforced at resolve time, not at registration.** Registering a factory +with four arguments compiles and succeeds silently; only `tryResolve` checks +`RegistrationIdentifierConstant.maximumArgumentCount` and throws `ResolutionError.tooManyArguments`. +A registration that exceeds the limit is therefore dead code that fails at runtime. + +**Argument matching uses compile-time types.** The registration key is built from the static types at +the call site, so registering with `any SomeProtocol` and resolving with a concrete conforming value +throws `ResolutionError.unmatchingArgumentType` even though the value conforms. + +**`AsyncContainer` deduplicates in-flight shared resolution.** It keeps a `sharedTasks` map, so N +concurrent resolves of the same `.shared` type run the factory once and all await the same task. If +the factory throws, the task entry is cleared so the next resolve retries rather than caching the +failure. + +## Testing + +Tests use Swift Testing (`@Suite` / `@Test` / `#expect`) and live under `Tests/`. Scoped conventions +for writing them are in [Tests/AGENTS.md](Tests/AGENTS.md). + +Expectations for a change: + +- Sync and async behaviour is tested in parallel suites. A behaviour change on one side normally + needs a matching case on the other. +- The package aims for full coverage of the public surface; new public behaviour should arrive with + tests. +- `swift test` must pass before a change is reported as working. + +## Documentation + +Each document owns a distinct slice; keep changes in the one that owns the fact. + +- **`README.md`** is the consumer source of truth: installation, registration, resolution, scopes, + arguments, property wrappers. Every code block in it must compile against the current API — the + examples went stale once already when parameter packs replaced the numbered `argument1:`/ + `argument2:` labels in 2.0.0. +- **`AGENTS.md`** is that same consumer surface condensed for coding agents: choosing a container, the + `ModuleRegistration` composition root, scope rules, runtime arguments, testing app code, and the + traps. It and `README.md` change together — a public API change that lands in one but not the other + is a bug. Its code blocks must compile too. +- **DocC comments** on the declarations own per-symbol behaviour. Sync and async declarations carry + deliberately duplicated comments; update both sides together. +- **`CHANGELOG.md`** follows [Keep a Changelog](http://keepachangelog.com/) and uses the section + vocabulary listed at the top of the file. CI requires it for anything over 50 lines. + +## Style + +Formatting and lint rules are defined by `.swiftformat` and `.swiftlint.yml` — defer to those files +rather than to prose. Match the surrounding code where a rule does not apply. diff --git a/Package.swift b/Package.swift index 6e17286..2be32f7 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "DependencyInjection", platforms: [ - .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), + .macOS(.v12), .iOS(.v15), .tvOS(.v15), .watchOS(.v8), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. @@ -22,6 +22,8 @@ let package = Package( name: "DependencyInjection", dependencies: [], path: "Sources", + // Agent instructions live alongside the code; they are documentation, not a resource. + exclude: ["AGENTS.md"], swiftSettings: [ .define("APPLICATION_EXTENSION_API_ONLY"), .enableUpcomingFeature("StrictConcurrency"), @@ -30,7 +32,8 @@ let package = Package( .testTarget( name: "DependencyInjectionTests", dependencies: ["DependencyInjection"], - path: "Tests" + path: "Tests", + exclude: ["AGENTS.md"] ), ] ) diff --git a/README.md b/README.md index a2a93bd..0304580 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,15 @@ [![build](https://github.com/strvcom/ios-dependency-injection/actions/workflows/integrations.yml/badge.svg)](https://github.com/strvcom/ios-dependency-injection/actions/workflows/integrations.yml/badge.svg) [![Coverage](https://img.shields.io/badge/Coverage-100%25-darkgreen?style=flat-square)](https://img.shields.io/badge/Coverage-100%25-darkgreen?style=flat-square) [![Platforms](https://img.shields.io/badge/Platforms-iOS_iPadOS_macOS_tvOS_watchOS-lightgrey?style=flat-square)](https://img.shields.io/badge/Platforms-iOS_iPadOS_macOS_tvOS_watchOS-lightgrey?style=flat-square) -[![Swift](https://img.shields.io/badge/Swift-5.3_5.4_5.5-blue?style=flat-square)](https://img.shields.io/badge/Swift-5.3_5.4_5.5-blue?style=flat-square) +[![Swift](https://img.shields.io/badge/Swift-5.9+-blue?style=flat-square)](https://img.shields.io/badge/Swift-5.9+-blue?style=flat-square) The lightweight library for dependency injection in Swift. For detailed API documentation, see the generated DocC documentation in Xcode (Product → Build Documentation) or browse the source code. ## Requirements -- iOS/iPadOS 13.0+, macOS 10.15+, watchOS 6.0+, tvOS 13.0+ -- Xcode 11+ -- Swift 5.3+ +- iOS/iPadOS 15.0+, macOS 12.0+, watchOS 8.0+, tvOS 15.0+ +- Xcode 15+ +- Swift 5.9+ ## Installation @@ -19,7 +19,7 @@ You can install the library with [Swift Package Manager](https://swift.org/packa ```swift dependencies: [ - .package(url: "https://github.com/strvcom/ios-dependency-injection.git", .upToNextMajor(from: "1.0.0")) + .package(url: "https://github.com/strvcom/ios-dependency-injection.git", .upToNextMajor(from: "2.0.0")) ] ``` @@ -32,12 +32,14 @@ If you are new to the concept of Dependency Injection, you can check [Wikipedia] A container is a key component of Dependency Injection. A container manages dependencies of your codebase. First, you register your dependencies within the container identified by either their types, or protocols or classes they conform to or inherit from respectively. Then, you use the container to get (i.e. resolve) instances of the registered dependencies. The library provides two container types: -- **`Container`** - Synchronous container for traditional dependency injection +- **`Container`** - Synchronous container for traditional dependency injection. It is not thread-safe, so use it from a single thread - **`AsyncContainer`** - Asynchronous, actor-based container for Swift concurrency (thread-safe) +`AsyncContainer` is the one to prefer in new code, especially under the Swift 6 language mode: being an actor, it is genuinely data-race safe, and it requires `Sendable` dependencies and arguments. `Container` is declared `@unchecked Sendable` over dictionaries it mutates without a lock, which silences the concurrency checker instead of proving safety. Use `Container` when you need `@Injected`/`@LazyInjected` or `autoregister` — neither exists on the async side — and confine its use to a single isolation domain. + Other terminology that might be useful: -- **[Factory](Sources/Protocols/Registration/DependencyRegistering.swift)** - A function or closure instantiating a dependency +- **[Factory](Sources/Protocols/Registration/Sync/DependencyRegistering.swift)** - A function or closure instantiating a dependency - **[Scope](Sources/Models/DependencyScope.swift)** - A scope of a registered dependency can be either `new` or `shared`. When a dependency is registered with `new` scope, a new instance of the dependency is created each time the dependency is resolved from the container. When a dependency is registered with `shared` scope, a new instance of the dependency is created only the first time it is resolved from the container. The created instance is cached and it is returned for all upcoming resolution requests, i.e. it is a singleton - **Registration with arguments** - All dependencies must be initialized and their initializers often have parameters. Typically, the objects that are passed as the input parameters are resolved from the same container. But you might want to have a registered dependency which requires a parameter in its initializer that can't be registered in the container. In such case, you register the dependency with variable arguments (1, 2, or 3 arguments supported) and you specify values of the arguments when the dependency is being resolved; the values are passed as input parameters to the dependency factory. @@ -54,15 +56,17 @@ container.register(type: Dependency.self, in: .shared) { container in ) } ``` -We can also use the fact that the type is by default inferred from the factory return type and `shared` is the default scope so we can simplify the above snippet into this: +We can also use the fact that the type is by default inferred from the factory return type so we can simplify the above snippet into this: ```swift let container = Container() -container.register { container in +container.register(in: .shared) { container in Dependency( manager: container.resolve(type: Manager.self) ) } ``` +The scope, on the other hand, always has to be specified. If we leave it out, the dependency is registered with the `new` scope. + Moreover, if we want to register a shared dependency that has no sub-dependencies from the container we can use an overloaded registration method with an autoclosure like this: ```swift let container = Container() @@ -89,7 +93,7 @@ container.register { container, number in Argument matching is based on the compile-time type of each argument. That means `ConcreteType` and `any SomeProtocol` are different registrations even if the concrete value conforms to the protocol: ```swift let container = Container() -container.register { _, dependency: any DIProtocol in +container.register { _, dependency -> DependencyWithProtocolParameter in DependencyWithProtocolParameter(subDependency: dependency) } @@ -105,7 +109,7 @@ let service: DependencyWithProtocolParameter = container.resolve(arguments: exis Let's have look at an example from above: ```swift let container = Container() -container.register { container in +container.register(in: .shared) { container in Dependency( manager: container.resolve(type: Manager.self) ) @@ -139,7 +143,7 @@ Dependency resolution is very straightforward. You can use either the container' You can resolve a registered dependency like this: ```swift let container = Container() -container.register { container in +container.register(in: .shared) { container in Dependency( manager: container.resolve(type: Manager.self) ) @@ -159,19 +163,21 @@ container.register { container, number in ) } -let dependency = container.resolve(type: Dependency.self, argument: 42) -let dependency2: Dependency = container.resolve(argument: 42) +let dependency = container.resolve(type: Dependency.self, arguments: 42) +let dependency2: Dependency = container.resolve(arguments: 42) ``` The library also supports 2 and 3 arguments: ```swift -container.register { container, userId, apiKey in +container.register { _, userId, apiKey in AuthenticatedService(userId: userId, apiKey: apiKey) } -let service: AuthenticatedService = container.resolve(argument1: "user123", argument2: "key456") +let service: AuthenticatedService = container.resolve(arguments: "user123", "key456") ``` +If we try to resolve a dependency with more than 3 arguments, the container throws `ResolutionError.tooManyArguments`. + ### Property wrappers The package contains also two convenient property wrappers `@Injected` and `@LazyInjected`. As long as you are fine with using the `Container.shared` or any other static container instance, you can use the following syntactic sugar to resolve dependencies: @@ -220,7 +226,7 @@ For Swift concurrency, use `AsyncContainer` which is an actor-based container pr let container = AsyncContainer.shared // Register dependencies (async) -await container.register { resolver in +await container.register(in: .shared) { resolver in await MyService( apiClient: await resolver.resolve(type: APIClient.self) ) @@ -230,7 +236,7 @@ await container.register { resolver in let service: MyService = await container.resolve() ``` -All `AsyncContainer` operations are `async` and thread-safe. Use it when working with Swift concurrency or when thread safety is required. +All `AsyncContainer` operations are `async` and thread-safe. Dependencies registered in `AsyncContainer` have to be `Sendable`, and so does every argument. Prefer it for new code under Swift 6; the trade-off is that `autoregister` and the property wrappers are not available, so factories are written out by hand. **Note:** Property wrappers (`@Injected` and `@LazyInjected`) work only with `Container`, not `AsyncContainer`. diff --git a/Sources/AGENTS.md b/Sources/AGENTS.md new file mode 100644 index 0000000..0edb7c6 --- /dev/null +++ b/Sources/AGENTS.md @@ -0,0 +1,59 @@ +# Sources/AGENTS.md + +Scoped to `Sources/`. General workflow is in [../CONTRIBUTING.md](../CONTRIBUTING.md), which also +carries the full list of container invariants; the three most load-bearing are repeated here because +they are the ones an edit in this tree is most likely to break. + +- **`Container` is not thread-safe, `resolve` traps, and omitting `in:` registers `.new`.** The class + is `@unchecked Sendable` but mutates `registrations`/`sharedInstances` without a lock. `resolve` is + `try! tryResolve`, so an unregistered type kills the process. `register { ... }` with no scope binds + the variadic-argument overload, which hard-codes `scope: .new`. Changing any of these three changes + the public contract — see ../CONTRIBUTING.md before touching them. + +- **Sync and async are parallel implementations that must stay in step.** The pairs are + `Container`/`AsyncContainer`, `Registration`/`AsyncRegistration`, `DependencyResolving`/ + `AsyncDependencyResolving`, `DependencyRegistering`/`AsyncDependencyRegistering`, and + `ModuleRegistration`/`AsyncModuleRegistration`. A behaviour change on one side almost always needs + the mirrored change on the other. The async side additionally requires `Sendable` bounds on + `Dependency` and on each `Argument`, and its factories are `@Sendable` and `async`. Note the one + deliberate asymmetry: `DependencyAutoregistering` has no async counterpart. + +- **Keep `Container`'s `register`/`tryResolve` methods `open` and in the class body.** They were + moved out of extensions in 1.0.3 specifically so subclasses can override them. Methods declared in + a class extension are statically dispatched, so relocating one there would compile cleanly while + silently ignoring every downstream override. + +- **The target enables the `StrictConcurrency` upcoming feature** (see `../Package.swift`). New code + must be strict-concurrency clean. + +- **`APPLICATION_EXTENSION_API_ONLY` is a convention here, not an enforced check.** It was added in + 1.0.3 to silence app-extension warnings, but it cannot do that: `Package.swift` passes it through + `.define(...)`, which is a Swift compilation condition (`-D`), not the Xcode + `APPLICATION_EXTENSION_API_ONLY = YES` build setting; the Xcode project likewise only puts it in + `SWIFT_ACTIVE_COMPILATION_CONDITIONS`, and no file guards on it with `#if`. Nothing in this package + will stop you calling API that is unavailable to app extensions — it breaks only when a consumer + builds an extension target. Real enforcement would need `unsafeFlags(["-application-extension"])`, + which SwiftPM forbids in a package consumed by version, so the convention is all there is: avoid + extension-unavailable API by hand. + +- **The force casts and force tries here are deliberate, and SwiftLint flags them anyway.** The `as!` + in `Container.getDependency`, and both cast sites in `AsyncContainer.getDependency` (the shared-task + path and the main path), are load-bearing: `Registration.factory` erases its return type to `Any` so + heterogeneous registrations can share one dictionary, and the cast is guaranteed by construction + because a `Registration` can only be built from a factory of the matching type. The `try!` in both + `resolve` overloads of `DependencyResolving` and `AsyncDependencyResolving` is the documented + trapping behaviour that distinguishes `resolve` from `tryResolve`. SwiftLint reports every one of + these as a `force_cast`/`force_try` violation; they are pre-existing and not enforced by CI. Do not + "fix" them — softening either one would hide registration bugs or silently change the public + contract. + +- **`RegistrationIdentifierConstant.maximumArgumentCount` is the single source of the argument cap.** + It is read by the guard in both containers' `tryResolve(type:arguments:)`. Changing the limit means + changing that constant, not the call sites. + +- **Doc comments are duplicated between sync and async declarations on purpose** — DocC renders each + symbol independently. When you correct one, correct its twin. + +- **Everything here ships as public API**, and it is the surface [../AGENTS.md](../AGENTS.md) + documents for app developers. A signature change needs a `CHANGELOG.md` entry and usually an + `../AGENTS.md` and `../README.md` update in the same pull request. diff --git a/Tests/AGENTS.md b/Tests/AGENTS.md new file mode 100644 index 0000000..fec3689 --- /dev/null +++ b/Tests/AGENTS.md @@ -0,0 +1,28 @@ +# Tests/AGENTS.md + +Scoped to `Tests/`. General workflow and testing expectations are in [../CONTRIBUTING.md](../CONTRIBUTING.md). + +- **These are Swift Testing suites, not XCTest** — `@Suite`, `@Test`, `#expect`, and `Issue.record` + for the failure paths. Suites run in parallel by default, which matters for the next point. + +- **A test that registers into `Container.shared` or `AsyncContainer.shared` must `clean()` before it + returns.** Those singletons are process-global, so a leftover registration leaks into every other + suite running in the same process and produces failures far from the cause. + `PropertyWrappers/PropertyWrapperTests.swift` shows the pattern. Prefer a local `Container()` + unless the test is specifically about singleton behaviour. + +- **Declare tags in `Common/TestTags.swift`, not inline.** Every suite carries tags from that file + (`.sync`/`.async` plus `.base`, `.arguments`, `.complex`, `.autoregistration`, + `.propertyWrappers`); they are how suites are selected when running a subset. Add a new `@Tag` + there when you need one. + +- **Reuse the fixture types in `Common/Dependencies.swift`** rather than declaring new ones per + suite. Anything used with `AsyncContainer` must be `Sendable`. + +- **`Container/Sync` and `Container/Async` are deliberate mirrors.** Adding a case to one usually + means adding the equivalent to the other, with the async version awaiting registration and + resolution. + +- **`.swiftlint.yml` excludes `Tests`, so lint rules do not apply here.** Force unwraps, magic + numbers, and long suites are expected in test code — do not "fix" them to satisfy rules that are + not enforced on this directory.