roast-ai 0.4.9 → 0.5.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 (194) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/docs/write-comments.md +36 -0
  3. data/.github/CODEOWNERS +1 -1
  4. data/.github/workflows/ci.yaml +10 -6
  5. data/.gitignore +0 -1
  6. data/.rubocop.yml +7 -1
  7. data/CLAUDE.md +2 -2
  8. data/CONTRIBUTING.md +2 -0
  9. data/Gemfile +18 -18
  10. data/Gemfile.lock +46 -57
  11. data/README.md +118 -1432
  12. data/README_LEGACY.md +1464 -0
  13. data/Rakefile +39 -4
  14. data/dev.yml +29 -0
  15. data/dsl/agent_sessions.rb +20 -0
  16. data/dsl/async_cogs.rb +49 -0
  17. data/dsl/async_cogs_complex.rb +67 -0
  18. data/dsl/call.rb +44 -0
  19. data/dsl/collect_from.rb +72 -0
  20. data/dsl/demo/Gemfile +4 -0
  21. data/dsl/demo/Gemfile.lock +120 -0
  22. data/dsl/demo/cogs/local.rb +15 -0
  23. data/dsl/demo/simple_external_cog.rb +17 -0
  24. data/dsl/json_output.rb +28 -0
  25. data/dsl/map.rb +55 -0
  26. data/dsl/map_reduce.rb +37 -0
  27. data/dsl/map_with_index.rb +49 -0
  28. data/dsl/next_break.rb +40 -0
  29. data/dsl/next_break_parallel.rb +44 -0
  30. data/dsl/outputs.rb +39 -0
  31. data/dsl/outputs_bang.rb +36 -0
  32. data/dsl/parallel_map.rb +37 -0
  33. data/dsl/plugin-gem-example/.gitignore +8 -0
  34. data/dsl/plugin-gem-example/Gemfile +13 -0
  35. data/dsl/plugin-gem-example/Gemfile.lock +178 -0
  36. data/dsl/plugin-gem-example/lib/other.rb +17 -0
  37. data/dsl/plugin-gem-example/lib/plugin_gem_example.rb +5 -0
  38. data/dsl/plugin-gem-example/lib/simple.rb +15 -0
  39. data/dsl/plugin-gem-example/lib/version.rb +10 -0
  40. data/dsl/plugin-gem-example/plugin-gem-example.gemspec +28 -0
  41. data/dsl/prompts/simple_prompt.md.erb +3 -0
  42. data/dsl/prototype.rb +10 -4
  43. data/dsl/repeat_loop_results.rb +53 -0
  44. data/dsl/ruby_cog.rb +72 -0
  45. data/dsl/simple_agent.rb +18 -0
  46. data/dsl/simple_chat.rb +26 -0
  47. data/dsl/simple_repeat.rb +29 -0
  48. data/dsl/skip.rb +36 -0
  49. data/dsl/step_communication.rb +10 -5
  50. data/dsl/targets_and_params.rb +57 -0
  51. data/dsl/temperature.rb +17 -0
  52. data/dsl/temporary_directory.rb +22 -0
  53. data/dsl/tutorial/01_your_first_workflow/README.md +179 -0
  54. data/dsl/tutorial/01_your_first_workflow/configured_chat.rb +33 -0
  55. data/dsl/tutorial/01_your_first_workflow/hello.rb +23 -0
  56. data/dsl/tutorial/02_chaining_cogs/README.md +310 -0
  57. data/dsl/tutorial/02_chaining_cogs/code_review.rb +104 -0
  58. data/dsl/tutorial/02_chaining_cogs/session_resumption.rb +92 -0
  59. data/dsl/tutorial/02_chaining_cogs/simple_chain.rb +84 -0
  60. data/dsl/tutorial/03_targets_and_params/README.md +230 -0
  61. data/dsl/tutorial/03_targets_and_params/multiple_targets.rb +65 -0
  62. data/dsl/tutorial/03_targets_and_params/single_target.rb +65 -0
  63. data/dsl/tutorial/04_configuration_options/README.md +209 -0
  64. data/dsl/tutorial/04_configuration_options/control_display_and_temperature.rb +104 -0
  65. data/dsl/tutorial/04_configuration_options/simple_config.rb +68 -0
  66. data/dsl/tutorial/05_control_flow/README.md +156 -0
  67. data/dsl/tutorial/05_control_flow/conditional_execution.rb +62 -0
  68. data/dsl/tutorial/05_control_flow/handling_failures.rb +77 -0
  69. data/dsl/tutorial/06_reusable_scopes/README.md +172 -0
  70. data/dsl/tutorial/06_reusable_scopes/accessing_scope_outputs.rb +126 -0
  71. data/dsl/tutorial/06_reusable_scopes/basic_scope.rb +63 -0
  72. data/dsl/tutorial/06_reusable_scopes/parameterized_scope.rb +78 -0
  73. data/dsl/tutorial/07_processing_collections/README.md +152 -0
  74. data/dsl/tutorial/07_processing_collections/basic_map.rb +70 -0
  75. data/dsl/tutorial/07_processing_collections/parallel_map.rb +74 -0
  76. data/dsl/tutorial/08_iterative_workflows/README.md +231 -0
  77. data/dsl/tutorial/08_iterative_workflows/basic_repeat.rb +57 -0
  78. data/dsl/tutorial/08_iterative_workflows/conditional_break.rb +57 -0
  79. data/dsl/tutorial/09_async_cogs/README.md +197 -0
  80. data/dsl/tutorial/09_async_cogs/basic_async.rb +38 -0
  81. data/dsl/tutorial/README.md +222 -0
  82. data/dsl/working_directory.rb +16 -0
  83. data/exe/roast +1 -1
  84. data/internal/documentation/architectural-notes.md +115 -0
  85. data/internal/documentation/doc-comments-external.md +686 -0
  86. data/internal/documentation/doc-comments-internal.md +342 -0
  87. data/internal/documentation/doc-comments.md +211 -0
  88. data/lib/roast/dsl/cog/config.rb +280 -4
  89. data/lib/roast/dsl/cog/input.rb +73 -0
  90. data/lib/roast/dsl/cog/output.rb +313 -0
  91. data/lib/roast/dsl/cog/registry.rb +71 -0
  92. data/lib/roast/dsl/cog/stack.rb +3 -2
  93. data/lib/roast/dsl/cog/store.rb +11 -8
  94. data/lib/roast/dsl/cog.rb +108 -31
  95. data/lib/roast/dsl/cog_input_context.rb +44 -0
  96. data/lib/roast/dsl/cog_input_manager.rb +156 -0
  97. data/lib/roast/dsl/cogs/agent/config.rb +465 -0
  98. data/lib/roast/dsl/cogs/agent/input.rb +81 -0
  99. data/lib/roast/dsl/cogs/agent/output.rb +59 -0
  100. data/lib/roast/dsl/cogs/agent/provider.rb +51 -0
  101. data/lib/roast/dsl/cogs/agent/providers/claude/claude_invocation.rb +185 -0
  102. data/lib/roast/dsl/cogs/agent/providers/claude/message.rb +73 -0
  103. data/lib/roast/dsl/cogs/agent/providers/claude/messages/assistant_message.rb +36 -0
  104. data/lib/roast/dsl/cogs/agent/providers/claude/messages/result_message.rb +61 -0
  105. data/lib/roast/dsl/cogs/agent/providers/claude/messages/system_message.rb +47 -0
  106. data/lib/roast/dsl/cogs/agent/providers/claude/messages/text_message.rb +36 -0
  107. data/lib/roast/dsl/cogs/agent/providers/claude/messages/tool_result_message.rb +47 -0
  108. data/lib/roast/dsl/cogs/agent/providers/claude/messages/tool_use_message.rb +46 -0
  109. data/lib/roast/dsl/cogs/agent/providers/claude/messages/unknown_message.rb +27 -0
  110. data/lib/roast/dsl/cogs/agent/providers/claude/messages/user_message.rb +37 -0
  111. data/lib/roast/dsl/cogs/agent/providers/claude/tool_result.rb +51 -0
  112. data/lib/roast/dsl/cogs/agent/providers/claude/tool_use.rb +48 -0
  113. data/lib/roast/dsl/cogs/agent/providers/claude.rb +31 -0
  114. data/lib/roast/dsl/cogs/agent/stats.rb +92 -0
  115. data/lib/roast/dsl/cogs/agent/usage.rb +62 -0
  116. data/lib/roast/dsl/cogs/agent.rb +75 -0
  117. data/lib/roast/dsl/cogs/chat/config.rb +453 -0
  118. data/lib/roast/dsl/cogs/chat/input.rb +92 -0
  119. data/lib/roast/dsl/cogs/chat/output.rb +64 -0
  120. data/lib/roast/dsl/cogs/chat/session.rb +68 -0
  121. data/lib/roast/dsl/cogs/chat.rb +81 -0
  122. data/lib/roast/dsl/cogs/cmd.rb +291 -27
  123. data/lib/roast/dsl/cogs/ruby.rb +171 -0
  124. data/lib/roast/dsl/command_runner.rb +191 -0
  125. data/lib/roast/dsl/config_context.rb +2 -47
  126. data/lib/roast/dsl/config_manager.rb +143 -0
  127. data/lib/roast/dsl/control_flow.rb +41 -0
  128. data/lib/roast/dsl/execution_context.rb +9 -0
  129. data/lib/roast/dsl/execution_manager.rb +267 -0
  130. data/lib/roast/dsl/nil_assertions.rb +23 -0
  131. data/lib/roast/dsl/system_cog/params.rb +32 -0
  132. data/lib/roast/dsl/system_cog.rb +36 -0
  133. data/lib/roast/dsl/system_cogs/call.rb +162 -0
  134. data/lib/roast/dsl/system_cogs/map.rb +448 -0
  135. data/lib/roast/dsl/system_cogs/repeat.rb +242 -0
  136. data/lib/roast/dsl/workflow.rb +123 -0
  137. data/lib/roast/dsl/workflow_context.rb +20 -0
  138. data/lib/roast/dsl/workflow_params.rb +24 -0
  139. data/lib/roast/sorbet_runtime_stub.rb +154 -0
  140. data/lib/roast/tools/apply_diff.rb +1 -3
  141. data/lib/roast/tools/cmd.rb +4 -3
  142. data/lib/roast/tools/read_file.rb +1 -1
  143. data/lib/roast/tools/update_files.rb +1 -1
  144. data/lib/roast/tools/write_file.rb +1 -1
  145. data/lib/roast/version.rb +1 -1
  146. data/lib/roast/workflow/base_workflow.rb +4 -0
  147. data/lib/roast/workflow/step_loader.rb +14 -2
  148. data/lib/roast-ai.rb +4 -0
  149. data/lib/roast.rb +60 -22
  150. data/{roast.gemspec → roast-ai.gemspec} +10 -13
  151. data/sorbet/config +1 -0
  152. data/sorbet/rbi/gems/async@2.34.0.rbi +1577 -0
  153. data/sorbet/rbi/gems/cli-kit@5.2.0.rbi +2063 -0
  154. data/sorbet/rbi/gems/{cli-ui@2.3.0.rbi → cli-ui@2.7.0-6bdefd1d06305e5d6ae312ac76f9c88f88658dda.rbi} +1418 -1013
  155. data/sorbet/rbi/gems/console@1.34.2.rbi +1193 -0
  156. data/sorbet/rbi/gems/fiber-annotation@0.2.0.rbi +50 -0
  157. data/sorbet/rbi/gems/fiber-local@1.1.0.rbi +35 -0
  158. data/sorbet/rbi/gems/fiber-storage@1.0.1.rbi +41 -0
  159. data/sorbet/rbi/gems/io-event@1.14.0.rbi +724 -0
  160. data/sorbet/rbi/gems/marcel@1.1.0.rbi +239 -0
  161. data/sorbet/rbi/gems/metrics@0.15.0.rbi +9 -0
  162. data/sorbet/rbi/gems/ruby_llm@1.8.2.rbi +5703 -0
  163. data/sorbet/rbi/gems/traces@0.18.2.rbi +9 -0
  164. data/sorbet/rbi/shims/lib/roast/dsl/cog_input_context.rbi +1197 -0
  165. data/sorbet/rbi/shims/lib/roast/dsl/config_context.rbi +314 -2
  166. data/sorbet/rbi/shims/lib/roast/dsl/execution_context.rbi +498 -0
  167. data/sorbet/tapioca/config.yml +6 -0
  168. data/sorbet/tapioca/require.rb +2 -0
  169. metadata +198 -34
  170. data/dsl/less_simple.rb +0 -112
  171. data/dsl/simple.rb +0 -8
  172. data/lib/roast/dsl/cog_execution_context.rb +0 -29
  173. data/lib/roast/dsl/cogs/graph.rb +0 -53
  174. data/lib/roast/dsl/cogs.rb +0 -65
  175. data/lib/roast/dsl/executor.rb +0 -82
  176. data/lib/roast/dsl/workflow_execution_context.rb +0 -47
  177. data/sorbet/rbi/gems/cgi@0.5.0.rbi +0 -2961
  178. data/sorbet/rbi/gems/claude_swarm@0.1.19.rbi +0 -568
  179. data/sorbet/rbi/gems/cli-kit@5.0.1.rbi +0 -1991
  180. data/sorbet/rbi/gems/dry-configurable@1.3.0.rbi +0 -672
  181. data/sorbet/rbi/gems/dry-core@1.1.0.rbi +0 -1894
  182. data/sorbet/rbi/gems/dry-inflector@1.2.0.rbi +0 -659
  183. data/sorbet/rbi/gems/dry-initializer@3.2.0.rbi +0 -781
  184. data/sorbet/rbi/gems/dry-logic@1.6.0.rbi +0 -1127
  185. data/sorbet/rbi/gems/dry-schema@1.14.1.rbi +0 -3727
  186. data/sorbet/rbi/gems/dry-types@1.8.3.rbi +0 -3969
  187. data/sorbet/rbi/gems/fast-mcp-annotations@1.5.3.rbi +0 -1588
  188. data/sorbet/rbi/gems/mime-types-data@3.2025.0617.rbi +0 -136
  189. data/sorbet/rbi/gems/mime-types@3.7.0.rbi +0 -1342
  190. data/sorbet/rbi/gems/rack@2.2.18.rbi +0 -5659
  191. data/sorbet/rbi/gems/rbs-inline@0.12.0.rbi +0 -2170
  192. data/sorbet/rbi/gems/yard-sorbet@0.9.0.rbi +0 -435
  193. data/sorbet/rbi/gems/yard@0.9.37.rbi +0 -18492
  194. data/sorbet/rbi/shims/lib/roast/dsl/workflow_execution_context.rbi +0 -11
