rails-ai-context 5.20.2 → 5.21.0

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +138 -0
  3. data/docs/CONFIGURATION.md +1 -1
  4. data/docs/GUIDE.md +5 -3
  5. data/gemfiles/prism_floor.gemfile +2 -0
  6. data/gemfiles/prism_head.gemfile +2 -0
  7. data/lib/generators/rails_ai_context/install/install_generator.rb +4 -2
  8. data/lib/rails_ai_context/concern_paths.rb +56 -0
  9. data/lib/rails_ai_context/configuration.rb +2 -2
  10. data/lib/rails_ai_context/introspector.rb +37 -0
  11. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +2 -11
  12. data/lib/rails_ai_context/introspectors/auth_introspector.rb +6 -1
  13. data/lib/rails_ai_context/introspectors/controller_introspector.rb +65 -11
  14. data/lib/rails_ai_context/introspectors/job_introspector.rb +30 -6
  15. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +19 -0
  16. data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +44 -0
  17. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +11 -1
  18. data/lib/rails_ai_context/introspectors/route_introspector.rb +106 -10
  19. data/lib/rails_ai_context/introspectors/test_introspector.rb +4 -1
  20. data/lib/rails_ai_context/route_coverage.rb +32 -0
  21. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
  22. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +1 -1
  23. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +1 -1
  24. data/lib/rails_ai_context/serializers/copilot_serializer.rb +1 -1
  25. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -1
  26. data/lib/rails_ai_context/serializers/markdown_serializer.rb +1 -1
  27. data/lib/rails_ai_context/server.rb +35 -8
  28. data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -1
  29. data/lib/rails_ai_context/tools/get_callbacks.rb +1 -4
  30. data/lib/rails_ai_context/tools/get_concern.rb +45 -43
  31. data/lib/rails_ai_context/tools/get_env.rb +30 -19
  32. data/lib/rails_ai_context/tools/get_job_pattern.rb +32 -6
  33. data/lib/rails_ai_context/tools/get_model_details.rb +1 -5
  34. data/lib/rails_ai_context/tools/get_routes.rb +6 -0
  35. data/lib/rails_ai_context/tools/get_service_pattern.rb +40 -17
  36. data/lib/rails_ai_context/tools/onboard.rb +3 -1
  37. data/lib/rails_ai_context/version.rb +1 -1
  38. data/server.json +1 -1
  39. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 81eedfa398603e64a27f7555b4288e39a11e41093c3a06450354c2f5cb1d17c5
4
- data.tar.gz: 34972bf4259e8e12695f008744111bff94ed7caa93155bd3f8352010e3134c15
3
+ metadata.gz: e2ccd7eb081bc5f6f34e998978e38d7d9d5282d097818224c10bd56ff4d30c75
4
+ data.tar.gz: 0bb8a8ad8f57a11a04a41dd2ab9e30f57f25b08382d1b5edca67af0c3db68f38
5
5
  SHA512:
