servactory 3.1.0.rc2 → 3.1.0.rc4

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +7 -2
  4. data/config/locales/ja.yml +134 -0
  5. data/lib/generators/README.md +2 -2
  6. data/lib/generators/servactory/extension/USAGE +1 -1
  7. data/lib/generators/servactory/install/USAGE +2 -2
  8. data/lib/generators/servactory/rspec/USAGE +4 -4
  9. data/lib/generators/servactory/rspec/rspec_generator.rb +2 -2
  10. data/lib/generators/servactory/service/USAGE +5 -5
  11. data/lib/servactory/actions/collection.rb +21 -5
  12. data/lib/servactory/actions/stages/collection.rb +19 -1
  13. data/lib/servactory/actions/tools/rules.rb +1 -1
  14. data/lib/servactory/actions/tools/runner.rb +1 -1
  15. data/lib/servactory/configuration/collection_mode/class_names_collection.rb +1 -1
  16. data/lib/servactory/configuration/config.rb +16 -0
  17. data/lib/servactory/configuration/configurable.rb +7 -4
  18. data/lib/servactory/configuration/factory.rb +30 -3
  19. data/lib/servactory/configuration/hash_mode/class_names_collection.rb +5 -1
  20. data/lib/servactory/configuration/option_helpers/option_helpers_collection.rb +156 -29
  21. data/lib/servactory/context/warehouse/inputs.rb +1 -1
  22. data/lib/servactory/context/workspace/inputs.rb +2 -2
  23. data/lib/servactory/context/workspace/internals.rb +2 -2
  24. data/lib/servactory/context/workspace/outputs.rb +2 -2
  25. data/lib/servactory/context/workspace.rb +7 -7
  26. data/lib/servactory/info/builder.rb +4 -3
  27. data/lib/servactory/inputs/dsl.rb +5 -1
  28. data/lib/servactory/inputs/input.rb +1 -2
  29. data/lib/servactory/inputs/tools/validation.rb +10 -18
  30. data/lib/servactory/internals/dsl.rb +7 -1
  31. data/lib/servactory/maintenance/attributes/base.rb +4 -1
  32. data/lib/servactory/maintenance/attributes/collection.rb +61 -6
  33. data/lib/servactory/maintenance/options/collection.rb +71 -30
  34. data/lib/servactory/maintenance/options/option.rb +3 -12
  35. data/lib/servactory/maintenance/options/registrar.rb +3 -13
  36. data/lib/servactory/maintenance/validations/checkers/type.rb +23 -5
  37. data/lib/servactory/maintenance/validations/performer.rb +12 -23
  38. data/lib/servactory/outputs/dsl.rb +7 -1
  39. data/lib/servactory/result.rb +21 -8
  40. data/lib/servactory/test_kit/rspec/helpers/exception_validator.rb +112 -0
  41. data/lib/servactory/test_kit/rspec/helpers/input_validator.rb +3 -1
  42. data/lib/servactory/test_kit/rspec/helpers/mock_executor.rb +125 -125
  43. data/lib/servactory/test_kit/rspec/helpers/service_inputs_guard.rb +98 -0
  44. data/lib/servactory/test_kit/rspec/helpers/service_inputs_matcher.rb +75 -0
  45. data/lib/servactory/test_kit/rspec/helpers/service_mock_builder.rb +185 -50
  46. data/lib/servactory/test_kit/rspec/helpers/service_mock_config.rb +45 -10
  47. data/lib/servactory/test_kit/rspec/helpers/wrap_block_adapter.rb +74 -0
  48. data/lib/servactory/test_kit/rspec/helpers.rb +4 -0
  49. data/lib/servactory/test_kit/rspec/matchers/base/attribute_matcher.rb +72 -16
  50. data/lib/servactory/test_kit/rspec/matchers/base/message_expectation.rb +194 -0
  51. data/lib/servactory/test_kit/rspec/matchers/base/submatcher.rb +8 -0
  52. data/lib/servactory/test_kit/rspec/matchers/concerns/option_helper_rules.rb +148 -0
  53. data/lib/servactory/test_kit/rspec/matchers/concerns/proc_message.rb +117 -0
  54. data/lib/servactory/test_kit/rspec/matchers/concerns/required_message.rb +92 -0
  55. data/lib/servactory/test_kit/rspec/matchers/have_service_input_matcher.rb +27 -4
  56. data/lib/servactory/test_kit/rspec/matchers/have_service_internal_matcher.rb +10 -3
  57. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/required_submatcher.rb +58 -23
  58. data/lib/servactory/test_kit/rspec/matchers/submatchers/input/valid_with_submatcher.rb +124 -59
  59. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/message_submatcher.rb +133 -39
  60. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/must_submatcher.rb +192 -17
  61. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/target_submatcher.rb +4 -2
  62. data/lib/servactory/test_kit/rspec/matchers/submatchers/shared/types_submatcher.rb +6 -0
  63. data/lib/servactory/test_kit/rspec/matchers.rb +15 -0
  64. data/lib/servactory/tool_kit/dynamic_options/consists_of.rb +34 -11
  65. data/lib/servactory/tool_kit/dynamic_options/format.rb +3 -4
  66. data/lib/servactory/tool_kit/dynamic_options/inclusion.rb +3 -3
  67. data/lib/servactory/tool_kit/dynamic_options/max.rb +3 -3
  68. data/lib/servactory/tool_kit/dynamic_options/min.rb +3 -3
  69. data/lib/servactory/tool_kit/dynamic_options/multiple_of.rb +35 -7
  70. data/lib/servactory/tool_kit/dynamic_options/must.rb +2 -3
  71. data/lib/servactory/tool_kit/dynamic_options/schema.rb +40 -25
  72. data/lib/servactory/tool_kit/dynamic_options/target.rb +4 -4
  73. data/lib/servactory/utils.rb +13 -3
  74. data/lib/servactory/version.rb +1 -1
  75. data/lib/servactory.rb +3 -4
  76. metadata +20 -4
