rails-ai-context 0.13.1 → 0.15.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/CLAUDE.md +1 -1
  4. data/README.md +9 -4
  5. data/docs/GUIDE.md +3 -3
  6. data/lib/rails_ai_context/configuration.rb +8 -1
  7. data/lib/rails_ai_context/introspectors/config_introspector.rb +43 -6
  8. data/lib/rails_ai_context/introspectors/controller_introspector.rb +138 -5
  9. data/lib/rails_ai_context/introspectors/model_introspector.rb +44 -3
  10. data/lib/rails_ai_context/introspectors/route_introspector.rb +3 -0
  11. data/lib/rails_ai_context/introspectors/schema_introspector.rb +58 -7
  12. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +37 -3
  13. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +14 -4
  14. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +22 -4
  15. data/lib/rails_ai_context/serializers/claude_serializer.rb +34 -7
  16. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -2
  17. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +3 -2
  18. data/lib/rails_ai_context/serializers/markdown_serializer.rb +5 -2
  19. data/lib/rails_ai_context/serializers/opencode_serializer.rb +33 -7
  20. data/lib/rails_ai_context/serializers/windsurf_serializer.rb +3 -2
  21. data/lib/rails_ai_context/tools/get_config.rb +40 -4
  22. data/lib/rails_ai_context/tools/get_controllers.rb +72 -24
  23. data/lib/rails_ai_context/tools/get_conventions.rb +10 -3
  24. data/lib/rails_ai_context/tools/get_edit_context.rb +36 -8
  25. data/lib/rails_ai_context/tools/get_gems.rb +2 -4
  26. data/lib/rails_ai_context/tools/get_model_details.rb +45 -11
  27. data/lib/rails_ai_context/tools/get_routes.rb +125 -16
  28. data/lib/rails_ai_context/tools/get_schema.rb +25 -5
  29. data/lib/rails_ai_context/tools/get_stimulus.rb +24 -5
  30. data/lib/rails_ai_context/tools/get_test_info.rb +16 -3
  31. data/lib/rails_ai_context/tools/get_view.rb +82 -25
  32. data/lib/rails_ai_context/tools/search_code.rb +35 -5
  33. data/lib/rails_ai_context/version.rb +1 -1
  34. data/server.json +2 -2
  35. metadata +1 -3
  36. data/demo.tape +0 -16
  37. data/demo_script.sh +0 -93
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9eb210617bf9e0de267dc49a092f8a06c26383bd3dc6ad82ecb52ed61ce08aa6
4
- data.tar.gz: '08929a12848a79b58a7e5e896a8a75c1d7068f8c1fe4910d8fc6f7eb8371aeec'
3
+ metadata.gz: 840dab1aeabc3323d5aca5702d95e712f7a013f24d05327424674262eee46de9
4
+ data.tar.gz: 2eed45a1bc5e79b9806f691c9e88ca2ced55660d53b74f56d8f354963c9ee497
5
5
  SHA512:
6
- metadata.gz: '0280fc9dff2714ddda17e7cc9f714e3147641bbbbe4a08f01c03515b4a847ac60acf7e68e55d0181ae768f6c8cb7fc1ac0d518b90af5ea390fd2af211d65a34b'
7
- data.tar.gz: 5b5e52eacd2efac4320b633e027af67c5d72aa506c217dd7ddf64d4e969fd471122daeb2d9a0ed5fb4f2b256b65b52509a8785f783e0befb70c15fbea2bcb1c3
6
+ metadata.gz: ac390f1d5b6b148f33fdac979d98269695fed76bceaaaab6b18c0682819a271a8dfdbaaab00f6c35eaaa2a867f652bc46c8d3d88199c5cd3bd1a87e20a0b3320
7
+ data.tar.gz: 0eb688c270bf08ddb5b70fc3dfbcca19432de3c6922c8a0f784081d82c117458fc3e136a56a962ee3988a9263a1ae9d9eff48716e4321e57bbc92d533622b249
data/CHANGELOG.md CHANGED
@@ -5,6 +5,65 @@ 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
+ ## [0.15.0] - 2026-03-22
9
+
10
+ ### Security
11
+
12
+ - **Sensitive file blocking** — `search_code` and `get_edit_context` now block access to `.env*`, `*.key`, `*.pem`, `config/master.key`, `config/credentials.yml.enc`. Configurable via `config.sensitive_patterns`.
13
+ - **Credentials key names redacted** — Replaced `credentials_keys` (exposed names like `stripe_secret_key`) with `credentials_configured` boolean. No more information disclosure via JSON output or MCP resources.
14
+ - **View content size cap** — `collect_all_view_content` capped at 5MB total / 500KB per file to prevent memory exhaustion.
15
+ - **Schema file size limits** — 10MB limit on `schema.rb`/`structure.sql` parsing. Cached `schema.rb` reads to avoid re-reading per table.
16
+
17
+ ### Added
18
+
19
+ - **Token optimization (~1,500-2,700 tokens/session saved)**:
20
+ - Filter framework filters (`verify_authenticity_token`, etc.) from controller output
21
+ - Filter framework/gem concerns (`Devise::*`, `Turbo::*`, `*::Generated*`) from models
22
+ - Combine duplicate PUT/PATCH routes into single `PATCH|PUT` entry
23
+ - Only show Nullable/Default columns when they have meaningful values
24
+ - Drop gem version numbers from default output
25
+ - Single HTML naming hint for Stimulus (not per-controller)
26
+ - Only show non-default middleware and initializers in config
27
+ - Group sibling controllers/routes with identical structure
28
+ - Compress repeated Tailwind classes in view full output
29
+ - Strip inline SVGs from view content
30
+ - Separate active vs lifecycle-only Stimulus controllers
31
+
32
+ ### Fixed
33
+
34
+ - **Controller staleness** — Source-file parsing for actions/filters instead of Ruby reflection. Filesystem discovery for new controllers not yet loaded as classes.
35
+ - **Schema `t.index` format** — Parse indexes inside `create_table` blocks (not just `add_index` outside).
36
+ - **Stimulus nested values** — Brace-depth counting for single-line `{ active: { type: String, default: "overview" } }`.
37
+ - **Stimulus phantom `type:Number`** — Exclude `type`/`default` as value names (JS keywords, not Stimulus values).
38
+ - **Search context_lines** — Use `--field-context-separator=:` for ripgrep `-C` output compatibility.
39
+ - **Schema defaults** — Supplement live DB nil defaults with values from `schema.rb`.
40
+ - **Config missing data** — Added `queue_adapter` and `mailer` settings to config introspector and tool.
41
+ - **View garbled fields** — Only extract from `@variable.field` patterns (not arbitrary method chains).
42
+ - **View shared partials** — `controller:"shared"` now finds partials in `app/views/shared/`.
43
+ - **View full detail** — Lists available controllers when no controller specified.
44
+ - **Edit context hint** — "Also found" only shown for matches outside the context window.
45
+ - **Model file structure** — Compressed to single-line format.
46
+ - **Strong params body** — Action detail now shows the actual `permit(...)` call.
47
+ - **AR-generated methods** — Filter `build_*`, `*_ids=`, etc. from model instance methods.
48
+
49
+ ## [0.14.0] - 2026-03-20
50
+
51
+ ### Fixed
52
+
53
+ - **Schema 0 indexes** — Fixed composite index parsing in schema.rb (regex didn't match array syntax) and structure.sql (`.first` only took first column). Both single and composite indexes now extracted correctly.
54
+ - **Stale routes after editing routes.rb** — Route introspector now calls `routes_reloader.execute_if_updated` to force Rails to reload routes before extraction.
55
+ - **Config "not available"** — Added `:config` to `:standard` preset. Was `:full` only, so default users never saw config data.
56
+ - **Stimulus values lost name** — Fixed parsing for both simple (`name: Type`) and complex (`name: { type: Type, default: val }`) formats. Now shows `max: Number (default: 3)`.
57
+ - **Model concerns noise** — Filtered out internal Rails modules (ActiveRecord::, ActiveModel::, Kernel, JSON::, etc.) from concerns list.
58
+
59
+ ### Added
60
+
61
+ - **Route helpers in standard detail** — `rails_get_routes(detail: "standard")` now includes route helper names alongside paths.
62
+ - **`app_only` filter for routes** — `rails_get_routes(app_only: true)` (default) hides internal Rails routes (Active Storage, Action Mailbox, Conductor).
63
+ - **Search context lines** — `rails_search_code(context_lines: 2)` adds surrounding lines to matches (passes `-C` to ripgrep).
64
+ - **Stimulus dash/underscore normalization** — Both `weekly-chart` and `weekly_chart` work for controller lookup. Output shows HTML `data-controller` attribute.
65
+ - **Model public method signatures** — `rails_get_model_details(model: "Cook")` shows method names with params from source, stopping at private boundary.
66
+
8
67
  ## [0.13.0] - 2026-03-20
9
68
 
10
69
  ### Added
data/CLAUDE.md CHANGED
@@ -32,7 +32,7 @@ structure to AI assistants via the Model Context Protocol (MCP).
32
32
  6. **Diff-aware** — context regeneration skips unchanged files
33
33
  7. **Per-assistant serializers** — each AI tool gets tailored output format
34
34
  8. **Zeitwerk autoloading** — files loaded on-demand, not all upfront
35
- 9. **Introspector presets** — `:standard` (12 core) default, `:full` (28) for power users
35
+ 9. **Introspector presets** — `:standard` (13 core) default, `:full` (28) for power users
36
36
  10. **MCP auto-discovery** — `.mcp.json` generated by install generator
37
37
  11. **Compact by default** — context files ≤150 lines, MCP tools use `detail` parameter (summary/standard/full)
38
38
  12. **Per-tool split rules** — `.claude/rules/`, `.cursor/rules/`, `.windsurf/rules/`, `.github/instructions/`
data/README.md CHANGED
@@ -147,8 +147,9 @@ your-rails-app/
147
147
  │ ├── CLAUDE.md ≤150 lines (compact)
148
148
  │ └── .claude/rules/
149
149
  │ ├── rails-context.md app overview
150
- │ ├── rails-schema.md table listing
150
+ │ ├── rails-schema.md table listing + column types
151
151
  │ ├── rails-models.md model listing
152
+ │ ├── rails-ui-patterns.md CSS/Tailwind component patterns
152
153
  │ └── rails-mcp-tools.md full tool reference
153
154
 
154
155
  ├── 🟢 Cursor
@@ -156,6 +157,7 @@ your-rails-app/
156
157
  │ ├── rails-project.mdc alwaysApply: true
157
158
  │ ├── rails-models.mdc globs: app/models/**
158
159
  │ ├── rails-controllers.mdc globs: app/controllers/**
160
+ │ ├── rails-ui-patterns.mdc globs: app/views/**
159
161
  │ └── rails-mcp-tools.mdc alwaysApply: true
160
162
 
161
163
  ├── ⚡ OpenCode
@@ -167,13 +169,16 @@ your-rails-app/
167
169
  │ ├── .windsurfrules ≤5,800 chars (6K limit)
168
170
  │ └── .windsurf/rules/
169
171
  │ ├── rails-context.md project overview
172
+ │ ├── rails-ui-patterns.md CSS component patterns
170
173
  │ └── rails-mcp-tools.md tool reference
171
174
 
172
175
  ├── 🟠 GitHub Copilot
173
176
  │ ├── .github/copilot-instructions.md ≤500 lines (compact)
174
177
  │ └── .github/instructions/
178
+ │ ├── rails-context.instructions.md applyTo: **/*
175
179
  │ ├── rails-models.instructions.md applyTo: app/models/**
176
180
  │ ├── rails-controllers.instructions.md applyTo: app/controllers/**
181
+ │ ├── rails-ui-patterns.instructions.md applyTo: app/views/**
177
182
  │ └── rails-mcp-tools.instructions.md applyTo: **/*
178
183
 
179
184
  ├── 📋 .ai-context.json full JSON (programmatic)
@@ -203,7 +208,7 @@ Root files (CLAUDE.md, AGENTS.md, etc.) use **section markers** — your custom
203
208
  | **DevOps** | Puma, Procfile, Docker, deployment tools, asset pipeline |
204
209
  | **Architecture** | Service objects, STI, polymorphism, state machines, multi-tenancy, engines |
205
210
 
206
- 29 introspectors total. The `:standard` preset runs 12 core ones by default; use `:full` for 28 (`database_stats` is opt-in, PostgreSQL only).
211
+ 29 introspectors total. The `:standard` preset runs 13 core ones by default; use `:full` for 28 (`database_stats` is opt-in, PostgreSQL only).
207
212
 
208
213
  ---
209
214
 
@@ -257,7 +262,7 @@ end
257
262
  ```ruby
258
263
  # config/initializers/rails_ai_context.rb
259
264
  RailsAiContext.configure do |config|
260
- # Presets: :standard (12 introspectors, default) or :full (all 28)
265
+ # Presets: :standard (13 introspectors, default) or :full (all 28)
261
266
  config.preset = :standard
262
267
 
263
268
  # Cherry-pick on top of a preset
@@ -291,7 +296,7 @@ end
291
296
  | Option | Default | Description |
292
297
  |--------|---------|-------------|
293
298
  | `preset` | `:standard` | Introspector preset (`:standard` or `:full`) |
294
- | `introspectors` | 12 core | Array of introspector symbols |
299
+ | `introspectors` | 13 core | Array of introspector symbols |
295
300
  | `context_mode` | `:compact` | `:compact` (≤150 lines) or `:full` (dump everything) |
296
301
  | `claude_max_lines` | `150` | Max lines for CLAUDE.md in compact mode |
297
302
  | `max_tool_response_chars` | `120_000` | Safety cap for MCP tool responses |
data/docs/GUIDE.md CHANGED
@@ -579,7 +579,7 @@ Both transports are **read-only** — they expose the same 13 tools and never mo
579
579
  RailsAiContext.configure do |config|
580
580
  # --- Introspectors ---
581
581
 
582
- # Presets: :standard (12 core, default) or :full (all 28)
582
+ # Presets: :standard (13 core, default) or :full (all 28)
583
583
  config.preset = :standard
584
584
 
585
585
  # Cherry-pick on top of a preset
@@ -636,7 +636,7 @@ end
636
636
  | Option | Type | Default | Description |
637
637
  |--------|------|---------|-------------|
638
638
  | `preset` | Symbol | `:standard` | Introspector preset (`:standard` or `:full`) |
639
- | `introspectors` | Array | 12 core symbols | Which introspectors to run |
639
+ | `introspectors` | Array | 13 core symbols | Which introspectors to run |
640
640
  | `context_mode` | Symbol | `:compact` | `:compact` or `:full` |
641
641
  | `claude_max_lines` | Integer | `150` | Max lines for CLAUDE.md in compact mode |
642
642
  | `max_tool_response_chars` | Integer | `120_000` | Safety cap for MCP tool responses |
@@ -673,7 +673,7 @@ All split rules include an app overview file, so no context is lost when root fi
673
673
 
674
674
  ## Introspectors — Full List
675
675
 
676
- ### Standard preset (12 introspectors)
676
+ ### Standard preset (13 introspectors)
677
677
 
678
678
  These run by default. Fast and cover core Rails structure.
679
679
 
@@ -3,7 +3,7 @@
3
3
  module RailsAiContext
4
4
  class Configuration
5
5
  PRESETS = {
6
- standard: %i[schema models routes jobs gems conventions controllers tests migrations stimulus view_templates design_tokens],
6
+ standard: %i[schema models routes jobs gems conventions controllers tests migrations stimulus view_templates design_tokens config],
7
7
  full: %i[schema models routes jobs gems conventions stimulus controllers views view_templates design_tokens turbo
8
8
  i18n config active_storage action_text auth api tests rake_tasks assets
9
9
  devops action_mailbox migrations seeds middleware engines multi_database]
@@ -18,6 +18,9 @@ module RailsAiContext
18
18
  # Paths to exclude from code search
19
19
  attr_accessor :excluded_paths
20
20
 
21
+ # Sensitive file patterns blocked from search and read tools
22
+ attr_accessor :sensitive_patterns
23
+
21
24
  # Whether to auto-mount the MCP HTTP endpoint
22
25
  attr_accessor :auto_mount
23
26
 
@@ -62,6 +65,10 @@ module RailsAiContext
62
65
  @server_version = RailsAiContext::VERSION
63
66
  @introspectors = PRESETS[:standard].dup
64
67
  @excluded_paths = %w[node_modules tmp log vendor .git]
68
+ @sensitive_patterns = %w[
69
+ .env .env.* config/master.key config/credentials.yml.enc
70
+ config/credentials/*.yml.enc *.pem *.key
71
+ ]
65
72
  @auto_mount = false
66
73
  @http_path = "/mcp"
67
74
  @http_bind = "127.0.0.1"
@@ -16,11 +16,13 @@ module RailsAiContext
16
16
  cache_store: detect_cache_store,
17
17
  session_store: detect_session_store,
18
18
  timezone: app.config.time_zone.to_s,
19
+ queue_adapter: detect_queue_adapter,
20
+ mailer: detect_mailer_settings,
19
21
  middleware_stack: extract_middleware,
20
22
  initializers: extract_initializers,
21
- credentials_keys: extract_credentials_keys,
23
+ credentials_configured: credentials_configured?,
22
24
  current_attributes: detect_current_attributes
23
- }
25
+ }.compact
24
26
  rescue => e
25
27
  { error: e.message }
26
28
  end
@@ -46,6 +48,40 @@ module RailsAiContext
46
48
  app.config.session_store&.name rescue "unknown"
47
49
  end
48
50
 
51
+ def detect_queue_adapter
52
+ adapter = app.config.active_job.queue_adapter
53
+ case adapter
54
+ when Symbol then adapter.to_s
55
+ when Class then adapter.name
56
+ else adapter.to_s
57
+ end
58
+ rescue
59
+ "unknown"
60
+ end
61
+
62
+ def detect_mailer_settings
63
+ mailer_config = app.config.action_mailer
64
+ settings = {}
65
+
66
+ if mailer_config.respond_to?(:delivery_method) && mailer_config.delivery_method
67
+ settings[:delivery_method] = mailer_config.delivery_method.to_s
68
+ end
69
+
70
+ if mailer_config.respond_to?(:default_options) && mailer_config.default_options.is_a?(Hash)
71
+ from = mailer_config.default_options[:from]
72
+ settings[:default_from] = from if from
73
+ end
74
+
75
+ if mailer_config.respond_to?(:default_url_options) && mailer_config.default_url_options.is_a?(Hash)
76
+ host = mailer_config.default_url_options[:host]
77
+ settings[:default_url_host] = host if host
78
+ end
79
+
80
+ settings.empty? ? nil : settings
81
+ rescue
82
+ nil
83
+ end
84
+
49
85
  def extract_middleware
50
86
  app.middleware.map { |m| m.name || m.klass.to_s }.uniq
51
87
  rescue
@@ -59,12 +95,13 @@ module RailsAiContext
59
95
  Dir.glob(File.join(dir, "*.rb")).map { |f| File.basename(f) }.sort
60
96
  end
61
97
 
62
- def extract_credentials_keys
98
+ # Returns whether credentials are configured (boolean).
99
+ # Does NOT expose key names — those could reveal integrated services.
100
+ def credentials_configured?
63
101
  creds = app.credentials
64
- return [] unless creds.respond_to?(:config)
65
- creds.config.keys.map(&:to_s).sort
102
+ creds.respond_to?(:config) && creds.config.keys.any?
66
103
  rescue
67
- []
104
+ false
68
105
  end
69
106
 
70
107
  def detect_current_attributes
@@ -4,9 +4,18 @@ module RailsAiContext
4
4
  module Introspectors
5
5
  # Discovers controllers and extracts filters, strong params,
6
6
  # respond_to formats, concerns, actions, and API detection.
7
+ # Uses source-file parsing (not just Ruby reflection) so that
8
+ # changes made mid-session are always visible.
7
9
  class ControllerIntrospector
8
10
  attr_reader :app
9
11
 
12
+ # Framework filters inherited from ActionController::Base — suppress to reduce noise
13
+ FRAMEWORK_FILTERS = %w[
14
+ verify_authenticity_token verify_same_origin_request
15
+ turbo_tracking_request_id handle_unverified_request
16
+ mark_for_same_origin_verification
17
+ ].freeze
18
+
10
19
  def initialize(app)
11
20
  @app = app
12
21
  end
@@ -21,6 +30,12 @@ module RailsAiContext
21
30
  hash[ctrl.name] = { error: e.message }
22
31
  end
23
32
 
33
+ # Discover controllers from filesystem that may not be loaded as classes
34
+ discover_from_filesystem.each do |name, path|
35
+ next if result.key?(name)
36
+ result[name] = extract_details_from_source(path)
37
+ end
38
+
24
39
  { controllers: result }
25
40
  rescue => e
26
41
  { error: e.message }
@@ -29,7 +44,16 @@ module RailsAiContext
29
44
  private
30
45
 
31
46
  def eager_load_controllers!
32
- Rails.application.eager_load! unless Rails.application.config.eager_load
47
+ return if Rails.application.config.eager_load
48
+
49
+ # Use targeted eager_load_dir to pick up newly created controller files
50
+ controllers_path = File.join(app.root, "app", "controllers")
51
+ if defined?(Zeitwerk) && Dir.exist?(controllers_path) &&
52
+ Rails.autoloaders.respond_to?(:main) && Rails.autoloaders.main.respond_to?(:eager_load_dir)
53
+ Rails.autoloaders.main.eager_load_dir(controllers_path)
54
+ else
55
+ Rails.application.eager_load!
56
+ end
33
57
  rescue
34
58
  nil
35
59
  end
@@ -46,14 +70,45 @@ module RailsAiContext
46
70
  end.uniq.sort_by(&:name)
47
71
  end
48
72
 
73
+ # Scan filesystem for controller files not yet loaded as classes
74
+ def discover_from_filesystem
75
+ controllers_dir = File.join(app.root, "app", "controllers")
76
+ return {} unless Dir.exist?(controllers_dir)
77
+
78
+ Dir.glob(File.join(controllers_dir, "**/*_controller.rb")).each_with_object({}) do |path, hash|
79
+ relative = path.sub("#{controllers_dir}/", "")
80
+ class_name = relative.sub(/\.rb\z/, "").split("/").map(&:camelize).join("::")
81
+ next if class_name == "ApplicationController"
82
+ next if class_name.start_with?("Rails::", "ActionMailbox::", "ActiveStorage::")
83
+ hash[class_name] = path
84
+ end
85
+ end
86
+
87
+ # Extract details purely from source file (for controllers not loaded as classes)
88
+ def extract_details_from_source(path)
89
+ source = File.read(path)
90
+ parent = source.match(/class\s+\S+\s*<\s*(\S+)/)&.send(:[], 1) || "Unknown"
91
+ {
92
+ parent_class: parent,
93
+ api_controller: parent.include?("API"),
94
+ actions: extract_actions_from_source(source),
95
+ filters: extract_filters_from_source(source),
96
+ concerns: extract_concerns_from_source(source),
97
+ strong_params: extract_strong_params(source),
98
+ respond_to_formats: extract_respond_to(source)
99
+ }.compact
100
+ rescue => e
101
+ { error: e.message }
102
+ end
103
+
49
104
  def extract_controller_details(ctrl)
50
105
  source = read_source(ctrl)
51
106
 
52
107
  {
53
108
  parent_class: ctrl.superclass.name,
54
109
  api_controller: api_controller?(ctrl),
55
- actions: extract_actions(ctrl),
56
- filters: extract_filters(ctrl),
110
+ actions: extract_actions(ctrl, source),
111
+ filters: extract_filters(ctrl, source),
57
112
  concerns: extract_concerns(ctrl),
58
113
  strong_params: extract_strong_params(source),
59
114
  respond_to_formats: extract_respond_to(source)
@@ -65,17 +120,51 @@ module RailsAiContext
65
120
  false
66
121
  end
67
122
 
68
- def extract_actions(ctrl)
123
+ # Prefer source-based parsing for actions — always reflects current file state.
124
+ # Falls back to reflection for controllers without readable source files.
125
+ def extract_actions(ctrl, source = nil)
126
+ if source
127
+ actions = extract_actions_from_source(source)
128
+ return actions if actions.any?
129
+ end
69
130
  ctrl.action_methods.to_a.sort
70
131
  rescue
71
132
  []
72
133
  end
73
134
 
74
- def extract_filters(ctrl)
135
+ def extract_actions_from_source(source)
136
+ in_private = false
137
+ actions = []
138
+
139
+ source.each_line do |line|
140
+ if line.match?(/\A\s*(private|protected)\s*$/)
141
+ in_private = true
142
+ elsif line.match?(/\A\s*public\s*$/)
143
+ in_private = false
144
+ end
145
+
146
+ next if in_private
147
+
148
+ if (match = line.match(/\A\s*def\s+(\w+[?!]?)/))
149
+ actions << match[1] unless match[1].start_with?("_")
150
+ end
151
+ end
152
+
153
+ actions.sort
154
+ end
155
+
156
+ # Prefer source-based parsing for filters — always reflects current file state.
157
+ # Falls back to reflection for controllers without readable source files.
158
+ def extract_filters(ctrl, source = nil)
159
+ if source
160
+ filters = extract_filters_from_source(source)
161
+ return filters if filters.any?
162
+ end
75
163
  return [] unless ctrl.respond_to?(:_process_action_callbacks)
76
164
 
77
165
  ctrl._process_action_callbacks.filter_map do |cb|
78
166
  next if cb.filter.is_a?(Proc) || cb.filter.to_s.start_with?("_")
167
+ next if FRAMEWORK_FILTERS.include?(cb.filter.to_s)
79
168
 
80
169
  filter = { name: cb.filter.to_s, kind: cb.kind.to_s }
81
170
  filter[:only] = cb.instance_variable_get(:@if)&.filter_map { |c| extract_action_condition(c) }&.flatten
@@ -88,6 +177,46 @@ module RailsAiContext
88
177
  []
89
178
  end
90
179
 
180
+ def extract_filters_from_source(source)
181
+ filters = []
182
+ source.each_line do |line|
183
+ next unless (match = line.match(
184
+ /\A\s*(before_action|after_action|around_action|prepend_before_action|append_before_action)\s+:(\w+[?!]?)/
185
+ ))
186
+
187
+ kind = match[1].sub(/_action\z/, "").sub(/\A(?:prepend|append)_/, "")
188
+ filter = { name: match[2], kind: kind }
189
+
190
+ only = parse_action_constraint(line, "only")
191
+ except = parse_action_constraint(line, "except")
192
+ filter[:only] = only if only&.any?
193
+ filter[:except] = except if except&.any?
194
+ filters << filter
195
+ end
196
+ filters
197
+ end
198
+
199
+ def parse_action_constraint(line, key)
200
+ return nil unless line.include?("#{key}:")
201
+
202
+ # %i[...] or %w[...] format
203
+ if (match = line.match(/#{key}:\s*%[iwIW]\[([^\]]+)\]/))
204
+ return match[1].split(/\s+/)
205
+ end
206
+
207
+ # [...] format with symbols
208
+ if (match = line.match(/#{key}:\s*\[([^\]]+)\]/))
209
+ return match[1].scan(/:(\w+[?!]?)/).flatten
210
+ end
211
+
212
+ # Single symbol format
213
+ if (match = line.match(/#{key}:\s*:(\w+[?!]?)/))
214
+ return [ match[1] ]
215
+ end
216
+
217
+ nil
218
+ end
219
+
91
220
  def extract_action_condition(condition)
92
221
  return nil unless condition.is_a?(String) || condition.respond_to?(:to_s)
93
222
  match = condition.to_s.match(/action_name\s*==\s*['"](\w+)['"]/)
@@ -104,6 +233,10 @@ module RailsAiContext
104
233
  []
105
234
  end
106
235
 
236
+ def extract_concerns_from_source(source)
237
+ source.scan(/^\s*include\s+(\w+(?:::\w+)*)/).flatten
238
+ end
239
+
107
240
  def extract_strong_params(source)
108
241
  return [] if source.nil?
109
242
 
@@ -29,7 +29,16 @@ module RailsAiContext
29
29
  private
30
30
 
31
31
  def eager_load_models!
32
- Rails.application.eager_load! unless Rails.application.config.eager_load
32
+ return if Rails.application.config.eager_load
33
+
34
+ # Use targeted eager_load_dir to pick up newly created model files
35
+ models_path = File.join(app.root, "app", "models")
36
+ if defined?(Zeitwerk) && Dir.exist?(models_path) &&
37
+ Rails.autoloaders.respond_to?(:main) && Rails.autoloaders.main.respond_to?(:eager_load_dir)
38
+ Rails.autoloaders.main.eager_load_dir(models_path)
39
+ else
40
+ Rails.application.eager_load!
41
+ end
33
42
  rescue
34
43
  # In some environments (CI, Claude Code) eager_load may partially fail
35
44
  nil
@@ -140,11 +149,20 @@ module RailsAiContext
140
149
  def extract_concerns(model)
141
150
  model.ancestors
142
151
  .select { |mod| mod.is_a?(Module) && !mod.is_a?(Class) }
143
- .reject { |mod| mod.name&.start_with?("ActiveRecord", "ActiveModel", "ActiveSupport") }
152
+ .reject { |mod| framework_concern?(mod.name) }
144
153
  .map(&:name)
145
154
  .compact
146
155
  end
147
156
 
157
+ def framework_concern?(name)
158
+ return true if name.nil?
159
+ return true if name.include?("::Generated")
160
+ return true if name.match?(/\A(ActiveRecord|ActiveModel|ActiveSupport|ActionText|ActionMailbox|ActiveStorage|ActionDispatch|ActionController|ActionView|AbstractController)/)
161
+ return true if name.match?(/\A(Devise::Models|Devise::Orm|Bullet::|Turbo::|GlobalID::|Rolify::)/)
162
+ return true if %w[Kernel JSON PP Marshal MessagePack].include?(name)
163
+ false
164
+ end
165
+
148
166
  def extract_public_class_methods(model)
149
167
  (model.methods - ActiveRecord::Base.methods - Object.methods)
150
168
  .reject { |m| m.to_s.start_with?("_", "autosave") }
@@ -154,13 +172,36 @@ module RailsAiContext
154
172
  end
155
173
 
156
174
  def extract_public_instance_methods(model)
175
+ generated = generated_association_methods(model)
176
+
157
177
  (model.instance_methods - ActiveRecord::Base.instance_methods - Object.instance_methods)
158
- .reject { |m| m.to_s.start_with?("_", "autosave", "validate_associated") }
178
+ .reject { |m|
179
+ ms = m.to_s
180
+ ms.start_with?("_", "autosave", "validate_associated") || generated.include?(ms)
181
+ }
159
182
  .sort
160
183
  .first(30)
161
184
  .map(&:to_s)
162
185
  end
163
186
 
187
+ # Build list of AR-generated association helper method names to exclude
188
+ def generated_association_methods(model)
189
+ methods = []
190
+ model.reflect_on_all_associations.each do |assoc|
191
+ name = assoc.name.to_s
192
+ singular = name.singularize
193
+ methods.concat(%W[
194
+ build_#{name} create_#{name} create_#{name}!
195
+ reload_#{name} reset_#{name}
196
+ #{name}_changed? #{name}_previously_changed?
197
+ #{singular}_ids #{singular}_ids=
198
+ ])
199
+ end
200
+ methods
201
+ rescue
202
+ []
203
+ end
204
+
164
205
  def extract_source_macros(model)
165
206
  path = model_source_path(model)
166
207
  return {} unless path && File.exist?(path)
@@ -26,6 +26,9 @@ module RailsAiContext
26
26
  private
27
27
 
28
28
  def extract_routes
29
+ # Force Rails to reload routes if routes.rb has changed
30
+ app.routes_reloader&.execute_if_updated rescue nil
31
+
29
32
  app.routes.routes.filter_map do |route|
30
33
  next if route.respond_to?(:internal?) && route.internal?
31
34
  next if route.defaults[:controller].blank?