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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +138 -0
- data/docs/CONFIGURATION.md +1 -1
- data/docs/GUIDE.md +5 -3
- data/gemfiles/prism_floor.gemfile +2 -0
- data/gemfiles/prism_head.gemfile +2 -0
- data/lib/generators/rails_ai_context/install/install_generator.rb +4 -2
- data/lib/rails_ai_context/concern_paths.rb +56 -0
- data/lib/rails_ai_context/configuration.rb +2 -2
- data/lib/rails_ai_context/introspector.rb +37 -0
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +2 -11
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +6 -1
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +65 -11
- data/lib/rails_ai_context/introspectors/job_introspector.rb +30 -6
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +19 -0
- data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +44 -0
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +11 -1
- data/lib/rails_ai_context/introspectors/route_introspector.rb +106 -10
- data/lib/rails_ai_context/introspectors/test_introspector.rb +4 -1
- data/lib/rails_ai_context/route_coverage.rb +32 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +1 -1
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +1 -1
- data/lib/rails_ai_context/server.rb +35 -8
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -1
- data/lib/rails_ai_context/tools/get_callbacks.rb +1 -4
- data/lib/rails_ai_context/tools/get_concern.rb +45 -43
- data/lib/rails_ai_context/tools/get_env.rb +30 -19
- data/lib/rails_ai_context/tools/get_job_pattern.rb +32 -6
- data/lib/rails_ai_context/tools/get_model_details.rb +1 -5
- data/lib/rails_ai_context/tools/get_routes.rb +6 -0
- data/lib/rails_ai_context/tools/get_service_pattern.rb +40 -17
- data/lib/rails_ai_context/tools/onboard.rb +3 -1
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2ccd7eb081bc5f6f34e998978e38d7d9d5282d097818224c10bd56ff4d30c75
|
|
4
|
+
data.tar.gz: 0bb8a8ad8f57a11a04a41dd2ab9e30f57f25b08382d1b5edca67af0c3db68f38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -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 | `
|
|
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
|
-
#
|
|
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 | `
|
|
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
|
data/gemfiles/prism_head.gemfile
CHANGED
|
@@ -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
|
-
#
|
|
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
|
|
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 =
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
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
|
|
6
|
-
#
|
|
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
|
|
259
|
-
#
|
|
260
|
-
#
|
|
261
|
-
#
|
|
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
|