rails-ai-context 5.32.2 → 5.34.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/CHANGELOG.md +1491 -0
- data/CONTEXT.md +27 -3
- data/README.md +16 -12
- data/Rakefile +6 -0
- data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
- data/config/routes.rb +2 -3
- data/docs/ARCHITECTURE.md +7 -5
- data/docs/CLI.md +79 -6
- data/docs/COMPATIBILITY.md +75 -1
- data/docs/CONFIGURATION.md +30 -11
- data/docs/CUSTOM_TOOLS.md +2 -1
- data/docs/FAQ.md +16 -6
- data/docs/GUIDE.md +117 -71
- data/docs/INTROSPECTORS.md +10 -8
- data/docs/QUICKSTART.md +3 -3
- data/docs/RECIPES.md +1 -1
- data/docs/SECURITY.md +109 -25
- data/docs/SETUP.md +30 -6
- data/docs/STANDALONE.md +10 -3
- data/docs/TOOLS.md +152 -34
- data/docs/TROUBLESHOOTING.md +90 -12
- data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
- data/docs/adr/0005-workspace-mcp-entries.md +39 -0
- data/exe/rails-ai-context +733 -104
- data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
- data/lib/rails_ai_context/app_kind.rb +18 -5
- data/lib/rails_ai_context/ast_cache.rb +7 -0
- data/lib/rails_ai_context/base_mixins.rb +8 -8
- data/lib/rails_ai_context/boot_manager.rb +22 -3
- data/lib/rails_ai_context/change_watch.rb +14 -11
- data/lib/rails_ai_context/cli/app_root.rb +401 -0
- data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
- data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
- data/lib/rails_ai_context/code_reloader.rb +115 -1
- data/lib/rails_ai_context/concern_membership.rb +3 -1
- data/lib/rails_ai_context/concern_paths.rb +8 -3
- data/lib/rails_ai_context/configuration.rb +41 -9
- data/lib/rails_ai_context/context_file_report.rb +19 -2
- data/lib/rails_ai_context/database_file.rb +26 -0
- data/lib/rails_ai_context/doctor.rb +1122 -184
- data/lib/rails_ai_context/engine.rb +18 -1
- data/lib/rails_ai_context/erb_source.rb +19 -0
- data/lib/rails_ai_context/executable.rb +21 -0
- data/lib/rails_ai_context/facts_formatter.rb +5 -2
- data/lib/rails_ai_context/file_walk.rb +61 -10
- data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
- data/lib/rails_ai_context/fingerprinter.rb +40 -12
- data/lib/rails_ai_context/gem_lock.rb +186 -27
- data/lib/rails_ai_context/git_ignore.rb +22 -0
- data/lib/rails_ai_context/install/ai_tool.rb +31 -5
- data/lib/rails_ai_context/install/cleanup.rb +21 -0
- data/lib/rails_ai_context/install/program.rb +209 -35
- data/lib/rails_ai_context/install/selection_record.rb +258 -97
- data/lib/rails_ai_context/install/surface.rb +6 -1
- data/lib/rails_ai_context/install/validation_hook.rb +282 -0
- data/lib/rails_ai_context/install/workspace.rb +166 -0
- data/lib/rails_ai_context/install_mode.rb +71 -13
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
- data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
- data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
- data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
- data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
- data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
- data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
- data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
- data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
- data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
- data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
- data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
- data/lib/rails_ai_context/live_reload.rb +20 -20
- data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
- data/lib/rails_ai_context/mcp_edge.rb +182 -32
- data/lib/rails_ai_context/middleware.rb +5 -1
- data/lib/rails_ai_context/migration_status.rb +88 -25
- data/lib/rails_ai_context/output_guard.rb +48 -2
- data/lib/rails_ai_context/package_json.rb +1 -1
- data/lib/rails_ai_context/path_resolver.rb +46 -13
- data/lib/rails_ai_context/payload.rb +92 -8
- data/lib/rails_ai_context/pending_migrations.rb +5 -1
- data/lib/rails_ai_context/portable_path.rb +3 -2
- data/lib/rails_ai_context/presets.rb +15 -11
- data/lib/rails_ai_context/redaction.rb +262 -51
- data/lib/rails_ai_context/resources.rb +55 -20
- data/lib/rails_ai_context/route_coverage.rb +8 -0
- data/lib/rails_ai_context/safe_file.rb +14 -1
- data/lib/rails_ai_context/safe_path.rb +101 -4
- data/lib/rails_ai_context/schema_adapter.rb +8 -0
- data/lib/rails_ai_context/schema_version.rb +9 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
- data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
- data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
- data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
- data/lib/rails_ai_context/server.rb +304 -71
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
- data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
- data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
- data/lib/rails_ai_context/tools/base_tool.rb +367 -46
- data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
- data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
- data/lib/rails_ai_context/tools/diagnose.rb +218 -11
- data/lib/rails_ai_context/tools/generate_test.rb +339 -39
- data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
- data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
- data/lib/rails_ai_context/tools/get_concern.rb +91 -27
- data/lib/rails_ai_context/tools/get_config.rb +13 -4
- data/lib/rails_ai_context/tools/get_context.rb +71 -14
- data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
- data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
- data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
- data/lib/rails_ai_context/tools/get_env.rb +18 -9
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
- data/lib/rails_ai_context/tools/get_gems.rb +1 -1
- data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
- data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
- data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
- data/lib/rails_ai_context/tools/get_routes.rb +29 -8
- data/lib/rails_ai_context/tools/get_schema.rb +52 -19
- data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
- data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
- data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
- data/lib/rails_ai_context/tools/get_view.rb +14 -14
- data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
- data/lib/rails_ai_context/tools/onboard.rb +52 -8
- data/lib/rails_ai_context/tools/performance_check.rb +5 -3
- data/lib/rails_ai_context/tools/query.rb +914 -86
- data/lib/rails_ai_context/tools/read_logs.rb +69 -15
- data/lib/rails_ai_context/tools/review_changes.rb +215 -53
- data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
- data/lib/rails_ai_context/tools/safe_call.rb +35 -14
- data/lib/rails_ai_context/tools/search_code.rb +243 -67
- data/lib/rails_ai_context/tools/search_docs.rb +4 -4
- data/lib/rails_ai_context/tools/security_scan.rb +143 -33
- data/lib/rails_ai_context/tools/session_context.rb +39 -5
- data/lib/rails_ai_context/tools/validate.rb +259 -40
- data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +67 -40
- data/lib/rails_ai_context/view_file.rb +41 -3
- data/lib/rails_ai_context/watcher.rb +51 -13
- data/lib/rails_ai_context.rb +21 -0
- data/server.json +1 -1
- metadata +14 -1
|
@@ -17,7 +17,9 @@ module RailsAiContext
|
|
|
17
17
|
module GemLock
|
|
18
18
|
MAX_SIZE = 5 * 1024 * 1024
|
|
19
19
|
SPEC_LINE = /\A {4}(\S+) \(([^)]+)\)\s*\z/
|
|
20
|
-
|
|
20
|
+
SPEC_DEPENDENCY_LINE = /\A {6}(\S+)/
|
|
21
|
+
# Bundler puts the "!" of a gem from its own source after the requirement: "sidekiq-pro (= 8.0.3)!".
|
|
22
|
+
DEPENDENCY_LINE = /\A {2}(\S+?)!?(?: \(.*\))?!?\s*\z/
|
|
21
23
|
# Bundler writes a non-CRuby engine after the version: "ruby 3.1.4p0 (jruby 9.4.8.0)".
|
|
22
24
|
RUBY_LINE = /\A\s+ruby (\S+)(?: \((\S+) ([^)]+)\))?/
|
|
23
25
|
REMOTE_LINE = /\A {2}remote: (.+?)\s*\z/
|
|
@@ -44,8 +46,9 @@ module RailsAiContext
|
|
|
44
46
|
attr_reader :gemfile_gems
|
|
45
47
|
|
|
46
48
|
def initialize(versions, ruby_versions: {}, ruby_engine: nil, reason: nil, absent: false, direct: nil, path_remotes: [],
|
|
47
|
-
outside_gemfile: nil, no_repo: false, gemfile_gems: nil)
|
|
49
|
+
outside_gemfile: nil, no_repo: false, gemfile_gems: nil, sources: {})
|
|
48
50
|
@versions = versions
|
|
51
|
+
@sources = sources
|
|
49
52
|
@gemfile_gems = gemfile_gems
|
|
50
53
|
@outside_gemfile = outside_gemfile
|
|
51
54
|
@no_repo = no_repo
|
|
@@ -89,6 +92,11 @@ module RailsAiContext
|
|
|
89
92
|
@versions.key?(name.to_s)
|
|
90
93
|
end
|
|
91
94
|
|
|
95
|
+
# Where the lockfile takes a gem from: [:gem] (a rubygems source), [:path, remote] or [:git]; nil when unlocked.
|
|
96
|
+
def source(name)
|
|
97
|
+
@sources[name.to_s]
|
|
98
|
+
end
|
|
99
|
+
|
|
92
100
|
def version(name)
|
|
93
101
|
@versions[name.to_s]
|
|
94
102
|
end
|
|
@@ -154,6 +162,18 @@ module RailsAiContext
|
|
|
154
162
|
end
|
|
155
163
|
end
|
|
156
164
|
|
|
165
|
+
# The lock of a Gemfile named outright, as BUNDLE_GEMFILE names a dual
|
|
166
|
+
# boot's Gemfile.next. Nil with no lockfile beside it.
|
|
167
|
+
def for_gemfile(gemfile, root)
|
|
168
|
+
gemfile = File.expand_path(gemfile.to_s)
|
|
169
|
+
dir = File.dirname(gemfile)
|
|
170
|
+
lockfile = File.basename(gemfile) == "gems.rb" ? File.join(dir, "gems.locked") : "#{gemfile}.lock"
|
|
171
|
+
return nil unless File.file?(lockfile)
|
|
172
|
+
|
|
173
|
+
parse(Bundle.new(lockfile: lockfile, gemfile: gemfile, lock_label: File.basename(lockfile), gemfile_label: File.basename(gemfile),
|
|
174
|
+
dir: dir, trusted: dir, outside: nil, no_repo: false), root.to_s)
|
|
175
|
+
end
|
|
176
|
+
|
|
157
177
|
# The app's own Gemfile and lockfile, or, with no lockfile of its own, the
|
|
158
178
|
# bundle config/boot.rb points Bundler at (an engine's test/dummy).
|
|
159
179
|
def bundle(root)
|
|
@@ -187,14 +207,20 @@ module RailsAiContext
|
|
|
187
207
|
end
|
|
188
208
|
private_class_method :boot_bundle
|
|
189
209
|
|
|
190
|
-
# The BUNDLE_GEMFILE config/boot.rb sets, when it is outside the app root
|
|
210
|
+
# The BUNDLE_GEMFILE config/boot.rb sets, when it is outside the app root:
|
|
211
|
+
# the Gemfile the app boots against when nothing else names one, which a
|
|
212
|
+
# caller may name whether or not it is in a repository this reads.
|
|
191
213
|
def boot_gemfile(root)
|
|
192
214
|
real_root = File.realpath(root)
|
|
193
215
|
real = File.realpath(File.join(real_root, "config/boot.rb"))
|
|
194
216
|
return nil unless SafePath.contained?(real, real_root)
|
|
195
217
|
|
|
196
|
-
|
|
197
|
-
|
|
218
|
+
relative, anchor = if loaded?
|
|
219
|
+
result = ruby_parse(real) or return nil
|
|
220
|
+
Introspectors::AstWalk.each(result.value).lazy.filter_map { |node| bundle_gemfile_path(node) }.first
|
|
221
|
+
else
|
|
222
|
+
Preboot.bundle_gemfile_path(Preboot.tree(real))
|
|
223
|
+
end
|
|
198
224
|
return nil unless relative
|
|
199
225
|
|
|
200
226
|
# Relative to __FILE__ the path starts from boot.rb itself, one level below __dir__.
|
|
@@ -203,7 +229,6 @@ module RailsAiContext
|
|
|
203
229
|
rescue SystemCallError
|
|
204
230
|
nil
|
|
205
231
|
end
|
|
206
|
-
private_class_method :boot_gemfile
|
|
207
232
|
|
|
208
233
|
# `ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)`, the line `rails new`
|
|
209
234
|
# and `rails plugin new` write, with `=` or `__FILE__` as older templates do.
|
|
@@ -272,13 +297,18 @@ module RailsAiContext
|
|
|
272
297
|
in_specs = false
|
|
273
298
|
in_dependencies = false
|
|
274
299
|
in_path = false
|
|
300
|
+
in_git = false
|
|
275
301
|
path_remotes = []
|
|
302
|
+
path_dependencies = {}
|
|
303
|
+
spec_name = nil
|
|
304
|
+
sources = {}
|
|
276
305
|
specs_section = false
|
|
277
306
|
content.each_line do |line|
|
|
278
307
|
if line.match?(/\A\S/)
|
|
279
308
|
in_specs = false
|
|
280
309
|
in_dependencies = line.start_with?("DEPENDENCIES")
|
|
281
310
|
in_path = line.strip == "PATH"
|
|
311
|
+
in_git = line.strip == "GIT"
|
|
282
312
|
elsif in_path && (match = line.match(REMOTE_LINE))
|
|
283
313
|
path_remotes << match[1].strip
|
|
284
314
|
elsif in_dependencies && (match = line.match(DEPENDENCY_LINE))
|
|
@@ -291,6 +321,10 @@ module RailsAiContext
|
|
|
291
321
|
# per platform. The text before the first hyphen is the version; a
|
|
292
322
|
# prerelease tag ("1.70.0-beta1") is dropped along with the platform.
|
|
293
323
|
versions[match[1]] ||= match[2].split("-", 2).first
|
|
324
|
+
spec_name = match[1]
|
|
325
|
+
sources[match[1]] ||= if in_path then [ :path, path_remotes.last ] elsif in_git then [ :git ] else [ :gem ] end
|
|
326
|
+
elsif in_specs && in_path && spec_name && (match = line.match(SPEC_DEPENDENCY_LINE))
|
|
327
|
+
(path_dependencies[spec_name] ||= []) << match[1]
|
|
294
328
|
elsif (match = line.match(RUBY_LINE)) && match[1].match?(PLAIN_VERSION)
|
|
295
329
|
ruby_version = [ match[1], engine_name(match[2], match[3]) ]
|
|
296
330
|
end
|
|
@@ -300,8 +334,11 @@ module RailsAiContext
|
|
|
300
334
|
# and answering it as an app with no gems denies every gem it holds.
|
|
301
335
|
return Spec.new({}, reason: "#{File.basename(path)} has no specs section") unless specs_section
|
|
302
336
|
|
|
337
|
+
# A gemspec or `path:` gem the Gemfile names brings its runtime dependencies in as the Gemfile's own, as Bundler resolves them.
|
|
338
|
+
path_dependencies.each { |name, deps| direct.merge(deps) if direct.include?(name) }
|
|
303
339
|
facts = gemfile(bundle)
|
|
304
|
-
Spec.new(versions, **declared_ruby(ruby_version, root, bundle, facts), direct: direct, path_remotes: path_remotes
|
|
340
|
+
Spec.new(versions, **declared_ruby(ruby_version, root, bundle, facts), direct: direct, path_remotes: path_remotes,
|
|
341
|
+
sources: sources)
|
|
305
342
|
end
|
|
306
343
|
private_class_method :parse
|
|
307
344
|
|
|
@@ -320,17 +357,14 @@ module RailsAiContext
|
|
|
320
357
|
def gemfile_entries(bundle)
|
|
321
358
|
return Introspectors::GemfileGems.read_bundle(bundle) if loaded?
|
|
322
359
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
when :
|
|
330
|
-
|
|
331
|
-
{ type: :ruby, version: literal(args.first), engine: options["engine"], engine_version: options["engine_version"] }
|
|
332
|
-
when :gem then args.first.is_a?(Prism::StringNode) ? { type: :gem, name: args.first.unescaped } : { type: :unknown_gems }
|
|
333
|
-
when :gemspec, :eval_gemfile then { type: :unknown_gems }
|
|
360
|
+
tree = Preboot.tree(bundle.gemfile) or return nil
|
|
361
|
+
Preboot.calls(tree).filter_map do |name, args|
|
|
362
|
+
case name
|
|
363
|
+
when "ruby"
|
|
364
|
+
options = Preboot.options(args)
|
|
365
|
+
{ type: :ruby, version: Preboot.literal(args.first), engine: options["engine"], engine_version: options["engine_version"] }
|
|
366
|
+
when "gem" then (gem = Preboot.string(args.first)) ? { type: :gem, name: gem } : { type: :unknown_gems }
|
|
367
|
+
when "gemspec", "eval_gemfile" then { type: :unknown_gems }
|
|
334
368
|
end
|
|
335
369
|
end
|
|
336
370
|
end
|
|
@@ -341,21 +375,146 @@ module RailsAiContext
|
|
|
341
375
|
end
|
|
342
376
|
private_class_method :loaded?
|
|
343
377
|
|
|
344
|
-
#
|
|
345
|
-
# concurrent-ruby would load ahead of the app's bundle.
|
|
378
|
+
# Once the gem is loaded; before that, Preboot reads the file.
|
|
346
379
|
def ruby_parse(path)
|
|
347
|
-
return nil unless path
|
|
348
|
-
return AstCache.parse(File.realpath(path)) if loaded?
|
|
380
|
+
return nil unless path && loaded?
|
|
349
381
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
content = SafeFile.read(path, max_size: MAX_SIZE)
|
|
353
|
-
content && Prism.parse(content)
|
|
354
|
-
rescue SystemCallError, ArgumentError, LoadError
|
|
382
|
+
AstCache.parse(File.realpath(path))
|
|
383
|
+
rescue SystemCallError, ArgumentError
|
|
355
384
|
nil
|
|
356
385
|
end
|
|
357
386
|
private_class_method :ruby_parse
|
|
358
387
|
|
|
388
|
+
# Before the boot the Gemfile and config/boot.rb are read with Ripper,
|
|
389
|
+
# which ships inside Ruby. Prism is a gem the app's bundle locks (every
|
|
390
|
+
# Rails 8 app's does), so ours, loaded first, had the app's copy load over
|
|
391
|
+
# it at the boot: "already initialized constant", then a mixed parser.
|
|
392
|
+
# AstCache would bring concurrent-ruby in ahead of the bundle the same way.
|
|
393
|
+
module Preboot
|
|
394
|
+
module_function
|
|
395
|
+
|
|
396
|
+
def tree(path)
|
|
397
|
+
return nil unless path
|
|
398
|
+
|
|
399
|
+
require "ripper"
|
|
400
|
+
content = SafeFile.read(path, max_size: MAX_SIZE)
|
|
401
|
+
content && Ripper.sexp(content)
|
|
402
|
+
rescue SystemCallError, ArgumentError, LoadError
|
|
403
|
+
nil
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Every node, parent first: a Ripper node is an Array led by its type.
|
|
407
|
+
def each(node, &block)
|
|
408
|
+
return unless node.is_a?(Array)
|
|
409
|
+
|
|
410
|
+
yield node if node.first.is_a?(Symbol)
|
|
411
|
+
node.each { |child| each(child, &block) }
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# [name, arguments] of each call with no receiver: `gem "x"`, `gem("x")`, a bare `gemspec`.
|
|
415
|
+
def calls(tree)
|
|
416
|
+
found = []
|
|
417
|
+
each(tree) do |node|
|
|
418
|
+
case node.first
|
|
419
|
+
when :command then found << [ ident(node[1]), arguments(node[2]) ]
|
|
420
|
+
when :method_add_arg then found << [ ident(node[1][1]), arguments(node[2]) ] if node[1].is_a?(Array) && node[1].first == :fcall
|
|
421
|
+
when :vcall then found << [ ident(node[1]), [] ]
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
found
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def ident(node)
|
|
428
|
+
node[1] if node.is_a?(Array) && node.first == :@ident
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def arguments(node)
|
|
432
|
+
return [] unless node.is_a?(Array)
|
|
433
|
+
|
|
434
|
+
case node.first
|
|
435
|
+
when :arg_paren then arguments(node[1])
|
|
436
|
+
when :args_add_block then node[1].is_a?(Array) ? node[1] : []
|
|
437
|
+
else []
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# A string as written, nil for anything computed. Escapes stay as
|
|
442
|
+
# written: no gem name, version or path the Gemfile needs holds one.
|
|
443
|
+
def string(node)
|
|
444
|
+
return nil unless node.is_a?(Array) && node.first == :string_literal
|
|
445
|
+
|
|
446
|
+
parts = node[1].is_a?(Array) ? node[1].drop(1) : []
|
|
447
|
+
return "" if parts.empty?
|
|
448
|
+
|
|
449
|
+
parts.first[1] if parts.one? && parts.first.is_a?(Array) && parts.first.first == :@tstring_content
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def literal(node)
|
|
453
|
+
return string(node) unless node.is_a?(Array) && node.first == :symbol_literal
|
|
454
|
+
|
|
455
|
+
symbol = node[1]
|
|
456
|
+
symbol[1][1] if symbol.is_a?(Array) && symbol.first == :symbol && symbol[1].is_a?(Array)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# `engine: "jruby"`, `"engine" => "jruby"` and `:engine => "jruby"` alike.
|
|
460
|
+
def options(args)
|
|
461
|
+
args.select { |arg| arg.is_a?(Array) && arg.first == :bare_assoc_hash }.flat_map { |hash| Array(hash[1]) }
|
|
462
|
+
.select { |pair| pair.is_a?(Array) && pair.first == :assoc_new }
|
|
463
|
+
.to_h { |pair| [ key(pair[1]), literal(pair[2]) ] }
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def key(node)
|
|
467
|
+
node.is_a?(Array) && node.first == :@label ? node[1].delete_suffix(":") : literal(node)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# [path, :file or :dir] from config/boot.rb's
|
|
471
|
+
# `ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)`, `=` and `__FILE__` alike.
|
|
472
|
+
def bundle_gemfile_path(tree)
|
|
473
|
+
each(tree) do |node|
|
|
474
|
+
value = case node.first
|
|
475
|
+
when :opassign then node[3] if env_field?(node[1]) && node[2].is_a?(Array) && node[2][1] == "||="
|
|
476
|
+
when :assign then node[2] if env_field?(node[1])
|
|
477
|
+
end
|
|
478
|
+
found = value && expand_path(value)
|
|
479
|
+
return found if found
|
|
480
|
+
end
|
|
481
|
+
nil
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def env_field?(node)
|
|
485
|
+
return false unless node.is_a?(Array) && node.first == :aref_field
|
|
486
|
+
|
|
487
|
+
receiver = node[1]
|
|
488
|
+
args = arguments(node[2])
|
|
489
|
+
receiver.is_a?(Array) && receiver.first == :var_ref && receiver[1].is_a?(Array) && receiver[1][1] == "ENV" &&
|
|
490
|
+
args.one? && string(args.first) == "BUNDLE_GEMFILE"
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def expand_path(node)
|
|
494
|
+
return nil unless node.is_a?(Array) && node.first == :method_add_arg
|
|
495
|
+
|
|
496
|
+
call = node[1]
|
|
497
|
+
return nil unless call.is_a?(Array) && call.first == :call && ident(call[3]) == "expand_path" &&
|
|
498
|
+
call[1].is_a?(Array) && call[1].first == :var_ref && call[1][1].is_a?(Array) && call[1][1][1] == "File"
|
|
499
|
+
|
|
500
|
+
path, anchor = args = arguments(node[2])
|
|
501
|
+
relative = string(path)
|
|
502
|
+
return nil unless relative && args.size == 2
|
|
503
|
+
|
|
504
|
+
if anchor.is_a?(Array) && anchor.first == :var_ref && anchor[1].is_a?(Array) && anchor[1][1] == "__FILE__" then [ relative, :file ]
|
|
505
|
+
elsif dir_call?(anchor) then [ relative, :dir ]
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def dir_call?(node)
|
|
510
|
+
return false unless node.is_a?(Array)
|
|
511
|
+
|
|
512
|
+
(node.first == :vcall && ident(node[1]) == "__dir__") ||
|
|
513
|
+
(node.first == :method_add_arg && node[1].is_a?(Array) && node[1].first == :fcall && ident(node[1][1]) == "__dir__")
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
private_constant :Preboot
|
|
517
|
+
|
|
359
518
|
# A requirement such as `ruby ">= 3.3.0"` names a range, not a version, so it is left unanswered.
|
|
360
519
|
def gemfile_ruby(entry)
|
|
361
520
|
version = entry[:version]
|
|
@@ -101,6 +101,28 @@ module RailsAiContext
|
|
|
101
101
|
nil
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
+
# The directory links ripgrep would meet under `start`, in its order, past
|
|
105
|
+
# the same ignore files and `skip`; it enters none of them.
|
|
106
|
+
def dir_links(start = @root, skip: nil)
|
|
107
|
+
Dir.children(start).sort.flat_map do |name|
|
|
108
|
+
next [] if name.start_with?(".")
|
|
109
|
+
|
|
110
|
+
path = File.join(start, name)
|
|
111
|
+
stat = File.lstat(path)
|
|
112
|
+
next [] unless stat.symlink? || stat.directory?
|
|
113
|
+
|
|
114
|
+
relative = path.delete_prefix("#{@root}/")
|
|
115
|
+
next [] if skip&.call(relative, true) || verdict(path, true) == :ignore
|
|
116
|
+
next dir_links(path, skip: skip) unless stat.symlink?
|
|
117
|
+
|
|
118
|
+
File.directory?(path) ? [ path ] : []
|
|
119
|
+
rescue SystemCallError
|
|
120
|
+
[]
|
|
121
|
+
end
|
|
122
|
+
rescue SystemCallError
|
|
123
|
+
[]
|
|
124
|
+
end
|
|
125
|
+
|
|
104
126
|
private
|
|
105
127
|
|
|
106
128
|
def dir_ignored?(path)
|
|
@@ -16,6 +16,17 @@ module RailsAiContext
|
|
|
16
16
|
#
|
|
17
17
|
# rule_files names what the gem writes inside rules_dir. The directory is shared with
|
|
18
18
|
# rules the user writes by hand, so cleanup removes these names and nothing else.
|
|
19
|
+
#
|
|
20
|
+
# mcp_config[:folder_variable] is how the tool's config names the folder it sits in, where
|
|
21
|
+
# it has a name for it: a workspace entry's app path starts from that name. The others get
|
|
22
|
+
# a bare relative path, which they resolve against the folder they were started in.
|
|
23
|
+
#
|
|
24
|
+
# mcp_config[:client] is the program that reads the config, and [:jsonc] whether it reads
|
|
25
|
+
# JSONC - comments and trailing commas - or refuses them, as JSON does. VS Code reads
|
|
26
|
+
# .vscode/mcp.json as a standalone configuration file, with its JSONC parser and trailing
|
|
27
|
+
# commas allowed; OpenCode reads opencode.json with jsonc-parser, trailing commas allowed.
|
|
28
|
+
# Claude Code reads .mcp.json with JSON.parse. Cursor's is held to JSON as well, the form
|
|
29
|
+
# every reader takes: nothing shows its reader takes JSONC.
|
|
19
30
|
AiTool = Struct.new(:number, :key, :name, :files, :context_paths, :rules_dir, :rule_files, :mcp_config,
|
|
20
31
|
:legacy_paths, :owned_dir, keyword_init: true)
|
|
21
32
|
|
|
@@ -27,7 +38,7 @@ module RailsAiContext
|
|
|
27
38
|
context_paths: %w[CLAUDE.md .claude/rules],
|
|
28
39
|
rules_dir: ".claude/rules",
|
|
29
40
|
rule_files: %w[rails-context.md rails-schema.md rails-models.md rails-mcp-tools.md rails-components.md],
|
|
30
|
-
mcp_config: { path: ".mcp.json", root_key: "mcpServers", format: :mcp_json },
|
|
41
|
+
mcp_config: { path: ".mcp.json", root_key: "mcpServers", format: :mcp_json, client: "Claude Code", jsonc: false },
|
|
31
42
|
legacy_paths: [ ".claude/rules/rails-ui-patterns.md", ".claude/rules/rails-accessibility.md" ],
|
|
32
43
|
owned_dir: ".claude"
|
|
33
44
|
),
|
|
@@ -37,7 +48,8 @@ module RailsAiContext
|
|
|
37
48
|
context_paths: %w[.cursor/rules .cursorrules],
|
|
38
49
|
rules_dir: ".cursor/rules",
|
|
39
50
|
rule_files: %w[rails-project.mdc rails-models.mdc rails-controllers.mdc rails-mcp-tools.mdc],
|
|
40
|
-
mcp_config: { path: ".cursor/mcp.json", root_key: "mcpServers", format: :mcp_json
|
|
51
|
+
mcp_config: { path: ".cursor/mcp.json", root_key: "mcpServers", format: :mcp_json,
|
|
52
|
+
folder_variable: "${workspaceFolder}", client: "Cursor", jsonc: false },
|
|
41
53
|
legacy_paths: [ ".cursor/rules/rails-ui-patterns.mdc" ],
|
|
42
54
|
owned_dir: ".cursor"
|
|
43
55
|
),
|
|
@@ -50,26 +62,34 @@ module RailsAiContext
|
|
|
50
62
|
rails-context.instructions.md rails-models.instructions.md
|
|
51
63
|
rails-controllers.instructions.md rails-mcp-tools.instructions.md
|
|
52
64
|
],
|
|
53
|
-
mcp_config: { path: ".vscode/mcp.json", root_key: "servers", format: :vscode_json
|
|
65
|
+
mcp_config: { path: ".vscode/mcp.json", root_key: "servers", format: :vscode_json,
|
|
66
|
+
folder_variable: "${workspaceFolder}", client: "VS Code", jsonc: true },
|
|
54
67
|
legacy_paths: [ ".github/instructions/rails-ui-patterns.instructions.md" ]
|
|
55
68
|
),
|
|
56
69
|
new(
|
|
57
70
|
number: "4", key: :opencode, name: "OpenCode",
|
|
58
71
|
files: "AGENTS.md",
|
|
59
72
|
context_paths: %w[AGENTS.md app/models/AGENTS.md app/controllers/AGENTS.md],
|
|
60
|
-
mcp_config: { path: "opencode.json", root_key: "mcp", format: :opencode_json },
|
|
73
|
+
mcp_config: { path: "opencode.json", root_key: "mcp", format: :opencode_json, client: "OpenCode", jsonc: true },
|
|
61
74
|
legacy_paths: []
|
|
62
75
|
),
|
|
63
76
|
new(
|
|
64
77
|
number: "5", key: :codex, name: "Codex CLI",
|
|
65
78
|
files: "AGENTS.md + .codex/config.toml",
|
|
66
79
|
context_paths: %w[AGENTS.md app/models/AGENTS.md app/controllers/AGENTS.md],
|
|
67
|
-
mcp_config: { path: ".codex/config.toml", root_key: nil, format: :codex_toml },
|
|
80
|
+
mcp_config: { path: ".codex/config.toml", root_key: nil, format: :codex_toml, client: "Codex CLI" },
|
|
68
81
|
legacy_paths: [],
|
|
69
82
|
owned_dir: ".codex"
|
|
70
83
|
)
|
|
71
84
|
].freeze
|
|
72
85
|
|
|
86
|
+
# What a summary lists for the tool. `files` names Codex CLI's MCP
|
|
87
|
+
# config beside its context files, and a CLI-mode or per-app summary
|
|
88
|
+
# writes no MCP config, so it leaves that part out.
|
|
89
|
+
def files_label(mcp: true)
|
|
90
|
+
mcp ? files : context_files
|
|
91
|
+
end
|
|
92
|
+
|
|
73
93
|
class << self
|
|
74
94
|
def all
|
|
75
95
|
ALL
|
|
@@ -88,6 +108,12 @@ module RailsAiContext
|
|
|
88
108
|
ALL.flat_map { |tool| tool.legacy_paths.map { |path| { path: path, ai_tool: tool.key } } }
|
|
89
109
|
end
|
|
90
110
|
end
|
|
111
|
+
|
|
112
|
+
# What `rails ai:context:<key>` writes for the tool: its files without
|
|
113
|
+
# its MCP config, which only the install writes.
|
|
114
|
+
def context_files
|
|
115
|
+
files.split(" + ").reject { |file| file == mcp_config[:path] }.join(" + ")
|
|
116
|
+
end
|
|
91
117
|
end
|
|
92
118
|
end
|
|
93
119
|
end
|
|
@@ -49,9 +49,30 @@ module RailsAiContext
|
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
prune_emptied(outcome, root)
|
|
52
53
|
outcome
|
|
53
54
|
end
|
|
54
55
|
|
|
56
|
+
# A tool's dot-directory (.cursor/, .github/) that the removal emptied
|
|
57
|
+
# goes too, and is reported like any other path. One that holds
|
|
58
|
+
# anything else stays, and a directory outside the dot-directories -
|
|
59
|
+
# app/models, emptied of its AGENTS.md - is never removed.
|
|
60
|
+
def prune_emptied(outcome, root)
|
|
61
|
+
outcome[:removed].map { |path| File.dirname(path.delete_suffix("/")) }.uniq.each do |dir|
|
|
62
|
+
while dir.start_with?(".") && dir != "."
|
|
63
|
+
full = File.join(root.to_s, dir)
|
|
64
|
+
break unless File.directory?(full) && !File.symlink?(full) && Dir.empty?(full)
|
|
65
|
+
|
|
66
|
+
Dir.rmdir(full)
|
|
67
|
+
outcome[:removed] << "#{dir}/"
|
|
68
|
+
dir = File.dirname(dir)
|
|
69
|
+
end
|
|
70
|
+
rescue SystemCallError
|
|
71
|
+
next
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
private_class_method :prune_emptied
|
|
75
|
+
|
|
55
76
|
# A symlink is somebody's own arrangement, and writing through one edits
|
|
56
77
|
# a file this run was not asked about.
|
|
57
78
|
def remove_rule_files(tool, dir, root, outcome)
|