spec_forge 0.7.1 → 1.0.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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -1
  3. data/README.md +124 -202
  4. data/bin/spec_forge +1 -1
  5. data/flake.lock +76 -4
  6. data/flake.nix +5 -4
  7. data/lib/spec_forge/attribute/chainable.rb +6 -6
  8. data/lib/spec_forge/attribute/environment.rb +45 -0
  9. data/lib/spec_forge/attribute/factory.rb +26 -17
  10. data/lib/spec_forge/attribute/faker.rb +6 -1
  11. data/lib/spec_forge/attribute/generate.rb +114 -0
  12. data/lib/spec_forge/attribute/literal.rb +1 -14
  13. data/lib/spec_forge/attribute/matcher.rb +6 -2
  14. data/lib/spec_forge/attribute/parameterized.rb +20 -22
  15. data/lib/spec_forge/attribute/resolvable_array.rb +16 -16
  16. data/lib/spec_forge/attribute/resolvable_hash.rb +17 -16
  17. data/lib/spec_forge/attribute/resolvable_struct.rb +67 -0
  18. data/lib/spec_forge/attribute/template.rb +118 -0
  19. data/lib/spec_forge/attribute/transform.rb +14 -19
  20. data/lib/spec_forge/attribute/variable.rb +31 -31
  21. data/lib/spec_forge/attribute.rb +54 -100
  22. data/lib/spec_forge/blueprint.rb +27 -0
  23. data/lib/spec_forge/cli/docs/generate.rb +28 -8
  24. data/lib/spec_forge/cli/docs.rb +5 -2
  25. data/lib/spec_forge/cli/init.rb +4 -4
  26. data/lib/spec_forge/cli/new.rb +78 -27
  27. data/lib/spec_forge/cli/run.rb +84 -52
  28. data/lib/spec_forge/cli/serve.rb +5 -0
  29. data/lib/spec_forge/cli.rb +6 -14
  30. data/lib/spec_forge/configuration.rb +209 -79
  31. data/lib/spec_forge/documentation/{loader → builder}/cache.rb +26 -23
  32. data/lib/spec_forge/documentation/builder/compiler.rb +373 -0
  33. data/lib/spec_forge/documentation/builder/extractor.rb +75 -0
  34. data/lib/spec_forge/documentation/builder.rb +77 -329
  35. data/lib/spec_forge/documentation/document/operation.rb +4 -4
  36. data/lib/spec_forge/documentation/document.rb +0 -6
  37. data/lib/spec_forge/documentation/generator.rb +88 -0
  38. data/lib/spec_forge/documentation/{generators/openapi → openapi/v3_0}/error_formatter.rb +2 -2
  39. data/lib/spec_forge/documentation/openapi/v3_0/example.rb +1 -1
  40. data/lib/spec_forge/documentation/openapi/v3_0/media_type.rb +1 -1
  41. data/lib/spec_forge/documentation/openapi/v3_0/operation.rb +21 -5
  42. data/lib/spec_forge/documentation/openapi/v3_0/response.rb +28 -6
  43. data/lib/spec_forge/documentation/openapi/v3_0/schema.rb +20 -2
  44. data/lib/spec_forge/documentation/openapi/v3_0/tag.rb +1 -1
  45. data/lib/spec_forge/documentation/openapi/v3_0.rb +116 -0
  46. data/lib/spec_forge/documentation/openapi.rb +40 -12
  47. data/lib/spec_forge/documentation.rb +1 -7
  48. data/lib/spec_forge/error.rb +215 -41
  49. data/lib/spec_forge/factory.rb +38 -18
  50. data/lib/spec_forge/forge/action.rb +41 -0
  51. data/lib/spec_forge/forge/actions/call.rb +33 -0
  52. data/lib/spec_forge/forge/actions/debug.rb +47 -0
  53. data/lib/spec_forge/forge/actions/expect.rb +44 -0
  54. data/lib/spec_forge/forge/actions/request.rb +65 -0
  55. data/lib/spec_forge/forge/actions/store.rb +31 -0
  56. data/lib/spec_forge/forge/callbacks.rb +80 -0
  57. data/lib/spec_forge/forge/context.rb +41 -0
  58. data/lib/spec_forge/forge/display.rb +503 -0
  59. data/lib/spec_forge/forge/hooks.rb +131 -0
  60. data/lib/spec_forge/forge/runner/array_io.rb +81 -0
  61. data/lib/spec_forge/forge/runner/content_validator.rb +92 -0
  62. data/lib/spec_forge/forge/runner/header_validator.rb +66 -0
  63. data/lib/spec_forge/forge/runner/reporter.rb +56 -0
  64. data/lib/spec_forge/forge/runner/schema_validator.rb +113 -0
  65. data/lib/spec_forge/forge/runner.rb +118 -0
  66. data/lib/spec_forge/forge/timer.rb +94 -0
  67. data/lib/spec_forge/forge/variables.rb +38 -0
  68. data/lib/spec_forge/forge.rb +207 -133
  69. data/lib/spec_forge/http/backend.rb +49 -146
  70. data/lib/spec_forge/http/client.rb +14 -17
  71. data/lib/spec_forge/http/request.rb +37 -84
  72. data/lib/spec_forge/http/verb.rb +4 -0
  73. data/lib/spec_forge/http.rb +0 -5
  74. data/lib/spec_forge/loader/filter.rb +85 -0
  75. data/lib/spec_forge/loader/step_processor.rb +282 -0
  76. data/lib/spec_forge/loader.rb +105 -220
  77. data/lib/spec_forge/normalizer/default.rb +1 -1
  78. data/lib/spec_forge/normalizer/structure.rb +140 -0
  79. data/lib/spec_forge/normalizer/transformers.rb +168 -0
  80. data/lib/spec_forge/normalizer/validators.rb +50 -8
  81. data/lib/spec_forge/normalizer.rb +76 -119
  82. data/lib/spec_forge/normalizers/callback.yml +38 -0
  83. data/lib/spec_forge/normalizers/configuration.yml +59 -9
  84. data/lib/spec_forge/normalizers/factory.yml +53 -2
  85. data/lib/spec_forge/normalizers/factory_reference.yml +63 -2
  86. data/lib/spec_forge/normalizers/json_schema.yml +79 -0
  87. data/lib/spec_forge/normalizers/step.yml +506 -0
  88. data/lib/spec_forge/step/call.rb +36 -0
  89. data/lib/spec_forge/step/expect.rb +110 -0
  90. data/lib/spec_forge/step/source.rb +22 -0
  91. data/lib/spec_forge/step.rb +129 -0
  92. data/lib/spec_forge/type.rb +115 -66
  93. data/lib/spec_forge/version.rb +1 -1
  94. data/lib/spec_forge.rb +44 -106
  95. data/lib/templates/forge_helper.rb.tt +43 -22
  96. data/lib/templates/new_blueprint.yml.tt +54 -0
  97. metadata +75 -44
  98. data/lib/spec_forge/attribute/global.rb +0 -96
  99. data/lib/spec_forge/attribute/store.rb +0 -65
  100. data/lib/spec_forge/backtrace_formatter.rb +0 -50
  101. data/lib/spec_forge/callbacks.rb +0 -88
  102. data/lib/spec_forge/context/callbacks.rb +0 -91
  103. data/lib/spec_forge/context/global.rb +0 -72
  104. data/lib/spec_forge/context/store.rb +0 -131
  105. data/lib/spec_forge/context/variables.rb +0 -91
  106. data/lib/spec_forge/context.rb +0 -36
  107. data/lib/spec_forge/core_ext/rspec.rb +0 -55
  108. data/lib/spec_forge/core_ext.rb +0 -5
  109. data/lib/spec_forge/documentation/generators/base.rb +0 -81
  110. data/lib/spec_forge/documentation/generators/openapi/base.rb +0 -100
  111. data/lib/spec_forge/documentation/generators/openapi/v3_0.rb +0 -65
  112. data/lib/spec_forge/documentation/generators/openapi.rb +0 -59
  113. data/lib/spec_forge/documentation/generators.rb +0 -17
  114. data/lib/spec_forge/documentation/loader.rb +0 -159
  115. data/lib/spec_forge/documentation/openapi/base.rb +0 -33
  116. data/lib/spec_forge/filter.rb +0 -86
  117. data/lib/spec_forge/normalizer/definition.rb +0 -248
  118. data/lib/spec_forge/normalizers/_shared.yml +0 -76
  119. data/lib/spec_forge/normalizers/constraint.yml +0 -8
  120. data/lib/spec_forge/normalizers/expectation.yml +0 -47
  121. data/lib/spec_forge/normalizers/global_context.yml +0 -28
  122. data/lib/spec_forge/normalizers/spec.yml +0 -50
  123. data/lib/spec_forge/runner/adapter.rb +0 -181
  124. data/lib/spec_forge/runner/callbacks.rb +0 -246
  125. data/lib/spec_forge/runner/debug_proxy.rb +0 -215
  126. data/lib/spec_forge/runner/listener.rb +0 -54
  127. data/lib/spec_forge/runner/metadata.rb +0 -58
  128. data/lib/spec_forge/runner/state.rb +0 -98
  129. data/lib/spec_forge/runner.rb +0 -75
  130. data/lib/spec_forge/spec/expectation/constraint.rb +0 -127
  131. data/lib/spec_forge/spec/expectation.rb +0 -68
  132. data/lib/spec_forge/spec.rb +0 -68
  133. data/lib/templates/new_spec.yml.tt +0 -43
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ class Attribute
5
+ #
6
+ # Handles string interpolation with {{ }} template syntax
7
+ #
8
+ # Parses strings containing {{ variable }} placeholders and resolves them
9
+ # at runtime by looking up variables, faker calls, or other attribute types.
10
+ #
11
+ # @example Template interpolation
12
+ # Template.new("Hello {{ user_name }}!")
13
+ # # When resolved with user_name = "Alice": "Hello Alice!"
14
+ #
15
+ # @example Multiple templates
16
+ # Template.new("/users/{{ user_id }}/posts/{{ post_id }}")
17
+ #
18
+ class Template < Attribute
19
+ # Regex pattern for matching {{ variable }} placeholders
20
+ #
21
+ # @return [Regexp]
22
+ REGEX = /\{\{\s*[\w.]+\s*\}\}/
23
+
24
+ #
25
+ # Creates a new template attribute by parsing placeholders
26
+ #
27
+ # @see Attribute#initialize
28
+ #
29
+ def initialize(...)
30
+ super
31
+
32
+ @parsed, @templates = parse_templates
33
+ end
34
+
35
+ #
36
+ # Returns the interpolated string value
37
+ #
38
+ # Replaces all {{ }} placeholders with their resolved values.
39
+ # If the entire string is a single placeholder, returns the value
40
+ # in its original type (not stringified).
41
+ #
42
+ # @return [Object] The interpolated value
43
+ #
44
+ def value
45
+ value =
46
+ @templates.each_with_object(@parsed.dup) do |(placeholder, attribute), string|
47
+ value = attribute.value
48
+
49
+ replacement_value =
50
+ case value
51
+ when Hash, Array
52
+ value.to_json
53
+ else
54
+ value.to_s
55
+ end
56
+
57
+ string.gsub!(placeholder, replacement_value)
58
+ end
59
+
60
+ if @templates.size == 1
61
+ placeholder, template_value = @templates.first
62
+ value = cast_to_type(value, template_value.value) if @parsed == placeholder
63
+ end
64
+
65
+ value
66
+ end
67
+
68
+ private
69
+
70
+ def parse_templates
71
+ templates = {}
72
+ reverse_lookup = {}
73
+
74
+ result = @input.gsub(REGEX).with_index do |match, index|
75
+ content = match[2..-3].strip
76
+
77
+ # We've already processed this content, use the same placeholder
78
+ if (placeholder = reverse_lookup[content])
79
+ next placeholder
80
+ end
81
+
82
+ attribute = Attribute.from(content, **@options)
83
+
84
+ # There is no such thing as a Literal inside a Template.
85
+ # This makes it significantly easier to detect variables
86
+ attribute = Attribute::Variable.new(content, **@options) if attribute.is_a?(Attribute::Literal)
87
+
88
+ placeholder = "⬣→SF#{index}"
89
+ templates[placeholder] = attribute
90
+ reverse_lookup[content] = placeholder
91
+
92
+ placeholder
93
+ end
94
+
95
+ [result, templates]
96
+ end
97
+
98
+ def cast_to_type(input, template_value)
99
+ case template_value
100
+ when Integer
101
+ input.to_i
102
+ when Float
103
+ input.to_f
104
+ when TrueClass, FalseClass
105
+ input == "true"
106
+ when Array
107
+ input.to_a
108
+ when Hash
109
+ input.to_h
110
+ when String
111
+ input
112
+ else
113
+ template_value # Matchers, etc.
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -5,16 +5,12 @@ module SpecForge
5
5
  #
6
6
  # Represents an attribute that transforms other attributes
7
7
  #
8
- # This class provides transformation functions like join that can be applied
9
- # to other attributes or values. It allows complex data manipulation without
10
- # writing Ruby code.
8
+ # This class provides transformation functions that can be applied to other
9
+ # attributes or values. It allows complex data manipulation without writing
10
+ # Ruby code.
11
11
  #
12
- # @example Join transformation in YAML
13
- # full_name:
14
- # transform.join:
15
- # - variables.first_name
16
- # - " "
17
- # - variables.last_name
12
+ # Note: String concatenation is handled via string interpolation ({{ }}) syntax
13
+ # rather than transformation functions.
18
14
  #
19
15
  class Transform < Parameterized
20
16
  #
@@ -30,24 +26,27 @@ module SpecForge
30
26
  #
31
27
  # @return [Array<String>]
32
28
  #
33
- TRANSFORM_METHODS = %w[
34
- join
35
- ].freeze
29
+ TRANSFORM_METHODS = %w[].freeze
36
30
 
31
+ # @return [String] The transformation function name
37
32
  attr_reader :function
38
33
 
39
34
  #
40
35
  # Creates a new transform attribute with the specified function and arguments
41
36
  #
37
+ # @raise [Error::InvalidTransformFunctionError] If the function is not supported
38
+ #
39
+ # @see Parameterized#initialize
40
+ #
42
41
  def initialize(...)
43
42
  super
44
43
 
45
44
  # Remove prefix
46
45
  @function = @input.sub("transform.", "")
47
46
 
48
- raise Error::InvalidTransformFunctionError, input unless TRANSFORM_METHODS.include?(function)
47
+ raise Error::InvalidTransformFunctionError.new(input, TRANSFORM_METHODS) unless TRANSFORM_METHODS.include?(function)
49
48
 
50
- prepare_arguments!
49
+ prepare_arguments
51
50
  end
52
51
 
53
52
  #
@@ -56,11 +55,7 @@ module SpecForge
56
55
  # @return [Object] The transformed value
57
56
  #
58
57
  def value
59
- case function
60
- when "join"
61
- # Technically supports any attribute, but I ain't gonna test all them edge cases
62
- arguments[:positional].resolve.join
63
- end
58
+ # Noop
64
59
  end
65
60
  end
66
61
  end
@@ -3,57 +3,57 @@
3
3
  module SpecForge
4
4
  class Attribute
5
5
  #
6
- # Represents an attribute that references a variable
6
+ # Represents a variable reference in a template
7
7
  #
8
- # This class allows referencing variables defined in the test context.
9
- # It supports chained access to methods and properties of variable values.
8
+ # Variables are resolved at runtime by looking up values stored in the
9
+ # current execution context. Supports dot notation for accessing nested
10
+ # properties (e.g., "response.body.id").
10
11
  #
11
- # @example Basic usage in YAML
12
- # user_id: variables.user.id
13
- # company_name: variables.company.name
12
+ # @example Simple variable
13
+ # Variable.new("user_id") # Looks up :user_id in context
14
14
  #
15
- # @example Nested access in YAML
16
- # post_author: variables.post.comments.first.author.name
15
+ # @example Nested access
16
+ # Variable.new("response.body.token") # response[:body][:token]
17
17
  #
18
18
  class Variable < Attribute
19
19
  include Chainable
20
20
 
21
- #
22
- # Regular expression pattern that matches attribute keywords with this prefix
23
- # Used for identifying this attribute type during parsing
24
- #
25
- # @return [Regexp]
26
- #
27
- KEYWORD_REGEX = /^variables\./i
28
-
29
21
  alias_method :variable_name, :header
30
22
 
31
23
  #
32
- # Binds the referenced variable to this attribute
33
- #
34
- # @param variables [Hash] A hash of variables to look up in
24
+ # Creates a new variable attribute
35
25
  #
36
- # @raise [Error::MissingVariableError] If the variable is not found
37
- # @raise [Error::InvalidTypeError] If variables is not a hash
26
+ # @param input [String] The variable path (e.g., "user_id", "response.body.id")
38
27
  #
39
- def bind_variables(variables)
40
- if !Type.hash?(variables)
41
- raise Error::InvalidTypeError.new(variables, Hash, for: "'variables'")
42
- end
28
+ def initialize(...)
29
+ super
43
30
 
44
- # Don't nil check here.
45
- raise Error::MissingVariableError, variable_name unless variables.key?(variable_name)
31
+ # Unset the keyword to keep chainable from displaying it in error messages
32
+ @keyword = nil
46
33
 
47
- @variable = variables[variable_name]
34
+ sections = @input.split(".")
35
+ @header = sections.first&.to_sym
36
+ @invocation_chain = sections[1..] || []
48
37
  end
49
38
 
50
39
  #
51
- # Returns the base object for the variable chain
40
+ # Returns the base object for this variable from the current context
52
41
  #
53
- # @return [Object] The variable value
42
+ # Looks up the variable name in the current Forge context's variables.
43
+ # Raises an error if the variable is not defined.
44
+ #
45
+ # @return [Object] The value stored under this variable name
46
+ #
47
+ # @raise [Error::MissingVariableError] If the variable is not defined
54
48
  #
55
49
  def base_object
56
- @variable || bind_variables(SpecForge.context.variables)
50
+ variables = @options[:context] || Forge.context&.variables || {}
51
+
52
+ if !variables.key?(variable_name)
53
+ raise Error::MissingVariableError.new(variable_name, available_variables: variables.keys)
54
+ end
55
+
56
+ variables[variable_name]
57
57
  end
58
58
  end
59
59
  end
@@ -1,10 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Need to be first
4
- require_relative "attribute/parameterized"
5
- require_relative "attribute/chainable"
6
- require_relative "attribute/resolvable"
7
-
8
3
  module SpecForge
9
4
  #
10
5
  # Base class for all attribute types in SpecForge.
@@ -21,50 +16,37 @@ module SpecForge
21
16
  # user_id: variables.user.id # A variable reference
22
17
  #
23
18
  class Attribute
24
- include Resolvable
25
-
26
- #
27
- # Binds variables to Attribute objects
28
- #
29
- # @param input [Array, Hash, Attribute] The input to loop through or bind to
30
- # @param variables [Hash] Any variables to available to assign
31
- #
32
- # @return [Array, Hash, Attribute] The input with bounded variables
33
- #
34
- def self.bind_variables(input, variables = {})
35
- case input
36
- when ArrayLike
37
- input.each { |v| v.bind_variables(variables) }
38
- when HashLike
39
- input.each_value { |v| v.bind_variables(variables) }
40
- when Attribute
41
- input.bind_variables(variables)
42
- end
43
-
44
- input
19
+ class << self
20
+ include Resolvable
45
21
  end
46
22
 
23
+ include Resolvable
24
+
47
25
  #
48
26
  # Creates an Attribute instance based on the input value's type and content.
49
27
  # Recursively converts Array and Hash
50
28
  #
51
29
  # @param value [Object] The input value to convert into an Attribute
30
+ # @param options [Hash] Additional options passed to attribute constructors
31
+ # @option options [Hash] :context Custom variable context for resolution
52
32
  #
53
33
  # @return [Attribute] A new Attribute instance of the appropriate subclass
54
34
  #
55
- def self.from(value)
35
+ def self.from(value, **options)
56
36
  case value
57
37
  when String
58
- from_string(value)
59
- when HashLike
60
- from_hash(value)
61
- when Attribute
38
+ from_string(value, **options)
39
+ when Hash
40
+ from_hash(value, **options)
41
+ when Attribute, ResolvableArray, ResolvableHash, ResolvableStruct
62
42
  value
63
- when ArrayLike
64
- array = value.map { |v| Attribute.from(v) }
65
- Attribute::ResolvableArray.new(array)
43
+ when Array
44
+ array = value.map { |v| Attribute.from(v, **options) }
45
+ ResolvableArray.new(array)
46
+ when Struct, Data, OpenStruct
47
+ ResolvableStruct.new(value)
66
48
  else
67
- Literal.new(value)
49
+ Literal.new(value, **options)
68
50
  end
69
51
  end
70
52
 
@@ -72,59 +54,63 @@ module SpecForge
72
54
  # Creates an Attribute instance from a string
73
55
  #
74
56
  # @param string [String] The input string
