expressir 2.4.26 → 2.4.28

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: 51179b5984b4c7c9f6f02e1b306c31e8f9b1b7ec420f7923b4b96957601244a1
4
- data.tar.gz: 15b0e981214636104b47fa472f69736612f192907783adf894160b1517288f03
3
+ metadata.gz: 45dd9d5d13614ca549340d534defb9a674a5fca35a9f3b16f5ef345b73e263eb
4
+ data.tar.gz: f5a14e8e4cb49ebebe7823d9015de7296459735b3f8ea06edc3494e072b0877f
5
5
  SHA512:
6
- metadata.gz: 44a3da19b7db2986d8b841641366254f1b365e09803d881a753ab946562dddf75513a8107dff8201bc457848535726d47ebc85f32faa37a70841f048803f5c5d
7
- data.tar.gz: 8da0ff1bdc5742383defcae54413bb201faed4b26a8a9c7c50c5aa194a02b1d5fb6ae8e8015aa51a4090bcb4347ff6978aad4aacd1951f86d358357d5e2b4398
6
+ metadata.gz: d46d905d2f0ca978c614cba3574a398fa38809f38296930115905b9e97cf1e223e8b0882c1c7431e026dc97015579f7e820f067173da4645ede0be33a00c6842
7
+ data.tar.gz: bc103128faa28cbaafdb27a61117431c9b17d77913b4dcc038db64638b815f19be7e90c4366ca45ef378196f360445296244cd40c435997038280d776de94dd1
@@ -52,7 +52,12 @@ jobs:
52
52
  EXPRESSIR_CORE_FORCE: "1"
53
53
  with:
54
54
  platform: ${{ matrix.ruby-platform }}
55
- ruby-versions: "3.2,3.3,3.4,4.0"
55
+ # Dock images provision cross rubies for 3.2-3.4 only; a 4.0
56
+ # leg silently falls back to the dock's host toolchain and
57
+ # fails linking (or worse, links against the wrong ruby).
58
+ # Ruby 4.0 users on these platforms get the pure-Ruby parser
59
+ # fallback until the images ship 4.0 cross rubies.
60
+ ruby-versions: "3.2,3.3,3.4"
56
61
 
57
62
  - uses: actions/upload-artifact@v4
58
63
  with:
@@ -0,0 +1,64 @@
1
+ # 00 — RefPath v2: ELF 5006 conformance
2
+
3
+ Goal: make `Expressir::Mapping::RefPath` a conforming validator for the
4
+ EXPRESS mapping language specification (ELF 5006:2025,
5
+ `~/src/expresslang/docs/sources/express-mapping/`), so that the WG12
6
+ validation of annotated EXPRESS links (TRThurman PDES proposal,
7
+ expressir 2.4.20+ capability, AP 242 ed5 DIS December 2026) finds
8
+ expressir ready at every reference position.
9
+
10
+ ## Gap matrix (ELF 5006 §8 vs current RefPath)
11
+
12
+ | §8 requirement | status |
13
+ |---|---|
14
+ | entity existence, case-insensitive | done |
15
+ | `<=` / `=>` subtype/supertype, direct or transitive | done |
16
+ | attribute existence for `->` / `<-` (inherited attrs) | done |
17
+ | forward/inverse TYPE compatibility (attr type references target; SELECT membership) | v2 |
18
+ | navigation continuity (running path context) | v2 |
19
+ | constraint: entity in path context; attribute exists; not DERIVED/INVERSE | v2 |
20
+ | constraint value typing (STRING/INTEGER/REAL/NUMBER/BOOLEAN/LOGICAL/ENUMERATION/SELECT table) | v2 |
21
+ | start entity = ARM entity (relaxable for attribute mappings); end entity = MIM/aimelt | v2 |
22
+ | `*>` / `<*` select extension semantics | v2 |
23
+ | `!{...}` negation, `<...>` required path, `||...||` marker, `*...*` tree | v2 (tokenize + semantics) |
24
+ | collect-all-errors | done |
25
+ | structured output (text/JSON/YAML), per-path VALID/INVALID | v2 |
26
+ | case-mismatch warnings (optional) | v2 optional |
27
+
28
+ ## Mapping-model gaps (ae-level fields in the wg12-step corpus)
29
+
30
+ 587 module mapping files; keys beyond the current model:
31
+
32
+ - `rules` — 159 ae entries carry a rules list (a reference position in
33
+ the PDES proposal: "the rules list").
34
+ - `alt_map` — 74 ae entries carry an alternative map ("an alternative
35
+ map" reference position).
36
+
37
+ Both must be modeled with lutaml-model attributes (never hand-rolled
38
+ parsing) and their link/reference positions validated like ae/sc.
39
+
40
+ ## Validation semantics (per §8 + §5/§6)
41
+
42
+ - Running context: the entity a path is "at". `<=`/`=>` move to the
43
+ right-hand entity; `a.attr -> b` requires attr on the current entity
44
+ whose type references b (supertypes and SELECT options count), moves
45
+ to b; `b <- a.attr` same checks, moves to a; `[i]`/`[n]` keep the
46
+ element type as context; a bare name restates or continues the
47
+ context (§6 chaining).
48
+ - Constraints `{e ... e.attr = v}`: e equals the current context entity
49
+ (§8: "at or before the constraint location"); attr exists on e and is
50
+ not DERIVED/INVERSE; v typed per the §8 table (STRING single-quoted,
51
+ INTEGER/REAL/NUMBER unquoted, BOOLEAN TRUE/FALSE, LOGICAL +UNKNOWN,
52
+ ENUMERATION item of the attribute's type, SELECT option match).
53
+ `!{...}` inverts the finding.
54
+ - Start entity: compatible with the ae `entity` (relaxed for aa-level
55
+ paths). End entity: a MIM-declared entity (cross-check `aimelt`).
56
+
57
+ ## Test oracle
58
+
59
+ - `spec/expressir/mapping/refpath_spec.rb` — extend with one example
60
+ per §8 rule (firing + clean negative).
61
+ - Corpus: all 587 module mapping.yaml files; the suma MappingDrift
62
+ baseline (1541 entries) is the pre-v2 reference; v2 must not add
63
+ errors on paths the corpus proves valid, and must explain every new
64
+ error by an ELF 5006 rule.
@@ -21,10 +21,16 @@ module Expressir
21
21
  say "refpath #{location} [step #{issue.step}]: #{issue.message}"
22
22
  end
23
23
 
24
- total = unknown.size + issues.size
24
+ rule_issues = Expressir::Mapping.rule_issues(document, repository)
25
+ rule_issues.each do |missing|
26
+ say "unknown rule: #{missing.text}"
27
+ end
28
+
29
+ total = unknown.size + issues.size + rule_issues.size
25
30
  unless total.zero?
26
31
  raise Thor::Error, "#{unknown.size} unknown link(s), " \
27
- "#{issues.size} refpath issue(s)"
32
+ "#{issues.size} refpath issue(s), " \
33
+ "#{rule_issues.size} unknown rule(s)"
28
34
  end
29
35
 
30
36
  say "all links and reference paths resolve"
@@ -35,10 +41,12 @@ module Expressir
35
41
  def refpath_issues(document, repository)
36
42
  Expressir::Mapping.refpaths(document).flat_map do |location, content|
37
43
  parsed = Expressir::Mapping::RefPath.parse(content)
44
+ start = Expressir::Mapping.entity_item(location)
38
45
  parsed.parse_errors.map do |error|
39
46
  [location, Expressir::Mapping::RefPath::Issue.new(step: nil,
40
47
  message: error)]
41
- end + Expressir::Mapping::RefPath.validate(parsed, repository)
48
+ end + Expressir::Mapping::RefPath.validate(parsed, repository,
49
+ start: start)
42
50
  .map { |issue| [location, issue] }
43
51
  end
44
52
  end
@@ -161,8 +161,13 @@ module Expressir
161
161
 
162
162
  repository = build_repository(all_exp_files, skip_references: skip_references)
163
163
  if set_path && !skip_references && !File.exist?(set_path) &&
164
- core_set_available? && !all_exp_files.include?(nil)
165
- write_compiled_set(set_path, files, all_exp_files, root_path)
164
+ !all_exp_files.include?(nil)
165
+ if core_set_available?
166
+ write_compiled_set(set_path, files, all_exp_files, root_path)
167
+ else
168
+ warn "expressir: compiled-set write to #{set_path} skipped: " \
169
+ "the expressir-core native extension is unavailable"
170
+ end
166
171
  end
167
172
  if set_path && !skip_references && File.exist?(set_path)
168
173
  files = all_exp_files.compact
@@ -247,7 +252,6 @@ module Expressir
247
252
  def self.core_set_available?
248
253
  Core::NATIVE_AVAILABLE && ::Expressir::Core.const_defined?(:Set, false)
249
254
  end
250
- private_class_method :core_set_available?
251
255
 
252
256
  # Concurrent core-path parse: workers compile in the background
253
257
  # while this thread hydrates, attaches remarks, and reports
@@ -4,39 +4,36 @@ module Expressir
4
4
  module Mapping
5
5
  # Formalization, parser, and validator for MIM reference paths
6
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.
7
+ # mapping.yaml files, written in the EXPRESS mapping language
8
+ # (ELF 5006:2025). Until now the notation was prose for humans
9
+ # only; this makes it machine-readable and checks it against real
10
+ # schemas.
10
11
  #
11
- # Grammar (corpus-verified over all module mapping.yaml refpaths):
12
+ # Grammar (ELF 5006 §5; corpus-verified over all module
13
+ # mapping.yaml refpaths):
12
14
  # path := step+
13
15
  # step := [link] node [index] [constraint]
16
+ # | constraint
14
17
  # | MARKER
15
- # link := "->" | "<-" | "<=" | "=>" | "=" NAME
16
- # (relation to the previous node)
18
+ # link := "->" | "<-" | "<=" | "=>" | "*>"
19
+ # | "<*" | "=" NAME (relation to the previous node)
17
20
  # node := NAME ["." ATTRIBUTE]
18
21
  # | LINK (<<express:SCHEMA.ITEM,ITEM>>,
19
22
  # #460: a link may stand
20
23
  # wherever a declaration
21
24
  # is referenced)
22
25
  # index := "[" ("i" | DIGITS) "]"
26
+ # constraint := ["!"] "{" NAME [NAME "."] "=" STRING "}"
23
27
  # MARKER := "{" | "}" | "[" | "]" | "(" | ")" | ":" | "|" | "!"
24
- # | "," | "/" | "*" (grouping/annotation; no
25
- # node semantics; never
26
- # breaks a pending link)
27
- # constraint := "=" STRING
28
+ # | "," | "/" | "*" | "<" | ">" (grouping/annotation;
29
+ # never breaks a
30
+ # pending link)
28
31
  #
29
- # Operator semantics per the module conventions:
30
- # -> the attribute named before -> references the entity or
31
- # select type named after ->
32
- # <- the attribute named before <- is referenced by the type
33
- # named after <-
34
- # <= the entity named before <= is a subtype of the entity after
35
- # => the entity named before => is a supertype of the entity after
36
- # = the node before = is of the type named after (used inside
37
- # parenthesized alternatives, e.g. `(unit = named_unit)`)
38
- # [i] the attribute named before [i] is an aggregate; any element
39
- # of that aggregate is referred to
32
+ # Validation (ELF 5006 §8): a running path context tracks the
33
+ # entity a path is "at"; each step must connect to it, attribute
34
+ # navigations must be type-compatible with their target, and
35
+ # constraints must reference an entity in the path context with an
36
+ # existing, constrainable attribute whose type accepts the literal.
40
37
  module RefPath
41
38
  # Wire-name mapping is declared here so a parsed path can itself
42
39
  # be serialized with lutaml-model.
@@ -46,8 +43,11 @@ module Expressir
46
43
  attribute :attribute, :string
47
44
  attribute :index, :string
48
45
  attribute :literal, :string
49
- # annotated EXPRESS link form: <<express:SCHEMA.ITEM,ITEM>>
50
46
  attribute :link, :string
47
+ attribute :negated, :boolean
48
+ # For constraint steps: the entity owning the constrained
49
+ # attribute when it differs from the constrained entity.
50
+ attribute :owner, :string
51
51
 
52
52
  key_value do
53
53
  map "operator", to: :operator
@@ -56,6 +56,8 @@ module Expressir
56
56
  map "index", to: :index
57
57
  map "literal", to: :literal
58
58
  map "link", to: :link
59
+ map "negated", to: :negated
60
+ map "owner", to: :owner
59
61
  end
60
62
  end
61
63
 
@@ -69,13 +71,43 @@ module Expressir
69
71
  end
70
72
  end
71
73
 
74
+ # ELF 5006 §8 "Validation output": one structured verdict per
75
+ # path, serializable to YAML/JSON for the toolchain.
76
+ class ReportIssue < Lutaml::Model::Serializable
77
+ attribute :step, :integer
78
+ attribute :message, :string
79
+
80
+ key_value do
81
+ map "step", to: :step
82
+ map "message", to: :message
83
+ end
84
+ end
85
+
86
+ class Report < Lutaml::Model::Serializable
87
+ attribute :status, :string
88
+ attribute :start, :string
89
+ attribute :ending, :string
90
+ attribute :steps, :integer
91
+ attribute :entities, :string, collection: true
92
+ attribute :issues, ReportIssue, collection: true
93
+
94
+ key_value do
95
+ map "status", to: :status
96
+ map "start", to: :start
97
+ map "ending", to: :ending
98
+ map "steps", to: :steps
99
+ map "entities", to: :entities
100
+ map "issues", to: :issues
101
+ end
102
+ end
103
+
72
104
  EXPRESS_LINK_PATH = /\A<<express:([^,>]+)(?:,[^>]+)?>>\z/
73
105
  private_constant :EXPRESS_LINK_PATH
74
106
 
75
- TOKEN = /<<express:[^,>]+(?:,[^>]+)?>>|->|<-|<=|=>|[{}\[\]()=:|!,*\/]|'(?:''|[^'])*'|\w+(?:\.\w+)?/
107
+ TOKEN = /<<express:[^,>]+(?:,[^>]+)?>>|->|<-|<=|=>|\*>|<\*|!?\{|[{}\[\]()=:|!,*\/<>]|'(?:''|[^'])*'|\w+(?:\.\w+)?/
76
108
  MARKERS = ["{", "}", "[", "]", "(", ")", ":", "|", "!", ",", "/",
77
- "*", "MAPPING_OF"].freeze
78
- LINKS = ["->", "<-", "<=", "=>"].freeze
109
+ "*", "<", ">", "MAPPING_OF", "!{"].freeze
110
+ LINKS = ["->", "<-", "<=", "=>", "*>", "<*"].freeze
79
111
  DECLARATION_COLLECTIONS = %i[types entities].freeze
