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 +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +2 -2
- data/docs/CONFIGURATION.md +1 -0
- data/docs/FAQ.md +1 -1
- data/docs/GUIDE.md +4 -0
- data/docs/STANDALONE.md +3 -0
- data/docs/TOOLS.md +1 -1
- data/docs/social-preview.html +1 -1
- data/exe/rails-ai-context +44 -6
- data/lib/generators/rails_ai_context/install/install_generator.rb +4 -0
- data/lib/rails_ai_context/configuration.rb +10 -1
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +62 -4
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +26 -0
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +1 -0
- data/lib/rails_ai_context/introspectors/job_introspector.rb +93 -3
- data/lib/rails_ai_context/introspectors/model_introspector.rb +2 -1
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +5 -1
- data/lib/rails_ai_context/tools/base_tool.rb +0 -22
- data/lib/rails_ai_context/tools/get_job_pattern.rb +69 -12
- data/lib/rails_ai_context/tools/migration_advisor.rb +68 -0
- data/lib/rails_ai_context/tools/query.rb +14 -0
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b34bd6800329b83e18ec7ee97eceb2d4ccab18019bf0c1f3444485e70273938
|
|
4
|
+
data.tar.gz: 548ab9e6f769651b2e06c81c01fa570e033b8a4c42a4400db7e888ff9a1ba377
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://github.com/crisnahine/rails-ai-context)
|
|
23
23
|
[](https://github.com/crisnahine/rails-ai-context)
|
|
24
|
-
[](https://github.com/crisnahine/rails-ai-context/actions)
|
|
25
25
|
[](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
|
-
|
|
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>
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
-
|
|
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
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
|
-
|
|
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
|
|
data/docs/social-preview.html
CHANGED
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,
|
|
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
|
-
|
|
41
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
#
|
|
37
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|