@@ -9,7 +9,20 @@ module Servactory
9
9
  #
10
10
  # OptionHelpersCollection provides a unified interface for storing and
11
11
  # querying Helper instances used by the configuration system.
12
- # It wraps a Set to ensure uniqueness and delegates common enumeration methods.
12
+ # It keeps helpers in an insertion-ordered Hash keyed by helper name,
13
+ # so every name resolves to exactly one helper.
14
+ #
15
+ # ## Built-in Helpers
16
+ #
17
+ # Helpers passed to the constructor are built-in: their names are
18
+ # reserved, and only `replace` may swap them (used to rebind built-in
19
+ # dynamic options to the configuration of a subclass).
20
+ #
21
+ # ## Inheritance
22
+ #
23
+ # A collection accepts one helper per name. A duplicate, made when
24
+ # a service class is inherited, may replace a helper registered before
25
+ # duplication in place without affecting the original.
13
26
  #
14
27
  # ## Usage
15
28
  #
@@ -17,70 +30,184 @@ module Servactory
17
30
  # to manage registered option helpers:
18
31
  #
19
32
  # ```ruby
20
- # collection = OptionHelpersCollection.new
21
- # collection << helper
33
+ # collection = OptionHelpersCollection.new(builtin_helpers)
34
+ # collection.register(helper) # => :registered
35
+ # collection << other_helper # => collection, or raises ArgumentError
36
+ # collection.merge([third_helper]) # => collection, or raises ArgumentError
22
37
  #
23
38
  # collection.find_by(name: :must) # => helper instance
24
39
  # collection.dynamic_options # => filtered OptionHelpersCollection
25
40
  # ```
26
41
  #
42
+ # ## Enumeration
43
+ #
44
+ # The collection includes Enumerable and yields helpers in registration
45
+ # order. Enumerable methods return plain values rather than collections:
46
+ # `filter` and `map` return Arrays, `find` returns a helper or nil, and
47
+ # `each_with_object` returns the memo object. Use `dynamic_options`
48
+ # to get a filtered OptionHelpersCollection.
49
+ #
27
50
  # ## Performance
28
51
  #
29
- # The collection uses memoization for frequently accessed data:
30
- # - `helpers_index` - cached hash for O(1) lookups by name
52
+ # Registration and lookup by name are O(1) Hash operations without
53
+ # cached indexes, so helpers registered after attributes are declared
54
+ # are visible to subsequent declarations.
31
55
  #
32
56
  class OptionHelpersCollection
33
- extend Forwardable
57
+ include Enumerable
34
58
 
35
- def_delegators :@collection,
36
- :<<,
37
- :filter,
38
- :each_with_object,
39
- :map,
40
- :merge
41
-
42
- # Initializes the collection with an optional pre-built Set.
59
+ # Initializes the collection with built-in helpers.
43
60
  #