@@ -1,30 +1,306 @@
1
- # typed: false
1
+ # typed: true
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Roast
5
5
  module DSL
6
6
  class Cog
7
+ # Base configuration class for all cogs
8
+ #
9
+ # Provides common configuration methods and utilities for cog behavior.
10
+ # Cogs extend this class to define their own configuration options using either
11
+ # the `field` class method for simple fields or custom methods for complex configuration.
7
12
  class Config
13
+ # Parent class for all configuration-related errors
14
+ class ConfigError < Roast::Error; end
15
+
16
+ # Raised when a configuration value is invalid or missing
17
+ class InvalidConfigError < ConfigError; end
18
+
19
+ # Validate that the config instance has all required parameters set in an acceptable manner
20
+ #
21
+ # Inheriting cogs should implement this method for their config class if validation is desired.
22
+ # This method is called after configuration is complete to ensure all required values are present
23
+ # and valid.
24
+ #
25
+ #: () -> void
26
+ def validate!; end
27
+
28
+ # The internal hash storing all configuration values
29
+ #
30
+ #: Hash[Symbol, untyped]
8
31
  attr_reader :values
9
32
 
33
+ #: (?Hash[Symbol, untyped]) -> void
10
34
  def initialize(initial = {})
11
35
  @values = initial
12
36
  end