80
112
  private_constant :TOKEN, :MARKERS, :LINKS, :DECLARATION_COLLECTIONS
81
113
 
@@ -83,6 +115,23 @@ module Expressir
83
115
 
84
116
  module_function
85
117
 
118
+ # @return [Report] the structured VALID/INVALID verdict for
119
+ # +content+ (ELF 5006 §8 Validation output), carrying the
120
+ # traversed entities and every collected issue.
121
+ def report(content, repository, start: nil)
122
+ path = parse(content)
123
+ issues = validate(path, repository, start: start)
124
+ entities = path.steps.filter_map(&:name).uniq
125
+ Report.new(
126
+ status: (issues.empty? && path.parse_errors.empty? ? "VALID" : "INVALID"),
127
+ start: start || entities.first,
128
+ ending: entities.last,
129
+ steps: path.steps.size,
130
+ entities: entities,
131
+ issues: issues.map { |i| ReportIssue.new(step: i.step, message: i.message) },
132
+ )
133
+ end
134
+
86
135
  # @param content [String] the mapping.yaml refpath content block
87
136
  # @return [Path]
88
137
  def parse(content)
@@ -91,10 +140,26 @@ module Expressir
91
140
  errors = []
92
141
  pending_link = nil
93
142
  pending_type_assign = false
143
+ false
94
144
  i = 0
95
145
  while i < tokens.length
96
146
  token = tokens[i]
97
147
  case token
148
+ when "!{"
149
+ true
150
+ step, consumed = parse_constraint(tokens[i..], negated: true)
151
+ steps << step
152
+ i += consumed
153
+ pending_link = nil
154
+ pending_type_assign = false
155
+ next
156
+ when "{"
157
+ step, consumed = parse_constraint(tokens[i..], negated: false)
158
+ steps << step
159
+ i += consumed
160
+ pending_link = nil
161
+ pending_type_assign = false
162
+ next
98
163
  when *LINKS
99
164
  pending_link = token
100
165
  when "="
@@ -136,6 +201,38 @@ module Expressir
136
201
  Path.new(steps: steps, parse_errors: errors)
137
202
  end
138
203
 
204
+ # A constraint block from +tokens+ (which start at "{" or "!{"):
205
+ # "{entity" or "{entity attr = literal}". Returns
206
+ # [Step, tokens_consumed].
207
+ def parse_constraint(tokens, negated:)
208
+ inner = []
209
+ consumed = 1
210
+ tokens[1..].each do |token|
211
+ consumed += 1
212
+ break if token == "}"
213
+
214
+ inner << token
215
+ end
216
+ eq = inner.index("=")
217
+ value = eq && inner[eq + 1]
218
+ literal = value || inner.find { |t| t.start_with?("'") }
219
+ names = inner.select { |t| t.match?(/\A\w+(\.\w+)?\z/) && !t.start_with?("'") }
220
+ qualified = names.find { |t| t.include?(".") }
221
+ entity = if qualified && names.size == 1
222
+ # {entity.attr = value} with the entity named only inside
223
+ # the qualified reference
224
+ qualified.split(".", 2)[0]
225
+ else
226
+ names.find { |t| !t.include?(".") }
227
+ end
228
+ owner, attribute = qualified ? qualified.split(".", 2) : [nil, nil]
229
+ owner = nil if entity && owner&.safe_downcase == entity.safe_downcase
230
+ step = Step.new(operator: "{", name: entity,
231
+ attribute: attribute, literal: literal,
232
+ negated: negated, owner: owner)
233
+ [step, consumed]
234
+ end
235
+
139
236
  def index_token?(token)
140
237
  token == "i" || token&.match?(/\A\d+\z/)
141
238
  end
@@ -155,28 +252,473 @@ module Expressir
155
252
  end
156
253
 
157
254
  # Validate a parsed path against a repository. Issues reference
158
- # the step index they attach to.
255
+ # the step index they attach to. +start+ names the ARM entity the
256
+ # path is expected to begin at (the ae entity); when given and
257
+ # the path's first entity differs, a warning-level issue is
258
+ # recorded (ELF 5006 §8: the rule "may be relaxed for attribute
259
+ # mappings").
159
260
  # @return [Array<Issue>]
160
- def validate(path, repository)
261
+ def validate(path, repository, start: nil)
161
262
  by_name = name_index(repository)
162
263
  issues = []
163
- prev = nil
264
+ ctx = nil
265
+ seen = {}
164
266
  last_qualified = nil
165
267
  path.steps.each_with_index do |step, pos|
166
268
  # markers never break the running link, mirroring parse
167
- next if MARKERS.include?(step.operator)
269
+ next if marker_step?(step)
168
270
 
169
271
  if step.link
170
272
  issues.concat(check_link_step(step, pos, repository))
273
+ prev = preceding_name(steps_before(path, pos))
274
+ if step.name
275
+ check_exists(issues, pos, step.name, by_name)
276
+ ctx = step.name&.safe_downcase
277
+ elsif prev
278
+ ctx = prev&.safe_downcase
279
+ end
280
+ last_qualified = nil
281
+ next
282
+ end
283
+
284
+ case step.operator
285
+ when "(", ")"
286
+ # alternative sections: each starts a fresh path context
287
+ ctx = nil
288
+ last_qualified = nil
289
+ when "{"
290
+ issues.concat(check_constraint(step, pos, ctx, seen, by_name))
291
+ when "<=", "=>"
292
+ issues.concat(relation_issues(step, pos, ctx, by_name))
293
+ ctx = step.name&.safe_downcase
294
+ when "->"
295
+ issues.concat(forward_issues(step, pos, ctx, last_qualified, by_name))
296
+ ctx = step.name&.safe_downcase
297
+ last_qualified = nil
298
+ when "<-"
299
+ issues.concat(inverse_issues(step, pos, ctx, by_name))
300
+ ctx = step.name&.safe_downcase
301
+ last_qualified = step.attribute ? step : nil
302
+ when "*>", "<*"
303
+ issues.concat(select_extension_issues(step, pos, ctx, by_name))
304
+ ctx = step.name&.safe_downcase
171
305
  else
172
- issues.concat(check_step(step, pos, prev, last_qualified, by_name))
306
+ issues.concat(check_step(step, pos, ctx, last_qualified, by_name))
307
+ if step.name
308
+ seen[step.name.safe_downcase] = true
309
+ last_qualified = step.attribute ? step : nil
310
+ ctx = step.name.safe_downcase unless ctx == step.name.safe_downcase && step.attribute
311
+ end
312
+ end
313
+ end
314
+ if start && path.steps.any?(&:name)
315
+ first = path.steps.find { |s| s.name && !s.link }
316
+ if first && first.name&.safe_downcase != start.safe_downcase
317
+ issues << Issue.new(step: 0,
318
+ message: "path starts at '#{first.name}' but " \
319
+ "the mapped entity is '#{start}'")
320
+ end
321
+ end
322
+ issues
323
+ end
324
+
325
+ def marker_step?(step)
326
+ step.name.nil? && step.attribute.nil? && step.link.nil? &&
327
+ step.operator != "{" && step.operator != "(" && step.operator != ")"
328
+ end
329
+
330
+ def steps_before(path, pos)
331
+ path.steps[0...pos].reverse_each
332
+ end
333
+
334
+ def preceding_name(steps)
335
+ steps.find { |s| s.name && !s.link }&.name
336
+ end
337
+
338
+ def check_exists(issues, pos, name, by_name)
339
+ return if name.nil? || by_name.key?(name.safe_downcase)
340
+
341
+ issues << Issue.new(step: pos,
342
+ message: "unknown type '#{name}'")
343
+ end
344
+
345
+ # ELF 5006 §8 constraint validation: the constrained entity must
346
+ # exist and appear in the path context; the constrained attribute
347
+ # must exist on it and be constrainable (not DERIVED/INVERSE);
348
+ # the literal must type-check against the attribute's type.
349
+ def check_constraint(step, pos, ctx, seen, by_name)
350
+ issues = []
351
+ entity = step.name
352
+ check_exists(issues, pos, entity, by_name)
353
+ return issues if entity.nil?
354
+
355
+ key = entity.safe_downcase
356
+ in_context = (ctx && ctx == key) || seen[key]
357
+ unless in_context
358
+ issues << Issue.new(step: pos,
359
+ message: "constraint entity '#{entity}' is not " \
360
+ "in the path context")
361
+ end
362
+
363
+ owner_key = (step.owner || entity).safe_downcase
364
+ decl = by_name[owner_key]&.last
365
+ return issues unless decl.is_a?(Model::Declarations::Entity)
366
+
367
+ if step.attribute
368
+ attribute = find_attribute(decl, step.attribute, by_name)
369
+ unless attribute
370
+ issues << Issue.new(step: pos,
371
+ message: "entity '#{entity}' has no attribute " \
372
+ "'#{step.attribute}'")
373
+ return issues
374
+ end
375
+
376
+ if attribute.respond_to?(:kind) &&
377
+ [Model::Declarations::Attribute::DERIVED, Model::Declarations::Attribute::INVERSE].include?(attribute.kind)
378
+ issues << Issue.new(step: pos,
379
+ message: "attribute '#{entity}." \
380
+ "#{step.attribute}' is " \
381
+ "#{attribute.kind} and cannot be " \
382
+ "constrained")
173
383
  end
174
- prev = step if step.name || step.link
175
- last_qualified = step if step.name && step.attribute
384
+
385
+ type = attribute.respond_to?(:type) ? attribute.type : nil
386
+ issue = value_type_issue(type, step.literal, entity,
387
+ step.attribute, by_name)
388
+ issues << Issue.new(step: pos, message: issue) if issue
389
+ elsif step.literal
390
+ issues << Issue.new(step: pos,
391
+ message: "constraint literal '#{step.literal}' " \
392
+ "has no attribute")
393
+ end
394
+ issues
395
+ end
396
+
397
+ # ELF 5006 §8 value-type compatibility table.
398
+ def value_type_issue(type, literal, entity, attribute, by_name)
399
+ return nil if literal.nil?
400
+
401
+ kind = value_kind(literal)
402
+ resolved = resolve_type_name(type, by_name)
403
+ case resolved
404
+ when :string
405
+ return nil if kind == :string
406
+
407
+ "value #{literal} is not a single-quoted string " \
408
+ "(#{entity}.#{attribute} is a STRING)"
409
+ when :integer
410
+ return nil if kind == :integer
411
+
412
+ "value #{literal} is not an unquoted integer " \
413
+ "(#{entity}.#{attribute} is an INTEGER)"
414
+ when :real, :number
415
+ return nil if %i[integer real].include?(kind)
416
+
417
+ "value #{literal} is not an unquoted number " \
418
+ "(#{entity}.#{attribute} is a #{resolved.to_s.upcase})"
419
+ when :boolean
420
+ return nil if kind == :boolean
421
+
422
+ "value #{literal} is not TRUE or FALSE " \
423
+ "(#{entity}.#{attribute} is a BOOLEAN)"
424
+ when :logical
425
+ return nil if %i[boolean logical].include?(kind)
426
+
427
+ "value #{literal} is not TRUE, FALSE or UNKNOWN " \
428
+ "(#{entity}.#{attribute} is a LOGICAL)"
429
+ when :enumeration
430
+ return nil if kind == :enumeration && enumeration_item?(type, literal, by_name)
431
+
432
+ "value #{literal} is not an item of the enumeration type of " \
433
+ "#{entity}.#{attribute}"
434
+ when :select
435
+ nil # any value shape may be legitimate against a SELECT arm
436
+ end
437
+ end
438
+
439
+ def value_kind(literal)
440
+ case literal
441
+ when /\A'/ then :string
442
+ when /\A(TRUE|FALSE)\z/ then :boolean
443
+ when /\AUNKNOWN\z/ then :logical
444
+ when /\A-?\d+\z/ then :integer
445
+ when /\A-?\d+\.\d+\z/ then :real
446
+ else :enumeration
447
+ end
448
+ end
449
+
450
+ # Resolve a parsed type node to a value-domain tag for the
451
+ # §8 table, or nil when the type cannot be determined.
452
+ def resolve_type_name(type, by_name)
453
+ case type
454
+ when Model::DataTypes::Boolean then :boolean
455
+ when Model::DataTypes::Logical then :logical
456
+ when Model::DataTypes::String then :string
457
+ when Model::DataTypes::Integer then :integer
458
+ when Model::DataTypes::Real then :real
459
+ when Model::DataTypes::Number then :number
460
+ when Model::DataTypes::Enumeration then :enumeration
461
+ when Model::DataTypes::Select then :select
462
+ when Model::ModelElement
463
+ return resolve_type_name(type.base_type, by_name) if type.respond_to?(:base_type) && type.base_type
464
+
465
+ id = if type.respond_to?(:base_path) && type.base_path
466
+ type.base_path.split(".").last
467
+ elsif type.respond_to?(:id)
468
+ type.id
469
+ end
470
+ return nil unless id
471
+
472
+ decl = by_name[id.safe_downcase]&.last
473
+ return nil unless decl.is_a?(Model::Declarations::Type)
474
+
475
+ resolve_type_name(decl.underlying_type, by_name)
476
+ end
477
+ end
478
+
479
+ def enumeration_item?(type, literal, by_name)
480
+ item = literal.delete_prefix("'").delete_suffix("'").safe_downcase
481
+ case type
482
+ when Model::DataTypes::Enumeration
483
+ type.items.to_a.any? { |i| i.id&.safe_downcase == item }
484
+ when Model::ModelElement
485
+ return enumeration_item?(type.base_type, literal, by_name) if type.respond_to?(:base_type) && type.base_type
486
+
487
+ id = if type.respond_to?(:base_path) && type.base_path
488
+ type.base_path.split(".").last
489
+ elsif type.respond_to?(:id)
490
+ type.id
491
+ end
492
+ decl = id && by_name[id.safe_downcase]&.last
493
+ decl.is_a?(Model::Declarations::Type) &&
494
+ enumeration_item?(decl.underlying_type, literal, by_name)
495
+ end
496
+ end
497
+
498
+ # ELF 5006 §8 navigation validation with a running context: the
499
+ # attribute must exist on the context entity, and its type must
500
+ # be able to reference the step's target entity.
501
+ def forward_issues(step, pos, ctx, last_qualified, by_name)
502
+ issues = []
503
+ unless ctx
504
+ issues << Issue.new(step: pos,
505
+ message: "'->' with no preceding entity in the " \
506
+ "path context")
507
+ return issues
508
+ end
509
+
510
+ owner = by_name[ctx]&.last
511
+ return issues unless owner.is_a?(Model::Declarations::Entity)
512
+
513
+ attr_name = last_qualified&.attribute || step.attribute
514
+ if attr_name.nil?
515
+ issues << Issue.new(step: pos,
516
+ message: "'->' requires an attribute-qualified " \
517
+ "node before it")
518
+ return issues
519
+ end
520
+
521
+ attribute = find_attribute(owner, attr_name, by_name)
522
+ unless attribute
523
+ issues << Issue.new(step: pos,
524
+ message: "entity '#{owner.id}' has no attribute " \
525
+ "'#{attr_name}'")
526
+ return issues
527
+ end
528
+
529
+ issues.concat(target_compat_issues(attribute, step, pos, ctx, by_name))
530
+ issues
531
+ end
532
+
533
+ def inverse_issues(step, pos, ctx, by_name)
534
+ issues = []
535
+ entity = step.name
536
+ check_exists(issues, pos, entity, by_name)
537
+ return issues if entity.nil?
538
+
539
+ decl = by_name[entity.safe_downcase]&.last
540
+ return issues unless decl.is_a?(Model::Declarations::Entity)
541
+
542
+ attr_name = step.attribute
543
+ if attr_name.nil?
544
+ issues << Issue.new(step: pos,
545
+ message: "'<-' requires an attribute-qualified " \
546
+ "node after it")
547
+ return issues
548
+ end
549
+
550
+ attribute = find_attribute(decl, attr_name, by_name)
551
+ unless attribute
552
+ issues << Issue.new(step: pos,
553
+ message: "entity '#{entity}' has no attribute " \
554
+ "'#{attr_name}'")
555
+ return issues
556
+ end
557
+
558
+ if ctx
559
+ issues.concat(target_compat_issues(attribute, step, pos, ctx, by_name,
560
+ inverse: true))
176
561
  end
