roast-ai 0.4.0 → 0.4.1

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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +2 -2
  3. data/CHANGELOG.md +65 -0
  4. data/CLAUDE.md +55 -9
  5. data/Gemfile +1 -0
  6. data/Gemfile.lock +8 -1
  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/workflow.png +0 -0
  45. data/examples/interpolation/workflow.png +0 -0
  46. data/examples/interpolation/workflow.yml +1 -1
  47. data/examples/iteration/workflow.png +0 -0
  48. data/examples/json_handling/workflow.png +0 -0
  49. data/examples/mcp/database_workflow.png +0 -0
  50. data/examples/mcp/env_demo/workflow.png +0 -0
  51. data/examples/mcp/filesystem_demo/workflow.png +0 -0
  52. data/examples/mcp/github_workflow.png +0 -0
  53. data/examples/mcp/multi_mcp_workflow.png +0 -0
  54. data/examples/mcp/workflow.png +0 -0
  55. data/examples/no_model_fallback/README.md +17 -0
  56. data/examples/no_model_fallback/analyze_file/prompt.md +1 -0
  57. data/examples/no_model_fallback/analyze_patterns/prompt.md +27 -0
  58. data/examples/no_model_fallback/generate_report_for_md/prompt.md +10 -0
  59. data/examples/no_model_fallback/generate_report_for_rb/prompt.md +3 -0
  60. data/examples/no_model_fallback/sample.rb +42 -0
  61. data/examples/no_model_fallback/workflow.yml +19 -0
  62. data/examples/openrouter_example/workflow.png +0 -0
  63. data/examples/pre_post_processing/workflow.png +0 -0
  64. data/examples/rspec_to_minitest/workflow.png +0 -0
  65. data/examples/shared_config/example_with_shared_config/workflow.png +0 -0
  66. data/examples/shared_config/shared.png +0 -0
  67. data/examples/single_target_prepost/workflow.png +0 -0
  68. data/examples/smart_coercion_defaults/workflow.png +0 -0
  69. data/examples/step_configuration/workflow.png +0 -0
  70. data/examples/swarm_example.yml +25 -0
  71. data/examples/tool_config_example/workflow.png +0 -0
  72. data/examples/user_input/funny_name/workflow.png +0 -0
  73. data/examples/user_input/simple_input_demo/workflow.png +0 -0
  74. data/examples/user_input/survey_workflow.png +0 -0
  75. data/examples/user_input/workflow.png +0 -0
  76. data/examples/workflow_generator/workflow.png +0 -0
  77. data/lib/roast/helpers/timeout_handler.rb +91 -0
  78. data/lib/roast/services/context_threshold_checker.rb +42 -0
  79. data/lib/roast/services/token_counting_service.rb +44 -0
  80. data/lib/roast/tools/apply_diff.rb +128 -0
  81. data/lib/roast/tools/bash.rb +15 -9
  82. data/lib/roast/tools/cmd.rb +32 -12
  83. data/lib/roast/tools/coding_agent.rb +64 -9
  84. data/lib/roast/tools/context_summarizer.rb +108 -0
  85. data/lib/roast/tools/swarm.rb +124 -0
  86. data/lib/roast/version.rb +1 -1
  87. data/lib/roast/workflow/agent_step.rb +9 -2
  88. data/lib/roast/workflow/base_iteration_step.rb +3 -2
  89. data/lib/roast/workflow/base_workflow.rb +41 -2
  90. data/lib/roast/workflow/configuration.rb +2 -1
  91. data/lib/roast/workflow/configuration_loader.rb +63 -1
  92. data/lib/roast/workflow/context_manager.rb +89 -0
  93. data/lib/roast/workflow/each_step.rb +1 -1
  94. data/lib/roast/workflow/output_handler.rb +1 -1
  95. data/lib/roast/workflow/repeat_step.rb +1 -1
  96. data/lib/roast/workflow/replay_handler.rb +1 -1
  97. data/lib/roast/workflow/sqlite_state_repository.rb +342 -0
  98. data/lib/roast/workflow/state_manager.rb +2 -2
  99. data/lib/roast/workflow/state_repository_factory.rb +36 -0
  100. data/lib/roast/workflow/step_completion_reporter.rb +27 -0
  101. data/lib/roast/workflow/step_executor_coordinator.rb +19 -18
  102. data/lib/roast/workflow/step_executor_with_reporting.rb +68 -0
  103. data/lib/roast/workflow/step_loader.rb +1 -1
  104. data/lib/roast/workflow/step_name_extractor.rb +84 -0
  105. data/lib/roast/workflow/validation_command.rb +197 -0
  106. data/lib/roast/workflow/validators/base_validator.rb +44 -0
  107. data/lib/roast/workflow/validators/dependency_validator.rb +223 -0
  108. data/lib/roast/workflow/validators/linting_validator.rb +113 -0
  109. data/lib/roast/workflow/validators/schema_validator.rb +90 -0
  110. data/lib/roast/workflow/validators/step_collector.rb +57 -0
  111. data/lib/roast/workflow/validators/validation_orchestrator.rb +52 -0
  112. data/lib/roast/workflow/workflow_executor.rb +11 -4
  113. data/lib/roast/workflow/workflow_runner.rb +6 -0
  114. data/lib/roast/workflow_diagram_generator.rb +298 -0
  115. data/lib/roast.rb +157 -0
  116. data/roast.gemspec +2 -1
  117. data/schema/workflow.json +77 -1
  118. metadata +101 -1
