ea 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.complete/01-t-xref-parsing.md +1 -1
  3. data/TODO.complete/02-per-label-geometry.md +1 -1
  4. data/TODO.complete/03-object-properties-constraints.md +1 -1
  5. data/TODO.complete/04-operation-params.md +1 -1
  6. data/TODO.complete/05-pdata-flag-matrix.md +1 -1
  7. data/TODO.complete/06-emf-image-rendering.md +1 -1
  8. data/TODO.complete/07-auxiliary-tables.md +1 -1
  9. data/TODO.complete/10-mdg-stereotype-registry.md +1 -1
  10. data/TODO.complete/11-gml-xsd-generation.md +1 -1
  11. data/TODO.complete/13-shapescript-interpreter.md +12 -23
  12. data/TODO.complete/20-cli-diff.md +1 -1
  13. data/TODO.complete/21-cli-render.md +1 -1
  14. data/TODO.complete/22-cli-export.md +1 -1
  15. data/TODO.complete/23-cli-mdg.md +1 -1
  16. data/TODO.complete/30-headerlines-ocp-refactor.md +1 -1
  17. data/TODO.complete/32-spec-coverage.md +1 -1
  18. data/TODO.complete/33-blocked-parity-gaps.md +1 -1
  19. data/TODO.complete/36-stereotype-icon-wiring.md +1 -1
  20. data/TODO.complete/37-cli-lint.md +1 -1
  21. data/TODO.complete/38-cli-query.md +1 -1
  22. data/TODO.complete/39-cli-info.md +1 -1
  23. data/TODO.complete/40-ocl-evaluator.md +1 -1
  24. data/TODO.complete/41-round-trip-spec.md +1 -1
  25. data/TODO.complete/42-html-diff-report.md +1 -1
  26. data/TODO.complete/43-json-schema-export.md +1 -1
  27. data/TODO.complete/44-remaining-qea-tables.md +1 -1
  28. data/TODO.complete/45-shapescript-extensions.md +1 -1
  29. data/TODO.complete/46-xsd-consolidation.md +1 -1
  30. data/TODO.complete/47-headerlines-legacy-removal.md +1 -1
  31. data/TODO.complete/51-remove-respond-to.md +42 -15
  32. data/TODO.complete/52-xsd-decouple-fixtures.md +1 -1
  33. data/TODO.complete/54-wire-stereotype-icon-renderer.md +1 -1
  34. data/TODO.complete/55-wire-shapescript.md +1 -1
  35. data/TODO.complete/56-wire-emf-renderer.md +1 -1
  36. data/TODO.complete/57-wire-ocl-evaluator.md +1 -1
  37. data/TODO.complete/58-diff-modifications.md +1 -1
  38. data/TODO.complete/59-delete-toy-xmi-export.md +1 -1
  39. data/TODO.complete/60-curate-json-export.md +1 -1
  40. data/TODO.complete/61-plantuml-relationships.md +1 -1
  41. data/TODO.complete/62-changelog.md +1 -1
  42. data/TODO.complete/63-dependabot-fix.md +1 -1
  43. data/TODO.complete/64-qeatoxmi-connectors.md +15 -0
  44. data/TODO.complete/65-qeatoxmi-diagrams.md +15 -0
  45. data/TODO.complete/66-qeatoxmi-style-tags-docs.md +35 -0
  46. data/TODO.complete/67-ocl-collection-ops.md +20 -0
  47. data/TODO.complete/68-ocl-comparison-ops.md +24 -0
  48. data/TODO.complete/69-spec-new-models.md +20 -0
  49. data/TODO.complete/70-specs-for-all-models.md +21 -0
  50. data/TODO.complete/71-autoload-audit.md +26 -0
  51. data/TODO.complete/72-extension-serializer-ocp-naming.md +26 -0
  52. data/TODO.complete/73-ocl-validator-latent-bugs.md +50 -0
  53. data/TODO.complete/74-visibility-symbol-dry.md +39 -0
  54. data/TODO.complete/75-database-table-collection-registry.md +32 -0
  55. data/TODO.complete/76-stale-todo-cleanup.md +52 -0
  56. data/TODO.complete/77-eliminate-banned-patterns-from-specs.md +55 -0
  57. data/TODO.complete/78-mdg-stereotype-emission.md +66 -0
  58. data/TODO.complete/79-equivalence-fixes.md +57 -0
  59. data/TODO.complete/80-performance-extension-serializer-indexes.md +43 -0
  60. data/TODO.complete/81-dry-scope-guid-extraction.md +33 -0
  61. data/TODO.complete/82-public-send-audit-conclusion.md +25 -0
  62. data/TODO.complete/83-dry-shared-xml-escape-guid-format.md +52 -0
  63. data/TODO.complete/84-ocp-markers-shape-registry.md +54 -0
  64. data/TODO.complete/85-frozen-literal-rubocop-normalize-type-spec.md +38 -0
  65. data/TODO.complete/86-frozen-sweep-lint-query-specs.md +55 -0
  66. data/TODO.complete/STATUS.md +26 -5
  67. data/lib/ea/cli/app.rb +1 -0
  68. data/lib/ea/cli/command/convert.rb +1 -1
  69. data/lib/ea/cli/command/export.rb +10 -1
  70. data/lib/ea/cli/command/mdg.rb +1 -10
  71. data/lib/ea/cli/command/render.rb +2 -1
  72. data/lib/ea/diff/html_reporter.rb +1 -5
  73. data/lib/ea/export/json/generator.rb +5 -6
  74. data/lib/ea/export/plantuml/generator.rb +5 -3
  75. data/lib/ea/guid_format.rb +91 -0
  76. data/lib/ea/mdg/document.rb +2 -1
  77. data/lib/ea/mdg/loader.rb +2 -1
  78. data/lib/ea/mdg/registry.rb +57 -2
  79. data/lib/ea/mdg/xml/applies_to.rb +1 -1
  80. data/lib/ea/mdg/xml/apply.rb +1 -1
  81. data/lib/ea/mdg/xml/content.rb +1 -1
  82. data/lib/ea/mdg/xml/documentation.rb +1 -1
  83. data/lib/ea/mdg/xml/stereotype.rb +1 -1
  84. data/lib/ea/mdg/xml/stereotypes.rb +1 -1
  85. data/lib/ea/mdg/xml/tag.rb +1 -1
  86. data/lib/ea/mdg/xml/tagged_values.rb +1 -1
  87. data/lib/ea/mdg/xml/technology.rb +1 -1
  88. data/lib/ea/mdg/xml/uml_profile.rb +1 -1
  89. data/lib/ea/mdg/xml/uml_profiles.rb +1 -1
  90. data/lib/ea/mdg/xml.rb +2 -1
  91. data/lib/ea/mdg.rb +2 -1
  92. data/lib/ea/model/ghost_label.rb +2 -1
  93. data/lib/ea/ocl/evaluator.rb +39 -0
  94. data/lib/ea/ocl/nodes.rb +9 -0
  95. data/lib/ea/ocl/parser.rb +60 -6
  96. data/lib/ea/qea/database.rb +57 -2
  97. data/lib/ea/qea/factory/reference_resolver.rb +1 -1
  98. data/lib/ea/qea/models/ea_attribute.rb +1 -17
  99. data/lib/ea/qea/models/ea_connector.rb +3 -2
  100. data/lib/ea/qea/models/ea_object.rb +14 -9
  101. data/lib/ea/qea/models/ea_operation.rb +1 -17
  102. data/lib/ea/qea/models/ea_xref.rb +3 -0
  103. data/lib/ea/qea/models/scope_predicate.rb +29 -0
  104. data/lib/ea/qea/models.rb +1 -0
  105. data/lib/ea/qea/repositories/base_repository.rb +4 -1
  106. data/lib/ea/qea/validation/base_validator.rb +3 -48
  107. data/lib/ea/qea/validation/database/ocl_constraint_validator.rb +23 -13
  108. data/lib/ea/shapescript/renderer.rb +1 -3
  109. data/lib/ea/sources/qea/diagram_builder.rb +1 -0
  110. data/lib/ea/sources/qea/id_normalizer.rb +2 -2
  111. data/lib/ea/svg/ea_emitter/background.rb +2 -1
  112. data/lib/ea/svg/ea_emitter/element/attribute_line_builder.rb +1 -6
  113. data/lib/ea/svg/ea_emitter/element/operation_renderer.rb +2 -7
  114. data/lib/ea/svg/ea_emitter/elements.rb +1 -3
  115. data/lib/ea/svg/ea_emitter/ghost_labels.rb +2 -1
  116. data/lib/ea/svg/ea_emitter/label/end_label.rb +1 -6
  117. data/lib/ea/svg/ea_emitter/marker/package_import.rb +1 -1
  118. data/lib/ea/svg/ea_emitter/markers.rb +52 -23
  119. data/lib/ea/svg/ea_emitter/text_renderer.rb +1 -4
  120. data/lib/ea/svg/ea_emitter/visibility_symbol.rb +37 -0
  121. data/lib/ea/svg/ea_emitter.rb +1 -0
  122. data/lib/ea/svg/parity/source.rb +0 -2
  123. data/lib/ea/svg/parity/suite.rb +0 -1
  124. data/lib/ea/transformers/qea_to_xmi/cardinality.rb +40 -28
  125. data/lib/ea/transformers/qea_to_xmi/context.rb +35 -1
  126. data/lib/ea/transformers/qea_to_xmi/extension_serializer.rb +686 -0
  127. data/lib/ea/transformers/qea_to_xmi/guid_format.rb +5 -48
  128. data/lib/ea/transformers/qea_to_xmi/id_allocator.rb +55 -54
  129. data/lib/ea/transformers/qea_to_xmi/primitive_types.rb +105 -0
  130. data/lib/ea/transformers/qea_to_xmi/profile_serializer.rb +163 -0
  131. data/lib/ea/transformers/qea_to_xmi/run_state.rb +7 -6
  132. data/lib/ea/transformers/qea_to_xmi/transformer.rb +423 -76
  133. data/lib/ea/transformers/qea_to_xmi/visibility.rb +30 -19
  134. data/lib/ea/transformers/qea_to_xmi.rb +5 -2
  135. data/lib/ea/transformers.rb +8 -2
  136. data/lib/ea/validation.rb +1 -1
  137. data/lib/ea/version.rb +1 -1
  138. data/lib/ea/xmi/liquid_drops/attribute_drop.rb +12 -4
  139. data/lib/ea/xmi/liquid_drops/klass_drop.rb +1 -2
  140. data/lib/ea/xmi/lookup_service.rb +4 -0
  141. data/lib/ea/xmi/parser.rb +30 -1
  142. data/lib/ea/xml_escape.rb +21 -0
  143. data/lib/ea.rb +2 -0
  144. metadata +34 -11
  145. data/lutaml-uml-0.5.1.gem +0 -0
