rails-ai-context 5.20.1 → 5.20.3

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +135 -0
  3. data/gemfiles/prism_floor.gemfile +2 -0
  4. data/gemfiles/prism_head.gemfile +2 -0
  5. data/lib/rails_ai_context/app_kind.rb +23 -0
  6. data/lib/rails_ai_context/cli/tool_runner.rb +44 -1
  7. data/lib/rails_ai_context/code_reloader.rb +79 -0
  8. data/lib/rails_ai_context/introspector.rb +37 -0
  9. data/lib/rails_ai_context/introspectors/api_introspector.rb +15 -1
  10. data/lib/rails_ai_context/introspectors/job_introspector.rb +38 -5
  11. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +11 -1
  12. data/lib/rails_ai_context/introspectors/model_introspector.rb +25 -6
  13. data/lib/rails_ai_context/introspectors/route_introspector.rb +106 -10
  14. data/lib/rails_ai_context/live_reload.rb +7 -2
  15. data/lib/rails_ai_context/resources.rb +7 -0
  16. data/lib/rails_ai_context/route_coverage.rb +32 -0
  17. data/lib/rails_ai_context/schema_adapter.rb +85 -0
  18. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
  19. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +2 -2
  20. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +2 -2
  21. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -2
  22. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +2 -2
  23. data/lib/rails_ai_context/serializers/markdown_serializer.rb +1 -1
  24. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +7 -0
  25. data/lib/rails_ai_context/server.rb +35 -8
  26. data/lib/rails_ai_context/tasks/rails_ai_context.rake +2 -2
  27. data/lib/rails_ai_context/tools/base_tool.rb +9 -1
  28. data/lib/rails_ai_context/tools/get_job_pattern.rb +36 -10
  29. data/lib/rails_ai_context/tools/get_routes.rb +6 -0
  30. data/lib/rails_ai_context/tools/get_schema.rb +9 -2
  31. data/lib/rails_ai_context/tools/onboard.rb +8 -26
  32. data/lib/rails_ai_context/tools/safe_call.rb +24 -9
  33. data/lib/rails_ai_context/tools/validate.rb +10 -1
  34. data/lib/rails_ai_context/version.rb +1 -1
  35. data/lib/rails_ai_context/watcher.rb +3 -0
  36. data/server.json +1 -1
  37. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e7cbf76ef448db45165cf06f3c2af8303b23f8629a709b20ffb61ae33093c192
4
- data.tar.gz: 65fe97840d3fd23ab66b1b8e6b108914b3675e3648c51d746769d4016b6a846a
3
+ metadata.gz: 812fc2b5160e39c746e9d399bf6cea5752339f53c57a80f3ce0d01a5e819b7d0
4
+ data.tar.gz: 22254f62db474a0f0b7f364f156b3098819b52285e88df2835b32ea25c86972b
5
5
  SHA512:
