Give every generated Swift model a public init, and build a consumer that would have caught it

Merged
JE
2mo ago
+1204-27

Closes #735.

Two halves, and the second is the one that closes the class

The defect. Swift's implicit memberwise initializer is internal. ModelEmitter.swift compensated with an explicit public init — but only if !requiredFields.isEmpty. An all-optional model got none, so nothing outside the module could construct it. The two emitters already disagreed about this: emitRequestModel emits its init unconditionally. That disagreement was the bug, so emitEntityModel now matches it.

Two of the affected models are request payloads, which made their operations uncallable: UpdateGaugeNeedleRequest(gaugeNeedle:) could only ever be handed nil from outside — an empty {} PUT, which bc3 answers with a 400. UpdateMyPreferences had the same shape via PreferencesPayload.

The blind spot — this is what the PR is really closing. Every test source in Tests/BasecampTests that imports the SDK imports it as @testable import Basecamp, which raises internal to visible; none plain-imports it. GaugesServiceTests.swift:537 already constructs GaugeNeedleUpdatePayload() and passes today, against a surface no consumer has. No target in swift/Package.swift built against the public API, so no CI job modelled a customer — which is the mechanism, not an adversary, that let this ship. Fixing only the emitter would leave that test green for a different reason and let the next all-optional model repeat the bug silently.

So this PR adds Sources/BasecampPublicAPIConsumer: a plain-import Basecamp target that constructs all 35 payloads and calls both affected operations the way an external app would.

Counts

beforeafter
generated model files220220
lacking a public init372

The two remaining are FirstWeekDay (a public enum — its cases are already public constructors) and WebhookHeadersMap (a public typealias to [String: String] — no initializer of its own). 35 models gained an init.

Why a non-test target

swift build compiles it, so make swift-build, make swift-check, the test-swift CI job, release-swift.yml, and the CodeQL Swift build (working-directory: swift, run: swift build) all cover it — strictly broader than swift test. (swift test builds it too, verified.) It stays out of products, so no package that depends on this one ever builds it.

swift build compiling a target with no product membership was verified empirically, not assumed.

One way it is not a customer, stated in its header rather than left for a reader to discover: it lives in the same SwiftPM package, so package-level declarations (e.g. BasecampClient.httpClient) are visible to it and not to an external consumer. Nothing in it touches one. Closing that last gap means a separate nested package, which would drop out of swift build and need its own CI step — not worth it for a failure class that is internal-vs-public, which this target does observe.

Guards, and what each one can actually see

PublicInitCoverageTests holds what the consumer target cannot:

  • emitter unit tests — run the generator directly, so they fail on the source change alone, before regeneration;
  • roster scan — every generated public struct must declare a memberwise public init, so a model added after the consumer's hand-written roster still gets covered. "Memberwise" is load-bearing: Codable's public init(from decoder:) throws also begins public init(, and nine models carry both, so the first cut of this scan would have stayed green after the memberwise one was deleted from any of them. It now recognizes an initializer by whether its parameters name the struct's own properties and cover every member it requires at construction. Red-proved on Draft.swift, where swift build still succeeds — nothing else in the repo observes the loss;
  • @testable guard — the consumer only proves anything while it imports the way a customer does, and adding @testable is the obvious way to make a compile error there go away. Verified: with @testable, the consumer still compiles, so nothing else would have caught it;
  • non-test-target guard — demoting it to .testTarget would narrow its CI coverage with no visible signal.

The consumer target itself lists all 35 models rather than a sample, so reverting the fix produces 35 independent errors rather than one arguable case.

Red proof

Emitter reverted by cp (never git checkout --), regenerated, mutation asserted to have reached the output (GaugeNeedleUpdatePayload.swift has no public init):

  • swift build → exit 1, 38 errors in PublicAPIConsumer.swift, nearly all missing argument for parameter 'from' in call — the only public initializer left being Codable's init(from:). That is the customer's experience, verbatim.
  • PublicInitCoverageTests → the emitter tests and the roster scan fail.
  • GaugesServiceTests → 24/24 still pass on that same reverted tree. The blind spot, demonstrated rather than asserted.

Per-case mutation matrix for the cases the emitter revert does not exercise:

casemutationresult
testConsumerTargetImportsBasecampWithoutTestableconsumer gains @testablefails on the right assertion
testConsumerIsDeclaredAsANonTestTarget.target( → .testTarget(fails on the right assertion
testRequiredMemberModelKeepsItsInitShaperequired params get = nilfails on the right assertion
testNonDictionaryPropertyIsSkippedByAllThreeLoopsthe guard line deletedfails on the right assertion

All mutations restored by cp + diff -q, verified on disk, and the generated tree re-diffed byte-for-byte against the pre-mutation snapshot.

Drive-by, in the block being rewritten

The property-declaration and init-parameter loops both skip a property whose schema is not a dictionary; the assignment loop did not, so it would emit self.x = x for a parameter that was never declared. Latent (every property in openapi.json is a dictionary) and pre-existing, but the three loops have to agree, and making the init unconditional widens where it could bite. Regenerated output is byte-identical with and without it, and it now has its own test (last row above).

Verification

LC_ALL=C throughout, real exit codes read back from log files:

  • make swift-generate → 0
  • make swift-check-drift → 0
  • make swift-check → 0 (423 tests, 0 failures)
  • make doc-constants-check → 0
  • the parity gates that read swift/Sources — check-deprecation-parity, check-idempotency-parity, check-retry-metadata-parity, check-readme-env-vars, check-write-semantics-parity → 0

Rebase check immediately before each push: origin/main had not moved off 49f18e463, so the branch is already on it.

Untouched on purpose: swift/Sources/Basecamp/Services/BaseService.swift, which a concurrent workstream owns.