spec_forge 0.5.0 → 0.7.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/.standard.yml +3 -3
  3. data/CHANGELOG.md +217 -2
  4. data/README.md +162 -25
  5. data/flake.lock +3 -3
  6. data/flake.nix +11 -5
  7. data/lib/spec_forge/attribute/chainable.rb +208 -20
  8. data/lib/spec_forge/attribute/factory.rb +92 -15
  9. data/lib/spec_forge/attribute/faker.rb +62 -13
  10. data/lib/spec_forge/attribute/global.rb +96 -0
  11. data/lib/spec_forge/attribute/literal.rb +15 -2
  12. data/lib/spec_forge/attribute/matcher.rb +186 -11
  13. data/lib/spec_forge/attribute/parameterized.rb +45 -12
  14. data/lib/spec_forge/attribute/regex.rb +55 -5
  15. data/lib/spec_forge/attribute/resolvable.rb +48 -5
  16. data/lib/spec_forge/attribute/resolvable_array.rb +62 -4
  17. data/lib/spec_forge/attribute/resolvable_hash.rb +62 -4
  18. data/lib/spec_forge/attribute/store.rb +65 -0
  19. data/lib/spec_forge/attribute/transform.rb +33 -5
  20. data/lib/spec_forge/attribute/variable.rb +37 -6
  21. data/lib/spec_forge/attribute.rb +166 -66
  22. data/lib/spec_forge/backtrace_formatter.rb +26 -3
  23. data/lib/spec_forge/callbacks.rb +88 -0
  24. data/lib/spec_forge/cli/actions.rb +27 -0
  25. data/lib/spec_forge/cli/command.rb +78 -24
  26. data/lib/spec_forge/cli/docs/generate.rb +72 -0
  27. data/lib/spec_forge/cli/docs.rb +92 -0
  28. data/lib/spec_forge/cli/init.rb +51 -9
  29. data/lib/spec_forge/cli/new.rb +67 -6
  30. data/lib/spec_forge/cli/run.rb +32 -4
  31. data/lib/spec_forge/cli/serve.rb +155 -0
  32. data/lib/spec_forge/cli.rb +26 -7
  33. data/lib/spec_forge/configuration.rb +96 -24
  34. data/lib/spec_forge/context/callbacks.rb +91 -0
  35. data/lib/spec_forge/context/global.rb +72 -0
  36. data/lib/spec_forge/context/store.rb +131 -0
  37. data/lib/spec_forge/context/variables.rb +91 -0
  38. data/lib/spec_forge/context.rb +36 -0
  39. data/lib/spec_forge/core_ext/array.rb +27 -0
  40. data/lib/spec_forge/core_ext/rspec.rb +22 -4
  41. data/lib/spec_forge/documentation/builder.rb +383 -0
  42. data/lib/spec_forge/documentation/document/operation.rb +47 -0
  43. data/lib/spec_forge/documentation/document/parameter.rb +22 -0
  44. data/lib/spec_forge/documentation/document/request_body.rb +24 -0
  45. data/lib/spec_forge/documentation/document/response.rb +39 -0
  46. data/lib/spec_forge/documentation/document/response_body.rb +27 -0
  47. data/lib/spec_forge/documentation/document.rb +48 -0
  48. data/lib/spec_forge/documentation/generators/base.rb +81 -0
  49. data/lib/spec_forge/documentation/generators/openapi/base.rb +100 -0
  50. data/lib/spec_forge/documentation/generators/openapi/error_formatter.rb +149 -0
  51. data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +65 -0
  52. data/lib/spec_forge/documentation/generators/openapi.rb +59 -0
  53. data/lib/spec_forge/documentation/generators.rb +17 -0
  54. data/lib/spec_forge/documentation/loader/cache.rb +138 -0
  55. data/lib/spec_forge/documentation/loader.rb +159 -0
  56. data/lib/spec_forge/documentation/openapi/base.rb +33 -0
  57. data/lib/spec_forge/documentation/openapi/v3_0/example.rb +44 -0
  58. data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +42 -0
  59. data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +175 -0
  60. data/lib/spec_forge/documentation/openapi/v3_0/response.rb +65 -0
  61. data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +80 -0
  62. data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +71 -0
  63. data/lib/spec_forge/documentation/openapi.rb +23 -0
  64. data/lib/spec_forge/documentation.rb +27 -0
  65. data/lib/spec_forge/error.rb +284 -113
  66. data/lib/spec_forge/factory.rb +35 -16
  67. data/lib/spec_forge/filter.rb +86 -0
  68. data/lib/spec_forge/forge.rb +171 -0
  69. data/lib/spec_forge/http/backend.rb +101 -29
  70. data/lib/spec_forge/http/client.rb +23 -13
  71. data/lib/spec_forge/http/request.rb +85 -62
  72. data/lib/spec_forge/http/verb.rb +79 -0
  73. data/lib/spec_forge/http.rb +105 -0
  74. data/lib/spec_forge/loader.rb +244 -0
  75. data/lib/spec_forge/matchers.rb +130 -0
  76. data/lib/spec_forge/normalizer/default.rb +51 -0
  77. data/lib/spec_forge/normalizer/definition.rb +248 -0
  78. data/lib/spec_forge/normalizer/validators.rb +99 -0
  79. data/lib/spec_forge/normalizer.rb +486 -115
  80. data/lib/spec_forge/normalizers/_shared.yml +74 -0
  81. data/lib/spec_forge/normalizers/configuration.yml +23 -0
  82. data/lib/spec_forge/normalizers/constraint.yml +8 -0
  83. data/lib/spec_forge/normalizers/expectation.yml +47 -0
  84. data/lib/spec_forge/normalizers/factory.yml +12 -0
  85. data/lib/spec_forge/normalizers/factory_reference.yml +15 -0
  86. data/lib/spec_forge/normalizers/global_context.yml +28 -0
  87. data/lib/spec_forge/normalizers/spec.yml +50 -0
  88. data/lib/spec_forge/runner/adapter.rb +183 -0
  89. data/lib/spec_forge/runner/callbacks.rb +246 -0
  90. data/lib/spec_forge/runner/debug_proxy.rb +213 -0
  91. data/lib/spec_forge/runner/listener.rb +54 -0
  92. data/lib/spec_forge/runner/metadata.rb +58 -0
  93. data/lib/spec_forge/runner/state.rb +98 -0
  94. data/lib/spec_forge/runner.rb +50 -125
  95. data/lib/spec_forge/spec/expectation/constraint.rb +100 -21
  96. data/lib/spec_forge/spec/expectation.rb +47 -51
  97. data/lib/spec_forge/spec.rb +50 -108
  98. data/lib/spec_forge/type.rb +36 -4
  99. data/lib/spec_forge/version.rb +4 -1
  100. data/lib/spec_forge.rb +168 -76
  101. data/lib/templates/openapi.yml.tt +22 -0
  102. data/lib/templates/redoc.html.tt +28 -0
  103. data/lib/templates/swagger.html.tt +59 -0
  104. metadata +109 -16
  105. data/lib/spec_forge/normalizer/configuration.rb +0 -77
  106. data/lib/spec_forge/normalizer/constraint.rb +0 -47
  107. data/lib/spec_forge/normalizer/expectation.rb +0 -86
  108. data/lib/spec_forge/normalizer/factory.rb +0 -65
  109. data/lib/spec_forge/normalizer/factory_reference.rb +0 -71
  110. data/lib/spec_forge/normalizer/spec.rb +0 -74
  111. data/spec_forge/factories/user.yml +0 -4
  112. data/spec_forge/forge_helper.rb +0 -48
  113. data/spec_forge/specs/users.yml +0 -65
  114. /data/lib/templates/{forge_helper.tt → forge_helper.rb.tt} +0 -0
  115. /data/lib/templates/{new_factory.tt → new_factory.yml.tt} +0 -0
  116. /data/lib/templates/{new_spec.tt → new_spec.yml.tt} +0 -0
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ #
5
+ # Provides custom RSpec matchers for SpecForge
6
+ #
7
+ # This singleton class is responsible for defining custom RSpec matchers
8
+ # that can be used in SpecForge tests. It makes these matchers available
9
+ # through RSpec's matcher system.
10
+ #
11
+ # @example Defining all matchers
12
+ # SpecForge::Matchers.define
13
+ #
14
+ class Matchers
15
+ include Singleton
16
+
17
+ #
18
+ # Defines all custom matchers for use in SpecForge tests
19
+ #
20
+ # This is the main entry point that should be called once during
21
+ # initialization to make all custom matchers available.
22
+ #
23
+ def self.define
24
+ instance.define_all
25
+ end
26
+
27
+ #
28
+ # Defines all available custom matchers
29
+ #
30
+ # This method calls individual definition methods for each
31
+ # custom matcher supported by SpecForge.
32
+ #
33
+ def define_all
34
+ define_forge_and
35
+ define_have_size
36
+ end
37
+
38
+ private
39
+
40
+ #
41
+ # Defines the forge_and matcher for combining multiple matchers.
42
+ # Explicitly has "forge_" prefix to avoid potentially clashing with someone's
43
+ # existing custom matchers.
44
+ #
45
+ # This matcher allows chaining multiple matchers together with an AND
46
+ # condition, requiring all matchers to pass. It provides detailed
47
+ # failure messages showing which specific matchers failed.
48
+ #
49
+ # @example Using forge_and in a test
50
+ # expect(response.body).to forge_and(
51
+ # have_key("name"),
52
+ # have_key("email"),
53
+ # include("active" => be_truthy)
54
+ # )
55
+ #
56
+ # @private
57
+ #
58
+ def define_forge_and
59
+ RSpec::Matchers.define :forge_and do |*matchers|
60
+ match do |actual|
61
+ @failures = []
62
+
63
+ matchers.each do |matcher|
64
+ next if matcher.matches?(actual)
65
+
66
+ @failures << [matcher, matcher.failure_message]
67
+ end
68
+
69
+ @failures.empty?
70
+ end
71
+
72
+ failure_message do
73
+ pass_count = matchers.size - @failures.size
74
+
75
+ message = "Expected to satisfy ALL of these conditions on:\n #{actual.inspect}\n\n"
76
+
77
+ matchers.each_with_index do |matcher, i|
78
+ failure = @failures.find { |m, _| m == matcher }
79
+
80
+ if failure
81
+ message += "❌ #{i + 1}. #{matcher.description}\n"
82
+ message += " → #{failure[1].gsub(/\s+/, " ").strip}\n\n"
83
+ else
84
+ message += "✅ #{i + 1}. #{matcher.description}\n\n"
85
+ end
86
+ end
87
+
88
+ message += "#{pass_count}/#{matchers.size} conditions met"
89
+ message
90
+ end
91
+
92
+ description do
93
+ "match all: " + matchers.join_map(", ", &:description)
94
+ end
95
+ end
96
+ end
97
+
98
+ #
99
+ # Defines the have_size matcher for checking collection sizes
100
+ #
101
+ # This matcher verifies that an object responds to the :size method
102
+ # and that its size matches the expected value.
103
+ #
104
+ # @example Using have_size in a test
105
+ # expect(response.body["items"]).to have_size(5)
106
+ #
107
+ # @private
108
+ #
109
+ def define_have_size
110
+ RSpec::Matchers.define :have_size do |expected|
111
+ expected = RSpec::Matchers::BuiltIn::Eq.new(expected) if expected.is_a?(Integer)
112
+
113
+ match do |actual|
114
+ actual.respond_to?(:size) && expected.matches?(actual.size)
115
+ end
116
+
117
+ failure_message do |actual|
118
+ if actual.respond_to?(:size)
119
+ "expected #{actual.inspect} size to #{expected.description}, but got #{actual.size}"
120
+ else
121
+ "expected #{actual.inspect} to respond to :size"
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ # Define the custom matchers
130
+ SpecForge::Matchers.define
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Normalizer
5
+ #
6
+ # Provides default value generation for Normalizer structures
7
+ #
8
+ # Contains helper methods for creating default values based on type
9
+ # definitions and structure specifications.
10
+ #
11
+ module Default
12
+ private
13
+
14
+ def default_from_structure(structure, include_optional: false)
15
+ structure.each_with_object({}) do |(attribute_name, attribute), hash|
16
+ type = attribute[:type]
17
+ has_default = attribute.key?(:default)
18
+ required = attribute[:required] != false
19
+
20
+ next if !(include_optional || required || has_default)
21
+
22
+ hash[attribute_name] =
23
+ if has_default
24
+ default = attribute[:default]
25
+ next if default.nil?
26
+
27
+ default.dup
28
+ elsif type.instance_of?(Array)
29
+ default_value_for_type(type.first)
30
+ else
31
+ default_value_for_type(type)
32
+ end
33
+ end
34
+ end
35
+
36
+ def default_value_for_type(type_class)
37
+ if type_class == Integer
38
+ 0
39
+ elsif type_class == Proc
40
+ -> {}
41
+ elsif type_class == TrueClass
42
+ true
43
+ elsif type_class == FalseClass
44
+ false
45
+ else
46
+ type_class.new
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Normalizer
5
+ #
6
+ # Manages structure definitions for the Normalizer
7
+ #
8
+ # Handles loading structure definitions from YAML files, processing references
9
+ # between structures, and normalizing structure formats for consistent validation.
10
+ #
11
+ # @example Loading all structure definitions
12
+ # structures = SpecForge::Normalizer::Definition.from_files
13
+ #
14
+ class Definition
15
+ #
16
+ # Mapping of structure names to their human-readable labels
17
+ #
18
+ # @return [Hash<Symbol, String>]
19
+ #
20
+ LABELS = {
21
+ factory_reference: "factory reference",
22
+ global_context: "global context"
23
+ }.freeze
24
+
25
+ #
26
+ # Core structure definition used to validate other structures
27
+ #
28
+ # Defines the valid attributes and types for structure definitions,
29
+ # creating a meta-structure that validates other structure definitions.
30
+ #
31
+ # @return [Hash]
32
+ #
33
+ STRUCTURE = {
34
+ type: {
35
+ type: [String, Array, Class],
36
+ default: nil,
37
+ validator: :present?
38
+ },
39
+ default: {
40
+ type: [String, NilClass, Numeric, Array, Hash, TrueClass, FalseClass],
41
+ required: false
42
+ },
43
+ required: {
44
+ type: [TrueClass, FalseClass],
45
+ required: false
46
+ },
47
+ aliases: {
48
+ type: Array,
49
+ required: false,
50
+ structure: {type: String}
51
+ },
52
+ structure: {
53
+ type: Hash,
54
+ required: false
55
+ },
56
+ validator: {
57
+ type: String,
58
+ required: false
59
+ }
60
+ }.freeze
61
+
62
+ #
63
+ # Loads normalizer definitions from YAML files
64
+ #
65
+ # Reads all YAML files in the normalizers directory, processes shared
66
+ # references, and prepares them for use by the Normalizer.
67
+ #
68
+ # @return [Hash] A hash mapping structure names to their definitions
69
+ #
70
+ def self.from_files
71
+ base_path = Pathname.new(File.expand_path("../normalizers", __dir__))
72
+ paths = Dir[base_path.join("**/*.yml")].sort
73
+
74
+ normalizers =
75
+ paths.each_with_object({}) do |path, hash|
76
+ path = Pathname.new(path)
77
+
78
+ # Include the directory name in the path to include normalizers in directories
79
+ name = path.relative_path_from(base_path).to_s.delete_suffix(".yml").to_sym
80
+
81
+ input = YAML.safe_load_file(path, symbolize_names: true)
82
+ raise Error, "Normalizer defined at #{path.to_s.in_quotes} is empty" if input.blank?
83
+
84
+ hash[name] = new(input, label: LABELS[name] || name.to_s.humanize.downcase)
85
+ end
86
+
87
+ # Pull the shared structures and prepare it
88
+ structures = normalizers.delete(:_shared).normalize
89
+
90
+ # Merge in the normalizers to allow referencing other normalizers
91
+ structures.merge!(normalizers.transform_values(&:input))
92
+
93
+ # Now prepare all of the other definitions with access to references
94
+ normalizers.transform_values!(with_key: true) do |definition, name|
95
+ structure = definition.normalize(structures)
96
+
97
+ {
98
+ label: definition.label,
99
+ structure:
100
+ }
101
+ end
102
+
103
+ normalizers
104
+ end
105
+
106
+ ##########################################################################
107
+
108
+ attr_reader :input, :label
109
+
110
+ def initialize(input, label: "")
111
+ @input = input
112
+ @label = label
113
+ end
114
+
115
+ #
116
+ # Normalizes a structure definition
117
+ #
118
+ # Processes references, resolves types, and ensures all attributes
119
+ # have a consistent format for validation.
120
+ #
121
+ # @param shared_structures [Hash] Optional shared structures for resolving references
122
+ #
123
+ # @return [Hash] The normalized structure definition
124
+ #
125
+ def normalize(shared_structures = {})
126
+ hash = @input.deep_dup
127
+
128
+ # First, we'll deeply replace any references
129
+ replace_references(hash, shared_structures)
130
+
131
+ # Second, normalize the root level keys
132
+ hash.transform_values!(with_key: true) do |attribute, name|
133
+ next if STRUCTURE.key?(name)
134
+
135
+ normalize_attribute(name, attribute)
136
+ end
137
+
138
+ # Third, normalize the underlying structures
139
+ hash.each do |name, attribute|
140
+ next unless attribute.is_a?(Hash)
141
+
142
+ structure = attribute[:structure]
143
+ next if structure.blank?
144
+
145
+ attribute[:structure] = normalize_structure(name, attribute)
146
+ end
147
+
148
+ hash
149
+ end
150
+
151
+ private
152
+
153
+ def replace_references(attributes, shared_structures)
154
+ return if shared_structures.blank?
155
+
156
+ # The goal is to walk down the hash and recursively replace any references
157
+ attributes.each do |attribute_name, attribute|
158
+ # Replace the top level reference
159
+ replace_with_reference(attribute_name, attribute, shared_structures:)
160
+ next unless attribute.is_a?(Hash) && attribute[:structure].present?
161
+
162
+ # Allow structures to reference other structures
163
+ if attribute.dig(:structure, :reference)
164
+ replace_with_reference(
165
+ "#{attribute_name}'s structure",
166
+ attribute[:structure],
167
+ shared_structures:
168
+ )
169
+ end
170
+
171
+ # Recursively replace any structures that have references
172
+ if [Array, "array"].include?(attribute[:type])
173
+ result = replace_references(attribute.slice(:structure), shared_structures)
174
+ attribute.merge!(result)
175
+ elsif [Hash, "hash"].include?(attribute[:type])
176
+ replace_references(attribute[:structure], shared_structures)
177
+ end
178
+ end
179
+ end
180
+
181
+ def replace_with_reference(attribute_name, attribute, shared_structures: {})
182
+ return unless attribute.is_a?(Hash) && attribute.key?(:reference)
183
+
184
+ reference_name = attribute.delete(:reference)
185
+ reference = shared_structures[reference_name.to_sym]
186
+
187
+ if reference.nil?
188
+ structures_names = shared_structures.keys.map(&:in_quotes).to_or_sentence
189
+
190
+ raise Error, "Attribute #{attribute_name.in_quotes}: Invalid reference name. Got #{reference_name&.in_quotes}, expected one of #{structures_names} in #{@label}"
191
+ end
192
+
193
+ # Allows overwriting data on the reference
194
+ attribute.reverse_merge!(reference)
195
+ end
196
+
197
+ def normalize_attribute(attribute_name, attribute)
198
+ case attribute
199
+ when String, Array # Array is multiple types
200
+ hash = {type: resolve_type(attribute)}
201
+
202
+ default = Normalizer.default(structure: STRUCTURE)
203
+ hash.merge!(default)
204
+ when Hash
205
+ hash = Normalizer.raise_errors! do
206
+ Normalizer.new(
207
+ "#{attribute_name.in_quotes} in #{@label}",
208
+ attribute,
209
+ structure: STRUCTURE
210
+ ).normalize
211
+ end
212
+
213
+ hash[:type] = resolve_type(attribute[:type])
214
+
215
+ if hash[:structure].present?
216
+ hash[:structure] = normalize_structure(attribute_name, hash) || {}
217
+ end
218
+
219
+ hash
220
+ else
221
+ raise ArgumentError, "Attribute #{attribute_name.in_quotes}: Expected String or Hash, got #{attribute.inspect}"
222
+ end
223
+ end
224
+
225
+ def normalize_structure(name, hash)
226
+ if hash[:type] == Array
227
+ normalize_attribute(name, hash[:structure])
228
+ elsif hash[:type] == Hash
229
+ hash[:structure].transform_values(with_key: true) { |v, k| normalize_attribute(k, v) }
230
+ end
231
+ end
232
+
233
+ def resolve_type(type)
234
+ if type == "boolean"
235
+ [TrueClass, FalseClass]
236
+ elsif type.instance_of?(Array)
237
+ type.map { |t| resolve_type(t) }
238
+ elsif type.is_a?(String)
239
+ type.classify.constantize
240
+ else
241
+ type
242
+ end
243
+ rescue NameError => e
244
+ raise Error, "#{e}. #{type.inspect} is not a valid type found in #{@label}"
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Normalizer
5
+ #
6
+ # Provides validation methods for Normalizer structures
7
+ #
8
+ # Contains validation functions that can be referenced by name
9
+ # in structure definitions to perform custom validation logic.
10
+ #
11
+ # @example Validator in a structure definition
12
+ # http_verb: {type: String, validator: :http_verb}
13
+ #
14
+ class Validators
15
+ #
16
+ # Calls a validator method with the provided value and context
17
+ #
18
+ # @param method_name [Symbol, String] The validator method to call
19
+ # @param value [Object] The value to validate
20
+ # @param label [String] A descriptive label for error messages
21
+ #
22
+ # @return [void]
23
+ #
24
+ # @raise [Error] If validation fails
25
+ #
26
+ def self.call(method_name, value, label:)
27
+ new(label).public_send(method_name, value)
28
+ end
29
+
30
+ #
31
+ # Initializes a new validator instance with a context label
32
+ #
33
+ # @param label [String] A descriptive label for error messages
34
+ #
35
+ # @return [Validators] A new validator instance
36
+ #
37
+ def initialize(label)
38
+ @label = label
39
+ end
40
+
41
+ #
42
+ # Validates that a value is not blank
43
+ #
44
+ # Ensures the provided value is not nil, empty, or contains only whitespace.
45
+ # This validator is useful for required fields that must have meaningful content.
46
+ #
47
+ # @param value [Object] The value to validate
48
+ #
49
+ # @raise [Error] If the value is blank
50
+ #
51
+ # @example Using the validator in a structure
52
+ # name: {type: String, validator: :present?}
53
+ #
54
+ def present?(value)
55
+ raise Error, "Value cannot be blank for #{@label}" if value.blank?
56
+ end
57
+
58
+ #
59
+ # Validates that a value is a supported HTTP verb
60
+ #
61
+ # Ensures the provided value is one of the supported HTTP methods
62
+ # (GET, POST, PUT, PATCH, DELETE). Case-insensitive matching is used.
63
+ #
64
+ # @param value [String, Symbol, nil] The HTTP verb to validate, or nil
65
+ #
66
+ # @raise [Error] If the value is not a supported HTTP verb
67
+ #
68
+ # @example Using the validator in a structure
69
+ # http_verb: {type: String, validator: :http_verb}
70
+ #
71
+ def http_verb(value)
72
+ valid_verbs = HTTP::Verb::VERBS.values.map(&:to_s)
73
+ return if value.blank? || valid_verbs.include?(value.to_s.upcase)
74
+
75
+ raise Error, "Invalid HTTP verb #{value.in_quotes} for #{@label}. Valid values are: #{valid_verbs.join_map(", ", &:in_quotes)}"
76
+ end
77
+
78
+ #
79
+ # Validates that a callback is registered in the system
80
+ #
81
+ # Ensures the referenced callback name has been registered with SpecForge
82
+ # before it's used in a test configuration.
83
+ #
84
+ # @param value [String, Symbol, nil] The callback name to validate, or nil
85
+ #
86
+ # @raise [Error::UndefinedCallbackError] If the callback is not registered
87
+ #
88
+ # @example Using the validator in a structure
89
+ # before_file: {type: String, validator: :callback}
90
+ #
91
+ def callback(value)
92
+ return if value.blank?
93
+ return if SpecForge::Callbacks.registered?(value)
94
+
95
+ raise Error::UndefinedCallbackError.new(value, SpecForge::Callbacks.registered_names)
96
+ end
97
+ end
98
+ end
99
+ end