rails-ai-context 4.3.0 → 4.3.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 +58 -8
- data/CLAUDE.md +4 -4
- data/CONTRIBUTING.md +1 -1
- data/README.md +7 -7
- data/SECURITY.md +2 -1
- data/docs/GUIDE.md +3 -3
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -2
- data/lib/rails_ai_context/configuration.rb +4 -2
- data/lib/rails_ai_context/doctor.rb +6 -1
- data/lib/rails_ai_context/fingerprinter.rb +24 -0
- data/lib/rails_ai_context/introspectors/component_introspector.rb +122 -7
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +31 -26
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +183 -6
- data/lib/rails_ai_context/introspectors/view_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +61 -8
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +13 -22
- data/lib/rails_ai_context/serializers/claude_serializer.rb +15 -3
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +15 -3
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -5
- data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +0 -1
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +15 -9
- data/lib/rails_ai_context/server.rb +8 -1
- data/lib/rails_ai_context/tools/analyze_feature.rb +24 -1
- data/lib/rails_ai_context/tools/base_tool.rb +78 -1
- data/lib/rails_ai_context/tools/dependency_graph.rb +4 -1
- data/lib/rails_ai_context/tools/diagnose.rb +135 -8
- data/lib/rails_ai_context/tools/generate_test.rb +87 -7
- data/lib/rails_ai_context/tools/get_callbacks.rb +27 -4
- data/lib/rails_ai_context/tools/get_component_catalog.rb +11 -2
- data/lib/rails_ai_context/tools/get_context.rb +71 -8
- data/lib/rails_ai_context/tools/get_conventions.rb +59 -0
- data/lib/rails_ai_context/tools/get_design_system.rb +45 -7
- data/lib/rails_ai_context/tools/get_edit_context.rb +3 -2
- data/lib/rails_ai_context/tools/get_env.rb +51 -24
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +100 -9
- data/lib/rails_ai_context/tools/get_model_details.rb +20 -0
- data/lib/rails_ai_context/tools/get_partial_interface.rb +12 -5
- data/lib/rails_ai_context/tools/get_schema.rb +1 -0
- data/lib/rails_ai_context/tools/get_stimulus.rb +13 -7
- data/lib/rails_ai_context/tools/get_turbo_map.rb +35 -2
- data/lib/rails_ai_context/tools/get_view.rb +65 -9
- data/lib/rails_ai_context/tools/migration_advisor.rb +10 -3
- data/lib/rails_ai_context/tools/onboard.rb +413 -27
- data/lib/rails_ai_context/tools/performance_check.rb +45 -28
- data/lib/rails_ai_context/tools/query.rb +28 -2
- data/lib/rails_ai_context/tools/read_logs.rb +4 -1
- data/lib/rails_ai_context/tools/review_changes.rb +27 -17
- data/lib/rails_ai_context/tools/runtime_info.rb +289 -0
- data/lib/rails_ai_context/tools/search_code.rb +23 -4
- data/lib/rails_ai_context/tools/security_scan.rb +7 -1
- data/lib/rails_ai_context/tools/session_context.rb +137 -0
- data/lib/rails_ai_context/tools/validate.rb +5 -0
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +6 -4
|
@@ -24,20 +24,20 @@ module RailsAiContext
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def tools_header
|
|
27
|
-
"## Tools (
|
|
27
|
+
"## Tools (39) — MANDATORY, Use Before Read"
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def tools_intro
|
|
31
31
|
case tool_mode
|
|
32
32
|
when :cli
|
|
33
33
|
[
|
|
34
|
-
"This project has
|
|
34
|
+
"This project has 39 introspection tools. **MANDATORY — use these instead of reading files.**",
|
|
35
35
|
"They return only relevant, structured data and save tokens. Read files ONLY when you are about to Edit them.",
|
|
36
36
|
""
|
|
37
37
|
]
|
|
38
38
|
else
|
|
39
39
|
[
|
|
40
|
-
"This project has
|
|
40
|
+
"This project has 39 MCP tools via `rails ai:serve` (configured in `.mcp.json`).",
|
|
41
41
|
"**MANDATORY — use these instead of reading files.** They return structured data and save tokens.",
|
|
42
42
|
"Read files ONLY when you are about to Edit them.",
|
|
43
43
|
"If MCP tools are not connected, use CLI fallback: `#{cli_cmd("TOOL_NAME", "param=value")}`",
|
|
@@ -127,7 +127,7 @@ module RailsAiContext
|
|
|
127
127
|
"",
|
|
128
128
|
"- **Don't read db/schema.rb** — use `get_schema`. It adds [indexed]/[unique] hints you'd miss.",
|
|
129
129
|
"- **Don't read model files for reference** — use `get_model_details`. It resolves concerns, inherited methods, and implicit belongs_to validations.",
|
|
130
|
-
"- **
|
|
130
|
+
"- **Prefer `#{search_tool}` over Grep** for method tracing and cross-layer search. It excludes sensitive files, supports `match_type:\"trace\"`, and paginates.",
|
|
131
131
|
"- **Don't call tools without a target** — `get_model_details()` without `model:` returns a paginated list, not an error. Always specify what you want.",
|
|
132
132
|
"- **Don't skip validation** — run `#{validate_tool}` after EVERY edit. It catches syntax errors AND Rails-specific issues (missing partials, bad column refs).",
|
|
133
133
|
"- **Don't ignore cross-references** — tool responses include `_Next:` hints suggesting the best follow-up call. Follow them.",
|
|
@@ -144,7 +144,7 @@ module RailsAiContext
|
|
|
144
144
|
"",
|
|
145
145
|
"1. **Use composite tools first** — `#{cli_cmd("context")}` and `#{cli_cmd("analyze_feature")}` before individual tools",
|
|
146
146
|
"2. **NEVER read reference files** — db/schema.rb, config/routes.rb, model files, test files — tools are better",
|
|
147
|
-
"3. **
|
|
147
|
+
"3. **Prefer `#{cli_cmd("search_code")}`** for tracing and cross-layer search — standard search tools are fine for simple targeted lookups",
|
|
148
148
|
"4. **Read files ONLY to Edit them** — not for reference",
|
|
149
149
|
"5. **Validate EVERY edit** — `#{cli_cmd("validate", "files=... level=rails")}`",
|
|
150
150
|
"6. **Follow _Next:_ hints** — tool responses suggest the best follow-up call",
|
|
@@ -156,7 +156,7 @@ module RailsAiContext
|
|
|
156
156
|
"",
|
|
157
157
|
"1. **Use composite tools first** — `rails_get_context` and `rails_analyze_feature` before individual tools",
|
|
158
158
|
"2. **NEVER read reference files** — db/schema.rb, config/routes.rb, model files, test files — tools are better",
|
|
159
|
-
"3. **
|
|
159
|
+
"3. **Prefer `rails_search_code`** for tracing and cross-layer search — standard search tools are fine for simple targeted lookups",
|
|
160
160
|
"4. **Read files ONLY to Edit them** — not for reference",
|
|
161
161
|
"5. **Validate EVERY edit** — `rails_validate(files:[...], level:\"rails\")`",
|
|
162
162
|
"6. **Follow _Next:_ hints** — tool responses suggest the best follow-up call",
|
|
@@ -167,7 +167,7 @@ module RailsAiContext
|
|
|
167
167
|
end
|
|
168
168
|
|
|
169
169
|
def tools_table # rubocop:disable Metrics/MethodLength
|
|
170
|
-
lines = [ "### All
|
|
170
|
+
lines = [ "### All 39 Tools", "" ]
|
|
171
171
|
|
|
172
172
|
if tool_mode == :cli
|
|
173
173
|
lines.concat(tools_table_cli)
|
|
@@ -218,7 +218,9 @@ module RailsAiContext
|
|
|
218
218
|
"| `rails_generate_test(model:\"X\")` | `#{cli_cmd("generate_test", "model=X")}` | Generate test scaffolding matching project patterns (framework, factories, style) |",
|
|
219
219
|
"| `rails_diagnose(error:\"X\")` | `#{cli_cmd("diagnose", "error=\"X\"")}` | One-call error diagnosis: context + git changes + logs + fix suggestions |",
|
|
220
220
|
"| `rails_review_changes(ref:\"main\")` | `#{cli_cmd("review_changes", "ref=main")}` | PR/commit review: file context + warnings (missing indexes, removed validations) |",
|
|
221
|
-
"| `rails_onboard(detail:\"standard\")` | `#{cli_cmd("onboard", "detail=standard")}` | Narrative app walkthrough for new developers or AI agents |"
|
|
221
|
+
"| `rails_onboard(detail:\"standard\")` | `#{cli_cmd("onboard", "detail=standard")}` | Narrative app walkthrough for new developers or AI agents |",
|
|
222
|
+
"| `rails_runtime_info(detail:\"standard\")` | `#{cli_cmd("runtime_info", "detail=standard")}` | Live runtime: DB pool, table sizes, cache stats, job queues, pending migrations |",
|
|
223
|
+
"| `rails_session_context(action:\"status\")` | `#{cli_cmd("session_context", "action=status")}` | Track what you've already queried, avoid redundant calls |"
|
|
222
224
|
]
|
|
223
225
|
end
|
|
224
226
|
|
|
@@ -262,7 +264,9 @@ module RailsAiContext
|
|
|
262
264
|
"| `#{cli_cmd("generate_test", "model=X")}` | Generate test scaffolding matching project patterns (framework, factories, style) |",
|
|
263
265
|
"| `#{cli_cmd("diagnose", "error=\"X\"")}` | One-call error diagnosis: context + git changes + logs + fix suggestions |",
|
|
264
266
|
"| `#{cli_cmd("review_changes", "ref=main")}` | PR/commit review: file context + warnings (missing indexes, removed validations) |",
|
|
265
|
-
"| `#{cli_cmd("onboard", "detail=standard")}` | Narrative app walkthrough for new developers or AI agents |"
|
|
267
|
+
"| `#{cli_cmd("onboard", "detail=standard")}` | Narrative app walkthrough for new developers or AI agents |",
|
|
268
|
+
"| `#{cli_cmd("runtime_info", "detail=standard")}` | Live runtime: DB pool, table sizes, cache stats, job queues, pending migrations |",
|
|
269
|
+
"| `#{cli_cmd("session_context", "action=status")}` | Track what you've already queried, avoid redundant calls |"
|
|
266
270
|
]
|
|
267
271
|
end
|
|
268
272
|
|
|
@@ -290,6 +294,7 @@ module RailsAiContext
|
|
|
290
294
|
lines.concat(tools_intro)
|
|
291
295
|
lines.concat(tools_power_tool_section)
|
|
292
296
|
lines.concat(tools_workflow_section)
|
|
297
|
+
lines.concat(tools_antipatterns_section)
|
|
293
298
|
lines.concat(tools_rules_section)
|
|
294
299
|
lines.concat(tools_name_list)
|
|
295
300
|
lines
|
|
@@ -309,6 +314,7 @@ module RailsAiContext
|
|
|
309
314
|
rails_migration_advisor rails_get_frontend_stack rails_search_docs
|
|
310
315
|
rails_query rails_read_logs rails_generate_test rails_diagnose
|
|
311
316
|
rails_review_changes rails_onboard
|
|
317
|
+
rails_runtime_info rails_session_context
|
|
312
318
|
]
|
|
313
319
|
[
|
|
314
320
|
"### All #{all_tools.size} tools",
|
|
@@ -45,7 +45,9 @@ module RailsAiContext
|
|
|
45
45
|
Tools::GenerateTest,
|
|
46
46
|
Tools::Diagnose,
|
|
47
47
|
Tools::ReviewChanges,
|
|
48
|
-
Tools::Onboard
|
|
48
|
+
Tools::Onboard,
|
|
49
|
+
Tools::RuntimeInfo,
|
|
50
|
+
Tools::SessionContext
|
|
49
51
|
].freeze
|
|
50
52
|
|
|
51
53
|
def initialize(app, transport: :stdio)
|
|
@@ -117,6 +119,11 @@ module RailsAiContext
|
|
|
117
119
|
# Build a minimal Rack app that delegates to the MCP transport
|
|
118
120
|
rack_app = build_rack_app(transport, config.http_path)
|
|
119
121
|
|
|
122
|
+
unless config.http_bind == "127.0.0.1" || config.http_bind == "::1" || config.http_bind == "localhost"
|
|
123
|
+
$stderr.puts "[rails-ai-context] WARNING: MCP HTTP transport binding to #{config.http_bind} — " \
|
|
124
|
+
"this exposes all tools to the network without authentication. " \
|
|
125
|
+
"Use 127.0.0.1 (default) unless you have external auth in place."
|
|
126
|
+
end
|
|
120
127
|
$stderr.puts "[rails-ai-context] MCP server starting on #{config.http_bind}:#{config.http_port}#{config.http_path}"
|
|
121
128
|
$stderr.puts "[rails-ai-context] Tools: #{TOOLS.map { |t| t.tool_name }.join(', ')}"
|
|
122
129
|
maybe_start_live_reload(server)
|
|
@@ -21,9 +21,14 @@ module RailsAiContext
|
|
|
21
21
|
|
|
22
22
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
23
23
|
|
|
24
|
+
# Map well-known feature keywords to gem-based patterns
|
|
25
|
+
AUTH_KEYWORDS = %w[auth authentication login signup signin session devise omniauth].freeze
|
|
26
|
+
AUTH_GEM_NAMES = %w[devise omniauth rodauth sorcery clearance authlogic warden jwt].freeze
|
|
27
|
+
|
|
24
28
|
def self.call(feature:, server_context: nil) # rubocop:disable Metrics
|
|
25
29
|
feature = feature.to_s.strip
|
|
26
30
|
return text_response("Please provide a feature keyword (e.g. 'cook', 'payment', 'authentication').") if feature.empty?
|
|
31
|
+
set_call_params(feature: feature)
|
|
27
32
|
|
|
28
33
|
ctx = cached_context
|
|
29
34
|
pattern = feature.downcase
|
|
@@ -48,6 +53,19 @@ module RailsAiContext
|
|
|
48
53
|
discover_accessibility(ctx, pattern, lines)
|
|
49
54
|
discover_components(ctx, pattern, lines)
|
|
50
55
|
|
|
56
|
+
# For auth-related keywords, also discover auth gems
|
|
57
|
+
if AUTH_KEYWORDS.include?(pattern)
|
|
58
|
+
gems = ctx[:gems]
|
|
59
|
+
if gems.is_a?(Hash) && !gems[:error]
|
|
60
|
+
notable = gems[:notable_gems] || []
|
|
61
|
+
auth_gems = notable.select { |g| AUTH_GEM_NAMES.include?(g[:name]) }
|
|
62
|
+
if auth_gems.any?
|
|
63
|
+
lines << "" << "## Auth Gems" << ""
|
|
64
|
+
auth_gems.each { |g| lines << "- **#{g[:name]}** #{g[:version]}#{g[:config] ? " (config: #{g[:config]})" : ""}" }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
51
69
|
# If nothing was discovered, return a clean "no match" with real suggestions
|
|
52
70
|
has_content = lines.any? { |l| l.start_with?("## ") || l.start_with?("### ") }
|
|
53
71
|
unless has_content
|
|
@@ -65,11 +83,16 @@ module RailsAiContext
|
|
|
65
83
|
# --- AF: Models ---
|
|
66
84
|
def discover_models(ctx, pattern, lines)
|
|
67
85
|
models = ctx[:models] || {}
|
|
86
|
+
|
|
87
|
+
# For auth-related keywords, also match the User model and auth-related concerns
|
|
88
|
+
extra_auth_match = AUTH_KEYWORDS.include?(pattern)
|
|
89
|
+
|
|
68
90
|
matched = models.select do |name, data|
|
|
69
91
|
next false if data[:error]
|
|
70
92
|
name.downcase.include?(pattern) ||
|
|
71
93
|
data[:table_name]&.downcase&.include?(pattern) ||
|
|
72
|
-
name.underscore.include?(pattern)
|
|
94
|
+
name.underscore.include?(pattern) ||
|
|
95
|
+
(extra_auth_match && (name == "User" || data[:concerns]&.any? { |c| c.to_s.downcase.match?(/authenticat|devise/) }))
|
|
73
96
|
end
|
|
74
97
|
|
|
75
98
|
if matched.any?
|
|
@@ -12,6 +12,11 @@ module RailsAiContext
|
|
|
12
12
|
# for thread safety in multi-threaded servers (e.g., Puma).
|
|
13
13
|
SHARED_CACHE = { mutex: Mutex.new }
|
|
14
14
|
|
|
15
|
+
# Session-level context tracking. Lets AI avoid redundant queries
|
|
16
|
+
# by recording what tools have been called with what params.
|
|
17
|
+
# In-memory only — resets on server restart (matches conversation lifecycle).
|
|
18
|
+
SESSION_CONTEXT = { mutex: Mutex.new, queries: {} }
|
|
19
|
+
|
|
15
20
|
class << self
|
|
16
21
|
# Convenience: access the Rails app and cached introspection
|
|
17
22
|
def rails_app
|
|
@@ -52,12 +57,62 @@ module RailsAiContext
|
|
|
52
57
|
# Reset the shared cache. Used by LiveReload to invalidate on file change.
|
|
53
58
|
def reset_all_caches!
|
|
54
59
|
reset_cache!
|
|
60
|
+
session_reset!
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# ── Session context helpers ──────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
def session_record(tool_name, params, summary = nil)
|
|
66
|
+
SESSION_CONTEXT[:mutex].synchronize do
|
|
67
|
+
key = session_key(tool_name, params)
|
|
68
|
+
existing = SESSION_CONTEXT[:queries][key]
|
|
69
|
+
if existing
|
|
70
|
+
existing[:call_count] = (existing[:call_count] || 1) + 1
|
|
71
|
+
existing[:last_timestamp] = Time.now.iso8601
|
|
72
|
+
existing[:summary] = summary if summary
|
|
73
|
+
else
|
|
74
|
+
SESSION_CONTEXT[:queries][key] = {
|
|
75
|
+
tool: tool_name.to_s,
|
|
76
|
+
params: params,
|
|
77
|
+
call_count: 1,
|
|
78
|
+
timestamp: Time.now.iso8601,
|
|
79
|
+
summary: summary
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def session_queried?(tool_name, **params)
|
|
86
|
+
SESSION_CONTEXT[:mutex].synchronize do
|
|
87
|
+
SESSION_CONTEXT[:queries].key?(session_key(tool_name, params))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def session_queries
|
|
92
|
+
SESSION_CONTEXT[:mutex].synchronize do
|
|
93
|
+
SESSION_CONTEXT[:queries].values.dup
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def session_reset!
|
|
98
|
+
SESSION_CONTEXT[:mutex].synchronize do
|
|
99
|
+
SESSION_CONTEXT[:queries].clear
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Auto-compress: if text exceeds 85% of max, call the fallback lambda for a shorter version
|
|
104
|
+
def auto_compress(full_text, &fallback)
|
|
105
|
+
max = config.max_tool_response_chars
|
|
106
|
+
return full_text if !max || full_text.length <= (max * 0.85).to_i
|
|
107
|
+
fallback ? fallback.call : full_text
|
|
55
108
|
end
|
|
56
109
|
|
|
57
110
|
# Structured not-found error with fuzzy suggestion and recovery hint.
|
|
58
111
|
# Helps AI agents self-correct without retrying blind.
|
|
59
112
|
def not_found_response(type, name, available, recovery_tool: nil)
|
|
60
113
|
suggestion = find_closest_match(name, available)
|
|
114
|
+
# Don't suggest the exact same string the user typed — that's useless
|
|
115
|
+
suggestion = nil if suggestion == name
|
|
61
116
|
lines = [ "#{type} '#{name}' not found." ]
|
|
62
117
|
lines << "Did you mean '#{suggestion}'?" if suggestion
|
|
63
118
|
lines << "Available: #{available.first(20).join(', ')}#{"..." if available.size > 20}"
|
|
@@ -108,8 +163,22 @@ module RailsAiContext
|
|
|
108
163
|
end
|
|
109
164
|
end
|
|
110
165
|
|
|
111
|
-
#
|
|
166
|
+
# Store call params for the current tool invocation (thread-safe)
|
|
167
|
+
def set_call_params(**params)
|
|
168
|
+
Thread.current[:rails_ai_context_call_params] = params.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Helper: wrap text in an MCP::Tool::Response with safety-net truncation.
|
|
172
|
+
# Auto-records the call in session context so session_context(action:"status") works.
|
|
112
173
|
def text_response(text)
|
|
174
|
+
# Auto-track: record this tool call in session context (skip SessionContext itself to avoid recursion)
|
|
175
|
+
if respond_to?(:tool_name) && tool_name != "rails_session_context"
|
|
176
|
+
summary = text.lines.first&.strip&.truncate(80)
|
|
177
|
+
params = Thread.current[:rails_ai_context_call_params] || {}
|
|
178
|
+
session_record(tool_name, params, summary)
|
|
179
|
+
Thread.current[:rails_ai_context_call_params] = nil
|
|
180
|
+
end
|
|
181
|
+
|
|
113
182
|
max = RailsAiContext.configuration.max_tool_response_chars
|
|
114
183
|
if max && text.length > max
|
|
115
184
|
truncated = text[0...max]
|
|
@@ -119,6 +188,14 @@ module RailsAiContext
|
|
|
119
188
|
MCP::Tool::Response.new([ { type: "text", text: text } ])
|
|
120
189
|
end
|
|
121
190
|
end
|
|
191
|
+
|
|
192
|
+
private
|
|
193
|
+
|
|
194
|
+
def session_key(tool_name, params)
|
|
195
|
+
normalized = tool_name.to_s.sub(/\Arails_/, "")
|
|
196
|
+
param_str = params.is_a?(Hash) ? params.sort_by { |k, _| k.to_s }.map { |k, v| "#{k}:#{v}" }.join(",") : params.to_s
|
|
197
|
+
"#{normalized}:#{param_str}"
|
|
198
|
+
end
|
|
122
199
|
end
|
|
123
200
|
end
|
|
124
201
|
end
|
|
@@ -41,6 +41,8 @@ module RailsAiContext
|
|
|
41
41
|
model = model.to_s.strip if model
|
|
42
42
|
depth = [ [ depth.to_i, 1 ].max, 3 ].min
|
|
43
43
|
|
|
44
|
+
set_call_params(model: model, depth: depth, format: format)
|
|
45
|
+
|
|
44
46
|
# Build adjacency list from model associations
|
|
45
47
|
graph = build_graph(models_data)
|
|
46
48
|
|
|
@@ -48,7 +50,8 @@ module RailsAiContext
|
|
|
48
50
|
# Filter to subgraph centered on the model
|
|
49
51
|
model_key = find_model_key(model, graph.keys)
|
|
50
52
|
unless model_key
|
|
51
|
-
return not_found_response("
|
|
53
|
+
return not_found_response("Model", model, graph.keys.sort,
|
|
54
|
+
recovery_tool: "Call rails_dependency_graph() without model to see all models")
|
|
52
55
|
end
|
|
53
56
|
subgraph = extract_subgraph(graph, model_key, depth)
|
|
54
57
|
else
|
|
@@ -37,12 +37,34 @@ module RailsAiContext
|
|
|
37
37
|
|
|
38
38
|
# ── Error classification ──────────────────────────────────────────
|
|
39
39
|
|
|
40
|
+
# Section size limits for output truncation
|
|
41
|
+
MAX_TOTAL_OUTPUT = 20_000
|
|
42
|
+
MAX_SECTION_CHARS = {
|
|
43
|
+
controller_context: 3_000,
|
|
44
|
+
model_context: 3_000,
|
|
45
|
+
code_context: 3_000,
|
|
46
|
+
schema_context: 3_000,
|
|
47
|
+
method_trace: 3_000,
|
|
48
|
+
git_changes: 2_000,
|
|
49
|
+
logs: 2_000
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
40
52
|
ERROR_CLASSIFICATIONS = {
|
|
41
|
-
/
|
|
53
|
+
/NameError.*uninitialized constant/ => {
|
|
54
|
+
type: :name_error,
|
|
55
|
+
likely: "A class or module constant could not be found. This usually means a typo in the class/module name, a missing require, or an autoload issue.",
|
|
56
|
+
fix: "1. Check for typo in class/module name, missing require, or autoload issue\n2. Verify the file is in the correct autoload path (e.g. app/models, app/services)\n3. Run `rails_search_code(pattern:\"ClassName\", match_type:\"trace\")` to find where the constant is defined"
|
|
57
|
+
},
|
|
58
|
+
/NoMethodError/ => {
|
|
42
59
|
type: :nil_reference,
|
|
43
60
|
likely: "A method was called on nil or an undefined name was referenced. Check for: missing association, unfetched record, typo in method name.",
|
|
44
61
|
fix: "1. Check the variable/object is not nil before calling the method\n2. Verify the association or attribute exists on the model\n3. Use `&.` safe navigation if the value can legitimately be nil"
|
|
45
62
|
},
|
|
63
|
+
/NameError/ => {
|
|
64
|
+
type: :name_error,
|
|
65
|
+
likely: "An undefined name was referenced. This could be a typo in a variable, method, or constant name, or a missing require/autoload.",
|
|
66
|
+
fix: "1. Check for typo in class/module name, missing require, or autoload issue\n2. Verify the name is defined and accessible in the current scope\n3. Use `rails_search_code(pattern:\"name\", match_type:\"trace\")` to find where it is defined"
|
|
67
|
+
},
|
|
46
68
|
/RecordNotFound/ => {
|
|
47
69
|
type: :record_not_found,
|
|
48
70
|
likely: "A `.find()` or `.find_by!()` call failed because the record doesn't exist. Common causes: stale URL, deleted record, wrong ID parameter.",
|
|
@@ -92,9 +114,11 @@ module RailsAiContext
|
|
|
92
114
|
lines << "**Classification:** #{classification[:type]}"
|
|
93
115
|
lines << ""
|
|
94
116
|
|
|
95
|
-
# Likely cause
|
|
117
|
+
# Likely cause — enriched with specific inference when possible
|
|
96
118
|
lines << "## Likely Cause"
|
|
97
119
|
lines << classification[:likely]
|
|
120
|
+
specific = infer_specific_cause(parsed, classification, file, action)
|
|
121
|
+
lines << "" << "**Specific:** #{specific}" if specific
|
|
98
122
|
lines << ""
|
|
99
123
|
|
|
100
124
|
# Suggested fix
|
|
@@ -103,14 +127,25 @@ module RailsAiContext
|
|
|
103
127
|
lines << ""
|
|
104
128
|
|
|
105
129
|
# Gather context based on parameters and error type
|
|
106
|
-
|
|
130
|
+
context_sections = gather_context(parsed, classification, file, line, action)
|
|
107
131
|
|
|
108
132
|
# Recent git changes
|
|
109
133
|
git_section = gather_git_context(file, parsed[:file_refs])
|
|
110
|
-
lines.concat(git_section) if git_section.any?
|
|
111
134
|
|
|
112
135
|
# Recent error logs
|
|
113
136
|
log_section = gather_log_context(parsed[:exception_class])
|
|
137
|
+
|
|
138
|
+
# Truncate large sections before assembling final output
|
|
139
|
+
context_sections = truncate_section(context_sections, "Controller Context", MAX_SECTION_CHARS[:controller_context])
|
|
140
|
+
context_sections = truncate_section(context_sections, "Code Context", MAX_SECTION_CHARS[:code_context])
|
|
141
|
+
context_sections = truncate_section(context_sections, "Schema Context", MAX_SECTION_CHARS[:schema_context])
|
|
142
|
+
context_sections = truncate_section(context_sections, "Model Context", MAX_SECTION_CHARS[:model_context])
|
|
143
|
+
context_sections = truncate_section(context_sections, "Method Trace", MAX_SECTION_CHARS[:method_trace])
|
|
144
|
+
git_section = truncate_section(git_section, "Recent Git Changes", MAX_SECTION_CHARS[:git_changes])
|
|
145
|
+
log_section = truncate_section(log_section, "Recent Error Logs", MAX_SECTION_CHARS[:logs])
|
|
146
|
+
|
|
147
|
+
lines.concat(context_sections)
|
|
148
|
+
lines.concat(git_section) if git_section.any?
|
|
114
149
|
lines.concat(log_section) if log_section.any?
|
|
115
150
|
|
|
116
151
|
# Next steps
|
|
@@ -122,7 +157,14 @@ module RailsAiContext
|
|
|
122
157
|
lines << "_Use `rails_search_code(pattern:\"#{parsed[:method_name]}\", match_type:\"trace\")` to trace the method._"
|
|
123
158
|
end
|
|
124
159
|
|
|
125
|
-
|
|
160
|
+
output = lines.join("\n")
|
|
161
|
+
|
|
162
|
+
# Final safety cap: if total output still exceeds limit, hard-truncate
|
|
163
|
+
if output.length > MAX_TOTAL_OUTPUT
|
|
164
|
+
output = output[0, MAX_TOTAL_OUTPUT] + "\n\n_... output truncated (#{output.length} chars exceeded #{MAX_TOTAL_OUTPUT} limit)._"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
text_response(output)
|
|
126
168
|
rescue => e
|
|
127
169
|
text_response("Diagnosis error: #{e.message}")
|
|
128
170
|
end
|
|
@@ -223,7 +265,7 @@ module RailsAiContext
|
|
|
223
265
|
lines << text
|
|
224
266
|
lines << ""
|
|
225
267
|
end
|
|
226
|
-
rescue; end
|
|
268
|
+
rescue => e; $stderr.puts "[rails-ai-context] Diagnosis step skipped: #{e.message}"; end
|
|
227
269
|
end
|
|
228
270
|
end
|
|
229
271
|
|
|
@@ -242,7 +284,7 @@ module RailsAiContext
|
|
|
242
284
|
lines << text
|
|
243
285
|
lines << ""
|
|
244
286
|
end
|
|
245
|
-
rescue; end
|
|
287
|
+
rescue => e; $stderr.puts "[rails-ai-context] Diagnosis step skipped: #{e.message}"; end
|
|
246
288
|
end
|
|
247
289
|
end
|
|
248
290
|
|
|
@@ -256,12 +298,61 @@ module RailsAiContext
|
|
|
256
298
|
lines << text
|
|
257
299
|
lines << ""
|
|
258
300
|
end
|
|
259
|
-
rescue; end
|
|
301
|
+
rescue => e; $stderr.puts "[rails-ai-context] Diagnosis step skipped: #{e.message}"; end
|
|
260
302
|
end
|
|
261
303
|
|
|
262
304
|
lines
|
|
263
305
|
end
|
|
264
306
|
|
|
307
|
+
# Infer a specific diagnosis from the error + context
|
|
308
|
+
def infer_specific_cause(parsed, classification, file, action) # rubocop:disable Metrics
|
|
309
|
+
msg = parsed[:message].to_s
|
|
310
|
+
method = parsed[:method_name]
|
|
311
|
+
|
|
312
|
+
# "undefined method X for nil" — identify WHAT is nil
|
|
313
|
+
if classification[:type] == :nil_reference && msg.include?("for nil")
|
|
314
|
+
# Check if calling on current_user (common: auth not running)
|
|
315
|
+
if file&.include?("controller") && msg.match?(/current_user/)
|
|
316
|
+
return "`current_user` is nil — the `authenticate_user!` before_action may not be running for this route. " \
|
|
317
|
+
"Check if this action is excluded via `unless:` or `skip_before_action`."
|
|
318
|
+
end
|
|
319
|
+
# Check if calling on an association
|
|
320
|
+
if method && file
|
|
321
|
+
begin
|
|
322
|
+
ctx = GetEditContext.call(file: file, near: method)
|
|
323
|
+
code = ctx.content.first[:text]
|
|
324
|
+
# Find the receiver: something.method_name
|
|
325
|
+
receiver_match = code.match(/(\w+)\.#{Regexp.escape(method)}/)
|
|
326
|
+
if receiver_match
|
|
327
|
+
receiver = receiver_match[1]
|
|
328
|
+
return "`#{receiver}` is nil when `.#{method}` is called. " \
|
|
329
|
+
"This variable may not be set in all code paths — check if it's assigned before use, " \
|
|
330
|
+
"or use `#{receiver}&.#{method}` for safe navigation."
|
|
331
|
+
end
|
|
332
|
+
rescue => e; $stderr.puts "[rails-ai-context] Diagnosis step skipped: #{e.message}"; end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# RecordNotFound — check if there's a set_* before_action
|
|
337
|
+
if classification[:type] == :record_not_found && action
|
|
338
|
+
ctrl, act = action.split("#", 2)
|
|
339
|
+
if ctrl && act
|
|
340
|
+
begin
|
|
341
|
+
ctrl_class = ctrl.end_with?("Controller") ? ctrl : "#{ctrl.camelize}Controller"
|
|
342
|
+
result = GetControllers.call(controller: ctrl_class, action: act)
|
|
343
|
+
text = result.content.first[:text]
|
|
344
|
+
if text.include?("set_") && text.include?("find")
|
|
345
|
+
return "The `set_*` before_action uses `.find` which raises RecordNotFound. " \
|
|
346
|
+
"The record with the given ID doesn't exist or doesn't belong to the current user. " \
|
|
347
|
+
"Check if the record was deleted or if the user is authorized to access it."
|
|
348
|
+
end
|
|
349
|
+
rescue => e; $stderr.puts "[rails-ai-context] Diagnosis step skipped: #{e.message}"; end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
|
|
265
356
|
def gather_git_context(file, file_refs)
|
|
266
357
|
lines = []
|
|
267
358
|
root = Rails.root.to_s
|
|
@@ -303,6 +394,42 @@ module RailsAiContext
|
|
|
303
394
|
[]
|
|
304
395
|
end
|
|
305
396
|
end
|
|
397
|
+
|
|
398
|
+
# Truncate the content of a named section (identified by "## heading") within a lines array.
|
|
399
|
+
# Returns a new array with the section's content lines truncated if they exceed max_chars.
|
|
400
|
+
def truncate_section(lines, heading, max_chars)
|
|
401
|
+
return lines if lines.empty? || max_chars.nil?
|
|
402
|
+
|
|
403
|
+
header_marker = "## #{heading}"
|
|
404
|
+
header_idx = lines.index(header_marker)
|
|
405
|
+
return lines unless header_idx
|
|
406
|
+
|
|
407
|
+
# Find the end of this section: next "## " header or end of array
|
|
408
|
+
section_end = nil
|
|
409
|
+
(header_idx + 1...lines.length).each do |i|
|
|
410
|
+
if lines[i].is_a?(String) && lines[i].start_with?("## ")
|
|
411
|
+
section_end = i
|
|
412
|
+
break
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
section_end ||= lines.length
|
|
416
|
+
|
|
417
|
+
# Measure content between header and section_end
|
|
418
|
+
content_lines = lines[(header_idx + 1)...section_end]
|
|
419
|
+
content = content_lines.join("\n")
|
|
420
|
+
|
|
421
|
+
return lines if content.length <= max_chars
|
|
422
|
+
|
|
423
|
+
# Truncate and rebuild
|
|
424
|
+
truncated_content = content[0, max_chars]
|
|
425
|
+
truncated_content += "\n\n_... section truncated (#{content.length} chars → #{max_chars} max)._"
|
|
426
|
+
|
|
427
|
+
result = lines[0...header_idx + 1]
|
|
428
|
+
result << truncated_content
|
|
429
|
+
result << ""
|
|
430
|
+
result.concat(lines[section_end..])
|
|
431
|
+
result
|
|
432
|
+
end
|
|
306
433
|
end
|
|
307
434
|
end
|
|
308
435
|
end
|
|
@@ -264,12 +264,20 @@ module RailsAiContext
|
|
|
264
264
|
lines << ""
|
|
265
265
|
lines << "class #{name}Test < ActiveSupport::TestCase"
|
|
266
266
|
|
|
267
|
-
setup_var =
|
|
267
|
+
setup_var = name.underscore
|
|
268
|
+
fixtures = tests_data[:fixtures]
|
|
269
|
+
fixture_names = tests_data[:fixture_names] || {}
|
|
270
|
+
# Determine data setup: factory > fixture > inline
|
|
271
|
+
lines << " setup do"
|
|
268
272
|
if factory
|
|
269
|
-
lines << " setup do"
|
|
270
273
|
lines << " @#{setup_var} = create(:#{factory})"
|
|
271
|
-
|
|
274
|
+
elsif fixtures
|
|
275
|
+
fixture_key = resolve_fixture_key(name, fixture_names)
|
|
276
|
+
lines << " @#{setup_var} = #{name.underscore.pluralize}(#{fixture_key})"
|
|
277
|
+
else
|
|
278
|
+
lines << " @#{setup_var} = #{name}.new"
|
|
272
279
|
end
|
|
280
|
+
lines << " end"
|
|
273
281
|
|
|
274
282
|
# Validations
|
|
275
283
|
validations = data[:validations] || []
|
|
@@ -282,7 +290,29 @@ module RailsAiContext
|
|
|
282
290
|
next if seen.include?(key)
|
|
283
291
|
seen << key
|
|
284
292
|
lines << " test \"validates #{v[:kind]} of #{attr}\" do"
|
|
285
|
-
|
|
293
|
+
case v[:kind]
|
|
294
|
+
when "presence"
|
|
295
|
+
lines << " @#{setup_var}.#{attr} = nil"
|
|
296
|
+
when "inclusion"
|
|
297
|
+
lines << " @#{setup_var}.#{attr} = \"__invalid_value__\""
|
|
298
|
+
when "uniqueness"
|
|
299
|
+
lines << " duplicate = @#{setup_var}.dup"
|
|
300
|
+
lines << " assert_not duplicate.valid?"
|
|
301
|
+
lines << " end"
|
|
302
|
+
lines << ""
|
|
303
|
+
next
|
|
304
|
+
when "numericality"
|
|
305
|
+
lines << " @#{setup_var}.#{attr} = \"not_a_number\""
|
|
306
|
+
when "length"
|
|
307
|
+
max = v.dig(:options, :maximum)
|
|
308
|
+
if max
|
|
309
|
+
lines << " @#{setup_var}.#{attr} = \"a\" * #{max + 1}"
|
|
310
|
+
else
|
|
311
|
+
lines << " @#{setup_var}.#{attr} = \"\""
|
|
312
|
+
end
|
|
313
|
+
when "format"
|
|
314
|
+
lines << " @#{setup_var}.#{attr} = \"invalid-format\""
|
|
315
|
+
end
|
|
286
316
|
lines << " assert_not @#{setup_var}.valid?"
|
|
287
317
|
lines << " end"
|
|
288
318
|
lines << ""
|
|
@@ -305,8 +335,18 @@ module RailsAiContext
|
|
|
305
335
|
scopes = data[:scopes] || []
|
|
306
336
|
scopes.each do |s|
|
|
307
337
|
scope_name = s.is_a?(Hash) ? s[:name] : s
|
|
338
|
+
scope_body = s.is_a?(Hash) ? s[:body] : nil
|
|
308
339
|
lines << " test \"scope .#{scope_name} returns expected records\" do"
|
|
309
|
-
|
|
340
|
+
if scope_body&.include?("order")
|
|
341
|
+
lines << " sql = #{name}.#{scope_name}.to_sql"
|
|
342
|
+
lines << " assert_match(/ORDER BY/i, sql)"
|
|
343
|
+
elsif scope_body&.include?("where")
|
|
344
|
+
lines << " results = #{name}.#{scope_name}"
|
|
345
|
+
lines << " assert_kind_of ActiveRecord::Relation, results"
|
|
346
|
+
else
|
|
347
|
+
lines << " results = #{name}.#{scope_name}"
|
|
348
|
+
lines << " assert_kind_of ActiveRecord::Relation, results"
|
|
349
|
+
end
|
|
310
350
|
lines << " end"
|
|
311
351
|
lines << ""
|
|
312
352
|
end
|
|
@@ -431,10 +471,12 @@ module RailsAiContext
|
|
|
431
471
|
|
|
432
472
|
has_devise = tests_data[:test_helper_setup]&.any? { |h| h.include?("Devise") }
|
|
433
473
|
if has_devise
|
|
474
|
+
fixture_names = tests_data[:fixture_names] || {}
|
|
475
|
+
user_fixture_key = resolve_fixture_key("User", fixture_names)
|
|
434
476
|
lines << " include Devise::Test::IntegrationHelpers"
|
|
435
477
|
lines << ""
|
|
436
478
|
lines << " setup do"
|
|
437
|
-
lines << " @user = users(
|
|
479
|
+
lines << " @user = users(#{user_fixture_key})"
|
|
438
480
|
lines << " sign_in @user"
|
|
439
481
|
lines << " end"
|
|
440
482
|
end
|
|
@@ -444,9 +486,24 @@ module RailsAiContext
|
|
|
444
486
|
path = r[:path] || "/#{snake}"
|
|
445
487
|
verb = (r[:verb] || "GET").downcase
|
|
446
488
|
|
|
489
|
+
# Extract dynamic segments (e.g. :post_id, :id) from the path
|
|
490
|
+
param_names = path.scan(/:(\w+)/).flatten
|
|
491
|
+
quoted_path = path.gsub(/:(\w+)/, '#{\\1}')
|
|
492
|
+
|
|
447
493
|
lines << ""
|
|
448
494
|
lines << " test \"#{r[:verb]} #{r[:path]} works\" do"
|
|
449
|
-
|
|
495
|
+
fixture_names = tests_data[:fixture_names] || {}
|
|
496
|
+
param_names.each do |param|
|
|
497
|
+
if param == "id"
|
|
498
|
+
fk = resolve_fixture_key(snake.singularize.camelize, fixture_names)
|
|
499
|
+
lines << " #{param} = #{snake.pluralize}(#{fk}).id"
|
|
500
|
+
elsif param.end_with?("_id")
|
|
501
|
+
resource = param.delete_suffix("_id")
|
|
502
|
+
fk = resolve_fixture_key(resource.camelize, fixture_names)
|
|
503
|
+
lines << " #{param} = #{resource.pluralize}(#{fk}).id"
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
lines << " #{verb} \"#{quoted_path}\""
|
|
450
507
|
lines << " assert_response :success"
|
|
451
508
|
lines << " end"
|
|
452
509
|
end
|
|
@@ -503,6 +560,29 @@ module RailsAiContext
|
|
|
503
560
|
|
|
504
561
|
# ── Helpers ──────────────────────────────────────────────────────
|
|
505
562
|
|
|
563
|
+
# Resolve the best fixture key for a model by reading actual fixture file contents.
|
|
564
|
+
# Falls back to :one if no fixture file is found.
|
|
565
|
+
def resolve_fixture_key(model_name, fixture_names)
|
|
566
|
+
plural = model_name.underscore.pluralize
|
|
567
|
+
# fixture_names is { "users" => [:chef_one, :chef_two], "cooks" => [:pending_cook, ...] }
|
|
568
|
+
keys = fixture_names[plural] || fixture_names[plural.to_sym]
|
|
569
|
+
if keys.is_a?(Array) && keys.any?
|
|
570
|
+
":#{keys.first}"
|
|
571
|
+
else
|
|
572
|
+
# Try reading the fixture file directly
|
|
573
|
+
fixture_file = File.join(Rails.root, "test", "fixtures", "#{plural}.yml")
|
|
574
|
+
if File.exist?(fixture_file)
|
|
575
|
+
content = File.read(fixture_file, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue nil
|
|
576
|
+
if content
|
|
577
|
+
# YAML fixture files have top-level keys as fixture names
|
|
578
|
+
first_key = content.scan(/^([a-z_]\w*):/i).first&.first
|
|
579
|
+
return ":#{first_key}" if first_key
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
":one"
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
506
586
|
def find_factory_name(model_name, tests_data)
|
|
507
587
|
factory_names = tests_data[:factory_names] || {}
|
|
508
588
|
underscore = model_name.underscore
|