roast-ai 0.4.0 → 0.4.2

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +2 -2
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +103 -0
  5. data/CLAUDE.md +55 -9
  6. data/Gemfile.lock +19 -10
  7. data/README.md +69 -3
  8. data/bin/console +1 -0
  9. data/docs/AGENT_STEPS.md +33 -9
  10. data/docs/VALIDATION.md +178 -0
  11. data/examples/agent_continue/add_documentation/prompt.md +5 -0
  12. data/examples/agent_continue/add_error_handling/prompt.md +5 -0
  13. data/examples/agent_continue/analyze_codebase/prompt.md +7 -0
  14. data/examples/agent_continue/combined_workflow.yml +24 -0
  15. data/examples/agent_continue/continue_adding_features/prompt.md +4 -0
  16. data/examples/agent_continue/create_integration_tests/prompt.md +3 -0
  17. data/examples/agent_continue/document_with_context/prompt.md +5 -0
  18. data/examples/agent_continue/explore_api/prompt.md +6 -0
  19. data/examples/agent_continue/implement_client/prompt.md +6 -0
  20. data/examples/agent_continue/inline_workflow.yml +20 -0
  21. data/examples/agent_continue/refactor_code/prompt.md +2 -0
  22. data/examples/agent_continue/verify_changes/prompt.md +6 -0
  23. data/examples/agent_continue/workflow.yml +27 -0
  24. data/examples/agent_workflow/workflow.png +0 -0
  25. data/examples/api_workflow/workflow.png +0 -0
  26. data/examples/apply_diff_demo/README.md +58 -0
  27. data/examples/apply_diff_demo/apply_simple_change/prompt.md +13 -0
  28. data/examples/apply_diff_demo/create_sample_file/prompt.md +11 -0
  29. data/examples/apply_diff_demo/workflow.yml +24 -0
  30. data/examples/available_tools_demo/workflow.png +0 -0
  31. data/examples/bash_prototyping/api_testing.png +0 -0
  32. data/examples/bash_prototyping/system_analysis.png +0 -0
  33. data/examples/case_when/workflow.png +0 -0
  34. data/examples/cmd/basic_workflow.png +0 -0
  35. data/examples/cmd/dev_workflow.png +0 -0
  36. data/examples/cmd/explorer_workflow.png +0 -0
  37. data/examples/conditional/simple_workflow.png +0 -0
  38. data/examples/conditional/workflow.png +0 -0
  39. data/examples/context_management_demo/README.md +43 -0
  40. data/examples/context_management_demo/workflow.yml +42 -0
  41. data/examples/direct_coerce_syntax/workflow.png +0 -0
  42. data/examples/dot_notation/workflow.png +0 -0
  43. data/examples/exit_on_error/workflow.png +0 -0
  44. data/examples/grading/rb_test_runner +1 -1
  45. data/examples/grading/workflow.png +0 -0
  46. data/examples/interpolation/workflow.png +0 -0
  47. data/examples/interpolation/workflow.yml +1 -1
  48. data/examples/iteration/workflow.png +0 -0
  49. data/examples/json_handling/workflow.png +0 -0
  50. data/examples/mcp/database_workflow.png +0 -0
  51. data/examples/mcp/env_demo/workflow.png +0 -0
  52. data/examples/mcp/filesystem_demo/workflow.png +0 -0
  53. data/examples/mcp/github_workflow.png +0 -0
  54. data/examples/mcp/multi_mcp_workflow.png +0 -0
  55. data/examples/mcp/workflow.png +0 -0
  56. data/examples/no_model_fallback/README.md +17 -0
  57. data/examples/no_model_fallback/analyze_file/prompt.md +1 -0
  58. data/examples/no_model_fallback/analyze_patterns/prompt.md +27 -0
  59. data/examples/no_model_fallback/generate_report_for_md/prompt.md +10 -0
  60. data/examples/no_model_fallback/generate_report_for_rb/prompt.md +3 -0
  61. data/examples/no_model_fallback/sample.rb +42 -0
  62. data/examples/no_model_fallback/workflow.yml +19 -0
  63. data/examples/openrouter_example/workflow.png +0 -0
  64. data/examples/pre_post_processing/workflow.png +0 -0
  65. data/examples/rspec_to_minitest/workflow.png +0 -0
  66. data/examples/shared_config/example_with_shared_config/workflow.png +0 -0
  67. data/examples/shared_config/shared.png +0 -0
  68. data/examples/single_target_prepost/workflow.png +0 -0
  69. data/examples/smart_coercion_defaults/workflow.png +0 -0
  70. data/examples/step_configuration/workflow.png +0 -0
  71. data/examples/swarm_example.yml +25 -0
  72. data/examples/tool_config_example/workflow.png +0 -0
  73. data/examples/user_input/funny_name/workflow.png +0 -0
  74. data/examples/user_input/simple_input_demo/workflow.png +0 -0
  75. data/examples/user_input/survey_workflow.png +0 -0
  76. data/examples/user_input/workflow.png +0 -0
  77. data/examples/workflow_generator/workflow.png +0 -0
  78. data/lib/roast/errors.rb +3 -0
  79. data/lib/roast/helpers/timeout_handler.rb +91 -0
  80. data/lib/roast/services/context_threshold_checker.rb +42 -0
  81. data/lib/roast/services/token_counting_service.rb +44 -0
  82. data/lib/roast/tools/apply_diff.rb +128 -0
  83. data/lib/roast/tools/bash.rb +15 -9
  84. data/lib/roast/tools/cmd.rb +32 -12
  85. data/lib/roast/tools/coding_agent.rb +65 -10
  86. data/lib/roast/tools/context_summarizer.rb +108 -0
  87. data/lib/roast/tools/swarm.rb +124 -0
  88. data/lib/roast/version.rb +1 -1
  89. data/lib/roast/workflow/agent_step.rb +9 -2
  90. data/lib/roast/workflow/base_iteration_step.rb +3 -2
  91. data/lib/roast/workflow/base_workflow.rb +41 -2
  92. data/lib/roast/workflow/command_executor.rb +3 -1
  93. data/lib/roast/workflow/configuration.rb +2 -1
  94. data/lib/roast/workflow/configuration_loader.rb +63 -1
  95. data/lib/roast/workflow/configuration_parser.rb +2 -0
  96. data/lib/roast/workflow/context_manager.rb +89 -0
  97. data/lib/roast/workflow/each_step.rb +1 -1
  98. data/lib/roast/workflow/input_step.rb +2 -0
  99. data/lib/roast/workflow/interpolator.rb +23 -1
  100. data/lib/roast/workflow/output_handler.rb +1 -1
  101. data/lib/roast/workflow/repeat_step.rb +1 -1
  102. data/lib/roast/workflow/replay_handler.rb +1 -1
  103. data/lib/roast/workflow/sqlite_state_repository.rb +342 -0
  104. data/lib/roast/workflow/state_manager.rb +2 -2
  105. data/lib/roast/workflow/state_repository_factory.rb +36 -0
  106. data/lib/roast/workflow/step_completion_reporter.rb +27 -0
  107. data/lib/roast/workflow/step_executor_coordinator.rb +19 -18
  108. data/lib/roast/workflow/step_executor_with_reporting.rb +68 -0
  109. data/lib/roast/workflow/step_loader.rb +1 -1
  110. data/lib/roast/workflow/step_name_extractor.rb +84 -0
  111. data/lib/roast/workflow/validation_command.rb +197 -0
  112. data/lib/roast/workflow/validators/base_validator.rb +44 -0
  113. data/lib/roast/workflow/validators/dependency_validator.rb +223 -0
  114. data/lib/roast/workflow/validators/linting_validator.rb +113 -0
  115. data/lib/roast/workflow/validators/schema_validator.rb +90 -0
  116. data/lib/roast/workflow/validators/step_collector.rb +57 -0
  117. data/lib/roast/workflow/validators/validation_orchestrator.rb +52 -0
  118. data/lib/roast/workflow/workflow_executor.rb +11 -4
  119. data/lib/roast/workflow/workflow_initializer.rb +80 -0
  120. data/lib/roast/workflow/workflow_runner.rb +6 -0
  121. data/lib/roast/workflow_diagram_generator.rb +298 -0
  122. data/lib/roast.rb +158 -0
  123. data/roast.gemspec +4 -1
  124. data/schema/workflow.json +77 -1
  125. metadata +129 -1
@@ -28,11 +28,11 @@ module Roast
28
28
  is_last_step = (index == workflow_steps.length - 1)
29
29
  case step
30
30
  when Hash
31
- execute(step, is_last_step: is_last_step)
31
+ execute(step, is_last_step:)
32
32
  when Array
33
- execute(step, is_last_step: is_last_step)
33
+ execute(step, is_last_step:)
34
34
  when String
35
- execute(step, is_last_step: is_last_step)
35
+ execute(step, is_last_step:)
36
36
  # Handle pause after string steps
37
37
  if @context.workflow.pause_step_name == step
38
38
  Kernel.binding.irb # rubocop:disable Lint/Debugger
@@ -62,17 +62,17 @@ module Roast
62
62
  when StepTypeResolver::AGENT_STEP
63
63
  execute_agent_step(step, options)
64
64
  when StepTypeResolver::GLOB_STEP
65
- execute_glob_step(step)
65
+ execute_glob_step(step, options)
66
66
  when StepTypeResolver::ITERATION_STEP
67
- execute_iteration_step(step)
67
+ execute_iteration_step(step, options)
68
68
  when StepTypeResolver::CONDITIONAL_STEP
69
- execute_conditional_step(step)
69
+ execute_conditional_step(step, options)
70
70
  when StepTypeResolver::CASE_STEP
