Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,9 @@ jobs:
fi
echo "Generated code is up to date"

- name: Check optional-array invariant
run: ruby ../scripts/check-kotlin-optional-arrays.rb

- name: Run Kotlin conformance tests
run: ./gradlew :conformance:run

Expand Down
10 changes: 8 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -743,7 +743,7 @@ tools:
# Spec-shape lints
#------------------------------------------------------------------------------

.PHONY: check-bucket-flat-parity validate-api-gaps check-deprecation-parity
.PHONY: check-bucket-flat-parity validate-api-gaps check-deprecation-parity kt-check-optional-arrays

# Verify every bucket-scoped GET list operation has a flat-path counterpart
# (or is justified in spec/bucket-scoped-allowlist.txt). Cross-project SDK
Expand All @@ -765,6 +765,12 @@ check-deprecation-parity: rb-build
validate-api-gaps:
@./scripts/validate-api-gaps.sh

# D-invariant: every optional generated Kotlin array is `List<T>? = null`, every
# required array stays `List<T>`, and none default to the `= emptyList()`
# sentinel — pinning the optional-array presence contract against regression.
kt-check-optional-arrays:
@./scripts/check-kotlin-optional-arrays.sh

#------------------------------------------------------------------------------
# Combined targets
#------------------------------------------------------------------------------
Expand All @@ -787,7 +793,7 @@ generate:
@echo "==> Generation complete"

# Run all checks (Smithy + Go + TypeScript + Ruby + Kotlin + Swift + Python + Behavior Model + Conformance + Provenance + Actions lint)
check: lint-actions sync-spec-version-check smithy-check behavior-model-check provenance-check sync-api-version-check go-check-drift go-check-wrapper-drift auth-routable-check kt-check-drift go-check ts-check rb-check kt-check swift-check py-check conformance check-bucket-flat-parity validate-api-gaps check-deprecation-parity
check: lint-actions sync-spec-version-check smithy-check behavior-model-check provenance-check sync-api-version-check go-check-drift go-check-wrapper-drift auth-routable-check kt-check-drift go-check ts-check rb-check kt-check swift-check py-check conformance check-bucket-flat-parity validate-api-gaps check-deprecation-parity kt-check-optional-arrays
@echo "==> All checks passed"

