expressir 2.3.6 → 2.4.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rake.yml +1 -1
  3. data/.gitignore +2 -0
  4. data/.rubocop_todo.yml +17 -12
  5. data/CHANGELOG.md +159 -0
  6. data/docs/_guides/ler/step-packages.adoc +385 -0
  7. data/lib/expressir/coverage.rb +4 -4
  8. data/lib/expressir/express/builder_registry.rb +3 -1
  9. data/lib/expressir/express/builders/expression_builder.rb +2 -2
  10. data/lib/expressir/express/builders/helpers.rb +2 -8
  11. data/lib/expressir/express/builders/qualifier_builder.rb +2 -1
  12. data/lib/expressir/express/formatter.rb +32 -164
  13. data/lib/expressir/express/formatters/data_types_formatter.rb +39 -6
  14. data/lib/expressir/express/formatters/declarations_formatter.rb +47 -8
  15. data/lib/expressir/express/formatters/expressions_formatter.rb +20 -5
  16. data/lib/expressir/express/formatters/literals_formatter.rb +11 -1
  17. data/lib/expressir/express/formatters/references_formatter.rb +10 -1
  18. data/lib/expressir/express/formatters/remark_formatter.rb +1 -89
  19. data/lib/expressir/express/formatters/remark_item_formatter.rb +5 -4
  20. data/lib/expressir/express/formatters/statements_formatter.rb +32 -9
  21. data/lib/expressir/express/formatters/supertype_expressions_formatter.rb +7 -3
  22. data/lib/expressir/express/hyperlink_formatter.rb +1 -3
  23. data/lib/expressir/express/line_map.rb +48 -0
  24. data/lib/expressir/express/model_visitor.rb +1 -1
  25. data/lib/expressir/express/pretty_formatter.rb +31 -108
  26. data/lib/expressir/express/remark_attacher.rb +144 -198
  27. data/lib/expressir/express/remark_scanner.rb +180 -0
  28. data/lib/expressir/express/schema_head_formatter.rb +1 -3
  29. data/lib/expressir/express.rb +2 -0
  30. data/lib/expressir/model/concerns.rb +6 -0
  31. data/lib/expressir/model/declarations/interface_item.rb +2 -0
  32. data/lib/expressir/model/declarations/interfaced_item.rb +3 -0
  33. data/lib/expressir/model/declarations/remark_item.rb +3 -0
  34. data/lib/expressir/model/declarations/schema.rb +7 -5
  35. data/lib/expressir/model/exp_file.rb +1 -0
  36. data/lib/expressir/model/identifier.rb +3 -1
  37. data/lib/expressir/model/model_element.rb +3 -3
  38. data/lib/expressir/model/repository.rb +8 -0
  39. data/lib/expressir/model/search_engine.rb +1 -1
  40. data/lib/expressir/package/reader.rb +9 -24
  41. data/lib/expressir/version.rb +1 -1
  42. metadata +6 -2
@@ -11,21 +11,44 @@ module Expressir
11
11
  # 2. Proximity-based matching for simple tags
12
12
  # 3. NOT creating spurious schema-level items for ambiguous tags
13
13
  class RemarkAttacher
14
- # Child collection attributes to walk when traversing model elements.
15
- # These are the named collections on model elements (e.g., entity.attributes,
16
- # schema.entities, function.parameters). Extracted to a constant to avoid
17
- # duplication between calculate_children_end_line and collect_children.
18
- CHILD_COLLECTION_ATTRIBUTES = %i[
19
- schemas types entities functions procedures rules constants
20
- attributes derived_attributes inverse_attributes
21
- where_rules unique_rules informal_propositions
22
- parameters variables statements items remark_items
23
- ].freeze
14
+ # Type-driven registry: maps each model class to its collection attributes.
15
+ # Replaces runtime method probing (method_defined?) with explicit type declarations.
16
+ COLLECTION_REGISTRY = {
17
+ Model::Declarations::Schema => %i[
18
+ constants types entities subtype_constraints
19
+ functions rules procedures remark_items
20
+ ],
21
+ Model::Declarations::Entity => %i[
22
+ attributes derived_attributes inverse_attributes
23
+ unique_rules where_rules informal_propositions remark_items
24
+ ],
25
+ Model::Declarations::Function => %i[
26
+ parameters types entities subtype_constraints
27
+ functions procedures constants variables statements remark_items
28
+ ],
29
+ Model::Declarations::Procedure => %i[
30
+ parameters types entities subtype_constraints
31
+ functions procedures constants variables statements remark_items
32
+ ],
33
+ Model::Declarations::Rule => %i[
34
+ applies_to types entities subtype_constraints
35
+ functions procedures constants variables statements
36
+ where_rules informal_propositions remark_items
37
+ ],
38
+ Model::Declarations::Type => %i[
39
+ where_rules informal_propositions remark_items
40
+ ],
41
+ Model::ExpFile => %i[schemas],
42
+ Model::Statements::Compound => %i[statements],
43
+ Model::Statements::If => %i[statements],
44
+ Model::Statements::Alias => %i[statements],
45
+ Model::Statements::Repeat => %i[statements],
46
+ }.freeze
24
47
 
25
48
  def initialize(source)
26
49
  @source = source
27
50
  @attached_spans = Set.new
28
- @line_cache = {}
51
+ @line_map = LineMap.new(source.b)
29
52
  @model = nil
30
53
  @source_lines = nil # cached @source.lines
31
54
  @scope_map = nil # cached scope at each line number
@@ -33,7 +56,7 @@ module Expressir
33
56
 
34
57
  def attach(model)
35
58
  @model = model
36
- remarks = extract_all_remarks
59
+ remarks = RemarkScanner.new(@source).scan
37
60
 
38
61
  # Build nodes_with_positions ONCE for both tagged and untagged remark passes.
39
62
  # This avoids double tree walk (381K nodes × 2 = 762K visits) which was
@@ -48,103 +71,22 @@ module Expressir
48
71
  @source = nil
49
72
  @source_lines = nil
50
73
  @scope_map = nil
51
- @line_cache = nil
74
+ @line_map = nil
52
75
 
53
76
  model
54
77
  end
55
78
 
56
79
  private
57
80
 
58
- def extract_all_remarks
59
- remarks = []
60
- byte_position = 0
61
-
62
- @source.each_line.with_index do |line, line_idx|
63
- line_bytesize = line.bytesize
64
- line_bytes = line.b # Get byte string for indexing
65
- if (dash_byte_idx = line_bytes.index("--"))
66
- remark_text = line.byteslice((dash_byte_idx + 2)..).strip
67
-
68
- # Check for special patterns like --IP1: content (informal proposition)
69
- if remark_text.match?(/^IP\d+:\s*(.*)$/)
70
- tag = remark_text[/^(IP\d+):/, 1]
71
- content = remark_text[/^IP\d+:\s*(.*)$/, 1]
72
- remarks << {
73
- position: byte_position + dash_byte_idx,
74
- line: line_idx + 1,
75
- text: content,
76
- tag: tag,
77
- format: "tail",
78
- }
79
- else
80
- tag, content = parse_tagged_remark(remark_text)
81
- remarks << {
82
- position: byte_position + dash_byte_idx,
83
- line: line_idx + 1,
84
- text: content || remark_text,
85
- tag: tag,
86
- format: "tail",
87
- }
88
- end
89
- end
90
- byte_position += line_bytesize
91
- end
92
-
93
- extract_embedded_remarks(remarks)
94
- # Sort by position to ensure remarks are processed in source order
95
- remarks.sort_by! { |r| r[:position] }
96
- remarks
97
- end
98
-
99
- def extract_embedded_remarks(remarks)
100
- source_bytes = @source.b
101
- start_pos = 0
102
- while (start_idx = source_bytes.index("(*", start_pos))
103
- end_idx = source_bytes.index("*)", start_idx + 2)
104
- break unless end_idx
105
-
106
- content = @source.byteslice((start_idx + 2)...end_idx)
107
- line_num = get_line_number(start_idx)
108
-
109
- tag, text = parse_tagged_embedded_remark(content)
110
-
111
- remarks << {
112
- position: start_idx,
113
- line: line_num,
114
- text: text,
115
- tag: tag,
116
- format: "embedded",
117
- }
118
-
119
- start_pos = end_idx + 2
120
- end
121
- end
122
-
123
- def parse_tagged_remark(text)
124
- if text.start_with?('"') && (end_quote = text.index('"', 1))
125
- [text[1...end_quote], text[(end_quote + 1)..].strip]
126
- else
127
- [nil, text]
128
- end
129
- end
130
-
131
- def parse_tagged_embedded_remark(content)
132
- stripped = content.strip
133
- if stripped.start_with?('"') && (end_quote = stripped.index('"', 1))
134
- [stripped[1...end_quote], stripped[(end_quote + 1)..].strip]
135
- else
136
- [nil, stripped]
137
- end
138
- end
81
+ # Remark extraction lives in {RemarkScanner}; this class only attaches.
82
+ # Line-number lookup is delegated to {LineMap} for O(log n) queries.
139
83
 
140
84
  def source_lines
141
85
  @source_lines ||= @source.lines
142
86
  end
143
87
 
144
88
  def get_line_number(position)
145
- return 1 if position.nil? || position.zero?
146
-
147
- @line_cache[position] ||= @source.byteslice(0...position).count("\n") + 1
89
+ @line_map.line_number(position)
148
90
  end
149
91
 
150
92
  def attach_tagged_remarks(model, remarks, nodes_with_positions)
@@ -157,23 +99,23 @@ module Expressir
157
99
  # This is the key optimization that makes scope lookup O(1) per remark
158
100
  @scope_map ||= build_scope_map
159
101
 
160
- tagged.sort_by { |r| r[:position] }.each do |remark|
161
- next if @attached_spans.include?(remark[:position])
102
+ tagged.sort_by(&:position).each do |remark|
103
+ next if @attached_spans.include?(remark.position)
162
104
 
163
- tag = remark[:tag]
105
+ tag = remark.tag
164
106
  target = nil
165
107
 
166
108
  # Find containing scope using pre-computed scope map (O(1))
167
109
  # Falls back to position-based lookup if scope map doesn't have the line
168
- containing_scope = find_containing_scope_by_name(remark[:line])
169
- containing_scope ||= find_containing_scope_position(remark[:line],
110
+ containing_scope = find_containing_scope_by_name(remark.line)
111
+ containing_scope ||= find_containing_scope_position(remark.line,
170
112
  nodes_with_positions)
171
113
 
172
114
  # Check if this is an informal proposition tag (IP\d+)
173
115
  if tag.match?(/^IP\d+$/)
174
116
  scope = containing_scope
175
117
  if scope.nil?
176
- scope = find_scope_by_source_text(remark[:line])
118
+ scope = find_scope_by_source_text(remark.line)
177
119
  end
178
120
  if scope && supports_informal_propositions?(scope)
179
121
  target = create_or_find_informal_proposition(scope, tag)
@@ -223,7 +165,7 @@ module Expressir
223
165
  # Special handling for remarks inside WHERE clauses
224
166
  if target.nil? && supports_where_rules?(containing_scope)
225
167
  target = find_target_in_where_clause(containing_scope, tag,
226
- remark[:line])
168
+ remark.line)
227
169
  end
228
170
 
229
171
  # Only fall back to schema prefix if NOT inside a function/rule/procedure
@@ -273,9 +215,8 @@ module Expressir
273
215
  end
274
216
 
275
217
  if target
276
- add_remark(target, remark[:text], format: remark[:format],
277
- tag: remark[:tag])
278
- @attached_spans << remark[:position]
218
+ add_remark(target, remark.text, format: remark.format, tag: remark.tag)
219
+ @attached_spans << remark.position
279
220
  end
280
221
  end
281
222
  end
@@ -376,7 +317,7 @@ module Expressir
376
317
  return schema if schema.id == scope_name
377
318
 
378
319
  %i[functions procedures rules entities types].each do |decl_type|
379
- collection = schema.send(decl_type)
320
+ collection = schema.public_send(decl_type)
380
321
  next unless collection.is_a?(Array)
381
322
 
382
323
  found = collection.find { |n| n.id == scope_name }
@@ -390,30 +331,22 @@ module Expressir
390
331
  def find_node_in_scope(scope, tag)
391
332
  return nil unless scope
392
333
 
393
- # Search within the scope for a node with the given tag/id
394
- # Check all collections that might contain nodes with ids
395
- %i[constants types variables parameters statements
396
- attributes derived_attributes inverse_attributes
397
- where_rules unique_rules informal_propositions].each do |attr|
398
- collection = safe_get_collection(scope, attr)
399
- next unless collection
400
-
334
+ collections_on(scope).each do |collection|
401
335
  collection.each do |item|
336
+ next unless item.is_a?(Model::HasRemarks)
402
337
  return item if item.id == tag
403
- rescue NoMethodError
404
- next
405
338
  end
406
339
  end
407
340
 
408
341
  # Search inside types for enumeration items
409
- types = safe_get_collection(scope, :types)
342
+ types = get_collection(scope, :types)
410
343
  types&.each do |type|