71
- execute_case_step(step)
71
+ execute_case_step(step, options)
72
72
  when StepTypeResolver::INPUT_STEP
73
- execute_input_step(step)
73
+ execute_input_step(step, options)
74
74
  when StepTypeResolver::HASH_STEP
75
- execute_hash_step(step)
75
+ execute_hash_step(step, options)
76
76
  when StepTypeResolver::PARALLEL_STEP
77
77
  # Use factory for parallel steps
78
78
  executor = StepExecutorFactory.for(step, workflow_executor)
@@ -189,11 +189,11 @@ module Roast
189
189
  step_orchestrator.execute_step(step_name, exit_on_error:, step_key: options[:step_key], agent_type: :coding_agent)
190
190
  end
191
191
 
192
- def execute_glob_step(step)
192
+ def execute_glob_step(step, options = {})
193
193
  Dir.glob(step).join("\n")
194
194
  end
195
195
 
196
- def execute_iteration_step(step)
196
+ def execute_iteration_step(step, options = {})
197
197
  name = step.keys.first
198
198
  command = step[name]
199
199
 
@@ -206,19 +206,19 @@ module Roast
206
206
  end
207
207
  end
208
208
 
209
- def execute_conditional_step(step)
209
+ def execute_conditional_step(step, options = {})
210
210
  conditional_executor.execute_conditional(step)
211
211
  end
212
212
 
213
- def execute_case_step(step)
213
+ def execute_case_step(step, options = {})
214
214
  case_executor.execute_case(step)
215
215
  end
216
216
 
217
- def execute_input_step(step)
217
+ def execute_input_step(step, options = {})
218
218
  input_executor.execute_input(step["input"])
219
219
  end
220
220
 
221
- def execute_hash_step(step)
221
+ def execute_hash_step(step, options = {})
222
222
  name, command = step.to_a.flatten
223
223
  interpolated_name = interpolator.interpolate(name)
224
224
 
@@ -230,7 +230,8 @@ module Roast
230
230
 
231
231
  # Execute the command directly using the appropriate executor
232
232
  # Pass the original key name for configuration lookup
233
- result = execute(interpolated_command, { exit_on_error: exit_on_error, step_key: interpolated_name })
233
+ # Merge options to preserve is_last_step
234
+ result = execute(interpolated_command, { exit_on_error:, step_key: interpolated_name }.merge(options))
234
235
  context.workflow.output[interpolated_name] = result
235
236
  result
236
237
  end
@@ -247,10 +248,10 @@ module Roast
247
248
  if StepTypeResolver.command_step?(interpolated_step)
248
249
  # Command step - execute directly, preserving any passed options
249
250
  exit_on_error = options.fetch(:exit_on_error, true)
250
- execute_command_step(interpolated_step, { exit_on_error: exit_on_error })
251
+ execute_command_step(interpolated_step, { exit_on_error: })
251
252
  else
252
253
  exit_on_error = options.fetch(:exit_on_error, context.exit_on_error?(step))
253
- execute_standard_step(interpolated_step, options.merge(exit_on_error: exit_on_error))
254
+ execute_standard_step(interpolated_step, options.merge(exit_on_error:))
254
255
  end
255
256
  end
256
257
 
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Decorator that adds token consumption reporting to step execution
6
+ class StepExecutorWithReporting
7
+ def initialize(base_executor, context, output: $stderr)
8
+ @base_executor = base_executor
9
+ @context = context
10
+ @reporter = StepCompletionReporter.new(output: output)
11
+ @name_extractor = StepNameExtractor.new
12
+ end
13
+
14
+ def execute(step, options = {})
15
+ # Track tokens before execution
16
+ tokens_before = @context.workflow.context_manager&.total_tokens || 0
17
+
18
+ # Execute the step
19
+ result = @base_executor.execute(step, options)
20
+
21
+ # Report token consumption after successful execution
22
+ tokens_after = @context.workflow.context_manager&.total_tokens || 0
23
+ tokens_consumed = tokens_after - tokens_before
24
+
25
+ step_type = StepTypeResolver.resolve(step, @context)
26
+ step_name = @name_extractor.extract(step, step_type)
27
+ @reporter.report(step_name, tokens_consumed, tokens_after)
28
+
29
+ result
30
+ end
31
+
32
+ # Override execute_steps to ensure reporting happens for each step
33
+ def execute_steps(workflow_steps)
34
+ workflow_steps.each_with_index do |step, index|
35
+ is_last_step = (index == workflow_steps.length - 1)
36
+ case step
37
+ when Hash
38
+ execute(step, is_last_step:)
39
+ when Array
40
+ execute(step, is_last_step:)
41
+ when String
42
+ execute(step, is_last_step:)
43
+ # Handle pause after string steps
44
+ if @context.workflow.pause_step_name == step
45
+ Kernel.binding.irb # rubocop:disable Lint/Debugger
46
+ end
47
+ else
48
+ # For other types, delegate to base executor
49
+ execute(step, is_last_step:)
50
+ end
51
+ end
52
+ end
53
+
54
+ # Delegate all other methods to the base executor
55
+ def method_missing(method, *args, **kwargs, &block)
56
+ if @base_executor.respond_to?(method)
57
+ @base_executor.send(method, *args, **kwargs, &block)
58
+ else
59
+ super
60
+ end
61
+ end
62
+
63
+ def respond_to_missing?(method, include_private = false)
64
+ @base_executor.respond_to?(method, include_private) || super
65
+ end
66
+ end
67
+ end
68
+ end
@@ -4,7 +4,7 @@ module Roast
4
4
  module Workflow
5
5
  # Handles loading and instantiation of workflow steps
6
6
  class StepLoader
