expressir 2.4.17 → 2.4.19

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: 6fb8953f2f2e9d76be3693148c8951ae36f562bbaf8214a8bc510b68888700d2
4
- data.tar.gz: dfbcc336cc1a580501ca69632d6e6bb348e196be470cbc712a637131ffe1993c
3
+ metadata.gz: a5174add01b6b7053e2317d479e746ba330901c6bdc547d5ba7429ea46889ae3
4
+ data.tar.gz: 66bd27f4a626a956e840f90013bbe5ce7dc6817f127c554bccf5cb413cbd2981
5
5
  SHA512:
6
- metadata.gz: 2e5259fb143ac5419ab85b8a97e8ab50a3a809826f799b8cd74242a673599c5cac0bd21cfe1dc33af562c16a806afc95f9f43f75f8eba43e18b68377104d5406
7
- data.tar.gz: 0d2520a7a817f1c8d72a6f768887e803c91a6070f3cf8d4230bde9e66dcb6a1580ff7ee39ac6e9c886106cde64db14e175db57e81be0b798ea8f45af57bfaaa7
6
+ metadata.gz: 6aa415b0bd88d3cf5bd95d3786b01dff6f66e00e13ca95a3a19755f1e60cc17163800841fd9f7ea21d98acee505c0b67ae1ef6a3e3e032394cfb848395b1295d
7
+ data.tar.gz: ce39ba436ff04bf239e16744625c2e989b75db1674ffed24314d9fb75ae393e0f3b9ca8f0ab9263a47cd79572807e958f29ebfcedc88c42f00e886c9dd75a14f
@@ -2,27 +2,47 @@
2
2
 
3
3
  module Expressir
4
4
  module Commands
5
- # `expressir mapping validate` (#88): load a module mapping.yaml
6
- # and resolve every <<express:...>> link against the ARM/MIM
7
- # interface closure. Exits 1 when unknown links remain.
5
+ # `expressir mapping validate` (#88): load a module mapping.yaml,
6
+ # resolve every <<express:...>> link and every reference path
7
+ # against the ARM/MIM interface closure. Exits 1 when unknown
8
+ # links or refpath issues remain.
8
9
  class MappingValidate < Base
9
10
  def run(path)
10
11
  document = Expressir::Mapping.load_file(path)
11
- unknown = Expressir::Mapping.unknown_links(document, repository_for(path))
12
-
13
- if unknown.empty?
14
- say "all links resolve"
15
- return
16
- end
12
+ repository = repository_for(path)
17
13
 
14
+ unknown = Expressir::Mapping.unknown_links(document, repository)
18
15
  unknown.each do |link|
19
16
  say "unknown: #{link.text}"
20
17
  end
21
- raise Thor::Error, "#{unknown.size} unknown link(s)"
18
+
19
+ issues = refpath_issues(document, repository)
20
+ issues.each do |location, issue|
21
+ say "refpath #{location} [step #{issue.step}]: #{issue.message}"
22
+ end
23
+
24
+ total = unknown.size + issues.size
25
+ unless total.zero?
26
+ raise Thor::Error, "#{unknown.size} unknown link(s), " \
27
+ "#{issues.size} refpath issue(s)"
28
+ end
29
+
30
+ say "all links and reference paths resolve"
22
31
  end
23
32
 
24
33
  private
25
34
 
35
+ def refpath_issues(document, repository)
36
+ Expressir::Mapping.refpaths(document).flat_map do |location, content|
37
+ parsed = Expressir::Mapping::RefPath.parse(content)
38
+ parsed.parse_errors.map do |error|
39
+ [location, Expressir::Mapping::RefPath::Issue.new(step: nil,
40
+ message: error)]
41
+ end + Expressir::Mapping::RefPath.validate(parsed, repository)
42
+ .map { |issue| [location, issue] }
43
+ end
44
+ end
45
+
26
46
  def repository_for(path)
27
47
  dir = File.dirname(File.expand_path(path))
