rails-ai-context 0.2.0 → 0.3.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +2 -2
  3. data/CHANGELOG.md +25 -0
  4. data/CLAUDE.md +47 -0
  5. data/README.md +72 -20
  6. data/exe/rails-ai-context +30 -2
  7. data/lib/rails_ai_context/configuration.rb +5 -1
  8. data/lib/rails_ai_context/doctor.rb +106 -0
  9. data/lib/rails_ai_context/engine.rb +8 -0
  10. data/lib/rails_ai_context/fingerprinter.rb +46 -0
  11. data/lib/rails_ai_context/introspector.rb +2 -0
  12. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +39 -0
  13. data/lib/rails_ai_context/introspectors/model_introspector.rb +10 -8
  14. data/lib/rails_ai_context/introspectors/route_introspector.rb +1 -1
  15. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +65 -0
  16. data/lib/rails_ai_context/middleware.rb +39 -0
  17. data/lib/rails_ai_context/resources.rb +87 -0
  18. data/lib/rails_ai_context/serializers/claude_serializer.rb +45 -0
  19. data/lib/rails_ai_context/serializers/context_file_serializer.rb +16 -10
  20. data/lib/rails_ai_context/serializers/copilot_serializer.rb +29 -0
  21. data/lib/rails_ai_context/serializers/markdown_serializer.rb +6 -6
  22. data/lib/rails_ai_context/serializers/rules_serializer.rb +26 -0
  23. data/lib/rails_ai_context/server.rb +6 -2
  24. data/lib/rails_ai_context/tasks/rails_ai_context.rake +42 -7
  25. data/lib/rails_ai_context/tools/base_tool.rb +15 -3
  26. data/lib/rails_ai_context/tools/get_conventions.rb +1 -1
  27. data/lib/rails_ai_context/tools/get_gems.rb +2 -2
  28. data/lib/rails_ai_context/tools/get_model_details.rb +1 -1
  29. data/lib/rails_ai_context/tools/get_routes.rb +1 -1
  30. data/lib/rails_ai_context/tools/get_schema.rb +1 -1
  31. data/lib/rails_ai_context/tools/search_code.rb +42 -9
  32. data/lib/rails_ai_context/version.rb +1 -1
  33. data/lib/rails_ai_context/watcher.rb +75 -0
  34. data/lib/rails_ai_context.rb +14 -1
  35. data/rails-ai-context.gemspec +4 -4
  36. metadata +13 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f762c8f111cca6b60293f4d3eb26ef68aeb9e1d0c97048b46dcf221ebf7d6069
4
- data.tar.gz: 01b9e656715c47aa0a3e67cfb75fb6ac1a5fd7d6444b4ac96837c6202a093487
3
+ metadata.gz: 6fa5518e88de666f981967ae1557990aadea0804c192511c4cbf204d6b45f146
4
+ data.tar.gz: e39c7430277939e504aa3056ac0ac85f9f347dd008ff907627a032d7607ad21f
5
5
  SHA512:
6
- metadata.gz: a5daa689b56aeee3225f0bfa983580adeabbc195c2184b669f5a3689d1cd2760d115afefcc2c925e14eefafea2b28729893b260f141acd00f5e89a6bcb4fc665
7
- data.tar.gz: 7fba10086525e15044867dc4c1b5c5a44fd8eaf5f66c08ffbbd6468dfb1f2831d5b03231ec3e0ca02e2fec2a30a4558cd5567262642edf92c750a4bd8286fdc4
6
+ metadata.gz: d1709f1becfbe976d7cd7a62690e90b31dbe5d61b2d09d9c9001ec43d0ce2d344d840d310d42d65ad9c1bf86bba2a0eb3285cb88eb074e131a373904224f7813
7
+ data.tar.gz: 04714a3b181c91844c27b7b64ad2c2e9db963a16ae5ee037721f01afd518bad151b4d60dee8df78a69638b0fbd87af2c18288b70d14c62a72e72bd44f23f0c2d
data/.rubocop.yml CHANGED
@@ -1,5 +1,5 @@
1
- require:
2
- - rubocop-rails-omakase
1
+ inherit_gem:
2
+ rubocop-rails-omakase: rubocop.yml
3
3
 
4
4
  AllCops:
5
5
  TargetRubyVersion: 3.2
data/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.0] - 2026-03-18
9
+
10
+ ### Added
11
+
12
+ - **Cache invalidation** — TTL + file fingerprinting for MCP tool cache (replaces permanent `||=` cache)
13
+ - **MCP Resources** — Static resources (`rails://schema`, `rails://routes`, `rails://conventions`, `rails://gems`) and resource template (`rails://models/{name}`)
14
+ - **Per-assistant serializers** — Claude gets behavioral rules, Cursor/Windsurf get compact rules, Copilot gets task-oriented GFM
15
+ - **Stimulus introspector** — Extracts Stimulus controller targets, values, and actions from JS/TS files
16
+ - **Database stats introspector** — Opt-in PostgreSQL approximate row counts via `pg_stat_user_tables`
17
+ - **Auto-mount HTTP middleware** — Rack middleware for MCP endpoint when `config.auto_mount = true`
18
+ - **Diff-aware regeneration** — Context file generation skips unchanged files
19
+ - **`rails ai:doctor`** — Diagnostic command with AI readiness score (0-100)
20
+ - **`rails ai:watch`** — File watcher that auto-regenerates context files on change (requires `listen` gem)
21
+
22
+ ### Fixed
23
+
24
+ - **Shell injection in SearchCode** — Replaced backtick execution with `Open3.capture2` array form; added file_type validation, max_results cap, and path traversal protection
25
+ - **Scope extraction** — Fixed broken `model.methods.grep(/^_scope_/)` by parsing source files for `scope :name` declarations
26
+ - **Route introspector** — Fixed `route.internal?` compatibility with Rails 8.1
27
+
28
+ ### Changed
29
+
30
+ - `generate_context` now returns `{ written: [], skipped: [] }` instead of flat array
31
+ - Default introspectors now include `:stimulus`
32
+
8
33
  ## [0.2.0] - 2026-03-18
9
34
 
10
35
  ### Added
data/CLAUDE.md ADDED
@@ -0,0 +1,47 @@
1
+ # CLAUDE.md — rails-ai-context development guide
2
+
3
+ This is a Ruby gem that auto-introspects Rails applications and exposes their
4
+ structure to AI assistants via the Model Context Protocol (MCP).
5
+
6
+ ## Architecture
7
+
8
+ - `lib/rails_ai_context.rb` — Main entry point, public API
9
+ - `lib/rails_ai_context/introspector.rb` — Orchestrates sub-introspectors
10
+ - `lib/rails_ai_context/introspectors/` — Individual introspectors (schema, models, routes, jobs, gems, conventions, stimulus, database_stats)
11
+ - `lib/rails_ai_context/tools/` — MCP tools using the official mcp SDK
12
+ - `lib/rails_ai_context/serializers/` — Output formatters (claude, rules, copilot, markdown, JSON)
13
+ - `lib/rails_ai_context/resources.rb` — MCP resources (static data AI clients read directly)
14
+ - `lib/rails_ai_context/server.rb` — MCP server configuration (stdio + HTTP transports)
15
+ - `lib/rails_ai_context/middleware.rb` — Rack middleware for auto-mounting MCP HTTP endpoint
16
+ - `lib/rails_ai_context/fingerprinter.rb` — SHA256 file fingerprinting for cache invalidation
17
+ - `lib/rails_ai_context/doctor.rb` — Diagnostic checks and AI readiness scoring
18
+ - `lib/rails_ai_context/watcher.rb` — File watcher for auto-regenerating context files
19
+ - `lib/rails_ai_context/engine.rb` — Rails Engine for auto-integration
20
+
21
+ ## Key Design Decisions
22
+
23
+ 1. **Built on official mcp SDK** — not a custom protocol implementation
24
+ 2. **Zero-config** — Railtie auto-registers at boot, introspects without setup
25
+ 3. **Graceful degradation** — works without DB by parsing schema.rb as text
26
+ 4. **Read-only tools only** — all MCP tools are annotated as non-destructive
27
+ 5. **Dual output** — static files (CLAUDE.md) + live MCP server (stdio/HTTP)
28
+ 6. **Diff-aware** — context regeneration skips unchanged files
29
+ 7. **Per-assistant serializers** — each AI tool gets tailored output format
30
+
31
+ ## Testing
32
+
33
+ ```bash
34
+ bundle exec rspec # Run specs
35
+ bundle exec rubocop # Lint
36
+ ```
37
+
38
+ Uses combustion gem for testing Rails engine behavior in isolation.
39
+
40
+ ## Conventions
41
+
42
+ - Ruby 3.2+ features OK (pattern matching, etc.)
43
+ - Follow rubocop-rails-omakase style
44
+ - Every introspector returns a Hash, never raises (wraps errors in `{ error: msg }`)
45
+ - MCP tools return `MCP::Tool::Response` objects per SDK convention
46
+ - All tools prefixed with `rails_` per MCP naming best practices
47
+ - `generate_context` returns `{ written: [], skipped: [] }` hash
data/README.md CHANGED
@@ -26,12 +26,13 @@ bundle add rails-ai-context
26
26
 
27
27
  That's it. Now your AI assistant knows:
28
28
 
29
- - 📦 **Every table, column, index, and foreign key** in your database
30
- - 🏗️ **Every model** with its associations, validations, scopes, enums, and callbacks
31
- - 🛤️ **Every route** with HTTP verbs, paths, and controller actions
32
- - **Every background job**, mailer, and Action Cable channel
33
- - 💎 **Every notable gem** and what it means (Devise auth, Sidekiq jobs, Turbo Hotwire)
34
- - 🏛️ **Your architecture patterns**: service objects, STI, polymorphism, state machines, multi-tenancy
29
+ - **Every table, column, index, and foreign key** in your database
30
+ - **Every model** with its associations, validations, scopes, enums, and callbacks
31
+ - **Every route** with HTTP verbs, paths, and controller actions
32
+ - **Every background job**, mailer, and Action Cable channel
33
+ - **Every notable gem** and what it means (Devise = auth, Sidekiq = jobs, Turbo = Hotwire)
34
+ - **Your architecture patterns**: service objects, STI, polymorphism, state machines, multi-tenancy
35
+ - **Stimulus controllers** with targets, values, and actions
35
36
 
36
37
  ---
37
38
 
@@ -51,12 +52,12 @@ rails ai:context
51
52
  ```
52
53
 
53
54
  This creates:
54
- - `CLAUDE.md` — for Claude Code
55
- - `.cursorrules` — for Cursor
56
- - `.windsurfrules` — for Windsurf
57
- - `.github/copilot-instructions.md` — for GitHub Copilot
55
+ - `CLAUDE.md` — for Claude Code (with behavioral rules)
56
+ - `.cursorrules` — for Cursor (compact rules format)
57
+ - `.windsurfrules` — for Windsurf (compact rules format)
58
+ - `.github/copilot-instructions.md` — for GitHub Copilot (task-oriented)
58
59
 
59
- **Commit these files.** Your entire team gets smarter AI assistance.
60
+ Each file is tailored to the AI assistant's preferred format. **Commit these files.** Your entire team gets smarter AI assistance.
60
61
 
61
62
  ### 3. Start the MCP Server
62
63
 
@@ -97,6 +98,18 @@ The gem exposes 6 tools via MCP that AI clients can call:
97
98
 
98
99
  All tools are **read-only** — they never modify your application or database.
99
100
 
101
+ ## MCP Resources
102
+
103
+ In addition to tools, the gem registers MCP resources that AI clients can read directly:
104
+
105
+ | Resource | Description |
106
+ |----------|-------------|
107
+ | `rails://schema` | Full database schema (JSON) |
108
+ | `rails://routes` | All routes (JSON) |
109
+ | `rails://conventions` | Detected patterns and architecture (JSON) |
110
+ | `rails://gems` | Notable gems with categories (JSON) |
111
+ | `rails://models/{name}` | Per-model details (resource template) |
112
+
100
113
  ---
101
114
 
102
115
  ## How It Works
@@ -124,9 +137,10 @@ All tools are **read-only** — they never modify your application or database.
124
137
  (reads file) any MCP client
125
138
  ```
126
139
 
127
- **Two modes:**
140
+ **Three modes:**
128
141
  1. **Static files** (`rails ai:context`) — generates markdown files that AI tools read as project context. Zero runtime cost. Works everywhere.
129
142
  2. **MCP server** (`rails ai:serve`) — live introspection tools that AI clients call on-demand. Richer, always up-to-date.
143
+ 3. **Watch mode** (`rails ai:watch`) — auto-regenerates context files when your code changes.
130
144
 
131
145
  ---
132
146
 
@@ -145,20 +159,50 @@ RailsAiContext.configure do |config|
145
159
  config.auto_mount = true
146
160
  config.http_path = "/mcp"
147
161
  config.http_port = 6029
162
+
163
+ # Cache TTL for MCP tool responses (seconds)
164
+ config.cache_ttl = 30
165
+
166
+ # Enable Postgres row count stats (opt-in)
167
+ # config.introspectors += [:database_stats]
148
168
  end
149
169
  ```
150
170
 
151
171
  ---
152
172
 
173
+ ## Diagnostics
174
+
175
+ Check your app's AI readiness:
176
+
177
+ ```bash
178
+ rails ai:doctor
179
+ ```
180
+
181
+ Reports pass/warn/fail for schema, models, routes, gems, context files, MCP server, and ripgrep. Includes fix suggestions and an AI readiness score (0-100).
182
+
183
+ ---
184
+
185
+ ## Stimulus Support
186
+
187
+ The gem automatically detects Stimulus controllers and extracts:
188
+ - Controller names (derived from filenames)
189
+ - Static targets
190
+ - Static values
191
+ - Action methods
192
+
193
+ This gives AI assistants context about your frontend JavaScript alongside your backend Ruby.
194
+
195
+ ---
196
+
153
197
  ## Supported AI Assistants
154
198
 
155
- | AI Assistant | Context File | Command |
156
- |--------------|-------------|---------|
157
- | Claude Code | `CLAUDE.md` | `rails ai:context:claude` |
158
- | Cursor | `.cursorrules` | `rails ai:context:cursor` |
159
- | Windsurf | `.windsurfrules` | `rails ai:context:windsurf` |
160
- | GitHub Copilot | `.github/copilot-instructions.md` | `rails ai:context:copilot` |
161
- | JSON (generic) | `.ai-context.json` | `rails ai:context:json` |
199
+ | AI Assistant | Context File | Format | Command |
200
+ |--------------|-------------|--------|---------|
201
+ | Claude Code | `CLAUDE.md` | Verbose + behavioral rules | `rails ai:context:claude` |
202
+ | Cursor | `.cursorrules` | Compact imperative rules | `rails ai:context:cursor` |
203
+ | Windsurf | `.windsurfrules` | Compact imperative rules | `rails ai:context:windsurf` |
204
+ | GitHub Copilot | `.github/copilot-instructions.md` | Task-oriented GFM | `rails ai:context:copilot` |
205
+ | JSON (generic) | `.ai-context.json` | Structured JSON | `rails ai:context:json` |
162
206
 
163
207
  ---
164
208
 
@@ -166,7 +210,7 @@ end
166
210
 
167
211
  | Command | Description |
168
212
  |---------|-------------|
169
- | `rails ai:context` | Generate all context files (CLAUDE.md, .cursorrules, etc.) |
213
+ | `rails ai:context` | Generate all context files (skips unchanged) |
170
214
  | `rails ai:context:claude` | Generate CLAUDE.md only |
171
215
  | `rails ai:context:cursor` | Generate .cursorrules only |
172
216
  | `rails ai:context:windsurf` | Generate .windsurfrules only |
@@ -175,6 +219,8 @@ end
175
219
  | `rails ai:serve` | Start MCP server (stdio, for Claude Code) |
176
220
  | `rails ai:serve_http` | Start MCP server (HTTP, for remote clients) |