177
562
  issues
178
563
  end
179
564
 
565
+ # ELF 5006 §8: the attribute type must reference the target
566
+ # entity, a supertype of it, or a SELECT whose options reach it.
567
+ # `ctx` is the target of the navigation either way: for `->` the
568
+ # step names it; for `<-` the path context carries it.
569
+ def target_compat_issues(attribute, step, pos, ctx, by_name, inverse: false)
570
+ type = attribute.respond_to?(:type) ? attribute.type : nil
571
+ target = inverse ? ctx : step.name
572
+ return [] if type.nil? || target.nil?
573
+ return [] if references_entity?(type, target, by_name, {}.compare_by_identity)
574
+
575
+ owner = attribute.parent.respond_to?(:id) ? attribute.parent.id : nil
576
+ [Issue.new(step: pos,
577
+ message: "attribute '#{"#{owner}." if owner}" \
578
+ "#{attribute.id}' cannot reference '#{target}'")]
579
+ end
580
+
581
+ def references_entity?(type, target, by_name, seen)
582
+ return false if type.nil? || seen[type]
583
+
584
+ seen[type] = true
585
+ if type.is_a?(Model::References::SimpleReference)
586
+ id = type.respond_to?(:base_path) && type.base_path ? type.base_path.to_s.split(".").last : type.id
587
+ return false if id.nil?
588
+
589
+ key = id.safe_downcase
590
+ return true if key == target
591
+
592
+ decl = by_name[key]&.last
593
+ return select_reaches?(decl, target, by_name, seen) if decl.is_a?(Model::Declarations::Type)
594
+
595
+ return entity_reaches?(decl, target, by_name, seen)
596
+ end
597
+ if type.respond_to?(:base_type) && type.base_type
598
+ return references_entity?(type.base_type, target, by_name, seen)
599
+ end
600
+ if type.is_a?(Model::DataTypes::Select)
601
+ return type.items.to_a.any? do |item|
602
+ id = item.respond_to?(:id) ? item.id&.safe_downcase : nil
603
+ next false if id.nil?
604
+ next true if id == target
605
+
606
+ decl = by_name[id]&.last
607
+ decl ? entity_reaches?(decl, target, by_name, seen) : false
608
+ end
609
+ end
610
+
611
+ false
612
+ end
613
+
614
+ def select_reaches?(type_decl, target, by_name, seen)
615
+ return false unless type_decl.is_a?(Model::Declarations::Type)
616
+
617
+ underlying = type_decl.underlying_type
618
+ references_entity?(underlying, target, by_name, seen)
619
+ end
620
+
621
+ # target is a subtype of the referenced entity decl (or equal).
622
+ def entity_reaches?(decl, target, by_name, _seen)
623
+ return false unless decl.is_a?(Model::Declarations::Entity)
624
+
625
+ key = decl.id&.safe_downcase
626
+ return true if key == target
627
+
628
+ supertype_ids(decl, by_name).any? { |s| s&.safe_downcase == target }
629
+ end
630
+
631
+ def select_extension_issues(step, pos, ctx, by_name)
632
+ issues = []
633
+ check_exists(issues, pos, step.name, by_name)
634
+ return issues if step.name.nil? || ctx.nil?
635
+
636
+ base = by_name[ctx]&.last
637
+ ext = by_name[step.name.safe_downcase]&.last
638
+ return issues unless base.is_a?(Model::Declarations::Type) &&
639
+ ext.is_a?(Model::Declarations::Type)
640
+
641
+ return issues if select_extends?(ext, base, by_name, {}.compare_by_identity)
642
+
643
+ issues << Issue.new(step: pos,
644
+ message: "SELECT '#{step.name}' does not extend '#{ctx}'")
645
+ issues
646
+ end
647
+
648
+ # select_b <* select_a / select_a *> select_b: select_b extends
649
+ # select_a when select_b's BASED ON chain reaches select_a.
650
+ def select_extends?(ext, base, by_name, seen)
651
+ return false if seen[ext]
652
+
653
+ seen[ext] = true
654
+ underlying = ext.underlying_type
655
+ return false unless underlying.is_a?(Model::DataTypes::Select)
656
+
657
+ based = underlying.based_on
658
+ raw = based && (based.respond_to?(:base_path) ? based.base_path : based.id)
659
+ based_id = raw && raw.to_s.split(".").last
660
+ return true if based_id&.safe_downcase == base.id&.safe_downcase
661
+
662
+ based_decl = based_id && by_name[based_id.safe_downcase]&.last
663
+ based_decl ? select_extends?(based_decl, base, by_name, seen) : false
664
+ end
665
+
666
+ def relation_issues(step, pos, ctx, by_name)
667
+ target = by_name[step.name&.safe_downcase]&.last
668
+ source = ctx && by_name[ctx]&.last
669
+ return [] unless source.is_a?(Model::Declarations::Entity) &&
670
+ target.is_a?(Model::Declarations::Entity)
671
+
672
+ if step.operator == "<="
673
+ return [] if supertype_ids(source, by_name)
674
+ .any? { |id| id&.safe_downcase == step.name&.safe_downcase }
675
+
676
+ [Issue.new(step: pos,
677
+ message: "'#{ctx}' is not a subtype of '#{step.name}'")]
678
+ else
679
+ return [] if supertype_ids(target, by_name)
680
+ .any? { |id| id&.safe_downcase == ctx }
681
+
682
+ [Issue.new(step: pos,
683
+ message: "'#{step.name}' is not a subtype of '#{ctx}'")]
684
+ end
685
+ end
686
+
687
+ def check_step(step, pos, ctx, _last_qualified, by_name)
688
+ found = by_name[step.name&.safe_downcase]
689
+ return [Issue.new(step: pos, message: "unknown type '#{step.name}'")] unless found
690
+
691
+ issues = attribute_issues(step, pos, found[1], by_name)
692
+ issues.concat(bare_continuity_issue(step, pos, ctx, found, by_name))
693
+ issues
694
+ end
695
+
696
+ # ELF 5006 §8 navigation continuity: a bare node naming a
697
+ # different entity than the context must still be related to it
698
+ # (either one a supertype of the other).
699
+ def bare_continuity_issue(step, pos, ctx, found, by_name)
700
+ return [] unless ctx
701
+ return [] if ctx == step.name&.safe_downcase
702
+
703
+ decl = found[1]
704
+ return [] unless decl.is_a?(Model::Declarations::Entity)
705
+
706
+ down = ->(id) { id&.safe_downcase }
707
+ if supertype_ids(decl, by_name).any? { |id| down.(id) == ctx }
708
+ return []
709
+ end
710
+
711
+ ctx_decl = by_name[ctx]&.last
712
+ if ctx_decl.is_a?(Model::Declarations::Entity) &&
713
+ supertype_ids(ctx_decl, by_name).any? { |id| down.(id) == decl.id&.safe_downcase }
714
+ return []
715
+ end
716
+
717
+ [Issue.new(step: pos,
718
+ message: "'#{step.name}' breaks navigation continuity " \
719
+ "(context is '#{ctx}')")]
720
+ end
721
+
180
722
  EXPRESS_BUILTINS = %w[NUMBER INTEGER REAL STRING BOOLEAN LOGICAL
181
723
  BINARY].freeze