411
344
  result = find_enumeration_item_in_type(type, tag)
412
345
  return result if result
413
346
  end
414
347
 
415
348
  # Search inside statements for nested items (alias, repeat, query)
416
- statements = safe_get_collection(scope, :statements)
349
+ statements = get_collection(scope, :statements)
417
350
  statements&.each do |stmt|
418
351
  result = find_node_in_statement(stmt, tag)
419
352
  return result if result
@@ -448,34 +381,51 @@ module Expressir
448
381
  nil
449
382
  end
450
383
 
384
+ # Expression and statement child attributes for QueryExpression search.
385
+ # Targeted traversal prevents over-matching on unrelated model attributes.
386
+ EXPRESSION_CHILDREN = {
387
+ Model::Expressions::BinaryExpression => %i[operand1 operand2],
388
+ Model::Expressions::UnaryExpression => %i[operand],
389
+ Model::Expressions::QueryExpression => %i[expression aggregate_source],
390
+ Model::Expressions::AggregateInitializerItem => %i[expression
391
+ repetition],
392
+ Model::Expressions::Interval => %i[low item high],
393
+ Model::Expressions::FunctionCall => %i[parameters],
394
+ Model::Expressions::EntityConstructor => %i[parameters],
395
+ Model::Expressions::AggregateInitializer => %i[items],
396
+ Model::Statements::Assignment => %i[expression],
397
+ Model::Statements::If => %i[expression],
398
+ Model::Statements::Case => %i[expression],
399
+ Model::Statements::CaseAction => %i[expression],
400
+ Model::Statements::Repeat => %i[while_expression until_expression],
401
+ }.freeze
402
+
451
403
  def find_query_in_expression(node, tag, visited = Set.new)
452
404
  return nil unless node
405
+ return nil unless node.is_a?(Model::ModelElement)
453
406
  return nil if visited.include?(node.object_id)
454
407
 
455
408
  visited.add(node.object_id)
456
409
 
457
- # Check if this node is a QueryExpression with matching id
458
410
  if node.is_a?(Model::Expressions::QueryExpression) && node.id == tag
459
411
  return node
460
412
  end
461
413
 
462
- # Recursively search expression attributes
463
- %i[expression operand left right condition aggregate
464
- query_expression repeat_control].each do |attr|
465
- child = safe_send(node, attr)
466
- next unless child
467
-
468
- result = find_query_in_expression(child, tag, visited)
469
- return result if result
470
- end
414
+ attrs = EXPRESSION_CHILDREN[node.class]
415
+ return nil unless attrs
471
416
 
472
- # Search in arrays
473
- %i[expressions operands parameters arguments].each do |attr|
474
- children = safe_send(node, attr)
475
- next unless children.is_a?(Array)
417
+ attrs.each do |attr|
418
+ value = node.public_send(attr)
419
+ case value
420
+ when Array
421
+ value.each do |val|
422
+ next unless val.is_a?(Model::ModelElement)
476
423
 
477
- children.each do |child|
478
- result = find_query_in_expression(child, tag, visited)
424
+ result = find_query_in_expression(val, tag, visited)
425
+ return result if result
426
+ end
427
+ when Model::ModelElement
428
+ result = find_query_in_expression(value, tag, visited)
479
429
  return result if result
480
430
  end
481
431
  end
@@ -505,7 +455,7 @@ module Expressir
505
455
  return nil unless collection_attr
506
456
 
507
457
  # First try to find in containing scope
508
- collection = safe_get_collection(containing_scope, collection_attr)
458
+ collection = get_collection(containing_scope, collection_attr)
509
459
  if collection
510
460
  found = collection.find { |item| item.is_a?(Model::ModelElement) && item.id == id }
511
461
  return found if found
@@ -525,7 +475,7 @@ module Expressir
525
475
  def find_target_in_where_clause(scope, tag, remark_line)
526
476
  return nil unless supports_where_rules?(scope)
527
477
 
528
- where_rules = safe_get_collection(scope, :where_rules)
478
+ where_rules = get_collection(scope, :where_rules)
529
479
  return nil unless where_rules&.any?
530
480
 
531
481
  # Search source text for WHERE clause containing this remark
@@ -652,24 +602,21 @@ module Expressir
652
602
  nil
653
603
  end
654
604
 
605
+ COLLECTION_ACCESSOR = {
606
+ Expressir::Model::Declarations::Entity => lambda(&:entities),
607
+ Expressir::Model::Declarations::Type => lambda(&:types),
608
+ Expressir::Model::Declarations::Rule => lambda(&:rules),
609
+ }.freeze
610
+
655
611
  def find_node_by_type_and_name(node_class, name)
656
612
  return nil unless @model && name
657
613
 
658
- # Search through all schemas
614
+ accessor = COLLECTION_ACCESSOR[node_class]
615
+ return nil unless accessor
616
+
659
617
  @model.schemas.each do |schema|
660
- collection = case node_class.name
661
- when "Expressir::Model::Declarations::Entity"
662
- schema.entities
663
- when "Expressir::Model::Declarations::Type"
664
- schema.types
665
- when "Expressir::Model::Declarations::Rule"
666
- schema.rules
667
- end
668
-
669
- if collection
670
- found = collection.find { |n| n.id == name }
671
- return found if found
672
- end
618
+ found = accessor.call(schema)&.find { |n| n.id == name }
619
+ return found if found
673
620
  end
674
621
 
675
622
  nil
@@ -800,28 +747,26 @@ module Expressir
800
747
  end
801
748
 
802
749
  def attach_untagged_remarks(remarks, nodes_with_positions)
803
- untagged = remarks.reject { |r| r[:tag] }
750
+ untagged = remarks.reject(&:tag)
804
751
  return unless untagged.any?
805
752
 
806
753
  untagged.each do |remark|
807
- next if @attached_spans.include?(remark[:position])
754
+ next if @attached_spans.include?(remark.position)
808
755
 
809
- if end_scope_line?(remark[:line])
756
+ if end_scope_line?(remark.line)
810
757
  matched_node = find_node_for_end_scope_remark(remark,
811
758
  nodes_with_positions)
812
759
  if matched_node
813
- add_remark(matched_node, remark[:text], format: remark[:format],
814
- tag: nil)
815
- @attached_spans << remark[:position]
760
+ add_remark(matched_node, remark.text, format: remark.format, tag: nil)
761
+ @attached_spans << remark.position
816
762
  next
817
763
  end
818
764
  end
819
765
 
820
766
  matched_node = find_nearest_node(remark, nodes_with_positions)
821
767
  if matched_node
822
- add_remark(matched_node, remark[:text], format: remark[:format],
823
- tag: nil)
824
- @attached_spans << remark[:position]
768
+ add_remark(matched_node, remark.text, format: remark.format, tag: nil)
769
+ @attached_spans << remark.position
825
770
  end
826
771
  end
827
772
  end
@@ -839,7 +784,7 @@ module Expressir
839
784
  end
840
785
 
841
786
  def find_node_for_end_scope_remark(remark, nodes)
842
- line_content = get_line_content(remark[:line])
787
+ line_content = get_line_content(remark.line)
843
788
 
844
789
  node_type = case line_content
845
790
  when /END_SCHEMA/i then Model::Declarations::Schema
@@ -854,8 +799,8 @@ module Expressir
854
799
 
855
800
  matching_nodes = nodes.select do |n|
856
801
  n[:node].is_a?(node_type) &&
857
- (n[:end_line] == remark[:line] ||
858
- (n[:end_line] && n[:end_line] <= remark[:line] && n[:end_line] >= remark[:line] - 2))
802
+ (n[:end_line] == remark.line ||
803
+ (n[:end_line] && n[:end_line] <= remark.line && n[:end_line] >= remark.line - 2))
859
804
  end
860
805
 
861
806
  matching_nodes.first&.dig(:node) || find_node_by_type(nodes, node_type)
@@ -872,7 +817,7 @@ module Expressir
872
817
  # Only add remarks to nodes that support them
873
818
  if supports_remarks?(node)
874
819
  # Always add to remarks attribute (for types that have it)
875
- if node.respond_to?(:remarks) && node.respond_to?(:remarks=)
820
+ if node_has_remarks?(node)
876
821
  node.remarks ||= []
877
822
  node.remarks << text
878
823
  end
@@ -891,6 +836,10 @@ module Expressir
891
836
  obj.is_a?(Model::ModelElement)
892
837
  end
893
838
 
839
+ def node_has_remarks?(obj)
840
+ obj.is_a?(Model::HasRemarks)
841
+ end
842
+
894
843
  # Types that include HasRemarkItems can have remark_items
895
844
  def supports_remark_items?(obj)
896
845
  obj.is_a?(Model::HasRemarkItems)
@@ -959,22 +908,20 @@ module Expressir
959
908
  def calculate_children_end_line(node)
960
909
  children_end_lines = []
961
910
 
962
- # Check standard children attribute
963
- children = safe_get_collection(node, :children)
964
- children&.each do |child|
965
- if child.is_a?(Model::ModelElement) && child.source_offset && child.source
966
- child_end_line = get_line_number(child.source_offset + child.source.length)
967
- children_end_lines << child_end_line
911
+ # Check computed children (Schema, ExpFile have a children method)
912
+ if node.is_a?(Model::Declarations::Schema)
913
+ Array(node.children).each do |child|
914
+ if child.is_a?(Model::ModelElement) && child.source_offset && child.source
915
+ children_end_lines << get_line_number(child.source_offset + child.source.length)
916
+ end
968
917
  end
969
918
  end
970
919
 
971
- # Check specific child collections
972
- CHILD_COLLECTION_ATTRIBUTES.each do |attr|
973
- collection = safe_get_collection(node, attr)
974
- collection&.each do |child|
920
+ # Visit declared collections from type registry
921
+ collections_on(node).each do |collection|
922
+ collection.each do |child|
975
923
  if child.is_a?(Model::ModelElement) && child.source_offset && child.source
976
- child_end_line = get_line_number(child.source_offset + child.source.length)
977
- children_end_lines << child_end_line
924
+ children_end_lines << get_line_number(child.source_offset + child.source.length)
978
925
  end
979
926
  end
980
927
  end
@@ -983,14 +930,14 @@ module Expressir
983
930
  end
984
931
 
985
932
  def collect_children(node, result, visited)
986
- children = safe_get_collection(node, :children)
987
- children&.each do |child|
988
- collect_nodes_with_positions(child, result, visited)
933
+ if node.is_a?(Model::Declarations::Schema)
934
+ Array(node.children).each do |child|
935
+ collect_nodes_with_positions(child, result, visited)
936
+ end
989
937
  end
990
938
 
991
- CHILD_COLLECTION_ATTRIBUTES.each do |attr|
992
- collection = safe_get_collection(node, attr)
993
- collection&.each do |item|
939
+ collections_on(node).each do |collection|
940
+ collection.each do |item|
994
941
  collect_nodes_with_positions(item, result, visited)
995
942
  end
996
943
  end
@@ -1007,7 +954,7 @@ module Expressir
1007
954
  end
1008
955
 
1009
956
  def find_nearest_node(remark, nodes)
1010
- remark_line = remark[:line]
957
+ remark_line = remark.line
1011
958
 
1012
959
  # For tail remarks, prefer nodes that START on the same line
1013
960
  # This handles cases like: "attr : STRING; -- tail remark"
@@ -1089,23 +1036,23 @@ module Expressir
1089
1036
  obj.is_a?(Model::HasWhereRules)
1090
1037
  end
1091
1038
 
1092
- # Safe accessor methods that return nil instead of NoMethodError
1039
+ # Type-driven collection access returns all collections for a node's type.
1040
+ def collections_on(node)
1041
+ attrs = COLLECTION_REGISTRY[node.class]
1042
+ return [] unless attrs
1093
1043
 
1094
- def safe_send(obj, method)
1095
- return nil unless obj
1096
-
1097
- obj.send(method)
1098
- rescue NoMethodError
1099
- nil
1044
+ attrs.filter_map do |attr|
1045
+ col = node.public_send(attr)
1046
+ col if col.is_a?(Array)
1047
+ end
1100
1048
  end
1101
1049
 
1102
- def safe_get_collection(obj, attr)
1103
- return nil unless obj
1050
+ # Type-driven single collection access — returns one named collection if the type has it.
1051
+ def get_collection(node, attr_name)
1052
+ return nil unless COLLECTION_REGISTRY[node.class]&.include?(attr_name)
1104
1053
 
1105
- collection = obj.send(attr)
1054
+ collection = node.public_send(attr_name)
1106
1055
  collection if collection.is_a?(Array)
1107
- rescue NoMethodError
1108
- nil
1109
1056
  end
1110
1057
 
1111
1058
  def safe_find(model, path)
@@ -1118,10 +1065,9 @@ module Expressir
1118
1065
 
1119
1066
  def safe_reset_children_by_id(obj)
1120
1067
  return unless obj
1068
+ return unless obj.is_a?(Model::ScopeContainer)
1121
1069
 
1122
1070
  obj.reset_children_by_id
1123
- rescue NoMethodError
1124
- nil
1125
1071
  end
1126
1072
  end
1127
1073
  end