13
37
 
38
+ # Merge another config object into this one, returning a new config instance
39
+ #
40
+ # Creates a new config object with values from both this config and the provided config.
41
+ # Values from the provided config take precedence over values from this config.
42
+ #
43
+ # #### See Also
44
+ # - `values`
45
+ #
46
+ #: (Cog::Config) -> Cog::Config
14
47
  def merge(config_object)
15
48
  self.class.new(values.merge(config_object.values))
16
49
  end
17
50
 
18
- # It is recommended to implement a custom config object for a nicer interface,
19
- # but for simple cases where it would just be a key value store we provide one by default.
20
-
51
+ # Set a configuration value using hash-style syntax
52
+ #
53
+ # This method provides basic key-value storage for cog configuration.
54
+ # All standard Roast cogs use imperative setter methods for config values.
55
+ # It is recommended that custom cogs implement their own config classes with similar methods
56
+ # for a more structured interface, but this hash-style syntax is provided for simple cases.
57
+ #
58
+ # #### See Also
59
+ # - `[]`
60
+ #
61
+ #: (Symbol, untyped) -> void
21
62
  def []=(key, value)
22
63
  @values[key] = value
23
64
  end
24
65
 
66
+ # Get a configuration value using hash-style syntax
67
+ #
68
+ # This method provides basic key-value retrieval for cog configuration.
69
+ # All standard Roast cogs use imperative setter methods for config values.
70
+ # It is recommended that custom cogs implement their own config classes with similar methods
71
+ # for a more structured interface, but this hash-style syntax is provided for simple cases.
72
+ #
73
+ # #### See Also
74
+ # - `[]=`
75
+ #
76
+ #: (Symbol) -> untyped
25
77
  def [](key)
26
78
  @values[key]
27
79
  end
80
+
81
+ class << self
82
+ # Define a configuration field with simple, out-of-the-box getter/setter behavior
83
+ # and default value handling
84
+ #
85
+ # #### Generated Methods
86
+ # This method creates two methods for a configuration field:
87
+ # 1. A dual-purpose method (`key`) that gets the value when called without arguments,
88
+ # or sets the value when called with an argument.
89
+ # 2. A bang method (`use_default_#{key}!`) that explicitly resets the field to its default value.
90
+ #
91
+ # When getting a value without arguments, the configured value is returned if set,
92
+ # otherwise the default value is returned.
93
+ # When setting a value with an argument, the validator block is applied if provided.
94
+ #
95
+ # #### Validation
96
+ #
97
+ # This method accepts an optional `validator` block that will be called with the new value
98
+ # when the field's setter method is invoked. The validator should raise an exception if the
99
+ # provided value is not valid. It's return value will be used as the new config value.
100
+ # This allows the validator to coerce an value into a standard form if desired.
101
+ #
102
+ # ##### See Also
103
+ # - `Cog::Config#validate!` - validates the config object as a whole, after all values have been set
104
+ #
105
+ # #### Parameters
106
+ # - `key` - The name of the configuration field
107
+ # - `default` - The default value for this field
108
+ # - `validator` - Optional block that validates and/or transforms the value before storing it
109
+ #
110
+ #: [T] (Symbol, T) ?{(T) -> T} -> void
111
+ def field(key, default, &validator)
112
+ default = default #: as untyped
113
+
114
+ define_method(key) do |*args|
115
+ if args.empty?
116
+ # with no args, return the configured value, or the default
117
+ @values[key] || default.deep_dup
118
+ else
119
+ # with an argument, set the configured value
120
+ new_value = args.first
121
+ @values[key] = validator ? validator.call(new_value) : new_value
122
+ end
123
+ end
124
+
125
+ define_method("use_default_#{key}!".to_sym) do
126
+ # explicitly set the configured value to the default
127
+ @values[key] = default.deep_dup
128
+ end
129
+ end
130
+ end
131
+
132
+ # Configure the cog to run asynchronously in the background
133
+ #
134
+ # When configured to run asynchronously, the cog will execute in the background
135
+ # and the next cog in the workflow will be able to start immediately without waiting
136
+ # for this cog to complete.
137
+ #
138
+ # If this cog has started running, attempts to access its output from another cog will
139
+ # block until this cog completes.
140
+ # If this cog has not yet started, attempts to access its output from another cog will
141
+ # fail in the same way that accessing the output of a synchronous cog that has not yet
142
+ # run would fail.
143
+ #
144
+ # The workflow will not complete until all asynchronous cogs have completed (or failed).
145
+ #
146
+ # #### Inverse Methods
147
+ # - `no_async!`
148
+ # - `sync!`
149
+ #
150
+ # #### See Also
151
+ # - `async?`
152
+ #
153
+ #: () -> void
154
+ def async!
155
+ @values[:async] = true
156
+ end
157
+
158
+ # Configure the cog __not__ to run asynchronously
159
+ #
160
+ # When configured not to run asynchronously, the cog will execute synchronously
161
+ # and the next cog in the workflow will wait for this cog to complete before starting.
162
+ #
163
+ # #### Alias Methods
164
+ # - `no_async!`
165
+ # - `sync!`
166
+ #
167
+ # #### Inverse Methods
168
+ # - `async!`
169
+ #
170
+ # #### See Also
171
+ # - `async?`
172
+ #
173
+ #: () -> void
174
+ def no_async!
175
+ @values[:async] = false
176
+ end
177
+
178
+ # Check if the cog is configured to run asynchronously
179
+ #
180
+ # #### See Also
181
+ # - `async!`
182
+ # - `no_async!`
183
+ # - `sync!`
184
+ #
185
+ #: () -> bool
186
+ def async?
187
+ !!@values[:async]
188
+ end
189
+
190
+ # Configure the cog to abort the workflow immediately if it fails to complete successfully
191
+ #
192
+ # Enabled by default.
193
+ #
194
+ # #### Inverse Methods
195
+ # - `continue_on_failure!`
196
+ # - `no_abort_on_failure!`
197
+ #
198
+ # #### See Also
199
+ # - `abort_on_failure?`
200
+ #
201
+ #: () -> void
202
+ def abort_on_failure!
203
+ @values[:abort_on_failure] = true
204
+ end
205
+
206
+ # Configure the cog __not__ to abort the workflow if it fails to complete successfully
207
+ #
208
+ # When a cog is configured not to abort on failure, the workflow will continue to run subsequent cogs
209
+ # even if a cog fails. However, attempts to access that cog's output from another cog will fail.
210
+ #
211
+ # #### Alias Methods
212
+ # - `continue_on_failure!`
213
+ #
214
+ # #### Inverse Methods
215
+ # - `abort_on_failure!`
216
+ #
217
+ # #### See Also
218
+ # - `abort_on_failure?`
219
+ #
220
+ #: () -> void
221
+ def no_abort_on_failure!
222
+ @values[:abort_on_failure] = false
223
+ end
224
+
225
+ # Check if the cog is configured to abort the workflow immediately on failure
226
+ #
227
+ # #### See Also
228
+ # - `abort_on_failure!`
229
+ # - `continue_on_failure!`
230
+ # - `no_abort_on_failure!`
231
+ #
232
+ #: () -> bool
233
+ def abort_on_failure?
234
+ @values[:abort_on_failure] ||= true
235
+ end
236
+
237
+ # Configure the cog to run external commands in the specified working directory
238
+ #
239
+ # The directory given can be relative or absolute.
240
+ # If relative, it will be understood in relation to the directory from which Roast is invoked.
241
+ #
242
+ # ---
243
+ #
244
+ # __Important Note__: this configuration option only applies to external commands invoked by a cog
245
+ # It does not affect the working directory in which Roast is running.
246
+ #
247
+ # ---
248
+ #
249
+ # #### See Also
250
+ # - `use_current_working_directory!`
251
+ # - `valid_working_directory`
252
+ #
253
+ #: (String) -> void
254
+ def working_directory(directory)
255
+ @values[:working_directory] = directory
256
+ end
257
+
258
+ # Configure the cog to run in the directory from which Roast is invoked
259
+ #
260
+ # ---
261
+ #
262
+ # __Important Note__: this configuration option only applies to external commands invoked by a cog
263
+ # It does not affect the working directory in which Roast is running.
264
+ #
265
+ # ---
266
+ #
267
+ # #### See Also
268
+ # - `working_directory`
269
+ # - `valid_working_directory`
270
+ #
271
+ #: () -> void
272
+ def use_current_working_directory!
273
+ @values[:working_directory] = nil
274
+ end
275
+
276
+ # Get the validated, configured value for the working directory path in which the cog should run
277
+ #
278
+ # A value of `nil` means to use the current working directory.
279
+ # This method will raise an `InvalidConfigError` if the path does not exist or is not a directory.
280
+ #
281
+ # ---
282
+ #
283
+ # __Important Note__: this configuration option only applies to external commands invoked by a cog
284
+ # It does not affect the working directory in which Roast is running.
285
+ #
286
+ # ---
287
+ #
288
+ # #### See Also
289
+ # - `working_directory`
290
+ # - `use_current_working_directory!`
291
+ #
292
+ #: () -> Pathname?
293
+ def valid_working_directory
294
+ path = Pathname.new(@values[:working_directory]).expand_path if @values[:working_directory]
295
+ return unless path
296
+ raise InvalidConfigError, "working directory '#{path}' does not exist'" unless path.exist?
297
+ raise InvalidConfigError, "working directory '#{path}' is not a directory'" unless path.directory?
298
+
299
+ path
300
+ end
301
+
302
+ alias_method(:continue_on_failure!, :no_abort_on_failure!)
303
+ alias_method(:sync!, :no_async!)
28
304
  end
29
305
  end
30
306
  end
@@ -0,0 +1,73 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ module DSL
6
+ class Cog
7
+ # Abstract parent class for inputs provided to a cog when it runs
8
+ #
9
+ # Cogs extend this class to define their own input types that specify what data the cog needs to execute.
10
+ # Input classes must be instantiatable with a no-argument constructor and expose methods to incrementally
11
+ # set their values.
12
+ #
13
+ # The input lifecycle:
14
+ # 1. An input instance is created when the cog is invoked
15
+ # 2. The `validate!` method is called to see if all required parameters are set correctly (errors are swallowed)
16
+ # 3. If validation fails, the `coerce` method is called with the return value from the input block (if provided)
17
+ # 4. The `validate!` method is called again to ensure all required parameters are set correctly (errors are raised)
18
+ # 4. The validated input is passed to the cog's `execute` method
19
+ class Input
20
+ # Parent class for all errors raised by the Roast::DSL::Input class
21
+ class InputError < Roast::Error; end
22
+
23
+ # Raised when validation fails on a cog's input object.
24
+ class InvalidInputError < InputError; end
25
+
26
+ # Validate that the input instance has all required parameters set in an acceptable manner
27
+ #
28
+ # Subclasses must implement this method to verify that the input is in a valid state before
29
+ # the cog executes. This method should raise an `InvalidInputError` if the input is not valid.
30
+ #
31
+ # #### See Also
32
+ # - `coerce`
33
+ #
34
+ #: () -> void
35
+ def validate!
36
+ raise NotImplementedError
37
+ end
38
+
39
+ # Use the value returned from the cog's input block to coerce the input to a valid state
40
+ #
41
+ # Subclasses may implement this method to automatically configure the input based on the return
42
+ # value from the input block. This is optional; if not implemented, the default behavior is to
43
+ # do nothing.
44
+ #
45
+ # #### See Also
46
+ # - `validate!`
47
+ #
48
+ #: (untyped) -> void
49
+ def coerce(input_return_value)
50
+ @coerce_ran = true
51
+ end
52
+
53
+ private
54
+
55
+ # Determine whether the input's coerce method has already been attempted
56
+ #
57
+ # This can be useful for validate! to adapt its behaviour based on whether it is being called the first
58
+ # or second time.
59
+ #
60
+ # For instance, if an input has an attribute than can legitimately be `nil`, but the cog
61
+ # still wants to attempt coercion if the attribute is not set to a non-`nil` value initially, `validate!`
62
+ # can be implemented to raise `InvalidInputError` if the attribute is `nil` and `coerce_ran?` is `false`,
63
+ # but not to raise if `coerce_ran?` is `true`, to allow the input to be ultimately validated with a `nil`
64
+ # value for that attribute.
65
+ #
66
+ #: () -> bool
67
+ def coerce_ran?
68
+ @coerce_ran ||= false
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,313 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Roast
5
+ module DSL
6
+ class Cog
7
+ # Generic output from running a cog.
8
+ # Cogs should extend this class with their own output types.
9
+ class Output
10
+ # @requires_ancestor: Roast::DSL::Cog::Output
11
+ module WithJson
12
+ # Get parsed JSON from the output, raising an error if parsing fails
13
+ #
14
+ # This method attempts to parse JSON from the output text using multiple fallback strategies,
15
+ # including extracting from code blocks and JSON-like patterns. If the input is nil or empty,
16
+ # an empty hash is returned.
17
+ #
18
+ # #### See Also
19
+ # - `json`
20
+ #
21
+ #: () -> Hash[Symbol, untyped]
22
+ def json!
23
+ input = raw_text
24
+ return {} if input.nil? || input.strip.empty?
25
+
26
+ @json ||= parse_json_with_fallbacks(input)
27
+ end
28
+
29
+ # Get parsed JSON from the output, returning nil if parsing fails
30
+ #
31
+ # This method provides a safe alternative to `json!` that returns `nil` instead of raising
32
+ # an error when JSON parsing fails.
33
+ #
34
+ # #### See Also
35
+ # - `json!`
36
+ #
37
+ #: () -> Hash[Symbol, untyped]?
38
+ def json
39
+ json!
40
+ rescue JSON::ParserError
41
+ nil
42
+ end
43
+
44
+ private
45
+
46
+ # Cogs should implement this method to provide the text value that should be parsed to provide the 'json' attribute
47
+ #
48
+ #: () -> String?
49
+ def raw_text
50
+ raise NotImplementedError
51
+ end
52
+
53
+ # Try parsing JSON from various possible formats in priority order
54
+ #
55
+ #: (String) -> Hash[Symbol, untyped]
56
+ def parse_json_with_fallbacks(input)
57
+ candidates = extract_json_candidates(input)
58
+ candidates.each do |candidate|
59
+ return JSON.parse(candidate.strip, symbolize_names: true)
60
+ rescue JSON::ParserError, TypeError
61
+ nil
62
+ end
63
+ raise JSON::ParserError, "Could not parse JSON from input:\n---\n#{input}\n---"
64
+ end
65
+
66
+ # Extract potential JSON strings in priority order
67
+ #
68
+ #: (String) -> Array[String]
69
+ def extract_json_candidates(input)
70
+ [
71
+ input.strip, # 1. Entire input
72
+ *extract_code_blocks(input, "json").reverse, # 2. ```json blocks (last first)
73
+ *extract_code_blocks(input, nil).reverse, # 3. ``` blocks (last first)
74
+ *extract_code_blocks(input, :any).reverse, # 4. ```type blocks (last first)
75
+ *extract_json_like_blocks(input), # 5. { } or [ ] blocks (longest first)
76
+ ].compact.uniq
77
+ end
78
+
79
+ # Extract code blocks with optional language specifier
80
+ # language can be: String (exact match), nil (no language), :any (any language except json/nil)
81
+ #
82
+ #: (String, String | Symbol | nil) -> Array[String]
83
+ def extract_code_blocks(input, language)
84
+ blocks = []
85
+ parts = input.split("```")
86
+
87
+ # Process pairs of splits (opening ``` and closing ```)
88
+ (1...parts.length).step(2) do |i|
89
+ block_with_header = parts[i]
90
+ next unless block_with_header
91
+
92
+ lines = block_with_header.lines
93
+ first_line = lines.first&.strip || ""
94
+ content = (lines[1..] || []).join
95
+
96
+ case language
97
+ when String
98
+ blocks << content if first_line == language
99
+ when nil
100
+ blocks << content if first_line.empty?
101
+ when :any
102
+ blocks << content if !first_line.empty? && first_line != "json"
103
+ end
104
+ end
105
+
106
+ blocks
107
+ rescue
108
+ []
109
+ end
110
+
111
+ # Extract blocks that look like JSON objects or arrays
112
+ #
113
+ #: (String) -> Array[String]
114
+ def extract_json_like_blocks(input)
115
+ blocks = []
116
+
117
+ # Find all potential JSON blocks starting with { or [ and ending with } or ]
118
+ input.scan(/^[ \t]*([{\[].*?[}\]])[ \t]*$/m) do |match|
119
+ blocks << match[0]
120
+ end
121
+
122
+ # Also try to find JSON anywhere in the text (not just at line boundaries)
123
+ input.scan(/([{\[](?:[^{}\[\]]|(?:\{(?:[^{}]|\{[^{}]*\})*\})|(?:\[(?:[^\[\]]|\[[^\[\]]*\])*\]))*[}\]])/m) do |match|
124
+ blocks << match[0]
125
+ end
126
+
127
+ # Sort by length (longest first) and deduplicate
128
+ blocks.uniq.sort_by { |b| -b.length }
129
+ end
130
+ rescue
131
+ []
132
+ end
133
+
134
+ # @requires_ancestor: Roast::DSL::Cog::Output
135
+ module WithNumber
136
+ # Get parsed float from the output, raising an error if parsing fails
137
+ #
138
+ # This method attempts to parse a float from the output text using multiple permissive fallback
139
+ # strategies to extract a substring that looks like a number.
140
+ #
141
+ # Raises `ArgumentError` if output text does not contain any value that can be parsed as a number.
142
+ #
143
+ # #### See Also
144
+ # - `float`
145
+ # - `integer!`
146
+ # - `integer`
147
+ #
148
+ #: () -> Float
149
+ def float!
150
+ @float ||= parse_number_with_fallbacks(raw_text || "")
151
+ end
152
+
153
+ # Get parsed float from the output, returning nil if parsing fails
154
+ #
155
+ # This method attempts to parse a float from the output text using multiple permissive fallback
156
+ # strategies to extract a substring that looks like a number.
157
+ #
158
+ # Returns `nil` if output text does not contain any value that can be parsed as a number.
159
+ #
160
+ # #### See Also
161
+ # - `float!`
162
+ # - `integer!`
163
+ # - `integer`
164
+ #
165
+ #: () -> Float?
166
+ def float
167
+ float!
168
+ rescue ArgumentError
169
+ nil
170
+ end
171
+
172
+ # Get parsed integer from the output, raising an error if parsing fails
173
+ #
174
+ # This method attempts to parse an integer from the output text using multiple permissive fallback
175
+ # strategies to extract a substring that looks like a number. This method will attempt to parse
176
+ # and round a floating point value; it will not strictly match only integers in the source text.
177
+ #
178
+ # Raises `ArgumentError` if output text does not contain any value that can be parsed as a number.
179
+ #
180
+ # #### See Also
181
+ # - `integer`
182
+ # - `float!`
183
+ # - `float`
184
+ #
185
+ #: () -> Integer
186
+ def integer!
187
+ @integer ||= float!.round
188
+ end
189
+
190
+ # Get parsed integer from the output, returning nil if parsing fails
191
+ #
192
+ # This method attempts to parse an integer from the output text using multiple permissive fallback
193
+ # strategies to extract a substring that looks like a number. This method will attempt to parse
194
+ # and round a floating point value; it will not strictly match only integers in the source text.
195
+ #
196
+ # Returns `nil` if output text does not contain any value that can be parsed as a number.
197
+ #
198
+ # #### See Also
199
+ # - `integer!`
200
+ # - `float!`
201
+ # - `float`
202
+ #
203
+ #: () -> Integer?
204
+ def integer
205
+ integer!
206
+ rescue ArgumentError
207
+ nil
208
+ end
209
+
210
+ private
211
+
212
+ # Try parsing a number from various possible formats in priority order
213
+ #
214
+ #: (String) -> Float
215
+ def parse_number_with_fallbacks(input)
216
+ candidates = extract_number_candidates(input)
217
+ candidates.each do |candidate|
218
+ normalized = normalize_number_string(candidate)
219
+ next if normalized.nil?
220
+
221
+ return Float(normalized)
222
+ rescue ArgumentError, TypeError
223
+ next
224
+ end
225
+ raise ArgumentError, "Could not parse number from input:\n---\n#{input}\n---"
226
+ end
227
+
228
+ # Extract potential number strings in priority order
229
+ #
230
+ #: (String) -> Array[String]
231
+ def extract_number_candidates(input)
232
+ candidates = []
233
+
234
+ # 1. Try the entire string
235
+ candidates << input.strip
236
+
237
+ # 2. Try each line from bottom up (with whitespace stripped)
238
+ lines = input.lines.map(&:strip).reject(&:empty?)
239
+ candidates.concat(lines.reverse)
240
+
241
+ # 3. Try to extract numbers from each line from bottom up
242
+ lines.reverse.each do |line|
243
+ # Look for numbers with various separators, formats, and currency symbols (very permissive)
244
+ # Matches: 123, 1,234, 1_234, 1 234, 1.23, -1.23, 1.23e10, 1.23e-10
245
+ matches = line.scan(/-?[\d\s$¢£€¥.,_]+(?:[eE][+-]?\d+)?/) #: as Array[String]
246
+ candidates.concat(matches.map(&:strip))
247
+ end
248
+
249
+ candidates.compact.uniq
250
+ end
251
+
252
+ # Normalize a number string by removing separators and currency codes and validating format
253
+ #
254
+ #: (String) -> String?
255
+ def normalize_number_string(raw)
256
+ # Remove common digit separators and currency codes
257
+ normalized = raw.strip.gsub(/[\s$¢£€¥,_]/, "")
258
+
259
+ # Validate it looks like a number (optional minus, digits, optional decimal, optional scientific notation)
260
+ normalized if normalized.match?(/\A-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?\z/)
261
+ end
262
+ end
263
+
264
+ # @requires_ancestor: Roast::DSL::Cog::Output
265
+ module WithText
266
+ # Get the output as a single string with surrounding whitespace removed
267
+ #
268
+ # This method returns the text output with leading and trailing whitespace stripped.
269
+ #
270
+ # #### See Also
271
+ # - `lines`
272
+ #
273
+ #: () -> String
274
+ def text
275
+ raw_text.strip
276
+ end
277
+
278
+ # Get the output as an array of lines with each line's whitespace stripped
279
+ #
280
+ # This method splits the output into individual lines and removes leading and trailing
281
+ # whitespace from each line.
282
+ #
283
+ # #### See Also
284
+ # - `text`
285
+ #
286
+ #: () -> Array[String]
287
+ def lines
288
+ raw_text.lines.map(&:strip)
289
+ end
290
+
291
+ private
292
+
293
+ # Cogs should implement this method to provide the text value of their output
294
+ #
295
+ #: () -> String
296
+ def raw_text
297
+ raise NotImplementedError
298
+ end
299
+ end
300
+
301
+ private
302
+
303
+ # Cogs should implement this method to provide the text value that should be parsed to provide the
304
+ # the values produced by the `WithText`, `WithJson`, and `WithNumber` modules.
305
+ #
306
+ #: () -> String?
307
+ def raw_text
308
+ raise NotImplementedError
309
+ end
310
+ end
311
+ end
312
+ end
313
+ end