7
- DEFAULT_MODEL = "openai/gpt-4o-mini"
7
+ DEFAULT_MODEL = "gpt-4o-mini"
8
8
 
9
9
  # Custom exception classes
10
10
  class StepLoaderError < StandardError
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Extracts human-readable names from various step types
6
+ class StepNameExtractor
7
+ def extract(step, step_type)
8
+ case step_type
9
+ when StepTypeResolver::COMMAND_STEP
10
+ extract_command_name(step)
11
+ when StepTypeResolver::HASH_STEP
12
+ extract_hash_step_name(step)
13
+ when StepTypeResolver::ITERATION_STEP
14
+ extract_iteration_step_name(step)
15
+ when StepTypeResolver::CONDITIONAL_STEP
16
+ extract_conditional_step_name(step)
17
+ when StepTypeResolver::CASE_STEP
18
+ "case"
19
+ when StepTypeResolver::INPUT_STEP
20
+ "input"
21
+ when StepTypeResolver::AGENT_STEP
22
+ StepTypeResolver.extract_name(step)
23
+ when StepTypeResolver::STRING_STEP
24
+ step.to_s
25
+ else
26
+ step.to_s
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def extract_command_name(step)
33
+ cmd = step.to_s.strip
34
+ cmd.length > 20 ? "#{cmd[0..19]}..." : cmd
35
+ end
36
+
37
+ def extract_hash_step_name(step)
38
+ key, value = step.to_a.first
39
+
40
+ # Check if this looks like an inline prompt (key is similar to sanitized value)
41
+ if value.is_a?(String)
42
+ # Get first non-empty line
43
+ first_line = value.lines.map(&:strip).find { |line| !line.empty? } || ""
44
+
45
+ # If key looks like it was auto-generated from the content, use truncated content
46
+ sanitized = first_line.downcase.gsub(/[^a-z0-9_]/, "_").squeeze("_").gsub(/^_|_$/, "")
47
+ if key.to_s == sanitized || key.to_s.start_with?(sanitized[0..15])
48
+ # This is likely an inline prompt
49
+ first_line.length > 20 ? "#{first_line[0..19]}..." : first_line
50
+ else
51
+ # This is a labeled step
52
+ key.to_s
53
+ end
54
+ else
55
+ key.to_s
56
+ end
57
+ end
58
+
59
+ def extract_iteration_step_name(step)
60
+ if step.key?("each")
61
+ items = step["each"]
62
+ count = items.respond_to?(:size) ? items.size : "?"
63
+ "each (#{count} items)"
64
+ elsif step.key?("repeat")
65
+ config = step["repeat"]
66
+ times = config.is_a?(Hash) ? config["times"] || "?" : config
67
+ "repeat (#{times} times)"
68
+ else
69
+ "iteration"
70
+ end
71
+ end
72
+
73
+ def extract_conditional_step_name(step)
74
+ if step.key?("if")
75
+ "if"
76
+ elsif step.key?("unless")
77
+ "unless"
78
+ else
79
+ "conditional"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Handles the validation command logic for the CLI
6
+ class ValidationCommand
7
+ def initialize(options = {})
8
+ @options = options
9
+ end
10
+
11
+ def execute(workflow_path = nil)
12
+ workflow_files = resolve_workflow_files(workflow_path)
13
+ validate_workflows(workflow_files)
14
+ end
15
+
16
+ private
17
+
18
+ def resolve_workflow_files(workflow_path)
19
+ if workflow_path.nil?
20
+ find_all_workflows
21
+ else
22
+ [expand_workflow_path(workflow_path)]
23
+ end
24
+ end
25
+
26
+ def find_all_workflows
27
+ roast_dir = File.join(Dir.pwd, "roast")
28
+ unless File.directory?(roast_dir)
29
+ raise Thor::Error, "No roast/ directory found in current path"
30
+ end
31
+
32
+ workflow_files = Dir.glob(File.join(roast_dir, "**/workflow.yml")).sort
33
+ if workflow_files.empty?
34
+ raise Thor::Error, "No workflow.yml files found in roast/ directory"
35
+ end
36
+
37
+ workflow_files
38
+ end
39
+
40
+ def expand_workflow_path(workflow_path)
41
+ expanded_path = if workflow_path.end_with?(".yml", ".yaml") || workflow_path.include?("/")
42
+ File.expand_path(workflow_path)
43
+ else
44
+ File.expand_path("roast/#{workflow_path}/workflow.yml")
45
+ end
46
+
47
+ unless File.exist?(expanded_path)
48
+ raise Thor::Error, "Workflow file not found: #{expanded_path}"
49
+ end
50
+
51
+ expanded_path
52
+ end
53
+
54
+ def validate_workflows(workflow_files)
55
+ results = ValidationResults.new
56
+
57
+ validate_multiple_workflows_display(workflow_files, results)
58
+
59
+ display_summary(results)
60
+ exit_if_needed(results)
61
+ end
62
+
63
+ def validate_multiple_workflows_display(workflow_files, results)
64
+ ::CLI::UI::Frame.open("Validating #{workflow_files.size} workflow(s)") do
65
+ validate_each_workflow(workflow_files, results)
66
+ end
67
+ end
68
+
69
+ def validate_each_workflow(workflow_files, results)
70
+ workflow_files.each do |workflow_path|
71
+ workflow_name = extract_workflow_name(workflow_path)
72
+ validator = create_validator(workflow_path)
73
+ # Ensure validation is performed to populate errors/warnings
74
+ is_valid = validator.valid?
75
+ results.add_result(workflow_path, validator)
76
+
77
+ display_workflow_result(workflow_name, validator, is_valid)
78
+ end
79
+ end
80
+
81
+ def display_workflow_result(workflow_name, validator, is_valid)
82
+ if is_valid
83
+ if validator.warnings.empty?
84
+ puts ::CLI::UI.fmt("{{green:✓}} {{bold:#{workflow_name}}}")
85
+ else
86
+ puts ::CLI::UI.fmt("{{green:✓}} {{bold:#{workflow_name}}} ({{yellow:#{validator.warnings.size} warning(s)}})")
87
+ end
88
+ else
89
+ puts ::CLI::UI.fmt("{{red:✗}} {{bold:#{workflow_name}}} ({{red:#{validator.errors.size} error(s)}})")
90
+ end
91
+ end
92
+
93
+ def create_validator(workflow_path)
94
+ yaml_content = File.read(workflow_path)
95
+ Validators::ValidationOrchestrator.new(yaml_content, workflow_path)
96
+ end
97
+
98
+ def extract_workflow_name(workflow_path)
99
+ workflow_path.sub("#{Dir.pwd}/roast/", "").sub("/workflow.yml", "")
100
+ end
101
+
102
+ def display_summary(results)
103
+ puts
104
+
105
+ if results.total_errors == 0 && results.total_warnings == 0
106
+ puts ::CLI::UI.fmt("{{green:All workflows are valid!}}")
107
+ elsif results.total_errors == 0
108
+ puts ::CLI::UI.fmt("{{green:All workflows are valid}} with {{yellow:#{results.total_warnings} total warning(s)}}")
109
+ display_all_warnings(results)
110
+ else
111
+ puts ::CLI::UI.fmt("{{red:Validation failed:}} #{results.total_errors} error(s), #{results.total_warnings} warning(s)")
112
+ display_all_errors(results)
113
+ display_all_warnings(results) if results.total_warnings > 0
114
+ end
115
+ end
116
+
117
+ def exit_if_needed(results)
118
+ if results.total_errors > 0
119
+ exit(1)
120
+ elsif results.total_warnings > 0 && @options[:strict]
121
+ exit(1)
122
+ end
123
+ end
124
+
125
+ def display_errors(errors)
126
+ ::CLI::UI::Frame.open("Errors", color: :red) do
127
+ errors.each do |error|
128
+ puts ::CLI::UI.fmt("{{red:• #{error[:message]}}}")
129
+ puts ::CLI::UI.fmt(" {{gray:→ #{error[:suggestion]}}}") if error[:suggestion]
130
+ puts
131
+ end
132
+ end
133
+ end
134
+
135
+ def display_warnings(warnings)
136
+ ::CLI::UI::Frame.open("Warnings", color: :yellow) do
137
+ warnings.each do |warning|
138
+ puts ::CLI::UI.fmt("{{yellow:• #{warning[:message]}}}")
139
+ puts ::CLI::UI.fmt(" {{gray:→ #{warning[:suggestion]}}}") if warning[:suggestion]
140
+ puts
141
+ end
142
+ end
143
+ end
144
+
145
+ def display_all_errors(results)
146
+ results.results_with_errors.each do |result|
147
+ workflow_name = extract_workflow_name(result[:path])
148
+ ::CLI::UI::Frame.open("Errors in #{workflow_name}", color: :red) do
149
+ result[:validator].errors.each do |error|
150
+ puts ::CLI::UI.fmt("{{red:• #{error[:message]}}}")
151
+ puts ::CLI::UI.fmt(" {{gray:→ #{error[:suggestion]}}}") if error[:suggestion]
152
+ puts
153
+ end
154
+ end
155
+ end
156
+ end
157
+
158
+ def display_all_warnings(results)
159
+ results.results_with_warnings.each do |result|
160
+ workflow_name = extract_workflow_name(result[:path])
161
+ ::CLI::UI::Frame.open("Warnings in #{workflow_name}", color: :yellow) do
162
+ result[:validator].warnings.each do |warning|
163
+ puts ::CLI::UI.fmt("{{yellow:• #{warning[:message]}}}")
164
+ puts ::CLI::UI.fmt(" {{gray:→ #{warning[:suggestion]}}}") if warning[:suggestion]
165
+ puts
166
+ end
167
+ end
168
+ end
169
+ end
170
+
171
+ # Tracks validation results across multiple workflows
172
+ class ValidationResults
173
+ attr_reader :total_errors, :total_warnings
174
+
175
+ def initialize
176
+ @total_errors = 0
177
+ @total_warnings = 0
178
+ @results = []
179
+ end
180
+
181
+ def add_result(workflow_path, validator)
182
+ @results << { path: workflow_path, validator: validator }
183
+ @total_errors += validator.errors.size
184
+ @total_warnings += validator.warnings.size
185
+ end
186
+
187
+ def results_with_errors
188
+ @results.select { |result| result[:validator].errors.any? }
189
+ end
190
+
191
+ def results_with_warnings
192
+ @results.select { |result| result[:validator].warnings.any? }
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ module Validators
6
+ # Base class for all validators
7
+ class BaseValidator
8
+ attr_reader :errors, :warnings
9
+
10
+ def initialize(parsed_yaml, workflow_path = nil)
11
+ @parsed_yaml = parsed_yaml
12
+ @workflow_path = workflow_path
13
+ @errors = []
14
+ @warnings = []
15
+ end
16
+
17
+ def validate
18
+ raise NotImplementedError, "Subclasses must implement validate"
19
+ end
20
+
21
+ def valid?
22
+ validate
23
+ @errors.empty?
24
+ end
25
+
26
+ protected
27
+
28
+ def add_error(type:, message:, suggestion: nil, **metadata)
29
+ error = { type: type, message: message }
30
+ error[:suggestion] = suggestion if suggestion
31
+ error.merge!(metadata)
32
+ @errors << error
33
+ end
34
+
35
+ def add_warning(type:, message:, suggestion: nil, **metadata)
36
+ warning = { type: type, message: message }
37
+ warning[:suggestion] = suggestion if suggestion
38
+ warning.merge!(metadata)
39
+ @warnings << warning
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end