From 5718d721fe18dacf9275f519d970911069a6f48c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 16 Aug 2026 21:56:36 -0700 Subject: [PATCH 1/6] Give every generated Swift model a public init, and build a consumer that would have caught it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift's implicit memberwise initializer is `internal`. The Swift model emitter compensated with an explicit `public init`, but only for structs with at least one required member (`if !requiredFields.isEmpty`). An all-optional model got none and was unconstructible outside the module. Two of the 35 affected models are request payloads, which made their operations uncallable: `UpdateGaugeNeedleRequest(gaugeNeedle:)` could only be handed `nil` from outside, and an empty `{}` PUT is what bc3 rejects with a 400. `UpdateMyPreferences` had the same shape via `PreferencesPayload`. The other two init-less models are an enum and a typealias, neither of which takes one. The two emitters already disagreed: `emitRequestModel` emits its init unconditionally. That disagreement was the bug, so `emitEntityModel` now matches it. The required-member shape is unchanged — required parameters still take no default, optional ones still default to nil. The second half is why this shipped at all. All 30 files in Tests/BasecampTests use `@testable import Basecamp`, which raises internal to visible; zero use a plain `import`. GaugesServiceTests already constructs `GaugeNeedleUpdatePayload()` and passes — against a surface no consumer has. No target in the package built against the public API, so no CI job modelled a customer, and fixing only the emitter would leave that test green for the wrong reason and let the next all-optional model repeat this silently. So `Sources/BasecampPublicAPIConsumer` is now a plain-`import` target that constructs all 35 payloads and calls both affected operations the way an external app would. It is a non-test target on purpose: `swift build` compiles it, so `make swift-build`, `make swift-check`, the Swift CI job, the release workflow and the CodeQL Swift build all cover it — broader than `swift test` alone. It stays out of `products` so no dependent package builds it. PublicInitCoverageTests holds what the consumer cannot: it unit-tests the emitter directly, scans every generated model for a `public init` (so a model added after the consumer's roster was written is still covered), and asserts the consumer never gains `@testable` and stays a non-test target — the two edits that would silently retire it. Verified by reverting the emitter and regenerating: the consumer target fails to compile with 38 errors, all of the form "missing argument for parameter 'from'" — the only public initializer left being Codable's. GaugesServiceTests still passes 24/24 against that same reverted tree. Closes #735 --- swift/Package.swift | 14 ++ .../Generated/Models/AccountLimits.swift | 12 ++ .../Generated/Models/AccountLogo.swift | 4 + .../Generated/Models/AccountSettings.swift | 6 + .../Models/AccountSubscription.swift | 20 ++ .../Models/CampfireLineAttachment.swift | 16 ++ .../Models/ClientApprovalResponse.swift | 34 ++++ .../Generated/Models/ClientSide.swift | 5 + .../CreateAttachmentResponseContent.swift | 4 + .../Generated/Models/DoorService.swift | 14 ++ .../Generated/Models/EventDetails.swift | 6 + .../Generated/Models/EverythingFile.swift | 68 +++++++ .../Models/GaugeNeedleUpdatePayload.swift | 4 + .../GetAssignedTodosResponseContent.swift | 6 + .../GetMyAssignmentsResponseContent.swift | 5 + .../GetOverdueTodosResponseContent.swift | 12 ++ .../GetPersonProgressResponseContent.swift | 5 + .../Generated/Models/OutOfOffice.swift | 16 ++ .../Models/PauseQuestionResponseContent.swift | 4 + .../Generated/Models/Preferences.swift | 14 ++ .../Generated/Models/PreferencesPayload.swift | 6 + .../Models/PreviewableAttachment.swift | 20 ++ .../Models/ProjectAccessResult.swift | 5 + .../Generated/Models/QuestionReminder.swift | 12 ++ .../Generated/Models/QuestionSchedule.swift | 22 +++ .../ResumeQuestionResponseContent.swift | 4 + .../Generated/Models/ScheduleAttributes.swift | 5 + .../Generated/Models/TimelineAttachment.swift | 78 ++++++++ .../Generated/Models/TimelineEvent.swift | 34 ++++ ...nNotificationSettingsResponseContent.swift | 5 + .../Generated/Models/WebhookCopy.swift | 12 ++ .../Generated/Models/WebhookCopyBucket.swift | 4 + .../Generated/Models/WebhookDelivery.swift | 12 ++ .../Models/WebhookDeliveryRequest.swift | 5 + .../Models/WebhookDeliveryResponse.swift | 6 + .../Generated/Models/WebhookEvent.swift | 18 ++ .../BasecampGenerator/ModelEmitter.swift | 63 ++++--- .../PublicAPIConsumer.swift | 138 ++++++++++++++ .../PublicInitCoverageTests.swift | 177 ++++++++++++++++++ 39 files changed, 868 insertions(+), 27 deletions(-) create mode 100644 swift/Sources/BasecampPublicAPIConsumer/PublicAPIConsumer.swift create mode 100644 swift/Tests/BasecampTests/PublicInitCoverageTests.swift diff --git a/swift/Package.swift b/swift/Package.swift index d34ae2c0d..250c34132 100644 --- a/swift/Package.swift +++ b/swift/Package.swift @@ -18,6 +18,20 @@ let package = Package( .swiftLanguageMode(.v6), ] ), + // Models a customer: depends on the `Basecamp` target and imports it + // plainly, so only the public surface is visible (#735). Deliberately a + // non-test target — `swift build` compiles it, which is broader cover + // than `swift test`, and it is deliberately absent from `products` so it + // is never built by a package that depends on this one. Its sources must + // never use `@testable`; PublicInitCoverageTests enforces both properties. + .target( + name: "BasecampPublicAPIConsumer", + dependencies: ["Basecamp"], + path: "Sources/BasecampPublicAPIConsumer", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), .executableTarget( name: "BasecampGenerator", path: "Sources/BasecampGenerator", diff --git a/swift/Sources/Basecamp/Generated/Models/AccountLimits.swift b/swift/Sources/Basecamp/Generated/Models/AccountLimits.swift index f272d690d..7308a2734 100644 --- a/swift/Sources/Basecamp/Generated/Models/AccountLimits.swift +++ b/swift/Sources/Basecamp/Generated/Models/AccountLimits.swift @@ -6,4 +6,16 @@ public struct AccountLimits: Codable, Sendable { public var canCreateUsers: Bool? public var canPinProjects: Bool? public var canUploadFiles: Bool? + + public init( + canCreateProjects: Bool? = nil, + canCreateUsers: Bool? = nil, + canPinProjects: Bool? = nil, + canUploadFiles: Bool? = nil + ) { + self.canCreateProjects = canCreateProjects + self.canCreateUsers = canCreateUsers + self.canPinProjects = canPinProjects + self.canUploadFiles = canUploadFiles + } } diff --git a/swift/Sources/Basecamp/Generated/Models/AccountLogo.swift b/swift/Sources/Basecamp/Generated/Models/AccountLogo.swift index 464733975..2ffedbf8d 100644 --- a/swift/Sources/Basecamp/Generated/Models/AccountLogo.swift +++ b/swift/Sources/Basecamp/Generated/Models/AccountLogo.swift @@ -3,4 +3,8 @@ import Foundation public struct AccountLogo: Codable, Sendable { public var url: String? + + public init(url: String? = nil) { + self.url = url + } } diff --git a/swift/Sources/Basecamp/Generated/Models/AccountSettings.swift b/swift/Sources/Basecamp/Generated/Models/AccountSettings.swift index 38feebb52..b151dc696 100644 --- a/swift/Sources/Basecamp/Generated/Models/AccountSettings.swift +++ b/swift/Sources/Basecamp/Generated/Models/AccountSettings.swift @@ -5,4 +5,10 @@ public struct AccountSettings: Codable, Sendable { public var companyHqEnabled: Bool? public var projectsEnabled: Bool? public var teamsEnabled: Bool? + + public init(companyHqEnabled: Bool? = nil, projectsEnabled: Bool? = nil, teamsEnabled: Bool? = nil) { + self.companyHqEnabled = companyHqEnabled + self.projectsEnabled = projectsEnabled + self.teamsEnabled = teamsEnabled + } } diff --git a/swift/Sources/Basecamp/Generated/Models/AccountSubscription.swift b/swift/Sources/Basecamp/Generated/Models/AccountSubscription.swift index 7a162e690..2a6601450 100644 --- a/swift/Sources/Basecamp/Generated/Models/AccountSubscription.swift +++ b/swift/Sources/Basecamp/Generated/Models/AccountSubscription.swift @@ -10,4 +10,24 @@ public struct AccountSubscription: Codable, Sendable { public var teams: Bool? public var templates: Bool? public var timesheet: Bool? + + public init( + clients: Bool? = nil, + logo: Bool? = nil, + projectLimit: Int32? = nil, + properName: String? = nil, + shortName: String? = nil, + teams: Bool? = nil, + templates: Bool? = nil, + timesheet: Bool? = nil + ) { + self.clients = clients + self.logo = logo + self.projectLimit = projectLimit + self.properName = properName + self.shortName = shortName + self.teams = teams + self.templates = templates + self.timesheet = timesheet + } } diff --git a/swift/Sources/Basecamp/Generated/Models/CampfireLineAttachment.swift b/swift/Sources/Basecamp/Generated/Models/CampfireLineAttachment.swift index 3019df3f2..fb8dd21e4 100644 --- a/swift/Sources/Basecamp/Generated/Models/CampfireLineAttachment.swift +++ b/swift/Sources/Basecamp/Generated/Models/CampfireLineAttachment.swift @@ -8,4 +8,20 @@ public struct CampfireLineAttachment: Codable, Sendable { public var filename: String? public var title: String? public var url: String? + + public init( + byteSize: Int? = nil, + contentType: String? = nil, + downloadUrl: String? = nil, + filename: String? = nil, + title: String? = nil, + url: String? = nil + ) { + self.byteSize = byteSize + self.contentType = contentType + self.downloadUrl = downloadUrl + self.filename = filename + self.title = title + self.url = url + } } diff --git a/swift/Sources/Basecamp/Generated/Models/ClientApprovalResponse.swift b/swift/Sources/Basecamp/Generated/Models/ClientApprovalResponse.swift index aca4ffd24..59f9d1f71 100644 --- a/swift/Sources/Basecamp/Generated/Models/ClientApprovalResponse.swift +++ b/swift/Sources/Basecamp/Generated/Models/ClientApprovalResponse.swift @@ -17,4 +17,38 @@ public struct ClientApprovalResponse: Codable, Sendable { public var type: String? public var updatedAt: String? public var visibleToClients: Bool? + + public init( + appUrl: String? = nil, + approved: Bool? = nil, + bookmarkUrl: String? = nil, + bucket: RecordingBucket? = nil, + content: String? = nil, + createdAt: String? = nil, + creator: Person? = nil, + id: Int? = nil, + inheritsStatus: Bool? = nil, + parent: RecordingParent? = nil, + status: String? = nil, + title: String? = nil, + type: String? = nil, + updatedAt: String? = nil, + visibleToClients: Bool? = nil + ) { + self.appUrl = appUrl + self.approved = approved + self.bookmarkUrl = bookmarkUrl + self.bucket = bucket + self.content = content + self.createdAt = createdAt + self.creator = creator + self.id = id + self.inheritsStatus = inheritsStatus + self.parent = parent + self.status = status + self.title = title + self.type = type + self.updatedAt = updatedAt + self.visibleToClients = visibleToClients + } } diff --git a/swift/Sources/Basecamp/Generated/Models/ClientSide.swift b/swift/Sources/Basecamp/Generated/Models/ClientSide.swift index 51b16846b..b63254132 100644 --- a/swift/Sources/Basecamp/Generated/Models/ClientSide.swift +++ b/swift/Sources/Basecamp/Generated/Models/ClientSide.swift @@ -5,4 +5,9 @@ import Foundation public struct ClientSide: Codable, Sendable { public var appUrl: String? public var url: String? + + public init(appUrl: String? = nil, url: String? = nil) { + self.appUrl = appUrl + self.url = url + } } diff --git a/swift/Sources/Basecamp/Generated/Models/CreateAttachmentResponseContent.swift b/swift/Sources/Basecamp/Generated/Models/CreateAttachmentResponseContent.swift index 2d1961ce1..d23fd6643 100644 --- a/swift/Sources/Basecamp/Generated/Models/CreateAttachmentResponseContent.swift +++ b/swift/Sources/Basecamp/Generated/Models/CreateAttachmentResponseContent.swift @@ -3,4 +3,8 @@ import Foundation public struct CreateAttachmentResponseContent: Codable, Sendable { public var attachableSgid: String? + + public init(attachableSgid: String? = nil) { + self.attachableSgid = attachableSgid + } } diff --git a/swift/Sources/Basecamp/Generated/Models/DoorService.swift b/swift/Sources/Basecamp/Generated/Models/DoorService.swift index 39379de58..31da97ece 100644 --- a/swift/Sources/Basecamp/Generated/Models/DoorService.swift +++ b/swift/Sources/Basecamp/Generated/Models/DoorService.swift @@ -7,4 +7,18 @@ public struct DoorService: Codable, Sendable { public var name: String? public var supportingText: String? public var validPatterns: [String]? + + public init( + code: String? = nil, + exampleUrl: String? = nil, + name: String? = nil, + supportingText: String? = nil, + validPatterns: [String]? = nil + ) { + self.code = code + self.exampleUrl = exampleUrl + self.name = name + self.supportingText = supportingText + self.validPatterns = validPatterns + } } diff --git a/swift/Sources/Basecamp/Generated/Models/EventDetails.swift b/swift/Sources/Basecamp/Generated/Models/EventDetails.swift index 32569e4c5..4e55e6b58 100644 --- a/swift/Sources/Basecamp/Generated/Models/EventDetails.swift +++ b/swift/Sources/Basecamp/Generated/Models/EventDetails.swift @@ -5,4 +5,10 @@ public struct EventDetails: Codable, Sendable { public var addedPersonIds: [Int]? public var notifiedRecipientIds: [Int]? public var removedPersonIds: [Int]? + + public init(addedPersonIds: [Int]? = nil, notifiedRecipientIds: [Int]? = nil, removedPersonIds: [Int]? = nil) { + self.addedPersonIds = addedPersonIds + self.notifiedRecipientIds = notifiedRecipientIds + self.removedPersonIds = removedPersonIds + } } diff --git a/swift/Sources/Basecamp/Generated/Models/EverythingFile.swift b/swift/Sources/Basecamp/Generated/Models/EverythingFile.swift index ba824a7ef..dc3e5d291 100644 --- a/swift/Sources/Basecamp/Generated/Models/EverythingFile.swift +++ b/swift/Sources/Basecamp/Generated/Models/EverythingFile.swift @@ -34,4 +34,72 @@ public struct EverythingFile: Codable, Sendable { public var url: String? public var visibleToClients: Bool? public var width: Int32? + + public init( + appDownloadUrl: String? = nil, + appUrl: String? = nil, + attachableSgid: String? = nil, + bookmarkUrl: String? = nil, + boostsCount: Int32? = nil, + boostsUrl: String? = nil, + bucket: RecordingBucket? = nil, + byteSize: Int? = nil, + commentsCount: Int32? = nil, + commentsUrl: String? = nil, + content: String? = nil, + contentAttachments: [RichTextAttachment]? = nil, + contentType: String? = nil, + createdAt: String? = nil, + creator: Person? = nil, + description: String? = nil, + descriptionAttachments: [RichTextAttachment]? = nil, + downloadUrl: String? = nil, + filename: String? = nil, + height: Int32? = nil, + id: Int? = nil, + inheritsStatus: Bool? = nil, + parent: RecordingParent? = nil, + position: Int32? = nil, + status: String? = nil, + subscriptionUrl: String? = nil, + title: String? = nil, + type: String? = nil, + updatedAt: String? = nil, + url: String? = nil, + visibleToClients: Bool? = nil, + width: Int32? = nil + ) { + self.appDownloadUrl = appDownloadUrl + self.appUrl = appUrl + self.attachableSgid = attachableSgid + self.bookmarkUrl = bookmarkUrl + self.boostsCount = boostsCount + self.boostsUrl = boostsUrl + self.bucket = bucket + self.byteSize = byteSize + self.commentsCount = commentsCount + self.commentsUrl = commentsUrl + self.content = content + self.contentAttachments = contentAttachments + self.contentType = contentType + self.createdAt = createdAt + self.creator = creator + self.description = description + self.descriptionAttachments = descriptionAttachments + self.downloadUrl = downloadUrl + self.filename = filename + self.height = height + self.id = id + self.inheritsStatus = inheritsStatus + self.parent = parent + self.position = position + self.status = status + self.subscriptionUrl = subscriptionUrl + self.title = title + self.type = type + self.updatedAt = updatedAt + self.url = url + self.visibleToClients = visibleToClients + self.width = width + } } diff --git a/swift/Sources/Basecamp/Generated/Models/GaugeNeedleUpdatePayload.swift b/swift/Sources/Basecamp/Generated/Models/GaugeNeedleUpdatePayload.swift index fdaa765aa..7897fb764 100644 --- a/swift/Sources/Basecamp/Generated/Models/GaugeNeedleUpdatePayload.swift +++ b/swift/Sources/Basecamp/Generated/Models/GaugeNeedleUpdatePayload.swift @@ -3,4 +3,8 @@ import Foundation public struct GaugeNeedleUpdatePayload: Codable, Sendable { public var description: String? + + public init(description: String? = nil) { + self.description = description + } } diff --git a/swift/Sources/Basecamp/Generated/Models/GetAssignedTodosResponseContent.swift b/swift/Sources/Basecamp/Generated/Models/GetAssignedTodosResponseContent.swift index 1d11af31a..202e095cc 100644 --- a/swift/Sources/Basecamp/Generated/Models/GetAssignedTodosResponseContent.swift +++ b/swift/Sources/Basecamp/Generated/Models/GetAssignedTodosResponseContent.swift @@ -5,4 +5,10 @@ public struct GetAssignedTodosResponseContent: Codable, Sendable { public var groupedBy: String? public var person: Person? public var todos: [Todo]? + + public init(groupedBy: String? = nil, person: Person? = nil, todos: [Todo]? = nil) { + self.groupedBy = groupedBy + self.person = person + self.todos = todos + } } diff --git a/swift/Sources/Basecamp/Generated/Models/GetMyAssignmentsResponseContent.swift b/swift/Sources/Basecamp/Generated/Models/GetMyAssignmentsResponseContent.swift index 113514e2d..8b8e7d1af 100644 --- a/swift/Sources/Basecamp/Generated/Models/GetMyAssignmentsResponseContent.swift +++ b/swift/Sources/Basecamp/Generated/Models/GetMyAssignmentsResponseContent.swift @@ -4,4 +4,9 @@ import Foundation public struct GetMyAssignmentsResponseContent: Codable, Sendable { public var nonPriorities: [MyAssignment]? public var priorities: [MyAssignment]? + + public init(nonPriorities: [MyAssignment]? = nil, priorities: [MyAssignment]? = nil) { + self.nonPriorities = nonPriorities + self.priorities = priorities + } } diff --git a/swift/Sources/Basecamp/Generated/Models/GetOverdueTodosResponseContent.swift b/swift/Sources/Basecamp/Generated/Models/GetOverdueTodosResponseContent.swift index 4776ab1b0..7f06faedf 100644 --- a/swift/Sources/Basecamp/Generated/Models/GetOverdueTodosResponseContent.swift +++ b/swift/Sources/Basecamp/Generated/Models/GetOverdueTodosResponseContent.swift @@ -6,4 +6,16 @@ public struct GetOverdueTodosResponseContent: Codable, Sendable { public var overAWeekLate: [Todo]? public var overThreeMonthsLate: [Todo]? public var underAWeekLate: [Todo]? + + public init( + overAMonthLate: [Todo]? = nil, + overAWeekLate: [Todo]? = nil, + overThreeMonthsLate: [Todo]? = nil, + underAWeekLate: [Todo]? = nil + ) { + self.overAMonthLate = overAMonthLate + self.overAWeekLate = overAWeekLate + self.overThreeMonthsLate = overThreeMonthsLate + self.underAWeekLate = underAWeekLate + } } diff --git a/swift/Sources/Basecamp/Generated/Models/GetPersonProgressResponseContent.swift b/swift/Sources/Basecamp/Generated/Models/GetPersonProgressResponseContent.swift index 1d51857f5..7ebc5ad80 100644 --- a/swift/Sources/Basecamp/Generated/Models/GetPersonProgressResponseContent.swift +++ b/swift/Sources/Basecamp/Generated/Models/GetPersonProgressResponseContent.swift @@ -4,4 +4,9 @@ import Foundation public struct GetPersonProgressResponseContent: Codable, Sendable { public var events: [TimelineEvent]? public var person: Person? + + public init(events: [TimelineEvent]? = nil, person: Person? = nil) { + self.events = events + self.person = person + } } diff --git a/swift/Sources/Basecamp/Generated/Models/OutOfOffice.swift b/swift/Sources/Basecamp/Generated/Models/OutOfOffice.swift index fcc738d8e..296e1fa58 100644 --- a/swift/Sources/Basecamp/Generated/Models/OutOfOffice.swift +++ b/swift/Sources/Basecamp/Generated/Models/OutOfOffice.swift @@ -8,4 +8,20 @@ public struct OutOfOffice: Codable, Sendable { public var ongoing: Bool? public var person: OutOfOfficePerson? public var startDate: String? + + public init( + backOnDate: String? = nil, + enabled: Bool? = nil, + endDate: String? = nil, + ongoing: Bool? = nil, + person: OutOfOfficePerson? = nil, + startDate: String? = nil + ) { + self.backOnDate = backOnDate + self.enabled = enabled + self.endDate = endDate + self.ongoing = ongoing + self.person = person + self.startDate = startDate + } } diff --git a/swift/Sources/Basecamp/Generated/Models/PauseQuestionResponseContent.swift b/swift/Sources/Basecamp/Generated/Models/PauseQuestionResponseContent.swift index d80dab5a1..b8771d570 100644 --- a/swift/Sources/Basecamp/Generated/Models/PauseQuestionResponseContent.swift +++ b/swift/Sources/Basecamp/Generated/Models/PauseQuestionResponseContent.swift @@ -3,4 +3,8 @@ import Foundation public struct PauseQuestionResponseContent: Codable, Sendable { public var paused: Bool? + + public init(paused: Bool? = nil) { + self.paused = paused + } } diff --git a/swift/Sources/Basecamp/Generated/Models/Preferences.swift b/swift/Sources/Basecamp/Generated/Models/Preferences.swift index 382afdb3f..f81c8fd6e 100644 --- a/swift/Sources/Basecamp/Generated/Models/Preferences.swift +++ b/swift/Sources/Basecamp/Generated/Models/Preferences.swift @@ -7,4 +7,18 @@ public struct Preferences: Codable, Sendable { public var timeFormat: String? public var timeZoneName: String? public var url: String? + + public init( + appUrl: String? = nil, + firstWeekDay: String? = nil, + timeFormat: String? = nil, + timeZoneName: String? = nil, + url: String? = nil + ) { + self.appUrl = appUrl + self.firstWeekDay = firstWeekDay + self.timeFormat = timeFormat + self.timeZoneName = timeZoneName + self.url = url + } } diff --git a/swift/Sources/Basecamp/Generated/Models/PreferencesPayload.swift b/swift/Sources/Basecamp/Generated/Models/PreferencesPayload.swift index 90767c68d..09dc79e59 100644 --- a/swift/Sources/Basecamp/Generated/Models/PreferencesPayload.swift +++ b/swift/Sources/Basecamp/Generated/Models/PreferencesPayload.swift @@ -5,4 +5,10 @@ public struct PreferencesPayload: Codable, Sendable { public var firstWeekDay: String? public var timeFormat: String? public var timeZoneName: String? + + public init(firstWeekDay: String? = nil, timeFormat: String? = nil, timeZoneName: String? = nil) { + self.firstWeekDay = firstWeekDay + self.timeFormat = timeFormat + self.timeZoneName = timeZoneName + } } diff --git a/swift/Sources/Basecamp/Generated/Models/PreviewableAttachment.swift b/swift/Sources/Basecamp/Generated/Models/PreviewableAttachment.swift index d773f27b8..739866df5 100644 --- a/swift/Sources/Basecamp/Generated/Models/PreviewableAttachment.swift +++ b/swift/Sources/Basecamp/Generated/Models/PreviewableAttachment.swift @@ -10,4 +10,24 @@ public struct PreviewableAttachment: Codable, Sendable { public var id: Int? public var url: String? public var width: Int32? + + public init( + appUrl: String? = nil, + contentType: String? = nil, + filename: String? = nil, + filesize: Int? = nil, + height: Int32? = nil, + id: Int? = nil, + url: String? = nil, + width: Int32? = nil + ) { + self.appUrl = appUrl + self.contentType = contentType + self.filename = filename + self.filesize = filesize + self.height = height + self.id = id + self.url = url + self.width = width + } } diff --git a/swift/Sources/Basecamp/Generated/Models/ProjectAccessResult.swift b/swift/Sources/Basecamp/Generated/Models/ProjectAccessResult.swift index f1ac0f564..186b194c6 100644 --- a/swift/Sources/Basecamp/Generated/Models/ProjectAccessResult.swift +++ b/swift/Sources/Basecamp/Generated/Models/ProjectAccessResult.swift @@ -4,4 +4,9 @@ import Foundation public struct ProjectAccessResult: Codable, Sendable { public var granted: [Person]? public var revoked: [Person]? + + public init(granted: [Person]? = nil, revoked: [Person]? = nil) { + self.granted = granted + self.revoked = revoked + } } diff --git a/swift/Sources/Basecamp/Generated/Models/QuestionReminder.swift b/swift/Sources/Basecamp/Generated/Models/QuestionReminder.swift index 1133771e4..d0207f38a 100644 --- a/swift/Sources/Basecamp/Generated/Models/QuestionReminder.swift +++ b/swift/Sources/Basecamp/Generated/Models/QuestionReminder.swift @@ -6,4 +6,16 @@ public struct QuestionReminder: Codable, Sendable { public var question: Question? public var remindAt: String? public var reminderId: Int? + + public init( + groupOn: String? = nil, + question: Question? = nil, + remindAt: String? = nil, + reminderId: Int? = nil + ) { + self.groupOn = groupOn + self.question = question + self.remindAt = remindAt + self.reminderId = reminderId + } } diff --git a/swift/Sources/Basecamp/Generated/Models/QuestionSchedule.swift b/swift/Sources/Basecamp/Generated/Models/QuestionSchedule.swift index 35a67cccb..2a05e6be1 100644 --- a/swift/Sources/Basecamp/Generated/Models/QuestionSchedule.swift +++ b/swift/Sources/Basecamp/Generated/Models/QuestionSchedule.swift @@ -11,4 +11,26 @@ public struct QuestionSchedule: Codable, Sendable { public var startDate: String? public var weekInstance: Int32? public var weekInterval: Int32? + + public init( + days: [Int32]? = nil, + endDate: String? = nil, + frequency: String? = nil, + hour: Int32? = nil, + minute: Int32? = nil, + monthInterval: Int32? = nil, + startDate: String? = nil, + weekInstance: Int32? = nil, + weekInterval: Int32? = nil + ) { + self.days = days + self.endDate = endDate + self.frequency = frequency + self.hour = hour + self.minute = minute + self.monthInterval = monthInterval + self.startDate = startDate + self.weekInstance = weekInstance + self.weekInterval = weekInterval + } } diff --git a/swift/Sources/Basecamp/Generated/Models/ResumeQuestionResponseContent.swift b/swift/Sources/Basecamp/Generated/Models/ResumeQuestionResponseContent.swift index c0b01b850..76296cddc 100644 --- a/swift/Sources/Basecamp/Generated/Models/ResumeQuestionResponseContent.swift +++ b/swift/Sources/Basecamp/Generated/Models/ResumeQuestionResponseContent.swift @@ -3,4 +3,8 @@ import Foundation public struct ResumeQuestionResponseContent: Codable, Sendable { public var paused: Bool? + + public init(paused: Bool? = nil) { + self.paused = paused + } } diff --git a/swift/Sources/Basecamp/Generated/Models/ScheduleAttributes.swift b/swift/Sources/Basecamp/Generated/Models/ScheduleAttributes.swift index ce678f030..05537243b 100644 --- a/swift/Sources/Basecamp/Generated/Models/ScheduleAttributes.swift +++ b/swift/Sources/Basecamp/Generated/Models/ScheduleAttributes.swift @@ -4,4 +4,9 @@ import Foundation public struct ScheduleAttributes: Codable, Sendable { public var endDate: String? public var startDate: String? + + public init(endDate: String? = nil, startDate: String? = nil) { + self.endDate = endDate + self.startDate = startDate + } } diff --git a/swift/Sources/Basecamp/Generated/Models/TimelineAttachment.swift b/swift/Sources/Basecamp/Generated/Models/TimelineAttachment.swift index f4824f52c..9191ac709 100644 --- a/swift/Sources/Basecamp/Generated/Models/TimelineAttachment.swift +++ b/swift/Sources/Basecamp/Generated/Models/TimelineAttachment.swift @@ -39,4 +39,82 @@ public struct TimelineAttachment: Codable, Sendable { public var url: String? public var visibleToClients: Bool? public var width: Int32? + + public init( + appDownloadUrl: String? = nil, + appUrl: String? = nil, + attachableSgid: String? = nil, + bookmarkUrl: String? = nil, + boostsCount: Int32? = nil, + boostsUrl: String? = nil, + bucket: TodoBucket? = nil, + byteSize: Int? = nil, + caption: String? = nil, + commentsCount: Int32? = nil, + commentsUrl: String? = nil, + contentType: String? = nil, + createdAt: String? = nil, + creator: Person? = nil, + description: String? = nil, + descriptionAttachments: [RichTextAttachment]? = nil, + downloadUrl: String? = nil, + filename: String? = nil, + height: Int32? = nil, + id: Int? = nil, + inheritsStatus: Bool? = nil, + key: String? = nil, + parent: RecordingParent? = nil, + position: Int32? = nil, + previewUrl: String? = nil, + previewable: Bool? = nil, + sgid: String? = nil, + status: String? = nil, + statusUrl: String? = nil, + subscriptionUrl: String? = nil, + thumbnailUrl: String? = nil, + title: String? = nil, + type: String? = nil, + updatedAt: String? = nil, + url: String? = nil, + visibleToClients: Bool? = nil, + width: Int32? = nil + ) { + self.appDownloadUrl = appDownloadUrl + self.appUrl = appUrl + self.attachableSgid = attachableSgid + self.bookmarkUrl = bookmarkUrl + self.boostsCount = boostsCount + self.boostsUrl = boostsUrl + self.bucket = bucket + self.byteSize = byteSize + self.caption = caption + self.commentsCount = commentsCount + self.commentsUrl = commentsUrl + self.contentType = contentType + self.createdAt = createdAt + self.creator = creator + self.description = description + self.descriptionAttachments = descriptionAttachments + self.downloadUrl = downloadUrl + self.filename = filename + self.height = height + self.id = id + self.inheritsStatus = inheritsStatus + self.key = key + self.parent = parent + self.position = position + self.previewUrl = previewUrl + self.previewable = previewable + self.sgid = sgid + self.status = status + self.statusUrl = statusUrl + self.subscriptionUrl = subscriptionUrl + self.thumbnailUrl = thumbnailUrl + self.title = title + self.type = type + self.updatedAt = updatedAt + self.url = url + self.visibleToClients = visibleToClients + self.width = width + } } diff --git a/swift/Sources/Basecamp/Generated/Models/TimelineEvent.swift b/swift/Sources/Basecamp/Generated/Models/TimelineEvent.swift index c8e925a44..2d1880b6f 100644 --- a/swift/Sources/Basecamp/Generated/Models/TimelineEvent.swift +++ b/swift/Sources/Basecamp/Generated/Models/TimelineEvent.swift @@ -17,4 +17,38 @@ public struct TimelineEvent: Codable, Sendable { public var target: String? public var title: String? public var url: String? + + public init( + action: String? = nil, + appUrl: String? = nil, + attachments: [TimelineAttachment]? = nil, + avatarsSample: [String]? = nil, + bucket: TodoBucket? = nil, + createdAt: String? = nil, + creator: Person? = nil, + data: TimelineEventData? = nil, + id: Int? = nil, + kind: String? = nil, + parentRecordingId: Int? = nil, + summaryExcerpt: String? = nil, + target: String? = nil, + title: String? = nil, + url: String? = nil + ) { + self.action = action + self.appUrl = appUrl + self.attachments = attachments + self.avatarsSample = avatarsSample + self.bucket = bucket + self.createdAt = createdAt + self.creator = creator + self.data = data + self.id = id + self.kind = kind + self.parentRecordingId = parentRecordingId + self.summaryExcerpt = summaryExcerpt + self.target = target + self.title = title + self.url = url + } } diff --git a/swift/Sources/Basecamp/Generated/Models/UpdateQuestionNotificationSettingsResponseContent.swift b/swift/Sources/Basecamp/Generated/Models/UpdateQuestionNotificationSettingsResponseContent.swift index a89dd46af..512047c87 100644 --- a/swift/Sources/Basecamp/Generated/Models/UpdateQuestionNotificationSettingsResponseContent.swift +++ b/swift/Sources/Basecamp/Generated/Models/UpdateQuestionNotificationSettingsResponseContent.swift @@ -4,4 +4,9 @@ import Foundation public struct UpdateQuestionNotificationSettingsResponseContent: Codable, Sendable { public var responding: Bool? public var subscribed: Bool? + + public init(responding: Bool? = nil, subscribed: Bool? = nil) { + self.responding = responding + self.subscribed = subscribed + } } diff --git a/swift/Sources/Basecamp/Generated/Models/WebhookCopy.swift b/swift/Sources/Basecamp/Generated/Models/WebhookCopy.swift index ee638b7eb..a55381598 100644 --- a/swift/Sources/Basecamp/Generated/Models/WebhookCopy.swift +++ b/swift/Sources/Basecamp/Generated/Models/WebhookCopy.swift @@ -6,4 +6,16 @@ public struct WebhookCopy: Codable, Sendable { public var bucket: WebhookCopyBucket? public var id: Int? public var url: String? + + public init( + appUrl: String? = nil, + bucket: WebhookCopyBucket? = nil, + id: Int? = nil, + url: String? = nil + ) { + self.appUrl = appUrl + self.bucket = bucket + self.id = id + self.url = url + } } diff --git a/swift/Sources/Basecamp/Generated/Models/WebhookCopyBucket.swift b/swift/Sources/Basecamp/Generated/Models/WebhookCopyBucket.swift index 3fd5f0239..6f7ec7d3e 100644 --- a/swift/Sources/Basecamp/Generated/Models/WebhookCopyBucket.swift +++ b/swift/Sources/Basecamp/Generated/Models/WebhookCopyBucket.swift @@ -3,4 +3,8 @@ import Foundation public struct WebhookCopyBucket: Codable, Sendable { public var id: Int? + + public init(id: Int? = nil) { + self.id = id + } } diff --git a/swift/Sources/Basecamp/Generated/Models/WebhookDelivery.swift b/swift/Sources/Basecamp/Generated/Models/WebhookDelivery.swift index 1fd7f5c8e..dbbf37e01 100644 --- a/swift/Sources/Basecamp/Generated/Models/WebhookDelivery.swift +++ b/swift/Sources/Basecamp/Generated/Models/WebhookDelivery.swift @@ -6,4 +6,16 @@ public struct WebhookDelivery: Codable, Sendable { public var id: Int? public var request: WebhookDeliveryRequest? public var response: WebhookDeliveryResponse? + + public init( + createdAt: String? = nil, + id: Int? = nil, + request: WebhookDeliveryRequest? = nil, + response: WebhookDeliveryResponse? = nil + ) { + self.createdAt = createdAt + self.id = id + self.request = request + self.response = response + } } diff --git a/swift/Sources/Basecamp/Generated/Models/WebhookDeliveryRequest.swift b/swift/Sources/Basecamp/Generated/Models/WebhookDeliveryRequest.swift index 5447e5796..6186f2472 100644 --- a/swift/Sources/Basecamp/Generated/Models/WebhookDeliveryRequest.swift +++ b/swift/Sources/Basecamp/Generated/Models/WebhookDeliveryRequest.swift @@ -4,4 +4,9 @@ import Foundation public struct WebhookDeliveryRequest: Codable, Sendable { public var body: WebhookEvent? public var headers: WebhookHeadersMap? + + public init(body: WebhookEvent? = nil, headers: WebhookHeadersMap? = nil) { + self.body = body + self.headers = headers + } } diff --git a/swift/Sources/Basecamp/Generated/Models/WebhookDeliveryResponse.swift b/swift/Sources/Basecamp/Generated/Models/WebhookDeliveryResponse.swift index 84d3684c0..23dbcd0f4 100644 --- a/swift/Sources/Basecamp/Generated/Models/WebhookDeliveryResponse.swift +++ b/swift/Sources/Basecamp/Generated/Models/WebhookDeliveryResponse.swift @@ -5,4 +5,10 @@ public struct WebhookDeliveryResponse: Codable, Sendable { public var code: Int32? public var headers: WebhookHeadersMap? public var message: String? + + public init(code: Int32? = nil, headers: WebhookHeadersMap? = nil, message: String? = nil) { + self.code = code + self.headers = headers + self.message = message + } } diff --git a/swift/Sources/Basecamp/Generated/Models/WebhookEvent.swift b/swift/Sources/Basecamp/Generated/Models/WebhookEvent.swift index 02170960c..afcc3be6b 100644 --- a/swift/Sources/Basecamp/Generated/Models/WebhookEvent.swift +++ b/swift/Sources/Basecamp/Generated/Models/WebhookEvent.swift @@ -9,4 +9,22 @@ public struct WebhookEvent: Codable, Sendable { public var id: Int? public var kind: String? public var recording: Recording? + + public init( + copy: WebhookCopy? = nil, + createdAt: String? = nil, + creator: Person? = nil, + details: String? = nil, + id: Int? = nil, + kind: String? = nil, + recording: Recording? = nil + ) { + self.copy = copy + self.createdAt = createdAt + self.creator = creator + self.details = details + self.id = id + self.kind = kind + self.recording = recording + } } diff --git a/swift/Sources/BasecampGenerator/ModelEmitter.swift b/swift/Sources/BasecampGenerator/ModelEmitter.swift index e89b95420..6f2303a86 100644 --- a/swift/Sources/BasecampGenerator/ModelEmitter.swift +++ b/swift/Sources/BasecampGenerator/ModelEmitter.swift @@ -213,37 +213,46 @@ func emitEntityModel(schemaName: String, schemas: [String: Any]) -> String { } } - if !requiredFields.isEmpty { - lines.append("") - var initParams: [String] = [] - for propName in orderedProps { - guard let propSchema = properties[propName] as? [String: Any] else { continue } - let baseType = schemaToSwiftType(propSchema) - let camelName = toCamelCase(propName) - let required = requiredFields.contains(propName) - let valueOptional = schemaIsNullable(propSchema) || !required - let propType = baseType + (valueOptional ? "?" : "") - // Required members take no default (caller must supply presence). - initParams.append(required ? "\(camelName): \(propType)" : "\(camelName): \(propType) = nil") - } + // Emitted unconditionally, matching `emitRequestModel`. Swift's implicit + // memberwise initializer is `internal`, so a struct without an explicit + // `public init` is unconstructible outside the module — an all-optional + // model would otherwise compile in-repo (every test uses `@testable + // import`) and be uncallable for a consumer that plain-`import`s the SDK + // (#735). `Sources/BasecampPublicAPIConsumer` is the target that observes + // this from outside. + lines.append("") + var initParams: [String] = [] + for propName in orderedProps { + guard let propSchema = properties[propName] as? [String: Any] else { continue } + let baseType = schemaToSwiftType(propSchema) + let camelName = toCamelCase(propName) + let required = requiredFields.contains(propName) + let valueOptional = schemaIsNullable(propSchema) || !required + let propType = baseType + (valueOptional ? "?" : "") + // Required members take no default (caller must supply presence). + initParams.append(required ? "\(camelName): \(propType)" : "\(camelName): \(propType) = nil") + } - if initParams.count <= 3 { - lines.append(" public init(\(initParams.joined(separator: ", "))) {") - } else { - lines.append(" public init(") - for (i, param) in initParams.enumerated() { - let comma = i < initParams.count - 1 ? "," : "" - lines.append(" \(param)\(comma)") - } - lines.append(" ) {") + if initParams.count <= 3 { + lines.append(" public init(\(initParams.joined(separator: ", "))) {") + } else { + lines.append(" public init(") + for (i, param) in initParams.enumerated() { + let comma = i < initParams.count - 1 ? "," : "" + lines.append(" \(param)\(comma)") } + lines.append(" ) {") + } - for propName in orderedProps { - let camelName = toCamelCase(propName) - lines.append(" self.\(camelName) = \(camelName)") - } - lines.append(" }") + // Same guard as the two loops above: a property whose schema is not a + // dictionary declares no member and takes no parameter, so it must not be + // assigned either. The three loops have to agree on which properties exist. + for propName in orderedProps { + guard properties[propName] is [String: Any] else { continue } + let camelName = toCamelCase(propName) + lines.append(" self.\(camelName) = \(camelName)") } + lines.append(" }") // Synthesized Codable treats an optional-typed property as decodeIfPresent // (missing OK) and omits nil on encode — which is wrong for a diff --git a/swift/Sources/BasecampPublicAPIConsumer/PublicAPIConsumer.swift b/swift/Sources/BasecampPublicAPIConsumer/PublicAPIConsumer.swift new file mode 100644 index 000000000..5d7220d1e --- /dev/null +++ b/swift/Sources/BasecampPublicAPIConsumer/PublicAPIConsumer.swift @@ -0,0 +1,138 @@ +// A stand-in for a customer's app. This target imports the SDK the way an +// external package does — plain `import Basecamp`, never `@testable import` — +// and it exists solely to be compiled. +// +// Why it exists (#735): Swift's implicit memberwise initializer is `internal`, +// so a generated model with no explicit `public init` is unconstructible +// outside the module. All 30 files in Tests/BasecampTests use +// `@testable import Basecamp`, which raises internal to visible, so 35 +// all-optional models — including two *request* payloads, which made their +// operations uncallable — compiled fine in-repo and shipped broken. Nothing in +// the package built against the public surface, so no CI job modelled a +// customer. The missing init was the defect; the missing consumer is why it +// took a customer to find it. +// +// Two rules keep this target honest, both enforced by PublicInitCoverageTests: +// +// 1. It must never use `@testable`. That single word re-opens the exact +// blind spot this target closes, and it is the natural "fix" for anyone +// hitting a compile error here. +// 2. It must stay a non-test target. `swift build` compiles it — so +// `make swift-build`, `make swift-check`, the Swift CI job, the release +// workflow, and the CodeQL Swift build all cover it, not just `swift test`. +// +// Nothing here performs I/O; the async entry points are type-checked, never run. + +import Basecamp +import Foundation + +/// Public-surface exercises. Every member is written the way a consumer would +/// write it, using only what `import Basecamp` exports. +public enum PublicAPIConsumer { + // MARK: - The #735 case: all-optional request payloads + + /// `GaugeNeedleUpdatePayload` has one optional member and no required one. + /// Before #735 this function could not be written outside the module: the + /// payload had no `public init`, so `UpdateGaugeNeedleRequest(gaugeNeedle:)` + /// could only ever be handed `nil` — an empty `{}` PUT that bc3 rejects. + public static func updateGaugeNeedleDescription( + account: AccountClient, + needleId: Int, + description: String + ) async throws -> GaugeNeedle { + var payload = GaugeNeedleUpdatePayload() + payload.description = description + return try await account.gauges.updateGaugeNeedle( + needleId: needleId, + req: UpdateGaugeNeedleRequest(gaugeNeedle: payload) + ) + } + + /// The same defect on the other affected operation: `PreferencesPayload` is + /// all-optional, and `UpdateMyPreferences` carries nothing else. + public static func updateMyTimeZone( + account: AccountClient, + timeZoneName: String + ) async throws -> Preferences { + let payload = PreferencesPayload(timeZoneName: timeZoneName) + return try await account.people.updateMyPreferences( + req: UpdateMyPreferencesRequest(person: payload) + ) + } + + // MARK: - Client construction + + /// The documented entry point, exercised end to end so the consumer path is + /// not just model construction. + public static func makeAccountClient(accessToken: String, accountId: String) -> AccountClient { + let client = BasecampClient( + accessToken: accessToken, + userAgent: "PublicAPIConsumer/1.0 (sdk@basecamp.com)" + ) + return client.forAccount(accountId) + } + + // MARK: - Every all-optional model, constructed from outside the module + + /// The full roster of generated models that carry no required member, each + /// built with the zero-argument initializer a consumer needs. + /// + /// Listing all of them rather than a sample is deliberate: each line is an + /// independent compile-time assertion, so reverting the generator fix + /// produces 35 errors here instead of one that could be argued away. A + /// model that disappears or is renamed also breaks this list, which is the + /// intended signal — the roster is meant to be re-read, not auto-followed. + /// + /// The complementary guarantee lives in `PublicInitCoverageTests`, which scans + /// *every* generated model for a `public init` and so covers models added + /// after this list was written. + public static func allOptionalModelsAreConstructibleFromOutsideTheModule() { + _ = AccountLimits() + _ = AccountLogo() + _ = AccountSettings() + _ = AccountSubscription() + _ = CampfireLineAttachment() + _ = ClientApprovalResponse() + _ = ClientSide() + _ = CreateAttachmentResponseContent() + _ = DoorService() + _ = EventDetails() + _ = EverythingFile() + _ = GaugeNeedleUpdatePayload() + _ = GetAssignedTodosResponseContent() + _ = GetMyAssignmentsResponseContent() + _ = GetOverdueTodosResponseContent() + _ = GetPersonProgressResponseContent() + _ = OutOfOffice() + _ = PauseQuestionResponseContent() + _ = Preferences() + _ = PreferencesPayload() + _ = PreviewableAttachment() + _ = ProjectAccessResult() + _ = QuestionReminder() + _ = QuestionSchedule() + _ = ResumeQuestionResponseContent() + _ = ScheduleAttributes() + _ = TimelineAttachment() + _ = TimelineEvent() + _ = UpdateQuestionNotificationSettingsResponseContent() + _ = WebhookCopy() + _ = WebhookCopyBucket() + _ = WebhookDelivery() + _ = WebhookDeliveryRequest() + _ = WebhookDeliveryResponse() + _ = WebhookEvent() + } + + // MARK: - Models that already had an init, so the fix is checked both ways + + /// `GaugeNeedlePayload` has a required `position` and was always + /// constructible. Keeping it here proves the generator change widened the + /// init to all-optional models without altering the required-member shape: + /// required parameters still take no default, optional ones still default + /// to nil. + public static func requiredMemberModelsKeepTheirInitShape() { + _ = GaugeNeedlePayload(position: 50) + _ = GaugeNeedlePayload(position: 50, color: "#00ff00", description: "
note
") + } +} diff --git a/swift/Tests/BasecampTests/PublicInitCoverageTests.swift b/swift/Tests/BasecampTests/PublicInitCoverageTests.swift new file mode 100644 index 000000000..62d576f67 --- /dev/null +++ b/swift/Tests/BasecampTests/PublicInitCoverageTests.swift @@ -0,0 +1,177 @@ +import XCTest + +@testable import BasecampGenerator + +/// #735: a generated model with no required member got no `public init`, and +/// Swift's implicit memberwise initializer is `internal` — so 35 models, two of +/// them request payloads, were unconstructible outside the module. Every file in +/// this test target uses `@testable import`, which raises internal to visible, +/// so the whole in-repo suite compiled against a surface no consumer has. +/// +/// Two instruments, deliberately different in what they can see: +/// +/// * the unit tests below hold the *emitter's* shape — they run the generator +/// directly, so they fail on the source change alone, before regeneration; +/// * `Sources/BasecampPublicAPIConsumer` holds the *behavior* — it plain- +/// `import`s the SDK, so it observes real access control rather than source +/// text, and it is compiled by `swift build`. +/// +/// Neither covers the other's gap. The consumer target names a fixed roster and +/// cannot see a model added next week; the roster scan here sees every model but +/// only as text. The `@testable` guard at the end is what keeps the consumer +/// target from quietly becoming a second copy of this one. +final class PublicInitCoverageTests: XCTestCase { + private var repoRoot: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // BasecampTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // swift + } + + // MARK: - Emitter shape + + /// The regression itself: an all-optional entity schema must still get an + /// explicit `public init`. Against pre-fix `ModelEmitter.swift` this fails. + func testAllOptionalEntityModelGetsAPublicInit() { + let schemas: [String: Any] = [ + "GaugeNeedleUpdatePayload": [ + "type": "object", + "properties": [ + "description": ["type": "string"] + ], + ] + ] + let code = emitEntityModel(schemaName: "GaugeNeedleUpdatePayload", schemas: schemas) + + XCTAssertTrue( + code.contains("public init(description: String? = nil) {"), + "an all-optional model must be constructible outside the module:\n\(code)") + XCTAssertTrue(code.contains("self.description = description"), code) + } + + /// A model with no properties at all is still a struct a consumer may be + /// handed back and want to build. The zero-parameter init is valid Swift and + /// must be emitted rather than skipped as a special case. + func testEmptyEntityModelGetsAZeroParameterPublicInit() { + let schemas: [String: Any] = [ + "Empty": ["type": "object", "properties": [String: Any]()] + ] + let code = emitEntityModel(schemaName: "Empty", schemas: schemas) + + XCTAssertTrue(code.contains("public init() {"), code) + } + + /// The fix widened *when* an init is emitted; it must not have changed + /// *what* is emitted for models that already had one. Required members keep + /// `let` with no default; optional members keep `var` with `= nil`. + func testRequiredMemberModelKeepsItsInitShape() { + let schemas: [String: Any] = [ + "GaugeNeedlePayload": [ + "type": "object", + "required": ["position"], + "properties": [ + "position": ["type": "integer", "format": "int32"], + "color": ["type": "string"], + ], + ] + ] + let code = emitEntityModel(schemaName: "GaugeNeedlePayload", schemas: schemas) + + XCTAssertTrue(code.contains("public let position: Int32"), code) + XCTAssertTrue(code.contains("public var color: String?"), code) + XCTAssertTrue( + code.contains("public init(position: Int32, color: String? = nil) {"), + "required members take no default, optional members default to nil:\n\(code)") + } + + // MARK: - Roster scan + + /// Covers models added after the consumer target's roster was written: every + /// generated `public struct` must carry a `public init`. Enums and + /// typealiases are exempt — an enum case is already a public constructor and + /// a typealias has no initializer of its own. + func testEveryGeneratedModelStructDeclaresAPublicInit() throws { + let modelsDir = repoRoot.appendingPathComponent("Sources/Basecamp/Generated/Models") + let files = try FileManager.default.contentsOfDirectory(atPath: modelsDir.path) + .filter { $0.hasSuffix(".swift") } + .sorted() + + XCTAssertGreaterThan(files.count, 200, "expected the full generated model roster") + + var missing: [String] = [] + var structs = 0 + for file in files { + let source = try String( + contentsOf: modelsDir.appendingPathComponent(file), encoding: .utf8) + let lines = source.components(separatedBy: "\n") + guard lines.contains(where: { $0.hasPrefix("public struct ") }) else { continue } + structs += 1 + if !lines.contains(where: { $0.hasPrefix(" public init(") }) { + missing.append(file) + } + } + + XCTAssertGreaterThan(structs, 180, "expected most generated models to be structs") + XCTAssertEqual( + missing, [], + "these generated models have no public init, so no consumer that " + + "plain-`import`s Basecamp can construct them (#735)") + } + + // MARK: - Blind-spot guards + + /// The consumer target only proves anything while it imports the SDK the way + /// a customer does. `@testable` there would raise internal to visible again + /// and silently restore the exact gap — and it is the obvious way to make a + /// compile error in that target go away. + func testConsumerTargetImportsBasecampWithoutTestable() throws { + let consumerDir = repoRoot.appendingPathComponent("Sources/BasecampPublicAPIConsumer") + let files = try FileManager.default.contentsOfDirectory(atPath: consumerDir.path) + .filter { $0.hasSuffix(".swift") } + .sorted() + + XCTAssertFalse(files.isEmpty, "the public-API consumer target has no sources") + + var sawPlainImport = false + for file in files { + let source = try String( + contentsOf: consumerDir.appendingPathComponent(file), encoding: .utf8) + for line in source.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + XCTAssertFalse( + trimmed.hasPrefix("@testable"), + "\(file) uses @testable, which re-opens the #735 blind spot this " + + "target exists to close") + if trimmed == "import Basecamp" { sawPlainImport = true } + } + } + XCTAssertTrue(sawPlainImport, "no source in the consumer target plain-imports Basecamp") + } + + /// Declared with `.target`, not `.testTarget`, on purpose: `swift build` + /// compiles it, so `make swift-build`, `make swift-check`, the Swift CI job, + /// the release workflow and the CodeQL Swift build all cover it. Demoting it + /// to a test target would narrow that to `swift test` without any visible + /// signal. + func testConsumerIsDeclaredAsANonTestTarget() throws { + let manifest = try String( + contentsOf: repoRoot.appendingPathComponent("Package.swift"), encoding: .utf8) + + guard let nameRange = manifest.range(of: "name: \"BasecampPublicAPIConsumer\"") else { + return XCTFail("Package.swift does not declare BasecampPublicAPIConsumer") + } + + let preceding = manifest[manifest.startIndex.. Date: Sun, 16 Aug 2026 22:03:44 -0700 Subject: [PATCH 2/6] Cover the non-schema-property branch, and drop two counts from the prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review points from the bot, all correct. The drive-by that made the assignment loop skip a property whose schema is not a dictionary had no test. It is unreachable through today's `openapi.json` — every property there is a dictionary — but a branch with no test is how the two loops drifted apart in the first place, so assert it directly: a mixed schema emits the valid property's declaration, parameter and assignment, and nothing at all for the non-schema one. Removing the guard turns that test red. The header comments stated file counts ("all 30 files in Tests/BasecampTests"), which were already off and would drift with every test added. The claim that matters is an invariant, not a census: every test source that imports the SDK imports it as `@testable import Basecamp`, and none plain-imports it. Both comments now say that. Also names the one way this target is not a customer: it lives in the same SwiftPM package, so `package`-level declarations are visible to it and not to an external consumer. Nothing here touches one. Closing that would take a separate nested package, which would drop out of `swift build` and need its own CI step, and the failure class this target exists for is internal-vs-public, which it does observe. --- .../PublicAPIConsumer.swift | 22 +++++++++---- .../PublicInitCoverageTests.swift | 32 +++++++++++++++++-- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/swift/Sources/BasecampPublicAPIConsumer/PublicAPIConsumer.swift b/swift/Sources/BasecampPublicAPIConsumer/PublicAPIConsumer.swift index 5d7220d1e..82cbcfc50 100644 --- a/swift/Sources/BasecampPublicAPIConsumer/PublicAPIConsumer.swift +++ b/swift/Sources/BasecampPublicAPIConsumer/PublicAPIConsumer.swift @@ -4,13 +4,13 @@ // // Why it exists (#735): Swift's implicit memberwise initializer is `internal`, // so a generated model with no explicit `public init` is unconstructible -// outside the module. All 30 files in Tests/BasecampTests use -// `@testable import Basecamp`, which raises internal to visible, so 35 -// all-optional models — including two *request* payloads, which made their -// operations uncallable — compiled fine in-repo and shipped broken. Nothing in -// the package built against the public surface, so no CI job modelled a -// customer. The missing init was the defect; the missing consumer is why it -// took a customer to find it. +// outside the module. Every test source in Tests/BasecampTests that imports the +// SDK imports it as `@testable import Basecamp`, which raises internal to +// visible, and none plain-imports it — so 35 all-optional models, two of them +// *request* payloads whose operations were therefore uncallable, compiled fine +// in-repo and shipped broken. Nothing in the package built against the public +// surface, so no CI job modelled a customer. The missing init was the defect; +// the missing consumer is why it took a customer to find it. // // Two rules keep this target honest, both enforced by PublicInitCoverageTests: // @@ -21,6 +21,14 @@ // `make swift-build`, `make swift-check`, the Swift CI job, the release // workflow, and the CodeQL Swift build all cover it, not just `swift test`. // +// One way this target is *not* a customer, stated so nobody assumes otherwise: +// it lives in the same SwiftPM package, so `package`-level declarations — e.g. +// `BasecampClient.httpClient` — are visible here and are not visible to an +// external package. Nothing below touches one, and nothing added below should. +// Closing that last gap would mean a separate nested package, which would drop +// out of `swift build` and need its own CI step; the failure class this target +// exists for is internal-vs-public, which it does observe. +// // Nothing here performs I/O; the async entry points are type-checked, never run. import Basecamp diff --git a/swift/Tests/BasecampTests/PublicInitCoverageTests.swift b/swift/Tests/BasecampTests/PublicInitCoverageTests.swift index 62d576f67..a1070fbdb 100644 --- a/swift/Tests/BasecampTests/PublicInitCoverageTests.swift +++ b/swift/Tests/BasecampTests/PublicInitCoverageTests.swift @@ -4,9 +4,10 @@ import XCTest /// #735: a generated model with no required member got no `public init`, and /// Swift's implicit memberwise initializer is `internal` — so 35 models, two of -/// them request payloads, were unconstructible outside the module. Every file in -/// this test target uses `@testable import`, which raises internal to visible, -/// so the whole in-repo suite compiled against a surface no consumer has. +/// them request payloads, were unconstructible outside the module. Every test +/// source here that imports the SDK imports it as `@testable import Basecamp`, +/// which raises internal to visible, and none plain-imports it — so the in-repo +/// suite compiled against a surface no consumer has. /// /// Two instruments, deliberately different in what they can see: /// @@ -84,6 +85,31 @@ final class PublicInitCoverageTests: XCTestCase { "required members take no default, optional members default to nil:\n\(code)") } + /// The three loops that build a struct — declare, parameterize, assign — + /// must agree on which properties exist. Only the first two skipped a + /// property whose schema is not a dictionary; the third would have emitted + /// `self.x = x` for a parameter that was never declared. Unreachable through + /// today's `openapi.json`, where every property is a dictionary, but the + /// init is now emitted for every model rather than only those with a + /// required member, so the disagreement had more surface to bite on. + func testNonDictionaryPropertyIsSkippedByAllThreeLoops() { + let schemas: [String: Any] = [ + "Mixed": [ + "type": "object", + "properties": [ + "title": ["type": "string"], + "bogus": "not-a-schema", + ], + ] + ] + let code = emitEntityModel(schemaName: "Mixed", schemas: schemas) + + XCTAssertTrue(code.contains("public var title: String?"), code) + XCTAssertTrue(code.contains("public init(title: String? = nil) {"), code) + XCTAssertTrue(code.contains("self.title = title"), code) + XCTAssertFalse(code.contains("bogus"), "a non-schema property must not be emitted:\n\(code)") + } + // MARK: - Roster scan /// Covers models added after the consumer target's roster was written: every From f79d989986a14001ce86ffcfb5c6b23acbb3b748 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 16 Aug 2026 22:22:03 -0700 Subject: [PATCH 3/6] Assert the skipped property per loop, not just once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blanket "the identifier appears nowhere in the output" assertion already forbade a declaration, a parameter and an assignment — absence is strictly stronger than three presence checks — but it could not say which loop leaked. Split it: one negative per emission shape, plus the blanket check behind them, so a regression names the loop instead of reporting "it leaked somewhere". Positive controls for the valid property, one per loop, sit alongside. Worth recording why only one of the three loops can regress this way: the declaration and parameter loops bind `propSchema` in their own `guard let`, so deleting that guard does not compile (`cannot find 'propSchema' in scope`, verified by deleting each). The assignment loop took no value from the property, which is exactly why its guard could go missing unnoticed — and it is the one the mutation check exercises, now failing on the loop-specific assertion. --- .../BasecampTests/PublicInitCoverageTests.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/swift/Tests/BasecampTests/PublicInitCoverageTests.swift b/swift/Tests/BasecampTests/PublicInitCoverageTests.swift index a1070fbdb..cf759745a 100644 --- a/swift/Tests/BasecampTests/PublicInitCoverageTests.swift +++ b/swift/Tests/BasecampTests/PublicInitCoverageTests.swift @@ -104,9 +104,21 @@ final class PublicInitCoverageTests: XCTestCase { ] let code = emitEntityModel(schemaName: "Mixed", schemas: schemas) + // Positive control, one per loop: the valid property is declared, + // parameterized and assigned. XCTAssertTrue(code.contains("public var title: String?"), code) XCTAssertTrue(code.contains("public init(title: String? = nil) {"), code) XCTAssertTrue(code.contains("self.title = title"), code) + + // The non-schema property, asserted per loop so a failure names the one + // that regressed rather than just "it leaked somewhere". + XCTAssertFalse( + code.contains("public var bogus"), "declaration loop emitted it:\n\(code)") + XCTAssertFalse(code.contains("bogus:"), "init-parameter loop emitted it:\n\(code)") + XCTAssertFalse(code.contains("self.bogus"), "assignment loop emitted it:\n\(code)") + + // And the blanket check, which is what actually forbids it: the three + // above name the known emission shapes, this one catches any other. XCTAssertFalse(code.contains("bogus"), "a non-schema property must not be emitted:\n\(code)") } From d8c31dee87afa8cfc4c6b4d3b63eaf00df7a1513 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 16 Aug 2026 22:44:42 -0700 Subject: [PATCH 4/6] Carry the narrowed invariant into the emitter's own comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same inaccurate claim the other two headers dropped survived here: "every test uses `@testable import`" is false for any test that does not import the SDK at all — this repo has several, including the new coverage test, which imports only BasecampGenerator. Say the invariant that actually carries the argument instead: every test source that imports the SDK imports it as `@testable import Basecamp`, and none plain-imports it (31 vs 0 in the current tree). Comment-only, inside the generator rather than in anything it emits: regenerating produces a zero-line diff under Generated/. --- swift/Sources/BasecampGenerator/ModelEmitter.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/swift/Sources/BasecampGenerator/ModelEmitter.swift b/swift/Sources/BasecampGenerator/ModelEmitter.swift index 6f2303a86..81826744c 100644 --- a/swift/Sources/BasecampGenerator/ModelEmitter.swift +++ b/swift/Sources/BasecampGenerator/ModelEmitter.swift @@ -216,10 +216,11 @@ func emitEntityModel(schemaName: String, schemas: [String: Any]) -> String { // Emitted unconditionally, matching `emitRequestModel`. Swift's implicit // memberwise initializer is `internal`, so a struct without an explicit // `public init` is unconstructible outside the module — an all-optional - // model would otherwise compile in-repo (every test uses `@testable - // import`) and be uncallable for a consumer that plain-`import`s the SDK - // (#735). `Sources/BasecampPublicAPIConsumer` is the target that observes - // this from outside. + // model would otherwise compile in-repo (every test source that imports + // the SDK imports it as `@testable import Basecamp`, and none plain-imports + // it) and be uncallable for a consumer that plain-`import`s the SDK (#735). + // `Sources/BasecampPublicAPIConsumer` is the target that observes this from + // outside. lines.append("") var initParams: [String] = [] for propName in orderedProps { From 3e9be682202c2a0e8e6e01c6d68c9b25696a776f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 16 Aug 2026 23:07:41 -0700 Subject: [PATCH 5/6] Stop the import guard from missing a real plain import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two suppressed review findings, both correct. They never became threads, so they were only visible inside the review body. The plain-import check was exact string equality, so `import Basecamp // note` or a doubled space read as "no plain import at all". That is a false negative in the guard whose entire job is to notice when the consumer target stops importing the SDK the way a customer does — an unrecognized import and a missing one look identical to it. Same brittleness class as the `@testable` blind spot, one level up. The obvious repair is a prefix check, and it is wrong the other way: `import BasecampGenerator` starts with `import Basecamp`. So tokenize — drop a `//` comment, ignore surrounding whitespace and a trailing semicolon, require exactly the two tokens. `isPlainBasecampImport` is now a named function with a table test over the spellings that must and must not count, because the disk scan only ever sees the one line the consumer happens to contain today. Both mutations are red: exact equality fails the trailing-comment case, a prefix check fails `import BasecampGenerator`. The roster scan's bounds were `files.count > 200` / `structs > 180`, close enough to the real numbers that ordinary spec churn would have failed this test for an unrelated reason. But dropping them for "non-empty" gives up something real: a scan that finds three files is as vacuous as one that finds none, and looks just as green. Kept as an extraction floor an order of magnitude below the true count (220), which only trips when the scan has lost ~90% of the roster. Demonstrated rather than asserted: with a deliberately broken file filter the test is red with the floor and vacuously green without it. The reasoning is in the test, not just here. --- .../PublicInitCoverageTests.swift | 86 ++++++++++++++++++- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/swift/Tests/BasecampTests/PublicInitCoverageTests.swift b/swift/Tests/BasecampTests/PublicInitCoverageTests.swift index cf759745a..41e215d61 100644 --- a/swift/Tests/BasecampTests/PublicInitCoverageTests.swift +++ b/swift/Tests/BasecampTests/PublicInitCoverageTests.swift @@ -134,7 +134,26 @@ final class PublicInitCoverageTests: XCTestCase { .filter { $0.hasSuffix(".swift") } .sorted() - XCTAssertGreaterThan(files.count, 200, "expected the full generated model roster") + // An extraction floor, not a count assertion — and the distinction is the + // whole point. The real failure it stops is a *collapsed scan*: a renamed + // or moved Models directory, or a filter that stops matching, leaves + // `files` empty, the loop below never runs, `missing` stays `[]`, and the + // test passes while checking nothing. Asserting only "non-empty" would + // also catch the total collapse, but not a partial one — a scan that + // finds three files is just as vacuous and looks just as green. + // + // So the floor sits an order of magnitude below the true count (220 at + // the time of writing) rather than just under it. A tight bound would + // turn ordinary spec churn into an unrelated failure here, which is the + // brittleness the review flagged; this one only trips if the scan has + // lost ~90% of the roster, which is never legitimate churn. It is + // deliberately NOT a tripwire for models being added or removed — the + // consumer target's roster already fails to compile on a rename, and + // `missing` below is the assertion that carries the actual contract. + XCTAssertGreaterThan( + files.count, 20, + "only \(files.count) model files found — the scan has collapsed, so " + + "an empty `missing` below would prove nothing") var missing: [String] = [] var structs = 0 @@ -149,13 +168,74 @@ final class PublicInitCoverageTests: XCTestCase { } } - XCTAssertGreaterThan(structs, 180, "expected most generated models to be structs") + // Same floor, same reason: the files could all be read and none of them + // recognized as a struct — a changed emitted prefix would do it — which + // again leaves `missing` empty for the wrong reason. + XCTAssertGreaterThan( + structs, 20, + "only \(structs) of \(files.count) model files parsed as a public " + + "struct — the struct detection has collapsed") XCTAssertEqual( missing, [], "these generated models have no public init, so no consumer that " + "plain-`import`s Basecamp can construct them (#735)") } + // MARK: - Plain-import recognition + + /// Recognizes a plain `import Basecamp` declaration. + /// + /// Exact string equality was the first cut and it was too strict: a trailing + /// comment or a doubled space made a genuine plain import invisible to the + /// guard. A false negative here is the worst kind — the guard's whole job is + /// to notice when the consumer target stops importing the SDK the way a + /// customer does, and an unrecognized-but-real import reads exactly like a + /// missing one. That is the same brittleness class as the `@testable` blind + /// spot this PR exists to close, one level up. + /// + /// A prefix check would be the easy fix and is wrong in the other direction: + /// `import BasecampGenerator` starts with `import Basecamp`. So tokenize + /// instead — drop any `//` comment, ignore surrounding whitespace and a + /// trailing semicolon, and require exactly the two tokens. That accepts the + /// real spellings and still rejects the generator import and `@testable`. + /// + /// Not handled, deliberately: a `/* … */` block comment mid-declaration. + /// Nothing in the repo writes one, and the tokenizer would have to become a + /// lexer to see it. If that ever appears, the guard fails closed — it reports + /// no plain import, which is a loud failure rather than a silent pass. + static func isPlainBasecampImport(_ line: String) -> Bool { + let withoutComment = line.components(separatedBy: "//")[0] + let tokens = + withoutComment + .trimmingCharacters(in: CharacterSet(charactersIn: " \t;")) + .components(separatedBy: .whitespaces) + .filter { !$0.isEmpty } + return tokens == ["import", "Basecamp"] + } + + /// Table-drives the matcher over the spellings that must and must not count. + /// The disk scan below cannot cover these — it only ever sees the one line + /// the consumer target happens to contain today. + func testPlainImportRecognition() { + // Real plain imports, however they are spelled. + XCTAssertTrue(Self.isPlainBasecampImport("import Basecamp")) + XCTAssertTrue( + Self.isPlainBasecampImport("import Basecamp // trailing comment"), + "a trailing comment does not stop it being a plain import") + XCTAssertTrue(Self.isPlainBasecampImport(" import Basecamp ")) + XCTAssertTrue(Self.isPlainBasecampImport("import Basecamp;")) + + // Not plain imports of this module. + XCTAssertFalse( + Self.isPlainBasecampImport("@testable import Basecamp"), + "@testable is the thing the guard exists to catch") + XCTAssertFalse( + Self.isPlainBasecampImport("import BasecampGenerator"), + "a prefix check would wrongly accept this") + XCTAssertFalse(Self.isPlainBasecampImport("import Foundation")) + XCTAssertFalse(Self.isPlainBasecampImport("// import Basecamp")) + } + // MARK: - Blind-spot guards /// The consumer target only proves anything while it imports the SDK the way @@ -180,7 +260,7 @@ final class PublicInitCoverageTests: XCTestCase { trimmed.hasPrefix("@testable"), "\(file) uses @testable, which re-opens the #735 blind spot this " + "target exists to close") - if trimmed == "import Basecamp" { sawPlainImport = true } + if Self.isPlainBasecampImport(line) { sawPlainImport = true } } } XCTAssertTrue(sawPlainImport, "no source in the consumer target plain-imports Basecamp") From 753b5988f5d1bd446da99e9e496ab05c34fbb6be Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 17 Aug 2026 10:56:04 -0700 Subject: [PATCH 6/6] Stop the roster scan from accepting Codable's initializer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan recognized a constructible model by any line starting ` public init(`, which Codable's `init(from decoder:) throws` also matches. Nine generated models carry both that and the memberwise initializer, so deleting the memberwise one from any of them left the scan green while consumers lost the ability to build the model — the test-passes-for-the-wrong-reason failure this file exists to catch. Recognize the memberwise form by what it is rather than blocklisting the one spelling of what it is not: an initializer counts when every parameter it takes names one of the struct's own properties and it takes every property the struct requires at construction. `from` names no property, so the decoding initializer no longer qualifies, and any future initializer is judged by the same rule instead of needing its own exception. A struct that has properties must also be built by an initializer that takes at least one, so a broken label parser cannot make the subset clause hold vacuously. Red-proved on `Draft.swift` — memberwise initializer deleted, decoding one left in place. The old predicate passes that tree, the new one fails naming `Draft.swift`, and `swift build` succeeds throughout: nothing else in the repo observes the loss. Eight of the nine dual-initializer models are constructed nowhere in the suite, so the compiler is not a backstop here. --- .../PublicInitCoverageTests.swift | 217 +++++++++++++++++- 1 file changed, 213 insertions(+), 4 deletions(-) diff --git a/swift/Tests/BasecampTests/PublicInitCoverageTests.swift b/swift/Tests/BasecampTests/PublicInitCoverageTests.swift index 41e215d61..2bd795349 100644 --- a/swift/Tests/BasecampTests/PublicInitCoverageTests.swift +++ b/swift/Tests/BasecampTests/PublicInitCoverageTests.swift @@ -122,10 +122,218 @@ final class PublicInitCoverageTests: XCTestCase { XCTAssertFalse(code.contains("bogus"), "a non-schema property must not be emitted:\n\(code)") } + // MARK: - Memberwise-initializer recognition + + /// The stored properties a generated model declares, paired with whether the + /// caller must supply one at construction. The emitter writes required + /// members as `let` and optional ones as `var`, so the keyword carries that. + static func declaredProperties(in source: String) -> [(name: String, required: Bool)] { + source.components(separatedBy: "\n").compactMap { line in + let keyword = [(" public let ", true), (" public var ", false)] + .first { line.hasPrefix($0.0) } + return keyword.flatMap { prefix, required in + line.dropFirst(prefix.count) + .components(separatedBy: ":").first + .map { (name: $0.trimmingCharacters(in: .whitespaces), required: required) } + } + } + } + + /// The external argument labels of every `public init` the source declares, + /// one entry per initializer. + /// + /// Both shapes the emitter writes are handled: up to three parameters go on + /// the declaration line, more are wrapped one per line and closed by `) {`. + /// Accumulating until the first `)` covers each without a second code path, + /// and covers the hand-written `init(from decoder:) throws` — whose closing + /// paren is not the end of its line — for free. + static func publicInitParameterLabels(in source: String) -> [[String]] { + let lines = source.components(separatedBy: "\n") + var labels: [[String]] = [] + for (start, line) in lines.enumerated() where line.hasPrefix(" public init(") { + var declaration = "" + for candidate in lines[start...] { + declaration += candidate + if candidate.contains(")") { break } + } + guard let open = declaration.firstIndex(of: "("), + let close = declaration.lastIndex(of: ")"), open < close + else { continue } + labels.append( + declaration[declaration.index(after: open).. Bool { + let properties = declaredProperties(in: source) + let names = Set(properties.map(\.name)) + let required = Set(properties.filter(\.required).map(\.name)) + + return publicInitParameterLabels(in: source).contains { labels in + let taken = Set(labels) + return taken.isSubset(of: names) && required.isSubset(of: taken) + && (names.isEmpty || !taken.isEmpty) + } + } + + /// Table-drives the matcher over the shapes the emitter writes and the ones + /// that must not be mistaken for them. The disk scan below can only ever see + /// the models that exist today, and today none of them is missing an init — + /// so without these the matcher's negative half is never exercised. + func testMemberwiseInitRecognition() { + // `SearchType` in miniature: a required-nullable member gives it both a + // memberwise and a decoding initializer. + let both = """ + public struct SearchType: Codable, Sendable { + public let key: String? + public let value: String + + public init(key: String?, value: String) { + self.key = key + } + + public init(from decoder: any Decoder) throws { + self.key = try container.decode(String?.self, forKey: .key) + } + } + """ + XCTAssertTrue(Self.declaresMemberwiseInit(in: both)) + + // The regression: the memberwise initializer is gone and only Codable's + // remains. A scan for a bare `public init` reads this as constructible, + // which is why the assertion below is the one that carries the contract. + let decoderOnly = """ + public struct SearchType: Codable, Sendable { + public let key: String? + public let value: String + + public init(from decoder: any Decoder) throws { + self.key = try container.decode(String?.self, forKey: .key) + } + } + """ + XCTAssertTrue( + decoderOnly.components(separatedBy: "\n") + .contains { $0.hasPrefix(" public init(") }, + "the mutated source must still declare a public init, or this proves nothing") + XCTAssertFalse( + Self.declaresMemberwiseInit(in: decoderOnly), + "init(from decoder:) does not make a model constructible from its values") + + // Over three parameters, the emitter wraps them one per line. + XCTAssertTrue( + Self.declaresMemberwiseInit( + in: """ + public struct Wrapped: Codable, Sendable { + public var a: String? + public var b: String? + public var c: String? + public var d: String? + + public init( + a: String? = nil, + b: String? = nil, + c: String? = nil, + d: String? = nil + ) { + self.a = a + } + } + """)) + + // A struct with no stored properties is fully built by `init()`. + XCTAssertTrue( + Self.declaresMemberwiseInit( + in: """ + public struct Empty: Codable, Sendable { + public init() { + } + } + """)) + + // ...but an empty parameter list is not a licence for a struct that has + // properties, which is what a broken label parser would produce. + XCTAssertFalse( + Self.declaresMemberwiseInit( + in: """ + public struct Broken: Codable, Sendable { + public var title: String? + + public init() { + } + } + """)) + + // An initializer that skips a required member leaves it unsettable. + XCTAssertFalse( + Self.declaresMemberwiseInit( + in: """ + public struct Partial: Codable, Sendable { + public let id: Int + public var title: String? + + public init(title: String? = nil) { + self.title = title + } + } + """), + "a required member absent from the parameter list cannot be supplied") + + // The `FlexibleInt` companion property is emitted alongside the member + // it labels and deliberately takes no parameter. It is a `var`, so the + // initializer still covers everything required. + XCTAssertTrue( + Self.declaresMemberwiseInit( + in: """ + public struct Companion: Codable, Sendable { + public let id: FlexibleInt + public var systemLabel: String? + + public init(id: FlexibleInt) { + self.id = id + } + } + """)) + } + // MARK: - Roster scan /// Covers models added after the consumer target's roster was written: every - /// generated `public struct` must carry a `public init`. Enums and + /// generated `public struct` must carry a memberwise `public init`. Enums and /// typealiases are exempt — an enum case is already a public constructor and /// a typealias has no initializer of its own. func testEveryGeneratedModelStructDeclaresAPublicInit() throws { @@ -163,7 +371,7 @@ final class PublicInitCoverageTests: XCTestCase { let lines = source.components(separatedBy: "\n") guard lines.contains(where: { $0.hasPrefix("public struct ") }) else { continue } structs += 1 - if !lines.contains(where: { $0.hasPrefix(" public init(") }) { + if !Self.declaresMemberwiseInit(in: source) { missing.append(file) } } @@ -177,8 +385,9 @@ final class PublicInitCoverageTests: XCTestCase { + "struct — the struct detection has collapsed") XCTAssertEqual( missing, [], - "these generated models have no public init, so no consumer that " - + "plain-`import`s Basecamp can construct them (#735)") + "these generated models declare no memberwise public init, so no " + + "consumer that plain-`import`s Basecamp can construct them " + + "from their property values (#735)") } // MARK: - Plain-import recognition