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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -8
  3. data/CLAUDE.md +4 -4
  4. data/CONTRIBUTING.md +1 -1
  5. data/README.md +7 -7
  6. data/SECURITY.md +2 -1
  7. data/docs/GUIDE.md +3 -3
  8. data/lib/generators/rails_ai_context/install/install_generator.rb +2 -2
  9. data/lib/rails_ai_context/configuration.rb +4 -2
  10. data/lib/rails_ai_context/doctor.rb +6 -1
  11. data/lib/rails_ai_context/fingerprinter.rb +24 -0
  12. data/lib/rails_ai_context/introspectors/component_introspector.rb +122 -7
  13. data/lib/rails_ai_context/introspectors/performance_introspector.rb +31 -26
  14. data/lib/rails_ai_context/introspectors/schema_introspector.rb +183 -6
  15. data/lib/rails_ai_context/introspectors/view_introspector.rb +2 -2
  16. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +61 -8
  17. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +13 -22
  18. data/lib/rails_ai_context/serializers/claude_serializer.rb +15 -3
  19. data/lib/rails_ai_context/serializers/context_file_serializer.rb +15 -3
  20. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -3
  21. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -5
  22. data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
  23. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +3 -3
  24. data/lib/rails_ai_context/serializers/opencode_serializer.rb +0 -1
  25. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +15 -9
  26. data/lib/rails_ai_context/server.rb +8 -1
  27. data/lib/rails_ai_context/tools/analyze_feature.rb +24 -1
  28. data/lib/rails_ai_context/tools/base_tool.rb +78 -1
  29. data/lib/rails_ai_context/tools/dependency_graph.rb +4 -1
  30. data/lib/rails_ai_context/tools/diagnose.rb +135 -8
  31. data/lib/rails_ai_context/tools/generate_test.rb +87 -7
  32. data/lib/rails_ai_context/tools/get_callbacks.rb +27 -4
  33. data/lib/rails_ai_context/tools/get_component_catalog.rb +11 -2
  34. data/lib/rails_ai_context/tools/get_context.rb +71 -8
  35. data/lib/rails_ai_context/tools/get_conventions.rb +59 -0
  36. data/lib/rails_ai_context/tools/get_design_system.rb +45 -7
  37. data/lib/rails_ai_context/tools/get_edit_context.rb +3 -2
  38. data/lib/rails_ai_context/tools/get_env.rb +51 -24
  39. data/lib/rails_ai_context/tools/get_frontend_stack.rb +100 -9
  40. data/lib/rails_ai_context/tools/get_model_details.rb +20 -0
  41. data/lib/rails_ai_context/tools/get_partial_interface.rb +12 -5
  42. data/lib/rails_ai_context/tools/get_schema.rb +1 -0
  43. data/lib/rails_ai_context/tools/get_stimulus.rb +13 -7
  44. data/lib/rails_ai_context/tools/get_turbo_map.rb +35 -2
  45. data/lib/rails_ai_context/tools/get_view.rb +65 -9
  46. data/lib/rails_ai_context/tools/migration_advisor.rb +10 -3
  47. data/lib/rails_ai_context/tools/onboard.rb +413 -27
  48. data/lib/rails_ai_context/tools/performance_check.rb +45 -28
  49. data/lib/rails_ai_context/tools/query.rb +28 -2
  50. data/lib/rails_ai_context/tools/read_logs.rb +4 -1
  51. data/lib/rails_ai_context/tools/review_changes.rb +27 -17
  52. data/lib/rails_ai_context/tools/runtime_info.rb +289 -0
  53. data/lib/rails_ai_context/tools/search_code.rb +23 -4
  54. data/lib/rails_ai_context/tools/security_scan.rb +7 -1
  55. data/lib/rails_ai_context/tools/session_context.rb +137 -0
  56. data/lib/rails_ai_context/tools/validate.rb +5 -0
  57. data/lib/rails_ai_context/version.rb +1 -1
  58. metadata +6 -4
@@ -24,20 +24,20 @@ module RailsAiContext
24
24
  end
25
25
 
26
26
  def tools_header
27
- "## Tools (37) — MANDATORY, Use Before Read"
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 37 introspection tools. **MANDATORY — use these instead of reading files.**",
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 37 MCP tools via `rails ai:serve` (configured in `.mcp.json`).",
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
- "- **Don't use Grep/search agents** use `#{search_tool}`. It excludes sensitive files, supports `match_type:\"trace\"`, and paginates.",
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. **NEVER use Grep** — use `#{cli_cmd("search_code")}`",
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. **NEVER use Grep**use `rails_search_code`",
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 37 Tools", "" ]
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
- # Helper: wrap text in an MCP::Tool::Response with safety-net truncation
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("model", model, graph.keys, recovery_tool: "rails_dependency_graph")
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
- /NoMethodError|NameError/ => {
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
- lines.concat(gather_context(parsed, classification, file, line, action))
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
- text_response(lines.join("\n"))
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 = factory ? "#{name.underscore}" : "#{name.underscore}"
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
- lines << " end"
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
- lines << " @#{setup_var}.#{attr} = nil" if v[:kind] == "presence"
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
- lines << " # TODO: create test data and verify"
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(:one)"
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
- lines << " #{verb} #{path.gsub(/:(\w+)/, '#{\\1}')}"
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