57
+ # @param options [Hash] Additional options passed to attribute constructors
58
+ # @option options [Hash] :context Custom variable context for resolution
75
59
  #
76
60
  # @return [Attribute]
77
61
  #
78
62
  # @private
79
63
  #
80
- def self.from_string(string)
64
+ def self.from_string(string, **options)
81
65
  klass =
82
66
  case string
67
+ when Template::REGEX
68
+ Template
69
+ when Environment::KEYWORD_REGEX
70
+ Environment
83
71
  when Factory::KEYWORD_REGEX
84
72
  Factory
85
73
  when Faker::KEYWORD_REGEX
86
74
  Faker
87
- when Global::KEYWORD_REGEX
88
- Global
89
75
  when Matcher::KEYWORD_REGEX
90
76
  Matcher
91
77
  when Regex::KEYWORD_REGEX
92
78
  Regex
93
- when Store::KEYWORD_REGEX
94
- Store
95
- when Variable::KEYWORD_REGEX
96
- Variable
97
79
  else
98
80
  Literal
99
81
  end
100
82
 
101
- klass.new(string)
83
+ klass.new(string, **options)
102
84
  end
103
85
 
104
86
  #
105
87
  # Creates an Attribute instance from a hash
106
88
  #
107
89
  # @param hash [Hash] The input hash
90
+ # @param options [Hash] Additional options passed to attribute constructors
91
+ # @option options [Hash] :context Custom variable context for resolution
108
92
  #
109
93
  # @return [Attribute]
110
94
  #
111
95
  # @private
112
96
  #
113
- def self.from_hash(hash)
97
+ def self.from_hash(hash, **options)
114
98
  # Determine if the hash is an expanded macro call
115
99
  has_macro = ->(h, regex) { h.any? { |k, _| k.match?(regex) } }
116
100
 
117
101
  if has_macro.call(hash, Transform::KEYWORD_REGEX)
118
- Transform.from_hash(hash)
102
+ Transform.from_hash(hash, **options)
103
+ elsif has_macro.call(hash, Generate::KEYWORD_REGEX)
104
+ Generate.from_hash(hash, **options)
119
105
  elsif has_macro.call(hash, Faker::KEYWORD_REGEX)
120
- Faker.from_hash(hash)
106
+ Faker.from_hash(hash, **options)
121
107
  elsif has_macro.call(hash, Matcher::KEYWORD_REGEX)
122
- Matcher.from_hash(hash)
108
+ Matcher.from_hash(hash, **options)
123
109
  elsif has_macro.call(hash, Factory::KEYWORD_REGEX)
124
- Factory.from_hash(hash)
110
+ Factory.from_hash(hash, **options)
125
111
  else
126
- hash = hash.transform_values { |v| Attribute.from(v) }
127
- Attribute::ResolvableHash.new(hash)
112
+ hash = hash.transform_values { |v| Attribute.from(v, **options) }
113
+ ResolvableHash.new(hash)
128
114
  end
129
115
  end
130
116
 
@@ -139,9 +125,13 @@ module SpecForge
139
125
  # Creates a new attribute
140
126
  #
141
127
  # @param input [Object] The original input value
128
+ # @param options [Hash] Additional options for attribute behavior
129
+ # @option options [Hash] :context Custom variable context for resolution,
130
+ # bypassing Forge.context lookup
142
131
  #
143
- def initialize(input)
132
+ def initialize(input, **options)
144
133
  @input = input
134
+ @options = options
145
135
  end
146
136
 
147
137
  #
@@ -216,56 +206,41 @@ module SpecForge
216
206
  #
217
207
  def resolve
218
208
  case value
219
- when ArrayLike
220
- value.map(&resolved_proc)
221
- when HashLike
222
- value.transform_values(&resolved_proc)
209
+ when Array
210
+ value.map(&resolve_proc)
211
+ when Hash
212
+ value.transform_values(&resolve_proc)
223
213
  else
224
214
  value
225
215
  end
226
216
  end
227
217
 
228
218
  #
229
- # Converts this attribute to an appropriate RSpec matcher.
230
- # Handles different types of values by creating the right matcher type:
231
- # - Arrays become contain_exactly matchers
232
- # - Hashes become include matchers
233
- # - Regexp become match matchers
234
- # - Existing matchers are passed through
235
- # - Other values become eq matchers
236
- #
237
- # This method is crucial for nested matcher structures and compound matchers
238
- # like matcher.and that require all values to be proper matchers.
239
- #
240
- # @return [RSpec::Matchers::BuiltIn::BaseMatcher] A matcher representing this attribute
219
+ # Converts the resolved value into an RSpec matcher
241
220
  #
242
- # @example Converting different values to matchers
243
- # literal_attr = Attribute::Literal.new("hello")
244
- # literal_attr.resolve_as_matcher # => eq("hello")
221
+ # Transforms the attribute's resolved value into an appropriate RSpec matcher
222
+ # for use in expectations. Handles arrays, hashes, matchers, regexes, and
223
+ # literal values differently to produce the correct matcher type.
245
224
  #
246
- # array_attr = Attribute::ResolvableArray.new([1, 2, 3])
247
- # array_attr.resolve_as_matcher # => contain_exactly(eq(1), eq(2), eq(3))
248
- #
249
- # hash_attr = Attribute::ResolvableHash.new({name: "Test"})
250
- # hash_attr.resolve_as_matcher # => include("name" => eq("Test"))
225
+ # @return [RSpec::Matchers::BuiltIn::BaseMatcher] An RSpec matcher for the value
251
226
  #
252
227
  def resolve_as_matcher
253
228
  methods = Attribute::Matcher::MATCHER_METHODS
254
229
 
255
230
  case resolved
256
- when Array, ArrayLike
231
+ when Array
257
232
  resolved_array = resolved.map(&resolve_as_matcher_proc)
258
233
 
259
234
  if resolved_array.size > 0
260
- methods.contain_exactly(*resolved_array)
235
+ resolved_array
261
236
  else
262
237
  methods.eq([])
263
238
  end
264
- when Hash, HashLike
239
+ when Hash
265
240
  resolved_hash = resolved.transform_values(&resolve_as_matcher_proc).stringify_keys
266
241
 
267
242
  if resolved_hash.size > 0
268
- methods.include(**resolved_hash)
243
+ resolved_hash
269
244
  else
270
245
  methods.eq({})
271
246
  end
@@ -279,26 +254,5 @@ module SpecForge
279
254
  methods.eq(resolved)
280
255
  end
281
256
  end
282
-
283
- #
284
- # Used to bind variables to self or any sub attributes
285
- #
286
- # @param variables [Hash] A hash of variable attributes
287
- #
288
- def bind_variables(variables)
289
- end
290
257
  end
291
258
  end
292
-
293
- # Order doesn't matter
294
- require_relative "attribute/factory"
295
- require_relative "attribute/faker"
296
- require_relative "attribute/global"
297
- require_relative "attribute/literal"
298
- require_relative "attribute/matcher"
299
- require_relative "attribute/regex"
300
- require_relative "attribute/resolvable_array"
301
- require_relative "attribute/resolvable_hash"
302
- require_relative "attribute/store"
303
- require_relative "attribute/transform"
304
- require_relative "attribute/variable"
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpecForge
4
+ #
5
+ # Represents a loaded blueprint containing a sequence of test steps
6
+ #
7
+ # A Blueprint is the runtime representation of a YAML blueprint file.
8
+ # It contains metadata about the file and an ordered list of Step objects
9
+ # ready for execution.
10
+ #
11
+ # @example
12
+ # blueprint = Blueprint.new(
13
+ # file_path: Pathname.new("spec_forge/blueprints/users.yml"),
14
+ # name: "users",
15
+ # steps: [{name: "Create user", request: {...}}]
16
+ # )
17
+ #
18
+ class Blueprint < Data.define(:file_path, :file_name, :hooks, :name, :steps)
19
+ def initialize(file_path:, name:, steps: [], hooks: {})
20
+ file_name = file_path.basename.to_s
21
+ steps = steps.map { |s| Step.new(**s) }
22
+ hooks = Step::Call.wrap_hooks(hooks)
23
+
24
+ super(file_path:, file_name:, hooks:, name:, steps:,)
25
+ end
26
+ end
27
+ end
@@ -12,19 +12,26 @@ module SpecForge
12
12
  #
13
13
  module Generate
14
14
  #
15
- # Generates OpenAPI documentation and writes it to disk
15
+ # Generates OpenAPI documentation from blueprint test results
16
16
  #
17
- # Runs the documentation generation pipeline: executes tests, extracts
18
- # endpoint data, generates OpenAPI spec, validates it, and writes the
17
+ # Runs blueprints with the configured verbosity level, extracts endpoint
18
+ # data, validates the specification (unless skipped), and writes the
19
19
  # output file in the specified format.
20
20
  #
21
- # @return [Pathname] The path to the generated documentation file
21
+ # @param base_path [String, Pathname, nil] Optional base path for blueprints
22
22
  #
23
- def generate_documentation
24
- generator = Documentation::Generators::OpenAPI["3.0"]
25
- output = generator.generate(use_cache: !options.fresh)
23
+ # @return [Pathname] Path to the generated documentation file
24
+ #
25
+ def generate_documentation(base_path: nil)
26
+ document = Documentation::Builder.create_document!(
27
+ base_path:,
28
+ use_cache: !options.fresh,
29
+ verbosity_level: determine_verbosity_level
30
+ )
31
+ generator_class = Documentation::OpenAPI["3.0"]
32
+ output = generator_class.new(document).generate
26
33
 
27
- generator.validate!(output) unless options.skip_validation
34
+ generator_class.validate!(output) unless options.skip_validation
28
35
 
29
36
  # Determine output format and path
30
37
  file_format = determine_file_format
@@ -66,6 +73,19 @@ module SpecForge
66
73
  SpecForge.openapi_path.join("generated", "openapi.#{extension}")
67
74
  end
68
75
  end
76
+
77
+ #
78
+ # Determines verbosity level from command options
79
+ #
80
+ # @return [Integer] Verbosity level (0-3)
81
+ #
82
+ def determine_verbosity_level
83
+ return 3 if options.trace
84
+ return 2 if options.debug
85
+ return 1 if options.verbose
86
+
87
+ 0
88
+ end
69
89
  end
70
90
  end
71
91
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "docs/generate"
4
-
5
3
  module SpecForge
6
4
  class CLI
7
5
  #
@@ -60,6 +58,9 @@ module SpecForge
60
58
  option "--format=FORMAT", "Output format: yml/yaml or json (default: yml)"
61
59
  option "--output=PATH", "Full file path for generated documentation"
62
60
  option "--skip-validation", "Skip OpenAPI specification validation during generation"
61
+ option "--verbose", "Show detailed step execution (verbosity level 1)"
62
+ option "--debug", "Show full request/response for failures (verbosity level 2)"
63
+ option "--trace", "Show everything for all steps (verbosity level 3)"
63
64
 
64
65
  #
65
66
  # Generates OpenAPI documentation from tests
@@ -86,6 +87,8 @@ module SpecForge
86
87
  Your OpenAPI specification is valid and ready to use.
87
88
  Output written to: #{file_path.relative_path_from(SpecForge.forge_path)}
88
89
  STRING
90
+ rescue NoBlueprintsError => e
91
+ puts e.message
89
92
  end
90
93
  end
91
94
  end
@@ -17,7 +17,7 @@ module SpecForge
17
17
  Creates the SpecForge project structure.
18
18
 
19
19
  Sets up:
20
- • spec_forge/specs/ for test files
20
+ • spec_forge/blueprints/ for test files
21
21
  • spec_forge/factories/ for test data (optional)
22
22
  • spec_forge/openapi/ for documentation config (optional)
23
23
  • forge_helper.rb for configuration
@@ -27,8 +27,8 @@ module SpecForge
27
27
  option "--skip-factories", "Skip generating the \"factories\" directory"
28
28
 
29
29
  #
30
- # Creates the "spec_forge", "spec_forge/factories", and "spec_forge/specs" directories
31
- # Also creates the "spec_forge.rb" initialization file
30
+ # Creates the "spec_forge", "spec_forge/factories", and "spec_forge/blueprints" directories
31
+ # Also creates the "forge_helper.rb" initialization file
32
32
  #
33
33
  def call
34
34
  initialize_forge
@@ -39,7 +39,7 @@ module SpecForge
39
39
 
40
40
  def initialize_forge
41
41
  base_path = SpecForge.forge_path
42
- actions.empty_directory(base_path.join("specs"))
42
+ actions.empty_directory(base_path.join("blueprints"))
43
43
  actions.empty_directory(base_path.join("factories")) unless options.skip_factories
44
44
  actions.template("forge_helper.rb.tt", base_path.join("forge_helper.rb"))
45
45
  end