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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c22976350fb7eb968d01a1da8f99452670b331026d838953474054bad6cf7c3a
4
- data.tar.gz: bc0d990afec5122a1a0bee44895dfc9cbd33675c2b8960346a79c60b288e7e4b
3
+ metadata.gz: 4544796594e923abe87c73ee94e07d87c3cec17aa0df7ad2d631dad8fbb4fee5
4
+ data.tar.gz: 4a41fc88963c28d5f3725ca1f6899c18d2d5b65a9ab201b9210d76f340e8229b
5
5
  SHA512:
6
- metadata.gz: 52409e4462a56f2f4e1b1a62d15f9a0972711beb48212913578b0935b9b9522610e522a4cb02a51895b0df247572d8d2774b1fd93ac8f43b2f38976db6c0de7c
7
- data.tar.gz: 04fa95c29a3b99c18261ae5f401f16158359638c14a017193ea6239ad8501b00ed72c6551fde2987ad83731fa2c109ab87138e3b8c0d9f58d2629c4ae9fb5d87
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
- runner = ::RailsAiContext::CLI::ToolRunner.new(name, args, json_mode: options[:json])
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: "Full feature analysis across all layers",
208
+ desc: "Architecture overview across all layers",
196
209
  tools: [
197
- { name: "analyze_feature", params: {} },
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 validate current state",
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: "validate", params: {} }
220
+ { name: "runtime_info", params: {} }
208
221
  ]
209
222
  },
210
223
  "migration" => {
211
- desc: "Schema overview with migration advice and validation",
224
+ desc: "Schema overview with migration status and index advice",
212
225
  tools: [
213
226
  { name: "get_schema", params: { detail: "summary" } },
214
- { name: "migration_advisor", params: { action: "status" } },
215
- { name: "validate", params: {} }
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[match[1]] = match[2]
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) && ActiveRecord::Base.connected?
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
- "unknown"
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
- filters = (info[:filters] || []).select { |f| f.is_a?(Hash) }.map do |f|
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
- parent_filters.each do |f|
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
- parent_filters.each do |f|
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
- lines << "- **State management:** #{data[:state_management]}" if data[:state_management]
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
- turbo_stream_response_count = turbo_data.is_a?(Hash) && !turbo_data[:error] ? turbo_data[:turbo_stream_responses]&.size.to_i : 0
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} (controller `.turbo_stream.erb` templates)" if turbo_stream_response_count > 0
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
- dir = File.dirname(path)
272
- siblings = Dir.glob(File.join(views_dir, dir, "*")).map { |f| "#{dir}/#{File.basename(f)}" }.sort.first(10)
273
- hint = siblings.any? ? " Files in #{dir}/: #{siblings.join(', ')}" : ""
274
- return text_response("View not found: #{path}.#{hint}")
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
- lines << "Total: #{routes[:total_routes]} routes across #{app_ctrls.size} controllers."
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
- case adapter
140
- when /postgresql/
141
- sql = "SELECT relname AS name, pg_total_relation_size(relid) AS bytes FROM pg_stat_user_tables ORDER BY bytes DESC"
142
- conn.select_all(sql).map { |r| { name: r["name"], bytes: r["bytes"].to_i } }
143
- when /mysql/
144
- sql = "SELECT table_name AS name, (data_length + index_length) AS bytes FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = DATABASE() ORDER BY bytes DESC"
145
- conn.select_all(sql).map { |r| { name: r["name"], bytes: r["bytes"].to_i } }
146
- when /sqlite/
147
- # Try dbstat virtual table first, fall back to whole-DB size
148
- begin
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
- # Fallback: whole database size
156
- page_count = conn.select_value("PRAGMA page_count").to_i
157
- page_size = conn.select_value("PRAGMA page_size").to_i
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.11.2"
4
+ VERSION = "5.12.0"
5
5
  end
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.0",
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.10.0/rails-ai-context-mcp.mcpb",
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"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.11.2
4
+ version: 5.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine