diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 096dd0f5..a5b02dae 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/Makefile b/Makefile index d5418538..9d215b88 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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? = null`, every +# required array stays `List`, 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 #------------------------------------------------------------------------------ @@ -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 diff --git a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/ModelEmitter.kt b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/ModelEmitter.kt index b755d3b8..22e84b6b 100644 --- a/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/ModelEmitter.kt +++ b/kotlin/generator/src/main/kotlin/com/basecamp/sdk/generator/ModelEmitter.kt @@ -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 = 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? = 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`. + if (!isRequired) "List<$itemType>?" else "List<$itemType>" } "object" -> if (isRequired) "JsonObject" else "JsonObject?" else -> if (isRequired) "JsonElement" else "JsonElement?" diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CampfireLine.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CampfireLine.kt index 64b8fc62..5c79bde1 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CampfireLine.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CampfireLine.kt @@ -27,7 +27,7 @@ data class CampfireLine( val creator: Person, @SerialName("bookmark_url") val bookmarkUrl: String? = null, val content: String? = null, - val attachments: List = emptyList(), + val attachments: List? = null, @SerialName("boosts_count") val boostsCount: Int = 0, @SerialName("boosts_url") val boostsUrl: String? = null ) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Card.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Card.kt index 1bcd8252..b1eace17 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Card.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Card.kt @@ -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 = emptyList(), - @SerialName("completion_subscribers") val completionSubscribers: List = emptyList(), - val steps: List = emptyList(), + val assignees: List? = null, + @SerialName("completion_subscribers") val completionSubscribers: List? = null, + val steps: List? = null, @SerialName("boosts_count") val boostsCount: Int = 0, @SerialName("boosts_url") val boostsUrl: String? = null ) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CardColumn.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CardColumn.kt index b6e6c21f..38bef1b6 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CardColumn.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CardColumn.kt @@ -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 = emptyList(), + val subscribers: List? = null, @SerialName("on_hold") val onHold: CardColumnOnHold? = null ) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CardStep.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CardStep.kt index 442ff237..e24f9345 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CardStep.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CardStep.kt @@ -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 = emptyList(), + val assignees: List? = null, @SerialName("completion_url") val completionUrl: String? = null ) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CardTable.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CardTable.kt index 2e765a26..e3f7cc62 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CardTable.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/CardTable.kt @@ -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 = emptyList(), - val lists: List = emptyList(), - val wormholes: List = emptyList() + val subscribers: List? = null, + val lists: List? = null, + val wormholes: List? = null ) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/ClientApproval.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/ClientApproval.kt index 52d08452..ec3d4518 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/ClientApproval.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/ClientApproval.kt @@ -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 = emptyList() + val responses: List? = null ) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/EventDetails.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/EventDetails.kt index d9512cf9..5528ad8e 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/EventDetails.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/EventDetails.kt @@ -12,7 +12,7 @@ import kotlinx.serialization.json.JsonObject */ @Serializable data class EventDetails( - @SerialName("added_person_ids") val addedPersonIds: List = emptyList(), - @SerialName("removed_person_ids") val removedPersonIds: List = emptyList(), - @SerialName("notified_recipient_ids") val notifiedRecipientIds: List = emptyList() + @SerialName("added_person_ids") val addedPersonIds: List? = null, + @SerialName("removed_person_ids") val removedPersonIds: List? = null, + @SerialName("notified_recipient_ids") val notifiedRecipientIds: List? = null ) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/HillChart.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/HillChart.kt index fc57948b..554e05c9 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/HillChart.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/HillChart.kt @@ -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 = emptyList() + val dots: List? = null ) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Project.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Project.kt index e7cc5383..ed5f7b94 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Project.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Project.kt @@ -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 = emptyList(), + val dock: List? = 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") diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/QuestionSchedule.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/QuestionSchedule.kt index 8b76611a..e4493f46 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/QuestionSchedule.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/QuestionSchedule.kt @@ -13,7 +13,7 @@ import kotlinx.serialization.json.JsonObject @Serializable data class QuestionSchedule( val frequency: String? = null, - val days: List = emptyList(), + val days: List? = null, val hour: Int = 0, val minute: Int = 0, @SerialName("week_instance") val weekInstance: Int = 0, diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/ScheduleEntry.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/ScheduleEntry.kt index 7453dc3b..6dc366b2 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/ScheduleEntry.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/ScheduleEntry.kt @@ -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 = emptyList(), + val participants: List? = null, @SerialName("boosts_count") val boostsCount: Int = 0, @SerialName("boosts_url") val boostsUrl: String? = null ) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Subscription.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Subscription.kt index 95131577..a6dd7b49 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Subscription.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Subscription.kt @@ -15,5 +15,5 @@ data class Subscription( val subscribed: Boolean, val count: Int, val url: String, - val subscribers: List = emptyList() + val subscribers: List? = null ) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Template.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Template.kt index dcbf8c04..576ada3a 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Template.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Template.kt @@ -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 = emptyList() + val dock: List? = null ) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Todo.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Todo.kt index 4bba0925..a0773850 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Todo.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Todo.kt @@ -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 = emptyList(), - @SerialName("completion_subscribers") val completionSubscribers: List = emptyList(), + val assignees: List? = null, + @SerialName("completion_subscribers") val completionSubscribers: List? = 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 = emptyList() + val steps: List? = null ) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Webhook.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Webhook.kt index 03f8f020..8d2fd39b 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Webhook.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/generated/models/Webhook.kt @@ -19,6 +19,6 @@ data class Webhook( val url: String, @SerialName("app_url") val appUrl: String, val active: Boolean = false, - val types: List = emptyList(), - @SerialName("recent_deliveries") val recentDeliveries: List = emptyList() + val types: List? = null, + @SerialName("recent_deliveries") val recentDeliveries: List? = null ) diff --git a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/TodosService.kt b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/TodosService.kt index 2baf3252..c59d495e 100644 --- a/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/TodosService.kt +++ b/kotlin/sdk/src/commonMain/kotlin/com/basecamp/sdk/services/TodosService.kt @@ -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 ?: "", ) diff --git a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WormholesServiceTest.kt b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WormholesServiceTest.kt index b9e589f2..1b4ac58a 100644 --- a/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WormholesServiceTest.kt +++ b/kotlin/sdk/src/commonTest/kotlin/com/basecamp/sdk/WormholesServiceTest.kt @@ -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() } diff --git a/rubric-audit.json b/rubric-audit.json index af8c27d8..eb5ab945 100644 --- a/rubric-audit.json +++ b/rubric-audit.json @@ -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? = 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, diff --git a/scripts/check-kotlin-optional-arrays.rb b/scripts/check-kotlin-optional-arrays.rb new file mode 100755 index 00000000..ececf0f6 --- /dev/null +++ b/scripts/check-kotlin-optional-arrays.rb @@ -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? = 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`. No generated array may default to the `= emptyList()` +# sentinel that this fix removed. +# +# Two levels of enforcement: +# +# * Schema-checked (generated/models/.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` +# (no `?`, no default); an OPTIONAL array MUST be `List? = null`. This +# proves both halves of the guarantee, so a required array mistakenly +# emitted as `List?` 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? = null` (so `= emptyList()` or any non-null +# default fails). Requiredness can't be derived here, so a bare `List` +# 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) + 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` 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? = 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? = null`" + elsif !nullable + errors << "#{rel}:#{lineno}: non-null array `#{type_part}` carries a " \ + "default (`= #{default_part}`) — an optional array must be " \ + "nullable (`List? = 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 diff --git a/scripts/check-kotlin-optional-arrays.sh b/scripts/check-kotlin-optional-arrays.sh new file mode 100755 index 00000000..d20dea60 --- /dev/null +++ b/scripts/check-kotlin-optional-arrays.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# D-invariant guard: every optional generated Kotlin array is `List? = null`, +# every required array stays `List`, and none default to `= emptyList()`. +# See scripts/check-kotlin-optional-arrays.rb. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if ! command -v ruby >/dev/null 2>&1; then + echo "ERROR: ruby is required for check-kotlin-optional-arrays" >&2 + exit 2 +fi + +exec ruby "$SCRIPT_DIR/check-kotlin-optional-arrays.rb" diff --git a/spec/api-gaps/rich-text-attachments-coverage.md b/spec/api-gaps/rich-text-attachments-coverage.md index 3381ae32..cebdadc4 100644 --- a/spec/api-gaps/rich-text-attachments-coverage.md +++ b/spec/api-gaps/rich-text-attachments-coverage.md @@ -135,8 +135,8 @@ present-empty, populated — round-trip faithfully in both directions: Go `*[]RichTextAttachment` (nil pointer omitted, non-nil pointer to `[]` re-encodes as `[]`), Swift `[RichTextAttachment]?`, TypeScript `?: …[]`, Python `NotRequired[…]`, Ruby nil-vs-`[]` (`parse_array`), and Kotlin `List? = null` -(the model generator emits a nullable list for optional `RichTextAttachmentList` -members rather than the empty-list default it uses for other optional arrays). The +(the model generator emits `List? = null` for *every* optional array, so an +absent array is distinct from a present-but-empty one). The 14 concrete `@required` members are always present, so they stay a plain non-optional list in each SDK.