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..81826744c 100644 --- a/swift/Sources/BasecampGenerator/ModelEmitter.swift +++ b/swift/Sources/BasecampGenerator/ModelEmitter.swift @@ -213,37 +213,47 @@ 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 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 { + 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..82cbcfc50 --- /dev/null +++ b/swift/Sources/BasecampPublicAPIConsumer/PublicAPIConsumer.swift @@ -0,0 +1,146 @@ +// 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. 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: +// +// 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`. +// +// 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 +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..2bd795349 --- /dev/null +++ b/swift/Tests/BasecampTests/PublicInitCoverageTests.swift @@ -0,0 +1,504 @@ +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 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: +/// +/// * 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)") + } + + /// 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) + + // 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)") + } + + // 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 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 { + let modelsDir = repoRoot.appendingPathComponent("Sources/Basecamp/Generated/Models") + let files = try FileManager.default.contentsOfDirectory(atPath: modelsDir.path) + .filter { $0.hasSuffix(".swift") } + .sorted() + + // 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 + 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 !Self.declaresMemberwiseInit(in: source) { + missing.append(file) + } + } + + // 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 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 + + /// 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 + /// 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 Self.isPlainBasecampImport(line) { 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..