rubyn-code 0.2.2 → 0.3.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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +91 -3
  3. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  4. data/lib/rubyn_code/agent/conversation.rb +55 -56
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
  6. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  7. data/lib/rubyn_code/agent/llm_caller.rb +149 -0
  8. data/lib/rubyn_code/agent/loop.rb +175 -683
  9. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  10. data/lib/rubyn_code/agent/prompts.rb +109 -0
  11. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  12. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  13. data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
  14. data/lib/rubyn_code/agent/tool_processor.rb +158 -0
  15. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  16. data/lib/rubyn_code/auth/oauth.rb +80 -64
  17. data/lib/rubyn_code/auth/server.rb +21 -24
  18. data/lib/rubyn_code/auth/token_store.rb +31 -44
  19. data/lib/rubyn_code/autonomous/daemon.rb +29 -18
  20. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
  21. data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
  22. data/lib/rubyn_code/background/worker.rb +64 -76
  23. data/lib/rubyn_code/cli/app.rb +128 -114
  24. data/lib/rubyn_code/cli/commands/model.rb +75 -18
  25. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
  27. data/lib/rubyn_code/cli/renderer.rb +109 -60
  28. data/lib/rubyn_code/cli/repl.rb +42 -373
  29. data/lib/rubyn_code/cli/repl_commands.rb +176 -0
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +145 -0
  32. data/lib/rubyn_code/cli/setup.rb +6 -2
  33. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  34. data/lib/rubyn_code/cli/version_check.rb +28 -11
  35. data/lib/rubyn_code/config/defaults.rb +10 -0
  36. data/lib/rubyn_code/config/project_profile.rb +185 -0
  37. data/lib/rubyn_code/config/settings.rb +100 -1
  38. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  39. data/lib/rubyn_code/context/context_budget.rb +167 -0
  40. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  41. data/lib/rubyn_code/context/manager.rb +7 -5
  42. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  43. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  44. data/lib/rubyn_code/db/connection.rb +31 -26
  45. data/lib/rubyn_code/db/migrator.rb +44 -28
  46. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  47. data/lib/rubyn_code/index/codebase_index.rb +245 -0
  48. data/lib/rubyn_code/learning/extractor.rb +65 -82
  49. data/lib/rubyn_code/learning/injector.rb +22 -23
  50. data/lib/rubyn_code/learning/instinct.rb +71 -42
  51. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  52. data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
  53. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  54. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  55. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  56. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  57. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
  58. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  59. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  60. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  61. data/lib/rubyn_code/llm/client.rb +55 -252
  62. data/lib/rubyn_code/llm/model_router.rb +237 -0
  63. data/lib/rubyn_code/llm/streaming.rb +4 -227
  64. data/lib/rubyn_code/mcp/client.rb +1 -1
  65. data/lib/rubyn_code/mcp/config.rb +9 -12
  66. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  67. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  69. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  70. data/lib/rubyn_code/memory/store.rb +42 -55
  71. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  72. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  73. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  74. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  75. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  76. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  77. data/lib/rubyn_code/output/formatter.rb +11 -11
  78. data/lib/rubyn_code/permissions/policy.rb +11 -13
  79. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  80. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  81. data/lib/rubyn_code/skills/document.rb +33 -29
  82. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  83. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  84. data/lib/rubyn_code/tasks/dag.rb +25 -24
  85. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  86. data/lib/rubyn_code/tools/background_run.rb +2 -1
  87. data/lib/rubyn_code/tools/base.rb +26 -32
  88. data/lib/rubyn_code/tools/bash.rb +2 -1
  89. data/lib/rubyn_code/tools/edit_file.rb +74 -18
  90. data/lib/rubyn_code/tools/executor.rb +74 -24
  91. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  92. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  93. data/lib/rubyn_code/tools/git_log.rb +12 -10
  94. data/lib/rubyn_code/tools/glob.rb +23 -7
  95. data/lib/rubyn_code/tools/grep.rb +2 -1
  96. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  97. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  98. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  99. data/lib/rubyn_code/tools/output_compressor.rb +185 -0
  100. data/lib/rubyn_code/tools/read_file.rb +11 -6
  101. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  102. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  103. data/lib/rubyn_code/tools/schema.rb +4 -10
  104. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  105. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  106. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  107. data/lib/rubyn_code/tools/task.rb +17 -17
  108. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  109. data/lib/rubyn_code/tools/web_search.rb +66 -48
  110. data/lib/rubyn_code/tools/write_file.rb +59 -1
  111. data/lib/rubyn_code/version.rb +1 -1
  112. data/lib/rubyn_code.rb +40 -1
  113. data/skills/rubyn_self_test.md +121 -0
  114. metadata +53 -1
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Tools
5
+ # Compresses tool output before it enters the conversation context.
6
+ # Each tool type has a strategy and threshold — outputs below the
7
+ # threshold pass through unchanged; larger outputs are compressed
8
+ # to keep context lean.
9
+ class OutputCompressor
10
+ CHARS_PER_TOKEN = 4
11
+
12
+ THRESHOLDS = {
13
+ 'run_specs' => { max_tokens: 500, strategy: :spec_summary },
14
+ 'bash' => { max_tokens: 1000, strategy: :head_tail },
15
+ 'git_log' => { max_tokens: 800, strategy: :head_tail },
16
+ 'git_diff' => { max_tokens: 2000, strategy: :relevant_hunks },
17
+ 'grep' => { max_tokens: 1000, strategy: :top_matches },
18
+ 'glob' => { max_tokens: 500, strategy: :tree },
19
+ 'git_status' => { max_tokens: 500, strategy: :head_tail },
20
+ 'read_file' => { max_tokens: 3000, strategy: :head_tail }
21
+ }.freeze
22
+
23
+ DEFAULT_THRESHOLD = { max_tokens: 1500, strategy: :head_tail }.freeze
24
+
25
+ attr_reader :stats
26
+
27
+ def initialize
28
+ @stats = { calls: 0, compressed: 0, tokens_saved: 0 }
29
+ end
30
+
31
+ def compress(tool_name, raw_output)
32
+ return raw_output if raw_output.nil? || raw_output.empty?
33
+
34
+ @stats[:calls] += 1
35
+ config = THRESHOLDS.fetch(tool_name.to_s, DEFAULT_THRESHOLD)
36
+ max_chars = config[:max_tokens] * CHARS_PER_TOKEN
37
+
38
+ return raw_output if raw_output.length <= max_chars
39
+
40
+ compressed = apply_strategy(config[:strategy], raw_output, max_chars)
41
+ record_savings(raw_output, compressed)
42
+ compressed
43
+ end
44
+
45
+ private
46
+
47
+ def apply_strategy(strategy, output, max_chars)
48
+ case strategy
49
+ when :spec_summary then compress_spec_output(output)
50
+ when :head_tail then head_tail(output, max_chars)
51
+ when :relevant_hunks then compress_diff(output, max_chars)
52
+ when :top_matches then top_matches(output, max_chars)
53
+ when :tree then collapse_tree(output, max_chars)
54
+ end
55
+ end
56
+
57
+ def compress_spec_output(output)
58
+ lines = output.lines
59
+ summary_line = find_summary_line(lines)
60
+
61
+ return summary_line.strip if summary_line&.include?('0 failures')
62
+
63
+ failures = extract_spec_failures(lines)
64
+ return summary_line.strip if failures.empty? && summary_line
65
+
66
+ assemble_failure_report(failures, summary_line)
67
+ end
68
+
69
+ def find_summary_line(lines)
70
+ lines.reverse_each.find { |l| l.match?(/\d+ examples?/) }
71
+ end
72
+
73
+ def assemble_failure_report(failures, summary_line)
74
+ parts = []
75
+ parts.concat(failures.first(10))
76
+ parts << summary_line.strip if summary_line
77
+ remaining = failures.size - 10
78
+ parts << "(#{remaining} more failures omitted)" if remaining.positive?
79
+ parts.join("\n")
80
+ end
81
+
82
+ def extract_spec_failures(lines)
83
+ failures = []
84
+ capturing = false
85
+
86
+ lines.each do |line|
87
+ if line.match?(/^\s+\d+\)\s/) || line.match?(%r{^Failure/Error:})
88
+ capturing = true
89
+ failures << +''
90
+ end
91
+
92
+ if capturing
93
+ failures.last << line
94
+ capturing = false if line.strip.empty? && failures.last.length > 20
95
+ end
96
+ end
97
+
98
+ failures
99
+ end
100
+
101
+ # rubocop:disable Metrics/AbcSize -- head/tail splitting requires coordinated arithmetic
102
+ def head_tail(output, max_chars)
103
+ lines = output.lines
104
+ return output if lines.size <= 10
105
+
106
+ head_chars = (max_chars * 0.6).to_i
107
+ tail_chars = (max_chars * 0.3).to_i
108
+
109
+ head_lines = take_lines_up_to(lines, head_chars)
110
+ tail_lines = take_lines_up_to(lines.reverse, tail_chars).reverse
111
+ omitted = lines.size - head_lines.size - tail_lines.size
112
+
113
+ parts = [head_lines.join]
114
+ parts << "\n... [#{omitted} lines omitted] ...\n" if omitted.positive?
115
+ parts << tail_lines.join
116
+ parts.join
117
+ end
118
+ # rubocop:enable Metrics/AbcSize
119
+
120
+ # rubocop:disable Metrics/AbcSize -- diff hunk iteration with header extraction
121
+ def compress_diff(output, max_chars)
122
+ hunks = output.split(/^(?=diff --git)/)
123
+ return head_tail(output, max_chars) if hunks.size <= 1
124
+
125
+ result = +''
126
+ hunks.each do |hunk|
127
+ header = hunk.lines.first(4).join
128
+ if result.length + hunk.length <= max_chars
129
+ result << hunk
130
+ else
131
+ result << header
132
+ result << " ... (#{hunk.lines.size - 4} lines in this file omitted)\n"
133
+ end
134
+ end
135
+
136
+ result
137
+ end
138
+ # rubocop:enable Metrics/AbcSize
139
+
140
+ def top_matches(output, max_chars)
141
+ lines = output.lines
142
+ kept = take_lines_up_to(lines, max_chars)
143
+ omitted = lines.size - kept.size
144
+
145
+ result = kept.join
146
+ result << "\n... (#{omitted} more matches omitted)\n" if omitted.positive?
147
+ result
148
+ end
149
+
150
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity -- multi-step tree collapse
151
+ def collapse_tree(output, max_chars)
152
+ paths = output.lines.map(&:strip).reject(&:empty?)
153
+ return output if output.length <= max_chars
154
+
155
+ dirs = paths.map { |p| File.dirname(p) }.tally.sort_by { |_, c| -c }
156
+ result = dirs.map { |dir, count| "#{dir}/ (#{count} files)" }.join("\n")
157
+
158
+ return result if result.length <= max_chars
159
+
160
+ head_tail(result, max_chars)
161
+ end
162
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
163
+
164
+ def take_lines_up_to(lines, max_chars)
165
+ taken = []
166
+ total = 0
167
+ lines.each do |line|
168
+ break if total + line.length > max_chars
169
+
170
+ taken << line
171
+ total += line.length
172
+ end
173
+ taken
174
+ end
175
+
176
+ def record_savings(original, compressed)
177
+ saved = (original.length - compressed.length) / CHARS_PER_TOKEN
178
+ return unless saved.positive?
179
+
180
+ @stats[:compressed] += 1
181
+ @stats[:tokens_saved] += saved
182
+ end
183
+ end
184
+ end
185
+ end
@@ -19,17 +19,22 @@ module RubynCode
19
19
 
20
20
  def execute(path:, offset: nil, limit: nil)
21
21
  resolved = read_file_safely(path)
22
-
23
22
  lines = File.readlines(resolved)
23
+ start_line, end_line = compute_range(offset, limit, lines.length)
24
+ format_lines(lines[start_line...end_line] || [], start_line)
25
+ end
24
26
 
25
- start_line = offset ? [offset.to_i - 1, 0].max : 0
26
- end_line = limit ? start_line + limit.to_i : lines.length
27
+ private
27
28
 
28
- selected = lines[start_line...end_line] || []
29
+ def compute_range(offset, limit, total)
30
+ start_line = offset ? [offset.to_i - 1, 0].max : 0
31
+ end_line = limit ? start_line + limit.to_i : total
32
+ [start_line, end_line]
33
+ end
29
34
 
35
+ def format_lines(selected, start_line)
30
36
  selected.each_with_index.map do |line, idx|
31
- line_num = start_line + idx + 1
32
- "#{line_num.to_s.rjust(6)}\t#{line}"
37
+ "#{(start_line + idx + 1).to_s.rjust(6)}\t#{line}"
33
38
  end.join
34
39
  end
35
40
  end
@@ -8,8 +8,8 @@ module RubynCode
8
8
  class ReviewPr < Base
9
9
  TOOL_NAME = 'review_pr'
10
10
  DESCRIPTION = 'Review current branch changes against Ruby/Rails best practices. ' \
11
- 'Gets the diff of the current branch vs the base branch, analyzes each changed file, ' \
12
- 'and provides actionable suggestions with explanations.'
11
+ 'Gets the diff of the current branch vs the base branch, analyzes ' \
12
+ 'each changed file, and provides actionable suggestions with explanations.'
13
13
  PARAMETERS = {
14
14
  base_branch: {
15
15
  type: :string,
@@ -25,95 +25,138 @@ module RubynCode
25
25
  RISK_LEVEL = :read
26
26
 
27
27
  def execute(base_branch: 'main', focus: 'all')
28
- # Check git is available
29
- unless system('git rev-parse --is-inside-work-tree > /dev/null 2>&1', chdir: project_root)
30
- return 'Error: Not a git repository or git is not installed.'
31
- end
28
+ error = validate_git_repo
29
+ return error if error
30
+
31
+ current = current_branch_name
32
+ return current if current.start_with?('Error:')
33
+
34
+ base_branch, error = resolve_base(base_branch, current)
35
+ return error if error
36
+
37
+ diff = run_git("diff #{base_branch}...HEAD")
38
+ return "No changes found between #{current} and #{base_branch}." if diff.strip.empty?
39
+
40
+ build_full_review(current, base_branch, diff, focus)
41
+ end
42
+
43
+ FILE_CATEGORIES = [
44
+ ['Ruby', /\.(rb|rake|gemspec|ru)$/],
45
+ ['Templates', /\.(erb|haml|slim)$/],
46
+ ['Specs', /_spec\.rb$|_test\.rb$/],
47
+ ['Migrations', %r{db/migrate}],
48
+ ['Config', %r{config/|\.ya?ml$}]
49
+ ].freeze
50
+
51
+ private
32
52
 
33
- # Get current branch
53
+ def validate_git_repo
54
+ return nil if system(
55
+ 'git rev-parse --is-inside-work-tree > /dev/null 2>&1',
56
+ chdir: project_root
57
+ )
58
+
59
+ 'Error: Not a git repository or git is not installed.'
60
+ end
61
+
62
+ def current_branch_name
34
63
  current = run_git('rev-parse --abbrev-ref HEAD').strip
35
64
  return 'Error: Could not determine current branch.' if current.empty?
36
65
 
66
+ current
67
+ end
68
+
69
+ def resolve_base(base_branch, current)
37
70
  if current == base_branch
38
- return "You're on #{base_branch}. Switch to a feature branch first, or specify a different base: review_pr(base_branch: 'develop')"
71
+ return [nil, "You're on #{base_branch}. Switch to a feature branch " \
72
+ "or specify a different base: review_pr(base_branch: 'develop')"]
39
73
  end
40
74
 
41
- # Check base branch exists
42
- unless run_git("rev-parse --verify #{base_branch} 2>/dev/null").strip.length.positive?
43
- # Try origin/main
44
- base_branch = "origin/#{base_branch}"
45
- unless run_git("rev-parse --verify #{base_branch} 2>/dev/null").strip.length.positive?
46
- return "Error: Base branch '#{base_branch}' not found."
47
- end
48
- end
75
+ return [base_branch, nil] if branch_exists?(base_branch)
49
76
 
50
- # Get the diff
51
- diff = run_git("diff #{base_branch}...HEAD")
52
- return "No changes found between #{current} and #{base_branch}." if diff.strip.empty?
77
+ origin = "origin/#{base_branch}"
78
+ return [origin, nil] if branch_exists?(origin)
79
+
80
+ [nil, "Error: Base branch '#{base_branch}' not found."]
81
+ end
53
82
 
54
- # Get changed files with stats
83
+ def build_full_review(current, base_branch, diff, focus)
84
+ review = build_review_header(current, base_branch)
85
+ review.concat(build_file_categories(base_branch))
86
+ review.concat(build_focus_section(focus))
87
+ review.concat(build_diff_section(diff))
88
+ review.concat(build_review_checklist)
89
+ truncate(review.join("\n"))
90
+ end
91
+
92
+ def branch_exists?(branch)
93
+ run_git("rev-parse --verify #{branch} 2>/dev/null").strip.length.positive?
94
+ end
95
+
96
+ def build_review_header(current, base_branch)
55
97
  stat = run_git("diff #{base_branch}...HEAD --stat")
56
- files_changed = run_git("diff #{base_branch}...HEAD --name-only").strip.split("\n")
57
98
  commit_log = run_git("log #{base_branch}..HEAD --oneline")
58
99
 
59
- # Build the review context
60
- ruby_files = files_changed.grep(/\.(rb|rake|gemspec|ru)$/)
61
- erb_files = files_changed.grep(/\.(erb|haml|slim)$/)
62
- spec_files = files_changed.grep(/_spec\.rb$|_test\.rb$/)
63
- migration_files = files_changed.select { |f| f.include?('db/migrate') }
64
- config_files = files_changed.grep(%r{config/|\.yml$|\.yaml$})
100
+ [
101
+ "# PR Review: #{current} -> #{base_branch}",
102
+ '',
103
+ '## Summary',
104
+ stat,
105
+ '',
106
+ '## Commits',
107
+ commit_log,
108
+ ''
109
+ ]
110
+ end
65
111
 
66
- review = []
67
- review << "# PR Review: #{current} → #{base_branch}"
68
- review << ''
69
- review << '## Summary'
70
- review << stat
71
- review << ''
72
- review << '## Commits'
73
- review << commit_log
74
- review << ''
75
- review << '## Files by Category'
76
- review << "- Ruby: #{ruby_files.length} files" unless ruby_files.empty?
77
- review << "- Templates: #{erb_files.length} files" unless erb_files.empty?
78
- review << "- Specs: #{spec_files.length} files" unless spec_files.empty?
79
- review << "- Migrations: #{migration_files.length} files" unless migration_files.empty?
80
- review << "- Config: #{config_files.length} files" unless config_files.empty?
112
+ def build_file_categories(base_branch)
113
+ files = run_git("diff #{base_branch}...HEAD --name-only").strip.split("\n")
114
+ review = ['## Files by Category']
115
+ FILE_CATEGORIES.each do |label, pattern|
116
+ review << "- #{label}: #{files.grep(pattern).length} files"
117
+ end
81
118
  review << ''
119
+ review
120
+ end
82
121
 
83
- # Add focus-specific review instructions
84
- review << "## Review Focus: #{focus.upcase}"
85
- review << review_instructions(focus)
86
- review << ''
122
+ def build_focus_section(focus)
123
+ [
124
+ "## Review Focus: #{focus.upcase}",
125
+ review_instructions(focus),
126
+ ''
127
+ ]
128
+ end
87
129
 
88
- # Add the diff (truncated if too large)
130
+ def build_diff_section(diff)
89
131
  if diff.length > 40_000
90
- review << "## Diff (truncated — #{diff.length} chars total)"
91
- review << diff[0...40_000]
92
- review << "\n... [truncated #{diff.length - 40_000} chars]"
132
+ [
133
+ "## Diff (truncated — #{diff.length} chars total)",
134
+ diff[0...40_000],
135
+ "\n... [truncated #{diff.length - 40_000} chars]"
136
+ ]
93
137
  else
94
- review << '## Full Diff'
95
- review << diff
138
+ ['## Full Diff', diff]
96
139
  end
97
-
98
- review << ''
99
- review << '---'
100
- review << 'Review this diff against Ruby/Rails best practices. For each issue found:'
101
- review << '1. Quote the specific code'
102
- review << "2. Explain what's wrong and WHY it matters"
103
- review << '3. Show the suggested fix'
104
- review << '4. Rate severity: [critical] [warning] [suggestion] [nitpick]'
105
- review << ''
106
- review << 'Also check for:'
107
- review << '- Missing tests for new code'
108
- review << '- N+1 queries in ActiveRecord changes'
109
- review << '- Security issues (SQL injection, XSS, mass assignment)'
110
- review << '- Missing database indexes for new associations'
111
- review << '- Proper error handling'
112
-
113
- truncate(review.join("\n"))
114
140
  end
115
141
 
116
- private
142
+ def build_review_checklist
143
+ [
144
+ '',
145
+ '---',
146
+ 'Review this diff against Ruby/Rails best practices. For each issue found:',
147
+ '1. Quote the specific code',
148
+ "2. Explain what's wrong and WHY it matters",
149
+ '3. Show the suggested fix',
150
+ '4. Rate severity: [critical] [warning] [suggestion] [nitpick]',
151
+ '',
152
+ 'Also check for:',
153
+ '- Missing tests for new code',
154
+ '- N+1 queries in ActiveRecord changes',
155
+ '- Security issues (SQL injection, XSS, mass assignment)',
156
+ '- Missing database indexes for new associations',
157
+ '- Proper error handling'
158
+ ]
159
+ end
117
160
 
118
161
  def run_git(command)
119
162
  `cd #{project_root} && git #{command} 2>/dev/null`
@@ -122,20 +165,24 @@ module RubynCode
122
165
  def review_instructions(focus)
123
166
  case focus.to_s.downcase
124
167
  when 'security'
125
- 'Focus on: SQL injection, XSS, CSRF, mass assignment, authentication/authorization gaps, ' \
126
- 'sensitive data exposure, insecure dependencies, command injection, path traversal.'
168
+ 'Focus on: SQL injection, XSS, CSRF, mass assignment, ' \
169
+ 'authentication/authorization gaps, sensitive data exposure, ' \
170
+ 'insecure dependencies, command injection, path traversal.'
127
171
  when 'performance'
128
- 'Focus on: N+1 queries, missing indexes, eager loading, caching opportunities, ' \
129
- 'unnecessary database calls, memory bloat, slow iterations, missing pagination.'
172
+ 'Focus on: N+1 queries, missing indexes, eager loading, caching ' \
173
+ 'opportunities, unnecessary database calls, memory bloat, slow ' \
174
+ 'iterations, missing pagination.'
130
175
  when 'style'
131
- 'Focus on: Ruby idioms, naming conventions, method length, class organization, ' \
132
- 'frozen string literals, guard clauses, DRY violations, dead code.'
176
+ 'Focus on: Ruby idioms, naming conventions, method length, class ' \
177
+ 'organization, frozen string literals, guard clauses, DRY ' \
178
+ 'violations, dead code.'
133
179
  when 'testing'
134
- 'Focus on: Missing test coverage, test quality, factory usage, assertion quality, ' \
135
- 'test isolation, flaky test risks, edge cases, integration vs unit test balance.'
180
+ 'Focus on: Missing test coverage, test quality, factory usage, ' \
181
+ 'assertion quality, test isolation, flaky test risks, edge ' \
182
+ 'cases, integration vs unit test balance.'
136
183
  else
137
- 'Review all aspects: code quality, security, performance, testing, Rails conventions, ' \
138
- 'Ruby idioms, and architectural patterns.'
184
+ 'Review all aspects: code quality, security, performance, testing, ' \
185
+ 'Rails conventions, Ruby idioms, and architectural patterns.'
139
186
  end
140
187
  end
141
188
  end
@@ -10,10 +10,18 @@ module RubynCode
10
10
  TOOL_NAME = 'run_specs'
11
11
  DESCRIPTION = 'Runs RSpec or Minitest specs. Auto-detects which test framework is in use.'
12
12
  PARAMETERS = {
13
- path: { type: :string, required: false, description: 'Specific test file or directory to run' },
14
- format: { type: :string, required: false, default: 'documentation',
15
- description: "Output format (default: 'documentation')" },
16
- fail_fast: { type: :boolean, required: false, description: 'Stop on first failure' }
13
+ path: {
14
+ type: :string, required: false,
15
+ description: 'Specific test file or directory to run'
16
+ },
17
+ format: {
18
+ type: :string, required: false, default: 'documentation',
19
+ description: "Output format (default: 'documentation')"
20
+ },
21
+ fail_fast: {
22
+ type: :boolean, required: false,
23
+ description: 'Stop on first failure'
24
+ }
17
25
  }.freeze
18
26
  RISK_LEVEL = :execute
19
27
  REQUIRES_CONFIRMATION = false
@@ -30,14 +38,21 @@ module RubynCode
30
38
  private
31
39
 
32
40
  def detect_framework
41
+ detect_from_gemfile || detect_from_files
42
+ end
43
+
44
+ def detect_from_gemfile
33
45
  gemfile_path = File.join(project_root, 'Gemfile')
46
+ return nil unless File.exist?(gemfile_path)
34
47
 
35
- if File.exist?(gemfile_path)
36
- content = File.read(gemfile_path)
37
- return :rspec if content.match?(/['"]rspec['"]/) || content.match?(/['"]rspec-rails['"]/)
38
- return :minitest if content.match?(/['"]minitest['"]/)
39
- end
48
+ content = File.read(gemfile_path)
49
+ return :rspec if content.match?(/['"]rspec['"]/) || content.match?(/['"]rspec-rails['"]/)
50
+ return :minitest if content.match?(/['"]minitest['"]/)
51
+
52
+ nil
53
+ end
40
54
 
55
+ def detect_from_files
41
56
  return :rspec if File.exist?(File.join(project_root, '.rspec'))
42
57
  return :rspec if File.directory?(File.join(project_root, 'spec'))
43
58
  return :minitest if File.directory?(File.join(project_root, 'test'))
@@ -54,17 +69,13 @@ module RubynCode
54
69
  cmd += " #{path}" if path
55
70
  cmd
56
71
  when :minitest
57
- if path
58
- "bundle exec ruby -Itest #{path}"
59
- else
60
- 'bundle exec rails test'
61
- end
72
+ path ? "bundle exec ruby -Itest #{path}" : 'bundle exec rails test'
62
73
  end
63
74
  end
64
75
 
65
76
  def build_output(stdout, stderr, status)
66
77
  parts = []
67
- parts << stdout unless stdout.empty?
78
+ parts << SpecOutputParser.parse(stdout) unless stdout.empty?
68
79
  parts << "STDERR:\n#{stderr}" unless stderr.empty?
69
80
  parts << "Exit code: #{status.exitstatus}" unless status.success?
70
81
  parts.empty? ? '(no output)' : parts.join("\n")
@@ -35,20 +35,14 @@ module RubynCode
35
35
  schema
36
36
  end
37
37
 
38
+ OPTIONAL_PROP_KEYS = %i[description default enum].freeze
39
+
38
40
  private
39
41
 
40
42
  def build_property(spec)
41
- prop = {}
42
-
43
- type = spec[:type] || :string
44
- prop[:type] = TYPE_MAP.fetch(type, type.to_s)
45
-
46
- prop[:description] = spec[:description] if spec[:description]
47
- prop[:default] = spec[:default] if spec.key?(:default)
48
- prop[:enum] = spec[:enum] if spec[:enum]
49
-
43
+ prop = { type: TYPE_MAP.fetch(spec[:type] || :string, (spec[:type] || :string).to_s) }
44
+ OPTIONAL_PROP_KEYS.each { |key| prop[key] = spec[key] if spec.key?(key) && spec[key] }
50
45
  prop[:items] = build_property(spec[:items]) if spec[:items]
51
-
52
46
  prop
53
47
  end
54
48
  end