28
48
  arm = Dir[File.join(dir, "arm.exp")].first
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Mapping
5
+ # Formalization, parser, and validator for MIM reference paths
6
+ # (expressir#88): the "Reference path" sections of module
7
+ # mapping.yaml files, written in the ISO 10303 module mapping
8
+ # notation. Until now the notation was prose for humans only; this
9
+ # makes it machine-readable and checks it against real schemas.
10
+ #
11
+ # Grammar (corpus-verified over all module mapping.yaml refpaths):
12
+ # path := step+
13
+ # step := [link] node [index] [constraint]
14
+ # | MARKER
15
+ # link := "->" | "<-" | "<=" | "=>" | "=" NAME
16
+ # (relation to the previous node)
17
+ # node := NAME ["." ATTRIBUTE]
18
+ # index := "[" ("i" | DIGITS) "]"
19
+ # MARKER := "{" | "}" | "[" | "]" | "(" | ")" | ":" | "|" | "!"
20
+ # | "," | "/" | "*" (grouping/annotation; no
21
+ # node semantics; never
22
+ # breaks a pending link)
23
+ # constraint := "=" STRING
24
+ #
25
+ # Operator semantics per the module conventions:
26
+ # -> the attribute named before -> references the entity or
27
+ # select type named after ->
28
+ # <- the attribute named before <- is referenced by the type
29
+ # named after <-
30
+ # <= the entity named before <= is a subtype of the entity after
31
+ # => the entity named before => is a supertype of the entity after
32
+ # = the node before = is of the type named after (used inside
33
+ # parenthesized alternatives, e.g. `(unit = named_unit)`)
34
+ # [i] the attribute named before [i] is an aggregate; any element
35
+ # of that aggregate is referred to
36
+ module RefPath
37
+ # Wire-name mapping is declared here so a parsed path can itself
38
+ # be serialized with lutaml-model.
39
+ class Step < Lutaml::Model::Serializable
40
+ attribute :operator, :string
41
+ attribute :name, :string
42
+ attribute :attribute, :string
43
+ attribute :index, :string
44
+ attribute :literal, :string
45
+
46
+ key_value do
47
+ map "operator", to: :operator
48
+ map "name", to: :name
49
+ map "attribute", to: :attribute
50
+ map "index", to: :index
51
+ map "literal", to: :literal
52
+ end
53
+ end
54
+
55
+ class Path < Lutaml::Model::Serializable
56
+ attribute :steps, Step, collection: true
57
+ attribute :parse_errors, :string, collection: true
58
+
59
+ key_value do
60
+ map "steps", to: :steps
61
+ map "parse_errors", to: :parse_errors
62
+ end
63
+ end
64
+
65
+ TOKEN = /->|<-|<=|=>|[{}\[\]()=:|!,*\/]|'(?:''|[^'])*'|\w+(?:\.\w+)?/
66
+ MARKERS = ["{", "}", "[", "]", "(", ")", ":", "|", "!", ",", "/",
67
+ "*", "MAPPING_OF"].freeze
68
+ LINKS = ["->", "<-", "<=", "=>"].freeze
69
+ DECLARATION_COLLECTIONS = %i[types entities].freeze
70
+ private_constant :TOKEN, :MARKERS, :LINKS, :DECLARATION_COLLECTIONS
71
+
72
+ Issue = Struct.new(:step, :message, keyword_init: true)
73
+
74
+ module_function
75
+
76
+ # @param content [String] the mapping.yaml refpath content block
77
+ # @return [Path]
78
+ def parse(content)
79
+ tokens = content.to_s.scan(TOKEN)
80
+ steps = []
81
+ errors = []
82
+ pending_link = nil
83
+ pending_type_assign = false
84
+ i = 0
85
+ while i < tokens.length
86
+ token = tokens[i]
87
+ case token
88
+ when *LINKS
89
+ pending_link = token
90
+ when "="
91
+ literal = tokens[i + 1]
92
+ if literal&.start_with?("'")
93
+ attach(steps, errors, :literal, literal)
94
+ i += 1
95
+ elsif literal.nil?
96
+ errors << "constraint '=' with nothing after it"
97
+ else
98
+ # `x = Type` type assignment: the following node (however
99
+ # many markers it sits behind, e.g. /MAPPING_OF(X)/)
100
+ # carries the "=" link.
101
+ pending_type_assign = true
102
+ end
103
+ when "["
104
+ if index_token?(tokens[i + 1])
105
+ attach(steps, errors, :index, tokens[i + 1])
106
+ i += 2 # the index token and the closing "]"
107
+ else
108
+ steps << Step.new(operator: "[")
109
+ end
110
+ when *MARKERS
111
+ steps << Step.new(operator: token)
112
+ else
113
+ name, _, attribute = token.partition(".")
114
+ operator = pending_link || ("=" if pending_type_assign)
115
+ steps << Step.new(operator: operator, name: name,
116
+ attribute: attribute.empty? ? nil : attribute)
117
+ pending_link = nil
118
+ pending_type_assign = false
119
+ end
120
+ i += 1
121
+ end
122
+ Path.new(steps: steps, parse_errors: errors)
123
+ end
124
+
125
+ def index_token?(token)
126
+ token == "i" || token&.match?(/\A\d+\z/)
127
+ end
128
+
129
+ # Attach an index or constraint literal to the most recent node
130
+ # step that does not carry one yet.
131
+ def attach(steps, errors, field, value)
132
+ step = steps.reverse.each.find { |s| s.name && !s.public_send(field) }
133
+ if step
134
+ step.public_send("#{field}=", value)
135
+ else
136
+ case field
137
+ when :index then errors << "aggregate index [#{value}] has no node"
138
+ when :literal then errors << "constraint #{value} has no node"
139
+ end
140
+ end
141
+ end
142
+
143
+ # Validate a parsed path against a repository. Issues reference
144
+ # the step index they attach to.
145
+ # @return [Array<Issue>]
146
+ def validate(path, repository)
147
+ by_name = name_index(repository)
148
+ issues = []
149
+ prev = nil
150
+ last_qualified = nil
151
+ path.steps.each_with_index do |step, pos|
152
+ # markers never break the running link, mirroring parse
153
+ next if MARKERS.include?(step.operator)
154
+
155
+ issues.concat(check_step(step, pos, prev, last_qualified, by_name))
156
+ prev = step if step.name
157
+ last_qualified = step if step.name && step.attribute
158
+ end
159
+ issues
160
+ end
161
+
162
+ EXPRESS_BUILTINS = %w[NUMBER INTEGER REAL STRING BOOLEAN LOGICAL
163
+ BINARY].freeze
164
+ private_constant :EXPRESS_BUILTINS
165
+
166
+ def name_index(repository)
167
+ index = EXPRESS_BUILTINS.to_h { |b| [b.downcase, [nil, nil]] }
168
+ repository.schemas.flat_map do |schema|
169
+ DECLARATION_COLLECTIONS.flat_map do |coll|
170
+ Array(schema.public_send(coll))
171
+ .select { |d| d.respond_to?(:id) && d.id }
172
+ .map { |d| [d.id.safe_downcase, [schema, d]] }
173
+ end
174
+ end.each { |pair| index[pair[0]] = pair[1] }
175
+ index
176
+ end
177
+
178
+ def check_step(step, pos, prev, last_qualified, by_name)
179
+ found = by_name[step.name&.safe_downcase]
180
+ return [Issue.new(step: pos, message: "unknown type '#{step.name}'")] unless found
181
+
182
+ issues = attribute_issues(step, pos, found[1], by_name)
183
+ issues.concat(link_issues(step, pos, prev, last_qualified, by_name))
184
+ issues
185
+ end
186
+
187
+ def attribute_issues(step, pos, decl, by_name)
188
+ return [] unless step.attribute
189
+
190
+ unless decl.is_a?(Model::Declarations::Entity)
191
+ return [Issue.new(step: pos,
192
+ message: "'#{step.name}' is not an entity, " \
193
+ "cannot carry '#{step.name}.#{step.attribute}'")]
194
+ end
195
+
196
+ attribute = find_attribute(decl, step.attribute, by_name)
197
+ unless attribute
198
+ return [Issue.new(step: pos,
199
+ message: "entity '#{step.name}' has no attribute " \
200
+ "'#{step.attribute}'")]
201
+ end
202
+
203
+ aggregate_issue(step, pos, attribute)
204
+ end
205
+
206
+ # Direct or inherited attribute: inheritance follows the
207
+ # subtype_of chain, resolved through the repository-wide index.
208
+ def find_attribute(entity, attribute_id, by_name)
209
+ key = attribute_id.safe_downcase
210
+ supertype_chain(entity, by_name).lazy.flat_map do |candidate|
211
+ Array(candidate.attributes)
212
+ end.find { |a| a.id&.safe_downcase == key }
213
+ end
214
+
215
+ def supertype_chain(entity, by_name)
216
+ seen = {}.compare_by_identity
217
+ queue = [entity]
218
+ result = []
219
+ until queue.empty?
220
+ current = queue.shift
221
+ next if seen[current]
222
+
223
+ seen[current] = true
224
+ result << current
225
+ current.subtype_of.to_a.each do |ref|
226
+ sup = by_name[ref.respond_to?(:id) ? ref.id&.safe_downcase : nil]&.last
227
+ queue << sup if sup.is_a?(Model::Declarations::Entity)
228
+ end
229
+ end
230
+ result
231
+ end
232
+
233
+ def aggregate_issue(step, pos, attribute)
234
+ return [] unless step.index
235
+ return [] if attribute.type.respond_to?(:bound1)
236
+
237
+ [Issue.new(step: pos,
238
+ message: "'#{step.name}.#{step.attribute}' is not an " \
239
+ "aggregate but carries [#{step.index}]")]
240
+ end
241
+
242
+ def link_issues(step, pos, prev, last_qualified, by_name)
243
+ return [] unless LINKS.include?(step.operator)
244
+ unless prev && step.name
245
+ return [Issue.new(step: pos,
246
+ message: "#{step.operator} with no preceding node")]
247
+ end
248
+
249
+ case step.operator
250
+ when "->", "<-"
251
+ attribute_link_issues(step, pos, prev, last_qualified)
252
+ when "<=" then subtype_link_issues(step, pos, prev, by_name)
253
+ when "=>" then supertype_link_issues(step, pos, prev, by_name)
254
+ end
255
+ end
256
+
257
+ # `->`: the attribute-qualified node precedes the operator;
258
+ # `<-`: it follows (the entity before <- is referenced BY the
259
+ # attribute after it). An aggregate dereference `[i]` keeps the
260
+ # running attribute owner qualified for the next link
261
+ # (`attr -> list_type[i] -> element_type`).
262
+ def attribute_link_issues(step, pos, prev, last_qualified)
263
+ if step.operator == "->"
264
+ qualified = if prev&.attribute
265
+ prev
266
+ else
267
+ (prev&.index ? last_qualified : nil)
268
+ end
269
+ unless qualified&.attribute
270
+ return [Issue.new(step: pos,
271
+ message: "'->' requires an attribute-qualified " \
272
+ "node before it, got '#{prev&.name}'")]
273
+ end
274
+
275
+ return []
276
+ end
277
+
278
+ return [] if step.attribute
279
+
280
+ [Issue.new(step: pos,
281
+ message: "'<-' requires an attribute-qualified node " \
282
+ "after it, got '#{step.name}'")]
283
+ end
284
+
285
+ # `a <= b`: a is a subtype of b.
286
+ def subtype_link_issues(step, pos, prev, by_name)
287
+ source = by_name[prev.name&.safe_downcase]&.last
288
+ target = by_name[step.name&.safe_downcase]&.last
289
+ return [] unless source.is_a?(Model::Declarations::Entity) &&
290
+ target.is_a?(Model::Declarations::Entity)
291
+
292
+ return [] if supertype_ids(source, by_name)
293
+ .any? { |id| id&.safe_downcase == step.name&.safe_downcase }
294
+
295
+ [Issue.new(step: pos,
296
+ message: "'#{prev.name}' is not a subtype of '#{step.name}'")]
297
+ end
298
+
299
+ # `a => b`: a is a supertype of b.
300
+ def supertype_link_issues(step, pos, prev, by_name)
301
+ source = by_name[step.name&.safe_downcase]&.last
302
+ target = by_name[prev.name&.safe_downcase]&.last
303
+ return [] unless source.is_a?(Model::Declarations::Entity) &&
304
+ target.is_a?(Model::Declarations::Entity)
305
+
306
+ return [] if supertype_ids(source, by_name)
307
+ .any? { |id| id&.safe_downcase == prev.name&.safe_downcase }
308
+
309
+ [Issue.new(step: pos,
310
+ message: "'#{prev.name}' is not a supertype of '#{step.name}'")]
311
+ end
312
+
313
+ # Supertype entity names of +entity+: the subtype_of references
314
+ # plus the leaves of the supertype expression tree, resolved
315
+ # through the repository-wide name index so inherited chains
316
+ # (including cross-schema USE FROM) walk correctly.
317
+ def supertype_ids(entity, by_name)
318
+ seen = {}.compare_by_identity
319
+ queue = [entity]
320
+ names = []
321
+ until queue.empty?
322
+ current = queue.shift
323
+ next if seen[current]
324
+
325
+ seen[current] = true
326
+ direct = current.subtype_of.to_a.filter_map do |ref|
327
+ ref.respond_to?(:id) ? ref.id : nil
328
+ end
329
+ leaves = expression_leaves(current.supertype_expression)
330
+ .filter_map { |leaf| leaf.respond_to?(:id) ? leaf.id : nil }
331
+ names.concat(direct)
332
+ names.concat(leaves)
333
+ direct.concat(leaves).each do |id|
334
+ sup = by_name[id&.safe_downcase]&.last
335
+ queue << sup if sup.is_a?(Model::Declarations::Entity)
336
+ end
337
+ end
338
+ names.uniq
339
+ end
340
+
341
+ # Leaf reference nodes of a supertype expression tree.
342
+ def expression_leaves(expr)
343
+ return [] unless expr
344
+
345
+ if expr.respond_to?(:operands)
346
+ Array(expr.operands).flat_map { |o| expression_leaves(o) }
347
+ elsif expr.respond_to?(:operand1)
348
+ expression_leaves(expr.operand1) + expression_leaves(expr.operand2)
349
+ elsif expr.respond_to?(:id) && expr.id
350
+ [expr]
351
+ else
352
+ []
353
+ end
354
+ end
355
+ end
356
+ end
357
+ end
@@ -13,6 +13,8 @@ module Expressir
13
13
  # are ignored by the mapping and survive round-trips untouched in
14
14
  # the source files.
15
15
  module Mapping
16
+ autoload :RefPath, "#{__dir__}/mapping/refpath"
17
+
16
18
  EXPRESS_LINK = /<<express:([^,>]+)(?:,([^>]+))?>>/
17
19
 
18
20
  class ReferencePath < Lutaml::Model::Serializable
@@ -137,5 +139,23 @@ module Expressir
137
139
  element.aa.to_a.flat_map { |aa| [aa.attribute, aa.aimelt] }
138
140
  end.compact
139
141
  end
142
+
143
+ # Every reference path in the document as [location, content]
144
+ # pairs; location names the application element (and attribute,
145
+ # for attribute-level paths) the path belongs to.
146
+ def refpaths(document)
147
+ document.ae.to_a.flat_map do |element|
148
+ entries = []
149
+ if element.refpath&.content
150
+ entries << [element.entity.to_s, element.refpath.content]
151
+ end
152
+ element.aa.to_a.each do |aa|
153
+ next unless aa.refpath&.content
154
+
155
+ entries << ["#{element.entity}.#{aa.attribute}", aa.refpath.content]
156
+ end
157
+ entries
158
+ end
159
+ end
140
160
  end
141
161
  end
@@ -3,6 +3,6 @@ module Expressir
3
3
  # autoload it (Ruby autoload only works for module/class constants, not
4
4
  # for string constants like `Expressir::VERSION`).
5
5
  module Version
6
- VERSION = "2.4.17".freeze
6
+ VERSION = "2.4.19".freeze
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: expressir
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.17
4
+ version: 2.4.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -537,6 +537,7 @@ files:
537
537
  - lib/expressir/manifest/resolver.rb
538
538
  - lib/expressir/manifest/validator.rb
539
539
  - lib/expressir/mapping.rb
540
+ - lib/expressir/mapping/refpath.rb
540
541
  - lib/expressir/model.rb
541
542
  - lib/expressir/model/cache.rb
542
543
  - lib/expressir/model/concerns.rb