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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +6 -1
  4. data/lib/rails_ai_context/configuration.rb +7 -0
  5. data/lib/rails_ai_context/introspectors/config_introspector.rb +43 -6
  6. data/lib/rails_ai_context/introspectors/controller_introspector.rb +138 -5
  7. data/lib/rails_ai_context/introspectors/model_introspector.rb +44 -3
  8. data/lib/rails_ai_context/introspectors/schema_introspector.rb +48 -2
  9. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +22 -3
  10. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +14 -4
  11. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +22 -4
  12. data/lib/rails_ai_context/serializers/claude_serializer.rb +34 -7
  13. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -2
  14. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +3 -2
  15. data/lib/rails_ai_context/serializers/markdown_serializer.rb +5 -2
  16. data/lib/rails_ai_context/serializers/opencode_serializer.rb +33 -7
  17. data/lib/rails_ai_context/serializers/windsurf_serializer.rb +3 -2
  18. data/lib/rails_ai_context/tools/get_config.rb +40 -4
  19. data/lib/rails_ai_context/tools/get_controllers.rb +72 -24
  20. data/lib/rails_ai_context/tools/get_conventions.rb +10 -3
  21. data/lib/rails_ai_context/tools/get_edit_context.rb +36 -8
  22. data/lib/rails_ai_context/tools/get_gems.rb +2 -4
  23. data/lib/rails_ai_context/tools/get_model_details.rb +39 -10
  24. data/lib/rails_ai_context/tools/get_routes.rb +111 -16
  25. data/lib/rails_ai_context/tools/get_schema.rb +25 -5
  26. data/lib/rails_ai_context/tools/get_stimulus.rb +19 -4
  27. data/lib/rails_ai_context/tools/get_test_info.rb +16 -3
  28. data/lib/rails_ai_context/tools/get_view.rb +82 -25
  29. data/lib/rails_ai_context/tools/search_code.rb +26 -2
  30. data/lib/rails_ai_context/version.rb +1 -1
  31. metadata +1 -3
  32. data/demo.tape +0 -16
  33. data/demo_script.sh +0 -93
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f5b745a3b7eec70c71340022f60a7fadacd80cae84f5d4586936b78633677f0e
4
- data.tar.gz: 5619c0302827cb96ef33175264e83f36fa2f055925e6517316b379d24599fff7
3
+ metadata.gz: 840dab1aeabc3323d5aca5702d95e712f7a013f24d05327424674262eee46de9
4
+ data.tar.gz: 2eed45a1bc5e79b9806f691c9e88ca2ced55660d53b74f56d8f354963c9ee497
5
5
  SHA512:
6
- metadata.gz: 810c890604f5d4b78d56e4580254279b3b741d15fd0dbe2419fbf661d2ee47dd70b678cf962aa65244e025e70633dcc5cceee30319c27edf60c04d2a6127ce39
7
- data.tar.gz: 4f6ed5c100bcf1907b5c38ac9043669ab4c4d833a30c65613d25f0a125de1909f054da48ce3b3fd87f427e1f5e7ff6f8fb422cd812016ed57b85218d592c5f52
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
- 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)
@@ -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
- }.compact
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
- match = content.match(/static\s+values\s*=\s*\{(.*?)\}/m)
56
- return {} unless match
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 = match[1]
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