6
- metadata.gz: c88339dc6c73a5fdb38209d69e61437a6494382c069ae943478e51a265e57b2b847d4cb89f22ef0449c024bdb43185feef7e58a5e92f199c0a0389f4822898ca
7
- data.tar.gz: 2f0a283b5691f00d4f49aeed839da56af13e248f566c9a526e3926cbc0c71185cf2e99a9b809d5e316d7e1144826456bb65ea36148493b4102f44b3bf0857584
6
+ metadata.gz: dadae053988700c4c82e841dc5d18c0c22f6584ceef3616e7a520388e57bea89bb1535dc2ca658b5960d3d5d3a11ecbedc912f69386485412c0d833d1c8d1cfa
7
+ data.tar.gz: 07bba392bb191112f18cf14e89147b445078d04b404d0bea9f974e148f42f1ef2ee4d83ccacc7049e132dacf2ad0c1ac17fd4079b779d2dd0d6039c739359092
data/CHANGELOG.md CHANGED
@@ -5,6 +5,144 @@ 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.21.0] - 2026-08-12
9
+
10
+ ### Fixed
11
+
12
+ - **`get_env` no longer offers a name the app never reads.** An app that builds
13
+ its variable names by interpolation, the normal shape when it carries several
14
+ Redis connections, had `#{prefix}URL` reported as a variable name and
15
+ `defaults[:port]` reported as that variable's default. The scan reads the
16
+ parser now instead of matching whatever sat between two quotes, so a name
17
+ that does not exist until runtime is not offered to someone writing a
18
+ `.env.example`, and a default is printed only when it is a value. Comments
19
+ drop out for free, including one trailing a line that also reads `ENV`.
20
+ (#129)
21
+
22
+ - **`get_controllers` no longer lists `set_locale` as an action.** A controller
23
+ whose own file defines no public method fell back to `action_methods`, which
24
+ subtracts inherited methods only as far as the nearest abstract ancestor:
25
+ everything `ApplicationController` and its concerns define publicly came back
26
+ as an action, so a two-route controller reported 19 of them. The actions of a
27
+ thin subclass now come from the source of the ancestor that defines them.
28
+ Reflection is left for the case that has no source to read, an ancestor this
29
+ app does not own the file for, which is what a controller inheriting from a
30
+ gem or an engine looks like. (#130)
31
+
32
+ - **`get_service_pattern` reports the service's entry point, not a nested
33
+ class's.** Nesting a query builder inside the service that uses it is a
34
+ normal way to organise a large one, and the line scan could not see it: it
35
+ named `QueryBuilder#build` as the entry point of a 312-line service, and a
36
+ `private` inside a nested class hid the real `call` that followed the nested
37
+ class's `end`, reporting the service as having no entry point at all. The
38
+ methods now come from the AST, scoped to the class the file is named for.
39
+ (#131)
40
+
41
+ - **`get_test_info` counts tests, not files.** "Test Counts by Category"
42
+ globbed every `.rb` under a category directory, so four mailer specs sitting
43
+ beside four mailer previews counted as eight tests. Anything a project keeps
44
+ next to its specs inflated the number. (#132)
45
+
46
+ - **`get_concern` finds every concern the app has.** It searched two hardcoded
47
+ directories while `get_active_support` searched five, so one run answered 80
48
+ concerns and 81 concerns for the same app, and the mailer concern only the
49
+ second one found could not be reached by name through the first. Both now
50
+ read one seam, which discovers `app/*/concerns` the way Rails autoloads it,
51
+ so an app that keeps `app/serializers/concerns` is covered too. Concerns are
52
+ grouped and filterable by the directory that owns them. (#133)
53
+
54
+ - **Namespaced Pundit policies keep their namespace.** Policy names came from
55
+ the basename, so `app/policies/admin/collection_policy.rb` and
56
+ `app/policies/collection_policy.rb` both read as `CollectionPolicy`. One name
57
+ was listed twice and the other class appeared nowhere in the generated
58
+ context or in `CLAUDE.md`, including the one that defines `destroy?`. (#135)
59
+
60
+ - **Re-running a release no longer fails on the MCP Registry.** The registry
61
+ answers a repeat version with a 400, and the publish step had no guard, so
62
+ re-running a release that had already succeeded turned the workflow red with
63
+ nothing wrong. It now checks for the version first, the way the RubyGems step
64
+ already did.
65
+
66
+ ### Changed
67
+
68
+ - **`config.concern_paths` replaces concern discovery instead of adding to
69
+ it.** Left unset, which is now the default, every `app/*/concerns` directory
70
+ is discovered. Setting it means those directories and no others, so it can
71
+ narrow as well as reach outside `app/`.
72
+
73
+ ## [5.20.3] - 2026-08-11
74
+
75
+ ### Fixed
76
+
77
+ - **`serve --transport http` no longer answers "Session not found" to about half
78
+ of all requests.** Rackup hands Puma the host app's `config/puma.rb`, and a
79
+ real app sets `workers` there, so the transport ran as a cluster - but MCP
80
+ sessions live in one process's memory, and a forked worker cannot answer a
81
+ request whose `initialize` a sibling handled. Puma is now pinned to single
82
+ mode. Both options are load-bearing: refusing the config file still leaves
83
+ `WEB_CONCURRENCY` able to start a cluster on its own, and pinning the worker
84
+ count still lets the file's `pidfile` and `preload_app!` through - the pidfile
85
+ being one this server would otherwise write over the app's own. (#123)
86
+
87
+ - **A gem's background job is no longer counted as the app's.** `extract_jobs`
88
+ filtered `ActiveJob::Base.descendants` with a list of framework name
89
+ prefixes, which no list can keep up with: on an app with 118 Sidekiq workers
90
+ and no ActiveJob of its own, the generated `CLAUDE.md` read `Async: 1 job`
91
+ and that job was an indexing worker belonging to a gem. Ownership is now
92
+ decided by where the class is defined. A class with no source location is
93
+ kept, because understating what the app runs is the worse mistake. (#120)
94
+
95
+ - **`get_job_pattern` now names the queues `config/sidekiq.yml` declares.**
96
+ `extract_sidekiq_config` already read them; holding the result back left the
97
+ answer describing `app/jobs/` and nothing else, on an app whose sidekiq.yml
98
+ is the one piece of evidence in reach that async work happens elsewhere. It
99
+ reaches the job listing as well as the empty-directory message - a count of
100
+ what `app/jobs/` holds is still a claim about the app's async work, and on an
101
+ app running most of it through Sidekiq that count is the small half. (#120)
102
+
103
+ - **The context no longer carries `static_parse` as a database name.**
104
+ `SchemaIntrospector` writes that placeholder when it reads a dump instead of
105
+ the connection, and laundering it at each rendering surface left the ones
106
+ nobody thought of - `.ai-context.json` and `rails://schema` - naming a
107
+ database that does not exist beside a `multi_database` section in the same
108
+ file naming the real one. It is now resolved once, where the context is
109
+ assembled, so nothing downstream has to remember. The raw observation stays
110
+ under `adapter_source`. (#128)
111
+
112
+ - **The static tier follows `draw` into `config/routes/*.rb`.** An app that
113
+ splits its routing table that way kept most of it in files the parser never
114
+ opened: `--no-boot` answered 94 routes on a 723-route app. Rails resolves
115
+ `draw(:admin)` by literal path, so following it is a plain file read; the
116
+ resolved path is confirmed inside `config/routes/` with `realpath` first, so
117
+ a symlink there cannot reach the rest of the disk. (#127)
118
+
119
+ - **Every surface that prints a route count says how much of the table it
120
+ could not expand.** `RouteIntrospector` recorded `dynamic_routes` for
121
+ constructs it refused to fabricate (`devise_for`, a `draw` whose target is
122
+ computed or too large to parse) and nothing read it, so `rails_get_routes`,
123
+ `rails_onboard`, `CLAUDE.md`, the Cursor and Copilot rule files, the markdown
124
+ context and the rake summary all quoted a partial count as the whole routing
125
+ table. `RouteCoverage` is the one answer they now share, shaped as a suffix
126
+ so no call site needs a conditional of its own - nine each having to remember
127
+ is what produced this. A `draw` whose routes are in the list does not count,
128
+ whether this pass read the file or an earlier branch did; one stopped by the
129
+ depth cap, or naming a file too large to parse, does. A drawn file that
130
+ cannot be parsed costs its own routes rather than the whole section. (#127)
131
+
132
+ - **The static tier counts an update route once.** Rails registers PATCH and
133
+ PUT separately for one action and every surface that lists routes merges
134
+ them, but the static total did not - so the generated files said "8 total"
135
+ where `rails_get_routes`, which merges for itself, said 7 on the same
136
+ `resources :posts`. (#127)
137
+
138
+ ### Changed
139
+
140
+ - `actionmailer` and `puma` are development dependencies now. Without
141
+ ActionMailer loaded, `ActionMailer::Base` is undefined and every assertion
142
+ about mailer actions passed over an empty array; the Puma single-mode pin is
143
+ only testable against Puma's own option semantics. Neither affects the
144
+ gem's runtime dependencies. (#123, #126)
145
+
8
146
  ## [5.20.2] - 2026-08-11
9
147
 
10
148
  ### Fixed
@@ -114,7 +114,7 @@ preset: full
114
114
  | `max_search_results` | Integer | `200` | Maximum search results |
115
115
  | `max_validate_files` | Integer | `50` | Maximum files for validation |
116
116
  | `search_extensions` | Array | `["rb", "js", "erb", "yml", "yaml", "json", "ts", "tsx", "vue", "svelte", "haml", "slim"]` | File extensions to search |
117
- | `concern_paths` | Array | `["app/models/concerns", "app/controllers/concerns"]` | Paths to scan for concerns |
117
+ | `concern_paths` | Array | `nil` (discovers `app/*/concerns`) | Paths to scan for concerns. Setting it replaces discovery, so it can narrow as well as widen |
118
118
  | `frontend_paths` | Array | `nil` (auto-detect) | Override frontend file paths |
119
119
  | `extra_app_paths` | Array | `[]` | Extra directories under the app root to treat as application code |
120
120
 
data/docs/GUIDE.md CHANGED
@@ -1285,8 +1285,10 @@ if defined?(RailsAiContext)
1285
1285
  # File extensions for Ruby fallback search
1286
1286
  # config.search_extensions = %w[rb js erb yml yaml json ts tsx vue svelte haml slim]
1287
1287
 
1288
- # Where to look for concern source files
1289
- # config.concern_paths = %w[app/models/concerns app/controllers/concerns]
1288
+ # Where to look for concern source files. Left unset, every
1289
+ # app/*/concerns directory is discovered. Setting this replaces
1290
+ # that list, so it can narrow as well as reach outside app/.
1291
+ # config.concern_paths = %w[app/models/concerns lib/concerns]
1290
1292
 
1291
1293
  # --- Live reload ---
1292
1294
 
@@ -1352,7 +1354,7 @@ end
1352
1354
  | `excluded_filters` | Array | `verify_authenticity_token`, etc. | Framework filter names hidden from controller output |
1353
1355
  | `excluded_middleware` | Array | standard Rails middleware | Default middleware hidden from config output |
1354
1356
  | `search_extensions` | Array | `rb js erb yml yaml json ts tsx vue svelte haml slim` | File extensions for Ruby fallback search |
1355
- | `concern_paths` | Array | `app/models/concerns app/controllers/concerns` | Where to look for concern source files |
1357
+ | `concern_paths` | Array | `nil` (discovers `app/*/concerns`) | Where to look for concern source files. Setting it replaces discovery |
1356
1358
 
1357
1359
  ### Root file generation
1358
1360
 
@@ -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
@@ -311,8 +311,10 @@ module RailsAiContext
311
311
  # File extensions for fallback search (when ripgrep unavailable)
312
312
  # config.search_extensions = %w[rb js erb yml yaml json ts tsx vue svelte haml slim]
313
313
 
314
- # Where to look for concern source files
315
- # config.concern_paths = %w[app/models/concerns app/controllers/concerns]
314
+ # Where to look for concern source files. Left unset, every
315
+ # app/*/concerns directory is discovered. Setting this replaces
316
+ # that list, so it can narrow as well as reach outside app/.
317
+ # config.concern_paths = %w[app/models/concerns lib/concerns]
316
318
  SECTION
317
319
  "Frontend" => <<~SECTION
318
320
  # ── Frontend Framework Detection ─────────────────────────────────
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ # One answer to "where does this app keep its concerns", for every surface
5
+ # that lists or counts them.
6
+ #
7
+ # `GetConcern` hardcoded two directories and `ActiveSupportIntrospector`
8
+ # hardcoded five, so a single run answered 80 concerns and 81 concerns for
9
+ # the same app, and the mailer concern only the second one found could not be
10
+ # reached through the first at all. Neither list matches Rails, which
11
+ # autoloads these paths by glob - `Rails::Engine::Configuration#paths` adds
12
+ # `app` with `glob: "{*,*/concerns}"` - so any hardcoded list is one entry
13
+ # behind an app that keeps `app/serializers/concerns`.
14
+ module ConcernPaths
15
+ module_function
16
+
17
+ # @param root [String] application root
18
+ # @return [Array<String>] absolute concern directories that exist
19
+ def resolve(root)
20
+ # An app that names its concern directories means those and no others -
21
+ # the setting has to be able to narrow, or it only ever adds noise. It is
22
+ # unset by default, which is what asks for discovery.
23
+ configured = RailsAiContext.configuration.concern_paths
24
+ dirs =
25
+ if configured.nil?
26
+ Dir.glob(File.join(root, "app", "*", "concerns"))
27
+ else
28
+ # A path that is already absolute is taken as given; `File.join`
29
+ # would graft it onto the root and point at nothing.
30
+ Array(configured).map { |rel| File.absolute_path?(rel) ? rel : File.join(root, rel) }
31
+ end
32
+
33
+ dirs.uniq.select { |dir| Dir.exist?(dir) }.sort
34
+ end
35
+
36
+ # The owner segment names the type: `app/mailers/concerns` holds mailer
37
+ # concerns. Singular so a filter reads `type: "mailer"`.
38
+ def type_for(dir)
39
+ segment = dir[%r{/app/([^/]+)/concerns/?\z}, 1]
40
+ segment ? segment.singularize : "other"
41
+ end
42
+
43
+ # Source file for a concern named by its constant, or nil.
44
+ #
45
+ # @param root [String] application root
46
+ # @param concern_name [String] constant name, e.g. "BulkMailSettingsConcern"
47
+ def find_file(root, concern_name)
48
+ underscore = concern_name.to_s.underscore
49
+ return nil if underscore.empty? || underscore.include?("..")
50
+
51
+ resolve(root)
52
+ .map { |dir| File.join(dir, "#{underscore}.rb") }
53
+ .find { |path| File.exist?(path) }
54
+ end
55
+ end
56
+ end
@@ -230,7 +230,7 @@ module RailsAiContext
230
230
 
231
231
  # Search and file discovery
232
232
  attr_accessor :search_extensions # File extensions for Ruby fallback search (default: rb,js,erb,yml,yaml,json)
233
- attr_accessor :concern_paths # Where to look for concern source files (default: app/models/concerns)
233
+ attr_accessor :concern_paths # Where to look for concern source files (default: nil, discovers app/*/concerns)
234
234
 
235
235
  # Frontend framework detection (optional overrides - auto-detected if nil)
236
236
  attr_accessor :frontend_paths # User-declared frontend dirs (e.g. ["app/frontend", "../web-client"])
@@ -307,7 +307,7 @@ module RailsAiContext
307
307
  @ai_tools = nil
308
308
  @tool_mode = :mcp
309
309
  @search_extensions = %w[rb js erb yml yaml json ts tsx vue svelte haml slim]
310
- @concern_paths = %w[app/models/concerns app/controllers/concerns]
310
+ @concern_paths = nil
311
311
  @frontend_paths = nil
312
312
  @extra_app_paths = []
313
313
  @query_timeout = 5
@@ -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
 
@@ -55,19 +55,10 @@ module RailsAiContext
55
55
  app.root.to_s
56
56
  end
57
57
 
58
- CONCERN_DIRS = %w[
59
- app/models/concerns
60
- app/controllers/concerns
61
- app/jobs/concerns
62
- app/mailers/concerns
63
- app/channels/concerns
64
- ].freeze
65
-
66
58
  def extract_concerns
67
59
  result = {}
68
- CONCERN_DIRS.each do |rel_dir|
69
- dir = File.join(root, rel_dir)
70
- next unless Dir.exist?(dir)
60
+ ConcernPaths.resolve(root).each do |dir|
61
+ rel_dir = dir.sub("#{root}/", "")
71
62
 
72
63
  modules = Dir.glob(File.join(dir, "**/*.rb")).sort.filter_map do |path|
73
64
  content = RailsAiContext::SafeFile.read(path) or next
@@ -132,8 +132,13 @@ module RailsAiContext
132
132
  # Pundit
133
133
  policies_dir = File.join(root, "app/policies")
134
134
  if Dir.exist?(policies_dir)
135
+ # Named from the path relative to app/policies, not the basename:
136
+ # Zeitwerk makes admin/collection_policy.rb `Admin::CollectionPolicy`,
137
+ # and demodulizing it collided with the top-level policy of the same
138
+ # base name, so one name was listed twice and the other class could
139
+ # not be reached from the context at all.
135
140
  policies = Dir.glob(File.join(policies_dir, "**/*.rb")).map do |f|
136
- File.basename(f, ".rb").camelize
141
+ f.sub("#{policies_dir}/", "").delete_suffix(".rb").camelize
137
142
  end.sort
138
143
  authz[:pundit] = policies if policies.any?
139
144
  end
@@ -164,18 +164,76 @@ module RailsAiContext
164
164
  end
165
165
 
166
166
  # Prefer source-based parsing for actions - always reflects current file state.
167
- # Falls back to reflection for controllers without readable source files.
167
+ #
168
+ # `action_methods` used to answer for a controller whose own file defines
169
+ # no public method, and it cannot: it subtracts inherited methods only as
170
+ # far as the nearest abstract ancestor, which is ActionController::Base.
171
+ # Everything ApplicationController and its concerns define publicly came
172
+ # back as an action, so a two-route controller reported 19 of them, most
173
+ # named things like `set_locale` and `pundit_user`.
174
+ #
175
+ # A thin subclass does still serve actions - its parent defines them - so
176
+ # the answer comes from the parent's source instead.
168
177
  def extract_actions(ctrl, source = nil)
169
- if source
170
- actions = extract_actions_from_source(source)
171
- return actions if actions.any?
172
- end
173
- ctrl.action_methods.to_a.sort
178
+ own = extract_actions_from_source(source) if source
179
+ return own if own&.any?
180
+
181
+ inherited, unreadable_ancestor = inherited_actions(ctrl)
182
+ return inherited if inherited.any?
183
+
184
+ # An ancestor exists that this app does not own the source of, so what
185
+ # it defines is invisible here. `rails g devise:controllers` writes
186
+ # exactly this: the app owns the file, every action in it is commented
187
+ # out, and the gem class supplies them. Reflection brings inherited
188
+ # helpers along, which is worse than the alternative only if the
189
+ # alternative is not claiming the controller serves nothing.
190
+ return ctrl.action_methods.to_a.sort if unreadable_ancestor || source.nil?
191
+
192
+ # Every ancestor was readable and none defines an action. That is an
193
+ # answer, and reflection would only overwrite it with helpers.
194
+ []
174
195
  rescue => e
175
196
  $stderr.puts "[rails-ai-context] extract_actions failed: #{e.message}" if ENV["DEBUG"]
176
197
  []
177
198
  end
178
199
 
200
+ # The nearest ancestor in the app that defines actions of its own.
201
+ #
202
+ # The walk stops at ApplicationController by convention: it is where an
203
+ # app puts the helpers every controller shares, not actions, and reading
204
+ # it is what produced the leak above. A base class that only sets up
205
+ # filters contributes nothing and the walk continues past it.
206
+ #
207
+ # Returns the actions and whether the walk passed an ancestor whose
208
+ # source it could not read, which is what tells an empty answer apart
209
+ # from one this app cannot see.
210
+ def inherited_actions(ctrl)
211
+ unreadable = false
212
+ klass = ctrl.superclass
213
+ while klass&.name && !framework_controller?(klass) && !app_base_controller?(klass)
214
+ src = read_source(klass)
215
+ if src
216
+ actions = extract_actions_from_source(src)
217
+ return [ actions, unreadable ] if actions.any?
218
+ else
219
+ unreadable = true
220
+ end
221
+ klass = klass.superclass
222
+ end
223
+ [ [], unreadable ]
224
+ end
225
+
226
+ def framework_controller?(klass)
227
+ return true if klass.name.start_with?("ActionController::", "AbstractController::")
228
+ return true if klass == ActionController::Base
229
+ return true if defined?(ActionController::API) && klass == ActionController::API
230
+ false
231
+ end
232
+
233
+ def app_base_controller?(klass)
234
+ klass.name == "ApplicationController" || klass.name.end_with?("::ApplicationController")
235
+ end
236
+
179
237
  def extract_actions_from_source(source)
180
238
  ast_result = SourceIntrospector.walk_source(source, {
181
239
  methods: Listeners::MethodsListener
@@ -236,11 +294,7 @@ module RailsAiContext
236
294
  def collect_source_constraints(ctrl, current_source = nil)
237
295
  constraints = {}
238
296
  klass = ctrl
239
- while klass && klass.name
240
- break if klass.name.start_with?("ActionController::", "AbstractController::")
241
- break if klass == ActionController::Base
242
- break if defined?(ActionController::API) && klass == ActionController::API
243
-
297
+ while klass&.name && !framework_controller?(klass)
244
298
  src = (klass == ctrl) ? (current_source || read_source(klass)) : read_source(klass)
245
299
  if src
246
300
  extract_filters_from_source(src).each do |sf|
@@ -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)
@@ -255,10 +278,11 @@ module RailsAiContext
255
278
  []
256
279
  end
257
280
 
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.
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.
262
286
  def extract_mailers_from_source
263
287
  source_classes(File.join(app.root, "app", "mailers")).filter_map do |name, methods|
264
288
  next if name == "ApplicationMailer"
@@ -45,10 +45,17 @@ module RailsAiContext
45
45
  method: "fetch",
46
46
  key: key,
47
47
  has_default: args.size > 1 || !node.block.nil?,
48
+ # nil unless the fallback is a value that can be printed as one.
49
+ # `ENV.fetch("PORT", defaults[:port])` has a default, but naming
50
+ # it `defaults[:port]` puts a Ruby expression where a reader
51
+ # expects something to copy into a .env file.
52
+ default: literal_value(args[1]),
48
53
  location: node.location.start_line
49
54
  }
50
55
  end
51
56
 
57
+ # An interpolated name has no value at parse time, so it is not a
58
+ # variable name and this returns nil for it.
52
59
  def string_value(node)
53
60
  case node
54
61
  when Prism::StringNode then node.unescaped
@@ -56,6 +63,18 @@ module RailsAiContext
56
63
  else nil
57
64
  end
58
65
  end
66
+
67
+ def literal_value(node)
68
+ case node
69
+ when Prism::StringNode then node.unescaped
70
+ when Prism::SymbolNode then ":#{node.value}"
71
+ when Prism::IntegerNode, Prism::FloatNode then node.value.to_s
72
+ when Prism::TrueNode then "true"
73
+ when Prism::FalseNode then "false"
74
+ when Prism::NilNode then "nil"
75
+ else nil
76
+ end
77
+ end
59
78
  end
60
79
  end
61
80
  end