182
724
  private_constant :EXPRESS_BUILTINS
@@ -193,8 +735,7 @@ module Expressir
193
735
  index
194
736
  end
195
737
 
196
- # Annotated EXPRESS link node: <<express:SCHEMA.ITEM,ITEM>>
197
- # (item part optional) resolves against the repository (#460).
738
+ # Validate links in aa.assertion_to (#460) and ae/sc entries.
198
739
  def check_link_step(step, pos, repository)
199
740
  parts = step.link.match(EXPRESS_LINK_PATH)
200
741
  return [Issue.new(step: pos, message: "malformed link '#{step.link}'")] unless parts
@@ -215,15 +756,6 @@ module Expressir
215
756
  "'#{item_id}' (#{step.link})")]
216
757
  end
217
758
 
218
- def check_step(step, pos, prev, last_qualified, by_name)
219
- found = by_name[step.name&.safe_downcase]
220
- return [Issue.new(step: pos, message: "unknown type '#{step.name}'")] unless found
221
-
222
- issues = attribute_issues(step, pos, found[1], by_name)
223
- issues.concat(link_issues(step, pos, prev, last_qualified, by_name))
224
- issues
225
- end
226
-
227
759
  def attribute_issues(step, pos, decl, by_name)
228
760
  return [] unless step.attribute
229
761
 
@@ -279,81 +811,9 @@ module Expressir
279
811
  "aggregate but carries [#{step.index}]")]
280
812
  end
281
813
 
282
- def link_issues(step, pos, prev, last_qualified, by_name)
283
- return [] unless LINKS.include?(step.operator)
284
- unless prev && step.name
285
- return [Issue.new(step: pos,
286
- message: "#{step.operator} with no preceding node")]
287
- end
288
-
289
- case step.operator
290
- when "->", "<-"
291
- attribute_link_issues(step, pos, prev, last_qualified)
292
- when "<=" then subtype_link_issues(step, pos, prev, by_name)
293
- when "=>" then supertype_link_issues(step, pos, prev, by_name)
294
- end
295
- end
296
-
297
- # `->`: the attribute-qualified node precedes the operator;
298
- # `<-`: it follows (the entity before <- is referenced BY the
299
- # attribute after it). An aggregate dereference `[i]` keeps the
300
- # running attribute owner qualified for the next link
301
- # (`attr -> list_type[i] -> element_type`).
302
- def attribute_link_issues(step, pos, prev, last_qualified)
303
- if step.operator == "->"
304
- qualified = if prev&.attribute
305
- prev
306
- else
307
- (prev&.index ? last_qualified : nil)
308
- end
309
- unless qualified&.attribute
310
- return [Issue.new(step: pos,
311
- message: "'->' requires an attribute-qualified " \
312
- "node before it, got '#{prev&.name}'")]
313
- end
314
-
315
- return []
316
- end
317
-
318
- return [] if step.attribute
319
-
320
- [Issue.new(step: pos,
321
- message: "'<-' requires an attribute-qualified node " \
322
- "after it, got '#{step.name}'")]
323
- end
324
-
325
- # `a <= b`: a is a subtype of b.
326
- def subtype_link_issues(step, pos, prev, by_name)
327
- source = by_name[prev.name&.safe_downcase]&.last
328
- target = by_name[step.name&.safe_downcase]&.last
329
- return [] unless source.is_a?(Model::Declarations::Entity) &&
330
- target.is_a?(Model::Declarations::Entity)
331
-
332
- return [] if supertype_ids(source, by_name)
333
- .any? { |id| id&.safe_downcase == step.name&.safe_downcase }
334
-
335
- [Issue.new(step: pos,
336
- message: "'#{prev.name}' is not a subtype of '#{step.name}'")]
337
- end
338
-
339
- # `a => b`: a is a supertype of b.
340
- def supertype_link_issues(step, pos, prev, by_name)
341
- source = by_name[step.name&.safe_downcase]&.last
342
- target = by_name[prev.name&.safe_downcase]&.last
343
- return [] unless source.is_a?(Model::Declarations::Entity) &&
344
- target.is_a?(Model::Declarations::Entity)
345
-
346
- return [] if supertype_ids(source, by_name)
347
- .any? { |id| id&.safe_downcase == prev.name&.safe_downcase }
348
-
349
- [Issue.new(step: pos,
350
- message: "'#{prev.name}' is not a supertype of '#{step.name}'")]
351
- end
352
-
353
- # Supertype entity names of +entity+: the subtype_of references
354
- # plus the leaves of the supertype expression tree, resolved
355
- # through the repository-wide name index so inherited chains
356
- # (including cross-schema USE FROM) walk correctly.
814
+ # ELF 5006 §8 subtype/supertype relations, resolved through the
815
+ # repository-wide index so inherited chains (including
816
+ # cross-schema USE FROM) walk correctly.
357
817
  def supertype_ids(entity, by_name)
