PureXML/third_party/rules-swift/package-import-contract.md

129 lines
8.2 KiB
Markdown

# Per-Target Import Contract
The rule for what each SPM target may `import`. Tiledown is a monorepo from day one with many targets in one package, and the engine (`TileKit`) and CLI (`tile-down`) are already two targets, so this contract applies now and governs every target added to the manifest.
This is the operational companion to [dependency-injection.md](dependency-injection.md) and [shared-protocols.md](shared-protocols.md). Read those first for the underlying principles (no singletons, constructor injection, protocol seams).
## The goal
Every target should be able to be lifted out of the monorepo and built on its own against only its declared external dependencies. To make that true, each target has a hard contract about what it may `import`. Anything outside that contract is a violation and must be fixed in the same change.
The shape is: single-responsibility targets with unidirectional dependencies. A target never reaches sideways into a peer's concrete implementation; it talks to a protocol seam, and the composition root supplies the concrete.
## The SPM target name is load-bearing
Before any restructure that touches a target (folder move, file rename, type rename, namespace change), treat the SPM target name as the highest-stability anchor. Preserve it unless the rename IS the goal of the change.
The target name is what every consumer writes as `import <TargetName>`. Changing it cascades through every file that imports it, every doc that names it, and every test target's `dependencies:` list. Treat it like the public API of the package.
Order of operations for a refactor:
1. Anchor on the target name. The import line stays the same.
2. Move and rename files inside the target freely. The compiler does not care.
3. Rename folders, including SPM `path:` overrides, freely. Consumers do not see this.
4. Rename public types behind a `public typealias OldName = NewName` so direct references still compile; migrate them later and drop the alias when uses hit zero.
5. Rename the target itself only as a dedicated change with explicit acceptance criteria (every consumer updated, every test green, every doc swept). Never as a side effect of a folder restructure.
Priority order: target name > public type > file name > folder layout.
## Stop rule (before adding any `import`)
Before adding an `import X` line to a producer target, ask:
1. Is `X` external (Foundation, a system framework, ArgumentParser, Testing, a vetted third-party dependency)? Allowed.
2. Is the target an `executableTarget` / composition root (for example the `tile-down` CLI)? Allowed; the composition root wires the universe.
3. Is `X` a foundation-tier target that is foundation-only by construction (shared constants, value types, read-only diagnostics)? Allowed.
4. Is `X` the `SharedProtocols` seam target (foundation-only by contract, carrying only protocols and value types)? Allowed.
5. Otherwise: **STOP.** Surface the situation; do not proceed. Importing another producer's concrete target is forbidden. Route the dependency through a protocol seam; the composition root supplies the concrete via injection.
If you proceed past the stop rule, you have coupled two producers, which is exactly
the coupling this rule exists to prevent. Every target keeps full autonomy and
every dependency is injectable, so the target can be lifted into a standalone
package at any time.
## The rules
1. **Declared external primitives are allowed when they build on the target's
supported platforms.** Foundation, system frameworks, and vetted third-party
products still appear in the target's explicit dependency/platform contract;
test frameworks belong in test targets, not production producers.
2. **A producer target may import `SharedProtocols`.** It is foundation-only by
construction and carries the named cross-target contracts and their supporting
value types.
3. **A producer target may not import another producer (feature) target.** Cross-feature coupling goes through protocols defined in `SharedProtocols`; the concrete is supplied at the composition root.
4. **A producer target may not depend on a concrete writer.** Consumers hold the protocol type from `SharedProtocols`; the binary supplies the live implementation.
5. **Read-only foundation infrastructure can be imported widely** (embedded resources, read-only diagnostics). Treat it like Foundation.
6. **Composition roots can import anything.** The CLI and any other app/binary wire the live concretes and pass them down.
7. **No singletons reachable from producer code.** No `static let shared = X()`, no static config accessors. Pass the value as a parameter. The composition root constructs once and threads it down.
8. **A "this singleton is sanctioned" argument is not a get-out.** Even where a singleton would be technically acceptable, the project rule is strict dependency injection.
## What this rule does not say
- It does not ban a per-category static `os.Logger` constant; that is the Apple idiom (an `os.Logger` is a value-type wrapper meant to be held statically).
- It does not ban a `static let cache = Cache()` internal detail that is not reachable from consumers (for example a memoization cache inside a static loader). That is not a singleton in the service-locator sense.
- It does not ban a composition root constructing one instance and treating it as a local `let`. That is just a value, not a singleton.
## Per-project checked-in contract
Keep a `docs/package-import-contract-status.md` with one table row per target listing:
- **Target name**
- **Allowed imports** (the explicit list; anything else is a violation)
- **Current state** (matches / excess imports listed)
This file is the single source of truth a reviewer can grep against.
## CI enforcement
Two checks back the contract:
- A source-import audit: grep `^import` lines per target and fail if a consumer imports a forbidden producer.
- A portability check: copy a target plus its declared transitive deps into a temporary directory, generate a minimal `Package.swift`, and run `swift build`. Green means the target genuinely lifts standalone with only its declared deps. Sweep across every producer target in CI.
## When auditing the targets
1. List every target.
2. For each, run
`find Sources/<Target> -name '*.swift' -exec grep -h '^import ' {} + | sort -u`
(or use `Packages/Sources/<Target>` in a project monorepo) to see actual
imports.
3. Compare against the contract. Producer importing another producer is a violation. Producer importing a concrete writer is a violation. A singleton reachable inside a producer is a violation.
4. Open an issue or PR for each violation, smallest first.
## No closure typealiases at cross-target seams
Closure typealiases like:
```swift
public typealias TileLookup = @Sendable (String) async throws -> Tile?
```
are forbidden for cross-target seams. Use a named protocol instead:
```swift
public protocol TileLookupStrategy: Sendable {
func lookup(id: String) async throws -> Tile?
}
```
Reasons:
1. **Named.** A protocol shows up by name in tooling, error messages, documentation, and test stubs. A closure typealias is just a tuple-to-tuple mapping.
2. **Discoverable.** "Find all conformers" works; "find all closure literals matching this signature" does not.
3. **Forces a real type at the conformer side.** A protocol conformer is a named struct or actor with explicit captured state, not an inline literal at the binding site.
4. **The Factory Method and Strategy patterns are defined in terms of types, not function values.** Keeping the named-type shape keeps the design legible.
This does not ban closures as method parameters or property values; `onProgress: (@Sendable (Progress) -> Void)?` is fine. It bans closure-typed named cross-target contracts.
## Reference
- Gamma, Helm, Johnson, Vlissides, *Design Patterns* (1994). Factory Method p. 107; Strategy p. 315; Singleton p. 127 (we do not use the third one).
- Mark Seemann, *Dependency Injection in .NET* (2011). Service Locator anti-pattern ch. 5; Composition Root ch. 4.
## Related rules
- [dependency-injection.md](dependency-injection.md): underlying DI principles
- [shared-protocols.md](shared-protocols.md): where the protocol seams live
- [package-architecture.md](package-architecture.md): target layers and granularity
- [package-structure.md](package-structure.md): repository and manifest layout