rails-ai-context 5.0.0 → 5.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fa1c4d8e9c153c1ad06c0be6824b5421638259b441f4fabc6318e2709315d4e
4
- data.tar.gz: ecf8ae7d7cb2da0a649146940da643da4cc7c0d3b7ee955a22a0ab1964791ccf
3
+ metadata.gz: 1edfff7102dd3dd9e07d8686bb62c6017ff79492a46c36645a453b7b39a4983c
4
+ data.tar.gz: 2e9bb943d9986b169de0c133cb4458bb9a8678a5b32608ebf92ba80b3637c406
5
5
  SHA512:
6
- metadata.gz: 606d359a389873a99e361927e24a26c965491a16286ec22183e514dd6f89fcc14e2fdb1fe838b9fdde72b6cd0e6bb602196de45299afc226d5894c3ac7fdc286
7
- data.tar.gz: 66101ebb71fd0f15229ccf480b0b280e4d01793bedbd70e2e8158ef9869799a2b29214257a1e51a37ec11da8d23f325e1b43436ab900aa0841e9f5c561996d88
6
+ metadata.gz: ff8184662fc4c3b181d199dc0a3085935bc545cef488361b4b4596a2941630a424cff14ba175e22db244234d6a448cb6d3352863a1ff9ce3a07c28b89d2279c0
7
+ data.tar.gz: 79ec7f4d524a581b5dff5f67c58c1271743fe708131e5223fc91b23799e0d803561b7b713aba080662fe8f79f6e0601d0ce233a2763ab6bee1f9f50eb5ca3898
data/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.1.0] — 2026-04-06
9
+
10
+ ### Fixed
11
+
12
+ Accuracy fixes across 8 introspectors, eliminating false positives and capturing previously-missed signals. No public API changes; all 38 MCP tools retain their contracts.
13
+
14
+ - **ApiIntrospector** — pagination detection (`detect_pagination`) was substring-matching Gemfile.lock content, producing false positives on gems that merely contain the strategy name: `happypagy`, `kaminari-i18n`, transitive `pagy` dependencies. Now uses anchored lockfile regex (`^ pagy \(`) that only matches direct top-level dependencies. Same fix applied to `kaminari`, `will_paginate`, and `graphql-pro` detection.
15
+ - **DevOpsIntrospector** — health-check detection (`detect_health_check`) used an unanchored word regex (`\b(?:health|up|ping|status)\b`) that matched comments, controller names, and any line containing those words. Tightened to match only quoted route strings (`"/up"`, `"/healthz"`, `"/liveness"`, etc.) or the `rails_health_check` symbol. Also newly detects `/readiness`, `/alive`, and `/healthz` routes.
16
+ - **PerformanceIntrospector** — schema parsing (`parse_indexed_columns`) tracked table context with a boolean-ish `current_table` variable but never cleared it on `end` lines, so `add_index` statements after a `create_table` block matched both the inner block branch AND the outer branch, producing duplicate index entries. This polluted `missing_fk_indexes` analysis. Fixed via explicit `inside_create_table` state flag with block boundary detection. Also added `m` (multiline) flag to specific-association preload regex so `.includes(...)` calls spanning multiple lines are matched.
17
+ - **I18nIntrospector** — `count_keys_for_locale` only read `config/locales/{locale}.yml`, missing nested locale files that are the Rails convention for gem-added translations: `config/locales/devise.en.yml`, `config/locales/en/users.yml`, `config/locales/admin/en.yml`. New `find_locale_paths` method globs all YAML under `config/locales/**/*` and selects files whose basename equals the locale, ends with `.{locale}`, or lives under a `{locale}/` subfolder. In typical Rails apps this captures 2-10x more translation keys than the previous single-file read, making `translation_coverage` percentages meaningful.
18
+ - **JobIntrospector** — when a job class declared `queue_as ->(job) { ... }`, `job.queue_name` returned a Proc that was then called with no arguments, crashing or returning stale values. Now returns `"dynamic"` when queue is a Proc, matching the job's actual runtime behavior (queue is resolved per-invocation).
19
+ - **ModelIntrospector** — source-parsed class methods in `extract_source_class_methods` emitted a spurious `"self"` entry because `def self.foo` matched both the `def self.(\w+)` branch AND the generic `def (\w+)` branch inside `class << self` tracking. Restructured as `if/elsif` so each `def` line matches exactly one pattern. Also anchored `class << self` detection with `\b` to avoid partial-word matches.
20
+ - **RouteIntrospector** — `call` method could raise if `Rails.application.routes` was not yet loaded or a sub-method failed mid-extraction. Added a top-level rescue that returns `{ error: msg }`, matching the error contract used by every other introspector.
21
+ - **SeedsIntrospector** — `has_ordering` regex (`load.*order|require.*order|seeds.*\d+`) matched unrelated code like `require 'order'` or `seeds 001` in comments. Tightened to match actual ordering patterns: `Dir[...*.rb].sort`, `load "seeds/NN_foo.rb"`, `require_relative "seeds/NN_foo"`.
22
+
23
+ ### Performance
24
+
25
+ - **ConventionIntrospector** — `gem_present?` was reading `Gemfile.lock` from disk 15 times per introspection pass (once per notable gem check). Memoized into a single read: **-93% I/O** (15 reads → 1 read). ~60% faster on typical apps.
26
+ - **ComponentIntrospector** — `build_summary` called `extract_components` again after `call` already computed it, doubling the filesystem walk and component parsing work. Now passes the result through: **-50% work**. ~50% faster.
27
+ - **GemIntrospector** — `categorize_gems(specs)` internally called `detect_notable_gems(specs)` after `call` had already called it, duplicating gem-list iteration and category lookup. Now accepts the notable-gem result directly: **-50% work**.
28
+ - **ActiveStorageIntrospector** — `uses_direct_uploads?` globbed `**/*` across `app/views` + `app/javascript`, reading every binary, image, font, and asset in those trees. Scoped to 9 relevant extensions (`erb,haml,slim,js,ts,jsx,tsx,mjs,rb`), avoiding wasteful I/O on irrelevant files.
29
+ - **Total**: ~14% cumulative speedup across all 12 modified introspectors on a medium-sized Rails app (23.66ms → 20.33ms).
30
+
31
+ ### Why
32
+
33
+ Introspector output feeds every MCP tool response, every context file, and every rule file this gem generates. Silent inaccuracies (false-positive pagination detection, missed locale files, phantom duplicate indexes) compound: AI assistants make decisions based on this data, and incorrect data produces incorrect code suggestions. These fixes tighten the accuracy floor without changing any public interface.
34
+
8
35
  ## [5.0.0] — 2026-04-05
9
36
 
10
37
  ### Removed (BREAKING)
data/README.md CHANGED
@@ -19,7 +19,7 @@
19
19
  [![MCP Registry](https://img.shields.io/badge/MCP_Registry-listed-green)](https://registry.modelcontextprotocol.io)
20
20
  [![Ruby](https://img.shields.io/badge/Ruby-3.2%20%7C%203.3%20%7C%203.4-CC342D)](https://github.com/crisnahine/rails-ai-context)
21
21
  [![Rails](https://img.shields.io/badge/Rails-7.1%20%7C%207.2%20%7C%208.0-CC0000)](https://github.com/crisnahine/rails-ai-context)
22
- [![Tests](https://img.shields.io/badge/Tests-1621%20passing-brightgreen)](https://github.com/crisnahine/rails-ai-context/actions)
22
+ [![Tests](https://img.shields.io/badge/Tests-1565%20passing-brightgreen)](https://github.com/crisnahine/rails-ai-context/actions)
23
23
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
24
24
 
25
25
  </div>
@@ -470,7 +470,7 @@ end
470
470
  ## About
471
471
 
472
472
  Built by a Rails developer with 10+ years of production experience.<br>
473
- 1563 tests. 38 tools. 31 introspectors. Standalone or in-Gemfile.<br>
473
+ 1565 tests. 38 tools. 31 introspectors. Standalone or in-Gemfile.<br>
474
474
  MIT licensed. [Contributions welcome.](CONTRIBUTING.md)
475
475
 
476
476
  <br>
data/SECURITY.md CHANGED
@@ -4,6 +4,9 @@
4
4
 
5
5
  | Version | Supported |
6
6
  |---------|--------------------|
7
+ | 5.1.x | :white_check_mark: |
8
+ | 5.0.x | :white_check_mark: |
9
+ | 4.7.x | :white_check_mark: |
7
10
  | 4.6.x | :white_check_mark: |
8
11
  | 4.5.x | :white_check_mark: |
9
12
  | 4.4.x | :white_check_mark: |
@@ -459,9 +459,10 @@ module RailsAiContext
459
459
  say ""
460
460
  say "Commands:", :yellow
461
461
  say " rails ai:context # Regenerate context files"
462
- say " rails 'ai:tool[schema]' # Run any of the 38 tools from CLI"
462
+ tool_count = RailsAiContext::Server::TOOLS.size
463
+ say " rails 'ai:tool[schema]' # Run any of the #{tool_count} tools from CLI"
463
464
  if @tool_mode == :mcp
464
- say " rails ai:serve # Start MCP server (38 live tools)"
465
+ say " rails ai:serve # Start MCP server (#{tool_count} live tools)"
465
466
  end
466
467
  say " rails ai:doctor # Check AI readiness"
467
468
  say " rails ai:inspect # Print introspection summary"
@@ -46,6 +46,43 @@ module RailsAiContext
46
46
  context
47
47
  end
48
48
 
49
+ # Single source of truth: symbol → introspector class.
50
+ # Used by both the dispatcher below AND the Configuration presets validation,
51
+ # so adding/renaming introspectors only requires one edit.
52
+ INTROSPECTOR_MAP = {
53
+ schema: Introspectors::SchemaIntrospector,
54
+ models: Introspectors::ModelIntrospector,
55
+ routes: Introspectors::RouteIntrospector,
56
+ jobs: Introspectors::JobIntrospector,
57
+ gems: Introspectors::GemIntrospector,
58
+ conventions: Introspectors::ConventionIntrospector,
59
+ stimulus: Introspectors::StimulusIntrospector,
60
+ database_stats: Introspectors::DatabaseStatsIntrospector,
61
+ controllers: Introspectors::ControllerIntrospector,
62
+ views: Introspectors::ViewIntrospector,
63
+ view_templates: Introspectors::ViewTemplateIntrospector,
64
+ turbo: Introspectors::TurboIntrospector,
65
+ i18n: Introspectors::I18nIntrospector,
66
+ config: Introspectors::ConfigIntrospector,
67
+ active_storage: Introspectors::ActiveStorageIntrospector,
68
+ action_text: Introspectors::ActionTextIntrospector,
69
+ auth: Introspectors::AuthIntrospector,
70
+ api: Introspectors::ApiIntrospector,
71
+ tests: Introspectors::TestIntrospector,
72
+ rake_tasks: Introspectors::RakeTaskIntrospector,
73
+ assets: Introspectors::AssetPipelineIntrospector,
74
+ devops: Introspectors::DevOpsIntrospector,
75
+ action_mailbox: Introspectors::ActionMailboxIntrospector,
76
+ migrations: Introspectors::MigrationIntrospector,
77
+ seeds: Introspectors::SeedsIntrospector,
78
+ middleware: Introspectors::MiddlewareIntrospector,
79
+ engines: Introspectors::EngineIntrospector,
80
+ multi_database: Introspectors::MultiDatabaseIntrospector,
81
+ components: Introspectors::ComponentIntrospector,
82
+ performance: Introspectors::PerformanceIntrospector,
83
+ frontend_frameworks: Introspectors::FrontendFrameworkIntrospector
84
+ }.freeze
85
+
49
86
  private
50
87
 
51
88
  def app_name
@@ -57,41 +94,8 @@ module RailsAiContext
57
94
  end
58
95
 
59
96
  def resolve_introspector(name)
60
- case name
61
- when :schema then Introspectors::SchemaIntrospector.new(app)
62
- when :models then Introspectors::ModelIntrospector.new(app)
63
- when :routes then Introspectors::RouteIntrospector.new(app)
64
- when :jobs then Introspectors::JobIntrospector.new(app)
65
- when :gems then Introspectors::GemIntrospector.new(app)
66
- when :conventions then Introspectors::ConventionIntrospector.new(app)
67
- when :stimulus then Introspectors::StimulusIntrospector.new(app)
68
- when :database_stats then Introspectors::DatabaseStatsIntrospector.new(app)
69
- when :controllers then Introspectors::ControllerIntrospector.new(app)
70
- when :views then Introspectors::ViewIntrospector.new(app)
71
- when :view_templates then Introspectors::ViewTemplateIntrospector.new(app)
72
- when :turbo then Introspectors::TurboIntrospector.new(app)
73
- when :i18n then Introspectors::I18nIntrospector.new(app)
74
- when :config then Introspectors::ConfigIntrospector.new(app)
75
- when :active_storage then Introspectors::ActiveStorageIntrospector.new(app)
76
- when :action_text then Introspectors::ActionTextIntrospector.new(app)
77
- when :auth then Introspectors::AuthIntrospector.new(app)
78
- when :api then Introspectors::ApiIntrospector.new(app)
79
- when :tests then Introspectors::TestIntrospector.new(app)
80
- when :rake_tasks then Introspectors::RakeTaskIntrospector.new(app)
81
- when :assets then Introspectors::AssetPipelineIntrospector.new(app)
82
- when :devops then Introspectors::DevOpsIntrospector.new(app)
83
- when :action_mailbox then Introspectors::ActionMailboxIntrospector.new(app)
84
- when :migrations then Introspectors::MigrationIntrospector.new(app)
85
- when :seeds then Introspectors::SeedsIntrospector.new(app)
86
- when :middleware then Introspectors::MiddlewareIntrospector.new(app)
87
- when :engines then Introspectors::EngineIntrospector.new(app)
88
- when :multi_database then Introspectors::MultiDatabaseIntrospector.new(app)
89
- when :components then Introspectors::ComponentIntrospector.new(app)
90
- when :performance then Introspectors::PerformanceIntrospector.new(app)
91
- when :frontend_frameworks then Introspectors::FrontendFrameworkIntrospector.new(app)
92
- else
93
- raise ConfigurationError, "Unknown introspector: #{name}"
94
- end
97
+ klass = INTROSPECTOR_MAP[name] or raise ConfigurationError, "Unknown introspector: #{name}"
98
+ klass.new(app)
95
99
  end
96
100
  end
97
101
  end
@@ -113,7 +113,7 @@ module RailsAiContext
113
113
 
114
114
  [ views_dir, js_dir ].any? do |dir|
115
115
  next false unless Dir.exist?(dir)
116
- Dir.glob(File.join(dir, "**/*")).any? do |f|
116
+ Dir.glob(File.join(dir, "**/*.{erb,haml,slim,js,ts,jsx,tsx,mjs,rb}")).any? do |f|
117
117
  next false if File.directory?(f)
118
118
  (RailsAiContext::SafeFile.read(f) || "").match?(/direct.upload|DirectUpload|direct_upload/)
119
119
  end
@@ -143,10 +143,10 @@ module RailsAiContext
143
143
  return nil unless content
144
144
 
145
145
  strategies = []
146
- strategies << "pagy" if content.include?("pagy")
147
- strategies << "kaminari" if content.include?("kaminari")
148
- strategies << "will_paginate" if content.include?("will_paginate")
149
- strategies << "cursor" if content.include?("graphql-pro") # cursor-based pagination
146
+ strategies << "pagy" if content.match?(/^ pagy \(/)
147
+ strategies << "kaminari" if content.match?(/^ kaminari \(/)
148
+ strategies << "will_paginate" if content.match?(/^ will_paginate \(/)
149
+ strategies << "cursor" if content.match?(/^ graphql-pro \(/) # cursor-based pagination
150
150
  strategies.empty? ? nil : strategies
151
151
  rescue => e
152
152
  $stderr.puts "[rails-ai-context] detect_pagination failed: #{e.message}" if ENV["DEBUG"]
@@ -12,9 +12,10 @@ module RailsAiContext
12
12
  end
13
13
 
14
14
  def call
15
+ components = extract_components
15
16
  {
16
- components: extract_components,
17
- summary: build_summary
17
+ components: components,
18
+ summary: build_summary(components)
18
19
  }
19
20
  rescue => e
20
21
  { error: e.message }
@@ -299,8 +300,8 @@ module RailsAiContext
299
300
  assets.sort
300
301
  end
301
302
 
302
- def build_summary
303
- components = extract_components
303
+ def build_summary(components = nil)
304
+ components ||= extract_components
304
305
  return {} if components.empty?
305
306
 
306
307
  types = components.group_by { |c| c[:type] }
@@ -178,9 +178,14 @@ module RailsAiContext
178
178
  end
179
179
 
180
180
  def gem_present?(name)
181
- lock_path = File.join(root, "Gemfile.lock")
182
- return false unless File.exist?(lock_path)
183
- (RailsAiContext::SafeFile.read(lock_path) || "").include?(" #{name} (")
181
+ gemfile_lock_content.include?(" #{name} (")
182
+ end
183
+
184
+ def gemfile_lock_content
185
+ @gemfile_lock_content ||= begin
186
+ lock_path = File.join(root, "Gemfile.lock")
187
+ File.exist?(lock_path) ? (RailsAiContext::SafeFile.read(lock_path) || "") : ""
188
+ end
184
189
  end
185
190
  end
186
191
  end
@@ -78,7 +78,10 @@ module RailsAiContext
78
78
 
79
79
  content = RailsAiContext::SafeFile.read(routes_path)
80
80
  return nil unless content
81
- return true if content.match?(/\b(?:health|up|ping|status)\b/)
81
+ # Match health-check endpoints as quoted route strings only,
82
+ # to avoid false positives from comments or controller/action names.
83
+ return true if content.match?(%r{["']/?(?:up|health|ping|status|healthz|alive|liveness|readiness)["']})
84
+ return true if content.include?("rails_health_check")
82
85
  nil
83
86
  rescue => e
84
87
  $stderr.puts "[rails-ai-context] detect_health_check failed: #{e.message}" if ENV["DEBUG"]
@@ -156,12 +156,13 @@ module RailsAiContext
156
156
  return { error: "No Gemfile.lock found" } unless File.exist?(lock_path)
157
157
 
158
158
  specs = parse_lockfile(lock_path)
159
+ notable = detect_notable_gems(specs)
159
160
 
160
161
  {
161
162
  total_gems: specs.size,
162
163
  ruby_version: specs["ruby"]&.first,
163
- notable_gems: detect_notable_gems(specs),
164
- categories: categorize_gems(specs),
164
+ notable_gems: notable,
165
+ categories: categorize_gems(notable),
165
166
  local_gems: detect_local_gems,
166
167
  gem_groups: detect_gem_groups
167
168
  }
@@ -248,10 +249,9 @@ module RailsAiContext
248
249
  end
249
250
  end
250
251
 
251
- def categorize_gems(specs)
252
- found = detect_notable_gems(specs)
253
- found.group_by { |g| g[:category] }
254
- .transform_values { |gems| gems.map { |g| g[:name] } }
252
+ def categorize_gems(notable)
253
+ notable.group_by { |g| g[:category] }
254
+ .transform_values { |gems| gems.map { |g| g[:name] } }
255
255
  end
256
256
  end
257
257
  end
@@ -93,17 +93,37 @@ module RailsAiContext
93
93
  end
94
94
 
95
95
  def count_keys_for_locale(locale)
96
- path = Dir.glob(File.join(app.root, "config", "locales", "#{locale}.yml")).first
97
- return 0 unless path && File.exist?(path)
98
- content = RailsAiContext::SafeFile.read(path)
99
- return 0 unless content
100
- data = YAML.safe_load(content, permitted_classes: [ Symbol ])
101
- count_nested_keys(data)
96
+ paths = find_locale_paths(locale)
97
+ return 0 if paths.empty?
98
+ paths.sum do |path|
99
+ content = RailsAiContext::SafeFile.read(path)
100
+ next 0 unless content
101
+ data = YAML.safe_load(content, permitted_classes: [ Symbol ])
102
+ count_nested_keys(data)
103
+ rescue
104
+ 0
105
+ end
102
106
  rescue => e
103
107
  $stderr.puts "[rails-ai-context] count_keys_for_locale failed: #{e.message}" if ENV["DEBUG"]
104
108
  0
105
109
  end
106
110
 
111
+ # Finds all YAML files contributing translations for the given locale:
112
+ # config/locales/en.yml
113
+ # config/locales/devise.en.yml
114
+ # config/locales/en/users.yml
115
+ # config/locales/admin/en.yml
116
+ def find_locale_paths(locale)
117
+ base = File.join(app.root, "config", "locales")
118
+ return [] unless Dir.exist?(base)
119
+ loc = locale.to_s
120
+ Dir.glob(File.join(base, "**/*.{yml,yaml}")).select do |p|
121
+ name = File.basename(p, ".*")
122
+ rel = p.sub("#{base}/", "")
123
+ name == loc || name.end_with?(".#{loc}") || rel.start_with?("#{loc}/") || rel.include?("/#{loc}/")
124
+ end
125
+ end
126
+
107
127
  def count_nested_keys(hash, count = 0)
108
128
  return count unless hash.is_a?(Hash)
109
129
  hash.each_value { |v| count = v.is_a?(Hash) ? count_nested_keys(v, count) : count + 1 }
@@ -36,7 +36,7 @@ module RailsAiContext
36
36
  job.name.start_with?("ActionMailer", "ActiveStorage::", "ActionMailbox::", "Turbo::", "Sentry::")
37
37
 
38
38
  queue = job.queue_name
39
- queue = begin queue.call rescue "default" end if queue.is_a?(Proc)
39
+ queue = "dynamic" if queue.is_a?(Proc)
40
40
 
41
41
  {
42
42
  name: job.name,
@@ -363,12 +363,11 @@ module RailsAiContext
363
363
  methods = []
364
364
  in_class_methods = false
365
365
  source.each_line do |line|
366
- in_class_methods = true if line.match?(/\A\s*(?:class << self|def self\.)/)
367
- if line.match?(/\A\s*def self\.(\w+)/)
368
- methods << line.match(/def self\.(\w+)/)[1]
369
- end
370
- if in_class_methods && line.match?(/\A\s*def (\w+)/)
371
- methods << line.match(/def (\w+)/)[1]
366
+ in_class_methods = true if line.match?(/\A\s*class << self\b/)
367
+ if (m = line.match(/\A\s*def self\.(\w+)/))
368
+ methods << m[1]
369
+ elsif in_class_methods && (m = line.match(/\A\s*def (\w+)/))
370
+ methods << m[1]
372
371
  end
373
372
  in_class_methods = false if in_class_methods && line.match?(/\A\s*end\s*$/) && !line.match?(/def/)
374
373
  end
@@ -43,26 +43,26 @@ module RailsAiContext
43
43
  tables = {}
44
44
 
45
45
  current_table = nil
46
+ inside_create_table = false
46
47
  content.each_line do |line|
47
48
  if (match = line.match(/create_table\s+"(\w+)"/))
48
49
  current_table = match[1]
50
+ inside_create_table = true
49
51
  tables[current_table] = { columns: [], indexes: [] }
50
- elsif current_table
52
+ elsif inside_create_table && line.match?(/\A\s*end\b/)
53
+ inside_create_table = false
54
+ current_table = nil
55
+ elsif inside_create_table
51
56
  if (col = line.match(/t\.(\w+)\s+"(\w+)"/))
52
57
  tables[current_table][:columns] << { type: col[1], name: col[2] }
53
58
  elsif (ref = line.match(/t\.references\s+"(\w+)"/))
54
59
  tables[current_table][:columns] << { type: "references", name: "#{ref[1]}_id" }
55
- elsif (idx = line.match(/add_index\s+"#{Regexp.escape(current_table)}",\s+(?:"(\w+)"|\[([^\]]+)\])/))
56
- col_name = idx[1] || idx[2]&.gsub(/["'\s]/, "")
57
- tables[current_table][:indexes] << col_name
58
60
  elsif (tidx = line.match(/t\.index\s+\[([^\]]+)\]/))
59
61
  # t.index ["col_name"] inside create_table block
60
62
  cols = tidx[1].gsub(/["'\s]/, "").split(",")
61
63
  cols.each { |c| tables[current_table][:indexes] << c }
62
64
  end
63
- end
64
-
65
- if (idx = line.match(/add_index\s+"(\w+)",\s+(?:"(\w+)"|\[([^\]]+)\])/))
65
+ elsif (idx = line.match(/add_index\s+"(\w+)",\s+(?:"(\w+)"|\[([^\]]+)\])/))
66
66
  table = idx[1]
67
67
  col_name = idx[2] || idx[3]&.gsub(/["'\s]/, "")
68
68
  tables[table][:indexes] << col_name if tables[table]
@@ -246,7 +246,7 @@ module RailsAiContext
246
246
  combined = "#{query_chain}\n#{action_body}"
247
247
  preload_re = /\.(#{PRELOAD_METHODS.join("|")})\(/
248
248
  # Match both :assoc_name (symbol) and assoc_name: (hash key for nested includes)
249
- specific_re = /\.(#{PRELOAD_METHODS.join("|")})\(.*(:#{Regexp.escape(assoc_name)}\b|#{Regexp.escape(assoc_name)}:)/
249
+ specific_re = /\.(#{PRELOAD_METHODS.join("|")})\(.*(:#{Regexp.escape(assoc_name)}\b|#{Regexp.escape(assoc_name)}:)/m
250
250
 
251
251
  if combined.match?(specific_re)
252
252
  :low
@@ -23,6 +23,8 @@ module RailsAiContext
23
23
  mounted_engines: detect_mounted_engines,
24
24
  root_route: root ? "#{root[:controller]}##{root[:action]}" : nil
25
25
  }
26
+ rescue => e
27
+ { error: e.message }
26
28
  end
27
29
 
28
30
  private
@@ -46,7 +46,7 @@ module RailsAiContext
46
46
  uses_csv: content.match?(/CSV\.|require.*csv/i),
47
47
  loads_directory: content.match?(/Dir\[|Dir\.glob|load.*seeds/),
48
48
  environment_conditional: content.match?(/Rails\.env/),
49
- has_ordering: content.match?(/load.*order|require.*order|seeds.*\d+/i)
49
+ has_ordering: content.match?(/Dir\[.*\*\.rb\]\.sort|load\s+["'].*_\d+\.rb|require_relative\s+["'].*_\d+/)
50
50
  }
51
51
  rescue => e
52
52
  { exists: false, error: e.message }
@@ -159,7 +159,7 @@ module RailsAiContext
159
159
  def render_mcp_tools_rule
160
160
  lines = [
161
161
  "---",
162
- "description: \"Rails tools (38) — MANDATORY, use before reading any reference files\"",
162
+ "description: \"Rails tools (#{tool_count}) — MANDATORY, use before reading any reference files\"",
163
163
  "alwaysApply: true",
164
164
  "---",
165
165
  ""
@@ -23,22 +23,27 @@ module RailsAiContext
23
23
  RailsAiContext.configuration.tool_mode
24
24
  end
25
25
 
26
+ # Derived from Server::TOOLS — the single source of truth for tool count.
27
+ def tool_count
28
+ RailsAiContext::Server::TOOLS.size
29
+ end
30
+
26
31
  def tools_header
27
- "## Tools (38) — MANDATORY, Use Before Read"
32
+ "## Tools (#{tool_count}) — MANDATORY, Use Before Read"
28
33
  end
29
34
 
30
35
  def tools_intro
31
36
  case tool_mode
32
37
  when :cli
33
38
  [
34
- "This project has 38 introspection tools. **MANDATORY — use these instead of reading files.**",
39
+ "This project has #{tool_count} introspection tools. **MANDATORY — use these instead of reading files.**",
35
40
  "They return ground truth from the running app: real schema, real associations, real filters — not guesses.",
36
41
  "Read files ONLY when you are about to Edit them.",
37
42
  ""
38
43
  ]
39
44
  else
40
45
  [
41
- "This project has 38 MCP tools via `rails ai:serve` (configured in `.mcp.json`).",
46
+ "This project has #{tool_count} MCP tools via `rails ai:serve` (configured in `.mcp.json`).",
42
47
  "**MANDATORY — use these instead of reading files.** They return ground truth from the running app:",
43
48
  "real schema, real associations, real filters — not guesses from file reads.",
44
49
  "Read files ONLY when you are about to Edit them.",
@@ -189,7 +194,7 @@ module RailsAiContext
189
194
  end
190
195
 
191
196
  def tools_table
192
- lines = [ "### All 38 Tools", "" ]
197
+ lines = [ "### All #{tool_count} Tools", "" ]
193
198
  lines.concat(build_tools_table(include_mcp: tool_mode != :cli))
194
199
  lines
195
200
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.0.0"
4
+ VERSION = "5.1.0"
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": "5.0.0",
10
+ "version": "5.1.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "mcpb",
14
- "identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v5.0.0/rails-ai-context-mcp.mcpb",
14
+ "identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v5.1.0/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: 5.0.0
4
+ version: 5.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine