suma 0.5.10 → 0.5.12

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01e0b1dd212172912b654cac481e0ce1eea1efee1275f45d4332c0a6bbb28e69
4
- data.tar.gz: 54399e492be26b0a09595debdc61c256f566da28ed13ca002cae2a6eb0925b57
3
+ metadata.gz: f847c46e5111be8368fe48664c875593f597137a4c19a715f0b3fd06bb72189f
4
+ data.tar.gz: 4577779a41047f406a1d80a517d6f4ef330bd2921fad215cbddf45f1cd948b44
5
5
  SHA512:
6
- metadata.gz: 9fb0aebf85233af1df6162efb518605bd7ab01c7396e7af6012144fd05c74a135ba7d2833b7bb633fa45c755e067d6799569412df0b21458b33d2730a5081a3a
7
- data.tar.gz: d9836497a01e7711aece5c76534ecfa4e7e0876bfd414aab783ada288b1906e07a3f3b6994d17085f294b79a827c16ecb61bab7cf305a823b9527933e5aa7f1c
6
+ metadata.gz: 07d6d13740c24fb1cf3f16854f7463fa3d546204fe164dc804590a029e8dfa284d62a0c929d1ed9b35e80838153d1dff564fddbc81557d142bb828092d5acf9e
7
+ data.tar.gz: c2e1ae8b870d49d765211f8e51d2e057a10fbfcf53723ba758bd2d5151fa7b37d91e1643df648806ae47b28f0694c9f3beb446d5b0dbe647d733596e5976a484
@@ -14,8 +14,15 @@ module Suma
14
14
  desc: "Generate file that contains all schemas in the collection."
15
15
  option :staged, type: :boolean, default: false,
16
16
  desc: "Memory-bounded staged build: compile each member in " \
17
- "its own process (sequential) and reinflate. For large " \
18
- "collections that OOM a single-process build (suma#94)."
17
+ "its own process (sequential unless --staged-workers " \
18
+ "is given) and reinflate. For large collections that " \
19
+ "OOM a single-process build (suma#94)."
20
+ option :staged_workers, type: :numeric, default: 1,
21
+ desc: "Concurrent staged render processes (batched " \
22
+ "members per process; requires --staged)"
23
+ option :staged_batch_size, type: :numeric,
24
+ desc: "Members per staged render process " \
25
+ "(default: 25)"
19
26
  option :incremental, type: :boolean, default: false,
20
27
  desc: "Re-render only changed schemas and their " \
21
28
  "transitive dependents (state file defaults " \
@@ -58,7 +65,6 @@ module Suma
58
65
  schemas_all_path: schemas_all_path,
59
66
  compile: options[:compile],
60
67
  output_directory: "_site",
61
- staged: options[:staged],
62
68
  incremental: options[:incremental],
63
69
  state_file: options[:state_file],
64
70
  jobs: options[:jobs],
@@ -66,9 +72,16 @@ module Suma
66
72
  sign_method: options[:sign],
67
73
  sign_key: options[:sign_key],
68
74
  no_valid: options[:no_valid],
75
+ **staged_options(options),
69
76
  ).run
70
77
  end
71
78
 
79
+ def staged_options(options)
80
+ { staged: options[:staged],
81
+ staged_workers: options[:staged_workers],
82
+ staged_batch_size: options[:staged_batch_size] }
83
+ end
84
+
72
85
  def log_error(error)
73
86
  Utils.log "[ERROR] Error occurred during processing. See details below."
74
87
  Utils.log error
@@ -36,8 +36,8 @@ module Suma
36
36
  BUILTIN_IDENTIFIERS = %w[
37
37
  abs acos asin atan atan2 bexists blength const_e cos exists exp
38
38
  format hibound hiindex length lobound loindex log log2 log10 nvl odd
39
- roles_of sin sizeof sqrt tan typeof usedin value value_in
40
- value_range value_list_by unique
39
+ roles_of rolesof sin sizeof sqrt tan typeof usedin value value_in
40
+ value_range value_list_by value_unique unique
41
41
  self and or xor not in like equal instanceof
42
42
  e pi
43
43
  ].freeze
@@ -61,9 +61,9 @@ module Suma
61
61
  parsed = manifest.schemas.map do |entry|
62
62
  [entry, Expressir::Express::Parser.from_file(entry.path.to_s)]
63
63
  end
64
- corpus_entities = parsed.flat_map { |_, repo| repo.schemas }
64
+ corpus_entities = parsed.flat_map { |_, repo| Array(repo.schemas) }
65
65
  .flat_map do |schema|
66
- schema.children.grep(Expressir::Model::Declarations::Entity)
66
+ Array(schema.children).grep(Expressir::Model::Declarations::Entity)
67
67
  end
68
68
  .to_h { |entity| [entity.id.downcase, entity] }
69
69
 
@@ -84,10 +84,10 @@ module Suma
84
84
  def validate_schema(file, schema, corpus_entities = {})
85
85
  violations = []
86
86
  scope = SchemaScope.new(schema, graph)
87
- entities = schema.children.grep(Expressir::Model::Declarations::Entity)
87
+ entities = Array(schema.children).grep(Expressir::Model::Declarations::Entity)
88
88
  .to_h { |entity| [entity.id.downcase, entity] }
89
89
 
90
- schema.interfaces.each do |interface|
90
+ Array(schema.interfaces).each do |interface|
91
91
  next if graph.schema_exists?(interface.schema.id)
92
92
 
93
93
  violations << unresolved(file, schema.id, schema.id, "interface",
@@ -95,7 +95,7 @@ module Suma
95
95
  "interface target not declared")
96
96
  end
97
97
 
98
- schema.children.each do |owner|
98
+ Array(schema.children).each do |owner|
99
99
  violations.concat(declared_element_references(file, schema.id, owner,
100
100
  scope, entities,
101
101
  corpus_entities))
@@ -145,7 +145,7 @@ module Suma
145
145
  refs = (entity.where_rules || []).flat_map do |rule|
146
146
  expression_references(rule.expression, "where", locals)
147
147
  end
148
- entity.attributes.each do |attribute|
148
+ Array(entity.attributes).each do |attribute|
149
149
  refs.concat(datatype_references(attribute.type, "attribute"))
150
150
  end
151
151
  refs.concat(supertype_references(entity))
@@ -159,7 +159,7 @@ module Suma
159
159
  end
160
160
 
161
161
  def rule_references(rule)
162
- refs = rule.applies_to.map { |ref| ["rule_applies_to", ref.id] }
162
+ refs = Array(rule.applies_to).map { |ref| ["rule_applies_to", ref.id] }
163
163
  locals = Array(rule.variables).filter_map do |variable|
164
164
  variable.id&.downcase
165
165
  end
@@ -383,24 +383,24 @@ module Suma
383
383
  @graph = graph
384
384
  @schema_id = schema.id
385
385
 
386
- schema.children.each { |declaration| add(declaration) }
387
- schema.entities.each do |entity|
388
- entity.attributes.each { |attribute| add(attribute) }
386
+ Array(schema.children).each { |declaration| add(declaration) }
387
+ Array(schema.entities).each do |entity|
388
+ Array(entity.attributes).each { |attribute| add(attribute) }
389
389
  end
390
- schema.rules.each do |rule|
391
- rule.variables.each { |variable| add(variable) }
392
- rule.applies_to.each do |ref|
393
- entity = schema.entities.find do |candidate|
390
+ Array(schema.rules).each do |rule|
391
+ Array(rule.variables).each { |variable| add(variable) }
392
+ Array(rule.applies_to).each do |ref|
393
+ entity = Array(schema.entities).find do |candidate|
394
394
  candidate.id&.downcase == ref.id&.downcase
395
395
  end
396
396
  next unless entity
397
397
 
398
- entity.attributes.each { |attribute| add(attribute) }
398
+ Array(entity.attributes).each { |attribute| add(attribute) }
399
399
  end
400
400
  end
401
- schema.constants.each { |constant| add(constant) }
402
- schema.functions.each do |function|
403
- (function.parameters || []).each { |parameter| add(parameter) }
401
+ Array(schema.constants).each { |constant| add(constant) }
402
+ Array(schema.functions).each do |function|
403
+ Array(function.parameters).each { |parameter| add(parameter) }
404
404
  end
405
405
  end
406
406
 
@@ -134,7 +134,7 @@ module Suma
134
134
 
135
135
  def declared_elements(schema)
136
136
  index = {}
137
- schema.children.each do |element|
137
+ Array(schema.children).each do |element|
138
138
  if element.respond_to?(:id) && element.id
139
139
  index[normalize(element.id)] =
140
140
  true
@@ -167,7 +167,7 @@ module Suma
167
167
  # the Expressir model and must not shadow declarations).
168
168
  def index_elements
169
169
  @schemas.each_with_object({}) do |(schema_key, schema), map|
170
- schema.children.each do |element|
170
+ Array(schema.children).each do |element|
171
171
  next unless class_element?(element)
172
172
 
173
173
  map[[schema_key, normalize(element.id)]] = element
@@ -231,7 +231,7 @@ module Suma
231
231
  end
232
232
 
233
233
  def project_schema(graph, schema_key, schema)
234
- schema.children.each do |element|
234
+ Array(schema.children).each do |element|
235
235
  case element
236
236
  when Expressir::Model::Declarations::Entity
237
237
  project_entity(graph, schema_key, element)
@@ -482,7 +482,7 @@ module Suma
482
482
 
483
483
  def validate_materialization(projected)
484
484
  projected.flat_map do |schema_key, schema|
485
- schema.children.filter_map do |element|
485
+ Array(schema.children).filter_map do |element|
486
486
  next unless class_element?(element)
487
487
 
488
488
  iri = element_iri(schema_key, element.id)
@@ -13,6 +13,7 @@ module Suma
13
13
 
14
14
  def initialize(metanorma_yaml_path:, schemas_all_path:, compile: true,
15
15
  output_directory: "_site", staged: false,
16
+ staged_workers: 1, staged_batch_size: nil,
16
17
  incremental: false, state_file: nil, jobs: 1,
17
18
  provenance_file: nil, sign_method: nil, sign_key: nil,
18
19
  no_valid: false)
@@ -23,6 +24,8 @@ module Suma
23
24
  # Opt-in memory-bounded staged build (metanorma/suma#94); the default
24
25
  # single-process build below is unchanged when false.
25
26
  @staged = staged
27
+ @staged_workers = staged_workers
28
+ @staged_batch_size = staged_batch_size
26
29
  @incremental = incremental
27
30
  @state_file = state_file
28
31
  @jobs = jobs
@@ -181,6 +184,9 @@ module Suma
181
184
  output_directory: output_directory,
182
185
  coverpage: collection_coverpage(collection_config),
183
186
  no_valid: @no_valid,
187
+ workers: @staged_workers,
188
+ batch_size: @staged_batch_size ||
189
+ StagedCollectionBuilder::DEFAULT_BATCH_SIZE,
184
190
  ).build
185
191
  else
186
192
  Metanorma::Collection.parse(COLLECTION_OUTPUT_PATH)
@@ -47,6 +47,18 @@ module Suma
47
47
  end
48
48
 
49
49
  def export_to_directory(schemas)
50
+ run_pool_export(schemas)
51
+ rescue SystemStackError
52
+ # One pathological schema (a very deep AST) can overflow the
53
+ # worker threads' ~1 MB stack. The main thread has the full OS
54
+ # stack and every completed export is cached, so the retry only
55
+ # pays for the schemas that did not finish.
56
+ Utils.log "[export] worker thread stack exhausted; " \
57
+ "retrying the remaining schemas on the main thread"
58
+ schemas.each { |schema| export_single_schema(schema) }
59
+ end
60
+
61
+ def run_pool_export(schemas)
50
62
  WorkerPool.new(size: options[:jobs]).map(schemas) do |schema|
51
63
  export_single_schema(schema)
52
64
  end
@@ -40,11 +40,11 @@ module Suma
40
40
 
41
41
  def element_collections(schema)
42
42
  [
43
- schema.entities,
44
- schema.types,
45
- schema.constants,
46
- schema.functions,
47
- schema.rules,
43
+ Array(schema.entities),
44
+ Array(schema.types),
45
+ Array(schema.constants),
46
+ Array(schema.functions),
47
+ Array(schema.rules),
48
48
  schema.procedures,
49
49
  schema.subtype_constraints,
50
50
  ]
@@ -16,10 +16,12 @@ module Suma
16
16
  # stub-bearing Semantic XML + anchors to a durable content-addressed store
17
17
  # (the metanorma engine's +preserve_unresolved:+ / +artifact_store_dir:+
18
18
  # render options, metanorma/metanorma#576);
19
- # 2. runs those per-member processes SEQUENTIALLY (never in parallel --
20
- # parallelism reintroduces the cross-linking / sectionsplit races that are
21
- # deliberately avoided); each process exits and the OS reclaims its memory
22
- # before the next, so peak memory is bounded by a single member; then
19
+ # 2. runs those per-member processes SEQUENTIALLY by default; with
20
+ # +workers+ > 1, batches of members are rendered by that many child
21
+ # processes concurrently (safe: members write disjoint store entries,
22
+ # and sectionsplit happens only in the sequential reinflate pass); each
23
+ # process exit hands its memory back to the OS, so peak memory stays
24
+ # bounded by workers x one member; then
23
25
  # 3. REINFLATES: a final pass assembles a manifest of the stored stubs and
24
26
  # resolves them into real cross-document links (+reinflate:+), producing
25
27
  # the same output an all-present build would.
@@ -34,12 +36,19 @@ module Suma
34
36
  class StagedCollectionBuilder
35
37
  STORE_DIRNAME = ".metanorma-collection-cache"
36
38
 
39
+ # Members rendered per child process: one Metanorma boot amortised over
40
+ # a batch of small members keeps boot cost under a few percent.
41
+ DEFAULT_BATCH_SIZE = 25
42
+
37
43
  # +collection_config_path+ is the emitted collection manifest (the same
38
44
  # +collection-output.yaml+ the normal build renders). Member filerefs are
39
45
  # relative to its directory, so all per-member manifests are written there.
40
- # (a data-declaring initializer)
46
+ # +workers+ > 1 runs that many renderer processes concurrently over batches
47
+ # of +batch_size+ members; members write disjoint, identifier-keyed store
48
+ # entries, so children cannot race. (a data-declaring initializer)
41
49
  def initialize(collection_config_path:, output_directory:, coverpage: nil,
42
- store_dir: nil, formats: %i[xml html], no_valid: false) # rubocop:disable Metrics/ParameterLists
50
+ store_dir: nil, formats: %i[xml html], no_valid: false,
51
+ workers: 1, batch_size: DEFAULT_BATCH_SIZE) # rubocop:disable Metrics/ParameterLists
43
52
  @config_path = collection_config_path
44
53
  @config_dir = File.dirname(File.expand_path(collection_config_path))
45
54
  @output_directory = output_directory
@@ -47,20 +56,139 @@ module Suma
47
56
  @store_dir = store_dir || File.join(output_directory, STORE_DIRNAME)
48
57
  @formats = formats
49
58
  @no_valid = no_valid
59
+ @workers = [workers.to_i, 1].max
60
+ @batch_size = [batch_size.to_i, 1].max
50
61
  end
51
62
 
52
63
  def build
53
64
  FileUtils.mkdir_p(@output_directory)
54
65
  FileUtils.mkdir_p(@store_dir)
55
66
  members = collection_members
56
- Utils.log "[staged] #{members.size} members; one process per member, " \
57
- "sequential; store: #{@store_dir}"
58
- members.each_with_index { |member, i| stage_member(member, i) }
67
+ Utils.log "[staged] #{members.size} members; workers: #{@workers}; " \
68
+ "batch size: #{@batch_size}; store: #{@store_dir}"
69
+ if @workers == 1
70
+ members.each_with_index { |member, i| stage_member(member, i) }
71
+ else
72
+ stage_parallel(members)
73
+ end
59
74
  reinflate(members)
60
75
  end
61
76
 
62
77
  private
63
78
 
79
+ # Batch the members and run +@workers+ renderer child processes
80
+ # concurrently. Each batch file carries fully-rendered single-member
81
+ # manifests; children claim batches through O_EXCL markers, so a slow
82
+ # worker never starves. One Metanorma boot amortises over
83
+ # +@batch_size+ members (the boot dominates small members), and each
84
+ # child exit hands its memory back to the OS. Members write disjoint,
85
+ # identifier-keyed store entries, so concurrent children cannot race;
86
+ # sectionsplit only happens at reinflate (a single sequential pass).
87
+ def stage_parallel(members)
88
+ paths, count = write_batch_files(members)
89
+ run_batch_workers(count)
90
+ ensure
91
+ remove_batch_files(paths, count)
92
+ end
93
+
94
+ def write_batch_files(members)
95
+ batches = batch_manifests(members)
96
+ paths = write_batch_bins(batches)
97
+ Utils.log "[staged] #{batches.size} batches over #{@workers} workers"
98
+ [paths, batches.size]
99
+ end
100
+
101
+ def batch_manifests(members)
102
+ members.each_slice(@batch_size).map do |slice|
103
+ slice.map do |member|
104
+ base_manifest.merge("manifest" => collection_manifest([member]))
105
+ end
106
+ end
107
+ end
108
+
109
+ def write_batch_bins(batches)
110
+ batches.map.with_index do |batch, i|
111
+ path = File.join(@config_dir, "._suma_batch_#{i}.bin")
112
+ File.binwrite(path, Marshal.dump(batch))
113
+ path
114
+ end
115
+ end
116
+
117
+ def run_batch_workers(count)
118
+ children = Array.new([@workers, count].min) do
119
+ Process.spawn(RbConfig.ruby, "-e", child_script, @config_dir,
120
+ count.to_s, @store_dir, @output_directory,
121
+ @no_valid ? "1" : "0", chdir: @config_dir)
122
+ end
123
+ failed = children
124
+ .filter_map { |pid| Process.waitpid2(pid) }
125
+ .map(&:last).reject(&:success?).size
126
+ return if failed.zero?
127
+
128
+ raise "[staged] #{failed} of #{@workers} batch workers failed; " \
129
+ "see the worker output above"
130
+ end
131
+
132
+ def remove_batch_files(paths, count)
133
+ paths&.each { |path| FileUtils.rm_f(path) }
134
+ (0...(count || 0)).each do |i|
135
+ FileUtils.rm_f(File.join(@config_dir, "._suma_batch_#{i}.claim"))
136
+ end
137
+ end
138
+
139
+ # Child renderer: claims and renders batches until none remain. The
140
+ # Metanorma boot is paid once per child, and GC runs between members to
141
+ # bound the heap. Any render failure exits nonzero and fails the build.
142
+ def child_script
143
+ <<~RUBY
144
+ require "yaml"
145
+ require "fileutils"
146
+ require "metanorma"
147
+ config_dir, batch_count, store_dir, out_dir, no_valid = ARGV
148
+ count = Integer(batch_count)
149
+ claimed = 0
150
+ loop do
151
+ idx = (0...count).find do |i|
152
+ begin
153
+ File.open(File.join(config_dir, "._suma_batch_\#{i}.claim"),
154
+ File::RDWR | File::CREAT | File::EXCL) {}
155
+ true
156
+ rescue Errno::EEXIST
157
+ false
158
+ end
159
+ end
160
+ break unless idx
161
+
162
+ manifests = Marshal.load(File.binread(File.join(
163
+ config_dir, "._suma_batch_\#{idx}.bin")))
164
+ manifests.each_with_index do |manifest, j|
165
+ one_path = File.join(config_dir,
166
+ "._suma_staged_b\#{idx}_\#{j}.yml")
167
+ out = File.join(out_dir, "._staged_out_b\#{idx}_\#{j}")
168
+ t0 = Time.now
169
+ begin
170
+ File.write(one_path, YAML.dump(manifest))
171
+ compile = { install_fonts: false }
172
+ compile[:novalid] = true if no_valid == "1"
173
+ Metanorma::Collection.parse(one_path).render(
174
+ format: [:xml], output_folder: out,
175
+ preserve_unresolved: true, artifact_store_dir: store_dir,
176
+ compile: compile,
177
+ )
178
+ warn format("[staged worker %d] batch %d/%d member %d OK (%ds)",
179
+ Process.pid, idx + 1, count, j, Time.now - t0)
180
+ ensure
181
+ File.unlink(one_path) rescue nil
182
+ FileUtils.rm_rf(out) rescue nil
183
+ GC.start
184
+ end
185
+ end
186
+ claimed += 1
187
+ end
188
+ exit 1 if claimed.zero? && count.positive?
189
+ RUBY
190
+ end
191
+
64
192
  def manifest
65
193
  @manifest ||= YAML.load_file(@config_path)
66
194
  end
@@ -94,7 +94,9 @@ module Suma
94
94
  section_ref = get_section_ref(schema)
95
95
 
96
96
  Glossarist::ManagedConceptCollection.new.tap do |collection|
97
- schema.entities.each do |entity|
97
+ # expressir leaves collection attributes nil when the clause is
98
+ # absent; guard every walk over parsed-model collections.
99
+ Array(schema.entities).each do |entity|
98
100
  managed_data = build_managed_data(schema, entity, section_ref,
99
101
  classification)
100
102
 
@@ -154,7 +156,7 @@ module Suma
154
156
  data.sources = [build_source_ref(schema, entity)]
155
157
 
156
158
  notes = NoteProcessor.call(
157
- entity.remarks,
159
+ Array(entity.remarks),
158
160
  definitions: data.definition,
159
161
  xref_to_mention: method(:xref_to_mention),
160
162
  )
@@ -273,12 +275,16 @@ module Suma
273
275
 
274
276
  entity_ref = urn_mention(term_urn("express-language.entity"), "entity")
275
277
 
276
- if entity.subtype_of.empty?
278
+ # expressir 2.4.44 returns nil for entities without SUBTYPE_OF
279
+ # (older versions returned []); guard both shapes.
280
+ subtypes = entity.subtype_of || []
281
+
282
+ if subtypes.empty?
277
283
  "#{entity_type} " \
278
284
  "that represents the " \
279
285
  "#{entity_name_to_text(entity.id)} #{entity_ref}"
280
286
  else
281
- entity_subtypes = entity.subtype_of.map do |e|
287
+ entity_subtypes = subtypes.map do |e|
282
288
  urn_mention(express_entity_urn("#{schema.id}.#{e.id}"), e.id)
283
289
  end
284
290
 
@@ -96,7 +96,7 @@ module Suma
96
96
  artifacts = {}
97
97
  schemas.each do |schema|
98
98
  artifacts[compose(:tech, schema.id)] = schema_payload(schema)
99
- schema.children.each do |element|
99
+ Array(schema.children).each do |element|
100
100
  payload = element_payload(schema, element)
101
101
  artifacts[compose(:tech, "#{schema.id}.#{element.id}")] = payload if
102
102
  payload
@@ -111,7 +111,7 @@ module Suma
111
111
  def add_attribute_artifacts(artifacts, schema, element)
112
112
  return unless element.is_a?(Expressir::Model::Declarations::Entity)
113
113
 
114
- element.attributes.each do |attribute|
114
+ Array(element.attributes).each do |attribute|
115
115
  key = compose(:tech,
116
116
  "#{schema.id}.#{element.id}.#{attribute.id}")
117
117
  artifacts[key] = attribute_artifact(schema, element, attribute)
@@ -150,7 +150,7 @@ module Suma
150
150
  "interface" => Array(schema.interfaces).filter_map do |interface|
151
151
  interface.schema&.id
152
152
  end,
153
- "elements" => schema.children.filter_map do |element|
153
+ "elements" => Array(schema.children).filter_map do |element|
154
154
  element.id if declared_element?(element)
155
155
  end.sort,
156
156
  }
data/lib/suma/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Suma
4
- VERSION = "0.5.10"
4
+ VERSION = "0.5.12"
5
5
  end
@@ -45,7 +45,11 @@ module Suma
45
45
  item, index = pair
46
46
  begin
47
47
  results[index] = block.call(item)
48
- rescue StandardError => e
48
+ # SystemStackError is not StandardError: deep per-item recursion
49
+ # (a pathological EXPRESS AST in the export pretty-printer)
50
+ # overflows the ~1 MB thread stack, and swallowing it would
51
+ # leave silent holes in results. The caller falls back.
52
+ rescue SystemStackError, StandardError => e
49
53
  errors << e
50
54
  break
51
55
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: suma
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.10
4
+ version: 0.5.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-21 00:00:00.000000000 Z
11
+ date: 2026-10-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: expressir