@@ -34,6 +34,7 @@ module Ea
34
34
  MODEL_NAME = "EA_Model"
35
35
  EXPORTER = "Enterprise Architect"
36
36
  EXPORTER_VERSION = "6.5"
37
+ BUILD_ID = "1624"
37
38
 
38
39
  RELATIONSHIP_AT_PACKAGE_LEVEL = {
39
40
  "Association" => :association,
@@ -55,12 +56,36 @@ module Ea
55
56
  class: ->(obj) { build_class(obj) },
56
57
  enumeration: ->(obj) { build_enumeration(obj) },
57
58
  data_type: ->(obj) { build_data_type(obj) },
59
+ signal: ->(obj) { build_class(obj, xmi_type: "uml:Signal") },
58
60
  instance: ->(obj) { build_instance(obj) },
61
+ association_element: ->(obj) { build_association_element(obj) },
59
62
  }.freeze
60
63
 
61
- def initialize(database)
64
+ # What each builder in CLASSIFIER_BUILDERS actually emits, keyed
65
+ # by the same symbols. Every builder must appear here: a kind
66
+ # left out silently loses its attribute bounds or its nested
67
+ # children — wrong XML, no exception — so the spec asserts these
68
+ # keys match CLASSIFIER_BUILDERS exactly.
69
+ #
70
+ # `:owned_attribute` — the builder emits `<ownedAttribute>` and so
71
+ # preallocates attribute bound IDs. Enumerations emit literals
72
+ # (never bounds) and instances emit slots.
73
+ # `:nested_classifier` — the builder walks children, so a kind
74
+ # listed here can adopt an object that carries a parentid.
75
+ # `build_class` is the only builder that walks children.
76
+ CLASSIFIER_CAPABILITIES = {
77
+ class: %i[owned_attribute nested_classifier],
78
+ signal: %i[owned_attribute nested_classifier],
79
+ data_type: %i[owned_attribute],
80
+ enumeration: [],
81
+ instance: [],
82
+ association_element: []
83
+ }.freeze
84
+
85
+ def initialize(database, mdg_registry: nil)
62
86
  @database = database
63
- @context = Context.new(database: database)
87
+ @mdg_registry = mdg_registry
88
+ @context = Context.new(database: database)
64
89
  end
65
90
 
66
91
  # @return [String] XMI XML document
@@ -70,8 +95,89 @@ module Ea
70
95
  # nil-valued attributes are skipped at the source. No
71
96
  # post-processing pass is needed — the prior XmlSanitizer
72
97
  # workaround (TODO 21 §1) has been removed.
73
- def serialize
74
- build_root.to_xml(use_prefix: true)
98
+ # Serialize to XMI XML.
99
+ #
100
+ # Default produces model tree only (parseable by the xmi gem's
101
+ # Sparx parser for round-trip). Pass `with_extensions: true` to
102
+ # also emit EA-specific `<connectors>` and `<diagrams>` sections
103
+ # inside `<xmi:Extension>`. Those use post-processing string
104
+ # injection that the xmi gem's parser doesn't model, so the
105
+ # output is NOT round-trippable when extensions are included.
106
+ #
107
+ # @param with_extensions [Boolean]
108
+ # @return [String] XMI XML
109
+ def serialize(with_extensions: true)
110
+ xml = build_root.to_xml(use_prefix: true)
111
+ xml = normalize_namespaces(xml)
112
+ xml = plain_parameter_type(xml)
113
+ with_extensions ? inject_extension_content(xml) : xml
114
+ end
115
+
116
+ # EA writes a return parameter's type unprefixed:
117
+ # `<ownedParameter … type="EAnone_void"/>`. Its reference export
118
+ # carries 12 of these and no `xmi:type` on ownedParameter at all.
119
+ #
120
+ # `xmi:type` is the XMI metaclass discriminator; `type` is the
121
+ # classifier reference. Two different attributes — but
122
+ # lutaml-model matches by local name and collapses them into one
123
+ # slot, so the xmi gem can only emit one of them. It keeps the
124
+ # namespaced form, which is right for general UML XMI, and
125
+ # rewriting that in the shared model would break round-tripping
126
+ # for every non-Sparx consumer. Restoring the reference EA
127
+ # actually writes is this exporter's job, so it happens here.
128
+ #
129
+ # Scoped to ownedParameter: no other element wants it. Walks
130
+ # complete name="value" pairs rather than scanning the tag as
131
+ # text — a parameter name is free text out of EA and may itself
132
+ # contain the literal `xmi:type=`, which a raw substitution
133
+ # would happily rewrite inside the quotes.
134
+ ATTRIBUTE_PAIR = /([A-Za-z_][\w:.-]*)=("[^"]*")/
135
+
136
+ def plain_parameter_type(xml)
137
+ xml.gsub(/<ownedParameter\b[^>]*?>/m) do |tag|
138
+ tag.gsub(ATTRIBUTE_PAIR) do
139
+ name = Regexp.last_match(1)
140
+ name == "xmi:type" ? %(type=#{Regexp.last_match(2)}) : Regexp.last_match(0)
141
+ end
142
+ end
143
+ end
144
+
145
+ # EA's reference XMI uses the 2011-07-01 XMI/UML namespace URIs;
146
+ # the xmi gem's Root model hard-codes 2013-10-01. Post-process
147
+ # the serialized XML to match EA's namespace version. This is a
148
+ # targeted string replacement on the xmlns declarations only,
149
+ # not a semantic change.
150
+ def normalize_namespaces(xml)
151
+ xml.gsub("http://www.omg.org/spec/XMI/20131001",
152
+ "http://www.omg.org/spec/XMI/20110701")
153
+ .gsub("http://www.omg.org/spec/UML/20131001/UMLDI",
154
+ "http://www.omg.org/spec/UML/20110701/UMLDI")
155
+ .gsub("http://www.omg.org/spec/UML/20131001/UMLDC",
156
+ "http://www.omg.org/spec/UML/20110701/UMLDC")
157
+ .gsub("http://www.omg.org/spec/UML/20131001",
158
+ "http://www.omg.org/spec/UML/20110701")
159
+ end
160
+
161
+ # Injects EA-specific extension content (elements, connectors,
162
+ # diagrams) into the serialized XMI. The xmi gem's Extension
163
+ # type is modeled but doesn't declare these children. We
164
+ # post-process the serialized XML to insert them inside the
165
+ # existing `<xmi:Extension>` element.
166
+ #
167
+ # Delegated to {ExtensionSerializer} — this class only handles
168
+ # the string substitution mechanics.
169
+ def inject_extension_content(xml)
170
+ content = ExtensionSerializer.new(@database, @context).call
171
+ return xml if content.empty?
172
+
173
+ if xml.include?("<xmi:Extension")
174
+ xml.sub(%r{<xmi:Extension ([^>]+)>\s*</xmi:Extension>},
175
+ "<xmi:Extension \\1>\n#{content}\n\t</xmi:Extension>")
176
+ .sub(/<xmi:Extension ([^>]+)\/>/,
177
+ "<xmi:Extension \\1>\n#{content}\n\t</xmi:Extension>")
178
+ else
179
+ xml
180
+ end
75
181
  end
76
182
 
77
183
  private
@@ -90,6 +196,7 @@ module Ea
90
196
  ::Xmi::Documentation.new(
91
197
  exporter: EXPORTER,
92
198
  exporter_version: EXPORTER_VERSION,
199
+ exporter_id: BUILD_ID,
93
200
  )
94
201
  end
95
202
 
@@ -97,7 +204,7 @@ module Ea
97
204
  ::Xmi::Uml::UmlModel.new(
98
205
  type: "uml:Model",
99
206
  name: MODEL_NAME,
100
- packaged_element: root_packages.map { |pkg| build_package(pkg) },
207
+ packaged_element: root_packages.map { |pkg| build_package(pkg) } + profile_elements,
101
208
  )
102
209
  end
103
210
 
@@ -111,6 +218,13 @@ module Ea
111
218
  .sort_by { |p| [p.tpos || 0, p.name.to_s] }
112
219
  end
113
220
 
221
+ # MDG stereotype definitions to include in the model tree.
222
+ # Empty when no MDG registry is wired. Each registered MDG
223
+ # contributes its stereotypes and extension relationships.
224
+ def profile_elements
225
+ ProfileSerializer.new(@mdg_registry).call
226
+ end
227
+
114
228
  # ---- Package tree ------------------------------------------------
115
229
 
116
230
  def build_package(pkg)
@@ -132,12 +246,128 @@ module Ea
132
246
  end
133
247
 
134
248
  def subpackages(pkg)
135
- sorted_by_position(@context.child_packages(pkg.package_id))
136
- .map { |sub| build_package(sub) }
249
+ @context.sorted_by_position(@context.child_packages(pkg.package_id))
250
+ .map { |sub| build_package(sub) }
137
251
  end
138
252
 
253
+ # EA emits a package's own classifiers first, then the children
254
+ # nobody adopted. The global LI counter follows that order.
139
255
  def classifier_objects(pkg)
140
- classifiers_in(pkg).map { |obj| build_classifier(obj) }.compact
256
+ top_level, orphaned = package_level_classifiers(pkg)
257
+ .partition { |obj| obj.parentid.to_i.zero? }
258
+ (top_level + orphaned).filter_map { |obj| build_classifier_with_bounds(obj) }
259
+ end
260
+
261
+ # Everything EA leaves at package level: an object stays here
262
+ # unless a classifier in the same package actually adopts it as
263
+ # a nestedClassifier. A child of an enumeration, of an
264
+ # instance, of a type we don't build, of a parent in another
265
+ # package, or of one that isn't there at all has no adopter.
266
+ #
267
+ # Adoption is decided from the package's own contents, never
268
+ # from what the walk has emitted so far — a child can sort
269
+ # before its own parent, and asking a set that the walk is
270
+ # still filling would emit that child twice.
271
+ def package_level_classifiers(pkg)
272
+ buildable_objects_in(pkg).reject { |obj| adopted?(obj) }
273
+ end
274
+
275
+ # A parent adopts a child only if it is a nesting kind in the
276
+ # same package AND the ParentID chain above the child actually
277
+ # terminates. A corrupt model can point an object at itself or
278
+ # round a cycle; leaving those adopted would drop them from the
279
+ # export, since the parent that owes them an emission is never
280
+ # reached. Shared with nested_classifiers_under so the two
281
+ # cannot disagree about who owns a child.
282
+ def adopted?(obj)
283
+ parent = nesting_parent_of(obj)
284
+ !parent.nil? && !cyclic_ancestry?(obj)
285
+ end
286
+
287
+ def nesting_parent_of(obj)
288
+ parent_id = obj.parentid.to_i
289
+ return nil unless parent_id.positive?
290
+
291
+ parent = @context.object_by_id(parent_id)
292
+ return nil unless parent && parent.package_id == obj.package_id
293
+ return nil unless nests_classifiers?(parent)
294
+
295
+ parent
296
+ end
297
+
298
+ def cyclic_ancestry?(obj)
299
+ seen = Set[obj.ea_object_id]
300
+ current = obj
301
+ while (current = nesting_parent_of(current))
302
+ return true unless seen.add?(current.ea_object_id)
303
+ end
304
+ false
305
+ end
306
+
307
+ # EA's class-centric LI allocation: attribute bounds first (name
308
+ # order), then the classifier itself, then the association-end
309
+ # bounds anchored here. The memoized allocator lets later
310
+ # builders reuse these IDs.
311
+ def build_classifier_with_bounds(obj)
312
+ preallocate_attribute_bounds(obj)
313
+ element = build_classifier(obj)
314
+ preallocate_end_bounds(obj)
315
+ element
316
+ end
317
+
318
+ # EA allocates a classifier's attribute LI bounds in attribute
319
+ # NAME order, while the document emits attributes in Pos order.
320
+ # Pre-allocating here (memoized) makes the later Pos-ordered
321
+ # build reuse the name-ordered counters. Only owners that go on
322
+ # to emit ownedAttribute take counters — an enumeration's
323
+ # literals never carry bounds.
324
+ def preallocate_attribute_bounds(obj)
325
+ return unless emits_attributes?(obj)
326
+
327
+ @context.attributes_for(obj.ea_object_id)
328
+ .sort_by { |a| a.name.to_s }
329
+ .each { |attr| build_attribute_bounds(attr) }
330
+ end
331
+
332
+ # Lower before upper — the global LI counter follows this order.
333
+ # @return [Array(Xmi::Uml::LowerValue, Xmi::Uml::UpperValue)]
334
+ def build_attribute_bounds(attr)
335
+ attr_id = @context.xmi_id_for(attr)
336
+ bounds = Cardinality.attribute_bounds(attr.lowerbound, attr.upperbound)
337
+ [build_lower_value(bounds[:lower],
338
+ seed: "mult-attr-#{attr.id}-lower", owner_id: attr_id),
339
+ build_upper_value(bounds[:upper],
340
+ seed: "mult-attr-#{attr.id}-upper", owner_id: attr_id)]
341
+ end
342
+
343
+ # EA's global LI counter is allocated class-centrically, not in
344
+ # document order: after a classifier's attribute bounds come the
345
+ # association-end bounds whose OPPOSITE endpoint is that
346
+ # classifier (dst ends of connectors starting here, src ends of
347
+ # connectors ending here). The memoized allocator lets the
348
+ # Association element reuse these IDs when it is built later.
349
+ def preallocate_end_bounds(obj)
350
+ association_ends_anchored_at(obj)
351
+ .each { |conn, side| build_association_end(conn, side: side) }
352
+ end
353
+
354
+ # The ends anchored at a classifier, ordered by connector GUID
355
+ # (ascending string order — the only key that fits every tied
356
+ # case in the reference exports; names, connector ids and
357
+ # opposite-endpoint ids all contradict at least one).
358
+ def association_ends_anchored_at(obj)
359
+ @context.connectors_for(obj.ea_object_id)
360
+ .flat_map { |conn| anchored_end_sides(conn, obj.ea_object_id) }
361
+ .sort_by { |conn, side| [conn.ea_guid.to_s, side == :destination ? 0 : 1] }
362
+ end
363
+
364
+ def anchored_end_sides(conn, object_id)
365
+ return [] unless RELATIONSHIP_AT_PACKAGE_LEVEL[conn.connector_type] == :association
366
+
367
+ pairs = []
368
+ pairs << [conn, :destination] if conn.start_object_id == object_id
369
+ pairs << [conn, :source] if conn.end_object_id == object_id
370
+ pairs
141
371
  end
142
372
 
143
373
  # Connectors owned by this package: connectors whose start_object is
@@ -146,7 +376,7 @@ module Ea
146
376
  # Realization are emitted inside the classifier itself.
147
377
  def package_level_relationships(pkg)
148
378
  emitted = Set.new
149
- classifiers_in(pkg).flat_map do |obj|
379
+ relationship_sources_in(pkg).flat_map do |obj|
150
380
  @context.connectors_starting_at(obj.ea_object_id).filter_map do |conn|
151
381
  key = RELATIONSHIP_AT_PACKAGE_LEVEL[conn.connector_type]
152
382
  next nil unless key
@@ -157,6 +387,13 @@ module Ea
157
387
  end
158
388
  end
159
389
 
390
+ # Relationship discovery must also see Package objects —
391
+ # package-to-package Dependency connectors hang off them even
392
+ # though they never emit as classifiers.
393
+ def relationship_sources_in(pkg)
394
+ @context.objects_in_package(pkg.package_id).reject { |o| note?(o) }
395
+ end
396
+
160
397
  def build_package_relationship(kind, conn)
161
398
  case kind
162
399
  when :association then build_association(conn)
@@ -171,16 +408,29 @@ module Ea
171
408
  # ---- Classifier dispatch (OCP registry) --------------------------
172
409
 
173
410
  def build_classifier(obj)
174
- kind = obj.transformer_type || obj.object_type&.downcase&.to_sym
175
- builder = CLASSIFIER_BUILDERS[kind]
411
+ builder = CLASSIFIER_BUILDERS[obj.transformer_type]
176
412
  return nil unless builder
177
413
 
178
414
  instance_exec(obj, &builder)
179
415
  end
180
416
 
181
- def build_class(obj)
417
+ # Objects that build no classifier at all (a Component parent, a
418
+ # Note) reach these predicates too, and answer no to both.
419
+ def capabilities_of(obj)
420
+ CLASSIFIER_CAPABILITIES.fetch(obj.transformer_type, [])
421
+ end
422
+
423
+ def emits_attributes?(obj)
424
+ capabilities_of(obj).include?(:owned_attribute)
425
+ end
426
+
427
+ def nests_classifiers?(obj)
428
+ capabilities_of(obj).include?(:nested_classifier)
429
+ end
430
+
431
+ def build_class(obj, xmi_type: nil)
182
432
  ::Xmi::Uml::PackagedElement.new(
183
- type: class_xmi_type(obj),
433
+ type: xmi_type || class_xmi_type(obj),
184
434
  id: @context.xmi_id_for(obj),
185
435
  name: obj.name,
186
436
  visibility: Visibility.from_scope(obj.scope),
@@ -189,9 +439,16 @@ module Ea
189
439
  interface_realization: interface_realizations_for(obj),
190
440
  owned_attribute: attributes_for(obj),
191
441
  owned_operation: operations_for(obj),
442
+ nested_classifier: nested_children_for(obj),
192
443
  )
193
444
  end
194
445
 
446
+ # Recursive: nested children carry their own nested children,
447
+ # walked with the same class-centric bound preallocation.
448
+ def nested_children_for(obj)
449
+ nested_classifiers_under(obj).filter_map { |child| build_classifier_with_bounds(child) }
450
+ end
451
+
195
452
  def build_enumeration(obj)
196
453
  ::Xmi::Uml::PackagedElement.new(
197
454
  type: "uml:Enumeration",
@@ -224,6 +481,15 @@ module Ea
224
481
  )
225
482
  end
226
483
 
484
+ # EA stores some associations as t_object rows. Its export
485
+ # emits them bare — id and name only, no ownedEnds and no
486
+ # visibility. The connector-backed associations
487
+ # (build_association) are a separate path.
488
+ def build_association_element(obj)
489
+ ::Xmi::Uml::PackagedElement.new(type: "uml:Association", id: @context.xmi_id_for(obj),
490
+ name: obj.name)
491
+ end
492
+
227
493
  def build_comment(obj)
228
494
  ::Xmi::Uml::OwnedComment.new(
229
495
  type: "uml:Comment",
@@ -245,28 +511,31 @@ module Ea
245
511
  # rather than as package-level `<packagedElement type=
246
512
  # "uml:Realization">`. We do the same.
247
513
  def interface_realizations_for(obj)
248
- realization_connectors(obj).map { |conn| build_interface_realization(conn) }
514
+ realization_connectors(obj).filter_map { |conn| build_interface_realization(conn) }
249
515
  end
250
516
 
251
517
  def attributes_for(obj)
252
- sorted_by_position(@context.attributes_for(obj.ea_object_id))
253
- .map { |attr| build_attribute(attr) }
518
+ @context.sorted_by_position(@context.attributes_for(obj.ea_object_id))
519
+ .map { |attr| build_attribute(attr) }
254
520
  end
255
521
 
256
522
  def operations_for(obj)
257
- sorted_by_position(@context.operations_for(obj.ea_object_id))
258
- .map { |op| build_operation(op) }
523
+ @context.sorted_by_position(@context.operations_for(obj.ea_object_id))
524
+ .map { |op| build_operation(op) }
259
525
  end
260
526
 
261
527
  def enum_literals(obj)
262
- sorted_by_position(@context.attributes_for(obj.ea_object_id))
263
- .map { |attr| build_owned_literal(attr) }
528
+ @context.sorted_by_position(@context.attributes_for(obj.ea_object_id))
529
+ .map { |attr| build_owned_literal(attr) }
264
530
  end
265
531
 
266
532
  # ---- Leaf element builders --------------------------------------
267
533
 
268
534
  def build_attribute(attr)
269
- parent_guid = parent_guid_for_attribute(attr)
535
+ # An attribute always carries both bounds; association ends
536
+ # check their single card field instead and stay bare when it
537
+ # is blank.
538
+ lower, upper = build_attribute_bounds(attr)
270
539
  ::Xmi::Uml::OwnedAttribute.new(
271
540
  type: "uml:Property",
272
541
  id: @context.xmi_id_for(attr),
@@ -276,8 +545,8 @@ module Ea
276
545
  is_ordered: Visibility.boolean_from_flag(attr.isordered),
277
546
  is_derived: Visibility.boolean_from_flag(attr.derived),
278
547
  uml_type: type_reference_model(attr.type, attr.classifier),
279
- upper_value: build_upper_value(attr.upperbound, seed: "mult-attr-#{attr.id}-upper", parent_guid: parent_guid),
280
- lower_value: build_lower_value(attr.lowerbound, seed: "mult-attr-#{attr.id}-lower", parent_guid: parent_guid),
548
+ upper_value: upper,
549
+ lower_value: lower,
281
550
  )
282
551
  end
283
552
 
@@ -289,7 +558,7 @@ module Ea
289
558
  is_static: Visibility.boolean_from_flag(op.isstatic),
290
559
  is_abstract: Visibility.boolean_from_flag(op.abstract),
291
560
  is_query: Visibility.boolean_from_flag(op.pure),
292
- concurrency: op.concurrency&.downcase,
561
+ concurrency: normalize_concurrency(op.concurrency),
293
562
  owned_parameter: operation_parameters(op),
294
563
  )
295
564
  end
@@ -314,30 +583,43 @@ module Ea
314
583
  end
315
584
 
316
585
  def build_interface_realization(conn)
586
+ client = @context.object_by_id(conn.start_object_id)
317
587
  supplier = @context.object_by_id(conn.end_object_id)
588
+ return nil unless client && supplier
589
+
590
+ supplier_ref = @context.xmi_id_for(supplier)
318
591
  ::Xmi::Uml::InterfaceRealization.new(
319
- type: "uml:InterfaceRealization",
320
- id: @context.xmi_id_for(conn),
321
- name: conn.name,
322
- client: @context.xmi_id_for(conn.start_object_id),
323
- supplier: supplier ? @context.xmi_id_for(supplier) : nil,
324
- contract: supplier ? @context.xmi_id_for(supplier) : nil,
592
+ type: "uml:InterfaceRealization", id: @context.xmi_id_for(conn),
593
+ name: conn.name, client: @context.xmi_id_for(client),
594
+ supplier: supplier_ref, contract: supplier_ref
325
595
  )
326
596
  end
327
597
 
328
598
  def build_dependency(conn)
329
- client = @context.object_by_id(conn.start_object_id)
330
- supplier = @context.object_by_id(conn.end_object_id)
599
+ client = dependency_endpoint_ref(conn.start_object_id)
600
+ supplier = dependency_endpoint_ref(conn.end_object_id)
331
601
  return nil unless client && supplier
332
602
 
333
603
  ::Xmi::Uml::PackagedElement.new(
334
604
  type: "uml:Dependency",
335
605
  id: @context.xmi_id_for(conn),
336
- client: @context.xmi_id_for(client),
337
- supplier: @context.xmi_id_for(supplier),
606
+ client: client,
607
+ supplier: supplier,
338
608
  )
339
609
  end
340
610
 
611
+ # Package-object endpoints resolve to their t_package via PDATA1
612
+ # and reference it as EAPK_ (matching EA); everything else keeps
613
+ # its own EAID_ reference.
614
+ def dependency_endpoint_ref(object_id)
615
+ obj = @context.object_by_id(object_id)
616
+ return nil unless obj
617
+ return @context.xmi_id_for(obj) unless package_object?(obj)
618
+
619
+ pkg = @database.find_package(obj.pdata1.to_i)
620
+ pkg && @context.xmi_id_for(pkg, prefix: "EAPK")
621
+ end
622
+
341
623
  # Sparx serialisation order for `uml:Association` is
342
624
  # destination member-end first, source member-end second.
343
625
  # Reordering these breaks round-trip fidelity with Sparx EA
@@ -365,8 +647,18 @@ module Ea
365
647
  target_id = side == :source ? conn.start_object_id : conn.end_object_id
366
648
  target_obj = @context.object_by_id(target_id)
367
649
  target_ref = target_obj ? @context.xmi_id_for(target_obj) : nil
368
- bounds = Cardinality.parse(cardinality_for(conn, side))
369
650
  containment = containment_for(conn, side)
651
+ # EA emits lower/upper only when the end's cardinality is set —
652
+ # a blank card gets a bare ownedEnd. Allocation order is lower
653
+ # before upper, with the END's id (EAID_dst…/EAID_src…) as the
654
+ # tail source.
655
+ raw_card = cardinality_for(conn, side)
656
+ lower = upper = nil
657
+ unless raw_card.to_s.strip.empty?
658
+ bounds = Cardinality.parse(raw_card)
659
+ lower = build_lower_value(bounds[:lower], seed: "mult-#{conn.connector_id}-#{side}-lower", owner_id: end_id)
660
+ upper = build_upper_value(bounds[:upper], seed: "mult-#{conn.connector_id}-#{side}-upper", owner_id: end_id)
661
+ end
370
662
 
371
663
  model = ::Xmi::Uml::OwnedEnd.new(
372
664
  type: "uml:Property",
@@ -376,8 +668,8 @@ module Ea
376
668
  aggregation: Visibility.aggregation_from_containment(containment),
377
669
  association: @context.xmi_id_for(conn),
378
670
  uml_type: target_ref ? ::Xmi::Uml::Type.new(idref: target_ref) : nil,
379
- upper_value: build_upper_value(bounds[:upper], seed: "mult-#{conn.connector_id}-#{side}-upper", parent_guid: conn.ea_guid),
380
- lower_value: build_lower_value(bounds[:lower], seed: "mult-#{conn.connector_id}-#{side}-lower", parent_guid: conn.ea_guid),
671
+ upper_value: upper,
672
+ lower_value: lower,
381
673
  )
382
674
 
383
675
  AssociationEnd.new(end_id, model)
@@ -385,28 +677,28 @@ module Ea
385
677
 
386
678
  # ---- Multiplicity helpers ---------------------------------------
387
679
 
388
- # Always emit both bounds UML defaults (lower=0, upper=-1) are
389
- # used when the EA field is blank. Matches real Sparx XMI, which
390
- # never omits `<upperValue>`/`<lowerValue>` on a Property.
391
- def build_upper_value(raw, seed:, parent_guid:)
680
+ # Callers emit bounds only when the EA cardinality field is set —
681
+ # EA's reference exports omit `<upperValue>`/`<lowerValue>`
682
+ # entirely on ends whose card is blank.
683
+ def build_upper_value(raw, seed:, owner_id:)
392
684
  ::Xmi::Uml::UpperValue.new(
393
685
  type: "uml:LiteralUnlimitedNatural",
394
686
  id: @context.id_allocator.allocate(
395
687
  prefix: IdAllocator::LITERAL_INTEGER,
396
688
  seed: seed,
397
- parent_guid: parent_guid,
689
+ owner_id: owner_id,
398
690
  ),
399
691
  value: Cardinality.normalize_upper(raw),
400
692
  )
401
693
  end
402
694
 
403
- def build_lower_value(raw, seed:, parent_guid:)
695
+ def build_lower_value(raw, seed:, owner_id:)
404
696
  ::Xmi::Uml::LowerValue.new(
405
697
  type: "uml:LiteralInteger",
406
698
  id: @context.id_allocator.allocate(
407
699
  prefix: IdAllocator::LITERAL_INTEGER,
408
700
  seed: seed,
409
- parent_guid: parent_guid,
701
+ owner_id: owner_id,
410
702
  ),
411
703
  value: Cardinality.normalize_lower(raw),
412
704
  )
@@ -415,18 +707,31 @@ module Ea
415
707
  # ---- Operation parameters ---------------------------------------
416
708
 
417
709
  def operation_parameters(op)
418
- params = sorted_by_position(@context.params_for_operation(op.operationid))
710
+ params = @context.params_for_operation(op.operationid)
419
711
  .reject(&:return?)
420
712
  .map { |p| build_owned_parameter(p) }
421
- params << build_return_parameter(op) if op.type && !op.type.empty?
713
+ params << build_return_parameter(op) if synthesized_return?(op)
422
714
  params
423
715
  end
424
716
 
717
+ # An untyped operation has no return to describe. One with no
718
+ # usable GUID has nothing to anchor the RT id's tail on, and the
719
+ # extension block skips it for the same reason — emitting it
720
+ # here would leave the two describing different operations.
721
+ # Type is blank-checked the way PrimitiveTypes normalizes names,
722
+ # so a whitespace-only Type cannot synthesize a return that
723
+ # primitive discovery then treats as absent.
724
+ def synthesized_return?(op)
725
+ return false if PrimitiveTypes.normalize_name(op.type).empty?
726
+
727
+ @context.identifiable?(op)
728
+ end
729
+
425
730
  def build_owned_parameter(param)
426
731
  ::Xmi::Uml::OwnedParameter.new(
427
732
  id: @context.xmi_id_for(param),
428
733
  name: param.name,
429
- direction: param.kind&.downcase,
734
+ direction: normalize_direction(param.kind),
430
735
  )
431
736
  end
432
737
 
@@ -435,10 +740,11 @@ module Ea
435
740
  id: @context.id_allocator.allocate(
436
741
  prefix: IdAllocator::RETURN_PARAMETER,
437
742
  seed: "return-#{op.operationid}",
438
- parent_guid: op.ea_guid,
743
+ owner_id: @context.xmi_id_for(op),
439
744
  ),
440
745
  name: "return",
441
746
  direction: "return",
747
+ type: type_reference(op.type, op.classifier),
442
748
  )