358
818
  seen = {}.compare_by_identity
359
819
  queue = [entity]
@@ -41,12 +41,33 @@ module Expressir
41
41
  end
42
42
  end
43
43
 
44
+ # An alternative mapping for an application element (corpus
45
+ # `alt_map:` entries): the same ARM concept mapped through a
46
+ # different MIM route, with its own reference path and rules.
47
+ class AltMap < Lutaml::Model::Serializable
48
+ attribute :id, :string
49
+ attribute :description, ReferencePath
50
+ attribute :aimelt, :string
51
+ attribute :refpath, ReferencePath
52
+ attribute :rules, ReferencePath
53
+
54
+ key_value do
55
+ map "id", to: :id
56
+ map "description", to: :description
57
+ map "aimelt", to: :aimelt
58
+ map "refpath", to: :refpath
59
+ map "rules", to: :rules
60
+ end
61
+ end
62
+
44
63
  class ApplicationElement < Lutaml::Model::Serializable
45
64
  attribute :entity, :string
46
65
  attribute :aimelt, :string
47
66
  attribute :extensible, :string
48
67
  attribute :original_module, :string
49
68
  attribute :refpath, ReferencePath
69
+ attribute :rules, ReferencePath
70
+ attribute :alt_map, AltMap, collection: true
50
71
  attribute :aa, AttributeMapping, collection: true
51
72
 
52
73
  key_value do
@@ -55,6 +76,8 @@ module Expressir
55
76
  map "extensible", to: :extensible
56
77
  map "original_module", to: :original_module
57
78
  map "refpath", to: :refpath
79
+ map "rules", to: :rules
80
+ map "alt_map", to: :alt_map
58
81
  map "aa", to: :aa
59
82
  end
60
83
  end
@@ -138,6 +161,9 @@ module Expressir
138
161
  [element.entity, element.aimelt] +
139
162
  element.aa.to_a.flat_map do |aa|
140
163
  [aa.attribute, aa.aimelt, aa.assertion_to]
164
+ end +
165
+ element.alt_map.to_a.flat_map do |alt|
166
+ [alt.aimelt, alt.description&.content]
141
167
  end
142
168
  end.compact + subtype_constraint_values(document)
143
169
  end
@@ -162,8 +188,66 @@ module Expressir
162
188
 
163
189
  entries << ["#{element.entity}.#{aa.attribute}", aa.refpath.content]
164
190
  end
191
+ element.alt_map.to_a.each do |alt|
192
+ next unless alt.refpath&.content
193
+
194
+ label = alt.id ? "#{element.entity}[#{alt.id}]" : element.entity.to_s
195
+ entries << [label, alt.refpath.content]
196
+ end
165
197
  entries
166
198
  end
167
199
  end
200
+
201
+ # Every `rules:` value in the document as [mapped entity, rule
202
+ # name, location label] triples — the rules-list reference
203
+ # position. The mapped entity is the ae entity's declared name
204
+ # (the item of its annotated link).
205
+ def rules_positions(document)
206
+ document.ae.to_a.flat_map do |element|
207
+ entity = entity_item(element.entity)
208
+ positions = []
209
+ if element.rules&.content
210
+ positions << [entity, element.rules.content, element.entity.to_s]
211
+ end
212
+ element.alt_map.to_a.each do |alt|
213
+ next unless alt.rules&.content
214
+
215
+ positions << [entity, alt.rules.content,
216
+ "#{element.entity}[#{alt.id}]"]
217
+ end
218
+ positions
219
+ end
220
+ end
221
+
222
+ # The declared item of an annotated link value
223
+ # (<<express:SCHEMA.ITEM,ITEM>> → ITEM; bare value → itself).
224
+ def entity_item(value)
225
+ match = value.to_s.match(EXPRESS_LINK)
226
+ match ? match[2] || match[1].split(".").last : value.to_s
227
+ end
228
+
229
+ # ELF 5006 §8 rules-list validation: each named rule must be a
230
+ # global RULE in the repository whose FOR list includes the mapped
231
+ # entity (rule declarations are schema-level).
232
+ def rule_issues(document, repository)
233
+ rules_by_name = repository.schemas.flat_map(&:rules)
234
+ .select { |r| r.respond_to?(:id) && r.id }
235
+ .to_h { |r| [r.id.safe_downcase, r] }
236
+ rules_positions(document).filter_map do |entity, rule, _label|
237
+ decl = rules_by_name[rule.safe_downcase]
238
+ next if decl.nil? == false && entity.nil?
239
+ next if decl && for_list_includes?(decl, entity)
240
+
241
+ UnknownLink.new(text: "#{entity}.#{rule}", schema: entity,
242
+ item: rule)
243
+ end
244
+ end
245
+
246
+ def for_list_includes?(rule_decl, entity)
247
+ Array(rule_decl.applies_to).any? do |ref|
248
+ id = ref.respond_to?(:id) ? ref.id : ref.to_s
249
+ id&.safe_downcase == entity.safe_downcase
250
+ end
251
+ end
168
252
  end
169
253
  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.26".freeze
6
+ VERSION = "2.4.28".freeze
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: expressir
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.26
4
+ version: 2.4.28
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-09-28 00:00:00.000000000 Z
11
+ date: 2026-09-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys
@@ -338,6 +338,7 @@ files:
338
338
  - TODO.parity-ee/18-mim-mapping.md
339
339
  - TODO.parity-ee/19-architecture.md
340
340
  - TODO.parity-ee/parity-matrix.md
341
+ - TODO.refpath-v2/00-overview.md
341
342
  - TODO.suma-improvements/00-overview.md
342
343
  - TODO.suma-improvements/01-prebuilt-platform-gems.md
343
344
  - TODO.suma-improvements/02-suma-e2e.md