# Clean all build artifacts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,17 +191,15 @@ class ModelEmitter(private val api: OpenApiParser) {
"string" -> if (isRequired) "String" else "String?"
"array" -> {
val itemType = resolveArrayItemType(schema["items"]?.jsonObject)
// Rich-text companion arrays (RichTextAttachmentList) carry a
// documented cross-SDK presence contract (SPEC.md §10): an
// OPTIONAL one must stay nullable so an absent array (a
// non-matching projection type or a webhook-sourced item) is
// distinct from a present-but-empty one, matching how Go (nil),
// Swift/TypeScript (optional), Python (NotRequired), and Ruby
// (nil) already decode it. A bare `List<X> = emptyList()` would
// be a sentinel for absence, which SPEC.md's Optional Fields
// rule forbids. Other optional arrays keep the empty-list
// convention (no such presence contract).
if (!isRequired && itemType == "RichTextAttachment") "List<$itemType>?" else "List<$itemType>"
// Every OPTIONAL array is nullable (`List<X>? = null`) so an
// absent array stays distinct from a present-but-empty one,
// per SPEC.md's Optional Fields rule (a sentinel — here
// `= emptyList()` — is not a substitute for absence). This
// aligns Kotlin with the other five SDKs, all of which already
// preserve the distinction: Go (nil pointer), Swift/TypeScript
// (optional), Python (NotRequired), Ruby (nil). REQUIRED arrays
// are always emitted, so they stay a plain non-null `List<X>`.
if (!isRequired) "List<$itemType>?" else "List<$itemType>"
}
"object" -> if (isRequired) "JsonObject" else "JsonObject?"
else -> if (isRequired) "JsonElement" else "JsonElement?"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ data class CampfireLine(
val creator: Person,
@SerialName("bookmark_url") val bookmarkUrl: String? = null,
val content: String? = null,
val attachments: List<CampfireLineAttachment> = emptyList(),
val attachments: List<CampfireLineAttachment>? = null,
@SerialName("boosts_count") val boostsCount: Int = 0,
@SerialName("boosts_url") val boostsUrl: String? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ data class Card(
@SerialName("comments_url") val commentsUrl: String? = null,
@SerialName("completion_url") val completionUrl: String? = null,
val completer: Person? = null,
val assignees: List<Person> = emptyList(),
@SerialName("completion_subscribers") val completionSubscribers: List<Person> = emptyList(),
val steps: List<CardStep> = emptyList(),
val assignees: List<Person>? = null,
@SerialName("completion_subscribers") val completionSubscribers: List<Person>? = null,
val steps: List<CardStep>? = null,
@SerialName("boosts_count") val boostsCount: Int = 0,
@SerialName("boosts_url") val boostsUrl: String? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,6 @@ data class CardColumn(
@SerialName("cards_count") val cardsCount: Int = 0,
@SerialName("comments_count") val commentsCount: Int = 0,
@SerialName("cards_url") val cardsUrl: String? = null,
val subscribers: List<Person> = emptyList(),
val subscribers: List<Person>? = null,
@SerialName("on_hold") val onHold: CardColumnOnHold? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@ data class CardStep(
val completed: Boolean = false,
@SerialName("completed_at") val completedAt: String? = null,
val completer: Person? = null,
val assignees: List<Person> = emptyList(),
val assignees: List<Person>? = null,
@SerialName("completion_url") val completionUrl: String? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ data class CardTable(
val creator: Person,
@SerialName("bookmark_url") val bookmarkUrl: String? = null,
@SerialName("subscription_url") val subscriptionUrl: String? = null,
val subscribers: List<Person> = emptyList(),
val lists: List<CardColumn> = emptyList(),
val wormholes: List<Wormhole> = emptyList()
val subscribers: List<Person>? = null,
val lists: List<CardColumn>? = null,
val wormholes: List<Wormhole>? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,5 @@ data class ClientApproval(
@SerialName("replies_url") val repliesUrl: String? = null,
@SerialName("approval_status") val approvalStatus: String? = null,
val approver: Person? = null,
val responses: List<ClientApprovalResponse> = emptyList()
val responses: List<ClientApprovalResponse>? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import kotlinx.serialization.json.JsonObject
*/
@Serializable
data class EventDetails(
@SerialName("added_person_ids") val addedPersonIds: List<Long> = emptyList(),
@SerialName("removed_person_ids") val removedPersonIds: List<Long> = emptyList(),
@SerialName("notified_recipient_ids") val notifiedRecipientIds: List<Long> = emptyList()
@SerialName("added_person_ids") val addedPersonIds: List<Long>? = null,
@SerialName("removed_person_ids") val removedPersonIds: List<Long>? = null,
@SerialName("notified_recipient_ids") val notifiedRecipientIds: List<Long>? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ data class HillChart(
@SerialName("updated_at") val updatedAt: String? = null,
@SerialName("app_update_url") val appUpdateUrl: String? = null,
@SerialName("app_versions_url") val appVersionsUrl: String? = null,
val dots: List<HillChartDot> = emptyList()
val dots: List<HillChartDot>? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ data class Project(
@SerialName("end_date") val endDate: String? = null,
@SerialName("clients_enabled") val clientsEnabled: Boolean = false,
@SerialName("bookmark_url") val bookmarkUrl: String? = null,
val dock: List<DockItem> = emptyList(),
val dock: List<DockItem>? = null,
val bookmarked: Boolean = false,
@SerialName("client_company") val clientCompany: ClientCompany? = null,
@Deprecated("This shape is deprecated since 2024-01: Use Client Visibility feature instead")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import kotlinx.serialization.json.JsonObject
@Serializable
data class QuestionSchedule(
val frequency: String? = null,
val days: List<Int> = emptyList(),
val days: List<Int>? = null,
val hour: Int = 0,
val minute: Int = 0,
@SerialName("week_instance") val weekInstance: Int = 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ data class ScheduleEntry(
@SerialName("all_day") val allDay: Boolean = false,
@SerialName("starts_at") val startsAt: String? = null,
@SerialName("ends_at") val endsAt: String? = null,
val participants: List<Person> = emptyList(),
val participants: List<Person>? = null,
@SerialName("boosts_count") val boostsCount: Int = 0,
@SerialName("boosts_url") val boostsUrl: String? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ data class Subscription(
val subscribed: Boolean,
val count: Int,
val url: String,
val subscribers: List<Person> = emptyList()
val subscribers: List<Person>? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ data class Template(
val description: String? = null,
val url: String? = null,
@SerialName("app_url") val appUrl: String? = null,
val dock: List<DockItem> = emptyList()
val dock: List<DockItem>? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ data class Todo(
val completed: Boolean = false,
@SerialName("starts_on") val startsOn: String? = null,
@SerialName("due_on") val dueOn: String? = null,
val assignees: List<Person> = emptyList(),
@SerialName("completion_subscribers") val completionSubscribers: List<Person> = emptyList(),
val assignees: List<Person>? = null,
@SerialName("completion_subscribers") val completionSubscribers: List<Person>? = null,
@SerialName("completion_url") val completionUrl: String? = null,
@SerialName("boosts_count") val boostsCount: Int = 0,
@SerialName("boosts_url") val boostsUrl: String? = null,
val steps: List<CardStep> = emptyList()
val steps: List<CardStep>? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ data class Webhook(
val url: String,
@SerialName("app_url") val appUrl: String,
val active: Boolean = false,
val types: List<String> = emptyList(),
@SerialName("recent_deliveries") val recentDeliveries: List<WebhookDelivery> = emptyList()
val types: List<String>? = null,
@SerialName("recent_deliveries") val recentDeliveries: List<WebhookDelivery>? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ class TodosService(client: AccountClient) :
private fun fieldsFromTodo(todo: Todo): TodoFields = TodoFields(
content = todo.content,
description = todo.description ?: "",
assigneeIds = todo.assignees.map { it.id },
completionSubscriberIds = todo.completionSubscribers.map { it.id },
assigneeIds = todo.assignees.orEmpty().map { it.id },
completionSubscriberIds = todo.completionSubscribers.orEmpty().map { it.id },
dueOn = todo.dueOn ?: "",
startsOn = todo.startsOn ?: "",
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,11 +276,12 @@ class WormholesServiceTest {
val account = client.forAccount("12345")
val cardTable = account.cardTables.get(cardTableId = 1069479345)

assertEquals(2, cardTable.wormholes.size)
assertTrue(cardTable.wormholes[0].linked)
assertNotNull(cardTable.wormholes[0].destinationUrl)
assertTrue(!cardTable.wormholes[1].linked)
assertNull(cardTable.wormholes[1].destinationUrl)
val wormholes = assertNotNull(cardTable.wormholes)
assertEquals(2, wormholes.size)
assertTrue(wormholes[0].linked)
assertNotNull(wormholes[0].destinationUrl)
assertTrue(!wormholes[1].linked)
assertNull(wormholes[1].destinationUrl)

client.close()
}
Expand Down
5 changes: 3 additions & 2 deletions rubric-audit.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
"note": "Request/response types generated from OpenAPI schema"
},
"1B.4": {
"pass": true,
"note": "Optional fields use pointers (Go), optional chaining (TS), nilable (Ruby)"
"pass": false,
"issue": 436,
"note": "Partially met. Optional OBJECT/REF/ARRAY and nullable-typed fields preserve absence across all six SDKs: Go pointers (*T / *[]T), TS optional chaining (?: T), Ruby nilable, Python NotRequired[T], Swift optionals (T?), Kotlin nullable (T? = null, incl. List<T>? = null for optional arrays after #433, pinned by check-kotlin-optional-arrays). NOT yet met for optional non-array SCALARS: Kotlin emits them with zero-value sentinels (Boolean = false, Int = 0, Long = 0L) — a static-type violation of SPEC.md's Optional Fields rule (0/'' are not substitutes for absence). Go optional NON-POINTER scalars collapse absence on DECODE too (an absent field and an explicit zero both unmarshal to the zero value); only Go pointer fields (*T) preserve it. Ruby/Swift decode faithfully but drop nil on RE-ENCODE (compact / synthesized encoder). All documented in SPEC.md §10 and tracked in #436. Go's *[]T is used only where rich-text projections need the wire distinction round-tripped both directions; not a blanket sweep."
},
"1B.5": {
"pass": true,
Expand Down
142 changes: 142 additions & 0 deletions scripts/check-kotlin-optional-arrays.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

# D-invariant guard for the Kotlin generator's optional-array contract.
#
# Every OPTIONAL generated array must be `List<T>? = null` so an absent array
# stays distinct from a present-but-empty one (SPEC.md Optional Fields rule),
# aligning Kotlin with the other five SDKs. Every REQUIRED array stays a plain
# non-null `List<T>`. No generated array may default to the `= emptyList()`
# sentinel that this fix removed.
#
# Two levels of enforcement:
#
# * Schema-checked (generated/models/<Component>.kt whose basename is an
# OpenAPI component): each array property is cross-checked against the
# component's `required` set — a REQUIRED array MUST be non-null `List<T>`
# (no `?`, no default); an OPTIONAL array MUST be `List<T>? = null`. This
# proves both halves of the guarantee, so a required array mistakenly
# emitted as `List<T>?` is caught, not silently accepted.
# * Structural (everything else — request/param types in generated/services,
# supporting model types not present as components): a property with a
# default MUST be `List<T>? = null` (so `= emptyList()` or any non-null
# default fails). Requiredness can't be derived here, so a bare `List<T>`
# or nullable-without-default is left alone.
#
# Pins the D fix against regression. Wired into `make check`.

require "json"

PROJECT_ROOT = File.expand_path("..", __dir__)
GENERATED_DIR = File.join(
PROJECT_ROOT,
"kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated"
)
MODELS_DIR = File.join(GENERATED_DIR, "models")
OPENAPI_FILE = File.join(PROJECT_ROOT, "openapi.json")

unless File.file?(OPENAPI_FILE)
warn "ERROR: openapi.json not found at #{OPENAPI_FILE}"
exit 2
end

# Read as UTF-8 regardless of process locale (LC_ALL=C would otherwise read as
# US-ASCII and choke on the spec's / generated code's UTF-8 bytes).
components = JSON.parse(File.read(OPENAPI_FILE, encoding: "UTF-8")).dig("components", "schemas") || {}

errors = []

files = Dir.glob(File.join(GENERATED_DIR, "**", "*.kt")).sort
if files.empty?
warn "ERROR: no generated Kotlin files found under #{GENERATED_DIR}"
exit 2
end

# Parse one property line into [wire_name, type_part, default_part] or nil when
# the line isn't an array-typed `val`. `wire_name` is the @SerialName value when
# present, else the Kotlin field name (the generator only adds @SerialName when
# the camelCase name differs from the wire name, so this recovers the wire name).
def parse_array_property(line)
return nil unless line =~ /:\s*(List<.*)$/

decl = Regexp.last_match(1).strip.sub(/[,)]\s*$/, "").strip
field = line[/\bval\s+(\w+)\s*:/, 1]
return nil unless field

serial = line[/@SerialName\("([^"]+)"\)/, 1]
wire = serial || field

type_part, default_part =
if decl.include?("=")
left, right = decl.split("=", 2)
[left.strip, right.strip]
else
[decl, nil]
end

[wire, type_part, default_part]
end

files.each do |path|
rel = path.sub("#{PROJECT_ROOT}/", "")
component_name =
if path.start_with?("#{MODELS_DIR}/")
base = File.basename(path, ".kt")
base if components.key?(base)
end
component = component_name ? components[component_name] : nil
required_fields = component ? (component["required"] || []) : nil
known_props = component ? (component["properties"] || {}) : nil

File.foreach(path, encoding: "UTF-8").with_index(1) do |line, lineno|
parsed = parse_array_property(line)
Comment thread
jeremy marked this conversation as resolved.
next unless parsed

wire, type_part, default_part = parsed
nullable = type_part.end_with?("?")
has_default = !default_part.nil?

# Schema-checked path: the field is a known property of an OpenAPI component.
if required_fields && known_props&.key?(wire)
if required_fields.include?(wire)
if nullable || has_default
errors << "#{rel}:#{lineno}: `#{wire}` is REQUIRED in schema " \
"`#{component_name}` but is emitted as `#{type_part}" \
"#{has_default ? " = #{default_part}" : ''}` — a required " \
"array must be non-null `List<T>` with no default"
end
elsif !nullable || default_part != "null"
errors << "#{rel}:#{lineno}: `#{wire}` is OPTIONAL in schema " \
"`#{component_name}` but is emitted as `#{type_part}" \
"#{has_default ? " = #{default_part}" : ''}` — an optional " \
"array must be `List<T>? = null`"
end
next
end

# Structural path: requiredness unknown (request/param types, supporting
# non-component models). Only a defaulted array is constrained.
next unless has_default

if default_part == "emptyList()"
errors << "#{rel}:#{lineno}: optional array uses the forbidden " \
"`= emptyList()` sentinel — must be `List<T>? = null`"
elsif !nullable
errors << "#{rel}:#{lineno}: non-null array `#{type_part}` carries a " \
"default (`= #{default_part}`) — an optional array must be " \
"nullable (`List<T>? = null`)"
elsif default_part != "null"
errors << "#{rel}:#{lineno}: nullable array defaults to " \
"`#{default_part}` — must default to `null`"
end
end
end

if errors.empty?
puts "==> Kotlin optional-array invariant clean — #{files.size} generated files scanned"
exit 0
else
warn "Kotlin optional-array invariant failed:"
errors.each { |e| warn " - #{e}" }
exit 1
end
Loading
Loading