443
749
  end
444
750
 
@@ -448,6 +754,26 @@ module Ea
448
754
  obj.interface? ? "uml:Interface" : "uml:Class"
449
755
  end
450
756
 
757
+ # EA's operation concurrency defaults to "Sequential" — omit
758
+ # it so the xmi gem doesn't emit concurrency="sequential"
759
+ # (EA's reference XMI omits the default).
760
+ def normalize_concurrency(raw)
761
+ return nil if raw.nil? || raw.to_s.strip.empty?
762
+
763
+ value = raw.to_s.downcase
764
+ value == "sequential" ? nil : value
765
+ end
766
+
767
+ # UML parameter direction defaults to "in" — omit it so the
768
+ # xmi gem doesn't emit direction="in" (EA's reference XMI
769
+ # omits the default).
770
+ def normalize_direction(raw)
771
+ return nil if raw.nil? || raw.to_s.strip.empty?
772
+
773
+ value = raw.to_s.downcase
774
+ value == "in" ? nil : value
775
+ end
776
+
451
777
  def inheritance_connectors(obj, type)
452
778
  @context.connectors_for(obj.ea_object_id).select do |conn|
453
779
  conn.start_object_id == obj.ea_object_id && conn.connector_type == type
@@ -455,15 +781,32 @@ module Ea
455
781
  end
