rails-ai-context 5.7.1 → 5.8.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: e613f50465df28b1b7830acae8a75665fb6c5ce1fc2058e9c4e33f0dbc7e5e47
4
- data.tar.gz: cc04cd3316cba5728ee08c5324ee546aedf60eea9dd656aa942754edccbbb58d
3
+ metadata.gz: 4b34bd6800329b83e18ec7ee97eceb2d4ccab18019bf0c1f3444485e70273938
4
+ data.tar.gz: 548ab9e6f769651b2e06c81c01fa570e033b8a4c42a4400db7e888ff9a1ba377
5
5
  SHA512:
6
- metadata.gz: d6950c03b5b7ad58f2ae1df320d698e82dc244976b1140b19fe8c0aad9a37b7430db2504d990a9bb794fc2278ff591e4416b68c0c6027df7df6001198d5fe55f
7
- data.tar.gz: e80e37aba8ea2602a9142490c69fb35fa574669e234cf9b7c71093846d57687b7c4b355dbdf3fd295459e74b0b4e19fc4316cb69fc009608c8e28b19e9558409
6
+ metadata.gz: 3c55e65978c097c6d7b16c4c5a20c1d36d494e9571183569ef4faa91252aeb97dddedd4e858fc8727d169982861320b1db4b963ceee94a1d1f2a38d7708bd61e
7
+ data.tar.gz: e24ececc1491709b0f07eb235e066048d0f751cd18756462cd6e76c8ee090abc30231e02a7211f23248bb9a40f476e8ce882dcdd106b6056aa3f26406c274b22
data/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ 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.8.0] — 2026-04-14
9
+
10
+ ### Added — Modern Rails Coverage Pass
11
+
12
+ Five targeted gaps in modern Rails introspection, identified by an audit of the introspectors against current Rails 7/8 patterns. Net result: the gem now surfaces what AI agents need to know about Rails 8 built-in auth, Solid Errors, async query usage, strong_migrations safety, and Action Cable channel detail.
13
+
14
+ - **Rails 8 built-in auth depth.** `auth_introspector#detect_authentication` previously detected `bin/rails generate authentication` only as a boolean. Now returns a hash with the Authentication concern path, the Sessions/Passwords controller paths, and a per-controller list of `allow_unauthenticated_access` filters with their `only:`/`except:` scope. Each declaration in a file yields its own entry (a controller with both `only:` and `except:` is captured fully, not collapsed to the first match), and trailing line comments are stripped from the captured scope. AI agents can answer "which controllers are public?" in one tool call.
15
+ - **Solid Errors gem detection.** Added `solid_errors` (Rails 8 database-backed error tracking, by @fractaledmind) to `gem_introspector.rb`'s `NOTABLE_GEMS` map under the `:monitoring` category. Was the only Solid-* gem missing from the list (`solid_queue`, `solid_cache`, `solid_cable` were already covered). `solid_health` is NOT a real published gem — Rails 8 ships a built-in `/up` healthcheck endpoint with no gem needed.
16
+ - **Async query pattern detection.** `convention_introspector#detect_patterns` now adds `async_queries` to the patterns array when it finds `load_async` or any of the `async_count`/`async_sum`/`async_minimum`/`async_maximum`/`async_average`/`async_pluck`/`async_ids`/`async_exists`/`async_find_by`/`async_find`/`async_first`/`async_last`/`async_take` calls in `app/controllers`, `app/services`, `app/jobs`, or `app/models`. Comment-only references (e.g. `# TODO: bring back load_async`) are skipped to avoid false positives. AI agents can recognize the perf optimization is in use without re-scanning.
17
+ - **Strong Migrations integration.** `migration_advisor` now emits a `## Strong Migrations Warnings` section when the `strong_migrations` gem is in `Gemfile.lock`. Catalog covers the most common breaking-change patterns: `remove_column` (needs `safety_assured` + `ignored_columns` first), `rename_column` (unsafe under load, two-step pattern), `change_column` type change (table rewrite), `add_index` without `algorithm: :concurrently` (Postgres write lock), `add_foreign_key` without `validate: false` (lock validation), and `add_column` with `null: false` but no default (table rewrite). Each warning includes the safer pattern. Fires only when the gem is detected — zero noise for projects that don't use it.
18
+ - **Action Cable channel detail.** `job_introspector#extract_channels` was returning `{ name, stream_methods }` only. Enriched to also extract `identified_by` attributes, `stream_from`/`stream_for` targets, `periodically` timers with their full intervals (including lambdas like `every: -> { current_user.interval }`), RPC action methods (excluding subscribed/unsubscribed/stream_*), and the source file path. **`get_job_pattern` now renders an "Action Cable Channels" section with all of these fields**, so AI agents calling the tool actually see the data instead of just the channel name. Also added `eager_load_channels!` to `JobIntrospector` so the channel set is populated in development mode (where `config.eager_load = false` and `ActionCable::Channel::Base.descendants` is otherwise empty until a client subscribes).
19
+
20
+ ### Changed — CI matrix expanded to cover Ruby 4.0 + Rails 8.1
21
+
22
+ - Added Ruby `4.0` and Rails `8.1` to the GitHub Actions test matrix. Net jobs: 12 (was 8). Excludes the unsupported combinations: Ruby 3.2 × Rails 8.x (Rails 8 needs 3.3+) and Ruby 4.0 × Rails 7.x (Rails 7 has no Ruby 4 support). Verified locally that the full spec suite passes on Ruby 4.0.2 + Rails 8.1.3 (the environment in #69) — 1925 examples, 0 failures, rubocop clean across all 282 source files.
23
+
24
+ ### Fixed — Standalone Install Path Crashed Inside Bundler-Backed Rails Apps
25
+
26
+ - **`rails-ai-context` installed via `gem install` (standalone path) crashed on every tool call** when run inside a Rails app that has its own `Gemfile`. Root cause: `boot_rails!` in `exe/rails-ai-context` calls `require config/environment.rb` which runs `Bundler.setup`, which strips `Gem.loaded_specs` to only the app's Gemfile-resolved gems. The MCP SDK reads `Gem.loaded_specs["json-schema"].full_gem_path` at tool-call time (`mcp/tool/schema.rb:45`) — but `json-schema` is a transitive dep of `mcp`, not in the app's Gemfile, so the lookup nils and crashes with `NoMethodError: undefined method 'full_gem_path' for nil`.
27
+ - **Fix:** added `restore_standalone_gem_specs` to `exe/rails-ai-context` which re-registers `mcp`, `json-schema`, and a couple of their transitive deps in `Gem.loaded_specs` after `Bundler.setup` runs. No-op in in-Gemfile mode (the specs are already registered). This was a pre-existing bug that was discovered during v5.8.0 pre-release E2E verification — affected v5.4.0 onward.
28
+
29
+ ### Fixed — MCP Tool Responses Rejected by Strict Clients (#69)
30
+
31
+ - **Removed default `output_schema` from all 38 tools.** Since v5.4.0, `BaseTool.inherited` automatically assigned a `DEFAULT_OUTPUT_SCHEMA` to every tool. The schema described the response wire envelope (`{content: [...]}`) rather than app-level structured data, and tools never returned matching `structured_content`. Per MCP spec, when a tool declares `outputSchema`, it MUST return `structuredContent` matching it. Strict MCP clients (e.g. Copilot CLI) reject responses that don't, with `MCP error -32600: Tool ... has an output schema but did not return structured content`. Lenient clients (Claude Code, Cursor) silently ignored the missing field, which is why the bug went unnoticed since v5.4.0.
32
+ - **Why this happened.** The MCP Ruby SDK does not enforce `output_schema` server-side (no `validate_result` call in `MCP::Server`), so the test suite passed end-to-end. Validation happens client-side, and only strict clients caught it. Reported by @pardeyke.
33
+ - **What changed.** Deleted `DEFAULT_OUTPUT_SCHEMA` constant and the `inherited` hook line that set it (`lib/rails_ai_context/tools/base_tool.rb`). Tools now ship with no `outputSchema` by default — matching what they actually return (text-only). Individual tools can still declare their own `output_schema` via the MCP::Tool DSL, provided they also return matching `structured_content`.
34
+ - **Regression spec added.** `spec/lib/rails_ai_context/tools_spec.rb` now asserts (a) no tool advertises a default `outputSchema`, and (b) any tool that *does* declare one must also have `structured_content:` in its source — preventing the v5.4.0 misuse from sneaking back in.
35
+ - **Future enhancement.** Per-tool structured output (returning parseable JSON alongside the Markdown text via `structured_content:`) is a future feature for tools where it adds value (`get_schema`, `get_routes`, etc.). Out of scope for this patch.
36
+
37
+ ### Added — Framework Association Noise Filter
38
+
39
+ - **`excluded_association_names` config option** — filters framework-generated associations (ActiveStorage, ActionText, ActionMailbox, Noticed) from model introspection output. 7 association names excluded by default. Configurable via initializer (`config.excluded_association_names += %w[...]`) or YAML. Closes #57.
40
+
8
41
  ## [5.7.1] — 2026-04-09
9
42
 
10
43
  ### Changed — SLOP Cleanup
data/README.md CHANGED
@@ -21,7 +21,7 @@
21
21
  <br>
22
22
  [![Ruby](https://img.shields.io/badge/Ruby-3.2%20%7C%203.3%20%7C%203.4-CC342D)](https://github.com/crisnahine/rails-ai-context)
23
23
  [![Rails](https://img.shields.io/badge/Rails-7.1%20%7C%207.2%20%7C%208.0-CC0000)](https://github.com/crisnahine/rails-ai-context)
24
- [![Tests](https://img.shields.io/badge/Tests-1889%20passing-brightgreen)](https://github.com/crisnahine/rails-ai-context/actions)
24
+ [![Tests](https://img.shields.io/badge/Tests-1925%20passing-brightgreen)](https://github.com/crisnahine/rails-ai-context/actions)
25
25
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
26
26
 
27
27
  </div>
@@ -543,7 +543,7 @@ end
543
543
  ## About
544
544
 
545
545
  Built by a Rails developer with 10+ years of production experience.<br>
546
- 1889 tests. 38 tools. 5 resource templates. 31 introspectors. Standalone or in-Gemfile.<br>
546
+ 1925 tests. 38 tools. 5 resource templates. 31 introspectors. Standalone or in-Gemfile.<br>
547
547
  MIT licensed. [Contributions welcome.](CONTRIBUTING.md)
548
548
 
549
549
  <br>
@@ -94,6 +94,7 @@ preset: full
94
94
  | `excluded_filters` | Array | 3 framework filters | Controller filters to skip |
95
95
  | `excluded_middleware` | Array | 24 framework middleware | Middleware to skip in listing |
96
96
  | `excluded_paths` | Array | `["node_modules", "tmp", "log", "vendor", ".git", "doc", "docs"]` | Paths excluded from search |
97
+ | `excluded_association_names` | Array | 7 framework associations | Association names to hide from model output |
97
98
  | `excluded_concerns` | Array of Regex | Framework concerns | Concerns to skip (supports regex) |
98
99
 
99
100
  ### File Size Limits
data/docs/FAQ.md CHANGED
@@ -81,7 +81,7 @@ config.skip_tools = %w[rails_security_scan rails_query]
81
81
 
82
82
  ### What's the `detail` parameter?
83
83
 
84
- Most tools accept `detail`: `summary` (compact), `standard` (default), `full` (everything). Start with summary and drill down as needed. This keeps AI context windows lean.
84
+ Individual lookup tools accept `detail`: `summary` (compact), `standard` (default), `full` (everything). Start with summary and drill down as needed. This keeps AI context windows lean. Composite tools (`rails_get_context`, `rails_analyze_feature`) do not accept `detail` — they always return their full bundled output.
85
85
 
86
86
  ### What are `[VERIFIED]` and `[INFERRED]` tags?
87
87
 
data/docs/GUIDE.md CHANGED
@@ -1243,6 +1243,9 @@ if defined?(RailsAiContext)
1243
1243
  # Route prefixes hidden with app_only (e.g. admin frameworks)
1244
1244
  # config.excluded_route_prefixes += %w[admin/]
1245
1245
 
1246
+ # Framework association names hidden from model output (ActiveStorage, ActionText, etc.)
1247
+ # config.excluded_association_names += %w[my_custom_framework_assoc]
1248
+
1246
1249
  # Regex patterns for concerns to hide from model output
1247
1250
  # config.excluded_concerns += [/MyInternal::/]
1248
1251
 
@@ -1342,6 +1345,7 @@ end
1342
1345
  | `max_validate_files` | Integer | `50` | Max files per validate call |
1343
1346
  | `excluded_controllers` | Array | `DeviseController`, etc. | Controller classes hidden from listings |
1344
1347
  | `excluded_route_prefixes` | Array | `action_mailbox/`, `active_storage/`, etc. | Route controller prefixes hidden with `app_only` |
1348
+ | `excluded_association_names` | Array | 7 framework associations | Framework association names hidden from model output |
1345
1349
  | `excluded_concerns` | Array | framework regex patterns | Regex patterns for concerns to hide from model output |
1346
1350
  | `excluded_filters` | Array | `verify_authenticity_token`, etc. | Framework filter names hidden from controller output |
1347
1351
  | `excluded_middleware` | Array | standard Rails middleware | Default middleware hidden from config output |
data/docs/STANDALONE.md CHANGED
@@ -77,6 +77,9 @@ allow_query_in_production: false
77
77
  # Filtering
78
78
  excluded_models:
79
79
  - ApplicationRecord
80
+ excluded_association_names:
81
+ - active_storage_attachments
82
+ - active_storage_blobs
80
83
  excluded_paths:
81
84
  - node_modules
82
85
  - tmp
data/docs/TOOLS.md CHANGED
@@ -43,7 +43,7 @@ Tool name resolution is flexible — all of these work:
43
43
  | `get_schema` | `rails_get_schema` |
44
44
  | `rails_get_schema` | `rails_get_schema` |
45
45
 
46
- Most tools accept a **`detail`** parameter: `summary` (compact), `standard` (default), or `full` (everything). Start with summary, drill down as needed.
46
+ Individual lookup tools accept a **`detail`** parameter: `summary` (compact), `standard` (default), or `full` (everything). Start with summary, drill down as needed. Composite tools (`rails_get_context`, `rails_analyze_feature`) do not accept `detail`.
47
47
 
48
48
  <p align="right"><a href="#table-of-contents">↑ back to top</a></p>
49
49
 
@@ -144,7 +144,7 @@
144
144
  <div class="stat-label">Introspectors</div>
145
145
  </div>
146
146
  <div class="stat">
147
- <div class="stat-number">1889</div>
147
+ <div class="stat-number">1925</div>
148
148
  <div class="stat-label">Tests</div>
149
149
  </div>
150
150
  <div class="stat">
data/exe/rails-ai-context CHANGED
@@ -414,6 +414,12 @@ class RailsAiContextCLI < Thor
414
414
  (result[:skipped] || []).each { |f| $stderr.puts " Skipped: #{f} (unchanged)" }
415
415
  end
416
416
 
417
+ # Gems that the MCP SDK reads from `Gem.loaded_specs` at tool-call time
418
+ # (specifically `Gem.loaded_specs["json-schema"].full_gem_path` in
419
+ # mcp/tool/schema.rb). Bundler.setup strips them in standalone mode so we
420
+ # capture references BEFORE Rails boot runs and re-register them afterwards.
421
+ STANDALONE_REQUIRED_GEMS = %w[mcp json-schema addressable public_suffix].freeze
422
+
417
423
  def boot_rails!
418
424
  config_path = File.join(Dir.pwd, "config", "environment.rb")
419
425
  unless File.exist?(config_path)
@@ -426,22 +432,54 @@ class RailsAiContextCLI < Thor
426
432
  # Bundler.setup (in config/boot.rb) strips $LOAD_PATH to Gemfile-only gems.
427
433
  # In standalone mode (gem not in Gemfile), this removes our gem and mcp paths.
428
434
  # We restore them after boot so lazy requires (mcp, etc.) still resolve.
429
- #
430
- # We do NOT require mcp or rails_ai_context before boot — that would activate
431
- # gem versions that conflict with Bundler's resolution (e.g., bigdecimal).
432
- # Instead, paths are restored and gems load lazily using Bundler-resolved deps.
433
435
  pre_boot_paths = $LOAD_PATH.dup
434
436
 
437
+ # Capture spec references BEFORE Bundler.setup runs. After Bundler.setup,
438
+ # Gem::Specification.find_by_name is filtered to only Gemfile-resolved gems
439
+ # and our transitive deps (mcp, json-schema) become invisible — find_by_name
440
+ # raises Gem::MissingSpecError. Capturing first means we hold real spec
441
+ # objects we can splice back in after boot. No-op in in-Gemfile mode (the
442
+ # captured specs already exist in Gem.loaded_specs and our `||=` skips them).
443
+ pre_boot_specs = capture_standalone_specs
444
+
435
445
  require config_path
436
446
 
437
- # Restore paths that Bundler.setup stripped (standalone mode).
438
- # For Gemfile users, this is a no-op — no paths were removed.
447
+ # Restore paths and specs that Bundler.setup stripped (standalone mode).
448
+ # For Gemfile users, both restorations are no-ops.
439
449
  (pre_boot_paths - $LOAD_PATH).each { |p| $LOAD_PATH << p }
450
+ pre_boot_specs.each { |name, spec| Gem.loaded_specs[name] ||= spec }
451
+
452
+ # Loud warning if any required spec couldn't be restored — silent failure
453
+ # here means tools will crash later with `undefined method 'full_gem_path'
454
+ # for nil` from inside the MCP SDK.
455
+ missing = STANDALONE_REQUIRED_GEMS.reject { |g| Gem.loaded_specs.key?(g) }
456
+ if missing.any? && !ENV["BUNDLE_BIN_PATH"]
457
+ $stderr.puts "[rails-ai-context] WARNING: standalone CLI could not restore gemspec(s): #{missing.join(', ')}."
458
+ $stderr.puts "[rails-ai-context] Tool calls may crash with `undefined method 'full_gem_path' for nil`."
459
+ $stderr.puts "[rails-ai-context] Try `gem install rails-ai-context` to ensure all transitive deps are installed."
460
+ end
440
461
 
441
462
  require "rails_ai_context"
442
463
 
443
464
  RailsAiContext::Configuration.auto_load!
444
465
  end
466
+
467
+ # Look up each required gem via Gem::Specification.find_by_name BEFORE
468
+ # Bundler.setup runs and filters the spec registry. Returns a hash of
469
+ # {gem_name => Gem::Specification}. In in-Gemfile mode the lookups still
470
+ # succeed but the result is unused (Bundler keeps these specs registered).
471
+ def capture_standalone_specs
472
+ return {} unless defined?(Gem) && Gem.respond_to?(:loaded_specs)
473
+
474
+ STANDALONE_REQUIRED_GEMS.each_with_object({}) do |gem_name, acc|
475
+ spec = Gem::Specification.find_by_name(gem_name)
476
+ acc[gem_name] = spec if spec
477
+ rescue Gem::MissingSpecError, Gem::LoadError
478
+ # The gem is not installed at all (not just Bundler-filtered). Skip
479
+ # silently — the downstream `require` produces a clearer error.
480
+ next
481
+ end
482
+ end
445
483
  end
446
484
 
447
485
  RailsAiContextCLI.start(ARGV)
@@ -203,6 +203,10 @@ module RailsAiContext
203
203
  # Models to exclude from introspection
204
204
  # config.excluded_models += %w[AdminUser InternalThing]
205
205
 
206
+ # Framework association names hidden from model output
207
+ # (ActiveStorage, ActionText, ActionMailbox, Noticed associations are excluded by default)
208
+ # config.excluded_association_names += %w[my_custom_framework_assoc]
209
+
206
210
  # Controllers to exclude from listings
207
211
  # config.excluded_controllers += %w[Admin::BaseController]
208
212
 
@@ -17,7 +17,7 @@ module RailsAiContext
17
17
  server_name cache_ttl max_tool_response_chars
18
18
  live_reload live_reload_debounce auto_mount http_path http_bind http_port
19
19
  output_dir skip_tools excluded_models excluded_controllers
20
- excluded_route_prefixes excluded_filters excluded_middleware excluded_paths
20
+ excluded_route_prefixes excluded_filters excluded_middleware excluded_association_names excluded_paths
21
21
  sensitive_patterns search_extensions concern_paths frontend_paths
22
22
  max_file_size max_test_file_size max_schema_file_size max_view_total_size
23
23
  max_view_file_size max_search_results max_validate_files
@@ -189,12 +189,20 @@ module RailsAiContext
189
189
  /\A(Devise::Models|Devise::Orm|Bullet::|Turbo::|GlobalID::|Rolify::)/
190
190
  ].freeze
191
191
 
192
+ DEFAULT_EXCLUDED_ASSOCIATION_NAMES = %w[
193
+ active_storage_attachments active_storage_blobs
194
+ rich_text_body rich_text_content
195
+ action_mailbox_inbound_emails
196
+ noticed_events noticed_notifications
197
+ ].freeze
198
+
192
199
  # Filtering — customize what's hidden from AI output
193
200
  attr_accessor :excluded_controllers # Controller classes hidden from listings (e.g. DeviseController)
194
201
  attr_accessor :excluded_route_prefixes # Route controller prefixes hidden with app_only (e.g. action_mailbox/)
195
202
  attr_accessor :excluded_concerns # Regex patterns for concerns to hide (e.g. /Devise::Models/)
196
203
  attr_accessor :excluded_filters # Framework filter names hidden from controller output
197
204
  attr_accessor :excluded_middleware # Default middleware hidden from config output
205
+ attr_accessor :excluded_association_names # Framework association names hidden from model output
198
206
 
199
207
  # Search and file discovery
200
208
  attr_accessor :search_extensions # File extensions for Ruby fallback search (default: rb,js,erb,yml,yaml,json)
@@ -255,6 +263,7 @@ module RailsAiContext
255
263
  @excluded_concerns = DEFAULT_EXCLUDED_CONCERNS.dup
256
264
  @excluded_filters = DEFAULT_EXCLUDED_FILTERS.dup
257
265
  @excluded_middleware = DEFAULT_EXCLUDED_MIDDLEWARE.dup
266
+ @excluded_association_names = DEFAULT_EXCLUDED_ASSOCIATION_NAMES.dup
258
267
  @custom_tools = []
259
268
  @skip_tools = []
260
269
  @ai_tools = nil
@@ -36,10 +36,9 @@ module RailsAiContext
36
36
  devise_models = scan_models_for(/devise\s+(.+)$/)
37
37
  auth[:devise] = devise_models if devise_models.any?
38
38
 
39
- # Rails 8 built-in auth
40
- if file_exists?("app/models/session.rb") && file_exists?("app/models/current.rb")
41
- auth[:rails_auth] = true
42
- end
39
+ # Rails 8 built-in auth (`bin/rails generate authentication`)
40
+ rails_auth = detect_rails_auth
41
+ auth[:rails_auth] = rails_auth if rails_auth
43
42
 
44
43
  # has_secure_password
45
44
  secure_pw = scan_models_for(/has_secure_password/)
@@ -56,6 +55,65 @@ module RailsAiContext
56
55
  auth
57
56
  end
58
57
 
58
+ # Rails 8's `bin/rails generate authentication` produces a Session model,
59
+ # a Current attributes model, an Authentication concern in app/controllers/concerns,
60
+ # a SessionsController, and a PasswordsController. AI agents need to know:
61
+ #
62
+ # 1. that this app uses the built-in pattern (not Devise / not custom)
63
+ # 2. which controllers opt out via `allow_unauthenticated_access`
64
+ # 3. where the Authentication concern lives so they can find before_actions
65
+ def detect_rails_auth
66
+ return nil unless file_exists?("app/models/session.rb") && file_exists?("app/models/current.rb")
67
+
68
+ result = { detected: true }
69
+
70
+ result[:authentication_concern] = "app/controllers/concerns/authentication.rb" if file_exists?("app/controllers/concerns/authentication.rb")
71
+ result[:sessions_controller] = "app/controllers/sessions_controller.rb" if file_exists?("app/controllers/sessions_controller.rb")
72
+ result[:passwords_controller] = "app/controllers/passwords_controller.rb" if file_exists?("app/controllers/passwords_controller.rb")
73
+
74
+ unauth = scan_allow_unauthenticated_access
75
+ result[:allow_unauthenticated_access] = unauth if unauth.any?
76
+
77
+ result
78
+ rescue => e
79
+ $stderr.puts "[rails-ai-context] detect_rails_auth failed: #{e.message}" if ENV["DEBUG"]
80
+ nil
81
+ end
82
+
83
+ def scan_allow_unauthenticated_access
84
+ controllers_dir = File.join(root, "app/controllers")
85
+ return [] unless Dir.exist?(controllers_dir)
86
+
87
+ Dir.glob(File.join(controllers_dir, "**/*.rb")).flat_map do |path|
88
+ content = RailsAiContext::SafeFile.read(path) or next []
89
+ next [] unless content.match?(/\ballow_unauthenticated_access\b/)
90
+
91
+ relative = path.sub("#{root}/", "")
92
+ # Capture every `only:` / `except:` declaration in the file. A single
93
+ # controller can have multiple (e.g. `only:` + `except:` mixed via concerns).
94
+ scoped = content.scan(/allow_unauthenticated_access\s+(only|except):\s*(\[?[^\n]+)/)
95
+
96
+ if scoped.empty?
97
+ [ { file: relative, scope: "all actions" } ]
98
+ else
99
+ scoped.map do |kw, value|
100
+ { file: relative, scope: "#{kw}: #{strip_trailing_comment(value).strip}" }
101
+ end
102
+ end
103
+ end.compact.sort_by { |h| h[:file] }
104
+ rescue => e
105
+ $stderr.puts "[rails-ai-context] scan_allow_unauthenticated_access failed: #{e.message}" if ENV["DEBUG"]
106
+ []
107
+ end
108
+
109
+ # Strip a trailing `# comment` from a captured scope value while preserving
110
+ # `#` characters that appear inside string literals or array element syntax.
111
+ # Conservative: only strips when the `#` is preceded by whitespace, which
112
+ # matches the common style (`only: %i[index] # comment`).
113
+ def strip_trailing_comment(value)
114
+ value.sub(/\s+#.*\z/, "")
115
+ end
116
+
59
117
  def detect_authorization
60
118
  authz = {}
61
119
 
@@ -105,10 +105,36 @@ module RailsAiContext
105
105
 
106
106
  patterns << "view_components" if dir_exists?("app/components")
107
107
  patterns << "phlex" if gem_present?("phlex-rails")
108
+ patterns << "async_queries" if uses_async_queries?
108
109
 
109
110
  patterns
110
111
  end
111
112
 
113
+ ASYNC_QUERY_PATTERN = /\bload_async\b|\.async_(count|sum|minimum|maximum|average|pluck|ids|exists|find_by|find|first|last|take)\b/
114
+
115
+ def uses_async_queries?
116
+ %w[app/controllers app/services app/jobs app/models].any? do |rel_dir|
117
+ dir = File.join(root, rel_dir)
118
+ next false unless Dir.exist?(dir)
119
+
120
+ Dir.glob(File.join(dir, "**/*.rb")).first(500).any? do |f|
121
+ content = RailsAiContext::SafeFile.read(f) or next false
122
+ # Skip lines whose first non-whitespace character is `#` so we don't
123
+ # match deleted-code comments or TODO references like
124
+ # `# TODO: use load_async here`. Doesn't strip in-line trailing
125
+ # comments — Ruby AST parsing would be needed for that, and the
126
+ # false-positive rate from in-line trailing comments is tiny.
127
+ content.each_line.any? do |line|
128
+ next false if line.lstrip.start_with?("#")
129
+ line.match?(ASYNC_QUERY_PATTERN)
130
+ end
131
+ end
132
+ end
133
+ rescue => e
134
+ $stderr.puts "[rails-ai-context] uses_async_queries? failed: #{e.message}" if ENV["DEBUG"]
135
+ false
136
+ end
137
+
112
138
  def scan_directory_structure
113
139
  important_dirs = %w[
114
140
  app/models app/controllers app/views app/jobs
@@ -97,6 +97,7 @@ module RailsAiContext
97
97
  "scout_apm" => { category: :monitoring, note: "APM via Scout." },
98
98
  "newrelic_rpm" => { category: :monitoring, note: "APM via New Relic." },
99
99
  "skylight" => { category: :monitoring, note: "Performance monitoring via Skylight." },
100
+ "solid_errors" => { category: :monitoring, note: "Database-backed error tracking via Solid Errors. Mounted at /solid_errors by default." },
100
101
 
101
102
  # Admin
102
103
  "activeadmin" => { category: :admin, note: "Admin interface via ActiveAdmin." },
@@ -150,20 +150,110 @@ module RailsAiContext
150
150
  def extract_channels
151
151
  return [] unless defined?(ActionCable::Channel::Base)
152
152
 
153
+ # In development (config.eager_load = false), channel files are not
154
+ # loaded until a client subscribes. Without this, .descendants is empty
155
+ # and the entire channels array is missing from the introspector output.
156
+ # Mirrors the eager_load pattern used by ModelIntrospector / ControllerIntrospector.
157
+ eager_load_channels!
158
+
153
159
  ActionCable::Channel::Base.descendants.filter_map do |channel|
154
160
  next if channel.name.nil? || channel.name == "ApplicationCable::Channel"
155
161
 
162
+ source = channel_source(channel)
163
+
156
164
  {
157
- name: channel.name,
165
+ name: channel.name,
166
+ file: channel_relative_path(channel),
158
167
  stream_methods: channel.instance_methods(false)
159
168
  .select { |m| m.to_s.start_with?("stream_") || m == :subscribed }
160
- .map(&:to_s)
161
- }
169
+ .map(&:to_s),
170
+ identified_by: extract_identified_by(source),
171
+ streams: extract_channel_streams(source),
172
+ periodic: extract_channel_periodic(source),
173
+ actions: extract_channel_actions(channel)
174
+ }.compact
162
175
  end.sort_by { |c| c[:name] }
163
176
  rescue => e
164
177
  $stderr.puts "[rails-ai-context] extract_channels failed: #{e.message}" if ENV["DEBUG"]
165
178
  []
166
179
  end
180
+
181
+ def eager_load_channels!
182
+ return if Rails.application.config.eager_load
183
+
184
+ channels_path = File.join(app.root, "app", "channels")
185
+ if defined?(Zeitwerk) && Dir.exist?(channels_path) &&
186
+ Rails.autoloaders.respond_to?(:main) && Rails.autoloaders.main.respond_to?(:eager_load_dir)
187
+ Rails.autoloaders.main.eager_load_dir(channels_path)
188
+ end
189
+ rescue => e
190
+ $stderr.puts "[rails-ai-context] eager_load_channels! failed: #{e.message}" if ENV["DEBUG"]
191
+ nil
192
+ end
193
+
194
+ def channel_source(channel)
195
+ path = channel_absolute_path(channel)
196
+ return nil unless path && File.exist?(path)
197
+ RailsAiContext::SafeFile.read(path)
198
+ end
199
+
200
+ def channel_absolute_path(channel)
201
+ method_source = channel.instance_methods(false).first
202
+ return nil unless method_source
203
+ location = channel.instance_method(method_source).source_location
204
+ location&.first
205
+ rescue
206
+ nil
207
+ end
208
+
209
+ def channel_relative_path(channel)
210
+ path = channel_absolute_path(channel)
211
+ return nil unless path
212
+ rails_root = app.root.to_s
213
+ path.start_with?(rails_root) ? path.sub("#{rails_root}/", "") : path
214
+ end
215
+
216
+ # `identified_by :current_user, :tenant` — declared on ApplicationCable::Connection,
217
+ # but channels can also use it. Returns array of attribute names.
218
+ def extract_identified_by(source)
219
+ return nil unless source
220
+ matches = source.scan(/\bidentified_by\s+([^\n]+)/)
221
+ return nil if matches.empty?
222
+ matches.flat_map { |m| m.first.scan(/:(\w+)/).flatten }.uniq
223
+ end
224
+
225
+ # `stream_from "channel_name"` and `stream_for object` — what the channel broadcasts.
226
+ def extract_channel_streams(source)
227
+ return nil unless source
228
+ from_targets = source.scan(/\bstream_from\s+["']([^"']+)["']/).flatten
229
+ for_targets = source.scan(/\bstream_for\s+([^\s\n,]+)/).flatten
230
+ result = {}
231
+ result[:stream_from] = from_targets.uniq if from_targets.any?
232
+ result[:stream_for] = for_targets.uniq if for_targets.any?
233
+ result.empty? ? nil : result
234
+ end
235
+
236
+ # `periodically :method_name, every: 3.seconds`
237
+ # The `[^\n]+` capture group is already line-bounded, so we keep the full
238
+ # captured value (after stripping whitespace). Earlier versions tried to
239
+ # trim past the first comma/whitespace, which mangled lambdas and
240
+ # complex intervals like `-> { current_user.interval }`.
241
+ def extract_channel_periodic(source)
242
+ return nil unless source
243
+ timers = source.scan(/\bperiodically\s+:(\w+),\s*every:\s*([^\n]+)/).map do |method_name, interval|
244
+ { method: method_name, every: interval.strip }
245
+ end
246
+ timers.any? ? timers : nil
247
+ end
248
+
249
+ # RPC actions = public instance methods that aren't lifecycle hooks or stream helpers.
250
+ def extract_channel_actions(channel)
251
+ ignored = %i[subscribed unsubscribed]
252
+ actions = channel.instance_methods(false).reject do |m|
253
+ ignored.include?(m) || m.to_s.start_with?("stream_")
254
+ end
255
+ actions.empty? ? nil : actions.map(&:to_s).sort
256
+ end
167
257
  end
168
258
  end
169
259
  end
@@ -135,7 +135,8 @@ module RailsAiContext
135
135
  # ── Reflection-based extraction (unchanged) ─────────────────────
136
136
 
137
137
  def extract_associations(model)
138
- model.reflect_on_all_associations.map do |assoc|
138
+ excluded = config.excluded_association_names
139
+ model.reflect_on_all_associations.reject { |assoc| excluded.include?(assoc.name.to_s) }.map do |assoc|
139
140
  detail = {
140
141
  name: assoc.name.to_s,
141
142
  type: assoc.macro.to_s,
@@ -75,15 +75,19 @@ module RailsAiContext
75
75
 
76
76
  def tools_detail_guidance
77
77
  detail_param = tool_mode == :cli ? "detail=summary" : "detail:\"summary\""
78
+ context_tool = tool_mode == :cli ? cli_cmd("context") : "rails_get_context"
79
+ analyze_tool = tool_mode == :cli ? cli_cmd("analyze_feature") : "rails_analyze_feature"
78
80
  [
79
81
  "### detail parameter — ALWAYS start with summary",
80
82
  "",
81
- "Most tools accept `#{detail_param}`. Use the right level:",
83
+ "Individual lookup tools accept `#{detail_param}`. Use the right level:",
82
84
  "- **summary** — first call, orient yourself (table list, model names, route overview)",
83
85
  "- **standard** — working detail (columns with types, associations, action source) — DEFAULT",
84
86
  "- **full** — only when you need indexes, foreign keys, code snippets, or complete content",
85
87
  "",
86
88
  "Pattern: summary to find the target → standard to understand it → full only if needed.",
89
+ "",
90
+ "**Do NOT pass `detail` to composite tools** — `#{context_tool}` and `#{analyze_tool}` do not accept it and will return an error.",
87
91
  ""
88
92
  ]
89
93
  end
@@ -8,27 +8,6 @@ module RailsAiContext
8
8
  # Inherits from the official MCP::Tool to get schema validation,
9
9
  # annotations, and protocol compliance for free.
10
10
  class BaseTool < MCP::Tool
11
- # Default output schema for all tools. MCP::Tool.inherited resets
12
- # @output_schema_value to nil on each subclass, so we re-set it
13
- # via our own inherited hook.
14
- DEFAULT_OUTPUT_SCHEMA = MCP::Tool::OutputSchema.new(
15
- type: "object",
16
- properties: {
17
- content: {
18
- type: "array",
19
- items: {
20
- type: "object",
21
- properties: {
22
- type: { type: "string", enum: [ "text" ] },
23
- text: { type: "string", description: "Tool response as Markdown-formatted text" }
24
- },
25
- required: [ "type", "text" ]
26
- }
27
- }
28
- },
29
- required: [ "content" ]
30
- ).freeze
31
-
32
11
  # ── Auto-registration ────────────────────────────────────────────
33
12
  # Every subclass is tracked automatically via inherited.
34
13
  # BaseTool itself is abstract — only concrete tools are registered.
@@ -39,7 +18,6 @@ module RailsAiContext
39
18
 
40
19
  def self.inherited(subclass)
41
20
  super
42
- subclass.instance_variable_set(:@output_schema_value, DEFAULT_OUTPUT_SCHEMA)
43
21
  subclass.instance_variable_set(:@abstract, false)
44
22
  # Thread-safe append. Mutex is NOT held during eager_load!'s const_get
45
23
  # (which triggers inherited), so no recursive locking risk here.
@@ -28,23 +28,37 @@ module RailsAiContext
28
28
  root = Rails.root.to_s
29
29
  jobs_dir = File.join(root, "app", "jobs")
30
30
 
31
- unless Dir.exist?(jobs_dir)
32
- return text_response("No app/jobs/ directory found. This app may not use background jobs.")
31
+ job_files = if Dir.exist?(jobs_dir)
32
+ files = Dir.glob(File.join(jobs_dir, "**", "*.rb")).sort
33
+ files.reject { |f| File.basename(f) == "application_job.rb" }
34
+ else
35
+ []
33
36
  end
34
37
 
35
- job_files = Dir.glob(File.join(jobs_dir, "**", "*.rb")).sort
36
- # Filter out application_job.rb base class
37
- job_files.reject! { |f| File.basename(f) == "application_job.rb" }
38
-
39
- if job_files.empty?
40
- return text_response("app/jobs/ directory exists but contains no job files (besides ApplicationJob).")
41
- end
38
+ # Pull enriched channel data from the cached introspector context — this
39
+ # gives us the v5.8.0 fields (identified_by, streams, periodic, actions)
40
+ # that JobIntrospector#extract_channels populates.
41
+ channels = (cached_context.dig(:jobs, :channels) || []).reject { |c| c.is_a?(Hash) && c[:error] }
42
42
 
43
+ # Single-job query: requires jobs to be present.
43
44
  if job
45
+ return text_response("No app/jobs/ directory found. This app may not use background jobs.") if job_files.empty?
44
46
  return format_single_job(job, job_files, jobs_dir, root)
45
47
  end
46
48
 
47
- format_job_listing(job_files, jobs_dir, root, detail)
49
+ # No jobs and no channels — bail out with the legacy message.
50
+ if job_files.empty? && channels.empty?
51
+ return text_response("No app/jobs/ directory found and no Action Cable channels detected. This app may not use background jobs.")
52
+ end
53
+
54
+ # Compose: jobs section (if any) + channels section (if any).
55
+ lines = []
56
+ lines.concat(format_job_listing_lines(job_files, jobs_dir, root, detail)) if job_files.any?
57
+ if channels.any?
58
+ lines << "" if lines.any?
59
+ lines.concat(format_channels_section(channels))
60
+ end
61
+ text_response(lines.join("\n"))
48
62
  end
49
63
 
50
64
  private_class_method def self.format_single_job(job, job_files, jobs_dir, root)
@@ -137,7 +151,7 @@ module RailsAiContext
137
151
  text_response(lines.join("\n"))
138
152
  end
139
153
 
140
- private_class_method def self.format_job_listing(job_files, jobs_dir, root, detail)
154
+ private_class_method def self.format_job_listing_lines(job_files, jobs_dir, root, detail)
141
155
  job_data = []
142
156
 
143
157
  job_files.each do |file|
@@ -219,7 +233,50 @@ module RailsAiContext
219
233
  lines << "_Use `job:\"Name\"` to see enqueuers and cross-references._"
220
234
  end
221
235
 
222
- text_response(lines.join("\n"))
236
+ lines
237
+ end
238
+
239
+ # Renders the v5.8.0 enriched Action Cable channel detail produced by
240
+ # JobIntrospector#extract_channels (identified_by, streams, periodic, actions).
241
+ # Returns lines, not a Response — caller composes.
242
+ private_class_method def self.format_channels_section(channels)
243
+ lines = [ "# Action Cable Channels (#{channels.size})", "" ]
244
+
245
+ channels.each do |c|
246
+ lines << "## `#{c[:name]}`"
247
+ lines << ""
248
+ lines << "- **File:** `#{c[:file]}`" if c[:file]
249
+
250
+ if (ids = c[:identified_by]) && ids.any?
251
+ lines << "- **Identified by:** #{ids.map { |i| "`#{i}`" }.join(', ')}"
252
+ end
253
+
254
+ if (streams = c[:streams])
255
+ from = Array(streams[:stream_from])
256
+ for_ = Array(streams[:stream_for])
257
+ lines << "- **stream_from:** #{from.map { |s| "`#{s}`" }.join(', ')}" if from.any?
258
+ lines << "- **stream_for:** #{for_.map { |s| "`#{s}`" }.join(', ')}" if for_.any?
259
+ end
260
+
261
+ if (periodic = c[:periodic]) && periodic.any?
262
+ lines << "- **Periodic timers:**"
263
+ periodic.each do |t|
264
+ lines << " - `#{t[:method]}` every `#{t[:every]}`"
265
+ end
266
+ end
267
+
268
+ if (actions = c[:actions]) && actions.any?
269
+ lines << "- **RPC actions:** #{actions.map { |a| "`#{a}`" }.join(', ')}"
270
+ end
271
+
272
+ if (sm = c[:stream_methods]) && sm.any?
273
+ lines << "- **Stream methods:** #{sm.map { |m| "`#{m}`" }.join(', ')}"
274
+ end
275
+
276
+ lines << ""
277
+ end
278
+
279
+ lines
223
280
  end
224
281
 
225
282
  private_class_method def self.extract_class_name(source)
@@ -101,6 +101,9 @@ module RailsAiContext
101
101
  # Show affected models
102
102
  lines.concat(show_affected_models(table, models))
103
103
 
104
+ # Strong Migrations warnings (only when the gem is present in the project)
105
+ lines.concat(strong_migrations_warnings(action, table, column, options)) if strong_migrations_gem_present?
106
+
104
107
  text_response(lines.join("\n"))
105
108
  end
106
109
 
@@ -338,6 +341,71 @@ module RailsAiContext
338
341
  lines
339
342
  end
340
343
 
344
+ # Strong Migrations integration — surfaces the same warnings the gem would raise
345
+ # at migration runtime, so AI agents see them at code-generation time. Only fires
346
+ # when the gem is actually present in the project's Gemfile.lock.
347
+ #
348
+ # Catalog covers the most common breaking-change patterns (columns, indexes, FKs).
349
+ # Not exhaustive — see https://github.com/ankane/strong_migrations#checks for the full list.
350
+ def strong_migrations_warnings(action, table, column, options)
351
+ warnings = case action
352
+ when "remove_column"
353
+ [
354
+ "**`remove_column` is unsafe under load.** strong_migrations requires:",
355
+ " 1. Add the column to `self.ignored_columns += %w[#{column}]` in `app/models/#{table.singularize}.rb` first.",
356
+ " 2. Deploy that change.",
357
+ " 3. THEN run the migration in a separate deploy.",
358
+ " Or wrap in `safety_assured do ... end` if you accept the risk."
359
+ ]
360
+ when "rename_column"
361
+ [
362
+ "**`rename_column` is unsafe under load.** Old code references the old name and breaks during the deploy window.",
363
+ "Safer pattern: add a new column, backfill, dual-write, deploy, then remove the old column in a later release."
364
+ ]
365
+ when "change_type"
366
+ [
367
+ "**`change_column` (type change) blocks writes** on Postgres for the duration of the table rewrite, which can be hours on large tables.",
368
+ "Safer pattern: add a new column with the new type, backfill, dual-write, swap, drop the old column."
369
+ ]
370
+ when "add_index"
371
+ unless options.to_s.include?("concurrently")
372
+ [
373
+ "**`add_index` without `algorithm: :concurrently`** acquires an `ACCESS EXCLUSIVE` lock on Postgres and blocks writes.",
374
+ "Use `add_index :#{table}, :#{column}, algorithm: :concurrently` and add `disable_ddl_transaction!` at the top of the migration."
375
+ ]
376
+ end
377
+ when "add_association"
378
+ [
379
+ "**`add_foreign_key` validates existing rows by default**, which acquires a `SHARE ROW EXCLUSIVE` lock on both tables.",
380
+ "Safer two-step pattern:",
381
+ " 1. `add_foreign_key :#{table}, :other_table, validate: false` (lock-free)",
382
+ " 2. In a separate migration: `validate_foreign_key :#{table}, :other_table`"
383
+ ]
384
+ when "add_column"
385
+ if options.to_s.match?(/null:\s*false/) && !options.to_s.include?("default:")
386
+ [
387
+ "**Adding a `NOT NULL` column without a default rewrites the table** on older Postgres and fails on existing rows.",
388
+ "Safer pattern: add the column nullable, backfill in batches, then add the NOT NULL constraint with `change_column_null`."
389
+ ]
390
+ end
391
+ end
392
+
393
+ return [] unless warnings&.any?
394
+
395
+ [ "", "## Strong Migrations Warnings", "", "_The `strong_migrations` gem is in your Gemfile — these warnings match what it would raise at migration runtime._", "" ] + warnings
396
+ end
397
+
398
+ def strong_migrations_gem_present?
399
+ lock_path = File.join(rails_app.root.to_s, "Gemfile.lock")
400
+ return false unless File.exist?(lock_path)
401
+ content = RailsAiContext::SafeFile.read(lock_path)
402
+ return false unless content
403
+ content.include?(" strong_migrations (")
404
+ rescue => e
405
+ $stderr.puts "[rails-ai-context] strong_migrations_gem_present? failed: #{e.message}" if ENV["DEBUG"]
406
+ false
407
+ end
408
+
341
409
  def show_affected_models(table, models)
342
410
  lines = [ "", "## Affected Models", "" ]
343
411
 
@@ -70,6 +70,20 @@ module RailsAiContext
70
70
  )
71
71
  end
72
72
 
73
+ # ── ActiveRecord guard (api-only apps) ──────────────────────
74
+ # Must come BEFORE any code that rescues ActiveRecord::* — Ruby
75
+ # resolves rescue class constants at raise time, and `rescue
76
+ # ActiveRecord::ConnectionNotEstablished` crashes with NameError
77
+ # on apps where ActiveRecord is not loaded (e.g.
78
+ # `rails new --api --skip-active-record`).
79
+ unless defined?(ActiveRecord::Base)
80
+ return text_response(
81
+ "Database queries are unavailable: ActiveRecord is not loaded in this app. " \
82
+ "This happens on API-only apps created with `rails new --api --skip-active-record`. " \
83
+ "rails_query requires a database connection to function."
84
+ )
85
+ end
86
+
73
87
  # ── Layer 1: SQL validation ─────────────────────────────────
74
88
  valid, error = validate_sql(sql)
75
89
  return text_response(error) unless valid
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.7.1"
4
+ VERSION = "5.8.0"
5
5
  end
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.7.1
4
+ version: 5.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine