rsmp 0.48.4 → 0.51.1

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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/Gemfile.lock +19 -20
  4. data/README.md +27 -3
  5. data/Rakefile +3 -1
  6. data/documentation/cli.md +79 -4
  7. data/documentation/collecting_message.md +90 -170
  8. data/documentation/configuration.md +8 -0
  9. data/documentation/message_distribution.md +17 -3
  10. data/documentation/tasks.md +65 -117
  11. data/lib/rsmp/cli.rb +93 -5
  12. data/lib/rsmp/collect/collector.rb +117 -134
  13. data/lib/rsmp/collect/distributor.rb +16 -3
  14. data/lib/rsmp/collect/queue.rb +14 -6
  15. data/lib/rsmp/collect/receiver.rb +1 -1
  16. data/lib/rsmp/collect/state_collector.rb +11 -2
  17. data/lib/rsmp/collection.rb +12 -0
  18. data/lib/rsmp/completion.rb +49 -0
  19. data/lib/rsmp/component/component_base.rb +3 -3
  20. data/lib/rsmp/convert/export/json_schema/index.rb +8 -4
  21. data/lib/rsmp/convert/import/yaml.rb +12 -8
  22. data/lib/rsmp/delivery.rb +18 -0
  23. data/lib/rsmp/event.rb +19 -0
  24. data/lib/rsmp/event_source.rb +36 -0
  25. data/lib/rsmp/exchange.rb +10 -0
  26. data/lib/rsmp/failure.rb +23 -0
  27. data/lib/rsmp/helpers/error.rb +14 -23
  28. data/lib/rsmp/message.rb +10 -9
  29. data/lib/rsmp/node/node.rb +3 -21
  30. data/lib/rsmp/node/site/connections.rb +4 -9
  31. data/lib/rsmp/node/site/site.rb +33 -17
  32. data/lib/rsmp/node/supervisor/modules/configuration.rb +24 -11
  33. data/lib/rsmp/node/supervisor/modules/connection.rb +19 -5
  34. data/lib/rsmp/node/supervisor/modules/sites.rb +16 -5
  35. data/lib/rsmp/node/supervisor/supervisor.rb +13 -14
  36. data/lib/rsmp/node/task.rb +59 -53
  37. data/lib/rsmp/proxy/modules/acknowledgements.rb +22 -9
  38. data/lib/rsmp/proxy/modules/lifecycle.rb +134 -0
  39. data/lib/rsmp/proxy/modules/receive.rb +109 -66
  40. data/lib/rsmp/proxy/modules/send.rb +109 -51
  41. data/lib/rsmp/proxy/modules/state.rb +15 -5
  42. data/lib/rsmp/proxy/modules/tasks.rb +96 -70
  43. data/lib/rsmp/proxy/modules/versions.rb +69 -19
  44. data/lib/rsmp/proxy/modules/watchdogs.rb +15 -2
  45. data/lib/rsmp/proxy/proxy.rb +7 -69
  46. data/lib/rsmp/proxy/site/connection.rb +27 -22
  47. data/lib/rsmp/proxy/site/modules/aggregated_status.rb +18 -8
  48. data/lib/rsmp/proxy/site/modules/alarms.rb +45 -38
  49. data/lib/rsmp/proxy/site/modules/commands.rb +18 -8
  50. data/lib/rsmp/proxy/site/modules/status.rb +44 -19
  51. data/lib/rsmp/proxy/site/site_proxy.rb +12 -10
  52. data/lib/rsmp/proxy/site/sxl_selection.rb +7 -2
  53. data/lib/rsmp/proxy/supervisor/modules/aggregated_status.rb +1 -1
  54. data/lib/rsmp/proxy/supervisor/modules/alarms.rb +1 -1
  55. data/lib/rsmp/proxy/supervisor/modules/commands.rb +1 -1
  56. data/lib/rsmp/proxy/supervisor/modules/message_buffer.rb +8 -9
  57. data/lib/rsmp/proxy/supervisor/modules/status.rb +1 -1
  58. data/lib/rsmp/proxy/supervisor/modules/status_updates.rb +1 -1
  59. data/lib/rsmp/proxy/supervisor/supervisor_proxy.rb +36 -31
  60. data/lib/rsmp/result.rb +95 -0
  61. data/lib/rsmp/schema/validation.rb +5 -3
  62. data/lib/rsmp/schema/version.rb +27 -0
  63. data/lib/rsmp/schema.rb +1 -0
  64. data/lib/rsmp/sxl/interface.rb +8 -1
  65. data/lib/rsmp/sxl/processing/catalogue.rb +61 -0
  66. data/lib/rsmp/sxl/processing/conflict_checker.rb +30 -0
  67. data/lib/rsmp/sxl/processing/dependency_graph.rb +37 -0
  68. data/lib/rsmp/sxl/processing/document.rb +158 -0
  69. data/lib/rsmp/sxl/processing/error.rb +8 -0
  70. data/lib/rsmp/sxl/processing/manifest.rb +144 -0
  71. data/lib/rsmp/sxl/processing/natural_sort.rb +22 -0
  72. data/lib/rsmp/sxl/processing/resolver.rb +94 -0
  73. data/lib/rsmp/sxl/processing/version_requirement.rb +86 -0
  74. data/lib/rsmp/sxl/processing.rb +18 -0
  75. data/lib/rsmp/sxl/supervisor_interface.rb +8 -1
  76. data/lib/rsmp/termination.rb +10 -0
  77. data/lib/rsmp/tlc/modules/plans.rb +0 -2
  78. data/lib/rsmp/tlc/modules/system.rb +2 -2
  79. data/lib/rsmp/tlc/proxy/control.rb +50 -16
  80. data/lib/rsmp/tlc/proxy/detectors.rb +15 -4
  81. data/lib/rsmp/tlc/proxy/io.rb +46 -31
  82. data/lib/rsmp/tlc/proxy/plans.rb +36 -19
  83. data/lib/rsmp/tlc/proxy/plans_bang.rb +44 -0
  84. data/lib/rsmp/tlc/proxy/status.rb +47 -23
  85. data/lib/rsmp/tlc/proxy/system.rb +16 -4
  86. data/lib/rsmp/tlc/supervisor_interface.rb +7 -1
  87. data/lib/rsmp/tlc/traffic_controller_site.rb +6 -9
  88. data/lib/rsmp/validation.rb +22 -0
  89. data/lib/rsmp/version.rb +1 -1
  90. data/lib/rsmp.rb +19 -4
  91. data/rsmp.gemspec +1 -1
  92. metadata +26 -4
  93. data/lib/rsmp/collect/collector/status.rb +0 -34
@@ -54,7 +54,7 @@ module RSMP
54
54
 
55
55
  apply_nts_message_attributes response
56
56
  acknowledge message
57
- send_message response
57
+ send_generated_message response
58
58
  end
59
59
 
60
60
  def add_status_subscription(component_id, subs, update_list, arg, now)
@@ -138,7 +138,7 @@ module RSMP
138
138
  'sS' => build_status_list(component, by_code)
139
139
  })
140
140
  apply_nts_message_attributes update
141
- send_message update
141
+ send_generated_message update
142
142
  store_last_sent_status update
143
143
  component.status_updates_sent
144
144
  end
@@ -34,45 +34,39 @@ module RSMP
34
34
  end
35
35
 
36
36
  loop do
37
- connect
37
+ connected = connect
38
+ unless connected.success?
39
+ publish_connection_attempt_failure(connected.failure)
40
+ break unless reconnect_delay?
41
+
42
+ next
43
+ end
38
44
  start_reader
39
45
  start_handshake
40
- wait_for_reader # run until disconnected
41
- break unless reconnect_delay?
42
- rescue Restart
43
- @logger.mute @ip, @port
44
- raise
45
- rescue RSMP::ConnectionError => e
46
- log e, level: :error
47
- break unless reconnect_delay?
48
- rescue StandardError => e
49
- distribute_error e, level: :internal
46
+ close_from_result(wait_for_reader)
50
47
  break unless reconnect_delay?
51
48
  ensure
49
+ close(reason: :internal_failure) if $ERROR_INFO
52
50
  close
53
- stop_subtasks
54
51
  end
55
52
  end
56
53
 
57
54
  def run_accepted_connection
55
+ begin_session
58
56
  self.state = :connected
59
57
  start_reader
60
58
  start_handshake
61
- wait_for_reader
62
- rescue RSMP::ConnectionError => e
63
- log e, level: :error
64
- rescue StandardError => e
65
- distribute_error e, level: :internal
59
+ close_from_result(wait_for_reader)
66
60
  ensure
61
+ close(reason: :internal_failure) if $ERROR_INFO
67
62
  close
68
- stop_subtasks
69
63
  end
70
64
 
71
65
  def start_handshake
72
- send_version_request @site_settings['site_id'], core_versions
66
+ send_version_request @site_settings['site_id'], advertised_core_versions
73
67
  end
74
68
 
75
- def close
69
+ def close(...)
76
70
  prune_unbuffered_status_subscriptions
77
71
  super
78
72
  end
@@ -80,14 +74,15 @@ module RSMP
80
74
  # connect to the supervisor and initiate handshake supervisor
81
75
  def connect
82
76
  log "Connecting to supervisor at #{@ip}:#{@port}", level: :info
77
+ begin_session
83
78
  self.state = :connecting
84
- connect_tcp
79
+ opened = connect_tcp
80
+ return opened if opened.failure?
81
+
82
+ self.state = :connected
85
83
  @logger.unmute @ip, @port
86
84
  log "Connected to supervisor at #{@ip}:#{@port}", level: :info
87
- rescue SystemCallError => e
88
- raise ConnectionError, "Could not connect to supervisor at #{@ip}:#{@port}: Errno #{e.errno} #{e}"
89
- rescue StandardError => e
90
- raise ConnectionError, "Error while connecting to supervisor at #{@ip}:#{@port}: #{e}"
85
+ Result.success(self)
91
86
  end
92
87
 
93
88
  def stop_task
@@ -108,10 +103,22 @@ module RSMP
108
103
 
109
104
  @stream = IO::Stream::Buffered.new(@socket)
110
105
  @protocol = RSMP::Protocol.new(@stream) # rsmp messages are json terminated with a form-feed
111
- self.state = :connected
106
+ Result.success(self)
112
107
  rescue Errno::ECONNREFUSED => e # rescue to avoid log output
113
108
  log 'Connection refused', level: :warning
114
- raise e
109
+ failed_connection_result(e)
110
+ rescue SystemCallError, SocketError, IOError, Async::TimeoutError => e
111
+ failed_connection_result(e)
112
+ end
113
+
114
+ def failed_connection_result(error)
115
+ Result.failure(
116
+ :connection_failed,
117
+ message: "Could not connect to supervisor at #{@ip}:#{@port}: #{error.message}",
118
+ source: :transport,
119
+ context: { ip: @ip, port: @port, session_id: @session_id },
120
+ cause: error
121
+ )
115
122
  end
116
123
 
117
124
  def handshake_complete
@@ -142,9 +149,6 @@ module RSMP
142
149
  else
143
150
  super
144
151
  end
145
- rescue UnknownComponent, UnknownCommand, UnknownStatus,
146
- MessageRejected, MissingAttribute => e
147
- dont_acknowledge message, '', e.to_s
148
152
  end
149
153
 
150
154
  def handle_interface_request(message)
@@ -209,6 +213,7 @@ module RSMP
209
213
  def check_sxl_version(message)
210
214
  if core_3_3?
211
215
  @rejected_sxls, @accepted_sxls = message.sxls.partition { |item| item['rejected'] }
216
+ validate_sxl_response! @accepted_sxls
212
217
  @receive_alarms = message.attributes.fetch('receiveAlarms', true)
213
218
  else
214
219
  primary = primary_configured_sxl
@@ -221,7 +226,7 @@ module RSMP
221
226
  end
222
227
 
223
228
  def send_component_list
224
- send_message ComponentList.new('components' => @site.component_list)
229
+ send_generated_message ComponentList.new('components' => @site.component_list)
225
230
  end
226
231
 
227
232
  def component_list_acknowledged
@@ -0,0 +1,95 @@
1
+ module RSMP
2
+ # Result values represent the expected outcome of finite operations.
3
+ module Result
4
+ # A successfully completed operation.
5
+ Success = Data.define(:value) do
6
+ def success?
7
+ true
8
+ end
9
+
10
+ def failure?
11
+ false
12
+ end
13
+
14
+ def failure
15
+ nil
16
+ end
17
+
18
+ def value!
19
+ value
20
+ end
21
+
22
+ def map
23
+ Result.success(yield(value))
24
+ end
25
+
26
+ def and_then
27
+ yield(value)
28
+ end
29
+ end
30
+
31
+ # An expected operational failure.
32
+ Failure = Data.define(:failure) do
33
+ def initialize(failure:)
34
+ raise ArgumentError, 'failure must be an RSMP::Failure' unless failure.is_a?(RSMP::Failure)
35
+
36
+ super
37
+ end
38
+
39
+ def success?
40
+ false
41
+ end
42
+
43
+ def failure?
44
+ true
45
+ end
46
+
47
+ def value
48
+ nil
49
+ end
50
+
51
+ def value!
52
+ exception = OperationError.new(failure)
53
+ raise exception, cause: failure.cause if failure.cause
54
+
55
+ raise exception
56
+ end
57
+
58
+ def map
59
+ self
60
+ end
61
+
62
+ def and_then
63
+ self
64
+ end
65
+ end
66
+
67
+ module_function
68
+
69
+ def success(value = nil)
70
+ Success.new(value)
71
+ end
72
+
73
+ def failure(code = nil, failure: nil, **details)
74
+ Failure.new(
75
+ failure: failure || RSMP::Failure.new(
76
+ code: code,
77
+ message: details.fetch(:message, code),
78
+ source: details.fetch(:source, :operation),
79
+ context: details.fetch(:context, {}),
80
+ cause: details[:cause]
81
+ )
82
+ )
83
+ end
84
+ end
85
+
86
+ # Raised by bang APIs when an expected failure result is explicitly unwrapped.
87
+ class OperationError < Error
88
+ attr_reader :failure
89
+
90
+ def initialize(failure)
91
+ @failure = failure
92
+ super(failure.message)
93
+ end
94
+ end
95
+ end
@@ -41,6 +41,8 @@ module RSMP
41
41
  end
42
42
 
43
43
  # Core must pass. SXL-defined messages pass if at least one SXL schema passes.
44
+ # A message which does not satisfy its schemas is an ordinary validation
45
+ # outcome. Invalid API arguments and unavailable schemas still raise.
44
46
  def self.validate(message, schemas, options = {})
45
47
  raise ArgumentError, 'message missing' unless message
46
48
  raise ArgumentError, 'schemas missing' unless schemas
@@ -49,9 +51,9 @@ module RSMP
49
51
 
50
52
  errors = validate_core(message, schemas, options)
51
53
  errors.concat validate_sxls(message, schemas, options) if errors.empty?
52
- return nil if errors.empty?
53
-
54
- errors
54
+ Validation.new(violations: errors)
55
+ rescue UnknownMessageCodeError => e
56
+ Validation.new(violations: [['', 'unknown_message_code', e.message]])
55
57
  end
56
58
  end
57
59
  end
@@ -0,0 +1,27 @@
1
+ module RSMP
2
+ # Provides JSON Schema validation for RSMP messages across core and SXL versions.
3
+ module Schema
4
+ STRICT_VERSION_PATTERN = /\A(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\z/
5
+ LEGACY_SHORT_VERSION_PATTERN = /\A(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\z/
6
+
7
+ # Return the schema version for a supported Core version string.
8
+ # Before Core 3.3, some releases used a two-part version on the wire.
9
+ # Only accept such a version when adding .0 identifies a known legacy
10
+ # schema unambiguously. Core 3.3 and later require all three parts.
11
+ def self.normalize_core_version(version)
12
+ return unless version.is_a?(String)
13
+ return version if core_versions.include? version
14
+ return unless LEGACY_SHORT_VERSION_PATTERN.match? version
15
+
16
+ normalized = "#{version}.0"
17
+ return unless core_versions.include? normalized
18
+ return unless Gem::Version.new(normalized) < Gem::Version.new('3.3.0')
19
+
20
+ normalized
21
+ end
22
+
23
+ def self.strict_version?(version)
24
+ version.is_a?(String) && STRICT_VERSION_PATTERN.match?(version)
25
+ end
26
+ end
27
+ end
data/lib/rsmp/schema.rb CHANGED
@@ -299,4 +299,5 @@ end
299
299
 
300
300
  require_relative 'schema/core_sxl_resolution'
301
301
  require_relative 'schema/message_resolution'
302
+ require_relative 'schema/version'
302
303
  require_relative 'schema/validation'
@@ -6,7 +6,14 @@ module RSMP
6
6
  class Interface
7
7
  extend Forwardable
8
8
 
9
- def_delegators :proxy, :send_message, :send_message_and_collect, :validate_ready, :log
9
+ def_delegators :proxy,
10
+ :send_message,
11
+ :send_message!,
12
+ :send_message_and_collect,
13
+ :send_message_and_collect!,
14
+ :validate_ready,
15
+ :validate_ready!,
16
+ :log
10
17
 
11
18
  attr_reader :proxy, :name, :version
12
19
 
@@ -0,0 +1,61 @@
1
+ module RSMP
2
+ module SXL
3
+ module Processing
4
+ # Indexes locally available SXL definitions by name and exact version.
5
+ class Catalogue
6
+ def initialize(paths = [])
7
+ @documents = Hash.new { |hash, key| hash[key] = {} }
8
+ Array(paths).each { |path| add_path(path) }
9
+ end
10
+
11
+ def add_path(path)
12
+ expanded_path = File.expand_path(path)
13
+ raise Error, "SXL source #{path} not found" unless File.exist?(expanded_path)
14
+
15
+ if File.directory?(expanded_path)
16
+ pattern = File.join(expanded_path, '**', '{sxl.yaml,sxl.yml}')
17
+ files = Dir.glob(pattern)
18
+ raise Error, "No sxl.yaml or sxl.yml files found under #{path}" if files.empty?
19
+
20
+ files.each { |file| add_file(file) }
21
+ else
22
+ add_file(expanded_path)
23
+ end
24
+ self
25
+ end
26
+
27
+ def add_document(document)
28
+ existing = @documents[document.name][document.version_string]
29
+ if existing && existing.path != document.path
30
+ raise Error,
31
+ "Duplicate SXL #{document.name} #{document.version_string} in #{existing.path} and #{document.path}"
32
+ end
33
+
34
+ @documents[document.name][document.version_string] = document
35
+ document
36
+ end
37
+
38
+ def candidates(name, requirements = [])
39
+ versions = @documents.fetch(name, {}).values.sort_by(&:version).reverse
40
+ versions.select do |document|
41
+ requirements.all? { |requirement| requirement.satisfied_by?(document.version) }
42
+ end
43
+ end
44
+
45
+ def versions(name)
46
+ @documents.fetch(name, {}).values.map(&:version_string).sort_by { |version| Gem::Version.new(version) }
47
+ end
48
+
49
+ def fetch(name, version)
50
+ @documents.fetch(name, {})[version.to_s]
51
+ end
52
+
53
+ private
54
+
55
+ def add_file(path)
56
+ add_document(Document.load(path))
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,30 @@
1
+ module RSMP
2
+ module SXL
3
+ module Processing
4
+ # Rejects component types or message codes owned by more than one SXL.
5
+ module ConflictChecker
6
+ def self.check!(documents)
7
+ check_definitions!(documents, :component_types, 'component type')
8
+ check_definitions!(documents, :message_codes, 'message code')
9
+ end
10
+
11
+ def self.check_definitions!(documents, collection, label)
12
+ owners = {}
13
+ documents.each do |document|
14
+ document.public_send(collection).each do |definition|
15
+ previous = owners[definition.id]
16
+ if previous
17
+ raise Error,
18
+ "Conflicting #{label} #{definition.id.inspect} in " \
19
+ "#{previous[:document].name} and #{document.name}"
20
+ end
21
+ owners[definition.id] = { document: document, definition: definition }
22
+ end
23
+ end
24
+ end
25
+
26
+ private_class_method :check_definitions!
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,37 @@
1
+ module RSMP
2
+ module SXL
3
+ module Processing
4
+ # Dependency graph validation shared by resolution and manifest verification.
5
+ module DependencyGraph
6
+ def self.cycle(documents)
7
+ by_name = documents.to_h { |document| [document.name, document] }
8
+ visited = {}
9
+ stack = []
10
+ by_name.each_key do |name|
11
+ found = visit(name, by_name, visited, stack)
12
+ return found if found
13
+ end
14
+ nil
15
+ end
16
+
17
+ def self.visit(name, documents, visited, stack)
18
+ return stack[stack.index(name)..] + [name] if stack.include?(name)
19
+ return nil if visited[name]
20
+
21
+ visited[name] = true
22
+ stack << name
23
+ documents.fetch(name).dependencies.each_key do |dependency|
24
+ next unless documents.key?(dependency)
25
+
26
+ found = visit(dependency, documents, visited, stack)
27
+ return found if found
28
+ end
29
+ stack.pop
30
+ nil
31
+ end
32
+
33
+ private_class_method :visit
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,158 @@
1
+ require 'psych'
2
+
3
+ module RSMP
4
+ module SXL
5
+ module Processing
6
+ # A validated SXL YAML document with a normalized component structure.
7
+ class Document
8
+ NAME = %r{\A[a-z0-9][a-z0-9_/-]*\z}
9
+ PREFIX = %r{\A[a-zA-Z0-9_/-]+/\z}
10
+ MESSAGE_KINDS = %w[alarms statuses commands].freeze
11
+
12
+ SymbolDefinition = Struct.new(:id, :kind, :location, keyword_init: true)
13
+
14
+ attr_reader :path, :data, :name, :version, :version_string, :minimum_core_version,
15
+ :dependencies, :component_types, :message_codes
16
+
17
+ def self.load(path)
18
+ expanded_path = File.expand_path(path)
19
+ raise Error, "SXL source #{path} not found" unless File.exist?(expanded_path)
20
+ raise Error, "SXL source #{path} is not a file" unless File.file?(expanded_path)
21
+
22
+ parse(File.read(expanded_path, encoding: 'UTF-8'), path: expanded_path)
23
+ rescue EncodingError => e
24
+ raise Error, "Cannot read SXL source #{path}: #{e.message}"
25
+ end
26
+
27
+ def self.parse(source, path: '(SXL source)')
28
+ data = Psych.safe_load(source, permitted_classes: [], permitted_symbols: [], aliases: false)
29
+ new(path: path, data: data)
30
+ rescue Psych::Exception => e
31
+ raise Error, "Cannot read SXL source #{path}: #{e.message}"
32
+ end
33
+
34
+ def self.validate_name!(value, label: 'SXL name')
35
+ return value if value.is_a?(String) && NAME.match?(value)
36
+
37
+ raise Error, "Invalid #{label} #{value.inspect}"
38
+ end
39
+
40
+ def initialize(path:, data:)
41
+ @path = path
42
+ @data = normalize_document(require_hash(data, 'document'))
43
+ read_metadata
44
+ read_dependencies
45
+ read_symbols
46
+ rescue Error => e
47
+ raise Error, "#{path}: #{e.message}"
48
+ end
49
+
50
+ private
51
+
52
+ def normalize_document(document)
53
+ components = component_definitions(document)
54
+ document.except('objects').merge('components' => components)
55
+ end
56
+
57
+ def component_definitions(document)
58
+ if document.key?('components') && document.key?('objects')
59
+ raise Error, 'Invalid document; use either components or objects, not both'
60
+ end
61
+
62
+ key = document.key?('components') ? 'components' : 'objects'
63
+ require_hash(document[key], key)
64
+ end
65
+
66
+ def read_metadata
67
+ meta = require_hash(data['meta'], 'meta')
68
+ @name = self.class.validate_name!(meta['name'])
69
+ @version_string = meta['version'].to_s
70
+ @version = VersionRequirement.exact_version(meta['version'], label: 'SXL version')
71
+ @minimum_core_version = read_minimum_core_version(meta)
72
+ @prefix = data['prefix']
73
+ validate_prefix!
74
+ end
75
+
76
+ def read_minimum_core_version(meta)
77
+ minimum = meta['minimum_core_version']
78
+ return unless minimum
79
+
80
+ VersionRequirement.exact_version(minimum, label: 'minimum Core version')
81
+ end
82
+
83
+ def validate_prefix!
84
+ return if @prefix.nil? || (@prefix.is_a?(String) && PREFIX.match?(@prefix))
85
+
86
+ raise Error, "Invalid SXL prefix #{@prefix.inspect}; it must end with /"
87
+ end
88
+
89
+ def read_dependencies
90
+ dependency_data = data['dependencies'] || {}
91
+ require_hash(dependency_data, 'dependencies')
92
+ @dependencies = dependency_data.to_h do |dependency_name, requirement|
93
+ self.class.validate_name!(dependency_name, label: 'dependency name')
94
+ unless requirement.is_a?(String)
95
+ raise Error, "Invalid requirement for #{dependency_name.inspect}; expected a string"
96
+ end
97
+
98
+ [dependency_name, VersionRequirement.new(requirement)]
99
+ end
100
+ end
101
+
102
+ def read_symbols
103
+ components = data['components']
104
+ @component_types = []
105
+ @message_codes = []
106
+ seen_components = {}
107
+ seen_messages = {}
108
+
109
+ components.each_pair do |component_id, component|
110
+ component_id = require_identifier(component_id, 'component type')
111
+ full_component_id = prefixed(component_id)
112
+ ensure_unique!(seen_components, full_component_id, 'component type')
113
+ @component_types << SymbolDefinition.new(id: full_component_id, kind: :component,
114
+ location: component_id)
115
+ read_component_messages(component_id, component, seen_messages)
116
+ end
117
+ end
118
+
119
+ def read_component_messages(component_id, component, seen_messages)
120
+ component = require_hash(component, "component #{component_id.inspect}")
121
+ MESSAGE_KINDS.each do |kind|
122
+ messages = component[kind] || {}
123
+ require_hash(messages, "#{kind} for component #{component_id.inspect}")
124
+ messages.each_key do |code|
125
+ code = require_identifier(code, 'message code')
126
+ full_code = prefixed(code)
127
+ ensure_unique!(seen_messages, full_code, 'message code')
128
+ @message_codes << SymbolDefinition.new(id: full_code, kind: kind.delete_suffix('s').to_sym,
129
+ location: "#{component_id}/#{kind}/#{code}")
130
+ end
131
+ end
132
+ end
133
+
134
+ def require_hash(value, label)
135
+ return value if value.is_a?(Hash)
136
+
137
+ raise Error, "Invalid #{label}; expected a mapping"
138
+ end
139
+
140
+ def require_identifier(value, label)
141
+ return value if value.is_a?(String) && !value.empty?
142
+
143
+ raise Error, "Invalid #{label} #{value.inspect}; expected a non-empty string"
144
+ end
145
+
146
+ def prefixed(identifier)
147
+ "#{@prefix}#{identifier}"
148
+ end
149
+
150
+ def ensure_unique!(seen, identifier, label)
151
+ raise Error, "Duplicate #{label} #{identifier.inspect}" if seen[identifier]
152
+
153
+ seen[identifier] = true
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,8 @@
1
+ module RSMP
2
+ module SXL
3
+ module Processing
4
+ # Base error for SXL dependency and manifest processing.
5
+ class Error < StandardError; end
6
+ end
7
+ end
8
+ end