456
782
 
457
783
  # Realization connectors owned by this class — those where
458
- # this object is the source (client) and the connector type
459
- # is Realization. Each emits an `<interfaceRealization>`.
784
+ # this object is the source (client). Each emits an
785
+ # `<interfaceRealization>`. Matched via the model predicate so
786
+ # both EA spellings (Realization/Realisation) are recognized.
460
787
  def realization_connectors(obj)
461
- inheritance_connectors(obj, "Realization")
788
+ @context.connectors_for(obj.ea_object_id).select do |conn|
789
+ conn.start_object_id == obj.ea_object_id && conn.realization?
790
+ end
462
791
  end
463
792
 
464
- def classifiers_in(pkg)
793
+ # EA walks classifiers in tree-position order (t_object.TPos,
794
+ # ties broken by name then object id), not insertion order.
795
+ # The global LI counter depends on it.
796
+ def buildable_objects_in(pkg)
465
797
  @context.objects_in_package(pkg.package_id)
466
798
  .reject { |o| note?(o) || package_object?(o) }
799
+ .sort_by { |o| [o.sort_position, o.name.to_s, o.ea_object_id] }
800
+ end
801
+
802
+ # Children nested under a classifier via t_object.ParentID.
803
+ # Level-1 order in EA's reference is descending object id;
804
+ # deeper levels don't match any column we've found — the LI
805
+ # counters inside deep nests may drift until that's derived.
806
+ def nested_classifiers_under(obj)
807
+ @context.objects_in_package(obj.package_id)
808
+ .select { |o| o.parentid.to_i == obj.ea_object_id && adopted?(o) }
809
+ .sort_by { |o| [o.sort_position, -o.ea_object_id] }
467
810
  end
468
811
 
469
812
  def notes_in(pkg)
@@ -478,27 +821,39 @@ module Ea
478
821
  obj.object_type == "Package"
479
822
  end
480
823
 
481
- def sorted_by_position(records)
482
- records.sort_by { |r| [r.sort_position, r.name.to_s] }
483
- end
484
-
485
824
  def primitive?(obj)
486
825
  obj.object_type == "PrimitiveType" ||
487
826
  (obj.gentype == "Java" && obj.stereotype_is?("primitive"))
488
827
  end
489
828
 
490
- def type_reference(type_name, classifier_guid)
491
- return nil if type_name.nil? || type_name.empty?
829
+ # The fallback id comes from the same module that emits the
830
+ # <primitivetypes> definitions, so the two normalize the name
831
+ # identically. A name that is blank once stripped gets no
832
+ # reference at all — PrimitiveTypes defines nothing for it.
833
+ def type_reference(type_name, classifier)
834
+ return nil if PrimitiveTypes.normalize_name(type_name).empty?
492
835
 
493
- if classifier_guid
494
- GuidFormat.ea_guid_to_xmi_id(classifier_guid)
495
- else
496
- "EAnone_#{type_name}"
497
- end
836
+ classifier_ref(classifier) || PrimitiveTypes.definition_id(type_name)
837
+ end
838
+
839
+ # QEA stores t_attribute.Classifier / t_operation.Classifier as
840
+ # an INTEGER t_object id ("0" = unresolved); EAP-era data can
841
+ # carry a GUID instead. Resolve either to the object's xmi id.
842
+ def classifier_ref(classifier)
843
+ text = PrimitiveTypes.normalize_name(classifier)
844
+ return nil if PrimitiveTypes.blank_classifier?(text)
845
+ return GuidFormat.ea_guid_to_xmi_id(text) unless text.match?(/\A\d+\z/)
846
+
847
+ obj = @context.object_by_id(text.to_i)
848
+ obj && @context.xmi_id_for(obj)
498
849
  end
499
850
 
500
- def type_reference_model(type_name, classifier_guid)
501
- ref = type_reference(type_name, classifier_guid)
851
+ def type_reference_model(type_name, classifier)
852
+ if (href = PrimitiveTypes.href_for(type_name, classifier))
853
+ return ::Xmi::Uml::Type.new(href: href)
854
+ end
855
+
856
+ ref = type_reference(type_name, classifier)
502
857
  ref ? ::Xmi::Uml::Type.new(idref: ref) : nil
503
858
  end
504
859
 
@@ -511,7 +866,7 @@ module Ea
511
866
  end
512
867
 
513
868
  def containment_for(conn, side)
514
- side == :source ? conn.sourcecontainment : conn.destcontainment
869
+ side == :source ? conn.sourceisaggregate : conn.destisaggregate
515
870
  end
516
871
 
517
872
  # EA's t_connector does not expose per-end visibility (the
@@ -522,14 +877,6 @@ module Ea
522
877
  nil
523
878
  end
524
879
 
525
- # The owning element for an attribute's synthesised IDs is the
526
- # attribute's classifier (parent object), not the attribute
527
- # itself — Sparx encodes the parent class GUID in the suffix.
528
- def parent_guid_for_attribute(attr)
529
- parent = @context.object_by_id(attr.ea_object_id)
530
- parent&.ea_guid
531
- end
532
-
533
880
  # InstanceSpecification classifier reference. EA stores this
534
881
  # directly in t_object.classifier as the classifier's
535
882
  # ea_object_id (NOT pdata1, which is a separate column used
@@ -574,7 +921,7 @@ module Ea
574
921
  @context.id_allocator.allocate(
575
922
  prefix: IdAllocator::SLOT,
576
923
  seed: "slot-#{instance.ea_object_id}-#{binding.variable}",
577
- parent_guid: instance.ea_guid,
924
+ owner_id: @context.xmi_id_for(instance),
578
925
  )
579
926
  end
580
927
 
@@ -582,7 +929,7 @@ module Ea
582
929
  @context.id_allocator.allocate(
583
930
  prefix: IdAllocator::OPAQUE_EXPRESSION,
584
931
  seed: "oe-#{instance.ea_object_id}-#{binding.variable}",
585
- parent_guid: instance.ea_guid,
932
+ owner_id: @context.xmi_id_for(instance),
586
933
  )
587
934
  end
588
935