rails-ai-context 0.15.6 → 0.15.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0eb46f9da0fc24418021dbb2ca4a6850017ab555ac0bbe98b589777ef30593a2
4
- data.tar.gz: 5b77c8e0b1c7832c17ea84fffdc96524060fc594e4691f11a84b5e010158f925
3
+ metadata.gz: 39df765c4841adb33e68870093b8f58d02ff28dfe5e245ee81655902a9b0fb78
4
+ data.tar.gz: 58385dad4ad55a627d854a2611ed16f951ac0409d54d80e96966efa8b0e1613b
5
5
  SHA512:
6
- metadata.gz: 13a6f6e8e274cf47210b6cb94f160ba00e4cebe3349eb6c1e2612b4ec53561ab87d99690c9949c4eae389427bf7e80850b1c5a930283792bfa3294010303ec04
7
- data.tar.gz: 5ae9e78447e0ec33496b608729be0088bf57f180a37f27690f7eaa0b6ca0a3c1a6637887a747d3de67bec808636a139be88e2b9ba72e71db0e07faa156541182
6
+ metadata.gz: 49755cc6de2afc6a536b470abea60b5a873a9f73419754f4ad304dd8988b34ebfa65b7d69e9878e2c7bdd3418183e9a71da16007f374161e87f44259c209f5f7
7
+ data.tar.gz: 83441b234d6f429d4a066ec79dece90fd5685d5ee4a3e5df882241b21b2b79cddde40d9c0c4fa3ac79322bf26764cf694a4beaf8dc9c419c82d9bc96e37009e3
data/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ 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.7] - 2026-03-22
9
+
10
+ ### Improved
11
+
12
+ - **Hybrid filter extraction** — controller filters now use reflection for complete names (handles inheritance + skips), with source parsing from the inheritance chain for only/except constraints.
13
+ - **Callback source fallback** — when reflection returns nothing (e.g. CI), falls back to parsing callback declarations from model source files.
14
+ - **ERB validation accuracy** — in-process compilation with `<%=` → `<%` pre-processing and yield wrapper eliminates false positives from block-form helpers.
15
+ - **Schema static parser** — now extracts `null: false`, `default:`, `array: true` from schema.rb columns, and parses `add_foreign_key` declarations.
16
+ - **Array column display** — schema tool shows PostgreSQL array types as `string[]`, `integer[]`, etc.
17
+ - **Concern test lookup** — `rails_get_test_info(model:"PlanLimitable")` searches concern test paths.
18
+ - **Controller flexible matching** — underscore-based normalization handles CamelCase, snake_case, and slash notation consistently.
19
+
8
20
  ## [0.15.6] - 2026-03-22
9
21
 
10
22
  ### Added
data/docs/GUIDE.md CHANGED
@@ -404,7 +404,7 @@ Returns test infrastructure details. Optionally filter by model or controller to
404
404
 
405
405
  | Param | Type | Description |
406
406
  |-------|------|-------------|
407
- | `model` | string | Show tests for a specific model (e.g. `User`). |
407
+ | `model` | string | Show tests for a specific model (e.g. `User`). Also searches concern test paths (`spec/models/concerns/`, `test/models/concerns/`). |
408
408
  | `controller` | string | Show tests for a specific controller (e.g. `Cooks`). |
409
409
  | `detail` | string | `summary` / `standard` (default) / `full`. |
410
410
 
@@ -782,6 +782,12 @@ RailsAiContext.configure do |config|
782
782
  # schema.rb / structure.sql parse limit (default: 10MB)
783
783
  # config.max_schema_file_size = 10_000_000
784
784
 
785
+ # Total aggregated view content for UI patterns (default: 5MB)
786
+ # config.max_view_total_size = 5_000_000
787
+
788
+ # Per-view file during aggregation (default: 500KB)
789
+ # config.max_view_file_size = 500_000
790
+
785
791
  # Max search results per call (default: 100)
786
792
  # config.max_search_results = 100
787
793
 
@@ -888,7 +894,7 @@ These run by default. Fast and cover core Rails structure.
888
894
  | `controllers` | Actions, filters (before/after/around with only/except), strong params methods, parent class, API controller detection, concerns. |
889
895
  | `tests` | Test framework (rspec/minitest), factories/fixtures with locations and counts, system tests, CI config files, coverage tool, test helpers, VCR cassettes. |
890
896
  | `migrations` | Total count, schema version, pending migrations, recent migration history with detected actions (create_table, add_column, etc.), migration statistics. |
