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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +135 -0
- data/gemfiles/prism_floor.gemfile +2 -0
- data/gemfiles/prism_head.gemfile +2 -0
- data/lib/rails_ai_context/app_kind.rb +23 -0
- data/lib/rails_ai_context/cli/tool_runner.rb +44 -1
- data/lib/rails_ai_context/code_reloader.rb +79 -0
- data/lib/rails_ai_context/introspector.rb +37 -0
- data/lib/rails_ai_context/introspectors/api_introspector.rb +15 -1
- data/lib/rails_ai_context/introspectors/job_introspector.rb +38 -5
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +11 -1
- data/lib/rails_ai_context/introspectors/model_introspector.rb +25 -6
- data/lib/rails_ai_context/introspectors/route_introspector.rb +106 -10
- data/lib/rails_ai_context/live_reload.rb +7 -2
- data/lib/rails_ai_context/resources.rb +7 -0
- data/lib/rails_ai_context/route_coverage.rb +32 -0
- data/lib/rails_ai_context/schema_adapter.rb +85 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +2 -2
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +7 -0
- data/lib/rails_ai_context/server.rb +35 -8
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +2 -2
- data/lib/rails_ai_context/tools/base_tool.rb +9 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +36 -10
- data/lib/rails_ai_context/tools/get_routes.rb +6 -0
- data/lib/rails_ai_context/tools/get_schema.rb +9 -2
- data/lib/rails_ai_context/tools/onboard.rb +8 -26
- data/lib/rails_ai_context/tools/safe_call.rb +24 -9
- data/lib/rails_ai_context/tools/validate.rb +10 -1
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/watcher.rb +3 -0
- data/server.json +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 812fc2b5160e39c746e9d399bf6cea5752339f53c57a80f3ce0d01a5e819b7d0
|
|
4
|
+
data.tar.gz: 22254f62db474a0f0b7f364f156b3098819b52285e88df2835b32ea25c86972b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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
|
|
@@ -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
|
-
|
|
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 :
|
|
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
|
|
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)
|
|
@@ -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
|
-
|
|
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,
|
|
252
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|