rails-ai-context 5.1.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/CONTRIBUTING.md +13 -0
  4. data/README.md +13 -11
  5. data/docs/GUIDE.md +109 -97
  6. data/lib/generators/rails_ai_context/install/install_generator.rb +60 -4
  7. data/lib/rails_ai_context/ast_cache.rb +79 -0
  8. data/lib/rails_ai_context/confidence.rb +45 -0
  9. data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +27 -0
  10. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +106 -0
  11. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +69 -0
  12. data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +100 -0
  13. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +124 -0
  14. data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +134 -0
  15. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -0
  16. data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +87 -0
  17. data/lib/rails_ai_context/introspectors/model_introspector.rb +246 -339
  18. data/lib/rails_ai_context/introspectors/source_introspector.rb +72 -0
  19. data/lib/rails_ai_context/tools/base_tool.rb +1 -0
  20. data/lib/rails_ai_context/tools/validate.rb +9 -37
  21. data/lib/rails_ai_context/version.rb +1 -1
  22. data/server.json +3 -3
  23. metadata +44 -7
  24. data/CLAUDE.md +0 -85
  25. data/docs/token-comparison.jpeg +0 -0
  26. /data/{demo-trace.gif → demo/demo-trace.gif} +0 -0
  27. /data/{demo-trace.tape → demo/demo-trace.tape} +0 -0
  28. /data/{demo.gif → demo/demo.gif} +0 -0
  29. /data/{demo.tape → demo/demo.tape} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1edfff7102dd3dd9e07d8686bb62c6017ff79492a46c36645a453b7b39a4983c
4
- data.tar.gz: 2e9bb943d9986b169de0c133cb4458bb9a8678a5b32608ebf92ba80b3637c406
3
+ metadata.gz: a1dd01df89a380a86e14436ae9275c5db3f88b0e4fc56ea6df7d308f2c468472
4
+ data.tar.gz: 6f2efe8319fa533b8ea19cc0ace32460b24cb9b96873af0956c4b126041cd14b
5
5
  SHA512:
6
- metadata.gz: ff8184662fc4c3b181d199dc0a3085935bc545cef488361b4b4596a2941630a424cff14ba175e22db244234d6a448cb6d3352863a1ff9ce3a07c28b89d2279c0
7
- data.tar.gz: 79ec7f4d524a581b5dff5f67c58c1271743fe708131e5223fc91b23799e0d803561b7b713aba080662fe8f79f6e0601d0ce233a2763ab6bee1f9f50eb5ca3898
6
+ metadata.gz: 1447735cb99e717f1fa43a1fa54cc89fe1d6e0a836fcf156a7a66cede53c321328fbe4088a0c72103d91fe9c49de88f537cb59cddcbe1782773416892f76c728
7
+ data.tar.gz: 57b9e93748454c414563d453661eb4b51f94a063f15ac12a7073d6695b3cbc1e7fb1db323be3972ac1f830c1b21d73fcc65dc8fc1d5f170c8787cd9c8031881d
data/CHANGELOG.md CHANGED
@@ -5,6 +5,44 @@ 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.2.0] — 2026-04-07
9
+
10
+ ### Added — Phase 1: Prism AST Foundation (Ground Truth Engine Blueprint #36)
11
+
12
+ System-wide AST migration replacing all regex-based Ruby source parsing with Prism AST visitors. This is the foundation layer for the Ground Truth Engine transformation (#37).
13
+
14
+ - **AstCache** (`lib/rails_ai_context/ast_cache.rb`) — Thread-safe Prism parse cache backed by `Concurrent::Map`. Keyed by path + SHA256 content hash + mtime. Invalidates automatically on file change. Shared by all AST-based introspectors.
15
+
16
+ - **VERIFIED/INFERRED confidence contract** — `Confidence.for_node(node)` determines whether an AST node's arguments are all static literals (`[VERIFIED]`) or contain dynamic expressions (`[INFERRED]`). Called from listeners via `BaseListener#confidence_for(node)`. Every source-level introspection result now carries a confidence tag.
17
+
18
+ - **7 Prism Listener classes** (`lib/rails_ai_context/introspectors/listeners/`):
19
+ - `AssociationsListener` — `belongs_to`, `has_many`, `has_one`, `has_and_belongs_to_many`
20
+ - `ValidationsListener` — `validates`, `validates_*_of`, custom `validate :method`
21
+ - `ScopesListener` — `scope :name, -> { ... }`
22
+ - `EnumsListener` — Rails 7+ and legacy enum syntax with prefix/suffix options
23
+ - `CallbacksListener` — all AR callback types including `after_commit` with `on:` resolution
24
+ - `MacrosListener` — `encrypts`, `normalizes`, `delegate`, `has_secure_password`, `serialize`, `store`, `has_one_attached`, `has_many_attached`, `has_rich_text`, `generates_token_for`, `attribute` API
25
+ - `MethodsListener` — `def`/`def self.` with visibility tracking, parameter extraction, `class << self` support
26
+
27
+ - **SourceIntrospector** (`lib/rails_ai_context/introspectors/source_introspector.rb`) — Single-pass Prism Dispatcher that walks the AST once and feeds events to all 7 listeners simultaneously. Available as `SourceIntrospector.call(path)` for file-based introspection or `SourceIntrospector.from_source(string)` for in-memory parsing.
28
+
29
+ - **73 new specs** covering AstCache, SourceIntrospector integration, and all 7 listener classes with edge cases (multi-line associations, legacy enums, visibility tracking, parameter extraction).
30
+
31
+ ### Changed
32
+
33
+ - **ModelIntrospector** rewritten to use AST-based source parsing via `SourceIntrospector` instead of regex. Reflection-based extraction (associations via AR, validations via AR, enums via AR) preserved where it provides runtime accuracy. All `source.scan(...)`, `source.each_line`, and `line.match?(...)` patterns in model introspection eliminated.
34
+
35
+ - **Install generator** now wraps `config/initializers/rails_ai_context.rb` in `if defined?(RailsAiContext)` so apps with the gem in `group :development` only don't crash in test/production. Re-install upgrades existing unguarded initializers and preserves indentation. All README and GUIDE initializer examples updated to the guarded form (#35).
36
+
37
+ ### Dependencies
38
+
39
+ - Added `prism >= 0.28` (stdlib in Ruby 3.3+, gem for 3.2)
40
+ - Added `concurrent-ruby >= 1.2` (thread-safe AST cache; already transitive via Rails)
41
+
42
+ ### Why
43
+
44
+ Regex-based Ruby source parsing was the #3 critical finding in the architecture audit: it breaks on heredocs, multi-line DSL calls, `class << self` blocks, and metaprogrammed constructs. Prism AST provides 100% syntax-level accuracy. The single-pass Dispatcher pattern means parsing a 500-line model file runs all 7 listeners in one tree walk — no repeated I/O or re-parsing. The confidence tagging gives AI agents explicit signal about what data is ground truth vs. what requires runtime verification.
45
+
8
46
  ## [5.1.0] — 2026-04-06
9
47
 
10
48
  ### Fixed
data/CONTRIBUTING.md CHANGED
@@ -44,6 +44,19 @@ lib/rails_ai_context/
44
44
  4. Register in `Server::TOOLS`
45
45
  5. Write specs in `spec/lib/rails_ai_context/tools/your_tool_spec.rb`
46
46
 
47
+ ## Adding a Prism Listener
48
+
49
+ Listeners extract specific concerns (associations, validations, etc.) from the AST via `Prism::Dispatcher` events.
50
+
51
+ 1. Create `lib/rails_ai_context/introspectors/listeners/your_listener.rb` inheriting from `BaseListener`
52
+ 2. Implement `on_call_node_enter(node)` and/or `on_def_node_enter(node)` — only the events your concern needs
53
+ 3. Use `confidence_for(node)` from `BaseListener` to tag results `[VERIFIED]` or `[INFERRED]`
54
+ 4. Store results in `@results` (accessed via `#results`)
55
+ 5. Register the key/class pair in `SourceIntrospector::LISTENER_MAP`
56
+ 6. Write specs in `spec/lib/rails_ai_context/introspectors/listeners/your_listener_spec.rb`
57
+
58
+ See existing listeners in `lib/rails_ai_context/introspectors/listeners/` for reference patterns.
59
+
47
60
  ## Adding a CLI Tool Interface
48
61
 
49
62
  The `ToolRunner` (`lib/rails_ai_context/cli/tool_runner.rb`) handles CLI execution of all MCP tools. It is tested in `spec/lib/rails_ai_context/cli/tool_runner_spec.rb`. If you add a new MCP tool, it is automatically available via CLI — no extra registration needed. Tool name resolution (`schema` → `get_schema` → `rails_get_schema`) works for all tools.
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-1565%20passing-brightgreen)](https://github.com/crisnahine/rails-ai-context/actions)
22
+ [![Tests](https://img.shields.io/badge/Tests-1668%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>
@@ -62,11 +62,11 @@ rails-ai-context serve # start MCP server
62
62
 
63
63
  <div align="center">
64
64
 
65
- ![Install demo](demo.gif)
65
+ ![Install demo](demo/demo.gif)
66
66
 
67
67
  </div>
68
68
 
69
- Now your AI doesn't guess — it **asks your app directly.** 38 tools that query your schema, models, routes, controllers, views, and conventions on demand. It gets the right answer the first time.
69
+ Now your AI doesn't guess — it **asks your app directly.** 38 tools that query your schema, models, routes, controllers, views, and conventions on demand. Model introspection uses Prism AST parsing every result carries a `[VERIFIED]` or `[INFERRED]` confidence tag so AI knows what's ground truth and what needs runtime checking.
70
70
 
71
71
  <br>
72
72
 
@@ -74,7 +74,7 @@ Now your AI doesn't guess — it **asks your app directly.** 38 tools that query
74
74
 
75
75
  <div align="center">
76
76
 
77
- ![Trace demo](demo-trace.gif)
77
+ ![Trace demo](demo/demo-trace.gif)
78
78
 
79
79
  </div>
80
80
 
@@ -263,7 +263,7 @@ Every tool is **read-only** and returns data verified against your actual app
263
263
  | Tool | What it does |
264
264
  |:-----|:------------|
265
265
  | `get_schema` | Columns with indexed/unique/encrypted/default hints |
266
- | `get_model_details` | Associations, validations, scopes, enums, macros, delegations |
266
+ | `get_model_details` | AST-parsed associations, validations, scopes, enums, macros each result tagged `[VERIFIED]` or `[INFERRED]` |
267
267
  | `get_callbacks` | Callbacks in Rails execution order with source |
268
268
  | `get_concern` | Concern methods + source + which models include it |
269
269
 
@@ -375,7 +375,7 @@ Enabled by default. Disable with `config.anti_hallucination_rules = false` if yo
375
375
 
376
376
  ┌─────────────────────────────────────────────────────────┐
377
377
  │ rails-ai-context │
378
- Parses everything. Caches results. Sensible defaults. │
378
+ Prism AST parsing. Cached. Confidence-tagged results. │
379
379
  └────────┬──────────────────┬──────────────┬──────────────┘
380
380
  │ │ │
381
381
  ▼ ▼ ▼
@@ -427,10 +427,12 @@ Both paths ask which AI tools you use and whether you want MCP or CLI mode. `.mc
427
427
 
428
428
  ```ruby
429
429
  # config/initializers/rails_ai_context.rb
430
- RailsAiContext.configure do |config|
431
- config.ai_tools = %i[claude cursor] # Which AI tools to generate for
432
- config.tool_mode = :mcp # :mcp (default) or :cli
433
- config.preset = :full # :full (31 introspectors) or :standard (17)
430
+ if defined?(RailsAiContext)
431
+ RailsAiContext.configure do |config|
432
+ config.ai_tools = %i[claude cursor] # Which AI tools to generate for
433
+ config.tool_mode = :mcp # :mcp (default) or :cli
434
+ config.preset = :full # :full (31 introspectors) or :standard (17)
435
+ end
434
436
  end
435
437
  ```
436
438
 
@@ -470,7 +472,7 @@ end
470
472
  ## About
471
473
 
472
474
  Built by a Rails developer with 10+ years of production experience.<br>
473
- 1565 tests. 38 tools. 31 introspectors. Standalone or in-Gemfile.<br>
475
+ 1668 tests. 38 tools. 31 introspectors. Standalone or in-Gemfile.<br>
474
476
  MIT licensed. [Contributions welcome.](CONTRIBUTING.md)
475
477
 
476
478
  <br>
data/docs/GUIDE.md CHANGED
@@ -121,8 +121,10 @@ CONTEXT_MODE=full rails ai:context:copilot
121
121
 
122
122
  ```ruby
123
123
  # config/initializers/rails_ai_context.rb
124
- RailsAiContext.configure do |config|
125
- config.context_mode = :full # or :compact (default)
124
+ if defined?(RailsAiContext)
125
+ RailsAiContext.configure do |config|
126
+ config.context_mode = :full # or :compact (default)
127
+ end
126
128
  end
127
129
  ```
128
130
 
@@ -306,10 +308,12 @@ Short names are resolved automatically:
306
308
  The `tool_mode` config controls how tool references appear in generated context files:
307
309
 
308
310
  ```ruby
309
- RailsAiContext.configure do |config|
310
- # :mcp (default) — MCP primary, CLI as fallback
311
- # :cliCLI only, no MCP server needed
312
- config.tool_mode = :mcp
311
+ if defined?(RailsAiContext)
312
+ RailsAiContext.configure do |config|
313
+ # :mcp (default) MCP primary, CLI as fallback
314
+ # :cli — CLI only, no MCP server needed
315
+ config.tool_mode = :mcp
316
+ end
313
317
  end
314
318
  ```
315
319
 
@@ -359,7 +363,7 @@ rails_get_schema(detail: "full", format: "json")
359
363
 
360
364
  ### rails_get_model_details
361
365
 
362
- Returns model details: associations, validations, scopes, enums, callbacks, concerns.
366
+ Returns model details: associations, validations, scopes, enums, callbacks, concerns. Source parsing uses Prism AST — every result carries a `[VERIFIED]` (static literal arguments) or `[INFERRED]` (dynamic expressions) confidence tag.
363
367
 
364
368
  **Parameters:**
365
369
 
@@ -1110,11 +1114,13 @@ rails ai:serve_http
1110
1114
  Or auto-mount inside your Rails app (no separate process):
1111
1115
 
1112
1116
  ```ruby
1113
- RailsAiContext.configure do |config|
1114
- config.auto_mount = true
1115
- config.http_path = "/mcp" # default
1116
- config.http_port = 6029 # default
1117
- config.http_bind = "127.0.0.1" # default (localhost only)
1117
+ if defined?(RailsAiContext)
1118
+ RailsAiContext.configure do |config|
1119
+ config.auto_mount = true
1120
+ config.http_path = "/mcp" # default
1121
+ config.http_port = 6029 # default
1122
+ config.http_bind = "127.0.0.1" # default (localhost only)
1123
+ end
1118
1124
  end
1119
1125
  ```
1120
1126
 
@@ -1126,116 +1132,118 @@ Both transports are **read-only** — they expose the same 38 tools and never mo
1126
1132
 
1127
1133
  ```ruby
1128
1134
  # config/initializers/rails_ai_context.rb
1129
- RailsAiContext.configure do |config|
1130
- # --- Introspectors ---
1135
+ if defined?(RailsAiContext)
1136
+ RailsAiContext.configure do |config|
1137
+ # --- Introspectors ---
1131
1138
 
1132
- # Presets: :full (31 introspectors, default) or :standard (17)
1133
- config.preset = :full
1139
+ # Presets: :full (31 introspectors, default) or :standard (17)
1140
+ config.preset = :full
1134
1141
 
1135
- # Cherry-pick on top of a preset
1136
- config.introspectors += %i[views turbo auth api]
1142
+ # Cherry-pick on top of a preset
1143
+ config.introspectors += %i[views turbo auth api]
1137
1144
 
1138
- # --- Context files ---
1145
+ # --- Context files ---
1139
1146
 
1140
- # Context mode: :compact (default) or :full
1141
- config.context_mode = :compact
1147
+ # Context mode: :compact (default) or :full
1148
+ config.context_mode = :compact
1142
1149
 
1143
- # Max lines for CLAUDE.md in compact mode
1144
- config.claude_max_lines = 150
1150
+ # Max lines for CLAUDE.md in compact mode
1151
+ config.claude_max_lines = 150
1145
1152
 
1146
- # Output directory for context files (default: Rails.root)
1147
- # config.output_dir = "/custom/path"
1153
+ # Output directory for context files (default: Rails.root)
1154
+ # config.output_dir = "/custom/path"
1148
1155
 
1149
- # --- MCP tools ---
1156
+ # --- MCP tools ---
1150
1157
 
1151
- # Tool mode: :mcp (MCP primary + CLI fallback) or :cli (CLI only)
1152
- config.tool_mode = :mcp
1158
+ # Tool mode: :mcp (MCP primary + CLI fallback) or :cli (CLI only)
1159
+ config.tool_mode = :mcp
1153
1160
 
1154
- # Max response size for tool results (safety net)
1155
- config.max_tool_response_chars = 200_000
1161
+ # Max response size for tool results (safety net)
1162
+ config.max_tool_response_chars = 200_000
1156
1163
 
1157
- # Cache TTL for introspection results (seconds)
1158
- config.cache_ttl = 60
1164
+ # Cache TTL for introspection results (seconds)
1165
+ config.cache_ttl = 60
1159
1166
 
1160
- # Additional MCP tool classes to register alongside built-in tools
1161
- # config.custom_tools = [MyApp::Tools::CustomTool]
1167
+ # Additional MCP tool classes to register alongside built-in tools
1168
+ # config.custom_tools = [MyApp::Tools::CustomTool]
1162
1169
 
1163
- # Exclude specific built-in tools (e.g. if you don't use Brakeman)
1164
- # config.skip_tools = %w[rails_security_scan]
1170
+ # Exclude specific built-in tools (e.g. if you don't use Brakeman)
1171
+ # config.skip_tools = %w[rails_security_scan]
1165
1172
 
1166
- # --- Exclusions ---
1173
+ # --- Exclusions ---
1167
1174
 
1168
- # Models to skip during introspection
1169
- config.excluded_models += %w[AdminUser InternalAuditLog]
1175
+ # Models to skip during introspection
1176
+ config.excluded_models += %w[AdminUser InternalAuditLog]
1170
1177
 
1171
- # Paths to exclude from code search
1172
- config.excluded_paths += %w[vendor/bundle]
1178
+ # Paths to exclude from code search
1179
+ config.excluded_paths += %w[vendor/bundle]
1173
1180
 
1174
- # Sensitive file patterns blocked from search and read tools
1175
- # config.sensitive_patterns += %w[config/my_secret.yml]
1181
+ # Sensitive file patterns blocked from search and read tools
1182
+ # config.sensitive_patterns += %w[config/my_secret.yml]
1176
1183
 
1177
- # Controllers hidden from listings (e.g. Devise internals)
1178
- # config.excluded_controllers += %w[MyInternalController]
1184
+ # Controllers hidden from listings (e.g. Devise internals)
1185
+ # config.excluded_controllers += %w[MyInternalController]
1179
1186
 
1180
- # Route prefixes hidden with app_only (e.g. admin frameworks)
1181
- # config.excluded_route_prefixes += %w[admin/]
1187
+ # Route prefixes hidden with app_only (e.g. admin frameworks)
1188
+ # config.excluded_route_prefixes += %w[admin/]
1182
1189
 
1183
- # Regex patterns for concerns to hide from model output
1184
- # config.excluded_concerns += [/MyInternal::/]
1190
+ # Regex patterns for concerns to hide from model output
1191
+ # config.excluded_concerns += [/MyInternal::/]
1185
1192
 
1186
- # Framework filter names hidden from controller output
1187
- # config.excluded_filters += %w[my_internal_filter]
1193
+ # Framework filter names hidden from controller output
1194
+ # config.excluded_filters += %w[my_internal_filter]
1188
1195
 
1189
- # Default middleware hidden from config output
1190
- # config.excluded_middleware += %w[MyMiddleware]
1196
+ # Default middleware hidden from config output
1197
+ # config.excluded_middleware += %w[MyMiddleware]
1191
1198
 
1192
- # --- File size limits ---
1199
+ # --- File size limits ---
1193
1200
 
1194
- # Per-file read limit for tools (default: 5MB)
1195
- # config.max_file_size = 5_000_000
1201
+ # Per-file read limit for tools (default: 5MB)
1202
+ # config.max_file_size = 5_000_000
1196
1203
 
1197
- # Test file read limit (default: 1MB)
1198
- # config.max_test_file_size = 1_000_000
1204
+ # Test file read limit (default: 1MB)
1205
+ # config.max_test_file_size = 1_000_000
1199
1206
 
1200
- # schema.rb / structure.sql parse limit (default: 10MB)
1201
- # config.max_schema_file_size = 10_000_000
1207
+ # schema.rb / structure.sql parse limit (default: 10MB)
1208
+ # config.max_schema_file_size = 10_000_000
1202
1209
 
1203
- # Total aggregated view content for UI patterns (default: 10MB)
1204
- # config.max_view_total_size = 10_000_000
1210
+ # Total aggregated view content for UI patterns (default: 10MB)
1211
+ # config.max_view_total_size = 10_000_000
1205
1212
 
1206
- # Per-view file during aggregation (default: 1MB)
1207
- # config.max_view_file_size = 1_000_000
1213
+ # Per-view file during aggregation (default: 1MB)
1214
+ # config.max_view_file_size = 1_000_000
1208
1215
 
1209
- # Max search results per call (default: 200)
1210
- # config.max_search_results = 200
1216
+ # Max search results per call (default: 200)
1217
+ # config.max_search_results = 200
1211
1218
 
1212
- # Max files per validate call (default: 50)
1213
- # config.max_validate_files = 50
1219
+ # Max files per validate call (default: 50)
1220
+ # config.max_validate_files = 50
1214
1221
 
1215
- # --- Search and file discovery ---
1222
+ # --- Search and file discovery ---
1216
1223
 
1217
- # File extensions for Ruby fallback search
1218
- # config.search_extensions = %w[rb js erb yml yaml json ts tsx vue svelte haml slim]
1224
+ # File extensions for Ruby fallback search
1225
+ # config.search_extensions = %w[rb js erb yml yaml json ts tsx vue svelte haml slim]
1219
1226
 
1220
- # Where to look for concern source files
1221
- # config.concern_paths = %w[app/models/concerns app/controllers/concerns]
1227
+ # Where to look for concern source files
1228
+ # config.concern_paths = %w[app/models/concerns app/controllers/concerns]
1222
1229
 
1223
- # --- Live reload ---
1230
+ # --- Live reload ---
1224
1231
 
1225
- # Auto-invalidate MCP tool caches on file changes
1226
- # :auto — enable if `listen` gem is available (default)
1227
- # true — enable, raise if `listen` is missing
1228
- # false — disable entirely
1229
- config.live_reload = :auto
1230
- config.live_reload_debounce = 1.5 # seconds
1232
+ # Auto-invalidate MCP tool caches on file changes
1233
+ # :auto — enable if `listen` gem is available (default)
1234
+ # true — enable, raise if `listen` is missing
1235
+ # false — disable entirely
1236
+ config.live_reload = :auto
1237
+ config.live_reload_debounce = 1.5 # seconds
1231
1238
 
1232
- # --- HTTP MCP endpoint ---
1239
+ # --- HTTP MCP endpoint ---
1233
1240
 
1234
- # Auto-mount Rack middleware for HTTP MCP
1235
- config.auto_mount = false
1236
- config.http_path = "/mcp"
1237
- config.http_bind = "127.0.0.1"
1238
- config.http_port = 6029
1241
+ # Auto-mount Rack middleware for HTTP MCP
1242
+ config.auto_mount = false
1243
+ config.http_path = "/mcp"
1244
+ config.http_bind = "127.0.0.1"
1245
+ config.http_port = 6029
1246
+ end
1239
1247
  end
1240
1248
  ```
1241
1249
 
@@ -1291,8 +1299,10 @@ By default, `rails ai:context` generates root files (CLAUDE.md, AGENTS.md, etc.)
1291
1299
  **Skip root files:** If you prefer to maintain root files yourself and only want split rules (`.claude/rules/`, `.cursor/rules/`, `.github/instructions/`):
1292
1300
 
1293
1301
  ```ruby
1294
- RailsAiContext.configure do |config|
1295
- config.generate_root_files = false
1302
+ if defined?(RailsAiContext)
1303
+ RailsAiContext.configure do |config|
1304
+ config.generate_root_files = false
1305
+ end
1296
1306
  end
1297
1307
  ```
1298
1308
 
@@ -1309,7 +1319,7 @@ Core Rails structure only. Use `config.preset = :standard` for a lighter footpri
1309
1319
  | Introspector | What it discovers |
1310
1320
  |-------------|-------------------|
1311
1321
  | `schema` | Tables, columns, types, indexes, foreign keys, primary keys. Falls back to `db/schema.rb` parsing when no DB connected. |
1312
- | `models` | Associations, validations, scopes, enums, callbacks, concerns, instance methods, class methods. Source-level macros: `has_secure_password`, `encrypts`, `normalizes`, `delegate`, `serialize`, `store`, `generates_token_for`, `has_one_attached`, `has_many_attached`, `has_rich_text`, `broadcasts_to`. |
1322
+ | `models` | Associations, validations, scopes, enums, callbacks, concerns, instance methods, class methods. Source-level macros via Prism AST (single-pass, 7 listeners): `has_secure_password`, `encrypts`, `normalizes`, `delegate`, `serialize`, `store`, `generates_token_for`, `has_one_attached`, `has_many_attached`, `has_rich_text`, `broadcasts_to`. Every result tagged `[VERIFIED]` or `[INFERRED]`. |
1313
1323
  | `routes` | All routes with HTTP verbs, paths, controller actions, route names, API namespaces, mounted engines. |
1314
1324
  | `jobs` | ActiveJob classes with queue names. Mailers with action methods. Action Cable channels. |
1315
1325
  | `gems` | 70+ notable gems categorized: auth, background_jobs, admin, monitoring, search, pagination, forms, file_upload, testing, linting, security, api, frontend, utilities. |
@@ -1543,14 +1553,16 @@ Live reload is **enabled by default** when the `listen` gem is available. No con
1543
1553
  ### Configuration
1544
1554
 
1545
1555
  ```ruby
1546
- RailsAiContext.configure do |config|
1547
- # :auto (default) — enable if `listen` gem is available, skip silently otherwise
1548
- # true — enable, raise if `listen` gem is missing
1549
- # false disable entirely
1550
- config.live_reload = :auto
1551
-
1552
- # Debounce interval in seconds (default: 1.5)
1553
- config.live_reload_debounce = 1.5
1556
+ if defined?(RailsAiContext)
1557
+ RailsAiContext.configure do |config|
1558
+ # :auto (default) — enable if `listen` gem is available, skip silently otherwise
1559
+ # true enable, raise if `listen` gem is missing
1560
+ # false — disable entirely
1561
+ config.live_reload = :auto
1562
+
1563
+ # Debounce interval in seconds (default: 1.5)
1564
+ config.live_reload_debounce = 1.5
1565
+ end
1554
1566
  end
1555
1567
  ```
1556
1568
 
@@ -310,6 +310,7 @@ module RailsAiContext
310
310
  end
311
311
 
312
312
  content += "end\n"
313
+ content, = ensure_initializer_guard(content)
313
314
 
314
315
  create_file path, content
315
316
  say "Created #{path} with all #{CONFIG_SECTIONS.size} config sections", :green
@@ -332,13 +333,16 @@ module RailsAiContext
332
333
  marker = "── #{name}"
333
334
  next if existing.include?(marker)
334
335
 
335
- insert_point = existing.rindex(/^end\b/)
336
+ insert_point = configure_block_end_index(existing)
336
337
  if insert_point
337
- existing = existing.insert(insert_point, "\n#{section_content}\n")
338
+ existing = existing.insert(insert_point, "\n#{reindent_section_content(section_content, existing)}\n")
338
339
  changes << "section: #{name}"
339
340
  end
340
341
  end
341
342
 
343
+ existing, changed = ensure_initializer_guard(existing)
344
+ changes << "guard" if changed
345
+
342
346
  if changes.any?
343
347
  File.write(full_path, existing)
344
348
  say "Updated #{full_path.relative_path_from(Rails.root)}: #{changes.join(', ')}", :green
@@ -350,9 +354,11 @@ module RailsAiContext
350
354
  # Replace or uncomment a config line. Returns [new_content, changed?]
351
355
  def update_config_line(content, key, new_line)
352
356
  # Match both commented and uncommented versions of this config key
353
- pattern = /^[ \t]*#?\s*#{Regexp.escape(key)}\s*=.*$/
357
+ pattern = /^([ \t]*)#?\s*#{Regexp.escape(key)}\s*=.*$/
354
358
  if content.match?(pattern)
355
- updated = content.sub(pattern, new_line)
359
+ updated = content.sub(pattern) do
360
+ "#{Regexp.last_match(1)}#{new_line.lstrip}"
361
+ end
356
362
  [ updated, updated != content ]
357
363
  else
358
364
  # Key not found at all — don't add (it's in a section that will be added)
@@ -360,6 +366,56 @@ module RailsAiContext
360
366
  end
361
367
  end
362
368
 
369
+ def configure_block_end_index(content)
370
+ end_positions = []
371
+ content.to_enum(:scan, /^[ \t]*end\b/).each do
372
+ end_positions << Regexp.last_match.begin(0)
373
+ end
374
+ return nil if end_positions.empty?
375
+
376
+ if guarded_initializer?(content) && end_positions.size >= 2
377
+ end_positions[-2]
378
+ else
379
+ end_positions[-1]
380
+ end
381
+ end
382
+
383
+ def ensure_initializer_guard(content)
384
+ return [ content, false ] if guarded_initializer?(content)
385
+
386
+ header_match = content.match(/\A# frozen_string_literal: true\n(?:\n)?/)
387
+ header = header_match ? "# frozen_string_literal: true\n\n" : ""
388
+ body = header_match ? content.delete_prefix(header_match[0]) : content
389
+ body = "#{body}\n" unless body.end_with?("\n")
390
+
391
+ wrapped = "#{header}if defined?(RailsAiContext)\n#{indent_content(body)}end\n"
392
+ [ wrapped, wrapped != content ]
393
+ end
394
+
395
+ def reindent_section_content(section_content, content)
396
+ indent = configure_body_indent(content)
397
+ section_content.lines.map do |line|
398
+ next line if line == "\n"
399
+
400
+ "#{indent}#{line.sub(/\A[ \t]{0,2}/, "")}"
401
+ end.join
402
+ end
403
+
404
+ def configure_body_indent(content)
405
+ match = content.match(/^([ \t]*)RailsAiContext\.configure do \|config\|$/)
406
+ return " " unless match
407
+
408
+ "#{match[1]} "
409
+ end
410
+
411
+ def guarded_initializer?(content)
412
+ content.match?(/^[ \t]*if defined\?\(RailsAiContext\)$/)
413
+ end
414
+
415
+ def indent_content(content)
416
+ content.lines.map { |line| line == "\n" ? line : " #{line}" }.join
417
+ end
418
+
363
419
  def build_ai_tools_line
364
420
  # Always write uncommented so re-install can detect previous selection
365
421
  " config.ai_tools = %i[#{@selected_formats.join(' ')}]"
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "concurrent"
5
+ require "prism"
6
+
7
+ module RailsAiContext
8
+ # Thread-safe AST parse cache backed by Concurrent::Map.
9
+ # Keyed by path + content hash + mtime — automatically invalidates
10
+ # when file content changes. Used by all Prism-based introspectors.
11
+ #
12
+ # Bounded: evicts oldest entries when MAX_SIZE is exceeded.
13
+ module AstCache
14
+ STORE = Concurrent::Map.new
15
+ MAX_SIZE = 500
16
+ EVICTION_MUTEX = Mutex.new
17
+
18
+ # Max file size for parsing (default: matches config.max_file_size).
19
+ # Public API — callers don't need to pre-check size.
20
+ MAX_PARSE_SIZE = 5_000_000
21
+
22
+ # Parse a Ruby source file and cache the result.
23
+ # Returns a Prism::ParseResult. Rejects files exceeding MAX_PARSE_SIZE.
24
+ #
25
+ # Reads content first, then checks size — avoids TOCTOU race where the
26
+ # file could change between File.size and File.read.
27
+ def self.parse(path)
28
+ content = File.read(path)
29
+ size = content.bytesize
30
+ raise ArgumentError, "File too large for AST parsing: #{path} (#{size} bytes, max #{MAX_PARSE_SIZE})" if size > MAX_PARSE_SIZE
31
+
32
+ mtime = File.mtime(path).to_i
33
+ key = "#{path}:#{Digest::SHA256.hexdigest(content)}:#{mtime}"
34
+
35
+ cached = STORE[key]
36
+ return cached if cached
37
+
38
+ # Evict BEFORE inserting to avoid running inside compute_if_absent
39
+ evict_if_full
40
+
41
+ STORE.compute_if_absent(key) { Prism.parse(content) }
42
+ end
43
+
44
+ # Parse a Ruby source string (no caching).
45
+ # Returns a Prism::ParseResult.
46
+ def self.parse_string(source)
47
+ Prism.parse(source)
48
+ end
49
+
50
+ # Invalidate all cached entries for a given path.
51
+ def self.invalidate(path)
52
+ prefix = "#{path}:"
53
+ STORE.each_key do |k|
54
+ STORE.delete(k) if k.start_with?(prefix)
55
+ end
56
+ end
57
+
58
+ # Clear the entire cache.
59
+ def self.clear
60
+ STORE.clear
61
+ end
62
+
63
+ # Number of cached entries (for diagnostics).
64
+ def self.size
65
+ STORE.size
66
+ end
67
+
68
+ # Evict ~25% of entries when cache exceeds MAX_SIZE.
69
+ # Synchronized to prevent multiple threads from over-evicting simultaneously.
70
+ def self.evict_if_full
71
+ EVICTION_MUTEX.synchronize do
72
+ return if STORE.size < MAX_SIZE
73
+ keys = STORE.keys
74
+ keys.first(keys.size / 4).each { |k| STORE.delete(k) }
75
+ end
76
+ end
77
+ private_class_method :evict_if_full
78
+ end
79
+ end