claricle 0.1.0 → 0.2.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +10 -1
  3. data/LICENSE +25 -0
  4. data/README.adoc +439 -157
  5. data/TODO.plan/00-overview.md +502 -0
  6. data/TODO.plan/01-core.md +296 -0
  7. data/TODO.plan/02-inspect.md +163 -0
  8. data/TODO.plan/03-conform.md +208 -0
  9. data/TODO.plan/04-convert.md +216 -0
  10. data/exe/claricle +1 -1
  11. data/lib/claricle/batch.rb +240 -0
  12. data/lib/claricle/cli.rb +514 -40
  13. data/lib/claricle/conformance_options.rb +49 -0
  14. data/lib/claricle/conversion_engine.rb +21 -0
  15. data/lib/claricle/convert.rb +21 -0
  16. data/lib/claricle/detector.rb +696 -0
  17. data/lib/claricle/emf_header.rb +33 -0
  18. data/lib/claricle/errors.rb +73 -0
  19. data/lib/claricle/fault.rb +69 -0
  20. data/lib/claricle/handlers/base.rb +169 -0
  21. data/lib/claricle/handlers/extension_points.rb +124 -0
  22. data/lib/claricle/handlers/metafile.rb +1011 -0
  23. data/lib/claricle/handlers/pdf.rb +961 -0
  24. data/lib/claricle/handlers/pdf_arlington/definitions.rb +54 -0
  25. data/lib/claricle/handlers/pdf_arlington/kind.rb +62 -0
  26. data/lib/claricle/handlers/pdf_arlington/predicate_gate.rb +39 -0
  27. data/lib/claricle/handlers/pdf_arlington/rules.rb +86 -0
  28. data/lib/claricle/handlers/pdf_arlington/walker.rb +131 -0
  29. data/lib/claricle/handlers/pdf_arlington.rb +38 -0
  30. data/lib/claricle/handlers/pdf_profiles.rb +66 -0
  31. data/lib/claricle/handlers/png.rb +1107 -0
  32. data/lib/claricle/handlers/postscript.rb +1357 -0
  33. data/lib/claricle/handlers/svg.rb +787 -0
  34. data/lib/claricle/image.rb +272 -0
  35. data/lib/claricle/lossiness/levels.rb +13 -0
  36. data/lib/claricle/lossiness.rb +864 -0
  37. data/lib/claricle/models/base.rb +460 -0
  38. data/lib/claricle/models/batch_item.rb +127 -0
  39. data/lib/claricle/models/conversion.rb +167 -0
  40. data/lib/claricle/models/format_capability.rb +40 -0
  41. data/lib/claricle/models/free_form_hash.rb +77 -0
  42. data/lib/claricle/models/inspection.rb +188 -0
  43. data/lib/claricle/models/issue.rb +23 -0
  44. data/lib/claricle/models/location.rb +51 -0
  45. data/lib/claricle/models/report.rb +54 -0
  46. data/lib/claricle/models/uncoerced_integer.rb +38 -0
  47. data/lib/claricle/postscript_fidelity/verdict.rb +37 -0
  48. data/lib/claricle/postscript_fidelity/view_box.rb +82 -0
  49. data/lib/claricle/postscript_fidelity.rb +160 -0
  50. data/lib/claricle/registry.rb +125 -0
  51. data/lib/claricle/version.rb +1 -1
  52. data/lib/claricle/writer.rb +468 -0
  53. data/lib/claricle.rb +244 -7
  54. metadata +201 -19
  55. data/claricle.gemspec +0 -42
  56. data/sig/claricle.rbs +0 -4
@@ -0,0 +1,460 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "lutaml/model"
5
+
6
+ module Claricle
7
+ module Models
8
+ # The per-attribute checks `validate_deeply` runs beyond what lutaml
9
+ # validates on its own. Its own module because Base crosses RuboCop's
10
+ # class-length limit with this much of it inline -- measured, not
11
+ # assumed: folded back in, RuboCop reports Base at 111 lines against
12
+ # a limit of 100. Dropping the Marshal protocol did not change that.
13
+ module Validation
14
+ CORE_INSTANCE = ::Object.instance_method(:instance_of?)
15
+ KIND_OF = ::Object.instance_method(:is_a?)
16
+ CLASS_OF = ::Object.instance_method(:class)
17
+ FROZEN = ::Object.instance_method(:frozen?)
18
+ ARRAY_ELEMENTS = ::Array.instance_method(:each)
19
+ ARRAY_FIRST = ::Array.instance_method(:first)
20
+ ARRAY_SIZE = ::Array.instance_method(:size)
21
+ private_constant :CORE_INSTANCE, :KIND_OF, :CLASS_OF, :FROZEN,
22
+ :ARRAY_ELEMENTS, :ARRAY_FIRST, :ARRAY_SIZE
23
+
24
+ private
25
+
26
+ def core_instance?(value, type)
27
+ CORE_INSTANCE.bind_call(value, type)
28
+ end
29
+
30
+ def inherits_from?(value, type)
31
+ KIND_OF.bind_call(value, type)
32
+ end
33
+
34
+ def class_of(value)
35
+ CLASS_OF.bind_call(value)
36
+ end
37
+
38
+ def object_frozen?(value)
39
+ FROZEN.bind_call(value)
40
+ end
41
+
42
+ def each_array(value, &)
43
+ ARRAY_ELEMENTS.bind_call(value, &)
44
+ end
45
+
46
+ def array_first(value)
47
+ ARRAY_FIRST.bind_call(value)
48
+ end
49
+
50
+ def array_size(value)
51
+ ARRAY_SIZE.bind_call(value)
52
+ end
53
+
54
+ # One sentence for every one of these checks, and one place that
55
+ # knows lutaml's ValidationError carries exceptions rather than
56
+ # Strings -- handed bare Strings, its own `error_messages` blows
57
+ # up. Subclasses raise through here too.
58
+ def refuse(name, expectation, got)
59
+ raise Lutaml::Model::ValidationError,
60
+ [TypeError.new("#{name} expects #{expectation}, got #{got}")]
61
+ end
62
+
63
+ # A declared model attribute is only cast when it arrives as a hash
64
+ # from a document. Handed a wrong-typed object directly, lutaml stores
65
+ # it as-is and the failure surfaces much later as a NoMethodError.
66
+ def validate_types
67
+ self.class.attributes.each do |name, attribute|
68
+ validate_finite(name)
69
+ validate_default_bookkeeping(name, attribute)
70
+
71
+ type = attribute.type
72
+ next unless type.is_a?(Class) && type < Base
73
+
74
+ validate_attribute(name, attribute, type)
75
+ end
76
+ end
77
+
78
+ # `using_default_for` is public on lutaml, for its own deserializer
79
+ # to mark an attribute absent from the document. A caller can reach
80
+ # it too -- through the block form of `new`, before this runs -- and
81
+ # flip ANY attribute to "using its default" while it still holds
82
+ # the value it was actually given. Rendering skips whatever is
83
+ # marked default, so the value survives the reader and vanishes
84
+ # from the document -- measured on a required attribute (`to_json`
85
+ # rendered `{"message":"m"}` for an Issue whose `severity` still
86
+ # read back "info", and reloading that JSON raised
87
+ # `ValidationError: Missing required attribute: severity`) and
88
+ # separately on optional, unguarded ones (`source_path`, `format`,
89
+ # `Issue#code` all round-tripped to nil having read back their real
90
+ # value right up to the freeze).
91
+ #
92
+ # None of these models declares `default:`, so a genuine default is
93
+ # always blank -- nil for a scalar, empty for a collection, once
94
+ # `normalize` has run (which it has by here). A NON-blank value
95
+ # under a true `using_default?` is therefore always the bookkeeping
96
+ # lying about a value that IS present, never a legitimate default,
97
+ # whether or not the attribute is required.
98
+ def validate_default_bookkeeping(name, attribute)
99
+ return unless using_default?(name)
100
+
101
+ value = public_send(name)
102
+ blank = attribute.collection? ? value.empty? : value.nil?
103
+ return if blank
104
+
105
+ refuse(name, "recorded as explicitly set", "flagged as using its default")
106
+ end
107
+
108
+ # JSON has neither Infinity nor NaN. lutaml coerces both without
109
+ # complaint -- measured: `{"width":1e400}` deserialized to
110
+ # Float::INFINITY, the lifecycle froze the model around it, and the
111
+ # `to_json` that followed raised `JSON::GeneratorError`, so a
112
+ # document that parsed could not be written back out. Refused here,
113
+ # where the attribute name is still in hand.
114
+ def validate_finite(name)
115
+ value = public_send(name)
116
+ return unless value.is_a?(Numeric) && !value.finite?
117
+
118
+ refuse(name, "a finite number", value)
119
+ end
120
+
121
+ # Lutaml's generated getter consults a scalar enum's backing Array
122
+ # before `validate_types`. Own a core copy first so virtual `first`
123
+ # cannot show validation a different value from the one later
124
+ # frozen and rendered.
125
+ def own_enum_storage(name, attribute, raw)
126
+ return raw if attribute.collection? || !attribute.enum?
127
+ return raw if core_instance?(raw, ::NilClass) ||
128
+ Lutaml::Model::Utils.uninitialized?(raw)
129
+
130
+ refuse(name, "a core Array", class_of(raw)) unless core_instance?(raw, ::Array)
131
+
132
+ copy = []
133
+ each_array(raw) { |value| copy << own_enum_value(name, attribute, value) }
134
+ object_frozen?(self) ? raw : copy
135
+ end
136
+
137
+ def own_enum_value(name, attribute, value)
138
+ return value if Lutaml::Model::Utils.uninitialized?(value)
139
+ return value unless attribute.type == Lutaml::Model::Type::String
140
+
141
+ refuse(name, "a String enum value", class_of(value)) unless inherits_from?(value, ::String)
142
+ own_string(name, value)
143
+ end
144
+
145
+ # No `unless value.is_a?(Array)` guard here any more: lutaml-model
146
+ # 0.8.32 (lutaml/lutaml-model#185) auto-wraps ANY non-Array value
147
+ # handed to a `collection: true` attribute into a one-element Array
148
+ # before this runs, at both construction and deserialization --
149
+ # `Report.new(issues: some_issue)` and
150
+ # `Report.from_json({"issues":{...}})` both now read back
151
+ # `issues == [some_issue]`. The shape check can no longer fire; only
152
+ # the per-element type check below still can, unchanged.
153
+ def validate_attribute(name, attribute, type)
154
+ value = public_send(name)
155
+ return validate_type(name, type, value, nullable: true) unless attribute.collection?
156
+
157
+ value.each { |element| validate_type(name, type, element, nullable: false) }
158
+ end
159
+
160
+ # A singular model attribute may be absent; a collection member may
161
+ # not -- a nil there reaches recursion and dies as a NoMethodError.
162
+ def validate_type(name, type, element, nullable:)
163
+ return if nullable && element.nil?
164
+ return if element.is_a?(type)
165
+
166
+ refuse(name, type, element.class)
167
+ end
168
+ end
169
+
170
+ # lutaml-model 0.8.19 validates nothing on its own: a bogus enum
171
+ # constructs and serializes, a missing attribute becomes nil, and
172
+ # `validate!` does not reach nested models. Every model therefore runs
173
+ # one lifecycle -- normalize, validate, freeze -- at both doors.
174
+ class Base < Lutaml::Model::Serializable
175
+ include Validation
176
+
177
+ DESERIALIZING = :claricle_models_deserializing
178
+ # Lutaml builds every model -- parent and nested alike -- by calling
179
+ # `new` with exactly this and populating it afterwards. Direct
180
+ # construction always carries real attributes.
181
+ BLANK_CONSTRUCTION = [:lutaml_register].freeze
182
+ private_constant :DESERIALIZING, :BLANK_CONSTRUCTION
183
+
184
+ # Public on lutaml, for its own XML allocation path
185
+ # (`allocate_for_deserialization`, reached only through an `xml`
186
+ # mapping block) to reset every attribute to a shared empty-
187
+ # collection sentinel or `UninitializedClass.instance` before
188
+ # filling it in field by field. None of these models declares an
189
+ # `xml` block, so lutaml never calls either method on one -- but a
190
+ # caller reached them too, through the block form of `new`, before
191
+ # this runs. Measured: `init_deserialization_state(:default)` wiped
192
+ # a Report's `source_path` to the sentinel and its whole `issues`
193
+ # collection to `[]`, and the model sealed reporting `valid: :yes`
194
+ # over an issue the caller had actually supplied -- no schema
195
+ # violation to catch, since the wiped shape is indistinguishable
196
+ # from a Report the caller genuinely built empty. Hidden rather
197
+ # than checked for, because the collection wipe leaves no value
198
+ # anywhere in the object for `validate_types` to catch.
199
+ private :init_deserialization_state, :finalize_deserialization
200
+
201
+ # Deserialization builds a blank instance and populates it afterwards,
202
+ # so `initialize` must not finalize during that window. Neither the
203
+ # key nor the value is secret; the protection is that BOTH the extent
204
+ # and lutaml's blank signature must hold, so ordinary construction
205
+ # validates however the thread-local was set. Forging both yields an
206
+ # empty model, which `allocate` already gives anyone who wants one.
207
+ # `Thread.current[]` is fiber-local, so a boundary it does not cross
208
+ # makes construction validate rather than skip.
209
+ #
210
+ # `of` rather than `from`: every `from_*` parses and then delegates
211
+ # here, so this one override also covers `of_json` and friends. A
212
+ # top-level array arrives already finalized, because `of` maps it back
213
+ # through itself one item at a time.
214
+ def self.of(format, doc, options = {})
215
+ outer = Thread.current[DESERIALIZING]
216
+ Thread.current[DESERIALIZING] = true
217
+ begin
218
+ result = super
219
+ ensure
220
+ Thread.current[DESERIALIZING] = outer
221
+ end
222
+ result.is_a?(Array) ? result : result.finalize
223
+ end
224
+
225
+ # Lutaml normally deletes bytes it cannot transcode before assigning
226
+ # them. Preserve those bytes so the name-aware lifecycle below can
227
+ # report why the declared String is unusable instead of calling a
228
+ # required value missing.
229
+ def self.ensure_utf8(value)
230
+ return super unless value.is_a?(::String) && !Lutaml::Model.opal?
231
+
232
+ value.encode(Encoding::UTF_8)
233
+ rescue EncodingError
234
+ value
235
+ end
236
+
237
+ # Lutaml also accepts a positional attributes Hash, so take whatever
238
+ # the superclass takes.
239
+ # Both conditions are required. Inside a deserialization but with
240
+ # real attributes means a caller built this themselves -- through a
241
+ # coercion callback, say -- so it validates now. Outside one, the
242
+ # thread-local is meaningless however it was set.
243
+ def initialize(*args, **kwargs)
244
+ super
245
+ finalize unless args.empty? &&
246
+ kwargs.keys == BLANK_CONSTRUCTION &&
247
+ Thread.current[DESERIALIZING]
248
+ end
249
+
250
+ def initialize_copy(other)
251
+ super
252
+ @claricle_sealed = false
253
+ finalize
254
+ end
255
+
256
+ def finalize
257
+ validate_deeply
258
+ seal
259
+ self
260
+ end
261
+
262
+ def validate_deeply
263
+ clear_uninitialized
264
+ normalize
265
+ validate!
266
+ validate_types
267
+ nested_models.each(&:validate_deeply)
268
+ end
269
+
270
+ protected
271
+
272
+ # Idempotent on our own bookkeeping rather than on `frozen?`, so a
273
+ # model frozen by other code is not mistaken for a sealed one. An
274
+ # empty model with a required attribute never reaches here --
275
+ # validation rejects it first; Report and Location have none, so an
276
+ # empty instance of either is valid and does reach this point. A
277
+ # populated one that dies does so partway through, which is the
278
+ # externally-frozen case tested below.
279
+ #
280
+ # The marker goes on last, beside the freeze it stands for. Set
281
+ # first, a child that refused to seal left the parent marked sealed
282
+ # and still mutable, and every later `seal` returned at the guard --
283
+ # measured: the block form of `new` handed back a Report that called
284
+ # itself sealed, took another issue, and changed its own verdict.
285
+ # There is no cycle for an early marker to break: nesting runs
286
+ # Report -> Issue -> Location and stops.
287
+ #
288
+ # Protected, not public: it freezes without revalidating, so a
289
+ # caller holding a model that failed to finalize (the block form of
290
+ # `new` hands one over before the exception) could mutate it into
291
+ # any shape and freeze it straight past `validate_deeply` -- measured,
292
+ # a poisoned Issue sealed with `severity: "bogus"` and serialized.
293
+ # `finalize` is the only public door, and it always validates first.
294
+ # Explicit block form for the recursive call below: `Symbol#to_proc`
295
+ # calls `public_send`, which a protected method refuses.
296
+ def seal
297
+ return self if @claricle_sealed
298
+
299
+ # rubocop:disable Style/SymbolProc -- &:seal calls public_send,
300
+ # which a protected method refuses; the block calls it directly.
301
+ nested_models.each { |model| model.seal }
302
+ # rubocop:enable Style/SymbolProc
303
+ freeze_attributes
304
+ # Lutaml tracks which attributes still hold defaults in a mutable
305
+ # hash, and `using_default_for` is public. Flipping an entry on a
306
+ # frozen model drops that attribute from the document -- measured:
307
+ # marking every one of an issue's fields turns "issues":[...] into
308
+ # "issues":null, and a warning report then reloads valid. Freezing
309
+ # it here, after the attribute pass, leaves composition intact.
310
+ instance_variable_get(:@using_default)&.freeze
311
+ @claricle_sealed = true
312
+ freeze
313
+ end
314
+
315
+ private
316
+
317
+ # lutaml has a sentinel object standing for "never set", and the
318
+ # block form of `new` is the one door that can put it in a SINGULAR
319
+ # attribute's ivar. Through the five others -- keyword, positional,
320
+ # `from_hash`, `from_json`, and nested inside a parent -- lutaml
321
+ # erases it before our lifecycle runs and the ivar holds a plain
322
+ # nil. That is why the doors are made to agree by normalising
323
+ # rather than by refusing: a refusal is only reachable from one of
324
+ # the six.
325
+ #
326
+ # Measured on 0.8.19, the block form disagreed with the rest and
327
+ # with itself. `Location.new { |l| l.byte_offset(sentinel) }` raised
328
+ # `ValidationError: byte_offset expects a non-negative integer, got
329
+ # uninitialized`, while `Location.new { |l| l.chunk(sentinel) }`
330
+ # sealed and handed the sentinel itself back out of `chunk`, where
331
+ # a caller expects a String or nil. Worse on a required attribute:
332
+ # `Issue.new(severity: "error") { |i| i.message(sentinel) }` sealed
333
+ # and rendered `{"severity":"error"}`, a document missing a field
334
+ # it cannot be missing, which reloaded as `Missing required
335
+ # attribute: message`. Cleared to nil, the required check sees it.
336
+ #
337
+ # A COLLECTION never reaches here: lutaml wraps whatever the setter
338
+ # is given, so the ivar holds `[sentinel]` rather than the sentinel
339
+ # and `uninitialized?` is false. `validate_types` refuses that on
340
+ # its own -- measured, `issues expects Claricle::Models::Issue, got
341
+ # Lutaml::Model::UninitializedClass` -- which is the right answer
342
+ # for a member of a typed collection and not this method's job.
343
+ #
344
+ # A no-op on every other path. Measured across 16 shapes -- all
345
+ # four models, empty and fully populated, through `new`,
346
+ # `from_json`, `from_yaml`, `from_hash`, `of_json`, `dup` and
347
+ # nested inside a parent -- it cleared nothing in any of them.
348
+ def clear_uninitialized
349
+ self.class.attributes.each do |name, attribute|
350
+ slot = :"@#{name}"
351
+ original = instance_variable_get(slot)
352
+ raw = own_enum_storage(name, attribute, original)
353
+ instance_variable_set(slot, raw) unless raw.equal?(original)
354
+ next unless Lutaml::Model::Utils.uninitialized?(stored_value(raw, attribute))
355
+
356
+ instance_variable_set(slot, nil)
357
+ end
358
+ end
359
+
360
+ # lutaml wraps a singular enum's value in an Array, so its sentinel
361
+ # arrives as `[sentinel]` and identity against the singleton walks
362
+ # straight past it. The same wrapper `own_enum_storage` reads
363
+ # through, and the same guard it uses to find it.
364
+ #
365
+ # Measured before this looked inside: `Inspection.new { |x|
366
+ # x.parse_status = sentinel }` sealed and rendered `{"issues":[]}`
367
+ # -- a required attribute absent from the document -- and reloading
368
+ # that raised `Missing required attribute: parse_status`. Every
369
+ # other door refused it outright. `Issue#severity` did the same.
370
+ #
371
+ # A collection's `[sentinel]` is deliberately left alone: there the
372
+ # Array is the value, not a wrapper, and `validate_types` refuses
373
+ # the member on its own.
374
+ def stored_value(raw, attribute)
375
+ return raw if attribute.collection? || !attribute.enum?
376
+ return raw unless core_instance?(raw, ::Array) && array_size(raw) == 1
377
+
378
+ array_first(raw)
379
+ end
380
+
381
+ # Refused, rather than supported. Ruby's default Marshal writes the
382
+ # ivars into a fresh object and runs neither the constructor nor the
383
+ # lifecycle -- measured: the copy came back unfrozen, took
384
+ # `severity = "bogus"` past the enum, and rendered it. Nothing in
385
+ # this gem forks, caches or crosses a process boundary, so no model
386
+ # is dumped at all rather than dumped through a protocol of its own.
387
+ # A real one can be written the day something actually needs it.
388
+ #
389
+ # This closes the dump side only, and does not pretend otherwise.
390
+ # `Marshal.load` of bytes a caller got from somewhere else still
391
+ # builds whatever those bytes describe -- measured, a hand-built
392
+ # ordinary-object payload naming this class loaded unfrozen with
393
+ # `severity: "bogus"`. That was equally true before this refusal
394
+ # existed: `_load` was only ever reached for payloads our own
395
+ # `_dump` had written, never for a payload someone else shaped.
396
+ # Nothing in this gem calls `Marshal.load`.
397
+ #
398
+ # `_dump` and not `marshal_dump`: Ruby prefers `marshal_dump` where
399
+ # both exist, so the refusal sits on the hook a later `marshal_dump`
400
+ # would have to displace deliberately. Private, because Marshal
401
+ # reaches a private `_dump` on the class and on every subclass --
402
+ # measured -- so nothing is gained by publishing it. It covers a
403
+ # model nested inside a plain Hash or Array too, also measured.
404
+ def _dump(_depth)
405
+ raise TypeError, "cannot marshal #{self.class}: Claricle models are not marshalable"
406
+ end
407
+
408
+ # Freeze declared attributes only, and freeze them where they lie.
409
+ # Inspection owns and freezes its free-form Hash before this pass.
410
+ # Works on the backing storage, not the getters. An enum declared with
411
+ # `values:` is stored as a mutable Array behind a String getter, so
412
+ # freezing what the getter returns leaves that array writable and a
413
+ # frozen report can still have its verdict changed. Writing the ivar
414
+ # rather than calling the setter also leaves lutaml's `using_default`
415
+ # bookkeeping alone.
416
+ #
417
+ # Strings are duplicated first: `new` hands us its own copy, but
418
+ # `from_hash` aliases the caller's, and freezing that would take their
419
+ # String away from them.
420
+ def freeze_attributes
421
+ self.class.attributes.each_key do |name|
422
+ slot = :"@#{name}"
423
+ raw = instance_variable_get(slot)
424
+ if inherits_from?(raw, ::String)
425
+ instance_variable_set(slot, own_string(name, raw))
426
+ elsif core_instance?(raw, ::Array)
427
+ instance_variable_set(slot, own_array(name, raw))
428
+ end
429
+ end
430
+ end
431
+
432
+ def own_array(name, value)
433
+ copy = []
434
+ each_array(value) { |element| copy << own_string(name, element) }
435
+ copy.freeze
436
+ end
437
+
438
+ def own_string(name, value)
439
+ return value unless inherits_from?(value, ::String)
440
+
441
+ copy = ::String.new(value)
442
+ JSON.generate(copy)
443
+ copy.freeze
444
+ rescue JSON::GeneratorError => e
445
+ refuse(name, "a String JSON can render", e.message)
446
+ end
447
+
448
+ def normalize; end
449
+
450
+ def nested_models
451
+ []
452
+ end
453
+ end
454
+
455
+ # Validation is an implementation detail of Base, not an extension
456
+ # point -- Base is private, so a mixin only Base uses has no caller
457
+ # left to serve.
458
+ private_constant :Base, :Validation
459
+ end
460
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "report"
5
+ require_relative "conversion"
6
+ require_relative "uncoerced_integer"
7
+
8
+ module Claricle
9
+ module Models
10
+ # Why this is not a Models::Issue: an Issue is the document's problem,
11
+ # and this is the operation's. A file that could not be opened, or whose
12
+ # format nothing handles, has no conformance verdict at all -- collapsing
13
+ # the two would blur the one line the envelope exists to draw.
14
+ class BatchError < Base
15
+ attribute :code, :string, required: true
16
+ attribute :message, :string, required: true
17
+ end
18
+
19
+ # One slot of a batch, and the only element type a batch ever holds. A
20
+ # heterogeneous array of results and failures would force a consumer to
21
+ # type-switch on whether a field is present, and `jq '.[].result.valid'`
22
+ # would answer null for an operational failure -- indistinguishable from
23
+ # a genuine null.
24
+ class BatchItem < Base
25
+ # The codes a process status can carry, so this cannot record one the
26
+ # shell would misread. The same range Cli::Runner::Status enforces.
27
+ CODES = (0..255)
28
+ private_constant :CODES
29
+
30
+ attribute :path, :string, required: true
31
+ # Recomputed in `normalize` from the two fields below, so whatever a
32
+ # caller or a document supplies here is overwritten before the model
33
+ # seals. Three values, not two: "the operation ran and said no" and
34
+ # "the operation could not run" are different answers, and the second
35
+ # is why an operational failure must never read as a verdict.
36
+ #
37
+ # Report#valid derives the same kind of summary through a `with:`
38
+ # mapping instead. That route is closed here: lutaml 0.8.22 invokes a
39
+ # `with:` mapping's `from` method on a throwaway instance built by
40
+ # `mapper_class.new` with no arguments at all
41
+ # (mapping_rule.rb:412-417), and Base's lifecycle validates that
42
+ # instance -- measured, `BatchItem.new` raises ValidationError on the
43
+ # two required attributes above. Report has no required attribute, so
44
+ # it never met this. A `to`-only mapping is refused outright
45
+ # ("`:with` argument for mapping 'status' requires :to and :from").
46
+ attribute :status, :string, values: %w[ok failed error]
47
+ attribute :exit_code, UncoercedInteger, required: true
48
+ # A union rather than a plain `Report`, now that a batch can also hold
49
+ # a conversion. Lutaml resolves which member a Hash/JSON value is by
50
+ # key coverage (`Union.conform_model`, lutaml-model union.rb:163-180):
51
+ # every input key must belong to the member's mapped fields. Report and
52
+ # Conversion both map "source_path", but are disjoint on every OTHER
53
+ # field -- Report also maps "format"/"issues"/"valid", Conversion also
54
+ # maps "source_format"/"target_format"/"lossiness"/"output_path" -- so
55
+ # a real document built by either one always carries at least one of
56
+ # those and resolves unambiguously. Only a Hash bearing "source_path"
57
+ # and nothing else would be genuinely ambiguous, and nothing in this
58
+ # codebase builds one. Declared order does not matter for any real
59
+ # input; Report is listed first as it is the existing, more common
60
+ # case.
61
+ #
62
+ attribute :result, [Report, Conversion]
63
+ alias cast_result= result=
64
+ private :cast_result=
65
+
66
+ # Lutaml casts a value matching neither union member to nil. Refuse it
67
+ # here while the caller's original value is still available.
68
+ def result=(value)
69
+ self.cast_result = value
70
+ validate_result(value)
71
+ value
72
+ end
73
+
74
+ attribute :error, BatchError
75
+
76
+ # Keyword and positional-hash construction cast union values before
77
+ # calling the writer, so retain the original until casting finishes.
78
+ def initialize(*args, **kwargs)
79
+ supplied_result = result_argument(args, kwargs)
80
+ super
81
+ validate_result(supplied_result)
82
+ end
83
+
84
+ private
85
+
86
+ def result_argument(args, kwargs)
87
+ return kwargs[:result] if kwargs.key?(:result)
88
+
89
+ attributes = args.first
90
+ attributes[:result] if attributes.is_a?(::Hash) && attributes.key?(:result)
91
+ end
92
+
93
+ def validate_result(value)
94
+ return if value.nil? || result.is_a?(Report) || result.is_a?(Conversion)
95
+
96
+ refuse(:result, "a Report or Conversion", value.class)
97
+ end
98
+
99
+ # The verdict is written here rather than read from what arrived, so a
100
+ # document cannot assert a status its own fields contradict, and the
101
+ # stored value cannot drift from them.
102
+ def normalize
103
+ self.status = derived_status
104
+ end
105
+
106
+ def derived_status
107
+ return "error" if error
108
+ return "ok" if exit_code.is_a?(::Integer) && exit_code.zero?
109
+
110
+ "failed"
111
+ end
112
+
113
+ # The declared type keeps the caller's value intact precisely so this
114
+ # can tell `"3"` from the `3` lutaml would have made of it.
115
+ def validate_types
116
+ super
117
+ return if exit_code.is_a?(::Integer) && CODES.cover?(exit_code)
118
+
119
+ refuse(:exit_code, "an Integer in #{CODES}", exit_code.inspect)
120
+ end
121
+
122
+ def nested_models
123
+ [result, error].compact
124
+ end
125
+ end
126
+ end
127
+ end