@@ -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
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ module Validators
6
+ # Validates dependencies: tools, step references, and resources
7
+ class DependencyValidator < BaseValidator
8
+ def initialize(parsed_yaml, workflow_path = nil, step_collector: nil)
9
+ super(parsed_yaml, workflow_path)
10
+ @step_collector = step_collector || StepCollector.new(parsed_yaml)
11
+ end
12
+
13
+ def validate
14
+ validate_tool_dependencies
15
+ validate_step_references
16
+ validate_resource_dependencies
17
+ end
18
+
19
+ private
20
+
21
+ def validate_tool_dependencies
22
+ return unless @parsed_yaml["tools"]
23
+
24
+ tools = extract_all_tools(@parsed_yaml["tools"])
25
+
26
+ tools.each do |tool|
27
+ next if tool_available?(tool)
28
+
29
+ add_error(
30
+ type: :tool_dependency,
31
+ tool: tool,
32
+ message: "Tool '#{tool}' is not available",
33
+ suggestion: suggest_tool_fix(tool),
34
+ )
35
+ end
36
+ end
37
+
38
+ def validate_step_references
39
+ all_steps = @step_collector.all_steps
40
+ step_names = all_steps.map { |s| extract_step_name(s) }.compact.uniq
41
+
42
+ check_step_references_in_config(@parsed_yaml, step_names)
43
+ end
44
+
45
+ def validate_resource_dependencies
46
+ # Validate file resources if target is specified
47
+ if @parsed_yaml["target"] && @workflow_path
48
+ validate_target_resource(@parsed_yaml["target"])
49
+ end
50
+
51
+ # Validate prompt files exist
52
+ validate_prompt_files if @workflow_path
53
+ end
54
+
55
+ def tool_available?(tool_name)
56
+ # Check if it's an MCP tool first
57
+ tools_config = @parsed_yaml["tools"] || []
58
+ tools_config.each do |tool_entry|
59
+ if tool_entry.is_a?(Hash) && tool_entry.keys.include?(tool_name)
60
+ # It's an MCP tool configuration
61
+ return true
62
+ end
63
+ end
64
+
65
+ # Check if tool module exists
66
+ begin
67
+ tool_name.constantize
68
+ true
69
+ rescue NameError
70
+ false
71
+ end
72
+ end
73
+
74
+ def suggest_tool_fix(tool)
75
+ # Suggest similar tools or common fixes
76
+ available_tools = [
77
+ "Roast::Tools::Bash",
78
+ "Roast::Tools::Cmd",
79
+ "Roast::Tools::ReadFile",
80
+ "Roast::Tools::WriteFile",
81
+ "Roast::Tools::UpdateFiles",
82
+ "Roast::Tools::SearchFile",
83
+ "Roast::Tools::Grep",
84
+ "Roast::Tools::AskUser",
85
+ "Roast::Tools::CodingAgent",
86
+ ]
87
+
88
+ # Simple similarity check
89
+ tool_base = tool.split("::").last&.downcase || tool.downcase
90
+ suggestions = available_tools.select do |t|
91
+ t_base = t.split("::").last&.downcase || ""
92
+ t_base.include?(tool_base) || tool_base.include?(t_base)
93
+ end
94
+
95
+ if suggestions.any?
96
+ "Did you mean: #{suggestions.join(", ")}?"
97
+ else
98
+ "Ensure the tool module exists or check the tool name spelling"
99
+ end
100
+ end
101
+
102
+ def extract_all_tools(tools_config)
103
+ tools = []
104
+ tools_config.each do |tool_entry|
105
+ case tool_entry
106
+ when String
107
+ tools << tool_entry
108
+ when Hash
109
+ tool_entry.each_key do |tool_name|
110
+ tools << tool_name
111
+ end
112
+ end
113
+ end
114
+ tools
115
+ end
116
+
117
+ def extract_step_name(step)
118
+ case step
119
+ when String
120
+ step
121
+ when Hash
122
+ # Complex step types don't have simple names
123
+ nil
124
+ end
125
+ end
126
+
127
+ def check_step_references_in_config(config, valid_step_names)
128
+ # Check steps array
129
+ ["steps", "pre_processing", "post_processing"].each do |key|
130
+ if config[key].is_a?(Array)
131
+ check_step_references(config[key], valid_step_names)
132
+ end
133
+ end
134
+ end
135
+
136
+ def check_step_references(steps, valid_step_names, path = [])
137
+ steps.each_with_index do |step, index|
138
+ current_path = path + [index]
139
+
140
+ case step
141
+ when Hash
142
+ # Check conditions that might reference steps
143
+ ["if", "unless", "case"].each do |condition_key|
144
+ next unless step[condition_key]
145
+
146
+ condition = step[condition_key]
147
+ next unless condition.is_a?(String) && !condition.include?("{{") && !condition.include?("$(")
148
+
149
+ # Check if it looks like a step reference (snake_case identifier)
150
+ # and is not a known boolean value
151
+ next unless condition.match?(/^[a-z_]+$/) && !["true", "false", "yes", "no", "on", "off"].include?(condition)
152
+
153
+ # This looks like it could be a step reference
154
+ next if valid_step_names.include?(condition)
155
+
156
+ add_error(
157
+ type: :step_reference,
158
+ message: "Step '#{condition}' referenced in #{condition_key} condition does not exist",
159
+ suggestion: "Ensure step '#{condition}' is defined before it's referenced",
160
+ )
161
+ end
162
+
163
+ # Check nested steps
164
+ ["then", "else", "steps"].each do |key|
165
+ if step[key].is_a?(Array)
166
+ check_step_references(step[key], valid_step_names, current_path + [key])
167
+ end
168
+ end
169
+
170
+ # Check case/when branches
171
+ if step["when"].is_a?(Hash)
172
+ step["when"].each do |when_value, when_steps|
173
+ if when_steps.is_a?(Array)
174
+ check_step_references(when_steps, valid_step_names, current_path + ["when", when_value])
175
+ end
176
+ end
177
+ end
178
+ when Array
179
+ check_step_references(step, valid_step_names, current_path)
180
+ end
181
+ end
182
+ end
183
+
184
+ def validate_target_resource(target)
185
+ return unless @workflow_path
186
+
187
+ workflow_dir = File.dirname(@workflow_path)
188
+
189
+ # If target is a glob pattern or shell command, skip file validation
190
+ return if target.include?("*") || target.start_with?("$(")
191
+
192
+ target_path = File.expand_path(target, workflow_dir)
193
+ unless File.exist?(target_path)
194
+ add_warning(
195
+ type: :resource,
196
+ message: "Target file '#{target}' does not exist",
197
+ suggestion: "Ensure the target file exists or use a glob pattern",
198
+ )
199
+ end
200
+ end
201
+
202
+ def validate_prompt_files
203
+ workflow_dir = File.dirname(@workflow_path)
204
+ all_steps = @step_collector.all_steps
205
+
206
+ all_steps.each do |step|
207
+ next unless step.is_a?(String)
208
+
209
+ # Check if corresponding prompt file exists
210
+ prompt_path = File.join(workflow_dir, step, "prompt.md")
211
+ next if File.exist?(prompt_path)
212
+
213
+ add_warning(
214
+ type: :resource,
215
+ message: "Prompt file missing for step '#{step}'",
216
+ suggestion: "Create file: #{prompt_path}",
217
+ )
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ module Validators
6
+ # Validates workflow configuration for best practices and common issues
7
+ class LintingValidator < BaseValidator
8
+ # Configurable thresholds
9
+ MAX_STEPS = 20
10
+ MAX_NESTING_DEPTH = 5
11
+
12
+ def initialize(parsed_yaml, workflow_path = nil, step_collector: nil)
13
+ super(parsed_yaml, workflow_path)
14
+ @step_collector = step_collector || StepCollector.new(parsed_yaml)
15
+ end
16
+
17
+ def validate
18
+ lint_naming_conventions
19
+ lint_step_complexity
20
+ lint_common_mistakes
21
+ end
22
+
23
+ private
24
+
25
+ def lint_naming_conventions
26
+ # Check workflow name
27
+ if @parsed_yaml["name"].nil? || @parsed_yaml["name"].empty?
28
+ add_warning(
29
+ type: :naming,
30
+ message: "Workflow should have a descriptive name",
31
+ suggestion: "Add a 'name' field to your workflow configuration",
32
+ )
33
+ end
34
+
35
+ # Check step naming
36
+ all_steps = @step_collector.all_steps
37
+ all_steps.each do |step|
38
+ next unless step.is_a?(String) && !step.match?(/^[a-z_]+$/)
39
+
40
+ add_warning(
41
+ type: :naming,
42
+ step: step,
43
+ message: "Step name '#{step}' should use snake_case",
44
+ suggestion: "Rename to '#{step.downcase.gsub(/[^a-z0-9]/, "_")}'",
45
+ )
46
+ end
47
+ end
48
+
49
+ def lint_step_complexity
50
+ # Check for overly complex workflows
51
+ all_steps = @step_collector.all_steps
52
+ if all_steps.size > MAX_STEPS
53
+ add_warning(
54
+ type: :complexity,
55
+ message: "Workflow has #{all_steps.size} steps, consider breaking it into smaller workflows",
56
+ suggestion: "Use sub-workflows or modularize complex logic",
57
+ )
58
+ end
59
+
60
+ # Check for deeply nested conditions
61
+ check_nesting_depth(@parsed_yaml["steps"] || [])
62
+ end
63
+
64
+ def lint_common_mistakes
65
+ # Missing error handling
66
+ if !@parsed_yaml["exit_on_error"] && !error_handling?
67
+ add_warning(
68
+ type: :error_handling,
69
+ message: "No error handling configured",
70
+ suggestion: "Consider adding 'exit_on_error: true' or error handling steps",
71
+ )
72
+ end
73
+ end
74
+
75
+ def check_nesting_depth(steps, depth = 0)
76
+ steps.each do |step|
77
+ next unless step.is_a?(Hash)
78
+
79
+ current_depth = depth + 1
80
+
81
+ if current_depth > MAX_NESTING_DEPTH
82
+ add_warning(
83
+ type: :complexity,
84
+ message: "Excessive nesting depth (#{current_depth} levels)",
85
+ suggestion: "Consider extracting nested logic into separate steps or workflows",
86
+ )
87
+ end
88
+
89
+ # Check nested steps
90
+ ["steps", "then", "else", "true", "false"].each do |key|
91
+ check_nesting_depth(step[key], current_depth) if step[key].is_a?(Array)
92
+ end
93
+
94
+ # Check case/when branches
95
+ next unless step["when"]
96
+
97
+ step["when"].each_value do |when_steps|
98
+ check_nesting_depth(when_steps, current_depth) if when_steps.is_a?(Array)
99
+ end
100
+ end
101
+ end
102
+
103
+ def error_handling?
104
+ # Check if workflow has any error handling mechanisms
105
+ all_steps = @step_collector.all_steps
106
+ all_steps.any? do |step|
107
+ step.is_a?(Hash) && (step["rescue"] || step["ensure"])
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ module Validators
6
+ # Validates workflow configuration against JSON schema
7
+ class SchemaValidator < BaseValidator
8
+ attr_reader :parsed_yaml
9
+
10
+ def initialize(yaml_content, workflow_path = nil) # rubocop:disable Lint/MissingSuper
11
+ @yaml_content = yaml_content&.strip || ""
12
+ @workflow_path = workflow_path
13
+ @errors = []
14
+ @warnings = []
15
+
16
+ begin
17
+ @parsed_yaml = @yaml_content.empty? ? {} : YAML.safe_load(@yaml_content)
18
+ rescue Psych::SyntaxError => e
19
+ @errors << format_yaml_error(e)
20
+ @parsed_yaml = {}
21
+ end
22
+ end
23
+
24
+ def validate
25
+ if @parsed_yaml.empty?
26
+ @errors << {
27
+ type: :empty_configuration,
28
+ message: "Workflow configuration is empty",
29
+ suggestion: "Provide a valid workflow configuration with required fields: name, tools, and steps",
30
+ }
31
+ return
32
+ end
33
+
34
+ validator = Validator.new(@yaml_content)
35
+ unless validator.valid?
36
+ validator.errors.each do |error|
37
+ @errors << format_schema_error(error)
38
+ end
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def format_yaml_error(error)
45
+ {
46
+ type: :yaml_syntax,
47
+ message: "YAML syntax error: #{error.message}",
48
+ line: error.line,
49
+ column: error.column,
50
+ suggestion: "Check YAML syntax at line #{error.line}, column #{error.column}",
51
+ }
52
+ end
53
+
54
+ def format_schema_error(error)
55
+ # Parse JSON Schema error and make it more user-friendly
56
+ if error.include?("did not contain a required property")
57
+ # Extract property name from error
58
+ match = error.match(/required property of '([^']+)'/)
59
+ if match
60
+ property = match[1]
61
+ {
62
+ type: :schema,
63
+ message: "Missing required field: '#{property}'",
64
+ suggestion: "Add '#{property}' to your workflow configuration",
65
+ }
66
+ else
67
+ {
68
+ type: :schema,
69
+ message: error,
70
+ suggestion: "Check the required fields in your workflow configuration",
71
+ }
72
+ end
73
+ elsif error.include?("does not match")
74
+ {
75
+ type: :schema,
76
+ message: error,
77
+ suggestion: "Check the workflow schema documentation for valid values",
78
+ }
79
+ else
80
+ {
81
+ type: :schema,
82
+ message: error,
83
+ suggestion: "Refer to the workflow schema for correct configuration structure",
84
+ }
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end