rails-ai-context 4.6.0 → 4.7.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: 7a6993d2929f87b6f9c29dffd5f16124a1d3ccb15ac3f8ba8f100364bf0feb06
4
- data.tar.gz: '08c5fe8f51d126c1f7cc52ca9cc8ca0dc2edf5d1dba5f569b8ec5afaeecbb0b4'
3
+ metadata.gz: c780366dc085c17be730aded4bbe8548b0e5e71c5c810caed96104a9379363fa
4
+ data.tar.gz: f5c46207b643df1a7dc1c5563c1d9e932f772ce5e27eb1839d9f14ddde116bd0
5
5
  SHA512:
6
- metadata.gz: 94ec45e9fd089ba075c1b1c2457bfbf6fc9f2fc9c6da31b2ccafc7e51e64ca9579a390e25b56816a0c1bf4023f3ecbf092124082d53681029dcea3a5446065f5
7
- data.tar.gz: fbab834f047ab3ae3392501964b6e0de1c090af063872473df3e3b074a161536c306cf4afd51daf8e44df8f127ddbb94fd885b15f21610f0884e27159ae118e3
6
+ metadata.gz: 934c4d703203801494a8addde164c71e56119e5701dd454574b0362d2a6d68523b6f0830654ff81eaa1d36fac7eca457374960e4e9fe54731f32431fe23ea8a4
7
+ data.tar.gz: ca2bf9e27db44fb35b8d9bd984748b7fce1108a013ef7ea6ec5a01757c48187b6ce6985c529ee8bf4df7cc9abd59085da8c356b3c73052e5196b07cc9a9a3790
data/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ 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
+ ## [4.7.0] — 2026-04-05
9
+
10
+ ### Added
11
+ - **Anti-Hallucination Protocol** — 6-rule verification section embedded in every generated context file (CLAUDE.md, AGENTS.md, .claude/rules/, .cursor/rules/, .github/instructions/, copilot-instructions.md). Targets specific AI failure modes: statistical priors overriding observed facts, pattern completion beating verification, inheritance blindness, empty-output-as-permission, stale-context-lies. Rules force AI to verify column/association/route/method/gem names before writing, mark assumptions with `[ASSUMPTION]` prefix, check inheritance chains, and re-query after writes. Enabled by default via new `config.anti_hallucination_rules` option (boolean, default: `true`). Set `false` to skip.
12
+
13
+ ### Changed
14
+ - **Repositioning: ground truth, not token savings** — the gem's mission is now explicit about what it actually does: stop AI from guessing your Rails app. Token savings are a side-effect, not the product. Updated README headline, "What stops being wrong" section (replaces "Measured token savings"), gemspec summary/description, server.json MCP registry description, docs/GUIDE.md intro, and the tools guide embedded in every generated CLAUDE.md/AGENTS.md/.cursor/rules. The core pitch: AI queries your running app for real schema, real associations, real filters — and writes correct code on the first try instead of iterating through corrections.
15
+
8
16
  ## [4.6.0] — 2026-04-04
9
17
 
10
18
  ### Added
data/CLAUDE.md CHANGED
@@ -63,11 +63,12 @@ structure to AI assistants via the Model Context Protocol (MCP).
63
63
  35. **YAML config** — `.rails-ai-context.yml` as alternative to initializer. Supports all config options except `custom_tools` (Ruby classes) and `excluded_concerns` (regex). Precedence: initializer > YAML > defaults.
64
64
  36. **Config auto-loading** — `Configuration.auto_load!` checks `configured_via_block?` flag. If initializer ran, YAML is skipped. Corrupted YAML degrades gracefully with a warning.
65
65
  37. **Three install paths** — In-Gemfile (`rails generate rails_ai_context:install`), Standalone (`rails-ai-context init`), Zero config (just run `rails-ai-context serve` with defaults). Users can switch between paths freely; `.mcp.json` command is updated on re-init/re-install.
66
+ 38. **Anti-Hallucination Protocol** — 6-rule verification section embedded in every generated context file (CLAUDE.md, AGENTS.md, .claude/rules/, .cursor/rules/, .github/instructions/). Targets AI failure modes: statistical priors overriding facts, pattern completion beating verification, stale context. Toggleable via `config.anti_hallucination_rules` (default: true). Rendered by `tools_anti_hallucination_section` in `tool_guide_helper.rb`, placed between intro and detail_guidance in both full and compact render methods.
66
67
 
67
68
  ## Testing
68
69
 
69
70
  ```bash
70
- bundle exec rspec # Run specs (1621 examples)
71
+ bundle exec rspec # Run specs (1627 examples)
71
72
  bundle exec rubocop # Lint
72
73
  ```
73
74
 
data/README.md CHANGED
@@ -35,12 +35,12 @@ You've seen it. Your AI:
35
35
  - **Uses the wrong association name** — `user.posts` when it's `user.articles`
36
36
  - **Generates tests that don't match your patterns** — factories when you use fixtures, or the reverse
37
37
  - **Adds a gem you already have** — or calls an API from one you don't
38
- - **Reads 2,000 lines of schema.rb** to answer a question about one table
39
38
  - **Misses `before_action` filters from parent controllers** — then wonders why auth fails
39
+ - **Invents a method** that isn't in your codebase — then you spend 10 minutes finding out
40
40
 
41
41
  You catch it. You fix it. You re-prompt. It breaks something else.
42
42
 
43
- **That loop is the actual cost of AI coding — not the tokens, the corrections.**
43
+ **The real cost of AI coding isn't the tokens it's the correction loop.** Every guess is a round-trip: you catch it, you fix it, you re-prompt, and something adjacent breaks. This gem kills the guessing at its source.
44
44
 
45
45
  <br>
46
46
 
@@ -82,37 +82,41 @@ One call returns: definition + source code + every caller grouped by type + test
82
82
 
83
83
  <br>
84
84
 
85
- ## Measured token savings
85
+ ## What stops being wrong
86
86
 
87
- Real numbers from a production Rails app:
87
+ Real scenarios where AI goes sideways — and what it does instead with ground truth:
88
88
 
89
- | Task | Without | With | Saved |
90
- |:-----|--------:|-----:|------:|
91
- | Get one table's columns | 1,492 tokens | 335 tokens | **77%** |
92
- | Trace a method across codebase | 10,556 tokens | 256 tokens | **97%** |
93
- | Understand a model | 1,754 tokens | 588 tokens | **66%** |
94
- | Map Stimulus controllers | 9,886 tokens | 620 tokens | **94%** |
95
- | Routes for one controller | 373 tokens | 121 tokens | **68%** |
89
+ | You ask AI to... | Without — AI guesses | With AI verifies first |
90
+ |:-----|:-----|:-----|
91
+ | Add a `subscription_tier` column to users | Writes the migration, duplicates an existing column | Reads live schema, spots `subscription_status` already exists, asks before migrating |
92
+ | Call `user.posts` in a controller | Uses the guess; runtime `NoMethodError` | Resolves the actual association (`user.articles`) from the model |
93
+ | Write tests for a new model | Scaffolds with FactoryBot | Detects your fixture-based suite and matches it |
94
+ | Fix a failing create action | Misses inherited `before_action :authenticate_user!` | Returns parent-controller filters inline with the action source |
95
+ | Build a dashboard page | Invents Tailwind classes from memory | Returns your actual button/card/alert patterns, copy-paste ready |
96
+ | Trace where `can_cook?` is used | Reads 6 files sequentially, still misses callers | Single call: definition + source + every caller + tests |
96
97
 
97
98
  <details>
98
- <summary><strong>How to reproduce these numbers yourself</strong></summary>
99
+ <summary><strong>Verify it on your own app</strong></summary>
99
100
 
100
101
  <br>
101
102
 
103
+ Run these before and after installing to see what changes in *your* codebase:
104
+
102
105
  ```bash
103
- # Schema: full file vs one table
104
- wc -c db/schema.rb
105
- rails 'ai:tool[schema]' table=users | wc -c
106
+ # Schema: does AI know what columns exist?
107
+ rails 'ai:tool[schema]' table=users
106
108
 
107
- # Trace: all files AI reads vs one call
108
- rails 'ai:tool[search_code]' pattern=your_method match_type=trace | wc -c
109
+ # Trace: find every caller of a method across the codebase
110
+ rails 'ai:tool[search_code]' pattern=your_method match_type=trace
111
+
112
+ # Model: associations, scopes, callbacks, concerns — all resolved
113
+ rails 'ai:tool[model_details]' model=User
109
114
 
110
- # Model: raw file + schema vs structured output
111
- wc -c app/models/user.rb db/schema.rb
112
- rails 'ai:tool[model_details]' model=User | wc -c
115
+ # Controllers: action source + inherited filters + strong params in one shot
116
+ rails 'ai:tool[controllers]' controller=UsersController action=create
113
117
  ```
114
118
 
115
- Divide bytes by 4 for rough token count. Bigger apps save more tool output stays focused while raw files grow.
119
+ Compare what AI outputs with and without these tools wired in. The difference is measured in *corrections avoided*, not bytes saved.
116
120
 
117
121
  </details>
118
122
 
@@ -230,7 +234,7 @@ rails 'ai:tool[stimulus]' controller=chart
230
234
 
231
235
  ## 39 Tools
232
236
 
233
- Every tool is **read-only** and returns structured, token-efficient context.
237
+ Every tool is **read-only** and returns data verified against your actual app — not guesses, not training data.
234
238
 
235
239
  <details>
236
240
  <summary><strong>Search & Trace</strong></summary>
@@ -339,6 +343,28 @@ Every tool is **read-only** and returns structured, token-efficient context.
339
343
 
340
344
  <br>
341
345
 
346
+ ## Anti-Hallucination Protocol
347
+
348
+ Every generated context file ships with **6 rules that force AI verification** before writing code. The protocol targets the exact cognitive failures that produce confident-wrong code: statistical priors overriding observed facts, pattern completion beating verification, stale context lies.
349
+
350
+ <details>
351
+ <summary><strong>The 6 rules (shown to AI in every CLAUDE.md / .cursor/rules / .github/instructions)</strong></summary>
352
+
353
+ <br>
354
+
355
+ 1. **Verify before you write.** Never reference a column, association, route, helper, method, class, partial, or gem not verified in THIS project via a tool call in THIS turn. Never invent names that "sound right."
356
+ 2. **Mark every assumption.** If proceeding without verification, prefix with `[ASSUMPTION]`. Silent assumptions forbidden. "I'd need to check X first" is a preferred answer.
357
+ 3. **Training data describes average Rails. This app isn't average.** When something feels "obviously" standard Rails, query anyway. Check `rails_get_conventions` + `rails_get_gems` BEFORE scaffolding.
358
+ 4. **Check the inheritance chain before every edit.** Inherited `before_action` filters, concerns, includes, STI parents. Inheritance is never flat.
359
+ 5. **Empty tool output is information, not permission.** "0 callers found" signals investigation, not license to proceed on guesses.
360
+ 6. **Stale context lies. Re-query after writes.** Earlier tool output may be wrong after edits.
361
+
362
+ Enabled by default. Disable with `config.anti_hallucination_rules = false` if you prefer your own rules.
363
+
364
+ </details>
365
+
366
+ <br>
367
+
342
368
  ## How it works
343
369
 
344
370
  ```
@@ -419,6 +445,7 @@ end
419
445
  | `preset` | `:full` | `:full` (33 introspectors) or `:standard` (19) |
420
446
  | `context_mode` | `:compact` | `:compact` (150 lines) or `:full` |
421
447
  | `generate_root_files` | `true` | Set `false` for split rules only |
448
+ | `anti_hallucination_rules` | `true` | Embed 6-rule verification protocol in generated context files |
422
449
  | `cache_ttl` | `60` | Cache TTL in seconds |
423
450
  | `max_tool_response_chars` | `200_000` | Safety cap for tool responses |
424
451
  | `live_reload` | `:auto` | `:auto`, `true`, or `false` |
data/docs/GUIDE.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  > Full documentation for [rails-ai-context](https://github.com/crisnahine/rails-ai-context).
4
4
  > For a quick overview, see the [README](../README.md).
5
+ >
6
+ > **Why this gem exists:** AI coding assistants guess your Rails app. They invent columns,
7
+ > use wrong association names, miss inherited filters, and scaffold tests that don't match
8
+ > your patterns. This gem turns your running app into the source of truth — so agents query
9
+ > real schema, real associations, and real conventions on demand, and write correct code
10
+ > on the first try.
5
11
 
6
12
  ---
7
13
 
@@ -1290,6 +1296,7 @@ end
1290
1296
  | `server_name` | String | `"rails-ai-context"` | MCP server name |
1291
1297
  | `server_version` | String | gem version | MCP server version |
1292
1298
  | `generate_root_files` | Boolean | `true` | Generate root files (CLAUDE.md, etc.) — set `false` for split rules only |
1299
+ | `anti_hallucination_rules` | Boolean | `true` | Embed 6-rule Anti-Hallucination Protocol in generated context files — set `false` to skip |
1293
1300
  | `max_file_size` | Integer | `5_000_000` | Per-file read limit for tools (5MB) |
1294
1301
  | `max_test_file_size` | Integer | `1_000_000` | Test file read limit (1MB) |
1295
1302
  | `max_schema_file_size` | Integer | `10_000_000` | schema.rb / structure.sql parse limit (10MB) |
@@ -187,6 +187,11 @@ module RailsAiContext
187
187
  # Whether to generate root files (CLAUDE.md, AGENTS.md, etc.)
188
188
  # Set false to only generate split rules (.claude/rules/, .cursor/rules/, etc.)
189
189
  # config.generate_root_files = true
190
+
191
+ # Anti-Hallucination Protocol: 6-rule verification section embedded in every
192
+ # generated context file. Forces AI to verify facts before writing code.
193
+ # Default: true. Set false to skip the protocol entirely.
194
+ # config.anti_hallucination_rules = true
190
195
  SECTION
191
196
  "Models & Filtering" => <<~SECTION,
192
197
  # ── Models & Filtering ────────────────────────────────────────────
@@ -13,6 +13,7 @@ module RailsAiContext
13
13
  # All YAML-supported keys (explicit allowlist for safety)
14
14
  YAML_KEYS = %i[
15
15
  ai_tools tool_mode preset context_mode generate_root_files claude_max_lines
16
+ anti_hallucination_rules
16
17
  server_name cache_ttl max_tool_response_chars
17
18
  live_reload live_reload_debounce auto_mount http_path http_bind http_port
18
19
  output_dir skip_tools excluded_models excluded_controllers
@@ -133,6 +134,11 @@ module RailsAiContext
133
134
  # When false, only generates split rule files (.claude/rules/, .cursor/rules/, etc.)
134
135
  attr_accessor :generate_root_files
135
136
 
137
+ # Whether to embed the Anti-Hallucination Protocol section in generated context files.
138
+ # Default: true. Set false to skip the 6-rule verification protocol in CLAUDE.md,
139
+ # AGENTS.md, .claude/rules/, .cursor/rules/, .github/instructions/.
140
+ attr_accessor :anti_hallucination_rules
141
+
136
142
  # File size limits (bytes) — increase for larger projects
137
143
  attr_accessor :max_file_size # Per-file read limit for tools (default: 2MB)
138
144
  attr_accessor :max_test_file_size # Test file read limit (default: 500KB)
@@ -231,6 +237,7 @@ module RailsAiContext
231
237
  @live_reload = :auto
232
238
  @live_reload_debounce = 1.5
233
239
  @generate_root_files = true
240
+ @anti_hallucination_rules = true
234
241
  @max_file_size = 5_000_000
235
242
  @max_test_file_size = 1_000_000
236
243
  @max_schema_file_size = 10_000_000
@@ -77,15 +77,8 @@ module RailsAiContext
77
77
  line += " (#{assoc_count}a, #{val_count}v)" if assoc_count > 0 || val_count > 0
78
78
  line += " — #{top_assocs}" if top_assocs && !top_assocs.empty?
79
79
  lines << line
80
- scopes = (data[:scopes] || [])
81
- constants = (data[:constants] || [])
82
- if scopes.any? || constants.any?
83
- extras = []
84
- scope_names = scope_names(scopes)
85
- extras << "scopes: #{scope_names.join(', ')}" if scopes.any?
86
- constants.each { |c| extras << "#{c[:name]}: #{c[:values].join(', ')}" }
87
- lines << " #{extras.join(' | ')}"
88
- end
80
+ extras = model_extras_line(data)
81
+ lines << extras if extras
89
82
  end
90
83
  lines << "- _...#{models.size - max_show} more (use `rails_get_model_details` tool)_" if models.size > max_show
91
84
  lines << ""
@@ -108,19 +101,6 @@ module RailsAiContext
108
101
  lines
109
102
  end
110
103
 
111
- def render_conventions
112
- conv = context[:conventions]
113
- return [] unless conv.is_a?(Hash) && !conv[:error]
114
-
115
- config_files = conv[:config_files] || []
116
- return [] if config_files.empty?
117
-
118
- lines = [ "## Key config files" ]
119
- config_files.first(5).each { |f| lines << "- `#{f}`" }
120
- lines << ""
121
- lines
122
- end
123
-
124
104
  def render_commands
125
105
  test_cmd = detect_test_command
126
106
  [
@@ -107,15 +107,8 @@ module RailsAiContext
107
107
  data = models[name]
108
108
  assocs = (data[:associations] || []).size
109
109
  lines << "- #{name} (#{assocs} associations)"
110
- scopes = (data[:scopes] || [])
111
- constants = (data[:constants] || [])
112
- if scopes.any? || constants.any?
113
- extras = []
114
- scope_names = scope_names(scopes)
115
- extras << "scopes: #{scope_names.join(', ')}" if scopes.any?
116
- constants.each { |c| extras << "#{c[:name]}: #{c[:values].join(', ')}" }
117
- lines << " #{extras.join(' | ')}"
118
- end
110
+ extras = model_extras_line(data)
111
+ lines << extras if extras
119
112
  end
120
113
 
121
114
  lines << "- ...#{models.size - 30} more" if models.size > 30
@@ -139,11 +132,7 @@ module RailsAiContext
139
132
  ""
140
133
  ]
141
134
 
142
- controllers.keys.sort.first(25).each do |name|
143
- info = controllers[name]
144
- actions = info[:actions]&.size || 0
145
- lines << "- #{name} (#{actions} actions)"
146
- end
135
+ lines.concat(render_compact_controllers_list(controllers))
147
136
 
148
137
  lines.join("\n")
149
138
  end
@@ -163,16 +152,7 @@ module RailsAiContext
163
152
 
164
153
  lines.concat(render_design_system_full(context))
165
154
 
166
- # Stimulus controllers
167
- stim = context[:stimulus]
168
- if stim.is_a?(Hash) && !stim[:error]
169
- controllers = stim[:controllers] || []
170
- if controllers.any?
171
- names = controllers.map { |c| c[:name] || c[:file]&.gsub("_controller.js", "") }.compact.sort
172
- lines << "" << "## Stimulus controllers"
173
- lines << names.join(", ")
174
- end
175
- end
155
+ lines.concat(render_stimulus_section(context))
176
156
 
177
157
  lines.join("\n")
178
158
  end
@@ -120,15 +120,8 @@ module RailsAiContext
120
120
  data = models[name]
121
121
  assocs = (data[:associations] || []).size
122
122
  lines << "- #{name} (#{assocs} associations, table: #{data[:table_name] || '?'})"
123
- scopes = (data[:scopes] || [])
124
- constants = (data[:constants] || [])
125
- if scopes.any? || constants.any?
126
- extras = []
127
- scope_names = scope_names(scopes)
128
- extras << "scopes: #{scope_names.join(', ')}" if scopes.any?
129
- constants.each { |c| extras << "#{c[:name]}: #{c[:values].join(', ')}" }
130
- lines << " #{extras.join(' | ')}"
131
- end
123
+ extras = model_extras_line(data)
124
+ lines << extras if extras
132
125
  end
133
126
 
134
127
  lines << "- ...#{models.size - 30} more" if models.size > 30
@@ -156,13 +149,8 @@ module RailsAiContext
156
149
  ""
157
150
  ]
158
151
 
159
- controllers.keys.sort.first(25).each do |name|
160
- info = controllers[name]
161
- action_count = info[:actions]&.size || 0
162
- lines << "- #{name} (#{action_count} actions)"
163
- end
152
+ lines.concat(render_compact_controllers_list(controllers))
164
153
 
165
- lines << "- ...#{controllers.size - 25} more" if controllers.size > 25
166
154
  lines << ""
167
155
  lines << "Use `rails_get_controllers` MCP tool with controller:\"Name\" for full detail."
168
156
 
@@ -198,16 +186,7 @@ module RailsAiContext
198
186
  end
199
187
  rescue => e; $stderr.puts "[rails-ai-context] Serializer section skipped: #{e.message}"; end
200
188
 
201
- # Stimulus controllers
202
- stim = context[:stimulus]
203
- if stim.is_a?(Hash) && !stim[:error]
204
- controllers = stim[:controllers] || []
205
- if controllers.any?
206
- names = controllers.map { |c| c[:name] || c[:file]&.gsub("_controller.js", "") }.compact.sort
207
- lines << "" << "## Stimulus controllers"
208
- lines << names.join(", ")
209
- end
210
- end
189
+ lines.concat(render_stimulus_section(context))
211
190
 
212
191
  lines.join("\n")
213
192
  end
@@ -56,15 +56,8 @@ module RailsAiContext
56
56
  line += " — #{assocs}" unless assocs.empty?
57
57
  line += " [#{vals}v]" if vals > 0
58
58
  lines << line
59
- scopes = (data[:scopes] || [])
60
- constants = (data[:constants] || [])
61
- if scopes.any? || constants.any?
62
- extras = []
63
- scope_names = scope_names(scopes)
64
- extras << "scopes: #{scope_names.join(', ')}" if scopes.any?
65
- constants.each { |c| extras << "#{c[:name]}: #{c[:values].join(', ')}" }
66
- lines << " #{extras.join(' | ')}"
67
- end
59
+ extras = model_extras_line(data)
60
+ lines << extras if extras
68
61
  end
69
62
 
70
63
  lines << "- _...#{models.size - 30} more_" if models.size > 30
@@ -113,6 +113,43 @@ module RailsAiContext
113
113
  scopes.map { |s| s.is_a?(Hash) ? s[:name] : s }
114
114
  end
115
115
 
116
+ # Render a compact controllers listing: "- Name (N actions)" + "...X more".
117
+ # Shared by cursor_rules and copilot_instructions serializers.
118
+ def render_compact_controllers_list(controllers_hash, limit: 25)
119
+ lines = []
120
+ controllers_hash.keys.sort.first(limit).each do |name|
121
+ info = controllers_hash[name]
122
+ action_count = info[:actions]&.size || 0
123
+ lines << "- #{name} (#{action_count} actions)"
124
+ end
125
+ lines << "- ...#{controllers_hash.size - limit} more" if controllers_hash.size > limit
126
+ lines
127
+ end
128
+
129
+ # Render a Stimulus controllers section from context[:stimulus].
130
+ # Returns lines or [] if no Stimulus controllers.
131
+ def render_stimulus_section(ctx = context)
132
+ stim = ctx[:stimulus]
133
+ return [] unless stim.is_a?(Hash) && !stim[:error]
134
+ controllers = stim[:controllers] || []
135
+ return [] if controllers.empty?
136
+ names = controllers.map { |c| c[:name] || c[:file]&.gsub("_controller.js", "") }.compact.sort
137
+ [ "", "## Stimulus controllers", names.join(", ") ]
138
+ end
139
+
140
+ # Render scopes and constants as a one-line extras summary for a model entry.
141
+ # Returns " scopes: a, b | STATUS: draft, active" or nil if no extras exist.
142
+ # Shared by cursor_rules, opencode_rules, copilot_instructions, compact_serializer_helper.
143
+ def model_extras_line(data)
144
+ scopes = data[:scopes] || []
145
+ constants = data[:constants] || []
146
+ return nil unless scopes.any? || constants.any?
147
+ extras = []
148
+ extras << "scopes: #{scope_names(scopes).join(', ')}" if scopes.any?
149
+ constants.each { |c| extras << "#{c[:name]}: #{c[:values].join(', ')}" }
150
+ " #{extras.join(' | ')}"
151
+ end
152
+
116
153
  # Extract notable gems with triple-fallback for varying introspector output shapes.
117
154
  def notable_gems_list(gems_data)
118
155
  return [] unless gems_data.is_a?(Hash) && !gems_data[:error]
@@ -32,13 +32,15 @@ module RailsAiContext
32
32
  when :cli
33
33
  [
34
34
  "This project has 39 introspection tools. **MANDATORY — use these instead of reading files.**",
35
- "They return only relevant, structured data and save tokens. Read files ONLY when you are about to Edit them.",
35
+ "They return ground truth from the running app: real schema, real associations, real filters not guesses.",
36
+ "Read files ONLY when you are about to Edit them.",
36
37
  ""
37
38
  ]
38
39
  else
39
40
  [
40
41
  "This project has 39 MCP tools via `rails ai:serve` (configured in `.mcp.json`).",
41
- "**MANDATORY — use these instead of reading files.** They return structured data and save tokens.",
42
+ "**MANDATORY — use these instead of reading files.** They return ground truth from the running app:",
43
+ "real schema, real associations, real filters — not guesses from file reads.",
42
44
  "Read files ONLY when you are about to Edit them.",
43
45
  "If MCP tools are not connected, use CLI fallback: `#{cli_cmd("TOOL_NAME", "param=value")}`",
44
46
  ""
@@ -46,6 +48,26 @@ module RailsAiContext
46
48
  end
47
49
  end
48
50
 
51
+ def tools_anti_hallucination_section
52
+ return [] unless RailsAiContext.configuration.anti_hallucination_rules
53
+
54
+ [
55
+ "### Anti-Hallucination Protocol — Verify Before You Write",
56
+ "",
57
+ "AI assistants produce confident-wrong code when statistical priors from training",
58
+ "data override observed facts in the current project. These 6 rules force",
59
+ "verification at the exact moments hallucination is most likely.",
60
+ "",
61
+ "1. **Verify before you write.** Never reference a column, association, route, helper, method, class, partial, or gem you have NOT verified in THIS project via a tool call in THIS turn. If it's not verified here, verify it now. Never invent names that \"sound right.\"",
62
+ "2. **Mark every assumption.** If you must proceed without verification, prefix the relevant output with `[ASSUMPTION]` and state what you're assuming and why. Silent assumptions are forbidden. \"I'd need to check X first\" is a valid and preferred answer.",
63
+ "3. **Training data describes average Rails. This app isn't average.** When something feels \"obviously\" like standard Rails, query anyway. Factories vs fixtures? Pundit vs CanCan? Devise vs has_secure_password? Check `rails_get_conventions` and `rails_get_gems` BEFORE scaffolding anything.",
64
+ "4. **Check the inheritance chain before every edit.** Before writing a controller action: inherited `before_action` filters and ancestor classes. Before writing a model method: concerns, includes, STI parents. Inheritance is never flat.",
65
+ "5. **Empty tool output is information, not permission.** \"0 callers found,\" \"no validations,\" or a missing model is a signal to investigate or confirm with the user — not a license to proceed on guesses. Follow `_Next:` hints.",
66
+ "6. **Stale context lies. Re-query after writes.** After any edit, tool output from earlier in this turn may be wrong. Re-query the affected tool before the next write.",
67
+ ""
68
+ ]
69
+ end
70
+
49
71
  def tools_detail_guidance
50
72
  detail_param = tool_mode == :cli ? "detail=summary" : "detail:\"summary\""
51
73
  [
@@ -131,7 +153,7 @@ module RailsAiContext
131
153
  "- **Don't call tools without a target** — `get_model_details()` without `model:` returns a paginated list, not an error. Always specify what you want.",
132
154
  "- **Don't skip validation** — run `#{validate_tool}` after EVERY edit. It catches syntax errors AND Rails-specific issues (missing partials, bad column refs).",
133
155
  "- **Don't ignore cross-references** — tool responses include `_Next:` hints suggesting the best follow-up call. Follow them.",
134
- "- **Don't call `detail:\"full\"` first** — start with `summary` to find your target, then drill in. Full responses waste tokens.",
156
+ "- **Don't call `detail:\"full\"` first** — start with `summary` to find your target, then drill in. Full responses bury the signal.",
135
157
  ""
136
158
  ]
137
159
  end
@@ -166,108 +188,69 @@ module RailsAiContext
166
188
  end
167
189
  end
168
190
 
169
- def tools_table # rubocop:disable Metrics/MethodLength
191
+ def tools_table
170
192
  lines = [ "### All 39 Tools", "" ]
171
-
172
- if tool_mode == :cli
173
- lines.concat(tools_table_cli)
174
- else
175
- lines.concat(tools_table_mcp_and_cli)
176
- end
177
-
193
+ lines.concat(build_tools_table(include_mcp: tool_mode != :cli))
178
194
  lines
179
195
  end
180
196
 
181
- def tools_table_mcp_and_cli # rubocop:disable Metrics/MethodLength
182
- [
183
- "| MCP | CLI | What it does |",
184
- "|-----|-----|-------------|",
185
- "| `rails_get_context(model:\"X\")` | `#{cli_cmd("context", "model=X")}` | **START HERE** — schema + model + controller + routes + views in one call |",
186
- "| `rails_analyze_feature(feature:\"X\")` | `#{cli_cmd("analyze_feature", "feature=X")}` | Full-stack: models + controllers + routes + services + jobs + views + tests |",
187
- "| `rails_search_code(pattern:\"X\", match_type:\"trace\")` | `#{cli_cmd("search_code", "pattern=X match_type=trace")}` | Search + trace: definition, source, callers, test coverage. Also: `match_type:\"any\"` for regex search |",
188
- "| `rails_get_controllers(controller:\"X\", action:\"Y\")` | `#{cli_cmd("controllers", "controller=X action=Y")}` | Action source + inherited filters + render map + private methods |",
189
- "| `rails_validate(files:[...], level:\"rails\")` | `#{cli_cmd("validate", "files=a.rb,b.rb level=rails")}` | Syntax + semantic validation (run after EVERY edit) |",
190
- "| `rails_get_schema(table:\"X\")` | `#{cli_cmd("schema", "table=X")}` | Columns with [indexed]/[unique]/[encrypted]/[default] hints |",
191
- "| `rails_get_model_details(model:\"X\")` | `#{cli_cmd("model_details", "model=X")}` | Associations, validations, scopes, enums, macros, delegations |",
192
- "| `rails_get_routes(controller:\"X\")` | `#{cli_cmd("routes", "controller=X")}` | Routes with code-ready helpers and controller filters inline |",
193
- "| `rails_get_view(controller:\"X\")` | `#{cli_cmd("view", "controller=X")}` | Templates with ivars, Turbo wiring, Stimulus refs, partial locals |",
194
- "| `rails_get_design_system` | `#{cli_cmd("design_system")}` | Canonical HTML/ERB copy-paste patterns for buttons, inputs, cards |",
195
- "| `rails_get_stimulus(controller:\"X\")` | `#{cli_cmd("stimulus", "controller=X")}` | Targets, values, actions + HTML data-attributes + view lookup |",
196
- "| `rails_get_test_info(model:\"X\")` | `#{cli_cmd("test_info", "model=X")}` | Tests + fixture contents + test template |",
197
- "| `rails_get_concern(name:\"X\", detail:\"full\")` | `#{cli_cmd("concern", "name=X detail=full")}` | Concern methods with source + which models include it |",
198
- "| `rails_get_callbacks(model:\"X\")` | `#{cli_cmd("callbacks", "model=X")}` | Callbacks in Rails execution order with source |",
199
- "| `rails_get_edit_context(file:\"X\", near:\"Y\")` | `#{cli_cmd("edit_context", "file=X near=Y")}` | Code around a match with class/method context |",
200
- "| `rails_get_service_pattern` | `#{cli_cmd("service_pattern")}` | Service objects: interface, dependencies, side effects, callers |",
201
- "| `rails_get_job_pattern` | `#{cli_cmd("job_pattern")}` | Jobs: queue, retries, guard clauses, broadcasts, schedules |",
202
- "| `rails_get_env` | `#{cli_cmd("env")}` | Environment variables + credentials keys (not values) |",
203
- "| `rails_get_partial_interface(partial:\"X\")` | `#{cli_cmd("partial_interface", "partial=X")}` | Partial locals contract: what to pass + usage examples |",
204
- "| `rails_get_turbo_map` | `#{cli_cmd("turbo_map")}` | Turbo Stream/Frame wiring + mismatch warnings |",
205
- "| `rails_get_helper_methods` | `#{cli_cmd("helper_methods")}` | App + framework helpers with view cross-references |",
206
- "| `rails_get_config` | `#{cli_cmd("config")}` | Database adapter, auth, assets, cache, queue, Action Cable |",
207
- "| `rails_get_gems` | `#{cli_cmd("gems")}` | Notable gems with versions, categories, config file locations |",
208
- "| `rails_get_conventions` | `#{cli_cmd("conventions")}` | App patterns: auth checks, flash messages, test patterns |",
209
- "| `rails_security_scan` | `#{cli_cmd("security_scan")}` | Brakeman static analysis: SQL injection, XSS, mass assignment |",
210
- "| `rails_get_component_catalog(component:\"X\")` | `#{cli_cmd("component_catalog", "component=X")}` | ViewComponent/Phlex: props, slots, previews, usage |",
211
- "| `rails_performance_check(model:\"X\")` | `#{cli_cmd("performance_check", "model=X")}` | N+1 risks, missing indexes, Model.all anti-patterns |",
212
- "| `rails_dependency_graph(model:\"X\")` | `#{cli_cmd("dependency_graph", "model=X")}` | Model association graph as Mermaid diagram |",
213
- "| `rails_migration_advisor(action:\"X\", table:\"Y\")` | `#{cli_cmd("migration_advisor", "action=X table=Y")}` | Generate migration code, flag irreversible ops |",
214
- "| `rails_get_frontend_stack` | `#{cli_cmd("frontend_stack")}` | React/Vue/Svelte/Angular, Inertia, TypeScript, package manager |",
215
- "| `rails_search_docs(query:\"X\")` | `#{cli_cmd("search_docs", "query=X")}` | Bundled topic index with weighted keyword search, on-demand GitHub fetch |",
216
- "| `rails_query(sql:\"X\")` | `#{cli_cmd("query", "sql=X")}` | Safe read-only SQL queries with timeout, row limit, column redaction |",
217
- "| `rails_read_logs(level:\"X\")` | `#{cli_cmd("read_logs", "level=X")}` | Reverse file tail with level filtering and sensitive data redaction |",
218
- "| `rails_generate_test(model:\"X\")` | `#{cli_cmd("generate_test", "model=X")}` | Generate test scaffolding matching project patterns (framework, factories, style) |",
219
- "| `rails_diagnose(error:\"X\")` | `#{cli_cmd("diagnose", "error=\"X\"")}` | One-call error diagnosis: context + git changes + logs + fix suggestions |",
220
- "| `rails_review_changes(ref:\"main\")` | `#{cli_cmd("review_changes", "ref=main")}` | PR/commit review: file context + warnings (missing indexes, removed validations) |",
221
- "| `rails_onboard(detail:\"standard\")` | `#{cli_cmd("onboard", "detail=standard")}` | Narrative app walkthrough for new developers or AI agents |",
222
- "| `rails_runtime_info(detail:\"standard\")` | `#{cli_cmd("runtime_info", "detail=standard")}` | Live runtime: DB pool, table sizes, cache stats, job queues, pending migrations |",
223
- "| `rails_session_context(action:\"status\")` | `#{cli_cmd("session_context", "action=status")}` | Track what you've already queried, avoid redundant calls |"
224
- ]
225
- end
197
+ # Single source of truth for the tools table.
198
+ # Each row is [mcp_call, cli_name, cli_args, description].
199
+ # Set include_mcp: false for CLI-only 2-column table.
200
+ TOOL_ROWS = [
201
+ [ 'rails_get_context(model:"X")', "context", "model=X", "**START HERE** — schema + model + controller + routes + views in one call" ],
202
+ [ 'rails_analyze_feature(feature:"X")', "analyze_feature", "feature=X", "Full-stack: models + controllers + routes + services + jobs + views + tests" ],
203
+ [ 'rails_search_code(pattern:"X", match_type:"trace")', "search_code", "pattern=X match_type=trace", 'Search + trace: definition, source, callers, test coverage. Also: `match_type:"any"` for regex search' ],
204
+ [ 'rails_get_controllers(controller:"X", action:"Y")', "controllers", "controller=X action=Y", "Action source + inherited filters + render map + private methods" ],
205
+ [ 'rails_validate(files:[...], level:"rails")', "validate", "files=a.rb,b.rb level=rails", "Syntax + semantic validation (run after EVERY edit)" ],
206
+ [ 'rails_get_schema(table:"X")', "schema", "table=X", "Columns with [indexed]/[unique]/[encrypted]/[default] hints" ],
207
+ [ 'rails_get_model_details(model:"X")', "model_details", "model=X", "Associations, validations, scopes, enums, macros, delegations" ],
208
+ [ 'rails_get_routes(controller:"X")', "routes", "controller=X", "Routes with code-ready helpers and controller filters inline" ],
209
+ [ 'rails_get_view(controller:"X")', "view", "controller=X", "Templates with ivars, Turbo wiring, Stimulus refs, partial locals" ],
210
+ [ "rails_get_design_system", "design_system", nil, "Canonical HTML/ERB copy-paste patterns for buttons, inputs, cards" ],
211
+ [ 'rails_get_stimulus(controller:"X")', "stimulus", "controller=X", "Targets, values, actions + HTML data-attributes + view lookup" ],
212
+ [ 'rails_get_test_info(model:"X")', "test_info", "model=X", "Tests + fixture contents + test template" ],
213
+ [ 'rails_get_concern(name:"X", detail:"full")', "concern", "name=X detail=full", "Concern methods with source + which models include it" ],
214
+ [ 'rails_get_callbacks(model:"X")', "callbacks", "model=X", "Callbacks in Rails execution order with source" ],
215
+ [ 'rails_get_edit_context(file:"X", near:"Y")', "edit_context", "file=X near=Y", "Code around a match with class/method context" ],
216
+ [ "rails_get_service_pattern", "service_pattern", nil, "Service objects: interface, dependencies, side effects, callers" ],
217
+ [ "rails_get_job_pattern", "job_pattern", nil, "Jobs: queue, retries, guard clauses, broadcasts, schedules" ],
218
+ [ "rails_get_env", "env", nil, "Environment variables + credentials keys (not values)" ],
219
+ [ 'rails_get_partial_interface(partial:"X")', "partial_interface", "partial=X", "Partial locals contract: what to pass + usage examples" ],
220
+ [ "rails_get_turbo_map", "turbo_map", nil, "Turbo Stream/Frame wiring + mismatch warnings" ],
221
+ [ "rails_get_helper_methods", "helper_methods", nil, "App + framework helpers with view cross-references" ],
222
+ [ "rails_get_config", "config", nil, "Database adapter, auth, assets, cache, queue, Action Cable" ],
223
+ [ "rails_get_gems", "gems", nil, "Notable gems with versions, categories, config file locations" ],
224
+ [ "rails_get_conventions", "conventions", nil, "App patterns: auth checks, flash messages, test patterns" ],
225
+ [ "rails_security_scan", "security_scan", nil, "Brakeman static analysis: SQL injection, XSS, mass assignment" ],
226
+ [ 'rails_get_component_catalog(component:"X")', "component_catalog", "component=X", "ViewComponent/Phlex: props, slots, previews, usage" ],
227
+ [ 'rails_performance_check(model:"X")', "performance_check", "model=X", "N+1 risks, missing indexes, Model.all anti-patterns" ],
228
+ [ 'rails_dependency_graph(model:"X")', "dependency_graph", "model=X", "Model association graph as Mermaid diagram" ],
229
+ [ 'rails_migration_advisor(action:"X", table:"Y")', "migration_advisor", "action=X table=Y", "Generate migration code, flag irreversible ops" ],
230
+ [ "rails_get_frontend_stack", "frontend_stack", nil, "React/Vue/Svelte/Angular, Inertia, TypeScript, package manager" ],
231
+ [ 'rails_search_docs(query:"X")', "search_docs", "query=X", "Bundled topic index with weighted keyword search, on-demand GitHub fetch" ],
232
+ [ 'rails_query(sql:"X")', "query", "sql=X", "Safe read-only SQL queries with timeout, row limit, column redaction" ],
233
+ [ 'rails_read_logs(level:"X")', "read_logs", "level=X", "Reverse file tail with level filtering and sensitive data redaction" ],
234
+ [ 'rails_generate_test(model:"X")', "generate_test", "model=X", "Generate test scaffolding matching project patterns (framework, factories, style)" ],
235
+ [ 'rails_diagnose(error:"X")', "diagnose", 'error="X"', "One-call error diagnosis: context + git changes + logs + fix suggestions" ],
236
+ [ 'rails_review_changes(ref:"main")', "review_changes", "ref=main", "PR/commit review: file context + warnings (missing indexes, removed validations)" ],
237
+ [ 'rails_onboard(detail:"standard")', "onboard", "detail=standard", "Narrative app walkthrough for new developers or AI agents" ],
238
+ [ 'rails_runtime_info(detail:"standard")', "runtime_info", "detail=standard", "Live runtime: DB pool, table sizes, cache stats, job queues, pending migrations" ],
239
+ [ 'rails_session_context(action:"status")', "session_context", "action=status", "Track what you've already queried, avoid redundant calls" ]
240
+ ].freeze
226
241
 
227
- def tools_table_cli # rubocop:disable Metrics/MethodLength
228
- [
229
- "| CLI | What it does |",
230
- "|-----|-------------|",
231
- "| `#{cli_cmd("context", "model=X")}` | **START HERE** — schema + model + controller + routes + views in one call |",
232
- "| `#{cli_cmd("analyze_feature", "feature=X")}` | Full-stack: models + controllers + routes + services + jobs + views + tests |",
233
- "| `#{cli_cmd("search_code", "pattern=X match_type=trace")}` | Search + trace: definition, source, callers, test coverage. Also: `match_type=any` for regex search |",
234
- "| `#{cli_cmd("controllers", "controller=X action=Y")}` | Action source + inherited filters + render map + private methods |",
235
- "| `#{cli_cmd("validate", "files=a.rb,b.rb level=rails")}` | Syntax + semantic validation (run after EVERY edit) |",
236
- "| `#{cli_cmd("schema", "table=X")}` | Columns with [indexed]/[unique]/[encrypted]/[default] hints |",
237
- "| `#{cli_cmd("model_details", "model=X")}` | Associations, validations, scopes, enums, macros, delegations |",
238
- "| `#{cli_cmd("routes", "controller=X")}` | Routes with code-ready helpers and controller filters inline |",
239
- "| `#{cli_cmd("view", "controller=X")}` | Templates with ivars, Turbo wiring, Stimulus refs, partial locals |",
240
- "| `#{cli_cmd("design_system")}` | Canonical HTML/ERB copy-paste patterns for buttons, inputs, cards |",
241
- "| `#{cli_cmd("stimulus", "controller=X")}` | Targets, values, actions + HTML data-attributes + view lookup |",
242
- "| `#{cli_cmd("test_info", "model=X")}` | Tests + fixture contents + test template |",
243
- "| `#{cli_cmd("concern", "name=X detail=full")}` | Concern methods with source + which models include it |",
244
- "| `#{cli_cmd("callbacks", "model=X")}` | Callbacks in Rails execution order with source |",
245
- "| `#{cli_cmd("edit_context", "file=X near=Y")}` | Code around a match with class/method context |",
246
- "| `#{cli_cmd("service_pattern")}` | Service objects: interface, dependencies, side effects, callers |",
247
- "| `#{cli_cmd("job_pattern")}` | Jobs: queue, retries, guard clauses, broadcasts, schedules |",
248
- "| `#{cli_cmd("env")}` | Environment variables + credentials keys (not values) |",
249
- "| `#{cli_cmd("partial_interface", "partial=X")}` | Partial locals contract: what to pass + usage examples |",
250
- "| `#{cli_cmd("turbo_map")}` | Turbo Stream/Frame wiring + mismatch warnings |",
251
- "| `#{cli_cmd("helper_methods")}` | App + framework helpers with view cross-references |",
252
- "| `#{cli_cmd("config")}` | Database adapter, auth, assets, cache, queue, Action Cable |",
253
- "| `#{cli_cmd("gems")}` | Notable gems with versions, categories, config file locations |",
254
- "| `#{cli_cmd("conventions")}` | App patterns: auth checks, flash messages, test patterns |",
255
- "| `#{cli_cmd("security_scan")}` | Brakeman static analysis: SQL injection, XSS, mass assignment |",
256
- "| `#{cli_cmd("component_catalog", "component=X")}` | ViewComponent/Phlex: props, slots, previews, usage |",
257
- "| `#{cli_cmd("performance_check", "model=X")}` | N+1 risks, missing indexes, Model.all anti-patterns |",
258
- "| `#{cli_cmd("dependency_graph", "model=X")}` | Model association graph as Mermaid diagram |",
259
- "| `#{cli_cmd("migration_advisor", "action=X table=Y")}` | Generate migration code, flag irreversible ops |",
260
- "| `#{cli_cmd("frontend_stack")}` | React/Vue/Svelte/Angular, Inertia, TypeScript, package manager |",
261
- "| `#{cli_cmd("search_docs", "query=X")}` | Bundled topic index with weighted keyword search, on-demand GitHub fetch |",
262
- "| `#{cli_cmd("query", "sql=X")}` | Safe read-only SQL queries with timeout, row limit, column redaction |",
263
- "| `#{cli_cmd("read_logs", "level=X")}` | Reverse file tail with level filtering and sensitive data redaction |",
264
- "| `#{cli_cmd("generate_test", "model=X")}` | Generate test scaffolding matching project patterns (framework, factories, style) |",
265
- "| `#{cli_cmd("diagnose", "error=\"X\"")}` | One-call error diagnosis: context + git changes + logs + fix suggestions |",
266
- "| `#{cli_cmd("review_changes", "ref=main")}` | PR/commit review: file context + warnings (missing indexes, removed validations) |",
267
- "| `#{cli_cmd("onboard", "detail=standard")}` | Narrative app walkthrough for new developers or AI agents |",
268
- "| `#{cli_cmd("runtime_info", "detail=standard")}` | Live runtime: DB pool, table sizes, cache stats, job queues, pending migrations |",
269
- "| `#{cli_cmd("session_context", "action=status")}` | Track what you've already queried, avoid redundant calls |"
270
- ]
242
+ def build_tools_table(include_mcp:)
243
+ # For CLI-only tables, `match_type=any` uses `=` (not `:`), so we tweak description.
244
+ rows = TOOL_ROWS.map do |mcp_call, cli_name, cli_args, desc|
245
+ cli = cli_cmd(cli_name, cli_args)
246
+ if include_mcp
247
+ "| `#{mcp_call}` | `#{cli}` | #{desc} |"
248
+ else
249
+ "| `#{cli}` | #{desc.gsub('match_type:"any"', "match_type=any")} |"
250
+ end
251
+ end
252
+ header = include_mcp ? [ "| MCP | CLI | What it does |", "|-----|-----|-------------|" ] : [ "| CLI | What it does |", "|-----|-------------|" ]
253
+ header + rows
271
254
  end
272
255
 
273
256
  # Full tool guide section — used by split rules files (.claude/rules/, .cursor/rules/, etc.)
@@ -276,6 +259,7 @@ module RailsAiContext
276
259
  lines << tools_header
277
260
  lines << ""
278
261
  lines.concat(tools_intro)
262
+ lines.concat(tools_anti_hallucination_section)
279
263
  lines.concat(tools_detail_guidance)
280
264
  lines.concat(tools_power_tool_section)
281
265
  lines.concat(tools_workflow_section)
@@ -292,6 +276,7 @@ module RailsAiContext
292
276
  lines << tools_header
293
277
  lines << ""
294
278
  lines.concat(tools_intro)
279
+ lines.concat(tools_anti_hallucination_section)
295
280
  lines.concat(tools_power_tool_section)
296
281
  lines.concat(tools_workflow_section)
297
282
  lines.concat(tools_antipatterns_section)
@@ -153,6 +153,60 @@ module RailsAiContext
153
153
  SHARED_CACHE[:fingerprint] || "none"
154
154
  end
155
155
 
156
+ # Case-insensitive fuzzy key lookup for hashes keyed by class/table names.
157
+ # Tries exact, underscore, singularize, and classify variants. Returns matching key or nil.
158
+ # Shared by get_model_details, get_callbacks, get_context, generate_test, dependency_graph.
159
+ def fuzzy_find_key(keys, query)
160
+ return nil if query.nil? || keys.nil? || keys.empty?
161
+ q = query.to_s.strip
162
+ return nil if q.empty?
163
+ q_down = q.downcase
164
+ q_under = q.underscore.downcase
165
+
166
+ keys.find { |k| k.to_s.downcase == q_down } ||
167
+ keys.find { |k| k.to_s.underscore.downcase == q_under } ||
168
+ keys.find { |k| k.to_s.downcase == q.singularize.downcase } ||
169
+ keys.find { |k| k.to_s.downcase == q.classify.downcase }
170
+ end
171
+
172
+ # Extract method source from a source string via indentation-based matching.
173
+ # Returns { code:, start_line:, end_line: } or nil. Shared by get_callbacks, get_concern.
174
+ def extract_method_source_from_string(source, method_name)
175
+ source_lines = source.lines
176
+ escaped = Regexp.escape(method_name.to_s)
177
+ # ? and ! ARE word boundaries, so skip \b after them
178
+ pattern = if method_name.to_s.end_with?("?", "!")
179
+ /\A\s*def\s+#{escaped}/
180
+ else
181
+ /\A\s*def\s+#{escaped}\b/
182
+ end
183
+ start_idx = source_lines.index { |l| l.match?(pattern) }
184
+ return nil unless start_idx
185
+
186
+ def_indent = source_lines[start_idx][/\A\s*/].length
187
+ result = []
188
+ end_idx = start_idx
189
+
190
+ source_lines[start_idx..].each_with_index do |line, i|
191
+ result << line.rstrip
192
+ end_idx = start_idx + i
193
+ break if i > 0 && line.match?(/\A\s{#{def_indent}}end\b/)
194
+ end
195
+
196
+ { code: result.join("\n"), start_line: start_idx + 1, end_line: end_idx + 1 }
197
+ rescue => e
198
+ $stderr.puts "[rails-ai-context] extract_method_source_from_string failed: #{e.message}" if ENV["DEBUG"]
199
+ nil
200
+ end
201
+
202
+ # Extract method source from a file path. Reads file safely. Returns hash or nil.
203
+ def extract_method_source_from_file(path, method_name)
204
+ return nil unless File.exist?(path)
205
+ return nil if File.size(path) > RailsAiContext.configuration.max_file_size
206
+ source = RailsAiContext::SafeFile.read(path) || ""
207
+ extract_method_source_from_string(source, method_name)
208
+ end
209
+
156
210
  # Store call params for the current tool invocation (thread-safe)
157
211
  def set_call_params(**params)
158
212
  Thread.current[:rails_ai_context_call_params] = params.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
@@ -148,8 +148,7 @@ module RailsAiContext
148
148
  end
149
149
 
150
150
  def find_model_key(query, keys)
151
- keys.find { |k| k.downcase == query.downcase } ||
152
- keys.find { |k| k.underscore.downcase == query.downcase }
151
+ fuzzy_find_key(keys, query)
153
152
  end
154
153
 
155
154
  def extract_subgraph(graph, center, depth)
@@ -99,8 +99,7 @@ module RailsAiContext
99
99
 
100
100
  def generate_model_test(model_name, framework, patterns, tests_data)
101
101
  models = cached_context[:models] || {}
102
- key = models.keys.find { |k| k.downcase == model_name.downcase } ||
103
- models.keys.find { |k| k.underscore == model_name.underscore }
102
+ key = fuzzy_find_key(models.keys, model_name)
104
103
  unless key
105
104
  return not_found_response("Model", model_name, models.keys.sort,
106
105
  recovery_tool: "Call rails_get_model_details(detail:\"summary\") to see all models")
@@ -57,7 +57,7 @@ module RailsAiContext
57
57
 
58
58
  # Specific model — show callbacks in execution order
59
59
  if model
60
- key = models.keys.find { |k| k.downcase == model.downcase } || model
60
+ key = fuzzy_find_key(models.keys, model) || model
61
61
  data = models[key]
62
62
  unless data
63
63
  return not_found_response("Model", model, models.keys.sort,
@@ -122,7 +122,7 @@ module RailsAiContext
122
122
  concern_callbacks.each do |concern_name, info|
123
123
  lines << "### #{concern_name}"
124
124
  info[:callbacks].each do |cb|
125
- source = extract_method_source(info[:path], cb[:method_name])
125
+ source = extract_method_source_from_file(info[:path], cb[:method_name])
126
126
  lines << "- #{cb[:declaration]}"
127
127
  if source
128
128
  lines << "```ruby"
@@ -227,39 +227,7 @@ module RailsAiContext
227
227
 
228
228
  private_class_method def self.extract_callback_source(model_name, method_name)
229
229
  path = Rails.root.join("app", "models", "#{model_name.underscore}.rb")
230
- extract_method_source(path, method_name)
231
- end
232
-
233
- private_class_method def self.extract_method_source(path, method_name)
234
- return nil unless File.exist?(path)
235
- return nil if File.size(path) > RailsAiContext.configuration.max_file_size
236
-
237
- source_lines = (RailsAiContext::SafeFile.read(path) || "").lines
238
- method_str = method_name.to_s
239
-
240
- # Find method definition (could be public or private)
241
- start_idx = source_lines.index { |l| l.match?(/\A\s*def\s+#{Regexp.escape(method_str)}\b/) }
242
- return nil unless start_idx
243
-
244
- # Use indentation-based matching
245
- def_indent = source_lines[start_idx][/\A\s*/].length
246
- result = []
247
- end_idx = start_idx
248
-
249
- source_lines[start_idx..].each_with_index do |line, i|
250
- result << line.rstrip
251
- end_idx = start_idx + i
252
- break if i > 0 && line.match?(/\A\s{#{def_indent}}end\b/)
253
- end
254
-
255
- {
256
- code: result.join("\n"),
257
- start_line: start_idx + 1,
258
- end_line: end_idx + 1
259
- }
260
- rescue => e
261
- $stderr.puts "[rails-ai-context] extract_method_source failed: #{e.message}" if ENV["DEBUG"]
262
- nil
230
+ extract_method_source_from_file(path, method_name)
263
231
  end
264
232
 
265
233
  private_class_method def self.find_concern_callbacks(model_name, data)
@@ -130,11 +130,11 @@ module RailsAiContext
130
130
  if detail == "full"
131
131
  public_methods.each do |m|
132
132
  method_name = m.to_s.split("(").first
133
- method_source = extract_method_source(source, method_name)
133
+ method_source = extract_method_source_from_string(source, method_name)
134
134
  if method_source
135
135
  lines << "### #{m}"
136
136
  lines << "```ruby"
137
- lines << method_source
137
+ lines << method_source[:code]
138
138
  lines << "```"
139
139
  lines << ""
140
140
  else
@@ -154,11 +154,11 @@ module RailsAiContext
154
154
  class_methods.each do |m|
155
155
  method_name = m.to_s.split("(").first
156
156
  # Try both `def method_name` and `def self.method_name`
157
- method_source = extract_method_source(source, method_name) || extract_method_source(source, "self.#{method_name}")
157
+ method_source = extract_method_source_from_string(source, method_name) || extract_method_source_from_string(source, "self.#{method_name}")
158
158
  if method_source
159
159
  lines << "### #{m}"
160
160
  lines << "```ruby"
161
- lines << method_source
161
+ lines << method_source[:code]
162
162
  lines << "```"
163
163
  lines << ""
164
164
  else
@@ -388,33 +388,6 @@ module RailsAiContext
388
388
  []
389
389
  end
390
390
 
391
- # Extract method source from raw source string using indentation-based matching
392
- private_class_method def self.extract_method_source(source, method_name)
393
- source_lines = source.lines
394
- escaped = Regexp.escape(method_name.to_s)
395
- # Don't use \b after ? or ! — they ARE word boundaries
396
- pattern = if method_name.to_s.end_with?("?") || method_name.to_s.end_with?("!")
397
- /\A\s*def\s+#{escaped}/
398
- else
399
- /\A\s*def\s+#{escaped}\b/
400
- end
401
- start_idx = source_lines.index { |l| l.match?(pattern) }
402
- return nil unless start_idx
403
-
404
- def_indent = source_lines[start_idx][/\A\s*/].length
405
- result = []
406
-
407
- source_lines[start_idx..].each_with_index do |line, i|
408
- result << line.rstrip
409
- break if i > 0 && line.match?(/\A\s{#{def_indent}}end\b/)
410
- end
411
-
412
- result.join("\n")
413
- rescue => e
414
- $stderr.puts "[rails-ai-context] extract_method_source failed: #{e.message}" if ENV["DEBUG"]
415
- nil
416
- end
417
-
418
391
  private_class_method def self.find_includers(concern_name, root, concern_type)
419
392
  includers = []
420
393
  search_dirs = []
@@ -214,9 +214,7 @@ module RailsAiContext
214
214
  # Normalize: try as-is, then singularized, then classified
215
215
  ctx = cached_context
216
216
  models = ctx[:models] || {}
217
- key = models.keys.find { |k| k.downcase == model_name.downcase } ||
218
- models.keys.find { |k| k.downcase == model_name.singularize.downcase } ||
219
- models.keys.find { |k| k.downcase == model_name.classify.downcase }
217
+ key = fuzzy_find_key(models.keys, model_name)
220
218
 
221
219
  resolved_name = key || model_name
222
220
 
@@ -41,8 +41,7 @@ module RailsAiContext
41
41
  # Specific model — always full detail (strip whitespace for fuzzy input)
42
42
  if model
43
43
  model = model.strip
44
- model_under = model.underscore
45
- key = models.keys.find { |k| k.downcase == model.downcase || k.underscore == model_under } || model
44
+ key = fuzzy_find_key(models.keys, model) || model
46
45
  data = models[key]
47
46
  unless data
48
47
  return not_found_response("Model", model, models.keys.sort,
@@ -246,13 +246,13 @@ module RailsAiContext
246
246
  text_response("# #{path}\n\n```erb\n#{content}\n```")
247
247
  end
248
248
 
249
- # Strip inline SVG blocks to save tokens — they're visual noise for code understanding.
249
+ # Strip inline SVG blocks — they're visual noise that buries the signal AI needs.
250
250
  # Replaces <svg ...>...</svg> with a compact placeholder.
251
251
  private_class_method def self.strip_svg(content)
252
252
  content.gsub(/<svg\b[^>]*>.*?<\/svg>/m, "<!-- svg icon -->")
253
253
  end
254
254
 
255
- # Compress repeated long Tailwind class strings to save tokens.
255
+ # Compress repeated long Tailwind class strings so the meaningful markup stays readable.
256
256
  # Replaces duplicate class="..." with a CSS variable reference after first occurrence.
257
257
  private_class_method def self.compress_tailwind(content)
258
258
  class_counts = Hash.new(0)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "4.6.0"
4
+ VERSION = "4.7.0"
5
5
  end
data/server.json CHANGED
@@ -2,16 +2,16 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.crisnahine/rails-ai-context",
4
4
  "title": "Rails AI Context",
5
- "description": "Auto-expose Rails app structure to AI via MCP or CLI. 39 read-only tools. Standalone or in-Gemfile.",
5
+ "description": "Stop AI from guessing your Rails app. 39 read-only tools give coding agents ground truth — schema, models, routes, conventions — on demand. MCP or CLI. Standalone or in-Gemfile.",
6
6
  "repository": {
7
7
  "url": "https://github.com/crisnahine/rails-ai-context",
8
8
  "source": "github"
9
9
  },
10
- "version": "4.6.0",
10
+ "version": "4.7.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "mcpb",
14
- "identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v4.6.0/rails-ai-context-mcp.mcpb",
14
+ "identifier": "https://github.com/crisnahine/rails-ai-context/releases/download/v4.7.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: 4.6.0
4
+ version: 4.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine
@@ -154,11 +154,12 @@ dependencies:
154
154
  - !ruby/object:Gem::Version
155
155
  version: '1.4'
156
156
  description: |
157
- rails-ai-context gives AI coding agents a complete mental model of your Rails
158
- app not just files, but how schema, models, routes, controllers, views, and
159
- conventions connect. 39 live tools (via MCP server or CLI) let agents query
160
- structure on demand with semantic validation that catches cross-file errors
161
- (wrong columns, missing partials, broken routes) before code runs.
157
+ rails-ai-context turns your running Rails app into the source of truth for AI
158
+ coding assistants. Instead of guessing from training data or stale file reads,
159
+ agents query 39 live tools (via MCP server or CLI) to get your actual schema,
160
+ associations, routes, inherited filters, conventions, and test patterns.
161
+ Semantic validation catches cross-file errors (wrong columns, missing partials,
162
+ broken routes) before code runs — so AI writes correct code on the first try.
162
163
  Auto-generates context files for Claude Code, Cursor, GitHub Copilot, and
163
164
  OpenCode. Works standalone or in-Gemfile.
164
165
  email:
@@ -332,6 +333,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
332
333
  requirements: []
333
334
  rubygems_version: 3.6.9
334
335
  specification_version: 4
335
- summary: Give AI agents a complete mental model of your Rails app 39 tools via MCP
336
- or CLI. Standalone or in-Gemfile.
336
+ summary: Stop AI from guessing your Rails app. 39 tools give coding agents ground
337
+ truth — schema, models, routes, conventions — on demand. MCP or CLI.
337
338
  test_files: []