6
- metadata.gz: b4816a51648a6aaee1a3fd48b649ff35967e2efea2c75805da08c605a0668b8afa7dbe113b8d7f555a13c268d72f1284b69091a3b9d102465e3a6b00f5b2282b
7
- data.tar.gz: cc12eaa6400a9a2906ca706b37fc92f211f0837e69038f14e6a25742be8207d529c915c4ecdb122c6bd2a20a9dc0eb86a3474cd4a65a321fa8ded9f2aa64a47d
6
+ metadata.gz: 4eddc9f145293be65bc99e9b0dd5334de7570ef217df4dca4f554c191ae314bd6544ed16f49be013a2db93c3394e49f02204c1d76c1c256d7394881ea273eb81
7
+ data.tar.gz: 53e469b48920a4baefe955ff90624199f60200a6759d180a02d040d4c1368322d0b9648637fe4a13d20cf1ecc9153d3b7a1b041e1d41ae835c7a8e523cfd4bdf
data/CHANGELOG.md CHANGED
@@ -5,6 +5,141 @@ 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.3] - 2026-08-11
9
+
10
+ ### Fixed
11
+
12
+ - **`serve --transport http` no longer answers "Session not found" to about half
13
+ of all requests.** Rackup hands Puma the host app's `config/puma.rb`, and a
14
+ real app sets `workers` there, so the transport ran as a cluster - but MCP
15
+ sessions live in one process's memory, and a forked worker cannot answer a
16
+ request whose `initialize` a sibling handled. Puma is now pinned to single
17
+ mode. Both options are load-bearing: refusing the config file still leaves
18
+ `WEB_CONCURRENCY` able to start a cluster on its own, and pinning the worker
19
+ count still lets the file's `pidfile` and `preload_app!` through - the pidfile
20
+ being one this server would otherwise write over the app's own. (#123)
21
+
22
+ - **A gem's background job is no longer counted as the app's.** `extract_jobs`
23
+ filtered `ActiveJob::Base.descendants` with a list of framework name
24
+ prefixes, which no list can keep up with: on an app with 118 Sidekiq workers
25
+ and no ActiveJob of its own, the generated `CLAUDE.md` read `Async: 1 job`
26
+ and that job was an indexing worker belonging to a gem. Ownership is now
27
+ decided by where the class is defined. A class with no source location is
28
+ kept, because understating what the app runs is the worse mistake. (#120)
29
+
30
+ - **`get_job_pattern` now names the queues `config/sidekiq.yml` declares.**
31
+ `extract_sidekiq_config` already read them; holding the result back left the
32
+ answer describing `app/jobs/` and nothing else, on an app whose sidekiq.yml
33
+ is the one piece of evidence in reach that async work happens elsewhere. It
34
+ reaches the job listing as well as the empty-directory message - a count of
35
+ what `app/jobs/` holds is still a claim about the app's async work, and on an
36
+ app running most of it through Sidekiq that count is the small half. (#120)
37
+
38
+ - **The context no longer carries `static_parse` as a database name.**
39
+ `SchemaIntrospector` writes that placeholder when it reads a dump instead of
40
+ the connection, and laundering it at each rendering surface left the ones
41
+ nobody thought of - `.ai-context.json` and `rails://schema` - naming a
42
+ database that does not exist beside a `multi_database` section in the same
43
+ file naming the real one. It is now resolved once, where the context is
44
+ assembled, so nothing downstream has to remember. The raw observation stays
45
+ under `adapter_source`. (#128)
46
+
47
+ - **The static tier follows `draw` into `config/routes/*.rb`.** An app that
48
+ splits its routing table that way kept most of it in files the parser never
49
+ opened: `--no-boot` answered 94 routes on a 723-route app. Rails resolves
50
+ `draw(:admin)` by literal path, so following it is a plain file read; the
51
+ resolved path is confirmed inside `config/routes/` with `realpath` first, so
52
+ a symlink there cannot reach the rest of the disk. (#127)
53
+
54
+ - **Every surface that prints a route count says how much of the table it
55
+ could not expand.** `RouteIntrospector` recorded `dynamic_routes` for
56
+ constructs it refused to fabricate (`devise_for`, a `draw` whose target is
57
+ computed or too large to parse) and nothing read it, so `rails_get_routes`,
58
+ `rails_onboard`, `CLAUDE.md`, the Cursor and Copilot rule files, the markdown
59
+ context and the rake summary all quoted a partial count as the whole routing
60
+ table. `RouteCoverage` is the one answer they now share, shaped as a suffix
61
+ so no call site needs a conditional of its own - nine each having to remember
62
+ is what produced this. A `draw` whose routes are in the list does not count,
63
+ whether this pass read the file or an earlier branch did; one stopped by the
64
+ depth cap, or naming a file too large to parse, does. A drawn file that
65
+ cannot be parsed costs its own routes rather than the whole section. (#127)
66
+
67
+ - **The static tier counts an update route once.** Rails registers PATCH and
68
+ PUT separately for one action and every surface that lists routes merges
69
+ them, but the static total did not - so the generated files said "8 total"
70
+ where `rails_get_routes`, which merges for itself, said 7 on the same
71
+ `resources :posts`. (#127)
72
+
73
+ ### Changed
74
+
75
+ - `actionmailer` and `puma` are development dependencies now. Without
76
+ ActionMailer loaded, `ActionMailer::Base` is undefined and every assertion
77
+ about mailer actions passed over an empty array; the Puma single-mode pin is
78
+ only testable against Puma's own option semantics. Neither affects the
79
+ gem's runtime dependencies. (#123, #126)
80
+
81
+ ## [5.20.2] - 2026-08-11
82
+
83
+ ### Fixed
84
+
85
+ - **A long-lived process no longer answers about the app as it was at boot.**
86
+ The MCP server and `watch` invalidated their own caches on a file change but
87
+ never reloaded Rails, and `Zeitwerk#eager_load_dir` does not re-scan a
88
+ directory it has already loaded - so a model written after the server started
89
+ stayed invisible for the life of the process, and the server told callers it
90
+ did not exist. Routes were unaffected because `RouteIntrospector` already
91
+ asked `routes_reloader.execute_if_updated`. Both change handlers now run
92
+ Rails' own reloader first, gated on `config.enable_reloading` (the flag that
93
+ decides whether Rails unloads anything - `eager_load` does not), and every
94
+ tool call and every resource read now runs inside the executor so a reload
95
+ cannot unload constants while one is reading them. A failing reload leaves
96
+ the process alive, and a tool failure is not reported to the host app's
97
+ error reporter - it is handled here and returned as an isError result.
98
+
99
+ - **A `validates_with` validator no longer replaces the whole model answer.**
100
+ `ActiveModel::Validator` has no `#attributes` - only `EachValidator` does -
101
+ so one of them anywhere on a model turned `rails_get_model_details`,
102
+ `rails_get_callbacks` and `rails://models/X` into a single
103
+ "Error inspecting X" line. (#121)
104
+ - **Static-tier callbacks are reported.** The two tiers handed back different
105
+ types for one key: a Hash keyed by callback type when booted, a flat Array
106
+ from the listener otherwise. Every consumer filters on `is_a?(Hash)`, so
107
+ `--no-boot` answered "No models with callbacks found" for an app full of
108
+ them, and `--model X --no-boot` raised `TypeError` on a Hash lookup against
109
+ an Array. (#122)
110
+ - **`get_mailers` reports the actions Rails calls actions.** It listed
111
+ `instance_methods(false)`, which includes ActiveSupport's generated
112
+ `_run_*_callbacks` and any public helper, so an abstract `ApplicationMailer`
113
+ was reported as having deliverable actions. It now asks
114
+ `action_methods`. (#126)
115
+ - **One answer to which database the app runs on.** Four surfaces each carried
116
+ their own substitute for the internal `static_parse` marker and disagreed:
117
+ the generated `CLAUDE.md`, `rails_get_schema`, `rails_onboard` and
118
+ `rails ai:inspect` could name three different databases for one app, and
119
+ `onboard`'s gem loop let the last match win where the serializer's let the
120
+ first. They now share `RailsAiContext::SchemaAdapter`, which resolves from
121
+ the app's own database configuration first, then the `structure.sql` dialect
122
+ already parsed, then the Gemfile - order-independent. (#125)
123
+ - **Generated context files no longer print `Database: static_parse`.** That is
124
+ the internal marker for "read from db/schema.rb, not the connection";
125
+ `onboard` already substituted the live adapter and the serializers did
126
+ not. (#125)
127
+ - **`get_job_pattern` says what it checked.** "This app may not use background
128
+ jobs" was a claim about the whole app drawn from one directory, and apps
129
+ keeping Sidekiq workers in `app/workers/` were told they have none. (#120)
130
+ - **`validate`'s empty-files message advises a syntax the CLI accepts.** It
131
+ recommended `files:["..."]`, which the CLI reads as a literal filename. (#124)
132
+ - **`validate --files a.rb b.rb` validates every file.** Array parameters
133
+ consumed only the first token, so the rest were dropped silently and the
134
+ summary read "1/1 files passed" for a set containing a broken file. (#124)
135
+
136
+ ### Added
137
+
138
+ - **The static tier reads `config.api_only`.** Without a booted app the view
139
+ tools fell back to "no Stimulus controllers found" on an API-only app, where a
140
+ booted run says "Not applicable". Both were true, but only one of them stops
141
+ an agent adding a view layer to an app that has none by design.
142
+
8
143
  ## [5.20.1] - 2026-08-11
9
144
 
10
145
  ### Fixed
@@ -15,6 +15,8 @@ rails_version = ENV.fetch("RAILS_VERSION", "8.0")
15
15
  group :development, :test do
16
16
  gem "railties", "~> #{rails_version}.0"
17
17
  gem "activerecord", "~> #{rails_version}.0"
18
+ gem "actionmailer", "~> #{rails_version}.0"
19
+ gem "puma"
18
20
  gem "sqlite3"
19
21
  gem "logger"
20
22
  end
@@ -14,6 +14,8 @@ rails_version = ENV.fetch("RAILS_VERSION", "8.0")
14
14
  group :development, :test do
15
15
  gem "railties", "~> #{rails_version}.0"
16
16
  gem "activerecord", "~> #{rails_version}.0"
17
+ gem "actionmailer", "~> #{rails_version}.0"
18
+ gem "puma"
17
19
  gem "sqlite3"
18
20
  gem "logger"
19
21
  end
@@ -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
@@ -48,6 +48,8 @@ module RailsAiContext
48
48
  end
49
49
  context[:_warnings] = warnings if warnings.any?
50
50
 
51
+ resolve_schema_adapter(context)
52
+
51
53
  context
52
54
  end
53
55
 
@@ -99,6 +101,41 @@ module RailsAiContext
99
101
 
100
102
  private
101
103
 
104
+ # `SchemaIntrospector` writes `static_parse` when it read a dump instead of
105
+ # the connection, and every surface that rendered the section raw named a
106
+ # database that does not exist. Fixing that one surface at a time meant
107
+ # `.ai-context.json` and `rails://schema` still disagreed with the
108
+ # multi_database section of the same file. Resolving it here means the
109
+ # context never carries the placeholder, so nothing downstream has to
110
+ # remember. The raw observation stays under `adapter_source` for a reader
111
+ # that needs to know the schema was parsed rather than observed.
112
+ #
113
+ # Runs after the loop: the answer comes from the database config and gem
114
+ # list, which are sections of this same context.
115
+ #
116
+ # Rescued for the same reason the loop above is: this reads two other
117
+ # sections, and a malformed one raising here would cost the whole context
118
+ # rather than one entry. The placeholder surviving is the honest failure.
119
+ def resolve_schema_adapter(context)
120
+ schema = context[:schema]
121
+ return unless schema.is_a?(Hash) && !schema[:error]
122
+ # Only correct a claim that was made. A missing or nil adapter said
123
+ # nothing about the database, and answering for it would be the
124
+ # fabrication this method exists to remove.
125
+ return if schema[:adapter].nil?
126
+ return unless SchemaAdapter.placeholder?(schema[:adapter])
127
+
128
+ # "unknown" is a placeholder too. Swapping one for another buys nothing
129
+ # and would bury the parse mode that at least says how the schema was read.
130
+ resolved = SchemaAdapter.label(context)
131
+ return if SchemaAdapter.placeholder?(resolved)
132
+
133
+ schema[:adapter_source] = schema[:adapter]
134
+ schema[:adapter] = resolved
135
+ rescue StandardError => e
136
+ RailsAiContext.log_warn "[rails-ai-context] schema adapter resolution failed: #{e.message}"
137
+ end
138
+
102
139
  def app_name
103
140
  return File.basename(app.root.to_s) if app.is_a?(RailsAiContext::StaticApp)
104
141
 
@@ -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,
@@ -2,8 +2,10 @@
2
2
 
3
3
  module RailsAiContext
4
4
  module Introspectors
5
- # Discovers background jobs (ActiveJob/Sidekiq), mailers,
6
- # and Action Cable channels.
5
+ # Discovers ActiveJob jobs, mailers, and Action Cable channels. Sidekiq
6
+ # reaches this only as config/sidekiq.yml: a class that includes
7
+ # Sidekiq::Worker without subclassing ActiveJob::Base is not a descendant
8
+ # and does not live in app/jobs/, so neither pass here finds it.
7
9
  class JobIntrospector
8
10
  extend StaticTier
9
11
  static_tier :alternate_source
@@ -52,6 +54,7 @@ module RailsAiContext
52
54
  ActiveJob::Base.descendants.filter_map do |job|
53
55
  next if job.name.nil? || job.name == "ApplicationJob" ||
54
56
  job.name.start_with?("ActionMailer", "ActiveStorage::", "ActionMailbox::", "Turbo::", "Sentry::")
57
+ next unless app_defined?(job)
55
58
 
56
59
  queue = job.queue_name
57
60
  queue = "dynamic" if queue.is_a?(Proc)
@@ -67,6 +70,26 @@ module RailsAiContext
67
70
  []
68
71
  end
69
72
 
73
+ # `descendants` is every ActiveJob subclass in the process, and the name
74
+ # prefixes above only cover the framework's own - a job from any other gem
75
+ # was counted as the app's. Where the class is defined answers it for gems
76
+ # the list has never heard of. A class with no source location stays:
77
+ # dropping one would understate what the app runs.
78
+ def app_defined?(job)
79
+ location = Object.const_source_location(job.name)&.first
80
+ return true unless location
81
+
82
+ File.realpath(location).start_with?("#{app_root_real}/")
83
+ rescue NameError, ArgumentError, TypeError, SystemCallError
84
+ true
85
+ end
86
+
87
+ def app_root_real
88
+ @app_root_real ||= File.realpath(app.root.to_s)
89
+ rescue SystemCallError
90
+ @app_root_real = app.root.to_s
91
+ end
92
+
70
93
  def extract_jobs_from_source
71
94
  jobs_dir = File.join(app.root, "app", "jobs")
72
95
  return [] unless Dir.exist?(jobs_dir)
@@ -234,7 +257,14 @@ module RailsAiContext
234
257
  ActionMailer::Base.descendants.filter_map do |mailer|
235
258
  next if mailer.name.nil?
236
259
 
237
- actions = mailer.instance_methods(false).map(&:to_s).sort
260
+ # Rails' own answer: it subtracts internal and inherited methods, so
261
+ # an abstract ApplicationMailer reports none and ActiveSupport's
262
+ # generated `_run_*_callbacks` never appears. `instance_methods(false)`
263
+ # listed both as deliverable actions. Called bare, like
264
+ # ControllerIntrospector does - AbstractController::Base has defined
265
+ # it across the whole supported range, so a fallback here would only
266
+ # be unreachable code holding the old bug.
267
+ actions = mailer.action_methods.to_a.map(&:to_s).sort
238
268
  next if actions.empty?
239
269
 
240
270
  {
@@ -248,8 +278,11 @@ module RailsAiContext
248
278
  []
249
279
  end
250
280
 
251
- # A mailer's actions are its public instance methods, which is exactly
252
- # what `instance_methods(false)` reports on the booted side.
281
+ # A mailer's actions are its public instance methods, and the AST sees
282
+ # one file at a time. `action_methods` counts the public methods a mailer
283
+ # inherits too, so a public helper on a base class is an action the
284
+ # booted tier reports and this one cannot - the entries are tagged STATIC
285
+ # for that reason.
253
286
  def extract_mailers_from_source
254
287
  source_classes(File.join(app.root, "app", "mailers")).filter_map do |name, methods|
255
288
  next if name == "ApplicationMailer"
@@ -272,7 +272,17 @@ module RailsAiContext
272
272
  def emit_dynamic(node)
273
273
  return if suppressed?
274
274
 
275
- @results << { type: :dynamic, macro: node.name, location: node.location.start_line }
275
+ record = { type: :dynamic, macro: node.name, location: node.location.start_line }
276
+ # `draw(:admin)` names a file, and Rails resolves it by literal path.
277
+ # Recording the name is what lets the introspector follow it instead
278
+ # of writing off everything the file defines.
279
+ record[:target] = draw_target(node) if node.name == :draw
280
+ @results << record
281
+ end
282
+
283
+ def draw_target(node)
284
+ target = extract_first_symbol(node)
285
+ target unless target == RailsAiContext::Confidence::INFERRED
276
286
  end
277
287
 
278
288
  # Keyword options including hash-rocket string keys, so
@@ -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)