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 +4 -4
- data/.github/workflows/native-gems.yml +6 -1
- data/TODO.refpath-v2/00-overview.md +64 -0
- data/lib/expressir/commands/mapping_validate.rb +11 -3
- data/lib/expressir/express/parser.rb +7 -3
- data/lib/expressir/mapping/refpath.rb +578 -118
- data/lib/expressir/mapping.rb +84 -0
- data/lib/expressir/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 45dd9d5d13614ca549340d534defb9a674a5fca35a9f3b16f5ef345b73e263eb
|
|
4
|
+
data.tar.gz: f5a14e8e4cb49ebebe7823d9015de7296459735b3f8ea06edc3494e072b0877f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
|
8
|
-
#
|
|
9
|
-
# makes it machine-readable and checks it against real
|
|
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
|
|
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 := "->" | "<-" | "<=" | "=>" | "
|
|
16
|
-
#
|
|
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
|
-
# | "," | "/" | "*"
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
# constraint := "=" STRING
|
|
28
|
+
# | "," | "/" | "*" | "<" | ">" (grouping/annotation;
|
|
29
|
+
# never breaks a
|
|
30
|
+
# pending link)
|
|
28
31
|
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
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:[^,>]+(?:,[^>]+)
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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]
|
data/lib/expressir/mapping.rb
CHANGED
|
@@ -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
|
data/lib/expressir/version.rb
CHANGED
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.
|
|
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-
|
|
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
|