44
- # @param collection [Set] initial set of helpers
61
+ # @param builtin_helpers [Enumerable<Maintenance::Options::Helper>] helpers whose names are reserved
45
62
  # @return [OptionHelpersCollection]
46
- def initialize(collection = Set.new)
47
- @collection = collection
63
+ def initialize(builtin_helpers = [])
64
+ @helpers = builtin_helpers.to_h { |helper| [helper.name, helper] }
65
+ @builtin_names = @helpers.keys.to_set.freeze
66
+ @own_names = Set.new
48
67
  end
49
68
 
50
- # Duplicates the collection, resetting memoized caches.
69
+ # Duplicates the collection so that registrations do not leak into the original.
70
+ #
71
+ # Helpers of the original may be replaced once in the duplicate.
51
72
  #
52
73
  # @param original [OptionHelpersCollection] the collection being duplicated
53
74
  # @return [void]
54
75
  def initialize_dup(original)
55
76
  super
56
- @collection = original.instance_variable_get(:@collection).dup
57
- @helpers_index = nil
77
+ @helpers = original.instance_variable_get(:@helpers).dup
78
+ @own_names = Set.new
79
+ end
80
+
81
+ # Iterates over helpers in registration order.
82
+ #
83
+ # @yieldparam helper [Maintenance::Options::Helper] each registered helper
84
+ # @return [OptionHelpersCollection, Enumerator] self, or an enumerator without a block
85
+ def each(&block)
86
+ return enum_for(:each) unless block_given?
87
+
88
+ @helpers.each_value(&block)
89
+ self
90
+ end
91
+
92
+ # Registers a helper under its name.
93
+ #
94
+ # A helper with a new name is appended; a helper replacing one registered
95
+ # before duplication keeps its position. The helpers are left unchanged
96
+ # unless the result is `:registered`.
97
+ #
98
+ # @param helper [Maintenance::Options::Helper] the helper to register
99
+ # @return [Symbol] `:registered`, `:skipped` when this very helper is
100
+ # already registered (the name then counts as registered by this collection
101
+ # itself), `:reserved` when the name belongs to a built-in helper,
102
+ # or `:duplicated` when this collection already registered the name itself
103
+ # since it was created or duplicated
104
+ def register(helper)
105
+ name = helper.name
106
+
107
+ if @helpers[name].equal?(helper)
108
+ @own_names << name
109
+ return :skipped
110
+ end
111
+
112
+ return :reserved if @builtin_names.include?(name)
113
+ return :duplicated if @own_names.include?(name)
114
+
115
+ @own_names << name
116
+ @helpers[name] = helper
117
+ :registered
118
+ end
119
+
120
+ # Registers a helper under its name, raising when the name is rejected.
121
+ #
122
+ # Follows `register`: a helper registered before duplication may be
123
+ # replaced once. Re-adding this very helper leaves the helpers unchanged
124
+ # but still counts as this collection registering the name itself, so
125
+ # after re-adding an inherited helper a different helper with that name
126
+ # raises instead of replacing it.
127
+ #
128
+ # @param helper [Maintenance::Options::Helper] the helper to register
129
+ # @return [OptionHelpersCollection] self
130
+ # @raise [ArgumentError] when the name belongs to a built-in helper
131
+ # or this collection already registered the name itself
132
+ def <<(helper)
133
+ case register(helper)
134
+ when :reserved
135
+ raise_error_about_reserved_name_with(helper.name)
136
+ when :duplicated
137
+ raise_error_about_duplicated_name_with(helper.name)
138
+ end
139
+
140
+ self
141
+ end
142
+
143
+ # Registers every helper of the given enumerables in order with `<<`.
144
+ #
145
+ # Helpers preceding a rejected one stay registered.
146
+ #
147
+ # A built-in helper coming from another service's collection counts as
148
+ # a reserved name unless it is the very helper of this collection. Each
149
+ # service rebinds the built-in dynamic options (`consists_of`, `schema`)
150
+ # to its own configuration, so merging another service's whole
151
+ # collection raises.
152
+ #
153
+ # @param enums [Array<Enumerable<Maintenance::Options::Helper>>] the helpers to register
154
+ # @return [OptionHelpersCollection] self
155
+ # @raise [ArgumentError] when `<<` rejects a helper
156
+ def merge(*enums)
157
+ enums.each { |helpers| helpers.each { |helper| self << helper } }
158
+ self
58
159
  end
