rubyn 0.1.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 (79) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +251 -0
  4. data/Rakefile +12 -0
  5. data/exe/rubyn +5 -0
  6. data/lib/generators/rubyn/install_generator.rb +16 -0
  7. data/lib/rubyn/cli.rb +85 -0
  8. data/lib/rubyn/client/api_client.rb +172 -0
  9. data/lib/rubyn/commands/agent.rb +191 -0
  10. data/lib/rubyn/commands/base.rb +60 -0
  11. data/lib/rubyn/commands/config.rb +51 -0
  12. data/lib/rubyn/commands/dashboard.rb +85 -0
  13. data/lib/rubyn/commands/index.rb +101 -0
  14. data/lib/rubyn/commands/init.rb +166 -0
  15. data/lib/rubyn/commands/refactor.rb +175 -0
  16. data/lib/rubyn/commands/review.rb +61 -0
  17. data/lib/rubyn/commands/spec.rb +72 -0
  18. data/lib/rubyn/commands/usage.rb +56 -0
  19. data/lib/rubyn/config/credentials.rb +39 -0
  20. data/lib/rubyn/config/project_config.rb +42 -0
  21. data/lib/rubyn/config/settings.rb +53 -0
  22. data/lib/rubyn/context/codebase_indexer.rb +195 -0
  23. data/lib/rubyn/context/context_builder.rb +36 -0
  24. data/lib/rubyn/context/file_resolver.rb +235 -0
  25. data/lib/rubyn/context/project_scanner.rb +132 -0
  26. data/lib/rubyn/engine/app/assets/images/rubyn/RubynLogo.png +0 -0
  27. data/lib/rubyn/engine/app/assets/javascripts/rubyn/application.js +579 -0
  28. data/lib/rubyn/engine/app/assets/stylesheets/rubyn/application.css +1073 -0
  29. data/lib/rubyn/engine/app/controllers/rubyn/agent_controller.rb +26 -0
  30. data/lib/rubyn/engine/app/controllers/rubyn/application_controller.rb +44 -0
  31. data/lib/rubyn/engine/app/controllers/rubyn/dashboard_controller.rb +19 -0
  32. data/lib/rubyn/engine/app/controllers/rubyn/feedback_controller.rb +17 -0
  33. data/lib/rubyn/engine/app/controllers/rubyn/files_controller.rb +54 -0
  34. data/lib/rubyn/engine/app/controllers/rubyn/refactor_controller.rb +56 -0
  35. data/lib/rubyn/engine/app/controllers/rubyn/reviews_controller.rb +32 -0
  36. data/lib/rubyn/engine/app/controllers/rubyn/settings_controller.rb +17 -0
  37. data/lib/rubyn/engine/app/controllers/rubyn/specs_controller.rb +33 -0
  38. data/lib/rubyn/engine/app/views/layouts/rubyn/application.html.erb +63 -0
  39. data/lib/rubyn/engine/app/views/rubyn/agent/show.html.erb +22 -0
  40. data/lib/rubyn/engine/app/views/rubyn/dashboard/index.html.erb +120 -0
  41. data/lib/rubyn/engine/app/views/rubyn/files/index.html.erb +45 -0
  42. data/lib/rubyn/engine/app/views/rubyn/refactor/show.html.erb +28 -0
  43. data/lib/rubyn/engine/app/views/rubyn/reviews/show.html.erb +28 -0
  44. data/lib/rubyn/engine/app/views/rubyn/settings/show.html.erb +42 -0
  45. data/lib/rubyn/engine/app/views/rubyn/specs/show.html.erb +28 -0
  46. data/lib/rubyn/engine/config/routes.rb +13 -0
  47. data/lib/rubyn/engine/engine.rb +18 -0
  48. data/lib/rubyn/output/diff_renderer.rb +106 -0
  49. data/lib/rubyn/output/formatter.rb +123 -0
  50. data/lib/rubyn/output/spinner.rb +26 -0
  51. data/lib/rubyn/tools/base_tool.rb +74 -0
  52. data/lib/rubyn/tools/bundle_add.rb +77 -0
  53. data/lib/rubyn/tools/create_file.rb +32 -0
  54. data/lib/rubyn/tools/delete_file.rb +29 -0
  55. data/lib/rubyn/tools/executor.rb +68 -0
  56. data/lib/rubyn/tools/find_files.rb +33 -0
  57. data/lib/rubyn/tools/find_references.rb +72 -0
  58. data/lib/rubyn/tools/git_commit.rb +65 -0
  59. data/lib/rubyn/tools/git_create_branch.rb +58 -0
  60. data/lib/rubyn/tools/git_diff.rb +42 -0
  61. data/lib/rubyn/tools/git_log.rb +43 -0
  62. data/lib/rubyn/tools/git_status.rb +26 -0
  63. data/lib/rubyn/tools/list_directory.rb +82 -0
  64. data/lib/rubyn/tools/move_file.rb +35 -0
  65. data/lib/rubyn/tools/patch_file.rb +47 -0
  66. data/lib/rubyn/tools/rails_generate.rb +40 -0
  67. data/lib/rubyn/tools/rails_migrate.rb +55 -0
  68. data/lib/rubyn/tools/rails_routes.rb +35 -0
  69. data/lib/rubyn/tools/read_file.rb +45 -0
  70. data/lib/rubyn/tools/registry.rb +28 -0
  71. data/lib/rubyn/tools/run_command.rb +48 -0
  72. data/lib/rubyn/tools/run_tests.rb +52 -0
  73. data/lib/rubyn/tools/search_files.rb +82 -0
  74. data/lib/rubyn/tools/write_file.rb +30 -0
  75. data/lib/rubyn/version.rb +5 -0
  76. data/lib/rubyn/version_checker.rb +74 -0
  77. data/lib/rubyn.rb +95 -0
  78. data/sig/rubyn.rbs +4 -0
  79. metadata +379 -0
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyn
4
+ module Context
5
+ class ContextBuilder
6
+ MAX_CONTEXT_BYTES = 100_000
7
+ AGENT_MAX_CONTEXT_BYTES = 500_000
8
+
9
+ attr_reader :project_root
10
+
11
+ def initialize(project_root = Dir.pwd, max_bytes: MAX_CONTEXT_BYTES)
12
+ @project_root = project_root
13
+ @max_bytes = max_bytes
14
+ end
15
+
16
+ def build(target_path, related_paths = [])
17
+ context = {}
18
+ total_size = 0
19
+
20
+ all_paths = [target_path] + related_paths
21
+ all_paths.uniq.each do |path|
22
+ full_path = File.join(project_root, path)
23
+ next unless File.exist?(full_path)
24
+
25
+ content = File.read(full_path)
26
+ break if total_size + content.bytesize > @max_bytes
27
+
28
+ context[path] = content
29
+ total_size += content.bytesize
30
+ end
31
+
32
+ context
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyn
4
+ module Context
5
+ class FileResolver
6
+ attr_reader :project_root, :project_type
7
+
8
+ def initialize(project_root = Dir.pwd, project_type: nil)
9
+ @project_root = project_root
10
+ @project_type = project_type || detect_project_type
11
+ end
12
+
13
+ def resolve(file_path)
14
+ relative_path = file_path.start_with?(project_root) ? file_path.sub("#{project_root}/", "") : file_path
15
+
16
+ related = case @project_type
17
+ when "rails_app"
18
+ resolve_rails(relative_path)
19
+ when "ruby_app"
20
+ resolve_ruby_gem(relative_path)
21
+ else
22
+ resolve_generic(relative_path)
23
+ end
24
+
25
+ related.select { |f| File.exist?(File.join(project_root, f)) }.uniq
26
+ end
27
+
28
+ private
29
+
30
+ def detect_project_type
31
+ if File.exist?(File.join(project_root, "config", "routes.rb"))
32
+ "rails_app"
33
+ elsif Dir.glob(File.join(project_root, "*.gemspec")).any?
34
+ "ruby_app"
35
+ else
36
+ "other_app"
37
+ end
38
+ end
39
+
40
+ def resolve_rails(path)
41
+ related = []
42
+
43
+ case path
44
+ when %r{^app/controllers/(.+)_controller\.rb$}
45
+ name = Regexp.last_match(1)
46
+ singular = singularize(File.basename(name))
47
+ related += controller_related(name, singular)
48
+ when %r{^app/models/(.+)\.rb$}
49
+ name = Regexp.last_match(1)
50
+ related += model_related(name)
51
+ when %r{^app/services/(.+)\.rb$}
52
+ name = Regexp.last_match(1)
53
+ related += service_related(name, path)
54
+ when %r{^spec/(.+)_spec\.rb$}
55
+ source = Regexp.last_match(1)
56
+ related << source_from_spec(source)
57
+ end
58
+
59
+ # Always scan for constant references to find dependencies
60
+ related += resolve_constants(path)
61
+
62
+ related << "config/routes.rb"
63
+ related << "db/schema.rb"
64
+ related
65
+ end
66
+
67
+ def controller_related(name, singular)
68
+ [
69
+ "app/models/#{singular}.rb",
70
+ "config/routes.rb",
71
+ "spec/requests/#{name}_spec.rb",
72
+ "spec/controllers/#{name}_controller_spec.rb"
73
+ ] + Dir.glob(File.join(project_root, "app/services/#{name}/**/*.rb")).map { |f| f.sub("#{project_root}/", "") }
74
+ end
75
+
76
+ def model_related(name)
77
+ plural = pluralize(name)
78
+ [
79
+ "db/schema.rb",
80
+ "app/controllers/#{plural}_controller.rb",
81
+ "spec/models/#{name}_spec.rb",
82
+ "spec/factories/#{plural}.rb",
83
+ "spec/factories/#{name}.rb"
84
+ ]
85
+ end
86
+
87
+ def service_related(name, path)
88
+ related = ["spec/services/#{name}_spec.rb"]
89
+ related + resolve_constants(path)
90
+ end
91
+
92
+ def resolve_ruby_gem(path)
93
+ related = []
94
+
95
+ case path
96
+ when %r{^lib/(.+)\.rb$}
97
+ spec_path = "spec/#{Regexp.last_match(1)}_spec.rb"
98
+ related << spec_path
99
+
100
+ dir = File.dirname(path)
101
+ if dir != "lib"
102
+ Dir.glob(File.join(project_root, dir, "*.rb")).each do |f|
103
+ sibling = f.sub("#{project_root}/", "")
104
+ related << sibling unless sibling == path
105
+ end
106
+ end
107
+ when %r{^spec/(.+)_spec\.rb$}
108
+ related << "lib/#{Regexp.last_match(1)}.rb"
109
+ end
110
+
111
+ related
112
+ end
113
+
114
+ def resolve_generic(path)
115
+ dir = File.dirname(path)
116
+ related = Dir.glob(File.join(project_root, dir, "*.rb")).map { |f| f.sub("#{project_root}/", "") }
117
+ related.reject { |f| f == path }
118
+ end
119
+
120
+ def source_from_spec(spec_path)
121
+ case spec_path
122
+ when %r{^requests/(.+)$}
123
+ "app/controllers/#{Regexp.last_match(1)}_controller.rb"
124
+ when %r{^models/(.+)$}
125
+ "app/models/#{Regexp.last_match(1)}.rb"
126
+ when %r{^services/(.+)$}
127
+ "app/services/#{Regexp.last_match(1)}.rb"
128
+ else
129
+ "lib/#{spec_path}.rb"
130
+ end
131
+ end
132
+
133
+ # Scan a file for class/module constant references and locate their source files
134
+ # in the project. Handles namespaced constants like Ai::ClaudeClient -> app/services/ai/claude_client.rb
135
+ # Also parses Rails associations (belongs_to, has_many, has_one) to find related models.
136
+ def resolve_constants(path)
137
+ full_path = File.join(project_root, path)
138
+ return [] unless File.exist?(full_path)
139
+
140
+ content = File.read(full_path)
141
+ found = []
142
+
143
+ # Match namespaced constants: Foo::Bar::Baz, Foo::Bar, or standalone Foo
144
+ # Excludes common Ruby/Rails constants that aren't project classes
145
+ constants = content.scan(/\b([A-Z][a-zA-Z0-9]*(?:::[A-Z][a-zA-Z0-9]*)*)/).flatten.uniq
146
+ constants -= ruby_stdlib_constants
147
+
148
+ # Parse Rails associations to find related models
149
+ # belongs_to :user -> User, has_many :line_items -> LineItem, has_one :profile -> Profile
150
+ content.scan(/(?:belongs_to|has_one|has_many|has_and_belongs_to_many)\s+:(\w+)/).flatten.each do |assoc_name|
151
+ # Singularize has_many names, then camelize
152
+ model_name = singularize(assoc_name)
153
+ constants << camelize(model_name)
154
+ end
155
+
156
+ constants.uniq!
157
+
158
+ constants.each do |const|
159
+ # Convert Foo::Bar::Baz to foo/bar/baz
160
+ const_path = const.split("::").map { |part| underscore(part) }.join("/")
161
+
162
+ # Search common Rails locations
163
+ candidates = [
164
+ "app/models/#{const_path}.rb",
165
+ "app/services/#{const_path}.rb",
166
+ "app/controllers/#{const_path}_controller.rb",
167
+ "app/jobs/#{const_path}.rb",
168
+ "app/mailers/#{const_path}.rb",
169
+ "app/forms/#{const_path}.rb",
170
+ "app/queries/#{const_path}.rb",
171
+ "app/policies/#{const_path}.rb",
172
+ "app/serializers/#{const_path}.rb",
173
+ "app/validators/#{const_path}.rb",
174
+ "app/workers/#{const_path}.rb",
175
+ "lib/#{const_path}.rb"
176
+ ]
177
+
178
+ candidates.each do |candidate|
179
+ if File.exist?(File.join(project_root, candidate)) && candidate != path
180
+ found << candidate
181
+ end
182
+ end
183
+ end
184
+
185
+ found.uniq
186
+ end
187
+
188
+ # Common Ruby/Rails constants to ignore when scanning for project dependencies
189
+ def ruby_stdlib_constants
190
+ @ruby_stdlib_constants ||= %w[
191
+ String Integer Float Array Hash Symbol Regexp Range IO File Dir
192
+ Time Date DateTime Numeric Boolean Object Class Module Kernel
193
+ Struct OpenStruct Comparable Enumerable Enumerator Proc Lambda
194
+ Set SortedSet Queue Thread Mutex Process Signal Fiber
195
+ StandardError RuntimeError ArgumentError TypeError NameError
196
+ NoMethodError NotImplementedError IOError Errno
197
+ ActiveRecord ActiveModel ActiveSupport ActionController ActionView
198
+ ActionMailer ActiveJob ActionCable ApplicationRecord ApplicationController
199
+ ApplicationMailer ApplicationJob ActiveStorage ActionText
200
+ Rails Rake Bundler Gem RSpec Minitest FactoryBot
201
+ Logger JSON YAML CSV ERB URI Net HTTP HTTPS
202
+ Base64 Digest OpenSSL SecureRandom Pathname Tempfile
203
+ BigDecimal Complex Rational
204
+ TRUE FALSE NIL ENV ARGV STDIN STDOUT STDERR
205
+ Grape API
206
+ ]
207
+ end
208
+
209
+ def singularize(word)
210
+ return word[0..-4] if word.end_with?("ies")
211
+ return word[0..-3] if word.end_with?("ses")
212
+ return word[0..-2] if word.end_with?("s")
213
+
214
+ word
215
+ end
216
+
217
+ def pluralize(word)
218
+ return "#{word[0..-2]}ies" if word.end_with?("y") && !word.end_with?("ey")
219
+ return "#{word}es" if word.end_with?("s", "x", "z", "ch", "sh")
220
+
221
+ "#{word}s"
222
+ end
223
+
224
+ def camelize(underscored)
225
+ underscored.split("_").map(&:capitalize).join
226
+ end
227
+
228
+ def underscore(camel)
229
+ camel.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
230
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
231
+ .downcase
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubyn
4
+ module Context
5
+ class ProjectScanner
6
+ attr_reader :project_root
7
+
8
+ def initialize(project_root = Dir.pwd)
9
+ @project_root = project_root
10
+ end
11
+
12
+ def scan
13
+ {
14
+ project_name: File.basename(project_root),
15
+ project_type: detect_project_type,
16
+ ruby_version: detect_ruby_version,
17
+ rails_version: detect_rails_version,
18
+ test_framework: detect_test_framework,
19
+ factory_library: detect_factory_library,
20
+ auth_library: detect_auth_library,
21
+ gems: detect_gems,
22
+ directory_structure: scan_directory_structure
23
+ }
24
+ end
25
+
26
+ private
27
+
28
+ def detect_project_type
29
+ if File.exist?(File.join(project_root, "config", "routes.rb"))
30
+ "rails_app"
31
+ elsif File.exist?(File.join(project_root, "config.ru")) && gemfile_contains?("sinatra")
32
+ "sinatra_app"
33
+ elsif Dir.glob(File.join(project_root, "*.gemspec")).any?
34
+ "ruby_app"
35
+ else
36
+ "other_app"
37
+ end
38
+ end
39
+
40
+ def detect_ruby_version
41
+ version_file = File.join(project_root, ".ruby-version")
42
+ return File.read(version_file).strip if File.exist?(version_file)
43
+
44
+ tool_versions = File.join(project_root, ".tool-versions")
45
+ if File.exist?(tool_versions)
46
+ match = File.read(tool_versions).match(/ruby\s+(\S+)/)
47
+ return match[1] if match
48
+ end
49
+
50
+ nil
51
+ end
52
+
53
+ def detect_rails_version
54
+ lockfile = lockfile_content
55
+ return nil unless lockfile
56
+
57
+ match = lockfile.match(/^\s+rails\s+\((\S+)\)/m)
58
+ match ? match[1] : nil
59
+ end
60
+
61
+ def detect_test_framework
62
+ lockfile = lockfile_content
63
+ return "rspec" if lockfile&.include?("rspec")
64
+ return "rspec" if Dir.exist?(File.join(project_root, "spec"))
65
+ return "minitest" if Dir.exist?(File.join(project_root, "test"))
66
+
67
+ "rspec"
68
+ end
69
+
70
+ def detect_factory_library
71
+ lockfile = lockfile_content
72
+ return "factory_bot" if lockfile&.include?("factory_bot")
73
+ return "fabrication" if lockfile&.include?("fabrication")
74
+
75
+ "no_factory"
76
+ end
77
+
78
+ def detect_auth_library
79
+ lockfile = lockfile_content
80
+ return "devise" if lockfile&.include?("devise")
81
+ return "clearance" if lockfile&.include?("clearance")
82
+
83
+ nil
84
+ end
85
+
86
+ def detect_gems
87
+ lockfile = lockfile_content
88
+ return [] unless lockfile
89
+
90
+ gems = []
91
+ in_specs = false
92
+ lockfile.each_line do |line|
93
+ if line.strip == "specs:"
94
+ in_specs = true
95
+ next
96
+ end
97
+ break if in_specs && !line.start_with?(" ")
98
+
99
+ if in_specs
100
+ match = line.match(/^\s{4}(\S+)\s+\((\S+)\)/)
101
+ gems << { name: match[1], version: match[2] } if match
102
+ end
103
+ end
104
+ gems
105
+ end
106
+
107
+ def scan_directory_structure
108
+ dirs = %w[app lib spec test config db]
109
+ structure = {}
110
+ dirs.each do |dir|
111
+ full_path = File.join(project_root, dir)
112
+ structure[dir] = Dir.exist?(full_path)
113
+ end
114
+ structure
115
+ end
116
+
117
+ def gemfile_contains?(gem_name)
118
+ gemfile = File.join(project_root, "Gemfile")
119
+ return false unless File.exist?(gemfile)
120
+
121
+ File.read(gemfile).include?(gem_name)
122
+ end
123
+
124
+ def lockfile_content
125
+ @lockfile_content ||= begin
126
+ path = File.join(project_root, "Gemfile.lock")
127
+ File.exist?(path) ? File.read(path) : nil
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end