rails-ai-context 5.20.1 → 5.20.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7cbf76ef448db45165cf06f3c2af8303b23f8629a709b20ffb61ae33093c192
4
- data.tar.gz: 65fe97840d3fd23ab66b1b8e6b108914b3675e3648c51d746769d4016b6a846a
3
+ metadata.gz: 81eedfa398603e64a27f7555b4288e39a11e41093c3a06450354c2f5cb1d17c5
4
+ data.tar.gz: 34972bf4259e8e12695f008744111bff94ed7caa93155bd3f8352010e3134c15
5
5
  SHA512:
6
- metadata.gz: b4816a51648a6aaee1a3fd48b649ff35967e2efea2c75805da08c605a0668b8afa7dbe113b8d7f555a13c268d72f1284b69091a3b9d102465e3a6b00f5b2282b
7
- data.tar.gz: cc12eaa6400a9a2906ca706b37fc92f211f0837e69038f14e6a25742be8207d529c915c4ecdb122c6bd2a20a9dc0eb86a3474cd4a65a321fa8ded9f2aa64a47d
6
+ metadata.gz: c88339dc6c73a5fdb38209d69e61437a6494382c069ae943478e51a265e57b2b847d4cb89f22ef0449c024bdb43185feef7e58a5e92f199c0a0389f4822898ca
7
+ data.tar.gz: 2f0a283b5691f00d4f49aeed839da56af13e248f566c9a526e3926cbc0c71185cf2e99a9b809d5e316d7e1144826456bb65ea36148493b4102f44b3bf0857584
data/CHANGELOG.md CHANGED
@@ -5,6 +5,68 @@ 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.20.2] - 2026-08-11
9
+
10
+ ### Fixed
11
+
12
+ - **A long-lived process no longer answers about the app as it was at boot.**
13
+ The MCP server and `watch` invalidated their own caches on a file change but
14
+ never reloaded Rails, and `Zeitwerk#eager_load_dir` does not re-scan a
15
+ directory it has already loaded - so a model written after the server started
16
+ stayed invisible for the life of the process, and the server told callers it
17
+ did not exist. Routes were unaffected because `RouteIntrospector` already
18
+ asked `routes_reloader.execute_if_updated`. Both change handlers now run
19
+ Rails' own reloader first, gated on `config.enable_reloading` (the flag that
20
+ decides whether Rails unloads anything - `eager_load` does not), and every
21
+ tool call and every resource read now runs inside the executor so a reload
22
+ cannot unload constants while one is reading them. A failing reload leaves
23
+ the process alive, and a tool failure is not reported to the host app's
24
+ error reporter - it is handled here and returned as an isError result.
25
+
26
+ - **A `validates_with` validator no longer replaces the whole model answer.**
27
+ `ActiveModel::Validator` has no `#attributes` - only `EachValidator` does -
28
+ so one of them anywhere on a model turned `rails_get_model_details`,
29
+ `rails_get_callbacks` and `rails://models/X` into a single
30
+ "Error inspecting X" line. (#121)
31
+ - **Static-tier callbacks are reported.** The two tiers handed back different
32
+ types for one key: a Hash keyed by callback type when booted, a flat Array
33
+ from the listener otherwise. Every consumer filters on `is_a?(Hash)`, so
34
+ `--no-boot` answered "No models with callbacks found" for an app full of
35
+ them, and `--model X --no-boot` raised `TypeError` on a Hash lookup against
36
+ an Array. (#122)
37
+ - **`get_mailers` reports the actions Rails calls actions.** It listed
38
+ `instance_methods(false)`, which includes ActiveSupport's generated
39
+ `_run_*_callbacks` and any public helper, so an abstract `ApplicationMailer`
40
+ was reported as having deliverable actions. It now asks
41
+ `action_methods`. (#126)
42
+ - **One answer to which database the app runs on.** Four surfaces each carried
43
+ their own substitute for the internal `static_parse` marker and disagreed:
44
+ the generated `CLAUDE.md`, `rails_get_schema`, `rails_onboard` and
45
+ `rails ai:inspect` could name three different databases for one app, and
46
+ `onboard`'s gem loop let the last match win where the serializer's let the
47
+ first. They now share `RailsAiContext::SchemaAdapter`, which resolves from
48
+ the app's own database configuration first, then the `structure.sql` dialect
49
+ already parsed, then the Gemfile - order-independent. (#125)
50
+ - **Generated context files no longer print `Database: static_parse`.** That is
51
+ the internal marker for "read from db/schema.rb, not the connection";
52
+ `onboard` already substituted the live adapter and the serializers did
53
+ not. (#125)
54
+ - **`get_job_pattern` says what it checked.** "This app may not use background
55
+ jobs" was a claim about the whole app drawn from one directory, and apps
56
+ keeping Sidekiq workers in `app/workers/` were told they have none. (#120)
57
+ - **`validate`'s empty-files message advises a syntax the CLI accepts.** It
58
+ recommended `files:["..."]`, which the CLI reads as a literal filename. (#124)
59
+ - **`validate --files a.rb b.rb` validates every file.** Array parameters
60
+ consumed only the first token, so the rest were dropped silently and the
61
+ summary read "1/1 files passed" for a set containing a broken file. (#124)
62
+
63
+ ### Added
64
+
65
+ - **The static tier reads `config.api_only`.** Without a booted app the view
66
+ tools fell back to "no Stimulus controllers found" on an API-only app, where a
67
+ booted run says "Not applicable". Both were true, but only one of them stops
68
+ an agent adding a view layer to an app that has none by design.
69
+
8
70
  ## [5.20.1] - 2026-08-11
9
71
 
10
72
  ### Fixed
@@ -27,5 +27,28 @@ module RailsAiContext
27
27
  content = RailsAiContext::SafeFile.read(gemfile)
28
28
  !!content&.match?(/^\s*gem\s+["']mongoid["']/)
29
29
  end
30
+
31
+ # An API-only app has no view layer, and saying "no Stimulus controllers
32
+ # found" about one invites an agent to add some. The flag is written in
33
+ # config/application.rb, so this answer needs no booted app.
34
+ #
35
+ # Read from the AST, not a regex: `config.api_only = true` is an
36
+ # assignment, which docs/INTROSPECTORS.md puts squarely in AST territory,
37
+ # and ConfigAssignmentListener already reports exactly this shape. A regex
38
+ # also has to hand-roll what the parser knows for free - comments, strings
39
+ # and heredocs that merely contain the text.
40
+ def api_only?(root)
41
+ path = File.join(root.to_s, "config", "application.rb")
42
+ return false unless File.exist?(path)
43
+ return false unless RailsAiContext::SafeFile.read(path)
44
+
45
+ walked = Introspectors::SourceIntrospector.walk(
46
+ path, { config: Introspectors::Listeners::ConfigAssignmentListener }
47
+ )
48
+ hit = Array(walked[:config]).find { |entry| entry[:path] == [ :api_only ] }
49
+ !hit.nil? && hit[:value] == true
50
+ rescue StandardError, ScriptError
51
+ false
52
+ end
30
53
  end
31
54
  end
@@ -211,7 +211,17 @@ module RailsAiContext
211
211
  if arg.include?("=")
212
212
  key, value = arg.sub("--", "").split("=", 2)
213
213
  key = key.tr("-", "_").to_sym
214
- result[key] = coerce_value(value, properties[key] || {})
214
+ prop = properties[key] || {}
215
+ if prop[:type] == "array"
216
+ # `--files=a.rb b.rb` is the same call as `--files a.rb b.rb`;
217
+ # honouring only one of the two spellings docs/CLI.md teaches
218
+ # left the other still dropping every file after the first.
219
+ trailing = collect_array_values(args, i + 1)
220
+ result[key] = Array(coerce_value(value, prop)) + trailing
221
+ i += 1 + values_consumed(args, i + 1)
222
+ next
223
+ end
224
+ result[key] = coerce_value(value, prop)
215
225
  else
216
226
  key = arg.sub("--", "").tr("-", "_").to_sym
217
227
  prop = properties[key] || {}
@@ -232,6 +242,24 @@ module RailsAiContext
232
242
  next
233
243
  end
234
244
 
245
+ if prop[:type] == "array"
246
+ values = collect_array_values(args, i + 1)
247
+ unless values.empty?
248
+ result[key] = values
249
+ i += 1 + values_consumed(args, i + 1)
250
+ next
251
+ end
252
+ end
253
+
254
+ # A bare `--files` with nothing after it is a mistake, not a
255
+ # request for `files: true` - an array param holding a Boolean
256
+ # reaches the tool as a type it never accepts.
257
+ if prop[:type] == "array"
258
+ result[key] = []
259
+ i += 1
260
+ next
261
+ end
262
+
235
263
  value = (i + 1 < args.size) ? args[i + 1] : nil
236
264
  if value && !value.start_with?("--")
237
265
  result[key] = coerce_value(value, prop)
@@ -258,6 +286,21 @@ module RailsAiContext
258
286
  result
259
287
  end
260
288
 
289
+ # Tokens belonging to an array flag: everything up to the next flag,
290
+ # stopping at a rake-style `key=value` token so `--include a model=Post`
291
+ # does not swallow the second parameter. Each token is still
292
+ # comma-split, so the documented `a.rb,b.rb` form keeps working and
293
+ # mixing the two spellings does not fabricate a path.
294
+ def collect_array_values(args, from)
295
+ args[from..].to_a
296
+ .take_while { |a| !a.start_with?("--") && !a.include?("=") }
297
+ .flat_map { |a| Array(coerce_value(a, { type: "array" })) }
298
+ end
299
+
300
+ def values_consumed(args, from)
301
+ args[from..].to_a.take_while { |a| !a.start_with?("--") && !a.include?("=") }.size
302
+ end
303
+
261
304
  # Coerce a string value to the type specified in the JSON Schema property.
262
305
  def coerce_value(raw, property_schema)
263
306
  case property_schema[:type]
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ # Re-runs Rails' own code reloader so a long-lived process sees files written
5
+ # after it booted.
6
+ #
7
+ # Clearing the gem's caches is not enough on its own. Introspectors reach the
8
+ # app through constants, and the eager loading they trigger
9
+ # (`Zeitwerk::Loader#eager_load_dir`) is idempotent per process - a directory
10
+ # already loaded is never re-scanned, so a model added after boot stays
11
+ # invisible for the life of the server. Routes never had the problem because
12
+ # RouteIntrospector asks `routes_reloader.execute_if_updated` every call.
13
+ module CodeReloader
14
+ module_function
15
+
16
+ # Reload the app's autoloaded code. Returns whether a reload actually ran,
17
+ # so callers can say what they did instead of guessing.
18
+ def reload!
19
+ return false unless reloadable?
20
+
21
+ Rails.application.reloader.reload!
22
+ true
23
+ rescue StandardError, ScriptError => e
24
+ # A broken file mid-edit is the common case. Zeitwerk reloads by
25
+ # unload-then-setup, so a raise can leave constants already unloaded -
26
+ # they re-autoload lazily on next reference. Either way a running server
27
+ # must survive it.
28
+ $stderr.puts "[rails-ai-context] code reload failed: #{e.class}: #{e.message}" if ENV["DEBUG"]
29
+ false
30
+ end
31
+
32
+ # Run a block with the sharing lock held, so a reload cannot unload
33
+ # constants underneath it.
34
+ #
35
+ # `Reloader#class_unload!` takes the unload lock through
36
+ # ActiveSupport::Dependencies.interlock, but that only blocks threads
37
+ # holding the sharing lock - which is acquired inside `executor.wrap`.
38
+ # Nothing here wrapped anything, so the Listen thread could clear
39
+ # DescendantsTracker while a tool call was midway through reading
40
+ # `ActiveRecord::Base.descendants`, returning a short list with no
41
+ # exception for the per-section rescue to notice.
42
+ #
43
+ # Only taken when a reload could actually happen; everywhere else this is
44
+ # a plain yield.
45
+ def with_app_code
46
+ return yield unless reloadable?
47
+
48
+ app = Rails.application
49
+ return yield unless app.respond_to?(:executor)
50
+
51
+ app.executor.wrap { yield }
52
+ end
53
+
54
+ # `enable_reloading` is the flag that decides whether Rails unloads
55
+ # anything: with it off, the finisher registers no class_unload callback,
56
+ # so `reload!` runs the prepare callbacks and returns having reloaded
57
+ # nothing. Gating on `eager_load` instead reported success for every
58
+ # eager_load=false + cache_classes=true environment (stock `test`, a
59
+ # cache-classes staging container) - a reload that never happened,
60
+ # announced as one.
61
+ def reloadable?
62
+ return false if RailsAiContext.static_tier?
63
+ return false unless defined?(Rails) && Rails.respond_to?(:application)
64
+
65
+ app = Rails.application
66
+ return false unless app.respond_to?(:reloader) && app.respond_to?(:config)
67
+
68
+ config = app.config
69
+ # Rails 7.1+ names it enable_reloading; older releases only have
70
+ # cache_classes, which is its inverse.
71
+ return config.enable_reloading if config.respond_to?(:enable_reloading)
72
+ return !config.cache_classes if config.respond_to?(:cache_classes)
73
+
74
+ false
75
+ rescue StandardError
76
+ false
77
+ end
78
+ end
79
+ end
@@ -6,7 +6,7 @@ module RailsAiContext
6
6
  # versioning patterns, rate limiting.
7
7
  class ApiIntrospector
8
8
  extend StaticTier
9
- static_tier :runtime_only
9
+ static_tier :alternate_source
10
10
 
11
11
  attr_reader :app
12
12
 
@@ -14,6 +14,20 @@ module RailsAiContext
14
14
  @app = app
15
15
  end
16
16
 
17
+ # Everything but api_only needs a booted app, but api_only itself is a
18
+ # plain assignment in config/application.rb - and the view tools already
19
+ # read it there. Leaving this section wholly unavailable meant one
20
+ # process answering "this is an API-only app" from get_stimulus and
21
+ # "cannot say" from get_api.
22
+ def static_call
23
+ {
24
+ api_only: AppKind.api_only?(app.root),
25
+ unavailable_sections: StaticTier.unavailable_reason
26
+ }
27
+ rescue StandardError
28
+ { unavailable: StaticTier.unavailable_reason }
29
+ end
30
+
17
31
  def call
18
32
  {
19
33
  api_only: app.config.api_only,
@@ -234,7 +234,14 @@ module RailsAiContext
234
234
  ActionMailer::Base.descendants.filter_map do |mailer|
235
235
  next if mailer.name.nil?
236
236
 
237
- actions = mailer.instance_methods(false).map(&:to_s).sort
237
+ # Rails' own answer: it subtracts internal and inherited methods, so
238
+ # an abstract ApplicationMailer reports none and ActiveSupport's
239
+ # generated `_run_*_callbacks` never appears. `instance_methods(false)`
240
+ # listed both as deliverable actions. Called bare, like
241
+ # ControllerIntrospector does - AbstractController::Base has defined
242
+ # it across the whole supported range, so a fallback here would only
243
+ # be unreachable code holding the old bug.
244
+ actions = mailer.action_methods.to_a.map(&:to_s).sort
238
245
  next if actions.empty?
239
246
 
240
247
  {
@@ -248,8 +255,10 @@ module RailsAiContext
248
255
  []
249
256
  end
250
257
 
251
- # A mailer's actions are its public instance methods, which is exactly
252
- # what `instance_methods(false)` reports on the booted side.
258
+ # A mailer's actions are its public instance methods. The booted side asks
259
+ # `action_methods`, which also subtracts inherited and internal ones - the
260
+ # AST cannot see that, so a public helper on a concrete mailer is still
261
+ # listed here and not there.
253
262
  def extract_mailers_from_source
254
263
  source_classes(File.join(app.root, "app", "mailers")).filter_map do |name, methods|
255
264
  next if name == "ApplicationMailer"
@@ -222,7 +222,10 @@ module RailsAiContext
222
222
  model.validators.map do |validator|
223
223
  {
224
224
  kind: validator.kind.to_s,
225
- attributes: validator.attributes.map(&:to_s),
225
+ # `validates_with` registers a bare ActiveModel::Validator, which
226
+ # has no #attributes - only EachValidator does. Calling it blind
227
+ # replaced the whole model's answer with one error line.
228
+ attributes: validator.respond_to?(:attributes) ? Array(validator.attributes).map(&:to_s) : [],
226
229
  options: sanitize_options(validator.options)
227
230
  }
228
231
  end
@@ -332,9 +335,15 @@ module RailsAiContext
332
335
  end
333
336
 
334
337
  def extract_callbacks_from_ast(source_data)
335
- source_data[:callbacks].each_with_object({}) do |cb, hash|
336
- type = cb[:type].to_s
337
- (hash[type] ||= []) << cb[:method]
338
+ group_callbacks_by_type(source_data[:callbacks])
339
+ end
340
+
341
+ # One shape for both tiers: { "before_validation" => ["normalize"] }.
342
+ def group_callbacks_by_type(callbacks)
343
+ Array(callbacks).each_with_object({}) do |cb, hash|
344
+ next unless cb.is_a?(Hash) && cb[:type]
345
+
346
+ (hash[cb[:type].to_s] ||= []) << cb[:method]
338
347
  end
339
348
  end
340
349
 
@@ -576,7 +585,12 @@ module RailsAiContext
576
585
  validations: data[:validations],
577
586
  scopes: data[:scopes],
578
587
  enums: data[:enums],
579
- callbacks: data[:callbacks],
588
+ # Same shape as the booted tier: a Hash keyed by callback type. The
589
+ # listener hands back a flat Array, and every consumer filters on
590
+ # `callbacks.is_a?(Hash)` - so passing it through rendered "No models
591
+ # with callbacks found" and then raised a TypeError on a Hash lookup
592
+ # against an Array.
593
+ callbacks: group_callbacks_by_type(data[:callbacks]),
580
594
  macros: data[:macros],
581
595
  methods: data[:methods]
582
596
  }
@@ -641,7 +655,12 @@ module RailsAiContext
641
655
  associations: data[:associations],
642
656
  validations: data[:validations],
643
657
  scopes: data[:scopes],
644
- callbacks: data[:callbacks],
658
+ # Same shape as the booted tier: a Hash keyed by callback type. The
659
+ # listener hands back a flat Array, and every consumer filters on
660
+ # `callbacks.is_a?(Hash)` - so passing it through rendered "No models
661
+ # with callbacks found" and then raised a TypeError on a Hash lookup
662
+ # against an Array.
663
+ callbacks: group_callbacks_by_type(data[:callbacks]),
645
664
  methods: data[:methods]
646
665
  }
647
666
  collection = macros.find { |m| m[:macro] == :store_in }&.dig(:options, :collection)
@@ -55,6 +55,11 @@ module RailsAiContext
55
55
 
56
56
  @last_fingerprint = Fingerprinter.compute(app)
57
57
 
58
+ # Order matters: reload the app's code first, then drop the caches built
59
+ # from the old constants. Clearing caches alone left the server answering
60
+ # from whatever Rails autoloaded at boot.
61
+ reloaded = CodeReloader.reload!
62
+
58
63
  # Invalidate all tool caches (includes AstCache.clear)
59
64
  Tools::BaseTool.reset_all_caches!
60
65
 
@@ -64,12 +69,12 @@ module RailsAiContext
64
69
  # Notify connected MCP clients
65
70
  mcp_server.notify_resources_list_changed
66
71
  mcp_server.notify_log_message(
67
- data: "#{message} Tool caches invalidated.",
72
+ data: "#{message} Tool caches invalidated#{reloaded ? " and app code reloaded" : ""}.",
68
73
  level: "info",
69
74
  logger: "rails-ai-context"
70
75
  )
71
76
 
72
- $stderr.puts "[rails-ai-context] #{message} Tool caches invalidated."
77
+ $stderr.puts "[rails-ai-context] #{message} Tool caches invalidated#{reloaded ? " and app code reloaded" : ""}."
73
78
  rescue => e
74
79
  $stderr.puts "[rails-ai-context] Live reload error: #{e.message}"
75
80
  end
@@ -143,6 +143,13 @@ module RailsAiContext
143
143
  # the handler's return value into the JSON-RPC response without renaming
144
144
  # keys, so snake_case would leak to clients as-is.
145
145
  def handle_read(params)
146
+ # Resource reads introspect just like tool calls do, and they bypassed
147
+ # SafeCall entirely - so a live reload could unload constants while one
148
+ # was reading them and hand back a short list with nothing raised.
149
+ RailsAiContext::CodeReloader.with_app_code { read_resource(params) }
150
+ end
151
+
152
+ def read_resource(params)
146
153
  uri = params[:uri]
147
154
 
148
155
  # The two schemes are historical; accept either one for every
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ # One answer to "which database is this app on", for every surface that asks.
5
+ #
6
+ # `SchemaIntrospector` writes `static_parse` when it read `db/schema.rb`
7
+ # instead of the connection. That is an internal detail: printed raw it names
8
+ # a database that does not exist. Four call sites each grew their own
9
+ # substitute and gave three different answers for one app - the generated
10
+ # CLAUDE.md said PostgreSQL, `rails_get_schema` said unknown, `rails ai:inspect`
11
+ # said static_parse. This is the seam they share.
12
+ module SchemaAdapter
13
+ # Values that mean "no adapter was observed", not "the adapter is X".
14
+ PLACEHOLDERS = %w[static_parse unknown].freeze
15
+
16
+ # Gemfile display names, only as the last resort.
17
+ GEM_ADAPTERS = {
18
+ "pg" => "PostgreSQL",
19
+ "mysql2" => "MySQL",
20
+ "trilogy" => "MySQL",
21
+ "sqlite3" => "SQLite"
22
+ }.freeze
23
+
24
+ # structure.sql dialects, as SchemaIntrospector records them.
25
+ DIALECTS = {
26
+ "postgresql" => "PostgreSQL",
27
+ "mysql" => "MySQL",
28
+ "sqlite" => "SQLite"
29
+ }.freeze
30
+
31
+ module_function
32
+
33
+ # @param context [Hash] a full introspection context
34
+ # @return [String] the adapter to show, never an internal placeholder
35
+ def label(context)
36
+ context = {} unless context.is_a?(Hash)
37
+ schema = context[:schema]
38
+ observed = schema.is_a?(Hash) ? schema[:adapter] : nil
39
+ return observed unless placeholder?(observed)
40
+
41
+ # Best first: the app's own database config, which needs no connection.
42
+ # Then the dialect parsed out of structure.sql. Only then the Gemfile,
43
+ # which is a guess - an app can carry two adapter gems.
44
+ from_configurations(context) || from_dialect(schema) || from_gems(context) || "unknown"
45
+ end
46
+
47
+ def placeholder?(adapter)
48
+ adapter.nil? || PLACEHOLDERS.include?(adapter.to_s)
49
+ end
50
+
51
+ def from_configurations(context)
52
+ multi = context[:multi_database]
53
+ return nil unless Serializers::SectionGuard.usable?(multi)
54
+
55
+ primary = Array(multi[:databases]).find { |db| db[:name].to_s == "primary" } ||
56
+ Array(multi[:databases]).first
57
+ adapter = primary&.dig(:adapter)
58
+ return nil if placeholder?(adapter)
59
+
60
+ # These are ActiveRecord adapter names (`postgresql`, `sqlite3`), not gem
61
+ # names. The two overlap by accident at sqlite3/mysql2/trilogy, so
62
+ # reading only GEM_ADAPTERS let `postgresql` fall through raw - a Postgres
63
+ # app rendered "Database: postgresql" beside a SQLite app's "SQLite".
64
+ DIALECTS[adapter.to_s] || GEM_ADAPTERS[adapter.to_s] || adapter
65
+ end
66
+
67
+ def from_dialect(schema)
68
+ return nil unless schema.is_a?(Hash)
69
+
70
+ DIALECTS[schema[:dialect].to_s]
71
+ end
72
+
73
+ # Last, and order-independent: an app with both `pg` and `sqlite3` gets the
74
+ # same answer here as anywhere else, rather than depending on which match
75
+ # a loop happened to see last.
76
+ def from_gems(context)
77
+ gems = context[:gems]
78
+ return nil unless Serializers::SectionGuard.usable?(gems)
79
+
80
+ names = Array(gems[:notable_gems]).map { |g| g[:name].to_s }
81
+ GEM_ADAPTERS.each_key { |gem_name| return GEM_ADAPTERS[gem_name] if names.include?(gem_name) }
82
+ nil
83
+ end
84
+ end
85
+ end
@@ -43,7 +43,7 @@ module RailsAiContext
43
43
  # Compact counts - gems and architecture are already in the root file (CLAUDE.md/AGENTS.md)
44
44
  schema = context[:schema]
45
45
  if SectionGuard.usable?(schema)
46
- lines << "- Database: #{schema[:adapter]} - #{count_phrase(schema[:total_tables], "table")}"
46
+ lines << "- Database: #{database_adapter_label(schema)} - #{count_phrase(schema[:total_tables], "table")}"
47
47
  end
48
48
 
49
49
  models = context[:models]
@@ -26,7 +26,7 @@ module RailsAiContext
26
26
  schema = context[:schema]
27
27
  if SectionGuard.usable?(schema)
28
28
  tables = schema[:total_tables]
29
- lines << "- Database: #{schema[:adapter]} - #{count_phrase(tables, "table")}"
29
+ lines << "- Database: #{database_adapter_label(schema)} - #{count_phrase(tables, "table")}"
30
30
  end
31
31
 
32
32
  models = context[:models]
@@ -45,7 +45,7 @@ module RailsAiContext
45
45
 
46
46
  schema = context[:schema]
47
47
  if SectionGuard.usable?(schema)
48
- lines << "- Database: #{schema[:adapter]} - #{count_phrase(schema[:total_tables], "table")}"
48
+ lines << "- Database: #{database_adapter_label(schema)} - #{count_phrase(schema[:total_tables], "table")}"
49
49
  end
50
50
 
51
51
  models = context[:models]
@@ -29,7 +29,7 @@ module RailsAiContext
29
29
  schema = context[:schema]
30
30
  if SectionGuard.usable?(schema)
31
31
  tables = schema[:total_tables]
32
- lines << "- Database: #{schema[:adapter]} - #{count_phrase(tables, "table")}"
32
+ lines << "- Database: #{database_adapter_label(schema)} - #{count_phrase(tables, "table")}"
33
33
  end
34
34
 
35
35
  models = context[:models]
@@ -74,7 +74,7 @@ module RailsAiContext
74
74
 
75
75
  schema = context[:schema]
76
76
  if SectionGuard.usable?(schema)
77
- lines << "- Database: #{schema[:adapter]} - #{count_phrase(schema[:total_tables], "table")}"
77
+ lines << "- Database: #{database_adapter_label(schema)} - #{count_phrase(schema[:total_tables], "table")}"
78
78
  end
79
79
 
80
80
  models = context[:models]
@@ -213,6 +213,13 @@ module RailsAiContext
213
213
  $stderr.puts "[rails-ai-context] Before actions scan skipped: #{e.message}" if ENV["DEBUG"]
214
214
  []
215
215
  end
216
+
217
+ # One seam for every surface that names the database, so the generated
218
+ # files, the tools and the rake task cannot answer differently for one
219
+ # app. See RailsAiContext::SchemaAdapter.
220
+ def database_adapter_label(_schema = nil)
221
+ SchemaAdapter.label(context)
222
+ end
216
223
  end
217
224
  end
218
225
  end
@@ -438,7 +438,7 @@ namespace :ai do
438
438
  puts ""
439
439
 
440
440
  if context[:schema] && !context[:schema][:error]
441
- puts "📦 Database: #{RailsAiContext::CountPhrase.call(context[:schema][:total_tables], "table")} (#{context[:schema][:adapter]})"
441
+ puts "📦 Database: #{RailsAiContext::CountPhrase.call(context[:schema][:total_tables], "table")} (#{RailsAiContext::SchemaAdapter.label(context)})"
442
442
  end
443
443
 
444
444
  if context[:models] && !context[:models].is_a?(Hash)
@@ -355,7 +355,15 @@ module RailsAiContext
355
355
  return api[:api_only] == true if api.is_a?(Hash) && api.key?(:api_only)
356
356
 
357
357
  app = rails_app
358
- app.respond_to?(:config) && app.config.respond_to?(:api_only) && app.config.api_only == true
358
+ if app.respond_to?(:config) && app.config.respond_to?(:api_only)
359
+ return app.config.api_only == true
360
+ end
361
+
362
+ # Static tier: no booted config to ask, but the flag is declared in
363
+ # config/application.rb. Without this the view tools fall back to
364
+ # "none found", which reads as "not built yet" for an app that has
365
+ # no view layer by design.
366
+ AppKind.api_only?(app.root)
359
367
  rescue StandardError
360
368
  false
361
369
  end
@@ -86,9 +86,9 @@ module RailsAiContext
86
86
  # directory is there (e.g. freshly generated by `rails new`).
87
87
  private_class_method def self.no_job_files_message(jobs_dir_exists)
88
88
  if jobs_dir_exists
89
- "app/jobs/ exists but has no job classes beyond ApplicationJob. This app may not use background jobs."
89
+ "No job classes in app/jobs/ beyond ApplicationJob. Workers outside that directory (Sidekiq::Worker in app/workers/, for example) are not covered by this tool."
90
90
  else
91
- "No app/jobs/ directory found. This app may not use background jobs."
91
+ "No app/jobs/ directory. Workers outside it (Sidekiq::Worker in app/workers/, for example) are not covered by this tool."
92
92
  end
93
93
  end
94
94
 
@@ -101,9 +101,9 @@ module RailsAiContext
101
101
  private_class_method def self.no_jobs_or_channels_message(jobs_dir_exists, channels_note)
102
102
  channels_clause = channels_note || "no Action Cable channels detected"
103
103
  if jobs_dir_exists
104
- "app/jobs/ exists but has no job classes beyond ApplicationJob, and #{channels_clause}. This app may not use background jobs."
104
+ "No job classes in app/jobs/ beyond ApplicationJob, and #{channels_clause}. Workers outside that directory are not covered by this tool."
105
105
  else
106
- "No app/jobs/ directory found and #{channels_clause}. This app may not use background jobs."
106
+ "No app/jobs/ directory and #{channels_clause}. Workers outside it are not covered by this tool."
107
107
  end
108
108
  end
109
109
 
@@ -95,7 +95,7 @@ module RailsAiContext
95
95
  return text_response("No tables at offset #{offset}. Total: #{total}. Use `offset:0` to start over.")
96
96
  end
97
97
  lines = [ "# Schema Summary (#{count_phrase(total, "table")})", "" ]
98
- lines << "**Adapter:** #{schema[:adapter]}" if schema[:adapter]
98
+ lines << "**Adapter:** #{adapter_label(schema)}" if schema[:adapter]
99
99
  lines.concat(static_source_lines(schema))
100
100
  paginated.each do |name|
101
101
  data = tables[name]
@@ -209,6 +209,13 @@ module RailsAiContext
209
209
  end
210
210
  end
211
211
 
212
+ # The same seam the generated context files use. Answering this question
213
+ # locally is how one app came to be told it runs on PostgreSQL by
214
+ # CLAUDE.md and on "unknown" by this tool, in the same session.
215
+ private_class_method def self.adapter_label(_schema = nil)
216
+ RailsAiContext::SchemaAdapter.label(cached_context)
217
+ end
218
+
212
219
  private_class_method def self.models_for_table(table_name)
213
220
  models = cached_context[:models]
214
221
  return [] unless models.is_a?(Hash)
@@ -333,7 +340,7 @@ module RailsAiContext
333
340
  lines = [
334
341
  "# Database Schema",
335
342
  "",
336
- "- Adapter: #{schema[:adapter]}",
343
+ "- Adapter: #{adapter_label(schema)}",
337
344
  "- Tables: #{schema[:total_tables]}",
338
345
  ""
339
346
  ]
@@ -594,31 +594,11 @@ module RailsAiContext
594
594
  end
595
595
  end
596
596
 
597
- # Resolve the DB adapter name, preferring live config over schema introspection
598
- def resolve_db_adapter(ctx, schema)
599
- adapter = schema[:adapter]
600
-
601
- # If the schema introspector returned a non-informative adapter name, try config
602
- if adapter.nil? || adapter == "static_parse" || adapter == "unknown"
603
- config_data = ctx[:config]
604
- if config_data.is_a?(Hash) && !config_data[:error]
605
- live_adapter = config_data[:database_adapter] || config_data[:adapter]
606
- adapter = live_adapter if live_adapter
607
- end
608
- end
609
-
610
- # Try to resolve from gems as a fallback
611
- if adapter.nil? || adapter == "static_parse" || adapter == "unknown"
612
- gems_data = ctx[:gems]
613
- if gems_data.is_a?(Hash) && !gems_data[:error]
614
- notable = gems_data[:notable_gems] || []
615
- adapter = "PostgreSQL" if notable.any? { |g| g[:name] == "pg" }
616
- adapter = "MySQL" if notable.any? { |g| %w[mysql2 trilogy].include?(g[:name]) }
617
- adapter = "SQLite" if notable.any? { |g| g[:name] == "sqlite3" }
618
- end
619
- end
620
-
621
- adapter || "unknown"
597
+ # The gems loop here let the LAST match win, so an app carrying both pg
598
+ # and sqlite3 was told SQLite by onboard and PostgreSQL by the
599
+ # generated files. One seam, one answer.
600
+ def resolve_db_adapter(ctx, _schema = nil)
601
+ RailsAiContext::SchemaAdapter.label(ctx)
622
602
  end
623
603
 
624
604
  def central_models(models, limit = 5)
@@ -23,23 +23,38 @@ module RailsAiContext
23
23
  kwargs = normalize_detail(kwargs)
24
24
  Thread.current[:rails_ai_context_call_params] = session_params(kwargs)
25
25
 
26
- append_note(super(**kwargs), invalid_detail_note(discarded))
27
- rescue StandardError => e
28
- # A failed call must not leak its recorded params into the next
29
- # call's session entry.
30
- Thread.current[:rails_ai_context_call_params] = nil
26
+ # Held across the tool body: a concurrent live reload must not unload
27
+ # constants while this call is reading them.
28
+ #
29
+ # The rescue sits INSIDE the block on purpose. `executor.wrap` reports
30
+ # anything that crosses it to the host app's error_reporter as
31
+ # unhandled, which would page a team over a failure this method turns
32
+ # into an ordinary isError result. Nothing escapes the block, so
33
+ # nothing is reported.
34
+ RailsAiContext::CodeReloader.with_app_code do
35
+ begin
36
+ append_note(super(**kwargs), invalid_detail_note(discarded))
37
+ rescue StandardError => e
38
+ # A failed call must not leak its recorded params into the next
39
+ # call's session entry.
40
+ Thread.current[:rails_ai_context_call_params] = nil
41
+ failure_response(e)
42
+ end
43
+ end
44
+ end
45
+
46
+ private
31
47
 
48
+ def failure_response(error)
32
49
  label = respond_to?(:tool_name) ? tool_name : name
33
- origin = Array(e.backtrace).first.to_s
34
- text = +"Tool #{label} failed: #{e.class}: #{e.message.to_s.lines.first&.strip}\n"
50
+ origin = Array(error.backtrace).first.to_s
51
+ text = +"Tool #{label} failed: #{error.class}: #{error.message.to_s.lines.first&.strip}\n"
35
52
  text << "At: #{origin}\n" unless origin.empty?
36
53
  text << "Recovery: retry with a narrower query (a single table, model, or " \
37
54
  "controller), or run `rails-ai-context doctor` to check app health."
38
55
  MCP::Tool::Response.new([ { type: "text", text: text } ], error: true)
39
56
  end
40
57
 
41
- private
42
-
43
58
  # The value the caller sent, when this tool takes a DetailLevel detail
44
59
  # and that value is not one of its levels.
45
60
  def discarded_detail(kwargs)
@@ -48,7 +48,16 @@ module RailsAiContext
48
48
  VALID_LEVELS = %w[syntax rails].freeze
49
49
 
50
50
  def self.call(files:, level: "syntax", server_context: nil)
51
- return text_response("No files provided. Pass file paths relative to Rails root (e.g. files:[\"app/models/post.rb\"]).") if files.nil? || files.empty?
51
+ # Both spellings, because the JSON form this used to suggest is not
52
+ # something the CLI accepts - following the advice produced
53
+ # "file not found" with the literal brackets in the name.
54
+ if files.nil? || files.empty?
55
+ return text_response(
56
+ "No files provided. Pass paths relative to Rails root - " \
57
+ "CLI: `--files app/models/post.rb app/models/user.rb`, " \
58
+ "MCP: `files: [\"app/models/post.rb\"]`."
59
+ )
60
+ end
52
61
  return text_response("Too many files (#{files.size}). Maximum is #{max_files} per call.") if files.size > max_files
53
62
  return text_response("Unknown level: '#{level}'. Valid values: #{VALID_LEVELS.join(', ')}") unless VALID_LEVELS.include?(level)
54
63
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAiContext
4
- VERSION = "5.20.1"
4
+ VERSION = "5.20.2"
5
5
  end
@@ -72,6 +72,9 @@ module RailsAiContext
72
72
  @last_fingerprint = Fingerprinter.compute(app)
73
73
 
74
74
  $stderr.puts "[rails-ai-context] Changes detected, regenerating context files..."
75
+ # Without this the regenerated files describe the app as it was when the
76
+ # watcher started, not as it is now.
77
+ CodeReloader.reload!
75
78
  result = RailsAiContext.generate_context(format: :all)
76
79
  result[:written].each { |f| $stderr.puts " Updated: #{f}" }
77
80
  result[:skipped].each { |f| $stderr.puts " Unchanged: #{f}" }
data/server.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "url": "https://github.com/crisnahine/rails-ai-context",
8
8
  "source": "github"
9
9
  },
10
- "version": "5.20.0",
10
+ "version": "5.20.1",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "mcpb",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-ai-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.20.1
4
+ version: 5.20.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - crisnahine
@@ -278,6 +278,7 @@ files:
278
278
  - lib/rails_ai_context/boot_manager.rb
279
279
  - lib/rails_ai_context/cli.rb
280
280
  - lib/rails_ai_context/cli/tool_runner.rb
281
+ - lib/rails_ai_context/code_reloader.rb
281
282
  - lib/rails_ai_context/confidence.rb
282
283
  - lib/rails_ai_context/configuration.rb
283
284
  - lib/rails_ai_context/count_phrase.rb
@@ -385,6 +386,7 @@ files:
385
386
  - lib/rails_ai_context/redaction.rb
386
387
  - lib/rails_ai_context/resources.rb
387
388
  - lib/rails_ai_context/safe_file.rb
389
+ - lib/rails_ai_context/schema_adapter.rb
388
390
  - lib/rails_ai_context/schema_hint.rb
389
391
  - lib/rails_ai_context/schema_version.rb
390
392
  - lib/rails_ai_context/serializers.rb