rails-ai-context 5.20.0 → 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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -0
  3. data/lib/rails_ai_context/app_kind.rb +23 -0
  4. data/lib/rails_ai_context/cli/tool_runner.rb +63 -3
  5. data/lib/rails_ai_context/code_reloader.rb +79 -0
  6. data/lib/rails_ai_context/introspector.rb +1 -3
  7. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +20 -1
  8. data/lib/rails_ai_context/introspectors/api_introspector.rb +15 -1
  9. data/lib/rails_ai_context/introspectors/engine_introspector.rb +15 -1
  10. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +67 -6
  11. data/lib/rails_ai_context/introspectors/job_introspector.rb +81 -2
  12. data/lib/rails_ai_context/introspectors/model_introspector.rb +25 -6
  13. data/lib/rails_ai_context/introspectors/route_introspector.rb +5 -1
  14. data/lib/rails_ai_context/introspectors/static_tier.rb +9 -0
  15. data/lib/rails_ai_context/live_reload.rb +7 -2
  16. data/lib/rails_ai_context/resources.rb +7 -0
  17. data/lib/rails_ai_context/schema_adapter.rb +85 -0
  18. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
  19. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +7 -6
  20. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +1 -1
  21. data/lib/rails_ai_context/serializers/copilot_serializer.rb +5 -3
  22. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -1
  23. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +7 -0
  24. data/lib/rails_ai_context/server.rb +45 -5
  25. data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -1
  26. data/lib/rails_ai_context/tools/base_tool.rb +9 -1
  27. data/lib/rails_ai_context/tools/get_active_support.rb +16 -0
  28. data/lib/rails_ai_context/tools/get_engines.rb +3 -1
  29. data/lib/rails_ai_context/tools/get_env.rb +20 -0
  30. data/lib/rails_ai_context/tools/get_gems.rb +4 -1
  31. data/lib/rails_ai_context/tools/get_i18n.rb +3 -1
  32. data/lib/rails_ai_context/tools/get_job_pattern.rb +4 -4
  33. data/lib/rails_ai_context/tools/get_mailers.rb +2 -1
  34. data/lib/rails_ai_context/tools/get_routes.rb +20 -11
  35. data/lib/rails_ai_context/tools/get_schema.rb +9 -2
  36. data/lib/rails_ai_context/tools/onboard.rb +5 -25
  37. data/lib/rails_ai_context/tools/safe_call.rb +24 -9
  38. data/lib/rails_ai_context/tools/section_fetch.rb +1 -3
  39. data/lib/rails_ai_context/tools/validate.rb +10 -1
  40. data/lib/rails_ai_context/version.rb +1 -1
  41. data/lib/rails_ai_context/watcher.rb +3 -0
  42. data/server.json +1 -1
  43. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e92439f72b8f723d75c6b62ca2fe3734f29b60548e50d2ddea836c93a66d952
4
- data.tar.gz: c541d140c1f7971fa0f62d5e664673a338ffc7e809b8df8578035890224567d0
3
+ metadata.gz: 81eedfa398603e64a27f7555b4288e39a11e41093c3a06450354c2f5cb1d17c5
4
+ data.tar.gz: 34972bf4259e8e12695f008744111bff94ed7caa93155bd3f8352010e3134c15
5
5
  SHA512:
6
- metadata.gz: e2e550d201dd7af30d4e95e5ec368d2f376d2b8812d5254ffcf10ab8f78b1f0e089df5ba5f2664fbe858dbf2a5277c15e2489df0e86b61b22ebd44868560854f
7
- data.tar.gz: 80daba010490cd5b6d1e0c4e770582b4bda8db4d073ebb9bead8d2eb3f015ec966c1ce1fef60f229755e1f1610b48b2287da480d790f20ed3be705d8b4ee95d7
6
+ metadata.gz: c88339dc6c73a5fdb38209d69e61437a6494382c069ae943478e51a265e57b2b847d4cb89f22ef0449c024bdb43185feef7e58a5e92f199c0a0389f4822898ca
7
+ data.tar.gz: 2f0a283b5691f00d4f49aeed839da56af13e248f566c9a526e3926cbc0c71185cf2e99a9b809d5e316d7e1144826456bb65ea36148493b4102f44b3bf0857584
data/CHANGELOG.md CHANGED
@@ -5,6 +5,104 @@ 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
+
70
+ ## [5.20.1] - 2026-08-11
71
+
72
+ ### Fixed
73
+
74
+ - **`config.custom_tools` no longer takes the MCP server down.** Naming a
75
+ `BaseTool` subclass resolved its constant, which autoloaded the class and
76
+ enrolled it in the same registry the built-in list is read from, so it was
77
+ offered to the SDK twice and rejected as a duplicate name. The server exited
78
+ 1 with an empty stdout while the CLI kept working. Tools are now merged by
79
+ name; two different classes claiming one name keep the built-in and warn.
80
+ - **`--flag value` no longer inverts boolean parameters.** A boolean flag
81
+ consumed no value, so `--app-only false` set `app_only` to true and dropped
82
+ the `false` without a warning. Affected every boolean on every tool, and
83
+ `--param value` is the form the CLI docs teach.
84
+ - **The static tier stops answering questions it cannot answer.** `mailers`
85
+ reported "no mailers found" for an app with mailers, `engines` reported no
86
+ engines loaded, `i18n` reported one locale while listing two locale files,
87
+ and the Action Cable channels, deprecators, on_load hooks, cache store and
88
+ credentials sections vanished with no marker. Mailers, channels and locales
89
+ are read from source through `static_call`; the rest report
90
+ `[UNAVAILABLE]`. Locale files using YAML anchors are read correctly, and an
91
+ `I18n.default_locale` set in an initializer is honoured.
92
+ - **`app_only:false` lists the routes it counts.** It announced the unfiltered
93
+ total above a body containing only app routes, and never showed a framework
94
+ route. App routes are now listed first, so they cannot paginate out of sight.
95
+ `app_only:true` says how many routes it hid and how to see them.
96
+ - **One app, one route count.** Generated context files counted raw routes
97
+ while the tools merged each resource's `PATCH`/`PUT` pair, so `CLAUDE.md` and
98
+ `rails_get_routes` quoted different numbers for the same app. The totals are
99
+ merged at the source, and the serializers read
100
+ `config.excluded_route_prefixes` instead of a hardcoded copy.
101
+ - **`rails_get_gems` past the last page** no longer opens with "No notable gems
102
+ found" above its own "No items at offset N" note.
103
+ - The MCP startup banner reads the list off the built server, so it cannot
104
+ announce a different set of tools than the server answers with.
105
+
8
106
  ## [5.20.0] - 2026-08-11
9
107
 
10
108
  ### Security
@@ -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
@@ -15,6 +15,13 @@ module RailsAiContext
15
15
  class ToolNotFoundError < StandardError; end
16
16
  class InvalidArgumentError < StandardError; end
17
17
 
18
+ # The only words that read as true, and every word a boolean flag will
19
+ # consume as its value. Kept together so the two cannot drift: a word
20
+ # accepted here but missing from TRUTHY would silently mean false.
21
+ TRUTHY_WORDS = %w[true 1 yes].freeze
22
+ FALSEY_WORDS = %w[false 0 no].freeze
23
+ BOOLEAN_WORDS = (TRUTHY_WORDS + FALSEY_WORDS).freeze
24
+
18
25
  attr_reader :tool_class, :raw_args, :json_mode, :error
19
26
 
20
27
  def initialize(tool_name, raw_args, json_mode: false)
@@ -204,13 +211,51 @@ module RailsAiContext
204
211
  if arg.include?("=")
205
212
  key, value = arg.sub("--", "").split("=", 2)
206
213
  key = key.tr("-", "_").to_sym
207
- 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)
208
225
  else
209
226
  key = arg.sub("--", "").tr("-", "_").to_sym
210
227
  prop = properties[key] || {}
211
228
 
212
229
  if prop[:type] == "boolean"
213
- result[key] = true
230
+ # `--flag false` is the form docs/CLI.md teaches for every
231
+ # other param, so a boolean has to honour it too. Only an
232
+ # explicit boolean word is consumed - anything else stays a
233
+ # separate argument and the flag means true.
234
+ nxt = args[i + 1]
235
+ if nxt && BOOLEAN_WORDS.include?(nxt.downcase)
236
+ result[key] = coerce_value(nxt, prop)
237
+ i += 2
238
+ else
239
+ result[key] = true
240
+ i += 1
241
+ end
242
+ next
243
+ end
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] = []
214
259
  i += 1
215
260
  next
216
261
  end
@@ -241,13 +286,28 @@ module RailsAiContext
241
286
  result
242
287
  end
243
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
+
244
304
  # Coerce a string value to the type specified in the JSON Schema property.
245
305
  def coerce_value(raw, property_schema)
246
306
  case property_schema[:type]
247
307
  when "integer"
248
308
  raw.to_i
249
309
  when "boolean"
250
- %w[true 1 yes].include?(raw.to_s.downcase)
310
+ TRUTHY_WORDS.include?(raw.to_s.downcase)
251
311
  when "array"
252
312
  raw.is_a?(Array) ? raw : raw.to_s.split(",").map(&:strip)
253
313
  else
@@ -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
@@ -122,9 +122,7 @@ module RailsAiContext
122
122
  end
123
123
 
124
124
  def unavailable_reason
125
- reason = RailsAiContext.static_reason
126
- base = "requires a booted Rails app"
127
- reason ? "#{base} (#{reason})" : base
125
+ Introspectors::StaticTier.unavailable_reason
128
126
  end
129
127
 
130
128
  def rails_version
@@ -8,7 +8,7 @@ module RailsAiContext
8
8
  # Covers RAILS_NERVOUS_SYSTEM.md §17 (ActiveSupport).
9
9
  class ActiveSupportIntrospector
10
10
  extend StaticTier
11
- static_tier :files_only
11
+ static_tier :alternate_source
12
12
 
13
13
  attr_reader :app
14
14
 
@@ -30,6 +30,25 @@ module RailsAiContext
30
30
  { error: e.message }
31
31
  end
32
32
 
33
+ # Concerns, MessageVerifier usage and tagged logging are read off disk.
34
+ # The registered deprecators, the subscribed load hooks and the cache
35
+ # store only exist in a running process; an empty list for them renders
36
+ # as "this app has none", so they refuse instead.
37
+ def static_call
38
+ unavailable = { unavailable: StaticTier.unavailable_reason }
39
+ {
40
+ concerns: extract_concerns,
41
+ deprecators: unavailable,
42
+ message_verifier_usage: extract_message_verifier_usage,
43
+ tagged_logging: detect_tagged_logging,
44
+ on_load_hooks: unavailable,
45
+ cache_usage: unavailable
46
+ }
47
+ rescue => e
48
+ $stderr.puts "[rails-ai-context] ActiveSupportIntrospector#static_call failed: #{e.message}" if ENV["DEBUG"]
49
+ { error: e.message }
50
+ end
51
+
33
52
  private
34
53
 
35
54
  def root
@@ -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,
@@ -6,7 +6,7 @@ module RailsAiContext
6
6
  # Identifies well-known engines and provides context about what each does.
7
7
  class EngineIntrospector
8
8
  extend StaticTier
9
- static_tier :files_only
9
+ static_tier :alternate_source
10
10
 
11
11
  attr_reader :app
12
12
 
@@ -50,6 +50,20 @@ module RailsAiContext
50
50
  { error: e.message }
51
51
  end
52
52
 
53
+ # config/routes.rb is a file, so mounts read the same either way. Which
54
+ # engine classes a process loaded is only knowable from that process -
55
+ # and testing `defined?(Rails::Engine)` instead of the tier answered from
56
+ # the half-finished boot that put us here, which is the common way into
57
+ # the static tier.
58
+ def static_call
59
+ {
60
+ mounted_engines: discover_mounted_engines,
61
+ rails_engines: { unavailable: StaticTier.unavailable_reason }
62
+ }
63
+ rescue => e
64
+ { error: e.message }
65
+ end
66
+
53
67
  private
54
68
 
55
69
  def root
@@ -7,7 +7,12 @@ module RailsAiContext
7
7
  # Discovers internationalization setup: locales, backends, key counts.
8
8
  class I18nIntrospector
9
9
  extend StaticTier
10
- static_tier :files_only
10
+ static_tier :alternate_source
11
+
12
+ # Both spellings apps use: `config.i18n.default_locale = :es` in
13
+ # application.rb or an environment file, and a bare
14
+ # `I18n.default_locale = :es` in an initializer.
15
+ DEFAULT_LOCALE_ASSIGNMENT = /(?:config\.i18n|I18n)\.default_locale\s*=\s*[:"']([\w-]+)/
11
16
 
12
17
  attr_reader :app
13
18
 
@@ -30,8 +35,65 @@ module RailsAiContext
30
35
  { error: e.message }
31
36
  end
32
37
 
38
+ # The locale files are the same files either way; only the list of
39
+ # locales and the default came from a running I18n. Read both from disk
40
+ # rather than report the library's own defaults as the app's.
41
+ def static_call
42
+ locales = locales_from_files
43
+ default = default_locale_from_config
44
+
45
+ {
46
+ default_locale: default,
47
+ available_locales: locales,
48
+ backend: nil,
49
+ locale_files: extract_locale_files,
50
+ total_locale_files: count_locale_files,
51
+ locale_coverage: detect_locale_coverage(locales: locales.map(&:to_sym), default: default.to_sym)
52
+ }.merge(detect_fallback_config)
53
+ rescue => e
54
+ { error: e.message }
55
+ end
56
+
33
57
  private
34
58
 
59
+ # Every top-level key across config/locales - the same population Rails
60
+ # builds available_locales from.
61
+ def locales_from_files
62
+ dir = File.join(root, "config/locales")
63
+ return [] unless Dir.exist?(dir)
64
+
65
+ Dir.glob(File.join(dir, "**/*.{yml,yaml}")).flat_map do |path|
66
+ content = RailsAiContext::SafeFile.read(path)
67
+ next [] unless content
68
+
69
+ # aliases: true, like extract_locale_files. Sharing formats through a
70
+ # YAML anchor is ordinary, and without the flag Psych raises, the
71
+ # rescue swallows it, and every locale in that file disappears while
72
+ # the Locale Files section still lists it.
73
+ data = YAML.safe_load(content, permitted_classes: [ Symbol ], aliases: true)
74
+ data.is_a?(Hash) ? data.keys.map(&:to_s) : []
75
+ rescue StandardError
76
+ []
77
+ end.uniq.sort
78
+ end
79
+
80
+ # Rails' own default is :en, so "en" is the right answer when the app
81
+ # never says otherwise - not a guess.
82
+ def default_locale_from_config
83
+ candidates = [ File.join(root, "config", "application.rb") ] +
84
+ Dir.glob(File.join(root, "config", "environments", "*.rb")) +
85
+ Dir.glob(File.join(root, "config", "initializers", "*.rb"))
86
+
87
+ candidates.each do |path|
88
+ next unless File.exist?(path)
89
+
90
+ content = RailsAiContext::SafeFile.read(path)
91
+ match = content&.match(DEFAULT_LOCALE_ASSIGNMENT)
92
+ return match[1] if match
93
+ end
94
+ "en"
95
+ end
96
+
35
97
  def root
36
98
  app.root.to_s
37
99
  end
@@ -77,8 +139,7 @@ module RailsAiContext
77
139
  {}
78
140
  end
79
141
 
80
- def detect_locale_coverage
81
- locales = I18n.available_locales
142
+ def detect_locale_coverage(locales: I18n.available_locales, default: I18n.default_locale)
82
143
  return {} if locales.size < 2
83
144
 
84
145
  # Coverage is the share of the default locale's keys that the other
@@ -86,8 +147,8 @@ module RailsAiContext
86
147
  # for a locale that translates few default keys but adds many of its
87
148
  # own - the one number a translator must not be told is fine.
88
149
  coverage = {}
89
- default_keys = key_paths_for_locale(I18n.default_locale)
90
- locales.reject { |l| l == I18n.default_locale }.each do |locale|
150
+ default_keys = key_paths_for_locale(default)
151
+ locales.reject { |l| l == default }.each do |locale|
91
152
  locale_keys = key_paths_for_locale(locale)
92
153
  translated = (default_keys & locale_keys).size
93
154
  coverage[locale.to_s] = {
@@ -110,7 +171,7 @@ module RailsAiContext
110
171
  find_locale_paths(locale).flat_map do |path|
111
172
  content = RailsAiContext::SafeFile.read(path)
112
173
  next [] unless content
113
- data = YAML.safe_load(content, permitted_classes: [ Symbol ])
174
+ data = YAML.safe_load(content, permitted_classes: [ Symbol ], aliases: true)
114
175
  next [] unless data.is_a?(Hash)
115
176
 
116
177
  # A locale root may be written `en:` or `:en:` - both load, and both
@@ -6,7 +6,7 @@ module RailsAiContext
6
6
  # and Action Cable channels.
7
7
  class JobIntrospector
8
8
  extend StaticTier
9
- static_tier :files_only
9
+ static_tier :alternate_source
10
10
 
11
11
  CHANNEL_MACROS = %i[identified_by stream_from stream_for periodically].freeze
12
12
 
@@ -31,6 +31,19 @@ module RailsAiContext
31
31
  }
32
32
  end
33
33
 
34
+ # Mailers and channels are ordinary classes in ordinary directories, so
35
+ # the answer is the same with or without a booted app. Only the way in
36
+ # differs: descendants when Rails is up, the AST when it is not.
37
+ def static_call
38
+ {
39
+ jobs: extract_jobs_from_source,
40
+ mailers: extract_mailers_from_source,
41
+ channels: extract_channels_from_source,
42
+ recurring_jobs: extract_solid_queue_recurring,
43
+ sidekiq_config: extract_sidekiq_config
44
+ }
45
+ end
46
+
34
47
  private
35
48
 
36
49
  def extract_jobs
@@ -221,7 +234,14 @@ module RailsAiContext
221
234
  ActionMailer::Base.descendants.filter_map do |mailer|
222
235
  next if mailer.name.nil?
223
236
 
224
- 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
225
245
  next if actions.empty?
226
246
 
227
247
  {
@@ -235,6 +255,65 @@ module RailsAiContext
235
255
  []
236
256
  end
237
257
 
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.
262
+ def extract_mailers_from_source
263
+ source_classes(File.join(app.root, "app", "mailers")).filter_map do |name, methods|
264
+ next if name == "ApplicationMailer"
265
+
266
+ actions = methods.select { |m| m[:scope] == :instance && m[:visibility] == :public }
267
+ .map { |m| m[:name] }.sort
268
+ next if actions.empty?
269
+
270
+ { name: name, actions: actions, confidence: RailsAiContext::Confidence::STATIC }
271
+ end.sort_by { |m| m[:name] }
272
+ end
273
+
274
+ def extract_channels_from_source
275
+ # ApplicationCable holds the base Channel and Connection, neither of
276
+ # which is a channel of the app's own.
277
+ source_classes(File.join(app.root, "app", "channels")).filter_map do |name, methods|
278
+ next if name.start_with?("ApplicationCable::")
279
+
280
+ stream_methods = methods.select { |m| m[:scope] == :instance }
281
+ .map { |m| m[:name] }
282
+ .select { |m| m.start_with?("stream_") || m == "subscribed" }
283
+ { name: name, stream_methods: stream_methods, confidence: RailsAiContext::Confidence::STATIC }
284
+ end.sort_by { |c| c[:name] }
285
+ end
286
+
287
+ # Class name plus method list for every .rb under `dir`, read from the
288
+ # AST. Yields nothing when the directory is absent.
289
+ #
290
+ # The name comes from the path, not the AST: Zeitwerk requires the two to
291
+ # agree, and only the path carries the namespace. Reading `class Channel`
292
+ # out of `application_cable/channel.rb` yields "Channel", which matches
293
+ # no base-class filter and no name the booted app would report.
294
+ def source_classes(dir)
295
+ return [] unless Dir.exist?(dir)
296
+
297
+ Dir.glob(File.join(dir, "**/*.rb")).sort.filter_map do |path|
298
+ next unless File.exist?(path) && File.size(path) > 0
299
+ next if File.size(path) > RailsAiContext.configuration.max_file_size
300
+ # Rails adds app/*/concerns as its own autoload root, so what lives
301
+ # there is a mixin, not a mailer or a channel - and naming it from
302
+ # the path would invent a `Concerns::` namespace that never exists.
303
+ next if path.sub("#{dir}/", "").start_with?("concerns/")
304
+
305
+ walked = SourceIntrospector.walk(path, { methods: Listeners::MethodsListener })
306
+ [ constant_name_for(path, dir), walked[:methods] || [] ]
307
+ rescue StandardError, ScriptError => e
308
+ $stderr.puts "[rails-ai-context] source_classes failed for #{path}: #{e.message}" if ENV["DEBUG"]
309
+ nil
310
+ end
311
+ end
312
+
313
+ def constant_name_for(path, dir)
314
+ path.sub("#{dir}/", "").sub(/\.rb\z/, "").camelize
315
+ end
316
+
238
317
  def extract_channels
239
318
  return [] unless defined?(ActionCable::Channel::Base)
240
319