expressir 2.4.17 → 2.4.18

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: 71c43838ba6c2853f84bfa1faca26760abe4919d35b98895ef224866bd49da62
4
+ data.tar.gz: 715a55be95f4ead656b98e0df59ccf06c2036eb532d6990a8ea036ee5f0f78c4
5
5
  SHA512:
6
- metadata.gz: 2e5259fb143ac5419ab85b8a97e8ab50a3a809826f799b8cd74242a673599c5cac0bd21cfe1dc33af562c16a806afc95f9f43f75f8eba43e18b68377104d5406
7
- data.tar.gz: 0d2520a7a817f1c8d72a6f768887e803c91a6070f3cf8d4230bde9e66dcb6a1580ff7ee39ac6e9c886106cde64db14e175db57e81be0b798ea8f45af57bfaaa7
6
+ metadata.gz: dac93ad5688fc8e2d3814fd6933b352ea35d78b89f157e99f30114ddafb07b53aaeb80aaa45fd63540a9ee400ac1aacc3724800446f868472499d9d4cf501629
7
+ data.tar.gz: 12cd7ef87afa0510215d0c1315e68ba5bbaed5a910255ebfa40e18b328b25f87ef3a24f93ce1073a798ce0e748e6344861f05900f863377ed801d9f1f66e7bab
@@ -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,338 @@
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
+ "*"].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
+ if name.casecmp("MAPPING_OF").zero? && tokens[i + 1] == "("
115
+ # notation wrapper: /MAPPING_OF(X)/ refers to X; the
116
+ # wrapper itself carries no node semantics
117
+ steps << Step.new(operator: token)
118
+ else
119
+ operator = pending_link || ("=" if pending_type_assign)
120
+ steps << Step.new(operator: operator, name: name,
121
+ attribute: attribute.empty? ? nil : attribute)
122
+ pending_link = nil
123
+ pending_type_assign = false
124
+ end
125
+ end
126
+ i += 1
127
+ end
128
+ Path.new(steps: steps, parse_errors: errors)
129
+ end
130
+
131
+ def index_token?(token)
132
+ token == "i" || token&.match?(/\A\d+\z/)
133
+ end
134
+
135
+ # Attach an index or constraint literal to the most recent node
136
+ # step that does not carry one yet.
137
+ def attach(steps, errors, field, value)
138
+ step = steps.reverse.each.find { |s| s.name && !s.public_send(field) }
139
+ if step
140
+ step.public_send("#{field}=", value)
141
+ else
142
+ case field
143
+ when :index then errors << "aggregate index [#{value}] has no node"
144
+ when :literal then errors << "constraint #{value} has no node"
145
+ end
146
+ end
147
+ end
148
+
149
+ # Validate a parsed path against a repository. Issues reference
150
+ # the step index they attach to.
151
+ # @return [Array<Issue>]
152
+ def validate(path, repository)
153
+ by_name = name_index(repository)
154
+ issues = []
155
+ prev = nil
156
+ path.steps.each_with_index do |step, pos|
157
+ if MARKERS.include?(step.operator)
158
+ prev = nil
159
+ next
160
+ end
161
+
162
+ issues.concat(check_step(step, pos, prev, by_name))
163
+ prev = step if step.name
164
+ end
165
+ issues
166
+ end
167
+
168
+ def name_index(repository)
169
+ repository.schemas.flat_map do |schema|
170
+ DECLARATION_COLLECTIONS.flat_map do |coll|
171
+ Array(schema.public_send(coll))
172
+ .select { |d| d.respond_to?(:id) && d.id }
173
+ .map { |d| [d.id.safe_downcase, [schema, d]] }
174
+ end
175
+ end.to_h
176
+ end
177
+
178
+ def check_step(step, pos, prev, 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, 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, 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 "->", "<-" then attribute_link_issues(step, pos, prev)
251
+ when "<=" then subtype_link_issues(step, pos, prev, by_name)
252
+ when "=>" then supertype_link_issues(step, pos, prev, by_name)
253
+ end
254
+ end
255
+
256
+ def attribute_link_issues(_step, pos, prev)
257
+ unless prev.attribute
258
+ return [Issue.new(step: pos,
259
+ message: "'->' requires an attribute-qualified " \
260
+ "node before it, got '#{prev.name}'")]
261
+ end
262
+
263
+ []
264
+ end
265
+
266
+ # `a <= b`: a is a subtype of b.
267
+ def subtype_link_issues(step, pos, prev, by_name)
268
+ source = by_name[prev.name&.safe_downcase]&.last
269
+ target = by_name[step.name&.safe_downcase]&.last
270
+ return [] unless source.is_a?(Model::Declarations::Entity) &&
271
+ target.is_a?(Model::Declarations::Entity)
272
+
273
+ return [] if supertype_ids(source, by_name)
274
+ .any? { |id| id&.safe_downcase == step.name&.safe_downcase }
275
+
276
+ [Issue.new(step: pos,
277
+ message: "'#{prev.name}' is not a subtype of '#{step.name}'")]
278
+ end
279
+
280
+ # `a => b`: a is a supertype of b.
281
+ def supertype_link_issues(step, pos, prev, by_name)
282
+ source = by_name[step.name&.safe_downcase]&.last
283
+ target = by_name[prev.name&.safe_downcase]&.last
284
+ return [] unless source.is_a?(Model::Declarations::Entity) &&
285
+ target.is_a?(Model::Declarations::Entity)
286
+
287
+ return [] if supertype_ids(source, by_name)
288
+ .any? { |id| id&.safe_downcase == prev.name&.safe_downcase }
289
+
290
+ [Issue.new(step: pos,
291
+ message: "'#{prev.name}' is not a supertype of '#{step.name}'")]
292
+ end
293
+
294
+ # Supertype entity names of +entity+: the subtype_of references
295
+ # plus the leaves of the supertype expression tree, resolved
296
+ # through the repository-wide name index so inherited chains
297
+ # (including cross-schema USE FROM) walk correctly.
298
+ def supertype_ids(entity, by_name)
299
+ seen = {}.compare_by_identity
300
+ queue = [entity]
301
+ names = []
302
+ until queue.empty?
303
+ current = queue.shift
304
+ next if seen[current]
305
+
306
+ seen[current] = true
307
+ direct = current.subtype_of.to_a.filter_map do |ref|
308
+ ref.respond_to?(:id) ? ref.id : nil
309
+ end
310
+ leaves = expression_leaves(current.supertype_expression)
311
+ .filter_map { |leaf| leaf.respond_to?(:id) ? leaf.id : nil }
312
+ names.concat(direct)
313
+ names.concat(leaves)
314
+ direct.concat(leaves).each do |id|
315
+ sup = by_name[id&.safe_downcase]&.last
316
+ queue << sup if sup.is_a?(Model::Declarations::Entity)
317
+ end
318
+ end
319
+ names.uniq
320
+ end
321
+
322
+ # Leaf reference nodes of a supertype expression tree.
323
+ def expression_leaves(expr)
324
+ return [] unless expr
325
+
326
+ if expr.respond_to?(:operands)
327
+ Array(expr.operands).flat_map { |o| expression_leaves(o) }
328
+ elsif expr.respond_to?(:operand1)
329
+ expression_leaves(expr.operand1) + expression_leaves(expr.operand2)
330
+ elsif expr.respond_to?(:id) && expr.id
331
+ [expr]
332
+ else
333
+ []
334
+ end
335
+ end
336
+ end
337
+ end
338
+ 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.18".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.18
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