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.
- checksums.yaml +4 -4
- data/README.md +91 -3
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +55 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +149 -0
- data/lib/rubyn_code/agent/loop.rb +175 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
- data/lib/rubyn_code/agent/tool_processor.rb +158 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +31 -44
- data/lib/rubyn_code/autonomous/daemon.rb +29 -18
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
- data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +128 -114
- data/lib/rubyn_code/cli/commands/model.rb +75 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +42 -373
- data/lib/rubyn_code/cli/repl_commands.rb +176 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
- data/lib/rubyn_code/cli/repl_setup.rb +145 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +10 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/settings.rb +100 -1
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +167 -0
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +7 -5
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/index/codebase_index.rb +245 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +55 -252
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +9 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +26 -32
- data/lib/rubyn_code/tools/bash.rb +2 -1
- data/lib/rubyn_code/tools/edit_file.rb +74 -18
- data/lib/rubyn_code/tools/executor.rb +74 -24
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +23 -7
- data/lib/rubyn_code/tools/grep.rb +2 -1
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +185 -0
- data/lib/rubyn_code/tools/read_file.rb +11 -6
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +59 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +40 -1
- data/skills/rubyn_self_test.md +121 -0
- 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
|
-
|
|
26
|
-
end_line = limit ? start_line + limit.to_i : lines.length
|
|
27
|
+
private
|
|
27
28
|
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
review
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
122
|
+
def build_focus_section(focus)
|
|
123
|
+
[
|
|
124
|
+
"## Review Focus: #{focus.upcase}",
|
|
125
|
+
review_instructions(focus),
|
|
126
|
+
''
|
|
127
|
+
]
|
|
128
|
+
end
|
|
87
129
|
|
|
88
|
-
|
|
130
|
+
def build_diff_section(diff)
|
|
89
131
|
if diff.length > 40_000
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
126
|
-
'sensitive data exposure,
|
|
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
|
|
129
|
-
'unnecessary database calls, memory bloat, slow
|
|
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
|
|
132
|
-
'frozen string literals, guard clauses, DRY
|
|
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,
|
|
135
|
-
'test isolation, flaky test risks, edge
|
|
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,
|
|
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: {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|