891
- | `config` | Cache store, session store, timezone, middleware stack, initializers, credentials keys, CurrentAttributes classes. |
897
+ | `config` | Cache store, session store, timezone, queue adapter, mailer settings, middleware stack, initializers, credentials status, CurrentAttributes classes. |
892
898
  | `stimulus` | Stimulus controllers with targets, values (with types), actions, outlets, classes. Extracted from JS/TS files. |
893
899
  | `view_templates` | View file contents, partial references, Stimulus data attributes, UI pattern extraction, model field usage in partials. |
894
900
  | `design_tokens` | Auto-detects CSS framework (Tailwind v3/v4, Bootstrap, Sass, plain CSS) and extracts design tokens from config files and built CSS. |
@@ -3,7 +3,7 @@
3
3
  module RailsAiContext
4
4
  module Introspectors
5
5
  # Extracts application configuration: cache store, session store,
6
- # timezone, middleware stack, initializers, credentials keys.
6
+ # timezone, middleware stack, initializers, credentials status.
7
7
  class ConfigIntrospector
8
8
  attr_reader :app
9
9
 
@@ -150,30 +150,63 @@ module RailsAiContext
150
150
  actions.sort
151
151
  end
152
152
 
153
- # Prefer source-based parsing for filters always reflects current file state.
154
- # Falls back to reflection for controllers without readable source files.
153
+ # Hybrid approach: reflection for complete filter names (handles inheritance + skips),
154
+ # source parsing from inheritance chain for only/except constraints.
155
155
  def extract_filters(ctrl, source = nil)
156
+ if ctrl.respond_to?(:_process_action_callbacks)
157
+ reflection_filters = ctrl._process_action_callbacks.filter_map do |cb|
158
+ next if cb.filter.is_a?(Proc) || cb.filter.to_s.start_with?("_")
159
+ next if excluded_filters.include?(cb.filter.to_s)
160
+ { name: cb.filter.to_s, kind: cb.kind.to_s }
161
+ end
162
+
163
+ if reflection_filters.any?
164
+ # Collect only/except constraints from source files in the inheritance chain
165
+ source_constraints = collect_source_constraints(ctrl, source)
166
+ reflection_filters.each do |f|
167
+ if (sc = source_constraints[f[:name]])
168
+ f[:only] = sc[:only] if sc[:only]&.any?
169
+ f[:except] = sc[:except] if sc[:except]&.any?
170
+ end
171
+ end
172
+ return reflection_filters
173
+ end
174
+ end
175
+
176
+ # Fallback to source parsing when reflection is unavailable
156
177
  if source
157
178
  filters = extract_filters_from_source(source)
158
179
  return filters if filters.any?
159
180
  end
160
- return [] unless ctrl.respond_to?(:_process_action_callbacks)
161
-
162
- ctrl._process_action_callbacks.filter_map do |cb|
163
- next if cb.filter.is_a?(Proc) || cb.filter.to_s.start_with?("_")
164
- next if excluded_filters.include?(cb.filter.to_s)
165
-
166
- filter = { name: cb.filter.to_s, kind: cb.kind.to_s }
167
- filter[:only] = cb.instance_variable_get(:@if)&.filter_map { |c| extract_action_condition(c) }&.flatten
168
- filter[:except] = cb.instance_variable_get(:@unless)&.filter_map { |c| extract_action_condition(c) }&.flatten
169
- filter.delete(:only) if filter[:only]&.empty?
170
- filter.delete(:except) if filter[:except]&.empty?
171
- filter
172
- end
181
+
182
+ []
173
183
  rescue
174
184
  []
175
185
  end
176
186
 
187
+ # Walk up the controller inheritance chain and collect filter constraints from source files
188
+ def collect_source_constraints(ctrl, current_source = nil)
189
+ constraints = {}
190
+ klass = ctrl
191
+ while klass && klass.name
192
+ break if klass.name.start_with?("ActionController::", "AbstractController::")
193
+ break if klass == ActionController::Base
194
+ break if defined?(ActionController::API) && klass == ActionController::API
195
+
196
+ src = (klass == ctrl) ? (current_source || read_source(klass)) : read_source(klass)
197
+ if src
198
+ extract_filters_from_source(src).each do |sf|
199
+ # First definition wins (most specific controller in chain)
200
+ constraints[sf[:name]] ||= sf
201
+ end
202
+ end
203
+ klass = klass.superclass
204
+ end
205
+ constraints
206
+ rescue
207
+ {}
208
+ end
209
+
177
210
  def extract_filters_from_source(source)
178
211
  filters = []
179
212
  source.each_line do |line|
@@ -145,7 +145,7 @@ module RailsAiContext
145
145
  after_commit after_rollback
146
146
  ]
147
147
 
148
- callback_types.each_with_object({}) do |type, hash|
148
+ result = callback_types.each_with_object({}) do |type, hash|
149
149
  callbacks = model.send(:"_#{type}_callbacks").reject do |cb|
150
150
  cb.filter.to_s.start_with?(*EXCLUDED_CALLBACKS) || cb.filter.is_a?(Proc)
151
151
  end
@@ -154,6 +154,29 @@ module RailsAiContext
154
154
 
155
155
  hash[type.to_s] = callbacks.map { |cb| cb.filter.to_s }
156
156
  end
157
+
158
+ # If reflection returned nothing, fall back to source parsing
159
+ return result if result.any?
160
+ extract_callbacks_from_source(model)
161
+ rescue
162
+ extract_callbacks_from_source(model)
163
+ end
164
+
165
+ # Parse callback declarations from model source file
166
+ def extract_callbacks_from_source(model)
167
+ source_path = model_source_path(model)
168
+ return {} unless source_path && File.exist?(source_path)
169
+
170
+ source = File.read(source_path)
171
+ callbacks = {}
172
+ source.each_line do |line|
173
+ if (match = line.match(/\A\s*(before_validation|after_validation|before_save|after_save|before_create|after_create|before_update|after_update|before_destroy|after_destroy|after_commit|after_rollback)\s+:(\w+)/))
174
+ type = match[1]
175
+ method_name = match[2]
176
+ (callbacks[type] ||= []) << method_name
177
+ end
178
+ end
179
+ callbacks
157
180
  rescue
158
181
  {}
159
182
  end
@@ -168,13 +191,18 @@ module RailsAiContext
168
191
 
169
192
  def framework_concern?(name)
170
193
  return true if name.nil?
171
- return true if %w[Kernel JSON PP Marshal MessagePack].include?(name)
194
+ return true if %w[Kernel JSON PP Marshal MessagePack].any? { |prefix| name == prefix || name.start_with?("#{prefix}::") }
195
+ return true if name.start_with?("ActiveModel::", "ActiveRecord::", "ActiveSupport::")
172
196
  RailsAiContext.configuration.excluded_concerns.any? { |pattern| name.match?(pattern) }
173
197
  end
174
198
 
175
199
  def extract_public_class_methods(model)
200
+ scope_names = extract_scopes(model).map(&:to_s)
176
201
  (model.methods - ActiveRecord::Base.methods - Object.methods)
177
- .reject { |m| m.to_s.start_with?("_", "autosave") }
202
+ .reject { |m|
203
+ ms = m.to_s
204
+ ms.start_with?("_", "autosave") || scope_names.include?(ms)
205
+ }
178
206
  .sort
179
207
  .first(30) # Cap to avoid noise
180
208
  .map(&:to_s)
@@ -179,7 +179,14 @@ module RailsAiContext
179
179
  next if current_table.start_with?("ar_internal_metadata", "schema_migrations")
180
180
  tables[current_table] = { columns: [], indexes: [], foreign_keys: [] }
181
181
  elsif current_table && (match = line.match(/t\.(\w+)\s+"(\w+)"/))
182
- tables[current_table][:columns] << { name: match[2], type: match[1] }
182
+ col = { name: match[2], type: match[1] }
183
+ col[:null] = false if line.include?("null: false")
184
+ if (default_match = line.match(/default:\s*("[^"]*"|\{[^}]*\}|\[[^\]]*\]|-?\d+(?:\.\d+)?|true|false)/))
185
+ raw = default_match[1]
186
+ col[:default] = raw.start_with?('"') ? raw[1..-2] : raw
187
+ end
188
+ col[:array] = true if line.include?("array: true")
189
+ tables[current_table][:columns] << col
183
190
  elsif current_table && (match = line.match(/t\.index\s+\[([^\]]*)\]/))
184
191
  cols = match[1].scan(/["'](\w+)["']/).flatten
185
192
  unique = line.include?("unique: true")
@@ -199,6 +206,17 @@ module RailsAiContext
199
206
  unique = rest.include?("unique: true")
200
207
  idx_name = rest.match(/name:\s*"(\w+)"/)&.send(:[], 1)
201
208
  tables[table_name]&.dig(:indexes)&.push({ name: idx_name, columns: cols, unique: unique }.compact) if cols.any?
209
+ elsif (match = line.match(/add_foreign_key\s+"(\w+)",\s+"(\w+)"/))
210
+ from_table = match[1]
211
+ to_table = match[2]
212
+ column_match = line.match(/column:\s*"(\w+)"/)
213
+ column = column_match ? column_match[1] : "#{to_table.singularize}_id"
214
+ pk_match = line.match(/primary_key:\s*"(\w+)"/)
215
+ primary_key = pk_match ? pk_match[1] : "id"
216
+ tables[from_table]&.dig(:foreign_keys)&.push({
217
+ from_table: from_table, to_table: to_table,
218
+ column: column, primary_key: primary_key
219
+ })
202
220
  end
203
221
  end
204
222
 
@@ -155,7 +155,7 @@ module RailsAiContext
155
155
 
156
156
  def detect_test_files
157
157
  categories = {}
158
- %w[models controllers requests system services integration features].each do |cat|
158
+ %w[models models/concerns controllers requests system services integration features].each do |cat|
159
159
  %w[spec test].each do |base|
160
160
  dir = File.join(root, base, cat)
161
161
  next unless Dir.exist?(dir)
@@ -49,12 +49,14 @@ module RailsAiContext
49
49
  # Flexible matching: "cooks", "CooksController", "cookscontroller" all work
50
50
  if controller
51
51
  # Accept multiple formats: "CooksController", "cooks", "bonus/crises", "Bonus::CrisesController"
52
- normalized = controller.downcase.delete_suffix("controller").tr("/", "::")
52
+ # Use underscore for CamelCase→snake_case: "OmniauthCallbacks" "omniauth_callbacks"
53
+ # Also match on plain downcase to handle "userscontroller" → "users"
54
+ input_snake = controller.gsub("/", "::").underscore.delete_suffix("_controller")
55
+ input_down = controller.downcase.delete_suffix("controller").tr("/", "::")
53
56
  key = controllers.keys.find { |k|
54
- kd = k.downcase
55
- kd == controller.downcase ||
56
- kd.delete_suffix("controller") == normalized ||
57
- kd.delete_suffix("controller").tr("::", "/") == controller.downcase.delete_suffix("controller")
57
+ key_snake = k.underscore.delete_suffix("_controller")
58
+ key_down = k.downcase.delete_suffix("controller")
59
+ key_snake == input_snake || key_down == input_down
58
60
  } || controller
59
61
  info = controllers[key]
60
62
  unless info
@@ -129,12 +129,17 @@ module RailsAiContext
129
129
  end
130
130
  end
131
131
 
132
- # Validations — compress repeated inclusion lists
132
+ # Validations — compress repeated inclusion lists, deduplicate same kind+attribute
133
133
  if data[:validations]&.any?
134
134
  lines << "" << "## Validations"
135
+ # Deduplicate validations with same kind and attributes (e.g. implicit belongs_to + explicit validates :user, presence)
136
+ seen_validations = Set.new
135
137
  # Track seen inclusion arrays to avoid repeating long lists
136
138
  seen_inclusions = {}
137
139
  data[:validations].each do |v|
140
+ dedup_key = "#{v[:kind]}:#{v[:attributes].sort.join(',')}"
141
+ next if seen_validations.include?(dedup_key)
142
+ seen_validations << dedup_key
138
143
  attrs = v[:attributes].join(", ")
139
144
  if v[:options]&.any?
140
145
  compressed_opts = v[:options].map do |k, val|
@@ -136,7 +136,8 @@ module RailsAiContext
136
136
 
137
137
  columns.each do |col|
138
138
  nullable = col.key?(:null) ? (col[:null] ? "yes" : "**NO**") : "yes"
139
- line = "| #{col[:name]} | #{col[:type]} | #{nullable}"
139
+ col_type = col[:array] ? "#{col[:type]}[]" : col[:type].to_s
140
+ line = "| #{col[:name]} | #{col_type} | #{nullable}"
140
141
  line += " | #{col[:default]}" if has_defaults
141
142
  lines << "#{line} |"
142
143
  end
@@ -130,7 +130,9 @@ module RailsAiContext
130
130
  when :model
131
131
  [
132
132
  "spec/models/#{snake}_spec.rb",
133
- "test/models/#{snake}_test.rb"
133
+ "test/models/#{snake}_test.rb",
134
+ "spec/models/concerns/#{snake}_spec.rb",
135
+ "test/models/concerns/#{snake}_test.rb"
134
136
  ]
135
137
  when :controller
136
138
  [
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "erb"
4
5
 
5
6
  module RailsAiContext
6
7
  module Tools
@@ -106,28 +107,44 @@ module RailsAiContext
106
107
  end
107
108
 
108
109
  # Validate ERB by compiling to Ruby source then syntax-checking the result.
109
- # ERB.new(...).src only validates ERB tag syntax it does NOT catch missing <% end %>.
110
- # So we compile to Ruby, then run ruby -c on the compiled output to catch block mismatches.
110
+ # Catches missing <% end %>, unclosed blocks, and mismatched do/end.
111
+ #
112
+ # Two key pre-processing steps avoid false positives:
113
+ # 1. Convert `<%= ... %>` to `<% ... %>` — prevents the `_buf << ( helper do ).to_s`
114
+ # ambiguity that standard ERB compilation creates for block-form helpers
115
+ # like `<%= link_to ... do %>`, `<%= form_with ... do |f| %>`, etc.
116
+ # 2. Wrap compiled source in a method def — makes `yield` syntactically valid.
111
117
  private_class_method def self.validate_erb(full_path)
112
- # Step 1: Compile ERB to Ruby source
113
- compile_script = "require 'erb'; print ERB.new(File.read(ARGV[0])).src"
114
- compiled, compile_status = Open3.capture2e("ruby", "-e", compile_script, full_path.to_s)
118
+ return [ false, "file too large" ] if File.size(full_path) > RailsAiContext.configuration.max_file_size
115
119
 
116
- unless compile_status.success?
117
- error = compiled.lines.reject { |l| l.strip.empty? }.first&.strip || "ERB syntax error"
118
- return [ false, error ]
119
- end
120
+ content = File.binread(full_path).force_encoding("UTF-8")
121
+
122
+ # Pre-process: convert output tags to non-output for syntax-only checking.
123
+ # This is safe because we only check structure (do/end, if/end matching),
124
+ # not whether output is correct.
125
+ processed = content.gsub("<%=", "<%")
126
+
127
+ # Compile ERB to Ruby, wrapped in a method so `yield` is valid syntax.
128
+ # Force UTF-8 on .src output — ERB may return ASCII-8BIT which breaks
129
+ # concatenation with UTF-8 strings when non-ASCII bytes (emoji, etc.) are present.
130
+ erb_src = +ERB.new(processed).src
131
+ erb_src.force_encoding("UTF-8")
132
+ compiled = "# encoding: utf-8\ndef __erb_syntax_check\n#{erb_src}\nend"
120
133
 
121
- # Step 2: Syntax-check the compiled Ruby to catch missing end, unclosed blocks, etc.
122
134
  check_result, check_status = Open3.capture2e("ruby", "-c", "-", stdin_data: compiled)
123
135
  if check_status.success?
124
136
  [ true, nil ]
125
137
  else
126
- error = check_result.lines.reject { |l| l.strip.empty? || l.include?("Syntax OK") }.first&.strip || "ERB error"
127
- # Make the error more helpful — it's from compiled source, translate back
128
- error = error.sub(/^-:/, "compiled ERB line ")
129
- [ false, error ]
138
+ # Adjust line numbers: subtract 1 for the wrapper def line
139
+ error = check_result.lines
140
+ .reject { |l| l.strip.empty? || l.include?("Syntax OK") }
141
+ .first(5)
142
+ .map { |l| l.strip.sub(/-:(\d+):/) { "ruby: -:#{$1.to_i - 1}:" } }
143
+ msg = error.any? ? error.join("\n") : "ERB syntax error"
144
+ [ false, msg ]
130
145
  end
146
+ rescue => e
147
+ [ false, "ERB check error: #{e.message}" ]
131
148
  end
132
149
 
133
150
  # Validate JavaScript syntax via `node -c` (no shell — uses Open3 array form)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "0.15.6"
4
+ VERSION = "0.15.7"
5
5
  end
data/server.json CHANGED
@@ -7,11 +7,11 @@
7
7
  "url": "https://github.com/crisnahine/rails-ai-context",
8
8
  "source": "github"
9
9
  },
10
- "version": "0.15.5",
10
+ "version": "0.15.6",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "mcpb",
14
- "identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v0.15.5/rails-ai-context-mcp.mcpb",
14
+ "identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v0.15.6/rails-ai-context-mcp.mcpb",
15
15
  "fileSha256": "dd711a0ad6c4de943ae4da94eaf59a6dc9494b9d57f726e24649ed4e2f156990",
16
16
  "transport": {
17
17
  "type": "stdio"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.6
4
+ version: 0.15.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine