skit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +469 -0
  4. data/exe/skit +31 -0
  5. data/lib/active_model/validations/skit_validator.rb +54 -0
  6. data/lib/skit/attribute.rb +63 -0
  7. data/lib/skit/json_schema/class_name_path.rb +67 -0
  8. data/lib/skit/json_schema/cli.rb +166 -0
  9. data/lib/skit/json_schema/code_generator.rb +132 -0
  10. data/lib/skit/json_schema/config.rb +67 -0
  11. data/lib/skit/json_schema/definitions/array_property_type.rb +36 -0
  12. data/lib/skit/json_schema/definitions/const_type.rb +68 -0
  13. data/lib/skit/json_schema/definitions/enum_type.rb +71 -0
  14. data/lib/skit/json_schema/definitions/hash_property_type.rb +36 -0
  15. data/lib/skit/json_schema/definitions/module.rb +54 -0
  16. data/lib/skit/json_schema/definitions/property_type.rb +39 -0
  17. data/lib/skit/json_schema/definitions/property_types.rb +13 -0
  18. data/lib/skit/json_schema/definitions/struct.rb +99 -0
  19. data/lib/skit/json_schema/definitions/struct_property.rb +75 -0
  20. data/lib/skit/json_schema/definitions/union_property_type.rb +40 -0
  21. data/lib/skit/json_schema/naming_utils.rb +25 -0
  22. data/lib/skit/json_schema/schema_analyzer.rb +407 -0
  23. data/lib/skit/json_schema/types/const.rb +69 -0
  24. data/lib/skit/json_schema.rb +77 -0
  25. data/lib/skit/serialization/errors.rb +23 -0
  26. data/lib/skit/serialization/path.rb +69 -0
  27. data/lib/skit/serialization/processor/array.rb +65 -0
  28. data/lib/skit/serialization/processor/base.rb +47 -0
  29. data/lib/skit/serialization/processor/boolean.rb +35 -0
  30. data/lib/skit/serialization/processor/date.rb +40 -0
  31. data/lib/skit/serialization/processor/enum.rb +54 -0
  32. data/lib/skit/serialization/processor/float.rb +36 -0
  33. data/lib/skit/serialization/processor/hash.rb +93 -0
  34. data/lib/skit/serialization/processor/integer.rb +31 -0
  35. data/lib/skit/serialization/processor/json_schema_const.rb +55 -0
  36. data/lib/skit/serialization/processor/nilable.rb +87 -0
  37. data/lib/skit/serialization/processor/simple_type.rb +51 -0
  38. data/lib/skit/serialization/processor/string.rb +31 -0
  39. data/lib/skit/serialization/processor/struct.rb +84 -0
  40. data/lib/skit/serialization/processor/symbol.rb +36 -0
  41. data/lib/skit/serialization/processor/time.rb +40 -0
  42. data/lib/skit/serialization/processor/union.rb +120 -0
  43. data/lib/skit/serialization/registry.rb +33 -0
  44. data/lib/skit/serialization.rb +60 -0
  45. data/lib/skit/version.rb +6 -0
  46. data/lib/skit.rb +46 -0
  47. data/lib/tapioca/dsl/compilers/skit.rb +105 -0
  48. metadata +135 -0
@@ -0,0 +1,99 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Skit
5
+ module JsonSchema
6
+ module Definitions
7
+ class Struct
8
+ extend T::Sig
9
+
10
+ sig { returns(String) }
11
+ attr_reader :class_name
12
+
13
+ sig { returns(T::Array[StructProperty]) }
14
+ attr_reader :properties
15
+
16
+ sig { returns(T.nilable(String)) }
17
+ attr_reader :description
18
+
19
+ sig do
20
+ params(
21
+ class_name: String,
22
+ properties: T::Array[StructProperty],
23
+ description: T.nilable(String)
24
+ ).void
25
+ end
26
+ def initialize(class_name:, properties: [], description: nil)
27
+ @class_name = T.let(validate_class_name(class_name), String)
28
+ @properties = properties
29
+ @description = description
30
+ end
31
+
32
+ sig { returns(T::Array[String]) }
33
+ def referenced_types
34
+ types = []
35
+ @properties.each do |property|
36
+ types.concat(extract_types_from_property_type(property.type))
37
+ end
38
+ types.uniq
39
+ end
40
+
41
+ sig { returns(T::Array[StructProperty]) }
42
+ def required_properties
43
+ @properties.select(&:required?)
44
+ end
45
+
46
+ sig { returns(T::Array[StructProperty]) }
47
+ def optional_properties
48
+ @properties.select(&:optional?)
49
+ end
50
+
51
+ sig { params(property: StructProperty).void }
52
+ def add_property(property)
53
+ @properties << property
54
+ end
55
+
56
+ private
57
+
58
+ sig { params(class_name: String).returns(String) }
59
+ def validate_class_name(class_name)
60
+ unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
61
+ raise ArgumentError,
62
+ "Invalid class name: #{class_name.inspect}. Must start with uppercase letter " \
63
+ "and contain only alphanumeric characters and underscores."
64
+ end
65
+
66
+ class_name
67
+ end
68
+
69
+ sig { params(property_type: PropertyTypes).returns(T::Array[String]) }
70
+ def extract_types_from_property_type(property_type)
71
+ types = []
72
+
73
+ case property_type
74
+ when ArrayPropertyType
75
+ types.concat(extract_types_from_property_type(property_type.item_type))
76
+ when HashPropertyType
77
+ types.concat(extract_types_from_property_type(property_type.value_type))
78
+ when UnionPropertyType
79
+ property_type.types.each do |union_type|
80
+ types.concat(extract_types_from_property_type(union_type))
81
+ end
82
+ when ConstType, EnumType
83
+ # ConstType/EnumType references itself as a custom type
84
+ types << property_type.class_name
85
+ when PropertyType
86
+ base_type = property_type.base_type
87
+ # Check if it's a custom class (inheriting from T::Struct)
88
+ types << base_type unless %w[String Integer Float T::Boolean Date Time
89
+ T.untyped].include?(base_type)
90
+ else
91
+ T.absurd(property_type)
92
+ end
93
+
94
+ types
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,75 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Skit
5
+ module JsonSchema
6
+ module Definitions
7
+ class StructProperty
8
+ extend T::Sig
9
+
10
+ sig { returns(String) }
11
+ attr_reader :name
12
+
13
+ sig { returns(PropertyTypes) }
14
+ attr_reader :type
15
+
16
+ sig { returns(Symbol) }
17
+ attr_reader :mutability
18
+
19
+ sig { returns(T.nilable(String)) }
20
+ attr_reader :default_value
21
+
22
+ sig { returns(T.nilable(String)) }
23
+ attr_reader :comment
24
+
25
+ sig do
26
+ params(
27
+ name: String,
28
+ type: PropertyTypes,
29
+ mutability: Symbol,
30
+ default_value: T.nilable(String),
31
+ comment: T.nilable(String)
32
+ ).void
33
+ end
34
+ def initialize(name:, type:, mutability: :prop, default_value: nil, comment: nil)
35
+ @name = name
36
+ @type = type
37
+ @mutability = T.let(validate_mutability(mutability), Symbol)
38
+ @default_value = default_value
39
+ @comment = comment
40
+ end
41
+
42
+ sig { returns(T::Boolean) }
43
+ def required?
44
+ !@type.nullable
45
+ end
46
+
47
+ sig { returns(T::Boolean) }
48
+ def optional?
49
+ @type.nullable
50
+ end
51
+
52
+ sig { returns(T::Boolean) }
53
+ def immutable?
54
+ @mutability == :const
55
+ end
56
+
57
+ sig { returns(T::Boolean) }
58
+ def mutable?
59
+ @mutability == :prop
60
+ end
61
+
62
+ private
63
+
64
+ sig { params(mutability: Symbol).returns(Symbol) }
65
+ def validate_mutability(mutability)
66
+ unless %i[prop const].include?(mutability)
67
+ raise ArgumentError, "mutability must be :prop or :const, got #{mutability.inspect}"
68
+ end
69
+
70
+ mutability
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,40 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Skit
5
+ module JsonSchema
6
+ module Definitions
7
+ class UnionPropertyType
8
+ extend T::Sig
9
+
10
+ sig { returns(T::Array[PropertyTypes]) }
11
+ attr_reader :types
12
+
13
+ sig { returns(T::Boolean) }
14
+ attr_reader :nullable
15
+
16
+ sig do
17
+ params(
18
+ types: T::Array[PropertyTypes],
19
+ nullable: T::Boolean
20
+ ).void
21
+ end
22
+ def initialize(types:, nullable: false)
23
+ @types = types
24
+ @nullable = nullable
25
+ end
26
+
27
+ sig { returns(String) }
28
+ def to_sorbet_type
29
+ union_str = "T.any(#{@types.map(&:to_sorbet_type).join(", ")})"
30
+ @nullable ? "T.nilable(#{union_str})" : union_str
31
+ end
32
+
33
+ sig { returns(UnionPropertyType) }
34
+ def with_nullable
35
+ UnionPropertyType.new(types: @types, nullable: true)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,25 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Skit
5
+ module JsonSchema
6
+ module NamingUtils
7
+ extend T::Sig
8
+
9
+ sig { params(value: String).returns(String) }
10
+ def self.to_pascal_case(value)
11
+ value.gsub(/[^a-zA-Z0-9]+/, "_")
12
+ .gsub(/^_+|_+$/, "")
13
+ .split("_")
14
+ .map(&:capitalize)
15
+ .join
16
+ end
17
+
18
+ sig { params(value: T.any(Integer, Float)).returns(String) }
19
+ def self.number_to_name(value)
20
+ num_str = value.to_s.gsub("-", "Minus").gsub(".", "Dot")
21
+ "Val#{num_str}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,407 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "json_schemer"
5
+
6
+ module Skit
7
+ module JsonSchema
8
+ class SchemaAnalyzer
9
+ extend T::Sig
10
+
11
+ sig { params(schema: T::Hash[String, T.untyped], config: Config).void }
12
+ def initialize(schema, config)
13
+ @schema = schema
14
+ @schemer = T.let(JSONSchemer.schema(@schema), T.untyped)
15
+ @nested_structs = T.let({}, T::Hash[String, Definitions::Struct])
16
+ @const_types = T.let({}, T::Hash[String, Definitions::ConstType])
17
+ @enum_types = T.let({}, T::Hash[String, Definitions::EnumType])
18
+ @config = config
19
+ @ref_stack = T.let([], T::Array[String])
20
+ end
21
+
22
+ sig { returns(Definitions::Module) }
23
+ def analyze
24
+ validate_schema
25
+
26
+ # Only object type is supported at the top level
27
+ raise Skit::Error, "Only object type schemas are supported at the top level" unless @schema["type"] == "object"
28
+
29
+ root_class_name_path = determine_root_class_name
30
+ root_struct = build_struct(@schema, root_class_name_path)
31
+
32
+ Definitions::Module.new(
33
+ root_struct: root_struct,
34
+ nested_structs: @nested_structs.values,
35
+ const_types: @const_types.values,
36
+ enum_types: @enum_types.values
37
+ )
38
+ end
39
+
40
+ private
41
+
42
+ sig { returns(ClassNamePath) }
43
+ def determine_root_class_name
44
+ # Determine root class name (in order of priority)
45
+ # 1. Class name specified by CLI option
46
+ # 2. Class name converted from title
47
+ # 3. Default class name
48
+ if (cli_class_name = @config.class_name)
49
+ ClassNamePath.new([cli_class_name])
50
+ elsif (title = extract_title(@schema))
51
+ ClassNamePath.title_to_class_name(title)
52
+ else
53
+ ClassNamePath.default
54
+ end
55
+ end
56
+
57
+ sig { params(schema: T::Hash[String, T.untyped]).returns(T.nilable(String)) }
58
+ def extract_title(schema)
59
+ title = schema["title"]
60
+ return nil unless title.is_a?(String) && !title.strip.empty?
61
+
62
+ title
63
+ end
64
+
65
+ sig { void }
66
+ def validate_schema
67
+ return if @schemer.valid_schema?
68
+
69
+ raise Skit::Error, "Invalid JSON Schema"
70
+ end
71
+
72
+ sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::Struct) }
73
+ def build_struct(schema, class_name_path)
74
+ unless schema["type"] == "object"
75
+ raise ArgumentError,
76
+ "Expected object type schema, got #{schema["type"].inspect}"
77
+ end
78
+
79
+ properties = []
80
+
81
+ if schema["properties"]
82
+ required_fields = T.cast(schema["required"] || [], T::Array[String])
83
+
84
+ schema["properties"].each do |prop_name, prop_schema|
85
+ prop_schema_typed = T.cast(prop_schema, T::Hash[String, T.untyped])
86
+
87
+ # Delegate all types to build_property_type (pass class name path for object types)
88
+ property_class_name_path = class_name_path.append(prop_name)
89
+ property_type = build_property_type(prop_schema_typed, property_class_name_path)
90
+
91
+ # Make nullable if not required
92
+ is_required = required_fields.include?(prop_name)
93
+ property_type = make_nullable(property_type) unless is_required
94
+
95
+ property = Definitions::StructProperty.new(
96
+ name: prop_name,
97
+ type: property_type,
98
+ comment: extract_comment(prop_schema_typed)
99
+ )
100
+ properties << property
101
+ end
102
+ end
103
+
104
+ Definitions::Struct.new(
105
+ class_name: class_name_path.to_class_name,
106
+ properties: properties,
107
+ description: schema["description"]
108
+ )
109
+ end
110
+
111
+ sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::PropertyTypes) }
112
+ def build_property_type(schema, class_name_path)
113
+ # Resolve $ref if present, then process
114
+ if (ref_path = schema["$ref"])
115
+ # Circular reference check
116
+ if @ref_stack.include?(ref_path)
117
+ raise Skit::Error,
118
+ "Circular reference detected: #{ref_path} -> #{@ref_stack.join(" -> ")}"
119
+ end
120
+
121
+ @ref_stack.push(ref_path)
122
+ begin
123
+ resolved_schema = resolve_ref(ref_path)
124
+ result = build_property_type(resolved_schema, class_name_path)
125
+ ensure
126
+ @ref_stack.pop
127
+ end
128
+ return result
129
+ end
130
+
131
+ # Const type processing
132
+ return build_const_type(schema, class_name_path) if schema.key?("const")
133
+
134
+ # Enum type processing
135
+ return build_enum_type(schema, class_name_path) if schema.key?("enum")
136
+
137
+ # Union type processing
138
+ return build_union_type(schema, class_name_path) if schema["anyOf"] || schema["oneOf"]
139
+
140
+ case schema["type"]
141
+ when "string"
142
+ build_string_type(schema)
143
+ when "integer"
144
+ Definitions::PropertyType.new(base_type: "Integer")
145
+ when "number"
146
+ Definitions::PropertyType.new(base_type: "Float")
147
+ when "boolean"
148
+ Definitions::PropertyType.new(base_type: "T::Boolean")
149
+ when "array"
150
+ build_array_type(schema, class_name_path)
151
+ when "object"
152
+ build_object_type(schema, class_name_path)
153
+ else
154
+ # Fallback to T.untyped for unsupported types
155
+ Definitions::PropertyType.new(base_type: "T.untyped")
156
+ end
157
+ end
158
+
159
+ sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::PropertyTypes) }
160
+ def build_object_type(schema, class_name_path)
161
+ if schema["properties"]
162
+ build_object_with_properties(schema, class_name_path)
163
+ else
164
+ # Generic hash when no properties are defined
165
+ untyped_value = Definitions::PropertyType.new(base_type: "T.untyped")
166
+ Definitions::HashPropertyType.new(value_type: untyped_value)
167
+ end
168
+ end
169
+
170
+ sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::PropertyType) }
171
+ def build_object_with_properties(schema, class_name_path)
172
+ # Use title when specified with priority
173
+ final_class_name_path = if (title = extract_title(schema))
174
+ # Use class name generated from title as-is when title is specified
175
+ ClassNamePath.title_to_class_name(title)
176
+ else
177
+ class_name_path
178
+ end
179
+
180
+ class_name = final_class_name_path.to_class_name
181
+
182
+ unless @nested_structs.key?(class_name)
183
+ struct_def = build_struct(schema, final_class_name_path)
184
+ @nested_structs[class_name] = struct_def
185
+ end
186
+
187
+ Definitions::PropertyType.new(base_type: class_name)
188
+ end
189
+
190
+ sig { params(schema: T::Hash[String, T.untyped]).returns(Definitions::PropertyType) }
191
+ def build_string_type(schema)
192
+ case schema["format"]
193
+ when "date-time", "time"
194
+ Definitions::PropertyType.new(base_type: "Time")
195
+ when "date"
196
+ Definitions::PropertyType.new(base_type: "Date")
197
+ else
198
+ Definitions::PropertyType.new(base_type: "String")
199
+ end
200
+ end
201
+
202
+ sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::ConstType) }
203
+ def build_const_type(schema, class_name_path)
204
+ const_value = schema["const"]
205
+
206
+ # Validate const value type (only string, integer, float, boolean are supported)
207
+ unless valid_const_value?(const_value)
208
+ raise Skit::Error, "Unsupported const value type: #{const_value.class}. " \
209
+ "Only String, Integer, Float, and Boolean are supported."
210
+ end
211
+
212
+ # Generate class name from property name and const value
213
+ class_name = generate_const_class_name(class_name_path, const_value)
214
+
215
+ const_type = Definitions::ConstType.new(
216
+ class_name: class_name,
217
+ value: const_value
218
+ )
219
+
220
+ # Store const type definition (dedup by class name)
221
+ @const_types[class_name] = const_type unless @const_types.key?(class_name)
222
+
223
+ const_type
224
+ end
225
+
226
+ sig { params(value: T.untyped).returns(T::Boolean) }
227
+ def valid_const_value?(value)
228
+ case value
229
+ when String, Integer, Float, TrueClass, FalseClass
230
+ true
231
+ else
232
+ false
233
+ end
234
+ end
235
+
236
+ sig { params(class_name_path: ClassNamePath, const_value: T.untyped).returns(String) }
237
+ def generate_const_class_name(class_name_path, const_value)
238
+ # Generate class name from property name and const value
239
+ # e.g., "type" property with value "dog" -> "TypeDog"
240
+ property_name = class_name_path.property_name
241
+
242
+ value_suffix = case const_value
243
+ when String
244
+ NamingUtils.to_pascal_case(const_value)
245
+ when Integer, Float
246
+ NamingUtils.number_to_name(const_value)
247
+ when TrueClass
248
+ "True"
249
+ when FalseClass
250
+ "False"
251
+ else
252
+ "Value"
253
+ end
254
+
255
+ "#{property_name}#{value_suffix}"
256
+ end
257
+
258
+ sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::PropertyTypes) }
259
+ def build_enum_type(schema, class_name_path)
260
+ enum_values = T.cast(schema["enum"], T::Array[T.untyped])
261
+
262
+ # Filter and validate enum values
263
+ valid_values = enum_values.select { |v| valid_enum_value?(v) }
264
+
265
+ # If no valid values or mixed types that can't be handled, fallback to T.untyped
266
+ return Definitions::PropertyType.new(base_type: "T.untyped") if valid_values.empty?
267
+
268
+ # Check if all values are of the same type category (all strings, all numbers, etc.)
269
+ return Definitions::PropertyType.new(base_type: "T.untyped") unless homogeneous_enum_values?(valid_values)
270
+
271
+ # Generate class name from property name
272
+ class_name = class_name_path.property_name
273
+
274
+ enum_type = Definitions::EnumType.new(
275
+ class_name: class_name,
276
+ values: valid_values
277
+ )
278
+
279
+ # Store enum type definition (dedup by class name)
280
+ @enum_types[class_name] = enum_type unless @enum_types.key?(class_name)
281
+
282
+ enum_type
283
+ end
284
+
285
+ sig { params(value: T.untyped).returns(T::Boolean) }
286
+ def valid_enum_value?(value)
287
+ case value
288
+ when String, Integer, Float
289
+ true
290
+ else
291
+ false
292
+ end
293
+ end
294
+
295
+ sig { params(values: T::Array[T.untyped]).returns(T::Boolean) }
296
+ def homogeneous_enum_values?(values)
297
+ return true if values.empty?
298
+
299
+ first_type = value_type_category(values.first)
300
+ values.all? { |v| value_type_category(v) == first_type }
301
+ end
302
+
303
+ sig { params(value: T.untyped).returns(Symbol) }
304
+ def value_type_category(value)
305
+ case value
306
+ when String
307
+ :string
308
+ when Integer, Float
309
+ :number
310
+ else
311
+ :other
312
+ end
313
+ end
314
+
315
+ sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::ArrayPropertyType) }
316
+ def build_array_type(schema, class_name_path)
317
+ if schema["items"]
318
+ item_schema = T.cast(schema["items"], T::Hash[String, T.untyped])
319
+ item_type = build_property_type(item_schema, class_name_path.append("item"))
320
+ Definitions::ArrayPropertyType.new(item_type: item_type)
321
+ else
322
+ # Array of T.untyped when items is not specified
323
+ untyped_item = Definitions::PropertyType.new(base_type: "T.untyped")
324
+ Definitions::ArrayPropertyType.new(item_type: untyped_item)
325
+ end
326
+ end
327
+
328
+ sig { params(schema: T::Hash[String, T.untyped], class_name_path: ClassNamePath).returns(Definitions::PropertyTypes) }
329
+ def build_union_type(schema, class_name_path)
330
+ union_schemas = T.cast(schema["anyOf"] || schema["oneOf"], T::Array[T.untyped])
331
+
332
+ # Handle null types: exclude null and make nullable at the end
333
+ has_null = T.let(false, T::Boolean)
334
+ non_null_schemas = T.let([], T::Array[T::Hash[String, T.untyped]])
335
+
336
+ union_schemas.each do |union_schema|
337
+ union_schema_typed = T.cast(union_schema, T::Hash[String, T.untyped])
338
+ if union_schema_typed["type"] == "null"
339
+ has_null = true
340
+ else
341
+ non_null_schemas << union_schema_typed
342
+ end
343
+ end
344
+
345
+ # Error if no non-null schemas exist
346
+ raise Skit::Error, "Union type with only null is not supported" if non_null_schemas.empty?
347
+
348
+ # Return nullable type when single type includes null
349
+ if non_null_schemas.length == 1
350
+ single_schema = T.must(non_null_schemas.first)
351
+ single_type = build_property_type(single_schema, class_name_path)
352
+ return has_null ? make_nullable(single_type) : single_type
353
+ end
354
+
355
+ # Analyze multiple types with unique class names for each member
356
+ types = non_null_schemas.each_with_index.map do |union_schema, index|
357
+ build_property_type(union_schema, class_name_path.append("Variant#{index}"))
358
+ end
359
+
360
+ union_type = Definitions::UnionPropertyType.new(types: types)
361
+ has_null ? make_nullable(union_type) : union_type
362
+ end
363
+
364
+ sig { params(property_type: Definitions::PropertyTypes).returns(Definitions::PropertyTypes) }
365
+ def make_nullable(property_type)
366
+ property_type.with_nullable
367
+ end
368
+
369
+ sig { params(schema: T::Hash[String, T.untyped]).returns(T.nilable(String)) }
370
+ def extract_comment(schema)
371
+ description = schema["description"]
372
+ examples = schema["examples"]
373
+
374
+ comment_parts = []
375
+ comment_parts << description if description
376
+ comment_parts << "Examples: #{examples.join(", ")}" if examples&.any?
377
+
378
+ comment_parts.empty? ? nil : comment_parts.join("\n")
379
+ end
380
+
381
+ sig { params(ref_path: String).returns(T::Hash[String, T.untyped]) }
382
+ def resolve_ref(ref_path)
383
+ # External references are not supported
384
+ raise Skit::Error, "External references not yet supported: #{ref_path}" unless ref_path.start_with?("#/")
385
+
386
+ # Parse JSON pointer and resolve reference
387
+ # #/$defs/Name -> ["$defs", "Name"]
388
+ path_parts = T.must(ref_path[2..]).split("/")
389
+
390
+ resolved = path_parts.reduce(@schema) do |current, part|
391
+ break nil unless current.is_a?(Hash)
392
+
393
+ current[part]
394
+ end
395
+
396
+ raise Skit::Error, "Cannot resolve reference: #{ref_path}" unless resolved
397
+
398
+ unless resolved.is_a?(Hash)
399
+ raise Skit::Error,
400
+ "Invalid reference target: #{ref_path} - expected object, got #{resolved.class}"
401
+ end
402
+
403
+ resolved
404
+ end
405
+ end
406
+ end
407
+ end