177
221
  | `rails ai:inspect` | Print introspection summary to stdout |
222
+ | `rails ai:doctor` | Run diagnostics and report AI readiness score |
223
+ | `rails ai:watch` | Watch for changes and auto-regenerate context files |
178
224
 
179
225
  > **zsh users:** The bracket syntax `rails ai:context_for[claude]` requires quoting in zsh (`rails 'ai:context_for[claude]'`). The named tasks above (`rails ai:context:claude`) work without quoting in any shell.
180
226
 
@@ -196,6 +242,8 @@ The gem gracefully degrades when no database is connected — it parses `db/sche
196
242
  - Ruby >= 3.2
197
243
  - Rails >= 7.1
198
244
  - [mcp](https://github.com/modelcontextprotocol/ruby-sdk) (official MCP SDK, installed automatically)
245
+ - Optional: `listen` gem for watch mode
246
+ - Optional: `ripgrep` for fast code search (falls back to Ruby)
199
247
 
200
248
  ---
201
249
 
@@ -225,6 +273,10 @@ bundle exec rspec
225
273
 
226
274
  Bug reports and pull requests welcome at https://github.com/crisnahine/rails-ai-context.
227
275
 
276
+ ## Sponsorship
277
+
278
+ If rails-ai-context helps your workflow, consider supporting the project — [become a monthly sponsor or buy me a coffee](https://github.com/sponsors/crisnahine).
279
+
228
280
  ## License
229
281
 
230
282
  [MIT License](LICENSE)
data/exe/rails-ai-context CHANGED
@@ -28,8 +28,9 @@ module RailsAiContext
28
28
 
29
29
  format = options[:format].to_sym
30
30
  $stderr.puts "Introspecting Rails app..."
31
- files = RailsAiContext.generate_context(format: format)
32
- files.each { |f| $stderr.puts " Written: #{f}" }
31
+ result = RailsAiContext.generate_context(format: format)
32
+ result[:written].each { |f| $stderr.puts " Written: #{f}" }
33
+ result[:skipped].each { |f| $stderr.puts " Skipped: #{f} (unchanged)" }
33
34
  end
34
35
 
35
36
  desc "inspect", "Print introspection summary"
@@ -42,6 +43,33 @@ module RailsAiContext
42
43
  puts JSON.pretty_generate(context)
43
44
  end
44
45
 
46
+ desc "watch", "Watch for changes and auto-regenerate context files"
47
+ def watch
48
+ boot_rails!
49
+ require "rails_ai_context"
50
+
51
+ RailsAiContext::Watcher.new.start
52
+ end
53
+
54
+ desc "doctor", "Run diagnostic checks and report AI readiness score"
55
+ def doctor
56
+ boot_rails!
57
+ require "rails_ai_context"
58
+
59
+ result = RailsAiContext::Doctor.new.run
60
+ result[:checks].each do |check|
61
+ icon = case check.status
62
+ when :pass then "PASS"
63
+ when :warn then "WARN"
64
+ when :fail then "FAIL"
65
+ end
66
+ $stderr.puts " [#{icon}] #{check.name}: #{check.message}"
67
+ $stderr.puts " Fix: #{check.fix}" if check.fix
68
+ end
69
+ $stderr.puts ""
70
+ $stderr.puts "AI Readiness Score: #{result[:score]}/100"
71
+ end
72
+
45
73
  desc "version", "Print version"
46
74
  def version
47
75
  require_relative "../rails_ai_context/version"
@@ -26,10 +26,13 @@ module RailsAiContext
26
26
  # Maximum depth for association traversal
27
27
  attr_accessor :max_association_depth
28
28
 
29
+ # TTL in seconds for cached introspection (default: 30)
30
+ attr_accessor :cache_ttl
31
+
29
32
  def initialize
30
33
  @server_name = "rails-ai-context"
31
34
  @server_version = RailsAiContext::VERSION
32
- @introspectors = %i[schema models routes jobs gems conventions]
35
+ @introspectors = %i[schema models routes jobs gems conventions stimulus]
33
36
  @excluded_paths = %w[node_modules tmp log vendor .git]
34
37
  @auto_mount = false
35
38
  @http_path = "/mcp"
@@ -43,6 +46,7 @@ module RailsAiContext
43
46
  ActionMailbox::InboundEmail ActionMailbox::Record
44
47
  ]
45
48
  @max_association_depth = 2
49
+ @cache_ttl = 30
46
50
  end
47
51
 
48
52
  def output_dir_for(app)
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ # Diagnostic checker that validates the environment and reports
5
+ # AI readiness with pass/warn/fail checks and a readiness score.
6
+ class Doctor
7
+ Check = Data.define(:name, :status, :message, :fix)
8
+
9
+ CHECKS = %i[
10
+ check_schema
11
+ check_models
12
+ check_routes
13
+ check_gems
14
+ check_context_files
15
+ check_mcp_buildable
16
+ check_ripgrep
17
+ ].freeze
18
+
19
+ attr_reader :app
20
+
21
+ def initialize(app = nil)
22
+ @app = app || Rails.application
23
+ end
24
+
25
+ def run
26
+ results = CHECKS.map { |check| send(check) }
27
+ score = compute_score(results)
28
+ { checks: results, score: score }
29
+ end
30
+
31
+ private
32
+
33
+ def check_schema
34
+ schema_path = File.join(app.root, "db/schema.rb")
35
+ if File.exist?(schema_path)
36
+ Check.new(name: "Schema", status: :pass, message: "db/schema.rb found", fix: nil)
37
+ else
38
+ Check.new(name: "Schema", status: :warn, message: "db/schema.rb not found", fix: "Run `rails db:schema:dump` to generate it")
39
+ end
40
+ end
41
+
42
+ def check_models
43
+ models_dir = File.join(app.root, "app/models")
44
+ if Dir.exist?(models_dir) && Dir.glob(File.join(models_dir, "**/*.rb")).any?
45
+ count = Dir.glob(File.join(models_dir, "**/*.rb")).size
46
+ Check.new(name: "Models", status: :pass, message: "#{count} model files found", fix: nil)
47
+ else
48
+ Check.new(name: "Models", status: :warn, message: "No model files found in app/models/", fix: "Generate models with `rails generate model`")
49
+ end
50
+ end
51
+
52
+ def check_routes
53
+ routes_path = File.join(app.root, "config/routes.rb")
54
+ if File.exist?(routes_path)
55
+ Check.new(name: "Routes", status: :pass, message: "config/routes.rb found", fix: nil)
56
+ else
57
+ Check.new(name: "Routes", status: :fail, message: "config/routes.rb not found", fix: "Ensure you're in a Rails app root directory")
58
+ end
59
+ end
60
+
61
+ def check_gems
62
+ lock_path = File.join(app.root, "Gemfile.lock")
63
+ if File.exist?(lock_path)
64
+ Check.new(name: "Gems", status: :pass, message: "Gemfile.lock found", fix: nil)
65
+ else
66
+ Check.new(name: "Gems", status: :warn, message: "Gemfile.lock not found", fix: "Run `bundle install` to generate it")
67
+ end
68
+ end
69
+
70
+ def check_context_files
71
+ claude_path = File.join(app.root, "CLAUDE.md")
72
+ if File.exist?(claude_path)
73
+ Check.new(name: "Context files", status: :pass, message: "CLAUDE.md exists", fix: nil)
74
+ else
75
+ Check.new(name: "Context files", status: :warn, message: "No context files generated yet", fix: "Run `rails ai:context` to generate them")
76
+ end
77
+ end
78
+
79
+ def check_mcp_buildable
80
+ Server.new(app).build
81
+ Check.new(name: "MCP server", status: :pass, message: "MCP server builds successfully", fix: nil)
82
+ rescue => e
83
+ Check.new(name: "MCP server", status: :fail, message: "MCP server failed to build: #{e.message}", fix: "Check mcp gem installation: `bundle info mcp`")
84
+ end
85
+
86
+ def check_ripgrep
87
+ if system("which rg > /dev/null 2>&1")
88
+ Check.new(name: "ripgrep", status: :pass, message: "rg available for code search", fix: nil)
89
+ else
90
+ Check.new(name: "ripgrep", status: :warn, message: "ripgrep not installed (code search will use slower Ruby fallback)", fix: "Install with `brew install ripgrep` or `apt install ripgrep`")
91
+ end
92
+ end
93
+
94
+ def compute_score(results)
95
+ total = results.size * 10
96
+ earned = results.sum do |check|
97
+ case check.status
98
+ when :pass then 10
99
+ when :warn then 5
100
+ else 0
101
+ end
102
+ end
103
+ ((earned.to_f / total) * 100).round
104
+ end
105
+ end
106
+ end
@@ -8,6 +8,14 @@ module RailsAiContext
8
8
  Rails.application.config.rails_ai_context = RailsAiContext.configuration
9
9
  end
10
10
 
11
+ # Auto-mount MCP HTTP middleware when configured
12
+ initializer "rails_ai_context.middleware" do |app|
13
+ if RailsAiContext.configuration.auto_mount
14
+ require_relative "middleware"
15
+ app.middleware.use RailsAiContext::Middleware
16
+ end
17
+ end
18
+
11
19
  # Register Rake tasks
12
20
  rake_tasks do
13
21
  load File.expand_path("tasks/rails_ai_context.rake", __dir__)
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module RailsAiContext
6
+ # Computes a SHA256 fingerprint of key application files to detect changes.
7
+ # Used by BaseTool to invalidate cached introspection when files change.
8
+ class Fingerprinter
9
+ WATCHED_FILES = %w[
10
+ db/schema.rb
11
+ config/routes.rb
12
+ Gemfile.lock
13
+ ].freeze
14
+
15
+ WATCHED_DIRS = %w[
16
+ app/models
17
+ ].freeze
18
+
19
+ class << self
20
+ def compute(app)
21
+ root = app.root.to_s
22
+ digest = Digest::SHA256.new
23
+
24
+ WATCHED_FILES.each do |file|
25
+ path = File.join(root, file)
26
+ digest.update(File.mtime(path).to_f.to_s) if File.exist?(path)
27
+ end
28
+
29
+ WATCHED_DIRS.each do |dir|
30
+ full_dir = File.join(root, dir)
31
+ next unless Dir.exist?(full_dir)
32
+
33
+ Dir.glob(File.join(full_dir, "**/*.rb")).sort.each do |path|
34
+ digest.update(File.mtime(path).to_f.to_s)
35
+ end
36
+ end
37
+
38
+ digest.hexdigest
39
+ end
40
+
41
+ def changed?(app, previous)
42
+ compute(app) != previous
43
+ end
44
+ end
45
+ end
46
+ end
@@ -53,6 +53,8 @@ module RailsAiContext
53
53
  when :jobs then Introspectors::JobIntrospector.new(app)
54
54
  when :gems then Introspectors::GemIntrospector.new(app)
55
55
  when :conventions then Introspectors::ConventionDetector.new(app)
56
+ when :stimulus then Introspectors::StimulusIntrospector.new(app)
57
+ when :database_stats then Introspectors::DatabaseStatsIntrospector.new(app)
56
58
  else
57
59
  raise ConfigurationError, "Unknown introspector: #{name}"
58
60
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # Collects approximate row counts from PostgreSQL's pg_stat_user_tables.
6
+ # Only activates for PostgreSQL adapter; returns { skipped: true } otherwise.
7
+ class DatabaseStatsIntrospector
8
+ attr_reader :app
9
+
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ def call
15
+ return { skipped: true, reason: "ActiveRecord not available" } unless defined?(ActiveRecord::Base)
16
+
17
+ adapter = ActiveRecord::Base.connection.adapter_name.downcase
18
+ unless adapter.include?("postgresql")
19
+ return { skipped: true, reason: "Only available for PostgreSQL (current: #{adapter})" }
20
+ end
21
+
22
+ rows = ActiveRecord::Base.connection.select_all(<<~SQL)
23
+ SELECT relname AS table_name,
24
+ n_live_tup AS approximate_row_count
25
+ FROM pg_stat_user_tables
26
+ ORDER BY n_live_tup DESC
27
+ SQL
28
+
29
+ tables = rows.map do |row|
30
+ { table: row["table_name"], approximate_rows: row["approximate_row_count"].to_i }
31
+ end
32
+
33
+ { adapter: "postgresql", tables: tables, total_tables: tables.size }
34
+ rescue => e
35
+ { error: e.message }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -86,18 +86,20 @@ module RailsAiContext
86
86
  end
87
87
 
88
88
  def extract_scopes(model)
89
- model.defined_enums.keys # Enums create scopes too, but we track those separately
90
- # Get actual named scopes
91
- if model.respond_to?(:scope_names)
92
- model.scope_names.map(&:to_s)
93
- else
94
- # Fallback: check for scope definitions via reflection
95
- model.methods.grep(/^_scope_/).map { |m| m.to_s.sub("_scope_", "") }
96
- end
89
+ source_path = model_source_path(model)
90
+ return [] unless source_path && File.exist?(source_path)
91
+
92
+ File.read(source_path).scan(/^\s*scope\s+:(\w+)/).flatten
97
93
  rescue
98
94
  []
99
95
  end
100
96
 
97
+ def model_source_path(model)
98
+ root = app.root.to_s
99
+ underscored = model.name.underscore
100
+ File.join(root, "app", "models", "#{underscored}.rb")
101
+ end
102
+
101
103
  def extract_enums(model)
102
104
  return {} unless model.respond_to?(:defined_enums)
103
105
 
@@ -27,7 +27,7 @@ module RailsAiContext
27
27
 
28
28
  def extract_routes
29
29
  app.routes.routes.filter_map do |route|
30
- next if route.internal? # Skip Rails internal routes
30
+ next if route.respond_to?(:internal?) && route.internal?
31
31
  next if route.defaults[:controller].blank?
32
32
 
33
33
  {
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # Scans Stimulus controllers and extracts targets, values, and actions.
6
+ class StimulusIntrospector
7
+ attr_reader :app
8
+
9
+ def initialize(app)
10
+ @app = app
11
+ end
12
+
13
+ def call
14
+ root = app.root.to_s
15
+ controllers_dir = File.join(root, "app/javascript/controllers")
16
+ return { controllers: [] } unless Dir.exist?(controllers_dir)
17
+
18
+ controllers = Dir.glob(File.join(controllers_dir, "**/*_controller.{js,ts}")).sort.filter_map do |path|
19
+ parse_controller(path, controllers_dir)
20
+ end
21
+
22
+ { controllers: controllers }
23
+ rescue => e
24
+ { error: e.message }
25
+ end
26
+
27
+ private
28
+
29
+ def parse_controller(path, base_dir)
30
+ relative = path.sub("#{base_dir}/", "")
31
+ name = relative.sub(/_controller\.(js|ts)\z/, "").tr("/", "--")
32
+ content = File.read(path)
33
+
34
+ {
35
+ name: name,
36
+ file: relative,
37
+ targets: extract_targets(content),
38
+ values: extract_values(content),
39
+ actions: extract_actions(content)
40
+ }
41
+ rescue => e
42
+ { name: File.basename(path), error: e.message }
43
+ end
44
+
45
+ def extract_targets(content)
46
+ match = content.match(/static\s+targets\s*=\s*\[([^\]]*)\]/)
47
+ return [] unless match
48
+
49
+ match[1].scan(/["'](\w+)["']/).flatten
50
+ end
51
+
52
+ def extract_values(content)
53
+ match = content.match(/static\s+values\s*=\s*\{([^}]*)\}/m)
54
+ return {} unless match
55
+
56
+ match[1].scan(/(\w+)\s*:\s*(\w+)/).to_h
57
+ end
58
+
59
+ def extract_actions(content)
60
+ content.scan(/^\s*(\w+)\s*\(/).flatten
61
+ .reject { |m| %w[constructor connect disconnect initialize].include?(m) }
62
+ end
63
+ end
64
+ end
65
+ end