rails-ai-context 5.7.0 → 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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.md +2 -2
  4. data/docs/CONFIGURATION.md +1 -0
  5. data/docs/FAQ.md +1 -1
  6. data/docs/GUIDE.md +4 -0
  7. data/docs/STANDALONE.md +3 -0
  8. data/docs/TOOLS.md +1 -1
  9. data/docs/social-preview.html +1 -1
  10. data/exe/rails-ai-context +44 -6
  11. data/lib/generators/rails_ai_context/install/install_generator.rb +4 -0
  12. data/lib/rails_ai_context/configuration.rb +10 -1
  13. data/lib/rails_ai_context/introspectors/auth_introspector.rb +62 -4
  14. data/lib/rails_ai_context/introspectors/convention_introspector.rb +26 -0
  15. data/lib/rails_ai_context/introspectors/gem_introspector.rb +1 -0
  16. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +1 -1
  17. data/lib/rails_ai_context/introspectors/job_introspector.rb +93 -3
  18. data/lib/rails_ai_context/introspectors/model_introspector.rb +2 -1
  19. data/lib/rails_ai_context/serializers/claude_serializer.rb +2 -21
  20. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +1 -1
  21. data/lib/rails_ai_context/serializers/markdown_serializer.rb +28 -1
  22. data/lib/rails_ai_context/serializers/opencode_serializer.rb +2 -21
  23. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -16
  24. data/lib/rails_ai_context/tools/base_tool.rb +21 -22
  25. data/lib/rails_ai_context/tools/get_config.rb +3 -3
  26. data/lib/rails_ai_context/tools/get_edit_context.rb +0 -16
  27. data/lib/rails_ai_context/tools/get_env.rb +1 -21
  28. data/lib/rails_ai_context/tools/get_job_pattern.rb +69 -20
  29. data/lib/rails_ai_context/tools/get_model_details.rb +0 -4
  30. data/lib/rails_ai_context/tools/get_partial_interface.rb +0 -8
  31. data/lib/rails_ai_context/tools/get_service_pattern.rb +0 -8
  32. data/lib/rails_ai_context/tools/get_turbo_map.rb +0 -8
  33. data/lib/rails_ai_context/tools/get_view.rb +0 -4
  34. data/lib/rails_ai_context/tools/migration_advisor.rb +68 -0
  35. data/lib/rails_ai_context/tools/query.rb +14 -0
  36. data/lib/rails_ai_context/tools/search_code.rb +2 -12
  37. data/lib/rails_ai_context/version.rb +1 -1
  38. data/server.json +2 -2
  39. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 313744756e1399207abeb841866440c3de88da5863f92163fd7d344b60c479c5
4
- data.tar.gz: 4de97686b1d62c8444a76ed78fc709aade9e0e004adae1a7a655c7d0eae8ce23
3
+ metadata.gz: 4b34bd6800329b83e18ec7ee97eceb2d4ccab18019bf0c1f3444485e70273938
4
+ data.tar.gz: 548ab9e6f769651b2e06c81c01fa570e033b8a4c42a4400db7e888ff9a1ba377
5
5
  SHA512:
6
- metadata.gz: 0df4cd4cd7c0e99f8c1e662baae65a960819ed8bea551e8bb536bf06cf2b1ba6c42fee462668e990c2bf48572d5b65b66b6f38c730f643c2cd6e19b751ab7a9c
7
- data.tar.gz: 88df4ee906be73bb71212bf47acc88e09efe0ac82a87abef786a3f9a829c5aed57bdb63d19c58d6f5a4361d8ebf5955b3ce29a09235a84aa01e9c204e7ee9471
6
+ metadata.gz: 3c55e65978c097c6d7b16c4c5a20c1d36d494e9571183569ef4faa91252aeb97dddedd4e858fc8727d169982861320b1db4b963ceee94a1d1f2a38d7708bd61e
7
+ data.tar.gz: e24ececc1491709b0f07eb235e066048d0f751cd18756462cd6e76c8ee090abc30231e02a7211f23248bb9a40f476e8ce882dcdd106b6056aa3f26406c274b22
data/CHANGELOG.md CHANGED
@@ -5,6 +5,52 @@ 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
+
41
+ ## [5.7.1] — 2026-04-09
42
+
43
+ ### Changed — SLOP Cleanup
44
+
45
+ Internal code quality improvements — no API changes, no new features.
46
+
47
+ - **Extract `safe_read`, `max_file_size`, `sensitive_file?` to BaseTool** — removed 16 duplicate one-liner methods across 8 tool files (get_env, get_job_pattern, get_service_pattern, get_turbo_map, get_partial_interface, get_view, get_edit_context, get_model_details, search_code)
48
+ - **Extract `FullSerializerBehavior` module** — deduplicated identical `footer` and `architecture_summary` methods from FullClaudeSerializer and FullOpencodeSerializer
49
+ - **Derive `tools_name_list` from `TOOL_ROWS`** — replaced hardcoded 38-tool name array with derivation from single source of truth in ToolGuideHelper
50
+ - **Fix `notable_gems_list` bypass** — copilot_instructions_serializer and markdown_serializer now use the triple-fallback helper instead of raw hash access
51
+ - **Narrow bare `rescue` to `rescue StandardError`** — 4 sites in get_config and i18n_introspector no longer catch `SignalException`/`NoMemoryError`
52
+ - **Delete dead `SENSITIVE_PATTERNS = nil` constant** — vestigial from get_edit_context
53
+
8
54
  ## [5.7.0] — 2026-04-09
9
55
 
10
56
  ### Quickstart — Two commands. Problem gone.
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." },
@@ -100,7 +100,7 @@ module RailsAiContext
100
100
  next 0 unless content
101
101
  data = YAML.safe_load(content, permitted_classes: [ Symbol ])
102
102
  count_nested_keys(data)
103
- rescue
103
+ rescue StandardError
104
104
  0
105
105
  end
106
106
  rescue => e
@@ -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,
@@ -104,6 +104,8 @@ module RailsAiContext
104
104
 
105
105
  # Internal: full-mode Claude serializer (wraps MarkdownSerializer with behavioral rules)
106
106
  class FullClaudeSerializer < MarkdownSerializer
107
+ include FullSerializerBehavior
108
+
107
109
  private
108
110
 
109
111
  def header
@@ -119,27 +121,6 @@ module RailsAiContext
119
121
  that matches this project's style.
120
122
  MD
121
123
  end
122
-
123
- def footer
124
- rules = []
125
- rules << "## Behavioral Rules"
126
- rules << ""
127
- rules << "When working in this codebase:"
128
- rules << "- Follow existing patterns and conventions detected above"
129
- rules << "- Use the database schema as the source of truth for column names and types"
130
- rules << "- Respect existing associations and validations when modifying models"
131
- rules << "- Match the project's architecture style (#{architecture_summary})" if architecture_summary
132
- test_cmd = detect_test_command
133
- rules << "- Run `#{test_cmd}` after making changes to verify correctness"
134
- rules << ""
135
- rules << super
136
- rules.join("\n")
137
- end
138
-
139
- def architecture_summary
140
- arch = context.dig(:conventions, :architecture)
141
- arch&.any? ? arch.join(", ") : nil
142
- end
143
124
  end
144
125
  end
145
126
  end
@@ -56,7 +56,7 @@ module RailsAiContext
56
56
 
57
57
  gems = context[:gems]
58
58
  if gems.is_a?(Hash) && !gems[:error]
59
- notable = gems[:notable_gems] || []
59
+ notable = notable_gems_list(gems)
60
60
  notable.group_by { |g| g[:category]&.to_s || "other" }.first(6).each do |cat, gem_list|
61
61
  lines << "- #{cat}: #{gem_list.map { |g| g[:name] }.join(', ')}"
62
62
  end
@@ -153,7 +153,7 @@ module RailsAiContext
153
153
  gems = context[:gems]
154
154
  return if gems[:error]
155
155
 
156
- notable = gems[:notable_gems] || []
156
+ notable = notable_gems_list(gems)
157
157
  return if notable.empty?
158
158
 
159
159
  lines = [ "## Notable Gems" ]
@@ -528,5 +528,32 @@ module RailsAiContext
528
528
  text.to_s.gsub(MARKDOWN_SPECIAL_CHARS, '\\\\\1')
529
529
  end
530
530
  end
531
+
532
+ # Shared behavior for full-mode serializers (FullClaudeSerializer, FullOpencodeSerializer).
533
+ # Provides behavioral rules footer and architecture summary.
534
+ module FullSerializerBehavior
535
+ private
536
+
537
+ def footer
538
+ rules = []
539
+ rules << "## Behavioral Rules"
540
+ rules << ""
541
+ rules << "When working in this codebase:"
542
+ rules << "- Follow existing patterns and conventions detected above"
543
+ rules << "- Use the database schema as the source of truth for column names and types"
544
+ rules << "- Respect existing associations and validations when modifying models"
545
+ rules << "- Match the project's architecture style (#{architecture_summary})" if architecture_summary
546
+ test_cmd = detect_test_command
547
+ rules << "- Run `#{test_cmd}` after making changes to verify correctness"
548
+ rules << ""
549
+ rules << super
550
+ rules.join("\n")
551
+ end
552
+
553
+ def architecture_summary
554
+ arch = context.dig(:conventions, :architecture)
555
+ arch&.any? ? arch.join(", ") : nil
556
+ end
557
+ end
531
558
  end
532
559
  end