rails-ai-context 5.11.2 → 5.12.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 +22 -0
- data/exe/rails-ai-context +56 -8
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +7 -1
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +19 -1
- data/lib/rails_ai_context/introspectors/test_introspector.rb +24 -1
- data/lib/rails_ai_context/resources.rb +13 -0
- data/lib/rails_ai_context/tools/analyze_feature.rb +5 -1
- data/lib/rails_ai_context/tools/diagnose.rb +33 -0
- data/lib/rails_ai_context/tools/get_controllers.rb +19 -3
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +6 -1
- data/lib/rails_ai_context/tools/get_partial_interface.rb +35 -0
- data/lib/rails_ai_context/tools/get_turbo_map.rb +30 -4
- data/lib/rails_ai_context/tools/get_view.rb +36 -4
- data/lib/rails_ai_context/tools/onboard.rb +8 -1
- data/lib/rails_ai_context/tools/runtime_info.rb +45 -24
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4544796594e923abe87c73ee94e07d87c3cec17aa0df7ad2d631dad8fbb4fee5
|
|
4
|
+
data.tar.gz: 4a41fc88963c28d5f3725ca1f6899c18d2d5b65a9ab201b9210d76f340e8229b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5bb1a0735533dcac1f9b35e639e15feb46c0f55e72686db352d3f312ccb737392f3783fd931ce0403141ccdb0a4bd46d95d85455519d0ebdb9e21a30c23d9d7d
|
|
7
|
+
data.tar.gz: 03cc5484cc2774320155ac1272564d58b82e5418a1a8e815f88e32fd7ed849a795f939fb32823beb53557a514050336f239c62bf9f06c0314ec8d96b23eb60cb
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [5.12.0] - 2026-06-29
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
Live-usage audit of the full MCP surface (all 38 tools over stdio + HTTP, the CLI tool runner, and the 9 resources / 5 resource templates) against a real Rails 8 app surfaced 15 correctness and edge-case defects, now fixed:
|
|
13
|
+
|
|
14
|
+
- **`rails_get_schema` (and every schema-backed tool) now reads the live database.** The schema introspector treated `ActiveRecord::Base.connected?` as "is a DB reachable", but that is `false` on a freshly booted server before any query runs, so it silently fell back to static `schema.rb` parsing -- which omits the implicit `id` primary key and reports schema.rb-approximate types. It now probes the connection (`SELECT 1`) and uses live column metadata, falling back to static parsing only when the database is genuinely unreachable. `rails_get_model_details`, `rails_get_context`, `rails_get_controllers` schema hints, and `rails_analyze_feature` all benefit.
|
|
15
|
+
- **`rails_get_view`** resolves a logical `controller/action` path (e.g. `posts/index`) to its template instead of reporting "View not found" for a file it then lists in the same hint.
|
|
16
|
+
- **`rails_diagnose`** classifies Ruby 3.4+ `undefined method 'x' for an instance of Y` errors (and other bare, class-prefix-less messages) by inferring the exception class from the message signature.
|
|
17
|
+
- **`rails_get_turbo_map`** now reports `.turbo_stream.erb` response templates and their stream actions. Apps using the common scaffold-style Turbo Stream pattern no longer show "No Turbo Streams or Frames detected".
|
|
18
|
+
- **`rails_get_partial_interface`** detects implicit object/collection render sites (`render @post`, `render post`, `render @posts`), not just explicit `render "posts/post"` calls.
|
|
19
|
+
- **`rails_get_controllers`** and **`rails_analyze_feature`** no longer list an inherited `before_action` twice (once as inherited, once from the reflection-derived full chain).
|
|
20
|
+
- **`rails_runtime_info`** "Table Sizes" lists real application tables instead of SQLite internal objects (`sqlite_schema`, `sqlite_autoindex_*`, `sqlite_sequence`) and index b-trees that were crowding out the app tables.
|
|
21
|
+
- **`rails_get_gems`** strips the platform suffix from versions (`sqlite3 2.9.5`, not `sqlite3 2.9.5-x86_64-linux-musl`) on multi-platform lockfiles.
|
|
22
|
+
- **`rails_get_test_info`** falls back to `Gemfile.lock` to report the test framework (minitest/rspec) when no `test/` or `spec/` directory exists yet, instead of "unknown".
|
|
23
|
+
- **`rails_get_frontend_stack`** no longer renders a literal `[]` for empty state-management data.
|
|
24
|
+
- **`rails_onboard`** reports the app route count instead of pairing the framework-inclusive total with the app-controller count ("14 app routes across 3 controllers", not "46 routes across 3 controllers").
|
|
25
|
+
- **MCP resources** return a proper `-32602 Resource not found: <uri>` for unknown URIs and blocked paths instead of a generic "Internal error" with the URI stripped (on `mcp >= 0.20`; older mcp keeps prior behavior). Path traversal and sensitive-file access remain blocked.
|
|
26
|
+
- **CLI `--json`** flag is honored in any position (`tool NAME args --json`), not only before the tool name.
|
|
27
|
+
- **CLI presets** (`architecture`, `debugging`, `migration`) now chain tools that produce useful output with zero arguments, instead of calling tools whose required arguments were never supplied.
|
|
28
|
+
- **Standalone/zero-config CLI on Ruby 3.4+** no longer crashes with `Gem::LoadError: already activated psych 5.4.0, but your Gemfile requires psych 5.3.1` (or the equivalent for `date`). The standalone executable pre-activates Ruby 3.4's newer default-gem versions before the app's `Bundler.setup` runs; it now clears those pre-activated specs first so Bundler activates the versions the app resolves. No-op under `bundle exec` (in-Gemfile mode is unaffected).
|
|
29
|
+
|
|
8
30
|
## [5.11.2] - 2026-06-16
|
|
9
31
|
|
|
10
32
|
### Fixed
|
data/exe/rails-ai-context
CHANGED
|
@@ -41,7 +41,15 @@ class RailsAiContextCLI < Thor
|
|
|
41
41
|
return
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
# `--json` is consumed as the Thor option only when it precedes the tool
|
|
45
|
+
# name; `stop_on_unknown_option! :tool` passes it through into `args` when
|
|
46
|
+
# it follows the name (the natural CLI position). Honor both placements by
|
|
47
|
+
# also stripping it from args here, mirroring the --help handling above.
|
|
48
|
+
json_mode = options[:json]
|
|
49
|
+
json_mode = true if args.delete("--json")
|
|
50
|
+
json_mode = true if args.delete("-j")
|
|
51
|
+
|
|
52
|
+
runner = ::RailsAiContext::CLI::ToolRunner.new(name, args, json_mode: json_mode)
|
|
45
53
|
puts runner.run
|
|
46
54
|
rescue SystemExit
|
|
47
55
|
raise # let exit() propagate (from boot_rails!)
|
|
@@ -190,29 +198,34 @@ class RailsAiContextCLI < Thor
|
|
|
190
198
|
|
|
191
199
|
desc "preset NAME", "Run a multi-tool preset (architecture, debugging, migration)"
|
|
192
200
|
def preset(name = nil)
|
|
201
|
+
# Each preset chains tools that produce useful output with ZERO
|
|
202
|
+
# user-supplied arguments. Tools that require a target (analyze_feature
|
|
203
|
+
# needs a feature, migration_advisor needs an action+table, validate needs
|
|
204
|
+
# file paths) are deliberately excluded - in a no-arg preset they only
|
|
205
|
+
# emit "please provide X" prompts.
|
|
193
206
|
presets = {
|
|
194
207
|
"architecture" => {
|
|
195
|
-
desc: "
|
|
208
|
+
desc: "Architecture overview across all layers",
|
|
196
209
|
tools: [
|
|
197
|
-
{ name: "
|
|
210
|
+
{ name: "onboard", params: {} },
|
|
198
211
|
{ name: "dependency_graph", params: {} },
|
|
199
212
|
{ name: "performance_check", params: {} }
|
|
200
213
|
]
|
|
201
214
|
},
|
|
202
215
|
"debugging" => {
|
|
203
|
-
desc: "Diagnose recent issues and
|
|
216
|
+
desc: "Diagnose recent issues and inspect live state",
|
|
204
217
|
tools: [
|
|
205
218
|
{ name: "read_logs", params: { level: "ERROR", lines: 100 } },
|
|
206
219
|
{ name: "review_changes", params: {} },
|
|
207
|
-
{ name: "
|
|
220
|
+
{ name: "runtime_info", params: {} }
|
|
208
221
|
]
|
|
209
222
|
},
|
|
210
223
|
"migration" => {
|
|
211
|
-
desc: "Schema overview with migration
|
|
224
|
+
desc: "Schema overview with migration status and index advice",
|
|
212
225
|
tools: [
|
|
213
226
|
{ name: "get_schema", params: { detail: "summary" } },
|
|
214
|
-
{ name: "
|
|
215
|
-
{ name: "
|
|
227
|
+
{ name: "runtime_info", params: { section: "database" } },
|
|
228
|
+
{ name: "performance_check", params: { category: "indexes" } }
|
|
216
229
|
]
|
|
217
230
|
}
|
|
218
231
|
}
|
|
@@ -577,6 +590,16 @@ class RailsAiContextCLI < Thor
|
|
|
577
590
|
# captured specs already exist in Gem.loaded_specs and our `||=` skips them).
|
|
578
591
|
pre_boot_specs = capture_standalone_specs
|
|
579
592
|
|
|
593
|
+
# Standalone mode on Ruby 3.4+: this CLI process lazily activates the
|
|
594
|
+
# newest/default version of stdlib gems (e.g. psych via `require "yaml"`,
|
|
595
|
+
# date via transitive requires) BEFORE the app's Bundler.setup runs inside
|
|
596
|
+
# `require config_path`. If the app resolves a different version, Bundler
|
|
597
|
+
# raises `Gem::LoadError: already activated psych 5.4.0, but your Gemfile
|
|
598
|
+
# requires psych 5.3.1`. Clear the pre-activated specs so Bundler activates
|
|
599
|
+
# the versions the app resolves. No-op under `bundle exec`, so in-Gemfile
|
|
600
|
+
# mode is unaffected.
|
|
601
|
+
drop_conflicting_gem_activations!
|
|
602
|
+
|
|
580
603
|
require config_path
|
|
581
604
|
|
|
582
605
|
# Restore paths and specs that Bundler.setup stripped (standalone mode).
|
|
@@ -615,6 +638,31 @@ class RailsAiContextCLI < Thor
|
|
|
615
638
|
next
|
|
616
639
|
end
|
|
617
640
|
end
|
|
641
|
+
|
|
642
|
+
# Remove activated gem specs whose version differs from the app's
|
|
643
|
+
# Gemfile.lock pin, so the app's Bundler.setup can activate the locked
|
|
644
|
+
# version instead of raising `Gem::LoadError: already activated`. Only the
|
|
645
|
+
# in-memory spec registration is removed; already-loaded stdlib code stays,
|
|
646
|
+
# which is safe (require is idempotent and the affected gems - psych, date,
|
|
647
|
+
# bigdecimal, stringio - keep stable APIs across the involved versions).
|
|
648
|
+
# No-op under `bundle exec` (BUNDLE_BIN_PATH set): Bundler already governs
|
|
649
|
+
# activation there, so in-Gemfile mode is untouched.
|
|
650
|
+
def drop_conflicting_gem_activations!
|
|
651
|
+
return if ENV["BUNDLE_BIN_PATH"]
|
|
652
|
+
return unless defined?(Gem) && Gem.respond_to?(:loaded_specs)
|
|
653
|
+
|
|
654
|
+
# Drop every pre-activated spec except bundler (which is mid-execution),
|
|
655
|
+
# so the app's Bundler.setup activates each gem at the version IT resolves
|
|
656
|
+
# rather than colliding with whatever RubyGems pre-activated. Without this,
|
|
657
|
+
# `require config_path` raises `Gem::LoadError: already activated psych
|
|
658
|
+
# 5.4.0, but your Gemfile requires psych 5.3.1`. Only the in-memory spec
|
|
659
|
+
# registration is removed; already-loaded code stays (require is
|
|
660
|
+
# idempotent). The gem's own spec and its MCP deps are captured before
|
|
661
|
+
# this call and restored after boot (see capture_standalone_specs).
|
|
662
|
+
Gem.loaded_specs.delete_if { |name, _| name != "bundler" }
|
|
663
|
+
rescue => e
|
|
664
|
+
$stderr.puts "[rails-ai-context] drop_conflicting_gem_activations! failed: #{e.message}" if ENV["DEBUG"]
|
|
665
|
+
end
|
|
618
666
|
end
|
|
619
667
|
|
|
620
668
|
RailsAiContextCLI.start(ARGV)
|
|
@@ -221,7 +221,13 @@ module RailsAiContext
|
|
|
221
221
|
end
|
|
222
222
|
|
|
223
223
|
if in_gems && (match = line.match(/^\s{4}(\S+)\s+\((.+)\)/))
|
|
224
|
-
gems
|
|
224
|
+
# Bundler writes platform-specific gems as "name (1.2.3-x86_64-linux-musl)",
|
|
225
|
+
# listing one line per platform in a multi-platform lockfile. Last-write-wins
|
|
226
|
+
# would otherwise surface an arbitrary, often wrong, platform suffix (e.g.
|
|
227
|
+
# x86_64-linux-musl on an arm64-darwin machine). RubyGems versions never
|
|
228
|
+
# contain a hyphen, so the text before the first "-" is the clean version;
|
|
229
|
+
# the platform tail is an install detail, not useful AI context.
|
|
230
|
+
gems[match[1]] = match[2].split("-", 2).first
|
|
225
231
|
end
|
|
226
232
|
end
|
|
227
233
|
|
|
@@ -52,7 +52,25 @@ module RailsAiContext
|
|
|
52
52
|
private
|
|
53
53
|
|
|
54
54
|
def active_record_connected?
|
|
55
|
-
defined?(ActiveRecord::Base)
|
|
55
|
+
return false unless defined?(ActiveRecord::Base)
|
|
56
|
+
|
|
57
|
+
# ActiveRecord::Base.connected? only reports whether a connection has
|
|
58
|
+
# ALREADY been checked out on this thread. On a freshly booted MCP
|
|
59
|
+
# server - before any query runs - it is false even when the database
|
|
60
|
+
# is fully reachable, which wrongly forces the static schema.rb parse.
|
|
61
|
+
# That parse omits the implicit `id` primary key and reports
|
|
62
|
+
# schema.rb-approximate types instead of the live column metadata,
|
|
63
|
+
# undercutting the gem's "live, zero stale data" promise.
|
|
64
|
+
return true if ActiveRecord::Base.connected?
|
|
65
|
+
|
|
66
|
+
# Force a real connection. In Rails 8 a lazily checked-out connection
|
|
67
|
+
# reports active? => nil until it actually materializes, so a trivial
|
|
68
|
+
# query is the reliable reachability probe. The rescue below falls back
|
|
69
|
+
# to static parsing when the database is genuinely unreachable (no DB
|
|
70
|
+
# configured, db:create not run, server down). SELECT 1 is valid on
|
|
71
|
+
# sqlite3, postgresql, mysql2, and trilogy.
|
|
72
|
+
ActiveRecord::Base.connection.select_value("SELECT 1")
|
|
73
|
+
true
|
|
56
74
|
rescue => e
|
|
57
75
|
$stderr.puts "[rails-ai-context] active_record_connected? failed: #{e.message}" if ENV["DEBUG"]
|
|
58
76
|
false
|
|
@@ -46,10 +46,33 @@ module RailsAiContext
|
|
|
46
46
|
elsif Dir.exist?(File.join(root, "test"))
|
|
47
47
|
"minitest"
|
|
48
48
|
else
|
|
49
|
-
|
|
49
|
+
# No test/spec directory yet (e.g. an app scaffolded with --skip-test,
|
|
50
|
+
# or before the first test is written). The framework is still
|
|
51
|
+
# knowable from the bundle: rspec-rails means RSpec, otherwise a Rails
|
|
52
|
+
# app uses its bundled minitest default. Reporting "unknown" here
|
|
53
|
+
# contradicts gems/generate_test, which both already resolve it.
|
|
54
|
+
detect_framework_from_lockfile || "unknown"
|
|
50
55
|
end
|
|
51
56
|
end
|
|
52
57
|
|
|
58
|
+
# Resolve the test framework from Gemfile.lock when no test directory
|
|
59
|
+
# exists. rspec-rails wins (it replaces the convention); otherwise
|
|
60
|
+
# minitest, which ships with every Rails app. Returns nil when there is
|
|
61
|
+
# no lockfile or no recognizable test gem.
|
|
62
|
+
def detect_framework_from_lockfile
|
|
63
|
+
lock_path = File.join(root, "Gemfile.lock")
|
|
64
|
+
return nil unless File.exist?(lock_path)
|
|
65
|
+
|
|
66
|
+
content = RailsAiContext::SafeFile.read(lock_path) || ""
|
|
67
|
+
return "rspec" if content.match?(/^\s{4}rspec-rails\s/)
|
|
68
|
+
return "minitest" if content.match?(/^\s{4}minitest\s/)
|
|
69
|
+
|
|
70
|
+
nil
|
|
71
|
+
rescue => e
|
|
72
|
+
$stderr.puts "[rails-ai-context] detect_framework_from_lockfile failed: #{e.message}" if ENV["DEBUG"]
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
53
76
|
def detect_factories
|
|
54
77
|
dirs = [
|
|
55
78
|
File.join(root, "spec/factories"),
|
|
@@ -121,6 +121,19 @@ module RailsAiContext
|
|
|
121
121
|
|
|
122
122
|
server.resources_read_handler do |params|
|
|
123
123
|
handle_read(params)
|
|
124
|
+
rescue RailsAiContext::Error => e
|
|
125
|
+
# handle_read / VFS raise RailsAiContext::Error for unknown URIs and
|
|
126
|
+
# blocked paths (traversal, sensitive files). Left unhandled, the MCP
|
|
127
|
+
# SDK collapses them into a generic "-32603 Internal error" that hides
|
|
128
|
+
# the URI. On mcp >= 0.20 re-raise as the SDK's ResourceNotFoundError
|
|
129
|
+
# so the client gets a proper "-32602 Resource not found: <uri>" with
|
|
130
|
+
# the URI in error data (the uniform message also avoids leaking why a
|
|
131
|
+
# blocked path was rejected). That class doesn't exist on older but
|
|
132
|
+
# still-supported mcp (gemspec allows >= 0.8), so fall back to the
|
|
133
|
+
# original error there - same behavior as before this wrapper.
|
|
134
|
+
raise e unless defined?(MCP::Server::ResourceNotFoundError)
|
|
135
|
+
|
|
136
|
+
raise MCP::Server::ResourceNotFoundError.new(params[:uri])
|
|
124
137
|
end
|
|
125
138
|
end
|
|
126
139
|
|
|
@@ -159,7 +159,11 @@ module RailsAiContext
|
|
|
159
159
|
lines << "- **Inherited filters:** #{parent_filters.map { |f| "#{f[:name]} _(from #{info[:parent_class]})_" }.join(', ')}"
|
|
160
160
|
end
|
|
161
161
|
|
|
162
|
-
|
|
162
|
+
# `info[:filters]` is reflection-derived and already includes the
|
|
163
|
+
# inherited chain, so drop the ones already shown on the Inherited
|
|
164
|
+
# line to avoid listing them twice (e.g. set_current_user).
|
|
165
|
+
parent_names = parent_filters.map { |f| f[:name] }.to_set
|
|
166
|
+
filters = (info[:filters] || []).select { |f| f.is_a?(Hash) && !parent_names.include?(f[:name]) }.map do |f|
|
|
163
167
|
label = "#{f[:kind]} #{f[:name]}"
|
|
164
168
|
label += " only: #{Array(f[:only]).join(', ')}" if f[:only]&.any?
|
|
165
169
|
label += " except: #{Array(f[:except]).join(', ')}" if f[:except]&.any?
|
|
@@ -194,9 +194,42 @@ module RailsAiContext
|
|
|
194
194
|
result[:method_name] = m[1]
|
|
195
195
|
end
|
|
196
196
|
|
|
197
|
+
# When the error is a bare message with no "ClassName:" prefix - e.g. a
|
|
198
|
+
# line copied from a browser error page, or Ruby 3.4+'s
|
|
199
|
+
# "undefined method 'x' for an instance of Y" phrasing that drops the
|
|
200
|
+
# trailing "(NoMethodError)" - infer the class from the message
|
|
201
|
+
# signature. classify_error matches on "exception_class message", so a
|
|
202
|
+
# recovered class name resolves both the header and the classification.
|
|
203
|
+
result[:exception_class] ||= infer_exception_class(result[:message])
|
|
204
|
+
|
|
197
205
|
result
|
|
198
206
|
end
|
|
199
207
|
|
|
208
|
+
# Map a raw error message (no class prefix) to its Ruby/Rails exception
|
|
209
|
+
# class by signature. Returns nil when nothing matches so the caller
|
|
210
|
+
# keeps the "Unknown" fallback. Order matters: the NameError
|
|
211
|
+
# "undefined local variable or method" phrasing is checked before the
|
|
212
|
+
# NoMethodError "undefined method" phrasing it shares a prefix with.
|
|
213
|
+
EXCEPTION_SIGNATURES = [
|
|
214
|
+
[ /undefined local variable or method\b/, "NameError" ],
|
|
215
|
+
[ /uninitialized constant\b/, "NameError" ],
|
|
216
|
+
[ /undefined method\b/, "NoMethodError" ],
|
|
217
|
+
[ /wrong number of arguments|missing keywords?:|unknown keywords?:/, "ArgumentError" ],
|
|
218
|
+
[ /couldn't find .+ with/i, "ActiveRecord::RecordNotFound" ],
|
|
219
|
+
[ /param is missing or the value is empty/i, "ActionController::ParameterMissing" ],
|
|
220
|
+
[ /no route matches/i, "ActionController::RoutingError" ],
|
|
221
|
+
[ /no such column|no such table|relation ".+" does not exist|column .+ does not exist/i, "ActiveRecord::StatementInvalid" ]
|
|
222
|
+
].freeze
|
|
223
|
+
|
|
224
|
+
def infer_exception_class(message)
|
|
225
|
+
return nil if message.nil? || message.empty?
|
|
226
|
+
|
|
227
|
+
EXCEPTION_SIGNATURES.each do |pattern, klass|
|
|
228
|
+
return klass if message.match?(pattern)
|
|
229
|
+
end
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
200
233
|
def classify_error(parsed)
|
|
201
234
|
error_str = "#{parsed[:exception_class]} #{parsed[:message]}"
|
|
202
235
|
|
|
@@ -204,11 +204,17 @@ module RailsAiContext
|
|
|
204
204
|
|
|
205
205
|
if parent_filters.any? || filters.any? || skipped_filters.any?
|
|
206
206
|
lines << "" << "## Applicable Filters"
|
|
207
|
-
|
|
207
|
+
# Dedupe parent vs own filters by name - `filters` is reflection-derived
|
|
208
|
+
# for loaded controllers and already includes the inherited chain, so
|
|
209
|
+
# listing parent_filters separately would double-list them.
|
|
210
|
+
applicable_names = filters.map { |f| f[:name] }.to_set
|
|
211
|
+
parent_names = parent_filters.map { |f| f[:name] }.to_set
|
|
212
|
+
parent_filters.reject { |f| applicable_names.include?(f[:name]) }.each do |f|
|
|
208
213
|
lines << "- `#{f[:kind]}` **#{f[:name]}** _(from #{info[:parent_class]})_"
|
|
209
214
|
end
|
|
210
215
|
filters.each do |f|
|
|
211
216
|
line = "- `#{f[:kind]}` **#{f[:name]}**"
|
|
217
|
+
line += " _(from #{info[:parent_class]})_" if parent_names.include?(f[:name])
|
|
212
218
|
line += " (only: #{f[:only].join(', ')})" if f[:only]&.any?
|
|
213
219
|
lines << line
|
|
214
220
|
end
|
|
@@ -484,16 +490,26 @@ module RailsAiContext
|
|
|
484
490
|
lines << info[:actions].map { |a| "- `#{a}`" }.join("\n")
|
|
485
491
|
end
|
|
486
492
|
|
|
487
|
-
# Show full filter chain including inherited from parent controller
|
|
493
|
+
# Show full filter chain including inherited from parent controller.
|
|
494
|
+
# `all_filters` is reflection-derived for loaded controllers and already
|
|
495
|
+
# includes the inherited chain; `parent_filters` re-parses the parent
|
|
496
|
+
# source. Listing both verbatim double-lists inherited filters (e.g.
|
|
497
|
+
# set_current_user appearing twice), so dedupe by name: surface only the
|
|
498
|
+
# parent filters missing from the own list (covers source-only
|
|
499
|
+
# controllers whose own list lacks the chain), then annotate the
|
|
500
|
+
# inherited entries in the own list rather than repeating them.
|
|
488
501
|
all_filters = info[:filters] || []
|
|
489
502
|
parent_filters = detect_parent_filters(info[:parent_class])
|
|
490
503
|
if parent_filters.any? || all_filters.any?
|
|
491
504
|
lines << "" << "## Filters"
|
|
492
|
-
|
|
505
|
+
all_names = all_filters.map { |f| f[:name] }.to_set
|
|
506
|
+
parent_names = parent_filters.map { |f| f[:name] }.to_set
|
|
507
|
+
parent_filters.reject { |f| all_names.include?(f[:name]) }.each do |f|
|
|
493
508
|
lines << "- `#{f[:kind]}` **#{f[:name]}** _(from #{info[:parent_class]})_"
|
|
494
509
|
end
|
|
495
510
|
all_filters.each do |f|
|
|
496
511
|
detail = "- `#{f[:kind]}` **#{f[:name]}**"
|
|
512
|
+
detail += " _(from #{info[:parent_class]})_" if parent_names.include?(f[:name])
|
|
497
513
|
detail += " (only: #{f[:only].join(', ')})" if f[:only]&.any?
|
|
498
514
|
lines << detail
|
|
499
515
|
end
|
|
@@ -110,7 +110,12 @@ module RailsAiContext
|
|
|
110
110
|
lines << "- **Framework:** #{data[:framework]}#{version_suffix(data[:version])}" if data[:framework]
|
|
111
111
|
lines << "- **Mounting strategy:** #{data[:mounting_strategy]}" if data[:mounting_strategy]
|
|
112
112
|
lines << "- **Build tool:** #{data[:build_tool]}" if data[:build_tool]
|
|
113
|
-
|
|
113
|
+
# state_management is an array for JS-framework apps and a string for
|
|
114
|
+
# others; an empty array is truthy in Ruby, so guard on presence and
|
|
115
|
+
# join arrays to avoid rendering a literal "[]".
|
|
116
|
+
state_management = data[:state_management]
|
|
117
|
+
state_management = state_management.join(", ") if state_management.is_a?(Array)
|
|
118
|
+
lines << "- **State management:** #{state_management}" if state_management.present?
|
|
114
119
|
lines << "- **Package manager:** #{data[:package_manager]}" if data[:package_manager]
|
|
115
120
|
|
|
116
121
|
# TypeScript
|
|
@@ -377,6 +377,21 @@ module RailsAiContext
|
|
|
377
377
|
basename # status_badge
|
|
378
378
|
].uniq
|
|
379
379
|
|
|
380
|
+
# Implicit object/collection rendering resolves to this partial via
|
|
381
|
+
# to_partial_path: `render @post`, `render post`, `render @posts`,
|
|
382
|
+
# `render posts` all hit posts/_post. These are extremely common and
|
|
383
|
+
# carry no quoted partial name, so the explicit-string match above
|
|
384
|
+
# misses them. Only treat the partial as a conventional resource
|
|
385
|
+
# partial (posts/_post) when its directory is the pluralized basename
|
|
386
|
+
# or it is top-level, to avoid matching unrelated variables.
|
|
387
|
+
plural = basename.pluralize
|
|
388
|
+
implicit_names =
|
|
389
|
+
if dir_prefix.empty? || dir_prefix.split("/").last == plural
|
|
390
|
+
[ basename, plural ].uniq
|
|
391
|
+
else
|
|
392
|
+
[]
|
|
393
|
+
end
|
|
394
|
+
|
|
380
395
|
view_files = Dir.glob(File.join(views_dir, "**", "*.{erb,haml,slim}")).sort
|
|
381
396
|
|
|
382
397
|
view_files.each do |file|
|
|
@@ -387,6 +402,7 @@ module RailsAiContext
|
|
|
387
402
|
relative = file.sub("#{root}/", "")
|
|
388
403
|
|
|
389
404
|
content.each_line.with_index(1) do |line, line_num|
|
|
405
|
+
matched_line = false
|
|
390
406
|
search_patterns.each do |search_name|
|
|
391
407
|
# Match render "partial_name" or render partial: "partial_name"
|
|
392
408
|
# Allow content before search_name (e.g. "shared/status_badge" matches "status_badge")
|
|
@@ -407,8 +423,27 @@ module RailsAiContext
|
|
|
407
423
|
locals: locals_passed,
|
|
408
424
|
snippet: line.strip
|
|
409
425
|
}
|
|
426
|
+
matched_line = true
|
|
410
427
|
break # one match per line is enough
|
|
411
428
|
end
|
|
429
|
+
|
|
430
|
+
next if matched_line
|
|
431
|
+
|
|
432
|
+
# Implicit object/collection render: `render @post`, `render post`,
|
|
433
|
+
# `render @posts`. The variable is a bareword or ivar (no quotes),
|
|
434
|
+
# and \b guards against prefixes like post_path / posts_controller.
|
|
435
|
+
implicit_names.each do |var|
|
|
436
|
+
next unless line.match?(/render\s+@?#{Regexp.escape(var)}\b/)
|
|
437
|
+
next if line.match?(/render\s.*["']/) # a quoted render is explicit, handled above
|
|
438
|
+
|
|
439
|
+
sites << {
|
|
440
|
+
file: relative,
|
|
441
|
+
line: line_num,
|
|
442
|
+
locals: [],
|
|
443
|
+
snippet: line.strip
|
|
444
|
+
}
|
|
445
|
+
break
|
|
446
|
+
end
|
|
412
447
|
end
|
|
413
448
|
end
|
|
414
449
|
|
|
@@ -106,10 +106,13 @@ module RailsAiContext
|
|
|
106
106
|
private_class_method def self.format_summary(model_broadcasts, rb_broadcasts, view_subscriptions, view_frames, warnings, filter_label: nil)
|
|
107
107
|
total_broadcasts = model_broadcasts.size + rb_broadcasts.size
|
|
108
108
|
turbo_data = cached_context[:turbo]
|
|
109
|
-
|
|
109
|
+
turbo_usable = turbo_data.is_a?(Hash) && !turbo_data[:error]
|
|
110
|
+
turbo_stream_response_count = turbo_usable ? turbo_data[:turbo_stream_responses]&.size.to_i : 0
|
|
111
|
+
turbo_stream_template_count = turbo_usable ? turbo_data[:turbo_streams]&.size.to_i : 0
|
|
110
112
|
|
|
111
113
|
lines = [ "# Turbo Map", "" ]
|
|
112
|
-
lines << "- **Turbo Stream responses:** #{turbo_stream_response_count} (
|
|
114
|
+
lines << "- **Turbo Stream responses:** #{turbo_stream_response_count} (controllers responding with `turbo_stream` format)" if turbo_stream_response_count > 0
|
|
115
|
+
lines << "- **Turbo Stream templates:** #{turbo_stream_template_count} (`.turbo_stream.erb` view templates)" if turbo_stream_template_count > 0
|
|
113
116
|
lines << "- **Model broadcasts:** #{model_broadcasts.size} (via `broadcasts`, `broadcasts_to`, etc.)"
|
|
114
117
|
lines << "- **Explicit broadcasts:** #{rb_broadcasts.size} (via `broadcast_*_to` calls in .rb files)"
|
|
115
118
|
lines << "- **Stream subscriptions:** #{view_subscriptions.size} (`turbo_stream_from` in views)"
|
|
@@ -151,6 +154,18 @@ module RailsAiContext
|
|
|
151
154
|
end
|
|
152
155
|
lines << ""
|
|
153
156
|
end
|
|
157
|
+
|
|
158
|
+
# .turbo_stream.erb response templates - the most common scaffold-style
|
|
159
|
+
# Turbo Stream pattern. The introspector collects these via its view
|
|
160
|
+
# scan; without rendering them here, an app whose streams are driven
|
|
161
|
+
# entirely by templates wrongly reports "no Turbo Streams detected".
|
|
162
|
+
if turbo_data[:turbo_streams]&.any?
|
|
163
|
+
actions = turbo_data[:stream_actions]
|
|
164
|
+
action_summary = actions.is_a?(Hash) && actions.any? ? " (actions: #{actions.map { |a, n| "#{a}×#{n}" }.join(', ')})" : ""
|
|
165
|
+
lines << "## Turbo Stream Templates (#{turbo_data[:turbo_streams].size})#{action_summary}"
|
|
166
|
+
turbo_data[:turbo_streams].first(20).each { |tpl| lines << "- `#{tpl}`" }
|
|
167
|
+
lines << ""
|
|
168
|
+
end
|
|
154
169
|
end
|
|
155
170
|
|
|
156
171
|
# Model broadcasts
|
|
@@ -200,8 +215,9 @@ module RailsAiContext
|
|
|
200
215
|
end
|
|
201
216
|
|
|
202
217
|
has_turbo_stream_responses = turbo_data.is_a?(Hash) && turbo_data[:turbo_stream_responses]&.any?
|
|
218
|
+
has_stream_templates = turbo_data.is_a?(Hash) && turbo_data[:turbo_streams]&.any?
|
|
203
219
|
|
|
204
|
-
if model_broadcasts.empty? && rb_broadcasts.empty? && view_subscriptions.empty? && view_frames.empty? && !has_turbo_stream_responses
|
|
220
|
+
if model_broadcasts.empty? && rb_broadcasts.empty? && view_subscriptions.empty? && view_frames.empty? && !has_turbo_stream_responses && !has_stream_templates
|
|
205
221
|
if filter_label
|
|
206
222
|
lines << "_No Turbo usage matching #{filter_label}. Try without filter to see all Turbo Streams and Frames._"
|
|
207
223
|
else
|
|
@@ -240,6 +256,15 @@ module RailsAiContext
|
|
|
240
256
|
end
|
|
241
257
|
lines << ""
|
|
242
258
|
end
|
|
259
|
+
|
|
260
|
+
# .turbo_stream.erb response templates (scaffold-style Turbo Streams).
|
|
261
|
+
if turbo_data[:turbo_streams]&.any?
|
|
262
|
+
lines << "## Turbo Stream Templates (#{turbo_data[:turbo_streams].size})"
|
|
263
|
+
turbo_data[:turbo_streams].each { |tpl| lines << "- `#{tpl}`" }
|
|
264
|
+
actions = turbo_data[:stream_actions]
|
|
265
|
+
lines << "- **Actions used:** #{actions.map { |a, n| "#{a}×#{n}" }.join(', ')}" if actions.is_a?(Hash) && actions.any?
|
|
266
|
+
lines << ""
|
|
267
|
+
end
|
|
243
268
|
end
|
|
244
269
|
|
|
245
270
|
# Model broadcasts with full context
|
|
@@ -321,8 +346,9 @@ module RailsAiContext
|
|
|
321
346
|
end
|
|
322
347
|
|
|
323
348
|
has_turbo_stream_responses = turbo_data.is_a?(Hash) && turbo_data[:turbo_stream_responses]&.any?
|
|
349
|
+
has_stream_templates = turbo_data.is_a?(Hash) && turbo_data[:turbo_streams]&.any?
|
|
324
350
|
|
|
325
|
-
if model_broadcasts.empty? && rb_broadcasts.empty? && view_subscriptions.empty? && view_frames.empty? && !has_turbo_stream_responses
|
|
351
|
+
if model_broadcasts.empty? && rb_broadcasts.empty? && view_subscriptions.empty? && view_frames.empty? && !has_turbo_stream_responses && !has_stream_templates
|
|
326
352
|
if filter_label
|
|
327
353
|
lines << "_No Turbo usage matching #{filter_label}. Try without filter to see all Turbo Streams and Frames._"
|
|
328
354
|
else
|
|
@@ -268,10 +268,22 @@ module RailsAiContext
|
|
|
268
268
|
full_path = views_dir.join(path)
|
|
269
269
|
|
|
270
270
|
unless File.exist?(full_path)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
271
|
+
# Agents (and this tool's own description) naturally address a view by
|
|
272
|
+
# its logical "controller/action" path with no extension, e.g.
|
|
273
|
+
# "posts/index". Resolve that to the concrete template
|
|
274
|
+
# ("posts/index.html.erb") before giving up. Only attempts resolution
|
|
275
|
+
# for safe, extension-less path shapes so the glob can't be abused;
|
|
276
|
+
# the resolved path still runs the realpath/sensitive/size checks below.
|
|
277
|
+
resolved = resolve_template_path(path, views_dir)
|
|
278
|
+
if resolved
|
|
279
|
+
path = resolved
|
|
280
|
+
full_path = views_dir.join(path)
|
|
281
|
+
else
|
|
282
|
+
dir = File.dirname(path)
|
|
283
|
+
siblings = Dir.glob(File.join(views_dir, dir, "*")).map { |f| "#{dir}/#{File.basename(f)}" }.sort.first(10)
|
|
284
|
+
hint = siblings.any? ? " Files in #{dir}/: #{siblings.join(', ')}" : ""
|
|
285
|
+
return text_response("View not found: #{path}.#{hint}")
|
|
286
|
+
end
|
|
275
287
|
end
|
|
276
288
|
# Containment check with separator + post-realpath sensitive recheck.
|
|
277
289
|
# Mirrors the v5.8.1 fix in vfs.rb / get_edit_context.rb. Without
|
|
@@ -304,6 +316,26 @@ module RailsAiContext
|
|
|
304
316
|
text_response("# #{path}\n\n```erb\n#{content}\n```")
|
|
305
317
|
end
|
|
306
318
|
|
|
319
|
+
# Resolve a logical, extension-less view path ("posts/index") to a
|
|
320
|
+
# concrete template relative path ("posts/index.html.erb"). Returns nil
|
|
321
|
+
# when the path already carries an extension, contains glob-unsafe
|
|
322
|
+
# characters, or matches no template - so the caller falls back to its
|
|
323
|
+
# not-found hint. Prefers an .html.* format when several exist (the page
|
|
324
|
+
# an agent almost always means), otherwise the first match alphabetically.
|
|
325
|
+
private_class_method def self.resolve_template_path(path, views_dir)
|
|
326
|
+
# Restrict to safe "segment/segment" shapes with no extension so the
|
|
327
|
+
# Dir.glob below stays literal (no *, ?, [] or dot to expand).
|
|
328
|
+
return nil unless path.match?(%r{\A[\w\-]+(?:/[\w\-]+)*\z})
|
|
329
|
+
|
|
330
|
+
matches = Dir.glob(File.join(views_dir, "#{path}.*"))
|
|
331
|
+
.reject { |f| File.directory?(f) }
|
|
332
|
+
.map { |f| f.sub("#{views_dir}/", "") }
|
|
333
|
+
.sort
|
|
334
|
+
return nil if matches.empty?
|
|
335
|
+
|
|
336
|
+
matches.find { |m| m.include?(".html.") || m.end_with?(".html") } || matches.first
|
|
337
|
+
end
|
|
338
|
+
|
|
307
339
|
# Strip inline SVG blocks - they're visual noise that buries the signal AI needs.
|
|
308
340
|
# Replaces <svg ...>...</svg> with a compact placeholder.
|
|
309
341
|
private_class_method def self.strip_svg(content)
|
|
@@ -261,7 +261,14 @@ module RailsAiContext
|
|
|
261
261
|
end
|
|
262
262
|
|
|
263
263
|
lines << ""
|
|
264
|
-
|
|
264
|
+
# total_routes counts every route incl. framework engines (action_mailbox,
|
|
265
|
+
# active_storage, etc.), so pairing it with the app-controller count
|
|
266
|
+
# misrepresents the app (e.g. "46 routes across 3 controllers"). Report
|
|
267
|
+
# the app route count and note the framework total separately.
|
|
268
|
+
app_route_count = app_ctrls.values.sum { |route_list| route_list.size }
|
|
269
|
+
total = routes[:total_routes].to_i
|
|
270
|
+
framework_note = total > app_route_count ? " (#{total} total including framework routes)" : ""
|
|
271
|
+
lines << "Total: #{app_route_count} app routes across #{app_ctrls.size} controllers#{framework_note}."
|
|
265
272
|
lines << ""
|
|
266
273
|
lines
|
|
267
274
|
end
|
|
@@ -135,37 +135,58 @@ module RailsAiContext
|
|
|
135
135
|
[ "## Database", "", "_Not available: #{e.message}_", "" ]
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
+
# Rails-managed bookkeeping tables - real tables, but noise in a
|
|
139
|
+
# "table sizes" view aimed at application data (matches the schema
|
|
140
|
+
# introspector, which also skips them).
|
|
141
|
+
INTERNAL_TABLES = %w[schema_migrations ar_internal_metadata].freeze
|
|
142
|
+
|
|
138
143
|
def gather_table_sizes(conn, adapter)
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
result = conn.select_all("SELECT name, SUM(pgsize) AS bytes FROM dbstat GROUP BY name ORDER BY bytes DESC")
|
|
150
|
-
return result.map { |r| { name: r["name"], bytes: r["bytes"].to_i } } if result.any?
|
|
151
|
-
rescue => e
|
|
152
|
-
$stderr.puts "[rails-ai-context] gather_table_sizes failed: #{e.message}" if ENV["DEBUG"]
|
|
153
|
-
nil
|
|
144
|
+
rows =
|
|
145
|
+
case adapter
|
|
146
|
+
when /postgresql/
|
|
147
|
+
sql = "SELECT relname AS name, pg_total_relation_size(relid) AS bytes FROM pg_stat_user_tables ORDER BY bytes DESC"
|
|
148
|
+
conn.select_all(sql).map { |r| { name: r["name"], bytes: r["bytes"].to_i } }
|
|
149
|
+
when /mysql/
|
|
150
|
+
sql = "SELECT table_name AS name, (data_length + index_length) AS bytes FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = DATABASE() ORDER BY bytes DESC"
|
|
151
|
+
conn.select_all(sql).map { |r| { name: r["name"], bytes: r["bytes"].to_i } }
|
|
152
|
+
when /sqlite/
|
|
153
|
+
gather_sqlite_table_sizes(conn)
|
|
154
154
|
end
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
total = page_count * page_size
|
|
159
|
-
tables = conn.select_values("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
|
160
|
-
tables.map { |t| { name: t, bytes: total / [ tables.size, 1 ].max } }
|
|
161
|
-
else
|
|
162
|
-
nil
|
|
163
|
-
end
|
|
155
|
+
return nil unless rows
|
|
156
|
+
|
|
157
|
+
rows.reject { |r| INTERNAL_TABLES.include?(r[:name]) }
|
|
164
158
|
rescue => e
|
|
165
159
|
$stderr.puts "[rails-ai-context] gather_table_sizes failed: #{e.message}" if ENV["DEBUG"]
|
|
166
160
|
nil
|
|
167
161
|
end
|
|
168
162
|
|
|
163
|
+
def gather_sqlite_table_sizes(conn)
|
|
164
|
+
# dbstat lists every b-tree: user tables, their indexes, and SQLite
|
|
165
|
+
# internals (sqlite_schema, sqlite_autoindex_*, sqlite_sequence).
|
|
166
|
+
# Restrict to real application tables so indexes and internal objects
|
|
167
|
+
# don't crowd out (or get mistaken for) the tables agents care about.
|
|
168
|
+
app_tables = conn.select_values(
|
|
169
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
170
|
+
).to_set
|
|
171
|
+
|
|
172
|
+
begin
|
|
173
|
+
result = conn.select_all("SELECT name, SUM(pgsize) AS bytes FROM dbstat GROUP BY name ORDER BY bytes DESC")
|
|
174
|
+
rows = result.filter_map do |r|
|
|
175
|
+
{ name: r["name"], bytes: r["bytes"].to_i } if app_tables.include?(r["name"])
|
|
176
|
+
end
|
|
177
|
+
return rows if rows.any?
|
|
178
|
+
rescue => e
|
|
179
|
+
$stderr.puts "[rails-ai-context] gather_sqlite_table_sizes (dbstat) failed: #{e.message}" if ENV["DEBUG"]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Fallback: whole-DB size split across user tables (dbstat unavailable).
|
|
183
|
+
page_count = conn.select_value("PRAGMA page_count").to_i
|
|
184
|
+
page_size = conn.select_value("PRAGMA page_size").to_i
|
|
185
|
+
total = page_count * page_size
|
|
186
|
+
tables = app_tables.to_a.sort
|
|
187
|
+
tables.map { |t| { name: t, bytes: total / [ tables.size, 1 ].max } }
|
|
188
|
+
end
|
|
189
|
+
|
|
169
190
|
def gather_pending_migrations
|
|
170
191
|
migrate_dir = File.join(Rails.root, "db/migrate")
|
|
171
192
|
return nil unless Dir.exist?(migrate_dir)
|
data/server.json
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
"url": "https://github.com/crisnahine/rails-ai-context",
|
|
8
8
|
"source": "github"
|
|
9
9
|
},
|
|
10
|
-
"version": "5.
|
|
10
|
+
"version": "5.12.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "mcpb",
|
|
14
|
-
"identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v5.
|
|
14
|
+
"identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v5.12.0/rails-ai-context-mcp.mcpb",
|
|
15
15
|
"fileSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
|
16
16
|
"transport": {
|
|
17
17
|
"type": "stdio"
|