rails-ai-context 5.16.0 → 5.16.2
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 +4 -4
- data/CHANGELOG.md +81 -0
- data/README.md +3 -3
- data/app/controllers/rails_ai_context/mcp_controller.rb +68 -4
- data/docs/ARCHITECTURE.md +4 -4
- data/docs/CLI.md +2 -2
- data/docs/CUSTOM_TOOLS.md +2 -2
- data/docs/FAQ.md +3 -3
- data/docs/GUIDE.md +4 -4
- data/docs/QUICKSTART.md +1 -1
- data/docs/SECURITY.md +2 -2
- data/docs/SETUP.md +3 -1
- data/docs/STANDALONE.md +2 -2
- data/docs/TOOLS.md +2 -2
- data/docs/TROUBLESHOOTING.md +2 -2
- data/docs/index.md +1 -1
- data/exe/rails-ai-context +29 -5
- data/lib/rails_ai_context/resources.rb +11 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +10 -7
- data/lib/rails_ai_context/tools/get_conventions.rb +18 -1
- data/lib/rails_ai_context/tools/get_helper_methods.rb +19 -4
- data/lib/rails_ai_context/tools/review_changes.rb +16 -0
- data/lib/rails_ai_context/tools/validate.rb +20 -6
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +26 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eea6e2b178ca5641f69ba9e78d20faa743e9639c706b2a8f847e60f854618b97
|
|
4
|
+
data.tar.gz: d688b1ac2b70859c0f2cdfecfb731bf0dda802743924664967f15680b4e9042f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb7f069772ba9a58014b905eb68629cf8024b767c7cd01f409fbcd4418f252e04b7d0618c39160bcc7f2364e4535240372da158d267284f721fb365409216699
|
|
7
|
+
data.tar.gz: b22aac0130e6ee73829982f5dce526036c5e944ed2104f93664ec89868df7a1db26ea12412f5d3c6d73c7daa9a4421e2e61bf7054f67814fde12b9da70c3a206
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,87 @@ 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.16.2] - 2026-07-17
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **Standalone static tier works when installed via `gem install`.**
|
|
13
|
+
`tool <name> --no-boot`, `tool <name> --help`, and `tool --list` outside
|
|
14
|
+
an app all died with `cannot load such file -- active_support/...`. The
|
|
15
|
+
RubyGems binstub eagerly activates the whole dependency tree, so the
|
|
16
|
+
exe's framework-path strip left every rails-family spec still flagged as
|
|
17
|
+
activated and `Gem.try_activate` refused to re-add the stripped paths.
|
|
18
|
+
The exe now stashes the stripped specs and splices their load paths back
|
|
19
|
+
at every app-less require site (Bundler-pinned paths still win after a
|
|
20
|
+
boot). Verified through the real binstub in an empty directory, in an
|
|
21
|
+
app whose Gemfile does not include the gem, and on the boot-failed
|
|
22
|
+
static fallback.
|
|
23
|
+
- **Engine-mounted MCP survives mcp SDK 0.24.** The SDK's SSE writer now
|
|
24
|
+
calls `stream.flush` after every event, and
|
|
25
|
+
`ActionController::Live::Buffer` defines no `flush` - every SSE-mode
|
|
26
|
+
`tools/call` through `mount RailsAiContext::Engine` 500'd on all
|
|
27
|
+
supported Rails versions (the payload still arrived, then the request
|
|
28
|
+
died). The controller now hands the transport a flush-capable stream.
|
|
29
|
+
Verified with live curl sessions on booted Rails 7.1 and 8.1 apps.
|
|
30
|
+
- **Engine-mounted GET streams actually stay open.** The transport
|
|
31
|
+
registers the server-push stream and returns; Live then closed the
|
|
32
|
+
response as soon as the action returned, so the SSE channel died
|
|
33
|
+
instantly, `notifications/tools/list_changed` could never be delivered,
|
|
34
|
+
and (once held open) clients would have waited up to 30s for response
|
|
35
|
+
headers. The action now commits headers immediately with an SSE comment
|
|
36
|
+
and holds the thread until the transport or the client closes the
|
|
37
|
+
stream. SETUP.md documents the one-thread-per-connected-client cost.
|
|
38
|
+
- **Helpers keep their namespaces.** `app/helpers/admin/dashboard_helper.rb`
|
|
39
|
+
was reported as `DashboardHelper`, and looking it up by its real constant
|
|
40
|
+
name `Admin::DashboardHelper` failed. Module names now derive from the
|
|
41
|
+
path under `app/helpers` (`app/helpers/concerns` stays its own root,
|
|
42
|
+
matching the railties autoload glob), and exact path matches win before
|
|
43
|
+
basename fallbacks so a top-level helper is not shadowed by a namespaced
|
|
44
|
+
one.
|
|
45
|
+
- **`rails_validate` checks qualified render targets in Ruby files.**
|
|
46
|
+
`render partial: "posts/missing"` in a controller or service passed
|
|
47
|
+
`level:"rails"` silently (the partial check only ran for ERB). Bare
|
|
48
|
+
`render "posts/show"` keeps template semantics in Ruby files, so the
|
|
49
|
+
controller template-render idiom is not false-flagged. Tool descriptions
|
|
50
|
+
now state exactly which column references are checked
|
|
51
|
+
(validates/permit/callbacks).
|
|
52
|
+
- **`rails_review_changes` describes untracked files.** New files rendered
|
|
53
|
+
as empty headings (a fresh app right after `rails new` plus install
|
|
54
|
+
showed 20 of them); they now read `_new file, N lines_`, and only the
|
|
55
|
+
HEAD flow makes that claim (a committed-then-reverted file against an
|
|
56
|
+
older ref reads `_no diff available_`).
|
|
57
|
+
- **Views resource resolves extension-less paths.** Reading
|
|
58
|
+
`rails-ai-context://views/posts/index` now serves
|
|
59
|
+
`posts/index.html.erb`; a same-named directory no longer defeats the
|
|
60
|
+
lookup, and sensitive-file candidates are rejected so the
|
|
61
|
+
not-found/not-allowed message split cannot act as an existence oracle
|
|
62
|
+
for secrets placed under `app/views`.
|
|
63
|
+
- **Resource templates accept both URI schemes.** `rails://` and
|
|
64
|
+
`rails-ai-context://` now both resolve for the controllers, views, and
|
|
65
|
+
routes templates, and contents echo the URI the client requested.
|
|
66
|
+
- **`preset` output no longer scrambles under pipes** in either CLI:
|
|
67
|
+
all framing (banner, separators, "Running:" labels) goes to stderr and
|
|
68
|
+
stdout carries pure tool output.
|
|
69
|
+
- **Context files stop leaking internal tokens.** Architecture sections
|
|
70
|
+
showed raw keys (`concerns_models`, `pwa`, `solid_queue`) next to
|
|
71
|
+
humanized labels; every token the convention introspector emits now has
|
|
72
|
+
a label. Stale doc counts corrected across README and docs (39 tools,
|
|
73
|
+
25 doctor checks).
|
|
74
|
+
|
|
75
|
+
## [5.16.1] - 2026-07-12
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- **Engine-mounted MCP works on Rails 7.0-7.2** (caught by the release e2e
|
|
80
|
+
matrix minutes after 5.16.0 shipped; 8.x was unaffected). The MCP
|
|
81
|
+
transport answers SSE-mode requests with a Rack 3 streaming body (a
|
|
82
|
+
Proc); Rails 7.x's response buffer fed that Proc through Rack::ETag,
|
|
83
|
+
which 500'd every `tools/call` through
|
|
84
|
+
`mount RailsAiContext::Engine, at: "/mcp"`. `McpController` now includes
|
|
85
|
+
`ActionController::Live` and pumps callable bodies through the live
|
|
86
|
+
stream (enumerable bodies are joined to a plain string), verified with
|
|
87
|
+
real curl MCP sessions against booted Rails 7.0, 7.1, 7.2, and 8.0 apps.
|
|
88
|
+
|
|
8
89
|
## [5.16.0] - 2026-07-12
|
|
9
90
|
|
|
10
91
|
### Fixed
|
data/README.md
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
<br>
|
|
22
22
|
[](https://github.com/crisnahine/rails-ai-context)
|
|
23
23
|
[](https://github.com/crisnahine/rails-ai-context)
|
|
24
|
-
[](https://github.com/crisnahine/rails-ai-context/actions)
|
|
25
25
|
[](LICENSE)
|
|
26
26
|
|
|
27
27
|
</div>
|
|
@@ -245,7 +245,7 @@ rails 'ai:tool[stimulus]' controller=chart
|
|
|
245
245
|
|
|
246
246
|
<br>
|
|
247
247
|
|
|
248
|
-
##
|
|
248
|
+
## 39 Tools
|
|
249
249
|
|
|
250
250
|
Every tool is **read-only** and returns data verified against your actual app - not guesses, not training data.
|
|
251
251
|
|
|
@@ -509,7 +509,7 @@ empty listings.
|
|
|
509
509
|
|
|
510
510
|
## Build your own tools
|
|
511
511
|
|
|
512
|
-
Register custom MCP tools alongside the
|
|
512
|
+
Register custom MCP tools alongside the 39 built-in ones:
|
|
513
513
|
|
|
514
514
|
```ruby
|
|
515
515
|
# app/mcp_tools/rails_get_business_metrics.rb
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
3
5
|
module RailsAiContext
|
|
4
6
|
# Rails controller for serving MCP over Streamable HTTP.
|
|
5
7
|
# Alternative to the Rack middleware - integrates with Rails routing,
|
|
@@ -7,11 +9,73 @@ module RailsAiContext
|
|
|
7
9
|
#
|
|
8
10
|
# Mount in routes: mount RailsAiContext::Engine, at: "/mcp"
|
|
9
11
|
class McpController < ActionController::API
|
|
12
|
+
# Live: the transport answers SSE-mode requests with a Rack 3 streaming
|
|
13
|
+
# body (a Proc that writes to a stream). Handing that Proc to a plain
|
|
14
|
+
# controller works only where the response body passes through untouched;
|
|
15
|
+
# Rails 7.x's response buffer feeds it through Rack::ETag, which 500s on
|
|
16
|
+
# the callable. Live's stream is exactly the interface the Proc expects
|
|
17
|
+
# (write/close) and keeps every middleware away from the body on all
|
|
18
|
+
# supported Rails versions.
|
|
19
|
+
include ActionController::Live
|
|
20
|
+
|
|
21
|
+
# The MCP SDK's SSE writer calls stream.flush after every event, but
|
|
22
|
+
# Live's buffer writes straight through to the client and defines no
|
|
23
|
+
# flush - the NoMethodError would 500 the request after the payload was
|
|
24
|
+
# already delivered and tear down long-lived GET streams.
|
|
25
|
+
class FlushableStream < SimpleDelegator
|
|
26
|
+
def flush; end
|
|
27
|
+
end
|
|
28
|
+
|
|
10
29
|
def handle
|
|
11
|
-
|
|
12
|
-
self.status =
|
|
13
|
-
|
|
14
|
-
|
|
30
|
+
status_code, rack_headers, body = self.class.mcp_transport.handle_request(request)
|
|
31
|
+
self.status = status_code
|
|
32
|
+
rack_headers.each { |k, v| response.headers[k] = v }
|
|
33
|
+
|
|
34
|
+
if body.respond_to?(:each)
|
|
35
|
+
# Plain enumerable body (initialize, errors, JSON mode): join to a
|
|
36
|
+
# string so Content-Length/ETag semantics stay conventional.
|
|
37
|
+
chunks = []
|
|
38
|
+
body.each { |chunk| chunks << chunk }
|
|
39
|
+
body.close if body.respond_to?(:close)
|
|
40
|
+
self.response_body = chunks.join
|
|
41
|
+
elsif body.respond_to?(:call)
|
|
42
|
+
stream = response.stream
|
|
43
|
+
stream = FlushableStream.new(stream) unless stream.respond_to?(:flush)
|
|
44
|
+
begin
|
|
45
|
+
body.call(stream)
|
|
46
|
+
# A GET opens the server-push channel: the transport registers the
|
|
47
|
+
# stream and returns, expecting it to outlive this call. Live closes
|
|
48
|
+
# the response when the action returns, so hold the thread until the
|
|
49
|
+
# transport's keepalive (or the client) closes the stream. Live also
|
|
50
|
+
# sends headers only on the first write - the transport writes
|
|
51
|
+
# nothing until its first keepalive ping, so commit with an SSE
|
|
52
|
+
# comment up front or clients sit waiting on headers.
|
|
53
|
+
if request.get?
|
|
54
|
+
begin
|
|
55
|
+
stream.write(": connected\n\n")
|
|
56
|
+
rescue IOError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
wait_for_stream_close
|
|
60
|
+
end
|
|
61
|
+
ensure
|
|
62
|
+
begin
|
|
63
|
+
response.stream.close
|
|
64
|
+
rescue StandardError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
else
|
|
69
|
+
self.response_body = body
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def wait_for_stream_close
|
|
76
|
+
sleep 0.5 until response.stream.closed?
|
|
77
|
+
rescue IOError
|
|
78
|
+
nil
|
|
15
79
|
end
|
|
16
80
|
|
|
17
81
|
class << self
|
data/docs/ARCHITECTURE.md
CHANGED
|
@@ -30,7 +30,7 @@ graph TD
|
|
|
30
30
|
H["Hydration Layer\nSchema hints\ninjected into\ntool responses"]
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
engine --> R["Tool Registry\
|
|
33
|
+
engine --> R["Tool Registry\n39 tools auto-discovered via inherited\n+ custom_tools - skip_tools = active tools"]
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
R --> MCP
|
|
@@ -40,7 +40,7 @@ graph TD
|
|
|
40
40
|
subgraph outputs["Output"]
|
|
41
41
|
direction LR
|
|
42
42
|
MCP["MCP Server\nstdio / HTTP\nResources\nVFS URIs"]
|
|
43
|
-
CLI["CLI Runner\nRake / Thor\nSame
|
|
43
|
+
CLI["CLI Runner\nRake / Thor\nSame 39 tools\nNo server needed"]
|
|
44
44
|
S["Serializers\n14 modules\nStatic files\nPer-AI-tool"]
|
|
45
45
|
end
|
|
46
46
|
|
|
@@ -192,7 +192,7 @@ Thor-based CLI that works standalone (no Gemfile entry):
|
|
|
192
192
|
|
|
193
193
|
- `ToolRunner` - Parses CLI args, resolves tool names, executes tools, formats output
|
|
194
194
|
- Supports `--json` mode for machine-readable output
|
|
195
|
-
- Same
|
|
195
|
+
- Same 39 tools available as MCP and CLI
|
|
196
196
|
|
|
197
197
|
### Caching
|
|
198
198
|
|
|
@@ -215,7 +215,7 @@ SHA256-based change detection:
|
|
|
215
215
|
## Key design decisions
|
|
216
216
|
|
|
217
217
|
1. **Official MCP SDK** - Not a custom protocol. Uses `mcp` gem's `MCP::Tool`, `MCP::Server`, transports.
|
|
218
|
-
2. **Read-only tools** - All
|
|
218
|
+
2. **Read-only tools** - All 39 tools annotated as non-destructive. Defense-in-depth for query tool.
|
|
219
219
|
3. **Graceful degradation** - Works without database (parses schema.rb as text), without Brakeman, without ripgrep, without listen gem.
|
|
220
220
|
4. **Zeitwerk autoloading** - Files loaded on-demand. No `require_relative` in the gem.
|
|
221
221
|
5. **Diff-aware generation** - Context file regeneration skips unchanged files using fingerprinting.
|
data/docs/CLI.md
CHANGED
|
@@ -40,7 +40,7 @@ rails-ai-context serve --transport http --port 6029 # HTTP transport
|
|
|
40
40
|
|
|
41
41
|
### `tool`
|
|
42
42
|
|
|
43
|
-
Run any of the
|
|
43
|
+
Run any of the 39 MCP tools from the terminal.
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
46
|
# Rake syntax
|
|
@@ -93,7 +93,7 @@ rails-ai-context context --format claude # Specific format
|
|
|
93
93
|
|
|
94
94
|
### `doctor`
|
|
95
95
|
|
|
96
|
-
Run
|
|
96
|
+
Run the diagnostic checks and report an AI readiness score.
|
|
97
97
|
|
|
98
98
|
```bash
|
|
99
99
|
rails ai:doctor
|
data/docs/CUSTOM_TOOLS.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Custom Tools
|
|
4
4
|
|
|
5
|
-
**Build your own MCP tools that run alongside the
|
|
5
|
+
**Build your own MCP tools that run alongside the 39 built-in ones.**
|
|
6
6
|
|
|
7
7
|
[Tools Reference](TOOLS.md) · [Configuration](CONFIGURATION.md) · [Architecture](ARCHITECTURE.md) · [FAQ](FAQ.md)
|
|
8
8
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
> [!NOTE]
|
|
14
|
-
> Custom tools have full access to your Rails environment - ActiveRecord, services, mailers, everything. They appear alongside the
|
|
14
|
+
> Custom tools have full access to your Rails environment - ActiveRecord, services, mailers, everything. They appear alongside the 39 built-in tools in both MCP and CLI.
|
|
15
15
|
|
|
16
16
|
## Creating a custom tool
|
|
17
17
|
|
data/docs/FAQ.md
CHANGED
|
@@ -25,7 +25,7 @@ Claude Code, Cursor, GitHub Copilot, OpenCode, and Codex CLI. Each gets tailored
|
|
|
25
25
|
No. The gem works three ways:
|
|
26
26
|
1. **MCP server** - AI calls tools via the protocol (best experience)
|
|
27
27
|
2. **Static files** - Generated context files (CLAUDE.md, .cursor/rules/, etc.)
|
|
28
|
-
3. **CLI** - Same
|
|
28
|
+
3. **CLI** - Same 39 tools from the terminal, no server needed
|
|
29
29
|
|
|
30
30
|
### Is this safe for production?
|
|
31
31
|
|
|
@@ -47,7 +47,7 @@ Yes. The gem gracefully degrades - it parses `db/schema.rb` as text when no data
|
|
|
47
47
|
|
|
48
48
|
### Can I switch between Gemfile and standalone?
|
|
49
49
|
|
|
50
|
-
Yes, freely. Both generate identical context files and provide the same
|
|
50
|
+
Yes, freely. Both generate identical context files and provide the same 39 tools. Just re-run the install/init to update MCP config files.
|
|
51
51
|
|
|
52
52
|
### Do I need to commit the generated files?
|
|
53
53
|
|
|
@@ -69,7 +69,7 @@ Start with `rails_onboard` for an app overview, `rails_analyze_feature` for feat
|
|
|
69
69
|
|
|
70
70
|
### Can I add my own tools?
|
|
71
71
|
|
|
72
|
-
Yes. See [Custom Tools](CUSTOM_TOOLS.md). Create an `MCP::Tool` subclass, register it via `config.custom_tools`, and it appears alongside the
|
|
72
|
+
Yes. See [Custom Tools](CUSTOM_TOOLS.md). Create an `MCP::Tool` subclass, register it via `config.custom_tools`, and it appears alongside the 39 built-in tools.
|
|
73
73
|
|
|
74
74
|
### Can I remove built-in tools?
|
|
75
75
|
|
data/docs/GUIDE.md
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
| Guide | Description |
|
|
19
19
|
|:------|:------------|
|
|
20
20
|
| [Quickstart](QUICKSTART.md) | Get running in 5 minutes |
|
|
21
|
-
| [Tools Reference](TOOLS.md) | All
|
|
21
|
+
| [Tools Reference](TOOLS.md) | All 39 MCP tools with parameters |
|
|
22
22
|
| [Recipes](RECIPES.md) | Real-world workflows and examples |
|
|
23
23
|
| [Custom Tools](CUSTOM_TOOLS.md) | Build your own MCP tools |
|
|
24
24
|
| [Configuration](CONFIGURATION.md) | Every config option |
|
|
@@ -283,7 +283,7 @@ rails ai:context:claude # Use this instead (no quoting needed)
|
|
|
283
283
|
|
|
284
284
|
## CLI Tools
|
|
285
285
|
|
|
286
|
-
All
|
|
286
|
+
All 39 MCP tools can be run directly from the terminal - no MCP server or AI client needed.
|
|
287
287
|
|
|
288
288
|
### Rake
|
|
289
289
|
|
|
@@ -349,7 +349,7 @@ The `tool_mode` is selected during `rails generate rails_ai_context:install`.
|
|
|
349
349
|
|
|
350
350
|
## MCP Tools - Full Reference
|
|
351
351
|
|
|
352
|
-
All
|
|
352
|
+
All 39 tools are **read-only** and **idempotent** - they never modify your application or database.
|
|
353
353
|
|
|
354
354
|
### rails_get_schema
|
|
355
355
|
|
|
@@ -1169,7 +1169,7 @@ if defined?(RailsAiContext)
|
|
|
1169
1169
|
end
|
|
1170
1170
|
```
|
|
1171
1171
|
|
|
1172
|
-
Both transports are **read-only** - they expose the same
|
|
1172
|
+
Both transports are **read-only** - they expose the same 39 tools and never modify your app.
|
|
1173
1173
|
|
|
1174
1174
|
### Controller Transport (Alternative)
|
|
1175
1175
|
|
data/docs/QUICKSTART.md
CHANGED
data/docs/SECURITY.md
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
> [!CAUTION]
|
|
14
|
-
> This gem is designed for **development environments**. The query tool is disabled in production by default. Sensitive files are blocked. All
|
|
14
|
+
> This gem is designed for **development environments**. The query tool is disabled in production by default. Sensitive files are blocked. All 39 tools are read-only.
|
|
15
15
|
|
|
16
16
|
## Design principles
|
|
17
17
|
|
|
18
|
-
1. **Read-only by design** - All
|
|
18
|
+
1. **Read-only by design** - All 39 tools are annotated as non-destructive in the MCP protocol
|
|
19
19
|
2. **Defense in depth** - Multiple security layers, not single points of failure
|
|
20
20
|
3. **Sensitive data blocking** - Configurable patterns prevent access to secrets
|
|
21
21
|
4. **Offline by default** - No network calls except optional `rails_search_docs` with `fetch: true`
|
data/docs/SETUP.md
CHANGED
|
@@ -134,7 +134,7 @@ The MCP tools rule uses `alwaysApply: false` with a descriptive `description:` f
|
|
|
134
134
|
|
|
135
135
|
```markdown
|
|
136
136
|
---
|
|
137
|
-
description:
|
|
137
|
+
description: 39 MCP tools for Rails introspection - schema, models, routes, controllers, views
|
|
138
138
|
alwaysApply: false
|
|
139
139
|
---
|
|
140
140
|
```
|
|
@@ -268,6 +268,8 @@ Instead of stdio, you can mount the MCP server inside your Rails app:
|
|
|
268
268
|
mount RailsAiContext::Engine, at: "/mcp"
|
|
269
269
|
```
|
|
270
270
|
|
|
271
|
+
Each connected MCP client that opens the server-push channel (a long-lived SSE `GET /mcp`) holds one server thread for the life of the connection. With Puma's default small thread pool, a handful of connected clients can exhaust it - fine for development, but raise the thread count (or prefer the standalone `rails-ai-context serve --transport http` process) if several clients or other traffic share the app.
|
|
272
|
+
|
|
271
273
|
Then point your AI tool's MCP config to the HTTP endpoint instead of a command:
|
|
272
274
|
|
|
273
275
|
```json
|
data/docs/STANDALONE.md
CHANGED
|
@@ -51,7 +51,7 @@ rails-ai-context version # Show version
|
|
|
51
51
|
2. **Restores `$LOAD_PATH`** entries that `Bundler.setup` strips (since the gem isn't in the Gemfile)
|
|
52
52
|
3. **YAML config** - uses `.rails-ai-context.yml` instead of a Ruby initializer
|
|
53
53
|
|
|
54
|
-
This means you get the same
|
|
54
|
+
This means you get the same 39 tools, same MCP server, same context generation - without touching the project's Gemfile.
|
|
55
55
|
|
|
56
56
|
## Configuration via YAML
|
|
57
57
|
|
|
@@ -140,7 +140,7 @@ bundle remove rails-ai-context
|
|
|
140
140
|
rails-ai-context init
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
-
The MCP config files are updated automatically. Both modes generate identical context files and provide the same
|
|
143
|
+
The MCP config files are updated automatically. Both modes generate identical context files and provide the same 39 tools.
|
|
144
144
|
|
|
145
145
|
## Troubleshooting
|
|
146
146
|
|
data/docs/TOOLS.md
CHANGED
|
@@ -276,8 +276,8 @@ Syntax + semantic + Brakeman security validation in one call.
|
|
|
276
276
|
|
|
277
277
|
| Parameter | Type | Default | Description |
|
|
278
278
|
|:----------|:-----|:--------|:------------|
|
|
279
|
-
| `files` |
|
|
280
|
-
| `level` | enum | `syntax` | `syntax`, `rails
|
|
279
|
+
| `files` | array | required | File paths to validate |
|
|
280
|
+
| `level` | enum | `syntax` | `syntax`, `rails` (rails includes the Brakeman security pass) |
|
|
281
281
|
|
|
282
282
|
### `rails_security_scan`
|
|
283
283
|
|
data/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
> [!TIP]
|
|
14
|
-
> Always start with `rails ai:doctor`. It runs
|
|
14
|
+
> Always start with `rails ai:doctor`. It runs 25 checks and catches most issues automatically.
|
|
15
15
|
|
|
16
16
|
## Diagnostics first
|
|
17
17
|
|
|
@@ -20,7 +20,7 @@ rails ai:doctor # In-Gemfile
|
|
|
20
20
|
rails-ai-context doctor # Standalone
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
This runs
|
|
23
|
+
This runs 25 checks and returns an AI readiness score (0-100). Each failed check includes a fix suggestion.
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
data/docs/index.md
CHANGED
|
@@ -4,7 +4,7 @@ title: Home
|
|
|
4
4
|
|
|
5
5
|
# rails-ai-context Documentation
|
|
6
6
|
|
|
7
|
-
**
|
|
7
|
+
**39 MCP tools that give AI agents live access to your Rails app.**
|
|
8
8
|
|
|
9
9
|
Works with Claude Code, Cursor, GitHub Copilot, OpenCode, and Codex CLI.
|
|
10
10
|
|
data/exe/rails-ai-context
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
# Skip this when running under `bundle exec` — Bundler has already resolved gems
|
|
11
11
|
# correctly, and clearing specs breaks gems that look up their own path at runtime
|
|
12
12
|
# (e.g., MCP SDK reads Gem.loaded_specs["json-schema"].full_gem_path).
|
|
13
|
+
stripped_framework_specs = {}
|
|
13
14
|
if defined?(Gem) && Gem.respond_to?(:loaded_specs) && !ENV["BUNDLE_BIN_PATH"]
|
|
14
15
|
# Also strip the Rails-family gems' lib paths. Leaving them in $LOAD_PATH
|
|
15
16
|
# lets the app's boot resolve framework files from the NEWEST installed
|
|
@@ -27,11 +28,19 @@ if defined?(Gem) && Gem.respond_to?(:loaded_specs) && !ENV["BUNDLE_BIN_PATH"]
|
|
|
27
28
|
].freeze
|
|
28
29
|
Gem.loaded_specs.each_value do |spec|
|
|
29
30
|
next if spec.default_gem? || !rails_family.include?(spec.name)
|
|
31
|
+
stripped_framework_specs[spec.name] = spec
|
|
30
32
|
spec.full_require_paths.each { |path| $LOAD_PATH.delete(path) }
|
|
31
33
|
end
|
|
32
34
|
Gem.loaded_specs.delete_if { |_, spec| !spec.default_gem? }
|
|
33
35
|
end
|
|
34
36
|
|
|
37
|
+
# App-less modes (--no-boot, tool --help, tool --list outside an app) need
|
|
38
|
+
# these paths spliced back by hand: the specs above keep their activated
|
|
39
|
+
# flag after the strip, so Gem.try_activate declines to re-add the paths and
|
|
40
|
+
# any framework require fails. There is no Bundler.setup in those modes to
|
|
41
|
+
# rebuild $LOAD_PATH either.
|
|
42
|
+
STRIPPED_FRAMEWORK_SPECS = stripped_framework_specs.freeze
|
|
43
|
+
|
|
35
44
|
require "thor"
|
|
36
45
|
require "yaml"
|
|
37
46
|
|
|
@@ -73,7 +82,7 @@ class RailsAiContextCLI < Thor
|
|
|
73
82
|
end
|
|
74
83
|
|
|
75
84
|
if args.include?("--help") || args.include?("-h")
|
|
76
|
-
|
|
85
|
+
require_gem_without_app!
|
|
77
86
|
tool_class = ::RailsAiContext::CLI::ToolRunner.new(name, []).tool_class
|
|
78
87
|
puts ::RailsAiContext::CLI::ToolRunner.tool_help(tool_class)
|
|
79
88
|
return
|
|
@@ -295,10 +304,13 @@ class RailsAiContextCLI < Thor
|
|
|
295
304
|
$stderr.puts "=" * 60
|
|
296
305
|
$stderr.puts ""
|
|
297
306
|
|
|
307
|
+
# All framing goes to stderr so stdout stays pure tool output - mixing
|
|
308
|
+
# the two scrambles ordering under pipes (stderr is unbuffered, piped
|
|
309
|
+
# stdout is block-buffered).
|
|
298
310
|
preset[:tools].each do |tool_spec|
|
|
299
311
|
begin
|
|
300
312
|
$stderr.puts "-" * 40
|
|
301
|
-
puts "Running: #{tool_spec[:name]}"
|
|
313
|
+
$stderr.puts "Running: #{tool_spec[:name]}"
|
|
302
314
|
$stderr.puts "-" * 40
|
|
303
315
|
runner = ::RailsAiContext::CLI::ToolRunner.new(
|
|
304
316
|
tool_spec[:name],
|
|
@@ -563,7 +575,7 @@ class RailsAiContextCLI < Thor
|
|
|
563
575
|
if defined?(::RailsAiContext::Server)
|
|
564
576
|
::RailsAiContext::Server.builtin_tools.size.to_s
|
|
565
577
|
else
|
|
566
|
-
"
|
|
578
|
+
"39"
|
|
567
579
|
end
|
|
568
580
|
end
|
|
569
581
|
|
|
@@ -586,7 +598,7 @@ class RailsAiContextCLI < Thor
|
|
|
586
598
|
end
|
|
587
599
|
|
|
588
600
|
begin
|
|
589
|
-
|
|
601
|
+
require_gem_without_app!
|
|
590
602
|
puts ::RailsAiContext::CLI::ToolRunner.tool_list
|
|
591
603
|
puts ""
|
|
592
604
|
puts "Run inside a Rails app to execute tools - static source analysis works even when the app can't boot (see --no-boot)."
|
|
@@ -750,11 +762,23 @@ class RailsAiContextCLI < Thor
|
|
|
750
762
|
$stderr.puts "[rails-ai-context] Try `gem install rails-ai-context` to ensure all transitive deps are installed."
|
|
751
763
|
end
|
|
752
764
|
|
|
765
|
+
# Loads the gem with no booted app. Framework gems whose load paths the
|
|
766
|
+
# top-of-file strip removed are spliced back from the stashed specs -
|
|
767
|
+
# except any gem a completed Bundler.setup already resolved, whose pinned
|
|
768
|
+
# paths must keep winning over the newest installed version.
|
|
769
|
+
def require_gem_without_app!
|
|
770
|
+
STRIPPED_FRAMEWORK_SPECS.each do |name, spec|
|
|
771
|
+
next if Gem.loaded_specs.key?(name)
|
|
772
|
+
spec.full_require_paths.each { |p| $LOAD_PATH.unshift(p) unless $LOAD_PATH.include?(p) }
|
|
773
|
+
end
|
|
774
|
+
require "rails_ai_context"
|
|
775
|
+
end
|
|
776
|
+
|
|
753
777
|
# Static tier: the gem loads standalone (no app constants), introspection
|
|
754
778
|
# runs against the filesystem, and every tool response carries the tier
|
|
755
779
|
# banner so consumers know runtime data is absent.
|
|
756
780
|
def enter_static_tier!(reason)
|
|
757
|
-
|
|
781
|
+
require_gem_without_app!
|
|
758
782
|
::RailsAiContext.tier = :static
|
|
759
783
|
::RailsAiContext.static_reason = reason
|
|
760
784
|
::RailsAiContext.configuration.app_root = Dir.pwd
|
|
@@ -145,6 +145,17 @@ module RailsAiContext
|
|
|
145
145
|
def handle_read(params)
|
|
146
146
|
uri = params[:uri]
|
|
147
147
|
|
|
148
|
+
# The two schemes are historical; accept either one for every
|
|
149
|
+
# template so clients don't have to remember which resource uses
|
|
150
|
+
# which. Bare "rails://controllers" (no path) stays a static
|
|
151
|
+
# resource; models already resolve under both schemes (legacy
|
|
152
|
+
# handler below, VFS for the rails-ai-context:// form). Contents are
|
|
153
|
+
# relabeled with the URI the client actually requested.
|
|
154
|
+
if uri.match?(%r{\Arails://(controllers|views|routes)/.})
|
|
155
|
+
normalized = uri.sub("rails://", "#{VFS::SCHEME}://")
|
|
156
|
+
return VFS.resolve(normalized).map { |content| content.merge(uri: uri) }
|
|
157
|
+
end
|
|
158
|
+
|
|
148
159
|
# Delegate rails-ai-context:// URIs to the VFS dispatcher
|
|
149
160
|
return VFS.resolve(uri) if uri.start_with?("#{VFS::SCHEME}://")
|
|
150
161
|
|
|
@@ -575,16 +575,19 @@ namespace :ai do
|
|
|
575
575
|
end
|
|
576
576
|
|
|
577
577
|
preset = presets[name]
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
puts ""
|
|
578
|
+
# All framing goes to stderr so stdout stays pure tool output - mixing
|
|
579
|
+
# the two scrambles ordering under pipes (stderr is unbuffered, piped
|
|
580
|
+
# stdout is block-buffered).
|
|
581
|
+
$stderr.puts "=" * 60
|
|
582
|
+
$stderr.puts " Preset: #{name} - #{preset[:desc]}"
|
|
583
|
+
$stderr.puts "=" * 60
|
|
584
|
+
$stderr.puts ""
|
|
582
585
|
|
|
583
586
|
preset[:tools].each do |tool_spec|
|
|
584
587
|
begin
|
|
585
|
-
puts "-" * 40
|
|
586
|
-
puts "Running: #{tool_spec[:name]}"
|
|
587
|
-
puts "-" * 40
|
|
588
|
+
$stderr.puts "-" * 40
|
|
589
|
+
$stderr.puts "Running: #{tool_spec[:name]}"
|
|
590
|
+
$stderr.puts "-" * 40
|
|
588
591
|
runner = RailsAiContext::CLI::ToolRunner.new(
|
|
589
592
|
tool_spec[:name],
|
|
590
593
|
tool_spec[:params]
|
|
@@ -111,7 +111,24 @@ module RailsAiContext
|
|
|
111
111
|
"importmaps" => "Import maps (no JS bundler)",
|
|
112
112
|
"docker" => "Dockerized",
|
|
113
113
|
"kamal" => "Kamal deployment",
|
|
114
|
-
"ci_github_actions" => "GitHub Actions CI"
|
|
114
|
+
"ci_github_actions" => "GitHub Actions CI",
|
|
115
|
+
"concerns_models" => "Model concerns (app/models/concerns/)",
|
|
116
|
+
"concerns_controllers" => "Controller concerns (app/controllers/concerns/)",
|
|
117
|
+
"validators" => "Custom validators (app/validators/)",
|
|
118
|
+
"policies" => "Policy objects (app/policies/)",
|
|
119
|
+
"serializers" => "Serializers (app/serializers/)",
|
|
120
|
+
"notifiers" => "Notifiers (app/notifiers/)",
|
|
121
|
+
"phlex" => "Phlex views",
|
|
122
|
+
"pwa" => "PWA (app/views/pwa/)",
|
|
123
|
+
"dry_rb" => "dry-rb stack",
|
|
124
|
+
"multi_tenant" => "Multi-tenancy",
|
|
125
|
+
"feature_flags" => "Feature flags",
|
|
126
|
+
"error_monitoring" => "Error monitoring",
|
|
127
|
+
"event_driven" => "Event-driven messaging",
|
|
128
|
+
"zeitwerk" => "Zeitwerk autoloading",
|
|
129
|
+
"solid_queue" => "Solid Queue (jobs)",
|
|
130
|
+
"solid_cache" => "Solid Cache",
|
|
131
|
+
"solid_cable" => "Solid Cable"
|
|
115
132
|
}.freeze
|
|
116
133
|
|
|
117
134
|
PATTERN_LABELS = {
|
|
@@ -75,16 +75,31 @@ module RailsAiContext
|
|
|
75
75
|
list_helpers(helper_files, real_helpers_dir, real_root, max_size, detail, offset: offset, limit: limit)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
# Module name from the path under app/helpers, so nested helpers keep
|
|
79
|
+
# their namespace (admin/dashboard_helper.rb => Admin::DashboardHelper).
|
|
80
|
+
# app/helpers/concerns is its own autoload root (railties globs
|
|
81
|
+
# "{*,*/concerns}"), so concerns/formattable.rb defines Formattable,
|
|
82
|
+
# not Concerns::Formattable.
|
|
83
|
+
private_class_method def self.module_name_for(file_path, helpers_dir)
|
|
84
|
+
file_path.sub("#{helpers_dir}/", "").delete_prefix("concerns/").delete_suffix(".rb").camelize
|
|
85
|
+
end
|
|
86
|
+
|
|
78
87
|
private_class_method def self.show_helper(name, helper_files, helpers_dir, root, max_size, detail)
|
|
79
|
-
# Find by module name or file name
|
|
88
|
+
# Find by module name (with or without namespace) or file name.
|
|
89
|
+
# Exact relative-path matches win before basename fallbacks so a
|
|
90
|
+
# top-level DashboardHelper isn't shadowed by admin/dashboard_helper.
|
|
80
91
|
underscore = name.underscore.delete_suffix("_helper")
|
|
81
92
|
file_path = helper_files.find do |f|
|
|
93
|
+
rel = f.sub("#{helpers_dir}/", "").delete_suffix(".rb")
|
|
94
|
+
rel == name.underscore || rel == "#{underscore}_helper"
|
|
95
|
+
end
|
|
96
|
+
file_path ||= helper_files.find do |f|
|
|
82
97
|
basename = File.basename(f, ".rb")
|
|
83
98
|
basename == "#{underscore}_helper" || basename == underscore || basename == name.underscore
|
|
84
99
|
end
|
|
85
100
|
|
|
86
101
|
unless file_path
|
|
87
|
-
available = helper_files.map { |f|
|
|
102
|
+
available = helper_files.map { |f| module_name_for(f, helpers_dir) }
|
|
88
103
|
return not_found_response("Helper", name, available,
|
|
89
104
|
recovery_tool: "Call rails_get_helper_methods() to see all helpers")
|
|
90
105
|
end
|
|
@@ -96,7 +111,7 @@ module RailsAiContext
|
|
|
96
111
|
source = RailsAiContext::SafeFile.read(file_path)
|
|
97
112
|
return text_response("Could not read helper file: #{file_path}") unless source
|
|
98
113
|
relative_path = file_path.sub("#{root}/", "")
|
|
99
|
-
module_name =
|
|
114
|
+
module_name = module_name_for(file_path, helpers_dir)
|
|
100
115
|
|
|
101
116
|
lines = [ "# #{module_name}", "" ]
|
|
102
117
|
lines << "**File:** `#{relative_path}` (#{source.lines.size} lines)"
|
|
@@ -149,7 +164,7 @@ module RailsAiContext
|
|
|
149
164
|
private_class_method def self.list_helpers(helper_files, helpers_dir, root, max_size, detail, offset: 0, limit: nil)
|
|
150
165
|
helpers_data = helper_files.filter_map do |file_path|
|
|
151
166
|
relative = file_path.sub("#{root}/", "")
|
|
152
|
-
module_name =
|
|
167
|
+
module_name = module_name_for(file_path, helpers_dir)
|
|
153
168
|
|
|
154
169
|
if File.size(file_path) <= max_size
|
|
155
170
|
source = RailsAiContext::SafeFile.read(file_path)
|
|
@@ -178,6 +178,22 @@ module RailsAiContext
|
|
|
178
178
|
lines.concat(content_lines.map(&:rstrip))
|
|
179
179
|
lines << "```"
|
|
180
180
|
end
|
|
181
|
+
else
|
|
182
|
+
# In the HEAD flow a nil diff means the file is untracked -
|
|
183
|
+
# summarize it so the section isn't an empty heading. Against an
|
|
184
|
+
# arbitrary ref a nil diff can also mean a committed change that
|
|
185
|
+
# was later reverted in the working tree, so don't claim "new".
|
|
186
|
+
full_path = File.join(root, file)
|
|
187
|
+
if ref == "HEAD" && File.file?(full_path)
|
|
188
|
+
line_count = begin
|
|
189
|
+
File.foreach(full_path).count
|
|
190
|
+
rescue StandardError
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
lines << (line_count ? "_new file, #{line_count} lines_" : "_new file_")
|
|
194
|
+
else
|
|
195
|
+
lines << "_no diff available_"
|
|
196
|
+
end
|
|
181
197
|
end
|
|
182
198
|
|
|
183
199
|
# Pull relevant context per file type
|
|
@@ -11,7 +11,8 @@ module RailsAiContext
|
|
|
11
11
|
tool_name "rails_validate"
|
|
12
12
|
description "Validate syntax and semantics of Ruby, ERB, and JavaScript files in a single call. " \
|
|
13
13
|
"Use when: after editing files, before committing, to catch syntax errors and Rails-specific issues. " \
|
|
14
|
-
"Pass files:[\"app/models/user.rb\"], use level:\"rails\" for semantic checks (missing partials,
|
|
14
|
+
"Pass files:[\"app/models/user.rb\"], use level:\"rails\" for semantic checks (missing partials, route helpers, " \
|
|
15
|
+
"column refs in validates/permit/callbacks)."
|
|
15
16
|
|
|
16
17
|
def self.max_files
|
|
17
18
|
RailsAiContext.configuration.max_validate_files
|
|
@@ -27,7 +28,7 @@ module RailsAiContext
|
|
|
27
28
|
level: {
|
|
28
29
|
type: "string",
|
|
29
30
|
enum: %w[syntax rails],
|
|
30
|
-
description: "Validation level. syntax: check syntax only (default, fast). rails: syntax + semantic checks (partial existence, route helpers, column
|
|
31
|
+
description: "Validation level. syntax: check syntax only (default, fast). rails: syntax + semantic checks (partial existence, route helpers, column refs in validates/permit/callbacks)."
|
|
31
32
|
}
|
|
32
33
|
},
|
|
33
34
|
required: %w[files]
|
|
@@ -398,14 +399,14 @@ module RailsAiContext
|
|
|
398
399
|
args.each do |arg|
|
|
399
400
|
case arg
|
|
400
401
|
when Prism::StringNode
|
|
401
|
-
@render_calls << { name: arg.unescaped, line: node.location.start_line }
|
|
402
|
+
@render_calls << { name: arg.unescaped, line: node.location.start_line, explicit_partial: false }
|
|
402
403
|
when Prism::KeywordHashNode
|
|
403
404
|
arg.elements.each do |elem|
|
|
404
405
|
next unless elem.is_a?(Prism::AssocNode)
|
|
405
406
|
key = elem.key
|
|
406
407
|
val = elem.value
|
|
407
408
|
if key.is_a?(Prism::SymbolNode) && key.value == "partial" && val.is_a?(Prism::StringNode)
|
|
408
|
-
@render_calls << { name: val.unescaped, line: node.location.start_line }
|
|
409
|
+
@render_calls << { name: val.unescaped, line: node.location.start_line, explicit_partial: true }
|
|
409
410
|
end
|
|
410
411
|
end
|
|
411
412
|
end
|
|
@@ -494,6 +495,7 @@ module RailsAiContext
|
|
|
494
495
|
elsif file.end_with?(".rb")
|
|
495
496
|
if visitor
|
|
496
497
|
warnings.concat(check_route_helpers_ast(visitor, context))
|
|
498
|
+
warnings.concat(check_partial_existence_ast(file, visitor, qualified_only: true))
|
|
497
499
|
warnings.concat(check_column_references_ast(file, visitor, context))
|
|
498
500
|
warnings.concat(check_strong_params_ast(file, visitor, context))
|
|
499
501
|
warnings.concat(check_callback_existence_ast(file, visitor, context))
|
|
@@ -536,19 +538,31 @@ module RailsAiContext
|
|
|
536
538
|
|
|
537
539
|
# ── CHECK 1: Partial existence (AST) ─────────────────────────────
|
|
538
540
|
|
|
539
|
-
|
|
541
|
+
# qualified_only: a Ruby file has no view directory to resolve bare
|
|
542
|
+
# partial names against, so only "dir/name" references are checkable.
|
|
543
|
+
private_class_method def self.check_partial_existence_ast(file, visitor, qualified_only: false)
|
|
540
544
|
warnings = []
|
|
541
545
|
visitor.render_calls.each do |rc|
|
|
542
546
|
ref = rc[:name]
|
|
543
547
|
next if ref.include?("@") || ref.include?("#") || ref.include?("{")
|
|
548
|
+
next if qualified_only && !ref.include?("/")
|
|
544
549
|
possible = resolve_partial_paths(file, ref)
|
|
550
|
+
# In a Ruby file, bare `render "dir/name"` renders the TEMPLATE
|
|
551
|
+
# (only `partial:` has partial semantics there), so accept either.
|
|
552
|
+
template_semantics = qualified_only && !rc[:explicit_partial]
|
|
553
|
+
possible += resolve_template_paths(ref) if template_semantics
|
|
545
554
|
unless possible.any? { |p| File.exist?(File.join(rails_app.root, "app", "views", p)) }
|
|
546
|
-
|
|
555
|
+
label = template_semantics ? "template/partial not found" : "partial not found"
|
|
556
|
+
warnings << "render \"#{ref}\" - #{label} (checked: #{possible.first(2).join(', ')})"
|
|
547
557
|
end
|
|
548
558
|
end
|
|
549
559
|
warnings
|
|
550
560
|
end
|
|
551
561
|
|
|
562
|
+
private_class_method def self.resolve_template_paths(ref)
|
|
563
|
+
%w[.html.erb .erb .turbo_stream.erb .json.jbuilder].map { |ext| "#{ref}#{ext}" }
|
|
564
|
+
end
|
|
565
|
+
|
|
552
566
|
# Regex fallback for non-Prism environments
|
|
553
567
|
private_class_method def self.check_partial_existence_regex(file, content)
|
|
554
568
|
warnings = []
|
data/lib/rails_ai_context/vfs.rb
CHANGED
|
@@ -137,8 +137,32 @@ module RailsAiContext
|
|
|
137
137
|
views_dir = Rails.root.join("app", "views")
|
|
138
138
|
full_path = views_dir.join(path)
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
# Extension-less lookup: "posts/index" resolves to the template file
|
|
141
|
+
# itself (posts/index.html.erb) so callers don't have to know the
|
|
142
|
+
# format/handler suffix chain. File.file? so a same-named directory
|
|
143
|
+
# (e.g. a posts/index/ partial dir) doesn't defeat the fallback.
|
|
144
|
+
unless File.file?(full_path)
|
|
145
|
+
dir = File.dirname(path)
|
|
146
|
+
base = File.basename(path)
|
|
147
|
+
parent = dir == "." ? views_dir.to_s : views_dir.join(dir).to_s
|
|
148
|
+
candidates = begin
|
|
149
|
+
Dir.children(parent).select { |e| e.start_with?("#{base}.") && File.file?(File.join(parent, e)) }
|
|
150
|
+
rescue SystemCallError
|
|
151
|
+
[]
|
|
152
|
+
end
|
|
153
|
+
# Never let the fallback land on a sensitive file: "master" matching
|
|
154
|
+
# master.key would turn the not-found/not-allowed message split into
|
|
155
|
+
# an existence oracle for secrets placed under app/views.
|
|
156
|
+
candidates.reject! { |e| RailsAiContext::Tools::BaseTool.send(:sensitive_file?, dir == "." ? e : File.join(dir, e)) }
|
|
157
|
+
if candidates.any?
|
|
158
|
+
chosen = candidates.min_by { |e| [ e.end_with?(".html.erb") ? 0 : 1, e ] }
|
|
159
|
+
path = dir == "." ? chosen : File.join(dir, chosen)
|
|
160
|
+
full_path = views_dir.join(path)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
unless File.file?(full_path)
|
|
165
|
+
content = JSON.pretty_generate(error: "View not found: #{path}. Paths are relative to app/views; the extension is optional (posts/index and posts/index.html.erb both resolve).")
|
|
142
166
|
return [ { uri: uri, mimeType: "application/json", text: content } ]
|
|
143
167
|
end
|
|
144
168
|
|