59
160
 
60
161
  # Returns a new collection containing only dynamic option helpers.
61
162
  #
62
163
  # @return [OptionHelpersCollection] filtered collection of dynamic helpers
63
164
  def dynamic_options
64
- OptionHelpersCollection.new(filter(&:dynamic_option?))
165
+ each_with_object(OptionHelpersCollection.new) do |helper, collection|
166
+ collection.register(helper) if helper.dynamic_option?
167
+ end
65
168
  end
66
169
 
67
- # Finds a helper by its name using indexed lookup.
170
+ # Finds a helper by its name with a direct Hash lookup.
68
171
  #
69
172
  # @param name [Symbol] the helper name to find
70
173
  # @return [Maintenance::Options::Helper, nil] the found helper or nil
71
174
  def find_by(name:)
72
- helpers_index[name]
175
+ @helpers[name]
176
+ end
177
+
178
+ # Replaces a built-in helper by name with a new one, keeping its position.
179
+ #
180
+ # Names that do not belong to a built-in helper are ignored.
181
+ #
182
+ # @param name [Symbol] the built-in helper name to replace
183
+ # @param with [Maintenance::Options::Helper] the replacement helper
184
+ # @return [void]
185
+ def replace(name:, with:)
186
+ return unless @builtin_names.include?(name)
187
+
188
+ @helpers[name] = with
73
189
  end
74
190
 
75
191
  private
76
192
 
77
- # Builds and caches a hash index for O(1) helper lookups.
193
+ # Raises an error about a name that belongs to a built-in helper.
78
194
  #
79
- # @return [Hash{Symbol => Maintenance::Options::Helper}] helper names mapped to Helper instances
80
- def helpers_index
81
- @helpers_index ||= each_with_object({}) do |helper, index|
82
- index[helper.name] = helper
83
- end
195
+ # @param name [Symbol] the rejected helper name
196
+ # @raise [ArgumentError] always
197
+ def raise_error_about_reserved_name_with(name)
198
+ raise ArgumentError,
199
+ "The `#{name}` option helper name is reserved by a built-in option helper. " \
200
+ "See configuration example here: https://servactory.com/guide/configuration"
201
+ end
202
+
203
+ # Raises an error about a name this collection already registered itself.
204
+ #
205
+ # @param name [Symbol] the rejected helper name
206
+ # @raise [ArgumentError] always
207
+ def raise_error_about_duplicated_name_with(name)
208
+ raise ArgumentError,
209
+ "The `#{name}` option helper is already registered in this collection. " \
210
+ "See configuration example here: https://servactory.com/guide/configuration"
84
211
  end
85
212
  end
86
213
  end
@@ -45,7 +45,7 @@ module Servactory
45
45
  # @param _args [Array] Method arguments (unused)
46
46
  # @return [Object] Input value or predicate result
47
47
  def method_missing(name, *_args)
48
- predicate = @context.config.predicate_methods_enabled && name.end_with?("?")
48
+ predicate = @context.class.config.predicate_methods_enabled && name.end_with?("?")
49
49
 
50
50
  input_name = predicate ? name.to_s.chomp("?").to_sym : name
51
51
 
@@ -38,7 +38,7 @@ module Servactory
38
38
  private
39
39
 
40
40
  def fetch_with(name:, &_block)
41
- predicate = @context.config.predicate_methods_enabled && name.end_with?("?")
41
+ predicate = @context.class.config.predicate_methods_enabled && name.end_with?("?")
42
42
 
43
43
  input_name = predicate ? name.to_s.chomp("?").to_sym : name
44
44
 
@@ -67,7 +67,7 @@ module Servactory
67
67
  def resolve_input(name)
68
68
  return true if @collection_of_inputs.find_by(name:)
69
69
 
70
- @context.config.predicate_methods_enabled &&
70
+ @context.class.config.predicate_methods_enabled &&
71
71
  name.to_s.end_with?("?") &&
72
72
  @collection_of_inputs.find_by(name: name.to_s.chomp("?").to_sym)
73
73
  end
@@ -60,7 +60,7 @@ module Servactory
60
60
  end
61
61
 
62
62
  def fetch_with(name:, &_block)
63
- predicate = @context.config.predicate_methods_enabled && name.end_with?("?")
63
+ predicate = @context.class.config.predicate_methods_enabled && name.end_with?("?")
64
64
 
65
65
  internal_name = predicate ? name.to_s.chomp("?").to_sym : name
66
66
  internal = @collection_of_internals.find_by(name: internal_name)
@@ -89,7 +89,7 @@ module Servactory
89
89
  def resolve_internal(name)
90
90
  return true if @collection_of_internals.find_by(name:)
91
91
 
92
- @context.config.predicate_methods_enabled &&
92
+ @context.class.config.predicate_methods_enabled &&
93
93
  name.to_s.end_with?("?") &&
94
94
  @collection_of_internals.find_by(name: name.to_s.chomp("?").to_sym)
95
95
  end
@@ -52,7 +52,7 @@ module Servactory
52
52
  end
53
53
 
54
54
  def fetch_with(name:, &_block)
55
- predicate = @context.config.predicate_methods_enabled && name.end_with?("?")
55
+ predicate = @context.class.config.predicate_methods_enabled && name.end_with?("?")
56
56
 
57
57
  output_name = predicate ? name.to_s.chomp("?").to_sym : name
58
58
  output = @collection_of_outputs.find_by(name: output_name)
@@ -67,7 +67,7 @@ module Servactory
67
67
  def resolve_output(name)
68
68
  return true if @collection_of_outputs.find_by(name:)
69
69
 
70
- @context.config.predicate_methods_enabled &&
70
+ @context.class.config.predicate_methods_enabled &&
71
71
  name.to_s.end_with?("?") &&
72
72
  @collection_of_outputs.find_by(name: name.to_s.chomp("?").to_sym)
73
73
  end
@@ -8,7 +8,7 @@ module Servactory
8
8
 
9
9
  def initialize(context)
10
10
  @class_name = context.class.name
11
- @i18n_root_key = context.config.i18n_root_key
11
+ @i18n_root_key = context.class.config.i18n_root_key
12
12
  end
13
13
 
14
14
  def translate(key, **options)
@@ -42,11 +42,11 @@ module Servactory
42
42
  end
43
43
 
44
44
  def success!
45
- raise config.success_class.new(context: self)
45
+ raise self.class.config.success_class.new(context: self)
46
46
  end
47
47
 
48
48
  def fail_input!(input_name, message:, meta: nil)
49
- raise config.input_exception_class.new(
49
+ raise self.class.config.input_exception_class.new(
50
50
  context: self,
51
51
  input_name:,
52
52
  message:,
@@ -55,7 +55,7 @@ module Servactory
55
55
  end
56
56
 
57
57
  def fail_internal!(internal_name, message:, meta: nil)
58
- raise config.internal_exception_class.new(
58
+ raise self.class.config.internal_exception_class.new(
59
59
  context: self,
60
60
  internal_name:,
61
61
  message:,
@@ -64,7 +64,7 @@ module Servactory
64
64
  end
65
65
 
66
66
  def fail_output!(output_name, message:, meta: nil)
67
- raise config.output_exception_class.new(
67
+ raise self.class.config.output_exception_class.new(
68
68
  context: self,
69
69
  output_name:,
70
70
  message:,
@@ -73,7 +73,7 @@ module Servactory
73
73
  end
74
74
 
75
75
  def fail!(type = :base, message:, meta: nil)
76
- raise config.failure_class.new(type:, message:, meta:)
76
+ raise self.class.config.failure_class.new(type:, message:, meta:)
77
77
  end
78
78
 
79
79
  def fail_result!(service_result)
@@ -118,7 +118,7 @@ module Servactory
118
118
  end
119
119
 
120
120
  def call
121
- raise config.failure_class.new(
121
+ raise self.class.config.failure_class.new(
122
122
  type: :base,
123
123
  message: servactory_service_info.translate(
124
124
  "methods.call.not_used"
@@ -26,7 +26,7 @@ module Servactory
26
26
 
27
27
  private
28
28
 
29
- # rubocop:disable Metrics/MethodLength
29
+ # rubocop:disable-next Metrics/MethodLength
30
30
  def build_all_attributes(inputs:, internals:, outputs:, stages:, config:)
31
31
  build_input_attributes_with(
32
32
  collection: inputs,
@@ -45,7 +45,6 @@ module Servactory
45
45
 
46
46
  build_action_stages_with(collection: stages)
47
47
  end
48
- # rubocop:enable Metrics/MethodLength
49
48
 
50
49
  def build_input_attributes_with(collection:, dynamic_options:)
51
50
  @inputs = build_attributes_with(
@@ -142,11 +141,13 @@ module Servactory
142
141
  end
143
142
 
144
143
  def enrich_options_with_common_fields(attribute:, options:)
144
+ types = attribute.collection_of_options.find_by(name: :types)
145
145
  must = attribute.collection_of_options.find_by(name: :must)
146
146
 
147
147
  options.merge(
148
148
  actor: attribute.actor,
149
- types: attribute.types,
149
+ type: { is: types.value, message: types.body[:message] },
150
+ types: types.value,
150
151
  must: must&.value
151
152
  )
152
153
  end
@@ -18,12 +18,16 @@ module Servactory
18
18
  private
19
19
 
20
20
  def input(name, *helpers, **options)
21
- collection_of_inputs << Input.new(
21
+ input = Input.new(
22
22
  name,
23
23
  *helpers,
24
24
  option_helpers: config.input_option_helpers,
25
25
  **options
26
26
  )
27
+
28
+ raise ArgumentError, "[#{self.name}] Input `#{name}` must have the `type` option" if input.types.empty?
29
+
30
+ collection_of_inputs << input
27
31
  end
28
32
 
29
33
  def collection_of_inputs
@@ -52,7 +52,7 @@ module Servactory
52
52
 
53
53
  attr_reader :internal_name
54
54
 
55
- # rubocop:disable Style/KeywordParametersOrder
55
+ # rubocop:disable-next Style/KeywordParametersOrder
56
56
  def initialize(
57
57
  name,
58
58
  *helpers,
@@ -64,7 +64,6 @@ module Servactory
64
64
 
65
65
  super(name, *helpers, option_helpers:, **options)
66
66
  end
67
- # rubocop:enable Style/KeywordParametersOrder
68
67
 
69
68
  def conflict_code
70
69
  @collection_of_options.defined_conflict_code
@@ -29,30 +29,22 @@ module Servactory
29
29
  def process_input(context, warehouse, input)
30
30
  value = warehouse.fetch_input(input.name)
31
31
 
32
- input.options_for_checks.each do |check_key, check_options|
33
- error = process_option(context, input, value, check_key, check_options)
32
+ input.collection_of_options.validations_for_checks.each do |check_key, check_options, validation_class|
33
+ error = process_option(context, input, value, check_key, check_options, validation_class)
34
34
  return error if error.present?
35
35
  end
36
36
 
37
37
  nil
38
38
  end
39
39
 
40
- def process_option(context, input, value, check_key, check_options) # rubocop:disable Metrics/MethodLength
41
- validation_classes = input.collection_of_options.validation_classes
42
- return if validation_classes.empty?
43
-
44
- validation_classes.each do |validation_class|
45
- error_message = validation_class.check(
46
- context:,
47
- attribute: input,
48
- value:,
49
- check_key:,
50
- check_options:
51
- )
52
- return error_message if error_message.present?
53
- end
54
-
55
- nil
40
+ def process_option(context, input, value, check_key, check_options, validation_class)
41
+ validation_class.check(
42
+ context:,
43
+ attribute: input,
44
+ value:,
45
+ check_key:,
46
+ check_options:
47
+ )
56
48
  end
57
49
  end
58
50
  end
@@ -17,12 +17,18 @@ module Servactory
17
17
  private
18
18
 
19
19
  def internal(name, *helpers, **options)
20
- collection_of_internals << Internal.new(
20
+ internal = Internal.new(
21
21
  name,
22
22
  *helpers,
23
23
  option_helpers: config.internal_option_helpers,
24
24
  **options
25
25
  )
26
+
27
+ if internal.types.empty?
28
+ raise ArgumentError, "[#{self.name}] Internal attribute `#{name}` must have the `type` option"
29
+ end
30
+
31
+ collection_of_internals << internal
26
32
  end
27
33
 
28
34
  def collection_of_internals
@@ -39,8 +39,11 @@ module Servactory
39
39
  @collection_of_options = options_registrar.collection
40
40
  end
41
41
 
42
+ # @deprecated Use collection_of_options.validations_for_checks instead.
42
43
  def options_for_checks
43
- @collection_of_options.options_for_checks
44
+ warn "[DEPRECATION] Servactory::Maintenance::Attributes::Base#options_for_checks is deprecated. " \
45
+ "Use collection_of_options.validations_for_checks instead."
46
+ @collection_of_options.send(:check_options_by_key)
44
47
  end
45
48
 
46
49
  def system_name
@@ -13,6 +13,10 @@ module Servactory
13
13
  # how attributes are indexed and filtered (e.g., inputs use `internal_name`
14
14
  # for the `as:` parameter).
15
15
  #
16
+ # An attribute is declared once per name: adding an attribute whose
17
+ # `name` is already declared (e.g., redeclaring a parent's input in
18
+ # a child service) replaces the earlier declaration in place.
19
+ #
16
20
  # ## Usage
17
21
  #
18
22
  # Subclasses are used internally by Input, Internal, and Output DSL modules
@@ -32,16 +36,15 @@ module Servactory
32
36
  #
33
37
  # The collection uses memoization for frequently accessed data:
34
38
  # - `attributes_index` - cached hash for O(1) lookups by name
39
+ # - `declarations_index` - cached hash for O(1) redeclaration checks
35
40
  #
36
41
  class Collection
37
42
  extend Forwardable
38
43
 
39
44
  def_delegators :@collection,
40
- :<<,
41
45
  :filter,
42
46
  :each, :each_with_object,
43
- :map, :to_h,
44
- :merge
47
+ :map, :to_h
45
48
 
46
49
  # Initializes the collection with an optional pre-built Set.
47
50
  #
@@ -51,6 +54,50 @@ module Servactory
51
54
  @collection = collection
52
55
  end
53
56
 
57
+ # Duplicates the collection, resetting memoized caches.
58
+ #
59
+ # @param original [Collection] the collection being duplicated
60
+ # @return [void]
61
+ def initialize_dup(original)
62
+ super
63
+ @collection = original.instance_variable_get(:@collection).dup
64
+ @attributes_index = nil
65
+ @declarations_index = nil
66
+ end
67
+
68
+ # Adds an attribute to the collection, invalidating the index cache.
69
+ #
70
+ # An attribute with an already declared `name` replaces
71
+ # the earlier declaration in place, keeping its position.
72
+ #
73
+ # @param attribute [Object] the attribute to add
74
+ # @return [Collection] self
75
+ def <<(attribute)
76
+ redeclared = declarations_index[attribute.name]
77
+
78
+ if redeclared.nil?
79
+ @collection << attribute
80
+ else
81
+ @collection = Set.new(@collection) { |declared| declared.equal?(redeclared) ? attribute : declared }
82
+ end
83
+
84
+ declarations_index[attribute.name] = attribute
85
+ @attributes_index = nil
86
+ self
87
+ end
88
+
89
+ # Merges attributes from another collection, invalidating the index cache.
90
+ #
91
+ # Each attribute is added as with `<<`, so redeclarations replace
92
+ # the earlier declarations in place.
93
+ #
94
+ # @param other [Collection, Enumerable] attributes to merge
95
+ # @return [Collection] self
96
+ def merge(other)
97
+ other.each { |attribute| self << attribute }
98
+ self
99
+ end
100
+
54
101
  # Returns names of all attributes in the collection.
55
102
  #
56
103
  # @return [Array<Symbol>] list of attribute names
@@ -88,9 +135,17 @@ module Servactory
88
135
  #
89
136
  # @return [Hash{Symbol => Object}] attribute names mapped to attribute instances
90
137
  def attributes_index
91
- @attributes_index ||= each_with_object({}) do |attribute, index|
92
- index[lookup_name(attribute)] = attribute
93
- end
138
+ @attributes_index ||= to_h { |attribute| [lookup_name(attribute), attribute] }
139
+ end
140
+
141
+ # Builds and caches a hash index of declared attributes by `name`.
142
+ #
143
+ # Unlike `attributes_index`, it is keyed by the declared name
144
+ # (the external name for inputs), which identifies a redeclaration.
145
+ #
146
+ # @return [Hash{Symbol => Object}] declared names mapped to attribute instances
147
+ def declarations_index
148
+ @declarations_index ||= to_h { |attribute| [attribute.name, attribute] }
94
149
  end
95
150
 
96
151
  # Returns the name used for indexing and filtering a given attribute.