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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -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/i18n_introspector.rb +1 -1
- 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/claude_serializer.rb +2 -21
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +28 -1
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +2 -21
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -16
- data/lib/rails_ai_context/tools/base_tool.rb +21 -22
- data/lib/rails_ai_context/tools/get_config.rb +3 -3
- data/lib/rails_ai_context/tools/get_edit_context.rb +0 -16
- data/lib/rails_ai_context/tools/get_env.rb +1 -21
- data/lib/rails_ai_context/tools/get_job_pattern.rb +69 -20
- data/lib/rails_ai_context/tools/get_model_details.rb +0 -4
- data/lib/rails_ai_context/tools/get_partial_interface.rb +0 -8
- data/lib/rails_ai_context/tools/get_service_pattern.rb +0 -8
- data/lib/rails_ai_context/tools/get_turbo_map.rb +0 -8
- data/lib/rails_ai_context/tools/get_view.rb +0 -4
- 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/tools/search_code.rb +2 -12
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 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,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
|
[](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,
|
|
@@ -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
|
|
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
|
|
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
|