rails-ai-context 0.14.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -0
- data/README.md +6 -1
- data/lib/rails_ai_context/configuration.rb +7 -0
- data/lib/rails_ai_context/introspectors/config_introspector.rb +43 -6
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +138 -5
- data/lib/rails_ai_context/introspectors/model_introspector.rb +44 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +48 -2
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +22 -3
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +14 -4
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +22 -4
- data/lib/rails_ai_context/serializers/claude_serializer.rb +34 -7
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -2
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +3 -2
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +5 -2
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +33 -7
- data/lib/rails_ai_context/serializers/windsurf_serializer.rb +3 -2
- data/lib/rails_ai_context/tools/get_config.rb +40 -4
- data/lib/rails_ai_context/tools/get_controllers.rb +72 -24
- data/lib/rails_ai_context/tools/get_conventions.rb +10 -3
- data/lib/rails_ai_context/tools/get_edit_context.rb +36 -8
- data/lib/rails_ai_context/tools/get_gems.rb +2 -4
- data/lib/rails_ai_context/tools/get_model_details.rb +39 -10
- data/lib/rails_ai_context/tools/get_routes.rb +111 -16
- data/lib/rails_ai_context/tools/get_schema.rb +25 -5
- data/lib/rails_ai_context/tools/get_stimulus.rb +19 -4
- data/lib/rails_ai_context/tools/get_test_info.rb +16 -3
- data/lib/rails_ai_context/tools/get_view.rb +82 -25
- data/lib/rails_ai_context/tools/search_code.rb +26 -2
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +1 -3
- data/demo.tape +0 -16
- data/demo_script.sh +0 -93
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 840dab1aeabc3323d5aca5702d95e712f7a013f24d05327424674262eee46de9
|
|
4
|
+
data.tar.gz: 2eed45a1bc5e79b9806f691c9e88ca2ced55660d53b74f56d8f354963c9ee497
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/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)
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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|
|
|
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)
|
|
@@ -57,8 +57,10 @@ module RailsAiContext
|
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def extract_columns(table)
|
|
60
|
+
schema_defaults = parse_schema_defaults_for_table(table)
|
|
61
|
+
|
|
60
62
|
connection.columns(table).map do |col|
|
|
61
|
-
{
|
|
63
|
+
entry = {
|
|
62
64
|
name: col.name,
|
|
63
65
|
type: col.type.to_s,
|
|
64
66
|
null: col.null,
|
|
@@ -67,7 +69,12 @@ module RailsAiContext
|
|
|
67
69
|
precision: col.precision,
|
|
68
70
|
scale: col.scale,
|
|
69
71
|
comment: col.comment
|
|
70
|
-
}
|
|
72
|
+
}
|
|
73
|
+
# Supplement with schema.rb default when live DB returns nil
|
|
74
|
+
if entry[:default].nil? && schema_defaults[col.name]
|
|
75
|
+
entry[:default] = schema_defaults[col.name]
|
|
76
|
+
end
|
|
77
|
+
entry.compact
|
|
71
78
|
end
|
|
72
79
|
end
|
|
73
80
|
|
|
@@ -97,6 +104,36 @@ module RailsAiContext
|
|
|
97
104
|
[] # Some adapters don't support foreign_keys
|
|
98
105
|
end
|
|
99
106
|
|
|
107
|
+
# Parse default values from schema.rb for a specific table.
|
|
108
|
+
# Used to supplement live DB column data when the adapter returns nil defaults.
|
|
109
|
+
# Caches the schema.rb content to avoid re-reading once per table.
|
|
110
|
+
def parse_schema_defaults_for_table(table)
|
|
111
|
+
return {} unless File.exist?(schema_file_path)
|
|
112
|
+
|
|
113
|
+
@schema_rb_content ||= File.read(schema_file_path)
|
|
114
|
+
defaults = {}
|
|
115
|
+
in_table = false
|
|
116
|
+
|
|
117
|
+
@schema_rb_content.each_line do |line|
|
|
118
|
+
if line.match?(/create_table\s+"#{Regexp.escape(table)}"/)
|
|
119
|
+
in_table = true
|
|
120
|
+
elsif in_table && line.match?(/\A\s*end\b/)
|
|
121
|
+
break
|
|
122
|
+
elsif in_table
|
|
123
|
+
# Match column with a simple default value (skip proc defaults like -> { })
|
|
124
|
+
if (match = line.match(/t\.\w+\s+"(\w+)".*,\s*default:\s*("[^"]*"|\d+(?:\.\d+)?|true|false)/))
|
|
125
|
+
col_name = match[1]
|
|
126
|
+
raw = match[2]
|
|
127
|
+
defaults[col_name] = raw.start_with?('"') ? raw[1..-2] : raw
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
defaults
|
|
133
|
+
rescue
|
|
134
|
+
{}
|
|
135
|
+
end
|
|
136
|
+
|
|
100
137
|
def current_schema_version
|
|
101
138
|
if File.exist?(schema_file_path)
|
|
102
139
|
content = File.read(schema_file_path)
|
|
@@ -113,6 +150,8 @@ module RailsAiContext
|
|
|
113
150
|
File.join(app.root, "db", "structure.sql")
|
|
114
151
|
end
|
|
115
152
|
|
|
153
|
+
MAX_SCHEMA_FILE_SIZE = 10_000_000 # 10MB safety limit for schema files
|
|
154
|
+
|
|
116
155
|
# Fallback: parse schema file as text when DB isn't connected.
|
|
117
156
|
# Tries db/schema.rb first, then db/structure.sql.
|
|
118
157
|
# This enables introspection in CI, Claude Code, etc.
|
|
@@ -127,6 +166,7 @@ module RailsAiContext
|
|
|
127
166
|
end
|
|
128
167
|
|
|
129
168
|
def parse_schema_rb(path)
|
|
169
|
+
return { error: "schema.rb too large (#{File.size(path)} bytes)" } if File.size(path) > MAX_SCHEMA_FILE_SIZE
|
|
130
170
|
content = File.read(path)
|
|
131
171
|
tables = {}
|
|
132
172
|
current_table = nil
|
|
@@ -138,6 +178,11 @@ module RailsAiContext
|
|
|
138
178
|
tables[current_table] = { columns: [], indexes: [], foreign_keys: [] }
|
|
139
179
|
elsif current_table && (match = line.match(/t\.(\w+)\s+"(\w+)"/))
|
|
140
180
|
tables[current_table][:columns] << { name: match[2], type: match[1] }
|
|
181
|
+
elsif current_table && (match = line.match(/t\.index\s+\[([^\]]*)\]/))
|
|
182
|
+
cols = match[1].scan(/["'](\w+)["']/).flatten
|
|
183
|
+
unique = line.include?("unique: true")
|
|
184
|
+
idx_name = line.match(/name:\s*["']([^"']+)["']/)&.send(:[], 1)
|
|
185
|
+
tables[current_table][:indexes] << { name: idx_name, columns: cols, unique: unique }.compact if cols.any?
|
|
141
186
|
elsif (match = line.match(/add_index\s+"(\w+)",\s+(.+)/))
|
|
142
187
|
table_name = match[1]
|
|
143
188
|
rest = match[2]
|
|
@@ -157,6 +202,7 @@ module RailsAiContext
|
|
|
157
202
|
end
|
|
158
203
|
|
|
159
204
|
def parse_structure_sql(path) # rubocop:disable Metrics/MethodLength
|
|
205
|
+
return { error: "structure.sql too large (#{File.size(path)} bytes)" } if File.size(path) > MAX_SCHEMA_FILE_SIZE
|
|
160
206
|
content = File.read(path)
|
|
161
207
|
tables = {}
|
|
162
208
|
|
|
@@ -52,10 +52,26 @@ module RailsAiContext
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def extract_values(content)
|
|
55
|
-
|
|
56
|
-
return {} unless
|
|
55
|
+
start_match = content.match(/static\s+values\s*=\s*\{/)
|
|
56
|
+
return {} unless start_match
|
|
57
|
+
|
|
58
|
+
# Use brace-depth counting to find the matching closing brace,
|
|
59
|
+
# handling nested objects like { active: { type: String, default: "overview" } }
|
|
60
|
+
start_pos = start_match.end(0)
|
|
61
|
+
depth = 1
|
|
62
|
+
pos = start_pos
|
|
63
|
+
|
|
64
|
+
while pos < content.length && depth > 0
|
|
65
|
+
case content[pos]
|
|
66
|
+
when "{" then depth += 1
|
|
67
|
+
when "}" then depth -= 1
|
|
68
|
+
end
|
|
69
|
+
pos += 1
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
return {} if depth != 0
|
|
57
73
|
|
|
58
|
-
body =
|
|
74
|
+
body = content[start_pos...pos - 1]
|
|
59
75
|
values = {}
|
|
60
76
|
|
|
61
77
|
# Handle complex format: name: { type: Type, default: val }
|
|
@@ -66,7 +82,10 @@ module RailsAiContext
|
|
|
66
82
|
end
|
|
67
83
|
|
|
68
84
|
# Handle simple format: name: Type (single line or multi-line)
|
|
85
|
+
# Skip 'type' and 'default' — they are keywords inside complex value definitions,
|
|
86
|
+
# not actual Stimulus value names
|
|
69
87
|
body.scan(/(\w+)\s*:\s*([A-Z]\w+)/).each do |name, type|
|
|
88
|
+
next if %w[type default].include?(name)
|
|
70
89
|
values[name] ||= type
|
|
71
90
|
end
|
|
72
91
|
|